Commit b9b768a
* fix: register device operation before dispatch to close the state_changed race
control_device_smart stored the pending operation AFTER awaiting call_service,
so a fast entity's state_changed could arrive before the op existed and be
dropped, leaving the op PENDING until a coincidental later event or timeout.
Register before dispatch; on dispatch failure flip the op to FAILED so a later
unrelated event can't spuriously complete a write that never happened.
* feat: component call_service capability with authoritative ha_mcp_tools domain block
The first Phase 3 write capability (issue #1813): an in-process
`ha_mcp_tools/call_service` WS command that fires exactly one
`hass.services.async_call` and returns the REAL pre->post state transition for
the target entities, event-confirmed via an `EVENT_STATE_CHANGED` listener
registered BEFORE the dispatch (D5) rather than a hardcoded expected-state guess.
All awaiting work lives in the async `_call_service_prep`; `_do_call_service` is
a pure formatter (D2). An authoritative component-side domain block refuses
`domain == "ha_mcp_tools"` before `has_service`/dispatch (D1), independent of the
server-side guard, so this second write path can never be turned into an
in-process invoker of the admin-gated `ha_mcp_tools.*` services. A confirmation
timeout is `partial`, never a failure (D4); pre-dispatch problems propagate for
the server to map (D7). Component side only; the server consumer and
bulk_call_service are separate later tasks.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FBYnVcu8ueWjeCfVTTDAc
* feat: component bulk_call_service capability (register-before-fire batch)
The batch write capability (Phase 3, D5a — issue #1813): an in-process
`ha_mcp_tools/bulk_call_service` WS command that runs the authoritative D1
`ha_mcp_tools` domain block for EVERY operation FIRST — before any pre-state
read, listener registration, or dispatch — so a batch is fail-closed: one
refused op (the guarded domain or an unknown service) raises the whole frame and
NOTHING is dispatched. No partial batch can smuggle a `ha_mcp_tools.*` op past
the guard.
It then registers ALL confirmation listeners in one synchronous pass BEFORE any
dispatch (register-before-fire is trivially correct for the batch), fires the
operations (`parallel` by default via `asyncio.gather(return_exceptions=True)`,
or sequentially), and waits on ONE shared deadline for every op's transition. A
per-op `async_call` failure under `parallel` is captured on that op's result
(`error` + `dispatched: false`) WITHOUT aborting the others; a post-dispatch
confirmation timeout is `partial`, never a failure (D4). All awaiting work lives
in the async `_bulk_call_service_prep`; `_do_bulk_call_service` is a pure
formatter that reuses the single `call_service` guard / transition / diff
helpers. Component side only; the server consumer is a separate later task.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FBYnVcu8ueWjeCfVTTDAc
* feat: route ha_call_service and ha_bulk_control through the write capabilities
* chore: bump component to 1.3.0 for the call_service write capabilities
* test: guarantee EVENT_STATE_CHANGED const and isolate caps probe for full-suite runs
* fix: address Phase 3 review — post-send at-most-once, loop-thread confirmation listener, non-confirmed result content
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FBYnVcu8ueWjeCfVTTDAc
* fix: close Phase 3 write at-most-once gaps from round-2 review
- add HomeAssistantCommandNotSent (never-sent subtype) raised only at
send_command's two pre-send sites; both write consumers catch it first and
fall back to legacy, while a post-send drop stays ambiguous (C1)
- treat a malformed/unusable SUCCESS envelope as ambiguous, not legacy, so an
already-landed write is never re-fired (I2)
- make the component's post-dispatch formatting total: raise-proof attribute
diff (_values_differ) plus a try-wrap degrading to a dispatched-but-unconfirmed
envelope, so a command error is genuinely pre-dispatch (I1)
- ride under the pending 1.2.0 (revert the 1.3.0 over-bump) (I3)
- minors: PENDING-only fail_pending_operation, per-op bulk frame timeout,
dispatched_unconfirmed status, verbose routes to legacy, None new_state is not
confirmed, accurate ambiguous progress message
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FBYnVcu8ueWjeCfVTTDAc
* style: ruff format round-2 test file
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FBYnVcu8ueWjeCfVTTDAc
* test: raise TypeError in array-like test double for CodeQL gate
py/unexpected-raise-in-special-method flags a __bool__ that always raises
ValueError; TypeError is the conventional bool-coercion failure and is
immaterial to _values_differ (catches any Exception).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FBYnVcu8ueWjeCfVTTDAc
* fix: treat a send() failure as ambiguous and close three write-routing bugs
- send_command: a send_json_message failure re-raises the ORIGINAL exception
(ambiguous — bytes may already be on the socket), not HomeAssistantCommandNotSent;
the readiness guard stays the one provably-never-sent site. Consumers then treat a
send failure as ambiguous/partial (never re-fired), only the readiness case as legacy
- _bulk_via_component: route the whole batch to legacy when an entity_id repeats (the
component's per-entity waiter collapses both ops onto the first state_changed)
- _maybe_component_call_service: a comma-separated (multi-target) entity_id routes to
legacy (the component confirms one literal entity_id, yielding a false partial)
- _bulk_frame_timeout: honor an explicit timeout_seconds=0 (only an absent key -> 10)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FBYnVcu8ueWjeCfVTTDAc
* fix: confirm component writes on the expected state, not the first event
The component confirmed a write on the FIRST state_changed event for a
target, a regression vs the legacy expected-state verifier: a multi-phase
service (lock -> locking -> locked) confirmed on "locking", an attribute-
only noise tick confirmed a state that never changed, and an idempotent
no-op (turn_on already-on, no event) waited out the full timeout then
falsely reported partial.
The server now passes its _SERVICE_TO_STATE.get(service) expected primary
state to the component as an optional confirmation-timing hint. The
component confirms only on reaching that state (skipping intermediate and
noise events), immediate-matches when the current state already equals it
(idempotent no-op, no wait), and falls back to today's any-first-event
behavior when no hint exists. The returned transition is still the real
observed one; the hint governs timing only.
_SERVICE_TO_STATE moves to util_helpers as the single source of truth,
imported by both the single (tools_service) and bulk (device_control)
write paths.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FBYnVcu8ueWjeCfVTTDAc
* style: use dict.fromkeys for constant-value expected_by_entity (C420)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FBYnVcu8ueWjeCfVTTDAc
* chore: allowlist _SERVICE_TO_STATE cross-module CodeQL false positive
The map moved to the leaf util_helpers module and is imported by
tools_service and device_control; CodeQL's single-file analysis misses
the cross-module read. Same class as the existing _tools_meta suppression.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FBYnVcu8ueWjeCfVTTDAc
---------
Co-authored-by: kingpanther13 <kingpanther13@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 692275d commit b9b768a
18 files changed
Lines changed: 5120 additions & 42 deletions
File tree
- custom_components/ha_mcp_tools
- scripts
- src/ha_mcp
- client
- tools
- utils
- tests/src/unit
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
113 | 113 | | |
114 | 114 | | |
115 | 115 | | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
116 | 126 | | |
117 | 127 | | |
118 | 128 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
48 | 48 | | |
49 | 49 | | |
50 | 50 | | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
51 | 69 | | |
52 | 70 | | |
53 | 71 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
23 | 23 | | |
24 | 24 | | |
25 | 25 | | |
| 26 | + | |
26 | 27 | | |
27 | 28 | | |
28 | 29 | | |
| |||
616 | 617 | | |
617 | 618 | | |
618 | 619 | | |
619 | | - | |
| 620 | + | |
| 621 | + | |
| 622 | + | |
| 623 | + | |
| 624 | + | |
| 625 | + | |
620 | 626 | | |
621 | 627 | | |
622 | 628 | | |
| |||
635 | 641 | | |
636 | 642 | | |
637 | 643 | | |
| 644 | + | |
| 645 | + | |
| 646 | + | |
| 647 | + | |
| 648 | + | |
| 649 | + | |
| 650 | + | |
638 | 651 | | |
639 | 652 | | |
640 | 653 | | |
| |||
0 commit comments