refactor: extract mqtt_demo/ — move bridge, config, logger, appliance descriptors

This commit is contained in:
Marc Billow committed 2026-07-04 16:11:10 -05:00
1 parent 24fc197a9e
commit aa9ec0f0d9
17 files changed
+51 -42

No files matched your search

+62
View File
@@ -0,0 +1,62 @@
# SmartThings-Local Bridge config.
# Copy to `.env` and fill in. Never commit `.env`.
# =============================================================
# Appliances — one process supervises N appliances over DTLS.
# =============================================================
# APPLIANCE_COUNT defines how many entries to read. Per-appliance
# keys are 1-indexed (APPLIANCE_1_*, APPLIANCE_2_*, …).
APPLIANCE_COUNT=1
# Appliance 1 — Samsung dryer
APPLIANCE_1_CLASS=dryer
APPLIANCE_1_IP=192.168.1.100
# Leave OCF_PORT blank to inherit the descriptor's default
# (dryer=49155, oven=49154).
APPLIANCE_1_OCF_PORT=
APPLIANCE_1_TOPIC=samsung_dryer
APPLIANCE_1_NAME=Samsung Dryer
# Future:
# APPLIANCE_2_CLASS=oven
# APPLIANCE_2_IP=192.168.1.101
# APPLIANCE_2_OCF_PORT=
# APPLIANCE_2_TOPIC=samsung_oven
# APPLIANCE_2_NAME=Samsung Oven
# (Don't forget to bump APPLIANCE_COUNT=2.)
# --- Cert paths ---
# Defaults work for Docker (mount as /config) and bare-metal (drop
# into ./certs). The client cert + key are built by setup_cert.py.
# CERT_PATH=./certs/client_fullchain.pem
# KEY_PATH=./certs/client.key
# --- MQTT broker (HA Mosquitto add-on or any broker) ---
MQTT_BROKER=192.168.1.5
MQTT_PORT=1883
MQTT_USER=samsung_bridge
MQTT_PASS=
# HA discovery prefix — must match the MQTT integration's setting in HA
# (default `homeassistant`).
HA_DISCOVERY_PREFIX=homeassistant
# Bridge timers (seconds).
# HEALTH_INTERVAL_S — how often <prefix>/bridge/health republishes.
# PING_INTERVAL_S — CoAP empty-CON ping cadence (DTLS-layer
# liveness). Three consecutive failures publish
# availability=offline.
# State freshness itself comes from the in-bridge PollScheduler whose
# tier cadences are declared in the appliance descriptor — there is
# no top-level heartbeat env var to tune.
HEALTH_INTERVAL_S=60
PING_INTERVAL_S=25
# Container TZ.
TZ=Europe/London
# --- Deploy (deploy.sh — tar + ssh docker compose) ---
SSH_HOST=user@your-server
REMOTE_DIR=/mnt/user/compose/smartthings-local
APPDATA_DIR=/mnt/user/appdata/smartthings-local
+34
View File
@@ -0,0 +1,34 @@
FROM python:3.11-slim
WORKDIR /app
# Python deps first so layer cache survives code changes
COPY mqtt_demo/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Application code — build context is the repo root (see
# docker-compose.yml's `context: ..`) since mqtt_demo/ depends on the
# protocol/ and ocf/ library packages that live alongside it.
COPY protocol/ ./protocol/
COPY ocf/ ./ocf/
COPY mqtt_demo/ ./mqtt_demo/
# /config holds the ab0b0ac4 client cert + key. Mount from the host so
# secrets aren't baked into the image.
RUN mkdir -p /config
# Unbuffered stdout so docker logs is live
ENV PYTHONUNBUFFERED=1
# Defaults — override in .env or `docker run -e …`. Per-appliance
# keys (APPLIANCE_COUNT, APPLIANCE_<n>_*) have no universal default
# and must be set in .env.
ENV CERT_PATH=/config/ab0b0ac4_fullchain.pem \
KEY_PATH=/config/ab0b0ac4.key \
HA_DISCOVERY_PREFIX=homeassistant \
HEALTH_INTERVAL_S=60 \
HEARTBEAT_INTERVAL_S=600
# No port — bridge is outbound-only (DTLS UDP to appliance, MQTT to broker).
CMD ["python", "-m", "mqtt_demo"]
View File
Whitespace-only changes.
+193
View File
@@ -0,0 +1,193 @@
#!/usr/bin/env python3
"""SmartThings-Local Bridge — entry point.
One process supervises N Samsung appliances over their OCF CoAP-DTLS
local APIs, publishing state + HA discovery to MQTT. Each appliance
runs its own DTLS session in its own thread, with an in-session
PollScheduler driving state freshness and a KeepaliveTask driving
DTLS-layer liveness. MQTT is shared.
Config is env-var driven:
* APPLIANCE_COUNT plus APPLIANCE_<n>_{CLASS,IP,OCF_PORT,TOPIC,NAME}
define the appliances to bridge.
* Shared keys (MQTT_*, HA_DISCOVERY_PREFIX, CERT_PATH, KEY_PATH,
HEALTH_INTERVAL_S, PING_INTERVAL_S) apply to all.
Reconnects on session errors per-appliance; shuts down cleanly on
SIGINT / SIGTERM."""
import logging
import os
import signal
import sys
import threading
import paho.mqtt.client as mqtt
from mqtt_demo.samples import get_descriptor
from mqtt_demo.bridge import PushBridge
from mqtt_demo.config import SharedConfig, load_appliances
from mqtt_demo.logger import logger
def main():
shared = SharedConfig.from_env()
try:
appliances = load_appliances()
except ValueError as e:
logger.error("config: %s", e)
return 2
if not shared.MQTT_BROKER:
logger.error("config: MQTT_BROKER not set")
return 2
for path in (shared.CERT_PATH, shared.KEY_PATH):
if not path.exists():
logger.error("client cert/key not found: %s", path)
return 2
# Resolve descriptors up front — bad APPLIANCE_<n>_CLASS should fail
# at startup, not 10s into the first DTLS attempt.
pairs = []
for app in appliances:
try:
desc = get_descriptor(app.klass)
except ValueError as e:
logger.error("APPLIANCE_%d_CLASS: %s", app.index, e)
return 2
pairs.append((app, desc))
logger.info("SmartThings-Local Bridge starting (%d appliance%s)",
len(appliances), '' if len(appliances) == 1 else 's')
logger.info(" broker = %s:%d (user=%s)",
shared.MQTT_BROKER, shared.MQTT_PORT,
shared.MQTT_USER or '<anon>')
for app, desc in pairs:
port = app.ocf_port or desc.default_observe_port
logger.info(" [%d] %s @ %s:%d (DTLS) → topic %s/*",
app.index, app.klass, app.ip, port, app.topic_prefix)
# --- MQTT client (shared) ---
cli = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2,
client_id='smartthings_local_bridge')
if shared.MQTT_USER:
cli.username_pw_set(shared.MQTT_USER, shared.MQTT_PASS)
# Headroom for the on_connect burst across all appliances (each
# publishes ~30 retained QoS-1 messages: discovery + state + avail).
# 100 + 50*N keeps us comfortably ahead of paho's default 20.
cli.max_inflight_messages_set(100 + 50 * len(appliances))
# Use the FIRST appliance's availability topic for LWT — paho only
# supports one will message. If a second appliance is added later,
# its availability is managed via explicit publishes on disconnect
# rather than LWT. For Phase 1 (dryer only) this is exact.
first_app = appliances[0]
cli.will_set(f"{first_app.topic_prefix}/availability",
payload='offline', qos=1, retain=True)
# Build bridges. Each builds its own discovery payloads in __init__.
bridges: list[PushBridge] = [PushBridge(shared, app, desc, cli)
for app, desc in pairs]
by_prefix = {b.cmd_topic_prefix.rstrip('/'): b for b in bridges}
def on_connect(client, userdata, flags, rc, props=None):
if rc != 0:
logger.warning("MQTT connect rc=%s", rc)
return
logger.info("MQTT connected → %s:%d",
shared.MQTT_BROKER, shared.MQTT_PORT)
for b in bridges:
for topic, payload in b.discovery_payloads:
client.publish(topic, payload, qos=1, retain=True)
cmd_wildcard = f"{b.app.topic_prefix}/cmd/#"
client.subscribe(cmd_wildcard, qos=1)
b.reassert_availability()
logger.info("subscribed to %d cmd wildcards", len(bridges))
def on_disconnect(client, userdata, flags, rc, props=None):
logger.warning("MQTT disconnected rc=%s", rc)
def on_message(client, userdata, msg):
# Route by topic prefix. Each appliance owns a distinct
# `<prefix>/cmd/*` namespace, so the prefix-match is unambiguous.
for prefix, bridge in by_prefix.items():
if msg.topic.startswith(prefix + '/'):
try:
payload = msg.payload.decode('utf-8',
errors='replace').strip()
except Exception:
return
bridge.handle_command(msg.topic, payload)
return
cli.on_connect = on_connect
cli.on_disconnect = on_disconnect
cli.on_message = on_message
if os.getenv('PAHO_DEBUG'):
paho_logger = logging.getLogger('paho.mqtt.client')
paho_logger.setLevel(logging.DEBUG)
paho_handler = logging.StreamHandler(sys.stdout)
paho_handler.setFormatter(logging.Formatter(
'%(asctime)s PAHO %(message)s', datefmt='%H:%M:%S'))
paho_logger.addHandler(paho_handler)
paho_logger.propagate = False
cli.enable_logger(paho_logger)
cli.connect_async(shared.MQTT_BROKER, shared.MQTT_PORT, keepalive=60)
cli.loop_start()
# Per-bridge runner / health / heartbeat threads.
threads: list[threading.Thread] = []
def make_health(b: PushBridge):
def loop():
while not b.stop.is_set():
b.publish_health()
if b.stop.wait(shared.HEALTH_INTERVAL_S):
break
return loop
for b in bridges:
tag = b.app.klass
threads.append(threading.Thread(
target=b.run_forever, daemon=True, name=f'{tag}-session'))
threads.append(threading.Thread(
target=make_health(b), daemon=True, name=f'{tag}-health'))
stopping = threading.Event()
def shutdown(*_):
if stopping.is_set():
return
stopping.set()
logger.info("shutting down…")
for b in bridges:
b.stop.set()
try: b.set_availability(False)
except Exception: pass
signal.signal(signal.SIGINT, shutdown)
signal.signal(signal.SIGTERM, shutdown)
for t in threads:
t.start()
# Wait for the session threads (the only non-daemon-equivalent
# loops). They exit when their bridge's stop event is set.
try:
for t in threads:
if t.name.endswith('-session'):
t.join()
finally:
try:
cli.loop_stop()
cli.disconnect()
except Exception:
pass
logger.info("stopped")
return 0
if __name__ == '__main__':
sys.exit(main())
+688
View File
@@ -0,0 +1,688 @@
"""Bridge: OCF CoAP-DTLS appliance → MQTT, polling-first with opportunistic OBSERVE.
Appliance ──CoAP DTLS─► PushBridge ──MQTT──► Home Assistant
State freshness comes from a tiered PollScheduler over a persistent DTLS
session. OBSERVE registrations are kept as an opportunistic freshness
accelerator — when the appliance has internet and pushes notifications,
the cache absorbs them; when it's air-gapped, polling carries the UX
unchanged.
DTLS-layer liveness is a separate KeepaliveTask (CoAP empty-CON ping
every PING_INTERVAL_S). Three consecutive failures publish MQTT
availability=offline.
Multiple PushBridges run concurrently — see main.py. They share one
MQTT client; each owns one DTLS session, one cache, one scheduler.
"""
import json
import os
import random
import threading
import time
import cbor2
from protocol.dtls_session import DtlsCoapSession, fmt_code
from ocf.keepalive import KeepaliveTask
from ocf.observe_refresh import ObserveRefreshTask
from ocf.poll_scheduler import PollScheduler
from ocf.state_cache import StateCache
from .descriptor import ApplianceDescriptor, bridge_diagnostic_discovery
from .config import ApplianceConfig, SharedConfig
from .logger import bridge_logger
DEBUG_BRIDGE = os.environ.get('DEBUG_BRIDGE') == '1'
def _href_to_segs(href: str) -> list[str]:
return [s for s in href.split('/') if s]
SERIAL_PATH = '/information/vs/0'
SERIAL_FIELD = 'x.com.samsung.da.serialNum'
# If the keepalive watchdog flags the device unreachable for this long,
# force a session reconnect. Catches the half-open case where the DTLS
# socket is still writable but the peer has gone silent — without this,
# the bridge sits in offline state until the reader thread dies on its
# own (which may not happen at all if the OS sees no socket errors).
UNREACHABLE_RECONNECT_S = 120.0
# Periodic OBSERVE re-subscribe interval. Safety net for the case where
# the device stays reachable on the DTLS layer but Samsung's RT-OCF
# clears its observer table (e.g. during cloud auth blips). Without
# this, push delivery stays dead even after upstream connectivity
# recovers, since nothing triggers a fresh subscribe on the existing
# session.
OBSERVE_REFRESH_INTERVAL_S = 6 * 3600.0
class PushBridge:
def __init__(self,
shared: SharedConfig,
app: ApplianceConfig,
descriptor: ApplianceDescriptor,
mqtt_client):
self.shared = shared
self.app = app
self.descriptor = descriptor
self.mqtt = mqtt_client
self.log = bridge_logger(app.klass)
self._serial: str | None = None
self.port = app.ocf_port or descriptor.default_observe_port
self.session: DtlsCoapSession | None = None
self.scheduler: PollScheduler | None = None
self.keepalive: KeepaliveTask | None = None
self.observe_refresh: ObserveRefreshTask | None = None
self.cache = StateCache(descriptor)
self.cache.set_on_change(self._on_cache_change)
self.last_state_pub = None
self.last_remote_pub = None
self.last_cycle_pub = None
self.last_avail_pub: str | None = None
self.stop = threading.Event()
self.started_ts = time.time()
self.session_started_ts = None
self.last_change_ts = None
self.last_seed_ts = None
self.notif_count = 0
self.connect_count = 0
self.error_count = 0
self._publish_gate = False
self._last_change_source: str | None = None
self._last_observe_change_ts: float | None = None
self._last_push_active_pub: str | None = None
# Wall-clock timestamp the keepalive watchdog first reported the
# device unreachable on the current session. Cleared on recovery
# or session start. Drives the force-reconnect watchdog below.
self._unreachable_since: float | None = None
self._force_close_in_flight: bool = False
# Push is considered "active" if an OBSERVE-sourced change
# arrived within this window. Long enough that a quiet but
# working appliance doesn't flap to inactive; short enough that
# a genuinely silent push channel is visible within minutes.
self.push_active_window_s = 600.0
# Snapshot counters for the per-health-window deltas surfaced in
# publish_health's log summary.
self._win_prev_poll = 0
self._win_prev_poll_err = 0
self._win_prev_ping_fail = 0
# Per-href fetchback generation counter coalesces bursts of
# OBSERVE-block2 partial notifications: rapid changes scheduled
# many fetchbacks but only the latest actually publishes.
self._fetch_gen: dict[str, int] = {}
self._fetch_lock = threading.Lock()
p = app.topic_prefix
self.state_topic = f"{p}/state"
self.avail_topic = f"{p}/availability"
self.remote_topic = f"{p}/remote_available"
self.cycle_topic = f"{p}/cycle_active"
self.health_topic = f"{p}/bridge/health"
self.push_active_topic = f"{p}/bridge/push_active"
self.cmd_handlers = descriptor.command_handlers()
self.cmd_topic_prefix = f"{p}/cmd/"
self.discovery_payloads = (
descriptor.build_discovery(
app.topic_prefix, shared.HA_DISCOVERY_PREFIX, app.device_name)
+ bridge_diagnostic_discovery(
app.topic_prefix, shared.HA_DISCOVERY_PREFIX, app.device_name,
model=descriptor.name.title()))
# ---- cache plumbing ---------------------------------------------
def _on_cache_change(self, changed: bool, source: str) -> None:
if changed:
self.notif_count += 1
self.last_change_ts = time.time()
self._last_change_source = source
if source == 'observe':
self._last_observe_change_ts = self.last_change_ts
self.maybe_publish_state()
def _on_notification(self, href, payload_bytes):
"""Reader-thread callback for OBSERVE notifications. Large
resources (oven /mode/vs/0 ~9KB) arrive truncated with Block2.M=1
and we use cbor-decode failure as the partial signal."""
if not payload_bytes:
self._schedule_fetchback(href)
return
try:
rep = cbor2.loads(payload_bytes)
except Exception:
self._schedule_fetchback(href)
return
if not isinstance(rep, dict):
return
if DEBUG_BRIDGE:
self._debug_log_rep(href, rep)
self.cache.apply_rep(href, rep, source='observe')
def _debug_log_rep(self, href, rep):
if href == '/mode/vs/0' and isinstance(rep, dict):
self.log.info("mode modes=%r options=%r",
rep.get('x.com.samsung.da.modes'),
rep.get('x.com.samsung.da.options'))
elif href in ('/operational/state/vs/0', '/oven/vs/0', '/power/vs/0'):
self.log.info("REP %s = %r", href, rep)
def _schedule_fetchback(self, href, delay_s: float = 0.0):
with self._fetch_lock:
gen = self._fetch_gen.get(href, 0) + 1
self._fetch_gen[href] = gen
threading.Thread(
target=self._fetch_back,
args=(href, delay_s, gen),
daemon=True,
name=f'fetch{href}',
).start()
def _fetch_back(self, href, delay_s: float, gen: int):
if delay_s > 0 and self.stop.wait(delay_s):
return
with self._fetch_lock:
if self._fetch_gen.get(href) != gen:
return
sess = self.session
if sess is None:
return
segs = _href_to_segs(href)
try:
code, payload = sess.get(segs, timeout=15.0)
except Exception as e:
self.log.warning("fetchback %s: %s", href, e)
return
with self._fetch_lock:
if self._fetch_gen.get(href) != gen:
return
if code != 0x45:
self.log.warning("fetchback %s: %s", href, fmt_code(code))
return
try:
rep = cbor2.loads(payload) if payload else {}
except Exception as e:
self.log.warning("fetchback %s cbor: %s", href, e)
return
if isinstance(rep, dict):
self.cache.apply_rep(href, rep, source='observe')
def _retag_logger_with_serial(self):
if self._serial is not None:
return
info = self.cache.get(SERIAL_PATH) or {}
serial = info.get(SERIAL_FIELD)
if not serial:
return
self._serial = serial
self.log = bridge_logger(self.app.klass, serial)
self.log.info("identified — serial=%s", serial)
# ---- session lifecycle ------------------------------------------
def session_once(self):
sess = DtlsCoapSession(
self.app.ip, self.port,
cert_path=self.shared.CERT_PATH,
key_path=self.shared.KEY_PATH,
on_notification=self._on_notification,
)
sess.connect()
self.session = sess
self.session_started_ts = time.time()
self.connect_count += 1
self.cache.descriptor_state.clear()
self._publish_gate = False
self._unreachable_since = None
self._force_close_in_flight = False
self.log.info("DTLS connected — subscribing %d paths",
len(self.descriptor.observe_paths))
sess.start_reader()
session_ended = threading.Event()
def _stop_watcher():
while not session_ended.is_set():
if self.stop.wait(1.0):
try:
sess.close()
except Exception as e:
self.log.warning("stop close: %s", e)
return
threading.Thread(target=_stop_watcher, daemon=True,
name=f'{self.app.klass}-stopw').start()
try:
self._run_session_inner(sess)
finally:
session_ended.set()
def _run_session_inner(self, sess):
for path in self.descriptor.observe_paths:
sess.subscribe(path)
time.sleep(0.05)
# Inline seed so the publish gate opens before the scheduler's
# first tick. The scheduler's sweep tier will refresh /device/0
# on its own cadence afterwards.
self._seed_from_device0(sess)
self._retag_logger_with_serial()
if DEBUG_BRIDGE:
self._debug_dump_links(sess)
self._publish_gate = True
self.maybe_publish_state(force=True)
self.set_availability(True)
self.log.info("seeded → %d links; sensors live",
len(self.cache.links))
scheduler = PollScheduler(
sess, self.cache,
tiers=self.descriptor.poll_tiers,
is_active_fn=self.descriptor.is_active,
logger=self.log,
)
# Half-open detection: if no successful poll lands inside this
# window the session is wedged, regardless of whether ping sends
# leave the socket. 60s gives ~60 hot-tier cycles of margin.
liveness_window_s = 60.0
keepalive = KeepaliveTask(
sess,
interval_s=float(self.shared.PING_INTERVAL_S),
fail_threshold=3,
on_reachable=self._on_reachable,
on_unreachable=self._on_unreachable,
logger=self.log,
liveness_fn=lambda: (time.monotonic() - scheduler.last_success_ts
) < liveness_window_s,
)
observe_refresh = ObserveRefreshTask(
sess,
paths=self.descriptor.observe_paths,
interval_s=OBSERVE_REFRESH_INTERVAL_S,
logger=self.log,
)
self.scheduler = scheduler
self.keepalive = keepalive
self.observe_refresh = observe_refresh
sched_t = threading.Thread(
target=scheduler.run_forever, args=(self.stop,),
daemon=True, name=f'{self.app.klass}-poll')
ka_t = threading.Thread(
target=keepalive.run_forever, args=(self.stop,),
daemon=True, name=f'{self.app.klass}-ping')
ref_t = threading.Thread(
target=observe_refresh.run_forever, args=(self.stop,),
daemon=True, name=f'{self.app.klass}-obsref')
sched_t.start()
ka_t.start()
ref_t.start()
try:
sess.join()
finally:
self.scheduler = None
self.keepalive = None
self.observe_refresh = None
def _seed_from_device0(self, sess):
code, pl = sess.get(self.descriptor.seed_path, timeout=15.0)
if code != 0x45:
raise RuntimeError(
f"/{'/'.join(self.descriptor.seed_path)} -> {fmt_code(code)}")
try:
body = cbor2.loads(pl)
except Exception as e:
raise RuntimeError(
f"/{'/'.join(self.descriptor.seed_path)} cbor decode: {e}"
) from e
# During the seed we want the cache populated without triggering
# a publish per resource — gate the on_change callback off until
# the publish gate opens just below.
for href, rep in StateCache.index_device_tree(body).items():
if href not in self.cache.links:
self.cache.apply_rep(href, rep, source='seed')
self.last_seed_ts = time.time()
def _debug_dump_links(self, sess):
for href, rep in sorted(self.cache.links.items()):
if href == '/mode/vs/0':
short = {k: v for k, v in rep.items()
if k not in (
'x.com.samsung.da.modeSpec',
'x.com.samsung.da.supportedModes',
)}
self.log.info("LINK %s = %r", href, short)
else:
self.log.info("LINK %s = %r", href, rep)
try:
code, pl = sess.get(['oic', 'res'], timeout=10.0)
if code == 0x45 and pl:
self.log.info("OIC_RES = %r", cbor2.loads(pl))
else:
self.log.info("oic/res → %s", fmt_code(code))
except Exception as e:
self.log.warning("oic/res get: %s", e)
def _on_reachable(self) -> None:
self._unreachable_since = None
self.set_availability(True)
self.reassert_availability()
def _on_unreachable(self) -> None:
if self._unreachable_since is None:
self._unreachable_since = time.time()
self.set_availability(False)
def _maybe_force_reconnect(self) -> None:
"""If the device has been unreachable for UNREACHABLE_RECONNECT_S,
close the DTLS session to break run_forever's session_once() out
of its sess.join() and trigger a fresh connect. Without this, a
half-open session (writable socket, silent peer) holds the bridge
in offline limbo until the OS surfaces a socket error."""
if self._unreachable_since is None or self._force_close_in_flight:
return
elapsed = time.time() - self._unreachable_since
if elapsed < UNREACHABLE_RECONNECT_S:
return
sess = self.session
if sess is None:
return
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:
self.log.warning("force-close: %s", e)
# ---- MQTT publishing --------------------------------------------
def maybe_publish_state(self, force=False):
if not force and not self._publish_gate:
return
snap = self.cache.snapshot()
sensors = self.descriptor.flatten(snap)
project = self.descriptor.project
if project is not None:
sensors = project(self.cache.descriptor_state, sensors)
if not force and sensors == self.last_state_pub:
return
if DEBUG_BRIDGE and self.last_state_pub is not None:
diffs = {k: (self.last_state_pub.get(k), v)
for k, v in sensors.items()
if self.last_state_pub.get(k) != v}
diffs.update({k: (self.last_state_pub.get(k), None)
for k in self.last_state_pub
if k not in sensors})
if diffs:
self.log.info("sensor diff: %s",
{k: f"{a!r} → {b!r}" for k, (a, b) in diffs.items()})
self.last_state_pub = sensors
self.mqtt.publish(self.state_topic,
json.dumps(sensors).encode(),
qos=1, retain=True)
field = self.descriptor.remote_available_field
if field is not None:
self.publish_remote_available(sensors.get(field))
cycle_field = self.descriptor.cycle_active_field
if cycle_field is not None:
self.publish_cycle_active(sensors.get(cycle_field))
if not force:
log_fn = self.descriptor.log_state_change
extra = log_fn(sensors) if log_fn is not None else ''
self.log.info("state changed [%s] (%s notif#%d)",
self._last_change_source or '?',
extra or 'descriptor-no-log', self.notif_count)
def publish_remote_available(self, remote_on, force=False):
value = 'online' if remote_on else 'offline'
if not force and value == self.last_remote_pub:
return
self.last_remote_pub = value
try:
self.mqtt.publish(self.remote_topic, value, qos=1, retain=True)
self.log.info("remote_available → %s", value)
except Exception as e:
self.log.warning("remote_available publish: %s", e)
def publish_cycle_active(self, cycle_on, force=False):
value = 'online' if cycle_on else 'offline'
if not force and value == self.last_cycle_pub:
return
self.last_cycle_pub = value
try:
self.mqtt.publish(self.cycle_topic, value, qos=1, retain=True)
self.log.info("cycle_active → %s", value)
except Exception as e:
self.log.warning("cycle_active publish: %s", e)
def reassert_availability(self):
if self.session is None or self.last_state_pub is None:
return
self.set_availability(True)
field = self.descriptor.remote_available_field
if field is not None:
self.publish_remote_available(
self.last_state_pub.get(field), force=True)
cycle_field = self.descriptor.cycle_active_field
if cycle_field is not None:
self.publish_cycle_active(
self.last_state_pub.get(cycle_field), force=True)
now = time.time()
active = (self._last_observe_change_ts is not None
and (now - self._last_observe_change_ts) <= self.push_active_window_s)
self.publish_push_active(active, force=True)
def set_availability(self, online):
value = 'online' if online else 'offline'
if value == self.last_avail_pub:
return
self.last_avail_pub = value
try:
self.mqtt.publish(self.avail_topic, value, qos=1, retain=True)
except Exception as e:
self.log.warning("avail publish: %s", e)
if not online and self.descriptor.remote_available_field is not None:
self.last_remote_pub = None
try:
self.mqtt.publish(self.remote_topic, 'offline',
qos=1, retain=True)
except Exception:
pass
if not online and self.descriptor.cycle_active_field is not None:
self.last_cycle_pub = None
try:
self.mqtt.publish(self.cycle_topic, 'offline',
qos=1, retain=True)
except Exception:
pass
if not online:
self._last_push_active_pub = None
try:
self.mqtt.publish(self.push_active_topic, 'offline',
qos=1, retain=True)
except Exception:
pass
# ---- MQTT command handling --------------------------------------
def handle_command(self, topic, payload):
if not topic.startswith(self.cmd_topic_prefix):
return
suffix = topic[len(self.cmd_topic_prefix) - len('cmd/'):]
handler = self.cmd_handlers.get(suffix)
if handler is None:
self.log.warning("unknown command topic: %s", topic)
return
# Handler gets a links snapshot so its read-modify-write sees a
# consistent view across the multi-field operation.
result = handler(payload, self.cache.snapshot())
if result is None:
self.log.warning("rejected command %s payload=%r",
topic, payload)
return
path_segs, body = result
sess = self.session
if sess is None:
self.log.warning("command %s: no DTLS session", topic)
return
href = '/' + '/'.join(path_segs)
sched = self.scheduler
defer_s = 4.0
if sched is not None:
sched.write_in_progress(href, settle_s=defer_s)
try:
code, _ = sess.post(path_segs, cbor2.dumps(body), timeout=8.0)
except Exception as e:
self.log.warning("command %s POST failed: %s", topic, e)
return
defer_note = f" (poll-defer {href} {defer_s:.0f}s)" if sched is not None else ''
self.log.info("command %s payload=%r → %s%s",
suffix, payload, fmt_code(code), defer_note)
if code >> 5 == 2:
# Optimistic local merge so HA sees the write reflected
# immediately. No Block2 fetchback — that triggers Samsung's
# 3-second revert (project_fetchback_revert_root_cause.md).
# The PollScheduler will reconcile on its next tier tick
# after the write_in_progress settle window expires.
self.cache.apply_optimistic(href, body)
def publish_health(self):
now = time.time()
sched = self.scheduler
ka = self.keepalive
last_obs_age = (round(now - self._last_observe_change_ts, 1)
if self._last_observe_change_ts else None)
push_active = (last_obs_age is not None
and last_obs_age <= self.push_active_window_s)
# Per-window deltas for the log summary AND the health topic
# (HA gets the same numbers without doing template arithmetic).
poll = sched.poll_count if sched else 0
poll_err = sched.poll_error_count if sched else 0
ping_fail = ka.ping_fail_count if ka else 0
d_poll = poll - self._win_prev_poll
d_err = poll_err - self._win_prev_poll_err
d_ping_fail = ping_fail - self._win_prev_ping_fail
self._win_prev_poll = poll
self._win_prev_poll_err = poll_err
self._win_prev_ping_fail = ping_fail
win_max_rtt, win_slow, win_timeouts = (
sched.take_window_stats() if sched else (0.0, 0, 0))
window_polls_ok = max(0, d_poll - d_err)
h = {
'mode': 'poll+observe',
'device_class': self.descriptor.name,
'serial': self._serial,
'connect_count': self.connect_count,
'error_count': self.error_count,
'notif_count': self.notif_count,
'poll_count': poll,
'poll_error_count': poll_err,
'poll_window_ok': window_polls_ok,
'poll_window_errors': d_err,
'poll_window_max_rtt_ms': round(win_max_rtt, 0),
'poll_window_slow_count': win_slow,
'poll_window_timeout_count': win_timeouts,
'ping_count': ka.ping_count if ka else 0,
'ping_fail_count': ping_fail,
'reachable': ka.reachable if ka else None,
'last_change_source': self._last_change_source,
'last_observe_age_s': last_obs_age,
'push_active': push_active,
'last_change_age_s': (round(now - self.last_change_ts, 1)
if self.last_change_ts else None),
'last_seed_age_s': (round(now - self.last_seed_ts, 1)
if self.last_seed_ts else None),
'session_age_s': (round(now - self.session_started_ts, 1)
if self.session_started_ts else None),
'uptime_seconds': round(now - self.started_ts, 0),
}
stalest = self.cache.stalest()
if stalest is not None:
h['stalest_href'] = stalest[0]
h['stalest_age_s'] = round(stalest[1], 1)
try:
self.mqtt.publish(self.health_topic, json.dumps(h).encode(),
qos=0, retain=True)
except Exception as e:
self.log.warning("health publish: %s", e)
self.publish_push_active(push_active)
self._maybe_force_reconnect()
if d_poll > 0 or d_err > 0 or d_ping_fail > 0:
self.log.info(
"poll-window: %d ok, %d err, %d ping-fail, "
"p_max=%.0fms, slow=%d, timeouts=%d (%ds)",
window_polls_ok, d_err, d_ping_fail,
win_max_rtt, win_slow, win_timeouts,
self.shared.HEALTH_INTERVAL_S)
def publish_push_active(self, active: bool, force: bool = False) -> None:
value = 'online' if active else 'offline'
if not force and value == self._last_push_active_pub:
return
self._last_push_active_pub = value
try:
self.mqtt.publish(self.push_active_topic, value,
qos=1, retain=True)
self.log.info("push_active → %s", value)
except Exception as e:
self.log.warning("push_active publish: %s", e)
# ---- top-level loop ---------------------------------------------
def run_forever(self):
backoff = 1.0
while not self.stop.is_set():
try:
self.session_once()
backoff = 1.0
except Exception as e:
self.error_count += 1
self.log.warning("session error: %s", e)
sess = self.session
self.session = None
if sess is not None:
try: sess.close()
except Exception: pass
self.set_availability(False)
self.session_started_ts = None
if self.stop.is_set():
break
# Jitter the backoff so multiple bridges (dryer + oven) don't
# reconnect in lockstep after a router blip — synchronized
# storms make the broker / DTLS layer flap harder than need
# be. ±30% noise spreads the retry attempts.
wait = min(backoff, 30.0) * random.uniform(0.7, 1.3)
self.log.info("reconnect in %.1fs", wait)
if self.stop.wait(wait):
break
backoff = min(backoff * 2, 30.0)
+117
View File
@@ -0,0 +1,117 @@
"""Configuration — env-var driven.
Two flavours:
* SharedConfig — MQTT broker, cert paths, HA prefix, timers. One per
process.
* ApplianceConfig — one per appliance the bridge is supervising. Keys
come from `APPLIANCE_<n>_*` env vars (1-indexed). The list of
appliances is `APPLIANCE_COUNT` entries long.
.env in cwd hydrates os.environ at import time; docker-compose env wins
over file contents."""
import os
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
def _load_env_file(filename='.env'):
"""If a .env-style file is present in cwd, hydrate os.environ from
it. Existing env wins so docker-compose `environment:` overrides
file contents."""
env_path = Path.cwd() / filename
if not env_path.exists():
return
for line in env_path.read_text().splitlines():
line = line.strip()
if not line or line.startswith('#') or '=' not in line:
continue
k, v = line.split('=', 1)
k = k.strip(); v = v.strip()
if k and k not in os.environ:
os.environ[k] = v
_load_env_file('.env')
def _resolve_cert(env_key, basename):
"""Cert lookup: explicit env > /config/<basename> (Docker mount) >
./certs/<basename> (bare-metal dev)."""
if os.getenv(env_key):
return Path(os.environ[env_key])
docker_path = Path('/config') / basename
if docker_path.exists():
return docker_path
return Path.cwd() / 'certs' / basename
@dataclass(frozen=True)
class SharedConfig:
"""Process-wide config (MQTT, cert paths, intervals)."""
CERT_PATH: Path
KEY_PATH: Path
MQTT_BROKER: Optional[str]
MQTT_PORT: int
MQTT_USER: Optional[str]
MQTT_PASS: Optional[str]
HA_DISCOVERY_PREFIX: str
HEALTH_INTERVAL_S: int
PING_INTERVAL_S: int
@classmethod
def from_env(cls) -> 'SharedConfig':
return cls(
CERT_PATH=_resolve_cert('CERT_PATH', 'client_fullchain.pem'),
KEY_PATH=_resolve_cert('KEY_PATH', 'client.key'),
MQTT_BROKER=os.getenv('MQTT_BROKER'),
MQTT_PORT=int(os.getenv('MQTT_PORT', '1883')),
MQTT_USER=os.getenv('MQTT_USER') or None,
MQTT_PASS=os.getenv('MQTT_PASS') or None,
HA_DISCOVERY_PREFIX=os.getenv('HA_DISCOVERY_PREFIX',
'homeassistant'),
HEALTH_INTERVAL_S=int(os.getenv('HEALTH_INTERVAL_S', '60')),
PING_INTERVAL_S=int(os.getenv('PING_INTERVAL_S', '25')),
)
@dataclass(frozen=True)
class ApplianceConfig:
"""Per-appliance runtime config.
Sourced from `APPLIANCE_<index>_*` env vars (1-indexed). `index` is
just a stable identifier for logs; it does not appear in MQTT
topics or HA discovery (those are keyed off `topic_prefix`)."""
index: int
klass: str # 'dryer', 'oven', …
ip: str
ocf_port: Optional[int] # None → descriptor.default_observe_port
topic_prefix: str
device_name: str
@classmethod
def from_env(cls, index: int) -> 'ApplianceConfig':
prefix = f'APPLIANCE_{index}_'
klass = os.getenv(prefix + 'CLASS')
if not klass:
raise ValueError(f"{prefix}CLASS not set")
ip = os.getenv(prefix + 'IP')
if not ip:
raise ValueError(f"{prefix}IP not set")
port_env = os.getenv(prefix + 'OCF_PORT')
port = int(port_env) if port_env else None
topic = os.getenv(prefix + 'TOPIC') or f'samsung_{klass}'
name = os.getenv(prefix + 'NAME') or f'Samsung {klass.title()}'
return cls(
index=index, klass=klass, ip=ip, ocf_port=port,
topic_prefix=topic, device_name=name,
)
def load_appliances() -> list[ApplianceConfig]:
"""Read APPLIANCE_COUNT and build the appliance list. At least one
is required."""
count = int(os.getenv('APPLIANCE_COUNT', '1'))
if count < 1:
raise ValueError("APPLIANCE_COUNT must be >= 1")
return [ApplianceConfig.from_env(i + 1) for i in range(count)]
+85
View File
@@ -0,0 +1,85 @@
#!/bin/bash
# Sync source + .env to the remote and rebuild the container.
#
# Two host paths are used:
# REMOTE_DIR — compose project (source code, .env, docker-compose.yml)
# Convention: /mnt/user/compose/samsung-bridge/
# APPDATA_DIR — bind-mount source for /config inside the container
# (client cert + key live here).
# Convention: /mnt/user/appdata/samsung-bridge/
#
# The remote must already have the certs in $APPDATA_DIR. Run once
# before the first deploy:
#
# source .env
# ssh "$SSH_HOST" mkdir -p "$APPDATA_DIR"
# scp certs/client_fullchain.pem certs/client.key \
# "$SSH_HOST:$APPDATA_DIR/"
#
# Subsequent deploys (this script) ship source code + .env only; the
# certs in $APPDATA_DIR are preserved.
set -e
# This script lives in mqtt_demo/ but the build context is the repo
# root (mqtt_demo/docker-compose.yml uses `context: ..`, since the
# image needs the protocol/ and ocf/ library packages alongside
# mqtt_demo/). Run everything from the repo root so the tar allowlist
# and remote layout line up with that context.
cd "$(dirname "$0")/.."
if [ ! -f mqtt_demo/.env ]; then
echo "Error: mqtt_demo/.env file not found. Copy mqtt_demo/.env.example to mqtt_demo/.env and configure it."
exit 1
fi
# Pull only the keys deploy.sh actually needs, without sourcing .env.
# Sourcing would tokenize unquoted spaces in values (e.g.
# `APPLIANCE_1_NAME=Samsung Dryer`) as shell commands.
get_env() {
grep -E "^${1}=" mqtt_demo/.env | head -1 | cut -d= -f2-
}
SSH_HOST=$(get_env SSH_HOST)
REMOTE_DIR=$(get_env REMOTE_DIR)
APPDATA_DIR=$(get_env APPDATA_DIR)
: "${SSH_HOST:?SSH_HOST not set in .env}"
: "${REMOTE_DIR:?REMOTE_DIR not set in .env}"
: "${APPDATA_DIR:?APPDATA_DIR not set in .env}"
echo "Deploying to ${SSH_HOST}:${REMOTE_DIR}…"
ssh "${SSH_HOST}" mkdir -p "${REMOTE_DIR}" "${APPDATA_DIR}"
# Source code — explicit allowlist instead of an excludelist. Anything
# else in the repo (research files, certs, logs, the .git dir) stays
# local. protocol/ and ocf/ are the library packages mqtt_demo/ imports
# from; they need to land as REMOTE_DIR's siblings of mqtt_demo/ so the
# compose file's `context: ..` resolves the same way it does locally.
COPYFILE_DISABLE=1 tar cz \
protocol/ \
ocf/ \
mqtt_demo/ \
README.md \
.gitignore \
| ssh "${SSH_HOST}" "cd ${REMOTE_DIR} && tar xz && find . -name '._*' -delete"
# Ship .env separately and lock it down on the remote.
scp mqtt_demo/.env "${SSH_HOST}:${REMOTE_DIR}/mqtt_demo/.env"
ssh "${SSH_HOST}" "chmod 600 ${REMOTE_DIR}/mqtt_demo/.env"
# Verify certs are present on the remote — they have to be uploaded
# once before the first build.
if ! ssh "${SSH_HOST}" "test -s ${APPDATA_DIR}/client_fullchain.pem && test -s ${APPDATA_DIR}/client.key"; then
echo
echo "WARNING: ${APPDATA_DIR}/client_fullchain.pem and client.key not"
echo "found on the remote. The container will start but fail to"
echo "connect to the appliance until you upload them, e.g.:"
echo " ssh ${SSH_HOST} mkdir -p ${APPDATA_DIR}"
echo " scp certs/client_fullchain.pem certs/client.key ${SSH_HOST}:${APPDATA_DIR}/"
echo
fi
echo "Rebuilding container…"
ssh "${SSH_HOST}" "cd ${REMOTE_DIR}/mqtt_demo && docker compose up -d --build"
echo "Done."
echo "Logs: ssh ${SSH_HOST} 'cd ${REMOTE_DIR}/mqtt_demo && docker compose logs -f'"
+228
View File
@@ -0,0 +1,228 @@
"""ApplianceDescriptor — the per-device-class abstraction.
The bridge is appliance-class-agnostic: it owns the DTLS session, the
OBSERVE-token bookkeeping, and MQTT publish gating. Each appliance
class (dryer, oven, …) provides a descriptor that supplies:
* observe_paths — which CoAP resources to OBSERVE
* seed_path — the resource to fetch on connect (usually
/device/0) to populate the link dict
* flatten(links) — links → flat-dict that lands on MQTT
* build_discovery(…) — list of (HA-discovery topic, payload)
* command_handlers() — MQTT command-suffix → (path_segs, body_dict)
Optional hooks let an appliance hold transient state across pushes:
* on_observation(state, href, rep) — capture anchors, e.g. for
time extrapolation
* project(state, sensors) — fill in extrapolated fields
on publish
state is a free-form dict the bridge owns and threads into both hooks.
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from typing import Callable, Optional, TYPE_CHECKING
if TYPE_CHECKING:
from ocf.poll_scheduler import PollTier
@dataclass
class ApplianceDescriptor:
"""Static description of a Samsung appliance class.
`name` is the value DEVICE_CLASS resolves to (e.g. 'dryer').
`default_observe_port` documents the UDP port this firmware
exposes its DTLS-CoAP endpoint on, for the .env templates and
docs — config.APPLIANCE_OCF_PORT still wins at runtime."""
name: str
default_observe_port: int
observe_paths: list[list[str]]
seed_path: list[str]
flatten: Callable[[dict], dict]
build_discovery: Callable[[str, str, str], list[tuple[str, bytes]]]
# command_handlers() returns {topic_suffix: fn(payload_str, links_snapshot)}
# where the fn returns (path_segs, body_dict) | None. Handlers receive
# a snapshot of the bridge's link dict so they can do read-modify-write
# on resources like `/mode/vs/0` options or `/temperatures/vs/0` items.
command_handlers: Callable[[], dict[str, Callable[[str, dict], Optional[tuple]]]]
# Optional behavioural hooks. state is a mutable dict the bridge
# threads in; the descriptor decides what keys to put in it.
on_observation: Optional[Callable[[dict, str, dict], None]] = None
project: Optional[Callable[[dict, dict], dict]] = None
# If set, the bridge maintains a second availability topic
# `<prefix>/remote_available` derived from sensors[remote_field].
# HA entities that the appliance only honours with Remote-Control
# enabled gate themselves on it.
remote_available_field: Optional[str] = None
# If set, the bridge maintains a third availability topic
# `<prefix>/cycle_active` derived from sensors[cycle_active_field]
# (boolean). HA entities that the appliance only accepts writes for
# while a cycle is running (e.g. oven setpoint, cook time, options
# toggles) gate themselves on it.
cycle_active_field: Optional[str] = None
# Optional log-line callback for state-change notifications. Gets
# the freshly-projected sensors dict; returns a short string.
log_state_change: Optional[Callable[[dict], str]] = None
# Tiered polling cadence. Hot tier resources are the user-visible
# state that needs sub-second freshness; warm/cold/sweep cover the
# rest. Empirically measured per-device ceilings in
# local-tools/probe_poll_rate_combined.py (dryer ~14 req/s, oven
# ~8 req/s) inform the defaults each descriptor sets.
poll_tiers: list['PollTier'] = field(default_factory=list)
# Predicate the PollScheduler calls each tick to decide whether to
# use a tier's active_interval_s. Gets a shallow snapshot of the
# link dict so it can read whichever resource indicates activity.
is_active: Optional[Callable[[dict], bool]] = None
# --- HA-discovery helpers ----------------------------------------------
# Pure builder fns used by descriptor build_discovery() implementations.
# Kept here so the per-appliance modules stay focused on their entity
# inventory.
def device_block(topic_prefix: str, device_name: str,
model: str) -> dict:
return {
'identifiers': [topic_prefix],
'name': device_name,
'manufacturer': 'Samsung',
'model': model,
}
def avail_base(avail_topic: str) -> list[dict]:
return [{'topic': avail_topic,
'payload_available': 'online',
'payload_not_available': 'offline'}]
def avail_with_remote(avail_topic: str,
remote_topic: str) -> list[dict]:
return [
{'topic': avail_topic,
'payload_available': 'online',
'payload_not_available': 'offline'},
{'topic': remote_topic,
'payload_available': 'online',
'payload_not_available': 'offline'},
]
def avail_with_cycle(avail_topic: str,
cycle_topic: str) -> list[dict]:
return [
{'topic': avail_topic,
'payload_available': 'online',
'payload_not_available': 'offline'},
{'topic': cycle_topic,
'payload_available': 'online',
'payload_not_available': 'offline'},
]
def avail_with_remote_and_cycle(avail_topic: str,
remote_topic: str,
cycle_topic: str) -> list[dict]:
return [
{'topic': avail_topic,
'payload_available': 'online',
'payload_not_available': 'offline'},
{'topic': remote_topic,
'payload_available': 'online',
'payload_not_available': 'offline'},
{'topic': cycle_topic,
'payload_available': 'online',
'payload_not_available': 'offline'},
]
def encode(cfg: dict) -> bytes:
return json.dumps(cfg).encode()
def bridge_diagnostic_discovery(topic_prefix: str,
ha_discovery_prefix: str,
device_name: str,
model: str = 'Bridge') -> list[tuple[str, bytes]]:
"""HA-discovery payloads for the per-bridge diagnostic entities
(push state, polling RTT, freshness). Returned as a list of
(topic, payload-bytes) tuples to be merged with the descriptor's
own build_discovery output."""
avail_topic = f"{topic_prefix}/availability"
health_topic = f"{topic_prefix}/bridge/health"
push_topic = f"{topic_prefix}/bridge/push_active"
device = device_block(topic_prefix, device_name, model)
avail = avail_base(avail_topic)
def sensor(slug: str, name: str, value_template: str,
unit: str | None = None, device_class: str | None = None,
icon: str | None = None) -> tuple[str, bytes]:
cfg = {
'name': name,
'unique_id': f"{topic_prefix}_bridge_{slug}",
'object_id': f"{topic_prefix}_bridge_{slug}",
'state_topic': health_topic,
'value_template': value_template,
'availability': avail,
'device': device,
'entity_category': 'diagnostic',
}
if unit is not None: cfg['unit_of_measurement'] = unit
if device_class is not None: cfg['device_class'] = device_class
if icon is not None: cfg['icon'] = icon
topic = (f"{ha_discovery_prefix}/sensor/"
f"{topic_prefix}/bridge_{slug}/config")
return topic, encode(cfg)
push_active_cfg = {
'name': 'Push Active',
'unique_id': f"{topic_prefix}_bridge_push_active",
'object_id': f"{topic_prefix}_bridge_push_active",
'state_topic': push_topic,
'payload_on': 'online',
'payload_off': 'offline',
'availability': avail,
'device': device,
'entity_category': 'diagnostic',
'icon': 'mdi:rss',
}
push_active_topic = (f"{ha_discovery_prefix}/binary_sensor/"
f"{topic_prefix}/bridge_push_active/config")
return [
(push_active_topic, encode(push_active_cfg)),
sensor('update_source', 'Last Update Source',
"{{ value_json.last_change_source | default('?') }}",
icon='mdi:transit-connection-variant'),
sensor('stalest_age_s', 'Stalest Resource Age',
"{{ value_json.stalest_age_s | default(0) }}",
unit='s', icon='mdi:clock-alert-outline'),
sensor('poll_max_rtt_ms', 'Poll Max RTT',
"{{ value_json.poll_window_max_rtt_ms | default(0) | int }}",
unit='ms', icon='mdi:speedometer'),
sensor('slow_polls', 'Slow Polls (window)',
"{{ value_json.poll_window_slow_count | default(0) }}",
icon='mdi:timer-sand'),
sensor('poll_timeouts', 'Poll Timeouts (window)',
"{{ value_json.poll_window_timeout_count | default(0) }}",
icon='mdi:timer-off-outline'),
sensor('poll_errors', 'Poll Errors (window)',
"{{ value_json.poll_window_errors | default(0) }}",
icon='mdi:alert-circle-outline'),
sensor('observe_age_s', 'Last OBSERVE Age',
"{{ value_json.last_observe_age_s if value_json.last_observe_age_s is not none else 'never' }}",
icon='mdi:radar'),
]
+22
View File
@@ -0,0 +1,22 @@
services:
smartthings-local:
build:
context: ..
dockerfile: mqtt_demo/Dockerfile
container_name: smartthings-local
restart: unless-stopped
# Bridge is outbound-only (DTLS UDP to each appliance, MQTT to the
# broker on 1883). No ports to expose.
volumes:
# Holds the client cert + key. APPDATA_DIR comes from .env;
# on Unraid this is typically /mnt/user/appdata/smartthings-local/.
# Bare-metal dev falls back to ./certs alongside this compose file.
- ${APPDATA_DIR:-./certs}:/config:ro
# All runtime config is in .env. env_file passes every variable
# straight into the container, so adding a new appliance is a
# .env edit only — no compose change.
env_file:
- .env
+70
View File
@@ -0,0 +1,70 @@
"""Stdout logging — works correctly under Docker's PYTHONUNBUFFERED=1.
Two logger families share the root handler:
* `samsung_appliance` (and its children) for module-level lines —
DTLS warnings, generic startup chatter, MQTT plumbing.
* `<class>.<serial>` for per-appliance bridge lines — e.g.
`dryer.<serial>`. Each PushBridge gets its own such logger
via bridge_logger() once the seed reveals the appliance's serial.
Both trees propagate to the root logger, which is the one with the
StreamHandler, so the same format applies everywhere.
WARNING lines render yellow, ERROR red, when colour is enabled. Set
`NO_COLOR=1` in the environment to fall back to plain text (e.g. when
writing logs to a file)."""
import logging
import os
import sys
_LEVEL_COLOURS = {
logging.WARNING: '\033[33m', # yellow
logging.ERROR: '\033[31m', # red
logging.CRITICAL: '\033[1;31m', # bold red
}
_RESET = '\033[0m'
def _colour_enabled() -> bool:
return 'NO_COLOR' not in os.environ
class _LevelColourFormatter(logging.Formatter):
"""Wraps the rendered line in an ANSI colour for WARNING+ levels.
INFO/DEBUG pass through unchanged so the bulk of the log stays
readable and a yellow line draws the eye."""
def __init__(self, fmt: str, datefmt: str, use_colour: bool):
super().__init__(fmt=fmt, datefmt=datefmt)
self.use_colour = use_colour
def format(self, record: logging.LogRecord) -> str:
line = super().format(record)
if not self.use_colour:
return line
colour = _LEVEL_COLOURS.get(record.levelno)
if colour is None:
return line
return f"{colour}{line}{_RESET}"
_handler = logging.StreamHandler(sys.stdout)
_handler.setFormatter(_LevelColourFormatter(
fmt='%(asctime)s %(levelname)-5s %(name)-26s %(message)s',
datefmt='%H:%M:%S',
use_colour=_colour_enabled(),
))
logging.basicConfig(level=logging.INFO, handlers=[_handler], force=True)
logger = logging.getLogger("samsung_appliance")
def bridge_logger(klass: str, serial: str | None = None) -> logging.Logger:
"""Return a top-level logger tagged with the appliance class and,
once known, the serial. Pre-seed callers pass serial=None and get
e.g. `dryer`; post-seed callers pass the serial and get e.g.
`dryer.<serial>`."""
name = f"{klass}.{serial}" if serial else klass
return logging.getLogger(name)
+9
View File
@@ -0,0 +1,9 @@
cbor2>=5.6
paho-mqtt>=2.0
# pyOpenSSL 23.x added the set_ciphertext_mtu API the DTLS handshake
# needs; older versions fragment the client cert across two UDP
# datagrams and TizenRT drops the second one.
pyOpenSSL>=23.0
# scapy is only needed for the offline analyze_pcap.py helper; keep
# optional to avoid pulling it into the bridge container.
# scapy>=2.5
+35
View File
@@ -0,0 +1,35 @@
"""Appliance descriptors registry.
Adding a new appliance class:
1. Write `appliances/<class>.py` with an ApplianceDescriptor named
after the class (uppercase, e.g. OVEN).
2. Add it to DESCRIPTORS below.
3. Set DEVICE_CLASS=<class> in the per-device .env.
main.py imports get_descriptor(name) to look up the descriptor at
startup; the bridge itself stays class-agnostic.
"""
from ..descriptor 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,
}
def get_descriptor(name: str) -> ApplianceDescriptor:
try:
return DESCRIPTORS[name]
except KeyError:
raise ValueError(
f"unknown DEVICE_CLASS={name!r}; "
f"available: {sorted(DESCRIPTORS)}") from None
__all__ = ['ApplianceDescriptor', 'DESCRIPTORS', 'get_descriptor']
+503
View File
@@ -0,0 +1,503 @@
"""Dryer descriptor.
Lifts the dryer-specific OBSERVE paths, sensor flattening, HA discovery
inventory, and MQTT command handlers out of the original
samsung_dryer/{bridge,sensors,discovery}.py modules into one place.
"""
import time
from ..descriptor import (
ApplianceDescriptor,
avail_base,
avail_with_remote,
device_block,
encode,
)
from ocf.poll_scheduler import PollTier
# --- OBSERVE paths -----------------------------------------------------
# Only Samsung's `/<x>/vs/0` siblings actually push notifications; the
# OCF-standard `/<x>/0` paths accept registration silently but never
# fire. flatten() derives the OCF-shaped values from the live /vs/0
# strings.
OBSERVE_PATHS = [
['operational', 'state', 'vs', '0'], # state, remainingTime, progress
['power', 'vs', '0'], # power on/off
['kidslock', 'vs', '0'], # child lock
['remotectrl', 'vs', '0'], # remote control enabled
['energy', 'consumption', 'vs', '0'],
['course', 'vs', '0'],
['washer', 'vs', '0'], # dryLevel, dryTime, type
['diagnosis', 'vs', '0'],
['alarms', 'vs', '0'],
['st', 'dryercourse', 'vs', '0'],
['wm', 'jobbeginingstatus', 'vs', '0'],
]
# --- Course table ------------------------------------------------------
# Captured 2026-05-29 by dialing every course on a
# DA_WM_TP2_20_COMMON_DV5000T dryer. Other Samsung dryers may report a
# different Table_NN; capture a fresh table for them with
# local-tools/course_mapper.py.
COURSE_NAMES = {
'Table_03': {
0x16: 'Cotton',
0x18: 'Synthetics',
0x19: 'Delicates',
0x1A: 'Wool',
0x1B: 'Bedding',
0x1C: 'Shirts',
0x1D: 'Towels',
0x1E: 'Outdoor',
0x1F: 'Mixed Load',
0x20: 'Iron Dry',
0x23: 'Quick Dry 35',
0x24: 'Cool Air',
0x25: 'Warm Air',
0x27: 'Time Dry',
},
}
_COURSE_CODE_BY_NAME = {
name: code
for table_codes in COURSE_NAMES.values()
for code, name in table_codes.items()
}
def _decode_course(s):
"""`Table_03_Course_16` → `Cotton`. Pass through verbatim if the
table or code isn't in our lookup."""
if not isinstance(s, str) or '_Course_' not in s:
return s
table_part, _, code_str = s.partition('_Course_')
table = COURSE_NAMES.get(table_part)
if not table:
return s
try:
code = int(code_str, 16)
except ValueError:
return s
return table.get(code, s)
def _encode_course(name):
"""`Cotton` → `Course_16`. Returns None for unknown names so the
caller refuses rather than POST garbage."""
code = _COURSE_CODE_BY_NAME.get(name)
if code is None:
return None
return f"Course_{code:02X}"
def _course_options():
"""Stable-sorted human course names for the HA select dropdown."""
return sorted(_COURSE_CODE_BY_NAME.keys())
# --- Samsung-state → OCF currentMachineState ---------------------------
_SAMSUNG_STATE_TO_OCF = {
'Ready': 'idle',
'Run': 'active',
'Running': 'active',
'Pause': 'pause',
'Paused': 'pause',
'End': 'idle',
}
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 a /device/0 link dict to the flat sensor dict that's
published to MQTT. Every field reads from `/<x>/vs/0` paths so push
updates immediately drive every entity."""
g = lambda href, k, default=None: (links.get(href) or {}).get(k, default)
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.cumulativePower'))
if inst_w is not None and inst_w < 0:
# The dryer reports a phantom -500W when idle; HA energy
# dashboard hates negatives.
inst_w = 0.0
sam_state = g('/operational/state/vs/0', 'x.com.samsung.da.state')
machine_state = (_SAMSUNG_STATE_TO_OCF.get(sam_state, sam_state)
if sam_state is not None
else g('/operational/state/0', 'currentMachineState'))
progress = g('/operational/state/vs/0', 'x.com.samsung.da.progress')
job_state = progress or g('/operational/state/0', 'currentJobState')
# HA's value_template treats the literal "None" as null (renders as
# "Unknown"). Substitute something we can render verbatim.
if job_state in (None, 'None'):
job_state = 'Idle'
if progress in (None, 'None'):
progress = 'Idle'
remaining = (g('/operational/state/vs/0',
'x.com.samsung.da.remainingTime')
or g('/operational/state/0', 'remainingTime'))
rem_min = None
if remaining:
try:
h, m, s = remaining.split(':')
rem_min = int(h) * 60 + int(m) + (1 if int(s) > 0 else 0)
except Exception:
pass
sam_power = g('/power/vs/0', 'x.com.samsung.da.power')
sam_kids = g('/kidslock/vs/0', 'x.com.samsung.da.kidsLock')
sam_rc = g('/remotectrl/vs/0',
'x.com.samsung.da.remoteControlEnabled')
power_bin = (sam_power == 'On') if sam_power is not None else None
kids_bin = (sam_kids != 'Ready') if sam_kids is not None else None
rc_bin = (str(sam_rc).lower() == 'true') if sam_rc is not None else None
return {
'machine_state': machine_state,
'job_state': job_state,
'progress': progress,
'progress_percentage': _int(g('/operational/state/vs/0',
'x.com.samsung.da.progressPercentage')
or g('/operational/state/0',
'progressPercentage')),
'completion_time': remaining,
'completion_minutes': rem_min,
'delay_end_time': g('/operational/state/vs/0',
'x.com.samsung.da.delayEndTime'),
'power_state': sam_power,
'power_state_binary': power_bin,
'child_lock': sam_kids,
'child_lock_binary': kids_bin,
'remote_control': sam_rc,
'remote_control_binary': rc_bin,
'power_watts': inst_w,
'energy_kwh': round(cum_wh / 1000.0, 2)
if cum_wh is not None else None,
'energy_wh_cumulative': int(cum_wh) if cum_wh is not None else None,
'dryer_mode': _decode_course(
g('/st/dryercourse/vs/0',
'x.com.samsung.da.st.dryerMode')),
'dry_level': _int(g('/washer/vs/0',
'x.com.samsung.da.dryLevel')),
'dry_time': g('/washer/vs/0',
'x.com.samsung.da.dryTime'),
'dryer_type': g('/washer/vs/0',
'x.com.samsung.da.dryerType'),
'wrinkle_prevent': g('/washer/vs/0',
'x.com.samsung.da.wrinklePrevent'),
'diagnosis': g('/diagnosis/vs/0',
'x.com.samsung.da.diagnosisStart'),
'country_code': g('/configuration/vs/0',
'x.com.samsung.da.countryCode'),
}
# --- Remaining-time anchor + extrapolation ----------------------------
# The dryer pushes /operational/state/vs/0 on state transitions but not
# on remainingTime ticks. Anchor = (timestamp, total_seconds) at last
# push; project() extrapolates downward while machine_state == 'active'.
def on_observation(state, href, rep):
if href != '/operational/state/vs/0':
return
rem = rep.get('x.com.samsung.da.remainingTime')
if not isinstance(rem, str):
return
try:
h, m, s = rem.split(':')
state['remaining_anchor'] = (time.time(),
int(h) * 3600 + int(m) * 60 + int(s))
except (ValueError, AttributeError):
pass
def project(state, sensors):
anchor = state.get('remaining_anchor')
if sensors.get('machine_state') != 'active' or anchor is None:
return sensors
ts, total = anchor
remaining = max(0, int(total - (time.time() - ts)))
h, rest = divmod(remaining, 3600)
m, s = divmod(rest, 60)
sensors = dict(sensors)
sensors['completion_time'] = f"{h}:{m:02d}:{s:02d}"
sensors['completion_minutes'] = h * 60 + m + (1 if s > 0 else 0)
return sensors
# --- Log-line ----------------------------------------------------------
def log_state_change(sensors):
return (f"machine={sensors.get('machine_state')} "
f"power={sensors.get('power_watts')}W "
f"energy={sensors.get('energy_kwh')}kWh")
# --- HA discovery ------------------------------------------------------
MODEL = 'OCF dryer (TizenRT-iotivity)'
# (key, friendly name, extra-config-dict)
_SENSORS = [
('machine_state', 'Machine state', {'icon': 'mdi:tumble-dryer'}),
('job_state', 'Job state', {}),
('progress', 'Progress', {}),
('progress_percentage', 'Progress percent',
{'unit_of_measurement': '%', 'state_class': 'measurement'}),
('completion_time', 'Completion time', {'icon': 'mdi:timer-sand'}),
('completion_minutes', 'Remaining minutes',
{'unit_of_measurement': 'min', 'device_class': 'duration',
'state_class': 'measurement'}),
('delay_end_time', 'Delay end time', {'icon': 'mdi:timer'}),
('power_state', 'Power state', {}),
('power_watts', 'Power',
{'unit_of_measurement': 'W', 'device_class': 'power',
'state_class': 'measurement'}),
('energy_kwh', 'Energy',
{'unit_of_measurement': 'kWh', 'device_class': 'energy',
'state_class': 'total_increasing'}),
('dryer_mode', 'Dryer mode', {}),
('dry_level', 'Dry level', {}),
('dry_time', 'Dry time', {}),
('dryer_type', 'Dryer type', {}),
('wrinkle_prevent', 'Wrinkle prevent', {}),
('diagnosis', 'Diagnosis', {}),
('country_code', 'Country code', {}),
]
# (key, friendly name, value_template, device_class)
_BINARY_SENSORS = [
('running', 'Running',
"{{ 'ON' if value_json.machine_state == 'active' else 'OFF' }}",
'running'),
('power_switch', 'Power switch',
"{{ 'ON' if value_json.power_state_binary else 'OFF' }}",
'power'),
('child_lock_active', 'Child lock',
"{{ 'ON' if value_json.child_lock_binary else 'OFF' }}",
'lock'),
('remote_control_enabled', 'Remote control',
"{{ 'ON' if value_json.remote_control_binary else 'OFF' }}",
'connectivity'),
]
# MQTT command-topic suffixes. The bridge subscribes to <prefix>/cmd/#
# and dispatches by suffix.
CMD_WRINKLE_PREVENT = 'cmd/wrinkle_prevent'
CMD_OPERATIONAL = 'cmd/operational_state'
CMD_DRYER_MODE = 'cmd/dryer_mode'
def build_discovery(topic_prefix, ha_prefix, device_name):
"""Return list of (discovery_topic, payload_bytes) tuples ready to
publish (retained) on MQTT connect."""
state_topic = f"{topic_prefix}/state"
avail_topic = f"{topic_prefix}/availability"
remote_topic = f"{topic_prefix}/remote_available"
dev = device_block(topic_prefix, device_name, MODEL)
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_base(avail_topic),
'device': dev,
}
cfg.update(extra)
out.append((f"{ha_prefix}/sensor/{topic_prefix}/{key}/config",
encode(cfg)))
for key, name, template, dclass 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',
'device_class': dclass,
'availability': avail_base(avail_topic),
'device': dev,
}
out.append((f"{ha_prefix}/binary_sensor/{topic_prefix}/{key}/config",
encode(cfg)))
# switch: wrinkle prevent (always available)
cfg = {
'name': 'Wrinkle prevent',
'unique_id': f"{topic_prefix}_wrinkle_prevent_switch",
'object_id': f"{topic_prefix}_wrinkle_prevent_switch",
'state_topic': state_topic,
'value_template': '{{ value_json.wrinkle_prevent }}',
'state_on': 'On',
'state_off': 'Off',
'command_topic': f"{topic_prefix}/{CMD_WRINKLE_PREVENT}",
'payload_on': 'On',
'payload_off': 'Off',
'icon': 'mdi:iron',
'availability': avail_base(avail_topic),
'device': dev,
}
out.append((f"{ha_prefix}/switch/{topic_prefix}/wrinkle_prevent/config",
encode(cfg)))
# buttons: Start / Pause / Stop (gated on remote control)
buttons = [
('start', 'Start cycle', 'Run', 'mdi:play'),
('pause', 'Pause cycle', 'Pause', 'mdi:pause'),
('stop', 'Stop cycle', 'Ready', 'mdi:stop'),
]
for key, name, payload_press, icon in buttons:
cfg = {
'name': name,
'unique_id': f"{topic_prefix}_{key}",
'object_id': f"{topic_prefix}_{key}",
'command_topic': f"{topic_prefix}/{CMD_OPERATIONAL}",
'payload_press': payload_press,
'icon': icon,
'availability': avail_with_remote(avail_topic, remote_topic),
'availability_mode': 'all',
'device': dev,
}
out.append((f"{ha_prefix}/button/{topic_prefix}/{key}/config",
encode(cfg)))
# select: course (gated on remote control)
cfg = {
'name': 'Course',
'unique_id': f"{topic_prefix}_course_select",
'object_id': f"{topic_prefix}_course_select",
'state_topic': state_topic,
'value_template': '{{ value_json.dryer_mode }}',
'command_topic': f"{topic_prefix}/{CMD_DRYER_MODE}",
'options': _course_options(),
'icon': 'mdi:tumble-dryer',
'availability': avail_with_remote(avail_topic, remote_topic),
'availability_mode': 'all',
'device': dev,
}
out.append((f"{ha_prefix}/select/{topic_prefix}/course/config",
encode(cfg)))
return out
# --- MQTT command handlers --------------------------------------------
def command_handlers():
"""topic_suffix → fn(payload, links) → (path_segs, body_dict) | None.
`None` means refuse the command (caller logs & drops). Dryer
handlers don't need the links snapshot — they're all single-field
writes."""
def _wrinkle(p, _links):
if p not in ('On', 'Off'):
return None
return ['washer', 'vs', '0'], {'x.com.samsung.da.wrinklePrevent': p}
def _operational(p, _links):
if p not in ('Run', 'Pause', 'Ready'):
return None
return ['operational', 'state', 'vs', '0'], {'x.com.samsung.da.state': p}
def _course(p, _links):
code = _encode_course(p)
if code is None:
return None
return ['st', 'dryercourse', 'vs', '0'], {'x.com.samsung.da.st.dryerMode': code}
return {
CMD_WRINKLE_PREVENT: _wrinkle,
CMD_OPERATIONAL: _operational,
CMD_DRYER_MODE: _course,
}
# --- Poll tiers --------------------------------------------------------
# Empirical ceiling on this firmware is ~14 req/s (probe_poll_rate_combined.py
# 2026-06-03). The hot tier sits at 1s idle / 0.5s active — comfortably under
# the ceiling and leaves headroom for the warm + sweep budgets.
# Per-tier timeouts are scaled to cadence: hot tier retries every 1s, so
# a tight 2s ceiling caps the cascade damage from one wedged poll. Warm
# tier has more headroom; sweep is multi-block Block2 and tolerates ~15s.
DRYER_POLL_TIERS = [
PollTier(
name='hot',
interval_s=1.0,
active_interval_s=0.5,
timeout_s=2.0,
paths=(
('operational', 'state', 'vs', '0'),
),
),
PollTier(
name='warm',
interval_s=15.0,
timeout_s=4.0,
paths=(
('power', 'vs', '0'),
('kidslock', 'vs', '0'),
('remotectrl', 'vs', '0'),
('alarms', 'vs', '0'),
('course', 'vs', '0'),
('washer', 'vs', '0'),
('st', 'dryercourse', 'vs', '0'),
('wm', 'jobbeginingstatus', 'vs', '0'),
('energy', 'consumption', 'vs', '0'),
('diagnosis', 'vs', '0'),
),
),
PollTier(
name='sweep',
interval_s=300.0,
timeout_s=15.0,
paths=(('device', '0'),),
is_sweep=True,
),
]
def _is_active(links: dict) -> bool:
rep = links.get('/operational/state/vs/0') or {}
sam_state = rep.get('x.com.samsung.da.state')
return _SAMSUNG_STATE_TO_OCF.get(sam_state) == 'active'
# --- Descriptor --------------------------------------------------------
DRYER = ApplianceDescriptor(
name='dryer',
default_observe_port=49155,
observe_paths=OBSERVE_PATHS,
seed_path=['device', '0'],
flatten=flatten,
build_discovery=build_discovery,
command_handlers=command_handlers,
on_observation=on_observation,
project=project,
remote_available_field='remote_control_binary',
log_state_change=log_state_change,
poll_tiers=DRYER_POLL_TIERS,
is_active=_is_active,
)
+493
View File
@@ -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 ..descriptor import (
ApplianceDescriptor,
avail_base,
device_block,
encode,
)
from ocf.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,
)
+862
View File
@@ -0,0 +1,862 @@
"""Oven descriptor (Samsung NV7000BS-class).
Resource map captured 2026-05-31 via DTLS-CoAP with the client cert.
See `local-tools/comparisons/oven-tree.md` for the full field reference.
Write surfaces this descriptor exposes:
proven:
* UpperLamp via /mode/vs/0 options RMW (probe_oven_lamp_toggle.py)
— works even with Remote Control off.
unproven (first HA use is also the test):
* Sound, FastPreheat — same RMW pattern as lamp.
* Setpoint via /temperatures/vs/0 items RMW. Mid-cook write may
or may not retune the element (plan §K-U #2).
* Mode select via /mode/vs/0 .modes — mid-cook acceptance unknown
(plan §K-U #3).
* Power on/off via /power/vs/0.
* Stop via /operational/state/vs/0 (dryer convention; oven may
use a different state value).
Untested writes are gated behind <prefix>/remote_available so HA
disables them in the UI when the oven's Remote Control switch is off."""
import time
from ..descriptor import (
ApplianceDescriptor,
avail_base,
avail_with_cycle,
avail_with_remote_and_cycle,
device_block,
encode,
)
from ocf.poll_scheduler import PollTier
# ---------------------------------------------------------------------
# OBSERVE paths — every push-eligible /<x>/vs/0 resource on the oven.
# Same wedge-safety story as the dryer: only `/<x>/vs/0` siblings push;
# OCF-standard `/<x>/0` paths accept registration but never fire.
# Security paths (/oic/sec/{doxm,pstat,acl,cred}) are deliberately
# EXCLUDED — those are the surfaces that nearly bricked the oven in
# prior sessions. The bridge has no reason to touch them.
# ---------------------------------------------------------------------
OBSERVE_PATHS = [
['operational', 'state', 'vs', '0'], # state, time, progress
['power', 'vs', '0'], # power On/Off
['oven', 'vs', '0'], # cavity state (Cooking, Idle, …)
['temperatures','vs', '0'], # current + desired temp
['doors', 'vs', '0'], # openState
['kidslock', 'vs', '0'], # child lock
['remotectrl', 'vs', '0'], # remote control enabled
['mode', 'vs', '0'], # cooking mode + options array
['alarms', 'vs', '0'], # alarm code (OV_E_OFF etc.)
['connected', 'vs', '0'], # cloud connectivity status
['otninformation', 'vs', '0'], # firmware-update flags
]
# Setpoint bounds — union across modeSpec entries on this oven. Per-mode
# bounds (e.g. PlateWarm 30–80) tighten this; the firmware will refuse
# out-of-range writes for the active mode and the HA UI will surface
# the resulting 4.xx in the bridge log.
SETPOINT_MIN_C = 30
SETPOINT_MAX_C = 270
SETPOINT_STEP_C = 5
# Samsung's operational state strings → OCF currentMachineState shape.
_SAMSUNG_STATE_TO_OCF = {
'Ready': 'idle',
'Run': 'active',
'Running': 'active',
'Pause': 'pause',
'Paused': 'pause',
'End': 'idle',
'Stop': 'idle',
}
def _num(v):
try: return float(v)
except (TypeError, ValueError): return None
def _int(v):
try: return int(v)
except (TypeError, ValueError): return None
def _option_value(options, prefix, default=None):
"""Find `<prefix>_<value>` in an options array and return <value>."""
for o in options:
if o.startswith(prefix + '_'):
return o.split('_', 1)[1]
return default
def _replace_in_options(options, prefix, new_value):
"""Return a new options array with any `<prefix>_*` entry replaced
by `<prefix>_<new_value>`. Caller must verify `options` is the live
options array first (Samsung uses replace-not-merge on this field)."""
return [f"{prefix}_{new_value}" if o.startswith(prefix + '_') else o
for o in options]
def _fmt_hms(seconds):
"""Format an integer second count as `H:MM:SS`. Returns None on
bad input so callers can leave the field null rather than emitting
a misleading `0:00:00`."""
try:
s = int(seconds)
except (TypeError, ValueError):
return None
if s < 0:
s = 0
h, rest = divmod(s, 3600)
m, sec = divmod(rest, 60)
return f"{h}:{m:02d}:{sec:02d}"
# ---------------------------------------------------------------------
# flatten — Samsung /device/0 links → HA-flavoured sensor dict.
# Every field reads from `/<x>/vs/0` paths so push updates immediately
# drive every entity. Where a field is settable (lamp, mode, setpoint),
# we publish it as a read-side sensor here AND as a writeable entity
# in build_discovery; the read side closes the HA UI feedback loop.
# ---------------------------------------------------------------------
def flatten(links):
g = lambda href, k, default=None: (links.get(href) or {}).get(k, default)
# Operational
sam_state = g('/operational/state/vs/0', 'x.com.samsung.da.state')
machine_state = (_SAMSUNG_STATE_TO_OCF.get(sam_state, sam_state)
if sam_state is not None else None)
operation_time = g('/operational/state/vs/0',
'x.com.samsung.da.operationTime')
remaining = g('/operational/state/vs/0',
'x.com.samsung.da.remainingTime')
rem_min = None
if remaining:
try:
h, m, s = remaining.split(':')
rem_min = int(h) * 60 + int(m) + (1 if int(s) > 0 else 0)
except Exception:
pass
# operationTime parsed as minutes — the source of truth for "Cook
# time" in HA (mid-cycle SmartThings updates land here, not in
# /mode/vs/0 UpperTimerSet which is vestigial).
op_min = None
if operation_time:
try:
h, m, s = operation_time.split(':')
op_min = int(h) * 60 + int(m) + (1 if int(s) > 0 else 0)
except Exception:
pass
# Cavity state — Cooking, Idle, Preheating, …
oven_state = g('/oven/vs/0', 'x.com.samsung.da.state')
# Temperatures
temps_items = (g('/temperatures/vs/0',
'x.com.samsung.da.items') or [])
cur_c = des_c = None
if temps_items:
cur_c = _int(temps_items[0].get('x.com.samsung.da.current'))
des_c = _int(temps_items[0].get('x.com.samsung.da.desired'))
# Door
doors_items = g('/doors/vs/0', 'x.com.samsung.da.items') or []
door = doors_items[0].get('x.com.samsung.da.openState') if doors_items else None
door_open = (door == 'Open') if door is not None else None
# Power
sam_power = g('/power/vs/0', 'x.com.samsung.da.power')
power_bin = (sam_power == 'On') if sam_power is not None else None
# Kidslock + Remote
sam_kids = g('/kidslock/vs/0', 'x.com.samsung.da.kidsLock')
kids_bin = (sam_kids != 'Ready') if sam_kids is not None else None
sam_rc = g('/remotectrl/vs/0',
'x.com.samsung.da.remoteControlEnabled')
rc_bin = (str(sam_rc).lower() == 'true') if sam_rc is not None else None
# Mode + options
modes = g('/mode/vs/0', 'x.com.samsung.da.modes') or []
current_mode = modes[0] if modes else None
options = g('/mode/vs/0', 'x.com.samsung.da.options') or []
lamp = _option_value(options, 'UpperLamp') # 'On' / 'Off'
# The door-coupling override (open → On, close → Off) lives in
# project() below — it needs descriptor_state to compare the
# latest door transition against the latest lamp value change so
# an HA-initiated optimistic write isn't clobbered by stale door
# state.
sound = _option_value(options, 'Sound') # 'On' / 'Off'
fastpreheat = _option_value(options, 'fastpreheat') # 'On' / 'Off'
# NaturalSteam only appears in the options array after it's been
# touched in the SmartThings app at least once. Until then it's
# absent, so `_option_value` returns None — surface that as None
# (HA renders as "Unknown") rather than "Off", which would falsely
# imply we know it's disabled.
natural_steam = _option_value(options, 'NaturalSteam') # 'On' / 'Off' / None
timer_state = _option_value(options, 'UpperTimerState') # 'Ready' / 'Running'
# UpperTimerCurrent/UpperTimerSet are integer seconds. Format as
# H:MM:SS for HA display so users see "1:10:00", not "4200".
timer_current_raw = _option_value(options, 'UpperTimerCurrent')
timer_set_raw = _option_value(options, 'UpperTimerSet')
timer_current = _fmt_hms(timer_current_raw)
timer_set = _fmt_hms(timer_set_raw)
timer_current_seconds = _int(timer_current_raw)
timer_set_seconds = _int(timer_set_raw)
# Alarms
alarm_items = g('/alarms/vs/0', 'x.com.samsung.da.items') or []
alarm_code = (alarm_items[0].get('x.com.samsung.da.code')
if alarm_items else None)
alarm_time = (alarm_items[0].get('x.com.samsung.da.triggeredTime')
if alarm_items else None)
# OV_E_OFF appears when the oven is off / no alarm; treat as inactive.
alarm_active = bool(alarm_code) and alarm_code != 'OV_E_OFF'
# Connectivity / firmware
sam_connected = g('/connected/vs/0', 'x.com.samsung.da.connected')
connected_bin = (sam_connected == 'On') if sam_connected is not None else None
fw_update_available = g('/otninformation/vs/0',
'x.com.samsung.da.newVersionAvailable')
fw_update_bin = (str(fw_update_available).lower() == 'true'
if fw_update_available is not None else None)
return {
'machine_state': machine_state,
# `cycle_active` gates the writable controls in HA. The oven
# only honours setpoint / cook-time / option writes (and Stop)
# while a cycle is active — outside an active cycle, writes
# return 2.04 but get rolled back within ~3s.
'cycle_active': machine_state == 'active',
'oven_state': oven_state,
'progress_percentage': _int(g('/operational/state/vs/0',
'x.com.samsung.da.progressPercentage')),
'operation_time': operation_time,
'operation_time_minutes': op_min,
'completion_time': remaining,
'completion_minutes': rem_min,
'current_temp_c': cur_c,
'target_temp_c': des_c,
'door': door,
'door_open': door_open,
'power_state': sam_power,
'power_state_binary': power_bin,
'child_lock': sam_kids,
'child_lock_binary': kids_bin,
'remote_control': sam_rc,
'remote_control_binary': rc_bin,
'mode': current_mode,
'lamp': lamp,
'sound': sound,
'fastpreheat': fastpreheat,
'natural_steam': natural_steam,
'timer_state': timer_state,
'timer_current': timer_current,
'timer_set': timer_set,
'timer_current_seconds': timer_current_seconds,
'timer_set_seconds': timer_set_seconds,
'alarm_code': alarm_code,
'alarm_time': alarm_time,
'alarm_active': alarm_active,
'connected': sam_connected,
'connected_binary': connected_bin,
'firmware_update_available': fw_update_bin,
}
# ---------------------------------------------------------------------
# Remaining-time anchor + projection. The oven pushes /operational/state
# on state transitions but probably not on remainingTime ticks (matches
# dryer behaviour). Capture (ts, total_seconds) at each push and
# extrapolate downward while machine_state == active.
# ---------------------------------------------------------------------
def on_observation(state, href, rep):
now = time.time()
if href == '/operational/state/vs/0':
rem = rep.get('x.com.samsung.da.remainingTime')
if isinstance(rem, str):
try:
h, m, s = rem.split(':')
state['remaining_anchor'] = (now,
int(h) * 3600 + int(m) * 60 + int(s))
except (ValueError, AttributeError):
pass
return
# Door + lamp tracking feeds the lamp/door coupling in project().
# Both timestamps bump only on value CHANGES so the comparison
# tells us which event happened more recently. /doors is hot-tier
# (1s) and would otherwise dominate; /mode is warm-tier (30s) and
# picks up HA optimistic writes via apply_optimistic → apply_rep.
if href == '/doors/vs/0':
items = rep.get('x.com.samsung.da.items') or []
door = items[0].get('x.com.samsung.da.openState') if items else None
if door != state.get('_door_last'):
state['_door_last'] = door
state['_door_change_ts'] = now
return
if href == '/mode/vs/0':
options = rep.get('x.com.samsung.da.options') or []
lamp = _option_value(options, 'UpperLamp')
if lamp != state.get('_lamp_last'):
state['_lamp_last'] = lamp
state['_lamp_change_ts'] = now
def project(state, sensors):
sensors = dict(sensors)
# Remaining-time projection: the oven pushes /operational/state on
# state transitions but not on remainingTime ticks. Extrapolate
# from the most recent anchor while the machine is active.
anchor = state.get('remaining_anchor')
if sensors.get('machine_state') == 'active' and anchor is not None:
ts, total = anchor
remaining = max(0, int(total - (time.time() - ts)))
h, rest = divmod(remaining, 3600)
m, s = divmod(rest, 60)
sensors['completion_time'] = f"{h}:{m:02d}:{s:02d}"
sensors['completion_minutes'] = h * 60 + m + (1 if s > 0 else 0)
# Lamp / door coupling. The oven hardware auto-drives the lamp from
# the door state, but /mode/vs/0 only polls every 30s. When a door
# TRANSITION is more recent than the last lamp VALUE change, derive
# lamp from door for sub-second freshness. When a lamp toggle is
# more recent (HA optimistic write, or panel-driven /mode diff),
# the cache value wins — preserves HA toggle responsiveness even
# while the door is closed.
door_ts = state.get('_door_change_ts')
lamp_ts = state.get('_lamp_change_ts')
door_open = sensors.get('door_open')
if door_ts is not None and (lamp_ts is None or door_ts > lamp_ts):
if door_open is True:
sensors['lamp'] = 'On'
elif door_open is False:
sensors['lamp'] = 'Off'
return sensors
def log_state_change(sensors):
return (f"machine={sensors.get('machine_state')} "
f"oven={sensors.get('oven_state')} "
f"temp={sensors.get('current_temp_c')}/"
f"{sensors.get('target_temp_c')}°C "
f"mode={sensors.get('mode')} "
f"timer_set={sensors.get('timer_set_seconds')} "
f"timer_cur={sensors.get('timer_current_seconds')}")
# ---------------------------------------------------------------------
# HA discovery inventory
# ---------------------------------------------------------------------
MODEL = 'OCF oven (TizenRT-iotivity, NV7000BS-class)'
# (key, friendly name, extra config)
#
# Only read-only sensors live here. Fields that ALSO have an
# interactive entity (light, switch, number, select) are removed —
# the interactive entity already surfaces the live state, so a
# duplicate read-only "Lamp state" / "Fast preheat state" / etc.
# sensor would just clutter the device card with the same value
# twice.
_SENSORS = [
('machine_state', 'Machine state', {'icon': 'mdi:stove'}),
('oven_state', 'Cavity state', {}),
# Cooking mode is read-only via local OCF — the oven owns the
# `modes` field once a cycle is active and rolls back any writes.
('mode', 'Cooking mode', {'icon': 'mdi:tune'}),
('progress_percentage', 'Progress percent',
{'unit_of_measurement': '%', 'state_class': 'measurement'}),
('operation_time', 'Elapsed time', {'icon': 'mdi:timer'}),
('completion_time', 'Completion time', {'icon': 'mdi:timer-sand'}),
('completion_minutes', 'Remaining minutes',
{'unit_of_measurement': 'min', 'device_class': 'duration',
'state_class': 'measurement'}),
('current_temp_c', 'Temperature',
{'unit_of_measurement': '°C', 'device_class': 'temperature',
'state_class': 'measurement'}),
# target_temp_c is also exposed as a Number entity for editing,
# but the Number is RC-gated. The sensor stays always-visible so
# the user can see the current setpoint even with Remote Control
# off at the oven.
('target_temp_c', 'Setpoint',
{'unit_of_measurement': '°C', 'device_class': 'temperature',
'state_class': 'measurement', 'icon': 'mdi:thermometer-chevron-up'}),
# power_state: read-only. The oven doesn't expose a meaningful
# POST /power/vs/0 from cold — turning the unit on at the panel
# is a physical action — so we don't ship a Power switch entity.
('power_state', 'Power state', {'icon': 'mdi:power'}),
('door', 'Door state', {}),
('child_lock', 'Child lock state', {}),
('remote_control', 'Remote control state', {}),
('timer_state', 'Timer state', {}),
('timer_current', 'Timer remaining', {'icon': 'mdi:timer-sand'}),
('timer_set', 'Timer set', {'icon': 'mdi:timer'}),
('alarm_code', 'Alarm code',
{'icon': 'mdi:alert', 'entity_category': 'diagnostic'}),
('alarm_time', 'Alarm time',
{'icon': 'mdi:clock-alert', 'entity_category': 'diagnostic'}),
('connected', 'Cloud connectivity',
{'entity_category': 'diagnostic'}),
]
# (key, friendly, value_template, device_class, extras)
_BINARY_SENSORS = [
('running', 'Running',
"{{ 'ON' if value_json.machine_state == 'active' else 'OFF' }}",
'running', {}),
('door_open', 'Door',
"{{ 'ON' if value_json.door_open else 'OFF' }}",
'door', {}),
# `power_switch` binary_sensor would duplicate the Power switch
# entity below; the switch already shows on/off state.
('child_lock_active', 'Child lock',
"{{ 'ON' if value_json.child_lock_binary else 'OFF' }}",
'lock', {}),
('remote_control_enabled', 'Remote control',
"{{ 'ON' if value_json.remote_control_binary else 'OFF' }}",
'connectivity', {}),
('alarm_active', 'Alarm active',
"{{ 'ON' if value_json.alarm_active else 'OFF' }}",
'problem', {}),
('connected_bin', 'Connected',
"{{ 'ON' if value_json.connected_binary else 'OFF' }}",
'connectivity', {'entity_category': 'diagnostic'}),
('firmware_update_available', 'Firmware update available',
"{{ 'ON' if value_json.firmware_update_available else 'OFF' }}",
'update', {'entity_category': 'diagnostic'}),
]
# MQTT command-topic suffixes (under <prefix>/cmd/…)
CMD_LAMP = 'cmd/lamp'
CMD_SOUND = 'cmd/sound'
CMD_FASTPREHEAT = 'cmd/fastpreheat'
CMD_NATURALSTEAM = 'cmd/naturalsteam'
CMD_POWER = 'cmd/power'
CMD_STOP = 'cmd/stop'
CMD_SETPOINT = 'cmd/setpoint'
CMD_COOK_TIME = 'cmd/cook_time'
# NOTE — no CMD_START or CMD_MODE. Reverse-engineered 2026-05-31:
# * `state='Run'` writes to /operational/state/vs/0 are accepted
# (2.04) and machine briefly goes active, but the oven cavity
# stays Ready (no Preheat) and the cycle self-cancels within
# ~3s. Tried every byte-level approximation of SmartThings's
# working start (matching all four fields on /operational/state,
# +operationTime, +remainingTime, +progressPercentage='1', plus
# /temperatures/vs/0 desired, with and without /mode/vs/0 modes,
# with PUT vs POST, paced 1s apart, with OCF-version-options
# 2049/2053, with Samsung vendor-option 65524=0xc0) — none of
# these engage the cavity. The differentiator must be something
# invisible at the OBSERVE-push level (likely a cloud-mediated
# auth path the SmartThings app uses). See project_oven_remote
# _start_open.md for full notes.
# * `modes=['Convection']` writes to /mode/vs/0 succeed (2.04)
# but the oven owns the field once a cycle is active and rolls
# local writes back to ['NoOperation']. mode is surfaced as a
# read-only sensor instead.
def build_discovery(topic_prefix, ha_prefix, device_name):
state_topic = f"{topic_prefix}/state"
avail_topic = f"{topic_prefix}/availability"
remote_topic = f"{topic_prefix}/remote_available"
cycle_topic = f"{topic_prefix}/cycle_active"
dev = device_block(topic_prefix, device_name, MODEL)
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_base(avail_topic),
'device': dev,
}
cfg.update(extra)
out.append((f"{ha_prefix}/sensor/{topic_prefix}/{key}/config",
encode(cfg)))
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',
'device_class': dclass,
'availability': avail_base(avail_topic),
'device': dev,
}
cfg.update(extra)
out.append((f"{ha_prefix}/binary_sensor/{topic_prefix}/{key}/config",
encode(cfg)))
# --- light: oven lamp (proven via probe_oven_lamp_states.py;
# binary On/Off only — High/Low/Dim coerce back to previous
# state. Works regardless of Remote Control switch, so we only
# gate on base availability). For the MQTT light default schema,
# state_value_template's output must match payload_on/payload_off
# exactly (case-sensitive) for HA to recognise the state.
cfg = {
'name': 'Lamp',
'unique_id': f"{topic_prefix}_lamp_light",
'object_id': f"{topic_prefix}_lamp_light",
'state_topic': state_topic,
'state_value_template': "{{ value_json.lamp }}",
'command_topic': f"{topic_prefix}/{CMD_LAMP}",
'payload_on': 'On',
'payload_off': 'Off',
'icon': 'mdi:track-light',
'availability': avail_base(avail_topic),
'device': dev,
}
out.append((f"{ha_prefix}/light/{topic_prefix}/lamp/config",
encode(cfg)))
# --- switches. Sound is always-available — independent of cycle
# state, no RC required. Fast preheat + Natural steam are
# options-array writes the oven only honours mid-cycle, so they
# gate on RC + cycle_active. Power deliberately omitted: turning
# the oven on is a physical-panel action; read-only power_state
# sensor reflects its state.
cfg = {
'name': 'Sound',
'unique_id': f"{topic_prefix}_sound_switch",
'object_id': f"{topic_prefix}_sound_switch",
'state_topic': state_topic,
'value_template': '{{ value_json.sound }}',
'state_on': 'On',
'state_off': 'Off',
'command_topic': f"{topic_prefix}/{CMD_SOUND}",
'payload_on': 'On',
'payload_off': 'Off',
'icon': 'mdi:volume-high',
'availability': avail_base(avail_topic),
'device': dev,
}
out.append((f"{ha_prefix}/switch/{topic_prefix}/sound/config",
encode(cfg)))
cycle_switches = [
('fastpreheat', 'Fast preheat', '{{ value_json.fastpreheat }}', CMD_FASTPREHEAT, 'mdi:fire'),
('natural_steam', 'Natural steam', '{{ value_json.natural_steam }}', CMD_NATURALSTEAM, 'mdi:kettle-steam'),
]
for key, name, tpl, cmd, icon in cycle_switches:
cfg = {
'name': name,
'unique_id': f"{topic_prefix}_{key}_switch",
'object_id': f"{topic_prefix}_{key}_switch",
'state_topic': state_topic,
'value_template': tpl,
'state_on': 'On',
'state_off': 'Off',
'command_topic': f"{topic_prefix}/{cmd}",
'payload_on': 'On',
'payload_off': 'Off',
'icon': icon,
'availability': avail_with_remote_and_cycle(
avail_topic, remote_topic, cycle_topic),
'availability_mode': 'all',
'device': dev,
}
out.append((f"{ha_prefix}/switch/{topic_prefix}/{key}/config",
encode(cfg)))
# --- number: setpoint (RC-gated, slider input) ------------------
cfg = {
'name': 'Setpoint',
'unique_id': f"{topic_prefix}_setpoint",
'object_id': f"{topic_prefix}_setpoint",
'state_topic': state_topic,
'value_template': '{{ value_json.target_temp_c }}',
'command_topic': f"{topic_prefix}/{CMD_SETPOINT}",
'min': SETPOINT_MIN_C,
'max': SETPOINT_MAX_C,
'step': SETPOINT_STEP_C,
'unit_of_measurement': '°C',
'device_class': 'temperature',
'mode': 'slider',
'icon': 'mdi:thermometer-chevron-up',
# RC + cycle_active gated — Samsung's local-OCF surface only
# honours setpoint changes while a cycle is actually running
# (idle writes get rolled back within ~3s).
'availability': avail_with_remote_and_cycle(
avail_topic, remote_topic, cycle_topic),
'availability_mode': 'all',
'device': dev,
}
out.append((f"{ha_prefix}/number/{topic_prefix}/setpoint/config",
encode(cfg)))
# --- button: Stop cycle ----------------------------------------
# Gated on cycle_active — there's nothing to stop when idle.
# There is no Start button: local-OCF cycle start is not
# reproducible on this firmware (see project_oven_remote_start
# _open.md memory note for the full investigation). Cooking mode
# is similarly omitted — read-only via local OCF, surfaced as a
# sensor.
cfg = {
'name': 'Stop cycle',
'unique_id': f"{topic_prefix}_stop",
'object_id': f"{topic_prefix}_stop",
'command_topic': f"{topic_prefix}/{CMD_STOP}",
'payload_press': 'Stop',
'icon': 'mdi:stop',
'availability': avail_with_cycle(avail_topic, cycle_topic),
'availability_mode': 'all',
'device': dev,
}
out.append((f"{ha_prefix}/button/{topic_prefix}/stop/config",
encode(cfg)))
# --- number: Cook time in minutes (RC + cycle gated; the oven
# only honours operationTime writes while running). Source of
# truth is `operationTime` on /operational/state/vs/0;
# SmartThings's mid-cycle time changes land in that same field.
cfg = {
'name': 'Cook time',
'unique_id': f"{topic_prefix}_cook_time",
'object_id': f"{topic_prefix}_cook_time",
'state_topic': state_topic,
'value_template': '{{ value_json.operation_time_minutes | int(0) }}',
'command_topic': f"{topic_prefix}/{CMD_COOK_TIME}",
'min': 0,
'max': 1439, # 23:59 — matches modeSpec timeMax
'step': 1,
'unit_of_measurement': 'min',
'mode': 'box',
'icon': 'mdi:timer',
'availability': avail_with_remote_and_cycle(
avail_topic, remote_topic, cycle_topic),
'availability_mode': 'all',
'device': dev,
}
out.append((f"{ha_prefix}/number/{topic_prefix}/cook_time/config",
encode(cfg)))
# --- removal: publish empty payload to the discovery topics of
# entities we used to expose. HA treats an empty retained payload
# on a discovery topic as "delete this entity", so previously-set
# up Start buttons and Cooking-mode selects disappear cleanly.
out.append((f"{ha_prefix}/button/{topic_prefix}/start/config", b''))
out.append((f"{ha_prefix}/select/{topic_prefix}/mode/config", b''))
return out
# ---------------------------------------------------------------------
# Command handlers — fn(payload, links) → (path_segs, body_dict) | None.
# Read-modify-write handlers (lamp/sound/fastpreheat) snapshot the
# `/mode/vs/0` options array and replace just their slot. /temperatures
# is also RMW because Samsung's write semantics on the items array are
# replace-not-merge.
# ---------------------------------------------------------------------
def _mode_options(links):
"""Return the live `/mode/vs/0` options array (a copy), or None
if /mode/vs/0 isn't seeded yet."""
rep = links.get('/mode/vs/0') or {}
opts = rep.get('x.com.samsung.da.options')
if not opts:
return None
return list(opts)
def _temps_items(links):
"""Return a deep-ish copy of the /temperatures/vs/0 items array."""
rep = links.get('/temperatures/vs/0') or {}
items = rep.get('x.com.samsung.da.items') or []
return [dict(it) for it in items] if items else None
def command_handlers():
def _lamp(p, links):
if p not in ('On', 'Off'):
return None
opts = _mode_options(links)
if opts is None:
return None
return ['mode', 'vs', '0'], {
'x.com.samsung.da.options': _replace_in_options(opts, 'UpperLamp', p),
}
def _sound(p, links):
if p not in ('On', 'Off'):
return None
opts = _mode_options(links)
if opts is None:
return None
return ['mode', 'vs', '0'], {
'x.com.samsung.da.options': _replace_in_options(opts, 'Sound', p),
}
def _fastpreheat(p, links):
if p not in ('On', 'Off'):
return None
opts = _mode_options(links)
if opts is None:
return None
return ['mode', 'vs', '0'], {
'x.com.samsung.da.options': _replace_in_options(
opts, 'fastpreheat', p),
}
def _naturalsteam(p, links):
# NaturalSteam_* only appears in the options array after the
# SmartThings app has touched it once. If absent, append the
# slot — the oven creates it on first write, so the bridge
# doesn't need a "prime via app" dance.
if p not in ('On', 'Off'):
return None
opts = _mode_options(links)
if opts is None:
return None
if not any(o.startswith('NaturalSteam_') for o in opts):
opts = opts + [f'NaturalSteam_{p}']
else:
opts = _replace_in_options(opts, 'NaturalSteam', p)
return ['mode', 'vs', '0'], {
'x.com.samsung.da.options': opts,
}
def _power(p, _links):
if p not in ('On', 'Off'):
return None
return ['power', 'vs', '0'], {'x.com.samsung.da.power': p}
def _stop(_p, _links):
return ['operational', 'state', 'vs', '0'], {
'x.com.samsung.da.state': 'Ready',
}
def _setpoint(p, links):
try:
temp = float(p)
except (TypeError, ValueError):
return None
temp_i = int(round(temp / SETPOINT_STEP_C) * SETPOINT_STEP_C)
if not (SETPOINT_MIN_C <= temp_i <= SETPOINT_MAX_C):
return None
items = _temps_items(links)
if items is None:
return None
items[0]['x.com.samsung.da.desired'] = str(temp_i)
return ['temperatures', 'vs', '0'], {
'x.com.samsung.da.items': items,
}
def _cook_time(p, links):
# HA sends minutes; oven cycle duration lives in
# /operational/state/vs/0 as `operationTime` / `remainingTime`
# (H:MM:SS strings). Writing both — mirroring SmartThings's
# observed behaviour, which resets the live countdown to the
# new duration. Clamp to modeSpec's 0..23:59.
try:
minutes = int(round(float(p)))
except (TypeError, ValueError):
return None
if not (0 <= minutes <= 1439):
return None
h, m = divmod(minutes, 60)
hms = f"{h:02d}:{m:02d}:00"
return ['operational', 'state', 'vs', '0'], {
'x.com.samsung.da.operationTime': hms,
'x.com.samsung.da.remainingTime': hms,
}
return {
CMD_LAMP: _lamp,
CMD_SOUND: _sound,
CMD_FASTPREHEAT: _fastpreheat,
CMD_NATURALSTEAM: _naturalsteam,
CMD_POWER: _power,
CMD_STOP: _stop,
CMD_SETPOINT: _setpoint,
CMD_COOK_TIME: _cook_time,
}
# --- Poll tiers --------------------------------------------------------
# Empirical ceiling on this firmware is ~8 req/s (probe_poll_rate_combined.py
# 2026-06-03). Hot tier covers what changes mid-cook; doors get the tightest
# cadence because door open/close needs sub-second freshness in HA.
# Per-tier timeouts are scaled to cadence: hot tier retries every 1s, so
# a tight 2s ceiling caps the cascade damage from one wedged poll. Warm
# and cold tiers have more headroom; sweep is multi-block Block2 and
# tolerates ~15s.
OVEN_POLL_TIERS = [
PollTier(
name='hot',
interval_s=1.0,
active_interval_s=0.5,
timeout_s=2.0,
paths=(
('operational', 'state', 'vs', '0'),
('doors', 'vs', '0'),
('oven', 'vs', '0'),
('temperatures', 'vs', '0'),
),
),
PollTier(
name='warm',
interval_s=30.0,
timeout_s=4.0,
paths=(
('power', 'vs', '0'),
('kidslock', 'vs', '0'),
('remotectrl', 'vs', '0'),
('mode', 'vs', '0'),
('alarms', 'vs', '0'),
('connected', 'vs', '0'),
),
),
PollTier(
name='cold',
interval_s=600.0,
timeout_s=6.0,
paths=(
('otninformation', 'vs', '0'),
),
),
PollTier(
name='sweep',
interval_s=300.0,
timeout_s=15.0,
paths=(('device', '0'),),
is_sweep=True,
),
]
def _is_active(links: dict) -> bool:
rep = links.get('/operational/state/vs/0') or {}
sam_state = rep.get('x.com.samsung.da.state')
return _SAMSUNG_STATE_TO_OCF.get(sam_state) == 'active'
# ---------------------------------------------------------------------
OVEN = ApplianceDescriptor(
name='oven',
default_observe_port=49154,
observe_paths=OBSERVE_PATHS,
seed_path=['device', '0'],
flatten=flatten,
build_discovery=build_discovery,
command_handlers=command_handlers,
on_observation=on_observation,
project=project,
remote_available_field='remote_control_binary',
cycle_active_field='cycle_active',
log_state_change=log_state_change,
poll_tiers=OVEN_POLL_TIERS,
is_active=_is_active,
)