Skip to content

34 โ€” Capability Detection

Scope. How the framework decides what a vacuum can do: the two kinds of adapter hint and why they are not the same rule, and the diagnostic vocabulary that records not only which entity won a role but how and why the alternatives lost. What a brand declares is 22 โ€” The Adapter Contract; the two shipped answers are 23 and 24.

core/capabilities.py::detect_capabilities holds no brand knowledge. Everything vacuum-specific โ€” the entity candidates, the model family, the hints, the maintenance catalog โ€” arrives as arguments from the adapter's registration.

The module spends a substantial share of its length on explaining its own answers, and that is the part worth reading. A capability map that says only yes or no cannot be debugged by the person whose install it is wrong on.


1. Two hint rules, and confusing them was a real defect

A hint is a model-based flag from the adapter. There are two ways one is combined with what the probe found, and they are deliberately different:

Permissive โ€” hint OR entity presence. A True hint means supported even when the entity is absent; False or absent falls back to what the probe saw. This is right for features whose evidence is an entity that may simply not be exposed yet.

Authoritative โ€” the hint wins outright. For capabilities a brand can categorically not do, a declared False must beat the derived value. Water control, edge mopping, passes, custom room config, room clean and zone clean use this rule.

The failure that produced the split is named in place: a hardcoded default is unreachable by any adapter, which is how edge-mopping support stayed True for a brand that declared it False. Under the permissive rule a declared False is indistinguishable from silence, so a brand saying "I cannot do this" and a brand saying nothing produce the same answer โ€” and the one that spoke is the one that gets a control it cannot honour.

Zone clean is the case caught mid-flight. Both shipped adapters hardcoded it True in their config dicts, which is exactly the unreachable-default shape: a model that categorically cannot zone-clean had nothing to declare against. True remains the default because both brands do support it โ€” but it is now a hint a model catalog can override. (24 ยง8 records that the brand end of that seam is still unwired.)


2. A typo in a hint is silent, so the key set is validated

core/capabilities.py::KNOWN_CAPABILITY_HINTS is the complete set of flags this module reads, and the reason it exists is sharper than documentation:

A hint whose key is not in the set is a silent no-op โ€” the lookup simply misses and the derived value stands. So a brand declaring supports_zone_cleen: False is ignored exactly as thoroughly as one declaring nothing. That is the same silent-wrong-answer shape the authoritative rule exists to prevent, arriving by a different route.

adapters/registry.py::_validate_adapter checks every declared hint against the set, so the typo fails at registration rather than surfacing months later as "why is this control showing?". The set is pinned by a contract test against the reads themselves, so the two cannot drift.


3. Absent and disabled are different answers

Both used to produce a bare None, and they need opposite fixes: an absent entity is our problem, a disabled one is a toggle in the user's own interface. A support round-trip was spent on exactly that ambiguity, with both position sensors present-but-disabled on the device.

The resolution outcome is now first-class, and the four states are distinguishable:

outcome means
core/capabilities.py::REASON_ABSENT no such entity exists
core/capabilities.py::REASON_DISABLED it exists and the user has disabled it
core/capabilities.py::REASON_REGISTERED_NO_STATE registered but reporting nothing yet
core/capabilities.py::REASON_OVERRIDE_UNRESOLVED ยง4

The general rule this earns: a diagnostic that collapses two causes into one symptom costs more than it saves, because the person reading it cannot tell which of the two fixes applies, and neither can whoever they ask.


4. A user override that stopped working must be visible

When a user pins an entity to a role and that entity is later renamed or deleted, resolution falls through to the normal candidates rather than pinning a dead id โ€” the role still resolves and the install keeps working.

That is the right behaviour and it is also dangerous, because the user's stated intent has been silently replaced by the framework's guess. core/capabilities.py::REASON_OVERRIDE_UNRESOLVED exists so the substitution is reported rather than hidden. The comment states the principle directly:

silently substituting our guess for their stated intent is the failure mode this whole effort exists to remove

Falling back and saying so is a different thing from falling back quietly, and only the second is a bug.


5. The answer records how it was reached

Two more vocabularies ride along with every resolved role.

Which rung decided a contested role โ€” object id, translation key, state class, or magnitude. These are first-class values rather than prose because, in the comment's own framing, six months later "chosen by: magnitude" sends you somewhere completely different from "chosen by: manual override". Magnitude is the last rung and requires a competing candidate to be substantially larger before size alone decides, so it cannot casually overturn a name match.

Where the winner came from โ€” derived, an override, or the device-sibling rescue โ€” is recorded for every role, not only contested ones. The reason is precise: without it, the diagnostic table labels an entity a name match when the sibling search actually rescued it, which asserts something visibly false. The comment calls that manufactured confidence, and the surface exists to catch it.

That is the whole design in one line: a detection report that cannot be wrong about its own provenance is worth more than one that is merely usually right about its conclusions.


6. Common wrong assumptions

assumption reality
a hint is a hint there are two rules, and a declared False is only authoritative under one of them โ€” ยง1
declaring a capability False disables it under the permissive rule it is indistinguishable from silence โ€” ยง1
a misspelled hint fails loudly it is a silent no-op at runtime; the registry validator is what makes it loud โ€” ยง2
a missing entity means the feature is unsupported it may be disabled by the user, which is a different fix โ€” ยง3
an unresolved override is an error state resolution falls through and the install works; the point is that it is reported โ€” ยง4
provenance is recorded for contested roles it is recorded for every role, precisely so the uncontested ones cannot lie โ€” ยง5

Registries

00b-invariants.md ยท 00c-replicas.md