diff --git a/README.md b/README.md index d17464b..0c7f2d9 100644 --- a/README.md +++ b/README.md @@ -216,18 +216,17 @@ session cannot be connected again. ## Writes and retransmission -Reads retransmit each Block2 request; writes send once. Where a lost write -has been shown to be the cause rather than a device that is simply refusing -load, `write_max_attempts` lets `post()` retransmit inside the caller's own -timeout, backing off per RFC 7252 §4.2 and pacing every retransmit: +Reads retransmit each Block2 request; writes send once. Where a lost write is +the proven cause, and not a device refusing load, `write_max_attempts` lets +`post()` retransmit inside the caller's timeout, backing off per RFC 7252 §4.2 +and pacing each retransmit: ```python sess = DtlsCoapSession("192.0.2.100", 49154, auth=auth, write_max_attempts=3) ``` -Each attempt resends the byte-identical datagram, so a server implementing -§4.5 can recognise the duplicate and answer from its dedupe cache instead of -re-running the write. Retrying from the caller cannot do that — a second +Each attempt resends the byte-identical datagram, so a §4.5 server answers the +duplicate from its dedupe cache. Retrying from the caller cannot do that — a second `post()` mints a fresh Message ID, which is a new request. It defaults to `1` (send once) because retransmitting into an appliance that is already dropping under load turns one lost write into several, and §4.5 dedupe is unverified on @@ -237,9 +236,8 @@ Note that `post()`'s `timeout` bounds the whole call, rate-limit pacing included, rather than only the wait that follows the send. Every attempt has to share one budget, and a caller that asked for 8 seconds should not wait 8 seconds plus however long the limiter withheld the request. At the default 5 -req/s that is at most 200 ms of the budget; at a hand-tuned `rate_limit_rps=1.0` -it is a full second, so a caller pairing a low rate limit with a short timeout -should raise the timeout to match. +req/s that costs at most 200 ms of it; at `rate_limit_rps=1.0` it costs a full +second, so pair a low rate limit with a longer timeout. ## Authentication @@ -388,7 +386,7 @@ sess = DtlsCoapSession("192.0.2.100", 49154, auth=auth) The identity must be the raw 16-byte OCF UUID and the key exactly 16 or 32 bytes. `PskAuth` selects only `ECDHE-PSK-AES128-CBC-SHA256` and does not acquire, derive, provision, rotate, or persist credentials. Ownership transfer and credential discovery are outside this package. -An identity containing a zero byte is rejected, and that limit is OpenSSL's rather than the appliance's. An OCF device takes the identity as bytes with an explicit length, so a zero byte means nothing to it, but OpenSSL's DTLS 1.2 PSK client callback returns the identity as a C string. Measured against OpenSSL 4.0.0, a 16-byte identity with a NUL at byte 8 reaches the wire as 8 bytes and the handshake raises nothing locally, so the appliance answers a truncated identity it has never seen. DTLS 1.2 offers no length-carrying PSK callback to fall back on, which leaves such a credential unusable through this library: roughly 6% of uniformly random 16-byte identities, and about 5% of UUIDv4s, whose version and variant bytes can never be zero. +An identity containing a zero byte is rejected, and that limit is OpenSSL's rather than the appliance's. An OCF device takes the identity as bytes with an explicit length, so a zero byte means nothing to it, but OpenSSL's DTLS 1.2 PSK client callback returns the identity as a C string. Measured against OpenSSL 4.0.0, a 16-byte identity with a NUL at byte 8 reaches the wire as 8 bytes and the handshake raises nothing locally, so the appliance answers a truncated identity it has never seen. DTLS 1.2 offers no length-carrying PSK callback, so such a credential is unusable here: roughly 6% of uniformly random 16-byte identities, and about 5% of UUIDv4s, which have two fixed bytes. Code holding a credential can check it, and report why, before building a provider or storing anything: @@ -429,52 +427,28 @@ resources, run OTM, or persist the result. ## Supported library imports -The public API is organized by responsibility rather than re-exported through -one large root namespace. Explicit imports from these modules are intentional -and covered by the downstream compatibility contract. - -**[`docs/api.md`](https://github.com/QuiteYellow/SmartThings-Local/blob/main/docs/api.md) is the full reference**: every supported name, with its signature read off the code. It is generated from `tools/api_contract.py` by `tools/generate_api_docs.py`, and the test suite fails if the page and the code disagree. A public name absent from it is reachable but incidental, and may move. The table below is the map; that page is the inventory. - -| Module | Supported responsibility | -| --- | --- | -| `smartthings_local.errors` | Classified, redacted library failures | -| `smartthings_local.protocol.auth` | Certificate, server-certificate, and PSK providers and Samsung server profiles | -| `smartthings_local.protocol.dtls_session` | Sustained CoAP-DTLS sessions and connect cancellation | -| `smartthings_local.protocol.dtls_probe` | Stateless DTLS liveness results, single/multi-port probes, and the opt-in provider-aware handshake diagnostic | -| `smartthings_local.protocol.endpoint` | Resolved IPv4/IPv6 UDP endpoints and connected/host-filtered socket setup | -| `smartthings_local.protocol.ocf_discovery` | Bounded plaintext OCF reads and advertised secure-port discovery | -| `smartthings_local.protocol.ocf_multicast` | Known-host OCF responder-port discovery | -| `smartthings_local.protocol.coap` | Datagram CoAP constants, builders, parsers, response classification, and Block2 accumulation | -| `smartthings_local.protocol.coap_tcp` | Pure reliable-transport CoAP framing and stream decoding | -| `smartthings_local.protocol.ble_ocf` | Pure IoTivity BLE fragmentation and reassembly | -| `smartthings_local.protocol.owner_psk` | Pure manufacturer-certificate OwnerPSK derivation | -| `smartthings_local.ocf.state_cache` | Resource-state cache used by library consumers | -| `smartthings_local.ocf.poll_scheduler` | Tiered polling scheduler | -| `smartthings_local.ocf.keepalive` | Session keepalive task | -| `smartthings_local.ocf.observe_refresh` | Periodic Observe refresh task | - -Use explicit imports from the owning module, as the examples in this README -do. `smartthings_local` and `smartthings_local.protocol` deliberately do not -duplicate these names: a root facade would hide which lifecycle or wire layer -an application depends on and would create collisions across the datagram, -TCP, and BLE codecs. Names beginning with `_`, implementation modules not -listed above, and the `mqtt_demo` application are not part of this contract. - -The current `0.1.x` line keeps the existing `DtlsCoapSession` constructor and -method signatures additive. The `cert_path` / `key_path` and `cert_pem` / -`key_pem` constructor pairs remain supported without warnings; new code should -prefer an immutable `CertificateAuth` or `PskAuth` provider so authentication -requirements stay explicit. Additive keyword-only options and new classified -error subclasses may appear in compatible releases. Existing broad catches -remain valid because each classified error preserves its documented built-in -base class. +**[`docs/api.md`](https://github.com/QuiteYellow/SmartThings-Local/blob/main/docs/api.md) is the reference**: every supported name, grouped by +layer, with its signature read off the code. It is generated, so it cannot +drift. + +Import from the owning module, as the examples here do. `smartthings_local` +and `smartthings_local.protocol` deliberately do not re-export those names: a +root facade would hide which wire layer an application depends on, and would +collide across the datagram, TCP and BLE codecs. Names beginning with `_`, +modules absent from `docs/api.md`, and `mqtt_demo` are outside the contract. + +`0.1.x` keeps the `DtlsCoapSession` constructor and method signatures +additive. The `cert_path`/`key_path` and `cert_pem`/`key_pem` pairs stay +supported without warnings, though new code should prefer a `CertificateAuth` +or `PskAuth` provider so the authentication requirement is explicit. Expect +additive keyword-only options and new classified error subclasses in +compatible releases; a broad catch stays valid, since every classified error +keeps its documented built-in base. The session lifecycle is caller-owned: finish `connect()` before -`start_reader()`, keep one reader per session, and use -`quiesce_for_close()`/`close()` or `abort()` for terminal shutdown. Notification -callbacks run on the session's reader path and should hand work off rather than -block it. OCF cache, polling, keepalive, and Observe helpers do not acquire -credentials, choose ownership policy, or create Home Assistant entities. +`start_reader()`, one reader per session, then `quiesce_for_close()`/`close()` +or `abort()`. Notification callbacks run on the reader path, so hand work off +instead of blocking it. ## CoAP over TCP framing @@ -504,8 +478,8 @@ decoder = CoapTcpStreamDecoder(max_message_size=64 * 1024) messages = decoder.feed(received_chunk) ``` -The module deliberately does not open a TCP/TLS/Bluetooth connection, choose a -carrier, or perform setup and ownership operations. Those remain caller policy. +The module opens no TCP/TLS/Bluetooth connection, chooses +no carrier, and performs no setup or ownership work. Those remain caller policy. ## BLE OCF framing @@ -767,10 +741,10 @@ the capability registry and Home Assistant integration. These each looked like obvious improvements at some point. Each one broke something. - **Don't add OBSERVE subscriptions on OCF-standard `//0` paths.** They register successfully but never push. Use the Samsung `//vs/0` siblings (which do). -- **Don't assume OBSERVE silence means the appliance is broken.** When the appliance can't reach Samsung's cloud, its OBSERVE notify dispatch goes quiet even though the local DTLS session, GETs, POSTs, and the cache continue to work normally (measured at `~14 req/s` dryer / `~8 req/s` oven with 200/200 GETs successful while firewalled). The polling tiers are the structural answer to this; treat OBSERVE strictly as an optional accelerator. +- **Don't assume OBSERVE silence means the appliance is broken.** With no route to Samsung's cloud, OBSERVE dispatch goes quiet while the local DTLS session, GETs, POSTs and cache keep working. Measured firewalled: `~14 req/s` dryer, `~8 req/s` oven, 200/200 GETs. The polling tiers are the structural answer to this; treat OBSERVE strictly as an optional accelerator. - **Don't touch `/oic/sec/*` (doxm, pstat, cred, acl).** The bridge doesn't, and you shouldn't from helper scripts either. Those resources have wedge/brick risk on Samsung's RT-OCF security stack. The bridge surfaces are strictly `//vs/0` and `/device/0`. - **Don't run two clients against the same appliance simultaneously.** Samsung's RT-OCF DTLS allows one active session per peer; a second handshake will get the device to drop the new socket. If HA seems to flap, check whether you've got `python -m mqtt_demo` running locally AND the Docker container up. -- **Expect gaps in write coverage, but few are hard limits.** The local DTLS surface appears to expose every write Samsung's own app uses; the ceiling is per-surface reverse-engineering (finding the resource, field, and encoding), not an API boundary. A control that isn't wired yet usually just hasn't been mapped. Oven cavity remote-start is the marquee open example: it works today through Samsung's cloud, and locally the write is accepted (`2.04`) but the cavity never engages. That's a reverse-engineering problem we haven't cracked yet, not a dead end. The hard limits are the few surfaces Samsung gates in hardware/firmware (power, child lock, remote-control enable), which accept the write then snap back to the physical switch. That mirrors Samsung's own behaviour, not a shortfall of the local path: the SmartThings app can't flip those remotely either (Remote Control is a button you press on the appliance). The optimistic-publish-then-verify pattern absorbs the reverts transparently: HA briefly shows the new value, then the PollScheduler's next tier poll (deferred ~4s past Samsung's revert window) re-reads and republishes the actual state. (The bridge deliberately does **not** fetch-back right after a write; that GET is itself what triggers the revert.) +- **Expect gaps in write coverage, but few are hard limits.** The local DTLS surface appears to expose every write Samsung's own app uses, so a control that isn't wired yet usually just hasn't been mapped: the ceiling is per-surface reverse-engineering, meaning the resource, field and encoding. Oven cavity remote-start is the open example. Samsung's cloud does it; locally the write is accepted (`2.04`) and the cavity never engages, which is a problem I haven't cracked rather than a dead end. The real limits are the few surfaces gated in hardware or firmware (power, child lock, remote-control enable), which accept a write and snap back to the physical switch. The SmartThings app cannot flip those remotely either, since Remote Control is a button on the appliance. --- diff --git a/docs/api.md b/docs/api.md index c6247a8..06469aa 100644 --- a/docs/api.md +++ b/docs/api.md @@ -62,59 +62,59 @@ Public, redacted exception types for smartthings-local. #### `AuthenticationError` -*exception* — The peer or local credentials could not be authenticated. +*exception*: The peer or local credentials could not be authenticated. #### `AuthorizationError` -*exception* — The authenticated peer is not authorized for an operation. +*exception*: The authenticated peer is not authorized for an operation. #### `BlockwiseError` -*exception* — A Block1 or Block2 transfer violated its bounded contract. +*exception*: A Block1 or Block2 transfer violated its bounded contract. #### `EndpointError` -*exception* — An endpoint could not be resolved, bound, or connected. +*exception*: An endpoint could not be resolved, bound, or connected. #### `HandshakePeerCleanupError` -*exception* — A cleanup alert was sent for an HVR-only half-open DTLS peer. +*exception*: A cleanup alert was sent for an HVR-only half-open DTLS peer. #### `MalformedMessageError` -*exception* — A protocol message could not be decoded safely. +*exception*: A protocol message could not be decoded safely. #### `ObserveError` -*exception* — A CoAP Observe relation could not be established or maintained. +*exception*: A CoAP Observe relation could not be established or maintained. #### `ProbeError` -*exception* — A DTLS probe failed before producing a protocol result. +*exception*: A DTLS probe failed before producing a protocol result. #### `SessionClosedError` -*exception* — An operation was attempted on a closed session. +*exception*: An operation was attempted on a closed session. #### `SessionError` -*exception* — A connected-session operation failed. +*exception*: A connected-session operation failed. #### `SessionIdentifierError` -*exception* — No free Message ID or token was available for a new exchange. +*exception*: No free Message ID or token was available for a new exchange. #### `SessionResetError` -*exception* — The peer rejected an exchange with a CoAP RST. +*exception*: The peer rejected an exchange with a CoAP RST. #### `SessionTimeoutError` -*exception* — A bounded session operation exceeded its deadline. +*exception*: A bounded session operation exceeded its deadline. #### `SmartThingsLocalError` -*exception* — Base class for classified library failures. +*exception*: Base class for classified library failures. ## Authentication @@ -128,9 +128,9 @@ Immutable authentication providers for DTLS sessions. AuthenticationProvider(*args, **kwargs) ``` -*class* — Configure authentication for a newly created DTLS context. +*class*: Configure authentication for a newly created DTLS context. -- `configure_context(context: OpenSSL.SSL.Context) -> None` — Configure a context while this provider remains session-owned. +- `configure_context(context: OpenSSL.SSL.Context) -> None`: Configure a context while this provider remains session-owned. #### `CertificateAuth` @@ -138,11 +138,11 @@ AuthenticationProvider(*args, **kwargs) CertificateAuth(*, certificate_path: str | os.PathLike[str] | None = None, private_key_path: str | os.PathLike[str] | None = None, certificate_pem: str | None = None, private_key_pem: str | None = None, server_profile: SamsungServerProfile | None = None) ``` -*class* — Certificate authentication loaded from files or in-memory PEM data. +*class*: Certificate authentication loaded from files or in-memory PEM data. -- `configure_context(context: OpenSSL.SSL.Context) -> None` — Apply the existing certificate authentication profile to a context. -- `from_files(cls, certificate_path: str | os.PathLike[str], private_key_path: str | os.PathLike[str], *, server_profile: SamsungServerProfile | None = None) -> CertificateAuth` — Create a provider backed by certificate-chain and key files. -- `from_memory(cls, certificate_pem: str, private_key_pem: str, *, server_profile: SamsungServerProfile | None = None) -> CertificateAuth` — Create a provider backed by an in-memory PEM chain and key. +- `configure_context(context: OpenSSL.SSL.Context) -> None`: Apply the existing certificate authentication profile to a context. +- `from_files(cls, certificate_path: str | os.PathLike[str], private_key_path: str | os.PathLike[str], *, server_profile: SamsungServerProfile | None = None) -> CertificateAuth`: Create a provider backed by certificate-chain and key files. +- `from_memory(cls, certificate_pem: str, private_key_pem: str, *, server_profile: SamsungServerProfile | None = None) -> CertificateAuth`: Create a provider backed by an in-memory PEM chain and key. #### `PskAuth` @@ -150,10 +150,10 @@ CertificateAuth(*, certificate_path: str | os.PathLike[str] | None = None, priva PskAuth(*, identity: bytes, key: bytes) ``` -*class* — DTLS authentication using an existing OCF PSK credential. +*class*: DTLS authentication using an existing OCF PSK credential. -- `configure_context(context: OpenSSL.SSL.Context) -> None` — Configure one context for the narrow Samsung OCF PSK profile. -- `validate_identity(identity: bytes) -> None` — Raise unless `identity` is one OpenSSL can put on the wire. +- `configure_context(context: OpenSSL.SSL.Context) -> None`: Configure one context for the narrow Samsung OCF PSK profile. +- `validate_identity(identity: bytes) -> None`: Raise unless `identity` is one OpenSSL can put on the wire. #### `SamsungServerProfile` @@ -161,14 +161,14 @@ PskAuth(*, identity: bytes, key: bytes) SamsungServerProfile(*, expected_certificate_identity: uuid.UUID | str, role: SamsungServerRole = , additional_ca_pem: str | None = None) ``` -*class* — Opt-in Samsung hardware-certificate verification profile. +*class*: Opt-in Samsung hardware-certificate verification profile. -- `bound_device(cls, expected_certificate_identity: uuid.UUID | str, *, role: SamsungServerRole = , additional_ca_pem: str | None = None) -> SamsungServerProfile` — Bind a verified Samsung hardware leaf to its certificate UUID. -- `discover_device(cls, *, role: SamsungServerRole = , additional_ca_pem: str | None = None) -> SamsungServerProfile` — Verify a Samsung hardware leaf before learning its certificate UUID. +- `bound_device(cls, expected_certificate_identity: uuid.UUID | str, *, role: SamsungServerRole = , additional_ca_pem: str | None = None) -> SamsungServerProfile`: Bind a verified Samsung hardware leaf to its certificate UUID. +- `discover_device(cls, *, role: SamsungServerRole = , additional_ca_pem: str | None = None) -> SamsungServerProfile`: Verify a Samsung hardware leaf before learning its certificate UUID. #### `SamsungServerRole` -*class* — Known Samsung OCF hardware-certificate subject roles. +*class*: Known Samsung OCF hardware-certificate subject roles. - `HOME_APPLIANCE` = `'OCF HA Device'` - `VD_DEVICE` = `'OCF VD Device'` @@ -179,9 +179,9 @@ SamsungServerProfile(*, expected_certificate_identity: uuid.UUID | str, role: Sa ServerCertificateAuth(*, server_profile: SamsungServerProfile) ``` -*class* — Verify a pinned Samsung server without a client certificate. +*class*: Verify a pinned Samsung server without a client certificate. -- `configure_context(context: OpenSSL.SSL.Context) -> None` — Verify the selected server profile without loading client material. +- `configure_context(context: OpenSSL.SSL.Context) -> None`: Verify the selected server profile without loading client material. ## Sessions @@ -191,10 +191,10 @@ CoAP-over-DTLS client for Samsung RT-OCF appliances (RFC 7252 + 6347). #### `ConnectCancellation` -*class* — One-way, socket-backed cancellation signal for `connect()`. +*class*: One-way, socket-backed cancellation signal for `connect()`. -- `is_set() -> bool` — Return whether cancellation has been requested. -- `set() -> None` — Cancel current and future connection attempts using this signal. +- `is_set() -> bool`: Return whether cancellation has been requested. +- `set() -> None`: Cancel current and future connection attempts using this signal. #### `DtlsCoapSession` @@ -202,22 +202,22 @@ CoAP-over-DTLS client for Samsung RT-OCF appliances (RFC 7252 + 6347). DtlsCoapSession(host, port, cert_path=None, key_path=None, *, cert_pem=None, key_pem=None, on_notification=None, mtu=1200, rate_limit_rps: float = 5.0, local_port=None, family=, write_max_attempts: int = 1, auth: AuthenticationProvider | None = None, on_legacy_notification=None, on_observe_pending=None, on_observe_error=None, on_observe_delivery=None) ``` -*class* — Single sustained DTLS-CoAP session. +*class*: Single sustained DTLS-CoAP session. -- `abort()` — Immediately stop work and close the established transport. -- `close()` — 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... -- `connect(*, timeout: float | None = None, cancel: ConnectCancellation | None = None, cleanup_hvr_peer: bool = False)` — Perform a cancellable DTLS handshake using a monotonic deadline. -- `delete(path_segs, timeout=8.0, *, query=(), extra_options=())` — Single-frame DELETE. Returns (code, payload_bytes). -- `get(path_segs, query=(), timeout=10.0, *, extra_options=())` — Token-stable Block2 GET. Returns (code, payload_bytes). -- `join()` — Block until the reader thread exits (i.e. socket dies). -- `pace() -> None` — Sleep only the part of the rate-limit interval not already consumed since the last real send. Uses _stop so session teardown wakes it. -- `ping()` — RFC 7252 §4.4 CoAP Ping — empty CON, no token, no payload. Fire-and-forget: we do not wait for the matching RST because Samsung's RT-OCF doesn't reliably emit one (verified 2026-06-04: every sync p... -- `post(path_segs, body_cbor, timeout=8.0, *, query=(), extra_options=())` — POST a CBOR-encoded body and return (code, payload_bytes). -- `quiesce_for_close()` — Stop new work while retaining an established socket for close(). -- `refresh_observes(paths, *, queries_by_href=None)` — Replace only the requested Observe paths and report send results. -- `start_reader()` — Spawn the reader thread. Must be called after connect(). -- `subscribe(path_segs, *, query=())` — 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). -- `unsubscribe(path_segs)` — Deregister and retire every active relation for one exact path. +- `abort()`: Immediately stop work and close the established transport. +- `close()`: 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... +- `connect(*, timeout: float | None = None, cancel: ConnectCancellation | None = None, cleanup_hvr_peer: bool = False)`: Perform a cancellable DTLS handshake using a monotonic deadline. +- `delete(path_segs, timeout=8.0, *, query=(), extra_options=())`: Single-frame DELETE. Returns (code, payload_bytes). +- `get(path_segs, query=(), timeout=10.0, *, extra_options=())`: Token-stable Block2 GET. Returns (code, payload_bytes). +- `join()`: Block until the reader thread exits (i.e. socket dies). +- `pace() -> None`: Sleep only the part of the rate-limit interval not already consumed since the last real send. Uses _stop so session teardown wakes it. +- `ping()`: RFC 7252 §4.4 CoAP Ping — empty CON, no token, no payload. Fire-and-forget: we do not wait for the matching RST because Samsung's RT-OCF doesn't reliably emit one (verified 2026-06-04: every sync p... +- `post(path_segs, body_cbor, timeout=8.0, *, query=(), extra_options=())`: POST a CBOR-encoded body and return (code, payload_bytes). +- `quiesce_for_close()`: Stop new work while retaining an established socket for close(). +- `refresh_observes(paths, *, queries_by_href=None)`: Replace only the requested Observe paths and report send results. +- `start_reader()`: Spawn the reader thread. Must be called after connect(). +- `subscribe(path_segs, *, query=())`: 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). +- `unsubscribe(path_segs)`: Deregister and retire every active relation for one exact path. #### `ObserveDelivery` @@ -225,7 +225,7 @@ DtlsCoapSession(host, port, cert_path=None, key_path=None, *, cert_pem=None, key ObserveDelivery(href: str, payload: bytes, query: tuple[str, ...] = (), registration: bool = False, sequence: int | None = None, legacy: bool = False) ``` -*class* — One representation delivered on an Observe relation. +*class*: One representation delivered on an Observe relation. ## Discovery and probing @@ -239,7 +239,7 @@ DTLS ClientHello probe — a cheap, deterministic liveness + diagnostic primitiv DtlsLivenessResult(port: int, response_kind: str | None, attempts: int, rtt_s: float | None = None, alert: tuple[int, str] | None = None, error_code: str | None = None) ``` -*class* — Bounded, non-sensitive result for one stateless port probe. +*class*: Bounded, non-sensitive result for one stateless port probe. #### `DtlsPortProbeResult` @@ -247,7 +247,7 @@ DtlsLivenessResult(port: int, response_kind: str | None, attempts: int, rtt_s: f DtlsPortProbeResult(outcome: str, selected_port: int | None, results: tuple[DtlsLivenessResult, ...]) ``` -*class* — Selection result for one bounded concurrent probe set. +*class*: Selection result for one bounded concurrent probe set. #### `diagnose_dtls_handshake` @@ -255,7 +255,7 @@ DtlsPortProbeResult(outcome: str, selected_port: int | None, results: tuple[Dtls diagnose_dtls_handshake(host, port, *, auth=None, cert_pem=None, key_pem=None, cert_path=None, key_path=None, retries=2, timeout=3.0, mtu=1200, family=) ``` -*function* — Opt in to a stateful DTLS handshake for protocol diagnosis. +*function*: Opt in to a stateful DTLS handshake for protocol diagnosis. #### `probe_dtls_port` @@ -263,7 +263,7 @@ diagnose_dtls_handshake(host, port, *, auth=None, cert_pem=None, key_pem=None, c probe_dtls_port(host, port, *, timeout=3.0, retries=2, mtu=1200, family=) ``` -*function* — Prove one DTLS listener without sending a cookie-bearing flight. +*function*: Prove one DTLS listener without sending a cookie-bearing flight. #### `probe_dtls_ports` @@ -271,7 +271,7 @@ probe_dtls_port(host, port, *, timeout=3.0, retries=2, mtu=1200, family=
) ``` -*function* — Probe a bounded port set concurrently and select without guessing. +*function*: Probe a bounded port set concurrently and select without guessing. **Constants** @@ -298,7 +298,7 @@ Bounded plaintext OCF reads and advertised secure-port discovery. OcfSecurePortDiscoveryResult(ports: tuple[int, ...], attempts: int, response_received: bool, error_code: str | None = None) ``` -*class* — Redacted outcome of one bounded secure-port discovery operation. +*class*: Redacted outcome of one bounded secure-port discovery operation. #### `PlaintextOcfResourceResult` @@ -306,7 +306,7 @@ OcfSecurePortDiscoveryResult(ports: tuple[int, ...], attempts: int, response_rec PlaintextOcfResourceResult(code: int | None, payload: bytes, blocks_received: int, content_format: int | None, size2: int | None, attempts: int, response_received: bool, error_code: str | None = None) ``` -*class* — Redacted outcome of one bounded plaintext OCF resource read. +*class*: Redacted outcome of one bounded plaintext OCF resource read. #### `discover_ocf_secure_ports` @@ -314,7 +314,7 @@ PlaintextOcfResourceResult(code: int | None, payload: bytes, blocks_received: in discover_ocf_secure_ports(host, *, discovery_port=5683, timeout=3.0, retries=1, family=) ``` -*function* — Discover secure ports advertised by a target's public OCF directory. +*function*: Discover secure ports advertised by a target's public OCF directory. #### `read_plaintext_ocf_resource` @@ -322,7 +322,7 @@ discover_ocf_secure_ports(host, *, discovery_port=5683, timeout=3.0, retries=1, read_plaintext_ocf_resource(host, href, *, query=(), port=5683, timeout=3.0, retries=1, family=) ``` -*function* — Read one known plaintext OCF resource under fixed transfer bounds. +*function*: Read one known plaintext OCF resource under fixed transfer bounds. ### `smartthings_local.protocol.ocf_multicast` @@ -334,7 +334,7 @@ Bounded discovery of a known host's plaintext OCF response port. OcfResponderPortDiscoveryResult(ports: tuple[int, ...], attempts: int, responses: int, error_code: str | None = None) ``` -*class* — Redacted result of one known-host multicast discovery operation. +*class*: Redacted result of one known-host multicast discovery operation. #### `discover_ocf_responder_ports` @@ -342,7 +342,7 @@ OcfResponderPortDiscoveryResult(ports: tuple[int, ...], attempts: int, responses discover_ocf_responder_ports(target_address: str, *, interface_address: str, discovery_port: int = 5683, timeout: float = 3.0, rounds: int = 2) -> OcfResponderPortDiscoveryResult ``` -*function* — Find plaintext OCF response ports for one known IPv4 host. +*function*: Find plaintext OCF response ports for one known IPv4 host. ### `smartthings_local.protocol.endpoint` @@ -354,14 +354,14 @@ Deterministic UDP endpoint resolution and connected socket setup. HostFilteredUdpSocket(sock, endpoint) ``` -*class* — UDP socket that accepts replies from any port on one target host. +*class*: UDP socket that accepts replies from any port on one target host. - `close()` - `fileno()` - `getsockname()` - `gettimeout()` -- `recv(bufsize)` — Return the next datagram from the target host. -- `send(data)` — Send to the resolved destination, mirroring `socket.send`. +- `recv(bufsize)`: Return the next datagram from the target host. +- `send(data)`: Send to the resolved destination, mirroring `socket.send`. - `settimeout(timeout)` #### `ResolvedUdpEndpoint` @@ -370,9 +370,9 @@ HostFilteredUdpSocket(sock, endpoint) ResolvedUdpEndpoint(family: int, sockaddr: tuple) ``` -*class* — One concrete IPv4 or IPv6 UDP destination. +*class*: One concrete IPv4 or IPv6 UDP destination. -- `bind_address(local_port)` — Return the wildcard bind tuple matching this endpoint's family. +- `bind_address(local_port)`: Return the wildcard bind tuple matching this endpoint's family. #### `open_connected_udp_socket` @@ -380,7 +380,7 @@ ResolvedUdpEndpoint(family: int, sockaddr: tuple) open_connected_udp_socket(host, port, *, family=, local_port=None, timeout=None) ``` -*function* — Create, optionally bind, and connect a UDP socket. +*function*: Create, optionally bind, and connect a UDP socket. #### `open_host_filtered_udp_socket` @@ -388,7 +388,7 @@ open_connected_udp_socket(host, port, *, family=, lo open_host_filtered_udp_socket(host, port, *, family=, local_port=None, timeout=None) ``` -*function* — Open an unconnected UDP socket bound to one target host. +*function*: Open an unconnected UDP socket bound to one target host. #### `resolve_udp_endpoint` @@ -396,7 +396,7 @@ open_host_filtered_udp_socket(host, port, *, family= resolve_udp_endpoint(host, port, *, family=) ``` -*function* — Resolve the first usable UDP candidate. +*function*: Resolve the first usable UDP candidate. #### `resolve_udp_endpoints` @@ -404,7 +404,7 @@ resolve_udp_endpoint(host, port, *, family=) resolve_udp_endpoints(host, port, *, family=) ``` -*function* — Resolve all unique IPv4/IPv6 UDP candidates in resolver order. +*function*: Resolve all unique IPv4/IPv6 UDP candidates in resolver order. ## Wire formats @@ -418,7 +418,7 @@ CoAP wire encoding/decoding (RFC 7252 + 7641 + 7959). Block2Accumulator(token, *, max_blocks=32, max_payload_bytes=65536, accepted_content_formats=None) ``` -*class* — Bounded, token-stable Block2 representation accumulator. +*class*: Bounded, token-stable Block2 representation accumulator. - `add_response(message)` @@ -428,7 +428,7 @@ Block2Accumulator(token, *, max_blocks=32, max_payload_bytes=65536, accepted_con CoapMessage(mtype: int, code: int, mid: int, token: bytes, options: tuple[tuple[int, bytes], ...], payload: bytes) ``` -*class* — One decoded CoAP datagram. +*class*: One decoded CoAP datagram. #### `CoapResponseClassification` @@ -436,7 +436,7 @@ CoapMessage(mtype: int, code: int, mid: int, token: bytes, options: tuple[tuple[ CoapResponseClassification(kind: str, message: coap.CoapMessage | None = None, acknowledgement: bytes | None = None) ``` -*class* — Transport-independent classification of a possible response. +*class*: Transport-independent classification of a possible response. #### `block_fields` @@ -444,7 +444,7 @@ CoapResponseClassification(kind: str, message: coap.CoapMessage | None = None, a block_fields(value) ``` -*function* — Decode a CoAP Block-N option value. Inverse of block_value(). Returns (num, more, szx). An empty value means block 0, no more, SZX=0 — RFC 7959 §2.2 allows a zero-length option to elide it. +*function*: Decode a CoAP Block-N option value. Inverse of block_value(). Returns (num, more, szx). An empty value means block 0, no more, SZX=0 — RFC 7959 §2.2 allows a zero-length option to elide it. #### `block_value` @@ -452,7 +452,7 @@ block_fields(value) block_value(num, more, szx) ``` -*function* — Encode a CoAP Block-N option value. +*function*: Encode a CoAP Block-N option value. #### `build_coap` @@ -460,7 +460,7 @@ block_value(num, more, szx) build_coap(mtype, code, mid, token, options, payload=b'') ``` -*function* — Build a CoAP datagram. mtype: CON/NON/ACK/RST. token: bytes (may be empty for ACK). options: list of (num, value_bytes). +*function*: Build a CoAP datagram. mtype: CON/NON/ACK/RST. token: bytes (may be empty for ACK). options: list of (num, value_bytes). #### `build_empty_ack` @@ -468,7 +468,7 @@ build_coap(mtype, code, mid, token, options, payload=b'') build_empty_ack(mid) ``` -*function* — Build the bare ACK required for a confirmable CoAP response. +*function*: Build the bare ACK required for a confirmable CoAP response. #### `build_get_request` @@ -476,7 +476,7 @@ build_empty_ack(mid) build_get_request(mtype, mid, token, path_segs, query=(), *, accept=b'<', block_number=None, block_szx=6, extra_options=()) ``` -*function* — Build a GET with optional query, Block2, and extension options. +*function*: Build a GET with optional query, Block2, and extension options. #### `classify_coap_response` @@ -484,7 +484,7 @@ build_get_request(mtype, mid, token, path_segs, query=(), *, accept=b'<', block_ classify_coap_response(datagram, *, token=None, request_mid=None) ``` -*function* — Classify a response without coupling it to a socket implementation. +*function*: Classify a response without coupling it to a socket implementation. #### `decode_uint_option` @@ -492,7 +492,7 @@ classify_coap_response(datagram, *, token=None, request_mid=None) decode_uint_option(options, number, *, max_length) ``` -*function* — Decode one optional CoAP uint option. +*function*: Decode one optional CoAP uint option. #### `fmt_code` @@ -500,7 +500,7 @@ decode_uint_option(options, number, *, max_length) fmt_code(c) ``` -*function* — 0x45 → '2.05', 0x84 → '4.04'. Used in log lines. +*function*: 0x45 → '2.05', 0x84 → '4.04'. Used in log lines. #### `option_values` @@ -508,7 +508,7 @@ fmt_code(c) option_values(options, number) ``` -*function* — Return all values for one option number, preserving wire order. +*function*: Return all values for one option number, preserving wire order. #### `parse_coap` @@ -516,7 +516,7 @@ option_values(options, number) parse_coap(data) ``` -*function* — Decode a CoAP datagram. Returns (mtype, code, mid, token, options, payload). options is a list of (num, value_bytes). +*function*: Decode a CoAP datagram. Returns (mtype, code, mid, token, options, payload). options is a list of (num, value_bytes). #### `parse_coap_message` @@ -524,7 +524,7 @@ parse_coap(data) parse_coap_message(data) ``` -*function* — Decode `data` into an immutable :class:`CoapMessage`. +*function*: Decode `data` into an immutable :class:`CoapMessage`. #### `split_dtls` @@ -532,7 +532,7 @@ parse_coap_message(data) split_dtls(buf) ``` -*function* — 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. +*function*: 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. **Constants** @@ -558,7 +558,7 @@ Pure CoAP-over-TCP wire codec used by Samsung's IoTivity stack. #### `CoapTcpCodecError` -*exception* — An invalid or unsupported CoAP-over-TCP message. +*exception*: An invalid or unsupported CoAP-over-TCP message. #### `CoapTcpMessage` @@ -566,7 +566,7 @@ Pure CoAP-over-TCP wire codec used by Samsung's IoTivity stack. CoapTcpMessage(code: int, token: bytes, options: tuple[tuple[int, bytes], ...], payload: bytes) ``` -*class* — One decoded CoAP-over-TCP message. +*class*: One decoded CoAP-over-TCP message. #### `CoapTcpStreamDecoder` @@ -574,11 +574,11 @@ CoapTcpMessage(code: int, token: bytes, options: tuple[tuple[int, bytes], ...], CoapTcpStreamDecoder(*, max_message_size: int = 4194304) ``` -*class* — Incrementally split and parse CoAP messages from a byte stream. +*class*: Incrementally split and parse CoAP messages from a byte stream. -- `feed(data: bytes | bytearray | memoryview) -> tuple[coap_tcp.CoapTcpMessage, ...]` — Consume a stream chunk and return every complete message in it. -- `finish() -> None` — Accept end-of-stream only when no partial message remains. -- `reset() -> None` — Discard an incomplete frame. +- `feed(data: bytes | bytearray | memoryview) -> tuple[coap_tcp.CoapTcpMessage, ...]`: Consume a stream chunk and return every complete message in it. +- `finish() -> None`: Accept end-of-stream only when no partial message remains. +- `reset() -> None`: Discard an incomplete frame. #### `build_coap_tcp_csm` @@ -586,7 +586,7 @@ CoapTcpStreamDecoder(*, max_message_size: int = 4194304) build_coap_tcp_csm(*, receive_max_message_size: int | None = None, block_wise_transfer: bool = False, max_message_size: int = 4194304) -> bytes ``` -*function* — Build an RFC 8323 Capabilities and Settings Message. +*function*: Build an RFC 8323 Capabilities and Settings Message. #### `build_coap_tcp_delete` @@ -594,7 +594,7 @@ build_coap_tcp_csm(*, receive_max_message_size: int | None = None, block_wise_tr build_coap_tcp_delete(path: str, *, token: bytes | bytearray | memoryview = b'', query: collections.abc.Iterable[str] = (), accept: int | None = None, extra_options: collections.abc.Iterable[tuple[int, bytes | bytearray | memoryview]] = (), max_message_size: int = 4194304) -> bytes ``` -*function* — Build a DELETE for one absolute OCF href and bounded query. +*function*: Build a DELETE for one absolute OCF href and bounded query. #### `build_coap_tcp_get` @@ -602,7 +602,7 @@ build_coap_tcp_delete(path: str, *, token: bytes | bytearray | memoryview = b'', build_coap_tcp_get(path: str, *, token: bytes | bytearray | memoryview = b'', query: collections.abc.Iterable[str] = (), accept: int | None = None, extra_options: collections.abc.Iterable[tuple[int, bytes | bytearray | memoryview]] = (), max_message_size: int = 4194304) -> bytes ``` -*function* — Build a GET with repeated Uri-Path and Uri-Query options. +*function*: Build a GET with repeated Uri-Path and Uri-Query options. #### `build_coap_tcp_message` @@ -610,7 +610,7 @@ build_coap_tcp_get(path: str, *, token: bytes | bytearray | memoryview = b'', qu build_coap_tcp_message(*, code: int, token: bytes | bytearray | memoryview = b'', options: collections.abc.Iterable[tuple[int, bytes | bytearray | memoryview]] = (), payload: bytes | bytearray | memoryview = b'', max_message_size: int = 4194304) -> bytes ``` -*function* — Build one bounded CoAP-over-TCP wire message. +*function*: Build one bounded CoAP-over-TCP wire message. #### `build_coap_tcp_post` @@ -618,7 +618,7 @@ build_coap_tcp_message(*, code: int, token: bytes | bytearray | memoryview = b'' build_coap_tcp_post(path: str, payload: bytes | bytearray | memoryview, *, token: bytes | bytearray | memoryview = b'', query: collections.abc.Iterable[str] = (), content_format: int = 60, accept: int | None = 60, extra_options: collections.abc.Iterable[tuple[int, bytes | bytearray | memoryview]] = (), max_message_size: int = 4194304) -> bytes ``` -*function* — Build a POST with one bounded payload for an absolute OCF href. +*function*: Build a POST with one bounded payload for an absolute OCF href. #### `encode_uint_option` @@ -626,7 +626,7 @@ build_coap_tcp_post(path: str, payload: bytes | bytearray | memoryview, *, token encode_uint_option(value: int) -> bytes ``` -*function* — Encode a non-negative CoAP uint option in its shortest form. +*function*: Encode a non-negative CoAP uint option in its shortest form. #### `parse_coap_tcp_message` @@ -634,7 +634,7 @@ encode_uint_option(value: int) -> bytes parse_coap_tcp_message(data: bytes | bytearray | memoryview, *, max_message_size: int = 4194304) -> coap_tcp.CoapTcpMessage ``` -*function* — Parse exactly one complete bounded CoAP-over-TCP message. +*function*: Parse exactly one complete bounded CoAP-over-TCP message. ### `smartthings_local.protocol.ble_ocf` @@ -646,14 +646,14 @@ Pure IoTivity BLE transport framing for OCF PDUs. AdaptiveBleOcfReassembler(*, max_pdu_size: int = 1048576) ``` -*class* — Infer IoTivity's transport frame size from each first frame. +*class*: Infer IoTivity's transport frame size from each first frame. -- `feed(frame: bytes | bytearray | memoryview) -> ble_ocf.ReassembledBleOcfPdu | None` — Consume one frame, inferring a new frame size at PDU boundaries. -- `reset() -> None` — Discard any partial PDU and its inferred frame size. +- `feed(frame: bytes | bytearray | memoryview) -> ble_ocf.ReassembledBleOcfPdu | None`: Consume one frame, inferring a new frame size at PDU boundaries. +- `reset() -> None`: Discard any partial PDU and its inferred frame size. #### `BleOcfCodecError` -*exception* — An invalid or unsupported BLE OCF frame. +*exception*: An invalid or unsupported BLE OCF frame. #### `BleOcfHeader` @@ -661,11 +661,11 @@ AdaptiveBleOcfReassembler(*, max_pdu_size: int = 1048576) BleOcfHeader(start: bool, source_port: int, secure: bool, destination_port: int) ``` -*class* — Semantic representation of the two-byte IoTivity BLE header. +*class*: Semantic representation of the two-byte IoTivity BLE header. #### `BleOcfInterleavedFrameError` -*exception* — A frame belongs to a different PDU than the active reassembly. +*exception*: A frame belongs to a different PDU than the active reassembly. #### `BleOcfReassembler` @@ -673,10 +673,10 @@ BleOcfHeader(start: bool, source_port: int, secure: bool, destination_port: int) BleOcfReassembler(*, mtu: int, max_pdu_size: int = 1048576) ``` -*class* — Strict, single-PDU IoTivity BLE reassembly state machine. +*class*: Strict, single-PDU IoTivity BLE reassembly state machine. -- `feed(frame: bytes | bytearray | memoryview) -> ble_ocf.ReassembledBleOcfPdu | None` — Consume one complete GATT value and return a PDU when complete. -- `reset() -> None` — Discard any partial PDU. +- `feed(frame: bytes | bytearray | memoryview) -> ble_ocf.ReassembledBleOcfPdu | None`: Consume one complete GATT value and return a PDU when complete. +- `reset() -> None`: Discard any partial PDU. #### `ReassembledBleOcfPdu` @@ -684,7 +684,7 @@ BleOcfReassembler(*, mtu: int, max_pdu_size: int = 1048576) ReassembledBleOcfPdu(pdu: bytes, source_port: int, destination_port: int, secure: bool) ``` -*class* — A complete OCF PDU and the transport metadata that carried it. +*class*: A complete OCF PDU and the transport metadata that carried it. #### `decode_header` @@ -692,7 +692,7 @@ ReassembledBleOcfPdu(pdu: bytes, source_port: int, destination_port: int, secure decode_header(frame: bytes | bytearray | memoryview) -> ble_ocf.BleOcfHeader ``` -*function* — Decode and validate the header at the beginning of `frame`. +*function*: Decode and validate the header at the beginning of `frame`. #### `encode_header` @@ -700,7 +700,7 @@ decode_header(frame: bytes | bytearray | memoryview) -> ble_ocf.BleOcfHeader encode_header(*, start: bool, source_port: int, secure: bool, destination_port: int) -> bytes ``` -*function* — Encode the two-byte IoTivity BLE transport header. +*function*: Encode the two-byte IoTivity BLE transport header. #### `fragment_pdu` @@ -708,7 +708,7 @@ encode_header(*, start: bool, source_port: int, secure: bool, destination_port: fragment_pdu(pdu: bytes | bytearray | memoryview, *, mtu: int, source_port: int, destination_port: int, secure: bool, max_pdu_size: int = 1048576) -> tuple[bytes, ...] ``` -*function* — Fragment one non-empty OCF PDU into IoTivity BLE frames. +*function*: Fragment one non-empty OCF PDU into IoTivity BLE frames. ### `smartthings_local.protocol.owner_psk` @@ -720,7 +720,7 @@ Pure IoTivity manufacturer-certificate OwnerPSK derivation. derive_mfg_certificate_owner_psk(*, master_secret: bytes, client_random: bytes, server_random: bytes, owner_uuid: bytes, device_uuid: bytes, cipher_name: str, oxm_label: bytes) -> bytes ``` -*function* — Derive a 128-bit OwnerPSK from caller-supplied DTLS state. +*function*: Derive a 128-bit OwnerPSK from caller-supplied DTLS state. **Constants** @@ -748,7 +748,7 @@ StateCache(descriptor: '_ObservationHook') - `apply_rep(href: str, rep: dict, source: str) -> bool` - `freshness_s(href: str) -> float | None` - `get(href: str) -> dict | None` -- `index_device_tree(device0_body) -> dict[str, dict]` — Turn a /device/0 CBOR list-of-{href, rep} sweep response into a dict keyed by href. Some responses put a device-level `/device/0` rep first, while others put a normal resource in that slot. +- `index_device_tree(device0_body) -> dict[str, dict]`: Turn a /device/0 CBOR list-of-{href, rep} sweep response into a dict keyed by href. Some responses put a device-level `/device/0` rep first, while others put a normal resource in that slot. - `set_on_change(cb: Callable[[bool, str], NoneType]) -> None` - `snapshot() -> dict[str, dict]` - `stalest() -> tuple[str, float] | None` @@ -766,7 +766,7 @@ PollScheduler(session: DtlsCoapSession, cache: 'StateCache', tiers: list[poll_sc *class* - `run_forever(stop: threading.Event) -> None` -- `take_window_stats() -> tuple[float, int, int]` — Return (max RTT ms over successful polls, slow-poll count, timeout count) seen since the last call, and reset all three. Slow threshold is `self.slow_threshold_ms`. The timeout count is snapshotted... +- `take_window_stats() -> tuple[float, int, int]`: Return (max RTT ms over successful polls, slow-poll count, timeout count) seen since the last call, and reset all three. Slow threshold is `self.slow_threshold_ms`. The timeout count is snapshotted... - `write_in_progress(href: str, settle_s: float = 4.0) -> None` #### `PollTier` @@ -775,7 +775,7 @@ PollScheduler(session: DtlsCoapSession, cache: 'StateCache', tiers: list[poll_sc PollTier(name: str, interval_s: float, paths: tuple[tuple[str, ...], ...], active_interval_s: float | None = None, is_sweep: bool = False, timeout_s: float | None = None) ``` -*class* — PollTier(name: 'str', interval_s: 'float', paths: 'tuple[tuple[str, ...], ...]', active_interval_s: 'Optional[float]' = None, is_sweep: 'bool' = False, timeout_s: 'Optional[float]' = None) +*class*: PollTier(name: 'str', interval_s: 'float', paths: 'tuple[tuple[str, ...], ...]', active_interval_s: 'Optional[float]' = None, is_sweep: 'bool' = False, timeout_s: 'Optional[float]' = None) ### `smartthings_local.ocf.keepalive` diff --git a/docs/bridge-demo.md b/docs/bridge-demo.md index b5e86d0..3320f5b 100644 --- a/docs/bridge-demo.md +++ b/docs/bridge-demo.md @@ -22,7 +22,7 @@ in the README. - **HA Energy Dashboard ready** (dryer): live watts + cumulative kWh as `total_increasing`. - **Bridge logs tagged per-appliance** with `.` once each device's serial is read on connect: `dryer.` and `oven.` interleave in the same log stream, easy to grep. - **Zero HA YAML:** every entity is auto-discovered via MQTT discovery. -- **Your state stays on your LAN:** bridge → broker → HA. Samsung's cloud sees nothing from HA. *(The appliance still maintains its own TLS session to Samsung. That's the appliance's design, not ours.)* +- **Your state stays on your LAN:** bridge → broker → HA. Samsung's cloud sees nothing from HA. *(The appliance still maintains its own TLS session to Samsung. That's the appliance's design, not the bridge's.)* - **A few controls the cloud HA integration doesn't offer.** Talking to the appliance directly surfaces some writes the official SmartThings integration doesn't currently expose for these models: dryer course selection ([HA core #162501](https://github.com/home-assistant/core/issues/162501)) and the oven temperature setpoint (where the cloud integration provides a read-only sensor). It's not a strict superset (the cloud integration still covers surfaces this doesn't), but the reverse-engineered write set is broad. ## Under the hood @@ -33,6 +33,12 @@ On the currently supported firmware families, authentication uses a client cert --- +A write that the appliance reverts is absorbed by publishing optimistically and +verifying later: Home Assistant shows the new value, then the `PollScheduler`'s +next tier poll, deferred about 4s past Samsung's revert window, re-reads and +republishes the real state. The bridge does not fetch back immediately after a +write, because that GET is itself what triggers the revert. + ## How the app keeps in sync with the appliance There are two parallel paths between the appliance and the app over the local CoAP-DTLS socket: @@ -149,7 +155,7 @@ In HA: **Settings → Devices & Services → MQTT** should show both devices pop | Power on/off | ❌ | Accepted (2.04) but reverts within seconds; hardware-mirrored | | Child Lock / Remote Control toggle | ❌ | Same; hardware-mirrored physical buttons | -The dryer's `/operational/state/vs/0` is on the bridge's hot poll tier (1s idle / 0.5s while a cycle is active) and also accepts OBSERVE registration. When the appliance has internet it pushes notifications within ~100ms of any state change and the cache absorbs them as fast freshness; when air-gapped the hot-tier poll carries the same UX with worst-case lag of one tier interval. +The dryer's `/operational/state/vs/0` sits on the hot poll tier (1s idle, 0.5s during a cycle) and accepts OBSERVE registration, pushing within ~100ms of a change when the appliance has internet. ### Oven diff --git a/docs/certificates.md b/docs/certificates.md index f24d172..ca84732 100644 --- a/docs/certificates.md +++ b/docs/certificates.md @@ -28,7 +28,7 @@ This README doesn't pin the literal UUID: the setup script extracts it live each - Each currently supported Tizen/RT-OCF firmware family has a **factory-baked ACE** in `/oic/sec/acl` granting this UUID `perm=31` on `href=*`. - TizenRT iotivity derives peerId from `memmem(subject_dn, "uuid:")`, which is RDN-agnostic. A cert with the UUID in CN authenticates the same as one with it in OU. -- We don't need the matching private key from the original keyholder. We mint our own key and have `AC14K_M` sign our leaf. Different key, same identity, same access. +- The original keyholder's private key never comes into it: `setup_cert.py` mints a fresh key and has `AC14K_M` sign its leaf. Different key, same identity, same access. ## One-command setup diff --git a/tools/generate_api_docs.py b/tools/generate_api_docs.py index 056029e..278e0dc 100644 --- a/tools/generate_api_docs.py +++ b/tools/generate_api_docs.py @@ -185,7 +185,7 @@ def _render_member(name: str, obj) -> list[str]: lines += ["```python", signature, "```", ""] kind = _kind(obj) summary = _summary(obj) - lines.append(f"*{kind}*" + (f" — {summary}" if summary else "")) + lines.append(f"*{kind}*" + (f": {summary}" if summary else "")) if is_enum: lines.append("") for member in obj: @@ -208,7 +208,7 @@ def _render_member(name: str, obj) -> list[str]: member_summary = _summary(underlying) bullet = f"- `{rendered}`" if member_summary: - bullet += f" — {member_summary}" + bullet += f": {member_summary}" lines.append(bullet) lines.append("") return lines