refactor: discover entities at connect; delete appliance descriptor files
PushBridge no longer takes a descriptor at construction time. Instead it discovers entities from the live device cache immediately after the seed (via registry.discover + build_runtime_descriptor), publishes HA discovery payloads, wires on_observation, and builds command handlers — all in the new idempotent _discover_and_publish(sess) method called from _run_session_inner. Changes: - bridge.py: remove descriptor param; self.descriptor=None until first connect; SEED_PATH constant replaces self.descriptor.seed_path; all self.descriptor accesses guarded or moved post-discovery; inlined _bridge_diagnostic_discovery (was in appliances/base.py); subscribe to observe paths now happens after discovery (seed → discover → subscribe). - config.py: klass is now optional (defaults to ''); topic_prefix/name defaults handle empty klass. - main.py: drop get_descriptor import and pairs loop; build bridges as PushBridge(shared, app, cli); on_connect no longer publishes discovery_payloads (bridge self-publishes at discovery time). - Delete samsung_appliance/appliances/ entirely (6 files, ~2600 lines).
This commit is contained in:
@@ -23,7 +23,6 @@ import threading
|
||||
|
||||
import paho.mqtt.client as mqtt
|
||||
|
||||
from samsung_appliance.appliances import get_descriptor
|
||||
from samsung_appliance.bridge import PushBridge
|
||||
from samsung_appliance.config import SharedConfig, load_appliances
|
||||
from samsung_appliance.logger import logger
|
||||
@@ -46,26 +45,15 @@ def main():
|
||||
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
|
||||
for app in appliances:
|
||||
port = app.ocf_port or 49154
|
||||
logger.info(" [%d] %s @ %s:%d (DTLS) → topic %s/*",
|
||||
app.index, app.klass, app.ip, port, app.topic_prefix)
|
||||
app.index, app.klass or '?', app.ip, port, app.topic_prefix)
|
||||
|
||||
# --- MQTT client (shared) ---
|
||||
cli = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2,
|
||||
@@ -85,9 +73,9 @@ def main():
|
||||
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]
|
||||
# Build bridges. Each will discover entities at first connect.
|
||||
bridges: list[PushBridge] = [PushBridge(shared, app, cli)
|
||||
for app in appliances]
|
||||
by_prefix = {b.cmd_topic_prefix.rstrip('/'): b for b in bridges}
|
||||
|
||||
def on_connect(client, userdata, flags, rc, props=None):
|
||||
@@ -97,8 +85,6 @@ def main():
|
||||
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()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""Samsung appliance local-API → MQTT bridge with HA discovery.
|
||||
|
||||
Multi-device: dryer, oven, etc. The device class is selected at startup
|
||||
via the DEVICE_CLASS env var (see samsung_appliance.appliances)."""
|
||||
Multi-device: dryer, oven, etc. Entities are discovered at connect-time
|
||||
from the live device resources via samsung_appliance.registry."""
|
||||
__version__ = "2.0.0"
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
"""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 .base import ApplianceDescriptor
|
||||
from .dishwasher import DISHWASHER
|
||||
from .dryer import DRYER
|
||||
from .oven import OVEN
|
||||
from .refrigerator import REFRIGERATOR
|
||||
|
||||
|
||||
DESCRIPTORS: dict[str, ApplianceDescriptor] = {
|
||||
DISHWASHER.name: DISHWASHER,
|
||||
DRYER.name: DRYER,
|
||||
OVEN.name: OVEN,
|
||||
REFRIGERATOR.name: REFRIGERATOR,
|
||||
}
|
||||
|
||||
|
||||
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']
|
||||
@@ -1,228 +0,0 @@
|
||||
"""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 ..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'),
|
||||
]
|
||||
@@ -1,452 +0,0 @@
|
||||
"""Dishwasher descriptor (Samsung DW9000F-class / ADW-WW-RTL-24-AILITE).
|
||||
|
||||
Resource map captured 2026-06-27 via DTLS-CoAP with the ab0b0ac4 cert
|
||||
from a DW90F89T0USRAA at 10.0.0.129:49154.
|
||||
See local-tools/dumps/10.0.0.129.json for the full field reference.
|
||||
|
||||
Write surfaces exposed by this descriptor:
|
||||
|
||||
modelled on dryer/operational-state write semantics (same firmware family):
|
||||
* Start / Pause / Stop via /operational/state/vs/0
|
||||
— requires Remote Control on.
|
||||
|
||||
modelled on oven option-write semantics (unproven, first HA use is the test):
|
||||
* Sound mode select via /settings/sound/mode/vs/0
|
||||
* Door LED brightness select via /doorled/light/vs/0
|
||||
* Door LED night light switch via /doorled/light/vs/0
|
||||
|
||||
Course selection is not yet exposed — the course encoding in
|
||||
/wm/editcourse/vs/0 uses an opaque hex string (EditCourseList_…) whose
|
||||
byte-to-course-name mapping requires the same capture process as the
|
||||
dryer's COURSE_NAMES table. Capture with the dishwasher running each
|
||||
course and compare /course/vs/0 + /dishwasher/vs/0 before/after.
|
||||
"""
|
||||
import time
|
||||
|
||||
from .base import (
|
||||
ApplianceDescriptor,
|
||||
avail_base,
|
||||
avail_with_remote,
|
||||
device_block,
|
||||
encode,
|
||||
)
|
||||
from ..poll_scheduler import PollTier
|
||||
|
||||
|
||||
# --- OBSERVE paths ---------------------------------------------------------
|
||||
# Only /<x>/vs/0 siblings push notifications. /course/vs/0 and
|
||||
# /dishwasher/vs/0 were included in the device tree but returned no
|
||||
# parseable state fields in the 2026-06-27 capture — include them so
|
||||
# future firmware pushes surface through the cache.
|
||||
OBSERVE_PATHS = [
|
||||
['operational', 'state', 'vs', '0'], # state, progress, remainingTime
|
||||
['power', 'vs', '0'], # power On/Off
|
||||
['kidslock', 'vs', '0'], # child lock
|
||||
['remotectrl', 'vs', '0'], # remote control enabled
|
||||
['energy', 'consumption', 'vs', '0'], # watts + cumulative Wh
|
||||
['water', 'consumption', 'vs', '0'], # cumulative water (mL)
|
||||
['alarms', 'vs', '0'],
|
||||
['course', 'vs', '0'],
|
||||
['dishwasher', 'vs', '0'],
|
||||
['doorled', 'light', 'vs', '0'], # door LED brightness + night light
|
||||
['settings', 'sound', 'mode', 'vs', '0'],
|
||||
['wm', 'jobbeginingstatus', 'vs', '0'],
|
||||
]
|
||||
|
||||
|
||||
_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
|
||||
|
||||
|
||||
# --- flatten ---------------------------------------------------------------
|
||||
def flatten(links):
|
||||
g = lambda href, k, default=None: (links.get(href) or {}).get(k, default)
|
||||
|
||||
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)
|
||||
|
||||
progress = g('/operational/state/vs/0', 'x.com.samsung.da.progress')
|
||||
if progress in (None, 'None'):
|
||||
progress = 'Idle'
|
||||
|
||||
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
|
||||
|
||||
delay_start = g('/operational/state/vs/0', 'x.com.samsung.da.delayStartTime')
|
||||
|
||||
# Energy — same phantom -500W idle issue as the dryer.
|
||||
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:
|
||||
inst_w = 0.0
|
||||
|
||||
# Water — cumulative in mL, exposed as liters.
|
||||
cum_water_ml = _num(g('/water/consumption/vs/0', 'x.com.samsung.da.cumulativeWater'))
|
||||
cum_water_l = round(cum_water_ml / 1000.0, 1) if cum_water_ml is not None else None
|
||||
|
||||
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
|
||||
|
||||
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')
|
||||
|
||||
alarm_items = g('/alarms/vs/0', 'x.com.samsung.da.items') or []
|
||||
alarm_code = (alarm_items[-1].get('x.com.samsung.da.code')
|
||||
if alarm_items else None)
|
||||
|
||||
# Door LED — keys have no x.com.samsung.da. prefix on this appliance.
|
||||
led_rep = links.get('/doorled/light/vs/0') or {}
|
||||
led_brightness = led_rep.get('setBrightness')
|
||||
led_night_light = led_rep.get('setNightLight')
|
||||
|
||||
sound_rep = links.get('/settings/sound/mode/vs/0') or {}
|
||||
sound_mode = sound_rep.get('mode')
|
||||
|
||||
return {
|
||||
'machine_state': machine_state,
|
||||
'progress': progress,
|
||||
'progress_percentage': _int(g('/operational/state/vs/0',
|
||||
'x.com.samsung.da.progressPercentage')),
|
||||
'completion_time': remaining,
|
||||
'completion_minutes': rem_min,
|
||||
'delay_start_time': delay_start,
|
||||
'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,
|
||||
'water_liters': cum_water_l,
|
||||
'filter_usage': filter_usage,
|
||||
'filter_status': filter_status,
|
||||
'alarm_code': alarm_code,
|
||||
'led_brightness': led_brightness,
|
||||
'led_night_light': led_night_light,
|
||||
'sound_mode': sound_mode,
|
||||
}
|
||||
|
||||
|
||||
# --- Remaining-time anchor + extrapolation --------------------------------
|
||||
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
|
||||
|
||||
|
||||
def log_state_change(sensors):
|
||||
return (f"machine={sensors.get('machine_state')} "
|
||||
f"progress={sensors.get('progress')} "
|
||||
f"power={sensors.get('power_watts')}W")
|
||||
|
||||
|
||||
# --- HA discovery ---------------------------------------------------------
|
||||
MODEL = 'OCF dishwasher (TizenRT-iotivity, DW9000F-class)'
|
||||
|
||||
_SENSORS = [
|
||||
('machine_state', 'Machine state', {'icon': 'mdi:dishwasher'}),
|
||||
('progress', 'Progress', {'icon': 'mdi:progress-wrench'}),
|
||||
('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_start_time', 'Delay start time', {'icon': 'mdi:timer-pause'}),
|
||||
('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'}),
|
||||
('water_liters', 'Water consumption',
|
||||
{'unit_of_measurement': 'L', 'device_class': 'water',
|
||||
'state_class': 'total_increasing', 'icon': 'mdi:water'}),
|
||||
('filter_usage', 'Filter usage',
|
||||
{'unit_of_measurement': '%', 'state_class': 'measurement',
|
||||
'icon': 'mdi:filter'}),
|
||||
('filter_status', 'Filter status', {'icon': 'mdi:filter-check'}),
|
||||
('alarm_code', 'Alarm code',
|
||||
{'icon': 'mdi:alert', 'entity_category': 'diagnostic'}),
|
||||
('sound_mode', 'Sound mode', {'icon': 'mdi:volume-high'}),
|
||||
]
|
||||
|
||||
_BINARY_SENSORS = [
|
||||
('running', 'Running',
|
||||
"{{ 'ON' if value_json.machine_state == 'active' else 'OFF' }}",
|
||||
'running'),
|
||||
('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'),
|
||||
]
|
||||
|
||||
CMD_OPERATIONAL = 'cmd/operational_state'
|
||||
CMD_SOUND_MODE = 'cmd/sound_mode'
|
||||
CMD_LED_BRIGHT = 'cmd/led_brightness'
|
||||
CMD_LED_NIGHT = 'cmd/led_night_light'
|
||||
|
||||
_SOUND_MODES = ['voice', 'tone', 'mute']
|
||||
_LED_LEVELS = ['Low', 'High']
|
||||
|
||||
|
||||
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"
|
||||
dev = device_block(topic_prefix, device_name, MODEL)
|
||||
out = []
|
||||
|
||||
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)))
|
||||
|
||||
# buttons: Start / Pause / Stop (RC-gated)
|
||||
for key, name, payload, icon in [
|
||||
('start', 'Start cycle', 'Run', 'mdi:play'),
|
||||
('pause', 'Pause cycle', 'Pause', 'mdi:pause'),
|
||||
('stop', 'Stop cycle', 'Ready', 'mdi:stop'),
|
||||
]:
|
||||
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,
|
||||
'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: sound mode
|
||||
cfg = {
|
||||
'name': 'Sound mode',
|
||||
'unique_id': f"{topic_prefix}_sound_mode_select",
|
||||
'object_id': f"{topic_prefix}_sound_mode_select",
|
||||
'state_topic': state_topic,
|
||||
'value_template': '{{ value_json.sound_mode }}',
|
||||
'command_topic': f"{topic_prefix}/{CMD_SOUND_MODE}",
|
||||
'options': _SOUND_MODES,
|
||||
'icon': 'mdi:volume-high',
|
||||
'availability': avail_base(avail_topic),
|
||||
'device': dev,
|
||||
}
|
||||
out.append((f"{ha_prefix}/select/{topic_prefix}/sound_mode/config",
|
||||
encode(cfg)))
|
||||
|
||||
# select: door LED brightness
|
||||
cfg = {
|
||||
'name': 'Door LED brightness',
|
||||
'unique_id': f"{topic_prefix}_led_brightness_select",
|
||||
'object_id': f"{topic_prefix}_led_brightness_select",
|
||||
'state_topic': state_topic,
|
||||
'value_template': '{{ value_json.led_brightness }}',
|
||||
'command_topic': f"{topic_prefix}/{CMD_LED_BRIGHT}",
|
||||
'options': _LED_LEVELS,
|
||||
'icon': 'mdi:brightness-6',
|
||||
'availability': avail_base(avail_topic),
|
||||
'device': dev,
|
||||
}
|
||||
out.append((f"{ha_prefix}/select/{topic_prefix}/led_brightness/config",
|
||||
encode(cfg)))
|
||||
|
||||
# switch: door LED night light
|
||||
cfg = {
|
||||
'name': 'Door LED night light',
|
||||
'unique_id': f"{topic_prefix}_led_night_light_switch",
|
||||
'object_id': f"{topic_prefix}_led_night_light_switch",
|
||||
'state_topic': state_topic,
|
||||
'value_template': '{{ value_json.led_night_light }}',
|
||||
'state_on': 'On',
|
||||
'state_off': 'Off',
|
||||
'command_topic': f"{topic_prefix}/{CMD_LED_NIGHT}",
|
||||
'payload_on': 'On',
|
||||
'payload_off': 'Off',
|
||||
'icon': 'mdi:weather-night',
|
||||
'availability': avail_base(avail_topic),
|
||||
'device': dev,
|
||||
}
|
||||
out.append((f"{ha_prefix}/switch/{topic_prefix}/led_night_light/config",
|
||||
encode(cfg)))
|
||||
|
||||
return out
|
||||
|
||||
|
||||
# --- MQTT command handlers ------------------------------------------------
|
||||
def command_handlers():
|
||||
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 _sound_mode(p, _links):
|
||||
if p not in _SOUND_MODES:
|
||||
return None
|
||||
return ['settings', 'sound', 'mode', 'vs', '0'], {'mode': p}
|
||||
|
||||
def _led_brightness(p, _links):
|
||||
if p not in _LED_LEVELS:
|
||||
return None
|
||||
return ['doorled', 'light', 'vs', '0'], {'setBrightness': p}
|
||||
|
||||
def _led_night(p, _links):
|
||||
if p not in ('On', 'Off'):
|
||||
return None
|
||||
return ['doorled', 'light', 'vs', '0'], {'setNightLight': p}
|
||||
|
||||
return {
|
||||
CMD_OPERATIONAL: _operational,
|
||||
CMD_SOUND_MODE: _sound_mode,
|
||||
CMD_LED_BRIGHT: _led_brightness,
|
||||
CMD_LED_NIGHT: _led_night,
|
||||
}
|
||||
|
||||
|
||||
# --- Poll tiers -----------------------------------------------------------
|
||||
# Same washer-family firmware as the dryer (~14 req/s empirical ceiling).
|
||||
DISHWASHER_POLL_TIERS = [
|
||||
PollTier(
|
||||
name='hot',
|
||||
interval_s=1.0,
|
||||
active_interval_s=0.5,
|
||||
paths=(
|
||||
('operational', 'state', 'vs', '0'),
|
||||
),
|
||||
),
|
||||
PollTier(
|
||||
name='warm',
|
||||
interval_s=15.0,
|
||||
paths=(
|
||||
('power', 'vs', '0'),
|
||||
('kidslock', 'vs', '0'),
|
||||
('remotectrl', 'vs', '0'),
|
||||
('alarms', 'vs', '0'),
|
||||
('energy', 'consumption', 'vs', '0'),
|
||||
('water', 'consumption', 'vs', '0'),
|
||||
('filter', 'waterfilter', 'vs', '0'),
|
||||
('doorled', 'light', 'vs', '0'),
|
||||
('settings', 'sound', 'mode', 'vs', '0'),
|
||||
('wm', 'jobbeginingstatus', 'vs', '0'),
|
||||
),
|
||||
),
|
||||
PollTier(
|
||||
name='sweep',
|
||||
interval_s=300.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 -----------------------------------------------------------
|
||||
DISHWASHER = ApplianceDescriptor(
|
||||
name='dishwasher',
|
||||
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',
|
||||
log_state_change=log_state_change,
|
||||
poll_tiers=DISHWASHER_POLL_TIERS,
|
||||
is_active=_is_active,
|
||||
)
|
||||
@@ -1,503 +0,0 @@
|
||||
"""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 .base import (
|
||||
ApplianceDescriptor,
|
||||
avail_base,
|
||||
avail_with_remote,
|
||||
device_block,
|
||||
encode,
|
||||
)
|
||||
from ..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,
|
||||
)
|
||||
@@ -1,862 +0,0 @@
|
||||
"""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 .base import (
|
||||
ApplianceDescriptor,
|
||||
avail_base,
|
||||
avail_with_cycle,
|
||||
avail_with_remote_and_cycle,
|
||||
device_block,
|
||||
encode,
|
||||
)
|
||||
from ..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,
|
||||
)
|
||||
@@ -1,520 +0,0 @@
|
||||
"""Refrigerator descriptor (Samsung RF9000B-class / TP1X_REF_21K).
|
||||
|
||||
Resource map captured 2026-06-27 via DTLS-CoAP with the ab0b0ac4 cert
|
||||
from a unit at 10.0.0.254:49154.
|
||||
See local-tools/dumps/10.0.0.254.json for the full field reference.
|
||||
|
||||
This appliance has no "cycle" concept — there is no machine_state and
|
||||
no remote-control gate. All controls are always available when the bridge
|
||||
is online.
|
||||
|
||||
Temperature units: this model reports in Fahrenheit.
|
||||
Freezer setpoint range: -8°F to 5°F
|
||||
Fridge setpoint range: 34°F to 44°F
|
||||
|
||||
Write surfaces (all unproven — modelled on oven/dryer write semantics,
|
||||
first HA interaction is the live test):
|
||||
* Freezer and fridge setpoints via /temperatures/vs/0 items RMW
|
||||
(same pattern as oven /temperatures/vs/0)
|
||||
* Rapid fridge / rapid freezing via /refrigeration/vs/0
|
||||
* Ice maker 1 (cubed) on/off via /icemaker/one/vs/0
|
||||
* Ice maker 2 (whiskey ball) type select via /icemaker/two/vs/0
|
||||
* Autofill via /autofill/vs/0
|
||||
* Cabinet light on/off via /cabinet/light/total/vs/0
|
||||
* Beverage zone mode select via /specialzone/one/vs/0
|
||||
"""
|
||||
|
||||
from .base import (
|
||||
ApplianceDescriptor,
|
||||
avail_base,
|
||||
device_block,
|
||||
encode,
|
||||
)
|
||||
from ..poll_scheduler import PollTier
|
||||
|
||||
|
||||
# --- OBSERVE paths --------------------------------------------------------
|
||||
OBSERVE_PATHS = [
|
||||
['doors', 'vs', '0'], # door open states (2 doors)
|
||||
['temperatures', 'vs', '0'], # current + desired temp, both zones
|
||||
['icemaker', 'status', 'vs', '0'], # global ice maker enabled
|
||||
['icemaker', 'one', 'vs', '0'], # cubed ice maker state + type
|
||||
['icemaker', 'two', 'vs', '0'], # whiskey ball ice maker type
|
||||
['refrigeration', 'vs', '0'], # rapidFridge, rapidFreezing
|
||||
['energy', 'consumption', 'vs', '0'],
|
||||
['alarms', 'vs', '0'],
|
||||
['cabinet', 'light', 'total', 'vs', '0'],
|
||||
['autofill', 'vs', '0'],
|
||||
['filter', 'waterfilter', 'vs', '0'],
|
||||
['sabbath', 'vs', '0'],
|
||||
['status', 'lock', 'vs', '0'], # device sound + ADO control
|
||||
]
|
||||
|
||||
# Setpoint bounds read from /temperatures/vs/0 items on 2026-06-27.
|
||||
FREEZER_MIN_F = -8
|
||||
FREEZER_MAX_F = 5
|
||||
FRIDGE_MIN_F = 34
|
||||
FRIDGE_MAX_F = 44
|
||||
|
||||
# Whiskey ball ice sizes available on this model.
|
||||
ICE2_MODES = ['Off', 'WHISKEY_ICEBALL_3', 'WHISKEY_ICEBALL_6', 'WHISKEY_ICEBALL_9']
|
||||
|
||||
# Beverage zone flex modes on this model.
|
||||
BZONE_MODES = ['SP_TTYPE_BEER_DRINKS', 'SP_TTYPE_WINE_DESSERT']
|
||||
|
||||
|
||||
def _int(v):
|
||||
try:
|
||||
return int(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _num(v):
|
||||
try:
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
# --- flatten --------------------------------------------------------------
|
||||
def flatten(links):
|
||||
g = lambda href, k, default=None: (links.get(href) or {}).get(k, default)
|
||||
|
||||
# Temperatures — items[0]=Freezer, items[1]=Fridge.
|
||||
temps_items = g('/temperatures/vs/0', 'x.com.samsung.da.items') or []
|
||||
freezer_cur = freezer_des = fridge_cur = fridge_des = None
|
||||
if len(temps_items) > 0:
|
||||
freezer_cur = _int(temps_items[0].get('x.com.samsung.da.current'))
|
||||
freezer_des = _int(temps_items[0].get('x.com.samsung.da.desired'))
|
||||
if len(temps_items) > 1:
|
||||
fridge_cur = _int(temps_items[1].get('x.com.samsung.da.current'))
|
||||
fridge_des = _int(temps_items[1].get('x.com.samsung.da.desired'))
|
||||
|
||||
# Doors — items[0] and [1], openState 'Open'/'Close'.
|
||||
doors_items = g('/doors/vs/0', 'x.com.samsung.da.items') or []
|
||||
door0_open = door1_open = None
|
||||
if len(doors_items) > 0:
|
||||
s = doors_items[0].get('x.com.samsung.da.openState')
|
||||
door0_open = (s == 'Open') if s is not None else None
|
||||
if len(doors_items) > 1:
|
||||
s = doors_items[1].get('x.com.samsung.da.openState')
|
||||
door1_open = (s == 'Open') if s is not None else None
|
||||
any_door_open = bool(door0_open or door1_open)
|
||||
|
||||
# Ice makers.
|
||||
ice_enabled = g('/icemaker/status/vs/0', 'x.com.samsung.da.iceMaker')
|
||||
ice1_state = g('/icemaker/one/vs/0', 'x.com.samsung.da.iceMaker.state')
|
||||
ice1_status = g('/icemaker/one/vs/0', 'x.com.samsung.da.iceMaker.iceMakingStatus')
|
||||
ice1_on = (ice1_state == 'On') if ice1_state is not None else None
|
||||
ice2_type = g('/icemaker/two/vs/0', 'x.com.samsung.da.iceType.desired')
|
||||
ice2_status = g('/icemaker/two/vs/0', 'x.com.samsung.da.iceMaker.iceMakingStatus')
|
||||
|
||||
# Modes.
|
||||
rapid_fridge = g('/refrigeration/vs/0', 'x.com.samsung.da.rapidFridge')
|
||||
rapid_freezing = g('/refrigeration/vs/0', 'x.com.samsung.da.rapidFreezing')
|
||||
autofill = g('/autofill/vs/0', 'x.com.samsung.da.autofill')
|
||||
sabbath = g('/sabbath/vs/0', 'x.com.samsung.da.sabbathMode')
|
||||
|
||||
# Cabinet light.
|
||||
light_control = g('/cabinet/light/total/vs/0', 'x.com.samsung.da.lightControl')
|
||||
light_on = (light_control == 'On') if light_control is not None else None
|
||||
|
||||
# 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')
|
||||
|
||||
# Beverage zone.
|
||||
bzone_rep = links.get('/specialzone/one/vs/0') or {}
|
||||
bzone_mode = bzone_rep.get('roomDesiredMode')
|
||||
|
||||
# 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.cumulativePower'))
|
||||
|
||||
# Alarms — empty array when nothing is active on this model.
|
||||
alarm_items = g('/alarms/vs/0', 'x.com.samsung.da.items') or []
|
||||
alarm_code = (alarm_items[-1].get('x.com.samsung.da.code')
|
||||
if alarm_items else None)
|
||||
|
||||
# Firmware update.
|
||||
fw_update = g('/otninformation/vs/0', 'x.com.samsung.da.newVersionAvailable')
|
||||
fw_update_bin = (str(fw_update).lower() == 'true'
|
||||
if fw_update is not None else None)
|
||||
|
||||
return {
|
||||
'freezer_temp_f': freezer_cur,
|
||||
'freezer_setpoint_f': freezer_des,
|
||||
'fridge_temp_f': fridge_cur,
|
||||
'fridge_setpoint_f': fridge_des,
|
||||
'door_freezer_open': door0_open,
|
||||
'door_fridge_open': door1_open,
|
||||
'any_door_open': any_door_open,
|
||||
'ice_maker_enabled': ice_enabled,
|
||||
'ice1_state': ice1_state,
|
||||
'ice1_on': ice1_on,
|
||||
'ice1_making_status': ice1_status,
|
||||
'ice2_type': ice2_type,
|
||||
'ice2_making_status': ice2_status,
|
||||
'rapid_fridge': rapid_fridge,
|
||||
'rapid_freezing': rapid_freezing,
|
||||
'autofill': autofill,
|
||||
'sabbath_mode': sabbath,
|
||||
'cabinet_light': light_control,
|
||||
'cabinet_light_on': light_on,
|
||||
'filter_usage': filter_usage,
|
||||
'filter_status': filter_status,
|
||||
'beverage_zone_mode': bzone_mode,
|
||||
'power_watts': max(0.0, inst_w) if inst_w is not None else None,
|
||||
'energy_kwh': round(cum_wh / 1000.0, 2)
|
||||
if cum_wh is not None else None,
|
||||
'alarm_code': alarm_code,
|
||||
'firmware_update': fw_update_bin,
|
||||
}
|
||||
|
||||
|
||||
def log_state_change(sensors):
|
||||
return (f"freezer={sensors.get('freezer_temp_f')}°F "
|
||||
f"fridge={sensors.get('fridge_temp_f')}°F "
|
||||
f"door={'open' if sensors.get('any_door_open') else 'closed'} "
|
||||
f"power={sensors.get('power_watts')}W")
|
||||
|
||||
|
||||
# --- HA discovery ---------------------------------------------------------
|
||||
MODEL = 'OCF refrigerator (TizenRT-iotivity, RF9000B-class)'
|
||||
|
||||
_SENSORS = [
|
||||
('freezer_temp_f', 'Freezer temperature',
|
||||
{'unit_of_measurement': '°F', 'device_class': 'temperature',
|
||||
'state_class': 'measurement'}),
|
||||
('freezer_setpoint_f', 'Freezer setpoint',
|
||||
{'unit_of_measurement': '°F', 'device_class': 'temperature',
|
||||
'icon': 'mdi:thermometer-chevron-down'}),
|
||||
('fridge_temp_f', 'Fridge temperature',
|
||||
{'unit_of_measurement': '°F', 'device_class': 'temperature',
|
||||
'state_class': 'measurement'}),
|
||||
('fridge_setpoint_f', 'Fridge setpoint',
|
||||
{'unit_of_measurement': '°F', 'device_class': 'temperature',
|
||||
'icon': 'mdi:thermometer-chevron-up'}),
|
||||
('ice1_making_status', 'Ice maker (cubed) status', {'icon': 'mdi:cube'}),
|
||||
('ice2_making_status', 'Ice maker (whiskey) status', {'icon': 'mdi:circle'}),
|
||||
('filter_usage', 'Water filter usage',
|
||||
{'unit_of_measurement': '%', 'state_class': 'measurement',
|
||||
'icon': 'mdi:filter'}),
|
||||
('filter_status', 'Water filter status', {'icon': 'mdi:filter-check'}),
|
||||
('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'}),
|
||||
('alarm_code', 'Alarm code',
|
||||
{'icon': 'mdi:alert', 'entity_category': 'diagnostic'}),
|
||||
]
|
||||
|
||||
_BINARY_SENSORS = [
|
||||
('door_freezer_open', 'Freezer door',
|
||||
"{{ 'ON' if value_json.door_freezer_open else 'OFF' }}", 'door'),
|
||||
('door_fridge_open', 'Fridge door',
|
||||
"{{ 'ON' if value_json.door_fridge_open else 'OFF' }}", 'door'),
|
||||
('ice1_on', 'Ice maker (cubed)',
|
||||
"{{ 'ON' if value_json.ice1_on else 'OFF' }}", 'running'),
|
||||
('firmware_update', 'Firmware update available',
|
||||
"{{ 'ON' if value_json.firmware_update else 'OFF' }}", 'update'),
|
||||
]
|
||||
|
||||
CMD_FREEZER_SETPOINT = 'cmd/freezer_setpoint'
|
||||
CMD_FRIDGE_SETPOINT = 'cmd/fridge_setpoint'
|
||||
CMD_RAPID_FRIDGE = 'cmd/rapid_fridge'
|
||||
CMD_RAPID_FREEZING = 'cmd/rapid_freezing'
|
||||
CMD_ICE1 = 'cmd/ice1'
|
||||
CMD_ICE2_TYPE = 'cmd/ice2_type'
|
||||
CMD_AUTOFILL = 'cmd/autofill'
|
||||
CMD_CABINET_LIGHT = 'cmd/cabinet_light'
|
||||
CMD_BZONE_MODE = 'cmd/beverage_zone_mode'
|
||||
|
||||
|
||||
def build_discovery(topic_prefix, ha_prefix, device_name):
|
||||
state_topic = f"{topic_prefix}/state"
|
||||
avail_topic = f"{topic_prefix}/availability"
|
||||
dev = device_block(topic_prefix, device_name, MODEL)
|
||||
avail = avail_base(avail_topic)
|
||||
out = []
|
||||
|
||||
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)))
|
||||
|
||||
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,
|
||||
'device': dev,
|
||||
}
|
||||
out.append((f"{ha_prefix}/binary_sensor/{topic_prefix}/{key}/config",
|
||||
encode(cfg)))
|
||||
|
||||
# number: freezer setpoint
|
||||
cfg = {
|
||||
'name': 'Freezer setpoint',
|
||||
'unique_id': f"{topic_prefix}_freezer_setpoint_num",
|
||||
'object_id': f"{topic_prefix}_freezer_setpoint_num",
|
||||
'state_topic': state_topic,
|
||||
'value_template': '{{ value_json.freezer_setpoint_f }}',
|
||||
'command_topic': f"{topic_prefix}/{CMD_FREEZER_SETPOINT}",
|
||||
'min': FREEZER_MIN_F,
|
||||
'max': FREEZER_MAX_F,
|
||||
'step': 1,
|
||||
'unit_of_measurement': '°F',
|
||||
'device_class': 'temperature',
|
||||
'mode': 'slider',
|
||||
'icon': 'mdi:thermometer-chevron-down',
|
||||
'availability': avail,
|
||||
'device': dev,
|
||||
}
|
||||
out.append((f"{ha_prefix}/number/{topic_prefix}/freezer_setpoint/config",
|
||||
encode(cfg)))
|
||||
|
||||
# number: fridge setpoint
|
||||
cfg = {
|
||||
'name': 'Fridge setpoint',
|
||||
'unique_id': f"{topic_prefix}_fridge_setpoint_num",
|
||||
'object_id': f"{topic_prefix}_fridge_setpoint_num",
|
||||
'state_topic': state_topic,
|
||||
'value_template': '{{ value_json.fridge_setpoint_f }}',
|
||||
'command_topic': f"{topic_prefix}/{CMD_FRIDGE_SETPOINT}",
|
||||
'min': FRIDGE_MIN_F,
|
||||
'max': FRIDGE_MAX_F,
|
||||
'step': 1,
|
||||
'unit_of_measurement': '°F',
|
||||
'device_class': 'temperature',
|
||||
'mode': 'slider',
|
||||
'icon': 'mdi:thermometer-chevron-up',
|
||||
'availability': avail,
|
||||
'device': dev,
|
||||
}
|
||||
out.append((f"{ha_prefix}/number/{topic_prefix}/fridge_setpoint/config",
|
||||
encode(cfg)))
|
||||
|
||||
# switches: rapid fridge, rapid freezing, autofill, cabinet light, ice maker 1
|
||||
for key, name, tpl, cmd, icon in [
|
||||
('rapid_fridge', 'Rapid fridge', '{{ value_json.rapid_fridge }}',
|
||||
CMD_RAPID_FRIDGE, 'mdi:fridge-industrial'),
|
||||
('rapid_freezing', 'Rapid freezing', '{{ value_json.rapid_freezing }}',
|
||||
CMD_RAPID_FREEZING, 'mdi:snowflake'),
|
||||
('autofill', 'Autofill', '{{ value_json.autofill }}',
|
||||
CMD_AUTOFILL, 'mdi:cup-water'),
|
||||
('cabinet_light', 'Cabinet light', '{{ value_json.cabinet_light }}',
|
||||
CMD_CABINET_LIGHT, 'mdi:fridge-outline'),
|
||||
('ice1_switch', 'Ice maker (cubed)', '{{ value_json.ice1_state }}',
|
||||
CMD_ICE1, 'mdi:cube'),
|
||||
]:
|
||||
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,
|
||||
'device': dev,
|
||||
}
|
||||
out.append((f"{ha_prefix}/switch/{topic_prefix}/{key}/config",
|
||||
encode(cfg)))
|
||||
|
||||
# select: whiskey ball ice type
|
||||
cfg = {
|
||||
'name': 'Whiskey ball ice type',
|
||||
'unique_id': f"{topic_prefix}_ice2_type_select",
|
||||
'object_id': f"{topic_prefix}_ice2_type_select",
|
||||
'state_topic': state_topic,
|
||||
'value_template': '{{ value_json.ice2_type }}',
|
||||
'command_topic': f"{topic_prefix}/{CMD_ICE2_TYPE}",
|
||||
'options': ICE2_MODES,
|
||||
'icon': 'mdi:circle',
|
||||
'availability': avail,
|
||||
'device': dev,
|
||||
}
|
||||
out.append((f"{ha_prefix}/select/{topic_prefix}/ice2_type/config",
|
||||
encode(cfg)))
|
||||
|
||||
# select: beverage zone mode
|
||||
cfg = {
|
||||
'name': 'Beverage zone mode',
|
||||
'unique_id': f"{topic_prefix}_bzone_mode_select",
|
||||
'object_id': f"{topic_prefix}_bzone_mode_select",
|
||||
'state_topic': state_topic,
|
||||
'value_template': '{{ value_json.beverage_zone_mode }}',
|
||||
'command_topic': f"{topic_prefix}/{CMD_BZONE_MODE}",
|
||||
'options': BZONE_MODES,
|
||||
'icon': 'mdi:glass-wine',
|
||||
'availability': avail,
|
||||
'device': dev,
|
||||
}
|
||||
out.append((f"{ha_prefix}/select/{topic_prefix}/bzone_mode/config",
|
||||
encode(cfg)))
|
||||
|
||||
return out
|
||||
|
||||
|
||||
# --- Command handlers -----------------------------------------------------
|
||||
def _temps_items(links):
|
||||
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 _freezer_setpoint(p, links):
|
||||
try:
|
||||
temp = int(round(float(p)))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if not (FREEZER_MIN_F <= temp <= FREEZER_MAX_F):
|
||||
return None
|
||||
items = _temps_items(links)
|
||||
if not items:
|
||||
return None
|
||||
items[0]['x.com.samsung.da.desired'] = str(temp)
|
||||
return ['temperatures', 'vs', '0'], {'x.com.samsung.da.items': items}
|
||||
|
||||
def _fridge_setpoint(p, links):
|
||||
try:
|
||||
temp = int(round(float(p)))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if not (FRIDGE_MIN_F <= temp <= FRIDGE_MAX_F):
|
||||
return None
|
||||
items = _temps_items(links)
|
||||
if not items or len(items) < 2:
|
||||
return None
|
||||
items[1]['x.com.samsung.da.desired'] = str(temp)
|
||||
return ['temperatures', 'vs', '0'], {'x.com.samsung.da.items': items}
|
||||
|
||||
def _on_off(key, path_segs):
|
||||
def _handler(p, _links):
|
||||
if p not in ('On', 'Off'):
|
||||
return None
|
||||
return path_segs, {key: p}
|
||||
return _handler
|
||||
|
||||
def _ice1(p, _links):
|
||||
if p not in ('On', 'Off'):
|
||||
return None
|
||||
return ['icemaker', 'one', 'vs', '0'], {
|
||||
'x.com.samsung.da.iceMaker.state': p,
|
||||
}
|
||||
|
||||
def _ice2_type(p, _links):
|
||||
if p not in ICE2_MODES:
|
||||
return None
|
||||
return ['icemaker', 'two', 'vs', '0'], {
|
||||
'x.com.samsung.da.iceType.desired': p,
|
||||
}
|
||||
|
||||
def _bzone_mode(p, _links):
|
||||
if p not in BZONE_MODES:
|
||||
return None
|
||||
return ['specialzone', 'one', 'vs', '0'], {'roomDesiredMode': p}
|
||||
|
||||
return {
|
||||
CMD_FREEZER_SETPOINT: _freezer_setpoint,
|
||||
CMD_FRIDGE_SETPOINT: _fridge_setpoint,
|
||||
CMD_RAPID_FRIDGE: _on_off('x.com.samsung.da.rapidFridge',
|
||||
['refrigeration', 'vs', '0']),
|
||||
CMD_RAPID_FREEZING: _on_off('x.com.samsung.da.rapidFreezing',
|
||||
['refrigeration', 'vs', '0']),
|
||||
CMD_ICE1: _ice1,
|
||||
CMD_ICE2_TYPE: _ice2_type,
|
||||
CMD_AUTOFILL: _on_off('x.com.samsung.da.autofill',
|
||||
['autofill', 'vs', '0']),
|
||||
CMD_CABINET_LIGHT: _on_off('x.com.samsung.da.lightControl',
|
||||
['cabinet', 'light', 'total', 'vs', '0']),
|
||||
CMD_BZONE_MODE: _bzone_mode,
|
||||
}
|
||||
|
||||
|
||||
# --- Poll tiers -----------------------------------------------------------
|
||||
# No empirical ceiling measurement on this device yet. Using a conservative
|
||||
# ~8 req/s budget (matching oven) until a probe is run.
|
||||
# Doors on the hot tier for sub-second open/close alerts.
|
||||
FRIDGE_POLL_TIERS = [
|
||||
PollTier(
|
||||
name='hot',
|
||||
interval_s=1.0,
|
||||
paths=(
|
||||
('doors', 'vs', '0'),
|
||||
),
|
||||
),
|
||||
PollTier(
|
||||
name='warm',
|
||||
interval_s=30.0,
|
||||
paths=(
|
||||
('temperatures', 'vs', '0'),
|
||||
('icemaker', 'status', 'vs', '0'),
|
||||
('icemaker', 'one', 'vs', '0'),
|
||||
('icemaker', 'two', 'vs', '0'),
|
||||
('refrigeration', 'vs', '0'),
|
||||
('energy', 'consumption', 'vs', '0'),
|
||||
('filter', 'waterfilter', 'vs', '0'),
|
||||
('autofill', 'vs', '0'),
|
||||
('cabinet', 'light', 'total', 'vs', '0'),
|
||||
('sabbath', 'vs', '0'),
|
||||
('status', 'lock', 'vs', '0'),
|
||||
('specialzone', 'one', 'vs', '0'),
|
||||
('alarms', 'vs', '0'),
|
||||
),
|
||||
),
|
||||
PollTier(
|
||||
name='cold',
|
||||
interval_s=600.0,
|
||||
paths=(
|
||||
('otninformation', 'vs', '0'),
|
||||
),
|
||||
),
|
||||
PollTier(
|
||||
name='sweep',
|
||||
interval_s=600.0,
|
||||
paths=(('device', '0'),),
|
||||
is_sweep=True,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
# --- Descriptor -----------------------------------------------------------
|
||||
REFRIGERATOR = ApplianceDescriptor(
|
||||
name='refrigerator',
|
||||
default_observe_port=49154,
|
||||
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,
|
||||
# No remote_available_field — fridge has no remote-control gate.
|
||||
# No cycle_active_field — fridge has no cycle concept.
|
||||
# No is_active — always uses normal poll interval.
|
||||
)
|
||||
+178
-48
@@ -23,7 +23,6 @@ import time
|
||||
|
||||
import cbor2
|
||||
|
||||
from .appliances.base import ApplianceDescriptor, bridge_diagnostic_discovery
|
||||
from .coap_dtls import DtlsCoapSession, fmt_code
|
||||
from .config import ApplianceConfig, SharedConfig
|
||||
from .keepalive import KeepaliveTask
|
||||
@@ -36,6 +35,10 @@ from .state_cache import StateCache
|
||||
|
||||
DEBUG_BRIDGE = os.environ.get('DEBUG_BRIDGE') == '1'
|
||||
|
||||
# /device/0 is the OCF resource that contains the full link dict used
|
||||
# to seed the state cache on every connect.
|
||||
SEED_PATH = ['device', '0']
|
||||
|
||||
|
||||
def _href_to_segs(href: str) -> list[str]:
|
||||
return [s for s in href.split('/') if s]
|
||||
@@ -60,29 +63,114 @@ UNREACHABLE_RECONNECT_S = 120.0
|
||||
OBSERVE_REFRESH_INTERVAL_S = 6 * 3600.0
|
||||
|
||||
|
||||
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 entity discovery."""
|
||||
avail_topic = f"{topic_prefix}/availability"
|
||||
health_topic = f"{topic_prefix}/bridge/health"
|
||||
push_topic = f"{topic_prefix}/bridge/push_active"
|
||||
device = {
|
||||
'identifiers': [topic_prefix],
|
||||
'name': device_name,
|
||||
'manufacturer': 'Samsung',
|
||||
'model': model,
|
||||
}
|
||||
avail = [{'topic': avail_topic,
|
||||
'payload_available': 'online',
|
||||
'payload_not_available': 'offline'}]
|
||||
|
||||
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_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'),
|
||||
]
|
||||
|
||||
|
||||
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.port = app.ocf_port or 49154
|
||||
|
||||
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)
|
||||
# descriptor is None until _discover_and_publish completes after seed.
|
||||
self.descriptor = None
|
||||
self._discovered = False
|
||||
self._entity_payloads: list = []
|
||||
|
||||
self.cache = StateCache()
|
||||
self.cache.set_on_change(self._on_cache_change)
|
||||
|
||||
self.last_state_pub = None
|
||||
@@ -132,16 +220,9 @@ class PushBridge:
|
||||
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_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:
|
||||
@@ -248,8 +329,7 @@ class PushBridge:
|
||||
self._unreachable_since = None
|
||||
self._force_close_in_flight = False
|
||||
|
||||
self.log.info("DTLS connected — subscribing %d paths",
|
||||
len(self.descriptor.observe_paths))
|
||||
self.log.info("DTLS connected — port %d", self.port)
|
||||
|
||||
sess.start_reader()
|
||||
|
||||
@@ -273,19 +353,25 @@ class PushBridge:
|
||||
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.
|
||||
# Seed first — populate cache before discovery can run.
|
||||
# The publish gate stays closed so seed writes don't trigger
|
||||
# per-resource publishes before the full snapshot is ready.
|
||||
self._seed_from_device0(sess)
|
||||
self._retag_logger_with_serial()
|
||||
|
||||
if DEBUG_BRIDGE:
|
||||
self._debug_dump_links(sess)
|
||||
|
||||
# Discover entities from the seeded cache, build the runtime
|
||||
# descriptor, and publish HA discovery payloads. Idempotent:
|
||||
# on reconnect the descriptor is reused from the first session.
|
||||
self._discover_and_publish(sess)
|
||||
|
||||
# Now subscribe to observe paths (descriptor populated above).
|
||||
for path in self.descriptor.observe_paths:
|
||||
sess.subscribe(path)
|
||||
time.sleep(0.05)
|
||||
|
||||
self._publish_gate = True
|
||||
self.maybe_publish_state(force=True)
|
||||
self.set_availability(True)
|
||||
@@ -344,15 +430,15 @@ class PushBridge:
|
||||
self.observe_refresh = None
|
||||
|
||||
def _seed_from_device0(self, sess):
|
||||
code, pl = sess.get(self.descriptor.seed_path, timeout=15.0)
|
||||
code, pl = sess.get(SEED_PATH, timeout=15.0)
|
||||
if code != 0x45:
|
||||
raise RuntimeError(
|
||||
f"/{'/'.join(self.descriptor.seed_path)} -> {fmt_code(code)}")
|
||||
f"/{'/'.join(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}"
|
||||
f"/{'/'.join(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
|
||||
@@ -362,6 +448,40 @@ class PushBridge:
|
||||
self.cache.apply_rep(href, rep, source='seed')
|
||||
self.last_seed_ts = time.time()
|
||||
|
||||
def _discover_and_publish(self, sess):
|
||||
"""Run entity discovery against the seeded cache and publish HA
|
||||
discovery payloads. Idempotent — on reconnect the descriptor
|
||||
built during the first session is reused; entity payloads are
|
||||
re-published via reassert_availability instead."""
|
||||
if self._discovered:
|
||||
return
|
||||
from .registry import CAPABILITIES, discover, build_runtime_descriptor
|
||||
from .registry.identity import read_identity
|
||||
snapshot = self.cache.snapshot()
|
||||
bound = discover(snapshot, CAPABILITIES,
|
||||
log=lambda m: self.log.debug("%s", m))
|
||||
ident = read_identity(sess, self._serial)
|
||||
model = ident.model or self.app.klass.title() or 'Samsung Appliance'
|
||||
device_name = self.app.device_name or ident.name or model
|
||||
self.descriptor = build_runtime_descriptor(
|
||||
bound,
|
||||
topic_prefix=self.app.topic_prefix,
|
||||
ha_prefix=self.shared.HA_DISCOVERY_PREFIX,
|
||||
device_name=device_name,
|
||||
model=model,
|
||||
name=(self.app.klass or 'appliance'),
|
||||
default_port=self.port)
|
||||
self.cache.set_on_observation(self.descriptor.on_observation)
|
||||
self.cmd_handlers = self.descriptor.command_handlers()
|
||||
diag_payloads = _bridge_diagnostic_discovery(
|
||||
self.app.topic_prefix, self.shared.HA_DISCOVERY_PREFIX,
|
||||
device_name, model=model)
|
||||
self._entity_payloads = self.descriptor.discovery_payloads + diag_payloads
|
||||
for topic, payload in self._entity_payloads:
|
||||
self.mqtt.publish(topic, payload, qos=1, retain=True)
|
||||
self._discovered = True
|
||||
self.log.info("discovered %d entities", len(self.descriptor.discovery_payloads))
|
||||
|
||||
def _debug_dump_links(self, sess):
|
||||
for href, rep in sorted(self.cache.links.items()):
|
||||
if href == '/mode/vs/0':
|
||||
@@ -417,6 +537,8 @@ class PushBridge:
|
||||
# ---- MQTT publishing --------------------------------------------
|
||||
|
||||
def maybe_publish_state(self, force=False):
|
||||
if self.descriptor is None:
|
||||
return
|
||||
if not force and not self._publish_gate:
|
||||
return
|
||||
snap = self.cache.snapshot()
|
||||
@@ -479,14 +601,21 @@ class PushBridge:
|
||||
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)
|
||||
if self._discovered:
|
||||
for topic, payload in self._entity_payloads:
|
||||
try:
|
||||
self.mqtt.publish(topic, payload, qos=1, retain=True)
|
||||
except Exception as e:
|
||||
self.log.warning("re-assert discovery: %s", e)
|
||||
if self.descriptor is not None:
|
||||
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)
|
||||
@@ -501,20 +630,21 @@ class PushBridge:
|
||||
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 and self.descriptor is not None:
|
||||
if 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 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:
|
||||
@@ -593,7 +723,7 @@ class PushBridge:
|
||||
|
||||
h = {
|
||||
'mode': 'poll+observe',
|
||||
'device_class': self.descriptor.name,
|
||||
'device_class': self.app.klass or 'appliance',
|
||||
'serial': self._serial,
|
||||
'connect_count': self.connect_count,
|
||||
'error_count': self.error_count,
|
||||
|
||||
@@ -83,25 +83,23 @@ class ApplianceConfig:
|
||||
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', …
|
||||
klass: str # 'dryer', 'oven', … — empty string if unknown
|
||||
ip: str
|
||||
ocf_port: Optional[int] # None → descriptor.default_observe_port
|
||||
ocf_port: Optional[int] # None → 49154 (default DTLS-CoAP 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")
|
||||
klass = os.getenv(prefix + 'CLASS') or ''
|
||||
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()}'
|
||||
topic = os.getenv(prefix + 'TOPIC') or (f'samsung_{klass}' if klass else 'samsung_appliance')
|
||||
name = os.getenv(prefix + 'NAME') or (f'Samsung {klass.title()}' if klass else 'Samsung Appliance')
|
||||
return cls(
|
||||
index=index, klass=klass, ip=ip, ocf_port=port,
|
||||
topic_prefix=topic, device_name=name,
|
||||
|
||||
Reference in New Issue
Block a user