OmniConnection.close: send ClientSessionTerminated to free panel slot

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.
This commit is contained in:
Ryan Malloy 2026-05-10 21:24:09 -06:00
parent 81725b4dbf
commit d91561a6d2

View File

@ -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():