Skip to content

Releases: QuiteYellow/SmartThings-Local

v0.1.21

Choose a tag to compare

@QuiteYellow QuiteYellow released this 28 Sep 18:36

Added

Responder enumeration for hosts that expose more than one OCF device.

  • read_ocf_responder(host, port) reads one responder's /oic/d identity
    (rt, di, name) and its advertised secure ports in a single call.
  • discover_ocf_responders(target, *, interface_address, ...) enumerates every
    responder at an IPv4 host and returns each with that identity, so two OCF
    devices behind one address can be told apart. It reads one responder at a time.
  • secure_ports_for_di(responders, di) selects a responder by its stored Device
    UUID, which is stable across the reboots that move the secure port.

These compose the existing discovery primitives and add no new wire behaviour.
Choosing which responder is the wanted appliance, and how to fall back when
multicast cannot cross a subnet, stay with the caller. The di returned here is
the plaintext one, for selection; it does not replace an authenticated
/oic/d.di check made after a handshake.

Motivated by the multiple-responders-per-IP case in mbillow/localthings#540.

v0.1.20

Choose a tag to compare

@QuiteYellow QuiteYellow released this 25 Sep 19:13
23a60ce

A repeated DTLS handshake message is now reported as repeated.

ProbeResult.handshake_msgs keyed its de-duplication on the message name,
so a peer that sent the same message twice was reported as having sent it
once. A re-issued HelloVerifyRequest is exactly that shape: the client
answers the first cookie request and ignores a second, so a peer that
keeps asking for one deadlocks the handshake until the deadline, and the
only record of it rendered as an ordinary cookie exchange.

Identity now comes from the message bytes. A retransmission repeats the
message verbatim and still collapses; a message differing in any byte is
listed again. Fragments of one message differ from each other without the
peer having said anything new, so identity is taken from the fragment at
offset zero and continuations are skipped.

handshake_counts is new: a Counter recording every transmission by name,
retransmissions included. A high count against a short handshake_msgs
says the peer kept resending what it had already sent.

Validated on hardware. The dryer and oven both complete with every count
at 1, the oven carrying six messages in eight datagrams: a fragmented
certificate chain, counted once. Three HelloVerifyRequests from a local
responder, two cookies between them, list twice and count three.

v0.1.19

Choose a tag to compare

@QuiteYellow QuiteYellow released this 25 Sep 19:49
2fe36db

Two behaviour changes to the OBSERVE path, both measured on hardware.

OBSERVE tokens widen from 1 byte to 8. The appliance firmware matches an
observer on the incoming token's length alone, across one global list, and
never clears relations when a DTLS session ends. A registration whose token
is already held is dropped in silence while the old relation keeps it, so
its notifications then arrive labelled with whatever href the token has
since been mapped to. Reproduced on hardware before the change; reported by
mbillow on mbillow/localthings#509.

OBSERVE notifications are merged into the cached representation rather than
assigned over it. A notification on these appliances can be a sparse delta
naming only what changed, and assigning one dropped every key it omitted
until the next sweep. Polls, sweeps, seeds and fetchbacks still replace, so
a key that really disappears is still honoured.

Tokens stay randomly allocated. Deriving them from the href is a second
change and waits for this one to be seen in the field.

v0.1.18

Choose a tag to compare

@QuiteYellow QuiteYellow released this 20 Sep 19:56
16ae1b2

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.

v0.1.17

Choose a tag to compare

@QuiteYellow QuiteYellow released this 12 Sep 16:26
e0b4843

Two library changes from #84 and #85, both validated against my oven and dryer before release, plus appliance clock synchronization in the reference bridge.

