Files
localthings/samsung_appliance/bridge.py
T
2026-05-31 15:50:15 +01:00

503 lines
20 KiB
Python

"""Push-mode bridge: OCF CoAP-DTLS Observe → MQTT.
Appliance ──CoAP OBSERVE notifications──► PushBridge
│
▼
MQTT broker
│
▼
Home Assistant
State changes push from the appliance over a sustained DTLS session.
The bridge updates an in-memory link dict, recomputes flat sensors via
the appliance descriptor, and publishes to MQTT ONLY when the flat-
sensor dict actually changes.
The bridge is appliance-class-agnostic — it delegates every
appliance-specific decision to an ApplianceDescriptor.
Multiple PushBridges run concurrently in a single process — see
main.py. They share one MQTT client; each owns one DTLS session.
"""
import json
import threading
import time
import cbor2
from .appliances.base import ApplianceDescriptor
from .coap_dtls import DtlsCoapSession, fmt_code
from .config import ApplianceConfig, SharedConfig
from .logger import bridge_logger, logger as module_logger
from .sensors import index_links
def _href_to_segs(href: str) -> list[str]:
"""`/mode/vs/0` → `['mode', 'vs', '0']`. Used to translate an
OBSERVE-notification href back into the path-segs the Block2 GET
needs."""
return [s for s in href.split('/') if s]
# Samsung's `/information/vs/0` resource carries a unique serial number.
# Verified on both dryer (DV5000T) and oven (NV7000BS); we use the
# value to tag per-bridge log lines once the seed completes.
SERIAL_PATH = '/information/vs/0'
SERIAL_FIELD = 'x.com.samsung.da.serialNum'
class PushBridge:
"""Single sustained DTLS-CoAP session to one appliance.
Reconnects with exponential backoff on session errors. Publishes
availability=offline when the appliance is unreachable so HA marks
entities unavailable instead of trusting stale state."""
def __init__(self,
shared: SharedConfig,
app: ApplianceConfig,
descriptor: ApplianceDescriptor,
mqtt_client):
self.shared = shared
self.app = app
self.descriptor = descriptor
self.mqtt = mqtt_client
# Bridge-scoped logger; retagged with serial after first seed.
self.log = bridge_logger(app.klass)
self._serial: str | None = None
# Resolve port (descriptor default if unset in env).
self.port = app.ocf_port or descriptor.default_observe_port
self.session: DtlsCoapSession | None = None
self.links: dict[str, dict] = {} # href → rep
self.descriptor_state: dict = {} # descriptor scratch space
self.last_state_pub = None
self.last_remote_pub = 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
# Per-href fetchback generation counter. Every new schedule
# bumps the gen; a fetchback aborts on wake (and again after
# its GET completes) if its captured gen is no longer the
# latest. This coalesces bursts: rapid lamp toggles or slider
# drags result in many scheduled fetchbacks but only the
# latest one actually publishes. Lock guards the dict mutation
# and the gen comparison.
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.health_topic = f"{p}/bridge/health"
self.cmd_handlers = descriptor.command_handlers()
self.cmd_topic_prefix = f"{p}/cmd/"
# Pre-built HA discovery payloads. Republished on every MQTT
# (re)connect by main.py.
self.discovery_payloads = descriptor.build_discovery(
app.topic_prefix, shared.HA_DISCOVERY_PREFIX, app.device_name)
# ---- DTLS session helpers ---------------------------------------
def _on_notification(self, href, payload_bytes):
"""Invoked by the DTLS reader thread for OBSERVE notifications.
Resources larger than one CoAP block (notably the oven's
`/mode/vs/0` at ~9KB) arrive truncated: Samsung sends only
block 0 with Block2.M=1 and expects the client to fetch the
rest via Block2 GET. We use cbor decode failure as the
robust "this notification is partial" signal, then spawn a
worker thread to fetch the full resource."""
if not payload_bytes:
# Empty payload — almost certainly a Block2 announcement.
self._schedule_fetchback(href)
return
try:
rep = cbor2.loads(payload_bytes)
except Exception:
self._schedule_fetchback(href)
return
if not isinstance(rep, dict):
return
self._apply_rep(href, rep)
def _apply_rep(self, href, rep):
"""Update self.links + fire descriptor hooks + maybe publish.
Shared between the OBSERVE path and the Block2 fetch-back path."""
self.links[href] = rep
hook = self.descriptor.on_observation
if hook is not None:
try:
hook(self.descriptor_state, href, rep)
except Exception as e:
self.log.warning("on_observation %s: %s", href, e)
self.notif_count += 1
self.last_change_ts = time.time()
self.maybe_publish_state()
def _apply_optimistic(self, href, body):
"""Optimistically merge a just-POSTed body into the link dict
and republish state. Samsung accepts (2.04) writes whose
bodies are field-replacements — we mirror that semantics here:
each top-level key in `body` overwrites the corresponding key
in the existing rep. The fetchback that follows republishes
the device's real state, which corrects any field where the
write was silently coerced or rejected."""
if not isinstance(body, dict):
return
rep = dict(self.links.get(href) or {})
rep.update(body)
self._apply_rep(href, rep)
def _schedule_fetchback(self, href, delay_s: float = 0.0):
"""Spawn a worker thread to fetch the full payload of `href`
via Block2 GET. Each schedule bumps a per-href generation
counter — if a newer fetchback is scheduled before this one
fires, this one aborts (so rapid commands coalesce into a
single verification read of the FINAL state)."""
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:
# Allow the device's read-side to propagate a recent
# write. The oven needs ~1s after a /mode/vs/0 POST;
# the dryer is faster but the delay is harmless there.
if self.stop.wait(delay_s):
return
# Has a newer fetchback been scheduled during our delay?
# If so, abort — our read would publish stale state relative
# to the user's most recent intent.
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
# Re-check generation after the GET — a new write may have
# come in during the Block2 round-trip, in which case our
# payload is also superseded.
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 not isinstance(rep, dict):
return
self._apply_rep(href, rep)
def _retag_logger_with_serial(self):
"""Look up the appliance's serial in the seeded link dict and
retarget self.log to a serial-tagged child. Idempotent."""
if self._serial is not None:
return
info = self.links.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):
"""Run one DTLS session end-to-end. Raises on error; the outer
run_forever wraps this with reconnect/backoff."""
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.descriptor_state = {}
self._publish_gate = False
self.log.info("DTLS connected — subscribing %d paths",
len(self.descriptor.observe_paths))
sess.start_reader()
for path in self.descriptor.observe_paths:
sess.subscribe(path)
time.sleep(0.05)
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
for href, rep in index_links(body).items():
self.links.setdefault(href, rep)
# Once the seed is in, we know the appliance's serial — retag
# the logger so the remaining log lines this session emits are
# serial-tagged.
self._retag_logger_with_serial()
hook = self.descriptor.on_observation
if hook is not None:
for href, rep in self.links.items():
try:
hook(self.descriptor_state, href, rep)
except Exception as e:
self.log.warning("seed on_observation %s: %s", href, e)
self.last_seed_ts = time.time()
self._publish_gate = True
self.maybe_publish_state(force=True)
self.set_availability(True)
self.log.info("seeded → %d links; sensors live", len(self.links))
sess.join()
def heartbeat(self):
sess = self.session
if sess is None:
return
try:
code, pl = sess.get(self.descriptor.seed_path, timeout=15.0)
except Exception as e:
self.log.warning("heartbeat seed: %s", e)
return
if code != 0x45:
self.log.warning("heartbeat seed: %s", fmt_code(code))
return
try:
body = cbor2.loads(pl)
except Exception as e:
self.log.warning("heartbeat seed cbor: %s", e)
return
# Refresh ALL resources, not just non-observed ones. We used
# to skip observed resources on the assumption OBSERVE kept
# them fresh, but the oven doesn't reliably push OBSERVE on
# /mode/vs/0 option changes (timer / lamp / sound), so we'd
# be stuck with stale values until the next user-driven POST
# triggered a fetchback. Refreshing everything bounds HA's
# divergence to HEARTBEAT_INTERVAL_S in the worst case.
hook = self.descriptor.on_observation
for href, rep in index_links(body).items():
self.links[href] = rep
if hook is not None:
try:
hook(self.descriptor_state, href, rep)
except Exception as e:
self.log.warning("heartbeat hook %s: %s", href, e)
self.last_seed_ts = time.time()
self.maybe_publish_state()
# ---- MQTT publishing --------------------------------------------
def maybe_publish_state(self, force=False):
if not force and not self._publish_gate:
return
sensors = self.descriptor.flatten(self.links)
project = self.descriptor.project
if project is not None:
sensors = project(self.descriptor_state, sensors)
if not force and sensors == self.last_state_pub:
return
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))
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 notif#%d)",
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 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)
def set_availability(self, online):
try:
self.mqtt.publish(self.avail_topic,
'online' if online else 'offline',
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
# ---- 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
# Shallow-snapshot self.links so the handler sees a consistent
# view across the read-modify-write it may need to perform
# (e.g. oven lamp / sound / fastpreheat all RMW /mode/vs/0
# options). Inner reps are mutated only by the OBSERVE reader
# thread; handlers that mutate items must deep-copy themselves.
result = handler(payload, dict(self.links))
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
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
self.log.info("command %s payload=%r → %s",
suffix, payload, fmt_code(code))
# Defensive re-read of the just-written resource. The dryer
# pushes an OBSERVE notification within ~100ms of a 2.xx write
# and we'd see the new state anyway, but the oven doesn't
# push on options-only writes (lamp/sound/fastpreheat). Without
# this fetchback, HA would only see the new state on the next
# heartbeat (10 min by default).
if code >> 5 == 2:
href = '/' + '/'.join(path_segs)
# Optimistic publish: apply the write to our local state
# and republish the MQTT state immediately. HA sees the
# new value with no UI flash. The fetchback that follows
# acts as verification — if the appliance didn't actually
# honour the write (silent coerce), the fetchback's
# publish will revert HA to the device's true state.
self._apply_optimistic(href, body)
# 3s settling window before the verification read.
# 1.5s was sometimes too short — the oven's read-side
# propagation lags more than that, and an early Block2
# GET on /mode/vs/0 right after a POST appears to be one
# of the triggers for the oven actively closing DTLS.
# The fetchback runs in its own worker thread, so this
# delay is non-blocking; the optimistic publish has
# already given HA the new state.
self._schedule_fetchback(href, delay_s=3.0)
def publish_health(self):
now = time.time()
h = {
'mode': 'push',
'device_class': self.descriptor.name,
'serial': self._serial,
'connect_count': self.connect_count,
'error_count': self.error_count,
'notif_count': self.notif_count,
'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),
}
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)
# ---- top-level loop ---------------------------------------------
def _publish_tick_loop(self):
while not self.stop.wait(15.0):
try:
self.maybe_publish_state()
except Exception as e:
self.log.warning("publish tick: %s", e)
def run_forever(self):
threading.Thread(target=self._publish_tick_loop, daemon=True,
name=f'{self.app.klass}-tick').start()
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
wait = min(backoff, 30.0)
self.log.info("reconnect in %.0fs", wait)
if self.stop.wait(wait):
break
backoff = min(backoff * 2, 30.0)