From 93f78f99a28ed3d7026c07bbbb8f97f5eebcab55 Mon Sep 17 00:00:00 2001 From: Aminorjourney Date: Sun, 28 Jun 2026 14:00:37 -0700 Subject: [PATCH 1/3] feat: add Samsung ARTIK051_REF_17K fridge-freezer descriptor - Full resource map discovered via live CoAP-DTLS session against ARTIK051_REF_17K fridge-freezer (firmware DA-REF-ART-COMMON-1_20201124) - seed_path: /device/0 (returns 32 links including hidden appliance resources) - Entities: fridge/freezer temp + setpoints, 3x door sensors (fridge, freezer, convertible zone), power (W), energy (kWh, total_increasing), water filter usage/status, ice maker, rapid fridge/freeze, sabbath mode - Port: 49155 (not 49154) First known public documentation of this firmware's local CoAP-DTLS resource layout. --- samsung_appliance/appliances/__init__.py | 2 + samsung_appliance/appliances/fridge.py | 493 +++++++++++++++++++++++ 2 files changed, 495 insertions(+) create mode 100644 samsung_appliance/appliances/fridge.py diff --git a/samsung_appliance/appliances/__init__.py b/samsung_appliance/appliances/__init__.py index 75a4046..5a6864d 100644 --- a/samsung_appliance/appliances/__init__.py +++ b/samsung_appliance/appliances/__init__.py @@ -12,11 +12,13 @@ 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, +) From 015e4a4e9e02773708038ba1e6caefefd278f236 Mon Sep 17 00:00:00 2001 From: Aminorjourney Date: Thu, 2 Jul 2026 21:15:00 +0100 Subject: [PATCH 2/3] Session-recovery robustness (from PR #1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Suppress ConnectionError log noise from in-flight requests draining after a session close (poll_scheduler + keepalive log at DEBUG). - Null KeepaliveTask.on_unreachable during a forced reconnect so the dying session's keepalive can't flip HA availability offline after the new session is already healthy. Ported into PR #5's _maybe_force_reconnect flow. Drops the _session_stop / publish-health force-close mechanism from the original PR — PR #5's last_success_ts + _maybe_force_reconnect already covers the 'session dead, restart it' goal via a different path, and running both means two paths force-closing the same session on the same failure. --- samsung_appliance/appliances/__init__.py | 1 + samsung_appliance/bridge.py | 5 +++++ samsung_appliance/keepalive.py | 2 ++ samsung_appliance/poll_scheduler.py | 12 +++++++++++- 4 files changed, 19 insertions(+), 1 deletion(-) diff --git a/samsung_appliance/appliances/__init__.py b/samsung_appliance/appliances/__init__.py index 5a6864d..20657a9 100644 --- a/samsung_appliance/appliances/__init__.py +++ b/samsung_appliance/appliances/__init__.py @@ -15,6 +15,7 @@ from .oven import OVEN from .fridge import FRIDGE + DESCRIPTORS: dict[str, ApplianceDescriptor] = { DRYER.name: DRYER, OVEN.name: OVEN, 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 From b34f0e824889c49a6a700c1d4dc9b9461e8de430 Mon Sep 17 00:00:00 2001 From: Jack Nagy Date: Thu, 2 Jul 2026 21:22:00 +0100 Subject: [PATCH 3/3] Docs: fridge notes + firmware-family limitation callout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add ARTIK051_REF_17K row to the tested-combinations table with a link to aminorjourney's PR. - New 'Firmware families — a limitation' section under Part 1 explaining that descriptors are firmware-family-specific with no runtime feature detection, so the wrong descriptor produces half-broken sensors rather than a clean error. - New 'Fridge (ARTIK051)' section under Per-appliance notes with the capability table + firmware-specific observations (port 49155, minimal /oic/res, vestigial /hass paths, collection-resource door model vs newer per-instance-resource fridges). - Update config-keys reference: CLASS list gains 'fridge', OCF_PORT defaults list gains fridge=49155. --- README.md | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) 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 |