refactor: move samsung_appliance/ into custom_components/localthings/ocf/

Makes the integration fully self-contained for HACS distribution.
Users installing via HACS get everything in one directory; no external
samsung_appliance package needed.

All component imports updated to relative (.ocf.*); all test imports
updated to custom_components.localthings.ocf.*. ocf_root_ca.pem moves
with the package and resolves correctly via Path(__file__).parent.
This commit is contained in:
Marc Billow committed 2026-06-30 16:34:20 -05:00
1 parent 0bb27a9204
commit cc2c1e5a1f
50 files changed
+50 -50

No files matched your search

@@ -6,7 +6,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from samsung_appliance.registry.entities import BinarySensorDesc
from .ocf.registry.entities import BinarySensorDesc
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
+2 -2
View File
@@ -26,8 +26,8 @@ def _probe_and_validate(host: str, cert_pem: str, key_pem: str) -> dict:
to a temp-file pair and cleaned up in the finally block.
"""
import cbor2
from samsung_appliance.coap_dtls import DtlsCoapSession
from samsung_appliance.batch import parse_device0_batch
from .ocf.coap_dtls import DtlsCoapSession
from .ocf.batch import parse_device0_batch
# Write PEM strings to temporary files — DtlsCoapSession requires
# cert_path / key_path rather than in-memory PEM strings.
+7 -7
View File
@@ -14,13 +14,13 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.helpers.device_registry import DeviceInfo
from samsung_appliance.batch import parse_device0_batch
from samsung_appliance.coap_dtls import DtlsCoapSession
from samsung_appliance.registry.by_type import for_device
from samsung_appliance.registry.discovery import discover, BoundEntity
from samsung_appliance.registry import CAPABILITIES
from samsung_appliance.registry.adapter import flatten, is_active
from samsung_appliance.registry.identity import read_identity, DeviceIdentity
from .ocf.batch import parse_device0_batch
from .ocf.coap_dtls import DtlsCoapSession
from .ocf.registry.by_type import for_device
from .ocf.registry.discovery import discover, BoundEntity
from .ocf.registry import CAPABILITIES
from .ocf.registry.adapter import flatten, is_active
from .ocf.registry.identity import read_identity, DeviceIdentity
from .const import (
DOMAIN, CONF_HOST, CONF_PORT, CONF_CERT_PEM, CONF_KEY_PEM,
+2 -2
View File
@@ -4,8 +4,8 @@ from __future__ import annotations
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.helpers.device_registry import DeviceInfo
from samsung_appliance.registry.adapter import _key
from samsung_appliance.registry.discovery import BoundEntity
from .ocf.registry.adapter import _key
from .ocf.registry.discovery import BoundEntity
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
+1 -1
View File
@@ -6,7 +6,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from samsung_appliance.registry.entities import NumberDesc
from .ocf.registry.entities import NumberDesc
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
@@ -0,0 +1,5 @@
"""Samsung appliance local-API library (custom_components/localthings/ocf).
CoAP/DTLS session management, OCF batch resource parsing, and a
capability registry for discovering and reading Samsung appliance state."""
__version__ = "2.0.0"
@@ -0,0 +1,19 @@
"""OCF /device/0 batch response parser."""
from __future__ import annotations
def parse_device0_batch(device0: list) -> dict[str, dict]:
"""Extract {href: rep} from a /device/0 CBOR list response."""
out = {}
for entry in device0[1:]: # skip [0] (device-level rep)
if not isinstance(entry, dict):
continue
href = entry.get('href')
rep = entry.get('rep')
if not href:
continue
# rep == {"href": "..."} is a stub (resource present, no current data).
# Include it as {} so capabilities still bind and the entity exists.
if isinstance(rep, dict):
out[href] = {} if set(rep.keys()) == {'href'} else rep
return out
@@ -0,0 +1,636 @@
"""CoAP-over-DTLS client for Samsung RT-OCF appliances (RFC 7252 + 6347).
Replaces the TLS-over-TCP transport used in the original dryer bridge.
Both the oven (UDP/49154) and the dryer (UDP/49155) speak CoAP-over-DTLS
with the ECDHE-ECDSA-AES128-GCM-SHA256 cipher and ab0b0ac4 client cert.
Wire-level details that matter (from local-tools/oven-findings.md §17):
* DTLS ciphertext MTU must be 1200; otherwise OpenSSL fragments the
client cert across two datagrams and TizenRT drops the second.
* Samsung's RT-OCF uses ACK+separate-CON for the larger responses.
The reader MUST correlate by (token, mid) — not arrival order —
or interleaved one-shot / OBSERVE traffic mis-attributes.
* Multi-block GET requires the SAME CoAP token across every block
of the response ("token-stable Block2"). Fresh-token-per-block
is silently dropped by the server.
Reader thread owns the UDP socket. Callers issue get()/post() and block
on a per-token Event the reader signals. OBSERVE notifications are
delivered via the on_notification callback.
"""
import os
import socket
import struct
import threading
import time
from pathlib import Path
from OpenSSL import SSL
_OCF_ROOT_CA = str(Path(__file__).parent / 'ocf_root_ca.pem')
import logging
logger = logging.getLogger(__name__)
# Diagnostic logging — when DEBUG_BRIDGE=1 in env, the bridge dumps
# every received CoAP frame, every /operational/state/vs/0 + /oven/vs/0
# + /power/vs/0 + /mode/vs/0-options rep change, the full link tree at
# seed time, and the /oic/res directory. Useful for reverse-engineering
# new resources and field semantics; otherwise quiet.
DEBUG_BRIDGE = os.environ.get('DEBUG_BRIDGE') == '1'
# CoAP option numbers (RFC 7252 + 7641 + 7959)
URI_PATH = 11
URI_QUERY = 15
OBSERVE = 6
CONTENT_FORMAT = 12
ACCEPT = 17
BLOCK2 = 23
SIZE2 = 28
# CoAP message types
TYPE_CON = 0
TYPE_NON = 1
TYPE_ACK = 2
TYPE_RST = 3
# CoAP method codes
METHOD_GET = 0x01
METHOD_POST = 0x02
# CoAP content-format value for application/cbor
CF_CBOR = b'\x3c'
# OBSERVE option values (RFC 7641 §2)
OBSERVE_REGISTER = b'' # register / refresh
OBSERVE_DEREGISTER = bytes([1]) # deregister
# Block2 SZX=6 → 1024-byte blocks. The largest size Samsung's RT-OCF
# will honour and the only one the probes have validated end-to-end.
BLOCK_SZX = 6
def _vlen(v):
"""Variable-length integer encoder used in option deltas + lengths."""
if v < 13: return v, b''
if v < 269: return 13, bytes([v - 13])
return 14, struct.pack('>H', v - 269)
def encode_options(opts):
"""Encode a list of (option_number, value_bytes) tuples."""
out = b''
prev = 0
for n, val in sorted(opts, key=lambda x: x[0]):
d, dx = _vlen(n - prev)
l, lx = _vlen(len(val))
out += bytes([(d << 4) | l]) + dx + lx + val
prev = n
return out
def parse_coap(data):
"""Decode a CoAP datagram. Returns (mtype, code, mid, token,
options, payload). options is a list of (num, value_bytes)."""
mt = (data[0] >> 4) & 0x03
tkl = data[0] & 0x0F
code = data[1]
mid = int.from_bytes(data[2:4], 'big')
tok = data[4:4 + tkl]
i = 4 + tkl
opts = []
prev = 0
payload = b''
while i < len(data):
b = data[i]
if b == 0xFF:
payload = data[i + 1:]
break
d_nib, l_nib = b >> 4, b & 0x0F
i += 1
if d_nib == 13:
delta = 13 + data[i]; i += 1
elif d_nib == 14:
delta = 269 + int.from_bytes(data[i:i + 2], 'big'); i += 2
elif d_nib == 15:
raise ValueError("reserved option delta nibble 15")
else:
delta = d_nib
if l_nib == 13:
length = 13 + data[i]; i += 1
elif l_nib == 14:
length = 269 + int.from_bytes(data[i:i + 2], 'big'); i += 2
elif l_nib == 15:
raise ValueError("reserved option length nibble 15")
else:
length = l_nib
num = prev + delta
opts.append((num, data[i:i + length]))
i += length
prev = num
return mt, code, mid, tok, opts, payload
def build_coap(mtype, code, mid, token, options, payload=b''):
"""Build a CoAP datagram. mtype: CON/NON/ACK/RST. token: bytes (may
be empty for ACK). options: list of (num, value_bytes)."""
tkl = len(token)
hdr = bytes([(1 << 6) | (mtype << 4) | tkl, code,
(mid >> 8) & 0xFF, mid & 0xFF])
body = hdr + token + encode_options(options)
if payload:
body += b'\xFF' + payload
return body
def block_value(num, more, szx):
"""Encode a CoAP Block-N option value."""
v = (num << 4) | ((more & 1) << 3) | (szx & 7)
if v <= 0xFF: return bytes([v])
if v <= 0xFFFF: return struct.pack('>H', v)
return struct.pack('>I', v)[1:]
def fmt_code(c):
"""0x45 → '2.05', 0x84 → '4.04'. Used in log lines."""
return f"{c >> 5}.{c & 0x1F:02d}"
def _split_dtls(buf):
"""Split a UDP datagram that contains one-or-more DTLS records.
OpenSSL sometimes hands the BIO multiple records back-to-back; we
must send each as its own UDP datagram or TizenRT drops them."""
o, out = 0, []
while o + 13 <= len(buf):
L = int.from_bytes(buf[o + 11:o + 13], 'big')
end = o + 13 + L
if end > len(buf):
break
out.append(buf[o:end])
o = end
return out
class DtlsCoapSession:
"""Single sustained DTLS-CoAP session.
Caller drives lifecycle:
sess = DtlsCoapSession(host, port, cert, key)
sess.connect()
sess.start_reader()
sess.subscribe([...], on_notification=cb) # OBSERVE
code, body = sess.get(['device', '0']) # Block2 fetch
code, _ = sess.post(['mode','vs','0'], cbor)
sess.close()
"""
HANDSHAKE_TIMEOUT_S = 12.0
READER_RECV_TIMEOUT_S = 1.0 # short so stop_event propagates quickly
MAX_BLOCKS = 32 # safety bound for Block2 fetches
def __init__(self, host, port, cert_path, key_path,
on_notification=None, mtu=1200):
self.host = host
self.port = port
self.cert_path = str(cert_path)
self.key_path = str(key_path)
self.on_notification = on_notification # fn(href, payload_bytes)
self.mtu = mtu
self.sock = None
self.conn = None
self.dest = None
self._send_lock = threading.Lock()
# Randomize MID and token counter starting points so reconnects
# don't reuse identifiers from previous sessions — Samsung's
# RT-OCF appears to remember observer state across DTLS
# sessions, and re-registering with a token it still thinks is
# active is silently no-ops.
self._mid = int.from_bytes(os.urandom(2), 'big')
self._tok_counter = int.from_bytes(os.urandom(4), 'big')
# OBSERVE tokens are 1-byte (Samsung silently drops TKL>1
# OBSERVE registrations). Pick a random starting byte in the
# 0x40..0xff range so each session uses fresh values.
self._observe_tok_counter = 0x40 + (os.urandom(1)[0] & 0xBF)
# token (bytes) → (Event, container_dict)
self._pending = {}
# token (bytes) → href (str)
self._observe_tokens = {}
self._stop = threading.Event()
self._reader_thread = None
# ---- lifecycle ---------------------------------------------------
def connect(self):
"""DTLS handshake. Blocks up to HANDSHAKE_TIMEOUT_S. Raises
ConnectionError / TimeoutError on failure."""
ctx = SSL.Context(SSL.DTLS_METHOD)
ctx.load_verify_locations(_OCF_ROOT_CA)
ctx.set_verify(SSL.VERIFY_PEER, lambda conn, cert, err, depth, ok: ok)
ctx.set_cipher_list(b'ECDHE-ECDSA-AES128-GCM-SHA256')
ctx.use_certificate_chain_file(self.cert_path)
ctx.use_privatekey_file(self.key_path)
ctx.check_privatekey()
conn = SSL.Connection(ctx, None)
conn.set_connect_state()
conn.set_ciphertext_mtu(self.mtu)
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.settimeout(2.0)
dest = (self.host, self.port)
t0 = time.time()
while time.time() - t0 < self.HANDSHAKE_TIMEOUT_S:
try:
conn.do_handshake()
break
except SSL.WantReadError:
pass
except SSL.Error as e:
sock.close()
raise ConnectionError(f"DTLS handshake error: {e}") from e
try:
o = conn.bio_read(65535)
if o:
for r in _split_dtls(o):
sock.sendto(r, dest)
except SSL.WantReadError:
pass
try:
d, _ = sock.recvfrom(65535)
if d:
conn.bio_write(d)
except socket.timeout:
pass
time.sleep(0.05)
else:
sock.close()
raise TimeoutError(
f"DTLS handshake timeout to {self.host}:{self.port}")
self.sock = sock
self.conn = conn
self.dest = dest
self._stop.clear()
def start_reader(self):
"""Spawn the reader thread. Must be called after connect()."""
if self.sock is None:
raise RuntimeError("connect() before start_reader()")
t = threading.Thread(target=self._reader_loop,
daemon=True, name='dtls-reader')
t.start()
self._reader_thread = t
def join(self):
"""Block until the reader thread exits (i.e. socket dies)."""
if self._reader_thread is not None:
self._reader_thread.join()
def _send_observe_dereg(self, tok, path_segs):
"""Send a single OBSERVE deregister GET (Observe option = 1)
on the existing token. Best-effort — caller swallows errors."""
if self.conn is None:
return
mid = self._next_mid()
opts = [(URI_PATH, s.encode()) for s in path_segs]
opts.append((OBSERVE, OBSERVE_DEREGISTER))
opts.append((ACCEPT, CF_CBOR))
self._send_dgram(
build_coap(TYPE_CON, METHOD_GET, mid, tok, opts))
def close(self):
"""Tear down session. Sends best-effort OBSERVE deregisters
first so Samsung's RT-OCF cleans up its observer table —
without this, the per-cert observer state survives DTLS close
and a quick reconnect with the same tokens silently no-ops."""
# Send dereg for every active observation while the conn is
# still healthy. Tiny sleep lets the records reach the wire
# before we shut DTLS down.
if self.conn is not None and self._observe_tokens:
for tok, href in list(self._observe_tokens.items()):
segs = [s for s in href.split('/') if s]
try:
self._send_observe_dereg(tok, segs)
except Exception as e:
logger.warning("dereg %s: %s", href, e)
time.sleep(0.1)
self._stop.set()
if self.conn is not None:
try:
self.conn.shutdown()
except Exception:
pass
if self.sock is not None:
try:
self.sock.close()
except Exception:
pass
for tok, (ev, container) in list(self._pending.items()):
container.setdefault('err', 'socket closed')
ev.set()
self._pending.clear()
self._observe_tokens.clear()
self.sock = None
self.conn = None
# ---- send / receive plumbing -------------------------------------
def _next_mid(self):
self._mid = (self._mid + 1) & 0xFFFF
return self._mid
def _next_tok(self):
self._tok_counter = (self._tok_counter + 1) & 0xFFFFFFFF
# 4-byte tokens — fits within tkl=8 cap with headroom and
# avoids collisions across long-running OBSERVE subscriptions.
return self._tok_counter.to_bytes(4, 'big')
def _next_observe_tok(self):
# Single-byte tokens for OBSERVE registrations. Samsung
# RT-OCF accepts these but silently drops TKL=4 OBSERVE
# registrations. Counter is randomly seeded per session so
# reconnects don't collide with stale observer state Samsung
# may still be holding from the previous run.
self._observe_tok_counter = (self._observe_tok_counter + 1) & 0xFF
# Avoid 0x00 — some CoAP stacks treat an all-zero token as
# equivalent to "no token" / empty (TKL=0).
if self._observe_tok_counter == 0:
self._observe_tok_counter = 1
return bytes([self._observe_tok_counter])
def _send_dgram(self, datagram):
"""Send a CoAP datagram. Holds the send lock for the
BIO-drain so two writers can't interleave records."""
with self._send_lock:
if self.conn is None:
raise ConnectionError("DTLS session closed")
self.conn.send(datagram)
try:
while True:
o = self.conn.bio_read(65535)
if not o:
break
for r in _split_dtls(o):
self.sock.sendto(r, self.dest)
except SSL.WantReadError:
pass
def _reader_loop(self):
"""Pump UDP socket → DTLS BIO → CoAP parser. Demuxes to pending
/ observe handlers. Exits on socket error or stop event."""
sock = self.sock
conn = self.conn
sock.settimeout(self.READER_RECV_TIMEOUT_S)
try:
while not self._stop.is_set():
try:
d, _ = sock.recvfrom(65535)
except socket.timeout:
continue
except (OSError, ValueError):
return
if not d:
continue
# pyOpenSSL's SSL.Connection is not thread-safe — the
# same SSL object must not be touched by multiple
# threads concurrently. Drain decrypted records into a
# local list under _send_lock so the reader never races
# a sender's conn.send()/bio_read(). Dispatch happens
# AFTER releasing the lock because _dispatch_coap may
# call _send_dgram (auto-ACK for CON frames), which
# re-acquires the lock — holding it across dispatch
# would deadlock.
packets = []
exit_reader = False
with self._send_lock:
try:
conn.bio_write(d)
except SSL.Error as e:
logger.warning("DTLS bio_write: %s", e)
return
while True:
try:
pl = conn.recv(65535)
except SSL.WantReadError:
break
except SSL.ZeroReturnError:
logger.info("DTLS peer closed connection")
exit_reader = True
break
except SSL.Error as e:
logger.warning("DTLS recv: %s", e)
exit_reader = True
break
if not pl:
break
packets.append(pl)
for pl in packets:
try:
self._dispatch_coap(pl)
except Exception as e:
logger.warning("dispatch: %s", e)
if exit_reader:
return
finally:
# Make sure pending waiters don't hang if the reader dies.
for tok, (ev, container) in list(self._pending.items()):
container.setdefault('err', 'reader exited')
ev.set()
def _dispatch_coap(self, datagram):
try:
mt, code, mid, tok, ropts, payload = parse_coap(datagram)
except Exception as e:
logger.debug("malformed CoAP: %s", e)
return
if DEBUG_BRIDGE:
kind = ['CON', 'NON', 'ACK', 'RST'][mt]
logger.info("rx %s code=%s mid=%04x tok=%s opts=%d pl=%d",
kind, fmt_code(code), mid, tok.hex() or '-',
len(ropts), len(payload))
# ACK back any CON from the device to suppress retransmits.
# RFC 7252 §4.2 — ACK is a bare frame (token len 0, code 0).
if mt == TYPE_CON:
try:
self._send_dgram(build_coap(TYPE_ACK, 0, mid, b'', []))
except Exception as e:
logger.warning("ACK send: %s", e)
# Empty ACK with no options & no payload = "separate response
# coming" — used by Samsung's RT-OCF for the larger reads. Stop
# the retransmit timer on the client side and wait for the CON.
if mt == TYPE_ACK and code == 0 and not payload and not ropts:
return
# Pending one-shot? Resolve and return.
rec = self._pending.get(tok)
if rec is not None:
ev, container = rec
container['code'] = code
container['mtype'] = mt
container['mid'] = mid
container['options'] = ropts
container['payload'] = payload
ev.set()
return
# OBSERVE notification?
href = self._observe_tokens.get(tok)
if href is not None:
if code != 0x45:
logger.warning("observe %s: non-2.05 %s",
href, fmt_code(code))
return
cb = self.on_notification
if cb is not None:
try:
cb(href, payload)
except Exception as e:
logger.warning("notification callback %s: %s",
href, e)
return
# Stale token (post-reconnect or unknown) — drop quietly.
# ---- request primitives ------------------------------------------
def get(self, path_segs, query=(), timeout=10.0):
"""Token-stable Block2 GET. Returns (code, payload_bytes).
Reuses one CoAP token across every block of a multi-block
response — Samsung's server keys per-transfer state on the
token, and dropping a fresh token on block 1+ silently drops
the request."""
if self.conn is None:
raise ConnectionError("DTLS session closed")
tok = self._next_tok()
blob = b''
num = 0
last_code = None
last_opts = []
deadline = time.time() + timeout
while True:
ev = threading.Event()
container = {}
self._pending[tok] = (ev, container)
try:
mid = self._next_mid()
opts = [(URI_PATH, s.encode()) for s in path_segs]
for q in query:
opts.append((URI_QUERY, q.encode()))
opts.append((ACCEPT, CF_CBOR))
if num > 0:
opts.append((BLOCK2, block_value(num, 0, BLOCK_SZX)))
self._send_dgram(
build_coap(TYPE_CON, METHOD_GET, mid, tok, opts))
wait = max(0.1, deadline - time.time())
if not ev.wait(wait):
raise TimeoutError(
f"GET /{'/'.join(path_segs)} block {num} timeout")
if 'err' in container:
raise ConnectionError(container['err'])
finally:
self._pending.pop(tok, None)
code = container['code']
payload = container['payload']
ropts = container['options']
last_code = code
last_opts = ropts
blob += payload
# 4.xx / 5.xx responses don't carry Block2 continuation —
# bail with whatever we got. Caller decides if 4.xx is fatal.
if code >> 5 != 2:
return code, blob
b2 = [v for n, v in ropts if n == BLOCK2]
more = 0
if b2:
bv = int.from_bytes(b2[0], 'big')
more = (bv >> 3) & 1
if not more:
break
num += 1
if num > self.MAX_BLOCKS:
raise ConnectionError(
f"GET /{'/'.join(path_segs)}: >{self.MAX_BLOCKS} "
f"blocks, aborting")
return last_code, blob
def post(self, path_segs, body_cbor, timeout=8.0):
"""Single-frame POST with a CBOR-encoded body. Returns
(code, payload_bytes). body_cbor must already be encoded."""
if self.conn is None:
raise ConnectionError("DTLS session closed")
tok = self._next_tok()
mid = self._next_mid()
opts = [(URI_PATH, s.encode()) for s in path_segs]
opts.append((CONTENT_FORMAT, CF_CBOR))
opts.append((ACCEPT, CF_CBOR))
datagram = build_coap(TYPE_CON, METHOD_POST, mid, tok, opts,
body_cbor)
ev = threading.Event()
container = {}
self._pending[tok] = (ev, container)
try:
self._send_dgram(datagram)
if not ev.wait(timeout):
raise TimeoutError(
f"POST /{'/'.join(path_segs)} timeout")
if 'err' in container:
raise ConnectionError(container['err'])
return container['code'], container['payload']
finally:
self._pending.pop(tok, None)
def ping(self):
"""RFC 7252 §4.4 CoAP Ping — empty CON, no token, no payload.
Peer MUST respond with an RST sharing the same Message ID.
We don't block waiting for the RST here; the reader thread
sees it, finds nothing in _pending/_observe_tokens for an
empty token, and drops it quietly — which is the documented
behaviour for unsolicited RST.
Sole purpose is keepalive: gives Samsung's RT-OCF stack a
visible "client still here" signal so it doesn't expire our
OBSERVE subscriptions (symptom of expiry: POSTs still get
2.04 but OBSERVE notifications stop arriving)."""
if self.conn is None:
raise ConnectionError("DTLS session closed")
mid = self._next_mid()
# Empty message: ver=01, type=CON, tkl=0, code=0.00, mid, no
# token, no options, no payload. build_coap with empty token
# and no options yields exactly that.
self._send_dgram(build_coap(TYPE_CON, 0, mid, b'', []))
return mid
def subscribe(self, path_segs):
"""Register an OBSERVE on the given path. The initial 2.05
notification and all subsequent state-change notifications
will fire on_notification(href, payload_bytes).
Returns the token used (in case the caller wants to deregister
later)."""
if self.conn is None:
raise ConnectionError("DTLS session closed")
tok = self._next_observe_tok()
href = '/' + '/'.join(path_segs)
# Register the token BEFORE sending — otherwise the device
# could respond between send() and the dict insert, and the
# reader thread would drop the initial 2.05 as "stale".
self._observe_tokens[tok] = href
mid = self._next_mid()
opts = [(URI_PATH, s.encode()) for s in path_segs]
opts.append((OBSERVE, OBSERVE_REGISTER))
opts.append((ACCEPT, CF_CBOR))
self._send_dgram(
build_coap(TYPE_CON, METHOD_GET, mid, tok, opts))
return tok
@@ -0,0 +1,50 @@
"""Periodic OBSERVE re-subscribe.
CoAP OBSERVE (RFC 7641) has no built-in TTL, but real-world peers age
out observer state on their own schedule — Samsung's RT-OCF is known
to silently drop notify delivery during cloud auth blips even though
the DTLS session stays healthy. Without a re-subscribe, recovery from
such a blip requires a full session reconnect.
This task derregisters the current observer tokens and re-subscribes
every `interval_s`. Cheap (one register CON per path), idempotent
(Samsung silently no-ops a register on an already-active token), and
resilient — individual subscribe failures are logged but don't abort
the task.
"""
from __future__ import annotations
import threading
from typing import Optional
from .coap_dtls import DtlsCoapSession
class ObserveRefreshTask:
def __init__(self,
session: DtlsCoapSession,
paths,
interval_s: float = 6 * 3600.0,
logger=None):
self.session = session
self.paths = [list(p) for p in paths]
self.interval_s = interval_s
self.log = logger
self._refresh_count = 0
@property
def refresh_count(self) -> int:
return self._refresh_count
def run_forever(self, stop: threading.Event) -> None:
while not stop.wait(self.interval_s):
try:
self.session.refresh_observes(self.paths)
self._refresh_count += 1
if self.log:
self.log.info("OBSERVE refresh #%d (%d paths)",
self._refresh_count, len(self.paths))
except Exception as e:
if self.log:
self.log.warning("OBSERVE refresh: %s", e)
@@ -0,0 +1,15 @@
-----BEGIN CERTIFICATE-----
MIICXTCCAgGgAwIBAgIBATAMBggqhkjOPQQDAgUAMGsxKDAmBgNVBAMTH1NhbXN1
bmcgRWxlY3Ryb25pY3MgT0NGIFJvb3QgQ0ExFDASBgNVBAsTC09DRiBSb290IENB
MRwwGgYDVQQKExNTYW1zdW5nIEVsZWN0cm9uaWNzMQswCQYDVQQGEwJLUjAgFw0x
NjExMjQwMjU1MTFaGA8yMDY5MTIzMTE0NTk1OVowazEoMCYGA1UEAxMfU2Ftc3Vu
ZyBFbGVjdHJvbmljcyBPQ0YgUm9vdCBDQTEUMBIGA1UECxMLT0NGIFJvb3QgQ0Ex
HDAaBgNVBAoTE1NhbXN1bmcgRWxlY3Ryb25pY3MxCzAJBgNVBAYTAktSMFkwEwYH
KoZIzj0CAQYIKoZIzj0DAQcDQgAEYsf/Kx+sUFBQESbuytTDPwLPIe0X/8/B1L7b
3abxE9w0gQZAfI8WYUkKfNfP7HXh1M5SCnOkfwWraltGOKTeX6OBkTCBjjAOBgNV
HQ8BAf8EBAMCAcYwMgYDVR0fBCswKTAnoCWgI4YhaHR0cDovL3Byb2RjYS5zYW1z
dW5naW90cy5jb20vY3JsMA8GA1UdEwEB/wQFMAMBAf8wNwYIKwYBBQUHAQEEKzAp
MCcGCCsGAQUFBzABhhtodHRwOi8vb2NzcC5zYW1zdW5naW90cy5jb20wDAYIKoZI
zj0EAwIFAANIADBFAiARY9aSE30q31q5v8B4sJczBqOp7AsD9o8ZIuNmH7IwSwIh
ALfR56jcXoFis/nDx0tQ2xTI/f0b7F6tWPqj2vyKQeNR
-----END CERTIFICATE-----
@@ -0,0 +1,5 @@
from .adapter import flatten, is_active, _key
from .discovery import BoundEntity, discover
from .registry import CAPABILITIES
__all__ = ['CAPABILITIES', 'discover', 'BoundEntity', 'flatten', 'is_active', '_key']
@@ -0,0 +1,33 @@
"""Adapter: BoundEntity list → flat state dict and command dispatch."""
from __future__ import annotations
from typing import Any
from .discovery import BoundEntity
def _key(b: BoundEntity) -> str:
return f"{b.key_override or b.desc.key}{b.instance}"
def flatten(bound: list[BoundEntity], resources: dict) -> dict[str, Any]:
"""Map bound entities to their current scalar values."""
out: dict[str, Any] = {}
for b in bound:
if not b.desc.field:
continue
rep = resources.get(b.href) or {}
if b.desc.exists_fn is not None and not b.desc.exists_fn(rep):
continue
out[_key(b)] = b.desc.value_fn(rep.get(b.desc.field))
return out
def is_active(bound: list[BoundEntity], resources: dict) -> bool:
"""Return True if any capability's active_when fires."""
for b in bound:
if b.capability.active_when:
rep = resources.get(b.href) or {}
if b.capability.active_when(rep):
return True
return False
@@ -0,0 +1,57 @@
"""Per-device-type registries."""
from typing import Optional
from ._base import DeviceRegistry
from . import dishwasher, dryer, oven, refrigerator
__all__ = ['DeviceRegistry', '_type_key', 'for_device']
_REGISTRY_BY_KEY: dict[str, DeviceRegistry] = {
'dishwasher': dishwasher.REGISTRY,
'dryer': dryer.REGISTRY,
'oven': oven.REGISTRY,
'refrigerator': refrigerator.REGISTRY,
}
def _type_key(one_ui_version: str) -> str:
"""Convert oneUiVersion string to registry key.
Args:
one_ui_version: String like '7.0 Dishwasher' or 'Oven'.
Returns:
Lowercase key with version prefix stripped and spaces/hyphens converted to underscores.
Examples:
'7.0 Dishwasher' -> 'dishwasher'
'7.0 French Door Refrigerator' -> 'french_door_refrigerator'
'Oven' -> 'oven'
"""
if ' ' in one_ui_version:
# Strip version prefix: everything before and including the first space
suffix = one_ui_version.split(' ', 1)[-1]
else:
suffix = one_ui_version
return suffix.lower().replace(' ', '_').replace('-', '_')
def for_device(one_ui_version: str) -> Optional[DeviceRegistry]:
"""Return the DeviceRegistry for the given oneUiVersion string, or None if unknown.
Args:
one_ui_version: Device's oneUiVersion string (e.g., '7.0 Dishwasher').
Returns:
DeviceRegistry if a matching registry exists, None otherwise.
"""
key = _type_key(one_ui_version)
if key in _REGISTRY_BY_KEY:
return _REGISTRY_BY_KEY[key]
# Suffix fallback: e.g. "french_door_refrigerator" ends with "_refrigerator"
for rkey, reg in _REGISTRY_BY_KEY.items():
if key.endswith(f'_{rkey}'):
return reg
return None
@@ -0,0 +1,47 @@
"""Base DeviceRegistry dataclass and builder."""
from __future__ import annotations
from dataclasses import dataclass, field
from ..capability import Capability
@dataclass(frozen=True)
class DeviceRegistry:
"""Registry of capabilities for a specific device type."""
name: str
capabilities: dict[str, list[Capability]]
pattern_capabilities: list[Capability] = field(default_factory=list)
def _build(caps: list[Capability]) -> dict[str, list[Capability]]:
"""Build a capabilities dict from a list of Capability objects.
Args:
caps: List of Capability objects to organize by href.
Returns:
A dict mapping href to list of Capability objects sharing that href.
Raises:
ValueError: If any capability has href=None (use pattern_capabilities instead),
or if multiple caps share an href without all having rt_filter or match_fn.
"""
out: dict[str, list[Capability]] = {}
for cap in caps:
if cap.href is None:
raise ValueError(f"Use pattern_capabilities for href=None caps")
if cap.href_prefix is not None:
raise ValueError(
f"href_prefix is only valid for pattern caps (href=None); "
f"cap with href={cap.href!r} must not set href_prefix")
out.setdefault(cap.href, []).append(cap)
# Validate that multi-cap hrefs have proper discrimination
for href, cs in out.items():
if len(cs) > 1 and any(c.rt_filter is None and c.match_fn is None for c in cs):
raise ValueError(
f"href {href!r} has multiple caps but at least one lacks rt_filter and match_fn")
return out
@@ -0,0 +1,20 @@
"""Dishwasher device registry."""
from ..capabilities import common, fridge, laundry, operational
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='dishwasher',
capabilities=_build([
common.POWER,
common.KIDS_LOCK,
common.REMOTE_CONTROL,
common.ALARMS,
common.ENERGY_METER,
common.WATER_METER,
common.WATER_FILTER,
operational.OPERATIONAL_STATE,
laundry.DOOR_LED,
laundry.SOUND_MODE,
fridge.FIRMWARE_UPDATE,
]),
)
@@ -0,0 +1,26 @@
"""Dryer device registry.
Note: dryer devices use port 49155 (not 49154). The config flow handles
this automatically via port probing.
"""
from ..capabilities import common, dryer, fridge, laundry, operational
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='dryer',
capabilities=_build([
common.POWER,
common.KIDS_LOCK,
common.REMOTE_CONTROL,
common.ALARMS,
common.ENERGY_METER,
operational.OPERATIONAL_STATE,
laundry.DOOR_LED,
laundry.SOUND_MODE,
dryer.DRYER_SETTINGS,
dryer.DRYER_COURSE,
dryer.JOB_BEGINNING_STATUS,
dryer.DRYER_DIAGNOSIS,
fridge.FIRMWARE_UPDATE,
]),
)
@@ -0,0 +1,20 @@
"""Oven device registry."""
from ..capabilities import common, fridge, oven
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='oven',
capabilities=_build([
common.POWER,
common.KIDS_LOCK,
common.REMOTE_CONTROL,
common.ALARMS,
oven.OVEN_CAVITY,
oven.OVEN_SETPOINT,
oven.OVEN_MODE,
oven.OVEN_OPERATIONAL_STATE,
oven.OVEN_DOOR,
oven.OVEN_CONNECTED,
fridge.FIRMWARE_UPDATE,
]),
)
@@ -0,0 +1,29 @@
"""Refrigerator device registry."""
from ..capabilities import common, fridge
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='refrigerator',
capabilities=_build([
common.POWER,
common.KIDS_LOCK,
common.REMOTE_CONTROL,
common.ALARMS,
common.ENERGY_METER,
common.WATER_FILTER,
fridge.DOORS_STATUS,
fridge.ICEMAKER_STATUS,
fridge.REFRIGERATION,
fridge.AUTOFILL,
fridge.CABINET_LIGHT,
fridge.SABBATH,
fridge.BEVERAGE_ZONE,
fridge.FIRMWARE_UPDATE,
]),
pattern_capabilities=[
fridge.TEMP_CURRENT_GENERIC,
fridge.TEMP_SETPOINT_GENERIC,
fridge.ICEMAKER_GENERIC,
fridge.DOOR_GENERIC,
],
)
@@ -0,0 +1,19 @@
from . import common, fridge, laundry, operational, oven
from ..capability import Capability
def _is_capability(v):
return isinstance(v, Capability)
# Only OVEN_CAVITY (/oven/vs/0) is safe to include globally — that href
# is oven-unique. OVEN_SETPOINT (/temperatures/vs/0) and OVEN_MODE
# (/mode/vs/0) collide with fridge hrefs that share the same path but
# have different schemas. Those capabilities require rt_filter or class-
# scoped registry support before they can be added back to ALL.
_OVEN_GLOBAL_CAPS = [
oven.OVEN_CAVITY,
]
ALL = [v for mod in (common, operational, laundry, fridge)
for v in vars(mod).values() if _is_capability(v)] + _OVEN_GLOBAL_CAPS
@@ -0,0 +1,126 @@
"""Capabilities shared across multiple appliance families.
Each capability is keyed on the stable OCF resource href (not rt), verified
against live device dumps:
/kidslock/vs/0 -> x.com.samsung.da.kidsLock
/remotectrl/vs/0 -> x.com.samsung.da.remoteControlEnabled
/power/vs/0 -> x.com.samsung.da.power
/alarms/vs/0 -> x.com.samsung.da.items
/energy/consumption/vs/0 -> x.com.samsung.da.instantaneousPower / cumulativePower
/water/consumption/vs/0 -> x.com.samsung.da.cumulativeWater
/filter/waterfilter/vs/0 -> x.com.samsung.da.filterUsage / filterStatus
"""
from ..capability import Capability
from ..entities import BinarySensorDesc, SensorDesc, SwitchDesc
def _num(v):
try:
return float(v)
except (TypeError, ValueError):
return None
def _clamp_power(v):
n = _num(v)
return 0.0 if (n is not None and n < 0) else n
def _wh_to_kwh(v):
n = _num(v)
return round(n / 1000.0, 2) if n is not None else None
def _ml_to_l(v):
n = _num(v)
return round(n / 1000.0, 1) if n is not None else None
def _last_alarm_code(items):
if items and isinstance(items, list):
return items[-1].get('x.com.samsung.da.code')
return None
KIDS_LOCK = Capability(
href='/kidslock/vs/0',
entities=(
BinarySensorDesc(key='child_lock', field='x.com.samsung.da.kidsLock',
name='Child lock', device_class='lock',
value_fn=lambda v: v != 'Ready'),
BinarySensorDesc(key='child_lock_binary', field='x.com.samsung.da.kidsLock',
name='Child lock (binary)', device_class='lock',
value_fn=lambda v: v != 'Ready'),
),
)
REMOTE_CONTROL = Capability(
href='/remotectrl/vs/0',
entities=(
BinarySensorDesc(key='remote_control',
field='x.com.samsung.da.remoteControlEnabled',
name='Remote control', device_class='connectivity',
value_fn=lambda v: str(v).lower() == 'true'),
BinarySensorDesc(key='remote_control_binary',
field='x.com.samsung.da.remoteControlEnabled',
name='Remote control (binary)', device_class='connectivity',
value_fn=lambda v: str(v).lower() == 'true'),
),
)
POWER = Capability(
href='/power/vs/0',
entities=(
SwitchDesc(key='power_switch', field='x.com.samsung.da.power',
name='Power',
value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: (['power', 'vs', '0'], {'x.com.samsung.da.power': 'On' if p == 'On' else 'Off'})),
SensorDesc(key='power_state', field='x.com.samsung.da.power',
name='Power state'),
BinarySensorDesc(key='power_state_binary', field='x.com.samsung.da.power',
name='Power state (binary)',
value_fn=lambda v: v == 'On'),
),
)
ALARMS = Capability(
href='/alarms/vs/0',
entities=(
SensorDesc(key='alarm_code', field='x.com.samsung.da.items',
name='Alarm code', icon='mdi:alert',
entity_category='diagnostic', value_fn=_last_alarm_code),
),
)
ENERGY_METER = Capability(
href='/energy/consumption/vs/0',
entities=(
SensorDesc(key='power_watts', field='x.com.samsung.da.instantaneousPower',
name='Power', device_class='power', state_class='measurement',
unit='W', value_fn=_clamp_power),
SensorDesc(key='energy_kwh', field='x.com.samsung.da.cumulativePower',
name='Energy', device_class='energy',
state_class='total_increasing', unit='kWh', value_fn=_wh_to_kwh),
),
)
WATER_METER = Capability(
href='/water/consumption/vs/0',
entities=(
SensorDesc(key='water_liters', field='x.com.samsung.da.cumulativeWater',
name='Water consumption', device_class='water',
state_class='total_increasing', unit='L', icon='mdi:water',
value_fn=_ml_to_l),
),
)
WATER_FILTER = Capability(
href='/filter/waterfilter/vs/0',
entities=(
SensorDesc(key='filter_usage', field='x.com.samsung.da.filterUsage',
name='Filter usage', unit='%', state_class='measurement',
icon='mdi:filter'),
SensorDesc(key='filter_status', field='x.com.samsung.da.filterStatus',
name='Filter status', icon='mdi:filter-check'),
),
)
@@ -0,0 +1,102 @@
"""Capabilities specific to the dryer family (Samsung DV5000T-class).
Resources derived from the old dryer.py descriptor OBSERVE_PATHS and
flatten() implementation:
/washer/vs/0 -> DRYER_SETTINGS (dryLevel, dryTime, dryerType, wrinklePrevent)
/st/dryercourse/vs/0 -> DRYER_COURSE (dryer_mode SelectDesc)
/wm/jobbeginingstatus/vs/0 -> JOB_BEGINNING_STATUS
/diagnosis/vs/0 -> DRYER_DIAGNOSIS
Course table captured 2026-05-29 on a DA_WM_TP2_20_COMMON_DV5000T. Other
dryer models may use a different course table; options=() means HA renders
whatever the device reports, and the write_fn validates against this table.
"""
from ..capability import Capability
from ..entities import SelectDesc, SensorDesc, SwitchDesc
# Course table: hex codes -> human names (Table_03, DV5000T-class).
_COURSE_NAMES = {
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 code, name in _COURSE_NAMES.items()}
def _wrinkle_write(p, rep, href=None):
if p not in ('On', 'Off'):
return None
return ['washer', 'vs', '0'], {'x.com.samsung.da.wrinklePrevent': p}
def _course_write(p, rep, href=None):
"""Encode a human course name to the Samsung hex-encoded course string."""
code = _COURSE_CODE_BY_NAME.get(p)
if code is None:
return None
return ['st', 'dryercourse', 'vs', '0'], {
'x.com.samsung.da.st.dryerMode': f'Course_{code:02X}',
}
# ---------------------------------------------------------------------------
# Capabilities
# ---------------------------------------------------------------------------
DRYER_SETTINGS = Capability(
href='/washer/vs/0',
poll_tier='warm',
entities=(
SensorDesc(key='dry_level', field='x.com.samsung.da.dryLevel',
name='Dry level', icon='mdi:water-percent'),
SensorDesc(key='dry_time', field='x.com.samsung.da.dryTime',
name='Dry time', icon='mdi:timer'),
SensorDesc(key='dryer_type', field='x.com.samsung.da.dryerType',
name='Dryer type', icon='mdi:tumble-dryer'),
SwitchDesc(key='wrinkle_prevent', field='x.com.samsung.da.wrinklePrevent',
name='Wrinkle prevent', icon='mdi:iron',
value_fn=lambda v: v == 'On',
write_fn=_wrinkle_write),
),
)
DRYER_COURSE = Capability(
href='/st/dryercourse/vs/0',
poll_tier='warm',
entities=(
SelectDesc(key='dryer_mode', field='x.com.samsung.da.st.dryerMode',
name='Dryer mode', icon='mdi:tumble-dryer',
options=(), write_fn=_course_write),
),
)
JOB_BEGINNING_STATUS = Capability(
href='/wm/jobbeginingstatus/vs/0',
poll_tier='warm',
entities=(
SensorDesc(key='job_beginning_status',
field='x.com.samsung.da.jobBeginingStatus',
name='Job beginning status',
entity_category='diagnostic'),
),
)
DRYER_DIAGNOSIS = Capability(
href='/diagnosis/vs/0',
poll_tier='warm',
entities=(
SensorDesc(key='diagnosis', field='x.com.samsung.da.diagnosisStart',
name='Diagnosis', entity_category='diagnostic'),
),
)
@@ -0,0 +1,278 @@
"""Capabilities for the refrigerator family (Samsung RF9000B-class).
Resources verified against the dump at local-tools/dumps/10.0.0.254.json.
Temperature fields in all /temperature/* resources are in Fahrenheit on this
model. Setpoints are NumberDesc with direct-write write_fn — generic caps
derive the CoAP PUT path from href at write time.
Multi-instance note: the two door resources (/door/cooler/0 and
/door/freezer/0) and the two ice-maker resources (/icemaker/one/vs/0 and
/icemaker/two/vs/0) use named path segments, so they are modelled via
pattern capabilities that auto-derive distinct entity keys from href segments.
"""
from ..capability import Capability
from ..entities import (
BinarySensorDesc, NumberDesc, SelectDesc, SensorDesc, SwitchDesc,
)
# Beverage zone flex modes.
BZONE_MODES = ('SP_TTYPE_BEER_DRINKS', 'SP_TTYPE_WINE_DESSERT')
def _int(v):
try:
return int(v)
except (TypeError, ValueError):
return None
# ---------------------------------------------------------------------------
# Temperature (generic — covers /temperature/current/* and /temperature/desired/*)
# ---------------------------------------------------------------------------
TEMP_CURRENT_GENERIC = Capability(
href=None,
href_prefix='/temperature/current/',
poll_tier='warm',
entities=(
SensorDesc(key='temp_f', field='temperature',
name=None, icon='mdi:thermometer'),
),
)
TEMP_SETPOINT_GENERIC = Capability(
href=None,
href_prefix='/temperature/desired/',
poll_tier='warm',
entities=(
NumberDesc(key='temp_f', field='temperature',
name=None, native_min=-20.0, native_max=50.0,
write_fn=lambda p, rep, href=None: (
[s for s in href.strip('/').split('/') if s],
{'temperature': int(round(float(p)))}
) if href else None),
),
)
# ---------------------------------------------------------------------------
# Icemaker (generic — covers /icemaker/one/vs/0, /icemaker/two/vs/0)
# /icemaker/status/vs/0 is kept as exact-href cap and binds first.
# /icemaker/nighttime/vs/0 is excluded by match_fn (lacks iceMaker.state).
# ---------------------------------------------------------------------------
def _icemaker_write(field):
return lambda p, rep, href=None: (
[s for s in href.strip('/').split('/') if s],
{field: p}
) if href else None
ICEMAKER_GENERIC = Capability(
href=None,
href_prefix='/icemaker/',
match_fn=lambda rep, resources: 'x.com.samsung.da.iceMaker.state' in rep,
poll_tier='warm',
entities=(
SensorDesc(key='state', field='x.com.samsung.da.iceMaker.state',
name=None, icon='mdi:cube-outline'),
BinarySensorDesc(key='on', field='x.com.samsung.da.iceMaker.state',
name=None, device_class='running',
value_fn=lambda v: v == 'On'),
SensorDesc(key='making_status',
field='x.com.samsung.da.iceMaker.iceMakingStatus',
name=None, icon='mdi:cube-outline'),
SwitchDesc(key='enabled', field='x.com.samsung.da.iceMaker.state',
name=None, icon='mdi:cube-outline',
value_fn=lambda v: v == 'On',
write_fn=_icemaker_write('x.com.samsung.da.iceMaker.state')),
SelectDesc(key='type', field='x.com.samsung.da.iceType.desired',
name=None, icon='mdi:cube-outline',
options=(),
write_fn=_icemaker_write('x.com.samsung.da.iceType.desired')),
),
)
# ---------------------------------------------------------------------------
# Doors
# ---------------------------------------------------------------------------
DOORS_STATUS = Capability(
href='/doors/vs/0',
poll_tier='hot',
entities=(
BinarySensorDesc(
key='any_door_open',
field='x.com.samsung.da.items',
name='Any door open',
device_class='door',
value_fn=lambda items: any(
it.get('x.com.samsung.da.openState') == 'Open'
for it in (items or [])
),
),
),
)
# ---------------------------------------------------------------------------
# Ice maker status (exact-href — binds before ICEMAKER_GENERIC pattern)
# ---------------------------------------------------------------------------
ICEMAKER_STATUS = Capability(
href='/icemaker/status/vs/0',
poll_tier='warm',
entities=(
SensorDesc(key='ice_maker_enabled', field='x.com.samsung.da.iceMaker',
name='Ice maker enabled', icon='mdi:snowflake'),
),
)
# ---------------------------------------------------------------------------
# Refrigeration modes (rapid cooling)
# ---------------------------------------------------------------------------
def _refrigeration_write(field_name):
def _write(p, rep, href=None):
if p not in ('On', 'Off'):
return None
return ['refrigeration', 'vs', '0'], {field_name: p}
return _write
REFRIGERATION = Capability(
href='/refrigeration/vs/0',
poll_tier='warm',
entities=(
SwitchDesc(key='rapid_fridge', field='x.com.samsung.da.rapidFridge',
name='Rapid fridge', icon='mdi:fridge-industrial',
value_fn=lambda v: v == 'On',
write_fn=_refrigeration_write('x.com.samsung.da.rapidFridge')),
SwitchDesc(key='rapid_freezing', field='x.com.samsung.da.rapidFreezing',
name='Rapid freezing', icon='mdi:snowflake',
value_fn=lambda v: v == 'On',
write_fn=_refrigeration_write('x.com.samsung.da.rapidFreezing')),
),
)
# ---------------------------------------------------------------------------
# Autofill
# ---------------------------------------------------------------------------
def _autofill_write(p, rep, href=None):
if p not in ('On', 'Off'):
return None
return ['autofill', 'vs', '0'], {'x.com.samsung.da.autofill': p}
AUTOFILL = Capability(
href='/autofill/vs/0',
poll_tier='warm',
entities=(
SwitchDesc(key='autofill', field='x.com.samsung.da.autofill',
name='Autofill', icon='mdi:cup-water',
value_fn=lambda v: v == 'On',
write_fn=_autofill_write),
),
)
# ---------------------------------------------------------------------------
# Cabinet light
# ---------------------------------------------------------------------------
def _cabinet_light_write(p, rep, href=None):
if p not in ('On', 'Off'):
return None
return ['cabinet', 'light', 'total', 'vs', '0'], {
'x.com.samsung.da.lightControl': p,
}
CABINET_LIGHT = Capability(
href='/cabinet/light/total/vs/0',
poll_tier='warm',
entities=(
SensorDesc(key='cabinet_light', field='x.com.samsung.da.lightControl',
name='Cabinet light level', icon='mdi:fridge-outline'),
BinarySensorDesc(key='cabinet_light_on', field='x.com.samsung.da.lightControl',
name='Cabinet light on', icon='mdi:fridge-outline',
value_fn=lambda v: v == 'On'),
SwitchDesc(key='cabinet_light_switch', field='x.com.samsung.da.lightControl',
name='Cabinet light', icon='mdi:fridge-outline',
value_fn=lambda v: v == 'On',
write_fn=_cabinet_light_write),
),
)
# ---------------------------------------------------------------------------
# Sabbath mode
# ---------------------------------------------------------------------------
def _sabbath_write(p, rep, href=None):
if p not in ('On', 'Off'):
return None
return ['sabbath', 'vs', '0'], {'x.com.samsung.da.sabbathMode': p}
SABBATH = Capability(
href='/sabbath/vs/0',
poll_tier='warm',
entities=(
SwitchDesc(key='sabbath_mode', field='x.com.samsung.da.sabbathMode',
name='Sabbath mode', icon='mdi:star-david',
value_fn=lambda v: v == 'On',
write_fn=_sabbath_write),
),
)
# ---------------------------------------------------------------------------
# Beverage zone
# ---------------------------------------------------------------------------
def _bzone_write(p, rep, href=None):
if p not in BZONE_MODES:
return None
return ['specialzone', 'one', 'vs', '0'], {'roomDesiredMode': p}
BEVERAGE_ZONE = Capability(
href='/specialzone/one/vs/0',
poll_tier='warm',
entities=(
SelectDesc(key='beverage_zone_mode', field='roomDesiredMode',
name='Beverage zone mode', icon='mdi:glass-wine',
options=BZONE_MODES, write_fn=_bzone_write),
),
)
# ---------------------------------------------------------------------------
# Firmware update
# ---------------------------------------------------------------------------
FIRMWARE_UPDATE = Capability(
href='/otninformation/vs/0',
poll_tier='cold',
entities=(
BinarySensorDesc(
key='firmware_update',
field='x.com.samsung.da.newVersionAvailable',
name='Firmware update available',
device_class='update',
entity_category='diagnostic',
value_fn=lambda v: str(v).lower() == 'true' if v is not None else None,
),
),
)
# ---------------------------------------------------------------------------
# Generic door pattern capability (href=None — use as pattern_cap only)
# ---------------------------------------------------------------------------
DOOR_GENERIC = Capability(
href=None,
href_prefix='/door/',
poll_tier='hot',
entities=(
BinarySensorDesc(key='open', field='openState',
name=None, device_class='door',
value_fn=lambda v: v == 'Open'),
),
)
@@ -0,0 +1,52 @@
"""Capabilities specific to the laundry-family appliances (dishwasher, dryer).
Resources verified against the DW9000F-class dump at 10.0.0.129.
Note: door-LED keys use NO `x.com.samsung.da.` prefix — `setBrightness` and
`setNightLight` — preserved exactly as they appear in the OCF resource rep.
"""
from ..capability import Capability
from ..entities import SelectDesc, SwitchDesc
_LED_LEVELS = ('Low', 'High')
_SOUND_MODES = ('voice', 'tone', 'mute')
def _led_brightness_write(p, rep, href=None):
if p not in _LED_LEVELS:
return None
return ['doorled', 'light', 'vs', '0'], {'setBrightness': p}
def _led_night_write(p, rep, href=None):
if p not in ('On', 'Off'):
return None
return ['doorled', 'light', 'vs', '0'], {'setNightLight': p}
def _sound_mode_write(p, rep, href=None):
if p not in _SOUND_MODES:
return None
return ['settings', 'sound', 'mode', 'vs', '0'], {'mode': p}
DOOR_LED = Capability(
href='/doorled/light/vs/0',
entities=(
SelectDesc(key='led_brightness', field='setBrightness',
name='Door LED brightness', icon='mdi:brightness-6',
options=_LED_LEVELS, write_fn=_led_brightness_write),
SwitchDesc(key='led_night_light', field='setNightLight',
name='Door LED night light', icon='mdi:weather-night',
value_fn=lambda v: v == 'On',
write_fn=_led_night_write),
),
)
SOUND_MODE = Capability(
href='/settings/sound/mode/vs/0',
entities=(
SelectDesc(key='sound_mode', field='mode',
name='Sound mode', icon='mdi:volume-high',
options=_SOUND_MODES, write_fn=_sound_mode_write),
),
)
@@ -0,0 +1,117 @@
"""Operational-state capability: machine state, progress, remaining-time.
Carries the rare cross-field hooks (active_when, on_observation, project) for
remaining-time extrapolation. Shared by dryer/dishwasher/oven/washer families.
"""
import time
from ..capability import Capability
from ..entities import BinarySensorDesc, ButtonDesc, SensorDesc
_SAMSUNG_STATE_TO_OCF = {
'Ready': 'idle', 'Run': 'active', 'Running': 'active',
'Pause': 'pause', 'Paused': 'pause', 'End': 'idle', 'Stop': 'idle',
}
def _to_ocf(v):
return _SAMSUNG_STATE_TO_OCF.get(v, v) if v is not None else None
def _progress(v):
return 'Idle' if v in (None, 'None') else v
def _int(v):
try:
return int(v)
except (TypeError, ValueError):
return None
def _active_when(rep):
return _SAMSUNG_STATE_TO_OCF.get(rep.get('x.com.samsung.da.state')) == 'active'
def _on_observation(state, rep):
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 _rem_minutes(remaining):
if not remaining:
return None
try:
h, m, s = remaining.split(':')
return int(h) * 60 + int(m) + (1 if int(s) > 0 else 0)
except Exception:
return None
OPERATIONAL_STATE = Capability(
href='/operational/state/vs/0',
poll_tier='hot',
entities=(
SensorDesc(key='machine_state', field='x.com.samsung.da.state',
name='Machine state', value_fn=_to_ocf),
# cycle_active is a bool derived from machine_state; used by the
# adapter to gate oven writes (cycle_active_field='cycle_active').
# Harmless for non-oven appliances — just an extra bool in state.
BinarySensorDesc(key='cycle_active', field='x.com.samsung.da.state',
name='Cycle active', device_class='running',
value_fn=lambda v: _SAMSUNG_STATE_TO_OCF.get(v) == 'active'),
SensorDesc(key='progress', field='x.com.samsung.da.progress',
name='Progress', icon='mdi:progress-wrench', value_fn=_progress),
SensorDesc(key='progress_percentage',
field='x.com.samsung.da.progressPercentage',
name='Progress percent', unit='%', state_class='measurement',
value_fn=_int),
SensorDesc(key='completion_time', field='x.com.samsung.da.remainingTime',
name='Completion time', icon='mdi:timer-sand'),
SensorDesc(key='completion_minutes', field='x.com.samsung.da.remainingTime',
name='Remaining minutes', unit='min', device_class='duration',
state_class='measurement',
value_fn=lambda r: _rem_minutes(r)),
SensorDesc(key='delay_start_time', field='x.com.samsung.da.delayStartTime',
name='Delay start time', icon='mdi:timer-pause'),
ButtonDesc(key='start', field='', name='Start cycle', payload='Run',
icon='mdi:play',
write_fn=lambda p, rep, href=None: (
['operational', 'state', 'vs', '0'],
{'x.com.samsung.da.state': p})),
ButtonDesc(key='pause', field='', name='Pause cycle', payload='Pause',
icon='mdi:pause',
write_fn=lambda p, rep, href=None: (
['operational', 'state', 'vs', '0'],
{'x.com.samsung.da.state': p})),
ButtonDesc(key='stop', field='', name='Stop cycle', payload='Ready',
icon='mdi:stop',
write_fn=lambda p, rep, href=None: (
['operational', 'state', 'vs', '0'],
{'x.com.samsung.da.state': p})),
),
active_when=_active_when,
on_observation=_on_observation,
project=_project,
)
@@ -0,0 +1,371 @@
"""Capabilities for the oven family (Samsung NV7000BS-class).
Resources verified against the live device via DTLS-CoAP.
See `local-tools/comparisons/oven-tree.md` for the full field reference.
Write surfaces this module exposes:
proven:
* Lamp 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, NaturalSteam — same RMW pattern as lamp.
* Setpoint via /temperatures/vs/0 items RMW.
* Cook time via /operational/state/vs/0 operationTime/remainingTime.
* Mode select via /mode/vs/0 .modes — mid-cook acceptance unknown.
* Stop via /operational/state/vs/0 state='Ready'.
Note: Cycle start is not implemented. Reverse-engineering shows local-OCF
cycle start is not reproducible on this firmware (see project_oven_remote
_start_open.md). Mode writes are also unreliable — the oven rolls them back
once a cycle is active. OVEN_MODE is provided as a SelectDesc for fidelity
but is effectively read-only in practice.
"""
import time
from ..capability import Capability
from ..entities import (
BinarySensorDesc, ButtonDesc, NumberDesc, SelectDesc, SensorDesc,
SwitchDesc,
)
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
SETPOINT_MIN_C = 30
SETPOINT_MAX_C = 270
SETPOINT_STEP_C = 5
# Mode options seen on NV7000BS-class. No dump exists so this list is inferred
# from Samsung documentation and firmware observations. The firmware will
# reject unknown modes; missing entries here are a coverage gap, not a bug.
_OVEN_MODES = (
'NoOperation',
'Bake',
'Broil',
'Convection',
'ConvectionBake',
'ConvectionBroil',
'FrozenPizzaPlus',
'SlowCook',
'PlateWarm',
'AirFry',
)
_SAMSUNG_STATE_TO_OCF = {
'Ready': 'idle',
'Run': 'active',
'Running': 'active',
'Pause': 'pause',
'Paused': 'pause',
'End': 'idle',
'Stop': 'idle',
}
def _to_ocf(v):
return _SAMSUNG_STATE_TO_OCF.get(v, v) if v is not None else None
def _int(v):
try:
return int(v)
except (TypeError, ValueError):
return None
def _rem_minutes(remaining):
if not remaining:
return None
try:
h, m, s = remaining.split(':')
return int(h) * 60 + int(m) + (1 if int(s) > 0 else 0)
except Exception:
return None
def _op_minutes(op_time):
"""Parse 'H:MM:SS' operationTime into integer minutes."""
if not op_time:
return None
try:
h, m, s = op_time.split(':')
return int(h) * 60 + int(m) + (1 if int(s) > 0 else 0)
except Exception:
return None
# ---------------------------------------------------------------------------
# Options-array helpers (shared by lamp, sound, fastpreheat, naturalsteam)
# ---------------------------------------------------------------------------
def _option_value(options, prefix):
"""Find `<prefix>_<value>` in an options array and return <value>."""
for o in (options or []):
if isinstance(o, str) and o.startswith(prefix + '_'):
return o.split('_', 1)[1]
return None
def _replace_in_options(options, prefix, new_value):
"""Return a new options list with the `<prefix>_*` slot replaced."""
return [f"{prefix}_{new_value}" if o.startswith(prefix + '_') else o
for o in options]
# ---------------------------------------------------------------------------
# Write functions
# ---------------------------------------------------------------------------
def _oven_setpoint_write(p, rep, href=None):
"""RMW write to /temperatures/vs/0 items array."""
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 = rep.get('x.com.samsung.da.items')
if not items:
return None
items = [dict(it) for it in items]
items[0]['x.com.samsung.da.desired'] = str(temp_i)
return ['temperatures', 'vs', '0'], {'x.com.samsung.da.items': items}
def _cook_time_write(p, rep, href=None):
"""Write operationTime + remainingTime (H:MM:SS) from minutes."""
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,
}
def _stop_write(p, rep, href=None):
return ['operational', 'state', 'vs', '0'], {
'x.com.samsung.da.state': 'Ready',
}
def _oven_mode_write(p, rep, href=None):
if p not in _OVEN_MODES:
return None
return ['mode', 'vs', '0'], {'x.com.samsung.da.modes': [p]}
def _lamp_write(p, rep, href=None):
if p not in ('On', 'Off'):
return None
opts = list(rep.get('x.com.samsung.da.options') or [])
if not opts:
return None
return ['mode', 'vs', '0'], {
'x.com.samsung.da.options': _replace_in_options(opts, 'UpperLamp', p),
}
def _sound_write(p, rep, href=None):
if p not in ('On', 'Off'):
return None
opts = list(rep.get('x.com.samsung.da.options') or [])
if not opts:
return None
return ['mode', 'vs', '0'], {
'x.com.samsung.da.options': _replace_in_options(opts, 'Sound', p),
}
def _fastpreheat_write(p, rep, href=None):
if p not in ('On', 'Off'):
return None
opts = list(rep.get('x.com.samsung.da.options') or [])
if not opts:
return None
return ['mode', 'vs', '0'], {
'x.com.samsung.da.options': _replace_in_options(opts, 'fastpreheat', p),
}
def _naturalsteam_write(p, rep, href=None):
if p not in ('On', 'Off'):
return None
opts = list(rep.get('x.com.samsung.da.options') or [])
if not opts:
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}
# ---------------------------------------------------------------------------
# on_observation / project for remaining-time extrapolation
# ---------------------------------------------------------------------------
def _active_when(rep):
return _SAMSUNG_STATE_TO_OCF.get(rep.get('x.com.samsung.da.state')) == 'active'
def _on_observation(state, rep):
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
# ---------------------------------------------------------------------------
# Capabilities
# ---------------------------------------------------------------------------
OVEN_OPERATIONAL_STATE = Capability(
href='/operational/state/vs/0',
poll_tier='hot',
active_when=_active_when,
on_observation=_on_observation,
project=_project,
entities=(
SensorDesc(key='machine_state', field='x.com.samsung.da.state',
name='Machine state', icon='mdi:stove', value_fn=_to_ocf),
BinarySensorDesc(key='cycle_active', field='x.com.samsung.da.state',
name='Cycle active', device_class='running',
value_fn=lambda v: _SAMSUNG_STATE_TO_OCF.get(v) == 'active'),
SensorDesc(key='progress_percentage',
field='x.com.samsung.da.progressPercentage',
name='Progress percent', unit='%', state_class='measurement',
value_fn=_int),
SensorDesc(key='operation_time', field='x.com.samsung.da.operationTime',
name='Elapsed time', icon='mdi:timer'),
SensorDesc(key='operation_time_minutes',
field='x.com.samsung.da.operationTime',
name='Operation time (minutes)', unit='min',
state_class='measurement', value_fn=_op_minutes),
SensorDesc(key='completion_time', field='x.com.samsung.da.remainingTime',
name='Completion time', icon='mdi:timer-sand'),
SensorDesc(key='completion_minutes', field='x.com.samsung.da.remainingTime',
name='Remaining minutes', unit='min', device_class='duration',
state_class='measurement', value_fn=_rem_minutes),
NumberDesc(key='cook_time', field='x.com.samsung.da.operationTime',
name='Cook time', unit='min', native_min=0, native_max=1439,
step=1.0, icon='mdi:timer', value_fn=_op_minutes,
write_fn=_cook_time_write),
ButtonDesc(key='stop', field='', name='Stop cycle', icon='mdi:stop',
payload='Stop', write_fn=_stop_write),
),
)
OVEN_CAVITY = Capability(
href='/oven/vs/0',
poll_tier='hot',
entities=(
SensorDesc(key='oven_state', field='x.com.samsung.da.state',
name='Cavity state'),
),
)
OVEN_SETPOINT = Capability(
href='/temperatures/vs/0',
poll_tier='hot',
entities=(
# NumberDesc first — test_oven_setpoint_write_is_read_modify_write uses entities[0]
NumberDesc(key='oven_setpoint', field='x.com.samsung.da.items',
name='Setpoint', device_class='temperature', unit='°C',
native_min=float(SETPOINT_MIN_C), native_max=float(SETPOINT_MAX_C),
step=float(SETPOINT_STEP_C), icon='mdi:thermometer-chevron-up',
value_fn=lambda items: _int(
(items[0].get('x.com.samsung.da.desired') if items else None)),
write_fn=_oven_setpoint_write),
SensorDesc(key='current_temp_c', field='x.com.samsung.da.items',
name='Temperature', device_class='temperature',
state_class='measurement', unit='°C',
value_fn=lambda items: _int(
(items[0].get('x.com.samsung.da.current') if items else None))),
),
)
OVEN_DOOR = Capability(
href='/doors/vs/0',
poll_tier='hot',
entities=(
SensorDesc(key='door', field='x.com.samsung.da.items',
name='Door state',
value_fn=lambda items: (
items[0].get('x.com.samsung.da.openState')
if items else None)),
BinarySensorDesc(key='door_open', field='x.com.samsung.da.items',
name='Door', device_class='door',
value_fn=lambda items: (
items[0].get('x.com.samsung.da.openState') == 'Open'
if items else None)),
),
)
OVEN_CONNECTED = Capability(
href='/connected/vs/0',
poll_tier='warm',
entities=(
BinarySensorDesc(key='cloud_connected', field='x.com.samsung.da.connected',
name='Cloud connected', device_class='connectivity',
entity_category='diagnostic',
value_fn=lambda v: v == 'On'),
),
)
OVEN_MODE = Capability(
href='/mode/vs/0',
poll_tier='warm',
entities=(
# SelectDesc first — test_oven_mode_options_nonempty uses entities[0]
SelectDesc(key='oven_mode', field='x.com.samsung.da.modes',
name='Cooking mode', icon='mdi:tune',
options=_OVEN_MODES,
value_fn=lambda v: v[0] if v else None,
write_fn=_oven_mode_write),
SwitchDesc(key='lamp', field='x.com.samsung.da.options',
name='Lamp', icon='mdi:track-light',
value_fn=lambda opts: _option_value(opts, 'UpperLamp') == 'On',
write_fn=_lamp_write),
SwitchDesc(key='sound', field='x.com.samsung.da.options',
name='Sound', icon='mdi:volume-high',
value_fn=lambda opts: _option_value(opts, 'Sound') == 'On',
write_fn=_sound_write),
SwitchDesc(key='fast_preheat', field='x.com.samsung.da.options',
name='Fast preheat', icon='mdi:fire',
value_fn=lambda opts: _option_value(opts, 'fastpreheat') == 'On',
write_fn=_fastpreheat_write),
SwitchDesc(key='natural_steam', field='x.com.samsung.da.options',
name='Natural steam', icon='mdi:kettle-steam',
value_fn=lambda opts: _option_value(opts, 'NaturalSteam') == 'On',
write_fn=_naturalsteam_write),
),
)
@@ -0,0 +1,21 @@
"""A Capability binds one OCF resource href to the entities it produces."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Callable, Optional
from .entities import SamsungEntityDescription
@dataclass(frozen=True, kw_only=True)
class Capability:
href: Optional[str] = None
entities: tuple[SamsungEntityDescription, ...] = ()
poll_tier: str = 'warm' # 'hot' | 'warm' | 'cold'
rt_filter: Optional[str] = None # bind only if rt_filter in rep.get('rt', ())
href_prefix: Optional[str] = None # pattern caps only: bind only if href starts with this
match_fn: Optional[Callable[[dict, dict], bool]] = None # match_fn(rep, resources) -> bool
# Rare optional hooks — only operational-state-style resources use these.
active_when: Optional[Callable[[dict], bool]] = None
on_observation: Optional[Callable[[dict, dict], None]] = None
project: Optional[Callable[[dict, dict], dict]] = None
@@ -0,0 +1,83 @@
"""Runtime discovery: device resources -> bound entities.
A device advertises a set of OCF resources keyed by href. For every href
present in the registry, emit the capability's entities bound to that resource.
Unknown hrefs are a coverage gap, logged at debug and skipped.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Callable, Iterable, Optional
from .capability import Capability
from .entities import SamsungEntityDescription
@dataclass
class BoundEntity:
href: str
capability: Capability
desc: SamsungEntityDescription
instance: str = ''
key_override: Optional[str] = None
def instance_suffix(href: str) -> str:
"""'' for the index-0 instance, else '_<n>' from the trailing segment."""
tail = href.rstrip('/').rsplit('/', 1)[-1]
if tail.isdigit() and tail != '0':
return f'_{tail}'
return ''
def discover(
resources: dict[str, dict],
registry: dict[str, list[Capability]],
pattern_caps: Iterable[Capability] = (),
log: Optional[Callable[[str], None]] = None,
) -> list[BoundEntity]:
out: list[BoundEntity] = []
for href, rep in resources.items():
if not isinstance(rep, dict):
continue
rts = rep.get('rt') or ()
caps = registry.get(href) or []
matched = False
for cap in caps:
if cap.rt_filter is not None and cap.rt_filter not in rts:
continue
if cap.match_fn is not None and not cap.match_fn(rep, resources):
continue
inst = instance_suffix(href)
for desc in cap.entities:
out.append(BoundEntity(href=href, capability=cap,
desc=desc, instance=inst))
matched = True
if matched:
continue
# Pattern cap fallback — first matching pattern wins
for cap in pattern_caps:
if cap.href_prefix and not href.startswith(cap.href_prefix):
continue
if cap.rt_filter is not None and cap.rt_filter not in rts:
continue
if cap.match_fn is not None and not cap.match_fn(rep, resources):
continue
inst = instance_suffix(href)
# Auto-derive key prefix from href: strip digits, join non-empty segs with '_'
segs = [s for s in href.strip('/').split('/') if s and not s.isdigit() and s != 'vs']
for desc in cap.entities:
key_override = '_'.join(segs) + '_' + desc.key
out.append(BoundEntity(href=href, capability=cap, desc=desc,
instance=inst, key_override=key_override))
matched = True
break
if not matched and log is not None:
log(f"unknown resource {href}")
return out
@@ -0,0 +1,79 @@
"""HA-shaped entity descriptions. The subclass *type* selects the HA platform.
Frozen dataclasses so the future native HA component can consume them as
EntityDescription subclasses unchanged. Read transforms live in value_fn;
presence gating in exists_fn; write logic in write_fn on command platforms.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Callable, Optional
WriteFn = Optional[Callable[[Any, dict], "tuple[list[str], dict] | None"]]
def _identity(v: Any) -> Any:
return v
@dataclass(frozen=True, kw_only=True)
class SamsungEntityDescription:
key: str
field: str = ''
name: Optional[str] = None
translation_key: Optional[str] = None
icon: Optional[str] = None
entity_category: Optional[str] = None # 'diagnostic' | 'config' | None
enabled_default: bool = True
value_fn: Callable[[Any], Any] = _identity
exists_fn: Optional[Callable[[dict], bool]] = None
@dataclass(frozen=True, kw_only=True)
class SensorDesc(SamsungEntityDescription):
device_class: Optional[str] = None
state_class: Optional[str] = None
unit: Optional[str] = None
@dataclass(frozen=True, kw_only=True)
class BinarySensorDesc(SamsungEntityDescription):
device_class: Optional[str] = None # value_fn must return bool
@dataclass(frozen=True, kw_only=True)
class SelectDesc(SamsungEntityDescription):
options: Any = () # tuple[str,...] | Callable[[dict], list[str]]
write_fn: WriteFn = None
@dataclass(frozen=True, kw_only=True)
class SwitchDesc(SamsungEntityDescription):
device_class: Optional[str] = None
write_fn: WriteFn = None
@dataclass(frozen=True, kw_only=True)
class ButtonDesc(SamsungEntityDescription):
payload: str = ''
write_fn: WriteFn = None
@dataclass(frozen=True, kw_only=True)
class NumberDesc(SamsungEntityDescription):
device_class: Optional[str] = None
unit: Optional[str] = None
native_min: Optional[float] = None
native_max: Optional[float] = None
step: Optional[float] = None
write_fn: WriteFn = None
PLATFORM_OF: dict[type, str] = {
SensorDesc: 'sensor',
BinarySensorDesc: 'binary_sensor',
SelectDesc: 'select',
SwitchDesc: 'switch',
ButtonDesc: 'button',
NumberDesc: 'number',
}
@@ -0,0 +1,37 @@
"""Read device identity from standard OCF resources (/oic/p, /oic/d)."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
import cbor2
@dataclass(frozen=True)
class DeviceIdentity:
manufacturer: str
model: str
name: str
serial: Optional[str]
def _get(sess, path) -> dict:
try:
code, pl = sess.get(path, timeout=10.0)
if code == 0x45 and pl:
body = cbor2.loads(pl)
return body if isinstance(body, dict) else {}
except Exception:
pass
return {}
def read_identity(sess, serial: Optional[str]) -> DeviceIdentity:
p = _get(sess, ['oic', 'p'])
d = _get(sess, ['oic', 'd'])
return DeviceIdentity(
manufacturer=p.get('mnmn') or 'Samsung',
model=p.get('mnmo') or '',
name=d.get('n') or '',
serial=serial,
)
@@ -0,0 +1,46 @@
"""Global CAPABILITIES registry: href -> list[Capability].
Built from the full list of Capability objects in the capabilities package.
Consumed by discover() at connection time to bind device resources to entities.
Raises ValueError at import if any href group contains an unfiltered cap
alongside other caps (i.e., a cap with neither rt_filter nor match_fn set
in a group with multiple caps).
"""
from .capabilities import ALL
from .capability import Capability
def _build() -> dict[str, list[Capability]]:
"""Build the registry, raising ValueError for invalid duplicate hrefs.
Duplicates are allowed only when every cap in the group has at least one
of rt_filter or match_fn set. An unfiltered cap sharing an href with
another cap would be ambiguous.
Skips capabilities with href=None (pattern capabilities, handled elsewhere).
"""
out: dict[str, list[Capability]] = {}
for cap in ALL:
if cap.href is None:
continue
if cap.href not in out:
out[cap.href] = [cap]
else:
out[cap.href].append(cap)
# Validate: every group with >1 cap must have all caps filtered
for href, caps in out.items():
if len(caps) > 1:
unfiltered = [c for c in caps
if c.rt_filter is None and c.match_fn is None]
if unfiltered:
raise ValueError(
f"duplicate capability href {href!r} has unfiltered cap(s); "
f"each cap sharing an href must set rt_filter or match_fn"
)
return out
CAPABILITIES: dict[str, list[Capability]] = _build()
+1 -1
View File
@@ -6,7 +6,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from samsung_appliance.registry.entities import SelectDesc
from .ocf.registry.entities import SelectDesc
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
+1 -1
View File
@@ -6,7 +6,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from samsung_appliance.registry.entities import SensorDesc
from .ocf.registry.entities import SensorDesc
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
+1 -1
View File
@@ -6,7 +6,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from samsung_appliance.registry.entities import SwitchDesc
from .ocf.registry.entities import SwitchDesc
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator