Skip to content

feat(connection): add typed exceptions, fast send reconnect, and API hardening - #132

Merged
GreenGrassBlueOcean merged 2 commits into
masterfrom
feat/typed-exceptions-and-api-hardening
Oct 20, 2026
Merged

GreenGrassBlueOcean merged 2 commits into
masterfrom
feat/typed-exceptions-and-api-hardening

Conversation

@GreenGrassBlueOcean

@GreenGrassBlueOcean GreenGrassBlueOcean commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

What is the problem

  1. Untyped Dict/Boolean Error Returns Across OWNd Session & Gateway APIs (OWNd/connection/):
    • Callers of connect(), test_connection(), test_gateway(), send(), send_to_gateway(), and connect_to_gateway() had no structured exception hierarchy (OWNError, OWNConnectionError, OWNTimeoutError, OWNAuthError, OWNNackError, OWNProtocolError) to distinguish socket connection failures, timeouts, OPEN/HMAC authentication rejections, and gateway *#*0## (NACK) responses, nor a session.last_error attribute when using default return values.
    • During OWNEventSession.get_next() reconnect loops, runtime password/auth failures could not be surfaced to a dedicated callback (on_auth_error) or raised (raise_on_auth_error=True).
  2. Slow Multi-Attempt Reconnect Blocking Queued Callers Inside OWNCommandSession._locked_send():
    • When a command session socket dropped mid-flight, _locked_send() ran a full multi-attempt exponential backoff reconnect while holding _send_lock, stalling every queued caller behind it.
  3. Narrow Transport/Command Signatures, _ConnectionModule Proxy Overhead & Obsolete Golden Scratch Docs:
    • OWNTransport.send, AsyncTcpTransport.send, and AsyncSerialTransport.send typed message as str only instead of str | OWNMessage.
    • OWNGatewayCommand.set_datetime_to_now(), set_date_to_today(), and set_time_to_now() rejected datetime.tzinfo instances (such as ZoneInfo("Europe/Rome") or datetime.timezone.utc), accepting only str.
    • OWNd/connection/__init__.py installed a custom _ConnectionModule sys.modules proxy instead of clean static re-exports, and _locked_send() logged routine command send successes at INFO instead of DEBUG.
    • Obsolete scratch notes (tools/golden/MASSI_OUTREACH.md and tools/golden/MCP_NOTES.md) were still tracked in tools/golden/ and referenced in tests/golden/README.md.

How is it solved

  1. Typed Exception Hierarchy & Opt-In raise_on_error (OWNd/exceptions.py, OWNd/__init__.py, OWNd/connection/):
    • Added OWNError, OWNConnectionError, OWNTimeoutError, OWNAuthError, OWNNackError, and OWNProtocolError in OWNd.exceptions (re-exported from OWNd and OWNd.connection).
    • Added session.last_error: OWNError | None and opt-in raise_on_error: bool = False across connect(), test_connection(), test_gateway(), send(), send_to_gateway(), and connect_to_gateway().
    • Added on_auth_error: Callable[[OWNAuthError], Any] | None and raise_on_auth_error: bool = False on OWNEventSession / get_next().
  2. Fast Single-Attempt Reconnect with Cooldown in _locked_send() (OWNd/connection/command_session.py):
    • Bounded in-send() reconnect inside OWNCommandSession._locked_send() to a single fast attempt (max_attempts=1) guarded by SEND_RECONNECT_COOLDOWN = 2.0 seconds so queued callers fail fast instead of blocking behind exponential backoff.
    • Demoted routine command send success logs in _locked_send() from INFO to DEBUG.
  3. API Hardening, Static Re-Exports & Golden Scratch Cleanup (OWNd/connection/, OWNd/message/gateway.py, tools/golden/, tests/golden/README.md):
    • Widened OWNTransport.send, AsyncTcpTransport.send, and AsyncSerialTransport.send signatures to message: str | OWNMessage.
    • Updated OWNGatewayCommand.set_datetime_to_now(), set_date_to_today(), and set_time_to_now() to accept datetime.tzinfo | str via _resolve_tzinfo().
    • Replaced _ConnectionModule in OWNd/connection/__init__.py with standard static re-exports.
    • Removed obsolete scratch notes tools/golden/MASSI_OUTREACH.md and tools/golden/MCP_NOTES.md and updated tests/golden/README.md.

