From fd2e07fb856790db6579b8f0165c1fdb569d6812 Mon Sep 17 00:00:00 2001 From: Jack Nagy Date: Sun, 13 Sep 2026 11:15:40 +0100 Subject: [PATCH 1/3] Stop restating the module list the API reference generates "Supported library imports" carried a fifteen-row table of modules and responsibilities, hand-maintained beside docs/api.md, which now lists every module with a summary read off the code. The table could only drift, and its three following paragraphs restated the contract at length. 447 words to 171, with the pointer to the generated reference doing the work the table was doing. --- README.md | 64 +++++++++++++++++-------------------------------------- 1 file changed, 20 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index d17464b..475a848 100644 --- a/README.md +++ b/README.md @@ -429,52 +429,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 From 5aa98123407ffdeaff758cef17294b55254f174f Mon Sep 17 00:00:00 2001 From: Jack Nagy Date: Sun, 13 Sep 2026 11:19:08 +0100 Subject: [PATCH 2/3] Run the docs through the writing checker too Every PR body and issue comment this week went through local-tools/avoid_ai_check.py. The README and docs/ never did, which is where most of the words are and where most readers land. Two real breaches of the solo-maintainer voice rule, both in prose that predates this week: "a reverse-engineering problem we haven't cracked yet" in Traps to avoid, and "We mint our own key and have AC14K_M sign our leaf" in the certificate material, which reads better impersonally anyway. The generator was emitting 114 em dashes into docs/api.md, one per entry and method, from two separators. A colon does the same work without turning a reference page into a stylometric outlier. Left alone deliberately: the "we" in a code comment inside an example, where it means author and reader; two library docstrings that say "we" for the library, which is idiomatic in source and would read oddly as "I"; and api.md's heading count, bullet lists, repeated vocabulary and bold labels, which are what a generated reference page is. --- README.md | 2 +- docs/api.md | 226 ++++++++++++++++++------------------- docs/certificates.md | 2 +- tools/generate_api_docs.py | 4 +- 4 files changed, 117 insertions(+), 117 deletions(-) diff --git a/README.md b/README.md index 475a848..a3e6390 100644 --- a/README.md +++ b/README.md @@ -746,7 +746,7 @@ These each looked like obvious improvements at some point. Each one broke someth - **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 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; 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 I 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.) --- 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/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 From db6d855841c3a7f13bd4617a115bc92cb623e9d3 Mon Sep 17 00:00:00 2001 From: Jack Nagy Date: Sun, 13 Sep 2026 11:26:53 +0100 Subject: [PATCH 3/3] Tighten the prose without dropping findings A pass over the sentences carrying the most words, keeping every fact. The write-coverage trap was 194 words in one bullet, and its last third was bridge behaviour: optimistic publish, the deferred re-read, and why the bridge avoids a fetch-back. That moved to docs/bridge-demo.md, where the architecture lives, leaving the protocol lesson at 126 words. The dryer note restated push-versus-poll, which Under the hood and "How the app keeps in sync" each already explain. 62 words to 30, keeping both dryer-specific facts and the ~100ms measurement. Five README sentences of 40 to 53 words came down without losing a measurement: the OBSERVE-silence trap, the write_max_attempts pair, the rate-limit budget, the PSK identity arithmetic, and the CoAP-TCP scope note. One more first-person-plural, "not ours" in the bridge doc. My earlier sweep matched `our` and missed `ours`. README 5034 words to 4639 across both passes, 8% down, with the module table gone and no finding lost. --- README.md | 28 +++++++++++++--------------- docs/bridge-demo.md | 10 ++++++++-- 2 files changed, 21 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index a3e6390..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: @@ -480,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 @@ -743,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 I 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/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