With no cycle set the oven reports x.com.samsung.da.desired = 0, and
flatten() published that straight through as target_temp_c. Home
Assistant rejects it against the Number entity's declared 30-270 range
on every publish, which produced 66,899 log errors over three weeks:
Invalid value for number.samsung_oven_setpoint: 0 (range 30.0 - 270.0)
0 is not a 0 degree target, it is the absence of a setpoint, so treat
anything outside the settable band as absent. null lands as unknown on
both the Number and the Setpoint sensor, the way completion_minutes
already reads when the oven is idle. _setpoint applied these bounds on
the write side already; only the read path was missing them.
Adds the first tests for the sample descriptors. One of them pins a
non-obvious asymmetry: the write path snaps to the 5 degree step grid
before bounds-checking, so 29 commits as 30 and 271 as 270, and only 0
is refused outright. The invariant that has to hold is the weaker one,
that every value the write path commits is one flatten() will publish
back, or a write appears to succeed and then reads as unknown.
The refetch path logged only at debug, and the bridge configures logging
at INFO, so a successful re-read and a total failure produced identical
output: nothing. That makes the hardware validation for #39 impossible
to read.
One line per refetch, promoted to INFO when DEBUG_BRIDGE=1 and left at
debug otherwise, naming the href, the one-shot token, the block count,
and the reassembled size. The token is the part that matters: it is what
shows the re-read used a fresh 4-byte token rather than the observation's
1-byte one, which is the assumption the whole design rests on.
Gating on DEBUG_BRIDGE rather than raising the logger keeps the per-block
retransmit lines out of the way, and matches how the module already gates
its frame dump.
A notification carries only the first block of a large representation
(RFC 7959 §2.6). _dispatch_coap handed that block straight to
on_notification, so consumers decoded a truncated CBOR buffer. Reported
twice on /mode/vs/0: #37 and mbillow/localthings#361.
The recovery is a re-read from block 0 on a fresh 4-byte one-shot token,
not a §2.6 continuation. §3.4 rules out reusing the observation's token,
and this server drops a transfer that opens at NUM>0 under a token it
has not seen, so a continuation is the one shape that cannot work here.
A truncated notification is now withheld and queued to a worker thread
that re-reads the resource and delivers the reassembled representation.
When the re-read fails the notification is dropped at debug level and
the poll tiers carry freshness, which is what they already did.
The re-read has to run off the reader thread: _dispatch_coap runs there
and the transfer waits on an event only that same thread can set. The
worker is serialized and paces between transfers, so a notification
storm stays under the firmware request ceiling.
Also in the Block2 loop, now extracted and shared by both paths:
- compare the response's Block2 NUM against the one requested, so a
retransmitted block is no longer concatenated as if it were the next
- compare ETags across blocks (§2.4) and restart once when the
representation changes mid-transfer
- recompute the next block number from the accumulated byte offset when
the server negotiates the block size down
- re-check reader liveness while waiting on a block, so a mid-transfer
reader death fails fast instead of burning the whole timeout
- guard the token counters and _pending with a lock, now that the
session issues concurrent reads of its own
Closes#39
The reader loop exited silently on any socket error, leaving conn/sock
set so the session still looked open. Every later get()/post()/ping()
then waited out its full request timeout on a session nobody was
reading, raising SessionTimeoutError on repeat, forever.
This started biting in v0.1.3 (d677c72), which moved to connected UDP
sockets: a connected socket surfaces ICMP errors on recv, so one
ECONNREFUSED from a rebooting appliance now killed the reader.
- Advisory ICMP errnos (ECONNREFUSED/EHOSTUNREACH/...) no longer kill
the reader; the next datagram usually works.
- Real reader exits log at WARNING; close()-driven exits stay quiet.
- A _reader_running Event lets get/post/ping/subscribe/refresh_observes
fail fast via _check_live() with SessionClosedError instead of waiting
out a timeout. Callers that never start a reader are unaffected.
Refs QuiteYellow/SmartThings-Local#37
* fix(setup_cert): surface openssl errors and work around SHA-1 crypto policy
The signing step forces -sha1 (the AC14K_M chain requires SHA-1-signed
leaves), which Fedora/RHEL's default crypto policy rejects on OpenSSL
3.x. run() also swallowed stderr, so the failure surfaced as an opaque
non-zero-exit traceback with no diagnostic.
- run() now raises CommandError carrying the command and openssl stderr
- mint_cert retries signing with a scoped OPENSSL_CONF enabling
rh-allow-sha1-signatures when the first attempt fails
- main() prints the update-crypto-policies fallback on failure
Fixes#15
* test(setup_cert): cover SHA-1 signing, error surfacing, and crypto-policy retry
Regression tests for the #15 fix:
- full mint_cert flow (SHA-1 leaf, UUID SAN, custom OIDs, chain assembly)
- CommandError surfaces openssl stderr on a genuine signing failure
- signing retries via the SHA-1 override when the plain attempt is blocked
- run() raises CommandError with detail
A stateless-by-default DTLS ClientHello probe that classifies a host:port
as DEAD/LIVE/COMPLETED/REJECTED in ~1 RTT off the server's first flight,
sitting in front of the full handshake.
Probe (smartthings_local/protocol/dtls_probe.py):
- Stateless liveness mode (default): stops at HelloVerifyRequest and never
sends the cookie'd second ClientHello, so by RFC 6347 §4.2.1 it leaves
no association on the device — safe to run before a real connect.
- Diagnostic mode (stateless=False): drives the handshake further to
capture cipher/cert-chain/CertificateRequest or a fatal Alert, for
OCF-PKI-wall characterization (#16). Kept out of hot reconnect paths.
- Retransmit + retries: services OpenSSL's DTLS retransmit timer so a
single dropped ClientHello no longer reads as a false DEAD.
MQTT bridge (mqtt_demo):
- Stateless pre-flight gate in session_once() rejects a silent/rebooting
device or wrong port in ~3s (retries=1) instead of eating the 12s
HANDSHAKE_TIMEOUT_S per reconnect.
- OCF-band port autodiscovery when OCF_PORT is unset: races the band in
parallel and returns on the first port to answer LIVE (~1 RTT, abandoning
the dead-port probes), cached across reconnects; the stateless gate
leaves no orphan, preserving the fixed-source-port §4.2.8 invariant.
Validated on real hardware (dryer 49155 / oven 49154): parallel discovery
resolves both ports in <1s, connect with no orphan cooldown, and a wrong
pinned port rejected in ~3s.
Tests: probe behaviour (retransmit recovery, stateless single-flight
guard, silent-port flight budget, diagnostic continuation) and bridge
port-resolution (pinned gate, parallel discovery early-exit, cache).
localthings mints its client cert at runtime through the HA config flow
and never writes it to disk. DtlsCoapSession only accepted cert_path/
key_path (file-based), which would have forced localthings to write its
in-memory cert/key to disk on every connect just to migrate off its
vendored copy of this transport layer.
Adds an alternate cert_pem/key_pem constructor path (ported from
localthings' own _load_pem_chain), validated so exactly one cert source
(file pair or PEM pair) is required. Existing file-path callers
(mqtt_demo, setup_cert.py) are unaffected — verified against both real
appliances with each constructor path.
Nest the two library packages under a single import namespace so they
can ship as one distribution:
protocol/ -> smartthings_local/protocol/
ocf/ -> smartthings_local/ocf/ (git mv, history preserved)
- Rewrite all imports protocol.* -> smartthings_local.protocol.*,
ocf.* -> smartthings_local.ocf.* across the ocf modules, mqtt_demo/
(bridge, descriptor, samples), and tests.
- Add pyproject.toml: dist name `smartthings-local`, hatch-vcs versioning
from v* tags, wheel ships only smartthings_local/.
- Add .github/workflows/publish.yml: build + PyPI Trusted Publishing on
v* tags (OIDC, no stored token).
- Force-include protocol/ocf_root_ca.pem via [tool.hatch.build] artifacts:
it is tracked but matches .gitignore's *.pem, so hatchling's VCS file
selection would drop it — and dtls_session.py loads it at runtime.
- Update mqtt_demo Dockerfile COPY and deploy.sh tar allowlist to the
single smartthings_local/ package.
- .gitignore: build artifacts (_version.py, dist/, *.egg-info/).
Validated: pytest tests/ (11 passed), python -m build produces sdist +
wheel with the pem bundled, fresh pip install resolves all nested imports
with the pem readable from site-packages.
- Add test_import_isolation.py: isolation test that copies protocol/ and ocf/
to a temp directory without mqtt_demo/ and verifies all modules import
- Add conftest.py: pytest configuration to add repo root to sys.path,
enabling tests to import protocol/ and ocf/ packages