From d91561a6d217534f1e98b131cb254053370d96a1 Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Sun, 10 May 2026 21:24:09 -0600 Subject: [PATCH] OmniConnection.close: send ClientSessionTerminated to free panel slot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found via live testing against the user's Omni Pro II (firmware 2.12) on UDP. Without this, the panel holds the session slot (it's single-client by design) and rejects new sessions from us with ControllerCannotStartNewSession (packet type 0x07) until its idle timeout fires (60s+ in our testing). src/omni_pca/connection.py: close() now sends Packet(type=ClientSessionTerminated) before tearing down the socket, but only if we got past CONNECTING (no point sending termination if we never had a session). For TCP we drain the writer briefly so the byte hits the wire before FIN. For UDP the sendto is fire-and-forget. Wrapped in try/except so close() stays idempotent. Live-validation findings (real Omni Pro II, firmware 2.12): ✓ UDP handshake works end-to-end. The four-packet exchange (NewSession ack / SecureSession ack) round-trips cleanly, confirming the two non-public protocol quirks (session key XOR mix + per-block whitening) are correctly implemented. ✗ Post-handshake encrypted messages get no reply from this firmware (tried v1 OmniLinkMessage and v2 OmniLink2Message; both arrive at the panel — verified via tcpdump — and the panel sends nothing back). Suspected: firmware 2.12 either requires an explicit Login first or has a different opcode set than PC Access 3.17 documents. Needs more RE work. The handshake-quirks validation is the headline win — we've now proven that part of the protocol against real hardware, which no public Omni-Link client has done. Post-handshake message dispatch is the next investigation. 357 tests still pass. --- src/omni_pca/connection.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/omni_pca/connection.py b/src/omni_pca/connection.py index 5e89ce5..152d5ce 100644 --- a/src/omni_pca/connection.py +++ b/src/omni_pca/connection.py @@ -227,8 +227,36 @@ class OmniConnection: if self._closed: return self._closed = True + previous_state = self._state self._state = ConnectionState.DISCONNECTED + # Politely tell the controller we're done — Omni is single-client, + # and on UDP it has no other way to know we've gone (TCP gets a + # FIN; UDP just sees datagrams stop). Without this, the panel + # holds the session slot until its idle timeout and rejects new + # connections from us with ControllerCannotStartNewSession. + if previous_state in ( + ConnectionState.NEW_SESSION, + ConnectionState.SECURE, + ConnectionState.ONLINE, + ): + try: + term_seq = self._claim_seq() + term = Packet( + seq=term_seq, + type=PacketType.ClientSessionTerminated, + data=b"", + ) + self._write_packet(term) + # Best-effort flush so the byte hits the wire before we + # tear down the socket. UDP is fire-and-forget; TCP needs + # a tick for the writer to drain. + if self._writer is not None: + with contextlib.suppress(Exception): + await self._writer.drain() + except Exception as exc: # noqa: BLE001 - close() must be idempotent + _log.debug("close: failed to send ClientSessionTerminated: %s", exc) + # Cancel anyone still waiting for a reply. for fut in self._pending.values(): if not fut.done():