17 — Services — Subsystem Test Map¶
The services subsystem is the HA service-call layer: thin async handlers that
resolve call data, delegate to the manager, and wrap failures as
HomeAssistantError / ServiceValidationError (the HA Silver action-exception
contract). Covered by 247 tests across 16 files.
Source: custom_components/eufy_vacuum/services/
Architecture reference: docs/dev/02-ha-integration.md
Scope. This doc covers only the services/ package — the handlers wired by
async_register_services. The integration registers many more eufy_vacuum.*
services from sibling packages, and those are tested under their own subsystem
docs, not here:
- Map/segment/layout/live-pose services in
mapping/mapping_services.py(registered viaasync_register_mapping_services) — see 07-mapping.md. - Learning + external-run services in
learning/services.py— see 06-learning.md. - Theme services in
themes/services.py— see 14-themes.md.
Rename repair (RS) — the manual half of D4¶
setup_repair_renamed_vacuum exists because the automatic migration is driven by a listener that
only records renames it was running to see. A rename from before that shipped left no record, and
Home Assistant does not keep the old entity id anywhere — so only the user can supply it.
Covered by tests/unit/test_services_repair_renamed_vacuum.py. Two targets carry the reasoning:
RS-4— a collision changes nothing by default and names what is in the way. This is the expected first call, not an edge case: the new id normally holds the empty recordensure_vacuum_recordcreated on the first restart after the rename. Reporting has to precede destruction, because a user cannot consent to discarding something nobody told them about. Ablating the default to always-overwrite turnsRS-4red on its own.RS-6— the response carriessections_movedandtree_moved, which are written bycore/manager.py::_apply_pending_entity_renamesand not by the service. Their presence is the evidence that the service delegated rather than growing a second, divergent migration.
Coverage map¶
| Source module | Stmts | Cov | Test file | Mocking |
|---|---|---|---|---|
job_control.py |
141 | 98% | test_services_job_control_read.py, test_services_job_control_write.py |
bare x10 |
run_profiles.py |
104 | 100% | test_services_run_profiles.py |
bare x1 |
adapter_config.py |
106 | 96% | test_services_adapter_config.py |
clean |
setup.py |
208 | 76% | test_services_errors_setup.py |
bare x2 |
dock.py |
80 | 100% | test_services_dock.py |
bare x5 |
room_profiles.py |
81 | 100% | test_services_room_profiles.py |
clean |
rooms.py |
108 | 98% | test_services_rooms.py |
bare x1 |
maintenance.py |
47 | 100% | test_services_maintenance_reset.py |
clean |
queue.py |
135 | 96% | test_services_queue.py, test_services_unmanaged_vacuum.py |
bare x1 |
snapshots.py |
50 | 93% | test_services_snapshots.py, test_services_unmanaged_vacuum.py |
clean |
errors.py |
40 | 96% | test_services_errors_setup.py, test_services_unmanaged_vacuum.py |
bare x2 |
access_graph.py |
34 | 88% | test_services_access_graph.py |
clean |
clean_order.py |
42 | 96% | test_services_clean_order.py |
spec'd |
_common.py |
54 | 94% | test_services_common.py, test_services_misc.py, test_services_unmanaged_vacuum.py |
clean |
stall_capture.py |
43 | 37% | — | - |
debug.py |
8 | 100% | test_services_misc.py |
clean |
What's tested¶
- A service exists because something CALLS it (
SNP-5) —test_services_snapshots.py.get_maintenance_source_candidatessat on the manager for a day with zero callers: no service, no snapshot field, nothing. It was written as "the picker's read half" and the picker could not have read it, while the suite stayed green throughout —f/audit_callsite_reachabilityexactly, a correct function whose absence of a call site no test could see.SNP-5therefore asserts the service answers, not the payload shape:has_service(...)first, then a real call.SNP-5bcarries theINKV8ZQDread guard so asking about an unmanaged vacuum cannot mint a record for it. Ablated: deleting theasync_registerblock reddens both, which is precisely the state the function shipped in.
Why the response is a dict wrapping a list: a HA service response must be a mapping, and
candidates stays a list even when empty — the card distinguishes "the fetch has not landed"
(null) from "there is nothing to offer" ([]), and returning the wrong one shows an
empty-state to somebody whose request is still in flight.
-
Unmanaged vacuums (
INKV8ZQD) —test_services_unmanaged_vacuum.py.cv.entity_idvalidates the SHAPEdomain.object_idand nothing more, so every per-vacuum store keyed off an unchecked id used to mint a durable bucket for whatever string an automation passed. A write now refuses (ServiceValidationError); a read returns the empty shape withreason: "unmanaged_vacuum"rather than raising, because a read is how the card discovers state.[UV-5]asserts the managed case still works — without it the whole file would pass against a guard that refused everything. Each guard was ablated and confirmed to turn a test red; that pass caught two of these tests being decorative, one because the ErrorTracker was never loaded so the guarded path was unreachable, and one because a second guard at the service layer masked the manager-level fix it was meant to prove. -
Read services — snapshots, job-control read, access-graph, saved profiles, dashboard snapshot: returned-shape assertions through the registry.
- Write services — job-control write, run-profile + room-profile + maintenance CRUD, queue build/clear, adapter-config set: side effects + persistence.
- Input validation — the
update_room_fieldscolor validator normalizes raw frontend input to canonical#rrggbb(#rgbexpansion, hash-prepend, lowercase, empty → clear) and rejects non-hex values at the service-schema boundary. - Error contract — a manager-layer failure surfaces as
HomeAssistantError(run-profile save/apply/rename/overwrite/delete, maintenance reset, set-interval save path), and not-found conditions raiseServiceValidationError. - Call-data resolution + job-finished payload (
_common) —resolved_call_datamap-id defaulting (incl. the no-active-map pass-through), andjob_finished_event_payloadbuilt from afinalize_result-wrapped result.
How it's tested¶
The manager_with_services fixture registers the full service set (same path as
async_setup_entry). Tests call hass.services.async_call(DOMAIN, name, data,
blocking=True, return_response=True). Error-contract tests monkeypatch a
manager method to raise and assert the wrapped exception type.
Known gaps¶
queue.py (77%) is now the real gap in this subsystem, not a defensive tail —
this campaign added the queue breaks/steps service handlers
(_handle_get_queue_steps, _handle_add_queue_break,
_handle_remove_queue_break, _handle_clear_queue_breaks,
_handle_set_queue_breaks, _handle_add_queue_zone, missing lines 202-244)
and their register() closures (missing lines 266-281) — none of them are
exercised by test_services_queue.py. The handler bodies are thin delegators
to the manager (get_manager(hass).<method>(**resolved_call_data(...)) +
async_save()), so this is closer to an untested wrapper than untested logic,
but it is a real gap, not a documented-defensive one — flagged here rather
than characterized as intentional. (Line 62 is a genuinely defensive
isinstance guard in a payload-normalization helper, unrelated to the above.)
The remaining misses elsewhere are still almost all defensive, not untested behavior, though line numbers have shifted with the campaign's growth:
manager is Noneearly-returns (defensive) — the runtime-not-available guards at the top of severalsetup.pyhandlers (missing lines 64, 114, 187, 190, 237, 264, 303, 335-338, 344-345, 356, 375, 425), plusadapter_config.py's missing lines 67, 116. Unreachable in the fixture-registered service set, intentionally uncovered.access_graph.py(88%) — missing lines 89-91, 104: defensive guards, not yet re-itemized by name this pass.- Registered-wrapper closures (trivial) —
rooms.pymissing line 270 (a wrapper/vol.Requiredfalse-arm the registered service can't reach). _common.py(96%) — missing line 138, a single defensive guard.
Module coverage: queue.py 77% (see above), setup.py 86%,
access_graph.py 88%, adapter_config.py 96%, _common.py 96%,
errors.py 95%, rooms.py 98%; dock.py, room_profiles.py,
maintenance.py, and snapshots.py are at 100%.