v0.1.18
Discovery gets the bulk of this release: the port probe had a correctness bug, and the bridge now asks an appliance for its secure port. The certificate script drops its throwaway CA and its network call. Two packaging fixes land here that could only ever take effect on a release.
Validated on my dryer and oven over several stop/start cycles. Both are OIC 1.1 appliances, so the directory shape below is n=2, and no standard-port or newer-PKI device has been tested.
The port probe selects on the port that answered (#98)
probe_dtls_ports read "a reply arrived after dialling port N" as proof that a DTLS server listens on N. It does not. An OCF stack binds its DTLS socket to port 0 and answers a first flight from that ephemeral port whatever port was addressed, and the probe's socket filtered inbound on host alone, so every port dialled looked live. The old code reported 5684 for an appliance whose secure port is 49155.
Selection now runs on the port a reply came from:
result = probe_dtls_ports(host, ports=[5684, 49154, 49155])
result.selected_port # proven, and the one to dial
result.responder_ports # the distinct ports replies came from
result.live_ports # ports dialled that drew a reply (diagnostics)live_ports keeps its name and loses its authority; responder_ports is what proves a listener. Each DtlsLivenessResult carries responder_port alongside the port dialled.
One behaviour change to know about: a reply whose source port went unrecorded now yields unreachable. It used to fall back to the port dialled, which only restored the rule this replaces. A regression in the socket layer therefore surfaces at connect time, where the old fallback would have opened a session confidently against the wrong endpoint.
Docker's bridge NAT had been discarding the mismatched replies, standing in for a correctness check the probe never had. That is why this stayed hidden for so long.
Discovery asks the device before sweeping (#98)
discover_ocf_secure_ports now reads both advertised forms of the secure port from whichever directory answer arrives. It used to read one form per lookup. Which form a device emits follows its spec generation: OCF 1.0 and later carry eps, while an OIC 1.1 device carries p.sec/port on its doxm link and no eps key anywhere. Reading one form per lookup charged every such device a round trip to learn what its first answer already said.
The doxm narrowing is unchanged and is the point. An eps URI is validated against the address the response came from; a bare p.port integer carries no address, so it is read only from the link that has to describe the device's own secure endpoint.
In the reference bridge, _resolve_port runs pinned OCF_PORT → cached port → /oic/res on 5683 → band sweep, and every tier's answer goes through the same probe, so an advertisement is only dialled once it has answered one. Each tier names itself in the log, because a swept port means the directory read got nothing out of that device, and that is the detail a bug report turns on.
The band sweep stays. 5683 is mandated only as the multicast listen port; it answers unicast because every OCF stack I have read wildcard-binds that socket, which is a strong convention with no clause behind it.
A peer that opens its own handshake is not a fault (#98)
After the bridge has been away, my dryer answers its ClientHello with a ClientHello of its own, and OpenSSL, being the client, rejects it. That read as a session fault, with a warning, an error count bump and a backoff, for something that clears itself in about two seconds.
connect() now raises PeerInitiatedHandshakeError when an inbound epoch-0 ClientHello preceded the failure:
from smartthings_local.errors import PeerInitiatedHandshakeError
try:
session.connect()
except PeerInitiatedHandshakeError:
time.sleep(0.5) # the peer drops its half as it rejects ours
session.connect()The caller owns the retry policy. The reference bridge retries four times at 0.5s without touching its error count or growing its backoff, then starts counting collisions as faults so a device that keeps refusing cannot drive a 2 Hz handshake loop while the health topic reports everything fine.
The certificate script mints one self-signing leaf (#95)
setup_cert.py --self-signed generated a CA with a random name and signed the leaf with it. Nothing trusts that CA and the device never looks above the leaf, so the leaf now signs itself and the run writes three files fewer.
mbillow ran the same one-variable comparison on two appliances I do not have, a dishwasher and a refrigerator (#96): a pure self-signed leaf read /oic/sec/acl at 2.05 on both, alongside a throwaway-CA leaf and an AC14K_M baseline, with a wrong-UUID control refused 4.01. Re-run here, the self-signed leaf reads the whole panel byte for byte with the AC14K_M baseline in the same run. Four appliances across three families is the whole evidence base.
The default path also makes no network calls now. Each run used to open a TLS connection to a vendor cloud host to read a UUID out of a certificate subject, and that value does not change: two certificates issued years apart, under different sub-CA generations, carry the same one. CLIENT_UUID holds it, and UUID=<uuid> still overrides.
Packaging and the package page (#93, #87, #86)
The sdist carries the library and its metadata, and stops shipping tests/, mqtt_demo/ and setup_cert.py. Those are in-repo development material, and nothing had ever run them from an sdist, because the packages they import stayed behind. It halves: the 0.1.17 sdist was 225833 bytes on PyPI and this one is 111167. The wheel was already library-only and is unchanged.
Every README link now resolves on the package page, and the supported API ships as a generated reference in docs/api.md, rendered from the same contract the test suite enforces, so a signature change cannot drift from the page.
For anyone running the reference bridge
mqtt_demo/docker-compose.yml moves to host networking, and the fixed DTLS source port base moves from 49700 to 26849 with it. Bridge NAT keys its conntrack entry on the port dialled. A reply from any other port reads as an unrelated inbound flow, the kernel drops it, and that silently ruled out the 5683 path. Host mode puts that bind on the host, so the base has to sit outside Linux's default net.ipv4.ip_local_port_range of 32768-60999, where an unrelated process can hold it and the bind fails EADDRINUSE. A container namespace had been isolating it. If MQTT_HOST names a Docker service, it stops resolving under host mode; give it an address. None of this affects the library.