The handshake diagnostic takes an authentication provider (#84)

diagnose_dtls_handshake accepted certificate credentials and nothing else, so a PSK endpoint could be reached but never characterised. It now takes any provider from protocol.auth:

from smartthings_local.protocol.auth import PskAuth
from smartthings_local.protocol.dtls_probe import diagnose_dtls_handshake

result = diagnose_dtls_handshake(host, port,
                                 auth=PskAuth(identity=psk_identity, key=psk_key))

auth= is mutually exclusive with cert_pem/key_pem/cert_path/key_path, which keep working unchanged.

A diagnostic never enforces trust, whichever credential reaches it, and that now includes a SamsungServerProfile's pinned server identity. CertificateAuth configures a context to gate on OpenSSL's verdict, and honouring that here would turn an appliance's fatal alert into a local verify failure. The result has to report what the appliance did, so an untrusted chain is classified. DtlsCoapSession gates as it always has.

On my hardware the two credentials separate cleanly. My dryer answers auth=PskAuth with a throwaway key by negotiating ECDHE-PSK and rejecting the identity, rejected with unknown_psk_identity. My oven answers the same call with handshake_failure and an empty flight, the shape of a cipher-list mismatch. Both complete a certificate handshake. Two units and one handshake each, so that says nothing about any other model, but the two alerts do separate a wrong credential from a wrong carrier.

The module CLI carries the same thing as --diagnostic --psk-identity HEX --psk-key HEX. A command line is readable by every process on the host, so pass a throwaway value there.

handshake_msgs is shorter, and truthful. A handshake record after ChangeCipherSpec is encrypted, so its first byte is ciphertext, and the classifier was reading that byte as a message type. Around one byte in 256 collides with a known name, so a completed handshake could report a message the server never sent: over 30 loopback handshakes it invented a Finished once. The list now holds the plaintext flight alone. NewSessionTicket and unknown_psk_identity gained entries, where a bare hs4 and 115 appeared before.

A NUL in a PSK identity is refused with the reason (#85)

PskAuth rejected an identity containing a zero byte with a bare identity cannot contain a NUL byte, which read as this library being fussy. The constraint is OpenSSL's, and the refusal is the safe outcome.

OpenSSL's DTLS 1.2 PSK client callback returns the identity as a C string and takes its strlen. Measured over a real ECDHE-PSK handshake, reading psk_identity out of the ClientKeyExchange: a 16-byte identity with a NUL at byte 8 reaches the wire as 8 bytes, and nothing raises locally. An appliance is then asked to authenticate an identity nobody holds. Both CI dependency sets confirm it, on OpenSSL 4.0.0 and 3.1.0, and the measurement ships as a test.

An appliance has no such limit: RT-OCF and iotivity-lite both carry the identity as counted bytes. DTLS 1.2 offers no length-carrying PSK callback, which leaves such a credential unusable here, roughly 6% of uniformly random 16-byte identities.

PskAuth.validate_identity(identity) exposes the check without a key, raising with the reason, so code holding a credential can refuse it at import time and show why:

try:
    PskAuth.validate_identity(psk_identity)
except (TypeError, ValueError) as exc:
    print(f"unusable PSK identity: {exc}")

Reference bridge: appliance clock synchronization

mqtt_demo can write the bridge host's clock to an appliance that exposes currentTime, which keeps a panel's own timestamps usable on a unit with no route to Samsung's cloud. Each stamp is logged with the zone it was written in. Demo-only, with no library change behind it.

The demo's bind-mount path variable is now SMARTTHINGS_LOCAL_APPDATA_DIR. A generic name could be shadowed by another Compose project's exported value, which would silently mount a different project's appdata.

v0.1.16

Choose a tag to compare

@QuiteYellow QuiteYellow released this 06 Sep 15:27
2f727d5

Two changes from #80, both validated against my oven and dryer before release.

Registration answers are no longer mistaken for pushes (#41)

A device answers every OBSERVE register CON with the current representation, and that answer arrives on the observe token exactly as a spontaneous notification does. on_notification carries (href, payload) alone, so a consumer had no way to tell the two apart and could only guess from arrival order. That guess fails on an href carrying several query-qualified relations, where both registration answers look alike.

ObserveDelivery carries the whole relation context, and the new on_observe_delivery callback receives it:

def on_delivery(delivery):
    if delivery.registration:
        seed(delivery.href, delivery.payload)   # answered because we asked
    else:
        record_push(delivery.href, delivery.payload)

sess = DtlsCoapSession(..., on_observe_delivery=on_delivery)

registration is true for the answer to the register CON, on both the compliant path and the optionless one some firmware sends. query completes the relation identity. sequence is the Observe option value, or None where there is none. Setting the callback suppresses on_notification and on_legacy_notification, so a representation is delivered once.

The flag travels with a queued blockwise relation, so a re-read stays a registration answer. Retiring a token clears its recorded sequence, so a reconnect or the periodic refresh registers afresh with nothing tracked in the caller.

This is additive and keyword-only. The callback defaults to None, and a consumer that sets none of it keeps receiving every representation through on_notification exactly as before.

In the reference bridge it closes #41, where Push Active reported an appliance as pushing while it had no route to Samsung's cloud and was emitting nothing.

A failed handshake now names the alert

connect() turned every SSL.Error into a bare SessionError and dropped the exception, so a rejected handshake reached the operator as session operation failed and nothing more.

The alert now goes to the local log, narrowed to the reason strings OpenSSL recorded:

dtls handshake failed at the TLS layer: tlsv1 alert unknown ca

The raised SessionError is unchanged. Its redaction is deliberate, since backend errors elsewhere can carry remote endpoints, local paths or credential metadata. A path or host sitting in another slot of the reason tuple is dropped, so only the protocol vocabulary reaches the log.

Validation

739 tests pass. On hardware, the previous build put both reference appliances online 55s after connect and held them there for the full ten-minute window; this one logged no push_active online event across 35 minutes, with both units at 0 err, 0 ping-fail and 0 timeouts.

One thing recorded in passing while testing reconnects: a bridge killed with SIGKILL, leaving an orphaned association on the device, re-handshaked on the same 5-tuple and was accepted first time on both appliances. That is the RFC 6347 §4.2.8 behaviour the fixed local port depends on, checked before only on the oven.

v0.1.15

Choose a tag to compare

@QuiteYellow QuiteYellow released this 06 Sep 10:10
d01dad7

Four changes from @Jason-Morcos, completing the connection surface described in #75.

A dropped device-tree resource (#74)

StateCache.index_device_tree() skipped entry zero of a /device/0 batch unconditionally, on the assumption that the slot always holds the device container. Some layouts put an ordinary resource there instead. The href identifies the container; its position does not, so indexing now inspects every href-bearing entry and excludes /device/0 by name.

This is the one change here that alters existing behaviour. On my reference oven it recovers /connectionconfig/vs/0, discarded on every sweep since the function was written: seeded and swept link counts moved 16 to 17 after deploying. My dryer stays at 25, since its entry zero carries no href at all. The sweep response already contained the resource, so nothing extra goes on the wire.

First-use server identity (#76)

SamsungServerProfile.bound_device() needs a certificate UUID that a caller lacks before the first manufacturer-certificate session. SamsungServerProfile.discover_device() covers that one step. It keeps CA chain verification, the exact C=KR and O=Samsung Electronics and selected OU role checks, and rejects a missing, malformed, or nil UUID. Only the pin is relaxed.

DtlsCoapSession.server_certificate_identity exposes the verified subject UUID after connect() succeeds. The profile retains no learned identity, so it stays reusable.

The certificate UUID and the OCF device UUID from /oic/d are separate identities. Bind the two over the same authenticated session before persisting, then use bound_device() for later connections.

Opt-in HVR peer cleanup (#77)

On @Jason-Morcos's two WD53-family appliances, a failed initial certificate handshake can stop after the DTLS HelloVerifyRequest, leaving a half-open peer that swallows a clean retry.

connect(cleanup_hvr_peer=True) sends one epoch-zero fatal handshake_failure alert for that peer and raises HandshakePeerCleanupError, leaving the settle delay and any retry to the caller. It requires a SamsungServerProfile and a fixed non-zero local port, fires only after the deadline has already expired, and acts only when the whole observed transcript is ClientHellos out and complete HelloVerifyRequests in. A malformed, fragmented, mixed, or oversized transcript stays an ordinary SessionTimeoutError.

HandshakePeerCleanupError subclasses SessionTimeoutError, so existing handlers keep working. The flag defaults off, and the default path is unchanged.

Stock RT-OCF corroborates the mechanism. rt_ssl_error_check frees the peer with ssl_remove_peer_from_list on any non-exempt negative return and suppresses the return alert, so the peer goes and the server stays quiet. In that 2023 tree the return code is BAD_HS_CLIENT_HELLO: ssl_parse_client_hello reads the record header itself and rejects every content type other than handshake, ahead of the alert path. Samsung's shipped firmware differs from that source, and the appliances in #75 are the evidence this recovers them.

Supported import contract (#78)

The README documents the module-by-module public API and the additive 0.1.x compatibility policy. A compatibility test imports the exact symbols LocalThings, the reference bridge, and the downstream integration rely on, and every Python block in the README is syntax-checked.

718 tests pass. The library tree in this release ran five clean poll windows on both reference appliances, at 0 err and 0 timeouts.

v0.1.14

Choose a tag to compare

@QuiteYellow QuiteYellow released this 31 Aug 19:26
325dfe6

One fix this release, for appliances that answer a DTLS handshake from a port the client never dialled (#66, #73).

Replies from an unexpected source port (#66)

dtls_probe and DtlsCoapSession opened a connected UDP socket, which accepts datagrams only from the exact port it addressed. @elirnyk's capture on an NQ8300T oven shows a ClientHello going to 5684 and the answer arriving from 59768, followed by ICMP port unreachable from the client's own host. The probe reported a live appliance as dead, and a session retransmitted a cookie-less ClientHello until its deadline.

smartthings_local.protocol.endpoint now provides HostFilteredUdpSocket and open_host_filtered_udp_socket. They bind without connecting and filter inbound datagrams on host alone, which is what ocf_discovery already did. Both probe call sites and the session use them. open_connected_udp_socket is unchanged.

Sending stays on the port originally dialled. The capture in #66 shows the appliance still accepting there, and following the reply port instead would be a behaviour change with no evidence behind it.

Why an appliance does this

Stock RT-OCF binds only its multicast socket to a fixed port. In rt_udp_open_server, ucast_v4 and dtls_v4 both bind port 0, so the kernel assigns them, and rt_udp_get_secure_port_v4 reads the assigned port back to advertise as dport in /oic/res.

A secure port is therefore ephemeral by design and is reassigned across a reboot. Two practical consequences. An advertised port that moves between readings is expected. And 5684 is a guess on these devices, so read dport with discover_ocf_secure_ports.

Boundary

Off-path spoofing resistance drops from address-and-port to address alone. The DTLS cookie exchange and handshake authentication remain the real protection, as they already were behind a connected socket, and ocf_discovery had already made this tradeoff.

The RT-OCF reading above comes from the public source. The reporter's appliance runs a Samsung build that plainly differs from it: nothing in stock RT-OCF binds 5684, yet that oven answered a datagram sent there. Why it does remains open.

676 tests pass, six of them new: acceptance from another port, send-target stability, rejection of another host, IPv6 scope as part of host identity, and a bounded deadline under a flood of foreign datagrams.

v0.1.13

Choose a tag to compare

@QuiteYellow QuiteYellow released this 31 Aug 18:47
c4005f1

One change this release: the BLE framing layer that carries OCF PDUs over GATT (#72). It sits below the CoAP-over-TCP codec added in v0.1.12, since the BLE payload is the same reliable-transport CoAP message.

BLE OCF framing codec (#72)

Adds smartthings_local.protocol.ble_ocf. The two-byte IoTivity header carries a start flag, source and destination virtual ports, and a secure flag; a start frame then carries the PDU's total length as four big-endian bytes. fragment_pdu splits one PDU into frames, BleOcfReassembler reassembles at a known frame size, and AdaptiveBleOcfReassembler derives the peer's frame size from each start frame, which works on any GATT stack regardless of how it exposes the negotiated MTU.

Reassembly is strict. It enforces IoTivity's full-frame invariant on every non-final fragment, holds port and secure metadata constant across a PDU, bounds the declared length before allocating, and discards partial state after a malformed or interleaved frame. Result objects keep the PDU bytes out of repr.

mtu here means the complete characteristic-value frame size the IoTivity adapter uses. That sits below the raw ATT MTU: a default ATT MTU of 23 normally leaves 20 bytes for a characteristic value.

Boundary

The package is a wire codec. It opens no Bluetooth connection, selects no GATT characteristic, and performs no setup or ownership work. The secure bit is transport metadata, so a secure frame is not encrypted or authenticated by this code.

The two-byte header has no fragment sequence number. A duplicated or reordered full-size continuation with otherwise identical metadata cannot be told apart at this layer, and IoTivity itself relies on GATT's ordered delivery for that. Duplicate starts, orphan continuations, detectable missing or shortened fragments, and changed ports are all rejected.

Nothing in this repo calls the module yet.

v0.1.12

Choose a tag to compare

@QuiteYellow QuiteYellow released this 29 Aug 19:05
c686b01

The Observe relation lifecycle gets its own vocabulary this release: confirmed relations (#68), targeted refresh and unsubscribe (#69), two-phase shutdown (#67), and probationary delivery with paced teardown (#71). Alongside it, a CoAP-over-TCP wire codec (#70).

Two-phase DTLS session shutdown (#67)

close() now flushes an authenticated close-notify record before closing the socket. RT-OCF frees its DTLS peer on close-notify, so a session that ends this way does not leave the appliance holding state a quick reconnect then collides with.

Hosts that stop network work before their blocking executor drains can split the teardown. quiesce_for_close() is terminal: it interrupts an in-progress handshake, wakes pending requests and notification refetches, and rejects new work while keeping the established socket. A later close() finishes the job.

Confirmed Observe relations (#68)

An RFC 7641 relation is confirmed only by a valid Observe response option, and duplicate or stale 24-bit sequence values are no longer delivered. Some older Samsung firmware answers a registration once and omits that option entirely. For those devices a plain initial 2.05 is probationary until a later packet arrives on the same token with a different Message ID, which keeps a one-off answer from being mistaken for a lasting subscription. on_observe_pending, on_legacy_notification, and on_observe_error let a consumer keep that compatibility path separate from confirmed notifications and from ordinary polling.

Targeted Observe operations (#69)

refresh_observes(paths) renews only the relations that need it and leaves unrelated observations alone, returning the hrefs that succeeded and a failure count. unsubscribe(href) retires one relation. An operation lock serialises relation mutation, so a periodic refresh can no longer interleave re-subscribes onto a session that is closing.

The deregistration sweep in close() is now paced at the session rate limit, one request per interval.

Probationary delivery and paced quiesced teardown (#71)

Two follow-ups from the three above, both found after merge.

A probationary relation withheld both its payload and its status. The complete representation now reaches on_notification while the relation itself stays probationary, so the two decisions are separate: the state is useful immediately, the subscription still has to prove itself. Same-MID retransmissions stay suppressed, and a blockwise probationary response is re-read in full before delivery, so a caller receives the whole representation.

The two-phase path sent no deregistrations at all. quiesce_for_close() set the lifecycle cancel that the sweep checked, so the sweep was skipped and only close-notify went out. Relation metadata now survives quiescence, and the later close() sends exact paced deregistrations through a teardown-only send path before flushing close-notify. Ordinary requests stay rejected after quiescence; only the closing thread is admitted, and only for that sweep.

CoAP-over-TCP framing codec (#70)

Adds smartthings_local.protocol.coap_tcp: a dependency-free encoder and strict parser for RFC 8323 reliable-transport framing, a CSM builder for Max-Message-Size and Block-Wise-Transfer, GET/POST/DELETE helpers with repeated query options and bounded payloads, and an incremental decoder that buffers at most one incomplete frame and rejects an oversized declaration as soon as the length prefix completes. Decoded messages keep tokens, options, and payload bytes out of repr.

The motivation is Block2. Over UDP an RT-OCF appliance fits one block in one datagram and expects a Block2 continuation its request handler drops, so a large resource can only ever be read as block 0. The same read over CoAP-over-TCP returns complete in a single frame. This release ships the wire codec only: it chooses no carrier, opens no socket, and performs no setup or ownership operation.

Boundary

coap_tcp has no caller yet and no carrier. Note that CoAP-over-TCP carries no Message ID, so the token is the only thing matching a response to its request; the request builders currently default to an empty token, and anything that pipelines needs to allocate tokens and demultiplex on them.

#71's quiesced teardown is covered by tests alone, since the bridge in this repo calls the single-phase close(). What hardware did cover: two container recreates against a dryer and an oven, both reconnecting cleanly, and a graceful shutdown measured at 4.05 s from signal to exit with 11 relations per appliance, which matches the paced sweep and sits well inside a 10 s stop grace. That figure comes from process log timestamps. A wire capture would be the stronger evidence, and remains open.