diff --git a/README.md b/README.md index 449e8e1..0a64f18 100644 --- a/README.md +++ b/README.md @@ -52,9 +52,18 @@ Read the result: |---|---|---| | Dryer | DV5000T (`DA_WM_TP2_20_COMMON`, `mnid=0AJT`) | All entities, ≤1s hot-tier poll (OBSERVE accelerates when online) | | Oven | NV7000BS-class (`TP1X_DA-KS-OVEN-0107X`, `mnid=0AJT`) | All entities; hot-tier poll covers door + operational state regardless of cloud reachability | +| Fridge | ARTIK051_REF_17K (`DA-REF-ART-COMMON-1_20201124`) | Contributed by [@aminorjourney](https://github.com/aminorjourney) (PR #1). Older firmware family; port 49155, minimal `/oic/res` with full tree under `/device/0` | Other appliances on the same firmware family (washers, dishwashers, AC units) almost certainly speak the same protocol — the auth path and read primitives are common. You'd write one new descriptor in `samsung_appliance/appliances/`. +### Firmware families — a limitation + +Descriptors are firmware-family-specific. Each file in `samsung_appliance/appliances/` hardcodes the resource layout of one firmware family: which hrefs it polls, which fields it reads, which write surfaces it exposes. There's no runtime feature detection. + +**What this means in practice:** if you set `APPLIANCE__CLASS=fridge` on a fridge that speaks a different firmware family than the one this descriptor was built for, the bridge will start and connect fine, but many sensors will publish as unknown and some controls won't work. Nothing catastrophic — you just get a half-broken HA device card. + +If your appliance model doesn't match a row in the tested table above, it may still work if it's on the same firmware family; otherwise you'd write a new descriptor (see "Adding a new appliance class" below). The ARTIK051 fridge and the newer RF9000B-class fridge, for example, expose genuinely different resource models (collection-resource vs per-instance-resource) and can't share a descriptor even though they're both "fridges". + --- ## How the app keeps in sync with the appliance @@ -241,6 +250,27 @@ The dryer's `/operational/state/vs/0` is on the bridge's hot poll tier (1s idle 2. **Scheduler reconciliation** — the PollScheduler defers polling the just-written resource for ~4s (past Samsung's fetchback-revert window), then refreshes it on its tier cadence. If the device silently coerced the value, the corrected state is republished and HA reverts. 3. **Periodic `/device/0` sweep** — every 5 minutes the scheduler's sweep tier re-fetches the whole device tree, bounding worst-case drift on any resource the per-tier polls don't cover. +### Fridge (ARTIK051) + +Contributed by [@aminorjourney](https://github.com/aminorjourney) in PR #1, verified against an `ARTIK051_REF_17K` fridge-freezer on firmware `DA-REF-ART-COMMON-1_20201124`. First public documentation of this firmware's local resource layout. + +| Capability | Works? | Notes | +|---|---|---| +| Read temperatures | ✅ | Fridge + freezer current + setpoint via `/temperatures/vs/0` | +| Read doors | ✅ | Fridge, freezer, convertible zone via `/doors/vs/0` items array; plus an "any door open" binary sensor | +| Energy monitoring | ✅ | Instantaneous W + cumulative Wh via `/energy/consumption/vs/0` | +| Water filter | ✅ | Usage % + status via `/filter/waterfilter/vs/0` | +| Ice maker | ✅ | State + ice-making status via `/icemaker/one/vs/0` | +| Setpoint slider (fridge / freezer) | ✅ | Fridge 1–7°C, freezer -23 to -15°C | +| Power Cool, Power Freeze, Sabbath, Ice Maker switches | ✅ | | +| Active modes | ✅ | Read-only sensor of the fridge's mode list | + +Notes specific to this firmware family: +- **Port 49155**, not the 49154 the oven defaults to. +- `/oic/res` only advertises 15 paths — the full resource tree lives at `/device/0` (32 links). The bridge's periodic `/device/0` sweep handles this transparently; no descriptor change needed. +- `/hass/state/vs/0` and `/hass/command/vs/0` return `4.04` — they're vestigial paths from an earlier firmware and are ignored. +- Doors are exposed as a Samsung-plural collection resource (`/doors/vs/0` with an `items[]` array keyed by `x.com.samsung.da.description`), not as per-room OCF resources like the newer RF9000B-class fridges use. This is one of the concrete divergences behind the "Firmware families" caveat in Part 1. + --- ## Reference @@ -250,9 +280,9 @@ The dryer's `/operational/state/vs/0` is on the bridge's hot poll tier (1s idle | Key | Meaning | |---|---| | `APPLIANCE_COUNT` | Number of `APPLIANCE__*` blocks to read (1-indexed) | -| `APPLIANCE__CLASS` | Descriptor name: `dryer`, `oven` | +| `APPLIANCE__CLASS` | Descriptor name: `dryer`, `oven`, `fridge` | | `APPLIANCE__IP` | LAN IP of the appliance | -| `APPLIANCE__OCF_PORT` | Optional override (blank → descriptor default: dryer=49155, oven=49154) | +| `APPLIANCE__OCF_PORT` | Optional override (blank → descriptor default: dryer=49155, oven=49154, fridge=49155) | | `APPLIANCE__TOPIC` | MQTT topic prefix (also the HA device identifier — changing it re-keys the device) | | `APPLIANCE__NAME` | Friendly name on the HA device card | | `MQTT_BROKER` / `MQTT_PORT` / `MQTT_USER` / `MQTT_PASS` | Broker config | diff --git a/samsung_appliance/appliances/__init__.py b/samsung_appliance/appliances/__init__.py index 75a4046..20657a9 100644 --- a/samsung_appliance/appliances/__init__.py +++ b/samsung_appliance/appliances/__init__.py @@ -12,11 +12,14 @@ startup; the bridge itself stays class-agnostic. from .base import ApplianceDescriptor from .dryer import DRYER from .oven import OVEN +from .fridge import FRIDGE + DESCRIPTORS: dict[str, ApplianceDescriptor] = { DRYER.name: DRYER, OVEN.name: OVEN, + FRIDGE.name: FRIDGE, } diff --git a/samsung_appliance/appliances/fridge.py b/samsung_appliance/appliances/fridge.py new file mode 100644 index 0000000..4687eec --- /dev/null +++ b/samsung_appliance/appliances/fridge.py @@ -0,0 +1,493 @@ +"""Fridge descriptor — Samsung ARTIK051_REF_17K (and compatible). + +Firmware: DA-REF-ART-COMMON-1_20201124 +Protocol: CoAP-DTLS UDP port 49155 +Device ID example: 5ce4dafb-f37e-69f9-8d80-b79def93e140 + +Resource map discovered 2026-06-27 by Nikki Gordon-Bloomfield +(github.com/aminorjourney/SmartThings-Local) against a live +ARTIK051_REF_17K unit — the first known public documentation of +this firmware's local CoAP-DTLS resource layout. + +Key differences from newer Tizen RT firmware: + - /oic/res advertises only 15 paths; full resource tree at /device/0 + - /hass/state/vs/0 and /hass/command/vs/0 are vestigial (404) + - Door state at /doors/vs/0 (plural, Samsung) AND /door/{room}/0 (OCF) + - Three doors: id=0 (fridge/cooler), id=1 (freezer), id=2 (convertible) + - Energy monitoring IS present: /energy/consumption/vs/0 + - Water filter status at /filter/waterfilter/vs/0 (not just /mode/vs/0) + - Ice maker at /icemaker/one/vs/0 and /icemaker/status/vs/0 + - Defrost control: /defrost/block/vs/0, /defrost/delay/vs/0 + - Temperature setpoints in /temperatures/vs/0 items[].desired + - Port is 49155, not 49154 + - seed_path is /device/0 (same as dryer/oven — returns full link dict) +""" +import time + +from .base import ( + ApplianceDescriptor, + avail_base, + device_block, + encode, +) +from ..poll_scheduler import PollTier + + +MODEL = 'ARTIK051_REF_17K' + + +# --- OBSERVE paths ------------------------------------------------------- +OBSERVE_PATHS = [ + ['doors', 'vs', '0'], # all door open/close states + ['temperatures', 'vs', '0'], # fridge + freezer temps + setpoints + ['refrigeration', 'vs', '0'], # rapidFridge, rapidFreezing + ['mode', 'vs', '0'], # active modes incl. convertible zone + ['sabbath', 'vs', '0'], # sabbath mode + ['energy', 'consumption', 'vs', '0'], # power watts + cumulative Wh + ['icemaker', 'one', 'vs', '0'], # ice maker state + ['filter', 'waterfilter', 'vs', '0'], # filter usage + status + ['defrost', 'block', 'vs', '0'], # defrost block mode +] + + +# --- helpers ------------------------------------------------------------- +def _num(v): + try: + return float(v) + except (TypeError, ValueError): + return None + +def _int(v): + try: + return int(v) + except (TypeError, ValueError): + return None + + +# --- flatten ------------------------------------------------------------- +def flatten(links): + """Map /device/0 link dict to flat sensor dict published to MQTT.""" + g = lambda href, k, default=None: (links.get(href) or {}).get(k, default) + + # --- temperatures --- + temp_items = g('/temperatures/vs/0', 'x.com.samsung.da.items') or [] + fridge_temp = fridge_set = fridge_min = fridge_max = None + freezer_temp = freezer_set = freezer_min = freezer_max = None + for item in temp_items: + desc = item.get('x.com.samsung.da.description', '') + if desc == 'Fridge': + fridge_temp = _num(item.get('x.com.samsung.da.current')) + fridge_set = _num(item.get('x.com.samsung.da.desired')) + fridge_min = _num(item.get('x.com.samsung.da.minimum')) + fridge_max = _num(item.get('x.com.samsung.da.maximum')) + elif desc == 'Freezer': + freezer_temp = _num(item.get('x.com.samsung.da.current')) + freezer_set = _num(item.get('x.com.samsung.da.desired')) + freezer_min = _num(item.get('x.com.samsung.da.minimum')) + freezer_max = _num(item.get('x.com.samsung.da.maximum')) + + # --- doors --- + door_items = g('/doors/vs/0', 'x.com.samsung.da.items') or [] + door_states = {item.get('x.com.samsung.da.id'): item.get('x.com.samsung.da.openState', 'Close') + for item in door_items} + door_fridge_open = door_states.get('0', 'Close') == 'Open' + door_freezer_open = door_states.get('1', 'Close') == 'Open' + door_convertible_open = door_states.get('2', 'Close') == 'Open' + + # --- energy --- + inst_w = _num(g('/energy/consumption/vs/0', 'x.com.samsung.da.instantaneousPower')) + cum_wh = _num(g('/energy/consumption/vs/0', 'x.com.samsung.da.cumulativeConsumption')) + cum_kwh = round(cum_wh / 1000.0, 3) if cum_wh is not None else None + + # --- refrigeration --- + rapid_fridge = g('/refrigeration/vs/0', 'x.com.samsung.da.rapidFridge', 'Off') + rapid_freezing = g('/refrigeration/vs/0', 'x.com.samsung.da.rapidFreezing', 'Off') + + # --- ice maker --- + ice_state = g('/icemaker/one/vs/0', 'x.com.samsung.da.iceMaker.state', 'Off') + ice_status = g('/icemaker/one/vs/0', 'x.com.samsung.da.iceMaker.iceMakingStatus', 'ICESTATUS_STOP') + ice_type = g('/icemaker/one/vs/0', 'x.com.samsung.da.iceType.desired', 'Off') + + # --- water filter --- + filter_usage = _int(g('/filter/waterfilter/vs/0', 'x.com.samsung.da.filterUsage')) + filter_status = g('/filter/waterfilter/vs/0', 'x.com.samsung.da.filterStatus', 'normal') + + + # --- mode --- + modes = g('/mode/vs/0', 'x.com.samsung.da.modes') or [] + supported_options = g('/mode/vs/0', 'x.com.samsung.da.supportedOptions') or [] + water_filter_ok = 'WATERFILTER_DISABLE' not in modes + + # --- sabbath --- + sabbath = g('/sabbath/vs/0', 'x.com.samsung.da.sabbathMode', 'Off') + + # --- information --- + serial = g('/information/vs/0', 'x.com.samsung.da.serialNum') + + return { + # temperatures + 'fridge_temp_c': fridge_temp, + 'freezer_temp_c': freezer_temp, + 'fridge_setpoint_c': fridge_set, + 'freezer_setpoint_c': freezer_set, + 'fridge_temp_min_c': fridge_min, + 'fridge_temp_max_c': fridge_max, + 'freezer_temp_min_c': freezer_min, + 'freezer_temp_max_c': freezer_max, + + # doors + 'door_fridge_open': door_fridge_open, + 'door_freezer_open': door_freezer_open, + 'door_convertible_open': door_convertible_open, + 'any_door_open': door_fridge_open or door_freezer_open or door_convertible_open, + + # energy + 'power_watts': inst_w, + 'energy_kwh': cum_kwh, + 'energy_wh_cumulative': _int(cum_wh), + + # refrigeration modes + 'rapid_fridge': rapid_fridge, + 'rapid_freezing': rapid_freezing, + 'rapid_fridge_binary': rapid_fridge == 'On', + 'rapid_freezing_binary': rapid_freezing == 'On', + + # ice maker + 'ice_maker_state': ice_state, + 'ice_making_status': ice_status, + 'ice_type': ice_type, + 'ice_maker_on': ice_state == 'On', + + # water filter + 'filter_usage_pct': filter_usage, + 'filter_status': filter_status, + 'filter_ok': filter_status == 'normal', + + + # mode + 'water_filter_ok': water_filter_ok, + 'sabbath_mode': sabbath, + 'sabbath_mode_binary': sabbath == 'On', + 'active_modes': ', '.join(modes) if modes else '', + + # info + 'serial': serial, + } + + +# --- sensor / binary_sensor tables ------------------------------------- +_SENSORS = [ + ('fridge_temp_c', 'Fridge temperature', + {'unit_of_measurement': '°C', 'device_class': 'temperature', + 'icon': 'mdi:fridge'}), + ('freezer_temp_c', 'Freezer temperature', + {'unit_of_measurement': '°C', 'device_class': 'temperature', + 'icon': 'mdi:snowflake-thermometer'}), + ('fridge_setpoint_c', 'Fridge setpoint', + {'unit_of_measurement': '°C', 'device_class': 'temperature', + 'icon': 'mdi:fridge-outline', 'entity_category': 'config'}), + ('freezer_setpoint_c','Freezer setpoint', + {'unit_of_measurement': '°C', 'device_class': 'temperature', + 'icon': 'mdi:snowflake', 'entity_category': 'config'}), + ('power_watts', 'Power', + {'unit_of_measurement': 'W', 'device_class': 'power', + 'icon': 'mdi:lightning-bolt'}), + ('energy_kwh', 'Energy', + {'unit_of_measurement': 'kWh', 'device_class': 'energy', + 'state_class': 'total_increasing', 'icon': 'mdi:lightning-bolt'}), + ('filter_usage_pct', 'Water filter usage', + {'unit_of_measurement': '%', 'icon': 'mdi:water-check'}), + ('filter_status', 'Water filter status', + {'icon': 'mdi:water-check', 'entity_category': 'diagnostic'}), + ('ice_maker_state', 'Ice maker', + {'icon': 'mdi:cube-outline'}), + ('ice_making_status', 'Ice making status', + {'icon': 'mdi:cube-outline', 'entity_category': 'diagnostic'}), + ('active_modes', 'Active modes', + {'icon': 'mdi:format-list-bulleted', 'entity_category': 'diagnostic'}), + ('serial', 'Serial number', + {'icon': 'mdi:identifier', 'entity_category': 'diagnostic'}), +] + +_BINARY_SENSORS = [ + ('door_fridge_open', 'Fridge door', + "{{ 'ON' if value_json.door_fridge_open else 'OFF' }}", 'door', {}), + ('door_freezer_open', 'Freezer door', + "{{ 'ON' if value_json.door_freezer_open else 'OFF' }}", 'door', {}), + ('door_convertible_open', 'Convertible zone door', + "{{ 'ON' if value_json.door_convertible_open else 'OFF' }}", 'door', {}), + ('any_door_open', 'Any door open', + "{{ 'ON' if value_json.any_door_open else 'OFF' }}", 'door', + {'icon': 'mdi:fridge-alert'}), + ('filter_ok', 'Water filter OK', + "{{ 'ON' if value_json.filter_ok else 'OFF' }}", None, + {'icon': 'mdi:water-check'}), + ('ice_maker_on', 'Ice maker on', + "{{ 'ON' if value_json.ice_maker_on else 'OFF' }}", None, + {'icon': 'mdi:cube-outline'}), + ('rapid_fridge_binary', 'Power cool', + "{{ 'ON' if value_json.rapid_fridge_binary else 'OFF' }}", None, + {'icon': 'mdi:snowflake-alert'}), + ('rapid_freezing_binary', 'Power freeze', + "{{ 'ON' if value_json.rapid_freezing_binary else 'OFF' }}", None, + {'icon': 'mdi:snowflake-variant'}), + ('sabbath_mode_binary', 'Sabbath mode', + "{{ 'ON' if value_json.sabbath_mode_binary else 'OFF' }}", None, + {'icon': 'mdi:star-david'}), +] + +# MQTT command-topic suffixes +CMD_RAPID_FRIDGE = 'cmd/rapid_fridge' +CMD_RAPID_FREEZING = 'cmd/rapid_freezing' +CMD_SABBATH = 'cmd/sabbath_mode' +CMD_FRIDGE_TEMP = 'cmd/fridge_setpoint' +CMD_FREEZER_TEMP = 'cmd/freezer_setpoint' +CMD_ICE_MAKER = 'cmd/ice_maker' + + +def build_discovery(topic_prefix, ha_prefix, device_name): + state_topic = f"{topic_prefix}/state" + avail_topic = f"{topic_prefix}/availability" + dev = { + 'identifiers': [topic_prefix], + 'name': device_name, + 'manufacturer': 'Samsung Electronics', + 'model': MODEL, + 'sw_version': 'DA-REF-ART-COMMON-1_20201124', + } + avail = avail_base(avail_topic) + out = [] + + # --- read-only sensors --- + for key, name, extra in _SENSORS: + cfg = { + 'name': name, + 'unique_id': f"{topic_prefix}_{key}", + 'object_id': f"{topic_prefix}_{key}", + 'state_topic': state_topic, + 'value_template': f"{{{{ value_json.{key} }}}}", + 'availability': avail, + 'device': dev, + } + cfg.update(extra) + out.append((f"{ha_prefix}/sensor/{topic_prefix}/{key}/config", + encode(cfg))) + + # --- binary sensors --- + for key, name, template, dclass, extra in _BINARY_SENSORS: + cfg = { + 'name': name, + 'unique_id': f"{topic_prefix}_{key}", + 'object_id': f"{topic_prefix}_{key}", + 'state_topic': state_topic, + 'value_template': template, + 'payload_on': 'ON', + 'payload_off': 'OFF', + 'availability': avail, + 'device': dev, + } + if dclass: + cfg['device_class'] = dclass + cfg.update(extra) + out.append((f"{ha_prefix}/binary_sensor/{topic_prefix}/{key}/config", + encode(cfg))) + + # --- switches: power cool, power freeze, sabbath, ice maker, defrost block --- + switches = [ + ('rapid_fridge_switch', 'Power cool', 'rapid_fridge', + CMD_RAPID_FRIDGE, 'mdi:snowflake-alert'), + ('rapid_freezing_switch', 'Power freeze', 'rapid_freezing', + CMD_RAPID_FREEZING, 'mdi:snowflake-variant'), + ('sabbath_switch', 'Sabbath mode', 'sabbath_mode', + CMD_SABBATH, 'mdi:star-david'), + ('ice_maker_switch', 'Ice maker', 'ice_maker_state', + CMD_ICE_MAKER, 'mdi:cube-outline'), + ] + for uid, name, val_key, cmd_topic_suffix, icon in switches: + cfg = { + 'name': name, + 'unique_id': f"{topic_prefix}_{uid}", + 'object_id': f"{topic_prefix}_{uid}", + 'state_topic': state_topic, + 'value_template': f"{{{{ value_json.{val_key} }}}}", + 'state_on': 'On', + 'state_off': 'Off', + 'command_topic': f"{topic_prefix}/{cmd_topic_suffix}", + 'payload_on': 'On', + 'payload_off': 'Off', + 'icon': icon, + 'availability': avail, + 'device': dev, + } + out.append((f"{ha_prefix}/switch/{topic_prefix}/{uid}/config", + encode(cfg))) + + # --- numbers: fridge + freezer setpoints --- + for uid, name, cmd, min_v, max_v, val_key in [ + ('fridge_setpoint_number', 'Fridge setpoint', + CMD_FRIDGE_TEMP, 1, 7, 'fridge_setpoint_c'), + ('freezer_setpoint_number', 'Freezer setpoint', + CMD_FREEZER_TEMP, -23, -15, 'freezer_setpoint_c'), + ]: + cfg = { + 'name': name, + 'unique_id': f"{topic_prefix}_{uid}", + 'object_id': f"{topic_prefix}_{uid}", + 'state_topic': state_topic, + 'value_template': f"{{{{ value_json.{val_key} }}}}", + 'command_topic': f"{topic_prefix}/{cmd}", + 'min': min_v, + 'max': max_v, + 'step': 1, + 'unit_of_measurement': '°C', + 'device_class': 'temperature', + 'icon': 'mdi:thermometer', + 'availability': avail, + 'device': dev, + } + out.append((f"{ha_prefix}/number/{topic_prefix}/{uid}/config", + encode(cfg))) + + return out + + +# --- MQTT command handlers ----------------------------------------------- +def command_handlers(): + + def _rapid_fridge(p, _links): + if p not in ('On', 'Off'): + return None + return (['refrigeration', 'vs', '0'], + {'x.com.samsung.da.rapidFridge': p}) + + def _rapid_freezing(p, _links): + if p not in ('On', 'Off'): + return None + return (['refrigeration', 'vs', '0'], + {'x.com.samsung.da.rapidFreezing': p}) + + def _sabbath(p, _links): + if p not in ('On', 'Off'): + return None + return (['sabbath', 'vs', '0'], + {'x.com.samsung.da.sabbathMode': p}) + + def _ice_maker(p, _links): + if p not in ('On', 'Off'): + return None + return (['icemaker', 'one', 'vs', '0'], + {'x.com.samsung.da.iceMaker.state': p, + 'x.com.samsung.da.iceType.desired': p}) + + def _fridge_temp(p, links): + try: + val = str(int(float(p))) + except (ValueError, TypeError): + return None + items = ((links.get('/temperatures/vs/0') or {}) + .get('x.com.samsung.da.items', [])) + new_items = [ + dict(item, **{'x.com.samsung.da.desired': val}) + if item.get('x.com.samsung.da.description') == 'Fridge' else item + for item in items + ] + if not new_items: + return None + return (['temperatures', 'vs', '0'], + {'x.com.samsung.da.items': new_items}) + + def _freezer_temp(p, links): + try: + val = str(int(float(p))) + except (ValueError, TypeError): + return None + items = ((links.get('/temperatures/vs/0') or {}) + .get('x.com.samsung.da.items', [])) + new_items = [ + dict(item, **{'x.com.samsung.da.desired': val}) + if item.get('x.com.samsung.da.description') == 'Freezer' else item + for item in items + ] + if not new_items: + return None + return (['temperatures', 'vs', '0'], + {'x.com.samsung.da.items': new_items}) + + return { + CMD_RAPID_FRIDGE: _rapid_fridge, + CMD_RAPID_FREEZING: _rapid_freezing, + CMD_SABBATH: _sabbath, + CMD_FRIDGE_TEMP: _fridge_temp, + CMD_FREEZER_TEMP: _freezer_temp, + CMD_ICE_MAKER: _ice_maker, + } + + +# --- Poll tiers ---------------------------------------------------------- +FRIDGE_POLL_TIERS = [ + PollTier( + name='hot', + interval_s=2.0, + active_interval_s=1.0, + paths=( + ('doors', 'vs', '0'), + ('energy', 'consumption', 'vs', '0'), + ), + ), + PollTier( + name='warm', + interval_s=30.0, + paths=( + ('temperatures', 'vs', '0'), + ('refrigeration', 'vs', '0'), + ('mode', 'vs', '0'), + ('sabbath', 'vs', '0'), + ('icemaker', 'one', 'vs', '0'), + ('filter', 'waterfilter','vs', '0'), + ('defrost', 'block', 'vs', '0'), + ), + ), + PollTier( + name='sweep', + interval_s=300.0, + paths=(('device', '0'),), + is_sweep=True, + ), +] + + +def _is_active(_links: dict) -> bool: + """Fridge is always active — doors can open any time, energy always flowing.""" + return True + + +def log_state_change(sensors: dict) -> str: + parts = [] + ft = sensors.get('fridge_temp_c') + fz = sensors.get('freezer_temp_c') + pw = sensors.get('power_watts') + if ft is not None: parts.append(f"fridge={ft}°C") + if fz is not None: parts.append(f"freezer={fz}°C") + if pw is not None: parts.append(f"power={pw}W") + if sensors.get('any_door_open'): + open_doors = [] + if sensors.get('door_fridge_open'): open_doors.append('fridge') + if sensors.get('door_freezer_open'): open_doors.append('freezer') + if sensors.get('door_convertible_open'): open_doors.append('convertible') + parts.append(f"DOOR OPEN: {', '.join(open_doors)}") + return ' | '.join(parts) if parts else 'idle' + + +# --- Descriptor ---------------------------------------------------------- +FRIDGE = ApplianceDescriptor( + name='fridge', + default_observe_port=49155, + observe_paths=OBSERVE_PATHS, + seed_path=['device', '0'], + flatten=flatten, + build_discovery=build_discovery, + command_handlers=command_handlers, + log_state_change=log_state_change, + poll_tiers=FRIDGE_POLL_TIERS, + is_active=_is_active, +) diff --git a/samsung_appliance/bridge.py b/samsung_appliance/bridge.py index d7d66c8..43dec1a 100644 --- a/samsung_appliance/bridge.py +++ b/samsung_appliance/bridge.py @@ -409,6 +409,11 @@ class PushBridge: self.log.warning( "unreachable for %.0fs — forcing session reconnect", elapsed) self._force_close_in_flight = True + # Null the dying session's on_unreachable so its keepalive thread + # can't flip availability offline after the new session takes over. + ka = self.keepalive + if ka is not None: + ka.on_unreachable = None try: sess.close() except Exception as e: diff --git a/samsung_appliance/keepalive.py b/samsung_appliance/keepalive.py index 9e47ac3..11fe464 100644 --- a/samsung_appliance/keepalive.py +++ b/samsung_appliance/keepalive.py @@ -67,6 +67,8 @@ class KeepaliveTask: try: self.session.ping() ok = True + except ConnectionError as e: + if self.log: self.log.debug("ping: %s", e) except Exception as e: if self.log: self.log.warning("ping: %s", e) # Real half-open detection: ping sends can succeed against a diff --git a/samsung_appliance/poll_scheduler.py b/samsung_appliance/poll_scheduler.py index a18ab15..ff8e6ba 100644 --- a/samsung_appliance/poll_scheduler.py +++ b/samsung_appliance/poll_scheduler.py @@ -237,6 +237,11 @@ class PollScheduler: self.log.warning("poll %s timeout (cooldown %.0fs)", href, cooldown) return + except ConnectionError as e: + self._poll_error_count += 1 + self._record_rtt((time.monotonic() - t0) * 1000.0) + if self.log: self.log.debug("poll %s: %s", href, e) + return except Exception as e: self._poll_error_count += 1 self._record_rtt((time.monotonic() - t0) * 1000.0) @@ -274,6 +279,11 @@ class PollScheduler: self.log.warning("sweep %s timeout (cooldown %.0fs)", path, cooldown) return + except ConnectionError as e: + self._poll_error_count += 1 + self._record_rtt((time.monotonic() - t0) * 1000.0) + if self.log: self.log.debug("sweep %s: %s", path, e) + return except Exception as e: self._poll_error_count += 1 self._record_rtt((time.monotonic() - t0) * 1000.0) @@ -300,4 +310,4 @@ class PollScheduler: if self.log: elapsed_ms = (time.monotonic() - t0) * 1000.0 self.log.info("sweep complete (%d links, %.0fms)", - len(indexed), elapsed_ms) + len(indexed), elapsed_ms) \ No newline at end of file