How is it tested

  • Full OWNd test suite (pytest tests -q) passing across Python 3.11–3.14 with 100.0% coverage on OWNd/exceptions.py, OWNd/connection/, and OWNd/message/gateway.py.
  • mypy OWNd (0 issues), ruff check, and scripts/sync_documentation.py --check all green.

State Diagram

stateDiagram-v2
    [*] --> Disconnected

    state "Connected Command Session" as Connected
    state "Single-Attempt Fast Reconnect (max_attempts=1)" as FastReconnect
    state "Cooldown Active (SEND_RECONNECT_COOLDOWN = 2.0s)" as Cooldown
    state "Raise OWNError / Record session.last_error" as ErrorOutcome

    Disconnected --> Connected : connect() -> ACK (*#*1##)
    Disconnected --> ErrorOutcome : connect() fails -> OWNAuthError / OWNConnectionError / OWNTimeoutError
    Connected --> Connected : send(cmd) -> ACK (*#*1##) / replies
    Connected --> ErrorOutcome : send(cmd) -> NACK (*#*0##) -> OWNNackError
    Connected --> FastReconnect : Socket closed & cooldown elapsed
    FastReconnect --> Connected : Reconnect succeeds -> write frame
    FastReconnect --> Cooldown : Reconnect fails -> stamp _last_reconnect_failure
    Cooldown --> ErrorOutcome : Subsequent queued send() within 2.0s fails fast (OWNConnectionError)
Loading

Where are the traces

  • Golden Conformance Corpus & Firmware Oracle (tests/golden/corpus.json, tests/golden/firmware_oracle.json):
  • Physical Plant Traces in OpenWebNet-HA/MyHOME:
    • MyHOME/tests/fixtures/traces/f461_live_2026-10-18/myhome_trace_F461_live_2026-10-18.json and f461_who14_live_2026-10-18/myhome_trace_F461_who14_live_2026-10-18.json (live F461 2.0.11 HMAC handshake, WHO=13 clock/timezone dimensions 0 & 22, and unpopulated-address 2.05 s bus timeout NACK captures).

What does the Oracle / Encyclopedia say

  1. Firmware Oracle (own-firmware-oracle / tests/golden/firmware_oracle.json):
    • Across the 12 emulated gateways (F450, F453, F453AV, F454, F459, F460, F461, H4684, MH200, MH200N, MH202, MyHomeServer1), command-session negotiations enforce either OPEN password challenge (*#98#...##) or HMAC-SHA256 (*#99*9## $\rightarrow$ *98*2##), answering *#*0## (OWNAuthError) on invalid credentials and *#*0## (OWNNackError) on unsupported WHO/dimension frames or unpopulated bus addresses (~2.05 s bus timeout on F460/F461/MyHomeServer1).
    • Single-session gateways (MH200, MH200N, F453, F453AV, H4684) drop idle command sockets aggressively; bounding in-send() reconnect to a single attempt with a 2.0 s cooldown prevents cascading lock contention when a gateway is unreachable.
  2. OpenWebNet Encyclopedia (functional/who-13-gateway-management/dimensions.md):
    • WHO=13 DIMENSION 0 (*#13**#0*H*M*S*TZ##), DIMENSION 1 (*#13**#1*0W*D*M*Y##), and DIMENSION 22 (*#13**#22*H*M*S*TZ*DOW*D*M*Y##) encode explicit signed UTC offset tokens (000..014, 101..112). Accepting datetime.tzinfo | str in set_datetime_to_now(), set_date_to_today(), and set_time_to_now() allows callers (such as Home Assistant's dt_util.get_time_zone()) to pass tzinfo instances directly alongside IANA timezone strings.

@codecov-commenter

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 98.18182% with 4 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
OWNd/connection/session.py 96.47% 0 Missing and 3 partials ⚠️
OWNd/connection/event_session.py 95.83% 0 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

@fedem95 fedem95 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tested merged with current master (a25024b): no conflicts, 1780 passed locally, CI green including the MyHOME suite. Also tried it on my MH201 (fw 3.6.27) with this branch's OWNd loaded from a temp dir, read-only frames only:

  • *#13**16## → reply in 0.02 s; after 200 s of silence ss showed the socket in CLOSE-WAIT (the MH201 closes idle command sessions after ~2.5-3 min); the next *#13**16## hit the dead socket, reconnected with a single attempt and got the reply in 0.07 s;
  • *#0## with raise_on_error=True → OWNNackError, last_error set.

Against a local fake gateway (not the MH201), per send():

  • EOF during negotiation: master 13.3 s / 5 TCP connects, this branch 0.05 s / 1 connect, and a second send() within 2 s makes no connect at all; same numbers when the fake sends *#*1## before closing;
  • reset during negotiation: master waited 60 s between attempts (I cut each send at 120 s, 3 queued sends still blocked after 200 s); this branch fails at once with one connect for all three.

So the lock stall is gone. Approving, with notes:

  1. The flip side, on an MH201 with exhausted session slots (it accepts TCP and closes during negotiation): a command used to get ~13 s of retries, now it is dropped after one attempt. Does MyHOME's worker retry a None?
  2. The description says set_datetime() / set_time() reject naive inputs, but neither function exists in OWNd; the diff only changes set_datetime_to_now, set_date_to_today and set_time_to_now.
  3. _resolve_discovery_fn in gateway.py exists only so tests can keep patching OWNd.connection.find_gateways/get_gateway/get_port; patching OWNd.connection.gateway.* in the tests would keep that out of the runtime path.
  4. tools/golden/MASSI_OUTREACH.md and MCP_NOTES.md are deleted without a mention in the description.

@GreenGrassBlueOcean
GreenGrassBlueOcean merged commit 08ea971 into master Oct 20, 2026
8 checks passed
@GreenGrassBlueOcean

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review @fedem95! Here is how each point has been addressed:

  1. MH201 with exhausted session slots & MyHOME worker retry behavior:
    In MyHOME (custom_components/myhome/gateway_sessions.py, CommandWorkerPool.sending_loop()), idle command sessions are closed after COMMAND_SESSION_IDLE_TIMEOUT = 15.0s. Whenever a command is dequeued while the session is closed (not session_is_open(_command_session) — the normal state after ≥15s of inactivity, or after any prior failed send() closed the streams), sending_loop() calls await _command_session.connect() explicitly on line 1329 before calling _command_session.send(). Direct connect() does not set _connect_max_attempts_override = 1, so opening a session from idle still gets the full MAX_CONNECT_ATTEMPTS = 5 (~13.3 s) retry loop when MH201 session slots are momentarily exhausted. Only when _command_session already held open streams (session_is_open(_command_session) == True) and the socket dropped mid-flight does _locked_send()'s single-attempt reconnect (max_attempts=1) run; if send() returns None, sending_loop() cancels the task's written future and does not re-queue the command (avoiding replay of non-idempotent commands whose ACK may have been lost).
  2. set_datetime() / set_time() in PR description:
    Updated the PR description above — those helpers belong to feat(message,profiles): add declarative expose frame builders, query helpers, and profile flags #129; this PR only updated set_datetime_to_now(), set_date_to_today(), and set_time_to_now() to accept datetime.tzinfo | str.
  3. _resolve_discovery_fn in OWNd/connection/gateway.py:
    Opened follow-up PR refactor(connection): remove _resolve_discovery_fn test shim and document MH201 retry semantics #133 removing _resolve_discovery_fn, _ORIG_FIND_GATEWAYS, _ORIG_GET_GATEWAY, _ORIG_GET_PORT, and import sys from OWNd/connection/gateway.py (alongside test(connection): patch OWNGateway.__module__ and document MH201 session-slot retry semantics MyHOME#758 updating the 3 test patches in MyHOME's conformance suite to patch OWNGateway.__module__).
  4. Deletion of tools/golden/MASSI_OUTREACH.md and tools/golden/MCP_NOTES.md:
    Added an explicit section to the PR description above documenting the removal of those obsolete scratch notes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants