refactor: extract mqtt_demo/ — move bridge, config, logger, appliance descriptors
This commit is contained in:
1 parent
24fc197a9e
commit
aa9ec0f0d9
17 files changed
+51
-42
No files matched your search
@@ -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
|
||||
@@ -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"]
|
||||
Whitespace-only changes.
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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)]
|
||||
Executable
+85
@@ -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'"
|
||||
@@ -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'),
|
||||
]
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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']
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
Reference in new issue
Block a user