Files
SmartThings-Local/main.py
T
Jack Nagy 8775f7a930 Add oven controls and fix Samsung-OCF write semantics
Major session of local-OCF reverse engineering against the NV7000BS
oven and DV5000T dryer. Surfaces a working set of HA entities for the
oven and resolves several Samsung-quirk regressions in the bridge's
write path.

Key behavioural fixes:
- OBSERVE registrations now use single-byte tokens. Samsung RT-OCF
  silently drops registrations with TKL>1; same 4-byte tokens work
  fine for GET/POST. Symptom was that writes returned 2.04 but the
  appliance never pushed state changes.
- Per-session random starting tokens + MID. Samsung retains observer
  state across DTLS reconnects from the same cert; reusing tokens on
  reconnect silently no-ops.
- OBSERVE deregister sent on DtlsCoapSession.close(), with a stop-
  watcher thread in PushBridge.session_once() so SIGINT/SIGTERM
  actually reaches close() instead of hanging in sess.join().
- pyOpenSSL is not thread-safe — reader-loop conn.* calls now hold
  the same _send_lock the sender uses, dispatching decrypted packets
  outside the lock so the auto-ACK send doesn't deadlock.
- Periodic CoAP Ping (RFC 7252 §4.4) keepalive to keep DTLS warm.
- Post-write Block2 fetchback REMOVED. It was the root cause of
  every "setpoint/operationTime/modes revert ~3s after write"
  symptom — Samsung's stack treats a read on a freshly-written
  resource as a signal to invalidate that write. OBSERVE pushes
  keep HA in sync without the verification GET.

HA-facing changes (oven):
- New entities: Lamp (light), Sound (switch), Fast preheat, Natural
  steam, Setpoint (number), Cook time (number), Stop cycle (button).
- Cooking mode surfaced as a read-only sensor — the oven owns the
  modes field once a cycle is active and rolls local writes back.
- Cook time writes operationTime + remainingTime on
  /operational/state/vs/0 (discovered via OBSERVE capture of
  SmartThings mid-cycle changes — UpperTimerSet on /mode/vs/0
  options is vestigial and doesn't drive the running cycle).
- New cycle_active MQTT availability topic. Writes the oven only
  honours mid-cycle (setpoint, cook time, fast preheat, natural
  steam, stop) gate on it via avail_with_cycle / avail_with_remote_
  and_cycle. Sound + Lamp remain always-available.
- Cycle Start deliberately NOT exposed. Every byte-level approxi-
  mation of SmartThings's working start sequence is rejected at
  the firmware level. Empty discovery payloads remove the previous
  Start button and Cooking-mode select cleanly from HA.

Diagnostics:
- DEBUG_BRIDGE=1 env var enables verbose tracing (rx CON/NON/ACK/
  RST per frame, full link-tree dump at seed, /oic/res directory,
  REP changes on /operational/state, /oven, /power, mode options).
  Quiet in production.
2026-05-31 20:43:47 +01:00

216 lines
7.6 KiB
Python

#!/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. 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, HEARTBEAT_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 samsung_appliance.appliances import get_descriptor
from samsung_appliance.bridge import PushBridge
from samsung_appliance.config import SharedConfig, load_appliances
from samsung_appliance.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
def make_heartbeat(b: PushBridge):
def loop():
while not b.stop.is_set():
if b.stop.wait(shared.HEARTBEAT_INTERVAL_S):
break
b.heartbeat()
return loop
def make_ping(b: PushBridge):
def loop():
while not b.stop.is_set():
if b.stop.wait(shared.PING_INTERVAL_S):
break
b.ping_once()
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'))
if shared.HEARTBEAT_INTERVAL_S > 0:
threads.append(threading.Thread(
target=make_heartbeat(b), daemon=True,
name=f'{tag}-heartbeat'))
if shared.PING_INTERVAL_S > 0:
threads.append(threading.Thread(
target=make_ping(b), daemon=True,
name=f'{tag}-ping'))
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())