Skip to content

Developer Triage Rules

Jason Rhubottom edited this page Jul 24, 2026 · 1 revision

Developer: Triage Rules

The diagnostics-triage engine turns a cover's config + last diagnostics snapshot into a list of findings. One declarative table β€” TRIAGE_RULES in custom_components/adaptive_cover_pro/diagnostics/triage.py β€” drives both the in-product Troubleshoot step and the offline scripts/triage_json.py run. Adding a check is intentionally small: four edits and it lights up on both surfaces.

Adding a rule β€” the four edits

  1. One TriageRule row in TRIAGE_RULES (diagnostics/triage.py), plus its check function and a new member on the TriageCode enum in const.py.
  2. One English template β€” add the leaf to _TRIAGE_TEMPLATES_EN in troubleshoot_i18n.py and the byte-identical leaf in troubleshoot_i18n/en.json.
  3. One JSON leaf per shipped language β€” add the translated leaf to troubleshoot_i18n/de.json and troubleshoot_i18n/fr.json (draft placeholder-exact, then run the acp-translate skill to polish).
  4. One test block in tests/test_triage_rules.py β€” a fires case, a near-miss, and (for per-entity rules) an N-entity case. RUNTIME rules also get a real-DiagnosticsBuilder contract test in tests/test_triage_contract.py.

Worked example

Say you want to flag a max tracking elevation so low it truncates the sun-tracking window.

const.py β€” a stable code:

class TriageCode(StrEnum):
    ...
    TRACKING_WINDOW_TRUNCATED = "triage.tracking_window_truncated"

diagnostics/triage.py β€” a check that yields param dicts, and a row:

def _check_tracking_window_truncated(data: Mapping) -> Iterable[Mapping]:
    options = _get(data, "options")
    if not isinstance(options, Mapping):
        return
    max_elev = options.get(CONF_MAX_ELEVATION)
    if isinstance(max_elev, (int, float)) and not isinstance(max_elev, bool):
        if max_elev <= 25:
            yield {"max_elevation": max_elev}

TriageRule(
    code=TriageCode.TRACKING_WINDOW_TRUNCATED,
    severity=Severity.WARNING,
    inputs=RuleInput.CONFIG,
    fix_step="sun_tracking",
    wiki="Troubleshooting-Findings#tracking-window-truncated",
    issues=(972,),
    check=_check_tracking_window_truncated,
),

troubleshoot_i18n.py + en.json β€” the {max_elevation} template, byte-identical in both. de.json / fr.json β€” the same leaf translated, same {max_elevation} placeholder. tests/test_triage_rules.py β€” a fires case (max_elevation: 25), a near-miss (26), and a key-absent case.

The CONFIG / RUNTIME seam

Every rule declares an inputs flag:

  • RuleInput.CONFIG β€” reads only options (and, in the config flow, capabilities / axis_requirements). Deterministic, coordinator-free.
  • RuleInput.RUNTIME β€” reads the diagnostics payload (decision trace, control status, cover commands, …).
  • RuleInput.CONFIG | RuleInput.RUNTIME β€” a mixed row.

run_triage(data, only=RuleInput.CONFIG) keeps a rule iff every one of its flags is in only (subset semantics): only=CONFIG drops both RUNTIME and mixed rows. The config summary and setup wizard pass only=CONFIG; the troubleshooter passes None (everything). Pick CONFIG only when the check would give the same answer without a running coordinator.

The _get dotted-accessor never-raises contract

Read every payload value through _get(data, "a.b.c") β€” a missing key or a non-mapping segment yields None, never an exception. run_triage also wraps each check in a try/except, so a single bad rule can never break triage:

run_triage({}) == []

This is a hard invariant. A check that reaches into data["x"]["y"] directly (bypassing _get) and raises on a partial payload is a bug β€” use _get, or guard with isinstance(..., Mapping).

The check-yields-iterable convention

A check receives the view mapping and yields zero or more param dicts β€” one Finding per yielded dict. Single-shot rules yield 0 or 1; per-entity rules yield N (one per offending entity). Never return a bool or a single dict; yield (or return early to yield nothing).

The wiki-anchor scheme

Every rule's wiki field points at the single canonical findings page with a per-code anchor:

Troubleshooting-Findings#<code without the "triage." prefix, underscores β†’ hyphens>

So triage.tracking_window_truncated β†’ Troubleshooting-Findings#tracking-window-truncated. Add a matching ### Tracking window truncated section to Troubleshooting Findings β€” the heading must GitHub-slugify to exactly that anchor.

Meta-tests a new row must satisfy

These run over the whole table in tests/test_triage_rules.py:

  • test_rule_table_covers_every_triage_code β€” the rule table and TriageCode are a bijection. Add a code without a row (or vice versa) and this fails.
  • test_rule_wiki_points_at_canonical_findings_page β€” the wiki field matches the anchor scheme.
  • test_rule_wiki_anchor_resolves_on_findings_page β€” the anchor is a real ### heading on Troubleshooting-Findings.md (skipped if no sibling wiki checkout).
  • test_rule_wiki_anchor_format β€” the wiki string is Page#anchor shaped.
  • test_rule_fix_step_is_reachable_from_cover_menu β€” fix_step is None or a real cover-options step.
  • test_rule_issues_non_empty_int_tuple β€” issues is a non-empty tuple of ints.
  • test_rule_template_exists_in_code_defaults_and_en_json β€” the template leaf exists in both the code dict and en.json.

Plus i18n parity in tests/test_troubleshoot_i18n.py: DE/FR must carry the same leaves with the same placeholder set as English.

Deliberately excluded

Some checks were considered and left out on purpose β€” do not re-add them:

  • delta_too_small / dry_run as skip faults. These are expected steady-state skips, not problems. The skip rules (SKIP_*) fire only on genuine faults (service_call_failed, no_capable_service, cover_unavailable).
  • Testimony rules β€” "manual override won but the user says they didn't touch it". The engine cannot know user intent; keep it to observable state.
  • Cover-type string branches. diagnostics/triage.py must never compare a cover-type string or read a hardcoded caps.get("has_*") literal β€” that boundary is enforced by tests/test_cover_types/test_axes.py. When a rule needs cover-type-specific data (rule 13's capability requirements), fold a policy-derived field into the view at the HA boundary (the troubleshoot step / offline adapter) and have the check read it as plain data.

See also CODING_GUIDELINES.md and the For Developers hub.

🏠 Home Β· ✨ Features Β· πŸ“° What's New

Buy Me A Coffee

πŸš€ Getting Started

🧠 Core Concepts

πŸ“ Cover Types

βš™οΈ Configuration

πŸ”Œ Entities & Services

πŸ› οΈ Operations

πŸ”§ Advanced Use Cases

🎨 Dashboard

πŸ§ͺ Testing & Simulation

πŸ“š Reference

πŸ‘©β€πŸ’» For Developers

Clone this wiki locally