From 889600edae24dfc3eeb106661674059c490a2294 Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Sun, 31 May 2026 22:16:59 -0500 Subject: [PATCH 001/160] chore: add doc overhaul brief with challenge findings for #928 --- design/specs/070-doc-overhaul/brief.md | 83 ++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 design/specs/070-doc-overhaul/brief.md diff --git a/design/specs/070-doc-overhaul/brief.md b/design/specs/070-doc-overhaul/brief.md new file mode 100644 index 000000000..b67b20069 --- /dev/null +++ b/design/specs/070-doc-overhaul/brief.md @@ -0,0 +1,83 @@ +# Brief: Documentation Overhaul + +**Date:** 2026-05-31 +**Status:** explored +**Issue:** #928 + +## Idea + +Rewrite all 76 documentation pages from scratch using an outline-first process. The current docs grew organically and have inconsistent depth, voice, and coverage despite well-defined standards (voice-guide.md, doc-rules.md). Rather than patching individual pages, this tears everything down and rebuilds with a planned structure — every page starts empty, even pages that were already close to the standard. + +## Key Decisions Made + +- **Audience priority:** All three personas (evaluators, new users, active developers) are equally important. No section gets deprioritized. +- **Truly blank slate:** Every page starts from an empty file. Existing pages are reference material, not starting points. No copy-paste-and-edit. **Exception:** Troubleshooting and operational runbook pages get a mandatory pre-write knowledge inventory pass — the author reads the existing page, extracts every named failure mode, log signature, timing value, and runbook command into a working note, then starts the blank page. Blank-slate applies to structure and prose; operational knowledge must be carried forward. +- **Full structural freedom in Phase 1:** Sections can be merged, split, renamed, or removed. The current 10-section nav is not sacred — Phase 1 should question everything. +- **Snippet audit happens in Phase 2:** Each page outline declares what code examples it needs. Snippets not claimed by any outline get dropped. No carrying forward 352 files by default. +- **Two exemplar pages before bulk writing:** One core concept page (hardest voice — system-as-subject, no "you") and one getting-started or recipe page (friendlier register). These anchor voice for everything that follows. **Exemplar selection is a Phase 1 deliverable** — criteria: the concept exemplar must (a) introduce multiple related terms, (b) send readers to sibling depth pages, and (c) have a clear new-reader audience. The recipe/getting-started exemplar must demonstrate the prose "How It Works" pattern from voice-guide.md (no bullet-with-bolded-lead-in). Any candidate that violates voice-guide.md requires remediation before use as exemplar. +- **Voice audit after each section PR:** Catch drift before it compounds across sections. **Operationalization:** Create a `docs-context.md` in the spec directory listing exemplar page paths — each writing session reads this at startup to calibrate. Produce a concrete voice audit checklist (5–10 items drawn from the most commonly violated voice-guide rules — e.g., no bullet lists in "How It Works," system-as-subject in concept pages, no bolded lead-ins, verification steps in recipes) as a Phase 1 deliverable. The checklist is the pass/fail gate, not a subjective scan. +- **Docs branch strategy:** Section PRs merge to a long-lived `docs` branch. One big PR from `docs` to `main` when everything is complete. Users see an atomic swap, but review happens incrementally. **Rebase checkpoint:** After each section PR merges to `docs`, rebase `docs` onto current `main` and run CI. Eight section PRs = eight opportunities to catch API drift before the final merge. +- **Delivery:** ~8 PRs to the docs branch (one per section), plus the planning phases as non-PR artifacts. + +## Reader Outcomes Per Section + +Each section PR is evaluated against these reader outcomes, not just voice consistency: + +- **Getting Started:** A new user can install Hassette, connect to Home Assistant, deploy a working app, and verify it connected — all without external help. +- **Core Concepts:** A reader can explain what Bus, Scheduler, Api, and StateManager do and when to use each, without looking it up. +- **Recipes:** A reader can adapt the example code to their own entities and verify the automation fired (every recipe includes a "Verify it's working" step pointing to `hassette log --app ` or the web UI Handlers tab). +- **CLI:** A reader can find and run any CLI command for their task. +- **Testing:** A reader can write and run a test for their app using the harness. +- **Web UI:** A reader can use the web UI to debug a failing handler or check app status. +- **Migration:** An AppDaemon user can map their existing automation to the Hassette equivalent. + +## Open Questions + +- **Which specific pages for the exemplars?** Bus overview is a strong candidate for the concept exemplar. First Automation or a recipe like Motion Lights for the second. Needs a decision before Phase 3 starts. +- **What happens to the Migration section?** AppDaemon migration docs (8 pages) may be declining in relevance. Phase 1 should decide whether to keep, condense, or drop. +- **Web UI section structure:** Currently mirrors the UI tab-by-tab (6 pages for app-detail alone), which is fragile — every UI change forces a doc change. **Target:** Task-oriented structure organized by what users are trying to do ("debug a failing handler," "check app status," "read logs"), not by UI elements. A single "Monitoring and Debugging with the Web UI" page could replace the six app-detail pages. Phase 1 should confirm or refine this direction. +- **The "Advanced" grab-bag:** **Target:** Delete "Advanced" as a section. Move Custom States, State Registry, and Type Registry into `core-concepts/states/` as depth pages (matching the pattern of bus sibling pages for handlers, filtering, DI). Move Log Level Tuning into Troubleshooting or a new "Operating Hassette" section. Fix the Managing Helpers nav/filesystem mismatch (currently in `pages/advanced/` but rendered under Core Concepts > API). Phase 1 confirms or refines these destinations. +- **Architecture / Internals split:** **Target:** Architecture page scopes to the app-author audience only — the "four handles" model (Bus, Scheduler, Api, StateManager). Dependency graphs, wave ordering, cycle detection, and the 14 internal service names move to `internals.md`. Keep `internals.md` whole as the contributor/maintainer reference, cross-linked from concept pages. Phase 1 confirms. +- **API reference (auto-generated):** The hand-written pages are the focus, but should Phase 1 also review which modules are in `PUBLIC_MODULES` in `gen_ref_pages.py`? +- **Issue #540 (final docs sweep):** Superseded by this issue per the issue body. Should be closed when this work begins. +- **DI canonical home:** Dependency injection is currently explained in three places at contradictory depth (getting-started, apps overview callout, bus/dependency-injection.md) and the Core Concepts index labels it "Advanced" while getting-started treats it as foundational. Phase 1 should designate `core-concepts/bus/dependency-injection.md` as the single canonical page; all other locations compress to one sentence with a link. When any page uses `D.*`, `states.*`, `C.*`, `P.*`, or `A.*` for the first time, it links to the canonical page. + +## Scope Boundaries + +**In scope:** +- All hand-written pages in `docs/pages/` (76 pages) +- All snippet files in `docs/pages/*/snippets/` (352 files — audit and replace) +- `mkdocs.yml` nav structure +- New snippet files as needed +- Two exemplar pages before bulk writing + +**Explicitly out:** +- API reference auto-generation (`tools/gen_ref_pages.py`) — review in Phase 1 but don't rewrite the generator +- Docstrings in source code — those are a separate concern from the docs site +- Design documents in `design/` — not part of the docs site +- `tokens.css`, design system, or frontend changes — docs content only + +**Pre-Phase 3 cleanup:** +- Scope Pyright suppressions in `docs/pyrightconfig.json` — audit whether `reportOperatorIssue` and `reportAssignmentType` can move from global suppressions to per-file exclusions (the pattern already exists in the config). New snippet files should not inherit broad suppressions by default. + +**Deferred:** +- Any new documentation pages for features that don't exist yet +- Docs CI improvements beyond what's needed to validate the rewrite + +## Risks and Concerns + +- **Scale:** 76 blank-slate pages is a month-plus effort. Fatigue and voice drift are real risks, especially across multiple Claude sessions. The two-exemplar + voice-audit-per-section strategy mitigates but doesn't eliminate this. +- **Snippet maintenance burden:** Even with the Phase 2 audit, the rewrite will likely produce a similar number of snippet files. The testing convention (Pyright CI) keeps them honest, but each snippet is a maintenance surface. +- **Cross-link breakage during writing:** Pages reference each other heavily. Writing section-by-section means early sections will have broken links to unwritten pages. The docs branch absorbs this — links only need to work when the big PR merges to main. **Mitigation:** Add a post-build HTML link checker (e.g., `muffet` or `htmltest` targeting the built `site/` directory) as a docs CI job to catch broken anchor fragments (`#section-name`) that `mkdocs build --strict` and lychee both miss. Run on every section PR. +- **Snippet sequencing:** `pymdownx.snippets` has `check_paths: true` — any `--8<--` reference to a non-existent file fails the build. Pages and snippets must be created together. **Mitigation:** For each page, create snippet files as minimal stubs before adding `--8<--` references in the markdown. Stubs satisfy `check_paths` and Pyright; content fills in as the page matures. +- **Regression risk:** Some current pages are genuinely good. Starting from blank means the rewrite could produce pages that are worse in spots, especially for sections like recipes that were already close. The exemplar + voice audit process is the guard against this, but it requires discipline. +- **Phase 1 is load-bearing:** If the site outline is wrong, every subsequent phase builds on a bad foundation. Phase 1 deserves disproportionate time and scrutiny. + +## Codebase Context + +- **Voice standards:** `voice-guide.md` (23 style rules with before/after examples) and `doc-rules.md` (page structure templates, example conventions, snippet rules, layering guidance) are mature and detailed. The standards are not the problem — adherence is. +- **Snippet convention:** External `.py` files with `--8<--` includes, CI type-checked via Pyright. This convention should carry forward unchanged. +- **Prior audit (#911):** Completed and closed. Found that recipes and getting-started were closest to the voice standard; core-concepts and advanced were the furthest. This informs priority but doesn't constrain the rewrite. +- **Superseded issue (#540):** A lighter-weight "final docs sweep before v1.0.0" that this issue replaces with a more thorough approach. +- **mkdocs plugins:** search, glightbox, panzoom, gen-files, literate-nav, autorefs, mkdocstrings. These stay — the rewrite is content, not tooling. +- **CSS checker scripts** in `tools/` enforce style hygiene but don't touch docs. No interaction. From 3d310846feb88d7e00b59044baab70c299b9c5c7 Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Mon, 1 Jun 2026 13:31:27 -0500 Subject: [PATCH 002/160] feat: add task files for 070-doc-overhaul --- .../tasks/.validation-report.md | 66 +++++++++++ .../tasks/T01-site-outline.md | 106 +++++++++++++++++ .../070-doc-overhaul/tasks/T02-ci-tooling.md | 62 ++++++++++ .../tasks/T03-exemplar-pages.md | 85 +++++++++++++ .../tasks/T04-content-outlines.md | 71 +++++++++++ .../tasks/T05-pyright-cleanup.md | 51 ++++++++ .../tasks/T06-write-getting-started.md | 64 ++++++++++ .../tasks/T07-write-core-concepts-1.md | 81 +++++++++++++ .../tasks/T08-write-core-concepts-2.md | 83 +++++++++++++ .../tasks/T09-write-web-ui.md | 58 +++++++++ .../tasks/T10-write-cli-testing.md | 60 ++++++++++ .../tasks/T11-write-recipes.md | 66 +++++++++++ .../tasks/T12-write-migration-ops.md | 75 ++++++++++++ .../070-doc-overhaul/tasks/T13-final-sweep.md | 91 ++++++++++++++ .../specs/070-doc-overhaul/tasks/context.md | 112 ++++++++++++++++++ 15 files changed, 1131 insertions(+) create mode 100644 design/specs/070-doc-overhaul/tasks/.validation-report.md create mode 100644 design/specs/070-doc-overhaul/tasks/T01-site-outline.md create mode 100644 design/specs/070-doc-overhaul/tasks/T02-ci-tooling.md create mode 100644 design/specs/070-doc-overhaul/tasks/T03-exemplar-pages.md create mode 100644 design/specs/070-doc-overhaul/tasks/T04-content-outlines.md create mode 100644 design/specs/070-doc-overhaul/tasks/T05-pyright-cleanup.md create mode 100644 design/specs/070-doc-overhaul/tasks/T06-write-getting-started.md create mode 100644 design/specs/070-doc-overhaul/tasks/T07-write-core-concepts-1.md create mode 100644 design/specs/070-doc-overhaul/tasks/T08-write-core-concepts-2.md create mode 100644 design/specs/070-doc-overhaul/tasks/T09-write-web-ui.md create mode 100644 design/specs/070-doc-overhaul/tasks/T10-write-cli-testing.md create mode 100644 design/specs/070-doc-overhaul/tasks/T11-write-recipes.md create mode 100644 design/specs/070-doc-overhaul/tasks/T12-write-migration-ops.md create mode 100644 design/specs/070-doc-overhaul/tasks/T13-final-sweep.md create mode 100644 design/specs/070-doc-overhaul/tasks/context.md diff --git a/design/specs/070-doc-overhaul/tasks/.validation-report.md b/design/specs/070-doc-overhaul/tasks/.validation-report.md new file mode 100644 index 000000000..c15d97e89 --- /dev/null +++ b/design/specs/070-doc-overhaul/tasks/.validation-report.md @@ -0,0 +1,66 @@ +## Status: APPROVED + +> APPROVED — all 18 FRs and 20 ACs are covered, no contradictions, context.md has all required sections with non-empty content. Five warnings found; none block approval. + +## Traceability Matrix + +| Identifier | Task(s) | Verify Criterion | +|------------|---------|-----------------| +| FR#1 | T03, T10 | T03: "All three exemplar pages pass every item on the voice audit checklist" / T10: "All pages pass the voice audit checklist" | +| FR#2 | T03, T07 | T03: "Concept exemplar uses system-as-subject voice throughout — no 'you' outside getting-started/recipe" / T07: "All concept pages use system-as-subject voice — no 'you' outside getting-started/recipe content" | +| FR#3 | T03, T06 | T03: "Getting-started/recipe exemplar uses direct 'you' address with code-first ordering" / T06: "All getting-started pages use direct 'you' address with code-first ordering" | +| FR#4 | T11 | "Every recipe includes a 'Verify it's working' section with a concrete command or UI action that produces observable output" | +| FR#5 | T01, T07 | T01: "`core-concepts/bus/dependency-injection.md` appears in the nav as the DI canonical page" / T07: "`core-concepts/bus/dependency-injection.md` contains the full DI explanation; grep other pages for DI references and confirm each is one sentence + link" | +| FR#6 | T03, T13 | T03: "Every first use of `D.*`, `states.*`, `C.*`, `P.*`, `A.*` links to the canonical page" / T13: "Spot-check of 5+ pages confirms first use of `D.*`, `states.*`, `C.*`, `P.*`, `A.*` links to canonical module page" | +| FR#7 | T01, T09 | T01: "Web UI section in `mkdocs.yml` contains ≤6 entries, each with a task-oriented title" / T09: "Web UI section contains ≤6 pages, each organized by user task (not UI element). Each page is justified as a discrete user task." | +| FR#8 | T01 | "No 'Advanced' section exists in `mkdocs.yml` nav" | +| FR#9 | T01, T08 | T01: "`core-concepts/states/` nav subsection has overview + at least 'Subscribing to State Changes' and 'DomainStates Reference' depth pages, plus Custom States, State Registry, Type Registry" / T08: "States subsection has overview page, 'Subscribing to State Changes' depth page, 'DomainStates Reference' depth page, plus Custom States, State Registry, and Type Registry" | +| FR#10 | T01, T12 | T01: "An 'Operating Hassette' section exists in the nav with Log Level Tuning and Upgrading content" / T12: "'Operating Hassette' section exists with Log Level Tuning and Upgrading content; Troubleshooting contains only symptom-lookup entries" | +| FR#11 | T07 | "Architecture page covers the five handles model for app-authors only" | +| FR#12 | T07 | "Internals page contains dependency graphs, wave ordering, cycle detection, and internal service names" | +| FR#13 | T02, T13 | T02: "The snippet orphan check script exists at `tools/check_snippet_orphans.py` and correctly identifies unreferenced snippet files" / T13: "Snippet orphan check returns 0 orphans after cleanup" | +| FR#14 | T03, T10 | T03: "Every code example comes from a snippet file via `--8<--` include — no inline code blocks" / T10: "Every Hassette code example comes from a snippet file — no inline code blocks for code examples (CLI output examples may be inline if they're not Hassette code)" | +| FR#15 | T04, T12 | T04: "Knowledge inventory exists at `design/specs/070-doc-overhaul/outlines/knowledge-inventory.md` and covers every named failure mode, log signature, timing value, and runbook command from the current troubleshooting and operational pages" / T12: "Every named failure mode, log signature, timing value, and runbook command from the knowledge inventory appears in the rewritten troubleshooting and operational pages" | +| FR#16 | T01 | "`managing-helpers.md` filesystem path is `pages/core-concepts/api/managing-helpers.md`" | +| FR#17 | T03, T13 | T03: "Every first use of Bus, Scheduler, Api, Cache, App, StateManager, or Resource has a functional definition" / T13: "Spot-check of 5+ pages confirms first use of Bus, Scheduler, Api, Cache, App, StateManager, or Resource includes functional definition" | +| FR#18 | T01, T06 | T01: "Getting Started section includes a dedicated evaluator page" / T06: "A dedicated evaluator page exists covering what Hassette is, who it's for, and how it compares to alternatives" | +| AC#1 | T03, T10 | T03: "Voice audit checklist applied and all items pass for each exemplar" / T10: "Voice audit checklist applied and all items pass" | +| AC#2 | T13 | "`uv run mkdocs build --strict` succeeds with zero warnings" | +| AC#3 | T02, T13 | T02: "A muffet-based link checker CI job exists in `.github/workflows/docs.yml` that runs against the built `site/` directory and checks anchor fragments" / T13: "Muffet link checker finds zero broken links including anchor fragments" | +| AC#4 | T05, T13 | T05: "`uv run pyright --project docs` passes with zero errors after config changes" / T13: "`uv run pyright --project docs` passes with zero errors" | +| AC#5 | T02, T13 | T02: "The snippet orphan check runs in CI on docs PRs and fails if any orphan `.py` files exist under `docs/pages/*/snippets/`" / T13: "`uv run python tools/check_snippet_orphans.py` exits 0" | +| AC#6 | T06 | "First automation page includes `hassette status` showing `websocket_connected: True` and `hassette app` showing the app listed" | +| AC#7 | T11 | "Each recipe's verification step names a specific command (`hassette log`, `hassette listener`) or UI action (Handlers tab) with expected output" | +| AC#8 | T01 | "`grep -c \"Advanced\" mkdocs.yml` returns 0 in the nav section" | +| AC#9 | T07, T13 | T07: "`core-concepts/bus/dependency-injection.md` is the only page with a full DI explanation" / T13: "`core-concepts/bus/dependency-injection.md` is the only page with full DI explanation; all others are one sentence + link" | +| AC#10 | T01, T09 | T01: "Web UI section in nav has ≤6 pages with task-oriented titles" / T09: "Web UI section in `mkdocs.yml` has ≤6 entries with task-oriented titles (not tab names like 'Apps,' 'Handlers,' 'Logs')" | +| AC#11 | T01, T08 | T01: "States subsection structure matches FR#9" / T08: "States subsection in `mkdocs.yml` matches the required structure with overview + depth pages + extension pages" | +| AC#12 | T03, T13 | T03: "Module cross-links present on first use" / T13: "Module cross-links verified on first use across spot-checked pages" | +| AC#13 | T01, T12 | T01: "'Operating Hassette' section exists in nav" / T12: "'Operating Hassette' section exists in `mkdocs.yml` with the required content" | +| AC#14 | T07 | "Architecture page does not mention dependency graphs, wave ordering, cycle detection, or internal service names" | +| AC#15 | T07 | "`internals.md` contains dependency graphs, wave ordering, cycle detection, and internal service names" | +| AC#16 | T03, T10 | T03: "No inline Hassette code examples — all from snippet files" / T10: "No inline Hassette code examples that should be in snippet files" | +| AC#17 | T04, T12 | T04: "Diff the knowledge inventory against the current troubleshooting page — every item in the current page has a corresponding entry in the inventory" / T12: "Diff the knowledge inventory against the final troubleshooting page — every item is preserved" | +| AC#18 | T01 | "Managing Helpers file path matches nav position" | +| AC#19 | T03, T13 | T03: "Hassette term definitions present on first use" / T13: "Term definitions verified on first use across spot-checked pages" | +| AC#20 | T01, T06 | T01: "Evaluator page exists in Getting Started nav" / T06: "Evaluator page exists in Getting Started nav" | + +## Coverage Gaps + +None. + +## Contradictions + +None. + +## Warnings + +1. **T05 is not a declared dependency of T06–T12, creating a sequencing risk.** T05's purpose is to scope Pyright suppressions per-file before Phase 3 writing begins — the design doc explicitly states "New snippet files written during Phase 3 should not inherit broad suppressions by default." T06–T12 all depend on T04 but not T05. If T06–T12 execute before T05, new snippets are created under the broad global suppression config, defeating T05's purpose. T05 should be listed as a dependency in T06–T12's `depends_on` fields, or the tasks should note that T05 must run first. + +2. **T13 does not declare T02 or T05 as dependencies, but relies on both.** T13 Prompt step 1 runs the muffet link checker CI job and the snippet orphan check script — both created by T02. It also runs `uv run pyright --project docs`, which relies on the config changes from T05. T13's `depends_on` lists only T06–T12. If T02 or T05 were incomplete, T13's validation steps would fail with missing tools. T13 should add T02 and T05 to its `depends_on`. + +3. **T06 Prompt says "Pages to write (8 total)" but enumerates 9 items.** The design doc Impact section states "8 pages (4 main + 4 docker)." T06 lists 5 main pages (evaluator, index.md, first-automation.md, ha_token.md, hassette-vs-ha-yaml.md) plus 4 docker pages = 9. The `hassette-vs-ha-yaml.md` page appears to be an addition beyond the design doc's count. Not a functional problem — the evaluator FR#18 and AC#6/AC#20 are all covered — but the executor will write 9 pages while the header says 8. + +4. **T01 Verify AC#11 is a cross-reference, not a concrete check.** The verify text reads "States subsection structure matches FR#9." This defers verification to the FR#9 verify item rather than stating a binary-checkable condition directly. The FR#9 verify item in T01 is concrete ("has overview + at least 'Subscribing to State Changes'..."), so coverage is not lost — but an executor reading only the AC#11 line cannot verify it without also reading the FR#9 line in the same task. + +5. **T10 and T03 Verify FR#1/AC#1 use "pass the voice audit checklist" without naming the checklist location.** The verify items read "All pages pass the voice audit checklist" and "Voice audit checklist applied and all items pass." The checklist itself is produced in T01 and consolidated in `docs-context.md`. An executor running T10 in isolation would need to find `docs-context.md` to know what "the checklist" means. The verify items are not vague in intent — the checklist is a well-defined artifact — but they implicitly depend on T01's output without naming the path. Acceptable given that T10 depends on T04 (which depends on T03, which depends on T01), but worth noting. diff --git a/design/specs/070-doc-overhaul/tasks/T01-site-outline.md b/design/specs/070-doc-overhaul/tasks/T01-site-outline.md new file mode 100644 index 000000000..3dfe5f73b --- /dev/null +++ b/design/specs/070-doc-overhaul/tasks/T01-site-outline.md @@ -0,0 +1,106 @@ +--- +task_id: "T01" +title: "Create docs branch, site outline, and calibration artifacts" +status: "planned" +depends_on: [] +implements: ["FR#5", "FR#7", "FR#8", "FR#9", "FR#10", "FR#16", "FR#18", "AC#8", "AC#10", "AC#11", "AC#13", "AC#18", "AC#20"] +--- + +## Summary + +The foundational task. Creates the long-lived docs branch, restructures the `mkdocs.yml` nav from scratch, generates stub files for every page in the new tree, and produces all Phase 1 calibration artifacts (exemplar selections, voice audit checklist, docs-context.md). Every subsequent task depends on this one. No page content is written — only structure and tooling. + +## Prompt + +Work in the `design/specs/070-doc-overhaul/` feature directory on the worktree at `/home/jessica/source/hassette/.claude/worktrees/928`. + +### 1. Create the docs branch + +Create a new branch `docs/overhaul` from the current HEAD. All subsequent work targets this branch. + +### 2. Restructure `mkdocs.yml` nav + +Rewrite lines 32–126 of `mkdocs.yml` (the `nav:` section). The new nav must implement these structural changes: + +- **Eliminate the "Advanced" section entirely.** Rehome its content: + - `custom-states.md`, `state-registry.md`, `type-registry.md` → `core-concepts/states/` as depth pages + - `log-level-tuning.md` → new "Operating Hassette" section + - `managing-helpers.md` → `core-concepts/api/managing-helpers.md` (move the file too) + - `advanced/index.md` → delete (content absorbed into rehomed pages) +- **Restructure Web UI** from tab-mirroring to task-oriented. Maximum 6 pages. Candidate tasks: debugging a failing handler, reading logs, managing apps (start/stop/reload/health). Consolidate the current 12 pages (6 top-level + 6 app-detail) into ≤6 task-oriented pages. Each page must represent a discrete user task — justify the page's existence. +- **Add "Operating Hassette" section** containing: Log Level Tuning (from Advanced), Upgrading Hassette (extracted from current Troubleshooting). Troubleshooting remains pure symptom-lookup. +- **Designate DI canonical home** at `core-concepts/bus/dependency-injection.md`. This is already the path — just confirm it in the nav and note that all other DI references compress to one sentence + link. +- **Structure States depth pages** under `core-concepts/states/`: overview (existing `index.md`), plus at minimum "Subscribing to State Changes" and "DomainStates Reference" as new depth pages. Custom States, State Registry, and Type Registry move here from Advanced. +- **Add evaluator page** to Getting Started (e.g., "Is Hassette Right for You?" or "Hassette vs. Alternatives"). +- **Fix Managing Helpers** — move from `pages/advanced/managing-helpers.md` to `pages/core-concepts/api/managing-helpers.md` so the filesystem path matches the nav position. +- **Decide Migration page count** — review the 8 current migration pages. Keep all 8 or condense to fewer. The section stays (drop is off the table). Justify the decision. +- **Review PUBLIC_MODULES** — read `tools/gen_ref_pages.py` lines 17–46. Check if the module list is stale (modules renamed, removed, or missing). Note any changes needed but do not modify the generator. + +### 3. Create stub files + +For every page in the new nav tree, create a stub `.md` file containing: +```markdown +# + +*This page is being rewritten as part of the documentation overhaul.* +``` + +This satisfies `mkdocs build --strict` for cross-links. Verify by running `uv run mkdocs build --strict` after creating all stubs. + +### 4. Select three exemplar candidates + +Choose three pages to serve as voice anchors, one for each mode: + +1. **Concept exemplar** — must (a) introduce multiple related terms, (b) send readers to sibling depth pages, (c) have a clear new-reader audience. Strong candidate: Bus overview. +2. **Getting-started or recipe exemplar** — must demonstrate the prose "How It Works" pattern from voice-guide.md. Strong candidate: First Automation or Motion Lights. +3. **Reference exemplar** — must demonstrate terse/tabular voice distinct from concept pages. Strong candidate: DI annotations page or CLI command reference. + +Document the selection and criteria in `design/specs/070-doc-overhaul/tasks/exemplar-selections.md`. + +### 5. Create voice audit checklist + +Write 5–10 concrete, binary pass/fail items drawn from the most commonly violated voice-guide.md rules. Likely items: +- No bullet lists with bolded lead-ins in "How It Works" sections +- System-as-subject in concept pages (no "you") +- No transition sentences opening paragraphs +- Verification steps in recipes name concrete commands +- Terms defined functionally on first use + +Add a reference-mode addendum (3–4 items): tables before prose in reference sections, no narrative arc in annotation tables, terse functional definitions in table cells, no admonitions in reference tables. + +### 6. Create docs-context.md + +Write `design/specs/070-doc-overhaul/docs-context.md` — the single calibration artifact read at the start of each writing session. Contents: +- Paths to all three exemplar pages +- The full voice audit checklist inline (not referenced — copied so the executor has it without reading another file) +- The 3 most common voice violations found in the current docs (identify these by sampling 5–6 current pages) + +## Focus + +**Current nav structure:** `mkdocs.yml` lines 32–126. Nine major sections: Home, Getting Started (4+4 docker), Core Concepts (8 subsections), Web UI (6+6 app-detail), CLI (3), Testing (4), Recipes (6), Advanced (5), Migration (8), plus Troubleshooting and auto-generated pages. + +**Web UI current state:** 12 pages totaling ~1085 lines. `app-detail/handlers.md` is the longest at 202 lines. The top-level pages (apps, handlers, logs, config, layout) mirror tab names; app-detail pages mirror sub-tabs. Task-oriented consolidation means asking "what is the user trying to DO?" not "which tab are they looking at?" + +**Advanced pages to rehome:** `custom-states.md` (→ states), `state-registry.md` (→ states), `type-registry.md` (→ states), `log-level-tuning.md` (→ Operating), `managing-helpers.md` (→ api), `index.md` (→ delete). + +**Snippet counts by section:** advanced: 60, core-concepts: 120, getting-started: 8, migration: 27, recipes: 8, testing: 34, web-ui: 1. The 60 advanced snippets move with their pages to states/. + +**Excluded from page count:** `docs/pages/core-concepts/configuration/snippets/file_discovery.md` is a `.md` file in a snippets dir, excluded by `exclude_docs`. + +**Voice-guide.md** has 22 rules (9 "We Always", 6 "We Never", 7 "When X, Do Y"). Read it fully before creating the checklist. + +## Verify + +- [ ] FR#5: `core-concepts/bus/dependency-injection.md` appears in the nav as the DI canonical page +- [ ] FR#7: Web UI section in `mkdocs.yml` contains ≤6 entries, each with a task-oriented title +- [ ] FR#8: No "Advanced" section exists in `mkdocs.yml` nav +- [ ] FR#9: `core-concepts/states/` nav subsection has overview + at least "Subscribing to State Changes" and "DomainStates Reference" depth pages, plus Custom States, State Registry, Type Registry +- [ ] FR#10: An "Operating Hassette" section exists in the nav with Log Level Tuning and Upgrading content +- [ ] FR#16: `managing-helpers.md` filesystem path is `pages/core-concepts/api/managing-helpers.md` +- [ ] FR#18: Getting Started section includes a dedicated evaluator page +- [ ] AC#8: `grep -c "Advanced" mkdocs.yml` returns 0 in the nav section +- [ ] AC#10: Web UI section in nav has ≤6 pages with task-oriented titles +- [ ] AC#11: States subsection in `mkdocs.yml` has overview, at least two depth pages (state change subscriptions, DomainStates reference), plus Custom States, State Registry, and Type Registry as extension pages +- [ ] AC#13: "Operating Hassette" section exists in nav +- [ ] AC#18: Managing Helpers file path matches nav position +- [ ] AC#20: Evaluator page exists in Getting Started nav diff --git a/design/specs/070-doc-overhaul/tasks/T02-ci-tooling.md b/design/specs/070-doc-overhaul/tasks/T02-ci-tooling.md new file mode 100644 index 000000000..8cde9c421 --- /dev/null +++ b/design/specs/070-doc-overhaul/tasks/T02-ci-tooling.md @@ -0,0 +1,62 @@ +--- +task_id: "T02" +title: "Add muffet link checker and snippet orphan check to CI" +status: "planned" +depends_on: ["T01"] +implements: ["FR#13", "AC#3", "AC#5"] +--- + +## Summary + +Adds two new CI validation jobs to catch documentation defects early: a post-build HTML link checker (muffet) targeting broken anchor fragments, and a snippet orphan check that finds unreferenced `.py` files under `docs/pages/*/snippets/`. Both run on every PR to the docs branch. These tools are prerequisites for the final sweep (T13) and provide ongoing protection after the rewrite. + +## Prompt + +Work on the `docs/overhaul` branch in the worktree at `/home/jessica/source/hassette/.claude/worktrees/928`. + +### 1. Muffet link checker + +Add a CI job that: +1. Runs `uv run mkdocs build --strict` to produce the `site/` directory +2. Starts a local HTTP server serving `site/` +3. Runs [muffet](https://github.com/raviqqe/muffet) against `http://localhost:` to check all internal links including anchor fragments +4. Fails the job if any broken links are found + +Place this in `.github/workflows/docs.yml` as a new job (or extend the existing build job). The existing lychee check in `lint.yml` checks markdown source files — muffet checks the built HTML and catches broken anchor fragments that lychee and `--strict` miss. + +Configuration notes: +- Exclude external URLs (muffet should only check internal links) +- Set a reasonable timeout and concurrency +- The existing lychee config (`lychee.toml`) excludes badges, star-history, localhost — muffet doesn't need these since it only checks the local build + +### 2. Snippet orphan check + +Write a script `tools/check_snippet_orphans.py` that: +1. Finds all `.py` files under `docs/pages/*/snippets/` +2. Finds all `--8<--` include references in `.md` files under `docs/pages/` +3. Reports any `.py` file not referenced by at least one include +4. Exits with code 1 if orphans are found, 0 otherwise + +Add this as a CI step in `.github/workflows/docs.yml` (alongside or after the build job). It should run on every PR that touches `docs/`. + +### 3. Verify both tools locally + +Run both tools against the current docs to establish a baseline: +- Muffet: build the site, serve it, run muffet. Note any existing broken links. +- Orphan check: run the script. The current 258 snippets may have orphans — note them but don't fix them (that's Phase 2/T04 work). + +## Focus + +**Existing CI structure:** `.github/workflows/docs.yml` has two jobs — `build` (mkdocs build --strict) and `docs-check` (API reference drift + Pyright on snippets). The link checker job needs the built `site/` directory, so it must run after the build step. + +**Existing link checking:** `lint.yml` runs lychee on markdown source files (README, CONTRIBUTING, docs/**/*.md) with config in `lychee.toml`. Lychee excludes badges, shields.io, localhost, star-history, docs/reference/ (auto-generated), and compare URLs. Muffet is complementary — it checks built HTML, not markdown source. + +**Snippet structure:** 258 `.py` files across 6 section directories: advanced (60), core-concepts (120), getting-started (8), migration (27), recipes (8), testing (34), web-ui (1). The orphan check should handle both full-file includes and fragment includes (section markers). + +**Include syntax:** Full file: `--8<-- "pages/core-concepts/bus/snippets/file.py"`. Fragment: `--8<-- "pages/core-concepts/bus/snippets/file.py:marker"`. The orphan check must match the file path portion regardless of fragment suffix. + +## Verify + +- [ ] FR#13: The snippet orphan check script exists at `tools/check_snippet_orphans.py` and correctly identifies unreferenced snippet files +- [ ] AC#3: A muffet-based link checker CI job exists in `.github/workflows/docs.yml` that runs against the built `site/` directory and checks anchor fragments +- [ ] AC#5: The snippet orphan check runs in CI on docs PRs and fails if any orphan `.py` files exist under `docs/pages/*/snippets/` diff --git a/design/specs/070-doc-overhaul/tasks/T03-exemplar-pages.md b/design/specs/070-doc-overhaul/tasks/T03-exemplar-pages.md new file mode 100644 index 000000000..6ba03103e --- /dev/null +++ b/design/specs/070-doc-overhaul/tasks/T03-exemplar-pages.md @@ -0,0 +1,85 @@ +--- +task_id: "T03" +title: "Write and review three exemplar pages" +status: "planned" +depends_on: ["T01"] +implements: ["FR#1", "FR#2", "FR#3", "FR#6", "FR#14", "FR#17", "AC#1", "AC#12", "AC#16", "AC#19"] +--- + +## Summary + +Writes the three exemplar pages from scratch — concept, getting-started/recipe, and reference. These pages anchor voice and quality for the entire rewrite. Each exemplar is written, voice-audited against the checklist from T01, and polished until it passes. No bulk writing begins until all three exemplars are approved. This task establishes the patterns that every subsequent writing task follows. + +## Prompt + +Work on the `docs/overhaul` branch. Read `design/specs/070-doc-overhaul/docs-context.md` (the calibration artifact from T01) before starting. It contains the exemplar selections, voice audit checklist, and common violation patterns. + +### For each exemplar page: + +1. **Read the current page content** for reference — extract any technical facts, code patterns, or entity names worth preserving. Do not copy prose. +2. **Read the relevant doc-rules.md template** for the page type (concept, recipe, getting-started, or API reference). +3. **Write the page from blank** following the template structure and voice-guide.md rules. +4. **Create all snippet files** needed by the page. Create stubs first (to satisfy `check_paths`), then fill with real code. Every code example must come from a snippet file via `--8<--` include. +5. **Run the voice audit checklist** item by item. Fix any failures. +6. **Run `uv run mkdocs build --strict`** to verify the page builds cleanly. +7. **Run `uv run pyright --project docs`** to verify snippets type-check. + +### Concept exemplar + +The hardest voice mode — system-as-subject, no "you." Follow the concept page template from doc-rules.md: opening line → basic example → how it works → common patterns → depth → next steps. + +Key voice rules to enforce: +- Rule #1: Open with the construct as subject (not "it," "this," or "you can use") +- Rule #2: 10–18 words per explanatory sentence +- Rule #10: No "you" — system is the subject +- Rule #15: No imperative mood +- Rule #16: name → define → show → constrain + +### Getting-started or recipe exemplar + +Friendlier register — "you" is allowed. Code appears first, explanation after (getting-started) or full runnable app followed by "How It Works" prose (recipe). + +Key voice rules: +- Rule #3: Main behavior first, caveats after +- Rule #17: Show code first, then explain (getting-started) +- Rule #21: Walk through one decision at a time in "How It Works" (recipe) +- "How It Works" must use flowing prose paragraphs, NOT bullet lists with bolded lead-ins + +### Reference exemplar + +Terse functional definitions. Tables before prose. No narrative arc. Distinct from concept voice. + +Key checklist items (reference-mode addendum): +- Tables before prose in reference sections +- No narrative arc in annotation tables +- Terse functional definitions in table cells +- No admonitions in reference tables + +### Cross-cutting requirements for all three: + +- **First use of `D.*`, `states.*`, `C.*`, `P.*`, or `A.*`** must link to the canonical page for that module (FR#6) +- **First use of Bus, Scheduler, Api, Cache, App, StateManager, or Resource** must include a functional definition (FR#17) +- **Every code example** from a snippet file (FR#14) + +## Focus + +**Voice-guide.md** has 22 rules across three sections: "We Always" (1–9), "We Never" (10–15), "When X, Do Y" (16–22). The concept exemplar is the hardest because rules 10 and 15 (no "you," no imperative) conflict with the instinct to address the reader. Read the before/after examples in voice-guide.md — they demonstrate the exact transformation. + +**doc-rules.md templates:** Concept pages have 6 parts, recipe pages 6 parts, getting-started pages 4 parts. Read the template for each exemplar's page type. + +**Snippet infrastructure:** `pymdownx.snippets` with `check_paths: true` and base_path: `docs`. Include paths are relative to `docs/`, e.g., `pages/core-concepts/bus/snippets/file.py`. Section markers: `# --8<-- [start:name]` / `# --8<-- [end:name]`. + +**Common voice violations** (from docs-context.md — created in T01): the 3 most common violations will be listed there. Watch for them in your writing. + +## Verify + +- [ ] FR#1: All three exemplar pages pass every item on the voice audit checklist +- [ ] FR#2: Concept exemplar uses system-as-subject voice throughout — no "you" outside getting-started/recipe +- [ ] FR#3: Getting-started/recipe exemplar uses direct "you" address with code-first ordering +- [ ] FR#6: Every first use of `D.*`, `states.*`, `C.*`, `P.*`, `A.*` links to the canonical page +- [ ] FR#14: Every code example comes from a snippet file via `--8<--` include — no inline code blocks +- [ ] FR#17: Every first use of Bus, Scheduler, Api, Cache, App, StateManager, or Resource has a functional definition +- [ ] AC#1: Voice audit checklist applied and all items pass for each exemplar +- [ ] AC#12: Module cross-links present on first use +- [ ] AC#16: No inline Hassette code examples — all from snippet files +- [ ] AC#19: Hassette term definitions present on first use diff --git a/design/specs/070-doc-overhaul/tasks/T04-content-outlines.md b/design/specs/070-doc-overhaul/tasks/T04-content-outlines.md new file mode 100644 index 000000000..b5267060a --- /dev/null +++ b/design/specs/070-doc-overhaul/tasks/T04-content-outlines.md @@ -0,0 +1,71 @@ +--- +task_id: "T04" +title: "Create per-page content outlines for all pages" +status: "planned" +depends_on: ["T01", "T03"] +implements: ["FR#15", "AC#17"] +--- + +## Summary + +Phase 2 of the rewrite. Creates detailed content outlines for every page in the new tree: section headings with 1–2 sentence descriptions, named snippet inventories, and unclaimed snippet mapping. For troubleshooting and operational pages, extracts a knowledge inventory (log signatures, timing values, runbook commands) from the current pages before they're overwritten. The outlines serve as the blueprint for Phase 3 writing tasks. + +## Prompt + +Work on the `docs/overhaul` branch. Read the finalized `mkdocs.yml` nav from T01 to know the full page tree. + +### For each page in the tree: + +Create an outline file at `design/specs/070-doc-overhaul/outlines/
/.md` containing: + +1. **Section headings** — the H2/H3 structure the final page will have. Each heading gets a 1–2 sentence description of what content belongs there. +2. **Snippet inventory** — named list of code examples the page needs. Format: `snippet-name.py — what it demonstrates`. Distinguish between: + - Existing snippets to keep (with current path) + - Existing snippets to rewrite (with current path and what changes) + - New snippets to create (with proposed path) +3. **Cross-links** — which other pages this page links to, and which pages link to it. + +### Unclaimed snippet mapping + +After outlining all pages, produce a summary at `design/specs/070-doc-overhaul/outlines/snippet-mapping.md`: + +- **Claimed:** snippet files assigned to at least one page outline +- **Unclaimed:** snippet files referenced by no outline (candidates for deletion in Phase 3) +- **New:** snippet files that need to be created + +Current snippet counts: advanced (60), core-concepts (120), getting-started (8), migration (27), recipes (8), testing (34), web-ui (1) = 258 total. + +### Knowledge inventory for operational pages + +For these pages, read the current content thoroughly and extract every piece of operational knowledge before it's overwritten: + +- **`troubleshooting.md`** — log signatures, error messages, timing values, resolution steps +- **`advanced/log-level-tuning.md`** → moving to Operating Hassette +- Any upgrade/migration runbook content in the current docs + +Write the inventory to `design/specs/070-doc-overhaul/outlines/knowledge-inventory.md`. Format: one entry per knowledge item with the source page and line range. This is the safety net for FR#15 — if something from the current docs doesn't appear in the inventory, it risks being lost. + +## Focus + +**Page count by section (from T01 nav):** +- Getting Started: 8 pages (4 main + 4 docker) +- Core Concepts: ~25 pages across 8+ subsections +- Web UI: ≤6 pages (consolidated) +- CLI: 4 pages +- Testing: 4 pages +- Recipes: 7 pages +- Migration: ≤8 pages +- Troubleshooting: 1 page +- Operating Hassette: 2–3 pages (new) +- Home: 1 page + +**Knowledge loss risk:** The troubleshooting page contains log signatures, timing values (e.g., reconnection delays, timeout thresholds), and step-by-step resolution procedures that exist nowhere else in the codebase. These are the hardest items to reconstruct if lost. Extract them first. + +**Snippet mapping complexity:** The 60 advanced snippets need remapping — custom-states, state-registry, and type-registry snippets move to core-concepts/states/snippets/. Log-level-tuning snippets move to the Operating Hassette section. Managing-helpers snippets move to core-concepts/api/snippets/. + +**Exemplar pages (from T03)** are the voice reference. The outlines don't need to demonstrate voice — they're structural blueprints. But they should note where voice mode switches (e.g., "How It Works uses system-as-subject per voice-guide rule #21"). + +## Verify + +- [ ] FR#15: Knowledge inventory exists at `design/specs/070-doc-overhaul/outlines/knowledge-inventory.md` and covers every named failure mode, log signature, timing value, and runbook command from the current troubleshooting and operational pages +- [ ] AC#17: Diff the knowledge inventory against the current troubleshooting page — every item in the current page has a corresponding entry in the inventory diff --git a/design/specs/070-doc-overhaul/tasks/T05-pyright-cleanup.md b/design/specs/070-doc-overhaul/tasks/T05-pyright-cleanup.md new file mode 100644 index 000000000..a874c1dd0 --- /dev/null +++ b/design/specs/070-doc-overhaul/tasks/T05-pyright-cleanup.md @@ -0,0 +1,51 @@ +--- +task_id: "T05" +title: "Scope Pyright suppressions per-file in docs config" +status: "planned" +depends_on: ["T04"] +implements: ["AC#4"] +--- + +## Summary + +Pre-Phase 3 cleanup. Audits the global Pyright suppressions in `docs/pyrightconfig.json` and moves them to per-file exclusions where possible. New snippet files written during Phase 3 should get strict type checking by default — broad global suppressions mask real type errors in new code. + +## Prompt + +Work on the `docs/overhaul` branch. + +### 1. Audit current suppressions + +Read `docs/pyrightconfig.json`. The current config has: +- Per-directory exclusions for `pages/advanced/snippets/custom-states`, `pages/advanced/snippets/state-registry`, `pages/advanced/snippets/type-registry/base_state_convert_call.py`, and `pages/migration/snippets` +- Global error checks: `reportAttributeAccessIssue`, `reportUndefinedVariable`, `reportReturnType`, `reportUnnecessaryComparison` all set to `"error"` + +The design doc flags `reportOperatorIssue` and `reportAssignmentType` as candidates for scoping — check whether these are currently suppressed globally (they may be off by default in the Pyright version used). + +### 2. Identify which snippets trigger suppressions + +Run `uv run pyright --project docs` with stricter settings to see what breaks. For each suppressed rule: +- Count how many snippet files trigger it +- Determine if those files are concentrated in specific directories or scattered + +### 3. Move to per-file where possible + +If a suppression is triggered by files in only 1–2 directories, move it from a global suppression to per-directory or per-file exclusions. If it's triggered across many directories, document why the global suppression must stay. + +Update `docs/pyrightconfig.json` with the new configuration. The advanced/ snippet paths will need updating since those snippets moved to core-concepts/states/ in T01. + +### 4. Verify + +Run `uv run pyright --project docs` and confirm it passes with the updated config. + +## Focus + +**Current exclusion paths are stale after T01:** The advanced/ snippets moved to core-concepts/states/snippets/ (custom-states, state-registry, type-registry). Update the exclusion paths to match the new locations. + +**Migration snippets** import `appdaemon` which isn't installed — these legitimately need suppression and should stay excluded. + +**New snippets in Phase 3** should not inherit broad suppressions. The goal is: existing files that need suppression get it explicitly, new files get strict checking. + +## Verify + +- [ ] AC#4: `uv run pyright --project docs` passes with zero errors after config changes diff --git a/design/specs/070-doc-overhaul/tasks/T06-write-getting-started.md b/design/specs/070-doc-overhaul/tasks/T06-write-getting-started.md new file mode 100644 index 000000000..748f42648 --- /dev/null +++ b/design/specs/070-doc-overhaul/tasks/T06-write-getting-started.md @@ -0,0 +1,64 @@ +--- +task_id: "T06" +title: "Write Getting Started section" +status: "planned" +depends_on: ["T04"] +implements: ["FR#3", "FR#18", "AC#6", "AC#20"] +--- + +## Summary + +Writes all Getting Started pages from blank: the evaluator-facing page, quickstart/installation, first automation, HA token guide, hassette-vs-ha-yaml comparison, and the 4 Docker pages. This is the user's first contact with Hassette docs — it must be approachable, concrete, and lead to a working setup. Uses "you" address and code-first ordering per the getting-started template. + +## Prompt + +Work on the `docs/overhaul` branch. Before writing, read: +- `design/specs/070-doc-overhaul/docs-context.md` (calibration artifact — exemplar paths, voice checklist) +- `design/specs/070-doc-overhaul/outlines/getting-started/` (Phase 2 outlines for each page) +- The getting-started exemplar page from T03 (voice reference) +- `.claude/rules/voice-guide.md` and `.claude/rules/doc-rules.md` + +### Pages to write (9 total): + +1. **Evaluator page** (new) — "Is Hassette Right for You?" or similar. What Hassette is, who it's for, how it compares to AppDaemon, HA YAML automations, and pyscript. Honest about tradeoffs. This is FR#18. +2. **index.md** — Quickstart overview. What you'll build, prerequisites, link to first automation. +3. **first-automation.md** — Complete walkthrough from empty file to working app. Code-first: show the code, then explain each part. +4. **ha_token.md** — How to get a long-lived access token from Home Assistant. +5. **hassette-vs-ha-yaml.md** — Side-by-side comparison for users coming from HA YAML automations. +6. **docker/index.md** — Docker deployment overview. +7. **docker/dependencies.md** — Managing Python dependencies in Docker. +8. **docker/image-tags.md** — Available image tags and which to use. +9. **docker/troubleshooting.md** — Docker-specific issues and fixes. + +### Voice for this section: + +- **Use "you" and "your"** — this is getting-started content (voice-guide rule #17) +- **Code first, then explain** — show the snippet, then walk through it +- **Short steps** — maximum 4–5 major steps per page, sub-steps are fine +- **Concrete verification** — each major step should produce visible progress. The first automation MUST include running `hassette status` and seeing `websocket_connected: True`, and running `hassette app` and seeing the app listed (AC#6). + +### For each page: + +1. Read the Phase 2 outline for section headings and snippet inventory +2. Read the current page content for technical facts to preserve (do not copy prose) +3. Write from blank following the getting-started template (what you'll build → prerequisites → steps → next steps) +4. Create snippet files (stubs first, then fill) — all code examples from snippets +5. Run voice audit checklist +6. Run `uv run mkdocs build --strict` and `uv run pyright --project docs` + +## Focus + +**Current getting-started pages** are already close to the voice standard (identified in the design doc as closest to target voice). The risk is regression — writing worse prose than what exists. Read the current pages for quality reference but don't copy. + +**Evaluator page is new** — no existing content. Reference the design doc User Scenarios section for the Evaluator actor's task flow: lands on home page or getting started → decides whether to invest time → follows quickstart or leaves. + +**AC#6 is specific:** the reader must run `hassette status` and see `websocket_connected: True`, and run `hassette app` and see their app listed as running. Build these verification steps into the first-automation page. + +**Docker pages** are a sub-section with their own flow. Users following the Docker path may skip the non-Docker quickstart. Ensure the Docker index provides a complete path. + +## Verify + +- [ ] FR#3: All getting-started pages use direct "you" address with code-first ordering +- [ ] FR#18: A dedicated evaluator page exists covering what Hassette is, who it's for, and how it compares to alternatives +- [ ] AC#6: First automation page includes `hassette status` showing `websocket_connected: True` and `hassette app` showing the app listed +- [ ] AC#20: Evaluator page exists in Getting Started nav diff --git a/design/specs/070-doc-overhaul/tasks/T07-write-core-concepts-1.md b/design/specs/070-doc-overhaul/tasks/T07-write-core-concepts-1.md new file mode 100644 index 000000000..a22fd9614 --- /dev/null +++ b/design/specs/070-doc-overhaul/tasks/T07-write-core-concepts-1.md @@ -0,0 +1,81 @@ +--- +task_id: "T07" +title: "Write Core Concepts — Apps, Bus, Architecture, Internals" +status: "planned" +depends_on: ["T04"] +implements: ["FR#2", "FR#5", "FR#11", "FR#12", "AC#9", "AC#14", "AC#15"] +--- + +## Summary + +Writes the first half of Core Concepts from blank: Architecture overview (the "five handles" model, app-author only), Apps subsection (overview, lifecycle, configuration, task-bucket), Bus subsection (overview, handlers, filtering, dependency-injection), Internals page, and database-telemetry page. The Bus subsection is critical because it contains the DI canonical page — the single authoritative source for dependency injection documentation. Architecture is scoped strictly to app-authors; contributor/maintainer content goes to Internals. + +## Prompt + +Work on the `docs/overhaul` branch. Before writing, read: +- `design/specs/070-doc-overhaul/docs-context.md` (calibration artifact) +- `design/specs/070-doc-overhaul/outlines/core-concepts/` (Phase 2 outlines for each page) +- The concept exemplar page from T03 (voice reference for system-as-subject) +- `.claude/rules/voice-guide.md` and `.claude/rules/doc-rules.md` + +### Pages to write (~12): + +**Architecture (1 page):** +- `core-concepts/index.md` — The "five handles" model: Bus, Scheduler, Api, StateManager, Cache. How apps work. App-author audience ONLY (FR#11). +- **Must NOT contain:** dependency graphs, wave ordering, cycle detection, internal service names (AC#14). These go in Internals. + +**Apps (4 pages):** +- `core-concepts/apps/index.md` — What an App is, the five handles available via `self.*`, how to create one +- `core-concepts/apps/lifecycle.md` — `on_initialize`, `on_ready`, `on_shutdown` hooks +- `core-concepts/apps/configuration.md` — `AppConfig`, `SettingsConfigDict`, env prefix +- `core-concepts/apps/task-bucket.md` — Background task management + +**Bus (4 pages):** +- `core-concepts/bus/index.md` — Event pub/sub overview, subscription methods, what fires when +- `core-concepts/bus/handlers.md` — Handler signatures, async handlers, error handling +- `core-concepts/bus/filtering.md` — Predicates (P), Conditions (C), Accessors (A), glob patterns, debounce, throttle +- `core-concepts/bus/dependency-injection.md` — THE canonical DI page (FR#5). Full explanation of `D.*` annotations, typed state injection, how Hassette resolves parameters. All other pages that mention DI compress to one sentence + link to this page. + +**Internals (1 page):** +- `core-concepts/internals.md` — Dependency graphs, wave ordering, cycle detection, internal service names, Resource hierarchy (FR#12, AC#15). Contributor/maintainer audience. + +**Database-telemetry (1 page):** +- `core-concepts/database-telemetry.md` — Telemetry DB schema, retention, what's tracked + +### Voice for this section: + +- **System-as-subject** — no "you" (voice-guide rule #10). "The bus delivers events" not "you receive events." +- **No imperative mood** in concept pages (rule #15). Use declarative statements. +- **Name → define → show → constrain** for introducing concepts (rule #16). +- **10–18 words per explanatory sentence** (rule #2). + +### DI canonical page (FR#5): + +This page is the single source of truth for DI in Hassette docs. It must cover: +- What DI is and how Hassette implements it (brief) +- All `D.*` annotations with examples +- How Hassette resolves handler parameters +- Typed state injection (`D.StateNew[states.SunState]`) +- Common patterns and gotchas + +After writing this page, grep all other pages for DI references. Each should be compressed to one sentence + link. Example: "Hassette injects typed state objects into handler parameters — see [Dependency Injection](../bus/dependency-injection.md)." + +## Focus + +**Current Bus snippets:** 53 files in `docs/pages/core-concepts/bus/snippets/`. Many will be rewritten. The Phase 2 outline (T04) has the keep/rewrite/new mapping. + +**Current Apps snippets:** 15 files. Includes `apps_cache_counter.py` (cache example). + +**Architecture page currently mixes audiences** — has both app-author content (five handles) and maintainer content (dependency graphs, wave ordering). The split is: Architecture gets the five handles model, Internals gets everything else. + +**DI is currently explained in:** `core-concepts/bus/dependency-injection.md` (detailed), various getting-started pages (introductory), and scattered references elsewhere. After this task, only the canonical page has the full explanation. + +## Verify + +- [ ] FR#2: All concept pages use system-as-subject voice — no "you" outside getting-started/recipe content +- [ ] FR#5: `core-concepts/bus/dependency-injection.md` contains the full DI explanation; grep other pages for DI references and confirm each is one sentence + link +- [ ] FR#11: Architecture page covers the five handles model for app-authors only +- [ ] FR#12: Internals page contains dependency graphs, wave ordering, cycle detection, and internal service names +- [ ] AC#9: `core-concepts/bus/dependency-injection.md` is the only page with a full DI explanation +- [ ] AC#14: Architecture page does not mention dependency graphs, wave ordering, cycle detection, or internal service names +- [ ] AC#15: `internals.md` contains dependency graphs, wave ordering, cycle detection, and internal service names diff --git a/design/specs/070-doc-overhaul/tasks/T08-write-core-concepts-2.md b/design/specs/070-doc-overhaul/tasks/T08-write-core-concepts-2.md new file mode 100644 index 000000000..bfbd610ec --- /dev/null +++ b/design/specs/070-doc-overhaul/tasks/T08-write-core-concepts-2.md @@ -0,0 +1,83 @@ +--- +task_id: "T08" +title: "Write Core Concepts — Scheduler, States, API, Cache, Config" +status: "planned" +depends_on: ["T04"] +implements: ["FR#9", "AC#11"] +--- + +## Summary + +Writes the second half of Core Concepts from blank: Scheduler (overview, methods, management), States (overview plus new depth pages including content rehomed from Advanced), API (overview, entities, services, utilities, managing-helpers), Cache (overview, patterns), and Configuration (overview, global, applications, auth). The States subsection is the most structurally changed — it gains depth pages matching the Bus pattern and absorbs Custom States, State Registry, and Type Registry from the eliminated Advanced section. + +## Prompt + +Work on the `docs/overhaul` branch. Before writing, read: +- `design/specs/070-doc-overhaul/docs-context.md` (calibration artifact) +- `design/specs/070-doc-overhaul/outlines/core-concepts/` (Phase 2 outlines) +- The concept exemplar page from T03 (voice reference) +- `.claude/rules/voice-guide.md` and `.claude/rules/doc-rules.md` + +### Pages to write (~14): + +**Scheduler (3 pages):** +- `core-concepts/scheduler/index.md` — Task scheduling overview, trigger types, the `schedule()` entry point +- `core-concepts/scheduler/methods.md` — `run_in()`, `run_once()`, `run_every()`, `run_daily()`, `run_cron()`, custom triggers +- `core-concepts/scheduler/management.md` — Job groups, `cancel_group()`, `list_jobs()`, jitter + +**States (4–5 pages):** +- `core-concepts/states/index.md` — State access overview, domain access, type conversion +- `core-concepts/states/subscribing.md` (new) — "Subscribing to State Changes" depth page +- `core-concepts/states/domain-states.md` (new) — "DomainStates Reference" depth page +- `core-concepts/states/custom-states.md` (from advanced/) — Custom state classes +- `core-concepts/states/state-registry.md` (from advanced/) — STATE_REGISTRY +- `core-concepts/states/type-registry.md` (from advanced/) — TYPE_REGISTRY + +**API (5 pages):** +- `core-concepts/api/index.md` — REST/WebSocket interface overview +- `core-concepts/api/entities.md` — Entity access, get_state, get_states +- `core-concepts/api/services.md` — call_service, fire_event +- `core-concepts/api/utilities.md` — set_state, utility methods +- `core-concepts/api/managing-helpers.md` (moved from advanced/) — Creating and managing HA helpers + +**Cache (2 pages):** +- `core-concepts/cache/index.md` — Persistent disk-based storage, basic usage +- `core-concepts/cache/patterns.md` — Rate limiting, counters, complex data, expiry + +**Configuration (4 pages):** +- `core-concepts/configuration/index.md` — Configuration overview, file discovery +- `core-concepts/configuration/global.md` — Global settings, hassette.toml structure +- `core-concepts/configuration/applications.md` — Per-app configuration, AppConfig +- `core-concepts/configuration/auth.md` — Authentication, HA token configuration + +### Voice: same as T07 + +System-as-subject, no "you," declarative, 10–18 words per sentence. See T07 for the full voice reference. + +### States subsection (FR#9): + +This subsection gains the most structure. The new depth pages must match the Bus pattern: +- Overview page introduces the concept and links to depth pages +- Each depth page goes deep on one aspect +- Custom States, State Registry, and Type Registry are rehomed from Advanced — rewrite the content, don't just move the files. The Advanced voice may not match the concept page standard. + +### Snippet migration: + +The 60 advanced snippets include files for custom-states, state-registry, and type-registry. These move to `core-concepts/states/snippets/`. The Phase 2 outline (T04) has the specific mapping. Ensure `--8<--` include paths are updated. + +## Focus + +**Scheduler snippets:** 22 files in `docs/pages/core-concepts/scheduler/snippets/`. + +**States currently has 1 page and 4 snippets.** This task expands it to 4–5 pages with significant new content. The 60 advanced snippets are the main source of reusable code — but they need rewriting to match concept-page voice. + +**API snippets:** 14 files. Managing-helpers moves from advanced to API — its snippets move too. + +**Cache snippets:** 9 files including basic usage, rate limiting, counters, complex data, expiry, performance. + +**Configuration has a .md snippet** (`file_discovery.md`) in its snippets directory — this is excluded from rendering by `exclude_docs` but is referenced by the configuration page as an include. + +## Verify + +- [ ] FR#9: States subsection has overview page, "Subscribing to State Changes" depth page, "DomainStates Reference" depth page, plus Custom States, State Registry, and Type Registry +- [ ] AC#11: States subsection in `mkdocs.yml` matches the required structure with overview + depth pages + extension pages diff --git a/design/specs/070-doc-overhaul/tasks/T09-write-web-ui.md b/design/specs/070-doc-overhaul/tasks/T09-write-web-ui.md new file mode 100644 index 000000000..07772e4e1 --- /dev/null +++ b/design/specs/070-doc-overhaul/tasks/T09-write-web-ui.md @@ -0,0 +1,58 @@ +--- +task_id: "T09" +title: "Write Web UI section" +status: "planned" +depends_on: ["T04"] +implements: ["FR#7", "AC#10"] +--- + +## Summary + +Writes the Web UI section from blank, consolidating the current 12 tab-mirroring pages into ≤6 task-oriented pages. The restructuring is the key change: instead of "Apps page," "Handlers page," "Logs page" (which describe UI elements), pages are organized by what the user is trying to do: "Debug a failing handler," "Read and filter logs," "Manage apps." Each page must justify its existence as a discrete user task. + +## Prompt + +Work on the `docs/overhaul` branch. Before writing, read: +- `design/specs/070-doc-overhaul/docs-context.md` (calibration artifact) +- `design/specs/070-doc-overhaul/outlines/web-ui/` (Phase 2 outlines) +- `.claude/rules/voice-guide.md` and `.claude/rules/doc-rules.md` + +### Pages to write (≤6): + +The exact page list was finalized in T01. Candidate task pages from the design doc: +- **Debugging a failing handler** — using the handlers view, invocation history, and logs to identify why a handler isn't working +- **Reading logs** — log filtering, log levels, finding specific events +- **Managing apps** — start, stop, reload, health checks via the web UI + +The T01 nav may have refined this list. Follow the T01 decisions. + +### Voice: + +Web UI docs bridge concept and tutorial modes. The reader has a working Hassette instance and needs to accomplish a specific task. Use system-as-subject for explaining what the UI shows, but "you" is acceptable in procedural steps ("click the Handlers tab," "filter by app key"). + +### Consolidation approach: + +The current 12 pages are: +- Top-level: index, apps, handlers, logs, config, layout (6 pages, ~503 lines) +- App detail: index, overview, handlers, code, config, logs (6 pages, ~583 lines) + +Consolidation means merging related content by user task. The "handlers" content from both top-level and app-detail may merge into one "Debug a failing handler" page. The "logs" content from both levels may merge into one "Read and filter logs" page. + +Delete the old page files after writing the new ones. The stubs from T01 already exist at the new paths. + +### Screenshots and UI references: + +The current web-ui pages reference specific UI elements. When rewriting, describe the UI elements the reader will interact with but don't assume a specific layout version. Focus on what the reader sees and does, not pixel-level descriptions. + +## Focus + +**Current Web UI has only 1 snippet** (`web-ui/snippets/`). The section is mostly prose and screenshots. New pages may need snippets for CLI commands used alongside the UI (e.g., `hassette log --app `). + +**App-detail pages have the most content** (583 lines total, handlers alone is 202 lines). The consolidation must preserve the useful operational knowledge while reorganizing by task. + +**The "layout" page** (`web-ui/layout.md`, 99 lines) describes sidebar navigation and responsive behavior. This content may fold into the overview page or be dropped if it doesn't serve a user task. + +## Verify + +- [ ] FR#7: Web UI section contains ≤6 pages, each organized by user task (not UI element). Each page is justified as a discrete user task. +- [ ] AC#10: Web UI section in `mkdocs.yml` has ≤6 entries with task-oriented titles (not tab names like "Apps," "Handlers," "Logs") diff --git a/design/specs/070-doc-overhaul/tasks/T10-write-cli-testing.md b/design/specs/070-doc-overhaul/tasks/T10-write-cli-testing.md new file mode 100644 index 000000000..401b67981 --- /dev/null +++ b/design/specs/070-doc-overhaul/tasks/T10-write-cli-testing.md @@ -0,0 +1,60 @@ +--- +task_id: "T10" +title: "Write CLI and Testing sections" +status: "planned" +depends_on: ["T04"] +implements: ["FR#1", "FR#14", "AC#1", "AC#16"] +--- + +## Summary + +Writes the CLI section (4 pages) and Testing section (4 pages) from blank. Both sections are reference-heavy — CLI is command/flag/example tables, Testing covers the harness, factories, time control, and concurrency helpers. These sections lean toward the reference exemplar voice: terse, tabular, functional definitions. Code examples come from the existing 34 testing snippets (rewritten as needed). + +## Prompt + +Work on the `docs/overhaul` branch. Before writing, read: +- `design/specs/070-doc-overhaul/docs-context.md` (calibration artifact) +- `design/specs/070-doc-overhaul/outlines/cli/` and `design/specs/070-doc-overhaul/outlines/testing/` (Phase 2 outlines) +- The reference exemplar page from T03 (voice reference for terse/tabular content) +- `.claude/rules/voice-guide.md` and `.claude/rules/doc-rules.md` + +### CLI pages (4): + +- `cli/index.md` — CLI overview, how to invoke `hassette` +- `cli/commands.md` — Command reference: `run`, `status`, `app`, `listener`, `log`, `job` +- `cli/configuration.md` — CLI-specific configuration, environment variables +- `cli/workflows.md` — Common CLI workflows (checking app health, tailing logs, debugging) + +CLI pages are scanning-oriented: command/flag/example tables, not prose. Follow the "Pages that don't fit a template" exception in doc-rules.md for CLI reference. + +### Testing pages (4): + +- `testing/index.md` — Testing overview, two mock strategies (HassetteHarness vs create_hassette_stub) +- `testing/factories.md` — Test factory functions for creating events, states, configs +- `testing/time-control.md` — Time manipulation in tests, freezing time, advancing schedulers +- `testing/concurrency.md` — Testing async handlers, concurrent operations, race conditions + +Testing pages follow the concept template but lean reference. The decision table for HassetteHarness vs stub is a key piece — readers need to quickly determine which strategy fits their test. + +### Voice: + +These sections are closest to the reference exemplar. Tables before prose in command references. Functional definitions in table cells. But concept-level content (like "when to use HassetteHarness vs stub") still uses the concept page voice (system-as-subject, declarative). + +### Snippet handling: + +Testing has 34 existing snippets. The Phase 2 outline (T04) maps which to keep, rewrite, or delete. CLI has no snippets currently — add them for command examples if the outlines call for it. + +## Focus + +**Testing snippets are substantial** — 34 files covering harness setup, factory usage, time control, and concurrency patterns. These are the examples users copy. Ensure they reflect current API signatures. + +**CLI has no snippets** — this is unusual. The Phase 2 outline may call for snippet files showing CLI invocations with expected output. If so, these would be non-Python files (shell commands) — check if `pymdownx.snippets` supports them or if inline code blocks are acceptable for CLI output examples. Note: FR#14 requires snippet files for Hassette *code* examples — CLI output may be an exception. + +**The harness vs stub decision** is one of the most-referenced pieces in the testing docs. The current `tests/TESTING.md` has the decision table — preserve and improve it. + +## Verify + +- [ ] FR#1: All pages pass the voice audit checklist +- [ ] FR#14: Every Hassette code example comes from a snippet file — no inline code blocks for code examples (CLI output examples may be inline if they're not Hassette code) +- [ ] AC#1: Voice audit checklist applied and all items pass +- [ ] AC#16: No inline Hassette code examples that should be in snippet files diff --git a/design/specs/070-doc-overhaul/tasks/T11-write-recipes.md b/design/specs/070-doc-overhaul/tasks/T11-write-recipes.md new file mode 100644 index 000000000..5f8c65b12 --- /dev/null +++ b/design/specs/070-doc-overhaul/tasks/T11-write-recipes.md @@ -0,0 +1,66 @@ +--- +task_id: "T11" +title: "Write Recipes section" +status: "planned" +depends_on: ["T04"] +implements: ["FR#4", "AC#7"] +--- + +## Summary + +Writes all 7 recipe pages from blank. Each recipe is a self-contained example: problem statement, full runnable app, "How It Works" prose walkthrough, verification step, and variations. The "Verify it's working" step (FR#4) is the key addition — every recipe must name a concrete command or UI action that produces observable output proving the automation fired. + +## Prompt + +Work on the `docs/overhaul` branch. Before writing, read: +- `design/specs/070-doc-overhaul/docs-context.md` (calibration artifact) +- `design/specs/070-doc-overhaul/outlines/recipes/` (Phase 2 outlines) +- The recipe exemplar page from T03 (voice reference for "How It Works" prose) +- `.claude/rules/voice-guide.md` and `.claude/rules/doc-rules.md` + +### Pages to write (7): + +- `recipes/index.md` — Recipe overview, how to use recipes, links to individual recipes +- `recipes/motion-lights.md` — Turn lights on with motion, off after delay +- `recipes/debounce-sensor-changes.md` — Wait for sensor stability before reacting +- `recipes/sensor-threshold.md` — React when a sensor crosses a threshold +- `recipes/daily-notification.md` — Send a notification at a specific time +- `recipes/service-call-reaction.md` — React to HA service calls +- `recipes/vacation-mode-toggle.md` — Toggle a set of automations with a single switch + +### Recipe template (from doc-rules.md): + +1. **Problem statement** — one paragraph, concrete example +2. **The Code** — full runnable app with config +3. **How it works** — prose walkthrough, one decision at a time (voice-guide rule #21). **Flowing prose paragraphs, NOT bullet lists with bolded lead-ins.** This is the most commonly violated pattern. +4. **Verify it's working** (FR#4) — a concrete command (`hassette log --app `, `hassette listener --app `) or web UI action (Handlers tab → check invocation count) the reader runs to confirm the automation fires. Show expected output. +5. **Variations** — alternative approaches or tweaks +6. **See also** — links to concept pages for features used, related recipes + +### Voice: + +Recipes use "you" in procedure sections and variations (voice-guide rule #21 exception). But "How It Works" sections use system-as-subject throughout — explain what the code does, not what "you" did. This is the subtle distinction: the reader acts in procedural steps, but the code is the subject when explaining behavior. + +### "Verify it's working" (FR#4): + +Every recipe MUST include this section. Examples: +- "Run `hassette log --app motion_lights --since 5m` and look for `handler fired: on_motion_change`" +- "Open the web UI Handlers tab, filter by `motion_lights`, and check that the invocation count increased" +- "Trigger the motion sensor and verify the light turns on within 2 seconds" + +The verification must be something the reader can actually do, not a theoretical description. + +## Focus + +**Current recipes are close to the voice standard** — identified in the design doc as one of the closest sections. The risk is regression. Read current recipes before rewriting to absorb what works. + +**Recipe snippets:** 8 files. Each recipe typically has one full app file. The Phase 2 outline (T04) maps these. + +**"How It Works" voice trap:** The most common violation in current docs is bullet lists with bolded lead-ins in "How It Works" sections. Voice-guide.md has explicit before/after examples showing the correct pattern. Read them before writing. + +**Real entity names** — use `light.kitchen`, `sensor.outdoor_temperature`, `binary_sensor.front_door` — not `entity.my_entity` or `sensor.test_sensor`. + +## Verify + +- [ ] FR#4: Every recipe includes a "Verify it's working" section with a concrete command or UI action that produces observable output +- [ ] AC#7: Each recipe's verification step names a specific command (`hassette log`, `hassette listener`) or UI action (Handlers tab) with expected output diff --git a/design/specs/070-doc-overhaul/tasks/T12-write-migration-ops.md b/design/specs/070-doc-overhaul/tasks/T12-write-migration-ops.md new file mode 100644 index 000000000..95fff2ed2 --- /dev/null +++ b/design/specs/070-doc-overhaul/tasks/T12-write-migration-ops.md @@ -0,0 +1,75 @@ +--- +task_id: "T12" +title: "Write Migration, Troubleshooting, and Operating Hassette" +status: "planned" +depends_on: ["T04"] +implements: ["FR#10", "FR#15", "AC#13", "AC#17"] +--- + +## Summary + +Writes three sections from blank: Migration (≤8 pages for AppDaemon users), Troubleshooting (1 page, pure symptom-lookup), and the new Operating Hassette section (2–3 pages for log tuning and upgrading). Troubleshooting and Operating are the highest-risk pages for knowledge loss — they contain log signatures, timing values, and runbook commands that exist nowhere else. The knowledge inventory from T04 is the safety net. + +## Prompt + +Work on the `docs/overhaul` branch. Before writing, read: +- `design/specs/070-doc-overhaul/docs-context.md` (calibration artifact) +- `design/specs/070-doc-overhaul/outlines/migration/`, `outlines/troubleshooting/`, `outlines/operating/` (Phase 2 outlines) +- `design/specs/070-doc-overhaul/outlines/knowledge-inventory.md` (CRITICAL — extracted operational knowledge from T04) +- `.claude/rules/voice-guide.md` and `.claude/rules/doc-rules.md` + +### Migration pages (≤8): + +The page count was decided in T01. Current pages: +- `migration/index.md` — Migration overview +- `migration/concepts.md` — AppDaemon vs Hassette concepts +- `migration/bus.md` — Event handling migration +- `migration/scheduler.md` — Scheduling migration +- `migration/api.md` — API migration +- `migration/configuration.md` — Config migration +- `migration/testing.md` — Testing migration +- `migration/checklist.md` — Migration checklist + +If T01 condensed to fewer pages, follow that decision. The section stays — Hassette has no existing users who've completed the migration, so this content is still a primary inflow path. + +Migration pages follow a comparison-driven structure: old way (AppDaemon) vs new way (Hassette). Use tabs for side-by-side comparison where it helps. Voice: direct "you" is acceptable since readers are performing migration steps. + +### Troubleshooting (1 page): + +- `troubleshooting.md` — Pure symptom-lookup. Problem/solution format: symptom, cause, fix. No how-to content (that goes in Operating Hassette). + +**CRITICAL: Use the knowledge inventory from T04.** Every named failure mode, log signature, timing value, and resolution step from the current troubleshooting page must appear in the rewritten version. The knowledge inventory is the checklist — diff the final page against it to verify nothing was lost. + +### Operating Hassette (2–3 pages, new section): + +- `operating/index.md` — Operational overview +- `operating/log-levels.md` — Log level tuning (from advanced/log-level-tuning.md) +- `operating/upgrading.md` — Upgrading Hassette (extracted from current troubleshooting) + +Operating pages are how-to content for running Hassette in production. Distinct from Troubleshooting (which is reactive symptom-lookup). Voice: procedural "you" is acceptable for step-by-step instructions. + +### Snippet handling: + +Migration has 27 snippets showing AppDaemon code alongside Hassette equivalents. These are valuable for side-by-side comparison — keep and update. Troubleshooting and Operating may need new snippets for configuration examples and CLI commands. + +## Focus + +**Knowledge loss is the primary risk.** The current troubleshooting page contains: +- Specific log signatures (e.g., exact error messages for WebSocket disconnection) +- Timing values (e.g., reconnection delay thresholds, timeout periods) +- Step-by-step runbook commands (e.g., checking service status, clearing state) + +None of these exist anywhere else in the codebase. The knowledge inventory from T04 is the authoritative list. Verify every item is preserved. + +**Migration snippets** import `appdaemon` which isn't installed in the project — the Pyright config excludes `pages/migration/snippets` for this reason. This exclusion must remain. + +**Log-level-tuning** moves from Advanced to Operating Hassette. Its snippets (from the 60 advanced snippets) move too. The Phase 2 outline has the specific files. + +**Upgrading content** is being extracted from troubleshooting into its own page. This is a structural split — the content exists today but is mixed in with symptom-lookup entries. + +## Verify + +- [ ] FR#10: "Operating Hassette" section exists with Log Level Tuning and Upgrading content; Troubleshooting contains only symptom-lookup entries +- [ ] FR#15: Every named failure mode, log signature, timing value, and runbook command from the knowledge inventory appears in the rewritten troubleshooting and operational pages +- [ ] AC#13: "Operating Hassette" section exists in `mkdocs.yml` with the required content +- [ ] AC#17: Diff the knowledge inventory against the final troubleshooting page — every item is preserved diff --git a/design/specs/070-doc-overhaul/tasks/T13-final-sweep.md b/design/specs/070-doc-overhaul/tasks/T13-final-sweep.md new file mode 100644 index 000000000..7124f91db --- /dev/null +++ b/design/specs/070-doc-overhaul/tasks/T13-final-sweep.md @@ -0,0 +1,91 @@ +--- +task_id: "T13" +title: "Final sweep, snippet cleanup, and docs branch merge" +status: "planned" +depends_on: ["T02", "T05", "T06", "T07", "T08", "T09", "T10", "T11", "T12"] +implements: ["FR#6", "FR#13", "FR#17", "AC#2", "AC#3", "AC#4", "AC#5", "AC#9", "AC#12", "AC#19"] +--- + +## Summary + +The final quality gate before merging the docs branch to main. Runs all CI validation (mkdocs build, Pyright, muffet link checker, snippet orphan check), performs a cross-cutting voice spot-check, verifies DI canonicalization, confirms module cross-links and term definitions across all pages, cleans up orphan snippets, and creates the merge PR from docs branch to main. + +## Prompt + +Work on the `docs/overhaul` branch. + +### 1. Full CI validation + +Run all checks and fix any failures: + +```bash +uv run mkdocs build --strict # AC#2 +uv run pyright --project docs # AC#4 +# Muffet link checker (from T02 CI job) # AC#3 +uv run python tools/check_snippet_orphans.py # AC#5 +``` + +### 2. Snippet orphan cleanup + +Run the snippet orphan check from T02. For each orphan `.py` file: +- Confirm it's genuinely unreferenced (not a false positive from fragment includes) +- Delete it +- Verify `uv run pyright --project docs` still passes after deletion + +### 3. DI canonicalization check (AC#9) + +Grep all pages for dependency injection references: +```bash +grep -rn "dependency injection\|DI\|D\.\|dependencies\." docs/pages/ --include='*.md' +``` + +Verify that `core-concepts/bus/dependency-injection.md` is the only page with a full explanation. All other references should be one sentence + link. Fix any violations. + +### 4. Module cross-link check (AC#12) + +For each page, verify that the first use of `D.*`, `states.*`, `C.*`, `P.*`, or `A.*` links to the canonical page for that module. Grep for these patterns and spot-check. + +### 5. Term definition check (AC#19) + +Spot-check 5–6 pages across different sections. Verify that the first use of Bus, Scheduler, Api, Cache, App, StateManager, or Resource includes a functional definition. + +### 6. Voice spot-check + +Pick one page from each section (8 pages total). Run the voice audit checklist on each. Fix any failures. This catches voice drift across sessions. + +### 7. Rebase and merge + +Rebase `docs/overhaul` onto current `main`. Run CI one final time. Create the merge PR from `docs/overhaul` to `main`. + +The PR description should: +- Summarize the rewrite scope (76 pages, blank-slate) +- List the structural changes (Advanced eliminated, Web UI consolidated, Operating section added, States expanded) +- Note the new CI jobs (muffet link checker, snippet orphan check) +- Reference issue #928 + +## Focus + +**This task touches every page** indirectly (validation runs across all pages). Budget time for fixing issues that surface — link checker and snippet orphan check may find problems not caught during section-by-section writing. + +**DI canonicalization** is the most likely cross-cutting violation. Writers for T06–T12 may have included DI explanations beyond one sentence + link. Grep is the reliable check. + +**Muffet may find broken anchor fragments** that `--strict` missed. These are typically `#section-heading` references where the heading text changed during rewriting. Fix by updating the link or the heading. + +**Snippet orphans** are expected — Phase 2 identified unclaimed snippets, but some may have been missed during writing. The orphan check script (T02) is the definitive tool. + +**README.md** — check if the docs site URL or getting-started link changed. Update if needed (design doc Blast Radius section flagged this). + +**Issue #540** — "final docs sweep before v1.0.0" is superseded by this issue. Close it in the PR description or separately. + +## Verify + +- [ ] FR#6: Spot-check of 5+ pages confirms first use of `D.*`, `states.*`, `C.*`, `P.*`, `A.*` links to canonical module page +- [ ] FR#13: Snippet orphan check returns 0 orphans after cleanup +- [ ] FR#17: Spot-check of 5+ pages confirms first use of Bus, Scheduler, Api, Cache, App, StateManager, or Resource includes functional definition +- [ ] AC#2: `uv run mkdocs build --strict` succeeds with zero warnings +- [ ] AC#3: Muffet link checker finds zero broken links including anchor fragments +- [ ] AC#4: `uv run pyright --project docs` passes with zero errors +- [ ] AC#5: `uv run python tools/check_snippet_orphans.py` exits 0 +- [ ] AC#9: `core-concepts/bus/dependency-injection.md` is the only page with full DI explanation; all others are one sentence + link +- [ ] AC#12: Module cross-links verified on first use across spot-checked pages +- [ ] AC#19: Term definitions verified on first use across spot-checked pages diff --git a/design/specs/070-doc-overhaul/tasks/context.md b/design/specs/070-doc-overhaul/tasks/context.md new file mode 100644 index 000000000..b2e4879de --- /dev/null +++ b/design/specs/070-doc-overhaul/tasks/context.md @@ -0,0 +1,112 @@ +# Context: Documentation Overhaul + +## Problem & Motivation + +The 76 hand-written documentation pages grew organically over months. Voice-guide.md and doc-rules.md define mature standards, but adherence varies widely: recipes and getting-started are closest to the target voice, core-concepts and advanced are furthest. Dependency injection is explained in three places at contradictory depth. Web UI docs organized by tab names mean readers searching "how do I debug a handler?" can't find it. State customization is buried in "Advanced" instead of next to the States concept page. The Architecture page mixes app-author and contributor audiences. A blank-slate rewrite with a planned structure is the faster path to consistent, reader-serving documentation. + +## Visual Artifacts + +None. + +## Key Decisions + +1. **Blank-slate rewrite over incremental patching.** Structural problems (scattered DI, tab-mirroring Web UI, Advanced grab-bag) compound across pages. Incremental fixes can't address cross-section structure. Trade-off: higher risk of regression on already-good pages, mitigated by exemplar anchoring. +2. **Three-phase process (outline → content outlines → writing).** Optimizes for structural consistency and voice coherence — everything is planned before anything is written. Trade-off: delays visible progress and increases scope fatigue risk. +3. **Three exemplar pages before bulk writing.** Concept, getting-started/recipe, and reference exemplars anchor voice. Written first, reviewed, then used as reference for all remaining pages. +4. **Section PRs to a long-lived docs branch.** Users see an atomic swap when the docs branch merges to main. Review happens incrementally per section. Rebase docs onto main after each section PR. +5. **`mkdocs build --strict` enforced from the start.** Phase 1 creates stub files for every page in the new tree. Stubs satisfy the strict checker even before content is written. Section PRs replace stubs with real content. +6. **Muffet for post-build HTML link checking.** Complements the existing lychee check (which runs on markdown source). Muffet checks built HTML and catches broken anchor fragments that `--strict` and lychee miss. +7. **Migration section stays.** Hassette has no existing users who've completed the migration, so AD migration content is still a primary inflow path. May condense from 8 pages to fewer. +8. **PUBLIC_MODULES review included in Phase 1.** Quick check during site outline to see if the auto-generated API reference module list is stale. + +## Constraints & Anti-Patterns + +- **Voice-guide.md (22 rules) and doc-rules.md are authoritative.** The rewrite conforms to them; it does not revise them (except: doc-rules.md recipe template updated to include "Verify it's working" step per FR#4). +- **`pymdownx.snippets` with `check_paths: true`** — any `--8<--` reference to a non-existent snippet file fails the build. Pages and snippets must be created together. Create snippet stubs first to satisfy the checker. +- **No "Advanced" section.** Content rehomed to core-concepts/states/ (custom states, state registry, type registry), troubleshooting (log level tuning), and the new Operating Hassette section. +- **DI has one canonical page** at `core-concepts/bus/dependency-injection.md`. All other pages that reference DI compress to one sentence with a link. +- **Web UI pages organized by user task, not UI element.** No tab-mirroring. Maximum 6 pages, each justified as a discrete user task. +- **"You" only in getting-started and recipe procedure sections.** Concept and API reference pages use system-as-subject voice. +- **No inline code examples.** Every code block for a Hassette example comes from a CI-tested snippet file via `--8<--` includes. +- **Non-goals:** API reference auto-generation changes, source code docstrings, design documents, frontend/CSS changes, new feature documentation, CI improvements beyond rewrite needs. + +## Design Doc References + +- `## Problem` — what's broken, reader costs, why patching won't work +- `## Goals` — concrete reader outcomes per section (install, explain, adapt, find, debug, migrate) +- `## User Scenarios` — three actors (Evaluator, New User, Active Developer) with task flows +- `## Functional Requirements` — FR#1–FR#18, covering voice, structure, snippets, knowledge preservation +- `## Edge Cases` — snippet sequencing, cross-link breakage, knowledge loss, voice drift, regression risk +- `## Acceptance Criteria` — AC#1–AC#20, mapped to FRs +- `## Architecture` — three-phase process, branch strategy, exemplars, voice audit checklist, link validation +- `## Replacement Targets` — nav structure, all 76 pages, advanced/ directory, unclaimed snippets, managing-helpers location +- `## Test Strategy` — existing CI (mkdocs build --strict, Pyright), new CI (muffet link checker, snippet orphan check) + +## Convention Examples + +### Voice: System-as-subject (concept pages) + +**Source:** `.claude/rules/voice-guide.md`, "After" example in Concept Page section + +```markdown +The event bus delivers Home Assistant events — state changes, service calls, +component loads — to any app handler that subscribes. It also delivers +Hassette-internal events. + +`self.bus` is available on every `App` instance. Hassette creates it at startup. +``` + +### Voice: Code-first with "you" (getting-started pages) + +**Source:** `.claude/rules/voice-guide.md`, "After" example in Getting-Started Page section + +```markdown +## Step 3: Subscribe to a state change + +Call `self.bus.on_state_change()` to subscribe. The `"sun.*"` pattern matches +any entity in the `sun` domain — in practice, `sun.sun`. +``` + +### Voice: Prose "How It Works" (recipe pages) + +**Source:** `.claude/rules/voice-guide.md`, "After" example in Recipe Page section + +```markdown +`on_state_change` subscribes to every state transition on the motion sensor. +`D.StateNew[states.BinarySensorState]` delivers the new state as a typed +object — the handler covers both `"on"` and `"off"` transitions in one place +rather than two separate subscriptions. +``` + +### Snippet inclusion pattern + +**Source:** `.claude/rules/doc-rules.md`, Examples section + +```markdown +Full file: + --8<-- "pages/core-concepts/bus/snippets/subscribe_example.py" + +Fragment via section markers: + --8<-- "pages/core-concepts/bus/snippets/bus_subscribe.py:subscribe" +``` + +### DO/DON'T: "How It Works" formatting + +**Source:** `.claude/rules/voice-guide.md`, Recipe Page before/after + +DON'T — bullet list with bolded lead-ins: +```markdown +- **`on_state_change`** subscribes to every state transition on the motion + sensor. The handler uses **dependency injection** ... +- When state is `"on"`, any pending off job is cancelled... +``` + +DO — flowing prose paragraphs: +```markdown +`on_state_change` subscribes to every state transition on the motion sensor. +`D.StateNew[states.BinarySensorState]` delivers the new state as a typed +object — the handler covers both transitions in one place. + +When motion turns on, any pending off job is cancelled before the light +turns on. This resets the timer... +``` From 2f523376b6d4f204444c8b4e1fa7066eb2b4ded4 Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Mon, 1 Jun 2026 14:07:50 -0500 Subject: [PATCH 003/160] chore: apply reviewer suggestions to task files --- design/specs/070-doc-overhaul/tasks/T04-content-outlines.md | 4 +++- .../specs/070-doc-overhaul/tasks/T06-write-getting-started.md | 2 +- .../specs/070-doc-overhaul/tasks/T07-write-core-concepts-1.md | 4 ++-- .../specs/070-doc-overhaul/tasks/T08-write-core-concepts-2.md | 2 +- design/specs/070-doc-overhaul/tasks/T09-write-web-ui.md | 2 +- design/specs/070-doc-overhaul/tasks/T10-write-cli-testing.md | 2 +- design/specs/070-doc-overhaul/tasks/T11-write-recipes.md | 2 +- .../specs/070-doc-overhaul/tasks/T12-write-migration-ops.md | 2 +- 8 files changed, 11 insertions(+), 9 deletions(-) diff --git a/design/specs/070-doc-overhaul/tasks/T04-content-outlines.md b/design/specs/070-doc-overhaul/tasks/T04-content-outlines.md index b5267060a..3280774f1 100644 --- a/design/specs/070-doc-overhaul/tasks/T04-content-outlines.md +++ b/design/specs/070-doc-overhaul/tasks/T04-content-outlines.md @@ -12,7 +12,9 @@ Phase 2 of the rewrite. Creates detailed content outlines for every page in the ## Prompt -Work on the `docs/overhaul` branch. Read the finalized `mkdocs.yml` nav from T01 to know the full page tree. +Work on the `docs/overhaul` branch. Before starting, read: +- The finalized `mkdocs.yml` nav from T01 to know the full page tree +- `design/specs/070-doc-overhaul/docs-context.md` (calibration artifact — exemplar paths and voice checklist, for reference when noting voice-mode switches in outlines) ### For each page in the tree: diff --git a/design/specs/070-doc-overhaul/tasks/T06-write-getting-started.md b/design/specs/070-doc-overhaul/tasks/T06-write-getting-started.md index 748f42648..e6218722f 100644 --- a/design/specs/070-doc-overhaul/tasks/T06-write-getting-started.md +++ b/design/specs/070-doc-overhaul/tasks/T06-write-getting-started.md @@ -39,7 +39,7 @@ Work on the `docs/overhaul` branch. Before writing, read: ### For each page: -1. Read the Phase 2 outline for section headings and snippet inventory +1. Read the Phase 2 outline for this page (H2/H3 section headings with 1–2 sentence descriptions, named snippet inventory with keep/rewrite/new status, and cross-link list) 2. Read the current page content for technical facts to preserve (do not copy prose) 3. Write from blank following the getting-started template (what you'll build → prerequisites → steps → next steps) 4. Create snippet files (stubs first, then fill) — all code examples from snippets diff --git a/design/specs/070-doc-overhaul/tasks/T07-write-core-concepts-1.md b/design/specs/070-doc-overhaul/tasks/T07-write-core-concepts-1.md index a22fd9614..c363c90ce 100644 --- a/design/specs/070-doc-overhaul/tasks/T07-write-core-concepts-1.md +++ b/design/specs/070-doc-overhaul/tasks/T07-write-core-concepts-1.md @@ -14,7 +14,7 @@ Writes the first half of Core Concepts from blank: Architecture overview (the "f Work on the `docs/overhaul` branch. Before writing, read: - `design/specs/070-doc-overhaul/docs-context.md` (calibration artifact) -- `design/specs/070-doc-overhaul/outlines/core-concepts/` (Phase 2 outlines for each page) +- `design/specs/070-doc-overhaul/outlines/core-concepts/` (Phase 2 outlines — each contains H2/H3 headings with descriptions, named snippet inventory with keep/rewrite/new status, and cross-links) - The concept exemplar page from T03 (voice reference for system-as-subject) - `.claude/rules/voice-guide.md` and `.claude/rules/doc-rules.md` @@ -58,7 +58,7 @@ This page is the single source of truth for DI in Hassette docs. It must cover: - Typed state injection (`D.StateNew[states.SunState]`) - Common patterns and gotchas -After writing this page, grep all other pages for DI references. Each should be compressed to one sentence + link. Example: "Hassette injects typed state objects into handler parameters — see [Dependency Injection](../bus/dependency-injection.md)." +After writing this page, grep all already-written pages for DI references and compress each to one sentence + link. This is a best-effort pass — the full canonicalization check runs in T13 after all sections are written. Example: "Hassette injects typed state objects into handler parameters — see [Dependency Injection](../bus/dependency-injection.md)." ## Focus diff --git a/design/specs/070-doc-overhaul/tasks/T08-write-core-concepts-2.md b/design/specs/070-doc-overhaul/tasks/T08-write-core-concepts-2.md index bfbd610ec..5d13a695e 100644 --- a/design/specs/070-doc-overhaul/tasks/T08-write-core-concepts-2.md +++ b/design/specs/070-doc-overhaul/tasks/T08-write-core-concepts-2.md @@ -14,7 +14,7 @@ Writes the second half of Core Concepts from blank: Scheduler (overview, methods Work on the `docs/overhaul` branch. Before writing, read: - `design/specs/070-doc-overhaul/docs-context.md` (calibration artifact) -- `design/specs/070-doc-overhaul/outlines/core-concepts/` (Phase 2 outlines) +- `design/specs/070-doc-overhaul/outlines/core-concepts/` (Phase 2 outlines — each contains H2/H3 headings with descriptions, named snippet inventory with keep/rewrite/new status, and cross-links) - The concept exemplar page from T03 (voice reference) - `.claude/rules/voice-guide.md` and `.claude/rules/doc-rules.md` diff --git a/design/specs/070-doc-overhaul/tasks/T09-write-web-ui.md b/design/specs/070-doc-overhaul/tasks/T09-write-web-ui.md index 07772e4e1..aac6f218d 100644 --- a/design/specs/070-doc-overhaul/tasks/T09-write-web-ui.md +++ b/design/specs/070-doc-overhaul/tasks/T09-write-web-ui.md @@ -14,7 +14,7 @@ Writes the Web UI section from blank, consolidating the current 12 tab-mirroring Work on the `docs/overhaul` branch. Before writing, read: - `design/specs/070-doc-overhaul/docs-context.md` (calibration artifact) -- `design/specs/070-doc-overhaul/outlines/web-ui/` (Phase 2 outlines) +- `design/specs/070-doc-overhaul/outlines/web-ui/` (Phase 2 outlines — each contains H2/H3 headings with descriptions, named snippet inventory with keep/rewrite/new status, and cross-links) - `.claude/rules/voice-guide.md` and `.claude/rules/doc-rules.md` ### Pages to write (≤6): diff --git a/design/specs/070-doc-overhaul/tasks/T10-write-cli-testing.md b/design/specs/070-doc-overhaul/tasks/T10-write-cli-testing.md index 401b67981..919e2a7fa 100644 --- a/design/specs/070-doc-overhaul/tasks/T10-write-cli-testing.md +++ b/design/specs/070-doc-overhaul/tasks/T10-write-cli-testing.md @@ -14,7 +14,7 @@ Writes the CLI section (4 pages) and Testing section (4 pages) from blank. Both Work on the `docs/overhaul` branch. Before writing, read: - `design/specs/070-doc-overhaul/docs-context.md` (calibration artifact) -- `design/specs/070-doc-overhaul/outlines/cli/` and `design/specs/070-doc-overhaul/outlines/testing/` (Phase 2 outlines) +- `design/specs/070-doc-overhaul/outlines/cli/` and `design/specs/070-doc-overhaul/outlines/testing/` (Phase 2 outlines — each contains H2/H3 headings with descriptions, named snippet inventory with keep/rewrite/new status, and cross-links) - The reference exemplar page from T03 (voice reference for terse/tabular content) - `.claude/rules/voice-guide.md` and `.claude/rules/doc-rules.md` diff --git a/design/specs/070-doc-overhaul/tasks/T11-write-recipes.md b/design/specs/070-doc-overhaul/tasks/T11-write-recipes.md index 5f8c65b12..ffeb555fc 100644 --- a/design/specs/070-doc-overhaul/tasks/T11-write-recipes.md +++ b/design/specs/070-doc-overhaul/tasks/T11-write-recipes.md @@ -14,7 +14,7 @@ Writes all 7 recipe pages from blank. Each recipe is a self-contained example: p Work on the `docs/overhaul` branch. Before writing, read: - `design/specs/070-doc-overhaul/docs-context.md` (calibration artifact) -- `design/specs/070-doc-overhaul/outlines/recipes/` (Phase 2 outlines) +- `design/specs/070-doc-overhaul/outlines/recipes/` (Phase 2 outlines — each contains H2/H3 headings with descriptions, named snippet inventory with keep/rewrite/new status, and cross-links) - The recipe exemplar page from T03 (voice reference for "How It Works" prose) - `.claude/rules/voice-guide.md` and `.claude/rules/doc-rules.md` diff --git a/design/specs/070-doc-overhaul/tasks/T12-write-migration-ops.md b/design/specs/070-doc-overhaul/tasks/T12-write-migration-ops.md index 95fff2ed2..a2e5ca53a 100644 --- a/design/specs/070-doc-overhaul/tasks/T12-write-migration-ops.md +++ b/design/specs/070-doc-overhaul/tasks/T12-write-migration-ops.md @@ -14,7 +14,7 @@ Writes three sections from blank: Migration (≤8 pages for AppDaemon users), Tr Work on the `docs/overhaul` branch. Before writing, read: - `design/specs/070-doc-overhaul/docs-context.md` (calibration artifact) -- `design/specs/070-doc-overhaul/outlines/migration/`, `outlines/troubleshooting/`, `outlines/operating/` (Phase 2 outlines) +- `design/specs/070-doc-overhaul/outlines/migration/`, `outlines/troubleshooting/`, `outlines/operating/` (Phase 2 outlines — each contains H2/H3 headings with descriptions, named snippet inventory with keep/rewrite/new status, and cross-links) - `design/specs/070-doc-overhaul/outlines/knowledge-inventory.md` (CRITICAL — extracted operational knowledge from T04) - `.claude/rules/voice-guide.md` and `.claude/rules/doc-rules.md` From ffb09a057ce999f59f6fb01330106f753effaa3f Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Mon, 1 Jun 2026 14:39:55 -0500 Subject: [PATCH 004/160] chore: add design doc, fix review findings across task files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add design.md (was untracked) - Add FR#4 'Verify it's working' step to doc-rules.md recipe template - Add FR#1/AC#1 voice audit to all writing tasks (T06-T12) - Add FR#2 to T08 (concept pages need system-as-subject) - Fix T08 page count (~14 → ~20, States 4-5 → 6) - Fix design doc Impact counts (Getting Started 8→9, Core Concepts 25→~31) - Remove resolved open questions (PUBLIC_MODULES, link checker tool) - Mark stale validation report warnings as resolved --- .claude/rules/doc-rules.md | 5 +- design/specs/070-doc-overhaul/design.md | 389 ++++++++++++++++++ .../tasks/.validation-report.md | 10 +- .../tasks/T06-write-getting-started.md | 4 +- .../tasks/T07-write-core-concepts-1.md | 4 +- .../tasks/T08-write-core-concepts-2.md | 9 +- .../tasks/T09-write-web-ui.md | 4 +- .../tasks/T11-write-recipes.md | 4 +- .../tasks/T12-write-migration-ops.md | 4 +- 9 files changed, 418 insertions(+), 15 deletions(-) create mode 100644 design/specs/070-doc-overhaul/design.md diff --git a/.claude/rules/doc-rules.md b/.claude/rules/doc-rules.md index d76f03187..e155fde47 100644 --- a/.claude/rules/doc-rules.md +++ b/.claude/rules/doc-rules.md @@ -73,8 +73,9 @@ The voice and approachability rules above are hard requirements. The structures 1. **Problem statement** — one paragraph describing the real-world situation. Use a concrete example ("Your motion sensor fires every time a cat walks by"). 2. **The Code** — a full, runnable app with config. This is the main attraction. 3. **How it works** — walk through the code, explaining each decision. Call out the Hassette features being used and link to their concept pages. -4. **Variations** — alternative approaches or tweaks for different scenarios. -5. **See also** — links to concept pages for the features used, and related recipes. +4. **Verify it's working** — a concrete verification step: a CLI command (`hassette log --app `) or web UI action (Handlers tab) the reader runs to confirm the automation fires. Show expected output. +5. **Variations** — alternative approaches or tweaks for different scenarios. +6. **See also** — links to concept pages for the features used, and related recipes. ### Getting-started pages diff --git a/design/specs/070-doc-overhaul/design.md b/design/specs/070-doc-overhaul/design.md new file mode 100644 index 000000000..414b9e834 --- /dev/null +++ b/design/specs/070-doc-overhaul/design.md @@ -0,0 +1,389 @@ +# Design: Documentation Overhaul + +**Date:** 2026-06-01 +**Status:** approved +**Scope-mode:** hold +**Research:** design/specs/070-doc-overhaul/brief.md + +## Problem + +The 76 hand-written documentation pages grew organically over months. Voice-guide.md and doc-rules.md define mature standards, but adherence varies widely: recipes and getting-started are closest to the target voice, core-concepts and advanced are furthest. The reader cost is concrete: dependency injection is explained in three places at contradictory depth, so readers get different answers depending on where they land. Web UI docs organized by tab name mean a reader searching "how do I debug a handler?" can't find it. State customization is buried in "Advanced" instead of next to the States concept page where readers look first. The Architecture page mixes app-author and contributor audiences. Patching individual pages won't fix structural rot. A blank-slate rewrite with a planned structure is the faster path to consistent, reader-serving documentation. + +## Goals + +Each section is evaluated against concrete reader outcomes: + +- **Getting Started:** A new user installs Hassette, connects to Home Assistant, deploys a working app, and verifies it connected — without external help. +- **Core Concepts:** A reader can explain what Bus, Scheduler, Api, StateManager, and Cache do and when to use each, without looking it up. +- **Recipes:** A reader can adapt example code to their own entities and verify the automation fired. +- **CLI:** A reader can find and run any CLI command for their task. +- **Testing:** A reader can write and run a test for their app using the harness. +- **Web UI:** A reader can use the web UI to debug a failing handler or check app status. +- **Migration:** An AppDaemon user can map their existing automation to the Hassette equivalent. + +## Non-Goals + +- API reference auto-generation (`tools/gen_ref_pages.py`) — Phase 1 reviews which modules are in `PUBLIC_MODULES` but does not rewrite the generator +- Source code docstrings — separate concern from the docs site +- Design documents in `design/` — not part of the docs site +- Frontend/CSS/design system changes +- New documentation for features that don't exist yet +- Docs CI improvements beyond what's needed to validate the rewrite + +## User Scenarios + +### Evaluator: Considering Hassette for their HA automations + +- **Goal:** Determine whether Hassette fits their needs +- **Context:** Comparing Hassette to AppDaemon, HA YAML automations, or pyscript + +#### Quick Assessment + +1. **Lands on home page or Getting Started** + - Sees: What Hassette is, who it's for, how it compares + - Decides: Whether to invest time in the quickstart + - Then: Follows quickstart or leaves + +#### Deeper Evaluation + +1. **Reads Architecture overview** + - Sees: The "five handles" model (Bus, Scheduler, Api, StateManager, Cache), how apps work + - Decides: Whether the programming model fits their mental model + - Then: Skims recipes to see real-world usage + +### New User: Building their first automation + +- **Goal:** Install Hassette, connect to HA, write and deploy a working app +- **Context:** Has a Home Assistant instance, knows Python, new to Hassette + +#### First App + +1. **Follows Quickstart** + - Sees: Installation commands, configuration steps, connection setup + - Decides: Nothing — follows prescribed steps + - Then: Has a running Hassette instance connected to HA +2. **Follows First Automation** + - Sees: A complete app with config, handler registration, and DI + - Decides: Which entity to subscribe to + - Then: Deploys the app and verifies it fires via `hassette log` +3. **Adapts a recipe** + - Sees: Full app code, how-it-works walkthrough, variations + - Decides: Which recipe matches their use case, what to customize + - Then: Deploys the adapted recipe and verifies via the prescribed verification step + +### Active Developer: Extending their automation suite + +- **Goal:** Look up specific API behavior, debug issues, use advanced features +- **Context:** Has working Hassette apps, needs reference and depth + +#### Debugging a Handler + +1. **Opens Web UI docs** + - Sees: Task-oriented guidance for "debug a failing handler" + - Decides: Which tool to use (web UI handlers page, logs, CLI) + - Then: Identifies the issue through handler invocation history or logs + +#### Using a New Feature + +1. **Reads concept page for the feature** + - Sees: What it does, minimal example, common patterns + - Decides: Whether and how to apply it + - Then: Scrolls to depth content or follows links to sibling pages + +#### Can't Find the Right Page + +1. **Searches for a topic (e.g., "dependency injection" or "DI")** + - Sees: Single canonical result, not three contradictory pages at different depths + - Decides: Whether this is the right page + - Then: Reads the canonical page +2. **If search fails, browses the nav** + - Sees: Task-oriented section titles that match what they're trying to do + - Decides: Which section to explore + - Then: Finds the content within 2-3 clicks from the top-level nav + +## Functional Requirements + +- **FR#1:** Every hand-written page conforms to the voice-guide.md style rules, verifiable via the voice audit checklist +- **FR#2:** Concept and API reference pages use system-as-subject voice — no "you" outside getting-started and recipe procedure sections +- **FR#3:** Getting-started pages use direct "you" address with code-first ordering +- **FR#4:** Every recipe includes a "Verify it's working" step with concrete verification commands (`hassette log --app ` or web UI Handlers tab) +- **FR#5:** Dependency injection has a single canonical documentation page at `core-concepts/bus/dependency-injection.md`; all other pages that reference DI compress to one sentence with a link +- **FR#6:** When any page uses `D.*`, `states.*`, `C.*`, `P.*`, or `A.*` for the first time, it links to the canonical page for that module +- **FR#7:** The Web UI docs section contains at most 6 pages organized by user task, not by UI element. Candidate task pages: debugging a failing handler, reading logs, managing apps (start/stop/reload/health). Phase 1 refines this list but must justify each page as a discrete user task +- **FR#8:** The "Advanced" section no longer exists in the `mkdocs.yml` nav +- **FR#9:** The States subsection under `core-concepts/states/` has depth pages matching the Bus pattern: overview, plus at minimum a "Subscribing to State Changes" page and a "DomainStates Reference" page. Custom States, State Registry, and Type Registry also reside here as extension pages +- **FR#10:** An "Operating Hassette" section exists in the nav for operational how-to content: Log Level Tuning, Upgrading Hassette (extracted from current Troubleshooting), and operational runbook content. Troubleshooting remains pure symptom-lookup +- **FR#11:** The Architecture page addresses app-authors only — the "five handles" model (Bus, Scheduler, Api, StateManager, Cache) +- **FR#12:** Contributor/maintainer content (dependency graphs, wave ordering, cycle detection, internal service names) resides in `internals.md` +- **FR#13:** Every snippet file in `docs/pages/*/snippets/` is referenced by at least one page via `--8<--` include +- **FR#14:** Every code example in a page comes from a CI-tested snippet file — no inline code blocks for examples +- **FR#15:** Troubleshooting and operational pages preserve all named failure modes, log signatures, timing values, and runbook commands from their predecessors +- **FR#16:** The Managing Helpers page has consistent nav placement and filesystem location (currently in `pages/advanced/` but rendered under Core Concepts > API) +- **FR#17:** Each page that introduces Hassette-specific terms (Bus, Scheduler, Api, Cache, App, StateManager, Resource) defines them functionally on first use within the page +- **FR#18:** The Getting Started section includes a dedicated evaluator-facing page covering what Hassette is, who it's for, and how it compares to AppDaemon, HA YAML automations, and pyscript + +## Edge Cases + +- **Snippet sequencing:** `pymdownx.snippets` has `check_paths: true` — any `--8<--` reference to a non-existent file fails the build. Pages and snippets must be created together. Mitigation: create snippet files as minimal stubs before adding references; stubs satisfy `check_paths` and Pyright. +- **Cross-link breakage:** Pages reference each other heavily. Phase 1 mitigates this by creating stub files for every page in the new tree alongside the nav — stubs satisfy `mkdocs build --strict` and the link checker even before content is written. Section PRs replace stubs with real content; `--strict` stays green throughout. +- **Knowledge loss on blank-slate operational pages:** Troubleshooting pages contain log signatures, timing values, and runbook commands that exist nowhere else in the codebase. FR#15 guards against this with the mandatory pre-write knowledge inventory. +- **Voice drift across sessions:** 76 pages written across many sessions risk gradual voice drift. The three exemplar pages plus per-section voice audit checklist guard against this. `docs-context.md` consolidates exemplar paths, the full checklist, and common violation patterns into a single calibration artifact read at the start of each writing session. +- **Regression on already-good pages:** Some recipes and getting-started pages are already close to the voice standard. Starting blank risks producing pages that are worse in spots. The exemplar + voice audit process guards against this, but requires discipline. + +## Acceptance Criteria + +- **AC#1:** All rewritten pages pass the voice audit checklist (5-10 items drawn from the most commonly violated voice-guide rules) — FR#1, FR#2, FR#3 +- **AC#2:** `mkdocs build --strict` succeeds with zero warnings on the final docs branch +- **AC#3:** Post-build link checker (new CI job) finds zero broken links, including anchor fragments +- **AC#4:** Pyright passes on all snippet files under `docs/pyrightconfig.json` +- **AC#5:** No snippet file exists under `docs/pages/*/snippets/` that isn't referenced by at least one page — FR#13 +- **AC#6:** The Getting Started section includes a step where the reader runs `hassette status` and sees `websocket_connected: True`, and a step where they run `hassette app` and see their app listed as running — reader outcome +- **AC#7:** Each recipe's "Verify it's working" step names a concrete command or UI action that produces observable output — FR#4 +- **AC#8:** `mkdocs.yml` nav contains no "Advanced" section — FR#8 +- **AC#9:** `core-concepts/bus/dependency-injection.md` is the only page with a full DI explanation; grep of other pages shows only one-sentence references with links — FR#5 +- **AC#10:** Web UI docs section in `mkdocs.yml` contains ≤6 pages with task-oriented titles, not tab names — FR#7 +- **AC#11:** The States subsection in `mkdocs.yml` has an overview, at least two depth pages (state change subscriptions, DomainStates reference), plus Custom States, State Registry, and Type Registry as extension pages — FR#9 +- **AC#12:** Every page that uses `D.*`, `states.*`, `C.*`, `P.*`, or `A.*` links to the canonical page for that module on first use — FR#6 +- **AC#13:** An "Operating Hassette" section exists in `mkdocs.yml` containing Log Level Tuning and Upgrading content; Troubleshooting contains only symptom-lookup entries — FR#10 +- **AC#14:** The Architecture page (`core-concepts/index.md`) does not mention dependency graphs, wave ordering, cycle detection, or internal service names — FR#11 +- **AC#15:** `internals.md` contains dependency graphs, wave ordering, cycle detection, and internal service names — FR#12 +- **AC#16:** No page contains an inline code example (fenced code block for a Hassette code example) that isn't sourced from a snippet file via `--8<--` — FR#14 +- **AC#17:** Troubleshooting page preserves every named failure mode and log signature from the current version (verified by pre-write knowledge inventory diff) — FR#15 +- **AC#18:** Managing Helpers page filesystem path matches its nav position under Core Concepts > API — FR#16 +- **AC#19:** Every page's first use of Bus, Scheduler, Api, Cache, App, StateManager, or Resource includes a functional definition — FR#17 +- **AC#20:** Getting Started section in `mkdocs.yml` includes a dedicated evaluator page (e.g., "Is Hassette Right for You?") — FR#18 + +## Key Constraints + +- Snippet files use `--8<--` includes with `check_paths: true` — pages and their snippets must exist simultaneously or the build breaks. No half-created states. +- The voice-guide.md 22-rule set and doc-rules.md page templates are the authoritative standards. The rewrite conforms to them; it does not revise them. +- `mkdocs build --strict` must pass on every section PR to the docs branch. Phase 1 creates stub files for all pages in the new tree so cross-links resolve from the start; section PRs replace stubs with real content. + +## Dependencies and Assumptions + +- **mkdocs and plugins** (search, glightbox, panzoom, gen-files, literate-nav, autorefs, mkdocstrings) — all stay as-is; the rewrite is content, not tooling +- **Pyright CI** — snippet type-checking continues using `docs/pyrightconfig.json` +- **CSS checker scripts** (`tools/check_global_css_allowlist.py`, etc.) — no interaction with docs content +- **Assumption:** Issue #540 ("final docs sweep before v1.0.0") is superseded by this issue and should be closed when work begins +- **Assumption:** The current 258 snippet files will be largely replaced — the Phase 2 audit determines which survive + +## Architecture + +The three-phase approach optimizes for structural consistency and voice coherence — everything is planned before anything is written. The trade-off is speed: outlining 76 pages before writing any of them delays visible progress and increases the risk of scope fatigue across the writing phase. + +### Three-Phase Process + +**Phase 1: Site Outline** — The most consequential phase. Deliverables: +- Restructured page tree and `mkdocs.yml` nav with stub files for every page (title + placeholder line) — stubs keep `mkdocs build --strict` green from the start +- Structural changes: eliminate "Advanced" (rehome content), restructure Web UI (task-oriented), scope Architecture (app-author only), fix Managing Helpers placement, designate DI canonical home +- Decision on Migration section page count: keep at 8 pages or condense to fewer (section stays — drop is off the table) +- Three exemplar page selections with criteria from the brief: concept exemplar must (a) introduce multiple related terms, (b) send readers to sibling depth pages, (c) have a clear new-reader audience; recipe/getting-started exemplar must demonstrate the prose "How It Works" pattern; reference exemplar must demonstrate terse/tabular voice distinct from concept pages +- Voice audit checklist (5-10 items from the most commonly violated voice-guide rules) +- `docs-context.md` in the spec directory — the single calibration artifact for writing sessions. Contains: paths to all three exemplar pages, the full voice audit checklist inline (not referenced), and the 3 most common voice violations found in the current docs +- Decision on whether to review `PUBLIC_MODULES` in `gen_ref_pages.py` + +**Phase 2: Per-Page Content Outlines** — For each page in the Phase 1 tree: +- Section headings with 1-2 sentence descriptions of content +- Snippet inventory: what code examples the page needs (named, not just counted) +- Mapping of unclaimed existing snippets → keep or kill +- For troubleshooting/operational pages: knowledge inventory extracted from current pages (log signatures, timing values, runbook commands) + +**Phase 3: Section-by-Section Writing** — For each section (approximately 8 section PRs): +- Write pages from blank with the Phase 2 outline as guide +- Create snippet files (stub-first to satisfy `check_paths`, then fill content) +- Voice audit against the checklist before the section PR +- Rebase docs branch onto main after each section PR merges + +### Branch Strategy + +``` +main ← docs ← section-pr-1, section-pr-2, ... +``` + +Section PRs merge to the long-lived `docs` branch. One big PR from `docs` to `main` when all sections are complete. Users see an atomic swap; review happens incrementally. After each section PR merges, rebase `docs` onto current `main` and run CI — eight section PRs = eight opportunities to catch API drift. + +### Exemplar Pages + +Three pages are written and reviewed before bulk writing begins: +1. **Concept exemplar** — hardest voice (system-as-subject, no "you"). Strong candidate: Bus overview. Must introduce multiple related terms, send readers to sibling depth pages. +2. **Getting-started or recipe exemplar** — friendlier register. Strong candidate: First Automation or Motion Lights. Must demonstrate the prose "How It Works" pattern from voice-guide.md. +3. **Reference exemplar** — terse functional definitions, tables before prose, no narrative arc. Strong candidate: DI annotations page or CLI command reference. Must demonstrate the reference-mode voice distinct from concept pages. + +Before using any candidate as an exemplar, verify it passes the voice audit checklist — remediate first if it does not. These anchor voice for everything that follows. Selection happens in Phase 1 with explicit criteria from the brief. + +### Voice Audit Checklist + +A Phase 1 deliverable. 5-10 concrete items drawn from the most commonly violated voice-guide rules. Examples of likely items: +- No bullet lists with bolded lead-ins in "How It Works" sections +- System-as-subject in concept pages (no "you") +- No transition sentences opening paragraphs +- Verification steps in recipes name concrete commands +- Terms defined functionally on first use + +The checklist includes a reference-mode addendum (3-4 items) for pages like CLI command reference, Testing factories, and DI annotation tables: tables before prose in reference sections, no narrative arc in annotation tables, terse functional definitions in table cells, no admonitions in reference tables. + +The checklist is the pass/fail gate for section PRs — not a subjective scan. + +### Link Validation + +Add a post-build HTML link checker (e.g., `muffet` or `htmltest`) targeting the built `site/` directory. `mkdocs build --strict` and lychee both miss broken anchor fragments (`#section-name`). Run on every section PR to the docs branch. + +### Pre-Phase 3 Cleanup + +Audit Pyright suppressions in `docs/pyrightconfig.json`: determine whether `reportOperatorIssue` and `reportAssignmentType` can move from global suppressions to per-file exclusions. New snippet files should not inherit broad suppressions by default. + +## Replacement Targets + +| Target | Replaced by | Action | +|---|---|---| +| `mkdocs.yml` nav structure (lines 32-126) | New nav from Phase 1 site outline | Replace in place | +| All 76 `.md` files under `docs/pages/` | Blank-slate rewrites from Phase 3 | Overwrite per section PR | +| `docs/pages/advanced/` directory (6 pages) | Content rehomed to `core-concepts/states/` and troubleshooting | Delete directory after rehoming | +| Unclaimed snippet files (count determined in Phase 2 audit) | Nothing — dead code | Delete | +| `docs/pages/advanced/managing-helpers.md` filesystem location | `docs/pages/core-concepts/api/managing-helpers.md` (consistent with nav placement) | Move file | + +## Convention Examples + +The conventions for this rewrite are the voice-guide.md style rules and doc-rules.md page templates. Rather than extracting code snippets, the authoritative convention sources are: + +### Voice: System-as-subject (concept pages) + +**Source:** `.claude/rules/voice-guide.md`, "After" example in Concept Page section + +```markdown +The event bus delivers Home Assistant events — state changes, service calls, +component loads — to any app handler that subscribes. It also delivers +Hassette-internal events. + +`self.bus` is available on every `App` instance. Hassette creates it at startup. +``` + +### Voice: Code-first with "you" (getting-started pages) + +**Source:** `.claude/rules/voice-guide.md`, "After" example in Getting-Started Page section + +```markdown +## Step 3: Subscribe to a state change + +Call `self.bus.on_state_change()` to subscribe. The `"sun.*"` pattern matches +any entity in the `sun` domain — in practice, `sun.sun`. +``` + +### Voice: Prose "How It Works" (recipe pages) + +**Source:** `.claude/rules/voice-guide.md`, "After" example in Recipe Page section + +```markdown +`on_state_change` subscribes to every state transition on the motion sensor. +`D.StateNew[states.BinarySensorState]` delivers the new state as a typed +object — the handler covers both `"on"` and `"off"` transitions in one place +rather than two separate subscriptions. +``` + +### Snippet inclusion pattern + +**Source:** `.claude/rules/doc-rules.md`, Examples section + +```markdown +Full file: + --8<-- "pages/core-concepts/bus/snippets/subscribe_example.py" + +Fragment via section markers: + --8<-- "pages/core-concepts/bus/snippets/bus_subscribe.py:subscribe" +``` + +### DO/DON'T: "How It Works" formatting + +**Source:** `.claude/rules/voice-guide.md`, Recipe Page before/after + +DON'T — bullet list with bolded lead-ins: +```markdown +- **`on_state_change`** subscribes to every state transition on the motion + sensor. The handler uses **dependency injection** ... +- When state is `"on"`, any pending off job is cancelled... +``` + +DO — flowing prose paragraphs: +```markdown +`on_state_change` subscribes to every state transition on the motion sensor. +`D.StateNew[states.BinarySensorState]` delivers the new state as a typed +object — the handler covers both transitions in one place. + +When motion turns on, any pending off job is cancelled before the light +turns on. This resets the timer... +``` + +## Alternatives Considered + +**Incremental patching (page-by-page voice and structure fixes):** Faster per-page but doesn't fix structural problems — "Advanced" grab-bag, DI scattered across three locations, tab-mirroring Web UI docs. The issues compound because each page links to and assumes the structure of others. Rejected because structural changes require a coordinated rewrite, not incremental patches. + +**Partial rewrite (rewrite worst sections, leave good ones):** Lower risk for already-good sections (recipes, getting-started). Rejected because it preserves the structural rot in the nav and cross-section organization. The blank-slate approach with exemplar anchoring guards against regression on good pages. + +**Automated voice linting:** A script that checks docs against voice-guide rules mechanically. Complementary but insufficient — voice rules like "system-as-subject" and "no transition sentences" require judgment. The voice audit checklist is the manual equivalent; automated checks could be added later for the mechanical subset. + +## Test Strategy + +### Existing Tests to Adapt + +No software tests are affected. The "tests" for documentation are CI validation jobs: + +- `mkdocs build --strict` — already runs in CI; continues as-is +- Pyright on `docs/pages/*/snippets/*.py` — already runs in CI; continues with current `docs/pyrightconfig.json` +- `tools/check_schemas_fresh.py` — pre-push hook; unaffected + +### New Test Coverage + +- **Link checker CI job** (AC#3): Post-build HTML link checker targeting the built `site/` directory to catch broken anchor fragments that `mkdocs build --strict` misses. Run on every PR to the docs branch. +- **Snippet orphan check** (FR#13, AC#5): Script or CI step that verifies every `.py` file under `docs/pages/*/snippets/` is referenced by at least one `--8<--` include in a `.md` file. Run on every PR to the docs branch. + +### Tests to Remove + +No tests to remove. Snippet files deleted during the Phase 2 audit are the only removals, and they have no dedicated test infrastructure beyond Pyright's glob-based inclusion. + +## Documentation Updates + +This change IS the documentation. No other documentation artifacts need updating: + +- `CHANGELOG.md` — auto-generated by release-please from commit messages; no manual edit +- `README.md` — if the docs site URL or getting-started link changes, update the README reference (verify in Phase 1 when finalizing nav) +- `.claude/rules/doc-rules.md` — recipe template updated to include "Verify it's working" step (FR#4 gap). Voice-guide.md unchanged + +## Impact + +### Changed Files + +**Cross-cutting:** +- `mkdocs.yml` — nav structure replaced (lines 32-126) + +**By section (all pages overwritten or moved):** +- `docs/pages/getting-started/` — 9 pages (5 main + 4 docker) +- `docs/pages/core-concepts/` — ~31 pages across 8 subsections + architecture + internals +- `docs/pages/web-ui/` — ≤6 pages (consolidated from 12 tab-mirroring pages to task-oriented) +- `docs/pages/cli/` — 4 pages +- `docs/pages/testing/` — 4 pages +- `docs/pages/recipes/` — 7 pages +- `docs/pages/advanced/` — 6 pages (directory deleted; content rehomed) +- `docs/pages/migration/` — 8 pages (Phase 1 decides whether to condense to fewer pages) +- `docs/pages/troubleshooting.md` — 1 page +- `docs/index.md` — home page + +**Snippet files:** 258 files across all sections — audited in Phase 2, unclaimed files deleted, remaining files rewritten alongside their pages + +### Behavioral Invariants + +- `mkdocs build --strict` must continue to pass +- Pyright CI on snippet files must continue to pass +- The docs site URL structure must not break existing external links (use `use_directory_urls: true` and preserve section-level paths where possible) +- API reference auto-generation via `gen_ref_pages.py` is unaffected + +### Blast Radius + +- **Docs site readers** — every page changes; users see an atomic swap when the docs branch merges to main +- **Issue #540** — superseded and should be closed +- **README.md** — may need link updates if nav paths change + +## Open Questions + +- Which specific pages become the three exemplars? Phase 1 decides. Bus overview is a strong candidate for the concept exemplar; First Automation or Motion Lights for the recipe/getting-started exemplar. +- Whether to keep the Migration section at 8 pages or condense to fewer (e.g., 3-4). The section stays — Hassette has no existing users who've completed the migration, so AD migration content is still a primary inflow path. Phase 1 decides the page count and structure. +- Pyright config scoping: whether `reportOperatorIssue` and `reportAssignmentType` can move from global suppressions to per-file exclusions. Pre-Phase 3 cleanup item. diff --git a/design/specs/070-doc-overhaul/tasks/.validation-report.md b/design/specs/070-doc-overhaul/tasks/.validation-report.md index c15d97e89..3fa1f1c04 100644 --- a/design/specs/070-doc-overhaul/tasks/.validation-report.md +++ b/design/specs/070-doc-overhaul/tasks/.validation-report.md @@ -6,8 +6,8 @@ | Identifier | Task(s) | Verify Criterion | |------------|---------|-----------------| -| FR#1 | T03, T10 | T03: "All three exemplar pages pass every item on the voice audit checklist" / T10: "All pages pass the voice audit checklist" | -| FR#2 | T03, T07 | T03: "Concept exemplar uses system-as-subject voice throughout — no 'you' outside getting-started/recipe" / T07: "All concept pages use system-as-subject voice — no 'you' outside getting-started/recipe content" | +| FR#1 | T03, T06, T07, T08, T09, T10, T11, T12 | T03: "All three exemplar pages pass every item on the voice audit checklist" / T06–T12: "All pages pass every item on the voice audit checklist (in `docs-context.md`)" | +| FR#2 | T03, T07, T08 | T03: "Concept exemplar uses system-as-subject voice throughout — no 'you' outside getting-started/recipe" / T07: "All concept pages use system-as-subject voice — no 'you' outside getting-started/recipe content" / T08: "All concept pages use system-as-subject voice — no 'you' outside getting-started/recipe content" | | FR#3 | T03, T06 | T03: "Getting-started/recipe exemplar uses direct 'you' address with code-first ordering" / T06: "All getting-started pages use direct 'you' address with code-first ordering" | | FR#4 | T11 | "Every recipe includes a 'Verify it's working' section with a concrete command or UI action that produces observable output" | | FR#5 | T01, T07 | T01: "`core-concepts/bus/dependency-injection.md` appears in the nav as the DI canonical page" / T07: "`core-concepts/bus/dependency-injection.md` contains the full DI explanation; grep other pages for DI references and confirm each is one sentence + link" | @@ -24,7 +24,7 @@ | FR#16 | T01 | "`managing-helpers.md` filesystem path is `pages/core-concepts/api/managing-helpers.md`" | | FR#17 | T03, T13 | T03: "Every first use of Bus, Scheduler, Api, Cache, App, StateManager, or Resource has a functional definition" / T13: "Spot-check of 5+ pages confirms first use of Bus, Scheduler, Api, Cache, App, StateManager, or Resource includes functional definition" | | FR#18 | T01, T06 | T01: "Getting Started section includes a dedicated evaluator page" / T06: "A dedicated evaluator page exists covering what Hassette is, who it's for, and how it compares to alternatives" | -| AC#1 | T03, T10 | T03: "Voice audit checklist applied and all items pass for each exemplar" / T10: "Voice audit checklist applied and all items pass" | +| AC#1 | T03, T06, T07, T08, T09, T10, T11, T12 | T03: "Voice audit checklist applied and all items pass for each exemplar" / T06–T12: "Voice audit checklist applied and all items pass" | | AC#2 | T13 | "`uv run mkdocs build --strict` succeeds with zero warnings" | | AC#3 | T02, T13 | T02: "A muffet-based link checker CI job exists in `.github/workflows/docs.yml` that runs against the built `site/` directory and checks anchor fragments" / T13: "Muffet link checker finds zero broken links including anchor fragments" | | AC#4 | T05, T13 | T05: "`uv run pyright --project docs` passes with zero errors after config changes" / T13: "`uv run pyright --project docs` passes with zero errors" | @@ -57,9 +57,9 @@ None. 1. **T05 is not a declared dependency of T06–T12, creating a sequencing risk.** T05's purpose is to scope Pyright suppressions per-file before Phase 3 writing begins — the design doc explicitly states "New snippet files written during Phase 3 should not inherit broad suppressions by default." T06–T12 all depend on T04 but not T05. If T06–T12 execute before T05, new snippets are created under the broad global suppression config, defeating T05's purpose. T05 should be listed as a dependency in T06–T12's `depends_on` fields, or the tasks should note that T05 must run first. -2. **T13 does not declare T02 or T05 as dependencies, but relies on both.** T13 Prompt step 1 runs the muffet link checker CI job and the snippet orphan check script — both created by T02. It also runs `uv run pyright --project docs`, which relies on the config changes from T05. T13's `depends_on` lists only T06–T12. If T02 or T05 were incomplete, T13's validation steps would fail with missing tools. T13 should add T02 and T05 to its `depends_on`. +2. ~~**T13 does not declare T02 or T05 as dependencies, but relies on both.**~~ RESOLVED — T13's `depends_on` now includes T02 and T05. -3. **T06 Prompt says "Pages to write (8 total)" but enumerates 9 items.** The design doc Impact section states "8 pages (4 main + 4 docker)." T06 lists 5 main pages (evaluator, index.md, first-automation.md, ha_token.md, hassette-vs-ha-yaml.md) plus 4 docker pages = 9. The `hassette-vs-ha-yaml.md` page appears to be an addition beyond the design doc's count. Not a functional problem — the evaluator FR#18 and AC#6/AC#20 are all covered — but the executor will write 9 pages while the header says 8. +3. ~~**T06 Prompt says "Pages to write (8 total)" but enumerates 9 items.**~~ RESOLVED — T06 header now says "(9 total)" and design doc Impact section updated to 9 pages. 4. **T01 Verify AC#11 is a cross-reference, not a concrete check.** The verify text reads "States subsection structure matches FR#9." This defers verification to the FR#9 verify item rather than stating a binary-checkable condition directly. The FR#9 verify item in T01 is concrete ("has overview + at least 'Subscribing to State Changes'..."), so coverage is not lost — but an executor reading only the AC#11 line cannot verify it without also reading the FR#9 line in the same task. diff --git a/design/specs/070-doc-overhaul/tasks/T06-write-getting-started.md b/design/specs/070-doc-overhaul/tasks/T06-write-getting-started.md index e6218722f..5ff79da4c 100644 --- a/design/specs/070-doc-overhaul/tasks/T06-write-getting-started.md +++ b/design/specs/070-doc-overhaul/tasks/T06-write-getting-started.md @@ -3,7 +3,7 @@ task_id: "T06" title: "Write Getting Started section" status: "planned" depends_on: ["T04"] -implements: ["FR#3", "FR#18", "AC#6", "AC#20"] +implements: ["FR#1", "FR#3", "FR#18", "AC#1", "AC#6", "AC#20"] --- ## Summary @@ -58,7 +58,9 @@ Work on the `docs/overhaul` branch. Before writing, read: ## Verify +- [ ] FR#1: All pages pass every item on the voice audit checklist (in `docs-context.md`) - [ ] FR#3: All getting-started pages use direct "you" address with code-first ordering - [ ] FR#18: A dedicated evaluator page exists covering what Hassette is, who it's for, and how it compares to alternatives +- [ ] AC#1: Voice audit checklist applied and all items pass - [ ] AC#6: First automation page includes `hassette status` showing `websocket_connected: True` and `hassette app` showing the app listed - [ ] AC#20: Evaluator page exists in Getting Started nav diff --git a/design/specs/070-doc-overhaul/tasks/T07-write-core-concepts-1.md b/design/specs/070-doc-overhaul/tasks/T07-write-core-concepts-1.md index c363c90ce..79793d4a6 100644 --- a/design/specs/070-doc-overhaul/tasks/T07-write-core-concepts-1.md +++ b/design/specs/070-doc-overhaul/tasks/T07-write-core-concepts-1.md @@ -3,7 +3,7 @@ task_id: "T07" title: "Write Core Concepts — Apps, Bus, Architecture, Internals" status: "planned" depends_on: ["T04"] -implements: ["FR#2", "FR#5", "FR#11", "FR#12", "AC#9", "AC#14", "AC#15"] +implements: ["FR#1", "FR#2", "FR#5", "FR#11", "FR#12", "AC#1", "AC#9", "AC#14", "AC#15"] --- ## Summary @@ -72,10 +72,12 @@ After writing this page, grep all already-written pages for DI references and co ## Verify +- [ ] FR#1: All pages pass every item on the voice audit checklist (in `docs-context.md`) - [ ] FR#2: All concept pages use system-as-subject voice — no "you" outside getting-started/recipe content - [ ] FR#5: `core-concepts/bus/dependency-injection.md` contains the full DI explanation; grep other pages for DI references and confirm each is one sentence + link - [ ] FR#11: Architecture page covers the five handles model for app-authors only - [ ] FR#12: Internals page contains dependency graphs, wave ordering, cycle detection, and internal service names +- [ ] AC#1: Voice audit checklist applied and all items pass - [ ] AC#9: `core-concepts/bus/dependency-injection.md` is the only page with a full DI explanation - [ ] AC#14: Architecture page does not mention dependency graphs, wave ordering, cycle detection, or internal service names - [ ] AC#15: `internals.md` contains dependency graphs, wave ordering, cycle detection, and internal service names diff --git a/design/specs/070-doc-overhaul/tasks/T08-write-core-concepts-2.md b/design/specs/070-doc-overhaul/tasks/T08-write-core-concepts-2.md index 5d13a695e..847edde92 100644 --- a/design/specs/070-doc-overhaul/tasks/T08-write-core-concepts-2.md +++ b/design/specs/070-doc-overhaul/tasks/T08-write-core-concepts-2.md @@ -3,7 +3,7 @@ task_id: "T08" title: "Write Core Concepts — Scheduler, States, API, Cache, Config" status: "planned" depends_on: ["T04"] -implements: ["FR#9", "AC#11"] +implements: ["FR#1", "FR#2", "FR#9", "AC#1", "AC#11"] --- ## Summary @@ -18,14 +18,14 @@ Work on the `docs/overhaul` branch. Before writing, read: - The concept exemplar page from T03 (voice reference) - `.claude/rules/voice-guide.md` and `.claude/rules/doc-rules.md` -### Pages to write (~14): +### Pages to write (~20): **Scheduler (3 pages):** - `core-concepts/scheduler/index.md` — Task scheduling overview, trigger types, the `schedule()` entry point - `core-concepts/scheduler/methods.md` — `run_in()`, `run_once()`, `run_every()`, `run_daily()`, `run_cron()`, custom triggers - `core-concepts/scheduler/management.md` — Job groups, `cancel_group()`, `list_jobs()`, jitter -**States (4–5 pages):** +**States (6 pages):** - `core-concepts/states/index.md` — State access overview, domain access, type conversion - `core-concepts/states/subscribing.md` (new) — "Subscribing to State Changes" depth page - `core-concepts/states/domain-states.md` (new) — "DomainStates Reference" depth page @@ -79,5 +79,8 @@ The 60 advanced snippets include files for custom-states, state-registry, and ty ## Verify +- [ ] FR#1: All pages pass every item on the voice audit checklist (in `docs-context.md`) +- [ ] FR#2: All concept pages use system-as-subject voice — no "you" outside getting-started/recipe content - [ ] FR#9: States subsection has overview page, "Subscribing to State Changes" depth page, "DomainStates Reference" depth page, plus Custom States, State Registry, and Type Registry +- [ ] AC#1: Voice audit checklist applied and all items pass - [ ] AC#11: States subsection in `mkdocs.yml` matches the required structure with overview + depth pages + extension pages diff --git a/design/specs/070-doc-overhaul/tasks/T09-write-web-ui.md b/design/specs/070-doc-overhaul/tasks/T09-write-web-ui.md index aac6f218d..e9b8a658a 100644 --- a/design/specs/070-doc-overhaul/tasks/T09-write-web-ui.md +++ b/design/specs/070-doc-overhaul/tasks/T09-write-web-ui.md @@ -3,7 +3,7 @@ task_id: "T09" title: "Write Web UI section" status: "planned" depends_on: ["T04"] -implements: ["FR#7", "AC#10"] +implements: ["FR#1", "FR#7", "AC#1", "AC#10"] --- ## Summary @@ -54,5 +54,7 @@ The current web-ui pages reference specific UI elements. When rewriting, describ ## Verify +- [ ] FR#1: All pages pass every item on the voice audit checklist (in `docs-context.md`) - [ ] FR#7: Web UI section contains ≤6 pages, each organized by user task (not UI element). Each page is justified as a discrete user task. +- [ ] AC#1: Voice audit checklist applied and all items pass - [ ] AC#10: Web UI section in `mkdocs.yml` has ≤6 entries with task-oriented titles (not tab names like "Apps," "Handlers," "Logs") diff --git a/design/specs/070-doc-overhaul/tasks/T11-write-recipes.md b/design/specs/070-doc-overhaul/tasks/T11-write-recipes.md index ffeb555fc..d92c36b3a 100644 --- a/design/specs/070-doc-overhaul/tasks/T11-write-recipes.md +++ b/design/specs/070-doc-overhaul/tasks/T11-write-recipes.md @@ -3,7 +3,7 @@ task_id: "T11" title: "Write Recipes section" status: "planned" depends_on: ["T04"] -implements: ["FR#4", "AC#7"] +implements: ["FR#1", "FR#4", "AC#1", "AC#7"] --- ## Summary @@ -62,5 +62,7 @@ The verification must be something the reader can actually do, not a theoretical ## Verify +- [ ] FR#1: All pages pass every item on the voice audit checklist (in `docs-context.md`) - [ ] FR#4: Every recipe includes a "Verify it's working" section with a concrete command or UI action that produces observable output +- [ ] AC#1: Voice audit checklist applied and all items pass - [ ] AC#7: Each recipe's verification step names a specific command (`hassette log`, `hassette listener`) or UI action (Handlers tab) with expected output diff --git a/design/specs/070-doc-overhaul/tasks/T12-write-migration-ops.md b/design/specs/070-doc-overhaul/tasks/T12-write-migration-ops.md index a2e5ca53a..bf09b90e4 100644 --- a/design/specs/070-doc-overhaul/tasks/T12-write-migration-ops.md +++ b/design/specs/070-doc-overhaul/tasks/T12-write-migration-ops.md @@ -3,7 +3,7 @@ task_id: "T12" title: "Write Migration, Troubleshooting, and Operating Hassette" status: "planned" depends_on: ["T04"] -implements: ["FR#10", "FR#15", "AC#13", "AC#17"] +implements: ["FR#1", "FR#10", "FR#15", "AC#1", "AC#13", "AC#17"] --- ## Summary @@ -69,7 +69,9 @@ None of these exist anywhere else in the codebase. The knowledge inventory from ## Verify +- [ ] FR#1: All pages pass every item on the voice audit checklist (in `docs-context.md`) - [ ] FR#10: "Operating Hassette" section exists with Log Level Tuning and Upgrading content; Troubleshooting contains only symptom-lookup entries - [ ] FR#15: Every named failure mode, log signature, timing value, and runbook command from the knowledge inventory appears in the rewritten troubleshooting and operational pages +- [ ] AC#1: Voice audit checklist applied and all items pass - [ ] AC#13: "Operating Hassette" section exists in `mkdocs.yml` with the required content - [ ] AC#17: Diff the knowledge inventory against the final troubleshooting page — every item is preserved From c0b39f62de87da04d93869ae9058087120aac979 Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Mon, 1 Jun 2026 16:10:24 -0500 Subject: [PATCH 005/160] docs: T01 site outline and T03 exemplar pages for doc overhaul MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T01 — site outline and calibration artifacts: - Restructure mkdocs.yml nav: eliminate Advanced, consolidate Web UI (12 pages → 5 task-oriented), expand States (1 → 6 depth pages), add Operating Hassette section, add evaluator page, fix Managing Helpers path - Create stub files for all new pages - Write voice audit checklist (15 items) in docs-context.md - Document exemplar selections and top 3 current voice violations - Review PUBLIC_MODULES — all 24 modules current - Add snippet line width rule to doc-rules.md T03 — three exemplar pages written from blank: - Bus overview (concept exemplar): system-as-subject voice, DI from first example, scope trimmed to overview with depth in siblings - Motion Lights (recipe exemplar): flowing prose How It Works, Verify It's Working section, changed_to variation - DI annotations (reference exemplar): tables-first, terse definitions, advanced content split to custom-extractors page Resolved decisions: Migration keeps 8 pages, Web UI consolidates to 5 task-oriented pages, exemplars are Bus overview / Motion Lights / DI annotations. --- .claude/rules/doc-rules.md | 1 + design/specs/070-doc-overhaul/design.md | 10 +- design/specs/070-doc-overhaul/docs-context.md | 73 +++++ .../tasks/exemplar-selections.md | 31 ++ .../core-concepts/api/managing-helpers.md | 3 + .../core-concepts/bus/custom-extractors.md | 3 + .../core-concepts/bus/dependency-injection.md | 280 +++--------------- docs/pages/core-concepts/bus/index.md | 116 +++----- .../bus/snippets/bus_basic_subscribe.py | 18 ++ .../bus/snippets/bus_rate_control.py | 20 +- .../dependency-injection/mixing_kwargs.py | 20 +- .../multiple_dependencies.py | 21 +- .../dependency-injection/quick_example.py | 6 +- .../state_object_extractors.py | 21 +- .../dependency-injection/union_types.py | 18 +- .../core-concepts/states/custom-states.md | 3 + .../core-concepts/states/domain-states.md | 3 + .../core-concepts/states/state-registry.md | 3 + .../pages/core-concepts/states/subscribing.md | 3 + .../core-concepts/states/type-registry.md | 3 + docs/pages/getting-started/evaluator.md | 3 + docs/pages/operating/index.md | 3 + docs/pages/operating/log-levels.md | 3 + docs/pages/operating/upgrading.md | 3 + docs/pages/recipes/motion-lights.md | 50 +++- docs/pages/recipes/snippets/motion_lights.py | 25 +- .../recipes/snippets/motion_lights_split.py | 47 +++ docs/pages/web-ui/debug-handler.md | 3 + docs/pages/web-ui/inspect-config-code.md | 3 + docs/pages/web-ui/manage-apps.md | 3 + mkdocs.yml | 40 ++- 31 files changed, 429 insertions(+), 410 deletions(-) create mode 100644 design/specs/070-doc-overhaul/docs-context.md create mode 100644 design/specs/070-doc-overhaul/tasks/exemplar-selections.md create mode 100644 docs/pages/core-concepts/api/managing-helpers.md create mode 100644 docs/pages/core-concepts/bus/custom-extractors.md create mode 100644 docs/pages/core-concepts/bus/snippets/bus_basic_subscribe.py create mode 100644 docs/pages/core-concepts/states/custom-states.md create mode 100644 docs/pages/core-concepts/states/domain-states.md create mode 100644 docs/pages/core-concepts/states/state-registry.md create mode 100644 docs/pages/core-concepts/states/subscribing.md create mode 100644 docs/pages/core-concepts/states/type-registry.md create mode 100644 docs/pages/getting-started/evaluator.md create mode 100644 docs/pages/operating/index.md create mode 100644 docs/pages/operating/log-levels.md create mode 100644 docs/pages/operating/upgrading.md create mode 100644 docs/pages/recipes/snippets/motion_lights_split.py create mode 100644 docs/pages/web-ui/debug-handler.md create mode 100644 docs/pages/web-ui/inspect-config-code.md create mode 100644 docs/pages/web-ui/manage-apps.md diff --git a/.claude/rules/doc-rules.md b/.claude/rules/doc-rules.md index e155fde47..8041bdb0e 100644 --- a/.claude/rules/doc-rules.md +++ b/.claude/rules/doc-rules.md @@ -144,6 +144,7 @@ This way minimal fragments are slices of tested files, not standalone untested c - **Show the outcome.** After a code block, briefly say what happens when it runs. "When the sensor crosses 75°F, the handler fires and turns on the fan." The reader should be able to predict behavior before running it. - **One concept per example.** An example that demonstrates debouncing should not also introduce conditions, predicates, and dependency injection. Layer concepts across examples, not within them. - **Real entity names.** Use `light.kitchen`, `sensor.outdoor_temperature`, `binary_sensor.front_door` — not `entity.my_entity` or `sensor.test_sensor`. Real names help readers map to their own setup. +- **Keep lines under 80 characters.** The docs content area is narrow — long lines force horizontal scrolling, which makes code hard to read. Break function signatures across multiple lines, use short variable names, and extract intermediate values. If a line is too long for the rendered page, it's too long for the snippet. ## Layering for Skill Levels diff --git a/design/specs/070-doc-overhaul/design.md b/design/specs/070-doc-overhaul/design.md index 414b9e834..d4a88540e 100644 --- a/design/specs/070-doc-overhaul/design.md +++ b/design/specs/070-doc-overhaul/design.md @@ -384,6 +384,12 @@ This change IS the documentation. No other documentation artifacts need updating ## Open Questions -- Which specific pages become the three exemplars? Phase 1 decides. Bus overview is a strong candidate for the concept exemplar; First Automation or Motion Lights for the recipe/getting-started exemplar. -- Whether to keep the Migration section at 8 pages or condense to fewer (e.g., 3-4). The section stays — Hassette has no existing users who've completed the migration, so AD migration content is still a primary inflow path. Phase 1 decides the page count and structure. - Pyright config scoping: whether `reportOperatorIssue` and `reportAssignmentType` can move from global suppressions to per-file exclusions. Pre-Phase 3 cleanup item. + +## Resolved Decisions (Phase 1) + +- **Exemplars:** Bus overview (concept), Motion Lights (recipe), DI annotations page (reference) +- **Migration:** Keep all 8 pages — each covers a distinct mapping and the section is a primary inflow path +- **Web UI consolidation:** 5 pages — Overview, Debug a Failing Handler, Read and Filter Logs, Manage Apps, Inspect App Configuration and Code +- **PUBLIC_MODULES:** Review included in Phase 1 (T01) +- **Link checker:** Muffet for post-build HTML link checking diff --git a/design/specs/070-doc-overhaul/docs-context.md b/design/specs/070-doc-overhaul/docs-context.md new file mode 100644 index 000000000..44294be50 --- /dev/null +++ b/design/specs/070-doc-overhaul/docs-context.md @@ -0,0 +1,73 @@ +# Docs-Context: Calibration Artifact + +Read this file at the start of every documentation writing session. It contains the voice anchors, pass/fail checklist, and common violation patterns that keep writing consistent across sessions. + +## Exemplar Pages + +These three pages are the voice reference for their respective modes. When in doubt about tone, sentence structure, or page shape, re-read the relevant exemplar. + +1. **Concept exemplar:** `docs/pages/core-concepts/bus/index.md` — system-as-subject, no "you," declarative +2. **Recipe exemplar:** `docs/pages/recipes/motion-lights.md` — "How It Works" prose pattern, verification step +3. **Reference exemplar:** `docs/pages/core-concepts/bus/dependency-injection.md` — terse tables, functional definitions, no narrative arc + +## Voice Audit Checklist + +Run every item on every page before marking a writing task complete. Each item is binary pass/fail. + +### General (all page types) + +1. **No transition sentences opening paragraphs.** No "Now that we understand X, let's look at Y." Start with the next thing directly. *(Rule 12)* +2. **Terms defined functionally on first use.** First mention of Bus, Scheduler, Api, Cache, App, StateManager, or Resource includes a one-sentence definition of what it does — not what category it belongs to. *(Rule 8)* +3. **Explanatory sentences are 10-18 words.** One idea per sentence. No stacked relative clauses. Inline code identifiers don't count toward the limit. *(Rule 2)* +4. **Main behavior stated first, caveats after.** The reader learns what something does before learning what it doesn't do or where it breaks. *(Rule 3)* +5. **Every limitation paired with a path forward.** One sentence for the constraint, one naming the alternative. *(Rule 4)* +6. **Module aliases linked on first use.** First use of `D.*`, `states.*`, `C.*`, `P.*`, or `A.*` links to the canonical page for that module. *(FR#6)* + +### Concept and API reference pages + +7. **System-as-subject throughout — no "you."** "The bus delivers events" not "you receive events." "your" is also banned. *(Rule 10)* +8. **No imperative mood.** No "Use X", "Pass Y", "Set Z." Use declarative: "X provides", "Y accepts", "Z controls." *(Rule 15)* +9. **Concept introductions follow name -> define -> show -> constrain.** Definition says what it does. Code example is minimal. Constraints come after. *(Rule 16)* + +### Recipe pages + +10. **"How It Works" uses flowing prose paragraphs, NOT bullet lists with bolded lead-ins.** Each paragraph covers one decision. No `- **method_name** does X` patterns. *(Rule 21)* +11. **"How It Works" uses system-as-subject.** The code is the subject when explaining behavior. "you" belongs only in procedural steps and variations. *(Rule 10, 21)* +12. **"Verify it's working" names a concrete command or UI action.** `hassette log --app `, Handlers tab, or similar — not a theoretical description. *(FR#4)* + +### Reference pages (addendum) + +13. **Tables before prose in reference sections.** The table is the primary content; prose supplements. +14. **Terse functional definitions in table cells.** No narrative. Each cell says what the thing does in one sentence. +15. **No admonitions in reference tables.** Tips, warnings, and notes belong outside the table. + +## Top 3 Current Violations + +These are the patterns you will most naturally fall into. Check for them last as a final pass. + +### 1. Imperative mood in concept pages (Rule 15) + +The most pervasive violation. Every current concept page has instances. + +**Wrong:** "Use `self.states` instead of API calls for instant access." +**Right:** "`self.states` provides instant access without an API call." + +**Wrong:** "Pass `immediate=True` to fire your handler at registration time." +**Right:** "`immediate=True` fires the handler at registration time, before any events arrive." + +### 2. "You" / "your" in concept pages (Rule 10) + +Found in 4 of 5 sampled pages. Often paired with imperative mood. + +**Wrong:** "The event bus connects your apps to Home Assistant." +**Right:** "The event bus delivers Home Assistant events to any app handler that subscribes." + +**Wrong:** "You don't need to manage resources yourself." +**Right:** "Hassette manages resource lifecycle automatically." + +### 3. Overlong explanatory sentences (Rule 2) + +Sentences exceeding 18 words in explanatory prose. Most common in technical descriptions that try to cover behavior + exception + workaround in one sentence. + +**Wrong:** "The StateManager event handler is prioritized over app event handlers to ensure you always have a consistent view of the latest states." (24 words + "you") +**Right:** "StateManager's event handler runs before app handlers. App handlers always see the latest state." (two sentences, 8 + 8 words) diff --git a/design/specs/070-doc-overhaul/tasks/exemplar-selections.md b/design/specs/070-doc-overhaul/tasks/exemplar-selections.md new file mode 100644 index 000000000..e0af2d136 --- /dev/null +++ b/design/specs/070-doc-overhaul/tasks/exemplar-selections.md @@ -0,0 +1,31 @@ +# Exemplar Selections + +## Concept Exemplar: Bus Overview + +**Page:** `docs/pages/core-concepts/bus/index.md` + +**Why this page:** +- Introduces multiple related terms (Bus, subscriptions, handlers, topics, events) +- Sends readers to three sibling depth pages (handlers, filtering, dependency-injection) +- Clear new-reader audience — the bus is the first thing an app-author interacts with after App itself +- Hardest voice mode — system-as-subject, no "you," no imperative. If this page works, everything downstream works + +## Recipe Exemplar: Motion-Activated Lights + +**Page:** `docs/pages/recipes/motion-lights.md` + +**Why this page:** +- Classic automation pattern — universally relatable +- Exercises the "How It Works" prose pattern (Rule 21) which is the most commonly violated recipe pattern +- Uses multiple Hassette features (bus subscription, scheduler, dependency injection, named jobs) — good for demonstrating cross-linking +- Has a natural verification step (trigger motion sensor, check lights) + +## Reference Exemplar: Dependency Injection Annotations + +**Page:** `docs/pages/core-concepts/bus/dependency-injection.md` + +**Why this page:** +- The canonical DI page (FR#5) — must be authoritative and complete +- Naturally tabular: `D.*` annotations map to types and behaviors +- Must demonstrate terse/functional voice distinct from concept narrative +- High cross-link traffic — every page that mentions DI points here diff --git a/docs/pages/core-concepts/api/managing-helpers.md b/docs/pages/core-concepts/api/managing-helpers.md new file mode 100644 index 000000000..ea8ce8e4d --- /dev/null +++ b/docs/pages/core-concepts/api/managing-helpers.md @@ -0,0 +1,3 @@ +# Managing Helpers + +*This page is being rewritten as part of the documentation overhaul.* diff --git a/docs/pages/core-concepts/bus/custom-extractors.md b/docs/pages/core-concepts/bus/custom-extractors.md new file mode 100644 index 000000000..f9501eb06 --- /dev/null +++ b/docs/pages/core-concepts/bus/custom-extractors.md @@ -0,0 +1,3 @@ +# Custom Extractors + +*This page is being rewritten as part of the documentation overhaul.* diff --git a/docs/pages/core-concepts/bus/dependency-injection.md b/docs/pages/core-concepts/bus/dependency-injection.md index 54239578c..4a2b741a3 100644 --- a/docs/pages/core-concepts/bus/dependency-injection.md +++ b/docs/pages/core-concepts/bus/dependency-injection.md @@ -1,296 +1,98 @@ # Dependency Injection -Instead of manually parsing event payloads, you declare what data you need using type annotations, and Hassette extracts and converts it for you. This is called **dependency injection** (DI). - -## Quick Example +Hassette's dependency injection system extracts typed data from events and passes it directly to handler parameters. Handlers declare what they need via type annotations; Hassette resolves the rest. ```python --8<-- "pages/core-concepts/bus/snippets/dependency-injection/quick_example.py" ``` -In this example, `new_state` and `entity_id` are automatically extracted from the `RawStateChangeEvent` and injected into your handler based on their type annotations. - -## Three Event Handling Patterns - -Hassette supports three patterns for handling events. The [Writing Handlers](handlers.md) page introduces all three starting from the simplest (raw events). Here they are ordered by what you'll use most in production code. - -### DI Extraction +`D.StateNew[states.LightState]` extracts the new state and converts it to a typed `LightState`. `D.EntityId` extracts the entity ID as a string. The handler receives clean data with no event parsing. -Extract only the specific data you need: - -```python ---8<-- "pages/core-concepts/bus/snippets/dependency-injection/pattern3_di.py" -``` - -**Use when:** You want clean, focused handlers with minimal boilerplate. - -### Typed Event - -Receive the full event with state objects converted to typed Pydantic models: - -```python ---8<-- "pages/core-concepts/bus/snippets/dependency-injection/pattern2_typed.py" -``` +All annotations live in `hassette.dependencies`, available as `D` from the top-level import: `from hassette import D`. -**Use when:** You want type safety but need access to the full event structure (context, timestamps, metadata, etc.). +## Annotation Reference +### State Extractors -### Raw Event (Untyped) +Extract typed state objects from state change events. `T` is any state class from `hassette.models.states`. -Receive the full event object with state data as untyped dictionaries: - -```python ---8<-- "pages/core-concepts/bus/snippets/dependency-injection/pattern1_raw.py" -``` - -**Use when:** You need full control or are working with dynamic/unknown state structures. - -!!! warning - While typed State models use `value` for the actual state value, raw state dictionaries are accessed via the `"state"` key, as - this is the key used by Home Assistant in its event payloads, and this data is not modified by Hassette. - - -## Available DI Annotations - -All dependency injection annotations are available in the `hassette.dependencies` module (commonly imported as `D`). - -### State Object Extractors - -Extract typed state objects from state change events: - -| Annotation | Type | Description | -| ------------------ | ------------- | -------------------------------------------- | -| `StateNew[T]` | `T` | Extract new state, raises if missing | -| `StateOld[T]` | `T` | Extract old state, raises if missing | -| `MaybeStateNew[T]` | `T` or `None` | Extract new state, returns `None` if missing | -| `MaybeStateOld[T]` | `T` or `None` | Extract old state, returns `None` if missing | +| Annotation | Returns | If missing | +|---|---|---| +| `D.StateNew[T]` | `T` | Handler not called | +| `D.StateOld[T]` | `T` | Handler not called | +| `D.MaybeStateNew[T]` | `T \| None` | `None` | +| `D.MaybeStateOld[T]` | `T \| None` | `None` | ```python --8<-- "pages/core-concepts/bus/snippets/dependency-injection/state_object_extractors.py" ``` -!!! note - In actual usage you should pass a condition to the `changed` parameter of `on_state_change`, which will handle this - condition for you. - - ```python - --8<-- "pages/core-concepts/bus/snippets/dependency-injection/note_changed_condition.py:note_changed" - ``` +When a required state is missing, Hassette skips the handler invocation — the exception is caught during resolution, not inside the handler. `MaybeStateOld` is useful for the first event after an entity appears, where there is no previous state. ### Identity Extractors -Extract entity IDs and domains from events: +Extract entity IDs and domains from events. -| Annotation | Type | Description | -| --------------- | ------------------- | ---------------------------------------------- | -| `EntityId` | `str` | Extract entity ID, raises if missing | -| `MaybeEntityId` | `str` or `MISSING_VALUE` (falsy sentinel) | Extract entity ID, returns sentinel if missing | -| `Domain` | `str` | Extract domain, raises if missing | -| `MaybeDomain` | `str` or `MISSING_VALUE` (falsy sentinel) | Extract domain, returns sentinel if missing | +| Annotation | Returns | If missing | +|---|---|---| +| `D.EntityId` | `str` | Handler not called | +| `D.MaybeEntityId` | `str \| MISSING_VALUE` | Falsy sentinel | +| `D.Domain` | `str` | Handler not called | +| `D.MaybeDomain` | `str \| MISSING_VALUE` | Falsy sentinel | ```python --8<-- "pages/core-concepts/bus/snippets/dependency-injection/identity_extractors.py" ``` -!!! note "Check with truthiness, not isinstance" - `MISSING_VALUE` is a `FalseySentinel` — it is always falsy. Use `if entity_id:` to check for a present value rather than isinstance checks. `FalseySentinel` is not a public type and should not be used in type checks directly. - - ```python - async def handler(entity_id: D.MaybeEntityId): - if entity_id: - # entity_id is a str here - ... - else: - # entity_id is MISSING_VALUE (falsy) - ... - ``` +`MISSING_VALUE` is a falsy sentinel. Test with `if entity_id:` rather than `isinstance` checks. ### Other Extractors -| Annotation | Type | Description | -| -------------------------- | -------------------------- | ---------------------------------------------------- | -| `EventData[T]` | `T` | Extract typed data from a `Bus.emit` broadcast event | -| `EventContext` | `HassContext` | Extract Home Assistant event context | -| `TypedStateChangeEvent[T]` | `TypedStateChangeEvent[T]` | Convert raw event to typed event | - -```python ---8<-- "pages/core-concepts/bus/snippets/dependency-injection/other_extractors.py" -``` +| Annotation | Returns | Use case | +|---|---|---| +| `D.EventData[T]` | `T` | Typed payload from [`Bus.emit`](../apps/index.md#broadcasting-events-between-apps) broadcast events | +| `D.EventContext` | `HassContext` | Home Assistant event context (user ID, parent/origin IDs) | +| `D.TypedStateChangeEvent[T]` | `TypedStateChangeEvent[T]` | Full event object with typed states | -`EventData[T]` extracts the typed payload from events sent via [`Bus.emit`](../apps/index.md#broadcasting-events-between-apps). The sender emits a plain dataclass; the receiver declares it as a parameter type: +`EventData[T]` pairs with `Bus.emit`. The sender emits a dataclass; the receiver declares the type: ```python --8<-- "pages/core-concepts/bus/snippets/dependency-injection/event_data_extractor.py" ``` -## Union Type Support - -DI extractors support Union types, allowing handlers to work with multiple state types: - -```python ---8<-- "pages/core-concepts/bus/snippets/dependency-injection/union_types.py" -``` - -The StateRegistry determines the correct state class based on the entity's domain, and the DI system converts the raw state dictionary to the appropriate Pydantic model. - -## Combining Multiple Dependencies +## Combining Annotations -You can extract multiple pieces of data in a single handler: +Handlers accept multiple DI parameters. Hassette resolves each independently. ```python --8<-- "pages/core-concepts/bus/snippets/dependency-injection/multiple_dependencies.py" ``` -## Mixing DI with Custom kwargs - -Dependency injection works with custom keyword arguments passed when registering handlers: - -```python ---8<-- "pages/core-concepts/bus/snippets/dependency-injection/mixing_kwargs.py" -``` - ---- - -## Advanced: Custom Extractors and Type Conversion - -??? note "The rest of this page covers custom extractors and type conversion" - If the built-in annotations above handle your use case, you can skip ahead to [Handlers](handlers.md) or [Filtering](filtering.md). - -## Custom Extractors - -You can create custom extractors using the `Annotated` type with either existing accessors from [`accessors`][hassette.event_handling.accessors] or custom callables: - -### Using Built-in Accessors - -```python ---8<-- "pages/core-concepts/bus/snippets/dependency-injection/custom_extractor_builtin.py" -``` - -### Writing Your Own Extractor - -Any callable that accepts an event and returns a value can be used as an extractor: - -```python ---8<-- "pages/core-concepts/bus/snippets/dependency-injection/custom_extractor_own.py" -``` - -### Advanced: Extractor + Converter Pattern +## Union Types -For more complex scenarios, you can use the `AnnotationDetails` class to combine extraction and type conversion: +State extractors accept union types for handlers that cover multiple entity domains. ```python ---8<-- "pages/core-concepts/bus/snippets/dependency-injection/custom_extractor_converter.py" -``` - -## Automatic Type Conversion with TypeRegistry - -Hassette's dependency injection system uses the [TypeRegistry](../../advanced/type-registry.md) to automatically convert extracted values to match your type annotations. This works automatically with custom extractors. - -### How It Works - -When you use a custom extractor with a type annotation, the DI system: - -1. **Extracts the value** using your extractor function -2. **Checks the type** of the extracted value against your annotation -3. **Automatically converts** if needed using the TypeRegistry -4. **Injects the converted value** into your handler - -This means you can write simple extractors that return raw values, and let TypeRegistry handle the type conversion: - -```python ---8<-- "pages/core-concepts/bus/snippets/dependency-injection/builtin_conversions_implicit.py" -``` - -### Built-in Conversions - -The TypeRegistry includes built-in conversions for common types: - -- **Numeric types**: `str` ↔ `int`, `float`, `Decimal` -- **Boolean**: `str` → `bool` (handles `"on"`, `"off"`, `"true"`, `"false"`, etc.) -- **DateTime types**: `str` → `datetime`, `date`, `time` (stdlib), and `whenever` types - -**Examples:** -```python ---8<-- "pages/core-concepts/bus/snippets/dependency-injection/builtin_conversions_explicit.py" -``` - -### Custom Type Converters - -You can register your own type converters for custom types: - -```python ---8<-- "pages/core-concepts/bus/snippets/dependency-injection/custom_type_converter.py" +--8<-- "pages/core-concepts/bus/snippets/dependency-injection/union_types.py" ``` -### When Conversion Happens - -Type conversion is skipped if the returned value is already the correct type or is `None`. - -If the value is not `None` and does not match the expected type, Hassette will attempt to convert it using the TypeRegistry. -The TypeRegistry will first look for a registered converter for the `(from_type, to_type)` pair. If `to_type` is a `tuple`, it will iterate -through each type in the tuple and use the first converter that succeeds. - -If there is no registered converter for the `(from_type, to_type)` pair, Hassette will attempt to call `to_type` as a constructor with the value as the sole argument. - -If type conversion fails, Hassette will raise a `UnableToConvertValueError`. For tuples, this will be raised only if all conversions fail. - - -### Bypassing Automatic Conversion - -If you want to handle conversion yourself, you can: - -1. **Use `Any` type annotation** to receive the raw value: - - ```python - --8<-- "pages/core-concepts/bus/snippets/dependency-injection/bypass_conversion_any.py" - ``` +The [State Registry](../states/state-registry.md) determines the correct state class based on the entity's domain. -2. **Provide a custom converter** in `AnnotationDetails`: +## Custom Keyword Arguments - ```python - --8<-- "pages/core-concepts/bus/snippets/dependency-injection/bypass_conversion_custom.py" - ``` - -### Error Handling - -When type conversion fails, Hassette provides clear error messages: +DI composes with custom `kwargs` passed at registration time. DI-annotated parameters are resolved from the event; remaining keyword arguments pass through unchanged. ```python ---8<-- "pages/core-concepts/bus/snippets/dependency-injection/error_handling.py" +--8<-- "pages/core-concepts/bus/snippets/dependency-injection/mixing_kwargs.py" ``` ## Handler Signature Restrictions -DI handlers have some restrictions to ensure unambiguous parameter injection: - -!!! warning "Handler Signature Rules" - Handlers using DI **cannot** have: - - - Positional-only parameters (parameters before `/`) - - Variadic positional arguments (`*args`) - - These restrictions ensure that Hassette can reliably match parameters to extracted values. - -!!! info "Type Annotations Required" - All parameters using dependency injection must have type annotations. Hassette uses these annotations to determine what to extract from events and how to convert the data. - -## How It Works - -Under the hood, Hassette's DI system: - -1. **Inspects handler signatures** using Python's `inspect` module to find annotated parameters -2. **Extracts type information** from `Annotated` types and recognizes special DI annotations -3. **Builds extractors** for each parameter that know how to pull data from events -4. **Converts types** using the StateRegistry for state objects, converting raw dictionaries to typed Pydantic models -5. **Injects values** at call time, passing extracted and converted values as keyword arguments - -The core implementation lives in: - -- [`extraction`][hassette.bus.extraction] - Signature inspection and parameter extraction -- [`dependencies`][hassette.event_handling.dependencies] - Pre-defined DI annotations -- [`accessors`][hassette.event_handling.accessors] - Low-level event data accessors +DI handlers cannot use positional-only parameters (before `/`) or `*args`. Regular parameters and `**kwargs` work fine. All DI parameters require type annotations — Hassette uses the annotation to determine what to extract. ## See Also -- [Type Registry](../../advanced/type-registry.md) - automatic type conversion system -- [State Registry](../../advanced/state-registry.md) - domain to state model mapping +- [Custom Extractors](custom-extractors.md) — writing custom extractors, accessors, `AnnotationDetails`, and automatic type conversion +- [Writing Handlers](handlers.md) — raw event and typed event patterns, handler error behavior +- [State Registry](../states/state-registry.md) — domain-to-model mapping +- [Type Registry](../states/type-registry.md) — automatic type conversion diff --git a/docs/pages/core-concepts/bus/index.md b/docs/pages/core-concepts/bus/index.md index c48e47b21..be4695bb4 100644 --- a/docs/pages/core-concepts/bus/index.md +++ b/docs/pages/core-concepts/bus/index.md @@ -1,8 +1,8 @@ -# Bus Overview +# Bus -The event bus connects your apps to Home Assistant and to Hassette itself. It delivers events such as state changes, service calls, or framework updates to any app that subscribes. +The event bus delivers Home Assistant events — state changes, service calls, component loads — to any app handler that subscribes. It also delivers Hassette-internal events. -Apps register event handlers through `self.bus`, which is created automatically at app instantiation. +`self.bus` is available on every [App](../apps/index.md) instance. Hassette creates it at startup. ```mermaid flowchart TD @@ -10,10 +10,8 @@ flowchart TD HA["Events"] end - subgraph framework["Framework"] - WS["WebsocketService"] - BUS["BusService"] - WS --> BUS + subgraph bus["Event Bus"] + BUS["Bus"] end subgraph handlers["App Handlers"] @@ -22,113 +20,75 @@ flowchart TD APP3["Handler 3
custom_event"] end - HA --> WS + HA --> BUS BUS --> APP1 & APP2 & APP3 style ha fill:#f0f0f0,stroke:#999 - style framework fill:#fff0e8,stroke:#cc8844 + style bus fill:#fff0e8,stroke:#cc8844 style handlers fill:#e8f0ff,stroke:#6688cc ``` ## Subscribing to Events -The `Bus` provides helper methods for common subscriptions. Each returns a [`Subscription`][hassette.bus.listeners.Subscription] handle. +[`Bus`][hassette.bus.Bus] provides typed subscription methods for common event types. Each returns a `Subscription` handle. -### Common Methods +```python +--8<-- "pages/core-concepts/bus/snippets/bus_basic_subscribe.py" +``` -- `on_state_change` - Listen for entity state changes. -- `on_attribute_change` - Listen for changes to a specific attribute. -- `on_call_service` - Listen for service calls. -- `on` - Generic subscription to any topic. -- `on_component_loaded` - Listen for Home Assistant component load events. +[`D.StateNew`][hassette.event_handling.dependencies] tells Hassette to extract the new state from the event and pass it as a typed `BinarySensorState`. The handler receives clean, typed data instead of a raw event dictionary. See [Dependency Injection](dependency-injection.md) for the full annotation reference. -### Example +Four subscription methods cover the common event types: -```python ---8<-- "pages/core-concepts/bus/snippets/bus_subscribe_state_change.py:subscribe" -``` +| Method | Fires when | +|---|---| +| `on_state_change` | An entity's state value changes | +| `on_attribute_change` | A named attribute on an entity changes | +| `on_call_service` | A Home Assistant service is called | +| `on` | Any event on a given topic string | + +All registration methods are async. Each requires a `name=` parameter — a stable string identifier for the listener. Additional specialized methods like `on_component_loaded` are covered in [Writing Handlers](handlers.md). ## Matching Multiple Entities -Most methods accept glob patterns for `entity_id`, `domain`, and `service`. +Subscription methods accept glob patterns for entity matching. ```python --8<-- "pages/core-concepts/bus/snippets/bus_glob_patterns.py:glob_patterns" ``` -!!! warning "Limitation" - Glob patterns work for identifiers but **not** for attribute names or complex data values. For that, use [Predicates](filtering.md). - -## Rate Control +`"light.*"` matches any entity in the `light` domain. `"sensor.bedroom_*"` matches sensors with a `bedroom_` prefix. The same patterns work for `domain` and `service` parameters on `on_call_service`. -You can rate-limit your handlers directly in the subscription call to handle noisy events. +!!! warning "Glob patterns match identifiers only" + Glob patterns do not match attribute names or data values. [Predicates](filtering.md) handle those cases. -```python ---8<-- "pages/core-concepts/bus/snippets/bus_rate_control.py:rate_control" -``` - -Both `debounce` and `throttle` must be positive; zero or negative values raise `ValueError` at registration. Specifying both `debounce` and `throttle` together also raises `ValueError` — only one rate-limiting strategy may be active at a time. Combining `once=True` with either also raises `ValueError`. +## Rate Control -## Immediate Fire +Three subscription parameters manage handler invocation frequency. -Pass `immediate=True` to fire your handler right at registration time if the entity already matches your predicates. The handler receives a synthetic event with `old_state=None` and `new_state=`. Without `immediate=True`, the handler only fires on the next change. +`debounce` delays the handler until the event source has been quiet for N seconds. Each new event resets the timer. ```python ---8<-- "pages/core-concepts/bus/snippets/bus_immediate_fire.py:immediate_fire" +--8<-- "pages/core-concepts/bus/snippets/bus_rate_control.py:debounce" ``` -`immediate=True` composes with `once=True`: the immediate fire counts as the single invocation, so the subscription is automatically cancelled afterward if the entity already matches. It also composes with `debounce` and `throttle` — the immediate fire passes through rate limiting the same way a live event does. - -!!! warning "Glob patterns not supported" - `immediate=True` cannot be combined with glob entity patterns (for example, `"light.*"`). Hassette cannot determine which entity to read state from when the pattern matches multiple entities. A `ValueError` is raised at registration. - -## Duration Hold - -Pass `duration=N` (seconds) to delay your handler until the entity has remained in the matching state for N continuous seconds. If the entity leaves the matching state before the duration elapses, the timer is cancelled and the handler does not fire. +`throttle` limits the handler to one invocation per N seconds. Events during the cooldown are dropped. ```python ---8<-- "pages/core-concepts/bus/snippets/bus_duration_hold.py:duration_hold" +--8<-- "pages/core-concepts/bus/snippets/bus_rate_control.py:throttle" ``` -`duration` composes with `once=True`: the handler fires at most once after the duration gate passes. It also composes with `immediate=True` for restart resilience: when the app starts and the entity is already in the target state, Hassette consults `last_changed` to compute how long it has already been there. If that elapsed time exceeds `duration`, the handler fires immediately. If not, a timer starts for the remaining time. - -**Validation rules:** - -- `duration` must be a positive number; zero or negative raises `ValueError`. -- `duration` cannot be combined with `debounce` or `throttle` — raises `ValueError`. Use duration for the "held for N seconds" pattern; use debounce for the "settled after N seconds of silence" pattern. They are different behaviors that cannot be composed. -- Glob entity patterns are not supported with `duration` — raises `ValueError`. - -## Timeouts - -All subscription methods (`on`, `on_state_change`, `on_attribute_change`, `on_call_service`, `on_component_loaded`) accept `timeout` and `timeout_disabled` parameters to control per-listener execution timeouts. - -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `timeout` | `float \| None` | `None` | Per-listener timeout in seconds. `None` uses the global `event_handler_timeout_seconds` default. A positive `float` overrides it. | -| `timeout_disabled` | `bool` | `False` | When `True`, disables timeout enforcement for this listener regardless of the global default. | +`once=True` fires the handler exactly once, then cancels the subscription. ```python ---8<-- "pages/core-concepts/bus/snippets/bus_timeouts.py" +--8<-- "pages/core-concepts/bus/snippets/bus_rate_control.py:once" ``` -See [Timeouts](../configuration/global.md#timeouts) for global configuration and override semantics. - -## Handler Exceptions - -If a handler raises an exception, Hassette catches it, logs it at `ERROR` level with the full traceback, and records the failure in the telemetry database. The exception does not propagate — the app keeps running, and the next event dispatches as normal. Other handlers for the same event are not affected. - -This is the same behavior as scheduled jobs: unhandled exceptions are logged to error but do not crash anything. - -??? info "Registration Identity" - All `bus.on_*()` subscription methods require a `name=` parameter — a stable string identifier that forms the listener's natural key `(app_key, instance_index, name, topic)`. Omitting `name=` raises `ListenerNameRequiredError` at call time. Registering a second listener with the same `(name, topic)` in the same app session raises `DuplicateListenerError`. - - ```python - --8<-- "pages/core-concepts/bus/snippets/bus_registration_identity.py:registration_identity" - ``` - - See [Subscription and Registration](handlers.md#subscription-and-registration) in the Handlers guide for the full error details and upsert semantics across restarts. +!!! warning "One strategy per subscription" + `debounce`, `throttle`, and `once` are mutually exclusive. Combining any two raises `ValueError` at registration. ## Next Steps -- **[Writing Handlers](handlers.md)**: Learn how to write handlers using Dependency Injection to extract clean data. -- **[Filtering & Predicates](filtering.md)**: Learn how to filter events efficiently using predicates. +- [Writing Handlers](handlers.md) — handler signatures, immediate fire, duration hold, timeouts, and error behavior +- [Filtering & Predicates](filtering.md) — predicates, conditions, and accessors for complex event matching +- [Dependency Injection](dependency-injection.md) — the full `D.*` annotation reference and how Hassette resolves handler parameters diff --git a/docs/pages/core-concepts/bus/snippets/bus_basic_subscribe.py b/docs/pages/core-concepts/bus/snippets/bus_basic_subscribe.py new file mode 100644 index 000000000..f3836dd1e --- /dev/null +++ b/docs/pages/core-concepts/bus/snippets/bus_basic_subscribe.py @@ -0,0 +1,18 @@ +from hassette import App, AppConfig, D, states + + +class DoorApp(App[AppConfig]): + async def on_initialize(self): + # --8<-- [start:subscribe] + await self.bus.on_state_change( + "binary_sensor.front_door", + handler=self.on_door_change, + name="front_door", + ) + # --8<-- [end:subscribe] + + # --8<-- [start:handler] + async def on_door_change(self, new: D.StateNew[states.BinarySensorState]): + if new.value is True: + self.logger.info("Front door opened") + # --8<-- [end:handler] diff --git a/docs/pages/core-concepts/bus/snippets/bus_rate_control.py b/docs/pages/core-concepts/bus/snippets/bus_rate_control.py index 62238b2b5..bac6ce6b2 100644 --- a/docs/pages/core-concepts/bus/snippets/bus_rate_control.py +++ b/docs/pages/core-concepts/bus/snippets/bus_rate_control.py @@ -4,29 +4,33 @@ class RateControlApp(App[AppConfig]): async def on_initialize(self): # --8<-- [start:rate_control] - # Debounce: wait for 2s of silence before calling + # --8<-- [start:debounce] await self.bus.on_state_change( "binary_sensor.motion", handler=self.on_settled, debounce=2.0, name="motion_debounced", ) + # --8<-- [end:debounce] - # Throttle: call at most once every 5s + # --8<-- [start:throttle] await self.bus.on_state_change( "sensor.temperature", handler=self.on_temp_log, throttle=5.0, name="temp_throttled", ) + # --8<-- [end:throttle] - # Once: unsubscribe automatically after first trigger - await self.bus.on_component_loaded( - "hue", - handler=self.on_hue_ready, + # --8<-- [start:once] + await self.bus.on_state_change( + "binary_sensor.front_door", + handler=self.on_door_opened, + changed_to="on", once=True, - name="hue_ready", + name="door_opened_once", ) + # --8<-- [end:once] # --8<-- [end:rate_control] async def on_settled(self): @@ -35,5 +39,5 @@ async def on_settled(self): async def on_temp_log(self): pass - async def on_hue_ready(self): + async def on_door_opened(self): pass diff --git a/docs/pages/core-concepts/bus/snippets/dependency-injection/mixing_kwargs.py b/docs/pages/core-concepts/bus/snippets/dependency-injection/mixing_kwargs.py index 2e4d2b2b7..e1b7d6dda 100644 --- a/docs/pages/core-concepts/bus/snippets/dependency-injection/mixing_kwargs.py +++ b/docs/pages/core-concepts/bus/snippets/dependency-injection/mixing_kwargs.py @@ -6,11 +6,21 @@ async def on_initialize(self): await self.bus.on_state_change( "sensor.temperature", handler=self.on_temp_change, - kwargs={"threshold": 75.0, "message": "Temperature %s (%.1f°F) exceeds threshold %.1f°F"}, - name="temp_threshold_alert", + kwargs={"threshold": 75.0}, + name="temp_threshold", ) - async def on_temp_change(self, new_state: D.StateNew[states.SensorState], entity_id: D.EntityId, threshold: float, message: str): - temp = float(new_state.value) if new_state.value else 0.0 + async def on_temp_change( + self, + new: D.StateNew[states.SensorState], + entity_id: D.EntityId, + threshold: float, + ): + temp = float(new.value) if new.value else 0.0 if temp > threshold: - self.logger.warning(message, entity_id, temp, threshold) + self.logger.warning( + "%s is %.1f°F (threshold: %.1f)", + entity_id, + temp, + threshold, + ) diff --git a/docs/pages/core-concepts/bus/snippets/dependency-injection/multiple_dependencies.py b/docs/pages/core-concepts/bus/snippets/dependency-injection/multiple_dependencies.py index 731902d09..d6d683d3d 100644 --- a/docs/pages/core-concepts/bus/snippets/dependency-injection/multiple_dependencies.py +++ b/docs/pages/core-concepts/bus/snippets/dependency-injection/multiple_dependencies.py @@ -4,19 +4,14 @@ class ClimateApp(App): async def on_climate_change( self, - new_state: D.StateNew[states.ClimateState], - old_state: D.MaybeStateOld[states.ClimateState], + new: D.StateNew[states.ClimateState], entity_id: D.EntityId, context: D.EventContext, ): - old_temp = old_state.attributes.current_temperature if old_state else None - new_temp = new_state.attributes.current_temperature - - if old_temp != new_temp: - self.logger.info( - "Climate %s temperature changed: %s -> %s (user: %s)", - entity_id, - old_temp, - new_temp, - context.user_id or "system", - ) + temp = new.attributes.current_temperature + self.logger.info( + "%s temperature: %s (user: %s)", + entity_id, + temp, + context.user_id or "system", + ) diff --git a/docs/pages/core-concepts/bus/snippets/dependency-injection/quick_example.py b/docs/pages/core-concepts/bus/snippets/dependency-injection/quick_example.py index 46ece3454..c1925862f 100644 --- a/docs/pages/core-concepts/bus/snippets/dependency-injection/quick_example.py +++ b/docs/pages/core-concepts/bus/snippets/dependency-injection/quick_example.py @@ -9,6 +9,10 @@ async def on_initialize(self): name="bedroom_light", ) - async def on_light_change(self, new_state: D.StateNew[states.LightState], entity_id: D.EntityId): + async def on_light_change( + self, + new_state: D.StateNew[states.LightState], + entity_id: D.EntityId, + ): brightness = new_state.attributes.brightness self.logger.info("%s brightness: %s", entity_id, brightness) diff --git a/docs/pages/core-concepts/bus/snippets/dependency-injection/state_object_extractors.py b/docs/pages/core-concepts/bus/snippets/dependency-injection/state_object_extractors.py index 3f8edea5e..e16eb3a6b 100644 --- a/docs/pages/core-concepts/bus/snippets/dependency-injection/state_object_extractors.py +++ b/docs/pages/core-concepts/bus/snippets/dependency-injection/state_object_extractors.py @@ -1,13 +1,14 @@ from hassette import App, D, states -class LightApp(App): - async def on_light_change(self, new_state: D.StateNew[states.LightState], old_state: D.MaybeStateOld[states.LightState]): - if old_state: - brightness_changed = new_state.attributes.brightness != old_state.attributes.brightness - if brightness_changed: - self.logger.info( - "Brightness: %s -> %s", - old_state.attributes.brightness, - new_state.attributes.brightness, - ) +class TempApp(App): + async def on_temp_change( + self, + new: D.StateNew[states.SensorState], + old: D.MaybeStateOld[states.SensorState], + ): + if old and old.value and new.value: + delta = float(new.value) - float(old.value) + self.logger.info( + "Temperature moved %.1f°F", delta + ) diff --git a/docs/pages/core-concepts/bus/snippets/dependency-injection/union_types.py b/docs/pages/core-concepts/bus/snippets/dependency-injection/union_types.py index d37c28947..7f9dbde8b 100644 --- a/docs/pages/core-concepts/bus/snippets/dependency-injection/union_types.py +++ b/docs/pages/core-concepts/bus/snippets/dependency-injection/union_types.py @@ -2,10 +2,14 @@ class SensorApp(App): - async def on_sensor_change(self, new_state: D.StateNew[states.SensorState | states.BinarySensorState], entity_id: D.EntityId): - # new_state is automatically converted to the correct type - # based on the entity's domain - if isinstance(new_state, states.SensorState): - self.logger.info("Sensor %s: %s", entity_id, new_state.value) - elif isinstance(new_state, states.BinarySensorState): - self.logger.info("Binary sensor %s: %s", entity_id, new_state.value) + async def on_sensor_change( + self, + new: D.StateNew[ + states.SensorState | states.BinarySensorState + ], + entity_id: D.EntityId, + ): + if isinstance(new, states.SensorState): + self.logger.info("Sensor %s: %s", entity_id, new.value) + else: + self.logger.info("Binary %s: %s", entity_id, new.value) diff --git a/docs/pages/core-concepts/states/custom-states.md b/docs/pages/core-concepts/states/custom-states.md new file mode 100644 index 000000000..34b22239f --- /dev/null +++ b/docs/pages/core-concepts/states/custom-states.md @@ -0,0 +1,3 @@ +# Custom States + +*This page is being rewritten as part of the documentation overhaul.* diff --git a/docs/pages/core-concepts/states/domain-states.md b/docs/pages/core-concepts/states/domain-states.md new file mode 100644 index 000000000..8dc0945e2 --- /dev/null +++ b/docs/pages/core-concepts/states/domain-states.md @@ -0,0 +1,3 @@ +# DomainStates Reference + +*This page is being rewritten as part of the documentation overhaul.* diff --git a/docs/pages/core-concepts/states/state-registry.md b/docs/pages/core-concepts/states/state-registry.md new file mode 100644 index 000000000..ce78967cf --- /dev/null +++ b/docs/pages/core-concepts/states/state-registry.md @@ -0,0 +1,3 @@ +# State Registry + +*This page is being rewritten as part of the documentation overhaul.* diff --git a/docs/pages/core-concepts/states/subscribing.md b/docs/pages/core-concepts/states/subscribing.md new file mode 100644 index 000000000..392530fb0 --- /dev/null +++ b/docs/pages/core-concepts/states/subscribing.md @@ -0,0 +1,3 @@ +# Subscribing to State Changes + +*This page is being rewritten as part of the documentation overhaul.* diff --git a/docs/pages/core-concepts/states/type-registry.md b/docs/pages/core-concepts/states/type-registry.md new file mode 100644 index 000000000..4046e840a --- /dev/null +++ b/docs/pages/core-concepts/states/type-registry.md @@ -0,0 +1,3 @@ +# Type Registry + +*This page is being rewritten as part of the documentation overhaul.* diff --git a/docs/pages/getting-started/evaluator.md b/docs/pages/getting-started/evaluator.md new file mode 100644 index 000000000..a45b91c3e --- /dev/null +++ b/docs/pages/getting-started/evaluator.md @@ -0,0 +1,3 @@ +# Is Hassette Right for You? + +*This page is being rewritten as part of the documentation overhaul.* diff --git a/docs/pages/operating/index.md b/docs/pages/operating/index.md new file mode 100644 index 000000000..43e9a3cf8 --- /dev/null +++ b/docs/pages/operating/index.md @@ -0,0 +1,3 @@ +# Operating Hassette + +*This page is being rewritten as part of the documentation overhaul.* diff --git a/docs/pages/operating/log-levels.md b/docs/pages/operating/log-levels.md new file mode 100644 index 000000000..c8824cbf5 --- /dev/null +++ b/docs/pages/operating/log-levels.md @@ -0,0 +1,3 @@ +# Log Level Tuning + +*This page is being rewritten as part of the documentation overhaul.* diff --git a/docs/pages/operating/upgrading.md b/docs/pages/operating/upgrading.md new file mode 100644 index 000000000..54cd1fa73 --- /dev/null +++ b/docs/pages/operating/upgrading.md @@ -0,0 +1,3 @@ +# Upgrading Hassette + +*This page is being rewritten as part of the documentation overhaul.* diff --git a/docs/pages/recipes/motion-lights.md b/docs/pages/recipes/motion-lights.md index 54091d9ca..cfd26dee8 100644 --- a/docs/pages/recipes/motion-lights.md +++ b/docs/pages/recipes/motion-lights.md @@ -1,6 +1,6 @@ # Motion-Activated Lights -Turns a light on when a motion sensor detects movement, then turns it off automatically after a configurable delay once motion clears. +A motion sensor in the hallway fires every time someone walks past. The light should turn on immediately and stay on until motion has been clear for a set period. If someone walks past again while the timer is running, the timeout should restart instead of firing at the original time. ## The Code @@ -10,27 +10,53 @@ Turns a light on when a motion sensor detects movement, then turns it off automa ## How It Works -- **`on_state_change`** subscribes to every state transition on the motion sensor. The handler uses **dependency injection** (`D.StateNew[states.BinarySensorState]`) to receive the new state as a typed object — both `"on"` and `"off"` are handled in one place. -- When state is `"on"`, any pending off job is cancelled before turning the light on — this resets the timeout if motion is detected again while the timer is running. -- When state is `"off"`, `run_in` schedules `turn_off_light` to fire 5 minutes later. The job is stored on `self._off_job` so it can be cancelled on re-trigger. -- **Named job** (`OFF_JOB_NAME`) keeps logs readable. Only one off job per app instance can exist with a given name — if you need multiple sensors driving the same light, give each instance a different name via config. -- Config fields (`motion_sensor`, `light`, `off_delay`) let you run the same app class for multiple rooms with different values in `hassette.toml`. +`on_state_change` subscribes to every state transition on the motion sensor. [`D.StateNew[states.BinarySensorState]`](../core-concepts/bus/dependency-injection.md) delivers the new state as a typed object — the handler covers both `True` and `False` transitions in one place rather than two separate subscriptions. + +When motion turns on, any pending off job is cancelled before the light turns on. This resets the timer — if motion fires again while the delay is running, the timeout starts over instead of firing at the original time. + +When motion clears, `run_in` schedules `turn_off_light` for `off_delay_seconds` seconds later. The returned `ScheduledJob` is stored on `self.off_job` so the on-handler can cancel it on re-trigger. + +`OFF_JOB_NAME` gives the scheduled job a stable name for log readability and deduplication. + +`motion_sensor`, `light`, and `off_delay_seconds` all come from config. Nothing in the app is hardcoded to a specific room. + +## Verify It's Working + +Trigger your motion sensor (or toggle it manually in Home Assistant) and check the handler fired: + +``` +hassette log --app motion_lights --since 5m +``` + +Look for `on_motion` entries showing the state transition. To verify the off timer, wait for the delay to elapse and confirm the light turns off: + +``` +hassette listener --app motion_lights +``` + +The `motion_sensor` listener should show an increasing invocation count each time motion fires. ## Variations -**Shorter or longer timeout** — Change `off_delay` in `hassette.toml` without touching the code: +**Shorter or longer timeout** — change `off_delay_seconds` in `hassette.toml` without touching the code: ```toml [apps.hallway_motion] module = "motion_lights" class = "MotionLights" -off_delay = 60 # 1 minute +off_delay_seconds = 60 +``` + +**Split handlers with `changed_to`** — instead of one handler that branches on the state value, two handlers with `changed_to` predicates each do one thing: + +```python +--8<-- "pages/recipes/snippets/motion_lights_split.py:split_handlers" ``` -**Multiple sensors, one light** — Deploy the app twice under different names, each pointing to its own sensor. The shared `light` entity is fine; whichever sensor detects motion last wins. +The trade-off: two subscriptions are easier to read individually, but the single-handler version keeps the on/off logic in one place where the relationship between them is visible. ## See Also -- [Bus: State Change Subscriptions](../core-concepts/bus/index.md) -- [Scheduler: `run_in` and Job Management](../core-concepts/scheduler/management.md) -- [Application Configuration](../core-concepts/configuration/applications.md) +- [Bus](../core-concepts/bus/index.md) — event subscriptions and rate control +- [Scheduler](../core-concepts/scheduler/index.md) — `run_in` and job management +- [Application Configuration](../core-concepts/configuration/applications.md) — per-instance config in `hassette.toml` diff --git a/docs/pages/recipes/snippets/motion_lights.py b/docs/pages/recipes/snippets/motion_lights.py index f21f8c259..063f8689f 100644 --- a/docs/pages/recipes/snippets/motion_lights.py +++ b/docs/pages/recipes/snippets/motion_lights.py @@ -1,20 +1,17 @@ from hassette import App, AppConfig, D, states from hassette.scheduler import ScheduledJob -MOTION_SENSOR = "binary_sensor.hallway_motion" -LIGHT = "light.hallway" -OFF_DELAY = 300 # seconds (5 minutes) OFF_JOB_NAME = "motion_lights_off" class MotionLightsConfig(AppConfig): - motion_sensor: str = MOTION_SENSOR - light: str = LIGHT - off_delay: float = OFF_DELAY + motion_sensor: str = "binary_sensor.hallway_motion" + light: str = "light.hallway" + off_delay_seconds: float = 300 class MotionLights(App[MotionLightsConfig]): - _off_job: ScheduledJob | None = None + off_job: ScheduledJob | None = None async def on_initialize(self) -> None: await self.bus.on_state_change( @@ -23,22 +20,22 @@ async def on_initialize(self) -> None: name="motion_sensor", ) - async def on_motion(self, new_state: D.StateNew[states.BinarySensorState]) -> None: + async def on_motion(self, new_state: D.StateNew[states.BinarySensorState]): if new_state.value is True: # Motion detected — cancel any pending off job and turn the light on. - if self._off_job is not None: - self._off_job.cancel() - self._off_job = None + if self.off_job is not None: + self.off_job.cancel() + self.off_job = None await self.api.turn_on(self.app_config.light, domain="light") elif new_state.value is False: # Motion cleared — schedule the light to turn off after the delay. - self._off_job = await self.scheduler.run_in( + self.off_job = await self.scheduler.run_in( self.turn_off_light, - delay=self.app_config.off_delay, + delay=self.app_config.off_delay_seconds, name=OFF_JOB_NAME, ) async def turn_off_light(self) -> None: - self._off_job = None + self.off_job = None await self.api.turn_off(self.app_config.light, domain="light") diff --git a/docs/pages/recipes/snippets/motion_lights_split.py b/docs/pages/recipes/snippets/motion_lights_split.py new file mode 100644 index 000000000..4a93581cd --- /dev/null +++ b/docs/pages/recipes/snippets/motion_lights_split.py @@ -0,0 +1,47 @@ +from hassette import App, AppConfig +from hassette.scheduler import ScheduledJob + +OFF_JOB_NAME = "motion_lights_off" + + +class MotionLightsConfig(AppConfig): + motion_sensor: str = "binary_sensor.hallway_motion" + light: str = "light.hallway" + off_delay_seconds: float = 300 + + +class MotionLights(App[MotionLightsConfig]): + off_job: ScheduledJob | None = None + + async def on_initialize(self) -> None: + # --8<-- [start:split_handlers] + await self.bus.on_state_change( + self.app_config.motion_sensor, + handler=self.on_motion_detected, + changed_to="on", + name="motion_on", + ) + await self.bus.on_state_change( + self.app_config.motion_sensor, + handler=self.on_motion_cleared, + changed_to="off", + name="motion_off", + ) + # --8<-- [end:split_handlers] + + async def on_motion_detected(self): + if self.off_job is not None: + self.off_job.cancel() + self.off_job = None + await self.api.turn_on(self.app_config.light, domain="light") + + async def on_motion_cleared(self): + self.off_job = await self.scheduler.run_in( + self.turn_off_light, + delay=self.app_config.off_delay_seconds, + name=OFF_JOB_NAME, + ) + + async def turn_off_light(self) -> None: + self.off_job = None + await self.api.turn_off(self.app_config.light, domain="light") diff --git a/docs/pages/web-ui/debug-handler.md b/docs/pages/web-ui/debug-handler.md new file mode 100644 index 000000000..12162be28 --- /dev/null +++ b/docs/pages/web-ui/debug-handler.md @@ -0,0 +1,3 @@ +# Debug a Failing Handler + +*This page is being rewritten as part of the documentation overhaul.* diff --git a/docs/pages/web-ui/inspect-config-code.md b/docs/pages/web-ui/inspect-config-code.md new file mode 100644 index 000000000..cadef4acb --- /dev/null +++ b/docs/pages/web-ui/inspect-config-code.md @@ -0,0 +1,3 @@ +# Inspect Configuration and Code + +*This page is being rewritten as part of the documentation overhaul.* diff --git a/docs/pages/web-ui/manage-apps.md b/docs/pages/web-ui/manage-apps.md new file mode 100644 index 000000000..d1b75468c --- /dev/null +++ b/docs/pages/web-ui/manage-apps.md @@ -0,0 +1,3 @@ +# Manage Apps + +*This page is being rewritten as part of the documentation overhaul.* diff --git a/mkdocs.yml b/mkdocs.yml index 4155f7e3a..2c83d9d7b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -32,10 +32,11 @@ theme: nav: - Home: index.md - Getting Started: - - Is Hassette Right for You?: pages/getting-started/hassette-vs-ha-yaml.md + - Is Hassette Right for You?: pages/getting-started/evaluator.md - Quickstart: pages/getting-started/index.md - Home Assistant Token: pages/getting-started/ha_token.md - Your First Automation: pages/getting-started/first-automation.md + - Hassette vs HA YAML: pages/getting-started/hassette-vs-ha-yaml.md - Docker Deployment: - Docker Setup: pages/getting-started/docker/index.md - Managing Dependencies: pages/getting-started/docker/dependencies.md @@ -52,17 +53,24 @@ nav: - Overview: pages/core-concepts/bus/index.md - Writing Handlers: pages/core-concepts/bus/handlers.md - Dependency Injection: pages/core-concepts/bus/dependency-injection.md + - Custom Extractors: pages/core-concepts/bus/custom-extractors.md - Filtering & Predicates: pages/core-concepts/bus/filtering.md - Scheduler: - Overview: pages/core-concepts/scheduler/index.md - Scheduling Methods: pages/core-concepts/scheduler/methods.md - Job Management: pages/core-concepts/scheduler/management.md - - States: pages/core-concepts/states/index.md + - States: + - Overview: pages/core-concepts/states/index.md + - Subscribing to State Changes: pages/core-concepts/states/subscribing.md + - DomainStates Reference: pages/core-concepts/states/domain-states.md + - Custom States: pages/core-concepts/states/custom-states.md + - State Registry: pages/core-concepts/states/state-registry.md + - Type Registry: pages/core-concepts/states/type-registry.md - API: - Overview: pages/core-concepts/api/index.md - Entities & States: pages/core-concepts/api/entities.md - Services: pages/core-concepts/api/services.md - - Managing Helpers: pages/advanced/managing-helpers.md + - Managing Helpers: pages/core-concepts/api/managing-helpers.md - Utilities: pages/core-concepts/api/utilities.md - App Cache: - Overview: pages/core-concepts/cache/index.md @@ -76,18 +84,10 @@ nav: - System Internals: pages/core-concepts/internals.md - Web UI: - Overview: pages/web-ui/index.md - - Layout & Navigation: pages/web-ui/layout.md - - Apps: pages/web-ui/apps.md - - App Detail: - - Overview: pages/web-ui/app-detail/index.md - - Overview Tab: pages/web-ui/app-detail/overview.md - - Handlers Tab: pages/web-ui/app-detail/handlers.md - - Code Tab: pages/web-ui/app-detail/code.md - - Logs Tab: pages/web-ui/app-detail/logs.md - - Config Tab: pages/web-ui/app-detail/config.md - - Handlers: pages/web-ui/handlers.md - - Logs: pages/web-ui/logs.md - - Config: pages/web-ui/config.md + - Debug a Failing Handler: pages/web-ui/debug-handler.md + - Read and Filter Logs: pages/web-ui/logs.md + - Manage Apps: pages/web-ui/manage-apps.md + - Inspect Configuration and Code: pages/web-ui/inspect-config-code.md - Health Endpoints: pages/web-ui/health-endpoints.md - CLI: - Overview: pages/cli/index.md @@ -107,12 +107,10 @@ nav: - React to a Service Call: pages/recipes/service-call-reaction.md - Monitor Sensor Thresholds: pages/recipes/sensor-threshold.md - Vacation Mode Toggle: pages/recipes/vacation-mode-toggle.md - - Advanced: - - Overview: pages/advanced/index.md - - Custom States: pages/advanced/custom-states.md - - State Registry: pages/advanced/state-registry.md - - Type Registry: pages/advanced/type-registry.md - - Log Level Tuning: pages/advanced/log-level-tuning.md + - Operating Hassette: + - Overview: pages/operating/index.md + - Log Level Tuning: pages/operating/log-levels.md + - Upgrading: pages/operating/upgrading.md - AppDaemon Migration: - Overview: pages/migration/index.md - Mental Model: pages/migration/concepts.md From d7a7f1bdec6dae8c9c65d8ba6d27031e9da661bc Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Mon, 1 Jun 2026 16:20:39 -0500 Subject: [PATCH 006/160] =?UTF-8?q?chore:=20update=20T12=20scope=20with=20?= =?UTF-8?q?troubleshooting=E2=86=92Operating=20content=20split?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../070-doc-overhaul/tasks/T12-write-migration-ops.md | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/design/specs/070-doc-overhaul/tasks/T12-write-migration-ops.md b/design/specs/070-doc-overhaul/tasks/T12-write-migration-ops.md index bf09b90e4..8be30ce8e 100644 --- a/design/specs/070-doc-overhaul/tasks/T12-write-migration-ops.md +++ b/design/specs/070-doc-overhaul/tasks/T12-write-migration-ops.md @@ -48,18 +48,15 @@ Migration pages follow a comparison-driven structure: old way (AppDaemon) vs new Operating pages are how-to content for running Hassette in production. Distinct from Troubleshooting (which is reactive symptom-lookup). Voice: procedural "you" is acceptable for step-by-step instructions. +**Additional content moving from troubleshooting to Operating:** The current troubleshooting page contains operational/behavioral content that isn't symptom-lookup: the WebSocket reconnection sequence (retry counts, backoff timings, sliding window budgets), handler exception behavior, and database degraded mode guidance. These describe how Hassette behaves, not problems to solve. Move them to the Operating overview or a dedicated "Runtime Behavior" subsection. Troubleshooting keeps only symptom → fix entries (can't connect, apps not loading, handler never runs, scheduler not firing, cache not persisting, custom state not registering, web UI not accessible). + ### Snippet handling: Migration has 27 snippets showing AppDaemon code alongside Hassette equivalents. These are valuable for side-by-side comparison — keep and update. Troubleshooting and Operating may need new snippets for configuration examples and CLI commands. ## Focus -**Knowledge loss is the primary risk.** The current troubleshooting page contains: -- Specific log signatures (e.g., exact error messages for WebSocket disconnection) -- Timing values (e.g., reconnection delay thresholds, timeout periods) -- Step-by-step runbook commands (e.g., checking service status, clearing state) - -None of these exist anywhere else in the codebase. The knowledge inventory from T04 is the authoritative list. Verify every item is preserved. +**Knowledge loss risk is lower than originally estimated.** The troubleshooting page was written by automated doc rewrites, not from production debugging discoveries. The specific values (retry counts, backoff timings) come from RestartSpec implementation code and are reconstructible. Still extract them in the T04 knowledge inventory, but the risk is misplacement (putting operational content in troubleshooting), not loss. **Migration snippets** import `appdaemon` which isn't installed in the project — the Pyright config excludes `pages/migration/snippets` for this reason. This exclusion must remain. From 28ce6aa90095e5d6748a8544b415a0a9e11b6bdd Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Mon, 1 Jun 2026 16:41:24 -0500 Subject: [PATCH 007/160] =?UTF-8?q?chore:=20T04=20=E2=80=94=20per-page=20c?= =?UTF-8?q?ontent=20outlines,=20knowledge=20inventory,=20and=20snippet=20m?= =?UTF-8?q?apping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 76 files covering all 74 nav pages plus knowledge inventory (13 items) and snippet mapping (355 files categorized). Key structural decisions captured: - States/subscribing: bridge page with state-change predicates/conditions - Bus/handlers: owns non-state events, error handling, subscription mechanics - Bus/filtering: general mechanism + service call filtering + P/C reference - System Internals: split into 3 pages (architecture, service details, lifecycle) - Troubleshooting: pure symptom-lookup; operational content → Operating section - All 66 advanced/ snippets mapped to new locations --- .../070-doc-overhaul/outlines/cli/commands.md | 31 ++++ .../outlines/cli/configuration.md | 37 ++++ .../070-doc-overhaul/outlines/cli/overview.md | 21 +++ .../outlines/cli/workflows.md | 27 +++ .../outlines/core-concepts/api/entities.md | 36 ++++ .../core-concepts/api/managing-helpers.md | 51 ++++++ .../outlines/core-concepts/api/overview.md | 35 ++++ .../outlines/core-concepts/api/services.md | 26 +++ .../outlines/core-concepts/api/utilities.md | 32 ++++ .../core-concepts/apps/configuration.md | 24 +++ .../outlines/core-concepts/apps/lifecycle.md | 27 +++ .../outlines/core-concepts/apps/overview.md | 47 +++++ .../core-concepts/apps/task-bucket.md | 33 ++++ .../outlines/core-concepts/architecture.md | 30 ++++ .../core-concepts/bus/custom-extractors.md | 39 ++++ .../core-concepts/bus/dependency-injection.md | 24 +++ .../outlines/core-concepts/bus/filtering.md | 54 ++++++ .../outlines/core-concepts/bus/handlers.md | 59 ++++++ .../outlines/core-concepts/bus/overview.md | 20 +++ .../outlines/core-concepts/cache/overview.md | 38 ++++ .../outlines/core-concepts/cache/patterns.md | 45 +++++ .../configuration/applications.md | 32 ++++ .../core-concepts/configuration/auth.md | 24 +++ .../core-concepts/configuration/global.md | 62 +++++++ .../core-concepts/configuration/overview.md | 27 +++ .../core-concepts/database-telemetry.md | 36 ++++ .../outlines/core-concepts/internals.md | 41 +++++ .../core-concepts/scheduler/management.md | 41 +++++ .../core-concepts/scheduler/methods.md | 48 +++++ .../core-concepts/scheduler/overview.md | 29 +++ .../core-concepts/states/custom-states.md | 63 +++++++ .../core-concepts/states/domain-states.md | 37 ++++ .../outlines/core-concepts/states/overview.md | 36 ++++ .../core-concepts/states/state-registry.md | 55 ++++++ .../core-concepts/states/subscribing.md | 51 ++++++ .../core-concepts/states/type-registry.md | 68 +++++++ .../getting-started/docker-dependencies.md | 70 ++++++++ .../getting-started/docker-image-tags.md | 51 ++++++ .../outlines/getting-started/docker-setup.md | 58 ++++++ .../getting-started/docker-troubleshooting.md | 82 +++++++++ .../outlines/getting-started/evaluator.md | 27 +++ .../getting-started/first-automation.md | 48 +++++ .../outlines/getting-started/ha-token.md | 24 +++ .../getting-started/hassette-vs-ha-yaml.md | 30 ++++ .../outlines/getting-started/quickstart.md | 42 +++++ .../specs/070-doc-overhaul/outlines/home.md | 38 ++++ .../outlines/knowledge-inventory.md | 170 ++++++++++++++++++ .../outlines/migration/api.md | 37 ++++ .../outlines/migration/bus.md | 37 ++++ .../outlines/migration/checklist.md | 44 +++++ .../outlines/migration/concepts.md | 35 ++++ .../outlines/migration/configuration.md | 34 ++++ .../outlines/migration/overview.md | 35 ++++ .../outlines/migration/scheduler.md | 35 ++++ .../outlines/migration/testing.md | 29 +++ .../outlines/operating/log-levels.md | 43 +++++ .../outlines/operating/overview.md | 32 ++++ .../outlines/operating/upgrading.md | 28 +++ .../outlines/recipes/daily-notification.md | 32 ++++ .../recipes/debounce-sensor-changes.md | 32 ++++ .../outlines/recipes/motion-lights.md | 19 ++ .../outlines/recipes/overview.md | 20 +++ .../outlines/recipes/sensor-threshold.md | 32 ++++ .../outlines/recipes/service-call-reaction.md | 32 ++++ .../outlines/recipes/vacation-mode-toggle.md | 32 ++++ .../outlines/snippet-mapping.md | 111 ++++++++++++ .../outlines/testing/concurrency.md | 32 ++++ .../outlines/testing/factories.md | 39 ++++ .../outlines/testing/overview.md | 54 ++++++ .../outlines/testing/time-control.md | 26 +++ .../troubleshooting/troubleshooting.md | 52 ++++++ .../outlines/web-ui/debug-handler.md | 32 ++++ .../outlines/web-ui/inspect-config-code.md | 26 +++ .../070-doc-overhaul/outlines/web-ui/logs.md | 33 ++++ .../outlines/web-ui/manage-apps.md | 32 ++++ .../outlines/web-ui/overview.md | 29 +++ 76 files changed, 3080 insertions(+) create mode 100644 design/specs/070-doc-overhaul/outlines/cli/commands.md create mode 100644 design/specs/070-doc-overhaul/outlines/cli/configuration.md create mode 100644 design/specs/070-doc-overhaul/outlines/cli/overview.md create mode 100644 design/specs/070-doc-overhaul/outlines/cli/workflows.md create mode 100644 design/specs/070-doc-overhaul/outlines/core-concepts/api/entities.md create mode 100644 design/specs/070-doc-overhaul/outlines/core-concepts/api/managing-helpers.md create mode 100644 design/specs/070-doc-overhaul/outlines/core-concepts/api/overview.md create mode 100644 design/specs/070-doc-overhaul/outlines/core-concepts/api/services.md create mode 100644 design/specs/070-doc-overhaul/outlines/core-concepts/api/utilities.md create mode 100644 design/specs/070-doc-overhaul/outlines/core-concepts/apps/configuration.md create mode 100644 design/specs/070-doc-overhaul/outlines/core-concepts/apps/lifecycle.md create mode 100644 design/specs/070-doc-overhaul/outlines/core-concepts/apps/overview.md create mode 100644 design/specs/070-doc-overhaul/outlines/core-concepts/apps/task-bucket.md create mode 100644 design/specs/070-doc-overhaul/outlines/core-concepts/architecture.md create mode 100644 design/specs/070-doc-overhaul/outlines/core-concepts/bus/custom-extractors.md create mode 100644 design/specs/070-doc-overhaul/outlines/core-concepts/bus/dependency-injection.md create mode 100644 design/specs/070-doc-overhaul/outlines/core-concepts/bus/filtering.md create mode 100644 design/specs/070-doc-overhaul/outlines/core-concepts/bus/handlers.md create mode 100644 design/specs/070-doc-overhaul/outlines/core-concepts/bus/overview.md create mode 100644 design/specs/070-doc-overhaul/outlines/core-concepts/cache/overview.md create mode 100644 design/specs/070-doc-overhaul/outlines/core-concepts/cache/patterns.md create mode 100644 design/specs/070-doc-overhaul/outlines/core-concepts/configuration/applications.md create mode 100644 design/specs/070-doc-overhaul/outlines/core-concepts/configuration/auth.md create mode 100644 design/specs/070-doc-overhaul/outlines/core-concepts/configuration/global.md create mode 100644 design/specs/070-doc-overhaul/outlines/core-concepts/configuration/overview.md create mode 100644 design/specs/070-doc-overhaul/outlines/core-concepts/database-telemetry.md create mode 100644 design/specs/070-doc-overhaul/outlines/core-concepts/internals.md create mode 100644 design/specs/070-doc-overhaul/outlines/core-concepts/scheduler/management.md create mode 100644 design/specs/070-doc-overhaul/outlines/core-concepts/scheduler/methods.md create mode 100644 design/specs/070-doc-overhaul/outlines/core-concepts/scheduler/overview.md create mode 100644 design/specs/070-doc-overhaul/outlines/core-concepts/states/custom-states.md create mode 100644 design/specs/070-doc-overhaul/outlines/core-concepts/states/domain-states.md create mode 100644 design/specs/070-doc-overhaul/outlines/core-concepts/states/overview.md create mode 100644 design/specs/070-doc-overhaul/outlines/core-concepts/states/state-registry.md create mode 100644 design/specs/070-doc-overhaul/outlines/core-concepts/states/subscribing.md create mode 100644 design/specs/070-doc-overhaul/outlines/core-concepts/states/type-registry.md create mode 100644 design/specs/070-doc-overhaul/outlines/getting-started/docker-dependencies.md create mode 100644 design/specs/070-doc-overhaul/outlines/getting-started/docker-image-tags.md create mode 100644 design/specs/070-doc-overhaul/outlines/getting-started/docker-setup.md create mode 100644 design/specs/070-doc-overhaul/outlines/getting-started/docker-troubleshooting.md create mode 100644 design/specs/070-doc-overhaul/outlines/getting-started/evaluator.md create mode 100644 design/specs/070-doc-overhaul/outlines/getting-started/first-automation.md create mode 100644 design/specs/070-doc-overhaul/outlines/getting-started/ha-token.md create mode 100644 design/specs/070-doc-overhaul/outlines/getting-started/hassette-vs-ha-yaml.md create mode 100644 design/specs/070-doc-overhaul/outlines/getting-started/quickstart.md create mode 100644 design/specs/070-doc-overhaul/outlines/home.md create mode 100644 design/specs/070-doc-overhaul/outlines/knowledge-inventory.md create mode 100644 design/specs/070-doc-overhaul/outlines/migration/api.md create mode 100644 design/specs/070-doc-overhaul/outlines/migration/bus.md create mode 100644 design/specs/070-doc-overhaul/outlines/migration/checklist.md create mode 100644 design/specs/070-doc-overhaul/outlines/migration/concepts.md create mode 100644 design/specs/070-doc-overhaul/outlines/migration/configuration.md create mode 100644 design/specs/070-doc-overhaul/outlines/migration/overview.md create mode 100644 design/specs/070-doc-overhaul/outlines/migration/scheduler.md create mode 100644 design/specs/070-doc-overhaul/outlines/migration/testing.md create mode 100644 design/specs/070-doc-overhaul/outlines/operating/log-levels.md create mode 100644 design/specs/070-doc-overhaul/outlines/operating/overview.md create mode 100644 design/specs/070-doc-overhaul/outlines/operating/upgrading.md create mode 100644 design/specs/070-doc-overhaul/outlines/recipes/daily-notification.md create mode 100644 design/specs/070-doc-overhaul/outlines/recipes/debounce-sensor-changes.md create mode 100644 design/specs/070-doc-overhaul/outlines/recipes/motion-lights.md create mode 100644 design/specs/070-doc-overhaul/outlines/recipes/overview.md create mode 100644 design/specs/070-doc-overhaul/outlines/recipes/sensor-threshold.md create mode 100644 design/specs/070-doc-overhaul/outlines/recipes/service-call-reaction.md create mode 100644 design/specs/070-doc-overhaul/outlines/recipes/vacation-mode-toggle.md create mode 100644 design/specs/070-doc-overhaul/outlines/snippet-mapping.md create mode 100644 design/specs/070-doc-overhaul/outlines/testing/concurrency.md create mode 100644 design/specs/070-doc-overhaul/outlines/testing/factories.md create mode 100644 design/specs/070-doc-overhaul/outlines/testing/overview.md create mode 100644 design/specs/070-doc-overhaul/outlines/testing/time-control.md create mode 100644 design/specs/070-doc-overhaul/outlines/troubleshooting/troubleshooting.md create mode 100644 design/specs/070-doc-overhaul/outlines/web-ui/debug-handler.md create mode 100644 design/specs/070-doc-overhaul/outlines/web-ui/inspect-config-code.md create mode 100644 design/specs/070-doc-overhaul/outlines/web-ui/logs.md create mode 100644 design/specs/070-doc-overhaul/outlines/web-ui/manage-apps.md create mode 100644 design/specs/070-doc-overhaul/outlines/web-ui/overview.md diff --git a/design/specs/070-doc-overhaul/outlines/cli/commands.md b/design/specs/070-doc-overhaul/outlines/cli/commands.md new file mode 100644 index 000000000..2bf185453 --- /dev/null +++ b/design/specs/070-doc-overhaul/outlines/cli/commands.md @@ -0,0 +1,31 @@ +# CLI — Command Reference + +**Status:** Exists (408 lines), comprehensive reference, voice polish needed +**Voice mode:** Reference — terse, tabular, scannable + +## Outline + +Full reference for every CLI command. Keep current structure — it's a lookup reference. + +### H2: `hassette run` — start the server +### H2: `hassette status` — system overview +### H2: `hassette app` — app management +Subcommands: health, activity, config, source. +### H2: `hassette listener` — listener inspection, invocation history +### H2: `hassette job` — scheduler job inspection, execution history +### H2: `hassette log` — log querying +### H2: `hassette execution` — execution detail +### H2: `hassette event` — event inspection +### H2: `hassette dashboard` — dashboard overview +### H2: `hassette config` — config inspection +### H2: `hassette telemetry` — telemetry management +### H2: Shared Flags — `--since`, `--instance`, `--json` + +## Snippet Inventory + +No code snippets — CLI output examples are inline. + +## Cross-Links + +- **Links to:** CLI overview, Workflows (how commands compose) +- **Linked from:** CLI overview, Operating (runbook commands reference these) diff --git a/design/specs/070-doc-overhaul/outlines/cli/configuration.md b/design/specs/070-doc-overhaul/outlines/cli/configuration.md new file mode 100644 index 000000000..690dda6c8 --- /dev/null +++ b/design/specs/070-doc-overhaul/outlines/cli/configuration.md @@ -0,0 +1,37 @@ +# CLI — Configuration & Scripting + +**Status:** Exists (231 lines), solid content, voice polish needed +**Voice mode:** Reference/procedural hybrid + +## Outline + +### H2: Configuration +#### H3: Discovery Order — how CLI finds hassette.toml +#### H3: Token — where CLI reads the HA token from + +### H2: Output Modes +#### H3: Human-Readable (Default) — formatted tables +#### H3: JSON (`--json`) — machine-readable output +#### H3: `NO_COLOR` — disabling color output + +### H2: Scripting with `jq` +Recipes for piping JSON output to jq. Health check script, alerting on error rate. + +### H2: Shell Completion +#### H3: Generate to stdout +#### H3: Install to default location + +### H2: Error Handling +#### H3: Exit Codes — table of exit codes +#### H3: Common Errors — connection refused, auth failed, etc. +#### H3: JSON Error Format +#### H3: Debug Mode (`--debug`) + +## Snippet Inventory + +No code snippets — shell command examples are inline. + +## Cross-Links + +- **Links to:** CLI overview, Commands +- **Linked from:** CLI overview diff --git a/design/specs/070-doc-overhaul/outlines/cli/overview.md b/design/specs/070-doc-overhaul/outlines/cli/overview.md new file mode 100644 index 000000000..1801cb4da --- /dev/null +++ b/design/specs/070-doc-overhaul/outlines/cli/overview.md @@ -0,0 +1,21 @@ +# CLI — Overview + +**Status:** Exists (67 lines), brief intro, voice polish needed +**Voice mode:** Getting-started — "you" allowed, quick orientation + +## Outline + +### H2: Quick Start +`hassette run`, `hassette status`, `hassette app` — the three commands you'll use daily. + +### H2: Next Steps +→ Command Reference, → Workflows, → Configuration & Scripting + +## Snippet Inventory + +No code snippets — CLI output examples may be inline. + +## Cross-Links + +- **Links to:** Commands, Workflows, CLI Configuration +- **Linked from:** Getting Started (next steps), Web UI overview diff --git a/design/specs/070-doc-overhaul/outlines/cli/workflows.md b/design/specs/070-doc-overhaul/outlines/cli/workflows.md new file mode 100644 index 000000000..2664323b9 --- /dev/null +++ b/design/specs/070-doc-overhaul/outlines/cli/workflows.md @@ -0,0 +1,27 @@ +# CLI — Workflows + +**Status:** Exists (139 lines), solid content, voice polish needed +**Voice mode:** Getting-started/procedural — "you" allowed, step-by-step + +## Outline + +### H2: Drill-Down: From Status to Root Cause +Numbered steps: status → find problem app → inspect listeners → view history → read logs. + +### H2: Monitoring a Specific App +Focused monitoring patterns. Multi-instance apps. + +### H2: Quick Health Checks +One-liner commands for common checks: running? all healthy? errors? recent activity? + +### H2: Comparing Time Windows +Before/after comparison patterns. + +## Snippet Inventory + +No code snippets — CLI command sequences. + +## Cross-Links + +- **Links to:** Commands (individual command details), Web UI/Debug Handler (UI alternative) +- **Linked from:** CLI overview, Operating, Troubleshooting diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/api/entities.md b/design/specs/070-doc-overhaul/outlines/core-concepts/api/entities.md new file mode 100644 index 000000000..08aa2b249 --- /dev/null +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/api/entities.md @@ -0,0 +1,36 @@ +# API — Entities & States + +**Status:** Exists (72 lines), solid content, voice polish needed +**Voice mode:** Concept — system-as-subject, no "you" + +## Outline + +### H2: Terminology +Entity, state, attributes — HA concepts mapped to Hassette types. + +### H2: Retrieving States +`get_state(entity_id)` — single entity. +#### H3: Raw vs Typed +Raw string state vs typed state model conversion. +#### H3: Checking Existence +What happens when an entity doesn't exist. + +### H2: Retrieving Multiple States +`get_states()` — all entities or filtered. + +### H2: Entities +Entity registry access. + +### H2: API vs StateManager +Expanded comparison: when to hit HA directly vs use the local cache. + +## Snippet Inventory + +| Snippet | Status | Notes | +|---|---|---| +| Relevant files from `api/snippets/` | Review | Entity access examples | + +## Cross-Links + +- **Links to:** States overview, State Registry, API overview +- **Linked from:** API overview diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/api/managing-helpers.md b/design/specs/070-doc-overhaul/outlines/core-concepts/api/managing-helpers.md new file mode 100644 index 000000000..1e1e9d4d4 --- /dev/null +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/api/managing-helpers.md @@ -0,0 +1,51 @@ +# API — Managing Helpers + +**Status:** Stub (3 lines), content moving from Advanced (168 lines) +**Voice mode:** Concept — system-as-subject, no "you" + +## Outline + +Content source: `docs/pages/advanced/managing-helpers.md` + +### H2: Typed Models +HA helper types (InputBoolean, InputNumber, etc.) as typed Pydantic models. + +### H2: Creating a Helper +`create_helper()` with typed model. + +### H2: Listing Helpers +`list_helpers()` with type filtering. + +### H2: Updating a Helper +`update_helper()` with partial updates. + +### H2: Deleting a Helper +`delete_helper()`. + +### H2: Idempotent Bootstrap (The Simple Pattern) +Create-if-not-exists pattern for app initialization. + +### H2: Counter Service-Call Shortcuts +`increment`, `decrement`, `reset` for input_number and counter helpers. + +### H2: Testing with the Harness +How `RecordingApi` handles helper operations in tests. + +### H2: Gotchas +Known limitations and edge cases (HA API quirks). + +## Snippet Inventory + +Moving from `advanced/snippets/managing-helpers/` (5 files): +| Snippet | Status | Notes | +|---|---|---| +| `create_helper.py` | Move | → `api/snippets/` | +| `crud_operations.py` | Move | | +| `counter_shortcuts.py` | Move | | +| `testing_harness.py` | Move | | +| `timer_call_service.py` | Move | | + +## Cross-Links + +- **Links to:** API overview, Testing (harness), Apps lifecycle (bootstrap in on_initialize) +- **Linked from:** API overview diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/api/overview.md b/design/specs/070-doc-overhaul/outlines/core-concepts/api/overview.md new file mode 100644 index 000000000..dc47073de --- /dev/null +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/api/overview.md @@ -0,0 +1,35 @@ +# API — Overview + +**Status:** Exists (70 lines), concise, voice polish needed +**Voice mode:** Concept — system-as-subject, no "you" + +## Outline + +### H2: (Opening) +What the API handle provides: async interface to HA REST and WebSocket APIs. Available as `self.api` on every app. + +### H2: Usage +Basic `call_service`, `get_state`, `get_states` patterns. + +### H2: Error Handling +What happens when HA is unreachable, timeouts, error responses. + +### H2: Synchronous Usage +`self.api.sync` for sync contexts (rare). + +### H2: API vs StateManager +When to use `self.api.get_state()` vs `self.states.get()`. API = fresh from HA; StateManager = cached local state. + +### H2: Next Steps +→ Entities & States, → Services, → Managing Helpers, → Utilities + +## Snippet Inventory + +| Snippet | Status | Notes | +|---|---|---| +| Files from `api/snippets/` (14 total) | Review | Assign per-page | + +## Cross-Links + +- **Links to:** Entities, Services, Managing Helpers, Utilities, States overview +- **Linked from:** Architecture, Apps overview diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/api/services.md b/design/specs/070-doc-overhaul/outlines/core-concepts/api/services.md new file mode 100644 index 000000000..1ec30f000 --- /dev/null +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/api/services.md @@ -0,0 +1,26 @@ +# API — Services + +**Status:** Exists (35 lines), brief, voice polish needed +**Voice mode:** Concept — system-as-subject, no "you" + +## Outline + +### H2: Basic Service Calls +`call_service(domain, service, data)` pattern. + +### H2: Convenience Helpers +`turn_on`, `turn_off`, `toggle` shortcuts. + +### H2: Service Responses +What `call_service` returns, response handling. + +## Snippet Inventory + +| Snippet | Status | Notes | +|---|---|---| +| Relevant files from `api/snippets/` | Review | Service call examples | + +## Cross-Links + +- **Links to:** API overview, Bus handlers (on_call_service for reacting to service calls) +- **Linked from:** API overview, Recipes diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/api/utilities.md b/design/specs/070-doc-overhaul/outlines/core-concepts/api/utilities.md new file mode 100644 index 000000000..b3e636dd5 --- /dev/null +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/api/utilities.md @@ -0,0 +1,32 @@ +# API — Utilities + +**Status:** Exists (81 lines), reference-style, voice polish needed +**Voice mode:** Reference — terse, system-as-subject + +## Outline + +### H2: Templates +`render_template()` — render HA Jinja2 templates. + +### H2: History +`get_history()` — retrieve entity history. + +### H2: Logbook +`get_logbook()` — retrieve logbook entries. + +### H2: Other Endpoints +#### H3: `fire_event` — fire custom HA events +#### H3: `set_state` — override entity state +#### H3: `get_calendars` — list calendars +#### H3: `get_calendar_events` — retrieve calendar events + +## Snippet Inventory + +| Snippet | Status | Notes | +|---|---|---| +| Relevant files from `api/snippets/` | Review | Utility method examples | + +## Cross-Links + +- **Links to:** API overview +- **Linked from:** API overview diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/apps/configuration.md b/design/specs/070-doc-overhaul/outlines/core-concepts/apps/configuration.md new file mode 100644 index 000000000..00db5663b --- /dev/null +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/apps/configuration.md @@ -0,0 +1,24 @@ +# Apps — Configuration + +**Status:** Exists (34 lines), very short, may need expansion +**Voice mode:** Concept — system-as-subject, no "you" + +## Outline + +### H2: Defining Config Models +AppConfig subclass with Pydantic SettingsConfigDict. How env_prefix maps to hassette.toml. + +### H2: Base Fields +Fields inherited from AppConfig (instance_name, etc.). + +### H2: Secrets & Environment Variables +Loading secrets from env vars via Pydantic. + +## Snippet Inventory + +Snippets from `apps/snippets/` that show config patterns — review and assign. + +## Cross-Links + +- **Links to:** Configuration/Applications (hassette.toml side), Apps overview +- **Linked from:** Apps overview, First Automation (step 2) diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/apps/lifecycle.md b/design/specs/070-doc-overhaul/outlines/core-concepts/apps/lifecycle.md new file mode 100644 index 000000000..8f980e2d1 --- /dev/null +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/apps/lifecycle.md @@ -0,0 +1,27 @@ +# Apps — Lifecycle + +**Status:** Exists (80 lines), concise, voice polish needed +**Voice mode:** Concept — system-as-subject, no "you" + +## Outline + +### H2: Initialization +`on_initialize` → `on_ready` sequence. What each hook is for. Registration happens in `on_initialize`; `on_ready` fires after all apps finish initializing. + +### H2: Shutdown +`on_shutdown` hook. Cleanup order. + +### H2: Automatic Cleanup +How Hassette cleans up bus subscriptions and scheduler jobs when an app shuts down. + +### H2: AppSync Lifecycle Hooks +`on_apps_ready` and `on_apps_shutdown` for cross-app coordination. + +## Snippet Inventory + +Snippets from `apps/snippets/` that demonstrate lifecycle hooks — review and assign. + +## Cross-Links + +- **Links to:** Apps overview, Task Bucket, Bus overview (registration in on_initialize) +- **Linked from:** Apps overview (next steps) diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/apps/overview.md b/design/specs/070-doc-overhaul/outlines/core-concepts/apps/overview.md new file mode 100644 index 000000000..d7d8a1d74 --- /dev/null +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/apps/overview.md @@ -0,0 +1,47 @@ +# Apps — Overview + +**Status:** Exists (186 lines), solid content, voice polish needed +**Voice mode:** Concept — system-as-subject, no "you" + +## Outline + +### H2: Structure +App[Config] generic, five handles (bus, scheduler, api, states, cache), logger. + +### H2: Defining an App +Minimal app example, AppConfig usage. + +### H2: Dates and Times +`whenever` library usage for date/time in apps. + +### H2: Core Capabilities +Brief overview linking to each capability's page: +#### H3: Reacting to Events +#### H3: Run Recurring Jobs +#### H3: Check Entity States +#### H3: Call Services +#### H3: Persist Data Between Restarts +#### H3: Run Background Tasks and Blocking Code + +### H2: Restricting to a Single App During Development +`--app` flag for development. + +### H2: Broadcasting Events Between Apps +`Bus.emit()` for inter-app communication. + +### H2: Synchronous Apps +`SyncApp` variant for simple scripts. + +### H2: Next Steps +Links to Lifecycle, Configuration, Task Bucket. + +## Snippet Inventory + +| Snippet | Status | Notes | +|---|---|---| +| 15 files in `apps/snippets/` | Review | Check each for voice, DI-first alignment | + +## Cross-Links + +- **Links to:** Lifecycle, Configuration, Task Bucket, Bus overview, Scheduler overview, States overview, API overview, Cache overview +- **Linked from:** Architecture, First Automation, Recipes diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/apps/task-bucket.md b/design/specs/070-doc-overhaul/outlines/core-concepts/apps/task-bucket.md new file mode 100644 index 000000000..d791cd367 --- /dev/null +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/apps/task-bucket.md @@ -0,0 +1,33 @@ +# Apps — Task Bucket + +**Status:** Exists (70 lines), good content, voice polish needed +**Voice mode:** Concept — system-as-subject, no "you" + +## Outline + +### H2: Spawning Background Tasks +`self.create_task()` for fire-and-forget async work. + +### H2: Offloading Blocking Code +`self.run_in_executor()` for sync/blocking calls (file I/O, HTTP libraries without async). + +### H2: Normalizing Sync/Async Callables +`self.normalize_callable()` for handling both sync and async handlers uniformly. + +### H2: Cross-Thread Communication +#### H3: Posting to the Event Loop +`self.call_soon()` for thread-safe event loop posting. +#### H3: Running Async from Sync Code +`self.run_coroutine()` for calling async from sync contexts. + +### H2: Shutdown Behavior +How pending tasks are handled during app shutdown. + +## Snippet Inventory + +Snippets from `apps/snippets/` that demonstrate task bucket patterns — review and assign. + +## Cross-Links + +- **Links to:** Apps overview, Apps lifecycle (shutdown) +- **Linked from:** Apps overview (core capabilities) diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/architecture.md b/design/specs/070-doc-overhaul/outlines/core-concepts/architecture.md new file mode 100644 index 000000000..02a892562 --- /dev/null +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/architecture.md @@ -0,0 +1,30 @@ +# Architecture + +**Status:** Exists (245 lines), structure solid, voice polish needed +**Voice mode:** Concept — system-as-subject, no "you" + +## Outline + +### H2: Hassette Architecture +Opening: what Hassette is and what it connects to. One paragraph. + +### H2: Diagrams +Three Mermaid diagrams (existing, keep): +1. High-level flow (HA ↔ Hassette ↔ Apps) +2. Core services inside Hassette +3. What each app gets (the five handles: Bus, Scheduler, Api, StateManager, Cache) + +### H2: Service Dependency Graph +How `depends_on` works, initialization/shutdown order, cycle detection. Framework dependency graph diagram. + +### H2: Deep Dive +Links to each core concept page. + +## Snippet Inventory + +No code snippets — diagrams are inline Mermaid. + +## Cross-Links + +- **Links to:** All core concept subsection overviews, System Internals +- **Linked from:** Home page, Getting Started (next steps) diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/bus/custom-extractors.md b/design/specs/070-doc-overhaul/outlines/core-concepts/bus/custom-extractors.md new file mode 100644 index 000000000..a09a9c351 --- /dev/null +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/bus/custom-extractors.md @@ -0,0 +1,39 @@ +# Bus — Custom Extractors + +**Status:** Stub (3 lines), content to be written in T07 +**Voice mode:** Concept/reference hybrid — system-as-subject, code-heavy + +## Outline + +### H2: Writing a Custom Extractor +How to implement the extractor protocol. When to write one (data not covered by built-in annotations). + +### H2: Custom Accessors with `A` +How accessors work, creating custom field accessors for event data. + +### H2: AnnotationDetails +The AnnotationDetails object that extractors receive. Fields and usage. + +### H2: Automatic Type Conversion +**Note:** This was originally considered for this page but belongs in Type Registry instead (confirmed during T03). Remove if still here; the Type Registry page covers conversion. + +**Revision:** Keep only extractor-specific type conversion concerns here (e.g., how extractors interact with the type registry). General type conversion → Type Registry page. + +## Snippet Inventory + +Existing snippets in `dependency-injection/` that belong here: +| Snippet | Status | Notes | +|---|---|---| +| `custom_extractor_builtin.py` | Move here | Built-in extractor internals | +| `custom_extractor_converter.py` | Move here | Extractor with type converter | +| `custom_extractor_own.py` | Move here | Writing your own extractor | +| `custom_type_converter.py` | Move here | Custom type converter (or Type Registry?) | +| `custom_accessors.py` (from filtering/) | Move here | Accessor examples | + +**New snippets needed:** +- AnnotationDetails usage example + +## Cross-Links + +- **Links to:** DI page (built-in annotations), Type Registry, State Registry +- **Linked from:** DI page (see also) diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/bus/dependency-injection.md b/design/specs/070-doc-overhaul/outlines/core-concepts/bus/dependency-injection.md new file mode 100644 index 000000000..c32d6889c --- /dev/null +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/bus/dependency-injection.md @@ -0,0 +1,24 @@ +# Bus — Dependency Injection + +**Status:** REWRITTEN in T03 (exemplar). 98 lines. Done. +**Voice mode:** Reference — tables-first, terse, system-as-subject + +## Outline + +Already complete. Covers: annotation reference (state/identity/other extractors), combining annotations, union types, custom kwargs, handler signature restrictions. + +## Snippet Inventory + +All snippets written and tested in T03: +- `dependency-injection/quick_example.py` +- `dependency-injection/state_object_extractors.py` (temperature delta) +- `dependency-injection/identity_extractors.py` +- `dependency-injection/event_data_extractor.py` +- `dependency-injection/multiple_dependencies.py` +- `dependency-injection/union_types.py` +- `dependency-injection/mixing_kwargs.py` + +## Cross-Links + +- **Links to:** Custom Extractors, Handlers, State Registry, Type Registry +- **Linked from:** Bus overview, First Automation, Recipes diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/bus/filtering.md b/design/specs/070-doc-overhaul/outlines/core-concepts/bus/filtering.md new file mode 100644 index 000000000..e543a6020 --- /dev/null +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/bus/filtering.md @@ -0,0 +1,54 @@ +# Bus — Filtering & Predicates + +**Status:** Exists (255 lines), needs restructuring — most predicate/condition content is state-change-specific and may partially move to States/Subscribing +**Voice mode:** Concept — system-as-subject, no "you" + +## Outline + +### H2: How Filtering Works +Overview: predicates test events, conditions test values. Predicates compose with `&` and `|`. + +### H2: Filtering State Changes +**Note:** Heavy overlap with States/Subscribing page. Decision: States/Subscribing covers the common state-change patterns (entity patterns, `changed` param, `changed_to`, `changed_from`, state-specific predicates). This page covers the general filtering mechanism and non-state-change filtering. + +Content that stays here: +- How predicates compose (`&`, `|`) +- General event filtering concept + +### H2: Filtering Service Calls +Dictionary filtering and predicate filtering for `on_call_service`. + +### H2: Advanced Topic Subscriptions +`on()` with custom topic strings and predicates. + +### H2: Complete Reference +#### H3: Predicates (`P`) +Full reference table: logic combinators, value/field matching, entity/domain/service matching, state change predicates. +#### H3: Conditions (`C`) +Full reference table: string matching, collection membership, none/missing checks, numeric comparison. + +## Snippet Inventory + +| Snippet | Status | Notes | +|---|---|---| +| `filtering_simple_start.py` | Review | May move to States/Subscribing | +| `filtering_simple_stop.py` | Review | May move to States/Subscribing | +| `filtering_predicate_lambda.py` | Keep | General predicate example | +| `filtering_predicate_isin.py` | Keep | Collection predicate | +| `filtering_combined_and.py` | Keep | Predicate composition | +| `filtering_combined_or.py` | Keep | Predicate composition | +| `filtering_service_literal.py` | Keep | Service call filtering | +| `filtering_service_callable.py` | Keep | Service call filtering | +| `filtering_service_predicates.py` | Keep | Service predicate | +| `filtering_service_presence.py` | Keep | Service presence check | +| `filtering_service_matches.py` | Keep | ServiceMatches predicate | +| `filtering_state_from_to.py` | Review | May move to States/Subscribing | +| `filtering_increased_decreased.py` | Review | May move to States/Subscribing | +| `filtering_advanced_topics.py` | Keep | Advanced topic subscription | +| `changed_false.py` | Review | May move to States/Subscribing | +| `custom_accessors.py` | Move | → Custom Extractors page | + +## Cross-Links + +- **Links to:** States/Subscribing (state-specific patterns), Custom Extractors (accessors), Handlers +- **Linked from:** Bus overview, States/Subscribing diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/bus/handlers.md b/design/specs/070-doc-overhaul/outlines/core-concepts/bus/handlers.md new file mode 100644 index 000000000..0bdc23e00 --- /dev/null +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/bus/handlers.md @@ -0,0 +1,59 @@ +# Bus — Writing Handlers + +**Status:** Exists (192 lines), needs restructuring to remove DI overlap +**Voice mode:** Concept — system-as-subject, no "you" + +## Outline + +### H2: Event Model +What events look like: `RawStateChangeEvent`, `RawCallServiceEvent`, `RawEvent`. The event dict structure. + +### H2: Raw Event Handlers +Handlers that receive the raw event dict. When to use: rare cases where DI doesn't cover the need, or when processing bulk events. Show the pattern. + +### H2: Non-State Event Types +Cover event types beyond state changes: +- `on_call_service` — reacting to service calls +- `on` — subscribing to raw HA event types (e.g., `event_triggered`, `automation_triggered`) +- `on_component_loaded` — HA component load events +- Hassette internal events +- HA startup/shutdown events + +### H2: Error Handling +#### H3: App-Level Error Handler +`on_bus_error` override. +#### H3: Per-Registration Error Handler +`error_handler=` parameter on subscription methods. +#### H3: What `BusErrorContext` Contains +Fields and how to use them for debugging. + +### H2: Subscription Mechanics +#### H3: The `name=` Parameter (Required) +Why it's required, what it's used for (telemetry, logging, idempotent registration). +#### H3: Registration Is Complete When the Awaited Call Returns +`db_id` is immediately valid. No background registration task. +#### H3: Sequential Operations Are Deterministic +Registration order guarantees. + +## Snippet Inventory + +| Snippet | Status | Notes | +|---|---|---| +| `handlers_no_data.py` | Keep | Raw handler example | +| `handlers_extract_data.py` | Review | May overlap with DI page — reassign if so | +| `handlers_multiple_dependencies.py` | Review | Likely belongs on DI page now | +| `handlers_custom_args.py` | Review | May belong on DI page (custom kwargs) | +| `bus_error_handler_app.py` | Keep | App-level error handler | +| `bus_error_handler_per_reg.py` | Keep | Per-registration error handler | +| `bus_subscription_patterns.py` | Keep | Subscription mechanics | +| `bus_registration_identity.py` | Keep | name= parameter, identity | +| `bus_timeouts.py` | Keep | Timeout configuration | +| `first_automation_step3_raw.py` (from getting-started) | New claim | Raw handler example from getting-started, now lives here | + +**New snippets needed:** +- Non-state event handler examples (on_call_service, on("event_triggered"), internal events, HA lifecycle events) + +## Cross-Links + +- **Links to:** DI page (for typed annotations), Filtering (for predicates), States/Subscribing (for state-specific patterns) +- **Linked from:** Bus overview, Apps overview diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/bus/overview.md b/design/specs/070-doc-overhaul/outlines/core-concepts/bus/overview.md new file mode 100644 index 000000000..cb786ad5f --- /dev/null +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/bus/overview.md @@ -0,0 +1,20 @@ +# Bus — Overview + +**Status:** REWRITTEN in T03 (exemplar). 94 lines. Done. +**Voice mode:** Concept — system-as-subject, no "you" + +## Outline + +Already complete. Covers: subscription methods table, matching multiple entities (glob patterns), rate control (debounce, throttle, once as separate explain→show blocks). + +## Snippet Inventory + +All snippets written and tested in T03: +- `bus_basic_subscribe.py` — DI-first subscription example +- `bus_glob_patterns.py` — glob pattern matching +- `bus_rate_control.py` — three section markers (debounce, throttle, once) + +## Cross-Links + +- **Links to:** Handlers, DI, Filtering, States/Subscribing, Scheduler overview +- **Linked from:** Architecture, Apps overview, First Automation diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/cache/overview.md b/design/specs/070-doc-overhaul/outlines/core-concepts/cache/overview.md new file mode 100644 index 000000000..b5d2b28fc --- /dev/null +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/cache/overview.md @@ -0,0 +1,38 @@ +# Cache — Overview + +**Status:** Exists (104 lines), solid content, voice polish needed +**Voice mode:** Concept — system-as-subject, no "you" + +## Outline + +### H2: When to Use the Cache +Persistent key-value storage for data that survives restarts. Not for entity state (use StateManager) or temporary data. + +### H2: Basic Usage +`self.cache.get()`, `self.cache.set()`, `self.cache.delete()`. + +### H2: How It Works +#### H3: Storage Location — DiskCache on filesystem +#### H3: Shared Cache — all app instances share one cache, keyed by app +#### H3: Lazy Initialization — cache dir created on first access +#### H3: Automatic Cleanup — TTL expiry + +### H2: Configuration +Cache-related settings in hassette.toml. + +### H2: Lifecycle +When cache is available during app lifecycle. + +### H2: Data Types +What can be cached (JSON-serializable types). + +## Snippet Inventory + +| Snippet | Status | Notes | +|---|---|---| +| 9 files in `cache/snippets/` | Keep | Basic cache operations | + +## Cross-Links + +- **Links to:** Patterns & Examples, Configuration/Global (cache settings) +- **Linked from:** Architecture, Apps overview diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/cache/patterns.md b/design/specs/070-doc-overhaul/outlines/core-concepts/cache/patterns.md new file mode 100644 index 000000000..0eb06da17 --- /dev/null +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/cache/patterns.md @@ -0,0 +1,45 @@ +# Cache — Patterns & Examples + +**Status:** Exists (141 lines), solid content, voice polish needed +**Voice mode:** Concept — system-as-subject, no "you" + +## Outline + +### H2: Pattern: API Response Caching +Cache expensive HA API calls. + +### H2: Pattern: Rate-Limiting Notifications +Use cache timestamps to prevent notification spam. + +### H2: Pattern: Persistent Counters +Counters that survive restarts. + +### H2: Pattern: Storing Complex Data +Dataclasses/dicts in cache. + +### H2: Pattern: Expiring Cache Entries +TTL-based expiry. + +### H2: Pattern: Load Once, Write on Shutdown +Batch cache operations for performance. + +### H2: Best Practices +#### H3: What to Cache +#### H3: Cache vs StateManager +#### H3: Performance + +### H2: Troubleshooting +#### H3: Cache Not Persisting +#### H3: Cache Size Exceeded +#### H3: Debugging Cache Operations + +## Snippet Inventory + +| Snippet | Status | Notes | +|---|---|---| +| 9 files in `cache/snippets/` | Keep | Pattern examples (shared with overview — assign per-page) | + +## Cross-Links + +- **Links to:** Cache overview, States overview (cache vs StateManager) +- **Linked from:** Cache overview, Recipes diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/configuration/applications.md b/design/specs/070-doc-overhaul/outlines/core-concepts/configuration/applications.md new file mode 100644 index 000000000..0092f0e9e --- /dev/null +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/configuration/applications.md @@ -0,0 +1,32 @@ +# Configuration — Applications + +**Status:** Exists (68 lines), solid content, voice polish needed +**Voice mode:** Concept — system-as-subject, no "you" + +## Outline + +### H2: App Registration +How apps are registered in hassette.toml `[apps]` section. + +### H2: Single Instance +Default single-instance configuration. + +### H2: App Configuration Parameters +Table of per-app settings (enabled, log_level, etc.). + +### H2: Multiple Instances +Running the same app class multiple times with different configs. + +### H2: Typed Configuration +Link to Apps/Configuration for AppConfig details. + +## Snippet Inventory + +| Snippet | Status | Notes | +|---|---|---| +| Relevant TOML examples | Review | May be inline rather than snippet files | + +## Cross-Links + +- **Links to:** Apps/Configuration, Configuration overview +- **Linked from:** Configuration overview, Apps overview diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/configuration/auth.md b/design/specs/070-doc-overhaul/outlines/core-concepts/configuration/auth.md new file mode 100644 index 000000000..0e5d800a6 --- /dev/null +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/configuration/auth.md @@ -0,0 +1,24 @@ +# Configuration — Authentication + +**Status:** Exists (43 lines), concise, voice polish needed +**Voice mode:** Concept — system-as-subject, no "you" + +## Outline + +### H2: Home Assistant Token +Where to set the token (env var, .env file). Link to HA Token getting-started page. + +### H2: SSL Verification +`ssl_verify` setting for self-signed certs. + +### H2: File Locations +Where .env is loaded from. + +## Snippet Inventory + +No dedicated snippets — .env examples are in Getting Started. + +## Cross-Links + +- **Links to:** Configuration overview, HA Token (getting-started) +- **Linked from:** Configuration overview diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/configuration/global.md b/design/specs/070-doc-overhaul/outlines/core-concepts/configuration/global.md new file mode 100644 index 000000000..a776c7b63 --- /dev/null +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/configuration/global.md @@ -0,0 +1,62 @@ +# Configuration — Global Settings + +**Status:** Exists (313 lines), dense reference, voice polish needed +**Voice mode:** Reference — tabular, terse, system-as-subject + +## Outline + +Long reference page documenting every global setting in hassette.toml. Keep current structure — it's a lookup reference. + +### H2: Connection Settings +`host`, `port`, `ssl_verify`, `token` location. + +### H2: Runtime Settings +`auto_reload`, `app_dir`, `project_dir`. + +### H2: Storage Settings +`data_dir`, `cache_dir`. + +### H2: Web UI Settings +`web_enabled`, `web_host`, `web_port`, CORS, static files. + +### H2: Database Settings +`db_path`, `db_retention_days`. + +### H2: Timeout Settings +#### H3: WebSocket Resilience — reconnection, sliding window budget, backoff +#### H3: Timeouts — per-item overrides, disabling, limitations + +### H2: Scheduler Settings +Default scheduler configuration. + +### H2: Logging Settings +Log level, format. + +### H2: Bus Filtering Settings +Default bus filter behavior. + +### H2: Production Settings +Settings recommended for production. + +### H2: App Detection Settings +How Hassette finds apps. + +### H2: Advanced Settings +Rarely-changed settings. + +### H2: Service Restart Policy +Default RestartSpec configuration. + +### H2: Other Advanced Settings + +### H2: Basic Example +Complete hassette.toml example. + +## Snippet Inventory + +No code snippets — TOML examples are inline. + +## Cross-Links + +- **Links to:** Configuration overview, Operating/Log Levels (log settings detail) +- **Linked from:** Configuration overview, Docker Setup, Operating diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/configuration/overview.md b/design/specs/070-doc-overhaul/outlines/core-concepts/configuration/overview.md new file mode 100644 index 000000000..a2bcf23f8 --- /dev/null +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/configuration/overview.md @@ -0,0 +1,27 @@ +# Configuration — Overview + +**Status:** Exists (46 lines), brief intro, voice polish needed +**Voice mode:** Concept — system-as-subject, no "you" + +## Outline + +### H2: Configuration Sources +hassette.toml (primary), environment variables (overrides), .env files. + +### H2: File Locations +Where Hassette looks for config files. Search order. + +### H2: Configuration Sections +Brief list of what's configurable, linking to sub-pages. + +### H2: Credentials +Token and SSL configuration — links to Auth page. + +## Snippet Inventory + +No dedicated snippets — links to sub-pages for examples. + +## Cross-Links + +- **Links to:** Auth, Global Settings, Applications, Apps/Configuration +- **Linked from:** Architecture, Getting Started (Quickstart, Docker Setup) diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/database-telemetry.md b/design/specs/070-doc-overhaul/outlines/core-concepts/database-telemetry.md new file mode 100644 index 000000000..ec7c1b9c4 --- /dev/null +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/database-telemetry.md @@ -0,0 +1,36 @@ +# Database & Telemetry + +**Status:** Exists (127 lines), solid content, voice polish needed +**Voice mode:** Concept — system-as-subject, no "you" + +## Outline + +### H2: What Is Collected +Listener invocations, scheduler executions, registration events. Source tier explanation. + +### H2: Execution Columns +Table of fields in the unified executions table. + +### H2: Configuration +Telemetry settings in hassette.toml. Retention policy. +#### H3: How Retention Works + +### H2: Monitoring Telemetry Health +#### H3: `/api/telemetry/status` — endpoint for checking telemetry pipeline +#### H3: `/api/health` — general health endpoint + +### H2: Registration Persistence +How listener and job registrations are stored. + +### H2: Degraded Mode +What happens when the database fails. Graceful degradation. +#### H3: Recovery + +## Snippet Inventory + +No dedicated snippets — this page is prose + tables + endpoint examples. + +## Cross-Links + +- **Links to:** Web UI/Logs (viewing telemetry data), Configuration/Global (db settings), Operating (degraded mode) +- **Linked from:** Architecture, System Internals diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/internals.md b/design/specs/070-doc-overhaul/outlines/core-concepts/internals.md new file mode 100644 index 000000000..0e16d9723 --- /dev/null +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/internals.md @@ -0,0 +1,41 @@ +# System Internals + +**Status:** Exists (574 lines), splitting into 2-3 pages per user decision +**Voice mode:** Concept — system-as-subject, no "you", contributor/deep-dive audience + +## Proposed Split + +The current single page covers 10 numbered sections. Split into: + +### Page 1: `internals/index.md` — Architecture & Data Flow +Sections 1-3 from current page: +- Component Ownership (which service owns which state) +- Service Dependencies (depends_on, initialization order) +- Event and Data Flow (how events travel through the system) + +### Page 2: `internals/service-details.md` — Per-Service Internals +Sections 4-9 from current page: +- Bus Internals (dispatch, matching, handler invocation) +- Scheduler Internals (trigger evaluation, job execution) +- Database Internals (schema, migrations, unified executions table, sync registration) +- Api Internals (REST/WS interface, connection management) +- StateManager and StateProxy (proxy pattern, domain routing) +- Web/UI Layer (endpoint registration, SSE, static serving) + +### Page 3: `internals/lifecycle.md` — Resource Lifecycle & Supervision +Section 10 from current page: +- State Transitions (resource state machine) +- Readiness vs Running +- Wave Startup and Shutdown +- Service Supervision (RestartSpec, RestartType, sliding-window budget, error routing, new statuses) + +**Nav update needed:** Change `System Internals: pages/core-concepts/internals.md` to a subsection with 3 entries. + +## Snippet Inventory + +No code snippets — diagrams and tables. Some Mermaid diagrams may exist inline. + +## Cross-Links + +- **Links to:** Architecture, each core concept's overview page +- **Linked from:** Architecture (deep dive) diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/scheduler/management.md b/design/specs/070-doc-overhaul/outlines/core-concepts/scheduler/management.md new file mode 100644 index 000000000..58bcf0f36 --- /dev/null +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/scheduler/management.md @@ -0,0 +1,41 @@ +# Scheduler — Job Management + +**Status:** Exists (155 lines), solid content, voice polish needed +**Voice mode:** Concept — system-as-subject, no "you" + +## Outline + +### H2: The ScheduledJob Object +What `schedule()` returns. Fields: job_id, name, group, next_run, cancelled. + +### H2: Cancelling Jobs +`job.cancel()`, `cancel_group()`, `list_jobs()`, checking cancellation state. + +### H2: Automatic Cleanup +Jobs cancelled automatically on app shutdown. + +### H2: Best Practices +Named jobs, groups for related jobs, cancellation patterns. + +### H2: Self-Cancelling Job Pattern +Job that cancels itself based on a condition. + +### H2: Troubleshooting +#### H3: Job Not Running? +#### H3: Runs Too Often? + +### H2: Error Handling +#### H3: App-Level Error Handler — `on_scheduler_error` +#### H3: Per-Registration Error Handler — `error_handler=` +#### H3: What `SchedulerErrorContext` Contains + +## Snippet Inventory + +| Snippet | Status | Notes | +|---|---|---| +| ~7 files from `scheduler/snippets/` | Review | Management-specific examples | + +## Cross-Links + +- **Links to:** Scheduling Methods, Apps lifecycle (shutdown cleanup) +- **Linked from:** Scheduler overview, Recipes (motion lights — job cancellation) diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/scheduler/methods.md b/design/specs/070-doc-overhaul/outlines/core-concepts/scheduler/methods.md new file mode 100644 index 000000000..aa7afa8a5 --- /dev/null +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/scheduler/methods.md @@ -0,0 +1,48 @@ +# Scheduler — Scheduling Methods + +**Status:** Exists (280 lines), comprehensive reference, voice polish needed +**Voice mode:** Reference — terse, system-as-subject, code-heavy + +## Outline + +### H2: Primary Entry Point — `schedule` +The generic `schedule(func, trigger)` method. All convenience methods are shortcuts for this. + +### H2: Convenience Methods +#### H3: `run_in` — run after a delay +#### H3: `run_once` — run at a specific time +#### H3: `run_every` — run at a fixed interval + +### H2: Convenience Interval Helpers +#### H3: `run_minutely` +#### H3: `run_hourly` +#### H3: `run_daily` + +### H2: Cron Scheduling — `run_cron` +Cron expression syntax, examples. + +### H2: Job Groups +`group=` parameter, `cancel_group()`, `list_jobs(group=)`. + +### H2: Jitter +`jitter=` parameter for randomizing execution times. + +### H2: Idempotent Registration +`name=` parameter for preventing duplicate jobs. + +### H2: Passing Arguments to Handlers +`args=` and `kwargs=` parameters. + +### H2: Custom Triggers +Implementing `TriggerProtocol` for custom scheduling logic. + +## Snippet Inventory + +| Snippet | Status | Notes | +|---|---|---| +| ~15 files from `scheduler/snippets/` | Review | Method-specific examples | + +## Cross-Links + +- **Links to:** Job Management, Scheduler overview, Custom Triggers (TriggerProtocol) +- **Linked from:** Scheduler overview, Recipes diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/scheduler/overview.md b/design/specs/070-doc-overhaul/outlines/core-concepts/scheduler/overview.md new file mode 100644 index 000000000..cf7f72864 --- /dev/null +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/scheduler/overview.md @@ -0,0 +1,29 @@ +# Scheduler — Overview + +**Status:** Exists (46 lines), brief intro, voice polish needed +**Voice mode:** Concept — system-as-subject, no "you" + +## Outline + +### H2: (Opening) +What the scheduler does: runs functions at specific times or intervals via trigger objects. Available as `self.scheduler` on every app. + +### H2: Trigger Types +Table of built-in triggers (After, Once, Every, Daily, Cron) with one-line descriptions. + +### H2: Examples +Minimal examples for the most common patterns (run_in, run_every, run_daily). + +### H2: Next Steps +→ Scheduling Methods (full reference), → Job Management (cancellation, groups, errors) + +## Snippet Inventory + +| Snippet | Status | Notes | +|---|---|---| +| Relevant files from `scheduler/snippets/` (22 total) | Review | Assign per-page | + +## Cross-Links + +- **Links to:** Scheduling Methods, Job Management, Apps overview +- **Linked from:** Architecture, First Automation, Recipes diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/states/custom-states.md b/design/specs/070-doc-overhaul/outlines/core-concepts/states/custom-states.md new file mode 100644 index 000000000..bed3a908a --- /dev/null +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/states/custom-states.md @@ -0,0 +1,63 @@ +# States — Custom States + +**Status:** Stub (3 lines), content moving from Advanced (159 lines) +**Voice mode:** Concept — system-as-subject, no "you" + +## Outline + +Content source: `docs/pages/advanced/custom-states.md` + +### H2: Basic Custom State Class +Defining a state class for a domain Hassette doesn't cover (custom integrations, etc.). + +### H2: Choosing a Base Class +#### H3: StringBaseState +#### H3: NumericBaseState +#### H3: BoolBaseState +#### H3: DateTimeBaseState +#### H3: TimeBaseState +#### H3: Define Your Own + +### H2: Adding Custom Attributes +Typed attributes beyond the base `value` field. + +### H2: Using Custom States in Apps +#### H3: Via `get_states()` +#### H3: With Dependency Injection +#### H3: Direct API Access + +### H2: Runtime vs Type-Time Access +How state classes interact with the registry at runtime. + +### H2: Complete Example +Full custom state class with attributes, registration, and usage in an app. + +### H2: Troubleshooting +#### H3: State Class Not Registering +#### H3: Type Hints Not Working +#### H3: State Conversion Fails + +## Snippet Inventory + +Moving from `advanced/snippets/custom-states/`: +| Snippet | Status | Notes | +|---|---|---| +| `basic_custom_state.py` | Move | → `states/snippets/` | +| `string_base_state.py` | Move | | +| `numeric_base_state.py` | Move | | +| `bool_base_state.py` | Move | | +| `datetime_base_state.py` | Move | | +| `time_base_state.py` | Move | | +| `define_your_own.py` | Move | | +| `adding_custom_attributes.py` | Move | | +| `via_get_states.py` | Move | | +| `known_domain_access.py` | Move | → DI usage example | +| `custom_domain_typed_access.py` | Move | | +| `custom_domain_runtime_access.py` | Move | | +| `direct_api_access.py` | Move | | +| `complete_example.py` | Move | | + +## Cross-Links + +- **Links to:** State Registry, Type Registry, DI page, DomainStates Reference +- **Linked from:** States overview, DomainStates Reference ("for domains not covered") diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/states/domain-states.md b/design/specs/070-doc-overhaul/outlines/core-concepts/states/domain-states.md new file mode 100644 index 000000000..c46132262 --- /dev/null +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/states/domain-states.md @@ -0,0 +1,37 @@ +# States — DomainStates Reference + +**Status:** Stub (3 lines), new content needed +**Voice mode:** Reference — tabular, terse, system-as-subject + +## Outline + +### H2: (Opening) +Brief explanation of auto-generated domain state classes. Each HA entity domain has a corresponding Python class with typed attributes. + +### H2: Reference Table +Large reference table of all domain state classes. For each: +- Domain name (e.g., `light`) +- State class (e.g., `LightState`) +- `value` type (bool, str, float, etc.) +- Key attributes (e.g., `brightness`, `color_temp`, `rgb_color`) + +### H2: Accessing Domain States +How to use these classes: `self.states.light.get("light.kitchen")` returns a `LightState`. Show the pattern once. + +### H2: Attribute Access +Common attribute patterns across domains. Attributes are Python-typed (not raw HA dicts). + +### H2: Generated vs Custom +These classes are auto-generated from HA core source. For domains not covered or for custom attributes, use Custom States. + +## Snippet Inventory + +| Snippet | Status | Notes | +|---|---|---| +| New: domain state access example | New | Accessing typed attributes from a LightState | +| New: sensor state example | New | SensorState with numeric value and unit | + +## Cross-Links + +- **Links to:** Custom States, State Registry, States overview +- **Linked from:** States overview, DI page (annotation types reference T) diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/states/overview.md b/design/specs/070-doc-overhaul/outlines/core-concepts/states/overview.md new file mode 100644 index 000000000..d81068cee --- /dev/null +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/states/overview.md @@ -0,0 +1,36 @@ +# States — Overview + +**Status:** Exists (179 lines), solid content, voice polish needed +**Voice mode:** Concept — system-as-subject, no "you" + +## Outline + +### H2: Diagram +Mermaid diagram showing StateManager → StateProxy → DomainStates flow. + +### H2: Using the StateManager +#### H3: Domain Access — `self.states.light`, `self.states.sensor` +#### H3: Direct Entity Access — `self.states.get("light.kitchen")` +#### H3: Generic Access — `self.states[CustomState]` +#### H3: Iteration + +### H2: DomainStates Collection Interface +Methods available on domain collections (filter, all, etc.). + +### H2: Built-in State Types +Table of all auto-generated domain state classes (SensorState, LightState, etc.) with key attributes. + +### H2: Good to Know +Edge cases, caching behavior, state freshness. + +## Snippet Inventory + +| Snippet | Status | Notes | +|---|---|---| +| 4 files in `states/snippets/` | Keep | Basic state access examples | +| Additional snippets from `core-concepts/snippets/` | Review | 3 files — check if states-related | + +## Cross-Links + +- **Links to:** Subscribing, DomainStates Reference, Custom States, State Registry, Type Registry +- **Linked from:** Architecture, Apps overview, API/Entities diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/states/state-registry.md b/design/specs/070-doc-overhaul/outlines/core-concepts/states/state-registry.md new file mode 100644 index 000000000..1b7ca42d1 --- /dev/null +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/states/state-registry.md @@ -0,0 +1,55 @@ +# States — State Registry + +**Status:** Stub (3 lines), content moving from Advanced (216 lines) +**Voice mode:** Concept/reference hybrid — system-as-subject + +## Outline + +Content source: `docs/pages/advanced/state-registry.md` + +### H2: What Is the State Registry? +Maps HA entity domains to Python state classes. Automatic registration via `__init_subclass__`. + +### H2: How It Works +#### H3: Automatic Registration +State classes register themselves when defined. +#### H3: Domain Lookup +`StateRegistry.get(domain)` → state class. + +### H2: Relationship with Type Registry +#### H3: The Complete Flow +Raw HA state string → State Registry (domain → class) → Type Registry (value → typed value). +#### H3: The `value_type` ClassVar +How state classes declare their value type. +#### H3: Why Two Registries? +State Registry = domain mapping, Type Registry = value conversion. Separate concerns. + +### H2: State Conversion +#### H3: Direct Conversion +#### H3: Via Dependency Injection + +### H2: Domain Override +Overriding the default state class for a domain. + +### H2: Union Type Support +How the registry handles `D.StateNew[SensorState | BinarySensorState]`. + +### H2: Error Handling +#### H3: InvalidDataForStateConversionError +#### H3: InvalidEntityIdError +#### H3: UnableToConvertStateError + +### H2: Advanced Usage — Accessing the Registry +Direct registry access for introspection. + +## Snippet Inventory + +Moving from `advanced/snippets/state-registry/` (18 files): +| Snippet | Status | Notes | +|---|---|---| +| All 18 files | Move | → `states/snippets/` (review for voice, trim redundant examples) | + +## Cross-Links + +- **Links to:** Type Registry, Custom States, DI page, States overview +- **Linked from:** States overview, DI page, Custom States diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/states/subscribing.md b/design/specs/070-doc-overhaul/outlines/core-concepts/states/subscribing.md new file mode 100644 index 000000000..515f4e6b5 --- /dev/null +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/states/subscribing.md @@ -0,0 +1,51 @@ +# States — Subscribing to State Changes + +**Status:** Stub (3 lines), new content needed +**Voice mode:** Concept — system-as-subject, no "you" + +## Outline + +Bridge page between Bus and States. Covers state-change-specific subscription patterns. Most predicates and conditions are designed for state changes — this is where they're shown in context. + +### H2: Subscribing to State Changes +`on_state_change` and `on_attribute_change` — the two primary state subscription methods. Entity ID patterns (exact, glob, domain wildcard). + +### H2: State-Specific DI Annotations +`D.StateNew[T]`, `D.StateOld[T]`, `D.MaybeStateNew[T]`, `D.MaybeStateOld[T]` — shown in state-change context (links to DI page for full reference). + +### H2: The `changed` Parameter +`changed=True` (default) vs `changed=False`. When to use each. + +### H2: Matching State Values +#### H3: `changed_to` and `changed_from` — simple value matching +#### H3: Predicates for State Changes +`P.StateFrom`, `P.StateTo`, `P.StateFromTo` — tracking transitions. +#### H3: Numeric Conditions +`C.Increased`, `C.Decreased`, `C.InRange` — monitoring numeric changes. + +### H2: Combining Predicates +`&` (and) and `|` (or) composition. Examples specific to state-change scenarios. + +### H2: Attribute Changes +`on_attribute_change` — monitoring specific attributes rather than the state string. + +### H2: See Also +→ Bus overview (general subscription), → Bus Filtering (service call filtering, complete predicate/condition reference), → DI page (full annotation reference) + +## Snippet Inventory + +Snippets moving from Bus/Filtering and new: +| Snippet | Status | Notes | +|---|---|---| +| `filtering_simple_start.py` | Move from filtering/ | `changed_to` example | +| `filtering_simple_stop.py` | Move from filtering/ | `changed_to` example | +| `filtering_state_from_to.py` | Move from filtering/ | State transition tracking | +| `filtering_increased_decreased.py` | Move from filtering/ | Numeric conditions | +| `changed_false.py` | Move from filtering/ | `changed=False` example | +| New: attribute change example | New | `on_attribute_change` with predicate | +| New: combined predicates for state | New | `&`/`|` composition in state context | + +## Cross-Links + +- **Links to:** Bus overview, Bus Filtering (complete reference), DI page, States overview +- **Linked from:** Bus overview, States overview, Recipes diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/states/type-registry.md b/design/specs/070-doc-overhaul/outlines/core-concepts/states/type-registry.md new file mode 100644 index 000000000..8deb9aa3f --- /dev/null +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/states/type-registry.md @@ -0,0 +1,68 @@ +# States — Type Registry + +**Status:** Stub (3 lines), content moving from Advanced (329 lines) +**Voice mode:** Concept/reference hybrid — system-as-subject + +## Outline + +Content source: `docs/pages/advanced/type-registry.md` + +### H2: Purpose +Converts raw HA values (strings) to typed Python values. The second step after State Registry picks the class. + +### H2: Core Concepts +#### H3: Registration System — Decorator and Simple Registration +#### H3: Conversion Lookup + +### H2: Integration with State Models +#### H3: The `value_type` ClassVar +#### H3: Automatic Conversion in Models +#### H3: Union Type Handling + +### H2: Integration with Dependency Injection +How type conversion works when DI extracts state data. + +### H2: Relationship with State Registry +The workflow: raw string → State Registry → Type Registry → typed value. + +### H2: Built-in Converters +#### H3: Numeric Conversions +#### H3: Boolean Conversions +#### H3: DateTime Conversions +#### H3: Conversion Errors +#### H3: Missing Converters +#### H3: Custom Error Messages + +### H2: Inspection and Debugging +Tools for inspecting the registry. + +### H2: Best Practices +Key rules for custom converters. + +### H2: Common Patterns +#### H3: Enum Conversion +#### H3: Structured Data +#### H3: Units of Measurement + +### H2: Automatic Type Conversion +Content originally considered for Custom Extractors page — lives here instead. How extractors use the type registry for automatic conversion. + +## Snippet Inventory + +Moving from `advanced/snippets/type-registry/` (24 files): +| Snippet | Status | Notes | +|---|---|---| +| All 24 files | Move | → `states/snippets/` (review for voice, trim if 329 lines of content is over-documented) | + +Also moving from `dependency-injection/`: +| Snippet | Status | Notes | +|---|---|---| +| `builtin_conversions_explicit.py` | Move here | Type conversion examples | +| `builtin_conversions_implicit.py` | Move here | | +| `bypass_conversion_any.py` | Move here | | +| `bypass_conversion_custom.py` | Move here | | + +## Cross-Links + +- **Links to:** State Registry, Custom States, DI page, Custom Extractors +- **Linked from:** States overview, State Registry, DI page diff --git a/design/specs/070-doc-overhaul/outlines/getting-started/docker-dependencies.md b/design/specs/070-doc-overhaul/outlines/getting-started/docker-dependencies.md new file mode 100644 index 000000000..8b1a93ea2 --- /dev/null +++ b/design/specs/070-doc-overhaul/outlines/getting-started/docker-dependencies.md @@ -0,0 +1,70 @@ +# Docker — Managing Dependencies + +**Status:** Exists (193 lines), structure solid, voice polish needed +**Voice mode:** Getting-started — "you" allowed, procedural + +## Outline + +### H2: Overview +How Hassette's Docker entrypoint handles dependency installation at startup. Brief mental model. + +#### H3: How Constraints Work +Hassette pins its own deps via constraints file to prevent conflicts. + +### H2: How the Startup Script Works +What happens at container start: detect project type, install deps, launch. + +#### H3: Key Behaviors +Bulleted list of the script's decisions. + +### H2: Understanding APP_DIR vs PROJECT_DIR +When to use which env var. Table or short comparison. + +### H2: Project Structures +#### H3: Simple Flat Structure +Directory layout, compose snippet. +#### H3: Traditional src/ Layout +Directory layout, compose snippet. + +### H2: Using pyproject.toml +#### H3: With a Lock File (Required) +How uv.lock works in Docker context. + +### H2: Using requirements.txt +Simpler alternative, when to use it. + +### H2: Startup Performance +#### H3: Using uv.lock for Faster Starts +#### H3: Pre-building a Custom Image +Dockerfile example for baking deps into the image. +#### H3: Known Limitations — Local Path Dependencies + +### H2: Complete Examples +Two full examples with compose + project structure. + +## Snippet Inventory + +All existing snippets (20+) are keeps — they demonstrate specific compose configurations and project structures. Full list: + +| Snippet | Status | +|---|---| +| `deps-example1-compose.yml` | Keep | +| `deps-example1-requirements.txt` | Keep | +| `deps-example2-compose.yml` | Keep | +| `deps-example2-pyproject.toml` | Keep | +| `deps-flat-compose.yml` | Keep | +| `deps-flat-dir-structure.txt` | Keep | +| `deps-install-deps-env.yml` | Keep | +| `deps-requirements-dir-structure.txt` | Keep | +| `deps-src-compose.yml` | Keep | +| `deps-src-dir-structure.txt` | Keep | +| `deps-startup-flow.mmd` | Keep | +| `custom-image-compose.yml` | Keep | +| `custom-image.dockerfile` | Keep | +| `pyproject-example.toml` | Keep | +| `requirements-example.txt` | Keep | + +## Cross-Links + +- **Links to:** Docker Setup, Image Tags +- **Linked from:** Docker Setup (next steps), Docker Troubleshooting diff --git a/design/specs/070-doc-overhaul/outlines/getting-started/docker-image-tags.md b/design/specs/070-doc-overhaul/outlines/getting-started/docker-image-tags.md new file mode 100644 index 000000000..d79d33199 --- /dev/null +++ b/design/specs/070-doc-overhaul/outlines/getting-started/docker-image-tags.md @@ -0,0 +1,51 @@ +# Docker — Image Tags + +**Status:** Exists (151 lines), reference-style, voice polish needed +**Voice mode:** Getting-started with reference feel — tables for scanning, "you" for recommendations + +## Outline + +### H2: Tag Format +#### H3: Recommended — Pin Both Version and Python +Primary recommendation with example. +#### H3: Track Latest Stable Release +When acceptable, risks. +#### H3: Testing Open Pull Requests +PR preview tags, when useful. +#### H3: Bleeding-Edge Main Branch +`main-py3.XX` tags, stability caveats. + +### H2: Tags NOT Published +What doesn't exist and why (no `latest` without Python version, no alpine, no slim). + +### H2: Supported Python Versions +Table of currently supported versions. + +### H2: Choosing a Tag +#### H3: For Production +#### H3: For Development +#### H3: For Testing Pre-release Features + +### H2: Updating Images +#### H3: Pull Latest +#### H3: Check Current Version + +## Snippet Inventory + +| Snippet | Status | Notes | +|---|---|---| +| `tag-format-latest.txt` | Keep | Tag examples | +| `tag-format-main.txt` | Keep | | +| `tag-format-pr.txt` | Keep | | +| `tag-format-versioned.txt` | Keep | | +| `tag-latest-compose.yml` | Keep | Compose examples | +| `tag-pinned-compose.yml` | Keep | | +| `tag-prerelease-compose.yml` | Keep | | +| `tag-prerelease-explicit.txt` | Keep | | +| `docker-pull-update.sh` | Keep | Update command | +| `docker-version-check.sh` | Keep | Version check | + +## Cross-Links + +- **Links to:** Docker Setup, Dependencies +- **Linked from:** Docker Setup (next steps) diff --git a/design/specs/070-doc-overhaul/outlines/getting-started/docker-setup.md b/design/specs/070-doc-overhaul/outlines/getting-started/docker-setup.md new file mode 100644 index 000000000..9f8ac6e2d --- /dev/null +++ b/design/specs/070-doc-overhaul/outlines/getting-started/docker-setup.md @@ -0,0 +1,58 @@ +# Docker Setup + +**Status:** Exists (173 lines), structure solid, voice polish needed +**Voice mode:** Getting-started — "you" allowed, step-by-step + +## Outline + +### H2: Prerequisites +Docker, Docker Compose, running HA instance. Brief. + +### H2: Quick Start +Numbered steps: create directory, docker-compose.yml, .env, hassette.toml, first app, `docker compose up`. Each step produces visible progress. + +### H2: Directory Structure +Show the resulting project layout after Quick Start. + +### H2: Configuration +#### H3: Home Assistant Token +Link to ha_token.md, show where it goes in .env. +#### H3: Environment Variables Reference +Table of HASSETTE__* env vars. + +### H2: Production Deployment +#### H3: Hot Reloading in Production +hassette.toml `auto_reload` setting, volume mount requirements. +#### H3: Graceful Shutdown +Docker stop signal handling. + +### H2: Viewing Logs +#### H3: Docker Compose Logs +`docker compose logs -f hassette` command. +#### H3: Web UI +Link to Web UI overview. + +### H2: Next Steps +→ Dependencies, → Image Tags, → First Automation + +## Snippet Inventory + +| Snippet | Status | Notes | +|---|---|---| +| `docker-compose.yml` | Keep | Main compose file | +| `env-file.sh` | Keep | .env creation | +| `hassette.toml` | Keep | Minimal config | +| `my_app.py` | Keep | Example app | +| `dir-structure.txt` | Keep | Layout diagram | +| `docker-compose-up.sh` | Keep | Start command | +| `docker-compose-logs.sh` | Keep | Log viewing | +| `docker-compose-logs-hassette.sh` | Keep | Filtered logs | +| `prod-reload.toml` | Keep | Hot reload config | +| `mkdir-project.sh` | Keep | Directory creation | +| `uv-cache-volume.yml` | Keep | Cache volume mount | +| `uv-lock.sh` | Keep | Lock file generation | + +## Cross-Links + +- **Links to:** HA Token, Dependencies, Image Tags, Docker Troubleshooting, Web UI, First Automation +- **Linked from:** Quickstart (alternative path), Evaluator diff --git a/design/specs/070-doc-overhaul/outlines/getting-started/docker-troubleshooting.md b/design/specs/070-doc-overhaul/outlines/getting-started/docker-troubleshooting.md new file mode 100644 index 000000000..b09094a3a --- /dev/null +++ b/design/specs/070-doc-overhaul/outlines/getting-started/docker-troubleshooting.md @@ -0,0 +1,82 @@ +# Docker — Troubleshooting + +**Status:** Exists (350 lines), symptom-lookup format, voice polish needed +**Voice mode:** Getting-started — "you" allowed, problem/solution format + +## Outline + +Pure symptom-lookup for Docker-specific issues. Each H2 is a symptom category, each H3 is a specific problem with cause and fix. + +### H2: Container Won't Start +#### H3: Check the Logs +#### H3: Token Not Set +#### H3: Can't Reach Home Assistant +#### H3: Permission Errors + +### H2: Apps Not Loading +#### H3: Check App Discovery +#### H3: Verify App Directory Configuration +#### H3: Check for Python Errors +#### H3: Verify App Configuration + +### H2: Dependency Installation Fails +#### H3: Check Installation Output +#### H3: Dependency Conflicts +#### H3: pyproject.toml Not Found +#### H3: Project Has pyproject.toml But Dependencies Don't Install +#### H3: requirements.txt Not Found +#### H3: Version Conflicts +#### H3: Import Errors at Runtime + +### H2: Health Check Failing +Symptoms, solutions. + +### H2: Hot Reload Not Working +Requirements, configuration, volume mount verification. + +### H2: Import Errors +#### H3: Package Not Found +#### H3: Hassette Module Not Found + +### H2: Performance Issues +#### H3: Slow Container Startup +#### H3: High Memory Usage + +### H2: Getting Help + +## Snippet Inventory + +All existing `ts-*` snippets (25+) are keeps — they show diagnostic commands and config fixes. These are Docker-specific troubleshooting commands. + +| Snippet | Status | +|---|---| +| `ts-app-config.toml` | Keep | +| `ts-app-dir-src-env.yml` | Keep | +| `ts-app-dir-toml.toml` | Keep | +| `ts-cat-pyproject.sh` | Keep | +| `ts-check-constraints.sh` | Keep | +| `ts-check-logs.sh` | Keep | +| `ts-check-logs-tail.sh` | Keep | +| `ts-chmod.sh` | Keep | +| `ts-curl-ha.sh` | Keep | +| `ts-dep-conflict.txt` | Keep | +| `ts-dep-install-logs.sh` | Keep | +| `ts-diagnostics.sh` | Keep | +| `ts-find-requirements.sh` | Keep | +| `ts-grep-errors.sh` | Keep | +| `ts-health-check-long-start.yml` | Keep | +| `ts-health-check.sh` | Keep | +| `ts-hot-reload.toml` | Keep | +| `ts-ls-apps.sh` | Keep | +| `ts-memory-limit.yml` | Keep | +| `ts-pin-hassette-pyproject.toml` | Keep | +| `ts-project-dir-env.yml` | Keep | +| `ts-pyproject-dep.toml` | Keep | +| `ts-uv-cache-vol.yml` | Keep | +| `ts-uv-relock.sh` | Keep | +| `ts-vol-mount.yml` | Keep | + +## Cross-Links + +- **Links to:** Docker Setup, Dependencies, Image Tags +- **Linked from:** Docker Setup, Dependencies (troubleshooting links) diff --git a/design/specs/070-doc-overhaul/outlines/getting-started/evaluator.md b/design/specs/070-doc-overhaul/outlines/getting-started/evaluator.md new file mode 100644 index 000000000..45a898721 --- /dev/null +++ b/design/specs/070-doc-overhaul/outlines/getting-started/evaluator.md @@ -0,0 +1,27 @@ +# Evaluator — Is Hassette Right for You? + +**Status:** New page (stub exists) +**Voice mode:** Getting-started — "you" allowed, friendly, direct + +## Outline + +### H2: When Hassette Makes Sense +1–2 sentence descriptions of the user profiles that benefit: Python-comfortable, complex automations (sequences, timing, conditions that compose), wants testability, needs to share/version-control automations. + +### H2: When HA YAML Is Enough +Honest acknowledgment: simple trigger→action automations, no coding preference, UI-built automations suffice. Not a dismissal — links to HA docs for those users. + +### H2: What Hassette Requires +Practical prerequisites: Python 3.11+, a machine to run the process (same box or Docker), a long-lived access token, comfort with async/await basics. Brief — not a setup guide. + +### H2: Next Steps +Two paths: "Ready to try it?" → Quickstart. "Want more detail on the tradeoffs?" → Hassette vs HA YAML. + +## Snippet Inventory + +None — prose-only decision page. + +## Cross-Links + +- **Links to:** Quickstart (`index.md`), Hassette vs HA YAML (`hassette-vs-ha-yaml.md`), Docker Setup (`docker/index.md`) +- **Linked from:** Home (`index.md`), Hassette vs HA YAML (back-link) diff --git a/design/specs/070-doc-overhaul/outlines/getting-started/first-automation.md b/design/specs/070-doc-overhaul/outlines/getting-started/first-automation.md new file mode 100644 index 000000000..3bf20daa7 --- /dev/null +++ b/design/specs/070-doc-overhaul/outlines/getting-started/first-automation.md @@ -0,0 +1,48 @@ +# First Automation + +**Status:** Exists (115 lines), needs restructuring for DI-first +**Voice mode:** Getting-started — "you" allowed, code-first, each step produces visible progress + +## Outline + +### H2: What You'll Build / What You'll Learn +Bulleted list: typed configuration, subscribing to state changes with DI, scheduling a recurring job. Sets expectations before the reader invests time. + +### H2: Step 1 — Understand the App Class +App[Config] generic, lifecycle hooks. Minimal — enough to read the next steps. + +### H2: Step 2 — Add Typed Configuration +AppConfig subclass, SettingsConfigDict, hassette.toml mapping. + +### H2: Step 3 — Subscribe to a State Change +**DI-first:** Show `D.StateNew[states.SunState]` as the primary and only pattern. Explain `D` and `states` imports. No raw event handler mention — that lives on the Bus handlers page. + +Voice-guide rule #17: show code first, then explain. Rule #8: functional definitions for `D` and `states` on first use. + +### H2: Step 4 — Schedule a Recurring Job +`self.scheduler.run_every()` with a simple heartbeat. Show the handler receiving no DI params (just `self`). + +### H2: Step 5 — Run It +Full app file, `hassette run`, expected output. + +### H2: What You Just Built +Recap: config, bus subscription with DI, scheduler. One paragraph. + +### H2: Next Steps +→ Bus overview, → Recipes, → Docker (production) + +## Snippet Inventory + +| Snippet | Status | Notes | +|---|---|---| +| `first_automation_step1.py` | Keep | App class basics | +| `first_automation_step2.py` | Keep | Config class | +| `first_automation_step3.py` | Rewrite | DI-first: use `D.StateNew[states.SunState]` as primary pattern | +| `first_automation_step3_raw.py` | Unclaimed | No longer used here — candidate for Bus handlers page | +| `first_automation_step4.py` | Keep | Scheduler example | +| `typed_handler.py` | Unclaimed | Redundant if DI is the default from step 3 | + +## Cross-Links + +- **Links to:** Bus overview, DI page, Scheduler overview, Recipes +- **Linked from:** Quickstart (next steps), Evaluator diff --git a/design/specs/070-doc-overhaul/outlines/getting-started/ha-token.md b/design/specs/070-doc-overhaul/outlines/getting-started/ha-token.md new file mode 100644 index 000000000..3efb9a194 --- /dev/null +++ b/design/specs/070-doc-overhaul/outlines/getting-started/ha-token.md @@ -0,0 +1,24 @@ +# Home Assistant Token + +**Status:** Exists (36 lines), minimal changes needed +**Voice mode:** Getting-started — "you" allowed, step-by-step + +## Outline + +### H2: Steps +Numbered sub-steps with screenshots (existing). Walk through HA Profile → Security → Long-Lived Access Tokens → Create → Copy. + +### H2: What to Do with the Token +Where to put it (`.env` file). Links back to Quickstart and Docker Setup for context. + +## Snippet Inventory + +| Snippet | Status | Notes | +|---|---|---| +| `env_token.sh` | Keep | token in .env | +| `env_file.sh` | Keep | full .env example | + +## Cross-Links + +- **Links to:** Quickstart, Docker Setup +- **Linked from:** Quickstart (step 3) diff --git a/design/specs/070-doc-overhaul/outlines/getting-started/hassette-vs-ha-yaml.md b/design/specs/070-doc-overhaul/outlines/getting-started/hassette-vs-ha-yaml.md new file mode 100644 index 000000000..ccf4b0053 --- /dev/null +++ b/design/specs/070-doc-overhaul/outlines/getting-started/hassette-vs-ha-yaml.md @@ -0,0 +1,30 @@ +# Hassette vs HA YAML + +**Status:** Exists (77 lines), voice polish needed +**Voice mode:** Getting-started — "you" allowed, comparison-driven + +## Outline + +### H2: Quick Comparison +Table comparing HA UI automations, HA YAML, and Hassette across dimensions (language, debugging, testing, version control, learning curve, complexity ceiling). + +### H2: HA UI / YAML Automations +What HA's built-in systems do well. Honest — not a strawman. + +### H2: Hassette +What Hassette adds: Python, testability, composition, IDE support. Concrete examples of what's easier. + +### H2: What Hassette Does Not Replace +Integrations, dashboards, add-ons. Hassette is automations only. + +### H2: Making the Call +Decision heuristic: if your automations are getting tangled or you want tests, try Hassette. If trigger→action covers your needs, stay with HA YAML. + +## Snippet Inventory + +None — prose and tables only. + +## Cross-Links + +- **Links to:** Evaluator (back-link), Quickstart +- **Linked from:** Evaluator ("Want more detail?"), possibly Home page diff --git a/design/specs/070-doc-overhaul/outlines/getting-started/quickstart.md b/design/specs/070-doc-overhaul/outlines/getting-started/quickstart.md new file mode 100644 index 000000000..2c1f071ef --- /dev/null +++ b/design/specs/070-doc-overhaul/outlines/getting-started/quickstart.md @@ -0,0 +1,42 @@ +# Quickstart + +**Status:** Exists (117 lines), structure solid, voice polish needed +**Voice mode:** Getting-started — "you" allowed, code-first, numbered steps + +## Outline + +### H2: Prerequisites +Python 3.11+, uv, running HA instance. Brief — links to Docker Setup for containerized alternative. + +### H2: Steps 1–7 (numbered) +Keep current numbered-step structure. Each step produces visible progress. +1. Create a project and install Hassette +2. Create a project layout +3. Create a Home Assistant token (links to ha_token.md) +4. Create `config/.env` +5. Create `config/hassette.toml` +6. Create your first app +7. Run Hassette + +Step 6 keeps a minimal app (no DI yet — that's first-automation's job). Step 7 shows expected output. + +### H2: Next Steps +→ First Automation (DI, bus, scheduler), → Docker Setup (production) + +## Snippet Inventory + +| Snippet | Status | Notes | +|---|---|---| +| `install.sh` | Keep | uv add hassette | +| `project_layout.sh` | Keep | directory structure | +| `env_file.sh` | Keep | .env creation | +| `hassette.toml` | Keep | minimal config | +| `first_app.py` | Rewrite | Check voice — may need DI note or just keep minimal | +| `run.sh` | Keep | hassette run | +| `run_output.txt` | Rewrite | Update if startup output has changed | +| `run_explicit.sh` | Keep | explicit module run | + +## Cross-Links + +- **Links to:** HA Token, First Automation, Docker Setup +- **Linked from:** Evaluator, Home page diff --git a/design/specs/070-doc-overhaul/outlines/home.md b/design/specs/070-doc-overhaul/outlines/home.md new file mode 100644 index 000000000..e7adc8c15 --- /dev/null +++ b/design/specs/070-doc-overhaul/outlines/home.md @@ -0,0 +1,38 @@ +# Home (index.md) + +**Status:** Exists (92 lines), solid landing page, voice polish needed +**Voice mode:** Marketing/getting-started hybrid — engaging, "you" allowed + +## Outline + +### H2: What is Hassette? +One-paragraph pitch. FastAPI analogy. "Who it's for" targeting. + +**Update needed:** "Is Hassette Right for You?" link currently points to `hassette-vs-ha-yaml.md` — should also link to the new `evaluator.md` page. + +### H2: Why Hassette? +Bulleted feature highlights: code, async, type-safe config, DI, test harness, web UI. + +### H2: See It in Action +Code screenshots/examples: autocomplete, event handling, web UI. + +### H2: What You Can Build +Brief examples of automation types. + +### H2: Quick Start +Three-step teaser linking to Quickstart. + +### H2: Already Using AppDaemon? +Link to Migration section. + +### H2: Next Steps +Links to Quickstart, Evaluator, Core Concepts, Recipes. + +## Snippet Inventory + +No dedicated snippet files — inline code examples and screenshots. + +## Cross-Links + +- **Links to:** Evaluator, Quickstart, Migration overview, Core Concepts/Architecture, Recipes +- **Linked from:** (entry point — linked from everywhere implicitly) diff --git a/design/specs/070-doc-overhaul/outlines/knowledge-inventory.md b/design/specs/070-doc-overhaul/outlines/knowledge-inventory.md new file mode 100644 index 000000000..07e11f953 --- /dev/null +++ b/design/specs/070-doc-overhaul/outlines/knowledge-inventory.md @@ -0,0 +1,170 @@ +# Knowledge Inventory + +Extracted from current docs pages before overwrite. Every item below must appear in the rewritten troubleshooting or operating pages. Diff against the final pages to verify nothing was lost. + +Source: `docs/pages/troubleshooting.md` (140 lines) and `docs/pages/advanced/log-level-tuning.md` (99 lines) + +--- + +## Operational Behavior (→ Operating Hassette) + +### KI-01: WebSocket Reconnection Sequence +**Source:** troubleshooting.md lines 31-61 + +**Timing values:** +- Initial connection retries: up to **5 times** with exponential backoff +- Initial backoff: starts at **1 second**, caps at **32 seconds** +- ServiceWatcher RestartSpec: **5 restarts** within **300-second sliding window** (TRANSIENT type) +- Restart delay: exponential starting at **2 seconds**, doubling each attempt, capped at **60 seconds** +- EXHAUSTED_COOLING duration: **300 seconds**, then budget resets + +**Bus events:** +- `hassette.event.websocket_disconnected` — fired on disconnect, apps can subscribe +- `hassette.event.websocket_connected` — fired on reconnect, budget resets + +**App behavior during reconnection:** +- Bus, scheduler, state manager remain active +- API calls (`call_service()`, `get_state()`) raise `ResourceNotReadyError` +- Handlers resume receiving events on reconnect — no re-registration needed + +**Log signatures:** +``` +WARNING hassette.WebsocketService -- Retrying _inner_connect in Xs as it raised CouldNotFindHomeAssistantError: ... +ERROR hassette.WebsocketService -- Serve() task failed: CouldNotFindHomeAssistantError ... +INFO hassette.ServiceWatcher -- Service 'WebsocketService' restarting (attempt N, waiting Xs) +DEBUG hassette.WebsocketService -- Connected to WebSocket at ws://... +INFO hassette.ServiceWatcher -- Service 'WebsocketService' in cooldown for 300.0s (cycle 1) +``` + +### KI-02: Event Handler Exception Behavior +**Source:** troubleshooting.md lines 62-83 + +**Behavior:** Exceptions caught by framework, logged at ERROR, swallowed. Do not propagate, crash app, or affect other handlers. + +**Telemetry:** Invocation recorded with `status='error'` and error type/message. + +**Log signature:** +``` +ERROR hassette.CommandExecutor -- Handler error (topic=hass.event.state_changed.light.kitchen, handler=Listener) +Traceback (most recent call last): + ... +AttributeError: 'NoneType' object has no attribute 'brightness' +``` + +**Note:** Matches scheduler behavior — exceptions fail silently (logged to error). + +### KI-03: Log Level Tuning +**Source:** advanced/log-level-tuning.md lines 1-99 + +**Mechanism:** Per-service log level via `hassette.toml` `[hassette.log_levels]` section. Field names match internal service names. + +**Available fields:** +- Listed in the current page (lines 28-50) — exact field names for each internal service + +**Fallback behavior:** Fields not set fall back to the global log level. + +**Per-app log levels:** Set in app config block, not in global log_levels. + +**Example configurations:** +- Debugging the scheduler: `scheduler = "DEBUG"` +- Quieting the file watcher: `file_watcher = "WARNING"` +- Debugging HA communication: `websocket = "DEBUG"`, `api = "DEBUG"` + +**Snippets moving:** 5 TOML files in `advanced/snippets/log-level-tuning/` + +--- + +## Symptom-Lookup (→ Troubleshooting) + +### KI-04: App Precheck Failure Signatures +**Source:** troubleshooting.md lines 14-20 + +**Log signatures:** +- Syntax error: `ERROR hassette.utils.app_utils — Failed to load app 'MyApp': SyntaxError: invalid syntax (at /apps/my_app.py:12)` +- Class not found: `AttributeError: Class MyApp not found in module apps.my_app` +- Invalid config: `ERROR ... Failed to load app 'MyApp' due to bad configuration` + +**Workaround:** `allow_startup_if_app_precheck_fails = true` in hassette.toml (temporary, for diagnosis) + +### KI-05: changed_to Type Mismatch +**Source:** troubleshooting.md line 25 + +`changed_to="on"` works; `changed_to=True` does not — HA state values are strings, not Python bools. + +### KI-06: Silently Excluded Domains +**Source:** troubleshooting.md line 26 + +`bus_excluded_domains` and `bus_excluded_entities` in hassette.toml silently drop events before reaching handlers. + +### KI-07: Attribute-Only Change Default +**Source:** troubleshooting.md line 28 + +`on_state_change` default `changed=True` only fires on state value change. Attribute-only changes require `changed=False`. + +### KI-08: Scheduler Past-Time Behavior +**Source:** troubleshooting.md lines 87-90 + +- `run_once(at="07:00")` called after 7 AM → deferred to tomorrow (WARNING log) +- `run_daily(at="07:00")` → next 7 AM occurrence (today if before, tomorrow if after) +- `run_every(seconds=5)` gotcha: 5 seconds, not minutes +- Cron pitfall: `"5 * * * *"` = "at minute 5 of every hour", not "every 5 minutes" — use `"*/5 * * * *"` + +### KI-09: Database Degraded Mode +**Source:** troubleshooting.md lines 92-97 + +- Stats strip shows zeroed metrics when DB unavailable +- Docker check: `docker compose exec hassette df -h /data` +- Database file: `/data/hassette.db` (default) +- Safe to delete — only loses telemetry history. Restart recreates DB. + +### KI-10: Cache Persistence +**Source:** troubleshooting.md lines 99-103 + +- Requires correct `data_dir` and writable path +- Docker: `/data` volume must be mounted +- All instances share one cache dir — use `instance_name` as key prefix + +### KI-11: Custom State Registration +**Source:** troubleshooting.md lines 106-109 + +- Requires `domain: Literal["your_domain"]` field +- Must call `super().__init_subclass__()` if overriding + +--- + +## Upgrading (→ Operating/Upgrading) + +### KI-12: Version Check and Upgrade Commands +**Source:** troubleshooting.md lines 111-128 + +- `hassette --version` (CLI) +- `uv pip show hassette` (project) +- `uv add hassette@latest` (upgrade) +- Changelog at `CHANGELOG.md`, breaking changes flagged + +### KI-13: Major Version Data Directory Change +**Source:** troubleshooting.md lines 128 + +- Bare-metal default `data_dir` includes major version: `~/.local/share/hassette/v0/` +- Future `v1/` would start fresh — set `data_dir`/`config_dir` explicitly to keep data +- Docker unaffected — `/data` and `/config` are version-independent + +--- + +## Disposition + +| Item | Destination | Notes | +|---|---|---| +| KI-01 | Operating/overview.md (Runtime Behavior) | Timing values, log signatures, bus events | +| KI-02 | Operating/overview.md (Runtime Behavior) | Exception handling behavior | +| KI-03 | Operating/log-levels.md | Moves from Advanced, content + 5 snippets | +| KI-04 | Troubleshooting | Symptom: apps not loading | +| KI-05 | Troubleshooting | Symptom: handler never runs | +| KI-06 | Troubleshooting | Symptom: handler never runs | +| KI-07 | Troubleshooting | Symptom: handler never runs | +| KI-08 | Troubleshooting | Symptom: scheduler not firing | +| KI-09 | Troubleshooting | Symptom: database degraded | +| KI-10 | Troubleshooting | Symptom: cache not persisting | +| KI-11 | Troubleshooting | Symptom: custom state not registering | +| KI-12 | Operating/upgrading.md | Version commands | +| KI-13 | Operating/upgrading.md | Data directory migration | diff --git a/design/specs/070-doc-overhaul/outlines/migration/api.md b/design/specs/070-doc-overhaul/outlines/migration/api.md new file mode 100644 index 000000000..4dfdac8e8 --- /dev/null +++ b/design/specs/070-doc-overhaul/outlines/migration/api.md @@ -0,0 +1,37 @@ +# Migration — API Calls + +**Status:** Exists (130 lines), comparison-driven, voice polish needed +**Voice mode:** Comparison — "you" allowed + +## Outline + +### H2: Overview +What changes: `self.get_state()` → `self.states.get()` or `self.api.get_state()`. + +### H2: Getting Entity State +#### H3: AppDaemon +#### H3: Hassette: State Cache (recommended) +#### H3: Hassette: Direct API Call + +### H2: Calling Services +AppDaemon `call_service` vs Hassette `api.call_service`. + +### H2: Setting States +AppDaemon `set_state` vs Hassette `api.set_state`. + +### H2: Logging +AppDaemon `self.log` vs Hassette `self.logger`. + +### H2: Full State Migration Example +Complete before/after. + +## Snippet Inventory + +| Snippet | Status | Notes | +|---|---|---| +| ~6 migration/api snippets | Keep | Comparison pairs | + +## Cross-Links + +- **Links to:** API overview, States overview, API/Entities +- **Linked from:** Migration overview diff --git a/design/specs/070-doc-overhaul/outlines/migration/bus.md b/design/specs/070-doc-overhaul/outlines/migration/bus.md new file mode 100644 index 000000000..011d78de2 --- /dev/null +++ b/design/specs/070-doc-overhaul/outlines/migration/bus.md @@ -0,0 +1,37 @@ +# Migration — Bus & Events + +**Status:** Exists (151 lines), comparison-driven, voice polish needed +**Voice mode:** Comparison — tabs for side-by-side, "you" allowed + +## Outline + +### H2: Overview +What changes: `listen_state` → `on_state_change`, `listen_event` → `on`. + +### H2: State Change Listeners +#### H3: AppDaemon — `listen_state` pattern +#### H3: Hassette: with DI (recommended) — `on_state_change` + `D.StateNew[T]` +#### H3: Hassette: with full event object +#### H3: Filter options — `changed_to`, `changed_from`, predicates + +### H2: Service Call Listeners +#### H3: AppDaemon — `listen_event("call_service")` +#### H3: Hassette: with DI (recommended) +#### H3: Hassette: with full event object + +### H2: Canceling Subscriptions +Handle patterns comparison. + +### H2: Common Migration Patterns +State changes with filter, service call subscriptions. + +## Snippet Inventory + +| Snippet | Status | Notes | +|---|---|---| +| ~8 migration/bus snippets | Keep | Side-by-side comparison pairs | + +## Cross-Links + +- **Links to:** Bus overview, DI page, States/Subscribing, Bus/Filtering +- **Linked from:** Migration overview diff --git a/design/specs/070-doc-overhaul/outlines/migration/checklist.md b/design/specs/070-doc-overhaul/outlines/migration/checklist.md new file mode 100644 index 000000000..7198caf8e --- /dev/null +++ b/design/specs/070-doc-overhaul/outlines/migration/checklist.md @@ -0,0 +1,44 @@ +# Migration — Migration Checklist + +**Status:** Exists (109 lines), step-by-step, voice polish needed +**Voice mode:** Procedural — "you" allowed, numbered steps + +## Outline + +### H2: Before You Start +Prerequisites: Hassette installed, HA token, project structure. + +### H2: Step 1: Configuration +Convert appdaemon.yaml → hassette.toml. + +### H2: Step 2: App Structure +Convert class, imports, initialization. + +### H2: Step 3: Event Listeners +Convert listen_state/listen_event → on_state_change/on. + +### H2: Step 4: Scheduler +Convert run_in/run_daily/run_every. + +### H2: Step 5: API Calls +Convert get_state/call_service/set_state. + +### H2: Step 6: Test +Write tests for the migrated app. + +### H2: Step 7: Verify Live +Run against real HA and verify behavior. + +### H2: Common Pitfalls +Known gotchas from the migration. + +## Snippet Inventory + +| Snippet | Status | Notes | +|---|---|---| +| ~2 migration/checklist snippets | Keep | Before/after snippets | + +## Cross-Links + +- **Links to:** All migration sub-pages, Testing overview +- **Linked from:** Migration overview diff --git a/design/specs/070-doc-overhaul/outlines/migration/concepts.md b/design/specs/070-doc-overhaul/outlines/migration/concepts.md new file mode 100644 index 000000000..d39db68b0 --- /dev/null +++ b/design/specs/070-doc-overhaul/outlines/migration/concepts.md @@ -0,0 +1,35 @@ +# Migration — Mental Model + +**Status:** Exists (90 lines), solid content, voice polish needed +**Voice mode:** Concept — comparison-driven, "you" allowed + +## Outline + +### H2: Execution Model +Single-threaded async (Hassette) vs multi-threaded (AppDaemon). + +### H2: Access Model +Handles vs global `self.get_state()`. + +### H2: Inheritance vs Composition +AppDaemon's Hass base class vs Hassette's App[Config] + handles. + +### H2: Typed vs Untyped +String-based AppDaemon vs typed Pydantic models. + +### H2: Callback Signatures +Raw dicts (AppDaemon) vs DI annotations (Hassette). + +### H2: Synchronous API +`self.call_service()` (AppDaemon) vs `await self.api.call_service()` (Hassette). + +## Snippet Inventory + +| Snippet | Status | Notes | +|---|---|---| +| Relevant comparison snippets from `migration/snippets/` | Review | Side-by-side examples | + +## Cross-Links + +- **Links to:** Migration overview, Apps overview +- **Linked from:** Migration overview diff --git a/design/specs/070-doc-overhaul/outlines/migration/configuration.md b/design/specs/070-doc-overhaul/outlines/migration/configuration.md new file mode 100644 index 000000000..ca9fbb13d --- /dev/null +++ b/design/specs/070-doc-overhaul/outlines/migration/configuration.md @@ -0,0 +1,34 @@ +# Migration — Configuration + +**Status:** Exists (127 lines), comparison-driven, voice polish needed +**Voice mode:** Comparison — "you" allowed + +## Outline + +### H2: Overview +YAML-based (AppDaemon) → TOML + Pydantic (Hassette). + +### H2: Global Configuration +#### H3: AppDaemon (`appdaemon.yaml`) +#### H3: Hassette (`hassette.toml`) + +### H2: Per-App Configuration +#### H3: AppDaemon (`apps.yaml`) +#### H3: Hassette (`hassette.toml` + `AppConfig`) + +### H2: Migration Steps +Step-by-step config conversion. + +### H2: Benefits of Typed Configuration +Why the change is worth the effort. + +## Snippet Inventory + +| Snippet | Status | Notes | +|---|---|---| +| ~4 migration/config snippets | Keep | YAML vs TOML examples | + +## Cross-Links + +- **Links to:** Configuration overview, Apps/Configuration +- **Linked from:** Migration overview diff --git a/design/specs/070-doc-overhaul/outlines/migration/overview.md b/design/specs/070-doc-overhaul/outlines/migration/overview.md new file mode 100644 index 000000000..fae173e77 --- /dev/null +++ b/design/specs/070-doc-overhaul/outlines/migration/overview.md @@ -0,0 +1,35 @@ +# Migration — Overview + +**Status:** Exists (92 lines), solid content, voice polish needed +**Voice mode:** Getting-started — "you" allowed, comparison-driven + +## Outline + +### H2: Is Migration Worth It? +Honest assessment of when migration makes sense. + +### H2: Known Gaps +What AppDaemon has that Hassette doesn't (yet). + +### H2: What Changes +High-level summary of differences. + +### H2: Quick Start Checklist +Abbreviated migration steps. + +### H2: Guide Structure +How the migration section is organized. + +### H2: Quick Reference Table +AppDaemon method → Hassette equivalent lookup table. + +## Snippet Inventory + +| Snippet | Status | Notes | +|---|---|---| +| Relevant files from `migration/snippets/` (27 total) | Review | Assign per-page | + +## Cross-Links + +- **Links to:** All migration sub-pages, Evaluator +- **Linked from:** Home page, Evaluator diff --git a/design/specs/070-doc-overhaul/outlines/migration/scheduler.md b/design/specs/070-doc-overhaul/outlines/migration/scheduler.md new file mode 100644 index 000000000..2b419ceca --- /dev/null +++ b/design/specs/070-doc-overhaul/outlines/migration/scheduler.md @@ -0,0 +1,35 @@ +# Migration — Scheduler + +**Status:** Exists (93 lines), comparison-driven, voice polish needed +**Voice mode:** Comparison — "you" allowed + +## Outline + +### H2: Overview +What changes: `run_in` stays, `run_daily` stays, `run_every` stays. Callback signature changes. + +### H2: Callback Signatures +AppDaemon kwargs dict → Hassette typed params. + +### H2: Method Equivalents +Table: AppDaemon method → Hassette method. + +### H2: Side-by-Side Comparison +Full example: daily task in AppDaemon vs Hassette. + +### H2: Migration Example +Complete before/after. + +### H2: Blocking Work in Scheduler Callbacks +`run_in_executor` for blocking code. + +## Snippet Inventory + +| Snippet | Status | Notes | +|---|---|---| +| ~5 migration/scheduler snippets | Keep | Comparison pairs | + +## Cross-Links + +- **Links to:** Scheduler overview, Scheduler/Methods +- **Linked from:** Migration overview diff --git a/design/specs/070-doc-overhaul/outlines/migration/testing.md b/design/specs/070-doc-overhaul/outlines/migration/testing.md new file mode 100644 index 000000000..41867572a --- /dev/null +++ b/design/specs/070-doc-overhaul/outlines/migration/testing.md @@ -0,0 +1,29 @@ +# Migration — Testing + +**Status:** Exists (41 lines), brief, voice polish needed +**Voice mode:** Comparison — "you" allowed + +## Outline + +### H2: The Mental Model Shift +AppDaemon has no testing story → Hassette has a full test harness. + +### H2: `asyncio_mode = "auto"` (Required) +pytest-asyncio configuration. + +### H2: `set_state()` Order Matters +State seeding before running the app. + +### H2: Full Reference +Link to Testing section. + +## Snippet Inventory + +| Snippet | Status | Notes | +|---|---|---| +| ~2 migration/testing snippets | Keep | Basic test example | + +## Cross-Links + +- **Links to:** Testing overview, Testing/Factories +- **Linked from:** Migration overview diff --git a/design/specs/070-doc-overhaul/outlines/operating/log-levels.md b/design/specs/070-doc-overhaul/outlines/operating/log-levels.md new file mode 100644 index 000000000..3b94ecb90 --- /dev/null +++ b/design/specs/070-doc-overhaul/outlines/operating/log-levels.md @@ -0,0 +1,43 @@ +# Operating Hassette — Log Level Tuning + +**Status:** Stub (3 lines), content moving from Advanced (99 lines) +**Voice mode:** Procedural — "you" allowed, step-by-step +**Content source:** `docs/pages/advanced/log-level-tuning.md` + KI-03 + +## Outline + +### H2: When to Use This +Debug a specific area without flooding logs. Brief. + +### H2: How It Works +`[hassette.log_levels]` in hassette.toml. Per-service granularity. + +### H2: Available Fields +Table of all service field names and what they control. + +### H2: Fallback Behavior +Unset fields use global log level. + +### H2: Per-App Log Levels +Set in the app config block, not in `[hassette.log_levels]`. + +### H2: Examples +#### H3: Debugging the Scheduler +#### H3: Quieting the File Watcher +#### H3: Debugging Home Assistant Communication + +## Snippet Inventory + +Moving from `advanced/snippets/log-level-tuning/`: +| Snippet | Status | Notes | +|---|---|---| +| `basic_example.toml` | Move | → `operating/snippets/` | +| `debug_scheduler.toml` | Move | | +| `quiet_file_watcher.toml` | Move | | +| `debug_ha_comms.toml` | Move | | +| `per_app_log_level.toml` | Move | | + +## Cross-Links + +- **Links to:** Operating overview, Configuration/Global (logging settings) +- **Linked from:** Operating overview, Web UI/Logs diff --git a/design/specs/070-doc-overhaul/outlines/operating/overview.md b/design/specs/070-doc-overhaul/outlines/operating/overview.md new file mode 100644 index 000000000..d69a16c8a --- /dev/null +++ b/design/specs/070-doc-overhaul/outlines/operating/overview.md @@ -0,0 +1,32 @@ +# Operating Hassette — Overview + +**Status:** Stub (3 lines), new page +**Voice mode:** Concept/procedural hybrid — system-as-subject for behavior descriptions, "you" for actions + +## Outline + +### H2: (Opening) +What this section covers: how Hassette behaves at runtime and how to operate it in production. + +### H2: Runtime Behavior + +#### H3: WebSocket Reconnection +**Content from KI-01.** Full reconnection sequence: initial retries (5x, 1s→32s backoff), ServiceWatcher RestartSpec (5 restarts / 300s window, 2s→60s backoff), EXHAUSTED_COOLING (300s cooldown), bus events (`websocket_disconnected`, `websocket_connected`), app behavior during reconnection (API raises `ResourceNotReadyError`, handlers resume automatically). Include log signatures. + +#### H3: Handler Exception Behavior +**Content from KI-02.** Exceptions caught and swallowed, logged at ERROR, recorded in telemetry with `status='error'`. Include log signature. Matches scheduler behavior. + +#### H3: Database Degraded Mode +Brief: what happens when the DB is unavailable. Links to Database & Telemetry page for full details. + +### H2: See Also +→ Log Level Tuning, → Upgrading, → Troubleshooting + +## Snippet Inventory + +No code snippets — log signatures are inline code blocks. + +## Cross-Links + +- **Links to:** Log Level Tuning, Upgrading, Troubleshooting, Database & Telemetry, Configuration/Global (WebSocket resilience settings) +- **Linked from:** Architecture, Troubleshooting (cross-reference) diff --git a/design/specs/070-doc-overhaul/outlines/operating/upgrading.md b/design/specs/070-doc-overhaul/outlines/operating/upgrading.md new file mode 100644 index 000000000..00b799d75 --- /dev/null +++ b/design/specs/070-doc-overhaul/outlines/operating/upgrading.md @@ -0,0 +1,28 @@ +# Operating Hassette — Upgrading + +**Status:** Stub (3 lines), content extracting from troubleshooting.md +**Voice mode:** Procedural — "you" allowed, step-by-step +**Content source:** troubleshooting.md lines 111-128 + KI-12, KI-13 + +## Outline + +### H2: Check Your Current Version +`hassette --version`, `uv pip show hassette` commands. + +### H2: Upgrade to Latest +`uv add hassette@latest`. Docker: pull new image tag. + +### H2: Reading the Changelog +Where to find it, how breaking changes are flagged. + +### H2: Major Version Upgrades +Data directory path includes major version (`~/.local/share/hassette/v0/`). Future `v1/` starts fresh unless `data_dir`/`config_dir` set explicitly. Docker unaffected. + +## Snippet Inventory + +No code snippets — shell commands are inline. + +## Cross-Links + +- **Links to:** Operating overview, Changelog, Docker/Image Tags +- **Linked from:** Operating overview diff --git a/design/specs/070-doc-overhaul/outlines/recipes/daily-notification.md b/design/specs/070-doc-overhaul/outlines/recipes/daily-notification.md new file mode 100644 index 000000000..c51e0aca4 --- /dev/null +++ b/design/specs/070-doc-overhaul/outlines/recipes/daily-notification.md @@ -0,0 +1,32 @@ +# Recipes — Daily Notification + +**Status:** Exists (47 lines), follows recipe template, voice polish needed +**Voice mode:** Recipe — problem statement, code, How It Works, variations + +## Outline + +### H2: (Problem Statement) +Send a notification at a fixed time every day (weather summary, reminder, etc.). + +### H2: The Code +Full app with `run_daily` and `call_service` for notify. + +### H2: How It Works +Walk through the code decisions. Voice-guide rule #21: system-as-subject, one decision per paragraph. + +### H2: Verify It's Working +**New section needed** — add `hassette log` / `hassette job` verification step per recipe template. + +### H2: Variations +Alternative triggers (cron), different notification services, conditional notifications. + +## Snippet Inventory + +| Snippet | Status | Notes | +|---|---|---| +| `daily_notification.py` (in `recipes/snippets/`) | Keep | Review for voice, DI alignment | + +## Cross-Links + +- **Links to:** Scheduler/Methods (run_daily, run_cron), API/Services (call_service) +- **Linked from:** Recipes overview diff --git a/design/specs/070-doc-overhaul/outlines/recipes/debounce-sensor-changes.md b/design/specs/070-doc-overhaul/outlines/recipes/debounce-sensor-changes.md new file mode 100644 index 000000000..74bdd2159 --- /dev/null +++ b/design/specs/070-doc-overhaul/outlines/recipes/debounce-sensor-changes.md @@ -0,0 +1,32 @@ +# Recipes — Debounce Sensor Changes + +**Status:** Exists (28 lines), follows recipe template, voice polish needed +**Voice mode:** Recipe — problem statement, code, How It Works, variations + +## Outline + +### H2: (Problem Statement) +Sensors emit bursts of near-identical readings. React only after the value stabilizes. + +### H2: The Code +App with `on_state_change(debounce=10.0)`. + +### H2: How It Works +What debounce does: resets timer on each new event, fires only after quiet period. + +### H2: Verify It's Working +**New section needed.** + +### H2: Variations +Different debounce values, combining with throttle, sensor-specific patterns. + +## Snippet Inventory + +| Snippet | Status | Notes | +|---|---|---| +| `debounce_sensor.py` (in `recipes/snippets/`) | Keep | Review for voice | + +## Cross-Links + +- **Links to:** Bus overview (rate control section), States/Subscribing +- **Linked from:** Recipes overview diff --git a/design/specs/070-doc-overhaul/outlines/recipes/motion-lights.md b/design/specs/070-doc-overhaul/outlines/recipes/motion-lights.md new file mode 100644 index 000000000..0c8797eb4 --- /dev/null +++ b/design/specs/070-doc-overhaul/outlines/recipes/motion-lights.md @@ -0,0 +1,19 @@ +# Recipes — Motion-Activated Lights + +**Status:** REWRITTEN in T03 (exemplar). 62 lines. Done. +**Voice mode:** Recipe — problem statement, code, flowing "How It Works" prose, verify step + +## Outline + +Already complete. Covers: problem statement, full app code, How It Works (flowing prose paragraphs), Verify It's Working (`hassette log`, `hassette listener`), Variations (changed_to split handler, adjustable delays). + +## Snippet Inventory + +Written and tested in T03: +- `motion_lights.py` — main app +- `motion_lights_split.py` — split handler variation with `changed_to` predicates + +## Cross-Links + +- **Links to:** Bus overview, Scheduler/Methods (run_in), States/Subscribing (on_state_change patterns) +- **Linked from:** Recipes overview, First Automation (next steps) diff --git a/design/specs/070-doc-overhaul/outlines/recipes/overview.md b/design/specs/070-doc-overhaul/outlines/recipes/overview.md new file mode 100644 index 000000000..452e598f0 --- /dev/null +++ b/design/specs/070-doc-overhaul/outlines/recipes/overview.md @@ -0,0 +1,20 @@ +# Recipes — Overview + +**Status:** Exists (24 lines), brief index, voice polish needed +**Voice mode:** Getting-started — friendly, "you" allowed + +## Outline + +### H2: Recipes +List of all recipes with one-line descriptions. Each links to its page. + +Brief intro explaining what recipes are: complete, runnable examples that solve real-world problems. + +## Snippet Inventory + +None — index page. + +## Cross-Links + +- **Links to:** All recipe pages +- **Linked from:** Home page, Getting Started (next steps), Bus overview, Scheduler overview diff --git a/design/specs/070-doc-overhaul/outlines/recipes/sensor-threshold.md b/design/specs/070-doc-overhaul/outlines/recipes/sensor-threshold.md new file mode 100644 index 000000000..071a9b5a8 --- /dev/null +++ b/design/specs/070-doc-overhaul/outlines/recipes/sensor-threshold.md @@ -0,0 +1,32 @@ +# Recipes — Monitor Sensor Thresholds + +**Status:** Exists (31 lines), follows recipe template, voice polish needed +**Voice mode:** Recipe — problem statement, code, How It Works, variations + +## Outline + +### H2: (Problem Statement) +Take action when a sensor crosses a threshold (temperature above 80°F, humidity below 30%). + +### H2: The Code +App with `on_state_change` + numeric condition or predicate. + +### H2: How It Works +How `C.Increased`/`C.Decreased` or threshold predicates work in this context. + +### H2: Verify It's Working +**New section needed.** + +### H2: Variations +Hysteresis (don't re-trigger until value drops back), multiple thresholds, combining sensors. + +## Snippet Inventory + +| Snippet | Status | Notes | +|---|---|---| +| `sensor_threshold.py` (in `recipes/snippets/`) | Keep | Review for voice | + +## Cross-Links + +- **Links to:** States/Subscribing (numeric conditions), Bus/Filtering (C.Increased, C.Decreased) +- **Linked from:** Recipes overview diff --git a/design/specs/070-doc-overhaul/outlines/recipes/service-call-reaction.md b/design/specs/070-doc-overhaul/outlines/recipes/service-call-reaction.md new file mode 100644 index 000000000..8efee3455 --- /dev/null +++ b/design/specs/070-doc-overhaul/outlines/recipes/service-call-reaction.md @@ -0,0 +1,32 @@ +# Recipes — React to a Service Call + +**Status:** Exists (32 lines), follows recipe template, voice polish needed +**Voice mode:** Recipe — problem statement, code, How It Works, variations + +## Outline + +### H2: (Problem Statement) +React when a specific HA service is called (e.g., log when someone turns on a light via the UI). + +### H2: The Code +App with `on_call_service` subscription. + +### H2: How It Works +Service call events, filtering by domain/service. + +### H2: Verify It's Working +**New section needed.** + +### H2: Variations +Filtering by entity, combining with state checks. + +## Snippet Inventory + +| Snippet | Status | Notes | +|---|---|---| +| `service_call_reaction.py` (in `recipes/snippets/`) | Keep | Review for voice | + +## Cross-Links + +- **Links to:** Bus/Handlers (on_call_service), Bus/Filtering (service call filtering) +- **Linked from:** Recipes overview diff --git a/design/specs/070-doc-overhaul/outlines/recipes/vacation-mode-toggle.md b/design/specs/070-doc-overhaul/outlines/recipes/vacation-mode-toggle.md new file mode 100644 index 000000000..af5f55ec2 --- /dev/null +++ b/design/specs/070-doc-overhaul/outlines/recipes/vacation-mode-toggle.md @@ -0,0 +1,32 @@ +# Recipes — Vacation Mode Toggle + +**Status:** Exists (29 lines), follows recipe template, voice polish needed +**Voice mode:** Recipe — problem statement, code, How It Works, variations + +## Outline + +### H2: (Problem Statement) +Toggle a set of automations on/off based on an input_boolean (vacation mode, guest mode, etc.). + +### H2: The Code +App watching an input_boolean, enabling/disabling other behaviors. + +### H2: How It Works +Pattern: input_boolean as a mode switch, conditional logic in handlers. + +### H2: Verify It's Working +**New section needed.** + +### H2: Variations +Multiple modes, time-based auto-toggle, notification on mode change. + +## Snippet Inventory + +| Snippet | Status | Notes | +|---|---|---| +| `vacation_mode.py` (in `recipes/snippets/`) | Keep | Review for voice | + +## Cross-Links + +- **Links to:** States/Subscribing (input_boolean state changes), API/Services (call_service for toggling), Cache (persisting mode state) +- **Linked from:** Recipes overview diff --git a/design/specs/070-doc-overhaul/outlines/snippet-mapping.md b/design/specs/070-doc-overhaul/outlines/snippet-mapping.md new file mode 100644 index 000000000..c9aa47d07 --- /dev/null +++ b/design/specs/070-doc-overhaul/outlines/snippet-mapping.md @@ -0,0 +1,111 @@ +# Snippet Mapping + +Total snippet files: **355** (260 .py, 30 .sh, 28 .toml, 18 .yml, 14 .txt, 2 .yaml, 1 .mmd, 1 .md, 1 .dockerfile) + +--- + +## Claimed — Staying in Place + +Snippets already in the correct location for their new-nav page. Voice polish and content review during Phase 3 writing tasks. + +| Directory | Files | Claimed by | +|---|---|---| +| `getting-started/snippets/` | 13 of 15 | Quickstart, HA Token, First Automation | +| `getting-started/docker/snippets/` | 62 | Docker Setup, Dependencies, Image Tags, Docker Troubleshooting | +| `core-concepts/bus/snippets/` (top-level) | ~12 | Bus overview (3 rewritten in T03), Handlers, Filtering | +| `core-concepts/bus/snippets/dependency-injection/` | ~14 | DI page (7 rewritten in T03), Custom Extractors | +| `core-concepts/bus/snippets/filtering/` | ~2 (after moves) | Filtering page | +| `core-concepts/bus/snippets/handlers/` | 3 | Handlers page | +| `core-concepts/apps/snippets/` | 15 | Apps overview, Lifecycle, Configuration, Task Bucket | +| `core-concepts/scheduler/snippets/` | 22 | Scheduler overview, Methods, Management | +| `core-concepts/states/snippets/` | 4 | States overview | +| `core-concepts/api/snippets/` | 14 | API overview, Entities, Services, Utilities | +| `core-concepts/cache/snippets/` | 9 | Cache overview, Patterns | +| `core-concepts/snippets/database-telemetry/` | 3 | Database & Telemetry | +| `core-concepts/snippets/` (top-level .py) | 2 | Architecture (depends_on), Internals (restart_spec) | +| `testing/snippets/` | 34 | Testing overview, Time Control, Concurrency, Factories | +| `migration/snippets/` | 27 | Migration pages (all 8) | +| `recipes/snippets/` | 9 | Recipe pages (motion-lights rewritten in T03) | + +**Subtotal: ~285 files staying in place** + +--- + +## Claimed — Moving + +Snippets moving from `advanced/snippets/` (being eliminated) or between subsections. + +| Source | Destination | Files | Reason | +|---|---|---|---| +| `advanced/snippets/custom-states/` | `core-concepts/states/snippets/` | 14 | Custom States page moves to States section | +| `advanced/snippets/state-registry/` | `core-concepts/states/snippets/` | 18 | State Registry page moves to States section | +| `advanced/snippets/type-registry/` | `core-concepts/states/snippets/` | 24 | Type Registry page moves to States section | +| `advanced/snippets/managing-helpers/` | `core-concepts/api/snippets/` | 5 | Managing Helpers page moves to API section | +| `advanced/snippets/log-level-tuning/` | `operating/snippets/` | 5 | Log Level Tuning moves to Operating section | +| `core-concepts/bus/snippets/dependency-injection/` (4 files) | `core-concepts/states/snippets/` | 4 | Type conversion snippets → Type Registry page | +| `core-concepts/bus/snippets/filtering/custom_accessors.py` | `core-concepts/bus/snippets/` (custom-extractors) | 1 | Custom Accessors → Custom Extractors page | +| `core-concepts/bus/snippets/filtering/` (state-specific) | `core-concepts/states/snippets/` | ~5 | State-change filtering → States/Subscribing page | +| `core-concepts/snippets/states_import.py` | `core-concepts/states/snippets/` | 1 | States import example | + +**Subtotal: ~77 files moving** + +### DI conversion snippets moving to Type Registry: +- `builtin_conversions_explicit.py` +- `builtin_conversions_implicit.py` +- `bypass_conversion_any.py` +- `bypass_conversion_custom.py` + +### Filtering snippets moving to States/Subscribing: +- `filtering_simple_start.py` +- `filtering_simple_stop.py` +- `filtering_state_from_to.py` +- `filtering_increased_decreased.py` +- `changed_false.py` + +--- + +## Unclaimed — Candidates for Deletion + +Snippets not referenced by any outline. Review before deleting — some may be needed by pages not yet identified. + +| File | Location | Reason | +|---|---|---| +| `first_automation_step3_raw.py` | `getting-started/snippets/` | Raw handler example — superseded by DI-first approach. **Possible claim:** Bus/Handlers page could use for raw handler example, but has its own `handlers_no_data.py`. | +| `typed_handler.py` | `getting-started/snippets/` | Redundant — DI is default from step 3. | +| `web-ui/snippets/disable-ui.toml` | `web-ui/snippets/` | May be absorbed into Web UI overview inline. Review. | +| `web-ui/app-detail/snippets/handler_registration.py` | `web-ui/app-detail/snippets/` | Old app-detail page being consolidated. Review — may be claimed by Debug Handler page. | + +**Subtotal: 2–4 files, pending review** + +--- + +## New — Snippets to Create + +| Proposed path | Page | What it demonstrates | +|---|---|---| +| `states/snippets/subscribing_attribute_change.py` | States/Subscribing | `on_attribute_change` with predicate | +| `states/snippets/subscribing_combined_predicates.py` | States/Subscribing | `&`/`|` composition in state context | +| `states/snippets/domain_state_access.py` | DomainStates Reference | Accessing typed attributes from LightState | +| `states/snippets/sensor_state_example.py` | DomainStates Reference | SensorState with numeric value and unit | +| `bus/snippets/handlers_service_call.py` | Handlers | `on_call_service` handler example | +| `bus/snippets/handlers_raw_topic.py` | Handlers | `on("event_triggered")` raw topic subscription | +| `bus/snippets/handlers_internal_events.py` | Handlers | Hassette internal events / HA lifecycle events | +| `bus/snippets/custom_extractors_annotation_details.py` | Custom Extractors | AnnotationDetails usage | + +**Subtotal: ~8 new files** + +--- + +## Summary + +| Category | Count | +|---|---| +| Staying in place | ~285 | +| Moving (Advanced → new locations) | ~77 | +| Unclaimed (deletion candidates) | 2–4 | +| New (to create) | ~8 | +| **Total after Phase 3** | **~370** | + +The `advanced/snippets/` directory (66 files) is fully mapped — every file moves to its new location. The `advanced/` section can be deleted after all moves are complete. + +The `web-ui/app-detail/` directory (1 snippet + 6 old pages) is being consolidated — review the 1 snippet during Web UI writing. diff --git a/design/specs/070-doc-overhaul/outlines/testing/concurrency.md b/design/specs/070-doc-overhaul/outlines/testing/concurrency.md new file mode 100644 index 000000000..d5c5aa962 --- /dev/null +++ b/design/specs/070-doc-overhaul/outlines/testing/concurrency.md @@ -0,0 +1,32 @@ +# Testing — Concurrency & pytest-xdist + +**Status:** Exists (52 lines), concise, voice polish needed +**Voice mode:** Concept — system-as-subject + +## Outline + +### H2: Same-Class Concurrency (Always Applies) +Why tests of the same app class can interfere; harness isolation. + +### H2: Time-Control Concurrency (`freeze_time` Only) +Global time state means parallel time-control tests conflict. + +### H2: Parallel Test Suites (pytest-xdist) +`--dist loadscope` requirement, why `-n auto` alone causes flakes. + +### H2: pytest-asyncio Mode +`auto` mode setting. + +### H2: `DrainFailure` Exception Hierarchy +What DrainFailure means and how to handle it in tests. + +## Snippet Inventory + +| Snippet | Status | Notes | +|---|---|---| +| Relevant files from `testing/snippets/` | Review | Concurrency examples | + +## Cross-Links + +- **Links to:** Testing overview, Time Control +- **Linked from:** Testing overview diff --git a/design/specs/070-doc-overhaul/outlines/testing/factories.md b/design/specs/070-doc-overhaul/outlines/testing/factories.md new file mode 100644 index 000000000..2eb9178e0 --- /dev/null +++ b/design/specs/070-doc-overhaul/outlines/testing/factories.md @@ -0,0 +1,39 @@ +# Testing — Factories & Internals + +**Status:** Exists (169 lines), reference-style, voice polish needed +**Voice mode:** Reference — terse, system-as-subject + +## Outline + +### H2: Event Factories +#### H3: `create_state_change_event` — build a state change event dict +#### H3: `create_call_service_event` — build a service call event dict + +### H2: State Factories +#### H3: `make_state_dict` — raw state dict +#### H3: `make_light_state_dict` — typed light state +#### H3: `make_sensor_state_dict` — typed sensor state +#### H3: `make_switch_state_dict` — typed switch state + +### H2: `make_mock_hassette` +Full mock Hassette instance for web/API tests (the `create_hassette_stub()` pattern). + +### H2: `make_test_config` +Test configuration builder. + +### H2: RecordingApi Coverage Boundary +What RecordingApi supports vs what needs mocking. + +### H2: Tier 2 Re-exports +Helper re-exports from `hassette.test_utils`. + +## Snippet Inventory + +| Snippet | Status | Notes | +|---|---|---| +| Relevant files from `testing/snippets/` | Review | Factory usage examples | + +## Cross-Links + +- **Links to:** Testing overview, API overview (RecordingApi boundary) +- **Linked from:** Testing overview diff --git a/design/specs/070-doc-overhaul/outlines/testing/overview.md b/design/specs/070-doc-overhaul/outlines/testing/overview.md new file mode 100644 index 000000000..7807653ad --- /dev/null +++ b/design/specs/070-doc-overhaul/outlines/testing/overview.md @@ -0,0 +1,54 @@ +# Testing — Testing Your Apps + +**Status:** Exists (243 lines), comprehensive, voice polish needed +**Voice mode:** Concept/getting-started hybrid — "you" allowed for procedural parts + +## Outline + +### H2: Installation +pytest + hassette test extras. + +### H2: Quick Start +Minimal test example with the harness. + +### H2: The Test Harness +#### H3: Constructor — `HassetteHarness(AppClass, config)` parameters +#### H3: Properties — harness.bus, harness.scheduler, harness.api, etc. + +### H2: State Seeding +Pre-populating entity states before running the app. + +### H2: Simulating Events +#### H3: State Changes +#### H3: Attribute Changes +#### H3: Service Call Events +#### H3: Timeouts and Slow Handlers +#### H3: Typed Dependency Injection in Handlers +#### H3: Hassette Service Events + +### H2: Asserting API Calls +#### H3: `assert_called` — verify service calls were made +#### H3: `assert_not_called` +#### H3: `assert_call_count` +#### H3: `get_calls` +#### H3: `reset` + +### H2: Configuration Errors +Testing invalid config detection. + +### H2: Harness Startup Failures +Testing apps that fail during initialization. + +### H2: Next Steps +→ Time Control, → Concurrency, → Factories + +## Snippet Inventory + +| Snippet | Status | Notes | +|---|---|---| +| 34 files in `testing/snippets/` | Review | Assign per-page across the 4 testing pages | + +## Cross-Links + +- **Links to:** Time Control, Concurrency, Factories, Apps overview +- **Linked from:** Getting Started (next steps), Migration/Testing diff --git a/design/specs/070-doc-overhaul/outlines/testing/time-control.md b/design/specs/070-doc-overhaul/outlines/testing/time-control.md new file mode 100644 index 000000000..c2174376b --- /dev/null +++ b/design/specs/070-doc-overhaul/outlines/testing/time-control.md @@ -0,0 +1,26 @@ +# Testing — Time Control + +**Status:** Exists (48 lines), concise, voice polish needed +**Voice mode:** Concept — system-as-subject + +## Outline + +### H2: `freeze_time(instant)` +Freezing time to a specific instant for deterministic tests. + +### H2: `advance_time` +Moving time forward by a duration. + +### H2: `trigger_due_jobs` +Manually triggering scheduler jobs that are due at the frozen time. + +## Snippet Inventory + +| Snippet | Status | Notes | +|---|---|---| +| Relevant files from `testing/snippets/` | Review | Time control examples | + +## Cross-Links + +- **Links to:** Testing overview, Scheduler/Methods (triggers interact with time) +- **Linked from:** Testing overview diff --git a/design/specs/070-doc-overhaul/outlines/troubleshooting/troubleshooting.md b/design/specs/070-doc-overhaul/outlines/troubleshooting/troubleshooting.md new file mode 100644 index 000000000..1c0c991fa --- /dev/null +++ b/design/specs/070-doc-overhaul/outlines/troubleshooting/troubleshooting.md @@ -0,0 +1,52 @@ +# Troubleshooting + +**Status:** Exists (140 lines), restructuring — operational content moves to Operating, pure symptom-lookup stays +**Voice mode:** Direct, "you" allowed, problem/solution format + +## Outline + +Pure symptom-lookup. Each H2 is a symptom. Each entry: symptom description, likely causes, fixes. No how-to content — that lives in Operating Hassette. + +### H2: Can't Connect to Home Assistant +Token issues, connection refused/timeout. Links to Auth, Docker Troubleshooting. + +### H2: Apps Not Loading +App not discovered, import errors, precheck failures. **KI-04**: Include all three log signatures and the `allow_startup_if_app_precheck_fails` workaround. + +### H2: Event Handler Never Runs +**KI-05**: `changed_to` type mismatch (string vs bool). +**KI-06**: `bus_excluded_domains`/`bus_excluded_entities` silently drop events. +**KI-07**: Attribute-only changes require `changed=False`. +Also: entity ID typos, app not enabled. + +### H2: Scheduler Not Firing +**KI-08**: Past-time behavior for `run_once` and `run_daily`, `seconds` vs `minutes` gotcha, cron expression pitfall. Links to Job Management troubleshooting. + +### H2: Database Degraded / Telemetry Missing +**KI-09**: Zeroed stats strip, Docker disk check command, DB file location, safe to delete. Links to Database & Telemetry degraded mode. + +### H2: Cache Not Persisting +**KI-10**: `data_dir` config, Docker volume mount, instance name key prefix. Links to Cache patterns troubleshooting. + +### H2: Custom State Class Not Registering +**KI-11**: `domain: Literal[...]` field required, `super().__init_subclass__()` call. Links to Custom States troubleshooting. + +### H2: Web UI Not Accessible +Port/URL, Docker port mapping, `web_api` settings. + +### H2: Docker-Specific Issues +Pointer to Docker Troubleshooting page. + +**Removed from this page (moved to Operating):** +- WebSocket reconnection sequence → Operating/overview.md +- Event handler exception behavior → Operating/overview.md +- Upgrading Hassette → Operating/upgrading.md + +## Snippet Inventory + +No code snippets — log signatures and config examples are inline. + +## Cross-Links + +- **Links to:** Operating (runtime behavior), Docker Troubleshooting, Auth, Configuration, Database & Telemetry, Cache patterns, Custom States +- **Linked from:** Getting Started (next steps), many concept pages diff --git a/design/specs/070-doc-overhaul/outlines/web-ui/debug-handler.md b/design/specs/070-doc-overhaul/outlines/web-ui/debug-handler.md new file mode 100644 index 000000000..4c6c26fb8 --- /dev/null +++ b/design/specs/070-doc-overhaul/outlines/web-ui/debug-handler.md @@ -0,0 +1,32 @@ +# Web UI — Debug a Failing Handler + +**Status:** Stub (3 lines), new task-oriented page +**Voice mode:** Getting-started feel — "you" allowed, procedural, task-focused + +## Outline + +Walks through using the Web UI to debug a handler that isn't firing or is throwing errors. Consolidates relevant content from old `handlers.md` and `app-detail/handlers.md`. + +### H2: Symptoms +What "failing handler" looks like: handler never fires, fires but errors, fires too often. + +### H2: Check the Handlers Page +Global handlers table. How to find your handler, read its status, see error counts. Consolidates from old `handlers.md`. + +### H2: Drill into an App's Handlers +App detail → Handlers tab. Per-handler invocation history, error details. Consolidates from old `app-detail/handlers.md`. + +### H2: Read the Invocation Logs +Execution ID filtering to trace a single invocation through the log stream. + +### H2: Common Causes +Brief problem/solution list specific to handlers (missing `name=`, wrong entity pattern, DI annotation mismatch). + +## Snippet Inventory + +No code snippets — screenshots or UI descriptions. + +## Cross-Links + +- **Links to:** Web UI overview, Logs page, Bus/Handlers (handler mechanics), Bus/DI (annotation reference) +- **Linked from:** Web UI overview, Bus/Handlers (troubleshooting) diff --git a/design/specs/070-doc-overhaul/outlines/web-ui/inspect-config-code.md b/design/specs/070-doc-overhaul/outlines/web-ui/inspect-config-code.md new file mode 100644 index 000000000..4b513020b --- /dev/null +++ b/design/specs/070-doc-overhaul/outlines/web-ui/inspect-config-code.md @@ -0,0 +1,26 @@ +# Web UI — Inspect Configuration and Code + +**Status:** Stub (3 lines), new task-oriented page +**Voice mode:** Procedural — "you" allowed, task-focused + +## Outline + +Consolidates content from old `config.md` (45 lines) and `app-detail/config.md`, `app-detail/code.md`. + +### H2: Global Configuration +Configuration groups view, value formatting. From old `config.md`. + +### H2: App Configuration +Per-app config view from the app detail page. From old `app-detail/config.md`. + +### H2: App Source Code +Viewing app source code in the UI. From old `app-detail/code.md`. + +## Snippet Inventory + +No code snippets — UI feature documentation. + +## Cross-Links + +- **Links to:** Web UI overview, Configuration (the actual config system) +- **Linked from:** Web UI overview diff --git a/design/specs/070-doc-overhaul/outlines/web-ui/logs.md b/design/specs/070-doc-overhaul/outlines/web-ui/logs.md new file mode 100644 index 000000000..68ee402e4 --- /dev/null +++ b/design/specs/070-doc-overhaul/outlines/web-ui/logs.md @@ -0,0 +1,33 @@ +# Web UI — Read and Filter Logs + +**Status:** Exists (119 lines), solid content, voice polish needed +**Voice mode:** Concept/procedural hybrid — system-as-subject for descriptions, "you" for actions + +## Outline + +### H2: Log Table +Column descriptions, data sources. + +### H2: Filtering and Search +Text search, level filter, app filter, time range. + +### H2: Column Picker +Customizing visible columns. + +### H2: Log Detail Drawer +Expanding a log entry to see full context. + +### H2: Live Streaming +SSE-based live log tail. Auto-pause on sort behavior. + +### H2: Execution ID Filtering +Tracing a single handler/job execution across log entries. + +## Snippet Inventory + +No code snippets — UI feature documentation. + +## Cross-Links + +- **Links to:** Web UI overview, Debug Handler (execution ID), Database & Telemetry +- **Linked from:** Web UI overview, Operating/Log Levels diff --git a/design/specs/070-doc-overhaul/outlines/web-ui/manage-apps.md b/design/specs/070-doc-overhaul/outlines/web-ui/manage-apps.md new file mode 100644 index 000000000..a0b1be11c --- /dev/null +++ b/design/specs/070-doc-overhaul/outlines/web-ui/manage-apps.md @@ -0,0 +1,32 @@ +# Web UI — Manage Apps + +**Status:** Stub (3 lines), new task-oriented page +**Voice mode:** Procedural — "you" allowed, task-focused + +## Outline + +Consolidates content from old `apps.md` (118 lines) and `app-detail/overview.md`. + +### H2: Apps Dashboard +Stats strip, app table, status filter, sorting, searching. From old `apps.md`. + +### H2: App Actions +Start, stop, reload individual apps. From old `apps.md` actions section. + +### H2: App Detail View +What you see when you click an app: overview tab, health, activity. From old `app-detail/overview.md`. + +### H2: Multi-Instance Apps +How multi-instance apps appear in the UI, instance-level actions. From old `apps.md`. + +### H2: Mobile Layout +Responsive behavior. Brief. + +## Snippet Inventory + +No code snippets — UI feature documentation. + +## Cross-Links + +- **Links to:** Web UI overview, Debug Handler, Apps/Lifecycle (app states) +- **Linked from:** Web UI overview diff --git a/design/specs/070-doc-overhaul/outlines/web-ui/overview.md b/design/specs/070-doc-overhaul/outlines/web-ui/overview.md new file mode 100644 index 000000000..060587557 --- /dev/null +++ b/design/specs/070-doc-overhaul/outlines/web-ui/overview.md @@ -0,0 +1,29 @@ +# Web UI — Overview + +**Status:** Exists (56 lines), brief intro, voice polish needed +**Voice mode:** Concept — system-as-subject, no "you" + +## Outline + +### H2: Enabling and Accessing +`web_enabled` setting, default port, how to access. + +### H2: Layout +Sidebar navigation, status bar, command palette, alert banners. Consolidates content from old `layout.md`. + +### H2: Related Pages +Links to the four task-oriented pages. + +**Note:** Old `layout.md` (99 lines), `apps.md` (118 lines), `config.md` (45 lines), `handlers.md` (65 lines), and `app-detail/*.md` (6 pages) are being consolidated into the 5 new task-oriented pages. The overview absorbs the layout description; the rest distribute across the task pages. + +## Snippet Inventory + +| Snippet | Status | Notes | +|---|---|---| +| 1 file in `web-ui/snippets/` | Review | Check if still relevant | +| 1 file in `web-ui/app-detail/snippets/` | Review | Check if still relevant | + +## Cross-Links + +- **Links to:** Debug Handler, Logs, Manage Apps, Inspect Config & Code, Configuration/Global (web settings) +- **Linked from:** Architecture, Docker Setup (viewing logs) From 2aa25e82448c53fcebeae542f7fc0a3d8211c1e1 Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Mon, 1 Jun 2026 16:59:59 -0500 Subject: [PATCH 008/160] =?UTF-8?q?chore:=20home=20page=20outline=20?= =?UTF-8?q?=E2=80=94=20require=20snippet=20files=20for=20all=20code=20exam?= =?UTF-8?q?ples?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- design/specs/070-doc-overhaul/outlines/home.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/design/specs/070-doc-overhaul/outlines/home.md b/design/specs/070-doc-overhaul/outlines/home.md index e7adc8c15..2f11d4ec9 100644 --- a/design/specs/070-doc-overhaul/outlines/home.md +++ b/design/specs/070-doc-overhaul/outlines/home.md @@ -30,7 +30,15 @@ Links to Quickstart, Evaluator, Core Concepts, Recipes. ## Snippet Inventory -No dedicated snippet files — inline code examples and screenshots. +The home page is the most-visited page — stale code here is the worst place for it. Every code example must come from a tested snippet file, not inline blocks. + +| Snippet | Status | Notes | +|---|---|---| +| `getting-started/snippets/install.sh` | Keep (already `--8<--` included) | Quick start install command | +| New: `home_event_handling.py` | New | If the video section is supplemented or replaced with a code example, it must be a snippet | +| New: `home_quick_app.py` | New | If a "see it in action" code block is added alongside/instead of videos | + +**Rule:** Any code block on this page uses `--8<--` includes. No inline code fences for app examples. Pyright CI catches drift automatically when snippets are external files. ## Cross-Links From eb6865c4cd166571a01bec55bfc6e4c82874e77a Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Mon, 1 Jun 2026 17:05:56 -0500 Subject: [PATCH 009/160] chore: move depends_on/wave content from Architecture to Internals outline --- .../outlines/core-concepts/architecture.md | 8 +++++--- .../070-doc-overhaul/outlines/core-concepts/internals.md | 8 ++++++-- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/architecture.md b/design/specs/070-doc-overhaul/outlines/core-concepts/architecture.md index 02a892562..e6d5c4ade 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/architecture.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/architecture.md @@ -14,15 +14,17 @@ Three Mermaid diagrams (existing, keep): 2. Core services inside Hassette 3. What each app gets (the five handles: Bus, Scheduler, Api, StateManager, Cache) -### H2: Service Dependency Graph -How `depends_on` works, initialization/shutdown order, cycle detection. Framework dependency graph diagram. +### H2: Startup +One sentence: "Hassette starts services in dependency order — your handles are ready by the time `on_initialize` runs." Links to System Internals for the full dependency graph, wave ordering, and cycle detection. + +**Removed from this page (moved to Internals):** `depends_on` code example, wave-based ordering explanation, cycle detection, framework dependency graph Mermaid diagram, EventStreamService note. These are framework plumbing, not app-author concerns. ### H2: Deep Dive Links to each core concept page. ## Snippet Inventory -No code snippets — diagrams are inline Mermaid. +No code snippets — diagrams are inline Mermaid. The `index_depends_on.py` snippet moves to System Internals. ## Cross-Links diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/internals.md b/design/specs/070-doc-overhaul/outlines/core-concepts/internals.md index 0e16d9723..b1ca4652f 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/internals.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/internals.md @@ -8,10 +8,14 @@ The current single page covers 10 numbered sections. Split into: ### Page 1: `internals/index.md` — Architecture & Data Flow -Sections 1-3 from current page: +Sections 1-3 from current page, plus content moving from Architecture: - Component Ownership (which service owns which state) -- Service Dependencies (depends_on, initialization order) +- Service Dependencies (depends_on, initialization order, cycle detection, framework dependency graph Mermaid diagram) - Event and Data Flow (how events travel through the system) +- Wave-based startup/shutdown ordering +- EventStreamService constructor-time dependency note + +The `depends_on` code snippet (`index_depends_on.py`) moves here from Architecture. ### Page 2: `internals/service-details.md` — Per-Service Internals Sections 4-9 from current page: From 980034d03b0a1608763360a3afc9c9d5c8d2ed59 Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Mon, 1 Jun 2026 17:09:56 -0500 Subject: [PATCH 010/160] fix: remove references to non-existent on_ready lifecycle hook on_ready does not exist in the source. The actual hooks are on_initialize and on_shutdown (plus _sync variants and before_/after_ variants under review for removal). --- CLAUDE.md | 2 +- .../outlines/core-concepts/apps/lifecycle.md | 6 +++--- .../070-doc-overhaul/tasks/T07-write-core-concepts-1.md | 2 +- docs/pages/core-concepts/states/index.md | 2 +- tests/TESTING.md | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e2ec2c220..1cd607a65 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,7 +54,7 @@ hassette app health --instance office **Hassette** (`src/hassette/core/core.py`) - Main coordinator that connects to Home Assistant via WebSocket, manages app lifecycle, and coordinates all services. -**App** (`src/hassette/app/app.py`) - Base class for user automations. Generic over `AppConfig` type. Each app gets its own Bus, Scheduler, Api, and StateManager. Lifecycle hooks: `on_initialize`, `on_ready`, `on_shutdown`. +**App** (`src/hassette/app/app.py`) - Base class for user automations. Generic over `AppConfig` type. Each app gets its own Bus, Scheduler, Api, and StateManager. Lifecycle hooks: `on_initialize`, `on_shutdown`. **Bus** (`src/hassette/bus/`) - Event pub/sub with filtering. Methods: `on_state_change`, `on_attribute_change`, `on_call_service`, `on`. All registration methods are `async` and must be awaited. `name=` is required on every DB-registered listener — omitting it raises `ListenerNameRequiredError` at call time. Supports glob patterns, predicates, conditions, debounce, throttle. The internal `Listener` dataclass composes four sub-structs: `ListenerIdentity` (ownership/telemetry fields), `ListenerOptions` (behavioral timing parameters), `HandlerInvoker` (handler invocation, dispatch, rate limiting), and `DurationConfig` (duration-hold configuration and timer lifecycle). Registration is synchronous with the DB — `sub.listener.db_id` is a valid integer immediately when the awaited call returns. `Subscription` no longer has a `registration_task` field. diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/apps/lifecycle.md b/design/specs/070-doc-overhaul/outlines/core-concepts/apps/lifecycle.md index 8f980e2d1..f7306618a 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/apps/lifecycle.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/apps/lifecycle.md @@ -6,7 +6,7 @@ ## Outline ### H2: Initialization -`on_initialize` → `on_ready` sequence. What each hook is for. Registration happens in `on_initialize`; `on_ready` fires after all apps finish initializing. +`on_initialize` → `on_shutdown` sequence. What each hook is for. Registration happens in `on_initialize`. ### H2: Shutdown `on_shutdown` hook. Cleanup order. @@ -14,8 +14,8 @@ ### H2: Automatic Cleanup How Hassette cleans up bus subscriptions and scheduler jobs when an app shuts down. -### H2: AppSync Lifecycle Hooks -`on_apps_ready` and `on_apps_shutdown` for cross-app coordination. +### H2: AppSync +`AppSync` base class for synchronous apps. Lifecycle hooks have `_sync` variants (`on_initialize_sync`, `on_shutdown_sync`). ## Snippet Inventory diff --git a/design/specs/070-doc-overhaul/tasks/T07-write-core-concepts-1.md b/design/specs/070-doc-overhaul/tasks/T07-write-core-concepts-1.md index 79793d4a6..4b17b9fa8 100644 --- a/design/specs/070-doc-overhaul/tasks/T07-write-core-concepts-1.md +++ b/design/specs/070-doc-overhaul/tasks/T07-write-core-concepts-1.md @@ -26,7 +26,7 @@ Work on the `docs/overhaul` branch. Before writing, read: **Apps (4 pages):** - `core-concepts/apps/index.md` — What an App is, the five handles available via `self.*`, how to create one -- `core-concepts/apps/lifecycle.md` — `on_initialize`, `on_ready`, `on_shutdown` hooks +- `core-concepts/apps/lifecycle.md` — `on_initialize`, `on_shutdown` hooks - `core-concepts/apps/configuration.md` — `AppConfig`, `SettingsConfigDict`, env prefix - `core-concepts/apps/task-bucket.md` — Background task management diff --git a/docs/pages/core-concepts/states/index.md b/docs/pages/core-concepts/states/index.md index 9bd228122..dfd6b3a0f 100644 --- a/docs/pages/core-concepts/states/index.md +++ b/docs/pages/core-concepts/states/index.md @@ -166,7 +166,7 @@ Hassette ships typed state classes for every standard Home Assistant domain. Imp ## Good to Know !!! note "Startup and staleness" - The cache is populated once at startup via a full API fetch, then kept current by WebSocket `state_changed` events — so states are available as soon as your app's `on_ready` hook runs. A periodic background poll (default every 30 seconds) guards against any events that were missed. During a HA reconnect the cache is temporarily cleared; the StateProxy marks itself not-ready and retries reads automatically, so your code does not need to handle this case. + The cache is populated once at startup via a full API fetch, then kept current by WebSocket `state_changed` events — so states are available as soon as your app's `on_initialize` hook runs. A periodic background poll (default every 30 seconds) guards against any events that were missed. During a HA reconnect the cache is temporarily cleared; the StateProxy marks itself not-ready and retries reads automatically, so your code does not need to handle this case. !!! note "Missing entities" `self.states.light.get("bedroom")` returns `None` when the entity is absent. `self.states.light["bedroom"]` raises `KeyError`. If you are not certain an entity exists, prefer `.get()` and check the result before use. diff --git a/tests/TESTING.md b/tests/TESTING.md index 9aa8dff84..c813de773 100644 --- a/tests/TESTING.md +++ b/tests/TESTING.md @@ -448,7 +448,7 @@ One file per user-visible subsystem: | `test_scheduler.py` | Scheduler: run_in, run_every, run_daily, cron triggers, job groups, jitter | | `test_state_proxy.py` | State proxy: initial cache, live updates, typed StateManager access | | `test_api.py` | HA REST/WebSocket API: get_state, call_service, fire_event | -| `test_app_lifecycle.py` | App lifecycle hooks: on_initialize, on_ready, on_shutdown | +| `test_app_lifecycle.py` | App lifecycle hooks: on_initialize, on_shutdown | | `test_reconnection.py` | WebSocket reconnection: disconnect detection, reconnect with subscriptions, state proxy refresh | | `test_shutdown.py` | Graceful shutdown: session status, resource teardown | | `test_web_api.py` | Web API endpoints: health, apps, config, telemetry, WebSocket events | From cb63af57fc1f200e0041478da2bd55307e91f79c Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Mon, 1 Jun 2026 17:16:05 -0500 Subject: [PATCH 011/160] =?UTF-8?q?fix:=20apps=20overview=20outline=20?= =?UTF-8?q?=E2=80=94=20@only=5Fapp=20decorator,=20not=20--app=20flag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../070-doc-overhaul/outlines/core-concepts/apps/overview.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/apps/overview.md b/design/specs/070-doc-overhaul/outlines/core-concepts/apps/overview.md index d7d8a1d74..b2a3a48c0 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/apps/overview.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/apps/overview.md @@ -24,7 +24,7 @@ Brief overview linking to each capability's page: #### H3: Run Background Tasks and Blocking Code ### H2: Restricting to a Single App During Development -`--app` flag for development. +`@only_app` decorator to isolate one app without editing config. ### H2: Broadcasting Events Between Apps `Bus.emit()` for inter-app communication. From b514d0dd058b7607a094349815075192e56372c0 Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Mon, 1 Jun 2026 17:19:32 -0500 Subject: [PATCH 012/160] =?UTF-8?q?chore:=20apps=20overview=20outline=20?= =?UTF-8?q?=E2=80=94=20link=20to=20all=20five=20handle=20pages=20in=20Next?= =?UTF-8?q?=20Steps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../070-doc-overhaul/outlines/core-concepts/apps/overview.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/apps/overview.md b/design/specs/070-doc-overhaul/outlines/core-concepts/apps/overview.md index b2a3a48c0..51fb12043 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/apps/overview.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/apps/overview.md @@ -33,7 +33,7 @@ Brief overview linking to each capability's page: `SyncApp` variant for simple scripts. ### H2: Next Steps -Links to Lifecycle, Configuration, Task Bucket. +Links to all sibling and handle pages: Lifecycle, Configuration, Task Bucket, Bus overview, Scheduler overview, States overview, API overview, Cache overview. ## Snippet Inventory From 0aacdba0c73502f362700b9406f002810011336e Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Mon, 1 Jun 2026 17:21:07 -0500 Subject: [PATCH 013/160] =?UTF-8?q?fix:=20task-bucket=20outline=20?= =?UTF-8?q?=E2=80=94=20make=5Fasync=5Fadapter,=20not=20normalize=5Fcallabl?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../outlines/core-concepts/apps/task-bucket.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/apps/task-bucket.md b/design/specs/070-doc-overhaul/outlines/core-concepts/apps/task-bucket.md index d791cd367..5510aca0a 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/apps/task-bucket.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/apps/task-bucket.md @@ -11,8 +11,8 @@ ### H2: Offloading Blocking Code `self.run_in_executor()` for sync/blocking calls (file I/O, HTTP libraries without async). -### H2: Normalizing Sync/Async Callables -`self.normalize_callable()` for handling both sync and async handlers uniformly. +### H2: Adapting Sync Callables to Async +`self.task_bucket.make_async_adapter()` for wrapping sync handlers so they run in the executor automatically. ### H2: Cross-Thread Communication #### H3: Posting to the Event Loop From 5d29f32013c650f9f9e645bfed4b6c3af3f1e09a Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Mon, 1 Jun 2026 17:25:57 -0500 Subject: [PATCH 014/160] fix: correct 20+ hallucinated identifiers across outline files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified by 7 parallel subagents grepping source code. Fixes: - handlers: RawCallServiceEvent→CallServiceEvent, RawEvent→Event, on_bus_error→Bus.on_error(), error_handler=→on_error= - task-bucket: create_task→spawn, run_in_executor→run_in_thread, call_soon→post_to_loop, run_coroutine→run_sync - scheduler: on_scheduler_error→on_error, job_id→db_id, removed non-existent cancelled field - state-registry: StateRegistry.get→StateRegistry.resolve - managing-helpers: generic methods→per-type methods - services: toggle→toggle_service - config: ssl_verify→verify_ssl, auto_reload→allow_reload_in_prod, app_dir→apps.directory, web_enabled→web_api.run, db_path→database.path, db_retention_days→database.retention_days - testing: HassetteHarness→AppTestHarness, separated make_mock_hassette from create_hassette_stub - operating: [hassette.log_levels]→[hassette.logging] --- .../outlines/core-concepts/api/managing-helpers.md | 10 +++++----- .../outlines/core-concepts/api/services.md | 2 +- .../outlines/core-concepts/apps/task-bucket.md | 8 ++++---- .../outlines/core-concepts/bus/handlers.md | 6 +++--- .../outlines/core-concepts/configuration/auth.md | 2 +- .../outlines/core-concepts/configuration/global.md | 8 ++++---- .../outlines/core-concepts/scheduler/management.md | 4 ++-- .../outlines/core-concepts/states/state-registry.md | 2 +- .../outlines/getting-started/docker-setup.md | 2 +- .../070-doc-overhaul/outlines/operating/log-levels.md | 2 +- .../070-doc-overhaul/outlines/testing/factories.md | 5 ++++- .../070-doc-overhaul/outlines/testing/overview.md | 2 +- .../specs/070-doc-overhaul/outlines/web-ui/overview.md | 2 +- 13 files changed, 29 insertions(+), 26 deletions(-) diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/api/managing-helpers.md b/design/specs/070-doc-overhaul/outlines/core-concepts/api/managing-helpers.md index 1e1e9d4d4..8ebffea9d 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/api/managing-helpers.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/api/managing-helpers.md @@ -11,22 +11,22 @@ Content source: `docs/pages/advanced/managing-helpers.md` HA helper types (InputBoolean, InputNumber, etc.) as typed Pydantic models. ### H2: Creating a Helper -`create_helper()` with typed model. +Per-type methods: `create_input_boolean()`, `create_input_number()`, etc. ### H2: Listing Helpers -`list_helpers()` with type filtering. +Per-type methods: `list_input_booleans()`, `list_input_numbers()`, `list_counters()`, `list_timers()`, etc. ### H2: Updating a Helper -`update_helper()` with partial updates. +Per-type methods: `update_input_boolean()`, `update_input_number()`, etc. ### H2: Deleting a Helper -`delete_helper()`. +Per-type methods: `delete_input_boolean()`, `delete_input_number()`, etc. ### H2: Idempotent Bootstrap (The Simple Pattern) Create-if-not-exists pattern for app initialization. ### H2: Counter Service-Call Shortcuts -`increment`, `decrement`, `reset` for input_number and counter helpers. +`increment_counter()`, `decrement_counter()`, `reset_counter()` for counter helpers. ### H2: Testing with the Harness How `RecordingApi` handles helper operations in tests. diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/api/services.md b/design/specs/070-doc-overhaul/outlines/core-concepts/api/services.md index 1ec30f000..4505736bb 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/api/services.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/api/services.md @@ -9,7 +9,7 @@ `call_service(domain, service, data)` pattern. ### H2: Convenience Helpers -`turn_on`, `turn_off`, `toggle` shortcuts. +`turn_on`, `turn_off`, `toggle_service` shortcuts. ### H2: Service Responses What `call_service` returns, response handling. diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/apps/task-bucket.md b/design/specs/070-doc-overhaul/outlines/core-concepts/apps/task-bucket.md index 5510aca0a..de15431ce 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/apps/task-bucket.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/apps/task-bucket.md @@ -6,19 +6,19 @@ ## Outline ### H2: Spawning Background Tasks -`self.create_task()` for fire-and-forget async work. +`self.task_bucket.spawn()` for fire-and-forget async work. ### H2: Offloading Blocking Code -`self.run_in_executor()` for sync/blocking calls (file I/O, HTTP libraries without async). +`self.task_bucket.run_in_thread()` for sync/blocking calls (file I/O, HTTP libraries without async). ### H2: Adapting Sync Callables to Async `self.task_bucket.make_async_adapter()` for wrapping sync handlers so they run in the executor automatically. ### H2: Cross-Thread Communication #### H3: Posting to the Event Loop -`self.call_soon()` for thread-safe event loop posting. +`self.task_bucket.post_to_loop()` for thread-safe event loop posting. #### H3: Running Async from Sync Code -`self.run_coroutine()` for calling async from sync contexts. +`self.task_bucket.run_sync()` for calling async from sync contexts. ### H2: Shutdown Behavior How pending tasks are handled during app shutdown. diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/bus/handlers.md b/design/specs/070-doc-overhaul/outlines/core-concepts/bus/handlers.md index 0bdc23e00..5d0a5645c 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/bus/handlers.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/bus/handlers.md @@ -6,7 +6,7 @@ ## Outline ### H2: Event Model -What events look like: `RawStateChangeEvent`, `RawCallServiceEvent`, `RawEvent`. The event dict structure. +What events look like: `RawStateChangeEvent`, `CallServiceEvent`, `Event`. The event dict structure. ### H2: Raw Event Handlers Handlers that receive the raw event dict. When to use: rare cases where DI doesn't cover the need, or when processing bulk events. Show the pattern. @@ -21,9 +21,9 @@ Cover event types beyond state changes: ### H2: Error Handling #### H3: App-Level Error Handler -`on_bus_error` override. +`Bus.on_error()` registration method. #### H3: Per-Registration Error Handler -`error_handler=` parameter on subscription methods. +`on_error=` parameter on subscription methods. #### H3: What `BusErrorContext` Contains Fields and how to use them for debugging. diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/configuration/auth.md b/design/specs/070-doc-overhaul/outlines/core-concepts/configuration/auth.md index 0e5d800a6..61420938d 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/configuration/auth.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/configuration/auth.md @@ -9,7 +9,7 @@ Where to set the token (env var, .env file). Link to HA Token getting-started page. ### H2: SSL Verification -`ssl_verify` setting for self-signed certs. +`verify_ssl` setting for self-signed certs. ### H2: File Locations Where .env is loaded from. diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/configuration/global.md b/design/specs/070-doc-overhaul/outlines/core-concepts/configuration/global.md index a776c7b63..b9aebc7f6 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/configuration/global.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/configuration/global.md @@ -8,19 +8,19 @@ Long reference page documenting every global setting in hassette.toml. Keep current structure — it's a lookup reference. ### H2: Connection Settings -`host`, `port`, `ssl_verify`, `token` location. +`host`, `port`, `verify_ssl`, `token` location. ### H2: Runtime Settings -`auto_reload`, `app_dir`, `project_dir`. +`allow_reload_in_prod`, `apps.directory`. ### H2: Storage Settings `data_dir`, `cache_dir`. ### H2: Web UI Settings -`web_enabled`, `web_host`, `web_port`, CORS, static files. +`web_api.run`, `web_api.run_ui`, `web_api.host`, `web_api.port`, CORS, static files. ### H2: Database Settings -`db_path`, `db_retention_days`. +`database.path`, `database.retention_days`. ### H2: Timeout Settings #### H3: WebSocket Resilience — reconnection, sliding window budget, backoff diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/scheduler/management.md b/design/specs/070-doc-overhaul/outlines/core-concepts/scheduler/management.md index 58bcf0f36..e9f4135bc 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/scheduler/management.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/scheduler/management.md @@ -6,7 +6,7 @@ ## Outline ### H2: The ScheduledJob Object -What `schedule()` returns. Fields: job_id, name, group, next_run, cancelled. +What `schedule()` returns. Fields: `db_id`, `name`, `group`, `next_run`. Note: no public `cancelled` field — cancellation state is checked via methods. ### H2: Cancelling Jobs `job.cancel()`, `cancel_group()`, `list_jobs()`, checking cancellation state. @@ -25,7 +25,7 @@ Job that cancels itself based on a condition. #### H3: Runs Too Often? ### H2: Error Handling -#### H3: App-Level Error Handler — `on_scheduler_error` +#### H3: App-Level Error Handler — `Scheduler.on_error()` #### H3: Per-Registration Error Handler — `error_handler=` #### H3: What `SchedulerErrorContext` Contains diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/states/state-registry.md b/design/specs/070-doc-overhaul/outlines/core-concepts/states/state-registry.md index 1b7ca42d1..14c4a2236 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/states/state-registry.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/states/state-registry.md @@ -14,7 +14,7 @@ Maps HA entity domains to Python state classes. Automatic registration via `__in #### H3: Automatic Registration State classes register themselves when defined. #### H3: Domain Lookup -`StateRegistry.get(domain)` → state class. +`StateRegistry.resolve(domain=...)` → state class. ### H2: Relationship with Type Registry #### H3: The Complete Flow diff --git a/design/specs/070-doc-overhaul/outlines/getting-started/docker-setup.md b/design/specs/070-doc-overhaul/outlines/getting-started/docker-setup.md index 9f8ac6e2d..1ce122c85 100644 --- a/design/specs/070-doc-overhaul/outlines/getting-started/docker-setup.md +++ b/design/specs/070-doc-overhaul/outlines/getting-started/docker-setup.md @@ -22,7 +22,7 @@ Table of HASSETTE__* env vars. ### H2: Production Deployment #### H3: Hot Reloading in Production -hassette.toml `auto_reload` setting, volume mount requirements. +hassette.toml `allow_reload_in_prod` setting, volume mount requirements. #### H3: Graceful Shutdown Docker stop signal handling. diff --git a/design/specs/070-doc-overhaul/outlines/operating/log-levels.md b/design/specs/070-doc-overhaul/outlines/operating/log-levels.md index 3b94ecb90..7afb5cfb3 100644 --- a/design/specs/070-doc-overhaul/outlines/operating/log-levels.md +++ b/design/specs/070-doc-overhaul/outlines/operating/log-levels.md @@ -10,7 +10,7 @@ Debug a specific area without flooding logs. Brief. ### H2: How It Works -`[hassette.log_levels]` in hassette.toml. Per-service granularity. +`[hassette.logging]` section in hassette.toml. Per-service granularity. ### H2: Available Fields Table of all service field names and what they control. diff --git a/design/specs/070-doc-overhaul/outlines/testing/factories.md b/design/specs/070-doc-overhaul/outlines/testing/factories.md index 2eb9178e0..111f2fc69 100644 --- a/design/specs/070-doc-overhaul/outlines/testing/factories.md +++ b/design/specs/070-doc-overhaul/outlines/testing/factories.md @@ -16,7 +16,10 @@ #### H3: `make_switch_state_dict` — typed switch state ### H2: `make_mock_hassette` -Full mock Hassette instance for web/API tests (the `create_hassette_stub()` pattern). +Full mock Hassette instance for unit tests. + +### H2: `create_hassette_stub` +Separate Tier 2 web-specific stub for HTTP/WebSocket tests. Not an alias for `make_mock_hassette`. ### H2: `make_test_config` Test configuration builder. diff --git a/design/specs/070-doc-overhaul/outlines/testing/overview.md b/design/specs/070-doc-overhaul/outlines/testing/overview.md index 7807653ad..dbd3e9fce 100644 --- a/design/specs/070-doc-overhaul/outlines/testing/overview.md +++ b/design/specs/070-doc-overhaul/outlines/testing/overview.md @@ -12,7 +12,7 @@ pytest + hassette test extras. Minimal test example with the harness. ### H2: The Test Harness -#### H3: Constructor — `HassetteHarness(AppClass, config)` parameters +#### H3: Constructor — `AppTestHarness(AppClass, config)` parameters #### H3: Properties — harness.bus, harness.scheduler, harness.api, etc. ### H2: State Seeding diff --git a/design/specs/070-doc-overhaul/outlines/web-ui/overview.md b/design/specs/070-doc-overhaul/outlines/web-ui/overview.md index 060587557..70281cbff 100644 --- a/design/specs/070-doc-overhaul/outlines/web-ui/overview.md +++ b/design/specs/070-doc-overhaul/outlines/web-ui/overview.md @@ -6,7 +6,7 @@ ## Outline ### H2: Enabling and Accessing -`web_enabled` setting, default port, how to access. +`web_api.run` and `web_api.run_ui` settings, default port (`web_api.port`), how to access. ### H2: Layout Sidebar navigation, status bar, command palette, alert banners. Consolidates content from old `layout.md`. From f261c90e302717b975a0d6cff9ea34c420e6897c Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Mon, 1 Jun 2026 17:29:59 -0500 Subject: [PATCH 015/160] =?UTF-8?q?fix:=20docker-setup=20outline=20?= =?UTF-8?q?=E2=80=94=20hot=20reload=20is=20file=5Fwatcher.watch=5Ffiles,?= =?UTF-8?q?=20not=20allow=5Freload=5Fin=5Fprod?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../070-doc-overhaul/outlines/getting-started/docker-setup.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/design/specs/070-doc-overhaul/outlines/getting-started/docker-setup.md b/design/specs/070-doc-overhaul/outlines/getting-started/docker-setup.md index 1ce122c85..11e301f99 100644 --- a/design/specs/070-doc-overhaul/outlines/getting-started/docker-setup.md +++ b/design/specs/070-doc-overhaul/outlines/getting-started/docker-setup.md @@ -22,7 +22,7 @@ Table of HASSETTE__* env vars. ### H2: Production Deployment #### H3: Hot Reloading in Production -hassette.toml `allow_reload_in_prod` setting, volume mount requirements. +hassette.toml `file_watcher.watch_files` setting (and `allow_reload_in_prod` guard), volume mount requirements. #### H3: Graceful Shutdown Docker stop signal handling. From df07792eb5604c660a0990dbb4eeaf7a7006aab2 Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Mon, 1 Jun 2026 17:32:18 -0500 Subject: [PATCH 016/160] chore: drop Execution Columns section from database-telemetry outline --- .../outlines/core-concepts/database-telemetry.md | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/database-telemetry.md b/design/specs/070-doc-overhaul/outlines/core-concepts/database-telemetry.md index ec7c1b9c4..13cd280fa 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/database-telemetry.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/database-telemetry.md @@ -6,10 +6,7 @@ ## Outline ### H2: What Is Collected -Listener invocations, scheduler executions, registration events. Source tier explanation. - -### H2: Execution Columns -Table of fields in the unified executions table. +Listener invocations, scheduler executions, registration events. Source tier explanation. Brief — the reader needs to know *what* is tracked, not the column schema. ### H2: Configuration Telemetry settings in hassette.toml. Retention policy. From efe4e1d57742b09978a603199bc4b0bacc1894fb Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Mon, 1 Jun 2026 17:33:55 -0500 Subject: [PATCH 017/160] =?UTF-8?q?chore:=20database-telemetry=20outline?= =?UTF-8?q?=20=E2=80=94=20add=20logs,=20dropped=20events,=20CLI=20equivale?= =?UTF-8?q?nts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../outlines/core-concepts/database-telemetry.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/database-telemetry.md b/design/specs/070-doc-overhaul/outlines/core-concepts/database-telemetry.md index 13cd280fa..a3577bdb2 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/database-telemetry.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/database-telemetry.md @@ -6,15 +6,21 @@ ## Outline ### H2: What Is Collected -Listener invocations, scheduler executions, registration events. Source tier explanation. Brief — the reader needs to know *what* is tracked, not the column schema. +Five data categories: listener invocations, scheduler executions, registration events (listeners + jobs), log records, and sessions. Also tracks dropped event counters (overflow, exhausted, shutdown). Brief — the reader needs to know *what* is tracked, not the column schema. ### H2: Configuration -Telemetry settings in hassette.toml. Retention policy. +Telemetry settings in hassette.toml (`database.*`). Retention policy. #### H3: How Retention Works ### H2: Monitoring Telemetry Health -#### H3: `/api/telemetry/status` — endpoint for checking telemetry pipeline -#### H3: `/api/health` — general health endpoint +Pair each API endpoint with its CLI equivalent so the reader knows both paths. + +#### H3: `/api/telemetry/status` and `hassette telemetry` +Checking the telemetry pipeline. +#### H3: `/api/health` and `hassette status` +General health endpoint. +#### H3: `hassette log`, `hassette execution` +Querying logs and execution history from the CLI. ### H2: Registration Persistence How listener and job registrations are stored. From eb01d40609ba7730abd6ff2adca8773763d97f1c Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Mon, 1 Jun 2026 17:34:34 -0500 Subject: [PATCH 018/160] =?UTF-8?q?chore:=20drop=20sessions=20from=20datab?= =?UTF-8?q?ase-telemetry=20outline=20=E2=80=94=20internal=20detail?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../outlines/core-concepts/database-telemetry.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/database-telemetry.md b/design/specs/070-doc-overhaul/outlines/core-concepts/database-telemetry.md index a3577bdb2..0e3ac1b83 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/database-telemetry.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/database-telemetry.md @@ -6,7 +6,7 @@ ## Outline ### H2: What Is Collected -Five data categories: listener invocations, scheduler executions, registration events (listeners + jobs), log records, and sessions. Also tracks dropped event counters (overflow, exhausted, shutdown). Brief — the reader needs to know *what* is tracked, not the column schema. +Four data categories: listener invocations, scheduler executions, registration events (listeners + jobs), and log records. Also tracks dropped event counters (overflow, exhausted, shutdown). Brief — the reader needs to know *what* is tracked, not the column schema. ### H2: Configuration Telemetry settings in hassette.toml (`database.*`). Retention policy. From 1d5e1130d80c2a749d50df11b5fdd88a5ca9dd75 Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Mon, 1 Jun 2026 17:36:38 -0500 Subject: [PATCH 019/160] chore: split System Internals into 3 pages with proper outlines and stubs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - internals/index.md — Architecture & Data Flow (component ownership, depends_on, wave ordering, event flow) - internals/service-details.md — Per-Service Internals (bus, scheduler, database, api, state, web) - internals/lifecycle.md — Resource Lifecycle & Supervision (state machine, readiness, RestartSpec, error routing) Each page has its own outline file with H2/H3 structure, snippet inventory, and cross-links. Nav updated in mkdocs.yml. --- .../outlines/core-concepts/internals.md | 45 ------------------- .../internals/architecture-data-flow.md | 36 +++++++++++++++ .../core-concepts/internals/lifecycle.md | 38 ++++++++++++++++ .../internals/service-details.md | 35 +++++++++++++++ docs/pages/core-concepts/internals/index.md | 3 ++ .../core-concepts/internals/lifecycle.md | 3 ++ .../internals/service-details.md | 3 ++ mkdocs.yml | 5 ++- 8 files changed, 122 insertions(+), 46 deletions(-) delete mode 100644 design/specs/070-doc-overhaul/outlines/core-concepts/internals.md create mode 100644 design/specs/070-doc-overhaul/outlines/core-concepts/internals/architecture-data-flow.md create mode 100644 design/specs/070-doc-overhaul/outlines/core-concepts/internals/lifecycle.md create mode 100644 design/specs/070-doc-overhaul/outlines/core-concepts/internals/service-details.md create mode 100644 docs/pages/core-concepts/internals/index.md create mode 100644 docs/pages/core-concepts/internals/lifecycle.md create mode 100644 docs/pages/core-concepts/internals/service-details.md diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/internals.md b/design/specs/070-doc-overhaul/outlines/core-concepts/internals.md deleted file mode 100644 index b1ca4652f..000000000 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/internals.md +++ /dev/null @@ -1,45 +0,0 @@ -# System Internals - -**Status:** Exists (574 lines), splitting into 2-3 pages per user decision -**Voice mode:** Concept — system-as-subject, no "you", contributor/deep-dive audience - -## Proposed Split - -The current single page covers 10 numbered sections. Split into: - -### Page 1: `internals/index.md` — Architecture & Data Flow -Sections 1-3 from current page, plus content moving from Architecture: -- Component Ownership (which service owns which state) -- Service Dependencies (depends_on, initialization order, cycle detection, framework dependency graph Mermaid diagram) -- Event and Data Flow (how events travel through the system) -- Wave-based startup/shutdown ordering -- EventStreamService constructor-time dependency note - -The `depends_on` code snippet (`index_depends_on.py`) moves here from Architecture. - -### Page 2: `internals/service-details.md` — Per-Service Internals -Sections 4-9 from current page: -- Bus Internals (dispatch, matching, handler invocation) -- Scheduler Internals (trigger evaluation, job execution) -- Database Internals (schema, migrations, unified executions table, sync registration) -- Api Internals (REST/WS interface, connection management) -- StateManager and StateProxy (proxy pattern, domain routing) -- Web/UI Layer (endpoint registration, SSE, static serving) - -### Page 3: `internals/lifecycle.md` — Resource Lifecycle & Supervision -Section 10 from current page: -- State Transitions (resource state machine) -- Readiness vs Running -- Wave Startup and Shutdown -- Service Supervision (RestartSpec, RestartType, sliding-window budget, error routing, new statuses) - -**Nav update needed:** Change `System Internals: pages/core-concepts/internals.md` to a subsection with 3 entries. - -## Snippet Inventory - -No code snippets — diagrams and tables. Some Mermaid diagrams may exist inline. - -## Cross-Links - -- **Links to:** Architecture, each core concept's overview page -- **Linked from:** Architecture (deep dive) diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/internals/architecture-data-flow.md b/design/specs/070-doc-overhaul/outlines/core-concepts/internals/architecture-data-flow.md new file mode 100644 index 000000000..c8cccbea7 --- /dev/null +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/internals/architecture-data-flow.md @@ -0,0 +1,36 @@ +# System Internals — Architecture & Data Flow + +**Status:** New page (content from current `internals.md` sections 1-3 + content from Architecture page) +**Voice mode:** Concept — system-as-subject, no "you", contributor/deep-dive audience + +## Outline + +### H2: Component Ownership +Which service owns which state. Map of services to the resources they manage. + +### H2: Service Dependencies +#### H3: `depends_on` ClassVar +How services declare startup dependencies. Code example (snippet `index_depends_on.py` moving from Architecture). +#### H3: Wave-Based Ordering +Dependency graph partitioned into levels. Each wave starts/shuts down concurrently; waves execute sequentially. +#### H3: Cycle Detection +`ValueError` with full cycle path at construction time. +#### H3: Framework Dependency Graph +Mermaid diagram showing all built-in services by startup wave (moving from Architecture page). + +### H2: Event and Data Flow +How events travel from HA WebSocket → EventStreamService → BusService → listener dispatch → handler invocation. + +### H2: EventStreamService Constructor-Time Dependency +Structural ordering via child registration order, not `depends_on`. + +## Snippet Inventory + +| Snippet | Status | Notes | +|---|---|---| +| `index_depends_on.py` | Move from `core-concepts/snippets/` | `depends_on` ClassVar example | + +## Cross-Links + +- **Links to:** Per-Service Internals, Lifecycle & Supervision, Architecture (back-link) +- **Linked from:** Architecture (deep dive), each core concept overview diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/internals/lifecycle.md b/design/specs/070-doc-overhaul/outlines/core-concepts/internals/lifecycle.md new file mode 100644 index 000000000..2975152a4 --- /dev/null +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/internals/lifecycle.md @@ -0,0 +1,38 @@ +# System Internals — Resource Lifecycle & Supervision + +**Status:** New page (content from current `internals.md` section 10) +**Voice mode:** Concept — system-as-subject, no "you", contributor/deep-dive audience + +## Outline + +### H2: Resource State Machine +State transitions diagram: CREATED → INITIALIZING → RUNNING → STOPPING → STOPPED (and error states FAILED, CRASHED). + +### H2: Readiness vs Running +`mark_ready()` signals readiness; RUNNING is the status. Why these are separate — a service can be running but not yet ready to serve dependents. + +### H2: Wave Startup and Shutdown +How the dependency graph drives startup waves (level 0 first, then level 1, etc.) and shutdown in reverse. + +### H2: Service Supervision +#### H3: RestartSpec +Class attribute on services: restart type, sliding-window budget, backoff parameters, error routing. +#### H3: RestartType +`PERMANENT` (always restart), `TRANSIENT` (restart on unexpected failure), `TEMPORARY` (never restart). +#### H3: Sliding-Window Budget +Intensity (max restarts) and period (window size). Budget resets on recovery. +#### H3: Error Routing +Fatal vs non-retryable error names. Three-layer routing: handler-level, service-level, framework-level. +#### H3: EXHAUSTED_COOLING +What happens when the restart budget runs out. Cooldown period, then budget resets. + +## Snippet Inventory + +| Snippet | Status | Notes | +|---|---|---| +| `internals_restart_spec.py` | Keep or move from `core-concepts/snippets/` | RestartSpec example | + +## Cross-Links + +- **Links to:** Architecture & Data Flow, Per-Service Internals, Operating/overview (runtime behavior references these mechanics) +- **Linked from:** Architecture & Data Flow, Operating overview diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/internals/service-details.md b/design/specs/070-doc-overhaul/outlines/core-concepts/internals/service-details.md new file mode 100644 index 000000000..18429950e --- /dev/null +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/internals/service-details.md @@ -0,0 +1,35 @@ +# System Internals — Per-Service Internals + +**Status:** New page (content from current `internals.md` sections 4-9) +**Voice mode:** Concept — system-as-subject, no "you", contributor/deep-dive audience + +## Outline + +### H2: Bus Internals +Event dispatch pipeline: topic matching, listener filtering, handler invocation order. How debounce/throttle/once are implemented. + +### H2: Scheduler Internals +Trigger evaluation loop, job heap, execution lifecycle. How `run_in`/`run_every`/`run_cron` translate to trigger objects. + +### H2: Database Internals +SQLite schema, migration system, unified executions table, synchronous registration pattern (why `db_id` is available immediately). + +### H2: Api Internals +REST and WebSocket interface to HA. Connection management, request routing, timeout handling. + +### H2: StateManager and StateProxy +Proxy pattern: StateProxy wraps the WS state cache, StateManager provides the app-facing typed access. Domain routing, DomainStates collection. + +### H2: Web/UI Layer +Endpoint registration, SSE streaming for live logs, static file serving, CORS configuration. + +## Snippet Inventory + +| Snippet | Status | Notes | +|---|---|---| +| `internals_restart_spec.py` | Review | May move to Lifecycle page instead | + +## Cross-Links + +- **Links to:** Architecture & Data Flow, Lifecycle & Supervision, each concept overview (Bus, Scheduler, etc.) +- **Linked from:** Architecture & Data Flow diff --git a/docs/pages/core-concepts/internals/index.md b/docs/pages/core-concepts/internals/index.md new file mode 100644 index 000000000..999525fca --- /dev/null +++ b/docs/pages/core-concepts/internals/index.md @@ -0,0 +1,3 @@ +# Architecture & Data Flow + +*This page is being rewritten as part of the documentation overhaul.* diff --git a/docs/pages/core-concepts/internals/lifecycle.md b/docs/pages/core-concepts/internals/lifecycle.md new file mode 100644 index 000000000..fd0ceb04a --- /dev/null +++ b/docs/pages/core-concepts/internals/lifecycle.md @@ -0,0 +1,3 @@ +# Resource Lifecycle & Supervision + +*This page is being rewritten as part of the documentation overhaul.* diff --git a/docs/pages/core-concepts/internals/service-details.md b/docs/pages/core-concepts/internals/service-details.md new file mode 100644 index 000000000..d4ea9d5a4 --- /dev/null +++ b/docs/pages/core-concepts/internals/service-details.md @@ -0,0 +1,3 @@ +# Per-Service Internals + +*This page is being rewritten as part of the documentation overhaul.* diff --git a/mkdocs.yml b/mkdocs.yml index 2c83d9d7b..45826d034 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -81,7 +81,10 @@ nav: - Authentication: pages/core-concepts/configuration/auth.md - Global Settings: pages/core-concepts/configuration/global.md - Applications: pages/core-concepts/configuration/applications.md - - System Internals: pages/core-concepts/internals.md + - System Internals: + - Architecture & Data Flow: pages/core-concepts/internals/index.md + - Per-Service Internals: pages/core-concepts/internals/service-details.md + - Resource Lifecycle & Supervision: pages/core-concepts/internals/lifecycle.md - Web UI: - Overview: pages/web-ui/index.md - Debug a Failing Handler: pages/web-ui/debug-handler.md From 512eeac65e4acbb26648607f764a68cae9ff8ee6 Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Mon, 1 Jun 2026 17:37:16 -0500 Subject: [PATCH 020/160] =?UTF-8?q?fix:=20apps=20overview=20=E2=80=94=20Ap?= =?UTF-8?q?pSync=20not=20SyncApp,=20framed=20for=20sync=20library=20compat?= =?UTF-8?q?=20not=20simplicity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../070-doc-overhaul/outlines/core-concepts/apps/overview.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/apps/overview.md b/design/specs/070-doc-overhaul/outlines/core-concepts/apps/overview.md index 51fb12043..9027b1261 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/apps/overview.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/apps/overview.md @@ -30,7 +30,7 @@ Brief overview linking to each capability's page: `Bus.emit()` for inter-app communication. ### H2: Synchronous Apps -`SyncApp` variant for simple scripts. +`AppSync` variant for apps where async adds unnecessary complexity or doesn't fit the libraries in use (e.g., `requests`, blocking database clients). ### H2: Next Steps Links to all sibling and handle pages: Lifecycle, Configuration, Task Bucket, Bus overview, Scheduler overview, States overview, API overview, Cache overview. From fb4ea56dd26ddedbe0ed335e3ac7d0388541a742 Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Mon, 1 Jun 2026 17:38:44 -0500 Subject: [PATCH 021/160] chore: remove deferred Automatic Type Conversion section from custom-extractors outline --- .../outlines/core-concepts/bus/custom-extractors.md | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/bus/custom-extractors.md b/design/specs/070-doc-overhaul/outlines/core-concepts/bus/custom-extractors.md index a09a9c351..904431d4a 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/bus/custom-extractors.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/bus/custom-extractors.md @@ -14,10 +14,7 @@ How accessors work, creating custom field accessors for event data. ### H2: AnnotationDetails The AnnotationDetails object that extractors receive. Fields and usage. -### H2: Automatic Type Conversion -**Note:** This was originally considered for this page but belongs in Type Registry instead (confirmed during T03). Remove if still here; the Type Registry page covers conversion. - -**Revision:** Keep only extractor-specific type conversion concerns here (e.g., how extractors interact with the type registry). General type conversion → Type Registry page. +General type conversion lives on the Type Registry page. This page covers only how extractors *interact* with the registry (e.g., calling converters from a custom extractor). ## Snippet Inventory From 3649dcca8a30673d80374f508797dc58d6b38c4b Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Mon, 1 Jun 2026 17:39:43 -0500 Subject: [PATCH 022/160] fix: predicate composition is AllOf/AnyOf, not &/| operators --- .../070-doc-overhaul/outlines/core-concepts/bus/filtering.md | 4 ++-- .../outlines/core-concepts/states/subscribing.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/bus/filtering.md b/design/specs/070-doc-overhaul/outlines/core-concepts/bus/filtering.md index e543a6020..bf4ef4e64 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/bus/filtering.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/bus/filtering.md @@ -6,13 +6,13 @@ ## Outline ### H2: How Filtering Works -Overview: predicates test events, conditions test values. Predicates compose with `&` and `|`. +Overview: predicates test events, conditions test values. Predicates compose with `AllOf` and `AnyOf`. ### H2: Filtering State Changes **Note:** Heavy overlap with States/Subscribing page. Decision: States/Subscribing covers the common state-change patterns (entity patterns, `changed` param, `changed_to`, `changed_from`, state-specific predicates). This page covers the general filtering mechanism and non-state-change filtering. Content that stays here: -- How predicates compose (`&`, `|`) +- How predicates compose (`AllOf`, `AnyOf`) - General event filtering concept ### H2: Filtering Service Calls diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/states/subscribing.md b/design/specs/070-doc-overhaul/outlines/core-concepts/states/subscribing.md index 515f4e6b5..64be57486 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/states/subscribing.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/states/subscribing.md @@ -24,7 +24,7 @@ Bridge page between Bus and States. Covers state-change-specific subscription pa `C.Increased`, `C.Decreased`, `C.InRange` — monitoring numeric changes. ### H2: Combining Predicates -`&` (and) and `|` (or) composition. Examples specific to state-change scenarios. +`AllOf` and `AnyOf` composition. Examples specific to state-change scenarios. ### H2: Attribute Changes `on_attribute_change` — monitoring specific attributes rather than the state string. @@ -43,7 +43,7 @@ Snippets moving from Bus/Filtering and new: | `filtering_increased_decreased.py` | Move from filtering/ | Numeric conditions | | `changed_false.py` | Move from filtering/ | `changed=False` example | | New: attribute change example | New | `on_attribute_change` with predicate | -| New: combined predicates for state | New | `&`/`|` composition in state context | +| New: combined predicates for state | New | `AllOf`/`AnyOf` composition in state context | ## Cross-Links From 1623c98926d1848e4278245b172f12b5888ee96b Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Mon, 1 Jun 2026 17:40:49 -0500 Subject: [PATCH 023/160] fix: add Accessors (A) reference to filtering page alongside P and C --- .../070-doc-overhaul/outlines/core-concepts/bus/filtering.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/bus/filtering.md b/design/specs/070-doc-overhaul/outlines/core-concepts/bus/filtering.md index bf4ef4e64..e80dd9031 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/bus/filtering.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/bus/filtering.md @@ -26,6 +26,8 @@ Dictionary filtering and predicate filtering for `on_call_service`. Full reference table: logic combinators, value/field matching, entity/domain/service matching, state change predicates. #### H3: Conditions (`C`) Full reference table: string matching, collection membership, none/missing checks, numeric comparison. +#### H3: Accessors (`A`) +Full reference table: built-in accessors (`get_state_value_new`, `get_state_value_old`, `get_service_data_key`, `get_path`, etc.). How accessors plug into predicates via the `source=` parameter. ## Snippet Inventory From 05c13f708844d8fa440bf19ffdb0d99279d73c48 Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Mon, 1 Jun 2026 17:57:30 -0500 Subject: [PATCH 024/160] chore: fix outline inaccuracies from source verification sweep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ran 8 parallel subagents to verify all 74 outlines against actual source code. Fixed wrong identifiers, missing features, and framing issues: Wrong identifiers fixed: - states/custom-states: get_states() → self.states[CustomStateClass] - states/overview: DomainStates has no filter()/all() - config/global: host/port don't exist (it's base_url), cache_dir doesn't exist - internals/lifecycle: CREATED/INITIALIZING → NOT_STARTED/STARTING - migration/scheduler: run_in_executor → task_bucket.run_in_thread() - testing/overview: harness.api → harness.api_recorder - web-ui/logs: SSE → WebSocket Framing fixes: - config/overview: env vars override TOML (precedence was backwards) - config/auth: token accepts 4 aliases - api/entities: get_states() has no filtering parameter - cache/overview: raw diskcache.Cache, keyed by class name - bus/custom-extractors: AnnotationDetails wraps extractors - states/subscribing: changed param is bool | ComparisonCondition High-impact missing items added: - bus/handlers: on_service_registered, Bus.emit(), internal event helpers, timeouts - bus/filtering: P.Not, P.Guard, full C/A reference lists - bus/di: Maybe* variants, EventContext, TypedStateChangeEvent - scheduler/methods: if_exists=, on_error=, timeout=, SchedulerSyncFacade - task-bucket: run_on_loop_thread, create_task_on_loop, pending_tasks, add - config/global: lifecycle, file_watcher, state_proxy, cache sections - states/subscribing: common parameters (name=, duration=, where=, etc.) - migration/bus+checklist: name= requirement, on_attribute_change - troubleshooting: ListenerNameRequiredError, DuplicateListenerError - docker-deps: HASSETTE__INSTALL_DEPS env var - testing: simulate_* surface, assert_called_partial/exact, seed_helper --- .claude/handoff.md | 61 +++++++++++++++++++ .../outlines/core-concepts/api/entities.md | 8 +-- .../core-concepts/apps/task-bucket.md | 10 ++- .../core-concepts/bus/custom-extractors.md | 2 +- .../core-concepts/bus/dependency-injection.md | 2 + .../outlines/core-concepts/bus/filtering.md | 6 +- .../outlines/core-concepts/bus/handlers.md | 11 +++- .../outlines/core-concepts/cache/overview.md | 12 ++-- .../outlines/core-concepts/cache/patterns.md | 3 +- .../core-concepts/configuration/auth.md | 2 +- .../core-concepts/configuration/global.md | 35 ++++++----- .../core-concepts/configuration/overview.md | 6 +- .../core-concepts/internals/lifecycle.md | 8 ++- .../core-concepts/scheduler/management.md | 3 +- .../core-concepts/scheduler/methods.md | 11 +++- .../core-concepts/states/custom-states.md | 3 +- .../outlines/core-concepts/states/overview.md | 2 +- .../core-concepts/states/subscribing.md | 7 ++- .../getting-started/docker-dependencies.md | 4 +- .../outlines/migration/bus.md | 6 ++ .../outlines/migration/checklist.md | 5 +- .../outlines/migration/scheduler.md | 2 +- .../outlines/testing/factories.md | 2 + .../outlines/testing/overview.md | 7 ++- .../troubleshooting/troubleshooting.md | 8 ++- .../070-doc-overhaul/outlines/web-ui/logs.md | 2 +- 26 files changed, 170 insertions(+), 58 deletions(-) create mode 100644 .claude/handoff.md diff --git a/.claude/handoff.md b/.claude/handoff.md new file mode 100644 index 000000000..ff25d0cba --- /dev/null +++ b/.claude/handoff.md @@ -0,0 +1,61 @@ +# Handoff: Doc Overhaul Brief for Issue #928 + +**Date:** 2026-05-31 +**Project:** hassette +**Directory:** /home/jessica/source/hassette/.claude/worktrees/928 +**Branch:** worktree-928 +**Tmux:** hassette-issue-928 + +## What We Were Working On + +Issue #928 calls for rewriting all 76 documentation pages from scratch using an outline-first process. The existing docs grew organically and have inconsistent depth, voice, and coverage despite mature standards (voice-guide.md, doc-rules.md). This session ran the grill and challenge workflows to produce a thorough brief before any implementation begins. The brief captures all key decisions, structural prescriptions, and process safeguards needed to execute the rewrite across three sequential phases. + +## Approach + +1. Deep-dived issue #928 to understand scope (76 pages, 352 snippets, 10 nav sections). +2. Ran `/mine.grill` — multi-angle interrogation across product, design, engineering, scope, and adversarial lenses. Pinned down audience priority, rewrite depth, snippet strategy, delivery model, voice drift mitigation, and branch strategy through 8 interactive questions. +3. Ran `/mine.challenge` against the resulting brief. Three critics (Documentation Architect, End-User Reader, Senior Engineer) produced 12 findings. All 12 were resolved — 3 auto-applied, 9 user-directed with the recommended option chosen each time. + +## Current State + +### Done +- Brief written, challenged, and committed: `design/specs/070-doc-overhaul/brief.md` +- Pushed to `worktree-928` on origin +- All 12 challenge findings resolved and applied to the brief + +### Not Started +- Phase 1: Site outline (page tree and nav structure) +- Phase 2: Per-page content outlines with snippet inventory +- Phase 3: Writing pages section-by-section + +## Uncommitted Changes + +None — all changes committed. + +## Decisions Made + +- **Truly blank slate** — every page starts empty, even pages already close to the voice standard. Exception carved out for troubleshooting/operational pages: mandatory pre-write knowledge inventory to preserve log signatures, timing values, and runbook commands. +- **Full structural freedom** — sections can be merged, split, renamed, or removed in Phase 1. Specific targets prescribed: delete "Advanced" section (rehome contents to core-concepts/states/ and troubleshooting), replace Web UI tab-by-tab with task-oriented structure, scope Architecture page to app-authors only (contributor content moves to internals.md). +- **Docs branch strategy** — section PRs merge to a long-lived docs branch, big bang PR to main when complete. Rebase checkpoint after each section PR to catch API drift. +- **Two exemplar pages** — one core concept, one getting-started/recipe. Selection is a Phase 1 deliverable with explicit criteria defined. Voice audit checklist (not just a subjective scan) also a Phase 1 deliverable. +- **Reader success criteria** — each section evaluated against concrete reader outcomes, not just voice consistency. +- **Snippet audit in Phase 2** — each outline declares needed examples; unclaimed snippets die. Stub-first convention during Phase 3 to keep CI green. + +## Open Questions + +- Which specific pages become the two exemplars (Phase 1 decides based on criteria in the brief) +- Whether to keep, condense, or drop the Migration section (8 AppDaemon pages) +- Pyright config scoping for snippet suppressions (pre-Phase 3 cleanup item) + +## Key Files + +- `design/specs/070-doc-overhaul/brief.md` — the challenged brief; starting point for `/mine.define` +- `.claude/rules/voice-guide.md` — 23 voice/style rules with before/after examples +- `.claude/rules/doc-rules.md` — page structure templates, snippet conventions, layering guidance +- `mkdocs.yml` — current nav structure (will change in Phase 1) + +## Next Steps + +1. Run `/mine.define design/specs/070-doc-overhaul` to turn the brief into a full spec with work packages +2. Execute Phase 1 (site outline) — the most consequential phase; deserves disproportionate scrutiny +3. Create the docs branch for incremental section PRs diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/api/entities.md b/design/specs/070-doc-overhaul/outlines/core-concepts/api/entities.md index 08aa2b249..81a842c52 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/api/entities.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/api/entities.md @@ -9,14 +9,14 @@ Entity, state, attributes — HA concepts mapped to Hassette types. ### H2: Retrieving States -`get_state(entity_id)` — single entity. +`get_state(entity_id)` — single entity. Also: `get_state_or_none()`, `get_state_value()`, `get_state_value_typed()`, `get_attribute(entity_id, attribute)`. #### H3: Raw vs Typed -Raw string state vs typed state model conversion. +`get_state_raw()` returns raw `HassStateDict`; `get_state()` returns typed model. #### H3: Checking Existence -What happens when an entity doesn't exist. +`entity_exists(entity_id)` for boolean check; `get_state_or_none()` for optional return. ### H2: Retrieving Multiple States -`get_states()` — all entities or filtered. +`get_states()` — returns all entities (no filtering parameter). `get_states_raw()` for raw dicts. ### H2: Entities Entity registry access. diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/apps/task-bucket.md b/design/specs/070-doc-overhaul/outlines/core-concepts/apps/task-bucket.md index de15431ce..d901a5128 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/apps/task-bucket.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/apps/task-bucket.md @@ -18,7 +18,15 @@ #### H3: Posting to the Event Loop `self.task_bucket.post_to_loop()` for thread-safe event loop posting. #### H3: Running Async from Sync Code -`self.task_bucket.run_sync()` for calling async from sync contexts. +`self.task_bucket.run_sync()` — takes a coroutine object, not a callable. Raises `RuntimeError` if called from within a running event loop. +#### H3: Running on the Loop Thread +`self.task_bucket.run_on_loop_thread()` — runs a sync function on the main event loop thread (for loop-affine code). +#### H3: Creating Tasks from Any Context +`self.task_bucket.create_task_on_loop()` — creates a task on the loop from any context. + +### H2: Task Lifecycle +#### H3: `add(task)` — register an externally-created `asyncio.Task` +#### H3: `pending_tasks()` — snapshot of non-completed tasks (for drain/test helpers) ### H2: Shutdown Behavior How pending tasks are handled during app shutdown. diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/bus/custom-extractors.md b/design/specs/070-doc-overhaul/outlines/core-concepts/bus/custom-extractors.md index 904431d4a..7ed5b5351 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/bus/custom-extractors.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/bus/custom-extractors.md @@ -12,7 +12,7 @@ How to implement the extractor protocol. When to write one (data not covered by How accessors work, creating custom field accessors for event data. ### H2: AnnotationDetails -The AnnotationDetails object that extractors receive. Fields and usage. +`AnnotationDetails` wraps an extractor (not "received by" it). Fields: `extractor` (required callable), `converter` (optional type converter). Placed inside `Annotated[T, AnnotationDetails(...)]` — the DI system (`extraction.py`) discovers it from the handler's signature. General type conversion lives on the Type Registry page. This page covers only how extractors *interact* with the registry (e.g., calling converters from a custom extractor). diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/bus/dependency-injection.md b/design/specs/070-doc-overhaul/outlines/core-concepts/bus/dependency-injection.md index c32d6889c..359a0a80b 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/bus/dependency-injection.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/bus/dependency-injection.md @@ -7,6 +7,8 @@ Already complete. Covers: annotation reference (state/identity/other extractors), combining annotations, union types, custom kwargs, handler signature restrictions. +**Ensure reference table includes:** `D.MaybeStateNew`, `D.MaybeStateOld`, `D.MaybeEntityId`, `D.MaybeDomain`, `D.EventContext`, `D.TypedStateChangeEvent[T]`. + ## Snippet Inventory All snippets written and tested in T03: diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/bus/filtering.md b/design/specs/070-doc-overhaul/outlines/core-concepts/bus/filtering.md index e80dd9031..5f14ac624 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/bus/filtering.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/bus/filtering.md @@ -23,11 +23,11 @@ Dictionary filtering and predicate filtering for `on_call_service`. ### H2: Complete Reference #### H3: Predicates (`P`) -Full reference table: logic combinators, value/field matching, entity/domain/service matching, state change predicates. +Full reference table. Include: `AllOf`, `AnyOf`, `Not`, `Guard`, `StateFrom`, `StateTo`, `StateFromTo`, `DidChange`, `IsPresent`, `IsMissing`, `ValueIs`, `EntityMatches`, `DomainMatches`, `ServiceMatches`, and any others in `predicates.py`. #### H3: Conditions (`C`) -Full reference table: string matching, collection membership, none/missing checks, numeric comparison. +Full reference table. Include: `Increased`, `Decreased`, `InRange`, `Comparison` (raw operator), `IsNone`, `IsNotNone`, `Present`, `Missing` (sentinel-based, distinct from `IsNone`), `IsIn`, `IsOrContains`, `NotIntersects`, `StartsWith`, `EndsWith`, `Contains`, `Regex`, `Glob`. #### H3: Accessors (`A`) -Full reference table: built-in accessors (`get_state_value_new`, `get_state_value_old`, `get_service_data_key`, `get_path`, etc.). How accessors plug into predicates via the `source=` parameter. +Full reference table grouped by category: state value (`get_state_value_new`, `get_state_value_old`, `get_state_value_old_new`), state object (`get_state_object_old`, `get_state_object_new`), attribute (`get_attr_old`, `get_attr_new`, `get_attr_old_new`, `get_attrs_old`, `get_attrs_new`, `get_all_attrs_old`, `get_all_attrs_new`), identity (`get_domain`, `get_entity_id`, `get_context`), service (`get_service`, `get_service_data`, `get_service_data_key`), path (`get_path`), diff (`get_all_changes`). How accessors plug into predicates via the `source=` parameter. ## Snippet Inventory diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/bus/handlers.md b/design/specs/070-doc-overhaul/outlines/core-concepts/bus/handlers.md index 5d0a5645c..1c7d1c653 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/bus/handlers.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/bus/handlers.md @@ -14,10 +14,12 @@ Handlers that receive the raw event dict. When to use: rare cases where DI doesn ### H2: Non-State Event Types Cover event types beyond state changes: - `on_call_service` — reacting to service calls +- `on_service_registered` — reacting to new HA service registrations - `on` — subscribing to raw HA event types (e.g., `event_triggered`, `automation_triggered`) - `on_component_loaded` — HA component load events -- Hassette internal events -- HA startup/shutdown events +- HA startup/shutdown events (`on_homeassistant_start`, `on_homeassistant_stop` — wrappers around `on_call_service` for `homeassistant` domain) +- Hassette internal events — typed helpers: `on_hassette_service_status`, `on_hassette_service_failed`, `on_hassette_service_crashed`, `on_hassette_service_started`, `on_websocket_connected`, `on_websocket_disconnected`, `on_app_state_changed`, `on_app_running`, `on_app_stopping` +- `Bus.emit()` for broadcasting Hassette-internal events between apps ### H2: Error Handling #### H3: App-Level Error Handler @@ -25,7 +27,10 @@ Cover event types beyond state changes: #### H3: Per-Registration Error Handler `on_error=` parameter on subscription methods. #### H3: What `BusErrorContext` Contains -Fields and how to use them for debugging. +Fields: `topic`, `listener_name`, `event`, plus inherited fields from `ErrorContext` (`exception`, `traceback`). + +### H2: Timeout Configuration +`timeout=` and `timeout_disabled=` options on subscription methods. ### H2: Subscription Mechanics #### H3: The `name=` Parameter (Required) diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/cache/overview.md b/design/specs/070-doc-overhaul/outlines/core-concepts/cache/overview.md index b5d2b28fc..ee5e6b0dc 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/cache/overview.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/cache/overview.md @@ -9,16 +9,16 @@ Persistent key-value storage for data that survives restarts. Not for entity state (use StateManager) or temporary data. ### H2: Basic Usage -`self.cache.get()`, `self.cache.set()`, `self.cache.delete()`. +`self.cache` is a raw `diskcache.Cache` instance — the full diskcache API is available directly (`.get()`, `.set()`, `.delete()`, `.pop()`, `.expire()`, etc.). ### H2: How It Works -#### H3: Storage Location — DiskCache on filesystem -#### H3: Shared Cache — all app instances share one cache, keyed by app -#### H3: Lazy Initialization — cache dir created on first access -#### H3: Automatic Cleanup — TTL expiry +#### H3: Storage Location — `diskcache.Cache` backed by filesystem +#### H3: Shared Cache — instances of the same class share one cache (keyed by class name, path: `data_dir//cache`) +#### H3: Lazy Initialization — cache dir created on first access via `cached_property` +#### H3: Automatic Cleanup — TTL expiry, silent eviction when `size_limit` reached ### H2: Configuration -Cache-related settings in hassette.toml. +Only setting: `default_cache_size` (default 100 MiB) in root `HassetteConfig`. No `[hassette.cache]` section. Cache path is derived automatically. ### H2: Lifecycle When cache is available during app lifecycle. diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/cache/patterns.md b/design/specs/070-doc-overhaul/outlines/core-concepts/cache/patterns.md index 0eb06da17..d7af2556f 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/cache/patterns.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/cache/patterns.md @@ -30,7 +30,8 @@ Batch cache operations for performance. ### H2: Troubleshooting #### H3: Cache Not Persisting -#### H3: Cache Size Exceeded +#### H3: Cache Size — Silent Eviction +DiskCache evicts old entries when `size_limit` is reached; no error is raised. #### H3: Debugging Cache Operations ## Snippet Inventory diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/configuration/auth.md b/design/specs/070-doc-overhaul/outlines/core-concepts/configuration/auth.md index 61420938d..eb3539746 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/configuration/auth.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/configuration/auth.md @@ -6,7 +6,7 @@ ## Outline ### H2: Home Assistant Token -Where to set the token (env var, .env file). Link to HA Token getting-started page. +Token field accepts four aliases: `token`, `hassette__token`, `ha_token`, `home_assistant_token`. Set via env var or .env file. Link to HA Token getting-started page. ### H2: SSL Verification `verify_ssl` setting for self-signed certs. diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/configuration/global.md b/design/specs/070-doc-overhaul/outlines/core-concepts/configuration/global.md index b9aebc7f6..1f23dbc99 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/configuration/global.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/configuration/global.md @@ -8,23 +8,31 @@ Long reference page documenting every global setting in hassette.toml. Keep current structure — it's a lookup reference. ### H2: Connection Settings -`host`, `port`, `verify_ssl`, `token` location. +`base_url` (single URL, default `http://127.0.0.1:8123`), `verify_ssl`, `token` location. ### H2: Runtime Settings -`allow_reload_in_prod`, `apps.directory`. +`allow_reload_in_prod`, `apps.directory`, `strict_lifecycle`, `asyncio_debug_mode`, `allow_only_app_in_prod`, `run_app_precheck`, `allow_startup_if_app_precheck_fails`, `import_dot_env_files`. ### H2: Storage Settings -`data_dir`, `cache_dir`. +`data_dir` (cache paths derived automatically from `data_dir//cache`; no `cache_dir` config key). ### H2: Web UI Settings -`web_api.run`, `web_api.run_ui`, `web_api.host`, `web_api.port`, CORS, static files. +`web_api.run`, `web_api.run_ui`, `web_api.ui_hot_reload`, `web_api.host`, `web_api.port`, `web_api.cors_origins`, `web_api.event_buffer_size`, `web_api.log_buffer_size`, `web_api.job_history_size`. ### H2: Database Settings `database.path`, `database.retention_days`. +### H2: Lifecycle Settings +`LifecycleConfig`: `startup_timeout_seconds`, `app_startup_timeout_seconds`, `app_shutdown_timeout_seconds`, `event_handler_timeout_seconds`, `error_handler_timeout_seconds`, `run_sync_timeout_seconds`. + +### H2: File Watcher Settings +`FileWatcherConfig`: `watch_files`, `debounce_milliseconds`, `step_milliseconds`. + +### H2: WebSocket Settings +`connect_retry_*` fields (initial connect retries within the service) and `early_drop_*` fields. Note: these are separate from the ServiceWatcher restart budget. + ### H2: Timeout Settings -#### H3: WebSocket Resilience — reconnection, sliding window budget, backoff -#### H3: Timeouts — per-item overrides, disabling, limitations +Per-item overrides, disabling, limitations. ### H2: Scheduler Settings Default scheduler configuration. @@ -33,22 +41,17 @@ Default scheduler configuration. Log level, format. ### H2: Bus Filtering Settings -Default bus filter behavior. +`bus_excluded_domains`, `bus_excluded_entities`, `hassette_event_buffer_size`. -### H2: Production Settings -Settings recommended for production. +### H2: State Proxy Settings +`state_proxy_poll_interval_seconds`, `disable_state_proxy_polling`. -### H2: App Detection Settings -How Hassette finds apps. - -### H2: Advanced Settings -Rarely-changed settings. +### H2: Cache Settings +`default_cache_size` (default 100 MiB). No other cache config — paths are derived from `data_dir`. ### H2: Service Restart Policy Default RestartSpec configuration. -### H2: Other Advanced Settings - ### H2: Basic Example Complete hassette.toml example. diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/configuration/overview.md b/design/specs/070-doc-overhaul/outlines/core-concepts/configuration/overview.md index a2bcf23f8..74e65757e 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/configuration/overview.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/configuration/overview.md @@ -6,10 +6,10 @@ ## Outline ### H2: Configuration Sources -hassette.toml (primary), environment variables (overrides), .env files. +Priority order (highest wins): init kwargs → env vars (`HASSETTE__` prefix, `__` nested delimiter) → dotenv (.env) → file secrets → hassette.toml. TOML is the base; env vars override it. -### H2: File Locations -Where Hassette looks for config files. Search order. +### H2: Search Paths +TOML: `/config/hassette.toml`, `hassette.toml`, `./config/hassette.toml`. `.env`: `/config/.env`, `.env`, `./config/.env`. Docker `/config/` path is first. ### H2: Configuration Sections Brief list of what's configurable, linking to sub-pages. diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/internals/lifecycle.md b/design/specs/070-doc-overhaul/outlines/core-concepts/internals/lifecycle.md index 2975152a4..1ab263775 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/internals/lifecycle.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/internals/lifecycle.md @@ -6,7 +6,7 @@ ## Outline ### H2: Resource State Machine -State transitions diagram: CREATED → INITIALIZING → RUNNING → STOPPING → STOPPED (and error states FAILED, CRASHED). +State transitions diagram: `NOT_STARTED` → `STARTING` → `RUNNING` → `STOPPING` → `STOPPED`. Error/terminal states: `FAILED`, `CRASHED`, `EXHAUSTED_COOLING`, `EXHAUSTED_DEAD`. ### H2: Readiness vs Running `mark_ready()` signals readiness; RUNNING is the status. Why these are separate — a service can be running but not yet ready to serve dependents. @@ -23,8 +23,10 @@ Class attribute on services: restart type, sliding-window budget, backoff parame Intensity (max restarts) and period (window size). Budget resets on recovery. #### H3: Error Routing Fatal vs non-retryable error names. Three-layer routing: handler-level, service-level, framework-level. -#### H3: EXHAUSTED_COOLING -What happens when the restart budget runs out. Cooldown period, then budget resets. +#### H3: Exhaustion States +`EXHAUSTED_COOLING` (cooldown period after budget runs out) → either budget resets or transitions to `EXHAUSTED_DEAD` after `max_cooldown_cycles` (0 = infinite). +#### H3: Backoff Parameters +`backoff_base_seconds`, `backoff_multiplier`, `backoff_max_seconds`, `startup_timeout_seconds`. ## Snippet Inventory diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/scheduler/management.md b/design/specs/070-doc-overhaul/outlines/core-concepts/scheduler/management.md index e9f4135bc..a7295a62b 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/scheduler/management.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/scheduler/management.md @@ -6,7 +6,7 @@ ## Outline ### H2: The ScheduledJob Object -What `schedule()` returns. Fields: `db_id`, `name`, `group`, `next_run`. Note: no public `cancelled` field — cancellation state is checked via methods. +What `schedule()` returns. Fields: `db_id`, `name`, `group`, `next_run`, `fire_at` (actual dispatch time after jitter, distinct from `next_run`). Note: no public `cancelled` field — cancellation state is checked via methods. ### H2: Cancelling Jobs `job.cancel()`, `cancel_group()`, `list_jobs()`, checking cancellation state. @@ -28,6 +28,7 @@ Job that cancels itself based on a condition. #### H3: App-Level Error Handler — `Scheduler.on_error()` #### H3: Per-Registration Error Handler — `error_handler=` #### H3: What `SchedulerErrorContext` Contains +Fields: `exception`, `traceback` (from `ErrorContext` base), `job_name`, `job_group`, `args`, `kwargs`. ## Snippet Inventory diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/scheduler/methods.md b/design/specs/070-doc-overhaul/outlines/core-concepts/scheduler/methods.md index aa7afa8a5..d1528a252 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/scheduler/methods.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/scheduler/methods.md @@ -28,10 +28,15 @@ Cron expression syntax, examples. `jitter=` parameter for randomizing execution times. ### H2: Idempotent Registration -`name=` parameter for preventing duplicate jobs. +`name=` identifies the job; `if_exists=` (`"error"`, `"skip"`, `"replace"`) controls behavior on duplicate name. -### H2: Passing Arguments to Handlers -`args=` and `kwargs=` parameters. +### H2: Per-Job Options +#### H3: `on_error=` — per-registration error handler +#### H3: `timeout=` / `timeout_disabled=` — per-job timeout control +#### H3: `args=` and `kwargs=` — passing arguments to handlers + +### H2: Synchronous Scheduling +`self.scheduler.sync` (`SchedulerSyncFacade`) — mirrors all methods as blocking calls for `AppSync` hooks. ### H2: Custom Triggers Implementing `TriggerProtocol` for custom scheduling logic. diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/states/custom-states.md b/design/specs/070-doc-overhaul/outlines/core-concepts/states/custom-states.md index bed3a908a..02a570e90 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/states/custom-states.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/states/custom-states.md @@ -22,7 +22,8 @@ Defining a state class for a domain Hassette doesn't cover (custom integrations, Typed attributes beyond the base `value` field. ### H2: Using Custom States in Apps -#### H3: Via `get_states()` +#### H3: Via `self.states[CustomStateClass]` +Generic access returns a `DomainStates` collection of the custom type. #### H3: With Dependency Injection #### H3: Direct API Access diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/states/overview.md b/design/specs/070-doc-overhaul/outlines/core-concepts/states/overview.md index d81068cee..4d87d46be 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/states/overview.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/states/overview.md @@ -15,7 +15,7 @@ Mermaid diagram showing StateManager → StateProxy → DomainStates flow. #### H3: Iteration ### H2: DomainStates Collection Interface -Methods available on domain collections (filter, all, etc.). +Methods: `get()`, `items()`, `keys()`, `values()`, `to_dict()`, `__iter__`, `__len__`, `__contains__`, `__getitem__`, `__bool__`. ### H2: Built-in State Types Table of all auto-generated domain state classes (SensorState, LightState, etc.) with key attributes. diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/states/subscribing.md b/design/specs/070-doc-overhaul/outlines/core-concepts/states/subscribing.md index 64be57486..ec7068108 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/states/subscribing.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/states/subscribing.md @@ -11,10 +11,10 @@ Bridge page between Bus and States. Covers state-change-specific subscription pa `on_state_change` and `on_attribute_change` — the two primary state subscription methods. Entity ID patterns (exact, glob, domain wildcard). ### H2: State-Specific DI Annotations -`D.StateNew[T]`, `D.StateOld[T]`, `D.MaybeStateNew[T]`, `D.MaybeStateOld[T]` — shown in state-change context (links to DI page for full reference). +`D.StateNew[T]`, `D.StateOld[T]`, `D.MaybeStateNew[T]`, `D.MaybeStateOld[T]`, `D.TypedStateChangeEvent[T]` — shown in state-change context (links to DI page for full reference). ### H2: The `changed` Parameter -`changed=True` (default) vs `changed=False`. When to use each. +Type is `bool | ComparisonCondition`, not just bool. `True` (default), `False`, or a `ComparisonCondition` (e.g., `C.Increased()`) that compares old vs new values. ### H2: Matching State Values #### H3: `changed_to` and `changed_from` — simple value matching @@ -29,6 +29,9 @@ Bridge page between Bus and States. Covers state-change-specific subscription pa ### H2: Attribute Changes `on_attribute_change` — monitoring specific attributes rather than the state string. +### H2: Common Parameters +`name=` (required), `duration=`, `immediate=`, `on_error=`, `where=` (additional predicates), `timeout=`, `timeout_disabled=`. + ### H2: See Also → Bus overview (general subscription), → Bus Filtering (service call filtering, complete predicate/condition reference), → DI page (full annotation reference) diff --git a/design/specs/070-doc-overhaul/outlines/getting-started/docker-dependencies.md b/design/specs/070-doc-overhaul/outlines/getting-started/docker-dependencies.md index 8b1a93ea2..a632b15e7 100644 --- a/design/specs/070-doc-overhaul/outlines/getting-started/docker-dependencies.md +++ b/design/specs/070-doc-overhaul/outlines/getting-started/docker-dependencies.md @@ -12,13 +12,13 @@ How Hassette's Docker entrypoint handles dependency installation at startup. Bri Hassette pins its own deps via constraints file to prevent conflicts. ### H2: How the Startup Script Works -What happens at container start: detect project type, install deps, launch. +What happens at container start: detect project type, install deps, launch. **`HASSETTE__INSTALL_DEPS=1`** must be set to activate dependency installation — without it, no requirements/pyproject install runs. #### H3: Key Behaviors Bulleted list of the script's decisions. ### H2: Understanding APP_DIR vs PROJECT_DIR -When to use which env var. Table or short comparison. +When to use which env var. Canonical env var: `HASSETTE__APPS__DIRECTORY` (legacy fallback: `HASSETTE__APP_DIR`). Table or short comparison. ### H2: Project Structures #### H3: Simple Flat Structure diff --git a/design/specs/070-doc-overhaul/outlines/migration/bus.md b/design/specs/070-doc-overhaul/outlines/migration/bus.md index 011d78de2..ba798d7e9 100644 --- a/design/specs/070-doc-overhaul/outlines/migration/bus.md +++ b/design/specs/070-doc-overhaul/outlines/migration/bus.md @@ -19,6 +19,12 @@ What changes: `listen_state` → `on_state_change`, `listen_event` → `on`. #### H3: Hassette: with DI (recommended) #### H3: Hassette: with full event object +### H2: Attribute Change Listeners +AppDaemon `listen_state(..., attribute=...)` → Hassette `on_attribute_change(entity_id, attribute, ...)`. + +### H2: The `name=` Requirement +All bus subscription methods require `name=`. Omitting it raises `ListenerNameRequiredError`. Most common migration breakage point. + ### H2: Canceling Subscriptions Handle patterns comparison. diff --git a/design/specs/070-doc-overhaul/outlines/migration/checklist.md b/design/specs/070-doc-overhaul/outlines/migration/checklist.md index 7198caf8e..0bb8af65b 100644 --- a/design/specs/070-doc-overhaul/outlines/migration/checklist.md +++ b/design/specs/070-doc-overhaul/outlines/migration/checklist.md @@ -30,7 +30,10 @@ Write tests for the migrated app. Run against real HA and verify behavior. ### H2: Common Pitfalls -Known gotchas from the migration. +Known gotchas from the migration: +- `name=` required on all bus subscriptions (`ListenerNameRequiredError`) +- `run_daily` signature differs from AppDaemon (takes `at="HH:MM"`, DST-safe) +- Blocking code must use `task_bucket.run_in_thread()`, not `run_in_executor` ## Snippet Inventory diff --git a/design/specs/070-doc-overhaul/outlines/migration/scheduler.md b/design/specs/070-doc-overhaul/outlines/migration/scheduler.md index 2b419ceca..e93d28770 100644 --- a/design/specs/070-doc-overhaul/outlines/migration/scheduler.md +++ b/design/specs/070-doc-overhaul/outlines/migration/scheduler.md @@ -21,7 +21,7 @@ Full example: daily task in AppDaemon vs Hassette. Complete before/after. ### H2: Blocking Work in Scheduler Callbacks -`run_in_executor` for blocking code. +`task_bucket.run_in_thread()` for blocking code. (There is no `run_in_executor`.) Alternatively, use `AppSync` with sync hooks for apps built around blocking libraries. ## Snippet Inventory diff --git a/design/specs/070-doc-overhaul/outlines/testing/factories.md b/design/specs/070-doc-overhaul/outlines/testing/factories.md index 111f2fc69..9df0e0bf2 100644 --- a/design/specs/070-doc-overhaul/outlines/testing/factories.md +++ b/design/specs/070-doc-overhaul/outlines/testing/factories.md @@ -7,7 +7,9 @@ ### H2: Event Factories #### H3: `create_state_change_event` — build a state change event dict +#### H3: `make_full_state_change_event` — build from pre-made state dicts #### H3: `create_call_service_event` — build a service call event dict +#### H3: `create_component_loaded_event`, `create_service_registered_event` ### H2: State Factories #### H3: `make_state_dict` — raw state dict diff --git a/design/specs/070-doc-overhaul/outlines/testing/overview.md b/design/specs/070-doc-overhaul/outlines/testing/overview.md index dbd3e9fce..628394b6a 100644 --- a/design/specs/070-doc-overhaul/outlines/testing/overview.md +++ b/design/specs/070-doc-overhaul/outlines/testing/overview.md @@ -13,10 +13,10 @@ Minimal test example with the harness. ### H2: The Test Harness #### H3: Constructor — `AppTestHarness(AppClass, config)` parameters -#### H3: Properties — harness.bus, harness.scheduler, harness.api, etc. +#### H3: Properties — `harness.bus`, `harness.scheduler`, `harness.api_recorder` (not `harness.api`), etc. ### H2: State Seeding -Pre-populating entity states before running the app. +`harness.set_state()`, `harness.set_states()` (bulk), `harness.seed_helper()` (helper config for CRUD tests). ### H2: Simulating Events #### H3: State Changes @@ -25,9 +25,12 @@ Pre-populating entity states before running the app. #### H3: Timeouts and Slow Handlers #### H3: Typed Dependency Injection in Handlers #### H3: Hassette Service Events +Also note the full `simulate_*` surface: `simulate_component_loaded`, `simulate_service_registered`, `simulate_websocket_connected/disconnected`, `simulate_homeassistant_restart/start/stop`, `simulate_app_state_changed/running/stopping`. ### H2: Asserting API Calls #### H3: `assert_called` — verify service calls were made +#### H3: `assert_called_partial` — subset match on kwargs +#### H3: `assert_called_exact` — exact kwargs match #### H3: `assert_not_called` #### H3: `assert_call_count` #### H3: `get_calls` diff --git a/design/specs/070-doc-overhaul/outlines/troubleshooting/troubleshooting.md b/design/specs/070-doc-overhaul/outlines/troubleshooting/troubleshooting.md index 1c0c991fa..7a1c32887 100644 --- a/design/specs/070-doc-overhaul/outlines/troubleshooting/troubleshooting.md +++ b/design/specs/070-doc-overhaul/outlines/troubleshooting/troubleshooting.md @@ -13,10 +13,16 @@ Token issues, connection refused/timeout. Links to Auth, Docker Troubleshooting. ### H2: Apps Not Loading App not discovered, import errors, precheck failures. **KI-04**: Include all three log signatures and the `allow_startup_if_app_precheck_fails` workaround. +### H2: Handler Registration Fails — `ListenerNameRequiredError` +`name=` is required on every bus subscription. Most common error for new users and migrators. + +### H2: Duplicate Handler — `DuplicateListenerError` +Same `(app_key, instance_index, name, topic)` registered twice in a session. + ### H2: Event Handler Never Runs **KI-05**: `changed_to` type mismatch (string vs bool). **KI-06**: `bus_excluded_domains`/`bus_excluded_entities` silently drop events. -**KI-07**: Attribute-only changes require `changed=False`. +**KI-07**: Attribute-only changes — use `on_attribute_change` for dedicated attribute monitoring; `on_state_change(changed=False)` fires on every state event regardless. Also: entity ID typos, app not enabled. ### H2: Scheduler Not Firing diff --git a/design/specs/070-doc-overhaul/outlines/web-ui/logs.md b/design/specs/070-doc-overhaul/outlines/web-ui/logs.md index 68ee402e4..c5ba8f724 100644 --- a/design/specs/070-doc-overhaul/outlines/web-ui/logs.md +++ b/design/specs/070-doc-overhaul/outlines/web-ui/logs.md @@ -18,7 +18,7 @@ Customizing visible columns. Expanding a log entry to see full context. ### H2: Live Streaming -SSE-based live log tail. Auto-pause on sort behavior. +WebSocket-based live log tail (not SSE). Auto-pause on sort behavior. Runtime log-level control via the WebSocket connection. ### H2: Execution ID Filtering Tracing a single handler/job execution across log entries. From 0f20b056e951a08d6af62be0678fc82ded09595d Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Mon, 1 Jun 2026 18:04:33 -0500 Subject: [PATCH 025/160] chore: fix subtler outline inaccuracies from second verification pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 2 verification with 8 subagents, focused on parameter types, misleading simplifications, and cross-page inconsistencies. Hallucinated entries removed: - P.StateFromTo does not exist (use separate StateFrom + StateTo) - C.InRange does not exist (use C.Comparison for range checks) Wrong descriptions fixed: - RestartType: TRANSIENT/TEMPORARY both restart within budget; they differ on exhaustion behavior (cooldown vs immediate death) - Cache data types: picklable (not JSON-serializable) — diskcache uses pickle - call_service: data is **kwargs, not positional dict; target is separate - turn_on/turn_off/toggle_service: default domain='homeassistant' is deprecated - Service Restart Policy: code-level class attribute, not hassette.toml setting - Config/applications: enabled (AppManifest) vs log_level (AppConfig) are at different TOML layers Important missing items added: - filtering: attribute predicates (AttrFrom/AttrTo/AttrDidChange/AttrComparison), ServiceDataWhere, StateDidChange, StateComparison, NotIn, Intersects - subscribing: name= flagged as required (raises ListenerNameRequiredError), on_attribute_change takes attr as required 2nd arg, once/debounce/throttle in common params, immediate= ValueError with globs - scheduler/methods: run_once if_past= parameter - lifecycle: cooldown_seconds in backoff params, all 10 LifecycleConfig fields - config/global: LoggingConfig expanded (13 per-service levels, debug flags, log_format, persistence), max_recovery_seconds in WebSocket settings - operating: three-layer reconnection model (connect/early-drop/ServiceWatcher), full topic strings for bus events - migration/scheduler: Hassette-only methods (run_once/minutely/hourly/cron) - migration/api: domain-typed access as recommended path - debounce recipe: debounce+throttle mutually exclusive --- .../outlines/core-concepts/api/services.md | 6 +++--- .../outlines/core-concepts/bus/filtering.md | 4 ++-- .../outlines/core-concepts/cache/overview.md | 2 +- .../outlines/core-concepts/configuration/applications.md | 2 +- .../outlines/core-concepts/configuration/global.md | 8 ++++---- .../outlines/core-concepts/internals/lifecycle.md | 4 ++-- .../outlines/core-concepts/scheduler/methods.md | 2 +- .../outlines/core-concepts/states/subscribing.md | 8 ++++---- design/specs/070-doc-overhaul/outlines/migration/api.md | 5 +++-- .../070-doc-overhaul/outlines/migration/scheduler.md | 4 ++-- .../specs/070-doc-overhaul/outlines/operating/overview.md | 7 ++++++- .../outlines/recipes/debounce-sensor-changes.md | 2 +- 12 files changed, 30 insertions(+), 24 deletions(-) diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/api/services.md b/design/specs/070-doc-overhaul/outlines/core-concepts/api/services.md index 4505736bb..9842c264d 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/api/services.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/api/services.md @@ -6,13 +6,13 @@ ## Outline ### H2: Basic Service Calls -`call_service(domain, service, data)` pattern. +`call_service(domain, service, target=None, return_response=False, **data)` — service data is passed as `**kwargs`, NOT a positional dict. `target` is a separate parameter for entity targeting. ### H2: Convenience Helpers -`turn_on`, `turn_off`, `toggle_service` shortcuts. +`turn_on`, `turn_off`, `toggle_service` — all default to `domain="homeassistant"` (the deprecated generic HA service). Docs should warn to pass the correct domain (e.g., `domain="light"`). ### H2: Service Responses -What `call_service` returns, response handling. +`return_response=True` changes return type from `None` to `ServiceResponse`. This is opt-in. ## Snippet Inventory diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/bus/filtering.md b/design/specs/070-doc-overhaul/outlines/core-concepts/bus/filtering.md index 5f14ac624..0bbfcad7d 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/bus/filtering.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/bus/filtering.md @@ -23,9 +23,9 @@ Dictionary filtering and predicate filtering for `on_call_service`. ### H2: Complete Reference #### H3: Predicates (`P`) -Full reference table. Include: `AllOf`, `AnyOf`, `Not`, `Guard`, `StateFrom`, `StateTo`, `StateFromTo`, `DidChange`, `IsPresent`, `IsMissing`, `ValueIs`, `EntityMatches`, `DomainMatches`, `ServiceMatches`, and any others in `predicates.py`. +Full reference table. Include: `AllOf`, `AnyOf`, `Not`, `Guard`, `StateFrom`, `StateTo`, `StateDidChange`, `StateComparison`, `AttrFrom`, `AttrTo`, `AttrDidChange`, `AttrComparison`, `DidChange`, `IsPresent`, `IsMissing`, `ValueIs`, `EntityMatches`, `DomainMatches`, `ServiceMatches`, `ServiceDataWhere` (with `from_kwargs` classmethod and `auto_glob` param), and any others in `predicates.py`. Note: `StateFromTo` does NOT exist — use separate `StateFrom` + `StateTo`. #### H3: Conditions (`C`) -Full reference table. Include: `Increased`, `Decreased`, `InRange`, `Comparison` (raw operator), `IsNone`, `IsNotNone`, `Present`, `Missing` (sentinel-based, distinct from `IsNone`), `IsIn`, `IsOrContains`, `NotIntersects`, `StartsWith`, `EndsWith`, `Contains`, `Regex`, `Glob`. +Full reference table. Include: `Increased`, `Decreased`, `Comparison` (raw operator), `IsNone`, `IsNotNone`, `Present`, `Missing` (sentinel-based, distinct from `IsNone`), `IsIn`, `NotIn`, `Intersects`, `NotIntersects`, `IsOrContains`, `StartsWith`, `EndsWith`, `Contains`, `Regex`, `Glob`. Note: `InRange` does NOT exist — use `Comparison` for range checks. #### H3: Accessors (`A`) Full reference table grouped by category: state value (`get_state_value_new`, `get_state_value_old`, `get_state_value_old_new`), state object (`get_state_object_old`, `get_state_object_new`), attribute (`get_attr_old`, `get_attr_new`, `get_attr_old_new`, `get_attrs_old`, `get_attrs_new`, `get_all_attrs_old`, `get_all_attrs_new`), identity (`get_domain`, `get_entity_id`, `get_context`), service (`get_service`, `get_service_data`, `get_service_data_key`), path (`get_path`), diff (`get_all_changes`). How accessors plug into predicates via the `source=` parameter. diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/cache/overview.md b/design/specs/070-doc-overhaul/outlines/core-concepts/cache/overview.md index ee5e6b0dc..034b4c5c9 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/cache/overview.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/cache/overview.md @@ -24,7 +24,7 @@ Only setting: `default_cache_size` (default 100 MiB) in root `HassetteConfig`. N When cache is available during app lifecycle. ### H2: Data Types -What can be cached (JSON-serializable types). +What can be cached (anything picklable — diskcache uses `pickle` as its default serializer). Includes dataclasses, Pydantic models, sets, custom objects. Not limited to JSON-serializable types. ## Snippet Inventory diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/configuration/applications.md b/design/specs/070-doc-overhaul/outlines/core-concepts/configuration/applications.md index 0092f0e9e..4ed335c0f 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/configuration/applications.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/configuration/applications.md @@ -12,7 +12,7 @@ How apps are registered in hassette.toml `[apps]` section. Default single-instance configuration. ### H2: App Configuration Parameters -Table of per-app settings (enabled, log_level, etc.). +Two distinct layers: `AppManifest` fields (under `[hassette.apps.]`: `enabled`, `filename`/`file_name`, `class_name`/`class`/`module`/`module_name`) vs `AppConfig` fields (under `[hassette.apps..config]`: `instance_name`, `log_level`, `app_key`, plus user-defined fields). These live at different TOML paths — don't conflate them in a single table. ### H2: Multiple Instances Running the same app class multiple times with different configs. diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/configuration/global.md b/design/specs/070-doc-overhaul/outlines/core-concepts/configuration/global.md index 1f23dbc99..f82e46524 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/configuration/global.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/configuration/global.md @@ -23,13 +23,13 @@ Long reference page documenting every global setting in hassette.toml. Keep curr `database.path`, `database.retention_days`. ### H2: Lifecycle Settings -`LifecycleConfig`: `startup_timeout_seconds`, `app_startup_timeout_seconds`, `app_shutdown_timeout_seconds`, `event_handler_timeout_seconds`, `error_handler_timeout_seconds`, `run_sync_timeout_seconds`. +`LifecycleConfig`: `startup_timeout_seconds`, `app_startup_timeout_seconds`, `app_shutdown_timeout_seconds`, `event_handler_timeout_seconds`, `error_handler_timeout_seconds`, `run_sync_timeout_seconds`, `resource_shutdown_timeout_seconds`, `total_shutdown_timeout_seconds`, `registration_await_timeout`, `task_cancellation_timeout_seconds`. ### H2: File Watcher Settings `FileWatcherConfig`: `watch_files`, `debounce_milliseconds`, `step_milliseconds`. ### H2: WebSocket Settings -`connect_retry_*` fields (initial connect retries within the service) and `early_drop_*` fields. Note: these are separate from the ServiceWatcher restart budget. +`connect_retry_*` fields (initial connect retries within the service), `early_drop_*` fields (fast reconnect on brief disconnects), and `max_recovery_seconds` (caps total wall-clock recovery time). Note: these are separate from the ServiceWatcher restart budget. ### H2: Timeout Settings Per-item overrides, disabling, limitations. @@ -38,7 +38,7 @@ Per-item overrides, disabling, limitations. Default scheduler configuration. ### H2: Logging Settings -Log level, format. +`LoggingConfig`: `log_format` (`"auto"`, `"console"`, `"json"`), `log_persistence_level`, `log_retention_days`, `log_queue_max`, 13 per-service log levels (`database_service`, `bus_service`, `scheduler_service`, `app_handler`, `web_api`, `websocket`, `service_watcher`, `file_watcher`, `task_bucket`, `command_executor`, `apps`, `state_proxy`, `api`), and debug flags (`all_events`, `all_hass_events`, `all_hassette_events`). ### H2: Bus Filtering Settings `bus_excluded_domains`, `bus_excluded_entities`, `hassette_event_buffer_size`. @@ -50,7 +50,7 @@ Log level, format. `default_cache_size` (default 100 MiB). No other cache config — paths are derived from `data_dir`. ### H2: Service Restart Policy -Default RestartSpec configuration. +Note: `RestartSpec` is a code-level class attribute on `Service` subclasses, NOT a `hassette.toml` setting. Document the defaults here for reference but clarify it is configured in code. ### H2: Basic Example Complete hassette.toml example. diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/internals/lifecycle.md b/design/specs/070-doc-overhaul/outlines/core-concepts/internals/lifecycle.md index 1ab263775..2f6f57900 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/internals/lifecycle.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/internals/lifecycle.md @@ -18,7 +18,7 @@ How the dependency graph drives startup waves (level 0 first, then level 1, etc. #### H3: RestartSpec Class attribute on services: restart type, sliding-window budget, backoff parameters, error routing. #### H3: RestartType -`PERMANENT` (always restart), `TRANSIENT` (restart on unexpected failure), `TEMPORARY` (never restart). +`PERMANENT` (always restart within budget), `TRANSIENT` (restarts within budget; on exhaustion enters `EXHAUSTED_COOLING` with cooldown/recovery cycle), `TEMPORARY` (restarts within budget; on exhaustion goes straight to `EXHAUSTED_DEAD` with no cooldown). All three types restart — they differ in exhaustion behavior. #### H3: Sliding-Window Budget Intensity (max restarts) and period (window size). Budget resets on recovery. #### H3: Error Routing @@ -26,7 +26,7 @@ Fatal vs non-retryable error names. Three-layer routing: handler-level, service- #### H3: Exhaustion States `EXHAUSTED_COOLING` (cooldown period after budget runs out) → either budget resets or transitions to `EXHAUSTED_DEAD` after `max_cooldown_cycles` (0 = infinite). #### H3: Backoff Parameters -`backoff_base_seconds`, `backoff_multiplier`, `backoff_max_seconds`, `startup_timeout_seconds`. +`backoff_base_seconds`, `backoff_multiplier`, `backoff_max_seconds`, `startup_timeout_seconds`, `cooldown_seconds` (duration of EXHAUSTED_COOLING phase for TRANSIENT services). ## Snippet Inventory diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/scheduler/methods.md b/design/specs/070-doc-overhaul/outlines/core-concepts/scheduler/methods.md index d1528a252..2919ca41d 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/scheduler/methods.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/scheduler/methods.md @@ -10,7 +10,7 @@ The generic `schedule(func, trigger)` method. All convenience methods are shortc ### H2: Convenience Methods #### H3: `run_in` — run after a delay -#### H3: `run_once` — run at a specific time +#### H3: `run_once` — run at a specific time. Has `if_past=` parameter (`"tomorrow"` or `"error"`, default `"tomorrow"`). For `ZonedDateTime` inputs, `if_past` has no effect (fires immediately). #### H3: `run_every` — run at a fixed interval ### H2: Convenience Interval Helpers diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/states/subscribing.md b/design/specs/070-doc-overhaul/outlines/core-concepts/states/subscribing.md index ec7068108..4063b6a82 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/states/subscribing.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/states/subscribing.md @@ -19,18 +19,18 @@ Type is `bool | ComparisonCondition`, not just bool. `True` (default), `False`, ### H2: Matching State Values #### H3: `changed_to` and `changed_from` — simple value matching #### H3: Predicates for State Changes -`P.StateFrom`, `P.StateTo`, `P.StateFromTo` — tracking transitions. +`P.StateFrom`, `P.StateTo` — tracking transitions. (No `P.StateFromTo` — combine with `AllOf`.) #### H3: Numeric Conditions -`C.Increased`, `C.Decreased`, `C.InRange` — monitoring numeric changes. +`C.Increased`, `C.Decreased` — monitoring numeric changes. (No `C.InRange` — use `C.Comparison` for range checks.) ### H2: Combining Predicates `AllOf` and `AnyOf` composition. Examples specific to state-change scenarios. ### H2: Attribute Changes -`on_attribute_change` — monitoring specific attributes rather than the state string. +`on_attribute_change(entity_id, attr, ...)` — `attr: str` is a required second positional argument. Monitors a specific attribute rather than the state string. Also has attribute-specific predicates: `P.AttrFrom`, `P.AttrTo`, `P.AttrDidChange`, `P.AttrComparison`. ### H2: Common Parameters -`name=` (required), `duration=`, `immediate=`, `on_error=`, `where=` (additional predicates), `timeout=`, `timeout_disabled=`. +`name=` (**required** — omitting raises `ListenerNameRequiredError`), `duration=`, `immediate=` (raises `ValueError` with glob patterns), `on_error=`, `where=` (additional predicates), `once=`, `debounce=`, `throttle=` (mutually exclusive with debounce), `timeout=`, `timeout_disabled=`. Note: `timeout`/`timeout_disabled`/`once`/`debounce`/`throttle` are passed via `**opts: Unpack[Options]`. ### H2: See Also → Bus overview (general subscription), → Bus Filtering (service call filtering, complete predicate/condition reference), → DI page (full annotation reference) diff --git a/design/specs/070-doc-overhaul/outlines/migration/api.md b/design/specs/070-doc-overhaul/outlines/migration/api.md index 4dfdac8e8..45d51b276 100644 --- a/design/specs/070-doc-overhaul/outlines/migration/api.md +++ b/design/specs/070-doc-overhaul/outlines/migration/api.md @@ -6,11 +6,12 @@ ## Outline ### H2: Overview -What changes: `self.get_state()` → `self.states.get()` or `self.api.get_state()`. +What changes: `self.get_state()` → domain-typed access `self.states.light.get("light.kitchen")` (preferred, returns typed `LightState | None`) or `self.states.get("entity_id")` (generic, returns `BaseState | None`) or `self.api.get_state()` (fresh from HA). ### H2: Getting Entity State #### H3: AppDaemon -#### H3: Hassette: State Cache (recommended) +#### H3: Hassette: Domain-Typed State Cache (recommended) +`self.states.light.get("light.kitchen")` → `LightState | None`. Prefer over generic `self.states.get()` for typed results. #### H3: Hassette: Direct API Call ### H2: Calling Services diff --git a/design/specs/070-doc-overhaul/outlines/migration/scheduler.md b/design/specs/070-doc-overhaul/outlines/migration/scheduler.md index e93d28770..bca4b0f3a 100644 --- a/design/specs/070-doc-overhaul/outlines/migration/scheduler.md +++ b/design/specs/070-doc-overhaul/outlines/migration/scheduler.md @@ -6,13 +6,13 @@ ## Outline ### H2: Overview -What changes: `run_in` stays, `run_daily` stays, `run_every` stays. Callback signature changes. +What changes: `run_in` stays, `run_daily` stays, `run_every` stays. Callback signature changes. `run_daily` default is `at="00:00"` (midnight) — make this explicit. ### H2: Callback Signatures AppDaemon kwargs dict → Hassette typed params. ### H2: Method Equivalents -Table: AppDaemon method → Hassette method. +Table: AppDaemon method → Hassette method. Also note Hassette-only additions with no AppDaemon equivalent: `run_once`, `run_minutely`, `run_hourly`, `run_cron`, `schedule()` (with trigger objects). ### H2: Side-by-Side Comparison Full example: daily task in AppDaemon vs Hassette. diff --git a/design/specs/070-doc-overhaul/outlines/operating/overview.md b/design/specs/070-doc-overhaul/outlines/operating/overview.md index d69a16c8a..24fed0b4f 100644 --- a/design/specs/070-doc-overhaul/outlines/operating/overview.md +++ b/design/specs/070-doc-overhaul/outlines/operating/overview.md @@ -11,7 +11,12 @@ What this section covers: how Hassette behaves at runtime and how to operate it ### H2: Runtime Behavior #### H3: WebSocket Reconnection -**Content from KI-01.** Full reconnection sequence: initial retries (5x, 1s→32s backoff), ServiceWatcher RestartSpec (5 restarts / 300s window, 2s→60s backoff), EXHAUSTED_COOLING (300s cooldown), bus events (`websocket_disconnected`, `websocket_connected`), app behavior during reconnection (API raises `ResourceNotReadyError`, handlers resume automatically). Include log signatures. +**Content from KI-01.** Three-layer reconnection model: +1. **Initial connect retries** (inside `_make_connection`): 5 attempts, 1s→32s exponential backoff with jitter +2. **Early-drop retries** (inside `serve()`): 5 attempts when connection drops within `early_drop_stable_window_seconds` (30s default), 2s→60s backoff. This layer handles brief HA restarts. +3. **ServiceWatcher restart budget**: 5 restarts / 300s sliding window, 2s→60s backoff → `EXHAUSTED_COOLING` (300s, configurable via `cooldown_seconds`) + +Bus events use full topic strings: `hassette.event.websocket_disconnected`, `hassette.event.websocket_connected`. App behavior during reconnection: `Api` and `StateProxy` raise `ResourceNotReadyError`, handlers resume automatically on reconnect. Include log signatures. #### H3: Handler Exception Behavior **Content from KI-02.** Exceptions caught and swallowed, logged at ERROR, recorded in telemetry with `status='error'`. Include log signature. Matches scheduler behavior. diff --git a/design/specs/070-doc-overhaul/outlines/recipes/debounce-sensor-changes.md b/design/specs/070-doc-overhaul/outlines/recipes/debounce-sensor-changes.md index 74bdd2159..d0ef9184c 100644 --- a/design/specs/070-doc-overhaul/outlines/recipes/debounce-sensor-changes.md +++ b/design/specs/070-doc-overhaul/outlines/recipes/debounce-sensor-changes.md @@ -18,7 +18,7 @@ What debounce does: resets timer on each new event, fires only after quiet perio **New section needed.** ### H2: Variations -Different debounce values, combining with throttle, sensor-specific patterns. +Different debounce values, switching to throttle instead (debounce and throttle are mutually exclusive — `ValueError` if both set), sensor-specific patterns. ## Snippet Inventory From 8307211b80d9f3865d21bc2d645f6571be94b403 Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Mon, 1 Jun 2026 18:15:12 -0500 Subject: [PATCH 026/160] chore: redistribute scheduler outline content to align with bus structure Overview gains behavioral features (groups, jitter, idempotent registration). Methods becomes a pure method reference. Management loses troubleshooting (already on global troubleshooting page) and best practices (folded into overview context). --- .../outlines/core-concepts/scheduler/management.md | 7 ------- .../outlines/core-concepts/scheduler/methods.md | 11 ++--------- .../outlines/core-concepts/scheduler/overview.md | 11 ++++++++++- 3 files changed, 12 insertions(+), 17 deletions(-) diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/scheduler/management.md b/design/specs/070-doc-overhaul/outlines/core-concepts/scheduler/management.md index a7295a62b..9dee99a38 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/scheduler/management.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/scheduler/management.md @@ -14,16 +14,9 @@ What `schedule()` returns. Fields: `db_id`, `name`, `group`, `next_run`, `fire_a ### H2: Automatic Cleanup Jobs cancelled automatically on app shutdown. -### H2: Best Practices -Named jobs, groups for related jobs, cancellation patterns. - ### H2: Self-Cancelling Job Pattern Job that cancels itself based on a condition. -### H2: Troubleshooting -#### H3: Job Not Running? -#### H3: Runs Too Often? - ### H2: Error Handling #### H3: App-Level Error Handler — `Scheduler.on_error()` #### H3: Per-Registration Error Handler — `error_handler=` diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/scheduler/methods.md b/design/specs/070-doc-overhaul/outlines/core-concepts/scheduler/methods.md index 2919ca41d..1e9bd47de 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/scheduler/methods.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/scheduler/methods.md @@ -21,20 +21,13 @@ The generic `schedule(func, trigger)` method. All convenience methods are shortc ### H2: Cron Scheduling — `run_cron` Cron expression syntax, examples. -### H2: Job Groups -`group=` parameter, `cancel_group()`, `list_jobs(group=)`. - -### H2: Jitter -`jitter=` parameter for randomizing execution times. - -### H2: Idempotent Registration -`name=` identifies the job; `if_exists=` (`"error"`, `"skip"`, `"replace"`) controls behavior on duplicate name. - ### H2: Per-Job Options #### H3: `on_error=` — per-registration error handler #### H3: `timeout=` / `timeout_disabled=` — per-job timeout control #### H3: `args=` and `kwargs=` — passing arguments to handlers +Groups, jitter, and idempotent registration are covered on the Scheduler Overview page. + ### H2: Synchronous Scheduling `self.scheduler.sync` (`SchedulerSyncFacade`) — mirrors all methods as blocking calls for `AppSync` hooks. diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/scheduler/overview.md b/design/specs/070-doc-overhaul/outlines/core-concepts/scheduler/overview.md index cf7f72864..28e28c210 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/scheduler/overview.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/scheduler/overview.md @@ -14,8 +14,17 @@ Table of built-in triggers (After, Once, Every, Daily, Cron) with one-line descr ### H2: Examples Minimal examples for the most common patterns (run_in, run_every, run_daily). +### H2: Job Groups +`group=` parameter for organizing related jobs. `cancel_group()` cancels all jobs in a group. `list_jobs(group=)` inspects active jobs. (Moved from Methods — this is a behavioral concept, not a method signature.) + +### H2: Jitter +`jitter=` parameter for randomizing execution times to avoid thundering herd. + +### H2: Idempotent Registration +`name=` identifies the job; `if_exists=` (`"error"`, `"skip"`, `"replace"`) controls behavior on duplicate name. + ### H2: Next Steps -→ Scheduling Methods (full reference), → Job Management (cancellation, groups, errors) +→ Scheduling Methods (full reference), → Job Management (cancellation, errors) ## Snippet Inventory From 2b68d5a26754e85f9f2381cb6f61bdaaf9842920 Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Mon, 1 Jun 2026 18:16:44 -0500 Subject: [PATCH 027/160] chore: expand api/entities outline with entity vs state vs value distinction Three levels of state access (full object, just value, single attribute) plus entity registry access (get_entity/get_entity_or_none) with clear 'when to use which' guidance. --- .../outlines/core-concepts/api/entities.md | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/api/entities.md b/design/specs/070-doc-overhaul/outlines/core-concepts/api/entities.md index 81a842c52..97a1fabf6 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/api/entities.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/api/entities.md @@ -9,17 +9,27 @@ Entity, state, attributes — HA concepts mapped to Hassette types. ### H2: Retrieving States -`get_state(entity_id)` — single entity. Also: `get_state_or_none()`, `get_state_value()`, `get_state_value_typed()`, `get_attribute(entity_id, attribute)`. -#### H3: Raw vs Typed -`get_state_raw()` returns raw `HassStateDict`; `get_state()` returns typed model. +Three levels of state access — clarify the distinction: +#### H3: Full State Object +`get_state(entity_id)` → typed state model (e.g., `LightState`) with `.value`, `.attributes`, `.last_changed`, etc. `get_state_or_none()` → same but returns `None` instead of raising. `get_state_raw()` → raw `HassStateDict` without type conversion. +#### H3: Just the Value +`get_state_value(entity_id)` → the state string only (e.g., `"on"`, `"23.5"`). `get_state_value_typed(entity_id)` → value run through the type registry (e.g., `True`, `23.5`). +#### H3: Single Attribute +`get_attribute(entity_id, attribute)` → one attribute value, supports dot-path for nested attributes. #### H3: Checking Existence `entity_exists(entity_id)` for boolean check; `get_state_or_none()` for optional return. ### H2: Retrieving Multiple States `get_states()` — returns all entities (no filtering parameter). `get_states_raw()` for raw dicts. -### H2: Entities -Entity registry access. +### H2: Entities vs States +Entity = the registry record (device info, area, capabilities). State = the current value and attributes. +#### H3: Entity Access +`get_entity(entity_id, model)` → typed `BaseEntity` subclass with registry metadata. Requires an explicit model type argument. `get_entity_or_none(entity_id, model)` → same but returns `None`. +#### H3: When to Use Which +- **`get_state`** — "what is this entity doing right now?" (value, attributes, last_changed) +- **`get_entity`** — "what IS this entity?" (device, area, capabilities, registry metadata) +- **`get_state_value`** — "just give me the value string, nothing else" ### H2: API vs StateManager Expanded comparison: when to hit HA directly vs use the local cache. From 5bedba81864681cc06d0241966b397752f78e70d Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Mon, 1 Jun 2026 18:18:59 -0500 Subject: [PATCH 028/160] chore: fix api/entities terminology to match existing docs entity = state + action methods (turn_on, turn_off, etc.) state = value + attributes + timestamps (full snapshot) state_value = the raw value string (what HA calls state.state) Restructured to match the terminology section already in the existing entities.md docs page. --- .../outlines/core-concepts/api/entities.md | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/api/entities.md b/design/specs/070-doc-overhaul/outlines/core-concepts/api/entities.md index 97a1fabf6..bb40a7d21 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/api/entities.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/api/entities.md @@ -6,14 +6,16 @@ ## Outline ### H2: Terminology -Entity, state, attributes — HA concepts mapped to Hassette types. +Three levels of abstraction — match the existing docs terminology section: +- **State Value** (`get_state_value`) — the raw value string, what HA calls `state.state` (e.g., `"on"`, `"23.5"`). Cheapest call when attributes/timestamps aren't needed. +- **State** (`get_state`) — full snapshot: value + typed attributes + timestamps + context. A `BaseState` subclass (e.g., `LightState`). The `.value` field holds the state value, coerced to the domain's type (e.g., `bool` for lights). +- **Entity** (`get_entity`) — wraps a state + adds action methods (`turn_on()`, `turn_off()`, `toggle()`, `refresh()`). A `BaseEntity` subclass (e.g., `LightEntity`). Requires an explicit model type argument. ### H2: Retrieving States -Three levels of state access — clarify the distinction: #### H3: Full State Object -`get_state(entity_id)` → typed state model (e.g., `LightState`) with `.value`, `.attributes`, `.last_changed`, etc. `get_state_or_none()` → same but returns `None` instead of raising. `get_state_raw()` → raw `HassStateDict` without type conversion. +`get_state(entity_id)` → typed `BaseState` subclass with `.value`, `.attributes`, `.last_changed`, etc. `get_state_or_none()` → returns `None` instead of raising. `get_state_raw()` → raw `HassStateDict` without type conversion. #### H3: Just the Value -`get_state_value(entity_id)` → the state string only (e.g., `"on"`, `"23.5"`). `get_state_value_typed(entity_id)` → value run through the type registry (e.g., `True`, `23.5`). +`get_state_value(entity_id)` → the raw state string only (what HA calls `state.state`). Skips model conversion — use when attributes and timestamps aren't needed. #### H3: Single Attribute `get_attribute(entity_id, attribute)` → one attribute value, supports dot-path for nested attributes. #### H3: Checking Existence @@ -22,14 +24,13 @@ Three levels of state access — clarify the distinction: ### H2: Retrieving Multiple States `get_states()` — returns all entities (no filtering parameter). `get_states_raw()` for raw dicts. -### H2: Entities vs States -Entity = the registry record (device info, area, capabilities). State = the current value and attributes. -#### H3: Entity Access -`get_entity(entity_id, model)` → typed `BaseEntity` subclass with registry metadata. Requires an explicit model type argument. `get_entity_or_none(entity_id, model)` → same but returns `None`. -#### H3: When to Use Which -- **`get_state`** — "what is this entity doing right now?" (value, attributes, last_changed) -- **`get_entity`** — "what IS this entity?" (device, area, capabilities, registry metadata) -- **`get_state_value`** — "just give me the value string, nothing else" +### H2: Retrieving Entities +`get_entity(entity_id, model)` → typed `BaseEntity` subclass. Wraps the state object and adds domain-specific service methods (e.g., `LightEntity.turn_on(brightness=255)`). `get_entity_or_none(entity_id, model)` → returns `None` instead of raising. Requires passing the entity model class explicitly — the API does not auto-resolve entity types. + +### H2: When to Use Which +- **`get_state_value`** — just need the value, nothing else +- **`get_state`** — need attributes, timestamps, or the typed value (most common) +- **`get_entity`** — need to call services on the entity (turn_on, turn_off, etc.) ### H2: API vs StateManager Expanded comparison: when to hit HA directly vs use the local cache. From 053cf3441d7e7735ae0c9458c4264a4949cd5397 Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Mon, 1 Jun 2026 18:24:14 -0500 Subject: [PATCH 029/160] =?UTF-8?q?chore:=20restructure=20configuration=20?= =?UTF-8?q?section=20=E2=80=94=204=20pages=20=E2=86=92=202=20pages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Absorb auth.md into the main config overview (token aliases and SSL are a section, not a page). Replace global.md field listings with a link to auto-generated reference — the Pydantic models have complete field-level docstrings that mkdocstrings can render. Keep ~90 lines of irreplaceable teaching content (WebSocket resilience, timeout behavior, data directory, app discovery) as Operational Tuning Guidance. Applications page stays — AppManifest vs AppConfig layers and multi-instance patterns are genuinely teaching material. Requires adding hassette.config.models and hassette.config.config to PUBLIC_MODULES in gen_ref_pages.py (noted in outline). --- .../core-concepts/configuration/auth.md | 24 ------- .../core-concepts/configuration/global.md | 65 ------------------- .../core-concepts/configuration/overview.md | 51 ++++++++++++--- mkdocs.yml | 2 - 4 files changed, 42 insertions(+), 100 deletions(-) delete mode 100644 design/specs/070-doc-overhaul/outlines/core-concepts/configuration/auth.md delete mode 100644 design/specs/070-doc-overhaul/outlines/core-concepts/configuration/global.md diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/configuration/auth.md b/design/specs/070-doc-overhaul/outlines/core-concepts/configuration/auth.md deleted file mode 100644 index eb3539746..000000000 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/configuration/auth.md +++ /dev/null @@ -1,24 +0,0 @@ -# Configuration — Authentication - -**Status:** Exists (43 lines), concise, voice polish needed -**Voice mode:** Concept — system-as-subject, no "you" - -## Outline - -### H2: Home Assistant Token -Token field accepts four aliases: `token`, `hassette__token`, `ha_token`, `home_assistant_token`. Set via env var or .env file. Link to HA Token getting-started page. - -### H2: SSL Verification -`verify_ssl` setting for self-signed certs. - -### H2: File Locations -Where .env is loaded from. - -## Snippet Inventory - -No dedicated snippets — .env examples are in Getting Started. - -## Cross-Links - -- **Links to:** Configuration overview, HA Token (getting-started) -- **Linked from:** Configuration overview diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/configuration/global.md b/design/specs/070-doc-overhaul/outlines/core-concepts/configuration/global.md deleted file mode 100644 index f82e46524..000000000 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/configuration/global.md +++ /dev/null @@ -1,65 +0,0 @@ -# Configuration — Global Settings - -**Status:** Exists (313 lines), dense reference, voice polish needed -**Voice mode:** Reference — tabular, terse, system-as-subject - -## Outline - -Long reference page documenting every global setting in hassette.toml. Keep current structure — it's a lookup reference. - -### H2: Connection Settings -`base_url` (single URL, default `http://127.0.0.1:8123`), `verify_ssl`, `token` location. - -### H2: Runtime Settings -`allow_reload_in_prod`, `apps.directory`, `strict_lifecycle`, `asyncio_debug_mode`, `allow_only_app_in_prod`, `run_app_precheck`, `allow_startup_if_app_precheck_fails`, `import_dot_env_files`. - -### H2: Storage Settings -`data_dir` (cache paths derived automatically from `data_dir//cache`; no `cache_dir` config key). - -### H2: Web UI Settings -`web_api.run`, `web_api.run_ui`, `web_api.ui_hot_reload`, `web_api.host`, `web_api.port`, `web_api.cors_origins`, `web_api.event_buffer_size`, `web_api.log_buffer_size`, `web_api.job_history_size`. - -### H2: Database Settings -`database.path`, `database.retention_days`. - -### H2: Lifecycle Settings -`LifecycleConfig`: `startup_timeout_seconds`, `app_startup_timeout_seconds`, `app_shutdown_timeout_seconds`, `event_handler_timeout_seconds`, `error_handler_timeout_seconds`, `run_sync_timeout_seconds`, `resource_shutdown_timeout_seconds`, `total_shutdown_timeout_seconds`, `registration_await_timeout`, `task_cancellation_timeout_seconds`. - -### H2: File Watcher Settings -`FileWatcherConfig`: `watch_files`, `debounce_milliseconds`, `step_milliseconds`. - -### H2: WebSocket Settings -`connect_retry_*` fields (initial connect retries within the service), `early_drop_*` fields (fast reconnect on brief disconnects), and `max_recovery_seconds` (caps total wall-clock recovery time). Note: these are separate from the ServiceWatcher restart budget. - -### H2: Timeout Settings -Per-item overrides, disabling, limitations. - -### H2: Scheduler Settings -Default scheduler configuration. - -### H2: Logging Settings -`LoggingConfig`: `log_format` (`"auto"`, `"console"`, `"json"`), `log_persistence_level`, `log_retention_days`, `log_queue_max`, 13 per-service log levels (`database_service`, `bus_service`, `scheduler_service`, `app_handler`, `web_api`, `websocket`, `service_watcher`, `file_watcher`, `task_bucket`, `command_executor`, `apps`, `state_proxy`, `api`), and debug flags (`all_events`, `all_hass_events`, `all_hassette_events`). - -### H2: Bus Filtering Settings -`bus_excluded_domains`, `bus_excluded_entities`, `hassette_event_buffer_size`. - -### H2: State Proxy Settings -`state_proxy_poll_interval_seconds`, `disable_state_proxy_polling`. - -### H2: Cache Settings -`default_cache_size` (default 100 MiB). No other cache config — paths are derived from `data_dir`. - -### H2: Service Restart Policy -Note: `RestartSpec` is a code-level class attribute on `Service` subclasses, NOT a `hassette.toml` setting. Document the defaults here for reference but clarify it is configured in code. - -### H2: Basic Example -Complete hassette.toml example. - -## Snippet Inventory - -No code snippets — TOML examples are inline. - -## Cross-Links - -- **Links to:** Configuration overview, Operating/Log Levels (log settings detail) -- **Linked from:** Configuration overview, Docker Setup, Operating diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/configuration/overview.md b/design/specs/070-doc-overhaul/outlines/core-concepts/configuration/overview.md index 74e65757e..e207f75f8 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/configuration/overview.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/configuration/overview.md @@ -1,6 +1,6 @@ # Configuration — Overview -**Status:** Exists (46 lines), brief intro, voice polish needed +**Status:** Exists (46 lines) + absorbing auth.md (43 lines) + teaching content from global.md (~90 lines). Rewrite needed. **Voice mode:** Concept — system-as-subject, no "you" ## Outline @@ -8,20 +8,53 @@ ### H2: Configuration Sources Priority order (highest wins): init kwargs → env vars (`HASSETTE__` prefix, `__` nested delimiter) → dotenv (.env) → file secrets → hassette.toml. TOML is the base; env vars override it. -### H2: Search Paths -TOML: `/config/hassette.toml`, `hassette.toml`, `./config/hassette.toml`. `.env`: `/config/.env`, `.env`, `./config/.env`. Docker `/config/` path is first. +### H2: File Locations +TOML: `/config/hassette.toml`, `hassette.toml`, `./config/hassette.toml`. `.env`: `/config/.env`, `.env`, `./config/.env`. Docker `/config/` paths are checked first. CLI flags `--config-file` / `--env-file` override discovery. + +### H2: Authentication +Token field accepts four aliases: `token`, `hassette__token`, `ha_token`, `home_assistant_token`. Set via env var or .env file. `verify_ssl` for self-signed certs. `import_dot_env_files` controls whether .env values are also injected into `os.environ`. Link to HA Token getting-started page for step-by-step. ### H2: Configuration Sections -Brief list of what's configurable, linking to sub-pages. +Brief map of what's configurable, linking to the auto-generated reference for field details: +- Connection: `base_url`, `verify_ssl` +- Apps: → Applications page +- Web UI: `[hassette.web_api]` +- Database: `[hassette.database]` +- WebSocket: `[hassette.websocket]` +- Logging: `[hassette.logging]` +- Lifecycle: `[hassette.lifecycle]` +- File Watcher: `[hassette.file_watcher]` +- Scheduler: `[hassette.scheduler]` + +### H2: Operational Tuning Guidance +Teaching content that can't come from auto-generated reference — explain the design, not just the fields: + +#### H3: WebSocket Resilience +Three-layer retry model (connect retries → early-drop retries → ServiceWatcher budget). When to tune each layer. `max_recovery_seconds` as the total wall-clock cap. + +#### H3: Timeout Behavior +Timeout enforcement limitations: sync handlers can't be interrupted mid-execution (the timeout fires but the handler continues). `TimeoutError` swallowing behavior. `run_sync_timeout_seconds` default (6s). -### H2: Credentials -Token and SSL configuration — links to Auth page. +#### H3: Data Directory and Upgrades +`data_dir` path, major version implications, cache path derivation (`data_dir//cache`). + +#### H3: App Discovery +`apps.directory`, `extend_exclude_dirs` vs `exclude_dirs` footgun, `run_app_precheck` and `allow_startup_if_app_precheck_fails`. + +### H2: Full Reference +Link to auto-generated API reference for `HassetteConfig` and all sub-models. All fields, types, defaults, and descriptions are maintained in the source code and rendered automatically. ## Snippet Inventory -No dedicated snippets — links to sub-pages for examples. +No code snippets — TOML examples are inline. ## Cross-Links -- **Links to:** Auth, Global Settings, Applications, Apps/Configuration -- **Linked from:** Architecture, Getting Started (Quickstart, Docker Setup) +- **Links to:** Applications (app registration), Auto-generated HassetteConfig reference, Operating/Log Levels (log tuning in practice), HA Token (getting-started) +- **Linked from:** Architecture, Getting Started (Quickstart, Docker Setup), Operating + +## Structural Notes + +- **auth.md absorbed** — token aliases and SSL verification folded into Authentication section above +- **global.md replaced** — field listings move to auto-generated reference; ~90 lines of teaching content (WebSocket resilience, timeout behavior, data directory) kept in Operational Tuning Guidance +- **Requires:** adding `hassette.config.models` and `hassette.config.config` to `PUBLIC_MODULES` in `tools/gen_ref_pages.py` diff --git a/mkdocs.yml b/mkdocs.yml index 45826d034..65175a537 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -78,8 +78,6 @@ nav: - Database & Telemetry: pages/core-concepts/database-telemetry.md - Configuration: - Overview: pages/core-concepts/configuration/index.md - - Authentication: pages/core-concepts/configuration/auth.md - - Global Settings: pages/core-concepts/configuration/global.md - Applications: pages/core-concepts/configuration/applications.md - System Internals: - Architecture & Data Flow: pages/core-concepts/internals/index.md From a735218a419e0d87ae8303974d40bb58b37030f2 Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Mon, 1 Jun 2026 18:36:57 -0500 Subject: [PATCH 030/160] =?UTF-8?q?chore:=20address=20outline=20review=20f?= =?UTF-8?q?eedback=20=E2=80=94=20trim,=20rename,=20restructure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLI: - Remove hassette event from commands (being removed) - Move jq scripting from configuration to workflows page Docker: - Remove Pre-building a Custom Image (CLI tooling planned) - HASSETTE__INSTALL_DEPS=1 confirmed correct via docker_start.sh - Slim docker-image-tags for getting-started audience (remove PR tags, bleeding-edge, decision matrix) Getting Started: - Rename evaluator.md → is-hassette-right-for-you.md (clearer name) - Add 'Coming from AppDaemon?' section linking to migration AD Migration: - Remove checklist page (thin summary, no unique content) - Move Common Pitfalls to migration overview - Simplify testing page (AD has no testing story — just point to Hassette's test helpers with a brief summary) - Note that existing AD code examples should be carried forward Operating: - Log levels: don't hardcode all 13 field names (will rot), link to auto-generated LoggingConfig reference instead, show 2-3 examples - Add debug flags and log_format sections Testing: - Fix 'Tier 2 Re-exports' → 'Internal Helpers' (they're _internal, not public API) --- .../070-doc-overhaul/outlines/cli/commands.md | 1 - .../outlines/cli/configuration.md | 3 -- .../outlines/cli/workflows.md | 3 ++ .../getting-started/docker-dependencies.md | 4 +- .../getting-started/docker-image-tags.md | 51 ++++++++----------- ...luator.md => is-hassette-right-for-you.md} | 5 +- .../specs/070-doc-overhaul/outlines/home.md | 4 +- .../outlines/migration/checklist.md | 47 ----------------- .../outlines/migration/overview.md | 17 ++++++- .../outlines/migration/testing.md | 25 ++++----- .../outlines/operating/log-levels.md | 7 ++- .../outlines/testing/factories.md | 6 +-- mkdocs.yml | 1 - 13 files changed, 68 insertions(+), 106 deletions(-) rename design/specs/070-doc-overhaul/outlines/getting-started/{evaluator.md => is-hassette-right-for-you.md} (88%) delete mode 100644 design/specs/070-doc-overhaul/outlines/migration/checklist.md diff --git a/design/specs/070-doc-overhaul/outlines/cli/commands.md b/design/specs/070-doc-overhaul/outlines/cli/commands.md index 2bf185453..1b7291680 100644 --- a/design/specs/070-doc-overhaul/outlines/cli/commands.md +++ b/design/specs/070-doc-overhaul/outlines/cli/commands.md @@ -15,7 +15,6 @@ Subcommands: health, activity, config, source. ### H2: `hassette job` — scheduler job inspection, execution history ### H2: `hassette log` — log querying ### H2: `hassette execution` — execution detail -### H2: `hassette event` — event inspection ### H2: `hassette dashboard` — dashboard overview ### H2: `hassette config` — config inspection ### H2: `hassette telemetry` — telemetry management diff --git a/design/specs/070-doc-overhaul/outlines/cli/configuration.md b/design/specs/070-doc-overhaul/outlines/cli/configuration.md index 690dda6c8..d88a58b70 100644 --- a/design/specs/070-doc-overhaul/outlines/cli/configuration.md +++ b/design/specs/070-doc-overhaul/outlines/cli/configuration.md @@ -14,9 +14,6 @@ #### H3: JSON (`--json`) — machine-readable output #### H3: `NO_COLOR` — disabling color output -### H2: Scripting with `jq` -Recipes for piping JSON output to jq. Health check script, alerting on error rate. - ### H2: Shell Completion #### H3: Generate to stdout #### H3: Install to default location diff --git a/design/specs/070-doc-overhaul/outlines/cli/workflows.md b/design/specs/070-doc-overhaul/outlines/cli/workflows.md index 2664323b9..604f33954 100644 --- a/design/specs/070-doc-overhaul/outlines/cli/workflows.md +++ b/design/specs/070-doc-overhaul/outlines/cli/workflows.md @@ -17,6 +17,9 @@ One-liner commands for common checks: running? all healthy? errors? recent activ ### H2: Comparing Time Windows Before/after comparison patterns. +### H2: Scripting with `jq` +Recipes for piping `--json` output to jq. Health check script, alerting on error rate. (Moved from cli/configuration.md — this is workflow content, not configuration.) + ## Snippet Inventory No code snippets — CLI command sequences. diff --git a/design/specs/070-doc-overhaul/outlines/getting-started/docker-dependencies.md b/design/specs/070-doc-overhaul/outlines/getting-started/docker-dependencies.md index a632b15e7..b8a57bd31 100644 --- a/design/specs/070-doc-overhaul/outlines/getting-started/docker-dependencies.md +++ b/design/specs/070-doc-overhaul/outlines/getting-started/docker-dependencies.md @@ -35,10 +35,10 @@ Simpler alternative, when to use it. ### H2: Startup Performance #### H3: Using uv.lock for Faster Starts -#### H3: Pre-building a Custom Image -Dockerfile example for baking deps into the image. #### H3: Known Limitations — Local Path Dependencies +Pre-building a custom image is omitted — CLI tooling for this is planned. + ### H2: Complete Examples Two full examples with compose + project structure. diff --git a/design/specs/070-doc-overhaul/outlines/getting-started/docker-image-tags.md b/design/specs/070-doc-overhaul/outlines/getting-started/docker-image-tags.md index d79d33199..32ed9a17a 100644 --- a/design/specs/070-doc-overhaul/outlines/getting-started/docker-image-tags.md +++ b/design/specs/070-doc-overhaul/outlines/getting-started/docker-image-tags.md @@ -1,49 +1,40 @@ # Docker — Image Tags -**Status:** Exists (151 lines), reference-style, voice polish needed -**Voice mode:** Getting-started with reference feel — tables for scanning, "you" for recommendations +**Status:** Exists (151 lines), needs trimming for getting-started audience +**Voice mode:** Getting-started — "you" allowed, brief, decision-oriented ## Outline ### H2: Tag Format -#### H3: Recommended — Pin Both Version and Python -Primary recommendation with example. -#### H3: Track Latest Stable Release -When acceptable, risks. -#### H3: Testing Open Pull Requests -PR preview tags, when useful. -#### H3: Bleeding-Edge Main Branch -`main-py3.XX` tags, stability caveats. - -### H2: Tags NOT Published -What doesn't exist and why (no `latest` without Python version, no alpine, no slim). +Brief explanation of the naming convention. One recommended tag example. -### H2: Supported Python Versions -Table of currently supported versions. +### H2: Recommended Tags +#### H3: Production — pin version + Python (e.g., `v0.35.0-py3.13`) +#### H3: Development — track latest stable (e.g., `latest-py3.13`) -### H2: Choosing a Tag -#### H3: For Production -#### H3: For Development -#### H3: For Testing Pre-release Features +### H2: Supported Python Versions +Short table: 3.11, 3.12, 3.13, 3.14. Note upper bound (<3.15). ### H2: Updating Images -#### H3: Pull Latest -#### H3: Check Current Version +`docker compose pull` + restart. + +Removed from this page (too detailed for getting-started): +- PR preview tags and bleeding-edge main branch tags +- "Tags NOT Published" section +- "Choosing a Tag" decision matrix (production/development/pre-release) +- Separate "Check Current Version" section + +If needed later, these could live in an Operating or Reference page. ## Snippet Inventory | Snippet | Status | Notes | |---|---|---| -| `tag-format-latest.txt` | Keep | Tag examples | -| `tag-format-main.txt` | Keep | | -| `tag-format-pr.txt` | Keep | | -| `tag-format-versioned.txt` | Keep | | -| `tag-latest-compose.yml` | Keep | Compose examples | -| `tag-pinned-compose.yml` | Keep | | -| `tag-prerelease-compose.yml` | Keep | | -| `tag-prerelease-explicit.txt` | Keep | | +| `tag-pinned-compose.yml` | Keep | Primary recommendation | +| `tag-latest-compose.yml` | Keep | Development alternative | | `docker-pull-update.sh` | Keep | Update command | -| `docker-version-check.sh` | Keep | Version check | +| `tag-format-versioned.txt` | Review | May fold into prose | +| Others | Drop or defer | PR/main/prerelease tags not needed here | ## Cross-Links diff --git a/design/specs/070-doc-overhaul/outlines/getting-started/evaluator.md b/design/specs/070-doc-overhaul/outlines/getting-started/is-hassette-right-for-you.md similarity index 88% rename from design/specs/070-doc-overhaul/outlines/getting-started/evaluator.md rename to design/specs/070-doc-overhaul/outlines/getting-started/is-hassette-right-for-you.md index 45a898721..99c4d156e 100644 --- a/design/specs/070-doc-overhaul/outlines/getting-started/evaluator.md +++ b/design/specs/070-doc-overhaul/outlines/getting-started/is-hassette-right-for-you.md @@ -1,4 +1,4 @@ -# Evaluator — Is Hassette Right for You? +# Is Hassette Right for You? **Status:** New page (stub exists) **Voice mode:** Getting-started — "you" allowed, friendly, direct @@ -14,6 +14,9 @@ Honest acknowledgment: simple trigger→action automations, no coding preference ### H2: What Hassette Requires Practical prerequisites: Python 3.11+, a machine to run the process (same box or Docker), a long-lived access token, comfort with async/await basics. Brief — not a setup guide. +### H2: Coming from AppDaemon? +Brief pointer to the AppDaemon Migration section for users migrating existing apps. + ### H2: Next Steps Two paths: "Ready to try it?" → Quickstart. "Want more detail on the tradeoffs?" → Hassette vs HA YAML. diff --git a/design/specs/070-doc-overhaul/outlines/home.md b/design/specs/070-doc-overhaul/outlines/home.md index 2f11d4ec9..cf585f31f 100644 --- a/design/specs/070-doc-overhaul/outlines/home.md +++ b/design/specs/070-doc-overhaul/outlines/home.md @@ -26,7 +26,7 @@ Three-step teaser linking to Quickstart. Link to Migration section. ### H2: Next Steps -Links to Quickstart, Evaluator, Core Concepts, Recipes. +Links to Quickstart, Is Hassette Right for You?, Core Concepts, Recipes. ## Snippet Inventory @@ -42,5 +42,5 @@ The home page is the most-visited page — stale code here is the worst place fo ## Cross-Links -- **Links to:** Evaluator, Quickstart, Migration overview, Core Concepts/Architecture, Recipes +- **Links to:** Is Hassette Right for You?, Quickstart, Migration overview, Core Concepts/Architecture, Recipes - **Linked from:** (entry point — linked from everywhere implicitly) diff --git a/design/specs/070-doc-overhaul/outlines/migration/checklist.md b/design/specs/070-doc-overhaul/outlines/migration/checklist.md deleted file mode 100644 index 0bb8af65b..000000000 --- a/design/specs/070-doc-overhaul/outlines/migration/checklist.md +++ /dev/null @@ -1,47 +0,0 @@ -# Migration — Migration Checklist - -**Status:** Exists (109 lines), step-by-step, voice polish needed -**Voice mode:** Procedural — "you" allowed, numbered steps - -## Outline - -### H2: Before You Start -Prerequisites: Hassette installed, HA token, project structure. - -### H2: Step 1: Configuration -Convert appdaemon.yaml → hassette.toml. - -### H2: Step 2: App Structure -Convert class, imports, initialization. - -### H2: Step 3: Event Listeners -Convert listen_state/listen_event → on_state_change/on. - -### H2: Step 4: Scheduler -Convert run_in/run_daily/run_every. - -### H2: Step 5: API Calls -Convert get_state/call_service/set_state. - -### H2: Step 6: Test -Write tests for the migrated app. - -### H2: Step 7: Verify Live -Run against real HA and verify behavior. - -### H2: Common Pitfalls -Known gotchas from the migration: -- `name=` required on all bus subscriptions (`ListenerNameRequiredError`) -- `run_daily` signature differs from AppDaemon (takes `at="HH:MM"`, DST-safe) -- Blocking code must use `task_bucket.run_in_thread()`, not `run_in_executor` - -## Snippet Inventory - -| Snippet | Status | Notes | -|---|---|---| -| ~2 migration/checklist snippets | Keep | Before/after snippets | - -## Cross-Links - -- **Links to:** All migration sub-pages, Testing overview -- **Linked from:** Migration overview diff --git a/design/specs/070-doc-overhaul/outlines/migration/overview.md b/design/specs/070-doc-overhaul/outlines/migration/overview.md index fae173e77..42d951549 100644 --- a/design/specs/070-doc-overhaul/outlines/migration/overview.md +++ b/design/specs/070-doc-overhaul/outlines/migration/overview.md @@ -23,13 +23,26 @@ How the migration section is organized. ### H2: Quick Reference Table AppDaemon method → Hassette equivalent lookup table. +### H2: Common Pitfalls +(Moved from deleted checklist) Known gotchas: +- `name=` required on all bus subscriptions (`ListenerNameRequiredError`) +- `run_daily` signature differs from AppDaemon (takes `at="HH:MM"`, default `"00:00"`, DST-safe) +- Blocking code must use `task_bucket.run_in_thread()`, not `run_in_executor` +- `self.states.light.get()` is the idiomatic typed access, not `self.states.get()` + ## Snippet Inventory | Snippet | Status | Notes | |---|---|---| | Relevant files from `migration/snippets/` (27 total) | Review | Assign per-page | +## Writing Note + +The existing AD migration docs already have AppDaemon-side code examples and side-by-side comparisons. Carry those forward — the AD parts should not be blank stubs. The current docs at `docs/pages/migration/` have these examples; reuse them. + +Migration Checklist has been removed — it was a thin summary of the sub-pages with no unique content. + ## Cross-Links -- **Links to:** All migration sub-pages, Evaluator -- **Linked from:** Home page, Evaluator +- **Links to:** All migration sub-pages, Is Hassette Right for You? +- **Linked from:** Home page, Is Hassette Right for You? diff --git a/design/specs/070-doc-overhaul/outlines/migration/testing.md b/design/specs/070-doc-overhaul/outlines/migration/testing.md index 41867572a..2b0f6d0d3 100644 --- a/design/specs/070-doc-overhaul/outlines/migration/testing.md +++ b/design/specs/070-doc-overhaul/outlines/migration/testing.md @@ -1,29 +1,30 @@ # Migration — Testing -**Status:** Exists (41 lines), brief, voice polish needed +**Status:** Exists (41 lines), needs reframing — AD doesn't have a real testing story **Voice mode:** Comparison — "you" allowed ## Outline -### H2: The Mental Model Shift -AppDaemon has no testing story → Hassette has a full test harness. +### H2: The Shift +AppDaemon has no built-in testing support. Third-party tools exist (appdaemontestframework, etc.) but they're limited and community-maintained. Hassette ships a full test harness. -### H2: `asyncio_mode = "auto"` (Required) -pytest-asyncio configuration. +### H2: What Hassette Provides +Brief summary — not a tutorial, just enough to show the migrator what's available: +- `AppTestHarness` for isolated app testing +- State seeding, event simulation, API call assertions +- Time control (`freeze_time`, `advance_time`) +- Concurrency helpers (drain) -### H2: `set_state()` Order Matters -State seeding before running the app. - -### H2: Full Reference -Link to Testing section. +### H2: Getting Started with Tests +Link to the Testing section for the full guide. ## Snippet Inventory | Snippet | Status | Notes | |---|---|---| -| ~2 migration/testing snippets | Keep | Basic test example | +| ~1 migration/testing snippet | Keep | Minimal "here's what a test looks like" example | ## Cross-Links -- **Links to:** Testing overview, Testing/Factories +- **Links to:** Testing overview - **Linked from:** Migration overview diff --git a/design/specs/070-doc-overhaul/outlines/operating/log-levels.md b/design/specs/070-doc-overhaul/outlines/operating/log-levels.md index 7afb5cfb3..46a4a39a2 100644 --- a/design/specs/070-doc-overhaul/outlines/operating/log-levels.md +++ b/design/specs/070-doc-overhaul/outlines/operating/log-levels.md @@ -13,13 +13,16 @@ Debug a specific area without flooding logs. Brief. `[hassette.logging]` section in hassette.toml. Per-service granularity. ### H2: Available Fields -Table of all service field names and what they control. +Link to auto-generated `LoggingConfig` reference for the full field list (avoids hardcoding names that may change). Provide 2-3 inline examples of the most common ones (`websocket`, `bus_service`, `scheduler_service`) to orient the reader, but don't enumerate all 13+. ### H2: Fallback Behavior Unset fields use global log level. +### H2: Debug Flags +`all_events`, `all_hass_events`, `all_hassette_events` — boolean flags for bus debug verbosity. `log_format` (`"auto"`, `"console"`, `"json"`) for output format. + ### H2: Per-App Log Levels -Set in the app config block, not in `[hassette.log_levels]`. +Set in the app config block under `[hassette.apps..config]`, not in `[hassette.logging]`. ### H2: Examples #### H3: Debugging the Scheduler diff --git a/design/specs/070-doc-overhaul/outlines/testing/factories.md b/design/specs/070-doc-overhaul/outlines/testing/factories.md index 9df0e0bf2..a35f5ed28 100644 --- a/design/specs/070-doc-overhaul/outlines/testing/factories.md +++ b/design/specs/070-doc-overhaul/outlines/testing/factories.md @@ -21,7 +21,7 @@ Full mock Hassette instance for unit tests. ### H2: `create_hassette_stub` -Separate Tier 2 web-specific stub for HTTP/WebSocket tests. Not an alias for `make_mock_hassette`. +Web-layer stub that wires a full FastAPI app stack — for testing web routes and WebSocket endpoints. Not an alias for `make_mock_hassette`. Internal helper (not in `__all__`), imported from `hassette.test_utils._internal`. ### H2: `make_test_config` Test configuration builder. @@ -29,8 +29,8 @@ Test configuration builder. ### H2: RecordingApi Coverage Boundary What RecordingApi supports vs what needs mocking. -### H2: Tier 2 Re-exports -Helper re-exports from `hassette.test_utils`. +### H2: Internal Helpers +Functions available from `hassette.test_utils._internal` (not in `__all__` — stable but not part of the public API contract). Includes `create_hassette_stub`, `create_component_loaded_event`, `create_service_registered_event`, `make_full_state_change_event`. Document what they do so users of the web layer can find them, but note the internal status. ## Snippet Inventory diff --git a/mkdocs.yml b/mkdocs.yml index 65175a537..6c97ce7c6 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -120,7 +120,6 @@ nav: - API Calls: pages/migration/api.md - Configuration: pages/migration/configuration.md - Testing: pages/migration/testing.md - - Migration Checklist: pages/migration/checklist.md - Troubleshooting: pages/troubleshooting.md - Changelog: CHANGELOG.md # symlinked from repo root - API Reference: reference/ From 06c96396fa24fd08746878bae20e4ce4cd5277e2 Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Tue, 2 Jun 2026 07:40:58 -0500 Subject: [PATCH 031/160] chore: coverage sweep and structural challenge on doc outlines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coverage sweep: 14 gaps folded into 10 outline files — sync facades, API discovery methods, history params, entity wrapper, state model properties, App.now(), exception hierarchy, config fields, CLI flags, scheduler minor items. Challenge findings (7 applied): - Collapse domain-states.md into states overview - Split testing into quickstart + reference pages - New bus/predicate-reference.md for P/C/A lookup tables - Merge hassette-vs-ha-yaml.md into is-hassette-right-for-you.md - Add internals/overview.md with contributor-audience label - Fill recipe verification sections (5 recipes) - Add testing cross-links to all 6 recipes --- .claude/handoff.md | 61 ------------------- .../070-doc-overhaul/outlines/cli/commands.md | 2 +- .../outlines/core-concepts/api/entities.md | 12 ++++ .../outlines/core-concepts/api/utilities.md | 9 ++- .../outlines/core-concepts/apps/overview.md | 2 +- .../outlines/core-concepts/bus/filtering.md | 21 +++---- .../outlines/core-concepts/bus/overview.md | 3 + .../core-concepts/bus/predicate-reference.md | 24 ++++++++ .../core-concepts/configuration/overview.md | 15 +++++ .../core-concepts/internals/overview.md | 24 ++++++++ .../core-concepts/scheduler/methods.md | 4 +- .../core-concepts/states/domain-states.md | 2 +- .../outlines/core-concepts/states/overview.md | 15 ++++- .../getting-started/hassette-vs-ha-yaml.md | 2 +- .../is-hassette-right-for-you.md | 11 +++- .../outlines/operating/log-levels.md | 2 +- .../outlines/recipes/daily-notification.md | 4 +- .../recipes/debounce-sensor-changes.md | 4 +- .../outlines/recipes/motion-lights.md | 2 +- .../outlines/recipes/sensor-threshold.md | 4 +- .../outlines/recipes/service-call-reaction.md | 4 +- .../outlines/recipes/vacation-mode-toggle.md | 4 +- .../outlines/testing/overview.md | 17 +++--- .../outlines/testing/quickstart.md | 38 ++++++++++++ .../troubleshooting/troubleshooting.md | 12 ++++ 25 files changed, 189 insertions(+), 109 deletions(-) delete mode 100644 .claude/handoff.md create mode 100644 design/specs/070-doc-overhaul/outlines/core-concepts/bus/predicate-reference.md create mode 100644 design/specs/070-doc-overhaul/outlines/core-concepts/internals/overview.md create mode 100644 design/specs/070-doc-overhaul/outlines/testing/quickstart.md diff --git a/.claude/handoff.md b/.claude/handoff.md deleted file mode 100644 index ff25d0cba..000000000 --- a/.claude/handoff.md +++ /dev/null @@ -1,61 +0,0 @@ -# Handoff: Doc Overhaul Brief for Issue #928 - -**Date:** 2026-05-31 -**Project:** hassette -**Directory:** /home/jessica/source/hassette/.claude/worktrees/928 -**Branch:** worktree-928 -**Tmux:** hassette-issue-928 - -## What We Were Working On - -Issue #928 calls for rewriting all 76 documentation pages from scratch using an outline-first process. The existing docs grew organically and have inconsistent depth, voice, and coverage despite mature standards (voice-guide.md, doc-rules.md). This session ran the grill and challenge workflows to produce a thorough brief before any implementation begins. The brief captures all key decisions, structural prescriptions, and process safeguards needed to execute the rewrite across three sequential phases. - -## Approach - -1. Deep-dived issue #928 to understand scope (76 pages, 352 snippets, 10 nav sections). -2. Ran `/mine.grill` — multi-angle interrogation across product, design, engineering, scope, and adversarial lenses. Pinned down audience priority, rewrite depth, snippet strategy, delivery model, voice drift mitigation, and branch strategy through 8 interactive questions. -3. Ran `/mine.challenge` against the resulting brief. Three critics (Documentation Architect, End-User Reader, Senior Engineer) produced 12 findings. All 12 were resolved — 3 auto-applied, 9 user-directed with the recommended option chosen each time. - -## Current State - -### Done -- Brief written, challenged, and committed: `design/specs/070-doc-overhaul/brief.md` -- Pushed to `worktree-928` on origin -- All 12 challenge findings resolved and applied to the brief - -### Not Started -- Phase 1: Site outline (page tree and nav structure) -- Phase 2: Per-page content outlines with snippet inventory -- Phase 3: Writing pages section-by-section - -## Uncommitted Changes - -None — all changes committed. - -## Decisions Made - -- **Truly blank slate** — every page starts empty, even pages already close to the voice standard. Exception carved out for troubleshooting/operational pages: mandatory pre-write knowledge inventory to preserve log signatures, timing values, and runbook commands. -- **Full structural freedom** — sections can be merged, split, renamed, or removed in Phase 1. Specific targets prescribed: delete "Advanced" section (rehome contents to core-concepts/states/ and troubleshooting), replace Web UI tab-by-tab with task-oriented structure, scope Architecture page to app-authors only (contributor content moves to internals.md). -- **Docs branch strategy** — section PRs merge to a long-lived docs branch, big bang PR to main when complete. Rebase checkpoint after each section PR to catch API drift. -- **Two exemplar pages** — one core concept, one getting-started/recipe. Selection is a Phase 1 deliverable with explicit criteria defined. Voice audit checklist (not just a subjective scan) also a Phase 1 deliverable. -- **Reader success criteria** — each section evaluated against concrete reader outcomes, not just voice consistency. -- **Snippet audit in Phase 2** — each outline declares needed examples; unclaimed snippets die. Stub-first convention during Phase 3 to keep CI green. - -## Open Questions - -- Which specific pages become the two exemplars (Phase 1 decides based on criteria in the brief) -- Whether to keep, condense, or drop the Migration section (8 AppDaemon pages) -- Pyright config scoping for snippet suppressions (pre-Phase 3 cleanup item) - -## Key Files - -- `design/specs/070-doc-overhaul/brief.md` — the challenged brief; starting point for `/mine.define` -- `.claude/rules/voice-guide.md` — 23 voice/style rules with before/after examples -- `.claude/rules/doc-rules.md` — page structure templates, snippet conventions, layering guidance -- `mkdocs.yml` — current nav structure (will change in Phase 1) - -## Next Steps - -1. Run `/mine.define design/specs/070-doc-overhaul` to turn the brief into a full spec with work packages -2. Execute Phase 1 (site outline) — the most consequential phase; deserves disproportionate scrutiny -3. Create the docs branch for incremental section PRs diff --git a/design/specs/070-doc-overhaul/outlines/cli/commands.md b/design/specs/070-doc-overhaul/outlines/cli/commands.md index 1b7291680..38cb538c0 100644 --- a/design/specs/070-doc-overhaul/outlines/cli/commands.md +++ b/design/specs/070-doc-overhaul/outlines/cli/commands.md @@ -18,7 +18,7 @@ Subcommands: health, activity, config, source. ### H2: `hassette dashboard` — dashboard overview ### H2: `hassette config` — config inspection ### H2: `hassette telemetry` — telemetry management -### H2: Shared Flags — `--since`, `--instance`, `--json` +### H2: Shared Flags — `--since`, `--instance`, `--json`, `--limit`, `--source-tier` ## Snippet Inventory diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/api/entities.md b/design/specs/070-doc-overhaul/outlines/core-concepts/api/entities.md index bb40a7d21..e6facc0ff 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/api/entities.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/api/entities.md @@ -27,6 +27,18 @@ Three levels of abstraction — match the existing docs terminology section: ### H2: Retrieving Entities `get_entity(entity_id, model)` → typed `BaseEntity` subclass. Wraps the state object and adds domain-specific service methods (e.g., `LightEntity.turn_on(brightness=255)`). `get_entity_or_none(entity_id, model)` → returns `None` instead of raising. Requires passing the entity model class explicitly — the API does not auto-resolve entity types. +#### H3: Entity Properties +`.state` — the underlying typed state object. `.value` — shortcut to `state.value`. `.entity_id`, `.domain` — identity fields. `.api` — direct access to the `Api` instance. `.hassette` — access to the `Hassette` coordinator instance. + +#### H3: Refreshing Entity State +`entity.refresh()` — re-fetches state from HA and updates the entity's state object in place. + +#### H3: Synchronous Entity Access +`entity.sync` (`BaseEntitySyncFacade`) — mirrors action methods as blocking calls. Available for sync contexts. + +#### H3: Generic Type Parameters +`BaseEntity[StateT, StateValueT]` — entities are generic over their state type and value type. Domain entity subclasses (e.g., `LightEntity`) bind these parameters to the corresponding state class. + ### H2: When to Use Which - **`get_state_value`** — just need the value, nothing else - **`get_state`** — need attributes, timestamps, or the typed value (most common) diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/api/utilities.md b/design/specs/070-doc-overhaul/outlines/core-concepts/api/utilities.md index b3e636dd5..76539edc1 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/api/utilities.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/api/utilities.md @@ -6,14 +6,19 @@ ## Outline ### H2: Templates -`render_template()` — render HA Jinja2 templates. +`render_template()` — render HA Jinja2 templates. Accepts a `variables` dict for template context. ### H2: History -`get_history()` — retrieve entity history. +`get_history()` — retrieve entity history. Parameters: `significant_changes_only` (filter to meaningful changes), `minimal_response` (delta-encoded entries, smaller payload), `no_attributes` (omit attribute data). `get_histories()` for batch retrieval of multiple entities. ### H2: Logbook `get_logbook()` — retrieve logbook entries. +### H2: Discovery +#### H3: `get_config` — retrieve HA configuration (version, location, units, components) +#### H3: `get_services` — list all available services and their fields +#### H3: `get_panels` — list HA frontend panels + ### H2: Other Endpoints #### H3: `fire_event` — fire custom HA events #### H3: `set_state` — override entity state diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/apps/overview.md b/design/specs/070-doc-overhaul/outlines/core-concepts/apps/overview.md index 9027b1261..e11da7055 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/apps/overview.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/apps/overview.md @@ -12,7 +12,7 @@ App[Config] generic, five handles (bus, scheduler, api, states, cache), logger. Minimal app example, AppConfig usage. ### H2: Dates and Times -`whenever` library usage for date/time in apps. +`whenever` library usage for date/time in apps. `self.now()` returns the current `ZonedDateTime`. ### H2: Core Capabilities Brief overview linking to each capability's page: diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/bus/filtering.md b/design/specs/070-doc-overhaul/outlines/core-concepts/bus/filtering.md index 0bbfcad7d..515981703 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/bus/filtering.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/bus/filtering.md @@ -21,20 +21,15 @@ Dictionary filtering and predicate filtering for `on_call_service`. ### H2: Advanced Topic Subscriptions `on()` with custom topic strings and predicates. -### H2: Complete Reference -#### H3: Predicates (`P`) -Full reference table. Include: `AllOf`, `AnyOf`, `Not`, `Guard`, `StateFrom`, `StateTo`, `StateDidChange`, `StateComparison`, `AttrFrom`, `AttrTo`, `AttrDidChange`, `AttrComparison`, `DidChange`, `IsPresent`, `IsMissing`, `ValueIs`, `EntityMatches`, `DomainMatches`, `ServiceMatches`, `ServiceDataWhere` (with `from_kwargs` classmethod and `auto_glob` param), and any others in `predicates.py`. Note: `StateFromTo` does NOT exist — use separate `StateFrom` + `StateTo`. -#### H3: Conditions (`C`) -Full reference table. Include: `Increased`, `Decreased`, `Comparison` (raw operator), `IsNone`, `IsNotNone`, `Present`, `Missing` (sentinel-based, distinct from `IsNone`), `IsIn`, `NotIn`, `Intersects`, `NotIntersects`, `IsOrContains`, `StartsWith`, `EndsWith`, `Contains`, `Regex`, `Glob`. Note: `InRange` does NOT exist — use `Comparison` for range checks. -#### H3: Accessors (`A`) -Full reference table grouped by category: state value (`get_state_value_new`, `get_state_value_old`, `get_state_value_old_new`), state object (`get_state_object_old`, `get_state_object_new`), attribute (`get_attr_old`, `get_attr_new`, `get_attr_old_new`, `get_attrs_old`, `get_attrs_new`, `get_all_attrs_old`, `get_all_attrs_new`), identity (`get_domain`, `get_entity_id`, `get_context`), service (`get_service`, `get_service_data`, `get_service_data_key`), path (`get_path`), diff (`get_all_changes`). How accessors plug into predicates via the `source=` parameter. +### H2: Full Reference +→ Predicate, Condition & Accessor Reference page (`bus/predicate-reference.md`) for the complete P/C/A lookup tables. ## Snippet Inventory | Snippet | Status | Notes | |---|---|---| -| `filtering_simple_start.py` | Review | May move to States/Subscribing | -| `filtering_simple_stop.py` | Review | May move to States/Subscribing | +| `filtering_simple_start.py` | Move → States/Subscribing | State-change-specific | +| `filtering_simple_stop.py` | Move → States/Subscribing | State-change-specific | | `filtering_predicate_lambda.py` | Keep | General predicate example | | `filtering_predicate_isin.py` | Keep | Collection predicate | | `filtering_combined_and.py` | Keep | Predicate composition | @@ -44,13 +39,13 @@ Full reference table grouped by category: state value (`get_state_value_new`, `g | `filtering_service_predicates.py` | Keep | Service predicate | | `filtering_service_presence.py` | Keep | Service presence check | | `filtering_service_matches.py` | Keep | ServiceMatches predicate | -| `filtering_state_from_to.py` | Review | May move to States/Subscribing | -| `filtering_increased_decreased.py` | Review | May move to States/Subscribing | +| `filtering_state_from_to.py` | Move → States/Subscribing | State-change-specific | +| `filtering_increased_decreased.py` | Move → States/Subscribing | State-change-specific | | `filtering_advanced_topics.py` | Keep | Advanced topic subscription | -| `changed_false.py` | Review | May move to States/Subscribing | +| `changed_false.py` | Move → States/Subscribing | State-change-specific | | `custom_accessors.py` | Move | → Custom Extractors page | ## Cross-Links -- **Links to:** States/Subscribing (state-specific patterns), Custom Extractors (accessors), Handlers +- **Links to:** Predicate Reference (P/C/A tables), States/Subscribing (state-specific patterns), Custom Extractors (accessors), Handlers - **Linked from:** Bus overview, States/Subscribing diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/bus/overview.md b/design/specs/070-doc-overhaul/outlines/core-concepts/bus/overview.md index cb786ad5f..423c61b90 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/bus/overview.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/bus/overview.md @@ -14,6 +14,9 @@ All snippets written and tested in T03: - `bus_glob_patterns.py` — glob pattern matching - `bus_rate_control.py` — three section markers (debounce, throttle, once) +### H2: Synchronous Usage +`self.bus.sync` (`BusSyncFacade`) — mirrors all subscription methods as blocking calls for `AppSync` hooks. + ## Cross-Links - **Links to:** Handlers, DI, Filtering, States/Subscribing, Scheduler overview diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/bus/predicate-reference.md b/design/specs/070-doc-overhaul/outlines/core-concepts/bus/predicate-reference.md new file mode 100644 index 000000000..c1c7317f2 --- /dev/null +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/bus/predicate-reference.md @@ -0,0 +1,24 @@ +# Bus — Predicate, Condition & Accessor Reference + +**Status:** New page (split from bus/filtering.md "Complete Reference" section) +**Voice mode:** Reference — tabular, terse, system-as-subject + +## Outline + +### H2: Predicates (`P`) +Full reference table. Include: `AllOf`, `AnyOf`, `Not`, `Guard`, `StateFrom`, `StateTo`, `StateDidChange`, `StateComparison`, `AttrFrom`, `AttrTo`, `AttrDidChange`, `AttrComparison`, `DidChange`, `IsPresent`, `IsMissing`, `ValueIs`, `EntityMatches`, `DomainMatches`, `ServiceMatches`, `ServiceDataWhere` (with `from_kwargs` classmethod and `auto_glob` param). Note: `StateFromTo` does NOT exist — use separate `StateFrom` + `StateTo`. + +### H2: Conditions (`C`) +Full reference table. Include: `Increased`, `Decreased`, `Comparison` (raw operator), `IsNone`, `IsNotNone`, `Present`, `Missing` (sentinel-based, distinct from `IsNone`), `IsIn`, `NotIn`, `Intersects`, `NotIntersects`, `IsOrContains`, `StartsWith`, `EndsWith`, `Contains`, `Regex`, `Glob`. Note: `InRange` does NOT exist — use `Comparison` for range checks. + +### H2: Accessors (`A`) +Full reference table grouped by category: state value (`get_state_value_new`, `get_state_value_old`, `get_state_value_old_new`), state object (`get_state_object_old`, `get_state_object_new`), attribute (`get_attr_old`, `get_attr_new`, `get_attr_old_new`, `get_attrs_old`, `get_attrs_new`, `get_all_attrs_old`, `get_all_attrs_new`), identity (`get_domain`, `get_entity_id`, `get_context`), service (`get_service`, `get_service_data`, `get_service_data_key`), path (`get_path`), diff (`get_all_changes`). How accessors plug into predicates via the `source=` parameter. + +## Snippet Inventory + +No snippets — pure reference tables. Usage examples live on bus/filtering.md and states/subscribing.md. + +## Cross-Links + +- **Links to:** Bus/Filtering (concept), States/Subscribing (state-change patterns), Custom Extractors +- **Linked from:** Bus/Filtering, States/Subscribing, Bus/Handlers, Recipes diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/configuration/overview.md b/design/specs/070-doc-overhaul/outlines/core-concepts/configuration/overview.md index e207f75f8..cad95624a 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/configuration/overview.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/configuration/overview.md @@ -41,6 +41,21 @@ Timeout enforcement limitations: sync handlers can't be interrupted mid-executio #### H3: App Discovery `apps.directory`, `extend_exclude_dirs` vs `exclude_dirs` footgun, `run_app_precheck` and `allow_startup_if_app_precheck_fails`. +#### H3: Event Filtering +`bus_excluded_domains` and `bus_excluded_entities` — glob patterns to silently drop events before they reach handlers. Common use: filtering noisy domains like `sensor` during development. Cross-link to troubleshooting KI-06 for the "handler never runs" pitfall. + +#### H3: Development and Debugging +`dev_mode` — auto-detected from debugger attachment or `python -X dev`; enables extra diagnostics. `asyncio_debug_mode` — enables asyncio debug mode for coroutine tracing. `ui_hot_reload` — live reloading of web UI files during development. + +#### H3: Web API +`cors_origins` — allowed CORS origins for the REST API. + +#### H3: Cache +`default_cache_size` — size limit for per-resource disk caches. + +#### H3: State Proxy Polling +`state_proxy_poll_interval_seconds` and `disable_state_proxy_polling` — control the StateProxy's background polling of HA state. Disabling polling means the cache only updates via the WebSocket event stream. + ### H2: Full Reference Link to auto-generated API reference for `HassetteConfig` and all sub-models. All fields, types, defaults, and descriptions are maintained in the source code and rendered automatically. diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/internals/overview.md b/design/specs/070-doc-overhaul/outlines/core-concepts/internals/overview.md new file mode 100644 index 000000000..493350c18 --- /dev/null +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/internals/overview.md @@ -0,0 +1,24 @@ +# Internals — Overview + +**Status:** New page +**Voice mode:** Concept — system-as-subject, no "you", contributor/deep-dive audience + +## Outline + +### H2: (Opening) +This section is for contributors to Hassette's core and for advanced users who want to understand the framework's internal architecture. App authors do not need to read this section — the core-concepts pages cover everything needed to build apps. + +### H2: What's Here +Brief index of the three internals pages: +- **Architecture & Data Flow** — how events flow from the WebSocket through the bus to handlers, with Mermaid diagram +- **Lifecycle & Supervision** — startup/shutdown wave ordering, resource state machine, RestartSpec and service supervision +- **Service Details** — database schema, migration system, registration persistence, individual service responsibilities + +## Snippet Inventory + +None — prose and diagrams only. + +## Cross-Links + +- **Links to:** Architecture & Data Flow, Lifecycle, Service Details +- **Linked from:** Architecture page (deep dive link) diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/scheduler/methods.md b/design/specs/070-doc-overhaul/outlines/core-concepts/scheduler/methods.md index 1e9bd47de..24368b22a 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/scheduler/methods.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/scheduler/methods.md @@ -9,9 +9,9 @@ The generic `schedule(func, trigger)` method. All convenience methods are shortcuts for this. ### H2: Convenience Methods -#### H3: `run_in` — run after a delay +#### H3: `run_in` — run after a delay. Accepts `seconds`, `minutes`, or a `TimeDelta` directly. #### H3: `run_once` — run at a specific time. Has `if_past=` parameter (`"tomorrow"` or `"error"`, default `"tomorrow"`). For `ZonedDateTime` inputs, `if_past` has no effect (fires immediately). -#### H3: `run_every` — run at a fixed interval +#### H3: `run_every` — run at a fixed interval. The underlying `Every` trigger exposes `interval_seconds` for introspection. ### H2: Convenience Interval Helpers #### H3: `run_minutely` diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/states/domain-states.md b/design/specs/070-doc-overhaul/outlines/core-concepts/states/domain-states.md index c46132262..d4fa1d09f 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/states/domain-states.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/states/domain-states.md @@ -1,6 +1,6 @@ # States — DomainStates Reference -**Status:** Stub (3 lines), new content needed +**Status:** ABSORBED into `states/overview.md` "Built-in State Types" section. This page will be removed from nav. **Voice mode:** Reference — tabular, terse, system-as-subject ## Outline diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/states/overview.md b/design/specs/070-doc-overhaul/outlines/core-concepts/states/overview.md index 4d87d46be..44532dadc 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/states/overview.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/states/overview.md @@ -18,7 +18,18 @@ Mermaid diagram showing StateManager → StateProxy → DomainStates flow. Methods: `get()`, `items()`, `keys()`, `values()`, `to_dict()`, `__iter__`, `__len__`, `__contains__`, `__getitem__`, `__bool__`. ### H2: Built-in State Types -Table of all auto-generated domain state classes (SensorState, LightState, etc.) with key attributes. +Reference table of all auto-generated domain state classes. For each: domain name, state class (e.g., `LightState`), `value` type (bool, str, float, etc.), key attributes (e.g., `brightness`, `color_temp`). These classes are auto-generated from HA core source. Common attribute patterns across domains. Attributes are Python-typed, not raw HA dicts. For domains not covered or custom attributes, link to Custom States. + +*Absorbs content from the former `domain-states.md` standalone page.* + +### H2: State Model Properties +Properties available on all `BaseState` subclasses beyond `value` and `attributes`: +- `is_unknown` / `is_unavailable` — boolean flags. When HA reports `"unknown"` or `"unavailable"`, the state string is not stored in `value` (which would break strong typing — e.g., `bool` for switches, `float` for sensors). Instead, `value` is set to `None` and the corresponding flag is set to `True`. Check these flags before using `value`. +- `is_group` — whether the entity is a group entity +- `extras` dict and `extra(key)` method — access to untyped attributes not declared on the typed attributes class + +Properties on `AttributesBase`: +- `has_feature(flag)` — bitfield check against `supported_features` for domain-specific capability detection (e.g., `SUPPORT_BRIGHTNESS`) ### H2: Good to Know Edge cases, caching behavior, state freshness. @@ -32,5 +43,5 @@ Edge cases, caching behavior, state freshness. ## Cross-Links -- **Links to:** Subscribing, DomainStates Reference, Custom States, State Registry, Type Registry +- **Links to:** Subscribing, Custom States, State Registry, Type Registry - **Linked from:** Architecture, Apps overview, API/Entities diff --git a/design/specs/070-doc-overhaul/outlines/getting-started/hassette-vs-ha-yaml.md b/design/specs/070-doc-overhaul/outlines/getting-started/hassette-vs-ha-yaml.md index ccf4b0053..9f460bb70 100644 --- a/design/specs/070-doc-overhaul/outlines/getting-started/hassette-vs-ha-yaml.md +++ b/design/specs/070-doc-overhaul/outlines/getting-started/hassette-vs-ha-yaml.md @@ -1,6 +1,6 @@ # Hassette vs HA YAML -**Status:** Exists (77 lines), voice polish needed +**Status:** ABSORBED into `is-hassette-right-for-you.md` "Quick Comparison" section. This page will be removed from nav. **Voice mode:** Getting-started — "you" allowed, comparison-driven ## Outline diff --git a/design/specs/070-doc-overhaul/outlines/getting-started/is-hassette-right-for-you.md b/design/specs/070-doc-overhaul/outlines/getting-started/is-hassette-right-for-you.md index 99c4d156e..7e20053d5 100644 --- a/design/specs/070-doc-overhaul/outlines/getting-started/is-hassette-right-for-you.md +++ b/design/specs/070-doc-overhaul/outlines/getting-started/is-hassette-right-for-you.md @@ -11,6 +11,11 @@ ### H2: When HA YAML Is Enough Honest acknowledgment: simple trigger→action automations, no coding preference, UI-built automations suffice. Not a dismissal — links to HA docs for those users. +### H2: Quick Comparison +Collapsible table comparing HA UI automations, HA YAML, and Hassette across dimensions (language, debugging, testing, version control, learning curve, complexity ceiling). What Hassette does not replace: integrations, dashboards, add-ons — Hassette is automations only. + +*Absorbs content from the former `hassette-vs-ha-yaml.md` page.* + ### H2: What Hassette Requires Practical prerequisites: Python 3.11+, a machine to run the process (same box or Docker), a long-lived access token, comfort with async/await basics. Brief — not a setup guide. @@ -18,7 +23,7 @@ Practical prerequisites: Python 3.11+, a machine to run the process (same box or Brief pointer to the AppDaemon Migration section for users migrating existing apps. ### H2: Next Steps -Two paths: "Ready to try it?" → Quickstart. "Want more detail on the tradeoffs?" → Hassette vs HA YAML. +"Ready to try it?" → Quickstart. Coming from AppDaemon? → Migration section. ## Snippet Inventory @@ -26,5 +31,5 @@ None — prose-only decision page. ## Cross-Links -- **Links to:** Quickstart (`index.md`), Hassette vs HA YAML (`hassette-vs-ha-yaml.md`), Docker Setup (`docker/index.md`) -- **Linked from:** Home (`index.md`), Hassette vs HA YAML (back-link) +- **Links to:** Quickstart (`index.md`), Docker Setup (`docker/index.md`), Migration overview +- **Linked from:** Home (`index.md`) diff --git a/design/specs/070-doc-overhaul/outlines/operating/log-levels.md b/design/specs/070-doc-overhaul/outlines/operating/log-levels.md index 46a4a39a2..79441a8d9 100644 --- a/design/specs/070-doc-overhaul/outlines/operating/log-levels.md +++ b/design/specs/070-doc-overhaul/outlines/operating/log-levels.md @@ -19,7 +19,7 @@ Link to auto-generated `LoggingConfig` reference for the full field list (avoids Unset fields use global log level. ### H2: Debug Flags -`all_events`, `all_hass_events`, `all_hassette_events` — boolean flags for bus debug verbosity. `log_format` (`"auto"`, `"console"`, `"json"`) for output format. +`all_events`, `all_hass_events`, `all_hassette_events` — boolean flags for bus debug verbosity. `log_format` (`"auto"`, `"console"`, `"json"`) for output format. `log_persistence_level` — minimum level for log records written to the database (separate from console level). ### H2: Per-App Log Levels Set in the app config block under `[hassette.apps..config]`, not in `[hassette.logging]`. diff --git a/design/specs/070-doc-overhaul/outlines/recipes/daily-notification.md b/design/specs/070-doc-overhaul/outlines/recipes/daily-notification.md index c51e0aca4..f4ee67308 100644 --- a/design/specs/070-doc-overhaul/outlines/recipes/daily-notification.md +++ b/design/specs/070-doc-overhaul/outlines/recipes/daily-notification.md @@ -15,7 +15,7 @@ Full app with `run_daily` and `call_service` for notify. Walk through the code decisions. Voice-guide rule #21: system-as-subject, one decision per paragraph. ### H2: Verify It's Working -**New section needed** — add `hassette log` / `hassette job` verification step per recipe template. +`hassette job --app ` to confirm the daily job is scheduled with the correct next-run time. `hassette log --app --since 1d` after the scheduled time to see the notification fire. Expected: one log entry per day at the configured time. ### H2: Variations Alternative triggers (cron), different notification services, conditional notifications. @@ -28,5 +28,5 @@ Alternative triggers (cron), different notification services, conditional notifi ## Cross-Links -- **Links to:** Scheduler/Methods (run_daily, run_cron), API/Services (call_service) +- **Links to:** Scheduler/Methods (run_daily, run_cron), API/Services (call_service), Testing overview (write a test for this pattern) - **Linked from:** Recipes overview diff --git a/design/specs/070-doc-overhaul/outlines/recipes/debounce-sensor-changes.md b/design/specs/070-doc-overhaul/outlines/recipes/debounce-sensor-changes.md index d0ef9184c..1395ff946 100644 --- a/design/specs/070-doc-overhaul/outlines/recipes/debounce-sensor-changes.md +++ b/design/specs/070-doc-overhaul/outlines/recipes/debounce-sensor-changes.md @@ -15,7 +15,7 @@ App with `on_state_change(debounce=10.0)`. What debounce does: resets timer on each new event, fires only after quiet period. ### H2: Verify It's Working -**New section needed.** +`hassette listener --app ` to confirm the handler is registered. `hassette log --app --since 5m` to see handler invocations. Expected: handler fires only after the debounce quiet period, not on every sensor reading. ### H2: Variations Different debounce values, switching to throttle instead (debounce and throttle are mutually exclusive — `ValueError` if both set), sensor-specific patterns. @@ -28,5 +28,5 @@ Different debounce values, switching to throttle instead (debounce and throttle ## Cross-Links -- **Links to:** Bus overview (rate control section), States/Subscribing +- **Links to:** Bus overview (rate control section), States/Subscribing, Testing overview (write a test for this pattern) - **Linked from:** Recipes overview diff --git a/design/specs/070-doc-overhaul/outlines/recipes/motion-lights.md b/design/specs/070-doc-overhaul/outlines/recipes/motion-lights.md index 0c8797eb4..b77d3f17f 100644 --- a/design/specs/070-doc-overhaul/outlines/recipes/motion-lights.md +++ b/design/specs/070-doc-overhaul/outlines/recipes/motion-lights.md @@ -15,5 +15,5 @@ Written and tested in T03: ## Cross-Links -- **Links to:** Bus overview, Scheduler/Methods (run_in), States/Subscribing (on_state_change patterns) +- **Links to:** Bus overview, Scheduler/Methods (run_in), States/Subscribing (on_state_change patterns), Testing overview (write a test for this pattern) - **Linked from:** Recipes overview, First Automation (next steps) diff --git a/design/specs/070-doc-overhaul/outlines/recipes/sensor-threshold.md b/design/specs/070-doc-overhaul/outlines/recipes/sensor-threshold.md index 071a9b5a8..cb1af9c6c 100644 --- a/design/specs/070-doc-overhaul/outlines/recipes/sensor-threshold.md +++ b/design/specs/070-doc-overhaul/outlines/recipes/sensor-threshold.md @@ -15,7 +15,7 @@ App with `on_state_change` + numeric condition or predicate. How `C.Increased`/`C.Decreased` or threshold predicates work in this context. ### H2: Verify It's Working -**New section needed.** +`hassette listener --app ` to confirm the handler is registered with the threshold predicate. `hassette log --app --since 1h` to see handler invocations. Expected: handler fires only when the sensor crosses the threshold, not on every reading. ### H2: Variations Hysteresis (don't re-trigger until value drops back), multiple thresholds, combining sensors. @@ -28,5 +28,5 @@ Hysteresis (don't re-trigger until value drops back), multiple thresholds, combi ## Cross-Links -- **Links to:** States/Subscribing (numeric conditions), Bus/Filtering (C.Increased, C.Decreased) +- **Links to:** States/Subscribing (numeric conditions), Bus/Filtering (C.Increased, C.Decreased), Testing overview (write a test for this pattern) - **Linked from:** Recipes overview diff --git a/design/specs/070-doc-overhaul/outlines/recipes/service-call-reaction.md b/design/specs/070-doc-overhaul/outlines/recipes/service-call-reaction.md index 8efee3455..1c746ffb5 100644 --- a/design/specs/070-doc-overhaul/outlines/recipes/service-call-reaction.md +++ b/design/specs/070-doc-overhaul/outlines/recipes/service-call-reaction.md @@ -15,7 +15,7 @@ App with `on_call_service` subscription. Service call events, filtering by domain/service. ### H2: Verify It's Working -**New section needed.** +`hassette listener --app ` to confirm the service-call handler is registered. Trigger the service via HA UI, then `hassette log --app --since 5m` to see the handler fire. Expected: one log entry per service call matching the filter. ### H2: Variations Filtering by entity, combining with state checks. @@ -28,5 +28,5 @@ Filtering by entity, combining with state checks. ## Cross-Links -- **Links to:** Bus/Handlers (on_call_service), Bus/Filtering (service call filtering) +- **Links to:** Bus/Handlers (on_call_service), Bus/Filtering (service call filtering), Testing overview (write a test for this pattern) - **Linked from:** Recipes overview diff --git a/design/specs/070-doc-overhaul/outlines/recipes/vacation-mode-toggle.md b/design/specs/070-doc-overhaul/outlines/recipes/vacation-mode-toggle.md index af5f55ec2..80a431169 100644 --- a/design/specs/070-doc-overhaul/outlines/recipes/vacation-mode-toggle.md +++ b/design/specs/070-doc-overhaul/outlines/recipes/vacation-mode-toggle.md @@ -15,7 +15,7 @@ App watching an input_boolean, enabling/disabling other behaviors. Pattern: input_boolean as a mode switch, conditional logic in handlers. ### H2: Verify It's Working -**New section needed.** +Toggle the input_boolean in HA UI, then `hassette log --app --since 5m` to see the mode-change handler fire. `hassette listener --app ` to confirm the subscription is registered. Expected: handler fires on each toggle with the correct boolean state. ### H2: Variations Multiple modes, time-based auto-toggle, notification on mode change. @@ -28,5 +28,5 @@ Multiple modes, time-based auto-toggle, notification on mode change. ## Cross-Links -- **Links to:** States/Subscribing (input_boolean state changes), API/Services (call_service for toggling), Cache (persisting mode state) +- **Links to:** States/Subscribing (input_boolean state changes), API/Services (call_service for toggling), Cache (persisting mode state), Testing overview (write a test for this pattern) - **Linked from:** Recipes overview diff --git a/design/specs/070-doc-overhaul/outlines/testing/overview.md b/design/specs/070-doc-overhaul/outlines/testing/overview.md index 628394b6a..7591fc8a4 100644 --- a/design/specs/070-doc-overhaul/outlines/testing/overview.md +++ b/design/specs/070-doc-overhaul/outlines/testing/overview.md @@ -1,15 +1,12 @@ -# Testing — Testing Your Apps +# Testing — Test Harness Reference -**Status:** Exists (243 lines), comprehensive, voice polish needed -**Voice mode:** Concept/getting-started hybrid — "you" allowed for procedural parts +**Status:** Exists (243 lines), restructured — Installation and Quick Start moved to `testing/quickstart.md` +**Voice mode:** Reference — system-as-subject, no "you" ## Outline -### H2: Installation -pytest + hassette test extras. - -### H2: Quick Start -Minimal test example with the harness. +### H2: Prerequisites +One-line note: `hassette[test]` extras required. Link to Testing Quickstart for setup walkthrough. ### H2: The Test Harness #### H3: Constructor — `AppTestHarness(AppClass, config)` parameters @@ -53,5 +50,5 @@ Testing apps that fail during initialization. ## Cross-Links -- **Links to:** Time Control, Concurrency, Factories, Apps overview -- **Linked from:** Getting Started (next steps), Migration/Testing +- **Links to:** Testing Quickstart, Time Control, Concurrency, Factories, Apps overview +- **Linked from:** Testing Quickstart, Recipes (see also), Migration/Testing diff --git a/design/specs/070-doc-overhaul/outlines/testing/quickstart.md b/design/specs/070-doc-overhaul/outlines/testing/quickstart.md new file mode 100644 index 000000000..537be1073 --- /dev/null +++ b/design/specs/070-doc-overhaul/outlines/testing/quickstart.md @@ -0,0 +1,38 @@ +# Testing — Write Your First Test + +**Status:** New page (split from testing/overview.md) +**Voice mode:** Getting-started — "you" allowed, step-by-step + +## Outline + +### H2: What You'll Learn +Write a test for a Hassette app using AppTestHarness. Seed state, simulate events, assert API calls. + +### H2: Prerequisites +pytest + hassette test extras installation. One-liner: `pip install hassette[test]` or `uv add hassette[test]`. + +### H2: Step 1: Create a Test File +Minimal test file structure, naming convention (`test_.py`). + +### H2: Step 2: Set Up the Harness +`AppTestHarness(YourApp, config)` — construct and initialize. Show the `async with` pattern. + +### H2: Step 3: Seed State and Simulate +Seed an entity state, simulate a state change, verify the handler ran. + +### H2: Step 4: Assert the Result +`harness.api_recorder.assert_called("light/turn_on", ...)` to verify the app called the right service. + +### H2: Next Steps +→ Testing overview (full API reference), → Time Control, → Recipes (write tests for recipe patterns) + +## Snippet Inventory + +| Snippet | Status | Notes | +|---|---|---| +| New: `first_test.py` | New | Complete minimal test example | + +## Cross-Links + +- **Links to:** Testing overview (reference), Time Control, Concurrency, Factories +- **Linked from:** Getting Started/First Automation (next steps), Recipes (see also) diff --git a/design/specs/070-doc-overhaul/outlines/troubleshooting/troubleshooting.md b/design/specs/070-doc-overhaul/outlines/troubleshooting/troubleshooting.md index 7a1c32887..09fa3ac6c 100644 --- a/design/specs/070-doc-overhaul/outlines/troubleshooting/troubleshooting.md +++ b/design/specs/070-doc-overhaul/outlines/troubleshooting/troubleshooting.md @@ -43,6 +43,18 @@ Port/URL, Docker port mapping, `web_api` settings. ### H2: Docker-Specific Issues Pointer to Docker Troubleshooting page. +### H2: Exception Reference +Common exceptions app authors may encounter, organized by category: +- **Connection:** `InvalidAuthError`, `BaseUrlRequiredError`, `CouldNotFindHomeAssistantError`, `ConnectionClosedError` +- **Registration:** `ListenerNameRequiredError` (cross-link to H2 above), `DuplicateListenerError` (cross-link to H2 above) +- **State conversion:** `EntityNotFoundError`, `DomainNotFoundError`, `RegistryNotReadyError` +- **Dependency injection:** `DependencyInjectionError`, `DependencyResolutionError` +- **Lifecycle:** `InvalidLifecycleTransitionError` (includes `from_status`, `to_status`, `resource_name` attributes) +- **Configuration:** `AppPrecheckFailedError` +- **Framework:** `HassetteError` (base), `FatalError` (non-restartable, triggers shutdown) + +Brief per-entry: what triggers it, what to do about it. Not a full API reference — link to auto-generated exception docs for the complete list. + **Removed from this page (moved to Operating):** - WebSocket reconnection sequence → Operating/overview.md - Event handler exception behavior → Operating/overview.md From dbeb7ff0d0f7940ed96a7819fa47fd61e6557218 Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Tue, 2 Jun 2026 08:31:24 -0500 Subject: [PATCH 032/160] =?UTF-8?q?chore:=20outline=20audit=20=E2=80=94=20?= =?UTF-8?q?fix=20nav=20structure,=20kill=20rotting=20pages,=20resolve=20co?= =?UTF-8?q?ntradictions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core-concepts/apps/configuration.md | 27 +++++--------- .../outlines/core-concepts/apps/overview.md | 7 ++++ .../core-concepts/configuration/overview.md | 18 ++++------ .../internals/architecture-data-flow.md | 3 ++ .../core-concepts/internals/overview.md | 23 ++---------- .../core-concepts/states/domain-states.md | 36 ++----------------- .../outlines/core-concepts/states/overview.md | 11 +++--- .../is-hassette-right-for-you.md | 2 +- .../outlines/operating/overview.md | 11 ++++++ .../outlines/testing/overview.md | 2 +- .../outlines/testing/quickstart.md | 2 +- mkdocs.yml | 9 +++-- 12 files changed, 55 insertions(+), 96 deletions(-) diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/apps/configuration.md b/design/specs/070-doc-overhaul/outlines/core-concepts/apps/configuration.md index 00db5663b..794fdcb82 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/apps/configuration.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/apps/configuration.md @@ -1,24 +1,13 @@ # Apps — Configuration -**Status:** Exists (34 lines), very short, may need expansion -**Voice mode:** Concept — system-as-subject, no "you" +**Status:** ABSORBED into `apps/overview.md`. Content becomes an H2 in the Apps overview. -## Outline +At 34 lines (3 base fields + env prefix + secrets), this doesn't justify its own page. The Apps overview already has "Defining an App" — config class definition belongs there. -### H2: Defining Config Models -AppConfig subclass with Pydantic SettingsConfigDict. How env_prefix maps to hassette.toml. +Content to fold into apps/overview.md: +- AppConfig subclass with SettingsConfigDict and env_prefix +- Base fields: `instance_name`, `log_level`, `app_key` (+ reserved prefix validator) +- `extra="allow"` behavior, `env_ignore_empty=True` +- Secrets & env vars via Pydantic BaseSettings -### H2: Base Fields -Fields inherited from AppConfig (instance_name, etc.). - -### H2: Secrets & Environment Variables -Loading secrets from env vars via Pydantic. - -## Snippet Inventory - -Snippets from `apps/snippets/` that show config patterns — review and assign. - -## Cross-Links - -- **Links to:** Configuration/Applications (hassette.toml side), Apps overview -- **Linked from:** Apps overview, First Automation (step 2) +See decision in outline audit (2026-06-02). diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/apps/overview.md b/design/specs/070-doc-overhaul/outlines/core-concepts/apps/overview.md index e11da7055..c2cac5a74 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/apps/overview.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/apps/overview.md @@ -11,6 +11,13 @@ App[Config] generic, five handles (bus, scheduler, api, states, cache), logger. ### H2: Defining an App Minimal app example, AppConfig usage. +*Absorbs content from the former `apps/configuration.md` (34 lines):* +- AppConfig subclass with `SettingsConfigDict` and `env_prefix` +- Base fields: `instance_name`, `log_level`, `app_key` (reserved prefix validator) +- `extra="allow"` (arbitrary config without defined fields), `env_ignore_empty=True` +- Secrets & env vars via Pydantic `BaseSettings` +- Link to Configuration/Applications for the TOML registration side + ### H2: Dates and Times `whenever` library usage for date/time in apps. `self.now()` returns the current `ZonedDateTime`. diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/configuration/overview.md b/design/specs/070-doc-overhaul/outlines/core-concepts/configuration/overview.md index cad95624a..a93cd2547 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/configuration/overview.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/configuration/overview.md @@ -26,14 +26,8 @@ Brief map of what's configurable, linking to the auto-generated reference for fi - File Watcher: `[hassette.file_watcher]` - Scheduler: `[hassette.scheduler]` -### H2: Operational Tuning Guidance -Teaching content that can't come from auto-generated reference — explain the design, not just the fields: - -#### H3: WebSocket Resilience -Three-layer retry model (connect retries → early-drop retries → ServiceWatcher budget). When to tune each layer. `max_recovery_seconds` as the total wall-clock cap. - -#### H3: Timeout Behavior -Timeout enforcement limitations: sync handlers can't be interrupted mid-execution (the timeout fires but the handler continues). `TimeoutError` swallowing behavior. `run_sync_timeout_seconds` default (6s). +### H2: Configuration Field Notes +Brief design-rationale notes for fields where the auto-generated reference doesn't explain the "why." Each H3 is 1-3 sentences. Readers looking for field types and defaults go to the auto-generated HassetteConfig reference; this section covers the design intent. #### H3: Data Directory and Upgrades `data_dir` path, major version implications, cache path derivation (`data_dir//cache`). @@ -42,10 +36,10 @@ Timeout enforcement limitations: sync handlers can't be interrupted mid-executio `apps.directory`, `extend_exclude_dirs` vs `exclude_dirs` footgun, `run_app_precheck` and `allow_startup_if_app_precheck_fails`. #### H3: Event Filtering -`bus_excluded_domains` and `bus_excluded_entities` — glob patterns to silently drop events before they reach handlers. Common use: filtering noisy domains like `sensor` during development. Cross-link to troubleshooting KI-06 for the "handler never runs" pitfall. +`bus_excluded_domains` and `bus_excluded_entities` — glob patterns to silently drop events before they reach handlers. Cross-link to troubleshooting KI-06. #### H3: Development and Debugging -`dev_mode` — auto-detected from debugger attachment or `python -X dev`; enables extra diagnostics. `asyncio_debug_mode` — enables asyncio debug mode for coroutine tracing. `ui_hot_reload` — live reloading of web UI files during development. +`dev_mode` — auto-detected from debugger attachment or `python -X dev`. `asyncio_debug_mode`. `ui_hot_reload`. #### H3: Web API `cors_origins` — allowed CORS origins for the REST API. @@ -54,7 +48,9 @@ Timeout enforcement limitations: sync handlers can't be interrupted mid-executio `default_cache_size` — size limit for per-resource disk caches. #### H3: State Proxy Polling -`state_proxy_poll_interval_seconds` and `disable_state_proxy_polling` — control the StateProxy's background polling of HA state. Disabling polling means the cache only updates via the WebSocket event stream. +`state_proxy_poll_interval_seconds` and `disable_state_proxy_polling`. + +*WebSocket resilience and timeout behavior moved to Operating/overview.md alongside KI-01/KI-02 — see outline audit (2026-06-02).* ### H2: Full Reference Link to auto-generated API reference for `HassetteConfig` and all sub-models. All fields, types, defaults, and descriptions are maintained in the source code and rendered automatically. diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/internals/architecture-data-flow.md b/design/specs/070-doc-overhaul/outlines/core-concepts/internals/architecture-data-flow.md index c8cccbea7..27898a68c 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/internals/architecture-data-flow.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/internals/architecture-data-flow.md @@ -5,6 +5,9 @@ ## Outline +### (Opening — absorbed from internals/overview.md) +Audience declaration: "This section is for contributors to Hassette's core and for advanced users who want to understand the framework's internal architecture. App authors do not need to read this section." Brief index of the three internals pages (Architecture & Data Flow, Lifecycle, Service Details). + ### H2: Component Ownership Which service owns which state. Map of services to the resources they manage. diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/internals/overview.md b/design/specs/070-doc-overhaul/outlines/core-concepts/internals/overview.md index 493350c18..a8d5a3bc9 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/internals/overview.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/internals/overview.md @@ -1,24 +1,7 @@ # Internals — Overview -**Status:** New page -**Voice mode:** Concept — system-as-subject, no "you", contributor/deep-dive audience +**Status:** FOLDED into `internals/architecture-data-flow.md` (the index.md). No separate overview page. -## Outline +The audience declaration ("This section is for contributors...") becomes the opening paragraph of the Architecture & Data Flow page. The 3-line index folds into a "What's in this section" note at the top. -### H2: (Opening) -This section is for contributors to Hassette's core and for advanced users who want to understand the framework's internal architecture. App authors do not need to read this section — the core-concepts pages cover everything needed to build apps. - -### H2: What's Here -Brief index of the three internals pages: -- **Architecture & Data Flow** — how events flow from the WebSocket through the bus to handlers, with Mermaid diagram -- **Lifecycle & Supervision** — startup/shutdown wave ordering, resource state machine, RestartSpec and service supervision -- **Service Details** — database schema, migration system, registration persistence, individual service responsibilities - -## Snippet Inventory - -None — prose and diagrams only. - -## Cross-Links - -- **Links to:** Architecture & Data Flow, Lifecycle, Service Details -- **Linked from:** Architecture page (deep dive link) +See decision in outline audit (2026-06-02): a 5-line index page doesn't justify its own nav entry. diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/states/domain-states.md b/design/specs/070-doc-overhaul/outlines/core-concepts/states/domain-states.md index d4fa1d09f..3749596d0 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/states/domain-states.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/states/domain-states.md @@ -1,37 +1,5 @@ # States — DomainStates Reference -**Status:** ABSORBED into `states/overview.md` "Built-in State Types" section. This page will be removed from nav. -**Voice mode:** Reference — tabular, terse, system-as-subject +**Status:** REMOVED. The auto-generated API reference (`hassette.models.states` is in `PUBLIC_MODULES`) serves as the authoritative domain state reference. A hand-written table would rot as domains are added. States overview links to the API reference instead. -## Outline - -### H2: (Opening) -Brief explanation of auto-generated domain state classes. Each HA entity domain has a corresponding Python class with typed attributes. - -### H2: Reference Table -Large reference table of all domain state classes. For each: -- Domain name (e.g., `light`) -- State class (e.g., `LightState`) -- `value` type (bool, str, float, etc.) -- Key attributes (e.g., `brightness`, `color_temp`, `rgb_color`) - -### H2: Accessing Domain States -How to use these classes: `self.states.light.get("light.kitchen")` returns a `LightState`. Show the pattern once. - -### H2: Attribute Access -Common attribute patterns across domains. Attributes are Python-typed (not raw HA dicts). - -### H2: Generated vs Custom -These classes are auto-generated from HA core source. For domains not covered or for custom attributes, use Custom States. - -## Snippet Inventory - -| Snippet | Status | Notes | -|---|---|---| -| New: domain state access example | New | Accessing typed attributes from a LightState | -| New: sensor state example | New | SensorState with numeric value and unit | - -## Cross-Links - -- **Links to:** Custom States, State Registry, States overview -- **Linked from:** States overview, DI page (annotation types reference T) +See decision in outline audit (2026-06-02): killed this page because mkdocstrings already generates per-class reference for all 47 state classes from source. diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/states/overview.md b/design/specs/070-doc-overhaul/outlines/core-concepts/states/overview.md index 44532dadc..bba6b59ad 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/states/overview.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/states/overview.md @@ -5,8 +5,11 @@ ## Outline -### H2: Diagram -Mermaid diagram showing StateManager → StateProxy → DomainStates flow. +### (Opening) +Functional definition of the StateManager: what it does, `self.states` access. Match the Bus exemplar pattern — prose first. + +### Mermaid Diagram +StateManager → StateProxy → DomainStates flow. Comes after the opening prose, not before it. ### H2: Using the StateManager #### H3: Domain Access — `self.states.light`, `self.states.sensor` @@ -18,9 +21,9 @@ Mermaid diagram showing StateManager → StateProxy → DomainStates flow. Methods: `get()`, `items()`, `keys()`, `values()`, `to_dict()`, `__iter__`, `__len__`, `__contains__`, `__getitem__`, `__bool__`. ### H2: Built-in State Types -Reference table of all auto-generated domain state classes. For each: domain name, state class (e.g., `LightState`), `value` type (bool, str, float, etc.), key attributes (e.g., `brightness`, `color_temp`). These classes are auto-generated from HA core source. Common attribute patterns across domains. Attributes are Python-typed, not raw HA dicts. For domains not covered or custom attributes, link to Custom States. +Brief introduction: Hassette auto-generates typed state classes for 47 HA domains from HA core source. Show 2-3 examples inline (LightState with brightness, SensorState with numeric value, BinarySensorState with device_class). Explain the pattern: domain → state class → typed `value` + typed attributes. Link to auto-generated API reference (`hassette.models.states`) for the full inventory. For domains not covered or custom attributes, link to Custom States. -*Absorbs content from the former `domain-states.md` standalone page.* +*No hand-written reference table — the API reference auto-generates from source and never rots.* ### H2: State Model Properties Properties available on all `BaseState` subclasses beyond `value` and `attributes`: diff --git a/design/specs/070-doc-overhaul/outlines/getting-started/is-hassette-right-for-you.md b/design/specs/070-doc-overhaul/outlines/getting-started/is-hassette-right-for-you.md index 7e20053d5..28bc829d8 100644 --- a/design/specs/070-doc-overhaul/outlines/getting-started/is-hassette-right-for-you.md +++ b/design/specs/070-doc-overhaul/outlines/getting-started/is-hassette-right-for-you.md @@ -1,6 +1,6 @@ # Is Hassette Right for You? -**Status:** New page (stub exists) +**Status:** New page (stub exists at `evaluator.md`, will be renamed to `is-hassette-right-for-you.md`) **Voice mode:** Getting-started — "you" allowed, friendly, direct ## Outline diff --git a/design/specs/070-doc-overhaul/outlines/operating/overview.md b/design/specs/070-doc-overhaul/outlines/operating/overview.md index 24fed0b4f..2eaaba892 100644 --- a/design/specs/070-doc-overhaul/outlines/operating/overview.md +++ b/design/specs/070-doc-overhaul/outlines/operating/overview.md @@ -18,9 +18,20 @@ What this section covers: how Hassette behaves at runtime and how to operate it Bus events use full topic strings: `hassette.event.websocket_disconnected`, `hassette.event.websocket_connected`. App behavior during reconnection: `Api` and `StateProxy` raise `ResourceNotReadyError`, handlers resume automatically on reconnect. Include log signatures. +**When to tune** (absorbed from configuration tuning guide): +- Slow HA restarts (>30s): increase `early_drop_stable_window_seconds` +- Flaky networks: increase `connect_retry_max_attempts` and `connect_retry_max_wait_seconds` +- Low tolerance for downtime: decrease backoff values +- `max_recovery_seconds` as the total wall-clock cap for the early-drop retry loop + #### H3: Handler Exception Behavior **Content from KI-02.** Exceptions caught and swallowed, logged at ERROR, recorded in telemetry with `status='error'`. Include log signature. Matches scheduler behavior. +#### H3: Timeout Behavior +**Absorbed from configuration tuning guide.** Two global defaults: `scheduler_job_timeout_seconds` (600s) and `event_handler_timeout_seconds` (600s). Per-item overrides via `timeout=` / `timeout_disabled=`. + +Enforcement limitations: sync handlers run in a thread executor — the timeout cancels the awaitable wrapper, not the thread. `TimeoutError` swallowing: if a handler catches `TimeoutError` internally, the framework cannot cancel it. `run_sync_timeout_seconds` default (6s). + #### H3: Database Degraded Mode Brief: what happens when the DB is unavailable. Links to Database & Telemetry page for full details. diff --git a/design/specs/070-doc-overhaul/outlines/testing/overview.md b/design/specs/070-doc-overhaul/outlines/testing/overview.md index 7591fc8a4..a0a87ea3e 100644 --- a/design/specs/070-doc-overhaul/outlines/testing/overview.md +++ b/design/specs/070-doc-overhaul/outlines/testing/overview.md @@ -1,6 +1,6 @@ # Testing — Test Harness Reference -**Status:** Exists (243 lines), restructured — Installation and Quick Start moved to `testing/quickstart.md` +**Status:** Exists (243 lines), restructured — Installation and Quick Start moved to `testing/quickstart.md` (now `index.md`). This page becomes `pages/testing/harness.md` ("Test Harness Reference"). **Voice mode:** Reference — system-as-subject, no "you" ## Outline diff --git a/design/specs/070-doc-overhaul/outlines/testing/quickstart.md b/design/specs/070-doc-overhaul/outlines/testing/quickstart.md index 537be1073..e24e5fad2 100644 --- a/design/specs/070-doc-overhaul/outlines/testing/quickstart.md +++ b/design/specs/070-doc-overhaul/outlines/testing/quickstart.md @@ -1,6 +1,6 @@ # Testing — Write Your First Test -**Status:** New page (split from testing/overview.md) +**Status:** New page (split from testing/overview.md). This is now the section index (`pages/testing/index.md`). **Voice mode:** Getting-started — "you" allowed, step-by-step ## Outline diff --git a/mkdocs.yml b/mkdocs.yml index 6c97ce7c6..70294ac28 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -32,11 +32,10 @@ theme: nav: - Home: index.md - Getting Started: - - Is Hassette Right for You?: pages/getting-started/evaluator.md + - Is Hassette Right for You?: pages/getting-started/is-hassette-right-for-you.md - Quickstart: pages/getting-started/index.md - Home Assistant Token: pages/getting-started/ha_token.md - Your First Automation: pages/getting-started/first-automation.md - - Hassette vs HA YAML: pages/getting-started/hassette-vs-ha-yaml.md - Docker Deployment: - Docker Setup: pages/getting-started/docker/index.md - Managing Dependencies: pages/getting-started/docker/dependencies.md @@ -47,7 +46,6 @@ nav: - Apps: - Overview: pages/core-concepts/apps/index.md - Lifecycle: pages/core-concepts/apps/lifecycle.md - - Configuration: pages/core-concepts/apps/configuration.md - Task Bucket: pages/core-concepts/apps/task-bucket.md - Bus: - Overview: pages/core-concepts/bus/index.md @@ -55,6 +53,7 @@ nav: - Dependency Injection: pages/core-concepts/bus/dependency-injection.md - Custom Extractors: pages/core-concepts/bus/custom-extractors.md - Filtering & Predicates: pages/core-concepts/bus/filtering.md + - Predicate & Condition Reference: pages/core-concepts/bus/predicate-reference.md - Scheduler: - Overview: pages/core-concepts/scheduler/index.md - Scheduling Methods: pages/core-concepts/scheduler/methods.md @@ -62,7 +61,6 @@ nav: - States: - Overview: pages/core-concepts/states/index.md - Subscribing to State Changes: pages/core-concepts/states/subscribing.md - - DomainStates Reference: pages/core-concepts/states/domain-states.md - Custom States: pages/core-concepts/states/custom-states.md - State Registry: pages/core-concepts/states/state-registry.md - Type Registry: pages/core-concepts/states/type-registry.md @@ -96,7 +94,8 @@ nav: - Workflows: pages/cli/workflows.md - Configuration & Scripting: pages/cli/configuration.md - Testing: - - Testing Your Apps: pages/testing/index.md + - Write Your First Test: pages/testing/index.md + - Test Harness Reference: pages/testing/harness.md - Time Control: pages/testing/time-control.md - Concurrency & pytest-xdist: pages/testing/concurrency.md - Factories & Internals: pages/testing/factories.md From d426908e7537b8f997663783faa5dc72d45ba9bc Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Tue, 2 Jun 2026 08:34:18 -0500 Subject: [PATCH 033/160] chore: mark T01, T03, T04 as done --- design/specs/070-doc-overhaul/tasks/T01-site-outline.md | 2 +- design/specs/070-doc-overhaul/tasks/T03-exemplar-pages.md | 2 +- design/specs/070-doc-overhaul/tasks/T04-content-outlines.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/design/specs/070-doc-overhaul/tasks/T01-site-outline.md b/design/specs/070-doc-overhaul/tasks/T01-site-outline.md index 3dfe5f73b..67194c24b 100644 --- a/design/specs/070-doc-overhaul/tasks/T01-site-outline.md +++ b/design/specs/070-doc-overhaul/tasks/T01-site-outline.md @@ -1,7 +1,7 @@ --- task_id: "T01" title: "Create docs branch, site outline, and calibration artifacts" -status: "planned" +status: "done" depends_on: [] implements: ["FR#5", "FR#7", "FR#8", "FR#9", "FR#10", "FR#16", "FR#18", "AC#8", "AC#10", "AC#11", "AC#13", "AC#18", "AC#20"] --- diff --git a/design/specs/070-doc-overhaul/tasks/T03-exemplar-pages.md b/design/specs/070-doc-overhaul/tasks/T03-exemplar-pages.md index 6ba03103e..1babd336f 100644 --- a/design/specs/070-doc-overhaul/tasks/T03-exemplar-pages.md +++ b/design/specs/070-doc-overhaul/tasks/T03-exemplar-pages.md @@ -1,7 +1,7 @@ --- task_id: "T03" title: "Write and review three exemplar pages" -status: "planned" +status: "done" depends_on: ["T01"] implements: ["FR#1", "FR#2", "FR#3", "FR#6", "FR#14", "FR#17", "AC#1", "AC#12", "AC#16", "AC#19"] --- diff --git a/design/specs/070-doc-overhaul/tasks/T04-content-outlines.md b/design/specs/070-doc-overhaul/tasks/T04-content-outlines.md index 3280774f1..b18ccdf7b 100644 --- a/design/specs/070-doc-overhaul/tasks/T04-content-outlines.md +++ b/design/specs/070-doc-overhaul/tasks/T04-content-outlines.md @@ -1,7 +1,7 @@ --- task_id: "T04" title: "Create per-page content outlines for all pages" -status: "planned" +status: "done" depends_on: ["T01", "T03"] implements: ["FR#15", "AC#17"] --- From 9061896f62796fac71d7b64e08c72b01c604ee5d Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Tue, 2 Jun 2026 08:38:58 -0500 Subject: [PATCH 034/160] chore: update task files T06-T12 to reflect outline audit decisions --- .../tasks/T06-write-getting-started.md | 15 ++++++----- .../tasks/T07-write-core-concepts-1.md | 25 +++++++++++-------- .../tasks/T08-write-core-concepts-2.md | 22 ++++++++-------- .../tasks/T10-write-cli-testing.md | 9 ++++--- .../tasks/T12-write-migration-ops.md | 19 ++++++-------- 5 files changed, 44 insertions(+), 46 deletions(-) diff --git a/design/specs/070-doc-overhaul/tasks/T06-write-getting-started.md b/design/specs/070-doc-overhaul/tasks/T06-write-getting-started.md index 5ff79da4c..57baf7e89 100644 --- a/design/specs/070-doc-overhaul/tasks/T06-write-getting-started.md +++ b/design/specs/070-doc-overhaul/tasks/T06-write-getting-started.md @@ -8,7 +8,7 @@ implements: ["FR#1", "FR#3", "FR#18", "AC#1", "AC#6", "AC#20"] ## Summary -Writes all Getting Started pages from blank: the evaluator-facing page, quickstart/installation, first automation, HA token guide, hassette-vs-ha-yaml comparison, and the 4 Docker pages. This is the user's first contact with Hassette docs — it must be approachable, concrete, and lead to a working setup. Uses "you" address and code-first ordering per the getting-started template. +Writes all Getting Started pages from blank: the evaluator-facing page (absorbing the former hassette-vs-ha-yaml comparison), quickstart/installation, first automation, HA token guide, and the 4 Docker pages. This is the user's first contact with Hassette docs — it must be approachable, concrete, and lead to a working setup. Uses "you" address and code-first ordering per the getting-started template. ## Prompt @@ -18,17 +18,16 @@ Work on the `docs/overhaul` branch. Before writing, read: - The getting-started exemplar page from T03 (voice reference) - `.claude/rules/voice-guide.md` and `.claude/rules/doc-rules.md` -### Pages to write (9 total): +### Pages to write (8 total): -1. **Evaluator page** (new) — "Is Hassette Right for You?" or similar. What Hassette is, who it's for, how it compares to AppDaemon, HA YAML automations, and pyscript. Honest about tradeoffs. This is FR#18. +1. **is-hassette-right-for-you.md** (new) — What Hassette is, who it's for, how it compares to AppDaemon, HA YAML automations, and pyscript. Absorbs the former `hassette-vs-ha-yaml.md` comparison content. This is FR#18. 2. **index.md** — Quickstart overview. What you'll build, prerequisites, link to first automation. 3. **first-automation.md** — Complete walkthrough from empty file to working app. Code-first: show the code, then explain each part. 4. **ha_token.md** — How to get a long-lived access token from Home Assistant. -5. **hassette-vs-ha-yaml.md** — Side-by-side comparison for users coming from HA YAML automations. -6. **docker/index.md** — Docker deployment overview. -7. **docker/dependencies.md** — Managing Python dependencies in Docker. -8. **docker/image-tags.md** — Available image tags and which to use. -9. **docker/troubleshooting.md** — Docker-specific issues and fixes. +5. **docker/index.md** — Docker deployment overview. +6. **docker/dependencies.md** — Managing Python dependencies in Docker. +7. **docker/image-tags.md** — Available image tags and which to use. +8. **docker/troubleshooting.md** — Docker-specific issues and fixes. ### Voice for this section: diff --git a/design/specs/070-doc-overhaul/tasks/T07-write-core-concepts-1.md b/design/specs/070-doc-overhaul/tasks/T07-write-core-concepts-1.md index 4b17b9fa8..21984d751 100644 --- a/design/specs/070-doc-overhaul/tasks/T07-write-core-concepts-1.md +++ b/design/specs/070-doc-overhaul/tasks/T07-write-core-concepts-1.md @@ -8,7 +8,7 @@ implements: ["FR#1", "FR#2", "FR#5", "FR#11", "FR#12", "AC#1", "AC#9", "AC#14", ## Summary -Writes the first half of Core Concepts from blank: Architecture overview (the "five handles" model, app-author only), Apps subsection (overview, lifecycle, configuration, task-bucket), Bus subsection (overview, handlers, filtering, dependency-injection), Internals page, and database-telemetry page. The Bus subsection is critical because it contains the DI canonical page — the single authoritative source for dependency injection documentation. Architecture is scoped strictly to app-authors; contributor/maintainer content goes to Internals. +Writes the first half of Core Concepts from blank: Architecture overview (the "five handles" model, app-author only), Apps subsection (overview with absorbed config content, lifecycle, task-bucket), Bus subsection (overview, handlers, DI, custom extractors, filtering, predicate reference), Internals subsection (3 pages with audience declaration), and database-telemetry page. The Bus subsection is critical because it contains the DI canonical page — the single authoritative source for dependency injection documentation. Architecture is scoped strictly to app-authors; contributor/maintainer content goes to Internals. ## Prompt @@ -18,26 +18,29 @@ Work on the `docs/overhaul` branch. Before writing, read: - The concept exemplar page from T03 (voice reference for system-as-subject) - `.claude/rules/voice-guide.md` and `.claude/rules/doc-rules.md` -### Pages to write (~12): +### Pages to write (~14): **Architecture (1 page):** - `core-concepts/index.md` — The "five handles" model: Bus, Scheduler, Api, StateManager, Cache. How apps work. App-author audience ONLY (FR#11). - **Must NOT contain:** dependency graphs, wave ordering, cycle detection, internal service names (AC#14). These go in Internals. -**Apps (4 pages):** -- `core-concepts/apps/index.md` — What an App is, the five handles available via `self.*`, how to create one +**Apps (3 pages):** +- `core-concepts/apps/index.md` — What an App is, the five handles available via `self.*`, how to create one. **Includes AppConfig content** (absorbed from former `apps/configuration.md`): `AppConfig` subclass, `SettingsConfigDict`, env prefix, base fields, secrets. - `core-concepts/apps/lifecycle.md` — `on_initialize`, `on_shutdown` hooks -- `core-concepts/apps/configuration.md` — `AppConfig`, `SettingsConfigDict`, env prefix - `core-concepts/apps/task-bucket.md` — Background task management -**Bus (4 pages):** +**Bus (6 pages):** - `core-concepts/bus/index.md` — Event pub/sub overview, subscription methods, what fires when -- `core-concepts/bus/handlers.md` — Handler signatures, async handlers, error handling -- `core-concepts/bus/filtering.md` — Predicates (P), Conditions (C), Accessors (A), glob patterns, debounce, throttle +- `core-concepts/bus/handlers.md` — Handler signatures, non-state event types, error handling, timeout config, subscription mechanics - `core-concepts/bus/dependency-injection.md` — THE canonical DI page (FR#5). Full explanation of `D.*` annotations, typed state injection, how Hassette resolves parameters. All other pages that mention DI compress to one sentence + link to this page. - -**Internals (1 page):** -- `core-concepts/internals.md` — Dependency graphs, wave ordering, cycle detection, internal service names, Resource hierarchy (FR#12, AC#15). Contributor/maintainer audience. +- `core-concepts/bus/custom-extractors.md` — Writing custom extractors, custom accessors with `A`, `AnnotationDetails` +- `core-concepts/bus/filtering.md` — Predicates (P), Conditions (C), predicate composition, service call filtering +- `core-concepts/bus/predicate-reference.md` — Full P/C/A lookup tables (pure reference, no snippets) + +**Internals (3 pages):** +- `core-concepts/internals/index.md` — Architecture & Data Flow. **Opens with audience declaration** (absorbed from former internals/overview.md): "This section is for contributors..." Dependency graphs, wave ordering, cycle detection, event flow (FR#12, AC#15). +- `core-concepts/internals/service-details.md` — Per-service internals, DB schema, migration system +- `core-concepts/internals/lifecycle.md` — Resource lifecycle state machine, RestartSpec, supervision **Database-telemetry (1 page):** - `core-concepts/database-telemetry.md` — Telemetry DB schema, retention, what's tracked diff --git a/design/specs/070-doc-overhaul/tasks/T08-write-core-concepts-2.md b/design/specs/070-doc-overhaul/tasks/T08-write-core-concepts-2.md index 847edde92..68c2f2904 100644 --- a/design/specs/070-doc-overhaul/tasks/T08-write-core-concepts-2.md +++ b/design/specs/070-doc-overhaul/tasks/T08-write-core-concepts-2.md @@ -8,7 +8,7 @@ implements: ["FR#1", "FR#2", "FR#9", "AC#1", "AC#11"] ## Summary -Writes the second half of Core Concepts from blank: Scheduler (overview, methods, management), States (overview plus new depth pages including content rehomed from Advanced), API (overview, entities, services, utilities, managing-helpers), Cache (overview, patterns), and Configuration (overview, global, applications, auth). The States subsection is the most structurally changed — it gains depth pages matching the Bus pattern and absorbs Custom States, State Registry, and Type Registry from the eliminated Advanced section. +Writes the second half of Core Concepts from blank: Scheduler (overview, methods, management), States (overview plus depth pages rehomed from Advanced — no DomainStates Reference page, killed in audit), API (overview, entities, services, utilities, managing-helpers), Cache (overview, patterns), and Configuration (overview, applications — auth absorbed into overview, global replaced by auto-generated reference). The States subsection is the most structurally changed — it gains depth pages matching the Bus pattern and absorbs Custom States, State Registry, and Type Registry from the eliminated Advanced section. ## Prompt @@ -18,20 +18,20 @@ Work on the `docs/overhaul` branch. Before writing, read: - The concept exemplar page from T03 (voice reference) - `.claude/rules/voice-guide.md` and `.claude/rules/doc-rules.md` -### Pages to write (~20): +### Pages to write (~15): **Scheduler (3 pages):** - `core-concepts/scheduler/index.md` — Task scheduling overview, trigger types, the `schedule()` entry point - `core-concepts/scheduler/methods.md` — `run_in()`, `run_once()`, `run_every()`, `run_daily()`, `run_cron()`, custom triggers - `core-concepts/scheduler/management.md` — Job groups, `cancel_group()`, `list_jobs()`, jitter -**States (6 pages):** -- `core-concepts/states/index.md` — State access overview, domain access, type conversion +**States (5 pages):** +- `core-concepts/states/index.md` — State access overview, domain access, type conversion. Opens with prose then diagram (not diagram-first). Built-in State Types section shows 2-3 examples and links to auto-generated API reference (no hand-written reference table). - `core-concepts/states/subscribing.md` (new) — "Subscribing to State Changes" depth page -- `core-concepts/states/domain-states.md` (new) — "DomainStates Reference" depth page - `core-concepts/states/custom-states.md` (from advanced/) — Custom state classes - `core-concepts/states/state-registry.md` (from advanced/) — STATE_REGISTRY - `core-concepts/states/type-registry.md` (from advanced/) — TYPE_REGISTRY +- ~~`domain-states.md`~~ — **Killed in outline audit.** Auto-generated API reference (`hassette.models.states` in `PUBLIC_MODULES`) serves as the authoritative domain state reference. **API (5 pages):** - `core-concepts/api/index.md` — REST/WebSocket interface overview @@ -44,11 +44,11 @@ Work on the `docs/overhaul` branch. Before writing, read: - `core-concepts/cache/index.md` — Persistent disk-based storage, basic usage - `core-concepts/cache/patterns.md` — Rate limiting, counters, complex data, expiry -**Configuration (4 pages):** -- `core-concepts/configuration/index.md` — Configuration overview, file discovery -- `core-concepts/configuration/global.md` — Global settings, hassette.toml structure -- `core-concepts/configuration/applications.md` — Per-app configuration, AppConfig -- `core-concepts/configuration/auth.md` — Authentication, HA token configuration +**Configuration (2 pages):** +- `core-concepts/configuration/index.md` — Configuration overview: sources, file locations, authentication (absorbed from former `auth.md`), section map, and brief field design notes for 7 topics (data dir, app discovery, event filtering, dev/debug, web API, cache, state proxy polling). Field-level reference deferred to auto-generated `HassetteConfig` docs. WebSocket resilience and timeout tuning moved to Operating/overview. +- `core-concepts/configuration/applications.md` — App registration in hassette.toml, manifests, multi-instance +- ~~`global.md`~~ — **Replaced by auto-generated reference.** Teaching content absorbed into overview's field notes. +- ~~`auth.md`~~ — **Absorbed into overview** Authentication section. ### Voice: same as T07 @@ -81,6 +81,6 @@ The 60 advanced snippets include files for custom-states, state-registry, and ty - [ ] FR#1: All pages pass every item on the voice audit checklist (in `docs-context.md`) - [ ] FR#2: All concept pages use system-as-subject voice — no "you" outside getting-started/recipe content -- [ ] FR#9: States subsection has overview page, "Subscribing to State Changes" depth page, "DomainStates Reference" depth page, plus Custom States, State Registry, and Type Registry +- [ ] FR#9: States subsection has overview page, "Subscribing to State Changes" depth page, plus Custom States, State Registry, and Type Registry. No DomainStates Reference page — auto-generated API reference covers this. - [ ] AC#1: Voice audit checklist applied and all items pass - [ ] AC#11: States subsection in `mkdocs.yml` matches the required structure with overview + depth pages + extension pages diff --git a/design/specs/070-doc-overhaul/tasks/T10-write-cli-testing.md b/design/specs/070-doc-overhaul/tasks/T10-write-cli-testing.md index 919e2a7fa..3dc741eee 100644 --- a/design/specs/070-doc-overhaul/tasks/T10-write-cli-testing.md +++ b/design/specs/070-doc-overhaul/tasks/T10-write-cli-testing.md @@ -8,7 +8,7 @@ implements: ["FR#1", "FR#14", "AC#1", "AC#16"] ## Summary -Writes the CLI section (4 pages) and Testing section (4 pages) from blank. Both sections are reference-heavy — CLI is command/flag/example tables, Testing covers the harness, factories, time control, and concurrency helpers. These sections lean toward the reference exemplar voice: terse, tabular, functional definitions. Code examples come from the existing 34 testing snippets (rewritten as needed). +Writes the CLI section (4 pages) and Testing section (5 pages) from blank. Both sections are reference-heavy — CLI is command/flag/example tables, Testing covers the quickstart, harness reference, factories, time control, and concurrency helpers. These sections lean toward the reference exemplar voice: terse, tabular, functional definitions. Code examples come from the existing 34 testing snippets (rewritten as needed). ## Prompt @@ -27,14 +27,15 @@ Work on the `docs/overhaul` branch. Before writing, read: CLI pages are scanning-oriented: command/flag/example tables, not prose. Follow the "Pages that don't fit a template" exception in doc-rules.md for CLI reference. -### Testing pages (4): +### Testing pages (5): -- `testing/index.md` — Testing overview, two mock strategies (HassetteHarness vs create_hassette_stub) +- `testing/index.md` — **"Write Your First Test"** quickstart. Step-by-step: install extras, create test file, set up harness, seed state, simulate, assert. Getting-started voice ("you" allowed). This is the section landing page. +- `testing/harness.md` — **"Test Harness Reference"**. Full API reference: constructor, properties, state seeding, simulating events, asserting API calls, config errors, startup failures. Two mock strategies decision table (HassetteHarness vs create_hassette_stub). Reference voice (system-as-subject). - `testing/factories.md` — Test factory functions for creating events, states, configs - `testing/time-control.md` — Time manipulation in tests, freezing time, advancing schedulers - `testing/concurrency.md` — Testing async handlers, concurrent operations, race conditions -Testing pages follow the concept template but lean reference. The decision table for HassetteHarness vs stub is a key piece — readers need to quickly determine which strategy fits their test. +The quickstart is the friendly entry point; the harness reference is the lookup page. The decision table for HassetteHarness vs stub is a key piece — readers need to quickly determine which strategy fits their test. ### Voice: diff --git a/design/specs/070-doc-overhaul/tasks/T12-write-migration-ops.md b/design/specs/070-doc-overhaul/tasks/T12-write-migration-ops.md index 8be30ce8e..408175537 100644 --- a/design/specs/070-doc-overhaul/tasks/T12-write-migration-ops.md +++ b/design/specs/070-doc-overhaul/tasks/T12-write-migration-ops.md @@ -8,7 +8,7 @@ implements: ["FR#1", "FR#10", "FR#15", "AC#1", "AC#13", "AC#17"] ## Summary -Writes three sections from blank: Migration (≤8 pages for AppDaemon users), Troubleshooting (1 page, pure symptom-lookup), and the new Operating Hassette section (2–3 pages for log tuning and upgrading). Troubleshooting and Operating are the highest-risk pages for knowledge loss — they contain log signatures, timing values, and runbook commands that exist nowhere else. The knowledge inventory from T04 is the safety net. +Writes three sections from blank: Migration (7 pages for AppDaemon users — checklist removed), Troubleshooting (1 page, pure symptom-lookup), and the new Operating Hassette section (3 pages — overview now includes WebSocket/timeout tuning content absorbed from Configuration). Troubleshooting and Operating are the highest-risk pages for knowledge loss — they contain log signatures, timing values, and runbook commands that exist nowhere else. The knowledge inventory from T04 is the safety net. ## Prompt @@ -18,19 +18,16 @@ Work on the `docs/overhaul` branch. Before writing, read: - `design/specs/070-doc-overhaul/outlines/knowledge-inventory.md` (CRITICAL — extracted operational knowledge from T04) - `.claude/rules/voice-guide.md` and `.claude/rules/doc-rules.md` -### Migration pages (≤8): +### Migration pages (7): -The page count was decided in T01. Current pages: -- `migration/index.md` — Migration overview +- `migration/index.md` — Migration overview (absorbs Common Pitfalls from removed checklist) - `migration/concepts.md` — AppDaemon vs Hassette concepts - `migration/bus.md` — Event handling migration - `migration/scheduler.md` — Scheduling migration - `migration/api.md` — API migration - `migration/configuration.md` — Config migration - `migration/testing.md` — Testing migration -- `migration/checklist.md` — Migration checklist - -If T01 condensed to fewer pages, follow that decision. The section stays — Hassette has no existing users who've completed the migration, so this content is still a primary inflow path. +- ~~`migration/checklist.md`~~ — **Removed.** Thin summary of sub-pages with no unique content. Common Pitfalls section moved to overview. Migration pages follow a comparison-driven structure: old way (AppDaemon) vs new way (Hassette). Use tabs for side-by-side comparison where it helps. Voice: direct "you" is acceptable since readers are performing migration steps. @@ -40,15 +37,13 @@ Migration pages follow a comparison-driven structure: old way (AppDaemon) vs new **CRITICAL: Use the knowledge inventory from T04.** Every named failure mode, log signature, timing value, and resolution step from the current troubleshooting page must appear in the rewritten version. The knowledge inventory is the checklist — diff the final page against it to verify nothing was lost. -### Operating Hassette (2–3 pages, new section): +### Operating Hassette (3 pages, new section): -- `operating/index.md` — Operational overview +- `operating/index.md` — Operational overview. Contains runtime behavior (WebSocket reconnection from KI-01, handler exceptions from KI-02, DB degraded mode) AND tuning guidance for WebSocket resilience and timeout behavior (absorbed from Configuration — these topics belong with the behavior they tune, not in a separate config page). - `operating/log-levels.md` — Log level tuning (from advanced/log-level-tuning.md) - `operating/upgrading.md` — Upgrading Hassette (extracted from current troubleshooting) -Operating pages are how-to content for running Hassette in production. Distinct from Troubleshooting (which is reactive symptom-lookup). Voice: procedural "you" is acceptable for step-by-step instructions. - -**Additional content moving from troubleshooting to Operating:** The current troubleshooting page contains operational/behavioral content that isn't symptom-lookup: the WebSocket reconnection sequence (retry counts, backoff timings, sliding window budgets), handler exception behavior, and database degraded mode guidance. These describe how Hassette behaves, not problems to solve. Move them to the Operating overview or a dedicated "Runtime Behavior" subsection. Troubleshooting keeps only symptom → fix entries (can't connect, apps not loading, handler never runs, scheduler not firing, cache not persisting, custom state not registering, web UI not accessible). +Operating pages are how-to content for running Hassette in production. Distinct from Troubleshooting (which is reactive symptom-lookup). Voice: procedural "you" is acceptable for step-by-step instructions. Troubleshooting keeps only symptom → fix entries (can't connect, apps not loading, handler never runs, scheduler not firing, cache not persisting, custom state not registering, web UI not accessible). ### Snippet handling: From a0ac2ff49117834f4740e44a8976c4596aab8f6d Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Tue, 2 Jun 2026 08:49:14 -0500 Subject: [PATCH 035/160] chore: add muffet link checker and snippet orphan check to docs CI (T02) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New `link-check` job in docs.yml: builds site, uploads artifact, runs muffet against localhost to catch broken anchor fragments that mkdocs --strict misses - New `tools/check_snippet_orphans.py`: finds unreferenced snippet files under docs/pages/*/snippets/ by scanning --8<-- includes - Both checks non-blocking (continue-on-error) until Phase 3 writing cleans up pre-existing issues (9 orphan snippets, 2 broken anchors) - Stub files for new nav entries: predicate-reference.md, harness.md - Rename evaluator.md → is-hassette-right-for-you.md (nav alignment) --- .github/workflows/docs.yml | 51 +++++++++++++++ .../core-concepts/bus/predicate-reference.md | 3 + ...luator.md => is-hassette-right-for-you.md} | 0 docs/pages/testing/harness.md | 3 + tools/check_snippet_orphans.py | 63 +++++++++++++++++++ 5 files changed, 120 insertions(+) create mode 100644 docs/pages/core-concepts/bus/predicate-reference.md rename docs/pages/getting-started/{evaluator.md => is-hassette-right-for-you.md} (100%) create mode 100644 docs/pages/testing/harness.md create mode 100755 tools/check_snippet_orphans.py diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 2374f2509..d91f37b07 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -6,12 +6,14 @@ on: - "docs/**" - "mkdocs.yml" - "pyproject.toml" + - "tools/check_snippet_orphans.py" - ".github/workflows/docs.yml" pull_request: paths: - "docs/**" - "mkdocs.yml" - "pyproject.toml" + - "tools/check_snippet_orphans.py" - ".github/workflows/docs.yml" workflow_dispatch: @@ -41,6 +43,55 @@ jobs: - name: Build docs (strict) run: uv run mkdocs build --strict + - name: Check snippet orphans + continue-on-error: true # TODO: remove after Phase 3 writing cleans up pre-existing orphans + run: uv run python tools/check_snippet_orphans.py + + - name: Upload site artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: docs-site + path: site/ + retention-days: 1 + + link-check: + needs: build + runs-on: ubuntu-latest + timeout-minutes: 10 + continue-on-error: true # TODO: remove after Phase 3 writing fixes pre-existing broken anchors + + steps: + - name: Download site artifact + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: docs-site + path: site/ + + - name: Set up Go + uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5 + with: + go-version: "stable" + cache: false + + - name: Install muffet + run: | + go install github.com/raviqqe/muffet/v2@v2.11.4 + echo "$(go env GOPATH)/bin" >> $GITHUB_PATH + + - name: Serve site + run: python3 -m http.server 8000 -d site & + + - name: Wait for server + run: timeout 10 bash -c 'until curl -s http://localhost:8000/ > /dev/null; do sleep 0.5; done' + + - name: Run muffet + run: | + muffet http://localhost:8000/ \ + --include '^http://localhost:8000' \ + --timeout 30 \ + --max-connections 10 \ + --buffer-size 8192 + docs-check: # Catches API reference drift when src/ changes without docs/ changes. runs-on: ubuntu-latest diff --git a/docs/pages/core-concepts/bus/predicate-reference.md b/docs/pages/core-concepts/bus/predicate-reference.md new file mode 100644 index 000000000..d6e53e6fa --- /dev/null +++ b/docs/pages/core-concepts/bus/predicate-reference.md @@ -0,0 +1,3 @@ +# Predicate & Condition Reference + +*Stub — content coming in Phase 3.* diff --git a/docs/pages/getting-started/evaluator.md b/docs/pages/getting-started/is-hassette-right-for-you.md similarity index 100% rename from docs/pages/getting-started/evaluator.md rename to docs/pages/getting-started/is-hassette-right-for-you.md diff --git a/docs/pages/testing/harness.md b/docs/pages/testing/harness.md new file mode 100644 index 000000000..670982f92 --- /dev/null +++ b/docs/pages/testing/harness.md @@ -0,0 +1,3 @@ +# Test Harness Reference + +*Stub — content coming in Phase 3.* diff --git a/tools/check_snippet_orphans.py b/tools/check_snippet_orphans.py new file mode 100755 index 000000000..d5640ee57 --- /dev/null +++ b/tools/check_snippet_orphans.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +"""CI guard: detect unreferenced snippet files under docs/pages/*/snippets/. + +Finds all files in snippet directories and checks each against --8<-- include +references in .md files. Reports files not referenced by any page. Exits +non-zero if orphans are found. + +Handles both full-file includes and fragment includes (section markers): + --8<-- "pages/core-concepts/bus/snippets/file.py" + --8<-- "pages/core-concepts/bus/snippets/file.py:marker" + +Usage: + python tools/check_snippet_orphans.py +""" + +import re +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +DOCS_DIR = REPO_ROOT / "docs" + +INCLUDE_RE = re.compile(r'--8<--\s+"([^"]+)"') + + +def find_snippet_files() -> set[Path]: + results: set[Path] = set() + for path in DOCS_DIR.rglob("*/snippets/*"): + if path.is_file(): + results.add(path) + return results + + +def find_referenced_paths() -> set[Path]: + referenced: set[Path] = set() + for md_file in DOCS_DIR.rglob("*.md"): + for match in INCLUDE_RE.finditer(md_file.read_text()): + raw = match.group(1) + file_part = raw.split(":")[0] + referenced.add((DOCS_DIR / file_part).resolve()) + return referenced + + +def main() -> int: + snippet_files = find_snippet_files() + referenced = find_referenced_paths() + + orphans = sorted(f.relative_to(DOCS_DIR) for f in snippet_files if f.resolve() not in referenced) + + if not orphans: + print(f"OK: all {len(snippet_files)} snippet files are referenced") + return 0 + + print(f"FAIL: {len(orphans)} orphaned snippet file(s) (not referenced by any --8<-- include):\n") + for orphan in orphans: + print(f" {orphan}") + + print(f"\n{len(snippet_files) - len(orphans)} referenced, {len(orphans)} orphaned") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) From b4da3876ed7257dde3ec077d38216907c6d9e984 Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Tue, 2 Jun 2026 08:49:25 -0500 Subject: [PATCH 036/160] chore: mark T02 as done --- design/specs/070-doc-overhaul/tasks/T02-ci-tooling.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/design/specs/070-doc-overhaul/tasks/T02-ci-tooling.md b/design/specs/070-doc-overhaul/tasks/T02-ci-tooling.md index 8cde9c421..99f803552 100644 --- a/design/specs/070-doc-overhaul/tasks/T02-ci-tooling.md +++ b/design/specs/070-doc-overhaul/tasks/T02-ci-tooling.md @@ -1,7 +1,7 @@ --- task_id: "T02" title: "Add muffet link checker and snippet orphan check to CI" -status: "planned" +status: "done" depends_on: ["T01"] implements: ["FR#13", "AC#3", "AC#5"] --- From f5cb026a2ae0d3f8abc965fb5b15ff6621101093 Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Tue, 2 Jun 2026 09:06:42 -0500 Subject: [PATCH 037/160] chore: scope pyright suppressions per-directory in docs config Remove 4 global suppressions (reportMissingImports, reportOperatorIssue, reportAssignmentType, reportGeneralTypeIssues) and replace with targeted scoping: - testing/snippets: reportMissingImports via executionEnvironments (fictional my_apps.* imports) - cache/snippets: operator/assignment/general via executionEnvironments (diskcache Cache.__getitem__ union type false positives) - bus/snippets/filtering_service_callable.py: inline pyright ignore - states/snippets/states_generic_access.py: inline pyright ignore New snippet files written in Phase 3 get strict type checking by default. --- .../snippets/filtering_service_callable.py | 2 +- .../states/snippets/states_generic_access.py | 2 +- docs/pyrightconfig.json | 30 ++++++++++++------- 3 files changed, 21 insertions(+), 13 deletions(-) diff --git a/docs/pages/core-concepts/bus/snippets/filtering_service_callable.py b/docs/pages/core-concepts/bus/snippets/filtering_service_callable.py index c2e4c871c..64fdecfd6 100644 --- a/docs/pages/core-concepts/bus/snippets/filtering_service_callable.py +++ b/docs/pages/core-concepts/bus/snippets/filtering_service_callable.py @@ -7,7 +7,7 @@ async def on_initialize(self): await self.bus.on_call_service( domain="light", service="turn_on", - where={"brightness": lambda v: v and v > 200}, + where={"brightness": lambda v: v and v > 200}, # pyright: ignore[reportOperatorIssue] handler=self.on_bright_lights, name="bright_lights", ) diff --git a/docs/pages/core-concepts/states/snippets/states_generic_access.py b/docs/pages/core-concepts/states/snippets/states_generic_access.py index 0cd5f542e..a4515b7d7 100644 --- a/docs/pages/core-concepts/states/snippets/states_generic_access.py +++ b/docs/pages/core-concepts/states/snippets/states_generic_access.py @@ -1,4 +1,4 @@ -from my_app import MyCustomState +from my_app import MyCustomState # pyright: ignore[reportMissingImports] from hassette import App diff --git a/docs/pyrightconfig.json b/docs/pyrightconfig.json index 293ec9045..a780adb9d 100644 --- a/docs/pyrightconfig.json +++ b/docs/pyrightconfig.json @@ -14,18 +14,26 @@ "pythonVersion": "3.11", "typeCheckingMode": "basic", - // Snippet-specific suppressions - // Fictional modules (my_apps.*, my_app, appdaemon) are imported for reader context - "reportMissingImports": "none", - // diskcache Cache.__getitem__ returns a complex union type that triggers false - // positives on arithmetic and assignment in cache snippet files - "reportOperatorIssue": "none", - "reportAssignmentType": "none", - "reportGeneralTypeIssues": "none", - - // Keep these active — they catch real bugs in snippet code + // Active checks — catch real bugs in snippet code "reportAttributeAccessIssue": "error", "reportUndefinedVariable": "error", "reportReturnType": "error", - "reportUnnecessaryComparison": "error" + "reportUnnecessaryComparison": "error", + + // Per-directory suppressions so new snippet files get strict checking by default + "executionEnvironments": [ + { + // Fictional my_apps.* imports for reader context + "root": "pages/testing/snippets", + "reportMissingImports": "none" + }, + { + // diskcache Cache.__getitem__ returns a complex union type that triggers + // false positives on arithmetic and assignment in cache snippet files + "root": "pages/core-concepts/cache/snippets", + "reportOperatorIssue": "none", + "reportAssignmentType": "none", + "reportGeneralTypeIssues": "none" + } + ] } From 8f978ff0702c2beeb35b31fe51d94a81687f23fe Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Tue, 2 Jun 2026 09:42:52 -0500 Subject: [PATCH 038/160] chore: add writing prompt template and voice checklist updates from eval Blind A/B eval of Sonnet vs Opus doc writers revealed consistent failure patterns. Updates: - docs-context.md: add getting-started checklist section (code-first hard rule, concrete CLI output, inline module links, no motivational preamble, no horizontal rules). Add violations #4 (category vs functional defs) and #5 (motivational preamble). - doc-rules.md: strengthen functional definition rule with concrete examples of wrong/right patterns for each core Hassette term. - writing-prompt-template.md: reusable prompt template for subagent doc writers with voice rules blocks per page type and a reviewer prompt. --- .claude/rules/doc-rules.md | 2 +- design/specs/070-doc-overhaul/docs-context.md | 60 ++++- .../writing-prompt-template.md | 205 ++++++++++++++++++ 3 files changed, 256 insertions(+), 11 deletions(-) create mode 100644 design/specs/070-doc-overhaul/writing-prompt-template.md diff --git a/.claude/rules/doc-rules.md b/.claude/rules/doc-rules.md index 8041bdb0e..0bbd6c7e4 100644 --- a/.claude/rules/doc-rules.md +++ b/.claude/rules/doc-rules.md @@ -29,7 +29,7 @@ Hassette docs should feel like a patient friend who's already built the thing yo ### Voice rules 1. **Use "you" and "your" in getting-started and recipe procedure sections.** In concept and API reference pages, make the system the subject instead — see voice-guide.md rule #10. -2. **Lead with what it does, not what it is.** "The scheduler lets you run functions at specific times" not "The scheduler is a service that manages timed execution of callable objects." +2. **Lead with what it does, not what it is.** Every first mention of a Hassette term (`App`, `Bus`, `Scheduler`, `Api`, `StateManager`, `AppConfig`) must define it by function, not by category. "The scheduler runs functions at specific times" not "The scheduler is a service that manages timed execution." "`AppConfig` loads and validates settings from `hassette.toml`" not "`AppConfig` is a Pydantic settings model." The category is invisible to the reader; the function is what they need. 3. **Use concrete examples in prose, not just code blocks.** "like temperature sensors that report every 2 seconds" not "such as high-frequency update sources." 4. **Short sentences for concepts, longer ones for flow.** Introduce an idea in one punchy line. Then explain how it works in a sentence or two that builds on the first. 5. **Active voice.** "Hassette connects to Home Assistant" not "a connection is established." Passive voice makes prose feel distant and academic. diff --git a/design/specs/070-doc-overhaul/docs-context.md b/design/specs/070-doc-overhaul/docs-context.md index 44294be50..672cfbc5a 100644 --- a/design/specs/070-doc-overhaul/docs-context.md +++ b/design/specs/070-doc-overhaul/docs-context.md @@ -23,25 +23,33 @@ Run every item on every page before marking a writing task complete. Each item i 5. **Every limitation paired with a path forward.** One sentence for the constraint, one naming the alternative. *(Rule 4)* 6. **Module aliases linked on first use.** First use of `D.*`, `states.*`, `C.*`, `P.*`, or `A.*` links to the canonical page for that module. *(FR#6)* +### Getting-started pages + +7. **"you" and "your" used throughout.** NOT system-as-subject. Direct address. *(Rule 17)* +8. **Code shown FIRST, then explained (HARD RULE).** The code block must be the very first element after the H2 heading. No introductory sentences, no motivational preamble ("Hard-coding values makes reuse difficult..."), no scene-setting. Explanation follows the code. The only exception is "What You'll Build" which has no code. *(Rule 17)* +9. **Show concrete CLI output.** When a step involves running a command, show the exact mock terminal output the reader will see. Don't say "look for X in the output" — show the output. +10. **No `---` horizontal rules between sections.** Headings provide enough visual separation. +11. **Link module aliases inline at first use.** When introducing `D`, `states`, `self.bus`, `self.scheduler`, or `self.api`, the first mention links to the canonical page inline, not in a deferred "See also" sentence. *(Rule 8, checklist #6)* + ### Concept and API reference pages -7. **System-as-subject throughout — no "you."** "The bus delivers events" not "you receive events." "your" is also banned. *(Rule 10)* -8. **No imperative mood.** No "Use X", "Pass Y", "Set Z." Use declarative: "X provides", "Y accepts", "Z controls." *(Rule 15)* -9. **Concept introductions follow name -> define -> show -> constrain.** Definition says what it does. Code example is minimal. Constraints come after. *(Rule 16)* +12. **System-as-subject throughout — no "you."** "The bus delivers events" not "you receive events." "your" is also banned. *(Rule 10)* +13. **No imperative mood.** No "Use X", "Pass Y", "Set Z." Use declarative: "X provides", "Y accepts", "Z controls." *(Rule 15)* +14. **Concept introductions follow name -> define -> show -> constrain.** Definition says what it does. Code example is minimal. Constraints come after. *(Rule 16)* ### Recipe pages -10. **"How It Works" uses flowing prose paragraphs, NOT bullet lists with bolded lead-ins.** Each paragraph covers one decision. No `- **method_name** does X` patterns. *(Rule 21)* -11. **"How It Works" uses system-as-subject.** The code is the subject when explaining behavior. "you" belongs only in procedural steps and variations. *(Rule 10, 21)* -12. **"Verify it's working" names a concrete command or UI action.** `hassette log --app `, Handlers tab, or similar — not a theoretical description. *(FR#4)* +15. **"How It Works" uses flowing prose paragraphs, NOT bullet lists with bolded lead-ins.** Each paragraph covers one decision. No `- **method_name** does X` patterns. *(Rule 21)* +16. **"How It Works" uses system-as-subject.** The code is the subject when explaining behavior. "you" belongs only in procedural steps and variations. *(Rule 10, 21)* +17. **"Verify it's working" names a concrete command or UI action.** `hassette log --app `, Handlers tab, or similar — not a theoretical description. *(FR#4)* ### Reference pages (addendum) -13. **Tables before prose in reference sections.** The table is the primary content; prose supplements. -14. **Terse functional definitions in table cells.** No narrative. Each cell says what the thing does in one sentence. -15. **No admonitions in reference tables.** Tips, warnings, and notes belong outside the table. +18. **Tables before prose in reference sections.** The table is the primary content; prose supplements. +19. **Terse functional definitions in table cells.** No narrative. Each cell says what the thing does in one sentence. +20. **No admonitions in reference tables.** Tips, warnings, and notes belong outside the table. -## Top 3 Current Violations +## Top Violations These are the patterns you will most naturally fall into. Check for them last as a final pass. @@ -71,3 +79,35 @@ Sentences exceeding 18 words in explanatory prose. Most common in technical desc **Wrong:** "The StateManager event handler is prioritized over app event handlers to ensure you always have a consistent view of the latest states." (24 words + "you") **Right:** "StateManager's event handler runs before app handlers. App handlers always see the latest state." (two sentences, 8 + 8 words) + +### 4. Category definitions instead of functional definitions (Rule 8) + +Defining a term by what it *is* rather than what it *does*. The most common form is "`X` is the base class for..." or "`X` is a Pydantic settings model." These tell the reader the taxonomy but not what the thing does for them. + +**Wrong:** "`App` is the base class for every Hassette automation." +**Right:** "`App` manages your handlers, scheduler, and connection to Home Assistant." + +**Wrong:** "`AppConfig` is a Pydantic settings model." +**Right:** "`AppConfig` loads and validates your app's settings from `hassette.toml`." + +**Wrong:** "`self.bus` is Hassette's event bus." +**Right:** "`self.bus` delivers Home Assistant events to your handlers." + +### 5. Motivational preamble before code (getting-started pages) + +Opening a step with a sentence explaining *why* the feature is useful before showing the code. The code speaks for itself. The reader came here to build, not to be convinced. + +**Wrong:** +``` +## Step 2 — Add Typed Configuration +Hard-coding entity IDs and strings makes your app hard to reuse. +Hassette gives every app a config class. +[code] +``` + +**Right:** +``` +## Step 2 — Add Typed Configuration +[code] +`MyAppConfig` extends `AppConfig` and declares the fields your app reads. +``` diff --git a/design/specs/070-doc-overhaul/writing-prompt-template.md b/design/specs/070-doc-overhaul/writing-prompt-template.md new file mode 100644 index 000000000..f9b39ed6e --- /dev/null +++ b/design/specs/070-doc-overhaul/writing-prompt-template.md @@ -0,0 +1,205 @@ +# Writing Prompt Template + +Template for briefing subagents that write documentation pages. Fill in the `{{variables}}` for each page. + +## How to use + +1. Copy the template below +2. Fill in all `{{variables}}` from the page's outline (in `outlines/`) and existing content +3. Send to a Sonnet subagent writing to a temp directory +4. Send the output to an Opus reviewer subagent with the review prompt (at the bottom) +5. Apply fixes in the main loop + +--- + +## Writer Prompt + +``` +You are writing a documentation page for Hassette, an async Python framework +for Home Assistant automations. Write the "{{page_title}}" page. + +## Output location + +Write the page to {{output_dir}}/{{filename}} and all snippet files to +{{output_dir}}/snippets/. + +## Page outline + +Follow this outline exactly: + +{{outline}} + +## Snippet files to create + +All code examples must be in snippet files, included via +`--8<-- "pages/{{section}}/snippets/filename.py"`. Create these files in +{{output_dir}}/snippets/: + +{{snippet_inventory}} + +## Voice rules (CRITICAL) + +This is a **{{page_type}}** page. + +{{voice_rules_block}} + +### Anti-patterns to avoid + +- No "serves as", "acts as", "functions as" when you mean "is" +- No "pivotal", "crucial", "fundamental", "robust" +- No dangling "-ing" phrases +- No synonym cycling (pick one term per concept and stick with it) +- No filler hedging ("It is important to note that", "In order to") +- No "leverage", "utilize", "facilitate" — use "use", "help", "show" +- No em dashes — use periods or commas +- No transition sentences opening paragraphs +- No motivational preamble before code +- No `---` horizontal rules between sections + +### Cross-links to use + +{{cross_links}} + +## Key technical facts + +{{technical_facts}} + +Write the complete page now. Include the `--8<--` snippet includes in the +markdown. Write every snippet file to {{output_dir}}/snippets/. +``` + +--- + +## Voice Rules Blocks + +### Getting-started pages + +``` +1. **Use "you" and "your"** — direct address throughout. +2. **CODE FIRST, THEN EXPLAIN (HARD RULE)** — Every step MUST open with the + code snippet include IMMEDIATELY after the H2 heading. No introductory + sentences before the code. The explanation comes AFTER the code block. + + WRONG (intro before code): + ## Step 2 — Add Typed Configuration + Hard-coding entity IDs makes apps hard to reuse. + ```python + --8<-- "snippet.py" + ``` + + RIGHT (code first): + ## Step 2 — Add Typed Configuration + ```python + --8<-- "snippet.py" + ``` + `MyAppConfig` extends `AppConfig`... + + The ONLY exception is "What You'll Build" which has no code. +3. **Short sentences for concepts.** 10-18 words. One idea per sentence. +4. **State the main behavior first, caveats after.** +5. **Every limitation paired with a path forward.** +6. **Present tense.** The thing does the thing. +7. **Anglo-Saxon verbs.** create, declare, run, fire, track, subscribe, set, + register, cancel, receive, pass, return, call. +8. **Introduce Hassette terms with functional definitions on first use.** + Say what it DOES, not what it IS. "`App` manages your handlers and + connection" not "`App` is the base class." "`AppConfig` loads and validates + settings" not "`AppConfig` is a Pydantic model." +9. **Code-format all identifiers, paths, parameters, and syntax elements.** +10. **No motivational preamble.** Don't explain WHY a feature is useful before + showing it. The code speaks for itself. +11. **No `---` horizontal rules between sections.** +12. **Link module aliases inline at first use.** When introducing `D`, `states`, + `self.bus`, `self.scheduler`, or `self.api`, link to the canonical page + inline in the definition, not in a deferred "See also" sentence. +13. **Show concrete CLI output.** When a step runs a command, show the exact + mock terminal output. Don't say "look for X" — show the output. +``` + +### Concept pages + +``` +1. **System-as-subject throughout — no "you" or "your".** "The bus delivers + events" not "you receive events." +2. **No imperative mood.** No "Use X", "Pass Y". Use declarative: "X provides", + "Y accepts." +3. **CODE FIRST for the opening example.** Show the basic example immediately + after the opening definition sentence. Walk-through follows. +4. **Short sentences.** 10-18 words. One idea per sentence. +5. **State the main behavior first, caveats after.** +6. **Every limitation paired with a path forward.** +7. **Present tense.** The thing does the thing. +8. **Anglo-Saxon verbs.** +9. **Introduce Hassette terms with functional definitions on first use.** + Say what it DOES, not what it IS. +10. **Code-format all identifiers.** +11. **Concept introductions follow name -> define -> show -> constrain.** +12. **Link module aliases inline at first use.** +13. **No `---` horizontal rules between sections.** +``` + +### Recipe pages + +``` +1. **Problem statement uses "you" and "your".** One paragraph, concrete scenario. +2. **"How It Works" uses system-as-subject.** The code is the subject. No "you" + in this section. +3. **"How It Works" uses flowing prose paragraphs, NOT bullet lists with bolded + lead-ins.** Each paragraph covers one decision. +4. **"Verify it's working" names a concrete command or UI action.** Show the + command and expected output. +5. **Short sentences.** 10-18 words. +6. **Introduce Hassette terms with functional definitions on first use.** +7. **Link module aliases inline at first use.** +8. **No `---` horizontal rules between sections.** +``` + +--- + +## Reviewer Prompt + +``` +You are reviewing a documentation page for Hassette, an async Python framework +for Home Assistant automations. The page is a {{page_type}} page +("{{page_title}}"). Find voice violations, technical inaccuracies, and suggest +concrete improvements. + +Read the page at {{page_path}}. + +## Voice Audit Checklist (apply every item) + +### General (all page types) +1. No transition sentences opening paragraphs. +2. Terms defined functionally on first use. Say what it DOES, not what it IS. + "`App` manages handlers and connection" not "`App` is the base class." +3. Explanatory sentences are 10-18 words. Inline code doesn't count. +4. Main behavior stated first, caveats after. +5. Every limitation paired with a path forward. +6. Module aliases (`D`, `states`, `self.bus`, etc.) linked on first use. + +{{page_type_checklist}} + +### Anti-patterns to flag +- "serves as", "acts as", "functions as" +- "pivotal", "crucial", "fundamental", "robust" +- Dangling "-ing" phrases +- Synonym cycling +- Filler hedging +- "leverage", "utilize", "facilitate" +- Em dashes (should be periods or commas) +- Transition sentences opening paragraphs +- Motivational preamble before code +- `---` horizontal rules between sections +- Sentences over 18 words (code identifiers don't count) +- Category definitions instead of functional definitions + +## Output format + +For each finding: +- **Location**: line number(s) +- **Issue**: what's wrong +- **Suggested fix**: concrete replacement text + +Group by: MUST FIX, SHOULD FIX, CONSIDER. +End with a one-paragraph overall assessment. +``` From 5931ee3c230ae444236578d1886ee9493a5de92e Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Tue, 2 Jun 2026 12:32:51 -0500 Subject: [PATCH 039/160] docs: rewrite Getting Started section from blank 8 pages rewritten from blank using Sonnet writers + Opus reviewers: - is-hassette-right-for-you.md (new evaluator page, absorbs hassette-vs-ha-yaml) - index.md (quickstart, simplified to 5 steps: uv tool install, .env, app, run) - first-automation.md (DI-first, builds on quickstart app) - ha_token.md (screenshot walkthrough) - docker/index.md, dependencies.md, image-tags.md, troubleshooting.md Key simplifications to quickstart: - uv tool install instead of uv init + uv add - .env in project root instead of config/ directory - No hassette.toml (apps auto-discovered, config defaults used) - hassette run -e .env instead of uv run hassette run - apps/ directory (matching framework default) instead of hassette_apps/ - Minimal app with no RawStateChangeEvent (DI introduced in first-automation) Voice: all pages follow voice-guide.md and docs-context.md checklist. Calibration artifact updated with violations #6 (deep imports) and #7 (broken linked method calls) discovered during review. --- design/specs/070-doc-overhaul/docs-context.md | 14 ++ .../getting-started/docker-dependencies.md | 2 +- .../getting-started/docker-image-tags.md | 2 +- .../outlines/getting-started/docker-setup.md | 2 +- .../getting-started/docker-troubleshooting.md | 2 +- .../getting-started/first-automation.md | 2 +- .../outlines/getting-started/ha-token.md | 2 +- .../is-hassette-right-for-you.md | 2 +- .../outlines/getting-started/quickstart.md | 2 +- .../getting-started/docker/dependencies.md | 117 ++++------ .../getting-started/docker/image-tags.md | 144 ++---------- docs/pages/getting-started/docker/index.md | 127 ++++------ .../getting-started/docker/troubleshooting.md | 216 ++++++++---------- .../pages/getting-started/first-automation.md | 117 +++------- docs/pages/getting-started/ha_token.md | 22 +- docs/pages/getting-started/index.md | 118 ++++------ .../is-hassette-right-for-you.md | 51 ++++- .../getting-started/snippets/env_file.sh | 3 +- .../getting-started/snippets/first_app.py | 16 -- .../snippets/first_automation_step3.py | 5 +- .../snippets/first_automation_step4.py | 7 +- .../getting-started/snippets/run_output.txt | 5 +- 22 files changed, 381 insertions(+), 597 deletions(-) diff --git a/design/specs/070-doc-overhaul/docs-context.md b/design/specs/070-doc-overhaul/docs-context.md index 672cfbc5a..ec88cc5aa 100644 --- a/design/specs/070-doc-overhaul/docs-context.md +++ b/design/specs/070-doc-overhaul/docs-context.md @@ -111,3 +111,17 @@ Hassette gives every app a config class. [code] `MyAppConfig` extends `AppConfig` and declares the fields your app reads. ``` + +### 6. Deep imports instead of top-level imports + +`D`, `states`, `P`, `C`, and `A` are all available as top-level imports from `hassette`. Do not use deep import paths in snippets. + +**Wrong:** `from hassette import dependencies as D` or `from hassette.models import states` +**Right:** `from hassette import App, AppConfig, D, states` + +### 7. Broken linked method calls + +When linking a method call like `self.bus.on_state_change()`, put the full call inside the link text. Splitting the link and the method creates a visible gap in the rendered output. + +**Wrong:** `` [`self.bus`](../core-concepts/bus/index.md)`.on_state_change()` `` +**Right:** `` [`self.bus.on_state_change()`](../core-concepts/bus/index.md) `` diff --git a/design/specs/070-doc-overhaul/outlines/getting-started/docker-dependencies.md b/design/specs/070-doc-overhaul/outlines/getting-started/docker-dependencies.md index b8a57bd31..068a4a888 100644 --- a/design/specs/070-doc-overhaul/outlines/getting-started/docker-dependencies.md +++ b/design/specs/070-doc-overhaul/outlines/getting-started/docker-dependencies.md @@ -1,6 +1,6 @@ # Docker — Managing Dependencies -**Status:** Exists (193 lines), structure solid, voice polish needed +**Status:** Rewrite from blank **Voice mode:** Getting-started — "you" allowed, procedural ## Outline diff --git a/design/specs/070-doc-overhaul/outlines/getting-started/docker-image-tags.md b/design/specs/070-doc-overhaul/outlines/getting-started/docker-image-tags.md index 32ed9a17a..dab4f276b 100644 --- a/design/specs/070-doc-overhaul/outlines/getting-started/docker-image-tags.md +++ b/design/specs/070-doc-overhaul/outlines/getting-started/docker-image-tags.md @@ -1,6 +1,6 @@ # Docker — Image Tags -**Status:** Exists (151 lines), needs trimming for getting-started audience +**Status:** Rewrite from blank **Voice mode:** Getting-started — "you" allowed, brief, decision-oriented ## Outline diff --git a/design/specs/070-doc-overhaul/outlines/getting-started/docker-setup.md b/design/specs/070-doc-overhaul/outlines/getting-started/docker-setup.md index 11e301f99..4e5c53307 100644 --- a/design/specs/070-doc-overhaul/outlines/getting-started/docker-setup.md +++ b/design/specs/070-doc-overhaul/outlines/getting-started/docker-setup.md @@ -1,6 +1,6 @@ # Docker Setup -**Status:** Exists (173 lines), structure solid, voice polish needed +**Status:** Rewrite from blank **Voice mode:** Getting-started — "you" allowed, step-by-step ## Outline diff --git a/design/specs/070-doc-overhaul/outlines/getting-started/docker-troubleshooting.md b/design/specs/070-doc-overhaul/outlines/getting-started/docker-troubleshooting.md index b09094a3a..6d8868fbf 100644 --- a/design/specs/070-doc-overhaul/outlines/getting-started/docker-troubleshooting.md +++ b/design/specs/070-doc-overhaul/outlines/getting-started/docker-troubleshooting.md @@ -1,6 +1,6 @@ # Docker — Troubleshooting -**Status:** Exists (350 lines), symptom-lookup format, voice polish needed +**Status:** Rewrite from blank **Voice mode:** Getting-started — "you" allowed, problem/solution format ## Outline diff --git a/design/specs/070-doc-overhaul/outlines/getting-started/first-automation.md b/design/specs/070-doc-overhaul/outlines/getting-started/first-automation.md index 3bf20daa7..530d4812e 100644 --- a/design/specs/070-doc-overhaul/outlines/getting-started/first-automation.md +++ b/design/specs/070-doc-overhaul/outlines/getting-started/first-automation.md @@ -1,6 +1,6 @@ # First Automation -**Status:** Exists (115 lines), needs restructuring for DI-first +**Status:** Rewrite from blank **Voice mode:** Getting-started — "you" allowed, code-first, each step produces visible progress ## Outline diff --git a/design/specs/070-doc-overhaul/outlines/getting-started/ha-token.md b/design/specs/070-doc-overhaul/outlines/getting-started/ha-token.md index 3efb9a194..c7f45e9dd 100644 --- a/design/specs/070-doc-overhaul/outlines/getting-started/ha-token.md +++ b/design/specs/070-doc-overhaul/outlines/getting-started/ha-token.md @@ -1,6 +1,6 @@ # Home Assistant Token -**Status:** Exists (36 lines), minimal changes needed +**Status:** Rewrite from blank **Voice mode:** Getting-started — "you" allowed, step-by-step ## Outline diff --git a/design/specs/070-doc-overhaul/outlines/getting-started/is-hassette-right-for-you.md b/design/specs/070-doc-overhaul/outlines/getting-started/is-hassette-right-for-you.md index 28bc829d8..906e8b4f1 100644 --- a/design/specs/070-doc-overhaul/outlines/getting-started/is-hassette-right-for-you.md +++ b/design/specs/070-doc-overhaul/outlines/getting-started/is-hassette-right-for-you.md @@ -1,6 +1,6 @@ # Is Hassette Right for You? -**Status:** New page (stub exists at `evaluator.md`, will be renamed to `is-hassette-right-for-you.md`) +**Status:** Rewrite from blank **Voice mode:** Getting-started — "you" allowed, friendly, direct ## Outline diff --git a/design/specs/070-doc-overhaul/outlines/getting-started/quickstart.md b/design/specs/070-doc-overhaul/outlines/getting-started/quickstart.md index 2c1f071ef..6abf004ef 100644 --- a/design/specs/070-doc-overhaul/outlines/getting-started/quickstart.md +++ b/design/specs/070-doc-overhaul/outlines/getting-started/quickstart.md @@ -1,6 +1,6 @@ # Quickstart -**Status:** Exists (117 lines), structure solid, voice polish needed +**Status:** Rewrite from blank **Voice mode:** Getting-started — "you" allowed, code-first, numbered steps ## Outline diff --git a/docs/pages/getting-started/docker/dependencies.md b/docs/pages/getting-started/docker/dependencies.md index e96c40129..7b568e072 100644 --- a/docs/pages/getting-started/docker/dependencies.md +++ b/docs/pages/getting-started/docker/dependencies.md @@ -1,169 +1,135 @@ # Managing Dependencies -This guide explains how to install Python packages for your Hassette apps when running in Docker. - ## Overview -Hassette's Docker startup script installs project dependencies automatically and optionally discovers `requirements.txt` files when enabled. Two methods are available: - -1. **Project-based** — using `pyproject.toml` and `uv.lock` (recommended for complex projects) -2. **Requirements files** — using `requirements.txt` (simple approach, opt-in) +Hassette's Docker startup script installs your project dependencies before launching. Two methods are available: a project-based install using `pyproject.toml` and `uv.lock`, and a simpler opt-in discovery of `requirements.txt` files. You can use one or both. ### How Constraints Work -Hassette's Docker image includes a constraints file (`/app/constraints.txt`) that records the compatible version ranges for all framework dependencies. When you install your own packages, the startup script passes this file to `uv`, so any dependency that would conflict with hassette's requirements causes a clear error message rather than a silent downgrade. If you see a conflict error, it usually means your `uv.lock` was generated against a different hassette version than the image — running `uv lock` locally and committing the result fixes it. See [Dependency Conflicts](troubleshooting.md#dependency-conflicts) for details. +The Docker image includes a constraints file at `/app/constraints.txt`. It records compatible version ranges for all framework dependencies. Every `uv pip install` the startup script runs passes `-c /app/constraints.txt`. A conflicting package produces a clear error rather than silently downgrading a hassette dependency. -## How the Startup Script Works +If you see a conflict error on startup, your `uv.lock` was likely generated against a different hassette version than the image. Run `uv lock` locally and commit the result. See [Dependency Conflicts](troubleshooting.md#dependency-conflicts) for more. -When the container starts, the [startup script](https://github.com/NodeJSmith/hassette/blob/main/scripts/docker_start.sh) performs these steps in order: +## How the Startup Script Works ```mermaid --8<-- "pages/getting-started/docker/snippets/deps-startup-flow.mmd" ``` +Set `HASSETTE__INSTALL_DEPS=1` to activate dependency installation. Without it, the startup script skips all requirements discovery. + +With it set, the script runs these steps in order: + +1. Check `HASSETTE__PROJECT_DIR` for a `pyproject.toml` and `uv.lock` +2. If both are present, export the lock file to a temporary requirements list and install through the constraints file +3. Scan `/config` and `/apps` for files named exactly `requirements.txt` (up to 5 directory levels deep) and install each one through the constraints file +4. Start Hassette + ### Key Behaviors -1. **Export-then-install**: When a `uv.lock` is found, the startup script exports your resolved dependencies to a temporary requirements file and installs them through the constraints file. This routes all dependency resolution through the constraints file rather than bypassing it. -2. **Opt-in requirements discovery**: `requirements.txt` files are only discovered when `HASSETTE__INSTALL_DEPS=1` is set. By default, no requirements files are scanned. -3. **Exact filename match**: Only files named exactly `requirements.txt` are discovered — not `requirements-dev.txt`, `requirements_test.txt`, or other variants. This prevents dev and test dependencies from being silently installed in the production container. -4. **Constraints protection for all installs**: Every `uv pip install` — whether from a project lockfile or a `requirements.txt` — passes `-c /app/constraints.txt`. Conflicts produce a clear error message before the container exits. -5. **Fail-fast**: A failing dependency install exits the container immediately with an actionable message. With `restart: unless-stopped`, Docker retries automatically, giving transient network issues a chance to resolve. -6. **Timeouts**: All network calls are wrapped with `timeout` (300 s for project export/install, 120 s per requirements file). -7. **Cache pruning**: After dependency installation, stale uv cache entries are pruned by default. Disable with `HASSETTE__PRUNE_UV_CACHE=0` if startup time is critical and you prefer to manage cache size manually. +- When a `uv.lock` exists, the script exports resolved dependencies to a flat requirements list before installing. This routes every package through the constraints file. +- `requirements.txt` files are only discovered when `HASSETTE__INSTALL_DEPS=1` is set. +- Only files named exactly `requirements.txt` are discovered. Variants like `requirements-dev.txt` or `requirements_test.txt` are ignored. This prevents dev and test dependencies from entering the production container. +- Every `uv pip install` call passes `-c /app/constraints.txt`. Conflicts produce a clear error before the container exits. +- A failing install exits the container immediately. With `restart: unless-stopped`, Docker retries automatically. Transient network errors resolve on their own. +- All network calls are wrapped with `timeout`: 300 s for project export and install, 120 s per requirements file. +- After installation, stale uv cache entries are pruned by default. Disable with `HASSETTE__PRUNE_UV_CACHE=0` to manage cache size manually. ## Understanding APP_DIR vs PROJECT_DIR -These two environment variables serve different purposes: +`HASSETTE__APPS__DIRECTORY` and `HASSETTE__PROJECT_DIR` serve different purposes: -| Variable | Purpose | Used By | -| ----------------------- | ------------------------------------------------------------------------------------- | ---------------- | -| `HASSETTE__APPS__DIRECTORY` | Where Hassette looks for `.py` files containing `App`/`AppSync` classes | Hassette runtime | -| `HASSETTE__PROJECT_DIR` | Where the startup script looks for `pyproject.toml`/`uv.lock` to install dependencies | Startup script | +| Variable | Purpose | Used by | +|---|---|---| +| `HASSETTE__APPS__DIRECTORY` | Where Hassette looks for `.py` files containing `App` and `AppSync` classes | Hassette runtime | +| `HASSETTE__PROJECT_DIR` | Where the startup script looks for `pyproject.toml` and `uv.lock` | Startup script | -!!! important "Key Distinction" - `HASSETTE__APPS__DIRECTORY` tells Hassette where your code lives. `HASSETTE__PROJECT_DIR` tells the startup script where your package definition lives. These can be the same directory or different directories depending on your project structure. +These can point to the same directory or different ones depending on your project layout. `HASSETTE__APP_DIR` is a legacy alias for `HASSETTE__APPS__DIRECTORY` and still works, but prefer the canonical name. ## Project Structures ### Simple Flat Structure -For basic apps where you do not need to import sibling files, use a simple flat structure: - ``` --8<-- "pages/getting-started/docker/snippets/deps-flat-dir-structure.txt" ``` -**docker-compose.yml:** - ```yaml --8<-- "pages/getting-started/docker/snippets/deps-flat-compose.yml" ``` -In this setup: - -- `HASSETTE__APPS__DIRECTORY` defaults to `/apps` ✓ -- `HASSETTE__PROJECT_DIR` defaults to `/apps` ✓ - -!!! note "Opt-in required for requirements.txt" - A `requirements.txt` in `/apps` is **not** installed automatically. You must set `HASSETTE__INSTALL_DEPS=1` for the startup script to discover and install it. See [Using requirements.txt](#using-requirementstxt) below. +Both `HASSETTE__APPS__DIRECTORY` and `HASSETTE__PROJECT_DIR` default to `/apps`, so no extra environment variables are needed. A `requirements.txt` in `/apps` is not installed automatically. Set `HASSETTE__INSTALL_DEPS=1` to enable discovery. ### Traditional src/ Layout -For projects using the standard Python `src/` layout: - ``` --8<-- "pages/getting-started/docker/snippets/deps-src-dir-structure.txt" ``` -**docker-compose.yml:** - ```yaml --8<-- "pages/getting-started/docker/snippets/deps-src-compose.yml" ``` -In this setup: - -- The project root (containing `pyproject.toml`) is mounted to `/apps` -- `HASSETTE__PROJECT_DIR=/apps` tells the startup script where to find dependencies -- `HASSETTE__APPS__DIRECTORY=/apps/src/my_apps` tells Hassette where to find your app files -- Your app files can import from the `my_apps` package normally +The project root containing `pyproject.toml` mounts to `/apps`. `HASSETTE__PROJECT_DIR=/apps` tells the startup script where to find the lock file. `HASSETTE__APPS__DIRECTORY=/apps/src/my_apps` tells Hassette where to find your app classes. Your apps can import from the `my_apps` package normally. ## Using pyproject.toml -Create a `pyproject.toml` in your project: - ```toml --8<-- "pages/getting-started/docker/snippets/pyproject-example.toml" ``` ### With a Lock File (Required) -Generate a lock file before deploying: - ```bash --8<-- "pages/getting-started/docker/snippets/uv-lock.sh" ``` -If a `uv.lock` file exists alongside your `pyproject.toml`, the startup script uses the export-then-install pattern: it exports your resolved dependencies as a flat requirements list and installs them through the constraints file. +Commit the `uv.lock` file alongside your `pyproject.toml`. The startup script detects both files and exports resolved dependencies through the constraints file. -!!! note "Lock file is required for project-based installs" - If your `pyproject.toml` is present but no `uv.lock` exists, the startup script logs a message directing you to run `uv lock` and skips the project install. If you can't run `uv` locally, use the `requirements.txt` path with `HASSETTE__INSTALL_DEPS=1` instead. +If `pyproject.toml` is present but no `uv.lock` exists, the startup script logs a message and skips the project install. If you cannot run `uv` locally, use `requirements.txt` with `HASSETTE__INSTALL_DEPS=1` instead. ## Using requirements.txt -For simpler setups, place a `requirements.txt` file in `/config` or `/apps`: - ``` --8<-- "pages/getting-started/docker/snippets/deps-requirements-dir-structure.txt" ``` -**apps/requirements.txt:** - ``` --8<-- "pages/getting-started/docker/snippets/requirements-example.txt" ``` -!!! warning "Opt-in required" - Requirements file discovery is disabled by default. Set `HASSETTE__INSTALL_DEPS=1` in your compose environment to enable it. - ```yaml --8<-- "pages/getting-started/docker/snippets/deps-install-deps-env.yml" ``` -The startup script uses `fd` to find files named exactly `requirements.txt` in both `/config` and `/apps` (up to 5 directory levels deep), then installs them in sorted path order with constraints applied. - -!!! note "Exact filename match only" - Only files named exactly `requirements.txt` are discovered. Files named `requirements-dev.txt`, `requirements_test.txt`, or any other variant are ignored. If you need multiple files, use the project-based install with `pyproject.toml` + `uv.lock`. +The startup script uses `fd` to find files named exactly `requirements.txt` in both `/config` and `/apps`. It installs them in sorted path order with constraints applied. ## Startup Performance ### Using uv.lock for Faster Starts -The `uv_cache` Docker volume caches downloaded packages. Combined with `uv.lock`, this makes subsequent container starts very fast because packages that are already cached don't need to be re-downloaded: - ```yaml --8<-- "pages/getting-started/docker/snippets/uv-cache-volume.yml" ``` -### Pre-building a Custom Image +The `uv_cache` Docker volume caches downloaded packages between container restarts. Combined with `uv.lock`, subsequent starts skip re-downloading cached packages. -For the fastest startup times, build a custom image with your dependencies pre-installed. Use the export-then-install pattern so the constraints file is still enforced: +### Known Limitations + +#### Local Path Dependencies + +Projects with local path dependencies (for example, `foo = { path = "../shared-lib" }` in `pyproject.toml`) fail during the export step. `uv export` emits `file:///absolute/path` references that do not resolve inside the container. If your project uses monorepo-style local deps, build a custom image with those packages copied in at build time: ```dockerfile --8<-- "pages/getting-started/docker/snippets/custom-image.dockerfile" ``` -Then in `docker-compose.yml`: - ```yaml --8<-- "pages/getting-started/docker/snippets/custom-image-compose.yml" ``` -### Known Limitations - -#### Local Path Dependencies - -User projects with local path dependencies (e.g., `foo = { path = "../shared-lib" }`) will fail during the export step because `uv export` emits `file:///absolute/path` references that don't resolve inside the container. If your project uses monorepo-style local deps, use the custom image build pattern above — copy all relevant packages into the image at build time and install them before deploying. +!!! note "CLI tooling for custom images is planned" + A `hassette docker build` command to streamline this workflow is on the roadmap. ## Complete Examples @@ -189,5 +155,6 @@ User projects with local path dependencies (e.g., `foo = { path = "../shared-lib ## See Also -- [Docker Overview](index.md) — Quick start guide -- [Troubleshooting](troubleshooting.md) — Common issues and solutions +- [Docker Setup](index.md) — Getting started with Docker +- [Image Tags](image-tags.md) — Choosing the right image version +- [Troubleshooting](troubleshooting.md) — Dependency conflicts and common errors diff --git a/docs/pages/getting-started/docker/image-tags.md b/docs/pages/getting-started/docker/image-tags.md index 9938dc368..f3e14c619 100644 --- a/docs/pages/getting-started/docker/image-tags.md +++ b/docs/pages/getting-started/docker/image-tags.md @@ -1,151 +1,53 @@ # Docker Image Tags -Hassette publishes Docker images for multiple Python versions. All tags explicitly include the Python version to avoid ambiguity. +Every image tag combines the Hassette version and the Python version: `ghcr.io/nodejsmith/hassette:-py`. For example, `v0.35.0-py3.13` means Hassette 0.35.0 on Python 3.13. -## Tag Format +## Recommended Tags -### Recommended: Pin Both Version and Python +### Production -For reproducible production builds, pin both the Hassette version and Python version: +Pin the Hassette version and the Python version. Your container won't change until you update the tag. +```yaml +--8<-- "pages/getting-started/docker/snippets/tag-pinned-compose.yml" ``` ---8<-- "pages/getting-started/docker/snippets/tag-format-versioned.txt" -``` - -**Examples:** - -- `ghcr.io/nodejsmith/hassette:0.24.0-py3.13` -- `ghcr.io/nodejsmith/hassette:0.24.0-py3.12` -- `ghcr.io/nodejsmith/hassette:0.24.0-py3.11` - -This is the **recommended tag format** for production. - -### Track Latest Stable Release - -If you want automatic upgrades within a Python line: - -``` ---8<-- "pages/getting-started/docker/snippets/tag-format-latest.txt" -``` - -**Examples:** - -- `ghcr.io/nodejsmith/hassette:latest-py3.13` -- `ghcr.io/nodejsmith/hassette:latest-py3.12` -- `ghcr.io/nodejsmith/hassette:latest-py3.11` - -!!! note "Stable Releases Only" - These tags only point to stable releases. Pre-releases (`.dev`, `a`, `b`, `rc`, etc.) are never published to `latest-py*` tags. - -!!! warning "Upgrade Risk" - `latest-py*` tags update automatically on every stable release. If a new Hassette release includes breaking changes to configuration or app APIs, your container will silently upgrade on the next `docker pull`. Pin to a specific version if you need to control when upgrades happen. - -### Testing Open Pull Requests - -Pull requests opened from branches **in this repository** get a stable, mutable tag pointing at the latest build of that PR: - -``` ---8<-- "pages/getting-started/docker/snippets/tag-format-pr.txt" -``` - -**Example:** - -- `ghcr.io/nodejsmith/hassette:pr-497-py3.13` - -The tag is updated on every push to the PR branch, so `docker pull` always fetches the most recent build. Use these to try out changes before they land in `main`. -!!! note "Python 3.13 Only" - PR images are only built for Python 3.13 to keep CI fast. Releases still build all supported Python versions. +### Development -!!! note "Fork PRs Not Published" - PRs opened from forks do **not** publish images — fork-PR workflows do not have the credentials or write permissions needed to push to this repository's GHCR package. To test a fork PR, pull the contributor's branch locally and build the image yourself, or ask a maintainer to rebase the PR into the main repository. +Track the latest stable release for a given Python version. -!!! warning "Mutable Tag" - `pr--py3.13` tags are mutable and will change as the PR evolves. Do not use them for reproducible builds — pin a version tag instead. - -### Bleeding-Edge Main Branch - -Every merge to `main` publishes a `main` tag for testing the latest unreleased code: - -``` ---8<-- "pages/getting-started/docker/snippets/tag-format-main.txt" +```yaml +--8<-- "pages/getting-started/docker/snippets/tag-latest-compose.yml" ``` -**Example:** - -- `ghcr.io/nodejsmith/hassette:main-py3.13` +`latest-py3.13` always points to the most recent stable release. It never includes pre-releases. Pull and restart when you want the newest version. -!!! note "Python 3.13 Only" - `main` images are only built for Python 3.13 to keep CI fast. Releases still build all supported Python versions. - -!!! warning "Mutable Tag" - `main-py3.13` is mutable and updates on every merge to `main`. It may contain unreleased, unvetted changes. Do not use in production — pin a version tag instead. - -## Tags NOT Published - -The following tag patterns are **not** published: - -| Pattern | Reason | -| ---------------------------------- | ------------------------------------------ | -| `latest` (without Python version) | Ambiguous — always specify Python version | -| Version tags without `-py` | Ambiguous — always specify Python version | -| Floating tags for pre-releases | Explicit version required for pre-releases | - -If you want a pre-release, you must explicitly request it by version: - -``` ---8<-- "pages/getting-started/docker/snippets/tag-prerelease-explicit.txt" -``` +!!! warning "Automatic upgrades" + `latest-py*` tags update on every stable release. If a new release has breaking changes, your container will pick them up on the next `docker pull`. Pin to a specific version if you need to control when upgrades happen. ## Supported Python Versions -Each release is built for multiple Python versions: +Each release is built for three Python versions. -| Python Version | Status | -| -------------- | ----------- | +| Python version | Status | +| -------------- | --------- | | 3.13 | Supported | | 3.12 | Supported | | 3.11 | Supported | -!!! note "Version Support" - Python versions are dropped when they reach end-of-life. Check the release notes when upgrading. - -## Choosing a Tag - -### For Production - -Use a pinned version with your preferred Python: - -```yaml ---8<-- "pages/getting-started/docker/snippets/tag-pinned-compose.yml" -``` - -### For Development - -Use the latest stable release: - -```yaml ---8<-- "pages/getting-started/docker/snippets/tag-latest-compose.yml" -``` - -### For Testing Pre-release Features - -Use a specific pre-release version: - -```yaml ---8<-- "pages/getting-started/docker/snippets/tag-prerelease-compose.yml" -``` +Python versions are dropped when they reach end-of-life. Check the release notes when upgrading Hassette to a new minor version. ## Updating Images -### Pull Latest +Pull the latest image and restart your containers. ```bash --8<-- "pages/getting-started/docker/snippets/docker-pull-update.sh" ``` -### Check Current Version +If you pin to a specific version tag, update the tag in your `docker-compose.yml` first, then run this command. -```bash ---8<-- "pages/getting-started/docker/snippets/docker-version-check.sh" -``` +## Next Steps + +- [Docker Setup](index.md) — full setup guide +- [Dependencies](dependencies.md) — adding Python packages to your container diff --git a/docs/pages/getting-started/docker/index.md b/docs/pages/getting-started/docker/index.md index 390086674..94508913c 100644 --- a/docs/pages/getting-started/docker/index.md +++ b/docs/pages/getting-started/docker/index.md @@ -1,148 +1,130 @@ # Docker Setup -This guide walks through deploying Hassette with Docker Compose, the recommended way to run Hassette in production. - -!!! tip "Why Docker?" - Docker provides isolation, easy updates, consistent environments across machines, and automatic restarts. - ## Prerequisites -- Docker and Docker Compose installed +- [Docker](https://docs.docker.com/get-docker/) and [Docker Compose](https://docs.docker.com/compose/install/) installed - A running Home Assistant instance -- A long-lived access token from your Home Assistant profile - -!!! note - If you don't have a token yet, the [Creating a Home Assistant token](../ha_token.md) page walks through generating one from the HA UI. +- A long-lived access token from your HA profile. See [Creating a Home Assistant token](../ha_token.md) ## Quick Start -The fastest path from zero to a running Hassette instance: - -**1. Create a project directory** +### 1. Create your project directory ```bash --8<-- "pages/getting-started/docker/snippets/mkdir-project.sh" ``` -**2. Create the Docker Compose file** - -Create `docker-compose.yml` in `project_dir`: +### 2. Create the Compose file ```yaml --8<-- "pages/getting-started/docker/snippets/docker-compose.yml" ``` -**3. Create your configuration** +Save this as `docker-compose.yml` in `project_dir`. -Create `config/.env` with your Home Assistant token: +### 3. Create your `.env` file ```bash --8<-- "pages/getting-started/docker/snippets/env-file.sh" ``` -Create `config/hassette.toml`: +Save this as `config/.env`. Replace `your_long_lived_access_token_here` with the token you generated. Add `config/.env` to your `.gitignore` to keep it out of version control. + +### 4. Create `hassette.toml` ```toml --8<-- "pages/getting-started/docker/snippets/hassette.toml" ``` -Create `apps/my_app.py`: +Save this as `config/hassette.toml`. Set `base_url` to your Home Assistant URL. If HA runs on the same machine, `http://homeassistant:8123` works when both containers share a Docker network. Use your HA's IP or hostname otherwise. + +### 5. Write your first app ```python --8<-- "pages/getting-started/docker/snippets/my_app.py" ``` -**4. Start Hassette** +Save this as `apps/my_app.py`. `App` lets you subscribe to HA events and control devices. `AppConfig` defines typed settings your app reads from `hassette.toml`. `on_initialize` runs once when Hassette loads the app. + +### 6. Start Hassette ```bash --8<-- "pages/getting-started/docker/snippets/docker-compose-up.sh" ``` -After a few seconds, check the logs: +Check that it connected: ```bash --8<-- "pages/getting-started/docker/snippets/docker-compose-logs-hassette.sh" ``` -You should see `"Connected to Home Assistant"` in the output. +You should see `Connected to Home Assistant` in the output. -!!! warning "Web UI Security" - The Docker Compose file exposes port 8126, which serves the web UI and REST API with **no authentication**. Anyone on your network can view, start, stop, and reload your automations. For remote servers, bind to `127.0.0.1` via `host` under `[hassette.web_api]` or place Hassette behind a reverse proxy with authentication. See [Web UI — Enabling and accessing](../../web-ui/index.md#enabling-and-accessing) for details. +!!! warning "Web UI security" + The Compose file exposes port `8126` with no authentication. Anyone on your network can view, start, stop, and reload your automations. For remote servers, bind to `127.0.0.1` via `host` under `[hassette.web_api]`. You can also place Hassette behind a reverse proxy. See [Web UI](../../web-ui/index.md) for details. ## Directory Structure -Hassette expects the following directory structure when running in Docker: +After the quick start, your project looks like this: ``` --8<-- "pages/getting-started/docker/snippets/dir-structure.txt" ``` -The Docker image uses four volumes: +The Docker image mounts four paths: -| Mount Point | Description | -| ----------- | -------------------------------------------------------- | -| `/config` | Configuration files (mounted from `./config`) | -| `/apps` | Your application code (mounted from `./apps`) | -| `/data` | Persistent data storage (Docker volume) | -| `/uv_cache` | Python package cache for faster restarts (Docker volume) | +| Mount | Purpose | +|---|---| +| `/config` | Configuration files: `hassette.toml`, `.env` | +| `/apps` | Your app Python files | +| `/data` | Persistent data storage: telemetry database | +| `/uv_cache` | Python package cache for faster restarts | -!!! note "Package Structure" - For simple setups, put `.py` files directly in `./apps`. For projects with external Python dependencies, see [Managing Dependencies](dependencies.md). +For projects with external Python dependencies, see [Managing Dependencies](dependencies.md). ## Configuration ### Home Assistant Token -Create `config/.env` with your Home Assistant token: - -```bash ---8<-- "pages/getting-started/docker/snippets/env-file.sh" -``` - -!!! warning "Security" - Never commit `.env` files to version control. Add `config/.env` to your `.gitignore`. +Hassette reads the token from `HASSETTE__TOKEN` in your `.env` file. The [Creating a Home Assistant token](../ha_token.md) page shows how to generate one. ### Environment Variables Reference -Override any configuration via environment variables using the `HASSETTE__` prefix: +Any setting from `hassette.toml` can be overridden with a `HASSETTE__` environment variable. Set these in `docker-compose.yml` under `environment`, or add them to `config/.env`. -| Variable | Description | -| ----------------------- | ---------------------------------------------------------------------------------------------------- | -| `HASSETTE__TOKEN` | Home Assistant long-lived access token | -| `HASSETTE__BASE_URL` | Home Assistant URL (e.g., `http://homeassistant:8123`) | -| `HASSETTE__APPS__DIRECTORY` | Directory containing your app Python files | -| `HASSETTE__PROJECT_DIR` | Directory containing `pyproject.toml`/`uv.lock` for dependency installation | -| `HASSETTE__CONFIG_DIR` | Directory containing configuration files | -| `HASSETTE__LOG_LEVEL` | Logging level (`debug`, `info`, `warning`, `error`). (`LOG_LEVEL` is also read at startup before the config initializes, but `HASSETTE__LOG_LEVEL` controls the full runtime log level.) | -| `HASSETTE__INSTALL_DEPS`| Set to `1` to enable `requirements.txt` file discovery and installation at startup | -| `HASSETTE__PRUNE_UV_CACHE` | Set to `0` to skip `uv cache prune` at startup (default: `1`) | -| `TZ` | System timezone (e.g., `America/New_York`) | +| Variable | Description | +|---|---| +| `HASSETTE__TOKEN` | Home Assistant long-lived access token | +| `HASSETTE__BASE_URL` | Home Assistant URL, e.g. `http://homeassistant:8123` | +| `HASSETTE__APPS__DIRECTORY` | Directory containing your app files | +| `HASSETTE__PROJECT_DIR` | Directory containing `pyproject.toml` and `uv.lock` for dependency installation | +| `HASSETTE__CONFIG_DIR` | Directory containing configuration files | +| `HASSETTE__LOG_LEVEL` | Log level: `debug`, `info`, `warning`, or `error` | +| `HASSETTE__INSTALL_DEPS` | Set to `1` to discover and install `requirements.txt` at startup | +| `HASSETTE__PRUNE_UV_CACHE` | Set to `0` to skip `uv cache prune` at startup (default: `1`) | +| `TZ` | System timezone, e.g. `America/New_York` | -See [Managing Dependencies](dependencies.md) for details on `HASSETTE__APPS__DIRECTORY` and `HASSETTE__PROJECT_DIR`. +For a full configuration reference, see [Configuration](../../core-concepts/configuration/index.md). ## Production Deployment ### Hot Reloading in Production -Hassette watches for file changes by default, but automatic app reloads require `allow_reload_in_prod = true` when running outside dev mode. To enable automatic reloads in production: - ```toml --8<-- "pages/getting-started/docker/snippets/prod-reload.toml" ``` -With this configuration, Hassette restarts apps when you change files in `./apps/`. +Hassette watches for file changes in `./apps/`, but reloads require an explicit opt-in outside dev mode. Add the snippet above to `config/hassette.toml`. -!!! warning "Performance" - File watching adds overhead. Only enable if you need it. +With `allow_reload_in_prod = true`, saving a file in `./apps/` restarts only the affected app. File watching adds overhead. Only enable it if your workflow requires in-place updates to a running container. ### Graceful Shutdown -Hassette handles `SIGTERM` (sent by `docker stop` and `docker compose down`) to shut down gracefully. It finalizes the active session, drains pending database writes, and closes all connections. +`docker stop` and `docker compose down` send `SIGTERM`. Hassette catches it, finalizes the active session, and drains pending database writes. All connections close before the process exits. -The compose examples include `stop_grace_period: 45s` to give Hassette enough time to complete its shutdown sequence. Docker's default of 10 seconds is too short and will force-kill the process before it finishes, leaving sessions marked as `unknown` on the next startup. +The Compose file sets `stop_grace_period: 45s`. Docker's default of 10 seconds is too short. The process gets force-killed before shutdown completes, leaving sessions marked as `unknown` on next startup. -!!! tip "Custom shutdown timeout" - If you override `total_shutdown_timeout_seconds` in your config, set `stop_grace_period` to at least 15 seconds more than your shutdown timeout to avoid Docker killing the process early. +If you override `total_shutdown_timeout_seconds` in your config, set `stop_grace_period` to at least 15 seconds more. ## Viewing Logs @@ -154,20 +136,11 @@ The compose examples include `stop_grace_period: 45s` to give Hassette enough ti ### Web UI -Hassette includes a web UI at `http://:8126/ui/` with app monitoring, handler detail, log streaming, and system configuration. No extra setup needed. See the [Web UI documentation](../../web-ui/index.md) for a full tour. +The web UI at `http://:8126/ui/` shows live app status, handler details, log streaming, and system configuration. It starts with the container. See [Web UI](../../web-ui/index.md) for a full tour. ## Next Steps - [Managing Dependencies](dependencies.md) — Install Python packages for your apps -- [Image Tags](image-tags.md) — Choose the right Docker image +- [Image Tags](image-tags.md) — Pick a versioned or Python-specific image tag +- [Write Your First Automation](../first-automation.md) — Subscribe to HA events and control devices - [Troubleshooting](troubleshooting.md) — Common issues and solutions - -!!! note "File Locations" - For details on where Hassette searches for `hassette.toml` and `.env` files, including the `-c` and `-e` override flags, see [Configuration — File Locations](../../core-concepts/configuration/index.md#file-locations). - -## See Also - -- [Getting Started](../index.md) — Local development setup -- [Configuration](../../core-concepts/configuration/index.md) — Complete configuration reference -- [Apps](../../core-concepts/apps/index.md) — Writing and structuring apps -- [Examples](https://github.com/NodeJSmith/hassette/tree/main/examples) — Example apps and configurations diff --git a/docs/pages/getting-started/docker/troubleshooting.md b/docs/pages/getting-started/docker/troubleshooting.md index 99dc7fd27..4c8334612 100644 --- a/docs/pages/getting-started/docker/troubleshooting.md +++ b/docs/pages/getting-started/docker/troubleshooting.md @@ -1,62 +1,60 @@ # Troubleshooting -This guide covers common issues when running Hassette in Docker and how to resolve them. - ## Container Won't Start ### Check the Logs -Always start by checking the logs: +The logs tell you why the container stopped: ```bash --8<-- "pages/getting-started/docker/snippets/ts-check-logs.sh" ``` -For more detail: +If the output is truncated, get more: ```bash --8<-- "pages/getting-started/docker/snippets/ts-check-logs-tail.sh" ``` -### Common Startup Issues - -#### Token Not Set +### Token Not Set -**Symptom:** Error about missing or invalid token +**Symptom:** The logs show an error about a missing or invalid token. -**Solution:** Ensure `HASSETTE__TOKEN` is set in `config/.env`: +`HASSETTE__TOKEN` is required. Set it in `config/.env` and restart: ```bash ---8<-- "pages/getting-started/docker/snippets/env-file.sh" +HASSETTE__TOKEN=your_long_lived_token_here ``` -#### Can't Reach Home Assistant +See [Docker Setup](index.md) for how to generate a long-lived token in Home Assistant. -**Symptom:** Connection refused or timeout errors +### Can't Reach Home Assistant -**Solutions:** +**Symptom:** The logs show `Connection refused` or a timeout when connecting to Home Assistant. -1. Verify `base_url` in `hassette.toml` is correct -2. Check network configuration -3. Test connectivity from the container: +Confirm `base_url` in `hassette.toml` matches your Home Assistant address. Then test connectivity from inside the container: ```bash --8<-- "pages/getting-started/docker/snippets/ts-curl-ha.sh" ``` -#### Permission Errors +If this command times out or returns a connection error, the container cannot reach Home Assistant. Check your Docker network configuration. Hassette and Home Assistant must be on the same network, or you must use a routable hostname. -**Symptom:** Permission denied when reading files +### Permission Errors -**Solution:** The container runs as user `hassette`. Ensure mounted files are readable: +**Symptom:** The logs show `Permission denied` when reading config or app files. + +The container runs as user `hassette`. Your mounted files must be readable by that user: ```bash --8<-- "pages/getting-started/docker/snippets/ts-chmod.sh" ``` +Restart the container after fixing permissions. + ## Apps Not Loading -### 1. Check App Discovery +### Check App Discovery Verify Hassette can see your app files: @@ -64,41 +62,49 @@ Verify Hassette can see your app files: --8<-- "pages/getting-started/docker/snippets/ts-ls-apps.sh" ``` -### 2. Verify App Directory Configuration +If your files aren't listed, the volume mount is wrong or the files don't exist at the expected path. -Ensure `apps.directory` in `hassette.toml` matches the container path: +### Verify App Directory Configuration + +`apps.directory` in `hassette.toml` must match the container path where your apps are mounted: ```toml --8<-- "pages/getting-started/docker/snippets/ts-app-dir-toml.toml" ``` -If using `src/` layout: +If your project uses a `src/` layout, override the directory with an environment variable instead: ```yaml --8<-- "pages/getting-started/docker/snippets/ts-app-dir-src-env.yml" ``` -### 3. Check for Python Errors +### Check for Python Errors + +**Symptom:** The app directory exists and the files are there, but apps still don't load. -Look for syntax or import errors in the logs: +Look for syntax errors or failed imports in the logs: ```bash --8<-- "pages/getting-started/docker/snippets/ts-grep-errors.sh" ``` -### 4. Verify App Configuration +A `SyntaxError` or `ImportError` in your app file prevents it from loading. Fix the error in your app code and restart. -Ensure your app is configured in `hassette.toml`: +### Verify App Configuration + +Each app needs a corresponding entry in `hassette.toml`. Without one, Hassette ignores the file: ```toml --8<-- "pages/getting-started/docker/snippets/ts-app-config.toml" ``` +Check that `filename` matches your actual filename and `class_name` matches the class inside it. + ## Dependency Installation Fails ### Check Installation Output -Look for installation errors in the logs: +Look for errors in the startup logs: ```bash --8<-- "pages/getting-started/docker/snippets/ts-dep-install-logs.sh" @@ -106,49 +112,45 @@ Look for installation errors in the logs: ### Dependency Conflicts -**Symptom:** Container exits at startup with a `DEPENDENCY CONFLICT` banner followed by a uv resolver error like: +**Symptom:** The container exits at startup with a `DEPENDENCY CONFLICT` banner: ``` --8<-- "pages/getting-started/docker/snippets/ts-dep-conflict.txt" ``` -**Why it happens:** Your project's `uv.lock` was resolved against a different version of Hassette than the Docker image you're running. When the startup script installs your dependencies through Hassette's constraints file, it detects the version mismatch and exits rather than silently downgrading a framework package. +Your `uv.lock` was resolved against a different Hassette version than the image provides. The startup script detects the mismatch and exits rather than silently downgrading a framework package. -**How to fix it:** - -For project-based installs (`pyproject.toml` + `uv.lock`): +Re-resolve locally against the current image version, then commit: ```bash --8<-- "pages/getting-started/docker/snippets/ts-uv-relock.sh" ``` -Then restart the container. - -For `requirements.txt`-based installs: relax any pinned versions that conflict, or check which version range hassette requires: +For `requirements.txt`-based installs, relax any pinned versions that conflict. Check which version range Hassette requires: ```bash --8<-- "pages/getting-started/docker/snippets/ts-check-constraints.sh" ``` -**How to prevent it:** Pin hassette in your project dependencies to match the image tag you're deploying. For example, if you're using the `0.24.0-py3.13` image: +To prevent this, pin Hassette in your project to match the image tag you deploy. If you're using the `0.24.0-py3.13` image: ```toml --8<-- "pages/getting-started/docker/snippets/ts-pin-hassette-pyproject.toml" ``` -Re-run `uv lock` after changing the pin, then commit both files. +Run `uv lock` after updating the pin, then commit both files. ### pyproject.toml Not Found -**Symptom:** "No pyproject.toml found" or dependencies not installing +**Symptom:** The logs say "No pyproject.toml found" or your project dependencies aren't installing. -**Solution:** Check `HASSETTE__PROJECT_DIR` points to the right location: +Check that `HASSETTE__PROJECT_DIR` points to the directory containing your `pyproject.toml`: ```yaml --8<-- "pages/getting-started/docker/snippets/ts-project-dir-env.yml" ``` -Verify the file exists: +Confirm the file is there: ```bash --8<-- "pages/getting-started/docker/snippets/ts-cat-pyproject.sh" @@ -156,31 +158,29 @@ Verify the file exists: ### Project Has pyproject.toml But Dependencies Don't Install -**Symptom:** You have a `pyproject.toml` but no `uv.lock`, and the startup log says "run 'uv lock' to generate a lockfile" +**Symptom:** You have a `pyproject.toml` but no `uv.lock`, and the startup log says "run 'uv lock' to generate a lockfile". -**Solution:** Generate a lockfile locally and commit it: +Hassette requires a lockfile to install project dependencies. Generate one locally and commit it: ```bash ---8<-- "pages/getting-started/docker/snippets/uv-lock.sh" +uv lock +git add uv.lock +git commit -m "add lockfile" ``` -If you cannot run `uv` locally, use the `requirements.txt` approach with `HASSETTE__INSTALL_DEPS=1` instead. +If you can't run `uv` locally, use the `requirements.txt` approach with `HASSETTE__INSTALL_DEPS=1` instead. See [Managing Dependencies](dependencies.md). ### requirements.txt Not Found -**Symptom:** `requirements.txt` files are not being installed +**Symptom:** Your `requirements.txt` exists but dependencies aren't installing. -**Solution:** Check these in order: +Check these in order: -1. **Confirm `HASSETTE__INSTALL_DEPS=1` is set** — requirements discovery is disabled by default. Without this variable, no requirements files are scanned. - -```yaml ---8<-- "pages/getting-started/docker/snippets/deps-install-deps-env.yml" -``` +1. **`HASSETTE__INSTALL_DEPS=1` must be set.** Requirements discovery is off by default. Without this variable, the startup script skips all `requirements.txt` scanning. -2. **Verify the filename is exactly `requirements.txt`** — the startup script only discovers files named exactly `requirements.txt`. Files named `requirements-dev.txt`, `requirements_test.txt`, or any other variant are ignored. +2. **The filename must be exactly `requirements.txt`.** Files named `requirements-dev.txt`, `requirements_test.txt`, or any other variant are ignored. -3. **Verify the file is under `/config` or `/apps`** and is not empty. +3. **The file must be under `/config` or `/apps`** and must not be empty. Check what the container sees: @@ -190,13 +190,11 @@ Check what the container sees: ### Version Conflicts -**Symptom:** Package version conflicts during installation +**Symptom:** Installation fails with a package version conflict. -**Solutions:** +Use `uv.lock` for consistent resolution. Packages are already pinned, so there's nothing to conflict. For `requirements.txt`, relax any overly tight version pins. -1. Use `uv.lock` for consistent, reproducible resolution -2. For `requirements.txt`, relax overly tight version pins -3. Check the constraints file to see what versions hassette requires: +Check the Hassette constraints file to see which version ranges the image requires: ```bash --8<-- "pages/getting-started/docker/snippets/ts-check-constraints.sh" @@ -204,11 +202,13 @@ Check what the container sees: ### Import Errors at Runtime -If apps fail to import installed packages: +**Symptom:** Dependencies installed at startup but fail to import when your app runs. + +Check three things: -1. Verify the package is listed in your dependencies -2. Check logs for installation errors at startup -3. Ensure `HASSETTE__APPS__DIRECTORY` points to the correct location +1. The package is listed in your `pyproject.toml` or `requirements.txt` +2. The startup logs show the package installing without errors +3. `HASSETTE__APPS__DIRECTORY` points to the correct location ## Hassette Restarts Whenever Home Assistant Goes Down @@ -228,34 +228,23 @@ If you need a separate traffic-routing signal, use `/api/health/ready` — but k The liveness check queries `http://127.0.0.1:8126/api/health/live`. This endpoint returns 200 whenever the Hassette process is up and the event loop can respond. It does not check Home Assistant connectivity — HA being down never makes the liveness probe return a non-200 response. -### Symptoms - -- Container marked as unhealthy -- Container keeps restarting - -### Solutions +**Symptom:** The container is marked unhealthy, or keeps restarting. -1. **Check if Hassette is starting successfully:** +First, check whether Hassette started at all: ```bash --8<-- "pages/getting-started/docker/snippets/ts-check-logs.sh" ``` -2. **Verify the health service is running:** - -The health service is enabled by default. Check if it's accessible: +If Hassette started but the health check still fails, test the endpoint directly: ```bash --8<-- "pages/getting-started/docker/snippets/ts-health-check.sh" ``` -3. **Check for port conflicts:** - -Ensure no other service uses port 8126 inside the container. +If port 8126 is in use by another process inside the container, the health service won't bind. Check your configuration for port conflicts. -4. **Increase start period:** - -If the container installs dependencies at startup, it may take more than a few seconds before Hassette is ready to respond to health checks. Increase `start_period` to give it time: +If the container installs dependencies at startup, it may not respond before the first health check fires. Increase `start_period` to give it more time: ```yaml --8<-- "pages/getting-started/docker/snippets/ts-health-check-long-start.yml" @@ -263,102 +252,95 @@ If the container installs dependencies at startup, it may take more than a few s ## Hot Reload Not Working -### Requirements +**Symptom:** You edit an app file but Hassette doesn't reload. -For hot reload to work: +Hot reload requires three things to be true at once: -1. `watch_files = true` in `hassette.toml` -2. Files are mounted as volumes (not copied into image) -3. If not in dev mode: `allow_reload_in_prod = true` +1. `watch_files = true` is set in `hassette.toml` +2. Your app files are mounted as volumes, not copied into the image +3. If `dev_mode = false`: `allow_reload_in_prod = true` is also set -### Configuration +Add both settings to `hassette.toml`: ```toml --8<-- "pages/getting-started/docker/snippets/ts-hot-reload.toml" ``` -### Verify Volume Mounts - -Ensure files are mounted, not copied: +Confirm your `docker-compose.yml` mounts the files rather than baking them in: ```yaml --8<-- "pages/getting-started/docker/snippets/ts-vol-mount.yml" ``` +Files copied into the image at build time won't reflect host edits. Use volume mounts for any files you want hot reload to track. + ## Import Errors ### Package Not Found -**Symptom:** `ModuleNotFoundError: No module named 'xyz'` - -**Solutions:** +**Symptom:** `ModuleNotFoundError: No module named 'xyz'` when your app starts. -1. Verify the package is in your dependencies: +Add the package to your project dependencies: ```toml --8<-- "pages/getting-started/docker/snippets/ts-pyproject-dep.toml" ``` -2. Check installation logs for errors +Then check the startup logs to confirm it installed: -3. Verify the correct Python environment is active +```bash +--8<-- "pages/getting-started/docker/snippets/ts-dep-install-logs.sh" +``` ### Hassette Module Not Found **Symptom:** `ModuleNotFoundError: No module named 'hassette'` -**Solution:** This usually means the virtual environment isn't activated or the Docker image is corrupt. Check the startup logs — the script validates that hassette is importable before doing anything else, and prints a clear error if that import fails. If you see `"ERROR: Failed to import hassette — the Docker image may be corrupt"`, try pulling the image again: +The startup script validates that Hassette is importable before doing anything else. If you see `ERROR: Failed to import hassette — the Docker image may be corrupt`, pull a fresh copy of the image: ```bash ---8<-- "pages/getting-started/docker/snippets/docker-pull-update.sh" +docker compose pull hassette +docker compose up -d ``` ## Performance Issues ### Slow Container Startup -**Causes:** - -- Installing many dependencies on each start -- No package cache +**Cause:** Installing many dependencies at each startup with no cached packages. -**Solutions:** - -1. Use `uv.lock` for faster resolution (packages are already pinned, no resolution needed) -2. Mount a persistent cache volume: +Mount a persistent `uv` cache volume so packages don't re-download on every start: ```yaml --8<-- "pages/getting-started/docker/snippets/ts-uv-cache-vol.yml" ``` -3. Pre-build a custom image with dependencies — see [Pre-building a Custom Image](dependencies.md#pre-building-a-custom-image) +`uv.lock` also speeds up resolution. Packages are already pinned, so `uv` skips the resolver entirely. -### High Memory Usage +For the fastest startup, pre-build a custom image with your dependencies installed. See [Known Limitations](dependencies.md#known-limitations) in the dependencies guide. -**Solutions:** +### High Memory Usage -1. Check for memory leaks in your apps -2. Limit container memory: +Set a memory limit in `docker-compose.yml` to prevent unbounded host memory consumption: ```yaml --8<-- "pages/getting-started/docker/snippets/ts-memory-limit.yml" ``` -## Getting Help - -If you can't resolve an issue: +If the container hits the limit and restarts repeatedly, check your apps for memory leaks. Common causes are accumulating state in module-level variables or unbounded queues. -1. **Search existing issues:** [GitHub Issues](https://github.com/NodeJSmith/hassette/issues) +## Getting Help -2. **Collect diagnostic information:** +If you're still stuck, collect diagnostic information first: ```bash --8<-- "pages/getting-started/docker/snippets/ts-diagnostics.sh" ``` -3. **Open a new issue** with the diagnostic information +Then search [existing issues](https://github.com/NodeJSmith/hassette/issues). Someone else may have hit the same problem. If not, open a new issue and include the diagnostic output. ## See Also -- [Docker Overview](index.md) — Quick start guide -- [Managing Dependencies](dependencies.md) — Dependency installation details +- [Docker Setup](index.md) +- [Managing Dependencies](dependencies.md) +- [Image Tags](image-tags.md) diff --git a/docs/pages/getting-started/first-automation.md b/docs/pages/getting-started/first-automation.md index 8dd57e4a4..ee586d040 100644 --- a/docs/pages/getting-started/first-automation.md +++ b/docs/pages/getting-started/first-automation.md @@ -1,115 +1,56 @@ # Your First Automation -**What you'll build**: An automation that logs a heartbeat every minute and turns on a light at sunset. +This page adds two features to the app from the [Quickstart](index.md): -**What you'll learn**: +- **A sunset handler** that turns on a light when the sun sets, with typed state data via dependency injection +- **A heartbeat job** that logs every minute -- How an `App` class is structured -- Why config is declared as a typed `AppConfig` subclass -- How to subscribe to events with `self.bus` -- How to schedule recurring tasks with `self.scheduler` -- How to call a Home Assistant service with `self.api` +## Subscribe to a State Change -**Prerequisites**: Hassette is installed and running (the [Quickstart guide](index.md) covers setup). - ---- - -## Step 1: Understand the App class - -Every Hassette automation is a Python class that extends `App`. Hassette calls `on_initialize()` when the app starts — this is where you register event handlers and schedule jobs. You don't call any setup yourself; you declare what you want, and Hassette wires it up. - -```python ---8<-- "pages/getting-started/snippets/first_automation_step1.py" -``` - -`on_initialize` is `async` because Hassette is built on `asyncio`. Your handlers can `await` API calls or other coroutines. - -## Step 2: Add typed configuration - -Rather than reading config from a dict (like `self.args["key"]`), Hassette uses a Pydantic model. Declare a class that extends `AppConfig` with the fields you want: - -```python ---8<-- "pages/getting-started/snippets/first_automation_step2.py" -``` - -`App[MyAppConfig]` is a generic that tells Hassette which config class to use. Hassette validates the config at startup — a missing required field raises a clear error before any of your code runs. - -`self.app_config.greeting` is typed: your IDE knows it's a `str`, and Pyright will catch typos. - -## Step 3: Subscribe to a state change - -Use `self.bus.on_state_change()` to react to HA state changes. The `"sun.*"` pattern matches any entity in the `sun` domain (typically `sun.sun`). - -The Quickstart used a raw event handler — that works, but Hassette can do better. With **dependency injection** (DI), you annotate handler parameters with types like `D.StateNew[T]`, and Hassette extracts and converts the data automatically — no event payload parsing required: - -```python +```python hl_lines="1 11 12 13 15 16 17 18 19 20" --8<-- "pages/getting-started/snippets/first_automation_step3.py" ``` -Two names appear here that aren't obvious at first glance: - -- **`D`** is a short alias for `hassette.dependencies` — a module containing type annotations that tell Hassette what to extract from each event and inject into your handler parameters. -- **`states`** is the `hassette.models.states` module — it contains typed state classes for each Home Assistant domain (`SunState`, `LightState`, `BinarySensorState`, and [many others](../core-concepts/states/index.md#built-in-state-types)). - -So `D.StateNew[states.SunState]` means: *extract the new state from this event and give it to me already converted to a `SunState` object*. The `.value` attribute holds the state string (`"above_horizon"` or `"below_horizon"`). Your IDE knows the type; Pyright will catch typos. - -`self.api.turn_on()` calls the HA service. The `domain="light"` argument routes it to `light.turn_on` instead of the generic `homeassistant.turn_on`. Use the entity's domain as the `domain=` value — e.g., `domain="switch"` for switch entities, `domain="input_boolean"` for input booleans. +[`self.bus.on_state_change()`](../core-concepts/bus/index.md) subscribes to entity state transitions. `"sun.*"` matches any entity in the `sun` domain. In practice, that's `sun.sun`. The `name=` parameter labels this handler in logs and the web UI. -??? note "Raw event form (verbose alternative)" - You can also receive the full untyped event object. Use this form when you need access to additional event data beyond the new state value: +The handler parameter `new_state: D.StateNew[states.SunState]` tells Hassette what to extract from the event and pass in: - ```python - --8<-- "pages/getting-started/snippets/first_automation_step3_raw.py:raw_handler" - ``` +- **[`D`](../core-concepts/bus/dependency-injection.md)** is `hassette.dependencies`, a module of type annotations. `D.StateNew[T]` means "give me the new state, converted to type `T`." +- **[`states`](../core-concepts/states/index.md#built-in-state-types)** is `hassette.models.states`, typed state classes for each HA domain. `states.SunState` has a `.value` attribute holding `"above_horizon"` or `"below_horizon"`. - Note that raw state dicts use `new_state["state"]` (the key Home Assistant uses in its event payload), while typed state objects use `.value`. +Hassette reads the annotation from your handler signature and passes in a typed `SunState` object. No event dict parsing. Your IDE knows the type, and Pyright catches typos. -## Step 4: Schedule a recurring job +[`self.api.turn_on()`](../core-concepts/api/index.md) calls a Home Assistant service. `domain="light"` routes it to `light.turn_on`. -Use `self.scheduler.run_minutely()` to run a handler every minute: +## Schedule a Recurring Job -```python +```python hl_lines="14 23 24" --8<-- "pages/getting-started/snippets/first_automation_step4.py" ``` -The first run fires one minute after Hassette starts (the default interval for `run_minutely`). +[`self.scheduler.run_minutely()`](../core-concepts/scheduler/methods.md) runs `log_heartbeat` every minute. The first run fires one minute after startup. Hassette tracks the job and cancels it automatically on shutdown. -## Step 5: Run it +`log_heartbeat` takes no DI parameters. Not every handler needs them. See [Scheduler Methods](../core-concepts/scheduler/methods.md) for `run_daily`, `run_cron`, `run_once`, and more. -With this code in place as `hassette_apps/main.py`, start Hassette: +## Run It -```bash -uv run hassette run -``` - -You should see output like: +Replace your `apps/main.py` with the complete app from the previous snippet and restart Hassette. You see new log lines: ``` -INFO hassette ... — Connected to Home Assistant -INFO hassette.MyApp.0 ... — This is from the config file! -INFO hassette.MyApp.0 ... — Heartbeat +INFO hassette.MyApp.0 — Hello from Hassette! +INFO hassette.MyApp.0 — Heartbeat ``` -Lines for `Sun changed` and `Porch light turned on` appear only at sunset. - -!!! tip "Testing the sunset handler" - The `on_sun_change` handler reacts to state transitions — it won't fire just because the sun is already below the horizon when Hassette starts. To test it without waiting for actual sunset, temporarily use `self.bus.on_state_change("sun.sun", ...)` and manually call `hass.states.set("sun.sun", "below_horizon")` from the Developer Tools in Home Assistant. - -## What you just built - -- **Typed config**: `MyAppConfig` declares the `greeting` field with a default. Hassette validates it at startup. -- **Event subscription**: `on_sun_change` fires every time the `sun.*` state changes. Dependency injection (`D.StateNew[states.SunState]`) delivers a typed state object — no event payload parsing. You didn't write any polling loop. See the [built-in state types](../core-concepts/states/index.md#built-in-state-types) for all available classes. -- **Scheduled job**: `log_heartbeat` runs every 60 seconds. Hassette tracks the job and cancels it automatically when the app shuts down. -- **API call**: `self.api.turn_on()` calls a HA service without you managing HTTP sessions or WebSocket framing. - -## Next steps +The `Sun changed` and `Porch light turned on` lines appear at the next sunset. -- **[Bus & Handlers](../core-concepts/bus/index.md)** — learn the full range of subscription options (attribute changes, service calls, glob patterns, predicates) -- **[Dependency Injection](../core-concepts/bus/dependency-injection.md)** — extract typed state objects directly into handler parameters instead of parsing event dicts -- **[Testing Your Apps](../testing/index.md)** — write unit tests for this automation using `AppTestHarness` -- **[Scheduler Methods](../core-concepts/scheduler/methods.md)** — `run_daily`, `run_cron`, `run_once`, and more -- **[Application Configuration](../core-concepts/configuration/applications.md)** — load multiple apps, run the same app with different config, or disable apps without deleting code +??? tip "Test the sunset handler without waiting" + `on_sun_change` fires on state transitions, not the current state. To test it now, open Home Assistant Developer Tools, go to States, set `sun.sun` to `below_horizon`, and click Set State. The handler fires within milliseconds. ---- +## Next Steps -You now have the essentials: a typed app class, config, event subscriptions, and scheduled jobs. Everything beyond this point adds depth — stronger filtering, richer injection types, testing, advanced scheduling — but you can build real automations with what you've just learned. +- [Bus & Handlers](../core-concepts/bus/index.md) — attribute changes, service calls, glob patterns, predicates, and conditions +- [Dependency Injection](../core-concepts/bus/dependency-injection.md) — all the types you can extract into handler parameters +- [Scheduler Methods](../core-concepts/scheduler/methods.md) — `run_daily`, `run_cron`, `run_once`, and jitter +- [Testing Your Apps](../testing/index.md) — unit tests using `AppTestHarness` +- [Recipes](../recipes/index.md) — complete worked examples for motion lights, presence detection, and more +- [Docker](docker/index.md) — run Hassette in production as a container diff --git a/docs/pages/getting-started/ha_token.md b/docs/pages/getting-started/ha_token.md index 354c1e6b8..54b527992 100644 --- a/docs/pages/getting-started/ha_token.md +++ b/docs/pages/getting-started/ha_token.md @@ -1,36 +1,34 @@ # Creating a Home Assistant Token -Hassette connects to Home Assistant over the WebSocket API. To authenticate, it needs a **long-lived access token** — a static credential that you generate once and store in your `.env` file as `HASSETTE__TOKEN`. - -Long-lived access tokens belong to your Home Assistant user account and grant the same permissions as that account. Create a dedicated token for Hassette so you can revoke it independently if needed. +Hassette authenticates to Home Assistant using a long-lived access token. You generate it once in the Home Assistant UI and store it in your `.env` file. ## Steps -#### Go to the [Profile](https://my.home-assistant.io/redirect/profile/) page in your Home Assistant instance and click the "Security" tab. +1. Go to the [Profile](https://my.home-assistant.io/redirect/profile/) page in your Home Assistant instance. Click the **Security** tab. ![Home Assistant Profile Security Tab](../../_static/ha-profile-page.png) -#### Scroll down to the "Long-Lived Access Tokens" section and click "Create Token". +2. Scroll down to **Long-Lived Access Tokens** and click **Create Token**. ![Create Long-Lived Access Token](../../_static/ha-create-token.png) -#### Enter a name for the token (e.g., "Hassette") and click "OK". +3. Enter a name, for example `Hassette`, and click **OK**. ![Name Long-Lived Access Token](../../_static/ha-token-name.png) -#### Copy the generated token and store it securely. You won't be able to see it again! +4. Copy the token. Home Assistant shows it only once. If you lose it, revoke it from the Security tab and create a new one. ![Copy Long-Lived Access Token](../../_static/ha-copy-token.png) -## What to do with the token +## What to Do with the Token -Add it to `config/.env`: +Add the token to your `.env` file: ```bash --8<-- "pages/getting-started/snippets/env_token.sh" ``` -The [Quickstart](index.md) guide covers the rest of the configuration. +The [Quickstart](index.md) covers the full `.env` setup and how to start Hassette. The [Docker Setup](docker/index.md) covers container-specific configuration. -!!! warning "Keep your token secret" - The token has the same permissions as your Home Assistant user account. Never commit it to version control or share it publicly. If a token is exposed, revoke it immediately from the same Security tab and generate a new one. +!!! warning "Token security" + A long-lived access token has the same permissions as your Home Assistant user account. Never commit it to version control or share it publicly. If a token is exposed, revoke it immediately from the Security tab and generate a new one. diff --git a/docs/pages/getting-started/index.md b/docs/pages/getting-started/index.md index f6b9835cd..1ae82d55b 100644 --- a/docs/pages/getting-started/index.md +++ b/docs/pages/getting-started/index.md @@ -1,117 +1,91 @@ # Quickstart -This guide covers **local development**: running Hassette directly with Python and `uv`. If you're deploying to a server or want a containerized setup, use [Docker Deployment](docker/index.md) instead. +Install Hassette, write a one-file automation, and see it connect to Home Assistant. For a containerized setup, see [Docker Setup](docker/index.md) instead. ## Prerequisites -- **Python 3.11 or later** — Hassette requires Python 3.11+. Check your version with `python --version`. -- **uv** — this guide uses `uv` as the package manager. See the [uv installation guide](https://docs.astral.sh/uv/getting-started/installation/) for installation methods. +- **Python 3.11 or later** — check with `python --version`. +- **[uv](https://docs.astral.sh/uv/getting-started/installation/)** — this guide uses `uv` to install Hassette. +- **A running Home Assistant instance** — you'll need its URL and a long-lived access token. -## 1. Create a project and install Hassette - -`uv init` creates a Python project with a `pyproject.toml`, then `uv add` installs Hassette into it: +## 1. Install Hassette ```bash ---8<-- "pages/getting-started/snippets/install.sh" +uv tool install hassette ``` -## 2. Create a project layout - -From your new project directory: +## 2. Create a Home Assistant token -```bash ---8<-- "pages/getting-started/snippets/project_layout.sh" -``` +[Create a long-lived access token](ha_token.md) from the Home Assistant UI. -## 3. Create a Home Assistant token +## 3. Set up a project directory -Follow the steps in [Creating a Home Assistant token](ha_token.md). - -## 4. Create `config/.env` - -Create `config/.env`: +Create an empty directory with a `.env` file and an `apps/` folder: ```bash ---8<-- "pages/getting-started/snippets/env_file.sh" +mkdir -p my-hassette/apps && cd my-hassette ``` -!!! note "Double underscore in `HASSETTE__TOKEN`" - The double underscore (`__`) is required — it follows the Pydantic settings convention for nested configuration. `HASSETTE_TOKEN` (single underscore) will not be recognized and Hassette will start but fail to authenticate with Home Assistant. - -!!! warning "Security" - Never commit `.env` files to version control. - -## 5. Create `config/hassette.toml` - -Create `config/hassette.toml`: - -```toml ---8<-- "pages/getting-started/snippets/hassette.toml" +```bash +--8<-- "pages/getting-started/snippets/env_file.sh" ``` -Update `base_url` to match your Home Assistant instance. - -!!! note "TOML `[[double bracket]]` syntax" - The `[[apps.my_app.config]]` section uses TOML array-of-tables syntax, which is required for the `config` section in `hassette.toml`. Using single brackets `[apps.my_app.config]` will cause a configuration parse error. +Replace the token value with the one you created in step 2. Update `HASSETTE__BASE_URL` to match your Home Assistant instance. ---8<-- "pages/core-concepts/configuration/snippets/file_discovery.md" - -!!! tip - Run Hassette from your project directory and it will pick up `./config/hassette.toml` and `./config/.env` automatically. - -## 6. Create your first app - -Create `hassette_apps/main.py`: +## 4. Create your first app ```python --8<-- "pages/getting-started/snippets/first_app.py" ``` -!!! warning "Replace `light.porch` with a real entity" - The example uses `light.porch` — replace it with an entity that actually exists in your Home Assistant instance. You can find your entity IDs in the Home Assistant UI under **Developer Tools > States**. - -!!! note "Typed handlers" - This example uses a raw event for simplicity. Once you're comfortable, Hassette's [dependency injection](../core-concepts/bus/handlers.md) lets you write cleaner handlers with automatic type conversion: - - Add to your imports: `from hassette import D, states` - - ```python - --8<-- "pages/getting-started/snippets/typed_handler.py:typed-handler" - ``` - -## 7. Run Hassette +Save this as `apps/main.py`. Hassette discovers app classes in `apps/` automatically. -From your project directory: +## 5. Run Hassette ```bash ---8<-- "pages/getting-started/snippets/run.sh" +hassette run -e .env ``` -`uv run` executes the command inside the project's virtual environment, so the `hassette` CLI is available without manually activating the venv. - -Hassette is a long-running process. You should see output like: +You see output like: ``` --8<-- "pages/getting-started/snippets/run_output.txt" ``` -Lines 4 and 5 appear only at sunset — you may not see them immediately. +Hassette loaded your config and logged the greeting. Open a second terminal to confirm the connection: -The greeting comes from the `greeting` field in your `hassette.toml` — Hassette loaded your config and passed it to your app as `self.app_config.greeting`. When the sun sets, the app calls `self.api.turn_on()` to switch on a light — a complete automation in a few lines of Python. +```bash +hassette status +``` -If you need explicit paths: +```console +╭──────────────────── SystemStatusResponse ────────────────────╮ +│ status ok │ +│ websocket_connected True │ +│ uptime_seconds 4.21 │ +│ app_count 1 │ +╰──────────────────────────────────────────────────────────────╯ +``` ```bash ---8<-- "pages/getting-started/snippets/run_explicit.sh" +hassette app ``` +```console +┏━━━━━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━━━━┓ +┃ App Key ┃ Status ┃ Display ┃ Instances ┃ Invoc/1h ┃ Enabled ┃ File ┃ +┃ ┃ ┃ Name ┃ ┃ ┃ ┃ ┃ +┡━━━━━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━━━━┩ +│ my_app │ running │ MyApp │ 1 │ 0 │ True │ main.py │ +└─────────────┴─────────┴─────────────┴───────────┴──────────┴─────────┴────────────┘ +``` + +`websocket_connected: True` confirms the Home Assistant connection. `my_app` shows `running`. + !!! tip "Having trouble?" - If Hassette fails to start or can't connect to Home Assistant, see the [Troubleshooting](../troubleshooting.md) page — the most common issue is an incorrect `base_url` or missing token. + If Hassette fails to connect, check `HASSETTE__BASE_URL` in your `.env` and confirm the token is correct. See [Troubleshooting](../troubleshooting.md) for common issues. ## Next steps -- [Your First Automation](first-automation.md) — step-by-step tutorial explaining how the app pattern works -- [Web UI](../web-ui/index.md) — open `http://localhost:8126/ui/` to see the web UI -- [Apps Overview](../core-concepts/apps/index.md) — writing your first automation -- [Configuration Overview](../core-concepts/configuration/index.md) — config precedence, file locations, and options -- [Application Configuration](../core-concepts/configuration/applications.md) — registering and configuring apps +- [Your First Automation](first-automation.md) — subscribe to events, use dependency injection, and schedule jobs +- [Docker Setup](docker/index.md) — deploy Hassette in production with Docker Compose diff --git a/docs/pages/getting-started/is-hassette-right-for-you.md b/docs/pages/getting-started/is-hassette-right-for-you.md index a45b91c3e..0f26a07aa 100644 --- a/docs/pages/getting-started/is-hassette-right-for-you.md +++ b/docs/pages/getting-started/is-hassette-right-for-you.md @@ -1,3 +1,52 @@ # Is Hassette Right for You? -*This page is being rewritten as part of the documentation overhaul.* +Hassette is a Python framework for writing Home Assistant automations. It runs as a separate process and connects over the WebSocket API. Your existing YAML automations, scripts, and scenes keep working. Hassette is an additional layer, not a replacement. + +## When Hassette Makes Sense + +Hassette fits you if you write Python and your automations have grown past what YAML handles well. + +**Complex logic.** Multi-step sequences, accumulated state, and real branching are straightforward in Python. In YAML they require nested templates and workarounds. + +**Testability.** Hassette apps run in a test harness. You can simulate events, advance time, and assert outcomes without a running Home Assistant instance. If you want confidence before deploying an automation that controls locks or heat, this matters. + +**Code reuse.** Python lets you share logic across apps through ordinary functions, base classes, and modules. In YAML, reuse means copy-paste. + +If you have ever debugged a Jinja2 template by adding `{{ "got here" }}` in the middle, that is also a sign. + +## When HA YAML Is Enough + +YAML automations are built into Home Assistant and need no additional setup. For simple patterns they are the right tool. + +Use YAML when your automations are straightforward trigger-action rules. Turn on a light when motion is detected. Send a notification at sunrise. Run a scene when you arrive home. The Home Assistant UI can build and edit these without touching a file. + +YAML also makes sense if you prefer visual tools or are new to programming. Hassette requires Python. If you do not want to write code, YAML is the better path. The [Home Assistant automation docs](https://www.home-assistant.io/docs/automation/) cover what YAML can do. + +## Quick Comparison + +| | HA YAML | Hassette | +|---|---|---| +| **Language** | YAML + Jinja2 | Python 3.11+ | +| **Debugging** | HA trace viewer | Structured logs, web UI, Python debugger | +| **Testing** | Manual | `AppTestHarness`, event simulation, time control | +| **Version control** | Text files | Text files | +| **Learning curve** | Low to medium | Medium (Python + async basics) | +| **Complexity ceiling** | Medium | High | + +Hassette does not replace Home Assistant integrations, dashboards, or add-ons. It handles automations only. Use the HA UI for dashboards and integrations, and Hassette for the automation logic behind them. Your existing YAML automations run alongside Hassette apps with no conflicts. + +## What Hassette Requires + +**Python 3.11 or later.** Hassette uses modern Python features and will not run on older versions. + +**A machine to run the process.** Hassette runs outside Home Assistant. The same box works fine, or you can run it in Docker. It does not run as a Home Assistant add-on yet. The [Docker Setup guide](docker/index.md) covers the recommended path. + +**A long-lived access token.** Hassette connects to Home Assistant via a token you generate in your profile settings. It needs read and write access to call services and read state. + +**Recommended: comfort with `async`/`await`.** Hassette apps are async Python classes. You do not need to understand the event loop deeply, but `await` and `async def` appear in every example. Hassette also offers `AppSync` for writing synchronous apps, though the async API is what the docs focus on. + +## Next Steps + +Ready to try it? The [Quickstart](index.md) takes about five minutes and ends with a running automation. + +Coming from AppDaemon? The [Migration section](../migration/index.md) walks through the differences and shows how to convert common patterns. diff --git a/docs/pages/getting-started/snippets/env_file.sh b/docs/pages/getting-started/snippets/env_file.sh index 330f675c0..0d2fa6f4d 100644 --- a/docs/pages/getting-started/snippets/env_file.sh +++ b/docs/pages/getting-started/snippets/env_file.sh @@ -1,2 +1,3 @@ -# config/.env +# .env HASSETTE__TOKEN=your_long_lived_access_token_here +HASSETTE__BASE_URL=http://homeassistant.local:8123 diff --git a/docs/pages/getting-started/snippets/first_app.py b/docs/pages/getting-started/snippets/first_app.py index fa9e00626..c8b4c99f4 100644 --- a/docs/pages/getting-started/snippets/first_app.py +++ b/docs/pages/getting-started/snippets/first_app.py @@ -1,5 +1,4 @@ from hassette import App, AppConfig -from hassette.events import RawStateChangeEvent class MyAppConfig(AppConfig): @@ -9,18 +8,3 @@ class MyAppConfig(AppConfig): class MyApp(App[MyAppConfig]): async def on_initialize(self): self.logger.info(self.app_config.greeting) - await self.bus.on_state_change("sun.*", handler=self.on_sun_change, name="sun_change") - await self.scheduler.run_minutely(self.log_heartbeat) # first run fires after 1 minute - - async def on_sun_change(self, event: RawStateChangeEvent): - new_state = event.payload.data.new_state - value = new_state.get("state") if new_state else "unknown" - self.logger.info("Sun changed: %s", value) - - # Turn on porch light at sunset - if value == "below_horizon": - await self.api.turn_on("light.porch", domain="light") # Replace "light.porch" with a real entity from your Home Assistant - self.logger.info("Porch light turned on") - - async def log_heartbeat(self): - self.logger.info("Heartbeat") diff --git a/docs/pages/getting-started/snippets/first_automation_step3.py b/docs/pages/getting-started/snippets/first_automation_step3.py index 66a81774e..3c7c72076 100644 --- a/docs/pages/getting-started/snippets/first_automation_step3.py +++ b/docs/pages/getting-started/snippets/first_automation_step3.py @@ -8,12 +8,13 @@ class MyAppConfig(AppConfig): class MyApp(App[MyAppConfig]): async def on_initialize(self): self.logger.info(self.app_config.greeting) - await self.bus.on_state_change("sun.*", handler=self.on_sun_change, name="sun_change") + await self.bus.on_state_change( + "sun.*", handler=self.on_sun_change, name="sun_change" + ) async def on_sun_change(self, new_state: D.StateNew[states.SunState]): self.logger.info("Sun changed: %s", new_state.value) if new_state.value == "below_horizon": - # Replace "light.porch" with an entity from your HA instance await self.api.turn_on("light.porch", domain="light") self.logger.info("Porch light turned on") diff --git a/docs/pages/getting-started/snippets/first_automation_step4.py b/docs/pages/getting-started/snippets/first_automation_step4.py index b67606db1..ef3af0a5b 100644 --- a/docs/pages/getting-started/snippets/first_automation_step4.py +++ b/docs/pages/getting-started/snippets/first_automation_step4.py @@ -8,14 +8,15 @@ class MyAppConfig(AppConfig): class MyApp(App[MyAppConfig]): async def on_initialize(self): self.logger.info(self.app_config.greeting) - await self.bus.on_state_change("sun.*", handler=self.on_sun_change, name="sun_change") - await self.scheduler.run_minutely(self.log_heartbeat) # first run fires after 1 minute + await self.bus.on_state_change( + "sun.*", handler=self.on_sun_change, name="sun_change" + ) + await self.scheduler.run_minutely(self.log_heartbeat) async def on_sun_change(self, new_state: D.StateNew[states.SunState]): self.logger.info("Sun changed: %s", new_state.value) if new_state.value == "below_horizon": - # Replace "light.porch" with an entity from your HA instance await self.api.turn_on("light.porch", domain="light") self.logger.info("Porch light turned on") diff --git a/docs/pages/getting-started/snippets/run_output.txt b/docs/pages/getting-started/snippets/run_output.txt index bc7d7d85d..e79a2742d 100644 --- a/docs/pages/getting-started/snippets/run_output.txt +++ b/docs/pages/getting-started/snippets/run_output.txt @@ -1,5 +1,2 @@ INFO hassette ... ─ Connected to Home Assistant -INFO hassette.MyApp.0 ... ─ This is from the config file! -INFO hassette.MyApp.0 ... ─ Heartbeat -INFO hassette.MyApp.0 ... ─ Sun changed: below_horizon -INFO hassette.MyApp.0 ... ─ Porch light turned on +INFO hassette.MyApp.0 ... ─ Hello from Hassette! From a2c0d08a4b0396d888d6ea74a412b57d75492ec4 Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Tue, 2 Jun 2026 15:06:12 -0500 Subject: [PATCH 040/160] docs: rewrite Docker getting-started pages with JTBD outlines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Redesigned all four Docker pages (setup, dependencies, image tags, troubleshooting) using Jobs to Be Done framework instead of copying existing page structure. Docker Setup: 6 steps → 4 steps, removed production deployment and env var reference sections (belong in Operating/Configuration). Dependencies: 15 snippets → 5, removed startup script internals and APP_DIR vs PROJECT_DIR comparison. Lead with requirements.txt as the 80% path. Image Tags: stripped to two recommendations and an update command. Troubleshooting: 7 categories with 20+ sub-problems → 6 flat symptoms. Also: - Added symbol verification instructions (Serena MCP) to writing prompt template and reviewer prompt - Added symbol accuracy checklist items to docs-context.md - Saved doc IA prior art research brief - Rewrote all four Docker outlines with JTBD framework - Deleted 44 orphaned snippet files --- .../research.md | 157 +++++++++ design/specs/070-doc-overhaul/docs-context.md | 33 +- .../getting-started/docker-dependencies.md | 88 +++-- .../getting-started/docker-image-tags.md | 41 ++- .../outlines/getting-started/docker-setup.md | 72 ++-- .../getting-started/docker-troubleshooting.md | 94 ++---- .../writing-prompt-template.md | 42 +++ .../getting-started/docker/dependencies.md | 174 +++------- .../getting-started/docker/image-tags.md | 44 +-- docs/pages/getting-started/docker/index.md | 133 +++----- .../docker/snippets/custom-image-compose.yml | 6 - .../docker/snippets/custom-image.dockerfile | 20 -- .../docker/snippets/deps-app-using-package.py | 13 + .../docker/snippets/deps-example1-compose.yml | 15 - .../snippets/deps-example1-requirements.txt | 1 - .../docker/snippets/deps-example2-compose.yml | 16 - .../snippets/deps-example2-pyproject.toml | 8 - .../snippets/deps-flat-dir-structure.txt | 9 - .../docker/snippets/deps-install-deps-env.yml | 16 +- ...compose.yml => deps-pyproject-compose.yml} | 3 +- .../deps-requirements-dir-structure.txt | 3 - .../docker/snippets/deps-src-compose.yml | 17 - .../snippets/deps-src-dir-structure.txt | 12 - .../docker/snippets/deps-startup-flow.mmd | 24 -- .../docker/snippets/dir-structure.txt | 8 - .../docker/snippets/docker-compose-logs.sh | 8 - .../docker/snippets/docker-pull-update.sh | 3 +- .../docker/snippets/docker-version-check.sh | 1 - .../docker/snippets/env-file.sh | 2 + .../docker/snippets/prod-reload.toml | 3 - .../docker/snippets/pyproject-example.toml | 5 +- .../docker/snippets/requirements-example.txt | 4 +- .../docker/snippets/tag-format-latest.txt | 1 - .../docker/snippets/tag-format-main.txt | 1 - .../docker/snippets/tag-format-pr.txt | 1 - .../docker/snippets/tag-format-versioned.txt | 1 - .../docker/snippets/tag-pinned-compose.yml | 2 +- .../snippets/tag-prerelease-compose.yml | 3 - .../snippets/tag-prerelease-explicit.txt | 1 - .../docker/snippets/ts-app-config.toml | 4 - .../docker/snippets/ts-app-dir-src-env.yml | 2 - .../docker/snippets/ts-app-dir-toml.toml | 2 - .../docker/snippets/ts-cat-pyproject.sh | 1 - .../docker/snippets/ts-check-constraints.sh | 1 - .../docker/snippets/ts-check-logs-tail.sh | 1 - .../docker/snippets/ts-chmod.sh | 1 - .../docker/snippets/ts-curl-ha.sh | 3 +- .../docker/snippets/ts-dep-conflict.txt | 11 - .../docker/snippets/ts-dep-install-logs.sh | 2 +- .../docker/snippets/ts-diagnostics.sh | 11 - .../docker/snippets/ts-find-requirements.sh | 1 - .../docker/snippets/ts-grep-errors.sh | 2 +- .../snippets/ts-health-check-long-start.yml | 6 - .../docker/snippets/ts-health-check.sh | 1 - .../docker/snippets/ts-hot-reload.toml | 3 - .../docker/snippets/ts-ls-apps.sh | 2 +- .../docker/snippets/ts-memory-limit.yml | 7 - .../snippets/ts-pin-hassette-pyproject.toml | 5 - .../docker/snippets/ts-project-dir-env.yml | 2 - .../docker/snippets/ts-pyproject-dep.toml | 2 - .../docker/snippets/ts-uv-cache-vol.yml | 2 - .../docker/snippets/ts-uv-relock.sh | 6 - .../docker/snippets/ts-vol-mount.yml | 2 - .../docker/snippets/uv-cache-volume.yml | 2 - .../docker/snippets/uv-lock.sh | 3 - .../getting-started/docker/troubleshooting.md | 310 +++--------------- 66 files changed, 538 insertions(+), 942 deletions(-) create mode 100644 design/research/2026-06-02-doc-information-architecture/research.md delete mode 100644 docs/pages/getting-started/docker/snippets/custom-image-compose.yml delete mode 100644 docs/pages/getting-started/docker/snippets/custom-image.dockerfile create mode 100644 docs/pages/getting-started/docker/snippets/deps-app-using-package.py delete mode 100644 docs/pages/getting-started/docker/snippets/deps-example1-compose.yml delete mode 100644 docs/pages/getting-started/docker/snippets/deps-example1-requirements.txt delete mode 100644 docs/pages/getting-started/docker/snippets/deps-example2-compose.yml delete mode 100644 docs/pages/getting-started/docker/snippets/deps-example2-pyproject.toml delete mode 100644 docs/pages/getting-started/docker/snippets/deps-flat-dir-structure.txt rename docs/pages/getting-started/docker/snippets/{deps-flat-compose.yml => deps-pyproject-compose.yml} (76%) delete mode 100644 docs/pages/getting-started/docker/snippets/deps-requirements-dir-structure.txt delete mode 100644 docs/pages/getting-started/docker/snippets/deps-src-compose.yml delete mode 100644 docs/pages/getting-started/docker/snippets/deps-src-dir-structure.txt delete mode 100644 docs/pages/getting-started/docker/snippets/deps-startup-flow.mmd delete mode 100644 docs/pages/getting-started/docker/snippets/dir-structure.txt delete mode 100644 docs/pages/getting-started/docker/snippets/docker-compose-logs.sh delete mode 100644 docs/pages/getting-started/docker/snippets/docker-version-check.sh delete mode 100644 docs/pages/getting-started/docker/snippets/prod-reload.toml delete mode 100644 docs/pages/getting-started/docker/snippets/tag-format-latest.txt delete mode 100644 docs/pages/getting-started/docker/snippets/tag-format-main.txt delete mode 100644 docs/pages/getting-started/docker/snippets/tag-format-pr.txt delete mode 100644 docs/pages/getting-started/docker/snippets/tag-format-versioned.txt delete mode 100644 docs/pages/getting-started/docker/snippets/tag-prerelease-compose.yml delete mode 100644 docs/pages/getting-started/docker/snippets/tag-prerelease-explicit.txt delete mode 100644 docs/pages/getting-started/docker/snippets/ts-app-config.toml delete mode 100644 docs/pages/getting-started/docker/snippets/ts-app-dir-src-env.yml delete mode 100644 docs/pages/getting-started/docker/snippets/ts-app-dir-toml.toml delete mode 100644 docs/pages/getting-started/docker/snippets/ts-cat-pyproject.sh delete mode 100644 docs/pages/getting-started/docker/snippets/ts-check-constraints.sh delete mode 100644 docs/pages/getting-started/docker/snippets/ts-check-logs-tail.sh delete mode 100644 docs/pages/getting-started/docker/snippets/ts-chmod.sh delete mode 100644 docs/pages/getting-started/docker/snippets/ts-dep-conflict.txt delete mode 100644 docs/pages/getting-started/docker/snippets/ts-diagnostics.sh delete mode 100644 docs/pages/getting-started/docker/snippets/ts-find-requirements.sh delete mode 100644 docs/pages/getting-started/docker/snippets/ts-health-check-long-start.yml delete mode 100644 docs/pages/getting-started/docker/snippets/ts-health-check.sh delete mode 100644 docs/pages/getting-started/docker/snippets/ts-hot-reload.toml delete mode 100644 docs/pages/getting-started/docker/snippets/ts-memory-limit.yml delete mode 100644 docs/pages/getting-started/docker/snippets/ts-pin-hassette-pyproject.toml delete mode 100644 docs/pages/getting-started/docker/snippets/ts-project-dir-env.yml delete mode 100644 docs/pages/getting-started/docker/snippets/ts-pyproject-dep.toml delete mode 100644 docs/pages/getting-started/docker/snippets/ts-uv-cache-vol.yml delete mode 100644 docs/pages/getting-started/docker/snippets/ts-uv-relock.sh delete mode 100644 docs/pages/getting-started/docker/snippets/ts-vol-mount.yml delete mode 100644 docs/pages/getting-started/docker/snippets/uv-cache-volume.yml delete mode 100644 docs/pages/getting-started/docker/snippets/uv-lock.sh diff --git a/design/research/2026-06-02-doc-information-architecture/research.md b/design/research/2026-06-02-doc-information-architecture/research.md new file mode 100644 index 000000000..943ad6d68 --- /dev/null +++ b/design/research/2026-06-02-doc-information-architecture/research.md @@ -0,0 +1,157 @@ +--- +topic: "documentation-information-architecture" +date: 2026-06-02 +status: Draft +--- + +# Prior Art: Documentation Information Architecture + +## The Problem + +When designing the structure of developer docs, teams naturally mirror their code's module hierarchy: one page per component, headings that match class names, navigation that follows the import graph. This feels complete to the authors but produces docs that are hard to navigate for readers who arrive with a task ("how do I schedule a job?"), not a module name ("what does `hassette.scheduler.triggers` contain?"). + +The question is: what frameworks exist for designing doc structure around reader goals instead of code structure? And specifically, how should a doc-architect agent think about redesigning outlines from scratch? + +## How We Do It Today + +Hassette's doc structure is reader-journey-first at the *nav level* — the section ordering (Getting Started -> Core Concepts -> Web UI -> CLI -> Recipes -> Testing -> Migration -> Troubleshooting) follows a natural progression. Page templates exist for different types (concept, recipe, getting-started, reference). But at the *page level*, 28 of 56 outlines are structural copies of existing pages — the code-mirror anti-pattern crept in during outline creation even though the high-level architecture avoids it. + +## Patterns Found + +### Pattern 1: Diataxis — Four Documentation Types + +**Used by**: Django, NumPy, Cloudflare, Sequin, Canonical/Ubuntu + +**How it works**: All documentation falls on two axes — theory vs. practice, and learning vs. working — producing four types: tutorials (learning by doing), how-to guides (working by doing), reference (working by knowing), and explanation (learning by knowing). Each type has distinct quality criteria. Tutorials must be completable by a beginner following literally. How-to guides assume competence and address a real-world goal. Reference is austere and complete. Explanation is the only place for opinion, context, and "why." + +The framework's strongest contribution is *diagnostic*: when a page feels wrong, it's usually mixing types. A tutorial that pauses to explain architecture theory loses momentum. A reference page with step-by-step instructions confuses readers looking for a quick fact. + +**Strengths**: Simple, widely adopted, diagnostic power for identifying type-mixing. Provides clear "what goes where" rules. + +**Weaknesses**: Doesn't cover all doc types (troubleshooting, migration guides, changelogs). Doesn't address page-level structure or cross-page navigation. Teams sometimes force-fit content into quadrants. + +**Example**: https://diataxis.fr/ + +### Pattern 2: Goal-Oriented Navigation (Stripe Model) + +**Used by**: Stripe, Twilio, Plaid + +**How it works**: Top-level nav reflects what developers want to accomplish ("Accept a payment"), not API surface. Each goal-oriented section contains its own quickstart, explanation, and API reference. Code samples are contextualized (shown with surrounding application code) and personalized. + +**Strengths**: Extremely effective for API products where developers arrive with a task. Reduces time-to-first-integration. + +**Weaknesses**: Expensive to maintain — same API endpoint appears in multiple guides. Better for API products than frameworks. Requires a documentation team or strong engineering culture around docs. + +**Example**: https://docs.stripe.com/payments + +### Pattern 3: Persona + Jobs to Be Done + +**Used by**: GitBook methodology, Adobe, enterprise doc teams + +**How it works**: Define 2-4 reader personas, then identify their Jobs to Be Done — not who they are, but what they need right now. Map each job to a documentation path. The same person uses different parts of the docs depending on their current job (evaluating vs. integrating vs. debugging at 2am). + +**Strengths**: Reveals structural gaps that topic-based organization misses. Produces docs that feel written for the reader's specific situation. + +**Weaknesses**: Requires actual user research for accurate personas. Teams that guess personas based on internal assumptions reproduce the codebase-mirror anti-pattern with extra steps. + +**Example**: https://gitbook.com/docs/guides/docs-workflow-optimization/documentation-personas + +### Pattern 4: Progressive Disclosure with Tiered Complexity + +**Used by**: Svelte, Vue, React + +**How it works**: Documentation in concentric rings of complexity. Outermost ring (getting started) assumes nothing and teaches the minimum viable subset. Next ring (core concepts) covers the 80% case. Inner rings (advanced, internals) cover edge cases and framework internals. The key decision is what goes in the outermost ring — the best implementations identify one "aha moment" and optimize the outer ring to reach it fast. + +**Strengths**: Matches natural learning progression. Readers self-select their depth. Works well for frameworks. + +**Weaknesses**: Requires judgment about the "80% case." Teams often include too much in the getting-started tier. + +**Example**: https://svelte.dev/docs + +### Pattern 5: Documentation Journeys (Narrative Paths) + +**Used by**: Adobe Experience Manager + +**How it works**: Curated narrative paths through existing documentation — not new pages but an overlay. A sequence of links to existing pages stitched together with transitional prose. Solves "I don't know what I don't know" without restructuring existing content. + +**Strengths**: Doesn't require restructuring existing docs. Adds guided paths on top of reference material. + +**Weaknesses**: Expensive to maintain. Links break silently when underlying pages change. Better for large enterprise products than small dev tools. + +**Example**: https://experienceleague.adobe.com/en/docs/experience-manager-cloud-service/content/overview/documentation-journeys + +## Anti-Patterns + +- **Codebase-mirror structure**: Organizing docs to match the source code module hierarchy. Internal teams fall into this because they think about the product in terms of its code structure. Card sorting with external users is the primary remedy. *(Cited: Fern IA guide, Smashing Magazine card sorting guide)* + +- **Explanation-first ordering**: Leading with "how it works" before the reader has touched the product. Engineers find architecture interesting; readers find it tedious. "Starting with explanatory content felt like a chore to readers and was like asking them to study for a test." *(Cited: Sequin blog, Diataxis framework)* + +- **Type-mixing within pages**: A single page that starts as a tutorial, detours into explanation, includes reference tables, and ends with how-to steps. The reader can't predict what they'll find. Diataxis identifies this as the most common cause of confusing docs. *(Cited: Diataxis, Tom Johnson)* + +- **Deep nesting**: More than two levels of nav depth. "Only create a maximum two levels of subpages — any more and things can become confusing." *(Cited: GitBook structure guide)* + +## Emerging Trends + +**AI as documentation consumer**: 70% of documentation teams now factor AI into IA decisions (GitBook State of Docs 2026). The structural practices that help AI (clear headings, single-topic pages, explicit scope markers) are the same ones that help human readers. + +**Documentation as product**: Stripe's model — doc quality affects promotions, custom tooling, writing classes for engineers — is spreading. Structure optimized for reader outcomes, not author convenience. + +## Relevance to Us + +Hassette's existing nav already follows Progressive Disclosure (Pattern 4) — the section ordering is reader-journey-first. The voice guide already draws from Svelte. These are strengths. + +The gap is at the *outline/page level*. The outlines were created by reading existing pages and transcribing their heading structure — the codebase-mirror anti-pattern at the page level, even though the nav avoids it at the section level. The result: 28 of 56 outlines are structural copies. + +The most actionable patterns for redesigning outlines: + +1. **Diataxis as diagnostic lens** — for each page, ask: "is this a tutorial, how-to, reference, or explanation?" If the answer is "all of them," the page needs splitting. This is already partially reflected in the page templates (concept pages, recipe pages, getting-started pages), but the outlines don't enforce it. + +2. **JTBD per page** — before writing an outline, answer: "What job is the reader doing when they land on this page? What do they need to know to complete that job?" The outline should cover exactly that, nothing more. A reader on the Bus filtering page has a different job than a reader on the Bus overview page. + +3. **Progressive disclosure within pages** — simplest case first, advanced in collapsible sections or linked pages. The existing outlines often present features in API order (method by method) rather than complexity order. + +4. **Anti-codebase-mirror check** — for each outline, ask: "Would a user group these concepts this way, or only someone who has read the source code?" If the outline's H2s map 1:1 to class methods, it's a reference page pretending to be a concept page. + +## Recommendation + +**Adopt Diataxis as a diagnostic lens + JTBD per page** as the framework for the doc-architect agent. Not rigid Diataxis quadrants (Hassette already has its own page types that work), but the diagnostic question: "What type is this page? Is it mixing types?" combined with "What job is the reader doing here?" + +The practical workflow for redesigning an outline: +1. Name the page type (concept / recipe / getting-started / reference / troubleshooting / migration) +2. State the reader's job in one sentence ("Understand how the bus filters events" or "Set up a motion-triggered light") +3. List what the reader needs to know to complete that job — and nothing else +4. Order by complexity (simplest first), not by API surface +5. Check: would a user organize this page this way, or only a developer who has read the source? + +This is lightweight enough for a subagent to execute per-page, and the diagnostic questions are concrete enough to produce different outlines from the copy-paste approach. + +Card sorting (Pattern 7 from web research) would be ideal for validating the new structure, but requires access to real users. For now, the JTBD + Diataxis diagnostic is the best available proxy. + +## Sources + +### Frameworks & standards +- https://diataxis.fr/ — Diataxis framework (four documentation types) +- https://buildwithfern.com/post/information-architecture-best-practices-documentation — Fern's IA guide (architecture tiers) + +### Reference implementations +- https://docs.stripe.com/payments — Stripe goal-oriented docs +- https://svelte.dev/docs — Svelte progressive disclosure +- https://experienceleague.adobe.com/en/docs/experience-manager-cloud-service/content/overview/documentation-journeys — Adobe documentation journeys + +### Blog posts & writeups +- https://idratherbewriting.com/blog/what-is-diataxis-documentation-framework — Tom Johnson's Diataxis review +- https://blog.sequinstream.com/we-fixed-our-documentation-with-the-diataxis-framework/ — Sequin's Diataxis case study +- https://www.moesif.com/blog/best-practices/api-product-management/the-stripe-developer-experience-and-docs-teardown/ — Moesif Stripe docs teardown +- https://apidog.com/blog/stripe-docs/ — Apidog Stripe docs analysis +- https://dev.to/erikaheidi/information-architecture-and-content-planning-for-documentation-websites-2cg6 — IA planning for doc sites +- https://docsbydesign.com/2026/02/15/what-makes-documentation-ai-ready-structure/ — AI-ready documentation structure + +### Methodology & guides +- https://gitbook.com/docs/guides/docs-best-practices/documentation-structure-tips — GitBook structural constraints +- https://gitbook.com/docs/guides/docs-workflow-optimization/documentation-personas — GitBook persona + JTBD methodology +- https://github.blog/developer-skills/documentation-done-right-a-developers-guide/ — GitHub docs guide +- https://www.nngroup.com/articles/card-sorting-definition/ — NNGroup card sorting reference +- https://www.smashingmagazine.com/2014/10/improving-information-architecture-card-sorting-beginners-guide/ — Card sorting tutorial + +### Industry reports +- https://www.gitbook.com/blog/state-of-docs-2026 — GitBook State of Docs 2026 diff --git a/design/specs/070-doc-overhaul/docs-context.md b/design/specs/070-doc-overhaul/docs-context.md index ec88cc5aa..86c1f2214 100644 --- a/design/specs/070-doc-overhaul/docs-context.md +++ b/design/specs/070-doc-overhaul/docs-context.md @@ -23,31 +23,36 @@ Run every item on every page before marking a writing task complete. Each item i 5. **Every limitation paired with a path forward.** One sentence for the constraint, one naming the alternative. *(Rule 4)* 6. **Module aliases linked on first use.** First use of `D.*`, `states.*`, `C.*`, `P.*`, or `A.*` links to the canonical page for that module. *(FR#6)* +### Symbol accuracy (all page types) + +7. **Every referenced symbol exists in the codebase.** Method names, parameter names, class names, and type annotations must match the actual source. Grep to verify. *(Writer/Reviewer instruction)* +8. **Imports use top-level paths.** `from hassette import App, AppConfig, D, states` — not `from hassette.models import states` or `from hassette import dependencies as D`. *(FR#6)* + ### Getting-started pages -7. **"you" and "your" used throughout.** NOT system-as-subject. Direct address. *(Rule 17)* -8. **Code shown FIRST, then explained (HARD RULE).** The code block must be the very first element after the H2 heading. No introductory sentences, no motivational preamble ("Hard-coding values makes reuse difficult..."), no scene-setting. Explanation follows the code. The only exception is "What You'll Build" which has no code. *(Rule 17)* -9. **Show concrete CLI output.** When a step involves running a command, show the exact mock terminal output the reader will see. Don't say "look for X in the output" — show the output. -10. **No `---` horizontal rules between sections.** Headings provide enough visual separation. -11. **Link module aliases inline at first use.** When introducing `D`, `states`, `self.bus`, `self.scheduler`, or `self.api`, the first mention links to the canonical page inline, not in a deferred "See also" sentence. *(Rule 8, checklist #6)* +9. **"you" and "your" used throughout.** NOT system-as-subject. Direct address. *(Rule 17)* +10. **Code shown FIRST, then explained (HARD RULE).** The code block must be the very first element after the H2 heading. No introductory sentences, no motivational preamble ("Hard-coding values makes reuse difficult..."), no scene-setting. Explanation follows the code. The only exception is "What You'll Build" which has no code. *(Rule 17)* +11. **Show concrete CLI output.** When a step involves running a command, show the exact mock terminal output the reader will see. Don't say "look for X in the output" — show the output. +12. **No `---` horizontal rules between sections.** Headings provide enough visual separation. +13. **Link module aliases inline at first use.** When introducing `D`, `states`, `self.bus`, `self.scheduler`, or `self.api`, the first mention links to the canonical page inline, not in a deferred "See also" sentence. *(Rule 8, checklist #6)* ### Concept and API reference pages -12. **System-as-subject throughout — no "you."** "The bus delivers events" not "you receive events." "your" is also banned. *(Rule 10)* -13. **No imperative mood.** No "Use X", "Pass Y", "Set Z." Use declarative: "X provides", "Y accepts", "Z controls." *(Rule 15)* -14. **Concept introductions follow name -> define -> show -> constrain.** Definition says what it does. Code example is minimal. Constraints come after. *(Rule 16)* +14. **System-as-subject throughout — no "you."** "The bus delivers events" not "you receive events." "your" is also banned. *(Rule 10)* +15. **No imperative mood.** No "Use X", "Pass Y", "Set Z." Use declarative: "X provides", "Y accepts", "Z controls." *(Rule 15)* +16. **Concept introductions follow name -> define -> show -> constrain.** Definition says what it does. Code example is minimal. Constraints come after. *(Rule 16)* ### Recipe pages -15. **"How It Works" uses flowing prose paragraphs, NOT bullet lists with bolded lead-ins.** Each paragraph covers one decision. No `- **method_name** does X` patterns. *(Rule 21)* -16. **"How It Works" uses system-as-subject.** The code is the subject when explaining behavior. "you" belongs only in procedural steps and variations. *(Rule 10, 21)* -17. **"Verify it's working" names a concrete command or UI action.** `hassette log --app `, Handlers tab, or similar — not a theoretical description. *(FR#4)* +17. **"How It Works" uses flowing prose paragraphs, NOT bullet lists with bolded lead-ins.** Each paragraph covers one decision. No `- **method_name** does X` patterns. *(Rule 21)* +18. **"How It Works" uses system-as-subject.** The code is the subject when explaining behavior. "you" belongs only in procedural steps and variations. *(Rule 10, 21)* +19. **"Verify it's working" names a concrete command or UI action.** `hassette log --app `, Handlers tab, or similar — not a theoretical description. *(FR#4)* ### Reference pages (addendum) -18. **Tables before prose in reference sections.** The table is the primary content; prose supplements. -19. **Terse functional definitions in table cells.** No narrative. Each cell says what the thing does in one sentence. -20. **No admonitions in reference tables.** Tips, warnings, and notes belong outside the table. +20. **Tables before prose in reference sections.** The table is the primary content; prose supplements. +21. **Terse functional definitions in table cells.** No narrative. Each cell says what the thing does in one sentence. +22. **No admonitions in reference tables.** Tips, warnings, and notes belong outside the table. ## Top Violations diff --git a/design/specs/070-doc-overhaul/outlines/getting-started/docker-dependencies.md b/design/specs/070-doc-overhaul/outlines/getting-started/docker-dependencies.md index 068a4a888..8a243a73e 100644 --- a/design/specs/070-doc-overhaul/outlines/getting-started/docker-dependencies.md +++ b/design/specs/070-doc-overhaul/outlines/getting-started/docker-dependencies.md @@ -2,69 +2,57 @@ **Status:** Rewrite from blank **Voice mode:** Getting-started — "you" allowed, procedural +**Page type:** How-to +**Reader's job:** Add a Python package their apps need +**One sentence:** "I need `httpx` in my app — how do I get it into the container?" -## Outline - -### H2: Overview -How Hassette's Docker entrypoint handles dependency installation at startup. Brief mental model. +## What was cut (and where it goes) -#### H3: How Constraints Work -Hassette pins its own deps via constraints file to prevent conflicts. +The original outline had 15 snippets, a Mermaid diagram of the startup script, +APP_DIR vs PROJECT_DIR comparison, two project structure layouts, performance +tips, and constraints file internals. The reader's job is "add a package" — they +don't need to understand the startup script to do that. -### H2: How the Startup Script Works -What happens at container start: detect project type, install deps, launch. **`HASSETTE__INSTALL_DEPS=1`** must be set to activate dependency installation — without it, no requirements/pyproject install runs. +Startup script internals, constraints, APP_DIR vs PROJECT_DIR, and performance +tuning belong in an Operating or Advanced Docker page. -#### H3: Key Behaviors -Bulleted list of the script's decisions. +## Outline -### H2: Understanding APP_DIR vs PROJECT_DIR -When to use which env var. Canonical env var: `HASSETTE__APPS__DIRECTORY` (legacy fallback: `HASSETTE__APP_DIR`). Table or short comparison. +### H2: Using requirements.txt +The simple path. Create `requirements.txt` in your project, add packages, +set `HASSETTE__INSTALL_DEPS=1` in compose, restart. Show the 3 files +(requirements.txt, compose snippet with env var, the app that imports the package). -### H2: Project Structures -#### H3: Simple Flat Structure -Directory layout, compose snippet. -#### H3: Traditional src/ Layout -Directory layout, compose snippet. +This is the 80% case. Lead with it. ### H2: Using pyproject.toml -#### H3: With a Lock File (Required) -How uv.lock works in Docker context. +For projects that already have a pyproject.toml. Needs a `uv.lock` file. +Show: run `uv lock` locally, mount the project dir, set `HASSETTE__PROJECT_DIR`. -### H2: Using requirements.txt -Simpler alternative, when to use it. - -### H2: Startup Performance -#### H3: Using uv.lock for Faster Starts -#### H3: Known Limitations — Local Path Dependencies +### H2: Known Limitations +- Local path dependencies (`file:///...`) don't work inside Docker +- First startup with new deps is slower (subsequent starts use the uv cache volume) -Pre-building a custom image is omitted — CLI tooling for this is planned. - -### H2: Complete Examples -Two full examples with compose + project structure. +Link to Troubleshooting for "dependency installation fails" problems. ## Snippet Inventory -All existing snippets (20+) are keeps — they demonstrate specific compose configurations and project structures. Full list: - -| Snippet | Status | -|---|---| -| `deps-example1-compose.yml` | Keep | -| `deps-example1-requirements.txt` | Keep | -| `deps-example2-compose.yml` | Keep | -| `deps-example2-pyproject.toml` | Keep | -| `deps-flat-compose.yml` | Keep | -| `deps-flat-dir-structure.txt` | Keep | -| `deps-install-deps-env.yml` | Keep | -| `deps-requirements-dir-structure.txt` | Keep | -| `deps-src-compose.yml` | Keep | -| `deps-src-dir-structure.txt` | Keep | -| `deps-startup-flow.mmd` | Keep | -| `custom-image-compose.yml` | Keep | -| `custom-image.dockerfile` | Keep | -| `pyproject-example.toml` | Keep | -| `requirements-example.txt` | Keep | +| Snippet | Status | Notes | +|---|---|---| +| `requirements-example.txt` | Keep | Simple requirements file | +| `deps-install-deps-env.yml` | Keep | Compose with INSTALL_DEPS | +| `pyproject-example.toml` | Keep | pyproject.toml example | +| `deps-startup-flow.mmd` | Drop | Internals, not needed for the job | +| `deps-flat-compose.yml` | Drop | Redundant with main compose | +| `deps-flat-dir-structure.txt` | Drop | Unnecessary | +| `deps-src-compose.yml` | Drop | Advanced layout, not getting-started | +| `deps-src-dir-structure.txt` | Drop | Unnecessary | +| `deps-requirements-dir-structure.txt` | Drop | Unnecessary | +| `deps-example1-*` | Drop | Over-engineered for this page | +| `deps-example2-*` | Drop | Over-engineered for this page | +| `custom-image-*` | Drop | Future feature | ## Cross-Links -- **Links to:** Docker Setup, Image Tags -- **Linked from:** Docker Setup (next steps), Docker Troubleshooting +- **Links to:** Docker Setup, Troubleshooting +- **Linked from:** Docker Setup (next steps) diff --git a/design/specs/070-doc-overhaul/outlines/getting-started/docker-image-tags.md b/design/specs/070-doc-overhaul/outlines/getting-started/docker-image-tags.md index dab4f276b..df0624248 100644 --- a/design/specs/070-doc-overhaul/outlines/getting-started/docker-image-tags.md +++ b/design/specs/070-doc-overhaul/outlines/getting-started/docker-image-tags.md @@ -2,41 +2,40 @@ **Status:** Rewrite from blank **Voice mode:** Getting-started — "you" allowed, brief, decision-oriented +**Page type:** Reference (minimal) +**Reader's job:** Pick the right tag for their compose file +**One sentence:** "What do I put after the colon in `ghcr.io/...hassette:`?" -## Outline +## What was cut -### H2: Tag Format -Brief explanation of the naming convention. One recommended tag example. +The original outline had tag format specs, a Python version table, and an +update procedure section. The reader needs a recommendation, not a specification. +Tag format details and version matrix belong in a reference page if they're +needed at all. -### H2: Recommended Tags -#### H3: Production — pin version + Python (e.g., `v0.35.0-py3.13`) -#### H3: Development — track latest stable (e.g., `latest-py3.13`) +## Outline -### H2: Supported Python Versions -Short table: 3.11, 3.12, 3.13, 3.14. Note upper bound (<3.15). +### H2: Which Tag to Use +Two recommendations, that's it: -### H2: Updating Images -`docker compose pull` + restart. +- **Production:** `v0.X.Y-py3.13` — pinned version, predictable +- **Development:** `latest-py3.13` — tracks the latest stable release -Removed from this page (too detailed for getting-started): -- PR preview tags and bleeding-edge main branch tags -- "Tags NOT Published" section -- "Choosing a Tag" decision matrix (production/development/pre-release) -- Separate "Check Current Version" section +Show a compose snippet for each. One sentence explaining the difference. -If needed later, these could live in an Operating or Reference page. +### H2: Updating +`docker compose pull && docker compose up -d`. Two lines. ## Snippet Inventory | Snippet | Status | Notes | |---|---|---| -| `tag-pinned-compose.yml` | Keep | Primary recommendation | -| `tag-latest-compose.yml` | Keep | Development alternative | +| `tag-pinned-compose.yml` | Keep | Production recommendation | +| `tag-latest-compose.yml` | Keep | Dev recommendation | | `docker-pull-update.sh` | Keep | Update command | -| `tag-format-versioned.txt` | Review | May fold into prose | -| Others | Drop or defer | PR/main/prerelease tags not needed here | +| `tag-format-versioned.txt` | Drop | Specification detail, not needed | ## Cross-Links -- **Links to:** Docker Setup, Dependencies +- **Links to:** Docker Setup - **Linked from:** Docker Setup (next steps) diff --git a/design/specs/070-doc-overhaul/outlines/getting-started/docker-setup.md b/design/specs/070-doc-overhaul/outlines/getting-started/docker-setup.md index 4e5c53307..021a80908 100644 --- a/design/specs/070-doc-overhaul/outlines/getting-started/docker-setup.md +++ b/design/specs/070-doc-overhaul/outlines/getting-started/docker-setup.md @@ -2,57 +2,63 @@ **Status:** Rewrite from blank **Voice mode:** Getting-started — "you" allowed, step-by-step +**Page type:** Tutorial +**Reader's job:** Get Hassette running in a Docker container +**Aha moment:** `docker compose up` prints "Connected to Home Assistant" + +## What was cut (and where it goes) + +The original outline included Production Deployment (hot reload, graceful shutdown), +an env var reference table, and directory structure details. These are operations and +reference content — they belong in Operating or Configuration pages, not a tutorial. + +The reader landing here has one job: get a container running and see it connect. Everything +else is a distraction from that job. ## Outline ### H2: Prerequisites -Docker, Docker Compose, running HA instance. Brief. +One-liner: Docker, Docker Compose, running HA, a token. Link to ha_token.md. ### H2: Quick Start -Numbered steps: create directory, docker-compose.yml, .env, hassette.toml, first app, `docker compose up`. Each step produces visible progress. +Four steps (not six — the original had unnecessary ceremony): -### H2: Directory Structure -Show the resulting project layout after Quick Start. +1. **Create the project** — `mkdir` the directory, create `apps/`, `config/`, `data/` +2. **Create `docker-compose.yml`** — minimal compose file with the four mounts +3. **Create `config/.env`** — token + base_url +4. **Start it** — `docker compose up -d`, check logs, see "Connected to Home Assistant" -### H2: Configuration -#### H3: Home Assistant Token -Link to ha_token.md, show where it goes in .env. -#### H3: Environment Variables Reference -Table of HASSETTE__* env vars. +That's it. The reader has a running container. -### H2: Production Deployment -#### H3: Hot Reloading in Production -hassette.toml `file_watcher.watch_files` setting (and `allow_reload_in_prod` guard), volume mount requirements. -#### H3: Graceful Shutdown -Docker stop signal handling. - -### H2: Viewing Logs -#### H3: Docker Compose Logs -`docker compose logs -f hassette` command. -#### H3: Web UI -Link to Web UI overview. +### H2: Write Your First App +Brief: create `apps/my_app.py` with the same minimal app from the local quickstart. +Restart the container, see the greeting in logs. Then link to First Automation +for the real stuff. ### H2: Next Steps -→ Dependencies, → Image Tags, → First Automation +- First Automation — subscribe to events, control devices +- Managing Dependencies — add Python packages +- Image Tags — pick a production-ready tag +- Troubleshooting — if something went wrong ## Snippet Inventory | Snippet | Status | Notes | |---|---|---| -| `docker-compose.yml` | Keep | Main compose file | -| `env-file.sh` | Keep | .env creation | -| `hassette.toml` | Keep | Minimal config | -| `my_app.py` | Keep | Example app | -| `dir-structure.txt` | Keep | Layout diagram | -| `docker-compose-up.sh` | Keep | Start command | -| `docker-compose-logs.sh` | Keep | Log viewing | -| `docker-compose-logs-hassette.sh` | Keep | Filtered logs | -| `prod-reload.toml` | Keep | Hot reload config | | `mkdir-project.sh` | Keep | Directory creation | -| `uv-cache-volume.yml` | Keep | Cache volume mount | -| `uv-lock.sh` | Keep | Lock file generation | +| `docker-compose.yml` | Keep | Minimal compose file | +| `env-file.sh` | Keep | .env with token + url | +| `my_app.py` | Keep | Minimal app | +| `docker-compose-up.sh` | Keep | Start command | +| `docker-compose-logs-hassette.sh` | Keep | Check logs | +| `hassette.toml` | Drop | Not needed for minimal setup if base_url is in .env | +| `dir-structure.txt` | Drop | Unnecessary — the steps show the structure | +| `docker-compose-logs.sh` | Drop | Redundant with hassette-filtered version | +| `prod-reload.toml` | Move to Operating | Production config, not getting-started | +| `uv-cache-volume.yml` | Move to Dependencies | Optimization, not getting-started | +| `uv-lock.sh` | Move to Dependencies | | ## Cross-Links -- **Links to:** HA Token, Dependencies, Image Tags, Docker Troubleshooting, Web UI, First Automation +- **Links to:** HA Token, First Automation, Dependencies, Image Tags, Troubleshooting - **Linked from:** Quickstart (alternative path), Evaluator diff --git a/design/specs/070-doc-overhaul/outlines/getting-started/docker-troubleshooting.md b/design/specs/070-doc-overhaul/outlines/getting-started/docker-troubleshooting.md index 6d8868fbf..f5d345144 100644 --- a/design/specs/070-doc-overhaul/outlines/getting-started/docker-troubleshooting.md +++ b/design/specs/070-doc-overhaul/outlines/getting-started/docker-troubleshooting.md @@ -2,81 +2,53 @@ **Status:** Rewrite from blank **Voice mode:** Getting-started — "you" allowed, problem/solution format +**Page type:** Troubleshooting +**Reader's job:** Fix their broken Docker setup +**One sentence:** "It's not working — what do I check?" -## Outline +## What was cut -Pure symptom-lookup for Docker-specific issues. Each H2 is a symptom category, each H3 is a specific problem with cause and fix. +The original outline had 7 H2 categories with 20+ H3 sub-problems and 25 +snippets. Most readers hit 2-3 problems total. A getting-started troubleshooting +page should cover the top problems a new user encounters, not every possible +Docker failure mode. Exhaustive troubleshooting belongs in an Operating page. -### H2: Container Won't Start -#### H3: Check the Logs -#### H3: Token Not Set -#### H3: Can't Reach Home Assistant -#### H3: Permission Errors +## Outline -### H2: Apps Not Loading -#### H3: Check App Discovery -#### H3: Verify App Directory Configuration -#### H3: Check for Python Errors -#### H3: Verify App Configuration +Each H2 is a symptom the reader sees. Each has: what to check, the likely +cause, the fix. No sub-categories — flat list, scannable. -### H2: Dependency Installation Fails -#### H3: Check Installation Output -#### H3: Dependency Conflicts -#### H3: pyproject.toml Not Found -#### H3: Project Has pyproject.toml But Dependencies Don't Install -#### H3: requirements.txt Not Found -#### H3: Version Conflicts -#### H3: Import Errors at Runtime +### H2: Container Exits Immediately +Check logs. Most common: token not set, can't reach HA, wrong base_url. -### H2: Health Check Failing -Symptoms, solutions. +### H2: "Connected" But Apps Don't Load +Check app directory mount, check for Python syntax errors in app files. -### H2: Hot Reload Not Working -Requirements, configuration, volume mount verification. +### H2: Dependencies Won't Install +HASSETTE__INSTALL_DEPS not set, or requirements.txt not in the mounted path. -### H2: Import Errors -#### H3: Package Not Found -#### H3: Hassette Module Not Found +### H2: Can't Access the Web UI +Port not published, or bound to wrong interface. -### H2: Performance Issues -#### H3: Slow Container Startup -#### H3: High Memory Usage +### H2: Changes to Apps Don't Take Effect +File watcher not enabled in production mode. Restart the container, or +enable hot reload (link to Operating page when it exists). ### H2: Getting Help +Link to GitHub issues, link to main troubleshooting page for non-Docker issues. ## Snippet Inventory -All existing `ts-*` snippets (25+) are keeps — they show diagnostic commands and config fixes. These are Docker-specific troubleshooting commands. - -| Snippet | Status | -|---|---| -| `ts-app-config.toml` | Keep | -| `ts-app-dir-src-env.yml` | Keep | -| `ts-app-dir-toml.toml` | Keep | -| `ts-cat-pyproject.sh` | Keep | -| `ts-check-constraints.sh` | Keep | -| `ts-check-logs.sh` | Keep | -| `ts-check-logs-tail.sh` | Keep | -| `ts-chmod.sh` | Keep | -| `ts-curl-ha.sh` | Keep | -| `ts-dep-conflict.txt` | Keep | -| `ts-dep-install-logs.sh` | Keep | -| `ts-diagnostics.sh` | Keep | -| `ts-find-requirements.sh` | Keep | -| `ts-grep-errors.sh` | Keep | -| `ts-health-check-long-start.yml` | Keep | -| `ts-health-check.sh` | Keep | -| `ts-hot-reload.toml` | Keep | -| `ts-ls-apps.sh` | Keep | -| `ts-memory-limit.yml` | Keep | -| `ts-pin-hassette-pyproject.toml` | Keep | -| `ts-project-dir-env.yml` | Keep | -| `ts-pyproject-dep.toml` | Keep | -| `ts-uv-cache-vol.yml` | Keep | -| `ts-uv-relock.sh` | Keep | -| `ts-vol-mount.yml` | Keep | +| Snippet | Status | Notes | +|---|---|---| +| `ts-check-logs.sh` | Keep | First diagnostic step | +| `ts-curl-ha.sh` | Keep | Test HA connectivity | +| `ts-ls-apps.sh` | Keep | Verify app mount | +| `ts-grep-errors.sh` | Keep | Find Python errors | +| `ts-dep-install-logs.sh` | Keep | Check dep installation | +| All other `ts-*` snippets | Drop | Too detailed for getting-started | ## Cross-Links -- **Links to:** Docker Setup, Dependencies, Image Tags -- **Linked from:** Docker Setup, Dependencies (troubleshooting links) +- **Links to:** Docker Setup, Dependencies, main Troubleshooting page +- **Linked from:** Docker Setup (next steps) diff --git a/design/specs/070-doc-overhaul/writing-prompt-template.md b/design/specs/070-doc-overhaul/writing-prompt-template.md index f9b39ed6e..7359131fe 100644 --- a/design/specs/070-doc-overhaul/writing-prompt-template.md +++ b/design/specs/070-doc-overhaul/writing-prompt-template.md @@ -60,6 +60,39 @@ This is a **{{page_type}}** page. {{cross_links}} +## Symbol verification (CRITICAL) + +Before referencing any method, parameter, class, or type in the documentation, +verify it exists in the codebase. Do NOT guess at method names or parameter +lists. A page that references a non-existent symbol is worse than one that +omits it. + +Use Serena MCP tools for verification (load via ToolSearch first): +- `mcp__serena__get_symbols_overview` — list all methods/classes in a file +- `mcp__serena__find_symbol` — find a specific symbol and read its signature + +Key source files: +- Bus methods & params: src/hassette/bus/bus.py +- Scheduler methods: src/hassette/scheduler/scheduler.py +- Api methods: src/hassette/api/api.py +- App class: src/hassette/app/app.py +- Predicates (P): src/hassette/event_handling/predicates.py +- Conditions (C): src/hassette/event_handling/conditions.py +- Dependencies (D): src/hassette/event_handling/dependencies.py +- Accessors (A): src/hassette/event_handling/accessors.py +- Triggers: src/hassette/scheduler/triggers.py +- Entities: src/hassette/models/entities/base.py +- StateManager: src/hassette/state_manager/state_manager.py + +Before writing a snippet or prose that uses a method, call +get_symbols_overview on the relevant file to confirm it exists, then +find_symbol with include_body=True to check the exact signature. Fallback: + grep -n 'def method_name' src/hassette/path/to/file.py + +Top-level imports are: `from hassette import App, AppConfig, D, states, P, C, A` +Do NOT use deep import paths like `from hassette.models import states` or +`from hassette import dependencies as D`. + ## Key technical facts {{technical_facts}} @@ -179,6 +212,15 @@ Read the page at {{page_path}}. {{page_type_checklist}} +### Symbol accuracy +For every method, parameter, or class the page references, verify it exists +using Serena MCP tools (load via ToolSearch). Use get_symbols_overview on the +relevant source file, then find_symbol with include_body=True to check exact +signatures. Flag any symbol that doesn't exist. Check parameter names and +types against the actual signatures. Verify imports use top-level paths: +`from hassette import App, AppConfig, D, states` (not deep paths like +`from hassette.models import states`). + ### Anti-patterns to flag - "serves as", "acts as", "functions as" - "pivotal", "crucial", "fundamental", "robust" diff --git a/docs/pages/getting-started/docker/dependencies.md b/docs/pages/getting-started/docker/dependencies.md index 7b568e072..2d8c3db48 100644 --- a/docs/pages/getting-started/docker/dependencies.md +++ b/docs/pages/getting-started/docker/dependencies.md @@ -1,76 +1,40 @@ # Managing Dependencies -## Overview +Your apps can use any Python package. Hassette installs them at startup +when you tell it to. `requirements.txt` works for most projects. +`pyproject.toml` works when your project already has one. -Hassette's Docker startup script installs your project dependencies before launching. Two methods are available: a project-based install using `pyproject.toml` and `uv.lock`, and a simpler opt-in discovery of `requirements.txt` files. You can use one or both. - -### How Constraints Work - -The Docker image includes a constraints file at `/app/constraints.txt`. It records compatible version ranges for all framework dependencies. Every `uv pip install` the startup script runs passes `-c /app/constraints.txt`. A conflicting package produces a clear error rather than silently downgrading a hassette dependency. - -If you see a conflict error on startup, your `uv.lock` was likely generated against a different hassette version than the image. Run `uv lock` locally and commit the result. See [Dependency Conflicts](troubleshooting.md#dependency-conflicts) for more. - -## How the Startup Script Works +## Using requirements.txt -```mermaid ---8<-- "pages/getting-started/docker/snippets/deps-startup-flow.mmd" +```txt +--8<-- "pages/getting-started/docker/snippets/requirements-example.txt" ``` -Set `HASSETTE__INSTALL_DEPS=1` to activate dependency installation. Without it, the startup script skips all requirements discovery. - -With it set, the script runs these steps in order: - -1. Check `HASSETTE__PROJECT_DIR` for a `pyproject.toml` and `uv.lock` -2. If both are present, export the lock file to a temporary requirements list and install through the constraints file -3. Scan `/config` and `/apps` for files named exactly `requirements.txt` (up to 5 directory levels deep) and install each one through the constraints file -4. Start Hassette - -### Key Behaviors - -- When a `uv.lock` exists, the script exports resolved dependencies to a flat requirements list before installing. This routes every package through the constraints file. -- `requirements.txt` files are only discovered when `HASSETTE__INSTALL_DEPS=1` is set. -- Only files named exactly `requirements.txt` are discovered. Variants like `requirements-dev.txt` or `requirements_test.txt` are ignored. This prevents dev and test dependencies from entering the production container. -- Every `uv pip install` call passes `-c /app/constraints.txt`. Conflicts produce a clear error before the container exits. -- A failing install exits the container immediately. With `restart: unless-stopped`, Docker retries automatically. Transient network errors resolve on their own. -- All network calls are wrapped with `timeout`: 300 s for project export and install, 120 s per requirements file. -- After installation, stale uv cache entries are pruned by default. Disable with `HASSETTE__PRUNE_UV_CACHE=0` to manage cache size manually. - -## Understanding APP_DIR vs PROJECT_DIR - -`HASSETTE__APPS__DIRECTORY` and `HASSETTE__PROJECT_DIR` serve different purposes: - -| Variable | Purpose | Used by | -|---|---|---| -| `HASSETTE__APPS__DIRECTORY` | Where Hassette looks for `.py` files containing `App` and `AppSync` classes | Hassette runtime | -| `HASSETTE__PROJECT_DIR` | Where the startup script looks for `pyproject.toml` and `uv.lock` | Startup script | - -These can point to the same directory or different ones depending on your project layout. `HASSETTE__APP_DIR` is a legacy alias for `HASSETTE__APPS__DIRECTORY` and still works, but prefer the canonical name. - -## Project Structures +Place this file at `config/requirements.txt` on your host. That maps to +`/config/requirements.txt` inside the container. -### Simple Flat Structure - -``` ---8<-- "pages/getting-started/docker/snippets/deps-flat-dir-structure.txt" -``` +Add `HASSETTE__INSTALL_DEPS: "1"` to your compose file: ```yaml ---8<-- "pages/getting-started/docker/snippets/deps-flat-compose.yml" +--8<-- "pages/getting-started/docker/snippets/deps-install-deps-env.yml" ``` -Both `HASSETTE__APPS__DIRECTORY` and `HASSETTE__PROJECT_DIR` default to `/apps`, so no extra environment variables are needed. A `requirements.txt` in `/apps` is not installed automatically. Set `HASSETTE__INSTALL_DEPS=1` to enable discovery. +Without `HASSETTE__INSTALL_DEPS`, Hassette skips installation entirely. +The `uv_cache` volume keeps downloaded packages across restarts. +Only the first startup is slow. -### Traditional src/ Layout +Restart the container. Your packages are available: -``` ---8<-- "pages/getting-started/docker/snippets/deps-src-dir-structure.txt" +```python +--8<-- "pages/getting-started/docker/snippets/deps-app-using-package.py" ``` -```yaml ---8<-- "pages/getting-started/docker/snippets/deps-src-compose.yml" -``` +The app imports `apprise` directly. No extra configuration needed. -The project root containing `pyproject.toml` mounts to `/apps`. `HASSETTE__PROJECT_DIR=/apps` tells the startup script where to find the lock file. `HASSETTE__APPS__DIRECTORY=/apps/src/my_apps` tells Hassette where to find your app classes. Your apps can import from the `my_apps` package normally. +!!! tip + After adding new packages to `requirements.txt`, restart the container + with `docker compose restart hassette`. Hassette re-runs the install on + every startup when `HASSETTE__INSTALL_DEPS` is set. ## Using pyproject.toml @@ -78,83 +42,49 @@ The project root containing `pyproject.toml` mounts to `/apps`. `HASSETTE__PROJE --8<-- "pages/getting-started/docker/snippets/pyproject-example.toml" ``` -### With a Lock File (Required) +If you already have a `pyproject.toml`, place it in your `apps/` +directory alongside your app files. You also need a `uv.lock` next to it. +Generate one locally before deploying: ```bash ---8<-- "pages/getting-started/docker/snippets/uv-lock.sh" -``` - -Commit the `uv.lock` file alongside your `pyproject.toml`. The startup script detects both files and exports resolved dependencies through the constraints file. - -If `pyproject.toml` is present but no `uv.lock` exists, the startup script logs a message and skips the project install. If you cannot run `uv` locally, use `requirements.txt` with `HASSETTE__INSTALL_DEPS=1` instead. - -## Using requirements.txt - -``` ---8<-- "pages/getting-started/docker/snippets/deps-requirements-dir-structure.txt" +uv lock ``` -``` ---8<-- "pages/getting-started/docker/snippets/requirements-example.txt" -``` +Your compose file stays the same as the Docker Setup page. No extra +environment variables are needed: ```yaml ---8<-- "pages/getting-started/docker/snippets/deps-install-deps-env.yml" +--8<-- "pages/getting-started/docker/snippets/deps-pyproject-compose.yml" ``` -The startup script uses `fd` to find files named exactly `requirements.txt` in both `/config` and `/apps`. It installs them in sorted path order with constraints applied. +Hassette checks `/apps` for a `uv.lock` on startup. If it finds one, +it installs the locked dependencies automatically. +`HASSETTE__INSTALL_DEPS` is not needed. -## Startup Performance - -### Using uv.lock for Faster Starts - -```yaml ---8<-- "pages/getting-started/docker/snippets/uv-cache-volume.yml" -``` +If your `pyproject.toml` lives somewhere other than `apps/`, set +`HASSETTE__PROJECT_DIR` to point Hassette at it. Add the variable to +your compose environment and mount the directory. -The `uv_cache` Docker volume caches downloaded packages between container restarts. Combined with `uv.lock`, subsequent starts skip re-downloading cached packages. +Hassette pins its own dependencies via a constraints file. Your packages +cannot conflict with packages Hassette depends on. -### Known Limitations - -#### Local Path Dependencies - -Projects with local path dependencies (for example, `foo = { path = "../shared-lib" }` in `pyproject.toml`) fail during the export step. `uv export` emits `file:///absolute/path` references that do not resolve inside the container. If your project uses monorepo-style local deps, build a custom image with those packages copied in at build time: - -```dockerfile ---8<-- "pages/getting-started/docker/snippets/custom-image.dockerfile" -``` - -```yaml ---8<-- "pages/getting-started/docker/snippets/custom-image-compose.yml" -``` +!!! note + Commit `uv.lock` to version control. Hassette uses it to reproduce the + exact package versions you tested locally. -!!! note "CLI tooling for custom images is planned" - A `hassette docker build` command to streamline this workflow is on the roadmap. +## Known Limitations -## Complete Examples - -### Example 1: Simple Flat Structure - -```yaml ---8<-- "pages/getting-started/docker/snippets/deps-example1-compose.yml" -``` - -``` ---8<-- "pages/getting-started/docker/snippets/deps-example1-requirements.txt" -``` - -### Example 2: src/ Layout with Lock File - -```yaml ---8<-- "pages/getting-started/docker/snippets/deps-example2-compose.yml" -``` - -```toml ---8<-- "pages/getting-started/docker/snippets/deps-example2-pyproject.toml" -``` +**Local path dependencies don't work inside Docker.** If your `pyproject.toml` +or `requirements.txt` contains a `file:///...` dependency, installation fails +because the host path does not exist inside the container. Publish shared +code as a package instead. Alternatively, mount it as a volume with a +relative path that matches the container layout. -## See Also +**First startup is slower with new dependencies.** Hassette runs `uv sync` +or `uv pip install` on every start when `HASSETTE__INSTALL_DEPS` is set. +New packages download on the first run. The `uv_cache` volume persists +the cache, so subsequent starts skip the download. If your cache volume +is missing or was pruned, the next startup downloads everything again. -- [Docker Setup](index.md) — Getting started with Docker -- [Image Tags](image-tags.md) — Choosing the right image version -- [Troubleshooting](troubleshooting.md) — Dependency conflicts and common errors +If installation fails at startup, see [Troubleshooting](troubleshooting.md#dependencies-wont-install) +for common causes and fixes. diff --git a/docs/pages/getting-started/docker/image-tags.md b/docs/pages/getting-started/docker/image-tags.md index f3e14c619..342966ed4 100644 --- a/docs/pages/getting-started/docker/image-tags.md +++ b/docs/pages/getting-started/docker/image-tags.md @@ -1,53 +1,31 @@ -# Docker Image Tags +# Image Tags -Every image tag combines the Hassette version and the Python version: `ghcr.io/nodejsmith/hassette:-py`. For example, `v0.35.0-py3.13` means Hassette 0.35.0 on Python 3.13. +Images are published to `ghcr.io/nodejsmith/hassette`. Each tag combines a version and a Python version: `v{version}-py{python_version}`. -## Recommended Tags +## Which Tag to Use -### Production - -Pin the Hassette version and the Python version. Your container won't change until you update the tag. +For production, pin to a specific version: ```yaml --8<-- "pages/getting-started/docker/snippets/tag-pinned-compose.yml" ``` -### Development +A pinned tag never changes. You get the same code on every `pull`. -Track the latest stable release for a given Python version. +For development, use `latest-py3.13`: ```yaml --8<-- "pages/getting-started/docker/snippets/tag-latest-compose.yml" ``` -`latest-py3.13` always points to the most recent stable release. It never includes pre-releases. Pull and restart when you want the newest version. - -!!! warning "Automatic upgrades" - `latest-py*` tags update on every stable release. If a new release has breaking changes, your container will pick them up on the next `docker pull`. Pin to a specific version if you need to control when upgrades happen. - -## Supported Python Versions - -Each release is built for three Python versions. +`latest-py3.13` tracks the most recent stable release. New features arrive on the next pull. -| Python version | Status | -| -------------- | --------- | -| 3.13 | Supported | -| 3.12 | Supported | -| 3.11 | Supported | +Python 3.11, 3.12, 3.13, and 3.14 are all supported. Replace `py3.13` with your preferred version. -Python versions are dropped when they reach end-of-life. Check the release notes when upgrading Hassette to a new minor version. +## Updating -## Updating Images - -Pull the latest image and restart your containers. - -```bash +```sh --8<-- "pages/getting-started/docker/snippets/docker-pull-update.sh" ``` -If you pin to a specific version tag, update the tag in your `docker-compose.yml` first, then run this command. - -## Next Steps - -- [Docker Setup](index.md) — full setup guide -- [Dependencies](dependencies.md) — adding Python packages to your container +`pull` fetches the new image. `up -d` restarts the container with it. diff --git a/docs/pages/getting-started/docker/index.md b/docs/pages/getting-started/docker/index.md index 94508913c..29499a853 100644 --- a/docs/pages/getting-started/docker/index.md +++ b/docs/pages/getting-started/docker/index.md @@ -1,146 +1,95 @@ # Docker Setup +Run Hassette in a container with Docker Compose. + ## Prerequisites -- [Docker](https://docs.docker.com/get-docker/) and [Docker Compose](https://docs.docker.com/compose/install/) installed -- A running Home Assistant instance -- A long-lived access token from your HA profile. See [Creating a Home Assistant token](../ha_token.md) +- **Docker and Docker Compose** installed on the host machine. +- **A running Home Assistant instance** with a long-lived access token. See [Creating a Home Assistant Token](../ha_token.md) for how to generate one. ## Quick Start -### 1. Create your project directory +### Step 1: Create the project ```bash --8<-- "pages/getting-started/docker/snippets/mkdir-project.sh" ``` -### 2. Create the Compose file +`config/` holds your token and settings. `apps/` holds your automation code. + +### Step 2: Create docker-compose.yml ```yaml --8<-- "pages/getting-started/docker/snippets/docker-compose.yml" ``` -Save this as `docker-compose.yml` in `project_dir`. +The volumes break down like this: -### 3. Create your `.env` file +- `./config` and `./apps` mount your local directories into the container. +- `data` and `uv_cache` are named volumes for persistent data and the package cache. -```bash ---8<-- "pages/getting-started/docker/snippets/env-file.sh" -``` +Port `8126` exposes the web UI. It is unauthenticated, so keep it off public networks. Set `TZ` to your local timezone so scheduled automations fire at the right times. -Save this as `config/.env`. Replace `your_long_lived_access_token_here` with the token you generated. Add `config/.env` to your `.gitignore` to keep it out of version control. +### Step 3: Create config/.env -### 4. Create `hassette.toml` - -```toml ---8<-- "pages/getting-started/docker/snippets/hassette.toml" +```bash +--8<-- "pages/getting-started/docker/snippets/env-file.sh" ``` -Save this as `config/hassette.toml`. Set `base_url` to your Home Assistant URL. If HA runs on the same machine, `http://homeassistant:8123` works when both containers share a Docker network. Use your HA's IP or hostname otherwise. - -### 5. Write your first app +Replace `your_long_lived_access_token_here` with your token. Set `HASSETTE__BASE_URL` to your Home Assistant's address, like `http://192.168.1.100:8123`. If HA runs in Docker on the same network, use the container name instead. -```python ---8<-- "pages/getting-started/docker/snippets/my_app.py" -``` +Hassette reads `/config/.env` automatically on startup. You do not need an `env_file:` directive in the compose file. -Save this as `apps/my_app.py`. `App` lets you subscribe to HA events and control devices. `AppConfig` defines typed settings your app reads from `hassette.toml`. `on_initialize` runs once when Hassette loads the app. - -### 6. Start Hassette +### Step 4: Start it ```bash --8<-- "pages/getting-started/docker/snippets/docker-compose-up.sh" ``` -Check that it connected: +Check the logs: ```bash --8<-- "pages/getting-started/docker/snippets/docker-compose-logs-hassette.sh" ``` -You should see `Connected to Home Assistant` in the output. - -!!! warning "Web UI security" - The Compose file exposes port `8126` with no authentication. Anyone on your network can view, start, stop, and reload your automations. For remote servers, bind to `127.0.0.1` via `host` under `[hassette.web_api]`. You can also place Hassette behind a reverse proxy. See [Web UI](../../web-ui/index.md) for details. - -## Directory Structure - -After the quick start, your project looks like this: +You see output like: ``` ---8<-- "pages/getting-started/docker/snippets/dir-structure.txt" +INFO hassette ... ─ Connected to Home Assistant ``` -The Docker image mounts four paths: - -| Mount | Purpose | -|---|---| -| `/config` | Configuration files: `hassette.toml`, `.env` | -| `/apps` | Your app Python files | -| `/data` | Persistent data storage: telemetry database | -| `/uv_cache` | Python package cache for faster restarts | - -For projects with external Python dependencies, see [Managing Dependencies](dependencies.md). +Hassette is running. -## Configuration +## Write Your First App -### Home Assistant Token +Create `apps/my_app.py`: -Hassette reads the token from `HASSETTE__TOKEN` in your `.env` file. The [Creating a Home Assistant token](../ha_token.md) page shows how to generate one. - -### Environment Variables Reference - -Any setting from `hassette.toml` can be overridden with a `HASSETTE__` environment variable. Set these in `docker-compose.yml` under `environment`, or add them to `config/.env`. - -| Variable | Description | -|---|---| -| `HASSETTE__TOKEN` | Home Assistant long-lived access token | -| `HASSETTE__BASE_URL` | Home Assistant URL, e.g. `http://homeassistant:8123` | -| `HASSETTE__APPS__DIRECTORY` | Directory containing your app files | -| `HASSETTE__PROJECT_DIR` | Directory containing `pyproject.toml` and `uv.lock` for dependency installation | -| `HASSETTE__CONFIG_DIR` | Directory containing configuration files | -| `HASSETTE__LOG_LEVEL` | Log level: `debug`, `info`, `warning`, or `error` | -| `HASSETTE__INSTALL_DEPS` | Set to `1` to discover and install `requirements.txt` at startup | -| `HASSETTE__PRUNE_UV_CACHE` | Set to `0` to skip `uv cache prune` at startup (default: `1`) | -| `TZ` | System timezone, e.g. `America/New_York` | - -For a full configuration reference, see [Configuration](../../core-concepts/configuration/index.md). - -## Production Deployment - -### Hot Reloading in Production - -```toml ---8<-- "pages/getting-started/docker/snippets/prod-reload.toml" +```python +--8<-- "pages/getting-started/docker/snippets/my_app.py" ``` -Hassette watches for file changes in `./apps/`, but reloads require an explicit opt-in outside dev mode. Add the snippet above to `config/hassette.toml`. +[`App`](../core-concepts/apps/index.md) runs your automation logic and gives you access to the bus, scheduler, and API. [`AppConfig`](../core-concepts/apps/configuration.md) loads and validates your app's settings from the environment. `on_initialize` runs once when the app starts. -With `allow_reload_in_prod = true`, saving a file in `./apps/` restarts only the affected app. File watching adds overhead. Only enable it if your workflow requires in-place updates to a running container. +Restart the container to pick up the new file: -### Graceful Shutdown - -`docker stop` and `docker compose down` send `SIGTERM`. Hassette catches it, finalizes the active session, and drains pending database writes. All connections close before the process exits. - -The Compose file sets `stop_grace_period: 45s`. Docker's default of 10 seconds is too short. The process gets force-killed before shutdown completes, leaving sessions marked as `unknown` on next startup. - -If you override `total_shutdown_timeout_seconds` in your config, set `stop_grace_period` to at least 15 seconds more. - -## Viewing Logs +```bash +docker compose restart hassette +``` -### Docker Compose Logs +Check the logs again. You see `Hello from Docker!` from your app: -```bash ---8<-- "pages/getting-started/docker/snippets/docker-compose-logs.sh" +``` +INFO hassette.MyApp.0 ... ─ Hello from Docker! ``` -### Web UI +!!! tip "Having trouble?" + If Hassette fails to connect, check `HASSETTE__BASE_URL` and your token in `config/.env`. See [Troubleshooting](troubleshooting.md) for common issues. -The web UI at `http://:8126/ui/` shows live app status, handler details, log streaming, and system configuration. It starts with the container. See [Web UI](../../web-ui/index.md) for a full tour. +From here, see [First Automation](../first-automation.md) to subscribe to Home Assistant events and control devices. ## Next Steps -- [Managing Dependencies](dependencies.md) — Install Python packages for your apps -- [Image Tags](image-tags.md) — Pick a versioned or Python-specific image tag -- [Write Your First Automation](../first-automation.md) — Subscribe to HA events and control devices -- [Troubleshooting](troubleshooting.md) — Common issues and solutions +- [First Automation](../first-automation.md) — subscribe to events, control devices +- [Managing Dependencies](dependencies.md) — add Python packages to your setup +- [Image Tags](image-tags.md) — pick a stable tag for production +- [Troubleshooting](troubleshooting.md) — diagnose connection and startup problems diff --git a/docs/pages/getting-started/docker/snippets/custom-image-compose.yml b/docs/pages/getting-started/docker/snippets/custom-image-compose.yml deleted file mode 100644 index 67e7a6f03..000000000 --- a/docs/pages/getting-started/docker/snippets/custom-image-compose.yml +++ /dev/null @@ -1,6 +0,0 @@ -services: - hassette: - build: . - volumes: - - ./apps:/apps/src/my_apps # Just mount app code - - ./config:/config diff --git a/docs/pages/getting-started/docker/snippets/custom-image.dockerfile b/docs/pages/getting-started/docker/snippets/custom-image.dockerfile deleted file mode 100644 index b2ecf6d91..000000000 --- a/docs/pages/getting-started/docker/snippets/custom-image.dockerfile +++ /dev/null @@ -1,20 +0,0 @@ -FROM ghcr.io/nodejsmith/hassette:latest-py3.13 - -# Copy your project files -COPY pyproject.toml uv.lock /project/ - -# Export resolved deps as a flat requirements list -RUN uv export \ - --no-hashes --frozen \ - --directory /project \ - --no-default-groups \ - --no-dev --no-editable --no-emit-project \ - --output-file /tmp/user-deps.txt - -# Install through constraints -RUN uv pip install -r /tmp/user-deps.txt -c /app/constraints.txt - -# Install the project package itself (no dep resolution) -RUN uv pip install --no-deps /project - -RUN rm /tmp/user-deps.txt diff --git a/docs/pages/getting-started/docker/snippets/deps-app-using-package.py b/docs/pages/getting-started/docker/snippets/deps-app-using-package.py new file mode 100644 index 000000000..2a4c0b772 --- /dev/null +++ b/docs/pages/getting-started/docker/snippets/deps-app-using-package.py @@ -0,0 +1,13 @@ +import apprise +from hassette import App, AppConfig + + +class NotifyConfig(AppConfig): + notify_url: str = "tgram://bot_token/chat_id" + + +class NotifyApp(App[NotifyConfig]): + async def on_initialize(self) -> None: + self.notifier = apprise.Apprise() + self.notifier.add(self.app_config.notify_url) + self.logger.info("Notification service ready") diff --git a/docs/pages/getting-started/docker/snippets/deps-example1-compose.yml b/docs/pages/getting-started/docker/snippets/deps-example1-compose.yml deleted file mode 100644 index 5d14e1df0..000000000 --- a/docs/pages/getting-started/docker/snippets/deps-example1-compose.yml +++ /dev/null @@ -1,15 +0,0 @@ -services: - hassette: - image: ghcr.io/nodejsmith/hassette:latest-py3.13 - volumes: - - ./config:/config - - ./apps:/apps - - data:/data - - uv_cache:/uv_cache - environment: - - TZ=America/New_York - - HASSETTE__INSTALL_DEPS=1 - -volumes: - data: - uv_cache: diff --git a/docs/pages/getting-started/docker/snippets/deps-example1-requirements.txt b/docs/pages/getting-started/docker/snippets/deps-example1-requirements.txt deleted file mode 100644 index 0eb8cae7f..000000000 --- a/docs/pages/getting-started/docker/snippets/deps-example1-requirements.txt +++ /dev/null @@ -1 +0,0 @@ -requests>=2.31.0 diff --git a/docs/pages/getting-started/docker/snippets/deps-example2-compose.yml b/docs/pages/getting-started/docker/snippets/deps-example2-compose.yml deleted file mode 100644 index e6eff2727..000000000 --- a/docs/pages/getting-started/docker/snippets/deps-example2-compose.yml +++ /dev/null @@ -1,16 +0,0 @@ -services: - hassette: - image: ghcr.io/nodejsmith/hassette:latest-py3.13 - volumes: - - ./config:/config - - .:/apps - - data:/data - - uv_cache:/uv_cache - environment: - - HASSETTE__PROJECT_DIR=/apps - - HASSETTE__APPS__DIRECTORY=/apps/src/my_apps - - TZ=America/New_York - -volumes: - data: - uv_cache: diff --git a/docs/pages/getting-started/docker/snippets/deps-example2-pyproject.toml b/docs/pages/getting-started/docker/snippets/deps-example2-pyproject.toml deleted file mode 100644 index 153c3b3db..000000000 --- a/docs/pages/getting-started/docker/snippets/deps-example2-pyproject.toml +++ /dev/null @@ -1,8 +0,0 @@ -[project] -name = "my-hassette-apps" -version = "0.1.0" -requires-python = ">=3.11" -dependencies = [ - "requests>=2.31.0", - "aiohttp>=3.9.0", -] diff --git a/docs/pages/getting-started/docker/snippets/deps-flat-dir-structure.txt b/docs/pages/getting-started/docker/snippets/deps-flat-dir-structure.txt deleted file mode 100644 index cba9fb4e1..000000000 --- a/docs/pages/getting-started/docker/snippets/deps-flat-dir-structure.txt +++ /dev/null @@ -1,9 +0,0 @@ -project_dir/ -├── docker-compose.yml -├── config/ -│ ├── hassette.toml -│ └── .env -└── apps/ - ├── my_app.py - ├── another_app.py - └── requirements.txt # optional diff --git a/docs/pages/getting-started/docker/snippets/deps-install-deps-env.yml b/docs/pages/getting-started/docker/snippets/deps-install-deps-env.yml index 804c32e43..df962ec84 100644 --- a/docs/pages/getting-started/docker/snippets/deps-install-deps-env.yml +++ b/docs/pages/getting-started/docker/snippets/deps-install-deps-env.yml @@ -1,2 +1,14 @@ -environment: - - HASSETTE__INSTALL_DEPS=1 +services: + hassette: + image: ghcr.io/nodejsmith/hassette:latest-py3.13 + environment: + HASSETTE__INSTALL_DEPS: "1" + volumes: + - ./config:/config + - ./apps:/apps + - data:/data + - uv_cache:/uv_cache + +volumes: + uv_cache: + data: diff --git a/docs/pages/getting-started/docker/snippets/deps-flat-compose.yml b/docs/pages/getting-started/docker/snippets/deps-pyproject-compose.yml similarity index 76% rename from docs/pages/getting-started/docker/snippets/deps-flat-compose.yml rename to docs/pages/getting-started/docker/snippets/deps-pyproject-compose.yml index ffa70481f..d6111e0e7 100644 --- a/docs/pages/getting-started/docker/snippets/deps-flat-compose.yml +++ b/docs/pages/getting-started/docker/snippets/deps-pyproject-compose.yml @@ -6,8 +6,7 @@ services: - ./apps:/apps - data:/data - uv_cache:/uv_cache - # No need to set APP_DIR or PROJECT_DIR - defaults work fine volumes: - data: uv_cache: + data: diff --git a/docs/pages/getting-started/docker/snippets/deps-requirements-dir-structure.txt b/docs/pages/getting-started/docker/snippets/deps-requirements-dir-structure.txt deleted file mode 100644 index 90b0cac56..000000000 --- a/docs/pages/getting-started/docker/snippets/deps-requirements-dir-structure.txt +++ /dev/null @@ -1,3 +0,0 @@ -apps/ -├── my_app.py -└── requirements.txt diff --git a/docs/pages/getting-started/docker/snippets/deps-src-compose.yml b/docs/pages/getting-started/docker/snippets/deps-src-compose.yml deleted file mode 100644 index c6baaab2b..000000000 --- a/docs/pages/getting-started/docker/snippets/deps-src-compose.yml +++ /dev/null @@ -1,17 +0,0 @@ -services: - hassette: - image: ghcr.io/nodejsmith/hassette:latest-py3.13 - volumes: - - ./config:/config - - .:/apps # Mount entire project to /apps - - data:/data - - uv_cache:/uv_cache - environment: - # Startup script finds pyproject.toml/uv.lock at /apps - - HASSETTE__PROJECT_DIR=/apps - # Hassette finds app files in /apps/src/my_apps - - HASSETTE__APPS__DIRECTORY=/apps/src/my_apps - -volumes: - data: - uv_cache: diff --git a/docs/pages/getting-started/docker/snippets/deps-src-dir-structure.txt b/docs/pages/getting-started/docker/snippets/deps-src-dir-structure.txt deleted file mode 100644 index f56f0ca32..000000000 --- a/docs/pages/getting-started/docker/snippets/deps-src-dir-structure.txt +++ /dev/null @@ -1,12 +0,0 @@ -project_dir/ -├── docker-compose.yml -├── config/ -│ ├── hassette.toml -│ └── .env -├── pyproject.toml -├── uv.lock -└── src/ - └── my_apps/ - ├── __init__.py - ├── app_one.py - └── app_two.py diff --git a/docs/pages/getting-started/docker/snippets/deps-startup-flow.mmd b/docs/pages/getting-started/docker/snippets/deps-startup-flow.mmd deleted file mode 100644 index 6f36a0425..000000000 --- a/docs/pages/getting-started/docker/snippets/deps-startup-flow.mmd +++ /dev/null @@ -1,24 +0,0 @@ -flowchart TD - A[Container Starts]:::neutral --> B[Activate venv\nValidate hassette importable]:::step - B --> C{uv.lock exists?}:::decision - C -->|Yes| D[uv export → /tmp/user-deps.txt]:::step - D --> E[uv pip install -r user-deps.txt\n-c constraints.txt]:::step - E --> F[uv pip install --no-deps project]:::step - C -->|No| G{pyproject.toml exists?}:::decision - G -->|Yes| H[Log: run uv lock to generate a lockfile]:::fallback - G -->|No| I[Skip project install]:::fallback - F --> J{HASSETTE__INSTALL_DEPS=1?}:::decision - H --> J - I --> J - J -->|Yes| K[Discover requirements.txt files via fd\nexact match only]:::step - K --> L[For each: uv pip install -r file\n-c constraints.txt]:::step - L --> N{HASSETTE__PRUNE_UV_CACHE=1?}:::decision - J -->|No| N - N -->|Yes| O[uv cache prune]:::step - O --> M[Start hassette]:::neutral - N -->|No| M - - classDef neutral fill:#f0f0f0,stroke:#999,color:#333 - classDef step fill:#e8f0ff,stroke:#6688cc,color:#333 - classDef decision fill:#f0f8e8,stroke:#88aa66,color:#333 - classDef fallback fill:#fff0e8,stroke:#cc8844,color:#333 diff --git a/docs/pages/getting-started/docker/snippets/dir-structure.txt b/docs/pages/getting-started/docker/snippets/dir-structure.txt deleted file mode 100644 index 435541514..000000000 --- a/docs/pages/getting-started/docker/snippets/dir-structure.txt +++ /dev/null @@ -1,8 +0,0 @@ -project_dir/ -├── docker-compose.yml -├── config/ -│ ├── hassette.toml # Hassette configuration -│ └── .env # Secrets (HA token, etc.) -└── apps/ - ├── my_app.py # Your app files - └── another_app.py diff --git a/docs/pages/getting-started/docker/snippets/docker-compose-logs.sh b/docs/pages/getting-started/docker/snippets/docker-compose-logs.sh deleted file mode 100644 index 36e40515b..000000000 --- a/docs/pages/getting-started/docker/snippets/docker-compose-logs.sh +++ /dev/null @@ -1,8 +0,0 @@ -# Follow all logs -docker compose logs -f - -# Just Hassette -docker compose logs -f hassette - -# Last 100 lines -docker compose logs --tail=100 hassette diff --git a/docs/pages/getting-started/docker/snippets/docker-pull-update.sh b/docs/pages/getting-started/docker/snippets/docker-pull-update.sh index c7731981a..90386cc0f 100644 --- a/docs/pages/getting-started/docker/snippets/docker-pull-update.sh +++ b/docs/pages/getting-started/docker/snippets/docker-pull-update.sh @@ -1,2 +1 @@ -docker compose pull -docker compose up -d +docker compose pull && docker compose up -d diff --git a/docs/pages/getting-started/docker/snippets/docker-version-check.sh b/docs/pages/getting-started/docker/snippets/docker-version-check.sh deleted file mode 100644 index 6d290330f..000000000 --- a/docs/pages/getting-started/docker/snippets/docker-version-check.sh +++ /dev/null @@ -1 +0,0 @@ -docker compose exec hassette hassette --version diff --git a/docs/pages/getting-started/docker/snippets/env-file.sh b/docs/pages/getting-started/docker/snippets/env-file.sh index dd2233647..a4b68a560 100644 --- a/docs/pages/getting-started/docker/snippets/env-file.sh +++ b/docs/pages/getting-started/docker/snippets/env-file.sh @@ -1 +1,3 @@ +# config/.env HASSETTE__TOKEN=your_long_lived_access_token_here +HASSETTE__BASE_URL=http://homeassistant:8123 diff --git a/docs/pages/getting-started/docker/snippets/prod-reload.toml b/docs/pages/getting-started/docker/snippets/prod-reload.toml deleted file mode 100644 index 2b7c6a39a..000000000 --- a/docs/pages/getting-started/docker/snippets/prod-reload.toml +++ /dev/null @@ -1,3 +0,0 @@ -[hassette] -allow_reload_in_prod = true # Enable file watching in production -watch_files = true diff --git a/docs/pages/getting-started/docker/snippets/pyproject-example.toml b/docs/pages/getting-started/docker/snippets/pyproject-example.toml index b2be5e8e0..ed44e0169 100644 --- a/docs/pages/getting-started/docker/snippets/pyproject-example.toml +++ b/docs/pages/getting-started/docker/snippets/pyproject-example.toml @@ -3,7 +3,6 @@ name = "my-hassette-apps" version = "0.1.0" requires-python = ">=3.11" dependencies = [ - "requests>=2.31.0", - "aiohttp>=3.9.0", - "pydantic>=2.0.0", + "apprise>=1.9", + "astral>=3.2", ] diff --git a/docs/pages/getting-started/docker/snippets/requirements-example.txt b/docs/pages/getting-started/docker/snippets/requirements-example.txt index 21444d1b6..a3edc52ee 100644 --- a/docs/pages/getting-started/docker/snippets/requirements-example.txt +++ b/docs/pages/getting-started/docker/snippets/requirements-example.txt @@ -1,2 +1,2 @@ -requests>=2.31.0 -aiohttp>=3.9.0 +apprise>=1.9 +astral>=3.2 diff --git a/docs/pages/getting-started/docker/snippets/tag-format-latest.txt b/docs/pages/getting-started/docker/snippets/tag-format-latest.txt deleted file mode 100644 index 730e5e899..000000000 --- a/docs/pages/getting-started/docker/snippets/tag-format-latest.txt +++ /dev/null @@ -1 +0,0 @@ -ghcr.io/nodejsmith/hassette:latest-py diff --git a/docs/pages/getting-started/docker/snippets/tag-format-main.txt b/docs/pages/getting-started/docker/snippets/tag-format-main.txt deleted file mode 100644 index 854f69772..000000000 --- a/docs/pages/getting-started/docker/snippets/tag-format-main.txt +++ /dev/null @@ -1 +0,0 @@ -ghcr.io/nodejsmith/hassette:main-py3.13 diff --git a/docs/pages/getting-started/docker/snippets/tag-format-pr.txt b/docs/pages/getting-started/docker/snippets/tag-format-pr.txt deleted file mode 100644 index 32d131198..000000000 --- a/docs/pages/getting-started/docker/snippets/tag-format-pr.txt +++ /dev/null @@ -1 +0,0 @@ -ghcr.io/nodejsmith/hassette:pr--py3.13 diff --git a/docs/pages/getting-started/docker/snippets/tag-format-versioned.txt b/docs/pages/getting-started/docker/snippets/tag-format-versioned.txt deleted file mode 100644 index 564aa75bb..000000000 --- a/docs/pages/getting-started/docker/snippets/tag-format-versioned.txt +++ /dev/null @@ -1 +0,0 @@ -ghcr.io/nodejsmith/hassette:-py diff --git a/docs/pages/getting-started/docker/snippets/tag-pinned-compose.yml b/docs/pages/getting-started/docker/snippets/tag-pinned-compose.yml index 9e109d5ed..eaea48149 100644 --- a/docs/pages/getting-started/docker/snippets/tag-pinned-compose.yml +++ b/docs/pages/getting-started/docker/snippets/tag-pinned-compose.yml @@ -1,3 +1,3 @@ services: hassette: - image: ghcr.io/nodejsmith/hassette:0.24.0-py3.13 + image: ghcr.io/nodejsmith/hassette:v0.39.0-py3.13 diff --git a/docs/pages/getting-started/docker/snippets/tag-prerelease-compose.yml b/docs/pages/getting-started/docker/snippets/tag-prerelease-compose.yml deleted file mode 100644 index 24274689d..000000000 --- a/docs/pages/getting-started/docker/snippets/tag-prerelease-compose.yml +++ /dev/null @@ -1,3 +0,0 @@ -services: - hassette: - image: ghcr.io/nodejsmith/hassette:0.25.0.dev1-py3.13 diff --git a/docs/pages/getting-started/docker/snippets/tag-prerelease-explicit.txt b/docs/pages/getting-started/docker/snippets/tag-prerelease-explicit.txt deleted file mode 100644 index 6979d84f4..000000000 --- a/docs/pages/getting-started/docker/snippets/tag-prerelease-explicit.txt +++ /dev/null @@ -1 +0,0 @@ -ghcr.io/nodejsmith/hassette:0.25.0.dev1-py3.13 diff --git a/docs/pages/getting-started/docker/snippets/ts-app-config.toml b/docs/pages/getting-started/docker/snippets/ts-app-config.toml deleted file mode 100644 index a6fa38c14..000000000 --- a/docs/pages/getting-started/docker/snippets/ts-app-config.toml +++ /dev/null @@ -1,4 +0,0 @@ -[apps.my_app] -filename = "my_app.py" -class_name = "MyApp" -enabled = true diff --git a/docs/pages/getting-started/docker/snippets/ts-app-dir-src-env.yml b/docs/pages/getting-started/docker/snippets/ts-app-dir-src-env.yml deleted file mode 100644 index a942c1714..000000000 --- a/docs/pages/getting-started/docker/snippets/ts-app-dir-src-env.yml +++ /dev/null @@ -1,2 +0,0 @@ -environment: - - HASSETTE__APPS__DIRECTORY=/apps/src/my_apps diff --git a/docs/pages/getting-started/docker/snippets/ts-app-dir-toml.toml b/docs/pages/getting-started/docker/snippets/ts-app-dir-toml.toml deleted file mode 100644 index 829be5e0d..000000000 --- a/docs/pages/getting-started/docker/snippets/ts-app-dir-toml.toml +++ /dev/null @@ -1,2 +0,0 @@ -[hassette.apps] -directory = "/apps" # Must match volume mount diff --git a/docs/pages/getting-started/docker/snippets/ts-cat-pyproject.sh b/docs/pages/getting-started/docker/snippets/ts-cat-pyproject.sh deleted file mode 100644 index 1d4f38c0e..000000000 --- a/docs/pages/getting-started/docker/snippets/ts-cat-pyproject.sh +++ /dev/null @@ -1 +0,0 @@ -docker compose exec hassette cat /apps/pyproject.toml diff --git a/docs/pages/getting-started/docker/snippets/ts-check-constraints.sh b/docs/pages/getting-started/docker/snippets/ts-check-constraints.sh deleted file mode 100644 index f05780fa2..000000000 --- a/docs/pages/getting-started/docker/snippets/ts-check-constraints.sh +++ /dev/null @@ -1 +0,0 @@ -docker compose exec hassette cat /app/constraints.txt | grep aiohttp # replace aiohttp with your package name diff --git a/docs/pages/getting-started/docker/snippets/ts-check-logs-tail.sh b/docs/pages/getting-started/docker/snippets/ts-check-logs-tail.sh deleted file mode 100644 index 5fb0684c9..000000000 --- a/docs/pages/getting-started/docker/snippets/ts-check-logs-tail.sh +++ /dev/null @@ -1 +0,0 @@ -docker compose logs --tail=200 hassette diff --git a/docs/pages/getting-started/docker/snippets/ts-chmod.sh b/docs/pages/getting-started/docker/snippets/ts-chmod.sh deleted file mode 100644 index 4454cf9e6..000000000 --- a/docs/pages/getting-started/docker/snippets/ts-chmod.sh +++ /dev/null @@ -1 +0,0 @@ -chmod -R a+r ./config ./apps diff --git a/docs/pages/getting-started/docker/snippets/ts-curl-ha.sh b/docs/pages/getting-started/docker/snippets/ts-curl-ha.sh index 5e1705b88..08a54b6d4 100644 --- a/docs/pages/getting-started/docker/snippets/ts-curl-ha.sh +++ b/docs/pages/getting-started/docker/snippets/ts-curl-ha.sh @@ -1 +1,2 @@ -docker compose exec hassette curl -I http://homeassistant:8123 +docker compose exec hassette curl -s http://homeassistant:8123/api/ \ + -H "Authorization: Bearer YOUR_TOKEN_HERE" diff --git a/docs/pages/getting-started/docker/snippets/ts-dep-conflict.txt b/docs/pages/getting-started/docker/snippets/ts-dep-conflict.txt deleted file mode 100644 index 63ae6c6a3..000000000 --- a/docs/pages/getting-started/docker/snippets/ts-dep-conflict.txt +++ /dev/null @@ -1,11 +0,0 @@ -───────────────────────────────────────────────────────── - DEPENDENCY CONFLICT - - Your project's dependencies conflict with this version - of Hassette. This usually means your uv.lock was generated - against a different Hassette version than this image. - - To fix: run 'uv lock' locally, commit uv.lock, and restart. -───────────────────────────────────────────────────────── -Because you require yarl==1.20.0 and yarl==1.22.0, we can conclude that -your requirements are unsatisfiable. diff --git a/docs/pages/getting-started/docker/snippets/ts-dep-install-logs.sh b/docs/pages/getting-started/docker/snippets/ts-dep-install-logs.sh index d8e1f8b67..c6ffdf5cd 100644 --- a/docs/pages/getting-started/docker/snippets/ts-dep-install-logs.sh +++ b/docs/pages/getting-started/docker/snippets/ts-dep-install-logs.sh @@ -1 +1 @@ -docker compose logs hassette | grep -i "installing\|error\|failed" +docker compose logs hassette | grep -i "install\|pip\|uv" diff --git a/docs/pages/getting-started/docker/snippets/ts-diagnostics.sh b/docs/pages/getting-started/docker/snippets/ts-diagnostics.sh deleted file mode 100644 index ca4afbf80..000000000 --- a/docs/pages/getting-started/docker/snippets/ts-diagnostics.sh +++ /dev/null @@ -1,11 +0,0 @@ -# Container status -docker compose ps - -# Full logs -docker compose logs hassette > hassette.log - -# Environment -docker compose exec hassette env | grep HASSETTE - -# File structure - example uses fdfind to automatically exclude pycache/pyc/etc. -docker compose exec hassette fdfind . /apps /config -t f diff --git a/docs/pages/getting-started/docker/snippets/ts-find-requirements.sh b/docs/pages/getting-started/docker/snippets/ts-find-requirements.sh deleted file mode 100644 index 3ce12cdd3..000000000 --- a/docs/pages/getting-started/docker/snippets/ts-find-requirements.sh +++ /dev/null @@ -1 +0,0 @@ -docker compose exec hassette fdfind -t f -a --max-depth 5 '^requirements\.txt$' /apps /config diff --git a/docs/pages/getting-started/docker/snippets/ts-grep-errors.sh b/docs/pages/getting-started/docker/snippets/ts-grep-errors.sh index d745cd599..aeb12f437 100644 --- a/docs/pages/getting-started/docker/snippets/ts-grep-errors.sh +++ b/docs/pages/getting-started/docker/snippets/ts-grep-errors.sh @@ -1 +1 @@ -docker compose logs hassette | grep -i "error\|exception\|traceback" +docker compose logs hassette | grep -i error diff --git a/docs/pages/getting-started/docker/snippets/ts-health-check-long-start.yml b/docs/pages/getting-started/docker/snippets/ts-health-check-long-start.yml deleted file mode 100644 index 38f04d433..000000000 --- a/docs/pages/getting-started/docker/snippets/ts-health-check-long-start.yml +++ /dev/null @@ -1,6 +0,0 @@ -healthcheck: - test: ["CMD", "curl", "-sf", "http://127.0.0.1:8126/api/health/live"] - interval: 30s - timeout: 5s - retries: 3 - start_period: 60s diff --git a/docs/pages/getting-started/docker/snippets/ts-health-check.sh b/docs/pages/getting-started/docker/snippets/ts-health-check.sh deleted file mode 100644 index bd1c22464..000000000 --- a/docs/pages/getting-started/docker/snippets/ts-health-check.sh +++ /dev/null @@ -1 +0,0 @@ -docker compose exec hassette curl -sf http://127.0.0.1:8126/api/health/live diff --git a/docs/pages/getting-started/docker/snippets/ts-hot-reload.toml b/docs/pages/getting-started/docker/snippets/ts-hot-reload.toml deleted file mode 100644 index 7f2c75457..000000000 --- a/docs/pages/getting-started/docker/snippets/ts-hot-reload.toml +++ /dev/null @@ -1,3 +0,0 @@ -[hassette] -watch_files = true -allow_reload_in_prod = true # Only if dev_mode = false diff --git a/docs/pages/getting-started/docker/snippets/ts-ls-apps.sh b/docs/pages/getting-started/docker/snippets/ts-ls-apps.sh index 0d183d454..c5590af97 100644 --- a/docs/pages/getting-started/docker/snippets/ts-ls-apps.sh +++ b/docs/pages/getting-started/docker/snippets/ts-ls-apps.sh @@ -1 +1 @@ -docker compose exec hassette ls -la /apps +docker compose exec hassette ls /apps diff --git a/docs/pages/getting-started/docker/snippets/ts-memory-limit.yml b/docs/pages/getting-started/docker/snippets/ts-memory-limit.yml deleted file mode 100644 index 75a915a5d..000000000 --- a/docs/pages/getting-started/docker/snippets/ts-memory-limit.yml +++ /dev/null @@ -1,7 +0,0 @@ -services: - hassette: - # ... other config ... - deploy: - resources: - limits: - memory: 512M diff --git a/docs/pages/getting-started/docker/snippets/ts-pin-hassette-pyproject.toml b/docs/pages/getting-started/docker/snippets/ts-pin-hassette-pyproject.toml deleted file mode 100644 index 52b8a7454..000000000 --- a/docs/pages/getting-started/docker/snippets/ts-pin-hassette-pyproject.toml +++ /dev/null @@ -1,5 +0,0 @@ -[project] -dependencies = [ - "hassette==0.24.0", - # ... your other deps -] diff --git a/docs/pages/getting-started/docker/snippets/ts-project-dir-env.yml b/docs/pages/getting-started/docker/snippets/ts-project-dir-env.yml deleted file mode 100644 index f0cfc9e5f..000000000 --- a/docs/pages/getting-started/docker/snippets/ts-project-dir-env.yml +++ /dev/null @@ -1,2 +0,0 @@ -environment: - - HASSETTE__PROJECT_DIR=/apps # Must contain pyproject.toml diff --git a/docs/pages/getting-started/docker/snippets/ts-pyproject-dep.toml b/docs/pages/getting-started/docker/snippets/ts-pyproject-dep.toml deleted file mode 100644 index 7378ace05..000000000 --- a/docs/pages/getting-started/docker/snippets/ts-pyproject-dep.toml +++ /dev/null @@ -1,2 +0,0 @@ -[project] -dependencies = ["xyz>=1.0.0"] diff --git a/docs/pages/getting-started/docker/snippets/ts-uv-cache-vol.yml b/docs/pages/getting-started/docker/snippets/ts-uv-cache-vol.yml deleted file mode 100644 index 162676694..000000000 --- a/docs/pages/getting-started/docker/snippets/ts-uv-cache-vol.yml +++ /dev/null @@ -1,2 +0,0 @@ -volumes: - - uv_cache:/uv_cache diff --git a/docs/pages/getting-started/docker/snippets/ts-uv-relock.sh b/docs/pages/getting-started/docker/snippets/ts-uv-relock.sh deleted file mode 100644 index be9cec170..000000000 --- a/docs/pages/getting-started/docker/snippets/ts-uv-relock.sh +++ /dev/null @@ -1,6 +0,0 @@ -# Re-resolve against the current hassette version -uv lock - -# Commit the updated lockfile -git add uv.lock -git commit -m "update uv.lock for hassette upgrade" diff --git a/docs/pages/getting-started/docker/snippets/ts-vol-mount.yml b/docs/pages/getting-started/docker/snippets/ts-vol-mount.yml deleted file mode 100644 index 587ab18fe..000000000 --- a/docs/pages/getting-started/docker/snippets/ts-vol-mount.yml +++ /dev/null @@ -1,2 +0,0 @@ -volumes: - - ./apps:/apps # Mounted - changes reflected diff --git a/docs/pages/getting-started/docker/snippets/uv-cache-volume.yml b/docs/pages/getting-started/docker/snippets/uv-cache-volume.yml deleted file mode 100644 index 07ec3e99a..000000000 --- a/docs/pages/getting-started/docker/snippets/uv-cache-volume.yml +++ /dev/null @@ -1,2 +0,0 @@ -volumes: - - uv_cache:/uv_cache # Persist package cache diff --git a/docs/pages/getting-started/docker/snippets/uv-lock.sh b/docs/pages/getting-started/docker/snippets/uv-lock.sh deleted file mode 100644 index 183616a21..000000000 --- a/docs/pages/getting-started/docker/snippets/uv-lock.sh +++ /dev/null @@ -1,3 +0,0 @@ -uv lock -git add uv.lock -git commit -m "add uv.lock" diff --git a/docs/pages/getting-started/docker/troubleshooting.md b/docs/pages/getting-started/docker/troubleshooting.md index 4c8334612..1e7532932 100644 --- a/docs/pages/getting-started/docker/troubleshooting.md +++ b/docs/pages/getting-started/docker/troubleshooting.md @@ -1,214 +1,106 @@ -# Troubleshooting +# Docker Troubleshooting -## Container Won't Start +Each section below covers one symptom. Jump to the one that matches your situation. -### Check the Logs +## Container Exits Immediately -The logs tell you why the container stopped: +The container starts and stops within a few seconds. Check the logs first: ```bash --8<-- "pages/getting-started/docker/snippets/ts-check-logs.sh" ``` -If the output is truncated, get more: +The two most common causes are a missing token and an unreachable Home Assistant instance. -```bash ---8<-- "pages/getting-started/docker/snippets/ts-check-logs-tail.sh" -``` - -### Token Not Set - -**Symptom:** The logs show an error about a missing or invalid token. - -`HASSETTE__TOKEN` is required. Set it in `config/.env` and restart: +**Missing token.** Hassette reads `HASSETTE__TOKEN` from `/config/.env` inside the container. If that value is absent, Hassette exits at startup. Open your `config/.env` file and confirm the line is present: -```bash +``` HASSETTE__TOKEN=your_long_lived_token_here ``` -See [Docker Setup](index.md) for how to generate a long-lived token in Home Assistant. - -### Can't Reach Home Assistant - -**Symptom:** The logs show `Connection refused` or a timeout when connecting to Home Assistant. +**Wrong base URL.** `HASSETTE__BASE_URL` must point to Home Assistant's HTTP interface. Use `http://homeassistant:8123` when running on the same Docker network, or your HA instance's IP address otherwise. A trailing slash or `https://` when HA serves plain HTTP will cause a connection failure. -Confirm `base_url` in `hassette.toml` matches your Home Assistant address. Then test connectivity from inside the container: +**Network not reachable.** If the URL looks correct, test the connection from inside the container: ```bash --8<-- "pages/getting-started/docker/snippets/ts-curl-ha.sh" ``` -If this command times out or returns a connection error, the container cannot reach Home Assistant. Check your Docker network configuration. Hassette and Home Assistant must be on the same network, or you must use a routable hostname. +A healthy response returns a JSON object with a `message` key. An empty response or connection error means the container can't reach HA on that address. -### Permission Errors +## "Connected" But Apps Don't Load -**Symptom:** The logs show `Permission denied` when reading config or app files. +Hassette reports a successful connection in the logs, but your apps never initialize. -The container runs as user `hassette`. Your mounted files must be readable by that user: - -```bash ---8<-- "pages/getting-started/docker/snippets/ts-chmod.sh" -``` - -Restart the container after fixing permissions. - -## Apps Not Loading - -### Check App Discovery - -Verify Hassette can see your app files: +**Apps directory not mounted.** Hassette looks for apps at `/apps` inside the container. Verify the mount is working: ```bash --8<-- "pages/getting-started/docker/snippets/ts-ls-apps.sh" ``` -If your files aren't listed, the volume mount is wrong or the files don't exist at the expected path. - -### Verify App Directory Configuration +If this returns an empty directory or an error, check your `volumes:` block in `compose.yml`. It should include a line like `./apps:/apps`. -`apps.directory` in `hassette.toml` must match the container path where your apps are mounted: - -```toml ---8<-- "pages/getting-started/docker/snippets/ts-app-dir-toml.toml" -``` - -If your project uses a `src/` layout, override the directory with an environment variable instead: - -```yaml ---8<-- "pages/getting-started/docker/snippets/ts-app-dir-src-env.yml" -``` - -### Check for Python Errors - -**Symptom:** The app directory exists and the files are there, but apps still don't load. - -Look for syntax errors or failed imports in the logs: +**Syntax error in an app file.** A Python syntax error prevents that app from loading. Scan the logs for errors: ```bash --8<-- "pages/getting-started/docker/snippets/ts-grep-errors.sh" ``` -A `SyntaxError` or `ImportError` in your app file prevents it from loading. Fix the error in your app code and restart. - -### Verify App Configuration - -Each app needs a corresponding entry in `hassette.toml`. Without one, Hassette ignores the file: - -```toml ---8<-- "pages/getting-started/docker/snippets/ts-app-config.toml" -``` - -Check that `filename` matches your actual filename and `class_name` matches the class inside it. - -## Dependency Installation Fails - -### Check Installation Output +Look for a `SyntaxError` or `ImportError` with a file path. Fix the error in that file, then restart with `docker compose restart hassette`. -Look for errors in the startup logs: +## Dependencies Won't Install -```bash ---8<-- "pages/getting-started/docker/snippets/ts-dep-install-logs.sh" -``` +Your app imports a third-party package, but Hassette reports an `ImportError` at startup. -### Dependency Conflicts +Hassette only installs from `requirements.txt` when `HASSETTE__INSTALL_DEPS=1` is set in the compose `environment:` block. Check your `compose.yml`: -**Symptom:** The container exits at startup with a `DEPENDENCY CONFLICT` banner: - -``` ---8<-- "pages/getting-started/docker/snippets/ts-dep-conflict.txt" +```yaml +environment: + HASSETTE__INSTALL_DEPS: "1" ``` -Your `uv.lock` was resolved against a different Hassette version than the image provides. The startup script detects the mismatch and exits rather than silently downgrading a framework package. - -Re-resolve locally against the current image version, then commit: +If the variable is set, confirm `requirements.txt` is mounted and readable at `/config/requirements.txt` inside the container: ```bash ---8<-- "pages/getting-started/docker/snippets/ts-uv-relock.sh" +docker compose exec hassette ls /config/requirements.txt ``` -For `requirements.txt`-based installs, relax any pinned versions that conflict. Check which version range Hassette requires: +Then check whether the install ran at startup: ```bash ---8<-- "pages/getting-started/docker/snippets/ts-check-constraints.sh" -``` - -To prevent this, pin Hassette in your project to match the image tag you deploy. If you're using the `0.24.0-py3.13` image: - -```toml ---8<-- "pages/getting-started/docker/snippets/ts-pin-hassette-pyproject.toml" +--8<-- "pages/getting-started/docker/snippets/ts-dep-install-logs.sh" ``` -Run `uv lock` after updating the pin, then commit both files. +If you see no install output, `HASSETTE__INSTALL_DEPS` was not picked up. Run `docker compose down && docker compose up -d` to reload the environment. -### pyproject.toml Not Found +## Can't Access the Web UI -**Symptom:** The logs say "No pyproject.toml found" or your project dependencies aren't installing. +You navigate to `http://your-host:8126` and get a connection refused error. -Check that `HASSETTE__PROJECT_DIR` points to the directory containing your `pyproject.toml`: +The port is not published unless your `compose.yml` includes a `ports:` mapping for the Hassette service: ```yaml ---8<-- "pages/getting-started/docker/snippets/ts-project-dir-env.yml" +services: + hassette: + ports: + - "8126:8126" ``` -Confirm the file is there: +If the port is listed but you still get connection refused, check the bind address. `"127.0.0.1:8126:8126"` only accepts connections from localhost. Use `"8126:8126"` to accept connections from any interface. -```bash ---8<-- "pages/getting-started/docker/snippets/ts-cat-pyproject.sh" -``` +After updating `compose.yml`, run `docker compose up -d` to apply the change. -### Project Has pyproject.toml But Dependencies Don't Install +## Changes to Apps Don't Take Effect -**Symptom:** You have a `pyproject.toml` but no `uv.lock`, and the startup log says "run 'uv lock' to generate a lockfile". +You edit an app file on disk, but Hassette continues running the old version. -Hassette requires a lockfile to install project dependencies. Generate one locally and commit it: +The file watcher is off in production mode by default. Restart the container to pick up your changes: ```bash -uv lock -git add uv.lock -git commit -m "add lockfile" +docker compose restart hassette ``` -If you can't run `uv` locally, use the `requirements.txt` approach with `HASSETTE__INSTALL_DEPS=1` instead. See [Managing Dependencies](dependencies.md). - -### requirements.txt Not Found - -**Symptom:** Your `requirements.txt` exists but dependencies aren't installing. - -Check these in order: - -1. **`HASSETTE__INSTALL_DEPS=1` must be set.** Requirements discovery is off by default. Without this variable, the startup script skips all `requirements.txt` scanning. - -2. **The filename must be exactly `requirements.txt`.** Files named `requirements-dev.txt`, `requirements_test.txt`, or any other variant are ignored. - -3. **The file must be under `/config` or `/apps`** and must not be empty. - -Check what the container sees: - -```bash ---8<-- "pages/getting-started/docker/snippets/ts-find-requirements.sh" -``` - -### Version Conflicts - -**Symptom:** Installation fails with a package version conflict. - -Use `uv.lock` for consistent resolution. Packages are already pinned, so there's nothing to conflict. For `requirements.txt`, relax any overly tight version pins. - -Check the Hassette constraints file to see which version ranges the image requires: - -```bash ---8<-- "pages/getting-started/docker/snippets/ts-check-constraints.sh" -``` - -### Import Errors at Runtime - -**Symptom:** Dependencies installed at startup but fail to import when your app runs. - -Check three things: - -1. The package is listed in your `pyproject.toml` or `requirements.txt` -2. The startup logs show the package installing without errors -3. `HASSETTE__APPS__DIRECTORY` points to the correct location +To apply edits without restarting, set `watch_files = true` and `allow_reload_in_prod = true` in your `hassette.toml`. ## Hassette Restarts Whenever Home Assistant Goes Down @@ -224,123 +116,11 @@ Check three things: If you need a separate traffic-routing signal, use `/api/health/ready` — but keep it out of any healthcheck that triggers restarts. See [Health Endpoints](../../web-ui/health-endpoints.md) for the full reference. -## Health Check Failing - -The liveness check queries `http://127.0.0.1:8126/api/health/live`. This endpoint returns 200 whenever the Hassette process is up and the event loop can respond. It does not check Home Assistant connectivity — HA being down never makes the liveness probe return a non-200 response. - -**Symptom:** The container is marked unhealthy, or keeps restarting. - -First, check whether Hassette started at all: - -```bash ---8<-- "pages/getting-started/docker/snippets/ts-check-logs.sh" -``` - -If Hassette started but the health check still fails, test the endpoint directly: - -```bash ---8<-- "pages/getting-started/docker/snippets/ts-health-check.sh" -``` - -If port 8126 is in use by another process inside the container, the health service won't bind. Check your configuration for port conflicts. - -If the container installs dependencies at startup, it may not respond before the first health check fires. Increase `start_period` to give it more time: - -```yaml ---8<-- "pages/getting-started/docker/snippets/ts-health-check-long-start.yml" -``` - -## Hot Reload Not Working - -**Symptom:** You edit an app file but Hassette doesn't reload. - -Hot reload requires three things to be true at once: - -1. `watch_files = true` is set in `hassette.toml` -2. Your app files are mounted as volumes, not copied into the image -3. If `dev_mode = false`: `allow_reload_in_prod = true` is also set - -Add both settings to `hassette.toml`: - -```toml ---8<-- "pages/getting-started/docker/snippets/ts-hot-reload.toml" -``` - -Confirm your `docker-compose.yml` mounts the files rather than baking them in: - -```yaml ---8<-- "pages/getting-started/docker/snippets/ts-vol-mount.yml" -``` - -Files copied into the image at build time won't reflect host edits. Use volume mounts for any files you want hot reload to track. - -## Import Errors - -### Package Not Found - -**Symptom:** `ModuleNotFoundError: No module named 'xyz'` when your app starts. - -Add the package to your project dependencies: - -```toml ---8<-- "pages/getting-started/docker/snippets/ts-pyproject-dep.toml" -``` - -Then check the startup logs to confirm it installed: - -```bash ---8<-- "pages/getting-started/docker/snippets/ts-dep-install-logs.sh" -``` - -### Hassette Module Not Found - -**Symptom:** `ModuleNotFoundError: No module named 'hassette'` - -The startup script validates that Hassette is importable before doing anything else. If you see `ERROR: Failed to import hassette — the Docker image may be corrupt`, pull a fresh copy of the image: - -```bash -docker compose pull hassette -docker compose up -d -``` - -## Performance Issues - -### Slow Container Startup - -**Cause:** Installing many dependencies at each startup with no cached packages. - -Mount a persistent `uv` cache volume so packages don't re-download on every start: - -```yaml ---8<-- "pages/getting-started/docker/snippets/ts-uv-cache-vol.yml" -``` - -`uv.lock` also speeds up resolution. Packages are already pinned, so `uv` skips the resolver entirely. - -For the fastest startup, pre-build a custom image with your dependencies installed. See [Known Limitations](dependencies.md#known-limitations) in the dependencies guide. - -### High Memory Usage - -Set a memory limit in `docker-compose.yml` to prevent unbounded host memory consumption: - -```yaml ---8<-- "pages/getting-started/docker/snippets/ts-memory-limit.yml" -``` - -If the container hits the limit and restarts repeatedly, check your apps for memory leaks. Common causes are accumulating state in module-level variables or unbounded queues. - ## Getting Help -If you're still stuck, collect diagnostic information first: - -```bash ---8<-- "pages/getting-started/docker/snippets/ts-diagnostics.sh" -``` - -Then search [existing issues](https://github.com/NodeJSmith/hassette/issues). Someone else may have hit the same problem. If not, open a new issue and include the diagnostic output. +If none of the above resolved your issue: -## See Also +- Search [GitHub Issues](https://github.com/NodeJSmith/hassette/issues) for your error message. Someone may have hit the same thing. +- Open a new issue with your `docker compose logs hassette` output and your `compose.yml` (redact your token). -- [Docker Setup](index.md) -- [Managing Dependencies](dependencies.md) -- [Image Tags](image-tags.md) +For problems not specific to Docker (app logic, bus subscriptions, scheduler behavior), see the main [Troubleshooting](../../troubleshooting.md) page. From 162be32a1be309d8a48f3bd04dbdca3ca542c6a0 Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Tue, 2 Jun 2026 15:20:32 -0500 Subject: [PATCH 041/160] docs: redesign all outlines with JTBD framework Applied the Jobs to Be Done framework to all 67 non-getting-started outlines. Each outline now has: - Page type classification - Reader's job (one sentence) - What was cut and where it goes - Structure ordered by reader need, not API surface Key structural changes across sections: Bus: filtering reordered by decision flow, handlers cut DI overlap, overview demoted BusSyncFacade Scheduler: overview leads with 3 common patterns instead of trigger types table, methods extracted shared params to one table API: entities dissolved Terminology section, services leads with turn_on/turn_off, managing-helpers leads with bootstrap pattern Apps: configuration absorbed into overview, task-bucket demoted threading primitives to collapsible sections States: subscribing redesigned around progression (basic to filtered), type-registry restructured around single job (register converter) Migration: overview leads with Quick Reference Table, bus leads with name= requirement, scheduler leads with method equivalents table CLI: jq scripting moved from configuration to workflows Recipes: all converted from bullet lists to prose in How It Works, all got Verify It's Working sections Testing: quickstart separated from harness reference, factories reordered to lead with state factories Web UI: all pages redesigned from feature-oriented to task-oriented Troubleshooting: operational knowledge moved to Operating section --- .../070-doc-overhaul/outlines/cli/commands.md | 75 +++++++--- .../outlines/cli/configuration.md | 53 +++++-- .../070-doc-overhaul/outlines/cli/overview.md | 25 +++- .../outlines/cli/workflows.md | 48 +++++-- .../outlines/core-concepts/api/entities.md | 85 +++++++----- .../core-concepts/api/managing-helpers.md | 81 +++++++---- .../outlines/core-concepts/api/overview.md | 48 ++++--- .../outlines/core-concepts/api/services.md | 45 ++++-- .../outlines/core-concepts/api/utilities.md | 67 ++++++--- .../core-concepts/apps/configuration.md | 25 +++- .../outlines/core-concepts/apps/lifecycle.md | 58 ++++++-- .../outlines/core-concepts/apps/overview.md | 105 ++++++++++---- .../core-concepts/apps/task-bucket.md | 57 +++++--- .../outlines/core-concepts/architecture.md | 52 +++++-- .../core-concepts/bus/custom-extractors.md | 62 ++++++--- .../core-concepts/bus/dependency-injection.md | 61 +++++++-- .../outlines/core-concepts/bus/filtering.md | 96 ++++++++----- .../outlines/core-concepts/bus/handlers.md | 89 +++++++----- .../outlines/core-concepts/bus/overview.md | 42 ++++-- .../core-concepts/bus/predicate-reference.md | 70 ++++++++-- .../outlines/core-concepts/cache/overview.md | 53 ++++--- .../outlines/core-concepts/cache/patterns.md | 63 +++++---- .../configuration/applications.md | 44 ++++-- .../core-concepts/configuration/overview.md | 64 +++++---- .../core-concepts/database-telemetry.md | 61 ++++++--- .../internals/architecture-data-flow.md | 57 ++++++-- .../core-concepts/internals/lifecycle.md | 67 ++++++--- .../core-concepts/internals/overview.md | 11 +- .../internals/service-details.md | 50 +++++-- .../core-concepts/scheduler/management.md | 72 ++++++++-- .../core-concepts/scheduler/methods.md | 86 ++++++++---- .../core-concepts/scheduler/overview.md | 43 +++--- .../core-concepts/states/custom-states.md | 91 ++++++------ .../core-concepts/states/domain-states.md | 10 +- .../outlines/core-concepts/states/overview.md | 75 ++++++---- .../core-concepts/states/state-registry.md | 92 ++++++++----- .../core-concepts/states/subscribing.md | 88 ++++++++---- .../core-concepts/states/type-registry.md | 120 ++++++++++------ .../specs/070-doc-overhaul/outlines/home.md | 74 +++++++--- .../outlines/migration/api.md | 45 +++--- .../outlines/migration/bus.md | 59 +++++--- .../outlines/migration/concepts.md | 38 ++++-- .../outlines/migration/configuration.md | 49 ++++--- .../outlines/migration/overview.md | 52 ++++--- .../outlines/migration/scheduler.md | 37 ++--- .../outlines/migration/testing.md | 36 +++-- .../outlines/operating/log-levels.md | 55 ++++---- .../outlines/operating/overview.md | 60 ++++---- .../outlines/operating/upgrading.md | 24 ++-- .../outlines/recipes/daily-notification.md | 52 +++++-- .../recipes/debounce-sensor-changes.md | 55 ++++++-- .../outlines/recipes/motion-lights.md | 44 +++++- .../outlines/recipes/overview.md | 35 ++++- .../outlines/recipes/sensor-threshold.md | 52 +++++-- .../outlines/recipes/service-call-reaction.md | 53 +++++-- .../outlines/recipes/vacation-mode-toggle.md | 52 +++++-- .../outlines/testing/concurrency.md | 60 ++++++-- .../outlines/testing/factories.md | 88 +++++++++--- .../outlines/testing/overview.md | 122 +++++++++++++---- .../outlines/testing/quickstart.md | 58 ++++++-- .../outlines/testing/time-control.md | 49 +++++-- .../troubleshooting/troubleshooting.md | 129 ++++++++++++------ .../outlines/web-ui/debug-handler.md | 68 ++++++--- .../outlines/web-ui/inspect-config-code.md | 43 ++++-- .../070-doc-overhaul/outlines/web-ui/logs.md | 53 +++++-- .../outlines/web-ui/manage-apps.md | 51 +++++-- .../outlines/web-ui/overview.md | 60 ++++++-- 67 files changed, 2873 insertions(+), 1171 deletions(-) diff --git a/design/specs/070-doc-overhaul/outlines/cli/commands.md b/design/specs/070-doc-overhaul/outlines/cli/commands.md index 38cb538c0..13a735483 100644 --- a/design/specs/070-doc-overhaul/outlines/cli/commands.md +++ b/design/specs/070-doc-overhaul/outlines/cli/commands.md @@ -1,24 +1,67 @@ # CLI — Command Reference -**Status:** Exists (408 lines), comprehensive reference, voice polish needed +**Status:** Exists (408 lines), comprehensive reference **Voice mode:** Reference — terse, tabular, scannable +**Page type:** Reference +**Reader's job:** Look up the exact flags, output format, and API endpoint for a specific CLI command. + +## What was cut (and where it goes) + +Nothing cut. This is a lookup reference — completeness is the point. The existing +page is well-organized by command with consistent structure (description, output +example, flags table, API endpoint). + +Anti-mirror check: the command order (run, status, app, listener, job, log, execution, +event, dashboard, config, telemetry) follows a rough frequency-of-use ordering, not +source-code order. `run` and `status` first because they are the first commands any +user runs. `app` and `listener` next because they are the primary inspection commands. +This is correct from the reader's perspective. + +The Shared Flags section at the bottom consolidates cross-cutting flags (`--since`, +`--instance`, `--json`, `--limit`, `--source-tier`) — the reader can look up format +details once rather than per-command. This is the right structure for a reference page. ## Outline -Full reference for every CLI command. Keep current structure — it's a lookup reference. - -### H2: `hassette run` — start the server -### H2: `hassette status` — system overview -### H2: `hassette app` — app management -Subcommands: health, activity, config, source. -### H2: `hassette listener` — listener inspection, invocation history -### H2: `hassette job` — scheduler job inspection, execution history -### H2: `hassette log` — log querying -### H2: `hassette execution` — execution detail -### H2: `hassette dashboard` — dashboard overview -### H2: `hassette config` — config inspection -### H2: `hassette telemetry` — telemetry management -### H2: Shared Flags — `--since`, `--instance`, `--json`, `--limit`, `--source-tier` +### H2: `hassette run` +Start the server. Flags table (--token, --base-url, --verify-ssl, --dev-mode). + +### H2: `hassette status` +System health summary. Output example, API endpoint. + +### H2: `hassette app` +App listing + subcommands (health, activity, config, source). Subcommand table, then +each subcommand with output example and flags. + +### H2: `hassette listener` +Listener listing + invocation history by ID. Output example, flags table, API +endpoints. + +### H2: `hassette job` +Job listing + execution history by ID. Output example, flags table, API endpoints. + +### H2: `hassette log` +Recent log entries. Output example, flags table, API endpoint. + +### H2: `hassette execution` +Logs for a specific execution UUID. Flags, API endpoint. + +### H2: `hassette event` +Recent HA events from the in-memory buffer. Output example, flags, API endpoint. + +### H2: `hassette dashboard` +App grid health summary. Output example, API endpoint. + +### H2: `hassette config` +Resolved Hassette configuration. API endpoint. + +### H2: `hassette telemetry` +Telemetry database statistics. Output example, API endpoint. + +### H2: Shared Flags +Cross-cutting flags table: `--app`, `--instance`, `--since`, `--limit`, +`--source-tier`, `--json`. `--since` format details (relative durations, absolute +timestamps). `--instance` resolution (index vs name, requires `--app`). ## Snippet Inventory @@ -26,5 +69,5 @@ No code snippets — CLI output examples are inline. ## Cross-Links -- **Links to:** CLI overview, Workflows (how commands compose) +- **Links to:** CLI overview, Workflows (how commands compose), Configuration & Scripting (output modes) - **Linked from:** CLI overview, Operating (runbook commands reference these) diff --git a/design/specs/070-doc-overhaul/outlines/cli/configuration.md b/design/specs/070-doc-overhaul/outlines/cli/configuration.md index d88a58b70..515b8200c 100644 --- a/design/specs/070-doc-overhaul/outlines/cli/configuration.md +++ b/design/specs/070-doc-overhaul/outlines/cli/configuration.md @@ -1,28 +1,61 @@ # CLI — Configuration & Scripting -**Status:** Exists (231 lines), solid content, voice polish needed +**Status:** Exists (231 lines), needs JTBD reorder **Voice mode:** Reference/procedural hybrid +**Page type:** Reference +**Reader's job:** Set up the CLI for their environment (remote instance, shell completion, scripting) and know how to handle errors in scripts. + +## What was cut (and where it goes) + +The "Scripting with jq" section (6 recipes + 2 scripts) moves to Workflows. That +content answers "how do I investigate a problem?" not "how do I configure the CLI?" +Keeping it here forces the reader to find workflow content on a configuration page. + +What stays: Configuration (discovery order, token), Output Modes (human/JSON/NO_COLOR), +Shell Completion, Error Handling. These are all setup-and-reference content. ## Outline ### H2: Configuration -#### H3: Discovery Order — how CLI finds hassette.toml -#### H3: Token — where CLI reads the HA token from +#### H3: Discovery Order +How the CLI finds the server address: env vars, .env file, hassette.toml, default. +Tip for remote instances (env var or .env file). + +#### H3: Token +Not required for CLI query commands — only for `hassette run`. Brief. ### H2: Output Modes -#### H3: Human-Readable (Default) — formatted tables -#### H3: JSON (`--json`) — machine-readable output -#### H3: `NO_COLOR` — disabling color output +#### H3: Human-Readable (Default) +Tables for collections, panels for single objects. Piped output strips ANSI and +disables truncation. + +#### H3: JSON (`--json`) +Structured output. Full response model (superset of table). One JSON document on +stdout. Exit code semantics with `--json`. + +#### H3: `NO_COLOR` +Disable ANSI color output. ### H2: Shell Completion #### H3: Generate to stdout +`--generate-completion` for zsh/bash/fish. + #### H3: Install to default location +`--install-completion --shell zsh`. Auto-detect behavior. ### H2: Error Handling -#### H3: Exit Codes — table of exit codes -#### H3: Common Errors — connection refused, auth failed, etc. +#### H3: Exit Codes +Table: 0 (success), 1 (server/usage error), 2 (network error). + +#### H3: Common Errors +Connection refused, request timed out, unknown instance name — each with the error +message and what to do. + #### H3: JSON Error Format +Error objects in JSON mode. Two examples (network error, server error). + #### H3: Debug Mode (`--debug`) +Full HTTP response on errors. Human mode and JSON mode examples. ## Snippet Inventory @@ -30,5 +63,5 @@ No code snippets — shell command examples are inline. ## Cross-Links -- **Links to:** CLI overview, Commands -- **Linked from:** CLI overview +- **Links to:** CLI overview, Commands, Workflows (jq recipes moved there) +- **Linked from:** CLI overview, Commands (output modes reference) diff --git a/design/specs/070-doc-overhaul/outlines/cli/overview.md b/design/specs/070-doc-overhaul/outlines/cli/overview.md index 1801cb4da..fddec968b 100644 --- a/design/specs/070-doc-overhaul/outlines/cli/overview.md +++ b/design/specs/070-doc-overhaul/outlines/cli/overview.md @@ -1,19 +1,36 @@ # CLI — Overview -**Status:** Exists (67 lines), brief intro, voice polish needed +**Status:** Exists (67 lines), structure is good **Voice mode:** Getting-started — "you" allowed, quick orientation +**Page type:** Getting-started (section landing) +**Reader's job:** Learn what the CLI can do and see it working in 30 seconds — so they know whether to use it or the web UI. + +## What was cut (and where it goes) + +Nothing cut. The existing page is lean and well-scoped: three example commands, a +connection error example, and links to deeper pages. This already matches the +reader's job. Adding JTBD metadata only. + +The one structural note: the Quick Start section shows `status`, `app`, and `log` but +not `dashboard` — which is arguably more useful as a "find the problem" starting point. +Consider swapping `log` for `dashboard` since the reader's first question after +"is it running?" is "are my apps healthy?" not "what do the logs say?" ## Outline ### H2: Quick Start -`hassette run`, `hassette status`, `hassette app` — the three commands you'll use daily. +Three commands with output: `hassette status` (is it running?), `hassette app` +(what apps are loaded?), `hassette log --limit 5` (what happened recently?). Connection +error example when Hassette is not running. Link to Configuration for remote instances. ### H2: Next Steps -→ Command Reference, → Workflows, → Configuration & Scripting +- Command Reference — every command with flags and output examples +- Workflows — how to drill down from status to root cause +- Configuration & Scripting — JSON mode, jq recipes, shell completion ## Snippet Inventory -No code snippets — CLI output examples may be inline. +No code snippets — CLI output examples are inline. ## Cross-Links diff --git a/design/specs/070-doc-overhaul/outlines/cli/workflows.md b/design/specs/070-doc-overhaul/outlines/cli/workflows.md index 604f33954..353af36d3 100644 --- a/design/specs/070-doc-overhaul/outlines/cli/workflows.md +++ b/design/specs/070-doc-overhaul/outlines/cli/workflows.md @@ -1,30 +1,58 @@ # CLI — Workflows -**Status:** Exists (139 lines), solid content, voice polish needed +**Status:** Exists (139 lines), needs JTBD reorder + jq content absorbed **Voice mode:** Getting-started/procedural — "you" allowed, step-by-step +**Page type:** Getting-started (procedural) +**Reader's job:** Diagnose a problem using the CLI — starting from "something is wrong" and ending at "here is what happened." + +## What was cut (and where it goes) + +Nothing cut. Content added: the jq scripting recipes move here from +cli/configuration.md, since they are workflow content ("how do I investigate?"), not +configuration content ("how do I set up the CLI?"). + +The existing page order is redesigned. The current page leads with the drill-down +workflow, then monitoring, then quick health checks, then time windows. The reader +who lands here is in one of two modes: "something is wrong right now" or "I want to +set up ongoing monitoring." Quick health checks serve the first mode better than a +5-step drill-down, so they move up front. ## Outline +### H2: Quick Health Checks +One-liner commands for common checks. The reader wants a fast answer: +- Is Hassette running? (`hassette status --json | jq ...`) +- Are all apps healthy? (`hassette dashboard --json | jq ...`) +- Any listeners with errors? (`hassette listener --json | jq ...`) +- What happened recently? (`hassette log --since 1h --limit 50`) + ### H2: Drill-Down: From Status to Root Cause -Numbered steps: status → find problem app → inspect listeners → view history → read logs. +The full investigation workflow, numbered steps: +1. `hassette status` — is the system ok? +2. `hassette dashboard` — which app has errors? +3. `hassette listener --app ` — which handler is failing? +4. `hassette listener --since 1h` — what do the invocations look like? +5. `hassette execution ` — full trace for one invocation. ### H2: Monitoring a Specific App -Focused monitoring patterns. Multi-instance apps. - -### H2: Quick Health Checks -One-liner commands for common checks: running? all healthy? errors? recent activity? +Focused monitoring with `--app` filters across all commands. Multi-instance apps +with `--instance`. ### H2: Comparing Time Windows -Before/after comparison patterns. +`--since` with different durations to compare current vs baseline vs trend. ### H2: Scripting with `jq` -Recipes for piping `--json` output to jq. Health check script, alerting on error rate. (Moved from cli/configuration.md — this is workflow content, not configuration.) +(Moved from cli/configuration.md) +Recipes for piping `--json` output to jq: +- Extract fields, filter by status, count failures +- Health check script (exit 1 if not ok) +- Alerting on error rate (dashboard + jq) ## Snippet Inventory -No code snippets — CLI command sequences. +No code snippets — CLI command sequences are inline. ## Cross-Links -- **Links to:** Commands (individual command details), Web UI/Debug Handler (UI alternative) +- **Links to:** Commands (individual command details), Configuration & Scripting (output modes, error handling), Web UI overview (browser alternative) - **Linked from:** CLI overview, Operating, Troubleshooting diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/api/entities.md b/design/specs/070-doc-overhaul/outlines/core-concepts/api/entities.md index e6facc0ff..83e7856c9 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/api/entities.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/api/entities.md @@ -1,59 +1,70 @@ # API — Entities & States -**Status:** Exists (72 lines), solid content, voice polish needed +**Status:** Rewrite from blank **Voice mode:** Concept — system-as-subject, no "you" +**Page type:** Concept +**Reader's job:** Retrieve the current state of an entity from Home Assistant, at the right level of detail for their use case. + +## What was cut (and where it goes) + +- **API vs StateManager** — removed from this page. The overview page already covers this decision, and repeating it here splits the reader's attention. A one-line callout linking back to the overview is enough. +- **Terminology as a standalone section** — dissolved. The three levels (value, state, entity) are introduced inline as each method is shown, not front-loaded as abstract definitions before the reader has seen any code. +- **Generic type parameters** (`BaseEntity[StateT, StateValueT]`) — cut from the main flow. This is implementation detail relevant to framework contributors or advanced users extending entity types. Mention in a collapsible section under Entities if needed. +- **Synchronous entity access** (`entity.sync`) — belongs in a collapsible note, not a full section. Sync usage is rare and covered at the overview level. ## Outline -### H2: Terminology -Three levels of abstraction — match the existing docs terminology section: -- **State Value** (`get_state_value`) — the raw value string, what HA calls `state.state` (e.g., `"on"`, `"23.5"`). Cheapest call when attributes/timestamps aren't needed. -- **State** (`get_state`) — full snapshot: value + typed attributes + timestamps + context. A `BaseState` subclass (e.g., `LightState`). The `.value` field holds the state value, coerced to the domain's type (e.g., `bool` for lights). -- **Entity** (`get_entity`) — wraps a state + adds action methods (`turn_on()`, `turn_off()`, `toggle()`, `refresh()`). A `BaseEntity` subclass (e.g., `LightEntity`). Requires an explicit model type argument. +### H2: (Opening — no heading) +One sentence: the API retrieves entity state directly from Home Assistant over the network. Three methods cover three levels of detail — pick the one that matches what the code needs. + +### H2: Get the Value +`get_state_value(entity_id)` returns the raw state string (`"on"`, `"23.5"`, `"above_horizon"`). The cheapest call when attributes and timestamps are not needed. + +Snippet: one-liner showing `get_state_value`. + +### H2: Get the Full State +`get_state(entity_id)` returns a typed `BaseState` subclass (e.g., `LightState`) with `.value`, `.attributes`, `.last_changed`, `.last_updated`, `.context`. The `.value` field is coerced to the domain's Python type (e.g., `bool` for lights, `float` for sensors). -### H2: Retrieving States -#### H3: Full State Object -`get_state(entity_id)` → typed `BaseState` subclass with `.value`, `.attributes`, `.last_changed`, etc. `get_state_or_none()` → returns `None` instead of raising. `get_state_raw()` → raw `HassStateDict` without type conversion. -#### H3: Just the Value -`get_state_value(entity_id)` → the raw state string only (what HA calls `state.state`). Skips model conversion — use when attributes and timestamps aren't needed. -#### H3: Single Attribute -`get_attribute(entity_id, attribute)` → one attribute value, supports dot-path for nested attributes. -#### H3: Checking Existence -`entity_exists(entity_id)` for boolean check; `get_state_or_none()` for optional return. +Snippet: `get_state` with attribute access. -### H2: Retrieving Multiple States -`get_states()` — returns all entities (no filtering parameter). `get_states_raw()` for raw dicts. +#### H3: Optional Lookup +`get_state_or_none(entity_id)` returns `None` instead of raising when the entity does not exist. `entity_exists(entity_id)` for a boolean check. -### H2: Retrieving Entities -`get_entity(entity_id, model)` → typed `BaseEntity` subclass. Wraps the state object and adds domain-specific service methods (e.g., `LightEntity.turn_on(brightness=255)`). `get_entity_or_none(entity_id, model)` → returns `None` instead of raising. Requires passing the entity model class explicitly — the API does not auto-resolve entity types. +#### H3: Raw Dict +`get_state_raw(entity_id)` returns the untyped `HassStateDict` — useful when working outside the type registry or debugging. -#### H3: Entity Properties -`.state` — the underlying typed state object. `.value` — shortcut to `state.value`. `.entity_id`, `.domain` — identity fields. `.api` — direct access to the `Api` instance. `.hassette` — access to the `Hassette` coordinator instance. +### H2: Get an Entity +`get_entity(entity_id, model)` wraps the state in a `BaseEntity` subclass that adds domain-specific action methods (`turn_on()`, `turn_off()`, `toggle()`, `refresh()`). Requires passing the entity model class explicitly. -#### H3: Refreshing Entity State -`entity.refresh()` — re-fetches state from HA and updates the entity's state object in place. +Snippet: `get_entity` with a `LightEntity` showing `.turn_on(brightness=255)`. -#### H3: Synchronous Entity Access -`entity.sync` (`BaseEntitySyncFacade`) — mirrors action methods as blocking calls. Available for sync contexts. +#### H3: Refreshing State +`entity.refresh()` re-fetches from HA and updates the entity's state in place. -#### H3: Generic Type Parameters -`BaseEntity[StateT, StateValueT]` — entities are generic over their state type and value type. Domain entity subclasses (e.g., `LightEntity`) bind these parameters to the corresponding state class. +### H2: Fetching Multiple States +`get_states()` retrieves all entities in one call. `get_states_raw()` for raw dicts. No filtering parameter — filter in Python after fetching. -### H2: When to Use Which -- **`get_state_value`** — just need the value, nothing else -- **`get_state`** — need attributes, timestamps, or the typed value (most common) -- **`get_entity`** — need to call services on the entity (turn_on, turn_off, etc.) +### H2: Which Method to Use +Short decision table (3 rows): +- Need just the value string -> `get_state_value` +- Need attributes, timestamps, or typed value -> `get_state` (most common) +- Need to call services on the entity -> `get_entity` -### H2: API vs StateManager -Expanded comparison: when to hit HA directly vs use the local cache. +### H2: See Also +Links to States overview (local cache), API overview, Services. ## Snippet Inventory -| Snippet | Status | Notes | +| Snippet | Decision | Notes | |---|---|---| -| Relevant files from `api/snippets/` | Review | Entity access examples | +| `api_get_state.py` | Keep | Full state example | +| `api_get_state_raw.py` | Keep | Raw dict example | +| `api_check_existence.py` | Keep | Optional lookup | +| `api_get_entity.py` | Keep | Entity with actions | +| `api_get_states.py` | Keep | Bulk fetch | +| New: `api_get_state_value.py` | Create | Simple value retrieval — currently missing | ## Cross-Links -- **Links to:** States overview, State Registry, API overview -- **Linked from:** API overview +- **Links to:** States overview (local cache), API overview, Services +- **Linked from:** API overview, Apps overview diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/api/managing-helpers.md b/design/specs/070-doc-overhaul/outlines/core-concepts/api/managing-helpers.md index 8ebffea9d..6e04cf743 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/api/managing-helpers.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/api/managing-helpers.md @@ -1,51 +1,78 @@ # API — Managing Helpers -**Status:** Stub (3 lines), content moving from Advanced (168 lines) +**Status:** Rewrite from blank **Voice mode:** Concept — system-as-subject, no "you" +**Page type:** Concept +**Reader's job:** Create and manage HA helpers (input_boolean, input_number, counter, timer, etc.) from their app, typically during startup to self-provision persistent entities. + +## What was cut (and where it goes) + +- **Typed Models section (the table of Record/Create/Update params)** — demoted to a collapsible section. Most readers want to create a helper, not understand the Pydantic model hierarchy. The model details matter when debugging serialization or understanding `exclude_unset` behavior, so they stay on the page but below the fold. +- **Per-domain method lists** (create_input_boolean, create_input_number, etc.) — the old outline listed each CRUD verb as its own H2, which mirrors the source code, not the reader's task. Consolidated into one section showing the pattern with one domain, then a reference table of all 8 domains. +- **Testing with the Harness** — kept but moved toward the end. Readers land here to create helpers, not to test them. Testing is the second job. ## Outline -Content source: `docs/pages/advanced/managing-helpers.md` +### H2: (Opening — no heading) +HA helpers (`input_boolean`, `input_number`, `counter`, `timer`, etc.) are persistent entities stored in HA's `.storage/`. Apps create and manage them through typed `Api` methods — 32 CRUD methods across 8 domains, plus 3 counter shortcuts. + +### H2: Creating a Helper on Startup +The most common pattern: create-if-not-exists in `on_initialize`. Show the idempotent bootstrap pattern (list, check, create) as the primary example. This is the snippet most readers will copy. + +Snippet: bootstrap pattern from `crud_operations.py:bootstrap`. -### H2: Typed Models -HA helper types (InputBoolean, InputNumber, etc.) as typed Pydantic models. +Warning callout: concurrent provisioning and HA's silent auto-suffix behavior. Mitigation is naming discipline (prefix with app name), not retry logic. -### H2: Creating a Helper -Per-type methods: `create_input_boolean()`, `create_input_number()`, etc. +### H2: CRUD Operations +Show create, list, update, delete using one domain (`input_boolean`) as the example. The pattern is identical across all 8 domains. -### H2: Listing Helpers -Per-type methods: `list_input_booleans()`, `list_input_numbers()`, `list_counters()`, `list_timers()`, etc. +#### H3: Create +Snippet: `create_input_boolean(CreateInputBooleanParams(...))`. -### H2: Updating a Helper -Per-type methods: `update_input_boolean()`, `update_input_number()`, etc. +#### H3: List +Snippet: `list_input_booleans()`. -### H2: Deleting a Helper -Per-type methods: `delete_input_boolean()`, `delete_input_number()`, etc. +#### H3: Update +`update_*` takes a `helper_id` (the stored id, not the display name) and a partial params object. Only fields passed are sent to HA. -### H2: Idempotent Bootstrap (The Simple Pattern) -Create-if-not-exists pattern for app initialization. +#### H3: Delete +Returns `None`. Raises `FailedMessageError(code="not_found")` if the id is absent. -### H2: Counter Service-Call Shortcuts -`increment_counter()`, `decrement_counter()`, `reset_counter()` for counter helpers. +#### H3: All Supported Domains +Reference table: 8 domains (input_boolean, input_number, input_text, input_select, input_datetime, input_button, counter, timer) with their create/list/update/delete method names. -### H2: Testing with the Harness -How `RecordingApi` handles helper operations in tests. +### H2: Counter Shortcuts +`increment_counter`, `decrement_counter`, `reset_counter` operate on the live entity state (not stored config). They take effect immediately. + +Note: timer actions (`timer.start`, `timer.pause`, `timer.cancel`) are not wrapped — call them via `call_service` directly. The asymmetry is intentional: counter operations are high-frequency; timer actions are one-off. + +### H2: Testing +`AppTestHarness.seed_helper(record)` pre-populates the harness store. Domain is derived from the record class. + +Snippet: harness test example. ### H2: Gotchas -Known limitations and edge cases (HA API quirks). +??? collapsible. Consolidated list: +- HA auto-suffixes on name collision (no error, silent `_2` suffix) +- `CreateInputDatetimeParams` requires `has_date=True` or `has_time=True` +- `exclude_unset=True` vs explicit `None` — omitting a field vs passing `None` produce different wire payloads +- `CounterRecord` vs `CounterState` — config vs runtime +- Helper creation persists across HA restarts +- `RetryableConnectionClosedError` as a second exception class + +??? collapsible: Typed Models detail (Record/CreateParams/UpdateParams table with extra policies). ## Snippet Inventory -Moving from `advanced/snippets/managing-helpers/` (5 files): -| Snippet | Status | Notes | +| Snippet | Decision | Notes | |---|---|---| -| `create_helper.py` | Move | → `api/snippets/` | -| `crud_operations.py` | Move | | -| `counter_shortcuts.py` | Move | | -| `testing_harness.py` | Move | | -| `timer_call_service.py` | Move | | +| `create_helper.py` | Move to `api/snippets/` | Single create example | +| `crud_operations.py` | Move to `api/snippets/` | List/update/delete/bootstrap | +| `counter_shortcuts.py` | Move to `api/snippets/` | Counter operations | +| `testing_harness.py` | Move to `api/snippets/` | Harness seed example | +| `timer_call_service.py` | Move to `api/snippets/` | Timer via call_service | ## Cross-Links -- **Links to:** API overview, Testing (harness), Apps lifecycle (bootstrap in on_initialize) +- **Links to:** API overview, Services (call_service for timer actions), Testing (harness), Apps lifecycle (bootstrap in on_initialize) - **Linked from:** API overview diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/api/overview.md b/design/specs/070-doc-overhaul/outlines/core-concepts/api/overview.md index dc47073de..4b6562e24 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/api/overview.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/api/overview.md @@ -1,35 +1,51 @@ -# API — Overview +# API Overview -**Status:** Exists (70 lines), concise, voice polish needed +**Status:** Rewrite from blank **Voice mode:** Concept — system-as-subject, no "you" +**Page type:** Concept +**Reader's job:** Understand what `self.api` is, when to use it vs `self.states`, and find the right subpage for their specific task. + +## What was cut (and where it goes) + +- **Detailed error handling** — the existing page lists three exception types and says "network errors are automatically retried." That is the right level for an overview. No change needed. +- **Synchronous usage** — kept but demoted to a collapsible section. Most readers use async; sync is a special case that shouldn't occupy equal visual weight on the landing page. ## Outline -### H2: (Opening) -What the API handle provides: async interface to HA REST and WebSocket APIs. Available as `self.api` on every app. +### H2: (Opening — no heading) +One-sentence definition: `self.api` is the async interface to Home Assistant's REST and WebSocket APIs. Available on every app. Handles auth, retries, and type conversion. + +Mermaid diagram showing App -> Api -> HA (keep existing diagram, it earns its space). + +### H2: Quick Example +Minimal snippet showing the two most common operations: reading state and calling a service. This answers "what does using it look like?" before anything else. -### H2: Usage -Basic `call_service`, `get_state`, `get_states` patterns. +### H2: API vs StateManager +This is the first decision the reader faces: should I even be on this page? Lead with the answer: prefer `self.states` for reading state (cached, sync, fast). Use `self.api` when fresh-from-HA data is needed, or for writes (service calls, set_state, helpers). + +Short table: StateManager vs API — access pattern, latency, use case. ### H2: Error Handling -What happens when HA is unreachable, timeouts, error responses. +Three exception types. Network errors retried automatically. Catch `HassetteError` as the base. ### H2: Synchronous Usage -`self.api.sync` for sync contexts (rare). - -### H2: API vs StateManager -When to use `self.api.get_state()` vs `self.states.get()`. API = fresh from HA; StateManager = cached local state. +??? collapsible. `self.api.sync` for `AppSync` contexts. Warning about deadlock if called from event loop. ### H2: Next Steps -→ Entities & States, → Services, → Managing Helpers, → Utilities +Links to subpages, ordered by frequency of use: +- Entities & States (reading data) +- Services (calling services) +- Managing Helpers (CRUD for input_boolean, counters, etc.) +- Utilities (history, templates, calendars) ## Snippet Inventory -| Snippet | Status | Notes | +| Snippet | Decision | Notes | |---|---|---| -| Files from `api/snippets/` (14 total) | Review | Assign per-page | +| `api_overview_usage.py` | Keep | Core overview example | +| `api_sync_usage.py` | Keep | Sync usage collapsible | ## Cross-Links -- **Links to:** Entities, Services, Managing Helpers, Utilities, States overview -- **Linked from:** Architecture, Apps overview +- **Links to:** Entities & States, Services, Managing Helpers, Utilities, States overview +- **Linked from:** Architecture, Apps overview, Getting Started diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/api/services.md b/design/specs/070-doc-overhaul/outlines/core-concepts/api/services.md index 9842c264d..a4dcdaf4a 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/api/services.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/api/services.md @@ -1,26 +1,49 @@ # API — Services -**Status:** Exists (35 lines), brief, voice polish needed +**Status:** Rewrite from blank **Voice mode:** Concept — system-as-subject, no "you" +**Page type:** Concept +**Reader's job:** Call a Home Assistant service from their app — turn on a light, send a notification, trigger an automation. + +## What was cut (and where it goes) + +- Nothing significant cut. The existing page is already lean. The rewrite restructures to lead with the most common pattern (convenience helpers) before the generic `call_service`, since most readers want to turn something on/off and the convenience helpers are the right answer for that. ## Outline -### H2: Basic Service Calls -`call_service(domain, service, target=None, return_response=False, **data)` — service data is passed as `**kwargs`, NOT a positional dict. `target` is a separate parameter for entity targeting. +### H2: (Opening — no heading) +One sentence: the API calls Home Assistant services — any action a service domain exposes (turning devices on/off, sending notifications, running scripts). + +### H2: Turning Things On and Off +`turn_on(entity_id)`, `turn_off(entity_id)`, `toggle_service(entity_id)` cover the most common case. Show the simplest snippet first. + +Snippet: `turn_on("light.porch")`, `turn_off("switch.fan")`. + +Note: these convenience methods call the `homeassistant` domain service by default. For domain-specific service data (e.g., `brightness` for lights), pass `domain="light"` explicitly. + +### H2: Generic Service Calls +`call_service(domain, service, target=None, return_response=False, **data)` handles any service. Service data is passed as keyword arguments, not a positional dict. `target` is a separate parameter for entity/area/device targeting. + +Snippet: `call_service("notify", "mobile_app", message="Motion detected")` and a light example with `brightness` and `color_temp`. + +### H2: Getting a Response +Some services return data (e.g., `weather.get_forecasts`). Pass `return_response=True` to receive a `ServiceResponse` dict. Without it, the return value is `None`. -### H2: Convenience Helpers -`turn_on`, `turn_off`, `toggle_service` — all default to `domain="homeassistant"` (the deprecated generic HA service). Docs should warn to pass the correct domain (e.g., `domain="light"`). +Snippet: service call with `return_response=True`. -### H2: Service Responses -`return_response=True` changes return type from `None` to `ServiceResponse`. This is opt-in. +### H2: See Also +- Entities & States (reading state after a service call) +- Bus `on_call_service` (reacting to service calls from other sources) ## Snippet Inventory -| Snippet | Status | Notes | +| Snippet | Decision | Notes | |---|---|---| -| Relevant files from `api/snippets/` | Review | Service call examples | +| `api_call_service.py` | Keep | Generic call_service | +| `api_helpers.py` | Keep | Convenience helpers | +| `api_response.py` | Keep | Service response | ## Cross-Links -- **Links to:** API overview, Bus handlers (on_call_service for reacting to service calls) -- **Linked from:** API overview, Recipes +- **Links to:** API overview, Entities & States, Bus (on_call_service) +- **Linked from:** API overview, Apps overview, Recipes diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/api/utilities.md b/design/specs/070-doc-overhaul/outlines/core-concepts/api/utilities.md index 76539edc1..2ee9dbec0 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/api/utilities.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/api/utilities.md @@ -1,37 +1,70 @@ # API — Utilities -**Status:** Exists (81 lines), reference-style, voice polish needed -**Voice mode:** Reference — terse, system-as-subject +**Status:** Rewrite from blank +**Voice mode:** Concept — system-as-subject, no "you" +**Page type:** Concept (reference-leaning) +**Reader's job:** Find the right API method for a less common task — rendering a template, fetching history, firing an event, writing a synthetic state, or querying calendars. + +## What was cut (and where it goes) + +- **Discovery methods** (`get_config`, `get_services`, `get_panels`) — cut from the outline. These are rarely used in automations and are better served by the API reference (mkdocstrings). Mentioning them in a "Discovery" collapsible adds clutter for a page where most readers want templates or history. +- **"Other Endpoints" grab-bag heading** — dissolved. Each method now has a short, descriptive heading that tells the reader what it does, not where it lives in the API surface. ## Outline +### H2: (Opening — no heading) +One sentence: beyond states and services, the API exposes templates, history, calendars, and event-firing. This page covers the less frequent but still useful methods. + ### H2: Templates -`render_template()` — render HA Jinja2 templates. Accepts a `variables` dict for template context. +`render_template(template, variables=None)` evaluates a Jinja2 template on the HA server. Useful when HA already knows how to compute something (averaging sensors, complex conditionals) and pulling all raw data into Python would be wasteful. + +Snippet: template rendering with variables. ### H2: History -`get_history()` — retrieve entity history. Parameters: `significant_changes_only` (filter to meaningful changes), `minimal_response` (delta-encoded entries, smaller payload), `no_attributes` (omit attribute data). `get_histories()` for batch retrieval of multiple entities. +`get_history(entity_id, start_time, end_time)` retrieves recorded state changes for one entity. Useful for trend analysis, energy reporting, or automations that depend on past sensor readings. + +`get_histories(entity_ids, start_time, end_time)` for batch retrieval. Passing a comma-separated string to `get_history` raises `ValueError`. + +Optional flags: `significant_changes_only`, `minimal_response`, `no_attributes`. + +Snippet: history retrieval. ### H2: Logbook -`get_logbook()` — retrieve logbook entries. +`get_logbook(entity_id, start_time, end_time)` retrieves human-readable log entries. Both `start_time` and `end_time` are required (unlike `get_history` where `end_time` is optional). + +Snippet: logbook query. + +### H2: Firing Events +`fire_event(event_type, **data)` sends an event to HA's event bus. Any HA automation or integration subscribed to that event type receives it. + +Note callout: for in-process broadcast between Hassette apps, use `self.bus.emit()` instead — it stays local, is faster, and keeps data typed. + +Snippet: fire_event. + +### H2: Writing Synthetic State +`set_state(entity_id, state, attributes=None)` writes a state entry to HA's state machine. Does not control a real device — use it for virtual sensors, exposing computed values to the HA dashboard, or sharing state between apps via HA. + +Existing attributes are merged: only pass keys to change. + +Note callout: synthetic states are not persisted across HA restarts. Restore them in `on_initialize` if needed. + +Snippet: set_state. -### H2: Discovery -#### H3: `get_config` — retrieve HA configuration (version, location, units, components) -#### H3: `get_services` — list all available services and their fields -#### H3: `get_panels` — list HA frontend panels +### H2: Calendars +`get_calendars()` lists all calendar entities. `get_calendar_events(calendar_id, start_time, end_time)` fetches events within a time window. -### H2: Other Endpoints -#### H3: `fire_event` — fire custom HA events -#### H3: `set_state` — override entity state -#### H3: `get_calendars` — list calendars -#### H3: `get_calendar_events` — retrieve calendar events +Snippet: calendar query. ## Snippet Inventory -| Snippet | Status | Notes | +| Snippet | Decision | Notes | |---|---|---| -| Relevant files from `api/snippets/` | Review | Utility method examples | +| `api_template.py` | Keep | Template rendering | +| `api_history.py` | Keep | History retrieval | +| `api_logbook.py` | Keep | Logbook query | +| `api_utilities.py` | Keep | fire_event, set_state, calendars (section markers) | ## Cross-Links -- **Links to:** API overview +- **Links to:** API overview, Bus (emit for in-process events), Apps overview (on_initialize for restoring set_state), Cache (for persisting data across restarts) - **Linked from:** API overview diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/apps/configuration.md b/design/specs/070-doc-overhaul/outlines/core-concepts/apps/configuration.md index 794fdcb82..e351a504e 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/apps/configuration.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/apps/configuration.md @@ -1,13 +1,24 @@ # Apps — Configuration -**Status:** ABSORBED into `apps/overview.md`. Content becomes an H2 in the Apps overview. +**Status:** ABSORBED into `apps/overview.md` +**Voice mode:** N/A +**Page type:** N/A (content merged) +**Reader's job:** N/A -At 34 lines (3 base fields + env prefix + secrets), this doesn't justify its own page. The Apps overview already has "Defining an App" — config class definition belongs there. +## Rationale -Content to fold into apps/overview.md: -- AppConfig subclass with SettingsConfigDict and env_prefix -- Base fields: `instance_name`, `log_level`, `app_key` (+ reserved prefix validator) +At 34 lines (3 base fields + env prefix + secrets), this does not justify its own page. The reader's job ("define typed config for my app") is a step within "write an app," not a standalone task. Creating a separate page forces the reader to navigate away from the app definition to understand config, then navigate back. + +Content folded into the Apps overview as an H2 "Configuration": +- `AppConfig` subclass with `SettingsConfigDict` and `env_prefix` +- Base fields: `instance_name`, `log_level` - `extra="allow"` behavior, `env_ignore_empty=True` -- Secrets & env vars via Pydantic BaseSettings +- Secrets and env vars via Pydantic `BaseSettings` + +The existing `configuration.md` doc page should redirect to the Apps overview Configuration section, or be replaced by the merged content. The TOML registration side stays on its own page at Configuration/Applications. + +## Snippet Inventory -See decision in outline audit (2026-06-02). +Snippets move to Apps overview: +- `app_config_definition.py` — used in overview H2: Configuration +- `app_config_env_prefix.py` — used in overview H2: Configuration diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/apps/lifecycle.md b/design/specs/070-doc-overhaul/outlines/core-concepts/apps/lifecycle.md index f7306618a..178534aac 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/apps/lifecycle.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/apps/lifecycle.md @@ -1,27 +1,67 @@ # Apps — Lifecycle -**Status:** Exists (80 lines), concise, voice polish needed +**Status:** Rewrite from blank **Voice mode:** Concept — system-as-subject, no "you" +**Page type:** Concept +**Reader's job:** Know which hooks to override for startup and shutdown logic, and understand what Hassette cleans up automatically so they do not duplicate that work. + +## What was cut (and where it goes) + +- **AppSync lifecycle table** — demoted to a collapsible section at the bottom. Most readers use async `App`. The sync variant is a lookup reference for the few who need it, not a primary learning path. +- **The "do not override `initialize`/`shutdown`/`cleanup`" warning** — kept and promoted slightly. This is a real trap (raises `CannotOverrideFinalError`) that readers hit when they guess at method names. ## Outline +### H2: (Opening — no heading) +One sentence: every app goes through initialization and shutdown. Hassette manages the resource lifecycle; the app declares what to do at each stage. + ### H2: Initialization -`on_initialize` → `on_shutdown` sequence. What each hook is for. Registration happens in `on_initialize`. +During startup, Hassette transitions the app through `STARTING -> RUNNING`. All core services (API, Bus, Scheduler, database) are ready before any hook runs. + +Three hooks fire in order: +1. `before_initialize` +2. `on_initialize` — the main hook. Register handlers, schedule jobs, provision helpers here. +3. `after_initialize` + +Snippet: `on_initialize` with handler registration and scheduler setup. + +Note: no `super()` call needed — base implementations are empty. ### H2: Shutdown -`on_shutdown` hook. Cleanup order. +During shutdown or reload, Hassette transitions through `STOPPING -> STOPPED`. + +Three hooks fire in order: +1. `before_shutdown` +2. `on_shutdown` +3. `after_shutdown` + +Implement `on_shutdown` only when the app has external resources to release (open files, raw sockets, external connections). For bus subscriptions, scheduler jobs, and task bucket tasks, Hassette handles cleanup automatically. ### H2: Automatic Cleanup -How Hassette cleans up bus subscriptions and scheduler jobs when an app shuts down. +After the shutdown hooks complete, Hassette: +- Cancels all bus subscriptions created by `self.bus` +- Cancels all scheduled jobs created by `self.scheduler` +- Cancels all background tasks tracked by `self.task_bucket` + +This means `on_shutdown` does not need to manually unsubscribe or cancel jobs. + +Warning: do not override `initialize`, `shutdown`, or `cleanup` directly. These are internal methods marked `@final`. Attempting to override them raises `CannotOverrideFinalError` at class load time. Use the `on_*` hooks instead. + +### H2: Synchronous Lifecycle +??? collapsible. `AppSync` uses `_sync` suffixed hooks. Table mapping async to sync variants. The bus, scheduler, and API are async — reach them via `.sync` facades from sync hooks. + +Snippet: `AppSync.on_initialize_sync` with `.sync` facade calls. -### H2: AppSync -`AppSync` base class for synchronous apps. Lifecycle hooks have `_sync` variants (`on_initialize_sync`, `on_shutdown_sync`). +Warning: overriding `on_initialize` (async) in an `AppSync` raises `NotImplementedError`. Override `on_initialize_sync` instead. ## Snippet Inventory -Snippets from `apps/snippets/` that demonstrate lifecycle hooks — review and assign. +| Snippet | Decision | Notes | +|---|---|---| +| `lifecycle_hooks.py` | Keep | Main initialization example | +| New: `lifecycle_sync.py` | Create | AppSync lifecycle (currently inline in existing page) | ## Cross-Links -- **Links to:** Apps overview, Task Bucket, Bus overview (registration in on_initialize) -- **Linked from:** Apps overview (next steps) +- **Links to:** Apps overview, Task Bucket (shutdown behavior), Bus overview (registration in on_initialize) +- **Linked from:** Apps overview (Next Steps) diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/apps/overview.md b/design/specs/070-doc-overhaul/outlines/core-concepts/apps/overview.md index c2cac5a74..708d5f3d9 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/apps/overview.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/apps/overview.md @@ -1,54 +1,103 @@ -# Apps — Overview +# Apps Overview -**Status:** Exists (186 lines), solid content, voice polish needed +**Status:** Rewrite from blank **Voice mode:** Concept — system-as-subject, no "you" +**Page type:** Concept +**Reader's job:** Understand what an app is, write a minimal one, and discover the handles available for building automations. + +## What was cut (and where it goes) + +- **Configuration absorbed into overview** — the old outline absorbed the configuration page (34 lines) as an H2. Keeping it. The content (AppConfig subclass, env_prefix, base fields, secrets) is small enough that a separate page creates navigation overhead for no benefit. It fits naturally after "Defining an App." +- **"Core Capabilities" as a bulleted link list** — restructured. The existing page has a list of 8 handles with one-line descriptions, then a "Common Use Cases" section with code snippets. The problem: the link list is pure navigation (belongs in Next Steps), and the use-case snippets duplicate content from the Bus, Scheduler, API, and Cache pages. The rewrite keeps 3-4 short snippets that show the handles working together in one app, not isolated patterns that each subpage covers better. +- **Synchronous Apps** — kept as a collapsible section. Most readers use async `App`. The existing page already uses `??? note` for this, which is right. ## Outline -### H2: Structure -App[Config] generic, five handles (bus, scheduler, api, states, cache), logger. +### H2: (Opening — no heading) +One sentence: an app is a Python class that reacts to events and controls devices. Each app has its own config, state, and a set of handles for interacting with HA. + +Mermaid diagram: App -> [Api, Bus, Scheduler, States, Cache] (keep existing). ### H2: Defining an App -Minimal app example, AppConfig usage. +Minimal app example: subclass `App[MyConfig]`, override `on_initialize`, register a handler, call a service. This is the anchor example — the reader sees the full shape before any explanation. + +Snippet: `example_app.py`. + +Brief DI callout (keep existing `!!! info` about `D.StateNew`). -*Absorbs content from the former `apps/configuration.md` (34 lines):* -- AppConfig subclass with `SettingsConfigDict` and `env_prefix` -- Base fields: `instance_name`, `log_level`, `app_key` (reserved prefix validator) -- `extra="allow"` (arbitrary config without defined fields), `env_ignore_empty=True` -- Secrets & env vars via Pydantic `BaseSettings` -- Link to Configuration/Applications for the TOML registration side +### H2: Configuration +AppConfig subclass with `SettingsConfigDict` and `env_prefix`. Base fields: `instance_name`, `log_level`. Secrets via env vars. `extra="allow"` for arbitrary config. + +Snippet: `app_config_definition.py` and `app_config_env_prefix.py`. + +Link to Configuration/Applications for the TOML registration side. ### H2: Dates and Times -`whenever` library usage for date/time in apps. `self.now()` returns the current `ZonedDateTime`. +`self.now()` returns a `ZonedDateTime` (from the `whenever` library). All scheduler parameters, persistent storage, and state definitions use `whenever` types. + +Brief explanation of why `whenever` over stdlib `datetime` (immutable, always timezone-aware). + +Snippet: `apps_whenever_dates.py`. + +### H2: What an App Can Do +3-4 short snippets showing the most common patterns, each with a one-line intro and a link to the full page. Ordered by what a new user needs first: + +#### H3: React to Events +`self.bus.on_state_change(...)` -> Bus page. + +#### H3: Schedule Jobs +`self.scheduler.run_every(...)` -> Scheduler page. + +#### H3: Read Entity States +`self.states.light["kitchen"]` -> States page. -### H2: Core Capabilities -Brief overview linking to each capability's page: -#### H3: Reacting to Events -#### H3: Run Recurring Jobs -#### H3: Check Entity States #### H3: Call Services -#### H3: Persist Data Between Restarts -#### H3: Run Background Tasks and Blocking Code +`self.api.call_service(...)` -> API page. + +`await` warning callout (keep existing — forgetting `await` is a real trap). + +#### H3: Persist Data +`self.cache.get(...)` / `self.cache.set(...)` -> Cache page. + +#### H3: Run Background Work +`self.task_bucket.spawn(...)` -> Task Bucket page. + +### H2: Restricting to a Single App +`@only_app` decorator for development isolation. Remove before deploying. + +### H2: Broadcasting Between Apps +`Bus.emit(topic, data)` for in-process inter-app events. `self.bus.on(topic=...)` to subscribe. Events stay local, are not persisted. -### H2: Restricting to a Single App During Development -`@only_app` decorator to isolate one app without editing config. +Snippet: sender and receiver apps. -### H2: Broadcasting Events Between Apps -`Bus.emit()` for inter-app communication. +Self-delivery note (app receives its own events — filter with a `source` field). ### H2: Synchronous Apps -`AppSync` variant for apps where async adds unnecessary complexity or doesn't fit the libraries in use (e.g., `requests`, blocking database clients). +??? collapsible. `AppSync` for blocking libraries. `_sync` lifecycle hooks. `.sync` facades for bus, scheduler, API. Prefer async `App` whenever possible. ### H2: Next Steps -Links to all sibling and handle pages: Lifecycle, Configuration, Task Bucket, Bus overview, Scheduler overview, States overview, API overview, Cache overview. +- Lifecycle — `on_initialize`, `on_shutdown`, automatic cleanup +- Task Bucket — background tasks, thread offloading ## Snippet Inventory -| Snippet | Status | Notes | +| Snippet | Decision | Notes | |---|---|---| -| 15 files in `apps/snippets/` | Review | Check each for voice, DI-first alignment | +| `example_app.py` | Keep | Primary defining example | +| `app_config_definition.py` | Keep | Config class definition | +| `app_config_env_prefix.py` | Keep | Env var injection | +| `app_config.toml` | Keep | TOML registration | +| `apps_whenever_dates.py` | Keep | Date/time usage | +| `apps_subscribe_state_change.py` | Keep | Bus snippet for "What an App Can Do" | +| `apps_run_hourly.py` | Keep | Scheduler snippet | +| `apps_check_state.py` | Keep | States snippet | +| `apps_call_service.py` | Keep | API snippet | +| `apps_cache_counter.py` | Keep | Cache snippet | +| `apps_task_bucket.py` | Keep | Task bucket snippet | +| `apps_bus_emit.py` | Keep | Inter-app broadcast | +| `apps_only_app.py` | Keep | Development isolation | ## Cross-Links -- **Links to:** Lifecycle, Configuration, Task Bucket, Bus overview, Scheduler overview, States overview, API overview, Cache overview +- **Links to:** Lifecycle, Task Bucket, Bus overview, Scheduler overview, States overview, API overview, Cache overview, Configuration/Applications (TOML side) - **Linked from:** Architecture, First Automation, Recipes diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/apps/task-bucket.md b/design/specs/070-doc-overhaul/outlines/core-concepts/apps/task-bucket.md index d901a5128..03c643e29 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/apps/task-bucket.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/apps/task-bucket.md @@ -1,41 +1,64 @@ # Apps — Task Bucket -**Status:** Exists (70 lines), good content, voice polish needed +**Status:** Rewrite from blank **Voice mode:** Concept — system-as-subject, no "you" +**Page type:** Concept +**Reader's job:** Run async work outside the current handler, or call blocking code without freezing the event loop. + +## What was cut (and where it goes) + +- **Cross-thread communication (4 methods)** — demoted to a collapsible section. `post_to_loop()`, `run_sync()`, `run_on_loop_thread()`, and `create_task_on_loop()` are advanced threading primitives most readers never need. The two common jobs (spawn a background task, offload blocking code) should dominate the page. Advanced threading is a lookup reference for the few who need it. +- **`add(task)` and `pending_tasks()`** — cut from the main outline. These are low-level internals (register an externally-created task, snapshot pending tasks for drain/test helpers). They belong in the API reference, not in a concept page. If mentioned at all, a one-line note in the shutdown section is enough. +- **`make_async_adapter()`** — demoted below spawn and run_in_thread. It is a composition utility, not a primary pattern. Most readers never accept user-provided callbacks that could be sync or async. ## Outline +### H2: (Opening — no heading) +One sentence: `self.task_bucket` runs background work and offloads blocking calls to threads. All tracked tasks are cancelled automatically on shutdown. + ### H2: Spawning Background Tasks -`self.task_bucket.spawn()` for fire-and-forget async work. +`spawn(coro)` fires off a coroutine that runs independently of the current handler. The bucket tracks it — no need to store the handle. Returns the `asyncio.Task` for manual inspection or cancellation if needed. + +Snippet: spawn a background task. ### H2: Offloading Blocking Code -`self.task_bucket.run_in_thread()` for sync/blocking calls (file I/O, HTTP libraries without async). +`run_in_thread(fn, *args)` runs a synchronous function in a thread pool. Await the result. Use for anything that blocks: HTTP clients without async, database drivers, file I/O, CPU-bound work. + +Snippet: run_in_thread with a blocking HTTP call. -### H2: Adapting Sync Callables to Async -`self.task_bucket.make_async_adapter()` for wrapping sync handlers so they run in the executor automatically. +### H2: Normalizing Sync/Async Callables +??? collapsible or brief section. `make_async_adapter(fn)` wraps any callable (sync or async) into a consistent async callable. Sync functions route through `run_in_thread()` automatically. Useful when the app accepts user-provided callbacks. + +Snippet: make_async_adapter. ### H2: Cross-Thread Communication +??? collapsible. Four methods for advanced threading scenarios: + #### H3: Posting to the Event Loop -`self.task_bucket.post_to_loop()` for thread-safe event loop posting. +`post_to_loop(fn)` schedules a callable on the main event loop from any thread. Use from inside `run_in_thread()` callbacks. + #### H3: Running Async from Sync Code -`self.task_bucket.run_sync()` — takes a coroutine object, not a callable. Raises `RuntimeError` if called from within a running event loop. +`run_sync(coro)` submits a coroutine to the event loop and blocks until it completes. Takes a coroutine object, not a callable. Warning: never call from the event loop thread — it deadlocks. Designed for `run_in_thread()` callbacks or `AppSync` methods. + #### H3: Running on the Loop Thread -`self.task_bucket.run_on_loop_thread()` — runs a sync function on the main event loop thread (for loop-affine code). +`run_on_loop_thread(fn)` runs a sync function on the main event loop thread (for loop-affine code). + #### H3: Creating Tasks from Any Context -`self.task_bucket.create_task_on_loop()` — creates a task on the loop from any context. +`create_task_on_loop(coro)` creates a task on the loop from any context. -### H2: Task Lifecycle -#### H3: `add(task)` — register an externally-created `asyncio.Task` -#### H3: `pending_tasks()` — snapshot of non-completed tasks (for drain/test helpers) +### H2: Shutdown +All tracked tasks are cancelled when the app shuts down. Hassette cancels every pending task, waits up to `task_cancellation_timeout_seconds` (configurable in global settings), and logs any tasks that do not respond to cancellation. -### H2: Shutdown Behavior -How pending tasks are handled during app shutdown. +No manual cleanup needed. ## Snippet Inventory -Snippets from `apps/snippets/` that demonstrate task bucket patterns — review and assign. +| Snippet | Decision | Notes | +|---|---|---| +| `apps_task_bucket.py` | Keep | spawn and run_in_thread | +| `apps_task_bucket_advanced.py` | Keep | make_async_adapter, post_to_loop, run_sync | ## Cross-Links -- **Links to:** Apps overview, Apps lifecycle (shutdown) -- **Linked from:** Apps overview (core capabilities) +- **Links to:** Apps overview, Lifecycle (shutdown order), Cache (for persisting data — task bucket is in-memory only) +- **Linked from:** Apps overview (What an App Can Do), Lifecycle diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/architecture.md b/design/specs/070-doc-overhaul/outlines/core-concepts/architecture.md index e6d5c4ade..1ecdc14d9 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/architecture.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/architecture.md @@ -1,32 +1,58 @@ # Architecture -**Status:** Exists (245 lines), structure solid, voice polish needed +**Status:** Exists (245 lines), structure needs JTBD redesign **Voice mode:** Concept — system-as-subject, no "you" +**Page type:** Concept (landing page) +**Reader's job:** Understand how Hassette is structured so they know which parts to learn and where their code fits. + +## What was cut (and where it goes) + +The existing page has two audiences fighting each other. App authors want to know +"what objects do I interact with?" and "how do they connect?" Framework contributors +want `depends_on` mechanics, wave ordering, cycle detection, and the full dependency +graph. The contributor content now lives in System Internals (internals/index.md). + +Removed from this page: +- `depends_on` code example, wave-based ordering explanation, cycle detection, + framework dependency graph Mermaid diagram, `EventStreamService` note, coordinator + gate vs service dependency. All moved to internals/index.md. +- `index_depends_on.py` snippet — moves to internals/index.md. + +What stays: the three Mermaid diagrams (high-level flow, core services, per-app +handles) — these answer the reader's actual question. The internal services list +stays as a collapsible section since it helps when reading debug logs. ## Outline ### H2: Hassette Architecture -Opening: what Hassette is and what it connects to. One paragraph. +Opening paragraph: what Hassette is and what it connects to. One sentence each for +apps, events, and resources — the three concepts from the current opening. -### H2: Diagrams -Three Mermaid diagrams (existing, keep): -1. High-level flow (HA ↔ Hassette ↔ Apps) -2. Core services inside Hassette -3. What each app gets (the five handles: Bus, Scheduler, Api, StateManager, Cache) +### H2: What Each App Gets +The four handles (Api, Bus, Scheduler, States) as a bulleted list with one-line +descriptions. This is the most important content on the page — it tells the reader +what objects they interact with. Mermaid diagram 3 (per-app handles) goes here, +directly after the list. -### H2: Startup -One sentence: "Hassette starts services in dependency order — your handles are ready by the time `on_initialize` runs." Links to System Internals for the full dependency graph, wave ordering, and cycle detection. +### H2: How It Fits Together +Mermaid diagram 1 (HA <-> Hassette <-> Apps) and diagram 2 (core services). Brief +prose connecting them. The internal services collapsible section goes under diagram 2. -**Removed from this page (moved to Internals):** `depends_on` code example, wave-based ordering explanation, cycle detection, framework dependency graph Mermaid diagram, EventStreamService note. These are framework plumbing, not app-author concerns. +### H2: Startup +One sentence: Hassette starts services in dependency order — the four handles are +ready by the time `on_initialize` runs. Link to System Internals for the full +dependency graph and wave ordering. ### H2: Deep Dive -Links to each core concept page. +Links to each core concept page and to System Internals. Collapsible section for +advanced topics (DI, type registry, state registry, custom states). ## Snippet Inventory -No code snippets — diagrams are inline Mermaid. The `index_depends_on.py` snippet moves to System Internals. +No code snippets — diagrams are inline Mermaid. The `index_depends_on.py` snippet +moves to internals/index.md. ## Cross-Links -- **Links to:** All core concept subsection overviews, System Internals +- **Links to:** Apps, Bus, Scheduler, API, States, Configuration, Web UI, System Internals, API Reference - **Linked from:** Home page, Getting Started (next steps) diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/bus/custom-extractors.md b/design/specs/070-doc-overhaul/outlines/core-concepts/bus/custom-extractors.md index 7ed5b5351..12d8d8767 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/bus/custom-extractors.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/bus/custom-extractors.md @@ -1,36 +1,58 @@ -# Bus — Custom Extractors +# Custom Extractors -**Status:** Stub (3 lines), content to be written in T07 -**Voice mode:** Concept/reference hybrid — system-as-subject, code-heavy +**Status:** Rewrite from blank +**Voice mode:** Concept — system-as-subject, no "you" +**Page type:** Concept (advanced) +**Reader's job:** Extract event data that the built-in `D.*` annotations don't cover. + +This is an advanced page. Most readers never need it — the built-in annotations handle common cases. The reader lands here because they have a specific piece of event data (a nested attribute, a custom event field, service data) that no built-in annotation extracts. They need to know: how do I write my own extractor, and how does it plug into the DI system? + +## What was cut (and where it goes) + +- **Type conversion details** (custom converters, the Type Registry itself) — the previous outline mixed extractor authoring with type conversion. Type conversion is a separate concern. This page covers only how extractors *interact* with converters via the `converter` field on `AnnotationDetails`. The Type Registry page covers everything else. ## Outline -### H2: Writing a Custom Extractor -How to implement the extractor protocol. When to write one (data not covered by built-in annotations). +### H2: When to Write a Custom Extractor +One paragraph: the built-in `D.*` annotations cover state values, entity IDs, domains, event data, and event context. A custom extractor is needed when the handler requires data from a different location in the event payload — for example, a specific key from `service_data`, a nested attribute, or a computed value derived from multiple event fields. + +### H2: Accessors (`A`) +Accessors are the simplest form of custom extraction. They point a predicate or `P.ValueIs` at a non-standard field. Show how `A.get_service_data_key("brightness")` works. This is custom extraction without writing a full extractor. + +Snippet: `custom_accessors.py`. + +### H2: Writing an Extractor +The `AnnotationDetails` dataclass: `extractor` (required callable that receives the event and returns a value) and `converter` (optional type converter). Show how to place it inside `Annotated[T, AnnotationDetails(...)]` and how the DI system in `extraction.py` discovers it from the handler's signature. + +Walk through one concrete example: extracting a brightness value from a state change event's attributes. + +Snippet: `custom_extractor_own.py`. + +### H2: How Built-In Extractors Work +Collapsible section. Show the internals of a built-in extractor (e.g., `D.StateNew`) to demystify the pattern. Readers who understand how the built-ins work can write their own with confidence. + +Snippet: `custom_extractor_builtin.py`. -### H2: Custom Accessors with `A` -How accessors work, creating custom field accessors for event data. +### H2: Adding Type Conversion +An extractor can declare a `converter` to automatically convert the extracted value. Show an extractor that extracts a raw string and converts it to a custom type. -### H2: AnnotationDetails -`AnnotationDetails` wraps an extractor (not "received by" it). Fields: `extractor` (required callable), `converter` (optional type converter). Placed inside `Annotated[T, AnnotationDetails(...)]` — the DI system (`extraction.py`) discovers it from the handler's signature. +Snippet: `custom_extractor_converter.py`. -General type conversion lives on the Type Registry page. This page covers only how extractors *interact* with the registry (e.g., calling converters from a custom extractor). +Link to Type Registry page for writing custom type converters. ## Snippet Inventory -Existing snippets in `dependency-injection/` that belong here: -| Snippet | Status | Notes | +| Snippet | Decision | Notes | |---|---|---| -| `custom_extractor_builtin.py` | Move here | Built-in extractor internals | -| `custom_extractor_converter.py` | Move here | Extractor with type converter | -| `custom_extractor_own.py` | Move here | Writing your own extractor | -| `custom_type_converter.py` | Move here | Custom type converter (or Type Registry?) | -| `custom_accessors.py` (from filtering/) | Move here | Accessor examples | +| `custom_accessors.py` (from `filtering/`) | Move here | Accessor examples | +| `custom_extractor_own.py` (from `dependency-injection/`) | Move here | Writing a custom extractor | +| `custom_extractor_builtin.py` (from `dependency-injection/`) | Move here | Built-in extractor internals | +| `custom_extractor_converter.py` (from `dependency-injection/`) | Move here | Extractor with type converter | **New snippets needed:** -- AnnotationDetails usage example +- `annotation_details_usage.py` — standalone `AnnotationDetails` usage showing the `Annotated[T, AnnotationDetails(...)]` pattern in a handler signature ## Cross-Links -- **Links to:** DI page (built-in annotations), Type Registry, State Registry -- **Linked from:** DI page (see also) +- **Links to:** DI page (built-in annotations), Type Registry (custom converters), State Registry +- **Linked from:** DI page ("See Also"), Filtering (accessor mention) diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/bus/dependency-injection.md b/design/specs/070-doc-overhaul/outlines/core-concepts/bus/dependency-injection.md index 359a0a80b..bf3f638bc 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/bus/dependency-injection.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/bus/dependency-injection.md @@ -1,24 +1,61 @@ -# Bus — Dependency Injection +# Dependency Injection -**Status:** REWRITTEN in T03 (exemplar). 98 lines. Done. +**Status:** Rewrite from blank **Voice mode:** Reference — tables-first, terse, system-as-subject +**Page type:** Reference (with concept intro) +**Reader's job:** Find the right `D.*` annotation for the data they need from an event. + +The existing page is well-structured for its reference job: quick example, then annotation tables grouped by category, then composition patterns. The reader lands here from the Handlers page or from a recipe, looking up "which annotation gives me X?" The tables-first approach is correct. + +## What was cut (and where it goes) + +Nothing cut. The previous outline was already rewritten as an exemplar and is complete. The JTBD metadata is added, and one structural note below. ## Outline -Already complete. Covers: annotation reference (state/identity/other extractors), combining annotations, union types, custom kwargs, handler signature restrictions. +### H2: (Opening) +One quick example showing a handler with `D.StateNew[T]` and `D.EntityId`. One sentence: "all annotations live in `hassette.dependencies`, available as `D`." + +### H2: Annotation Reference +Three tables, each with annotation, return type, and missing-value behavior: +#### H3: State Extractors +`D.StateNew[T]`, `D.StateOld[T]`, `D.MaybeStateNew[T]`, `D.MaybeStateOld[T]`. Snippet showing temperature delta calculation. + +#### H3: Identity Extractors +`D.EntityId`, `D.MaybeEntityId`, `D.Domain`, `D.MaybeDomain`. Snippet showing multi-entity routing. + +#### H3: Other Extractors +`D.EventData[T]`, `D.EventContext`, `D.TypedStateChangeEvent[T]`. Snippet showing `Bus.emit` usage with `EventData`. + +### H2: Combining Annotations +Multiple DI parameters in one handler. Snippet. + +### H2: Union Types +State extractors with union types for multi-domain handlers. Snippet. Link to State Registry. + +### H2: Custom Keyword Arguments +DI composes with `kwargs=` at registration. Snippet. + +### H2: Handler Signature Restrictions +No positional-only params, no `*args`. All DI params need annotations. -**Ensure reference table includes:** `D.MaybeStateNew`, `D.MaybeStateOld`, `D.MaybeEntityId`, `D.MaybeDomain`, `D.EventContext`, `D.TypedStateChangeEvent[T]`. +### H2: See Also +Custom Extractors, Handlers, State Registry, Type Registry. ## Snippet Inventory -All snippets written and tested in T03: -- `dependency-injection/quick_example.py` -- `dependency-injection/state_object_extractors.py` (temperature delta) -- `dependency-injection/identity_extractors.py` -- `dependency-injection/event_data_extractor.py` -- `dependency-injection/multiple_dependencies.py` -- `dependency-injection/union_types.py` -- `dependency-injection/mixing_kwargs.py` +All snippets written and tested (exemplar): +| Snippet | Decision | Notes | +|---|---|---| +| `dependency-injection/quick_example.py` | Keep | Opening example | +| `dependency-injection/state_object_extractors.py` | Keep | Temperature delta | +| `dependency-injection/identity_extractors.py` | Keep | Multi-entity routing | +| `dependency-injection/event_data_extractor.py` | Keep | `Bus.emit` + `EventData` | +| `dependency-injection/multiple_dependencies.py` | Keep | Combining annotations | +| `dependency-injection/union_types.py` | Keep | Union state types | +| `dependency-injection/mixing_kwargs.py` | Keep | DI + custom kwargs | + +No new snippets needed. ## Cross-Links diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/bus/filtering.md b/design/specs/070-doc-overhaul/outlines/core-concepts/bus/filtering.md index 515981703..7ac37672e 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/bus/filtering.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/bus/filtering.md @@ -1,51 +1,85 @@ -# Bus — Filtering & Predicates +# Filtering & Predicates -**Status:** Exists (255 lines), needs restructuring — most predicate/condition content is state-change-specific and may partially move to States/Subscribing +**Status:** Rewrite from blank **Voice mode:** Concept — system-as-subject, no "you" +**Page type:** Concept +**Reader's job:** Control which events trigger a handler, beyond simple entity matching. -## Outline +The existing page is organized by API surface (predicates, then conditions, then accessors), mixed with state-change-specific patterns. A reader lands here because their handler fires too often or on the wrong events. They need to learn: what filtering tools exist, and which one solves their problem. Order by the reader's decision flow: simplest filtering first (built-in parameters), then composition, then service-call filtering, then raw topic filtering. + +## What was cut (and where it goes) -### H2: How Filtering Works -Overview: predicates test events, conditions test values. Predicates compose with `AllOf` and `AnyOf`. +- **State-change-specific filtering** (`changed_to`, `changed_from`, `changed=False`, `P.StateFrom`/`P.StateTo`, `C.Increased`/`C.Decreased`) — stays on this page. The previous outline proposed moving these to a States/Subscribing page, but that page doesn't exist yet and these patterns are filtering patterns. Readers looking for "how do I filter state changes" will look here. Keep them, but order them as the simplest entry point. +- **Complete P/C/A reference tables** — moved to the Predicate Reference page. This page teaches the concepts and common patterns; the reference page is for lookup. +- **Custom accessors (`A`)** — brief mention with link to Custom Extractors page. The previous page had a full section that duplicated content. + +## Outline ### H2: Filtering State Changes -**Note:** Heavy overlap with States/Subscribing page. Decision: States/Subscribing covers the common state-change patterns (entity patterns, `changed` param, `changed_to`, `changed_from`, state-specific predicates). This page covers the general filtering mechanism and non-state-change filtering. +The most common case. Three built-in parameters that handle 80% of state-change filtering without predicates: +- `changed_to` — fire only when the new state matches a value, callable, or condition +- `changed_from` — fire only when the old state matches +- `changed=False` — fire on attribute-only changes too (default is state-value-only) + +Snippets: `filtering_simple_start.py`, `filtering_simple_stop.py`, `changed_false.py`. + +### H2: Conditions +Conditions are the value-level matchers passed to `changed_to`, `changed_from`, or predicates. Show the most common ones inline: +- `C.IsIn(["on", "home"])` — match from a set +- `C.Comparison(">", 75)` — numeric comparison +- `C.Increased()` / `C.Decreased()` — numeric direction (used with `changed=` or `P.StateComparison`) + +Snippets: `filtering_predicate_isin.py`, `filtering_predicate_lambda.py`, `filtering_increased_decreased.py`. -Content that stays here: -- How predicates compose (`AllOf`, `AnyOf`) -- General event filtering concept +Link to Predicate Reference for the full conditions table. + +### H2: Predicates and the `where` Parameter +When built-in parameters aren't enough, `where=` accepts a list of predicates (ANDed). Introduce `P.StateFrom`, `P.StateTo`, `P.AllOf`, `P.AnyOf`. + +Snippets: `filtering_state_from_to.py`, `filtering_combined_and.py`, `filtering_combined_or.py`. ### H2: Filtering Service Calls -Dictionary filtering and predicate filtering for `on_call_service`. +`on_call_service` filtering with dict-based matching (literal, presence, callable) and predicate-based (`P.ServiceMatches`, `P.ServiceDataWhere`). + +Snippets: `filtering_service_literal.py`, `filtering_service_presence.py`, `filtering_service_callable.py`, `filtering_service_predicates.py`, `filtering_service_matches.py`. + +### H2: Raw Topic Subscriptions +`on()` with custom topic strings and `where=` predicates. For event types not covered by helper methods. -### H2: Advanced Topic Subscriptions -`on()` with custom topic strings and predicates. +Snippet: `filtering_advanced_topics.py`. + +### H2: Custom Accessors +One paragraph: `A` (accessors) point predicates at non-standard fields. Brief example of `P.ValueIs` with a custom accessor. Link to Custom Extractors page for the full guide. + +Snippet: `custom_accessors.py` (brief inline). ### H2: Full Reference -→ Predicate, Condition & Accessor Reference page (`bus/predicate-reference.md`) for the complete P/C/A lookup tables. +Link to Predicate Reference page for the complete P/C/A lookup tables. ## Snippet Inventory -| Snippet | Status | Notes | +| Snippet | Decision | Notes | |---|---|---| -| `filtering_simple_start.py` | Move → States/Subscribing | State-change-specific | -| `filtering_simple_stop.py` | Move → States/Subscribing | State-change-specific | -| `filtering_predicate_lambda.py` | Keep | General predicate example | -| `filtering_predicate_isin.py` | Keep | Collection predicate | -| `filtering_combined_and.py` | Keep | Predicate composition | -| `filtering_combined_or.py` | Keep | Predicate composition | -| `filtering_service_literal.py` | Keep | Service call filtering | -| `filtering_service_callable.py` | Keep | Service call filtering | -| `filtering_service_predicates.py` | Keep | Service predicate | +| `filtering_simple_start.py` | Keep | `changed_to` basic | +| `filtering_simple_stop.py` | Keep | `changed_from` basic | +| `changed_false.py` | Keep | `changed=False` | +| `filtering_predicate_isin.py` | Keep | Collection condition | +| `filtering_predicate_lambda.py` | Keep | Comparison condition | +| `filtering_increased_decreased.py` | Keep | Numeric direction | +| `filtering_state_from_to.py` | Keep | `P.StateFrom`/`P.StateTo` | +| `filtering_combined_and.py` | Keep | Predicate composition AND | +| `filtering_combined_or.py` | Keep | Predicate composition OR | +| `filtering_service_literal.py` | Keep | Service dict filtering | | `filtering_service_presence.py` | Keep | Service presence check | -| `filtering_service_matches.py` | Keep | ServiceMatches predicate | -| `filtering_state_from_to.py` | Move → States/Subscribing | State-change-specific | -| `filtering_increased_decreased.py` | Move → States/Subscribing | State-change-specific | -| `filtering_advanced_topics.py` | Keep | Advanced topic subscription | -| `changed_false.py` | Move → States/Subscribing | State-change-specific | -| `custom_accessors.py` | Move | → Custom Extractors page | +| `filtering_service_callable.py` | Keep | Service callable filter | +| `filtering_service_predicates.py` | Keep | `P.ServiceDataWhere` | +| `filtering_service_matches.py` | Keep | `P.ServiceMatches` | +| `filtering_advanced_topics.py` | Keep | Raw topic subscription | +| `custom_accessors.py` | Keep | Brief accessor example | + +No new snippets needed. ## Cross-Links -- **Links to:** Predicate Reference (P/C/A tables), States/Subscribing (state-specific patterns), Custom Extractors (accessors), Handlers -- **Linked from:** Bus overview, States/Subscribing +- **Links to:** Predicate Reference (full tables), Custom Extractors (accessors in depth), Handlers, DI +- **Linked from:** Bus overview, Recipes diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/bus/handlers.md b/design/specs/070-doc-overhaul/outlines/core-concepts/bus/handlers.md index 1c7d1c653..a6b4075a6 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/bus/handlers.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/bus/handlers.md @@ -1,64 +1,79 @@ -# Bus — Writing Handlers +# Writing Event Handlers -**Status:** Exists (192 lines), needs restructuring to remove DI overlap +**Status:** Rewrite from blank **Voice mode:** Concept — system-as-subject, no "you" +**Page type:** Concept +**Reader's job:** Write a handler that receives the right data, handles errors, and registers correctly. + +The existing page mixes three concerns: how to write handlers, how DI works, and how registration works. Readers come here for one of two reasons: "how do I get data into my handler?" or "how do I handle errors and register reliably?" The DI details already have a dedicated page. This page should show the handler patterns (raw through DI), then cover the operational concerns (errors, timeouts, registration mechanics). + +## What was cut (and where it goes) + +- **DI annotation details** (combining multiple deps, available annotations, union types, custom kwargs) — already on the DI page. This page shows the progression from raw to DI, then links there for the full reference. Snippets `handlers_extract_data.py`, `handlers_multiple_dependencies.py`, and `handlers_custom_args.py` are reviewed below for overlap. +- **Non-state event types** were listed in the previous outline but never existed in the page. This is a catalog of subscription methods, not handler-writing guidance. Move to a new section on this page that covers "what events can handlers receive?" as a brief table with links. The detailed method signatures already live in the Bus class API reference. ## Outline -### H2: Event Model -What events look like: `RawStateChangeEvent`, `CallServiceEvent`, `Event`. The event dict structure. +### H2: Handler Patterns +The simplest-first progression. Each pattern gets a snippet and a one-sentence explanation of when to use it: +1. **No data needed** — handler takes no event params. Use for side-effect-only reactions. +2. **Raw event** — handler receives the untyped `Event` object. Use when exploring or when DI doesn't cover the event type. +3. **Typed state event** — handler receives `D.TypedStateChangeEvent[T]`. Use when both old and new states are needed together. +4. **Extracted data (recommended)** — handler receives specific fields via DI annotations (`D.StateNew[T]`, `D.EntityId`, etc.). Production default. -### H2: Raw Event Handlers -Handlers that receive the raw event dict. When to use: rare cases where DI doesn't cover the need, or when processing bulk events. Show the pattern. +Link to DI page for the full annotation reference and advanced patterns. ### H2: Non-State Event Types -Cover event types beyond state changes: -- `on_call_service` — reacting to service calls -- `on_service_registered` — reacting to new HA service registrations -- `on` — subscribing to raw HA event types (e.g., `event_triggered`, `automation_triggered`) -- `on_component_loaded` — HA component load events -- HA startup/shutdown events (`on_homeassistant_start`, `on_homeassistant_stop` — wrappers around `on_call_service` for `homeassistant` domain) -- Hassette internal events — typed helpers: `on_hassette_service_status`, `on_hassette_service_failed`, `on_hassette_service_crashed`, `on_hassette_service_started`, `on_websocket_connected`, `on_websocket_disconnected`, `on_app_state_changed`, `on_app_running`, `on_app_stopping` -- `Bus.emit()` for broadcasting Hassette-internal events between apps +Brief table of subscription methods beyond `on_state_change`: +- `on_attribute_change` — attribute value changes +- `on_call_service` — HA service calls +- `on_component_loaded` — HA component loads +- `on_service_registered` — new HA service registrations +- `on_homeassistant_start` / `on_homeassistant_stop` / `on_homeassistant_restart` — HA lifecycle +- `on` — any raw HA event topic +- `emit()` — Hassette-internal broadcast between apps + +Hassette-internal event helpers (one table): `on_hassette_service_status`, `on_hassette_service_failed`, `on_hassette_service_crashed`, `on_hassette_service_started`, `on_websocket_connected`, `on_websocket_disconnected`, `on_app_state_changed`, `on_app_running`, `on_app_stopping`. ### H2: Error Handling #### H3: App-Level Error Handler -`Bus.on_error()` registration method. +`bus.on_error(handler)` — applies to all listeners without a per-registration handler. Register as the first statement in `on_initialize()` to avoid the reload gap. #### H3: Per-Registration Error Handler -`on_error=` parameter on subscription methods. +`on_error=` parameter on subscription methods — takes precedence over app-level. #### H3: What `BusErrorContext` Contains -Fields: `topic`, `listener_name`, `event`, plus inherited fields from `ErrorContext` (`exception`, `traceback`). +Table: `topic`, `listener_name`, `event`, plus `exception` and `traceback` from `ErrorContext` base. ### H2: Timeout Configuration -`timeout=` and `timeout_disabled=` options on subscription methods. +`timeout=` overrides the global default per listener. `timeout_disabled=True` disables enforcement entirely. Brief snippet. -### H2: Subscription Mechanics -#### H3: The `name=` Parameter (Required) -Why it's required, what it's used for (telemetry, logging, idempotent registration). -#### H3: Registration Is Complete When the Awaited Call Returns -`db_id` is immediately valid. No background registration task. +### H2: Registration Mechanics +#### H3: The `name=` Parameter +Required on all DB-registered listeners. Natural key: `(app_key, instance_index, name, topic)`. `ListenerNameRequiredError` when omitted. `DuplicateListenerError` when colliding within a session. +#### H3: Registration Completes Synchronously +`db_id` is valid immediately when the awaited call returns. No background task. #### H3: Sequential Operations Are Deterministic -Registration order guarantees. +Cancel-then-resubscribe has no race conditions. ## Snippet Inventory -| Snippet | Status | Notes | +| Snippet | Decision | Notes | |---|---|---| -| `handlers_no_data.py` | Keep | Raw handler example | -| `handlers_extract_data.py` | Review | May overlap with DI page — reassign if so | -| `handlers_multiple_dependencies.py` | Review | Likely belongs on DI page now | -| `handlers_custom_args.py` | Review | May belong on DI page (custom kwargs) | -| `bus_error_handler_app.py` | Keep | App-level error handler | -| `bus_error_handler_per_reg.py` | Keep | Per-registration error handler | -| `bus_subscription_patterns.py` | Keep | Subscription mechanics | -| `bus_registration_identity.py` | Keep | name= parameter, identity | -| `bus_timeouts.py` | Keep | Timeout configuration | -| `first_automation_step3_raw.py` (from getting-started) | New claim | Raw handler example from getting-started, now lives here | +| `handlers_no_data.py` | Keep | Pattern 1 | +| `handlers_raw_event.py` | Keep | Pattern 2 | +| `handlers_typed_event.py` | Keep | Pattern 3 | +| `handlers_extract_data.py` | Keep | Pattern 4 — brief DI example here, full details on DI page | +| `handlers_multiple_dependencies.py` | Drop from this page | Lives on DI page | +| `handlers_custom_args.py` | Drop from this page | Lives on DI page (`mixing_kwargs.py`) | +| `bus_error_handler_app.py` | Keep | Error handling | +| `bus_error_handler_per_reg.py` | Keep | Error handling | +| `bus_subscription_patterns.py` | Keep | Registration mechanics | +| `bus_registration_identity.py` | Keep | name= parameter | +| `bus_timeouts.py` | Keep | Timeout config | **New snippets needed:** -- Non-state event handler examples (on_call_service, on("event_triggered"), internal events, HA lifecycle events) +- Non-state event handler examples (at least one `on_call_service` handler snippet, one `on("event_type")` snippet, one Hassette-internal event snippet) ## Cross-Links -- **Links to:** DI page (for typed annotations), Filtering (for predicates), States/Subscribing (for state-specific patterns) +- **Links to:** DI page (annotation reference), Filtering (predicates), States/Subscribing (state-specific patterns) - **Linked from:** Bus overview, Apps overview diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/bus/overview.md b/design/specs/070-doc-overhaul/outlines/core-concepts/bus/overview.md index 423c61b90..a46155cc3 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/bus/overview.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/bus/overview.md @@ -1,21 +1,45 @@ -# Bus — Overview +# Bus -**Status:** REWRITTEN in T03 (exemplar). 94 lines. Done. +**Status:** Rewrite from blank **Voice mode:** Concept — system-as-subject, no "you" +**Page type:** Concept (landing page) +**Reader's job:** Understand what the bus does and learn the basic patterns for subscribing to events. + +## What was cut (and where it goes) + +- Synchronous usage (`BusSyncFacade`) was listed in the previous outline but never written. It belongs as a callout or collapsible section on this page, not a full H2 — most readers use async and don't need it until they hit `AppSync` hooks. One paragraph with a link to the Apps page suffices. ## Outline -Already complete. Covers: subscription methods table, matching multiple entities (glob patterns), rate control (debounce, throttle, once as separate explain→show blocks). +### H2: Subscribing to Events +The core job: register a handler, receive typed data. One code example showing `on_state_change` with DI. Then the four-method table (`on_state_change`, `on_attribute_change`, `on_call_service`, `on`). Mention `name=` is required. Link to Handlers for the full event type catalog. -## Snippet Inventory +### H2: Matching Multiple Entities +Glob patterns for entity IDs, domains, and services. Short snippet showing `"light.*"` and `"sensor.bedroom_*"`. One warning: globs match identifiers only, not attribute names or data values — link to Filtering for those. -All snippets written and tested in T03: -- `bus_basic_subscribe.py` — DI-first subscription example -- `bus_glob_patterns.py` — glob pattern matching -- `bus_rate_control.py` — three section markers (debounce, throttle, once) +### H2: Rate Control +Three parameters that limit handler invocation frequency, each with a one-sentence explanation and a minimal snippet: +- `debounce` — wait until quiet for N seconds +- `throttle` — at most once per N seconds +- `once=True` — fire once then auto-cancel + +Warning: mutually exclusive. ### H2: Synchronous Usage -`self.bus.sync` (`BusSyncFacade`) — mirrors all subscription methods as blocking calls for `AppSync` hooks. +One paragraph: `self.bus.sync` (`BusSyncFacade`) mirrors all subscription methods as blocking calls for `AppSync` hooks. Link to Apps page. + +### H2: Next Steps +Links to: Handlers, Filtering, Dependency Injection. + +## Snippet Inventory + +| Snippet | Decision | Notes | +|---|---|---| +| `bus_basic_subscribe.py` | Keep | DI-first subscription example | +| `bus_glob_patterns.py` | Keep | Glob pattern matching | +| `bus_rate_control.py` | Keep | Three section markers (debounce, throttle, once) | + +No new snippets needed. ## Cross-Links diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/bus/predicate-reference.md b/design/specs/070-doc-overhaul/outlines/core-concepts/bus/predicate-reference.md index c1c7317f2..e935fc575 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/bus/predicate-reference.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/bus/predicate-reference.md @@ -1,24 +1,78 @@ -# Bus — Predicate, Condition & Accessor Reference +# Predicate, Condition & Accessor Reference -**Status:** New page (split from bus/filtering.md "Complete Reference" section) +**Status:** Rewrite from blank **Voice mode:** Reference — tabular, terse, system-as-subject +**Page type:** Reference +**Reader's job:** Look up the exact predicate, condition, or accessor for a filtering task. + +This is a pure lookup page. The reader arrives from the Filtering page (or from a recipe) knowing they need a predicate or condition but unsure of the exact name or signature. Every entry needs: name, one-line description, and which event types it works with. + +## What was cut (and where it goes) + +- **Usage examples and explanations** — these live on the Filtering page. This page has no prose beyond one-sentence descriptions per entry. If a reader needs to learn how predicates work, they go to Filtering first. +- **`StateFromTo`** — does NOT exist. Use separate `P.StateFrom` + `P.StateTo`. Note this explicitly. +- **`C.InRange`** — does NOT exist. Use `C.Comparison` for range checks. Note this explicitly. ## Outline ### H2: Predicates (`P`) -Full reference table. Include: `AllOf`, `AnyOf`, `Not`, `Guard`, `StateFrom`, `StateTo`, `StateDidChange`, `StateComparison`, `AttrFrom`, `AttrTo`, `AttrDidChange`, `AttrComparison`, `DidChange`, `IsPresent`, `IsMissing`, `ValueIs`, `EntityMatches`, `DomainMatches`, `ServiceMatches`, `ServiceDataWhere` (with `from_kwargs` classmethod and `auto_glob` param). Note: `StateFromTo` does NOT exist — use separate `StateFrom` + `StateTo`. +Tables grouped by purpose: + +#### H3: Logic Combinators +`P.AllOf`, `P.AnyOf`, `P.Not`, `P.Guard`. Works with: any event. + +#### H3: Value / Field Matching +`P.ValueIs`, `P.DidChange`, `P.IsPresent`, `P.IsMissing`. Works with: any event. + +#### H3: Entity / Domain / Service Matching +`P.DomainMatches`, `P.EntityMatches`, `P.ServiceMatches`, `P.ServiceDataWhere` (note `from_kwargs` classmethod and `auto_glob` param). Works with: `HassEvent` / `CallServiceEvent`. + +#### H3: State Change Predicates +`P.StateFrom`, `P.StateTo`, `P.StateComparison`, `P.StateDidChange`, `P.AttrFrom`, `P.AttrTo`, `P.AttrComparison`, `P.AttrDidChange`. Works with: `RawStateChangeEvent`. ### H2: Conditions (`C`) -Full reference table. Include: `Increased`, `Decreased`, `Comparison` (raw operator), `IsNone`, `IsNotNone`, `Present`, `Missing` (sentinel-based, distinct from `IsNone`), `IsIn`, `NotIn`, `Intersects`, `NotIntersects`, `IsOrContains`, `StartsWith`, `EndsWith`, `Contains`, `Regex`, `Glob`. Note: `InRange` does NOT exist — use `Comparison` for range checks. +Tables grouped by purpose: + +#### H3: String Matching +`C.Glob`, `C.StartsWith`, `C.EndsWith`, `C.Contains`, `C.Regex`. + +#### H3: Collection Membership +`C.IsIn`, `C.NotIn`, `C.Intersects`, `C.NotIntersects`, `C.IsOrContains`. + +#### H3: None / Missing Checks +`C.IsNone`, `C.IsNotNone`, `C.Present`, `C.Missing`. + +#### H3: Numeric Comparison +`C.Comparison`, `C.Increased`, `C.Decreased`. ### H2: Accessors (`A`) -Full reference table grouped by category: state value (`get_state_value_new`, `get_state_value_old`, `get_state_value_old_new`), state object (`get_state_object_old`, `get_state_object_new`), attribute (`get_attr_old`, `get_attr_new`, `get_attr_old_new`, `get_attrs_old`, `get_attrs_new`, `get_all_attrs_old`, `get_all_attrs_new`), identity (`get_domain`, `get_entity_id`, `get_context`), service (`get_service`, `get_service_data`, `get_service_data_key`), path (`get_path`), diff (`get_all_changes`). How accessors plug into predicates via the `source=` parameter. +Tables grouped by data source: + +#### H3: State Value +`get_state_value_new`, `get_state_value_old`, `get_state_value_old_new`. + +#### H3: State Object +`get_state_object_old`, `get_state_object_new`. + +#### H3: Attribute +`get_attr_old`, `get_attr_new`, `get_attr_old_new`, `get_attrs_old`, `get_attrs_new`, `get_all_attrs_old`, `get_all_attrs_new`. + +#### H3: Identity +`get_domain`, `get_entity_id`, `get_context`. + +#### H3: Service +`get_service`, `get_service_data`, `get_service_data_key`. + +#### H3: Other +`get_path`, `get_all_changes`. + +One paragraph at end: how accessors plug into predicates via the `source=` parameter. ## Snippet Inventory -No snippets — pure reference tables. Usage examples live on bus/filtering.md and states/subscribing.md. +No snippets. Pure reference tables. Usage examples live on Filtering and States/Subscribing pages. ## Cross-Links -- **Links to:** Bus/Filtering (concept), States/Subscribing (state-change patterns), Custom Extractors -- **Linked from:** Bus/Filtering, States/Subscribing, Bus/Handlers, Recipes +- **Links to:** Filtering (concepts and examples), Custom Extractors (writing custom accessors) +- **Linked from:** Filtering, Handlers, Recipes diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/cache/overview.md b/design/specs/070-doc-overhaul/outlines/core-concepts/cache/overview.md index 034b4c5c9..b43074661 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/cache/overview.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/cache/overview.md @@ -1,38 +1,55 @@ -# Cache — Overview +# App Cache -**Status:** Exists (104 lines), solid content, voice polish needed +**Status:** Rewrite from blank **Voice mode:** Concept — system-as-subject, no "you" +**Page type:** Concept +**Reader's job:** Store app data that survives restarts — counters, timestamps, API responses — using `self.cache`. + +## What was cut (and where it goes) + +- **"When to Use the Cache" bullet list** — replaced with a one-sentence functional definition in the opening, then a brief "not for entity state" note. The existing bullet list front-loads four use cases before the reader sees any code. The patterns page already shows each use case with full examples. +- **"How It Works" sub-sections** (Storage Location, Shared Cache, Lazy Initialization, Automatic Cleanup) — kept but reordered. "Shared Cache" moves up because it is the most common surprise (multi-instance key collisions). Storage location and lazy init are implementation details that go after basic usage. ## Outline -### H2: When to Use the Cache -Persistent key-value storage for data that survives restarts. Not for entity state (use StateManager) or temporary data. +### (Opening) +`self.cache` provides persistent key-value storage on every app. Data written to the cache survives restarts and is available immediately at the next startup. The cache is a `diskcache.Cache` instance — the full diskcache API is available directly. + +For real-time HA entity state, use `self.states`. The cache is for app data: counters, timestamps, API responses, preferences. ### H2: Basic Usage -`self.cache` is a raw `diskcache.Cache` instance — the full diskcache API is available directly (`.get()`, `.set()`, `.delete()`, `.pop()`, `.expire()`, etc.). +Dictionary-like API: `get`, `set`, `delete`, check membership. Show a minimal code example. Emphasize: no open, flush, or close needed. -### H2: How It Works -#### H3: Storage Location — `diskcache.Cache` backed by filesystem -#### H3: Shared Cache — instances of the same class share one cache (keyed by class name, path: `data_dir//cache`) -#### H3: Lazy Initialization — cache dir created on first access via `cached_property` -#### H3: Automatic Cleanup — TTL expiry, silent eviction when `size_limit` reached +### H2: Shared Cache and Multi-Instance Apps +All instances of the same class share one cache directory (keyed by class name). For multi-instance apps, prefix keys with `self.app_config.instance_name` to avoid collisions. + +### H2: What Can Be Cached +Anything picklable: primitives, collections, `whenever` timestamps, Pydantic models, dataclasses. Not limited to JSON-serializable types. + +!!! tip on `self.now()` for timestamps. ### H2: Configuration -Only setting: `default_cache_size` (default 100 MiB) in root `HassetteConfig`. No `[hassette.cache]` section. Cache path is derived automatically. +`default_cache_size` (100 MiB default) in root `HassetteConfig`. Cache path derived from `data_dir`. Brief TOML example. -### H2: Lifecycle -When cache is available during app lifecycle. +### H2: How It Works +- **Storage Location** — `{data_dir}/{ClassName}/cache/` +- **Lazy Initialization** — cache dir created on first access +- **Lifecycle** — available from first access, flushed at shutdown +- **Automatic Cleanup** — TTL expiry, silent LRU eviction at `size_limit` -### H2: Data Types -What can be cached (anything picklable — diskcache uses `pickle` as its default serializer). Includes dataclasses, Pydantic models, sets, custom objects. Not limited to JSON-serializable types. +### H2: See Also +Patterns & Examples, Configuration, States, diskcache docs. ## Snippet Inventory | Snippet | Status | Notes | |---|---|---| -| 9 files in `cache/snippets/` | Keep | Basic cache operations | +| `cache_basic_usage.py` | Keep | H2: Basic Usage | +| `cache_instance_prefix.py` | Keep | H2: Shared Cache | + +Remaining 7 snippets (`cache_api_response.py`, `cache_rate_limit.py`, `cache_counter.py`, `cache_complex_data.py`, `cache_expire.py`, `cache_expiring.py`, `cache_performance.py`) belong to the Patterns page. ## Cross-Links -- **Links to:** Patterns & Examples, Configuration/Global (cache settings) -- **Linked from:** Architecture, Apps overview +- **Links to:** Patterns & Examples, Configuration (cache settings), diskcache docs +- **Linked from:** Architecture, Apps overview, States overview diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/cache/patterns.md b/design/specs/070-doc-overhaul/outlines/core-concepts/cache/patterns.md index d7af2556f..b8db1b4f3 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/cache/patterns.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/cache/patterns.md @@ -1,46 +1,63 @@ -# Cache — Patterns & Examples +# App Cache: Patterns & Examples -**Status:** Exists (141 lines), solid content, voice polish needed +**Status:** Rewrite from blank **Voice mode:** Concept — system-as-subject, no "you" +**Page type:** Concept (patterns/recipes hybrid) +**Reader's job:** Find a proven pattern for a specific caching problem — rate limiting, counters, expiry, complex data — and adapt it. + +## What was cut (and where it goes) + +- **"Best Practices — What to Cache"** — cut. The overview page already states what the cache is for vs what `self.states` is for. Repeating it here as a "good uses / avoid caching" list is padding. +- **"Best Practices — Cache vs StateManager"** — cut. Same content as the overview page's "not for entity state" note plus a comparison table that restates the obvious. +- **"Best Practices — Performance"** — folded into the "Load Once, Write on Shutdown" pattern where it naturally belongs. +- **"Troubleshooting — Debugging Cache Operations"** — tightened. The advice to set `log_level = "DEBUG"` and check the cache directory is two sentences, not a section. ## Outline -### H2: Pattern: API Response Caching -Cache expensive HA API calls. +### (Opening) +Practical patterns for `self.cache`. Each pattern solves a specific problem with a complete, runnable example. The overview page covers setup and basic usage. -### H2: Pattern: Rate-Limiting Notifications -Use cache timestamps to prevent notification spam. +### H2: Rate-Limiting Notifications +Problem: prevent notification spam. Store a timestamp, check cooldown before sending. Show per-entity variant (entity ID in the cache key). -### H2: Pattern: Persistent Counters -Counters that survive restarts. +### H2: Persistent Counters +Problem: track events across restarts. Load from cache at init, write back on every increment. -### H2: Pattern: Storing Complex Data -Dataclasses/dicts in cache. +### H2: API Response Caching +Problem: avoid hitting external rate limits. Store response with timestamp, check freshness before re-fetching. -### H2: Pattern: Expiring Cache Entries -TTL-based expiry. +### H2: Expiring Entries +Two approaches, simplest first: +- `self.cache.set(key, value, expire=seconds)` — diskcache handles TTL automatically. +- Store a timestamp alongside the value for custom staleness logic or "last fetched" display. -### H2: Pattern: Load Once, Write on Shutdown -Batch cache operations for performance. +### H2: Storing Complex Data +Dataclasses, Pydantic models, dicts. Note: use `dataclasses.replace()` for immutability. -### H2: Best Practices -#### H3: What to Cache -#### H3: Cache vs StateManager -#### H3: Performance +### H2: Load Once, Write on Shutdown +Load into an instance variable at init, write back at shutdown. Avoids disk I/O on every access. Note: cache is thread-safe for concurrent async access. ### H2: Troubleshooting + #### H3: Cache Not Persisting -#### H3: Cache Size — Silent Eviction -DiskCache evicts old entries when `size_limit` is reached; no error is raised. -#### H3: Debugging Cache Operations +Checklist: writing to `self.cache` not a local var, app completes init without exception, directory has write permissions, value is picklable. + +#### H3: Cache Size Exceeded +LRU eviction is automatic and silent. Increase `default_cache_size`, implement TTL expiry, or store large objects externally. ## Snippet Inventory | Snippet | Status | Notes | |---|---|---| -| 9 files in `cache/snippets/` | Keep | Pattern examples (shared with overview — assign per-page) | +| `cache_rate_limit.py` | Keep | H2: Rate-Limiting | +| `cache_counter.py` | Keep | H2: Persistent Counters | +| `cache_api_response.py` | Keep | H2: API Response Caching | +| `cache_expire.py` | Keep | H2: Expiring Entries (TTL) | +| `cache_expiring.py` | Keep | H2: Expiring Entries (timestamp) | +| `cache_complex_data.py` | Keep | H2: Storing Complex Data | +| `cache_performance.py` | Keep | H2: Load Once, Write on Shutdown | ## Cross-Links -- **Links to:** Cache overview, States overview (cache vs StateManager) +- **Links to:** Cache overview, States overview (cache vs StateManager), Configuration - **Linked from:** Cache overview, Recipes diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/configuration/applications.md b/design/specs/070-doc-overhaul/outlines/core-concepts/configuration/applications.md index 4ed335c0f..543ca2ff7 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/configuration/applications.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/configuration/applications.md @@ -1,32 +1,52 @@ -# Configuration — Applications +# Application Configuration -**Status:** Exists (68 lines), solid content, voice polish needed +**Status:** Rewrite from blank **Voice mode:** Concept — system-as-subject, no "you" +**Page type:** Concept +**Reader's job:** Register an app in `hassette.toml` and pass configuration values to it, including running multiple instances of the same app. + +## What was cut (and where it goes) + +- **"Typed Configuration" section** — kept as a one-sentence link. The Python side of `AppConfig` belongs on the Apps/Configuration page. This page covers the TOML side only. +- **"App Configuration Parameters" as a separate section from "App Registration"** — merged. The distinction between `AppManifest` fields and `AppConfig` fields is important but does not need two top-level sections. Show registration first (the reader's first task), then configuration (the second task), with a clear callout that they live at different TOML paths. ## Outline -### H2: App Registration -How apps are registered in hassette.toml `[apps]` section. +### (Opening) +Apps are registered in `hassette.toml` under `[hassette.apps.]`. Each block tells Hassette which Python file and class to load and passes configuration values to the app. + +This page covers the TOML side of app configuration. For defining typed `AppConfig` models in Python, see Apps/Configuration. + +### H2: Registering an App +Required fields: `filename` (or `file_name`) and `class_name` (or `class`/`module`/`module_name`). Optional: `enabled`, `display_name`. Show a single-instance TOML example. + +Brief note: prefer `filename` and `class_name` in new configs; alternatives exist for compatibility. + +### H2: Passing Configuration +`config` field supplies values to the app's `AppConfig` model. + +Two TOML forms: +- Inline: `config = { key = "value" }` +- Table: `[hassette.apps..config]` -### H2: Single Instance -Default single-instance configuration. +Callout: manifest fields (`filename`, `class_name`, `enabled`) live under `[hassette.apps.]`. App config fields live under `[hassette.apps..config]`. Different TOML paths — do not conflate them. -### H2: App Configuration Parameters -Two distinct layers: `AppManifest` fields (under `[hassette.apps.]`: `enabled`, `filename`/`file_name`, `class_name`/`class`/`module`/`module_name`) vs `AppConfig` fields (under `[hassette.apps..config]`: `instance_name`, `log_level`, `app_key`, plus user-defined fields). These live at different TOML paths — don't conflate them in a single table. +Environment variable overrides: `HASSETTE__APPS____CONFIG__` pattern. ### H2: Multiple Instances -Running the same app class multiple times with different configs. +Running the same app class with different configurations using `[[hassette.apps..config]]` (TOML array of tables). Each block produces a separate app instance. Show a concrete example (same app, two rooms). ### H2: Typed Configuration -Link to Apps/Configuration for AppConfig details. +One-sentence link: the values supplied here are validated at startup against an `AppConfig` subclass defined in Python. Link to Apps/Configuration for how to define the model. ## Snippet Inventory | Snippet | Status | Notes | |---|---|---| -| Relevant TOML examples | Review | May be inline rather than snippet files | +| `single_instance.toml` | Keep | H2: Registering an App | +| `multiple_instances.toml` | Keep | H2: Multiple Instances | ## Cross-Links -- **Links to:** Apps/Configuration, Configuration overview +- **Links to:** Apps/Configuration (Python `AppConfig` model), Configuration overview - **Linked from:** Configuration overview, Apps overview diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/configuration/overview.md b/design/specs/070-doc-overhaul/outlines/core-concepts/configuration/overview.md index a93cd2547..1b8726b53 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/configuration/overview.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/configuration/overview.md @@ -1,23 +1,34 @@ -# Configuration — Overview +# Configuration -**Status:** Exists (46 lines) + absorbing auth.md (43 lines) + teaching content from global.md (~90 lines). Rewrite needed. +**Status:** Rewrite from blank **Voice mode:** Concept — system-as-subject, no "you" +**Page type:** Concept +**Reader's job:** Configure Hassette to connect to Home Assistant, set up app discovery, and understand where settings live. + +## What was cut (and where it goes) + +- **auth.md as a separate page** — absorbed. Token setup is 2 paragraphs, not a page. The reader who needs to configure Hassette should not have to navigate to a separate page for the most common first-time task. +- **global.md as a separate page** — replaced by auto-generated `HassetteConfig` API reference. The existing global.md is a 300-line hand-maintained field listing that duplicates what mkdocstrings generates. The teaching content (WebSocket resilience, timeout behavior) moves to Operating/overview.md. The overview page keeps brief design-rationale notes for fields where the "why" is not obvious from the field name and type. +- **Credentials section** — absorbed into Authentication. The existing page had a "Credentials" section that just said "see Authentication." One indirection removed. ## Outline +### (Opening) +All Hassette settings live in `hassette.toml`. Environment variables and CLI flags override TOML values. The configuration controls connection, app discovery, the web UI, storage, and runtime behavior. + ### H2: Configuration Sources -Priority order (highest wins): init kwargs → env vars (`HASSETTE__` prefix, `__` nested delimiter) → dotenv (.env) → file secrets → hassette.toml. TOML is the base; env vars override it. +Priority order (highest wins): CLI flags -> env vars (`HASSETTE__` prefix, `__` nested delimiter) -> `.env` files -> `hassette.toml`. When the same setting appears in multiple sources, the higher-precedence source wins. ### H2: File Locations -TOML: `/config/hassette.toml`, `hassette.toml`, `./config/hassette.toml`. `.env`: `/config/.env`, `.env`, `./config/.env`. Docker `/config/` paths are checked first. CLI flags `--config-file` / `--env-file` override discovery. +TOML and `.env` discovery paths. Docker `/config/` paths checked first. CLI flags `--config-file` / `--env-file` override discovery. ### H2: Authentication -Token field accepts four aliases: `token`, `hassette__token`, `ha_token`, `home_assistant_token`. Set via env var or .env file. `verify_ssl` for self-signed certs. `import_dot_env_files` controls whether .env values are also injected into `os.environ`. Link to HA Token getting-started page for step-by-step. +Token field accepts four aliases: `token`, `hassette__token`, `ha_token`, `home_assistant_token`. Recommended: `HASSETTE__TOKEN` env var or `.env` file. Never commit tokens. `verify_ssl = false` for self-signed certs. Link to Getting Started HA Token page for step-by-step creation. ### H2: Configuration Sections -Brief map of what's configurable, linking to the auto-generated reference for field details: +Brief map of what is configurable, with links: - Connection: `base_url`, `verify_ssl` -- Apps: → Applications page +- Apps: -> Applications page - Web UI: `[hassette.web_api]` - Database: `[hassette.database]` - WebSocket: `[hassette.websocket]` @@ -26,46 +37,45 @@ Brief map of what's configurable, linking to the auto-generated reference for fi - File Watcher: `[hassette.file_watcher]` - Scheduler: `[hassette.scheduler]` -### H2: Configuration Field Notes -Brief design-rationale notes for fields where the auto-generated reference doesn't explain the "why." Each H3 is 1-3 sentences. Readers looking for field types and defaults go to the auto-generated HassetteConfig reference; this section covers the design intent. +### H2: Design Notes +Brief rationale for fields where the auto-generated reference does not explain the "why." Each H3 is 1-3 sentences. Readers looking for field types and defaults go to the auto-generated reference. #### H3: Data Directory and Upgrades -`data_dir` path, major version implications, cache path derivation (`data_dir//cache`). +`data_dir` path, major version implications, cache path derivation. #### H3: App Discovery -`apps.directory`, `extend_exclude_dirs` vs `exclude_dirs` footgun, `run_app_precheck` and `allow_startup_if_app_precheck_fails`. +`apps.directory`, `extend_exclude_dirs` vs `exclude_dirs` footgun, precheck behavior. #### H3: Event Filtering -`bus_excluded_domains` and `bus_excluded_entities` — glob patterns to silently drop events before they reach handlers. Cross-link to troubleshooting KI-06. +`bus_excluded_domains` and `bus_excluded_entities` — glob patterns to drop events before handlers. #### H3: Development and Debugging -`dev_mode` — auto-detected from debugger attachment or `python -X dev`. `asyncio_debug_mode`. `ui_hot_reload`. - -#### H3: Web API -`cors_origins` — allowed CORS origins for the REST API. - -#### H3: Cache -`default_cache_size` — size limit for per-resource disk caches. +`dev_mode` auto-detection, `asyncio_debug_mode`, `ui_hot_reload`. #### H3: State Proxy Polling `state_proxy_poll_interval_seconds` and `disable_state_proxy_polling`. -*WebSocket resilience and timeout behavior moved to Operating/overview.md alongside KI-01/KI-02 — see outline audit (2026-06-02).* - ### H2: Full Reference -Link to auto-generated API reference for `HassetteConfig` and all sub-models. All fields, types, defaults, and descriptions are maintained in the source code and rendered automatically. +Link to auto-generated API reference for `HassetteConfig` and all sub-models. ## Snippet Inventory -No code snippets — TOML examples are inline. +| Snippet | Status | Notes | +|---|---|---| +| `file_discovery.md` | Keep | H2: File Locations (included via --8<--) | +| `basic_config.toml` | Keep | Could use as opening example | +| `storage_example.toml` | Drop | Covered by auto-generated reference | +| `web_ui_example.toml` | Drop | Covered by auto-generated reference | +| `database_example.toml` | Drop | Covered by auto-generated reference | +| `bus_filter_example.toml` | Keep | H3: Event Filtering | ## Cross-Links -- **Links to:** Applications (app registration), Auto-generated HassetteConfig reference, Operating/Log Levels (log tuning in practice), HA Token (getting-started) -- **Linked from:** Architecture, Getting Started (Quickstart, Docker Setup), Operating +- **Links to:** Applications (app registration), Auto-generated HassetteConfig reference, Operating/overview (WebSocket resilience, timeouts), HA Token (getting-started) +- **Linked from:** Architecture, Getting Started, Operating ## Structural Notes -- **auth.md absorbed** — token aliases and SSL verification folded into Authentication section above -- **global.md replaced** — field listings move to auto-generated reference; ~90 lines of teaching content (WebSocket resilience, timeout behavior, data directory) kept in Operational Tuning Guidance +- **auth.md absorbed** — token aliases and SSL verification folded into Authentication section +- **global.md replaced** — field listings move to auto-generated reference; teaching content (WebSocket resilience, timeout behavior) moves to Operating/overview.md - **Requires:** adding `hassette.config.models` and `hassette.config.config` to `PUBLIC_MODULES` in `tools/gen_ref_pages.py` diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/database-telemetry.md b/design/specs/070-doc-overhaul/outlines/core-concepts/database-telemetry.md index 0e3ac1b83..ab4f7a6b8 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/database-telemetry.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/database-telemetry.md @@ -1,39 +1,68 @@ # Database & Telemetry -**Status:** Exists (127 lines), solid content, voice polish needed +**Status:** Exists (127 lines), solid content, needs JTBD reorder **Voice mode:** Concept — system-as-subject, no "you" +**Page type:** Concept +**Reader's job:** Understand what Hassette tracks automatically and how to check whether telemetry is working — so they can trust the numbers in the web UI and CLI. + +## What was cut (and where it goes) + +The Execution Columns table (12 columns, pre-migration note) is reference detail that +serves contributors and debuggers, not the typical reader. It moves to a collapsible +section. The Source Tier internals note is already collapsible and stays that way. + +The Registration Persistence section is reordered: the current page buries it below +Monitoring, but the reader needs to understand persistence before monitoring makes +sense (registration counts appearing in the UI after restart is surprising without +context). ## Outline ### H2: What Is Collected -Four data categories: listener invocations, scheduler executions, registration events (listeners + jobs), and log records. Also tracks dropped event counters (overflow, exhausted, shutdown). Brief — the reader needs to know *what* is tracked, not the column schema. +Two categories: handler invocations and job executions. Collected automatically — no +code needed. Source Tier callout (framework vs app, how they appear in the web UI). +Keep collapsible "Internal detail" note about `source_tier` column and `__hassette__` +app keys. ### H2: Configuration -Telemetry settings in hassette.toml (`database.*`). Retention policy. +Telemetry settings in `hassette.toml` (`database.*`). Three-field table (path, +retention_days, max_size_mb). Defaults work out of the box. + #### H3: How Retention Works +Two routines: time-based (hourly, older than retention_days) and size-based failsafe +(hourly, exceeds max_size_mb). Background, non-blocking. -### H2: Monitoring Telemetry Health -Pair each API endpoint with its CLI equivalent so the reader knows both paths. +### H2: Registration Persistence +How listener and job registrations survive restarts. Upsert by natural key, `retired_at` +for removed registrations. Why stats strip shows accurate counts across restarts. -#### H3: `/api/telemetry/status` and `hassette telemetry` -Checking the telemetry pipeline. -#### H3: `/api/health` and `hassette status` -General health endpoint. -#### H3: `hassette log`, `hassette execution` -Querying logs and execution history from the CLI. +### H2: Checking Telemetry Health +Each check paired with both its API endpoint and CLI equivalent: +- `/api/telemetry/status` / `hassette telemetry` — telemetry pipeline health +- `/api/health` / `hassette status` — system-level health (Docker healthchecks) +- `hassette log` / `hassette execution` — querying logs and execution history -### H2: Registration Persistence -How listener and job registrations are stored. +Admonition: choosing between `/api/health` (uptime monitoring) and `/api/telemetry/status` (telemetry-specific). ### H2: Degraded Mode -What happens when the database fails. Graceful degradation. +What happens when the database fails. UI continues, telemetry panels show zeros, +automations unaffected. Registration data also unavailable (same SQLite file). + #### H3: Recovery +Steps: check disk space, check permissions, delete and restart if corrupt. + +??? note "Execution Columns" +Full column table for contributors and debuggers. Pre-migration note. ## Snippet Inventory -No dedicated snippets — this page is prose + tables + endpoint examples. +| Snippet | Status | Notes | +|---|---|---| +| `database-telemetry/db_config.toml` | Keep | TOML config example | +| `database-telemetry/healthcheck.yml` | Keep | Docker healthcheck | +| `database-telemetry/db_recovery.sh` | Keep | Recovery commands | ## Cross-Links -- **Links to:** Web UI/Logs (viewing telemetry data), Configuration/Global (db settings), Operating (degraded mode) +- **Links to:** Configuration/Global (db settings), Web UI/Logs, CLI/Workflows, Operating overview - **Linked from:** Architecture, System Internals diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/internals/architecture-data-flow.md b/design/specs/070-doc-overhaul/outlines/core-concepts/internals/architecture-data-flow.md index 27898a68c..893897e20 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/internals/architecture-data-flow.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/internals/architecture-data-flow.md @@ -2,30 +2,61 @@ **Status:** New page (content from current `internals.md` sections 1-3 + content from Architecture page) **Voice mode:** Concept — system-as-subject, no "you", contributor/deep-dive audience +**Page type:** Concept (deep-dive, section landing) +**Reader's job:** Trace how data moves through the system — from HA WebSocket to handler invocation and back — to debug event routing issues or understand framework structure. + +## What was cut (and where it goes) + +The original outline mirrored the source-code structure (component ownership, then +dependencies, then data flow). The reader arriving here from the Architecture page +already knows the high-level components. Their question is "how does an event get from +HA to my handler?" — so the data flow pipeline comes first, the dependency mechanics +second. + +Component ownership moves from lead section to supporting reference — it answers +"who owns what?" which matters for debugging, not for initial understanding. ## Outline -### (Opening — absorbed from internals/overview.md) -Audience declaration: "This section is for contributors to Hassette's core and for advanced users who want to understand the framework's internal architecture. App authors do not need to read this section." Brief index of the three internals pages (Architecture & Data Flow, Lifecycle, Service Details). +### (Opening) +Audience statement: this section is for contributors and advanced users who want to +understand the framework's internals. App authors do not need to read this section. +Brief index of the three internals pages (this page, Lifecycle, Service Details). -### H2: Component Ownership -Which service owns which state. Map of services to the resources they manage. +### H2: Event Pipeline +How events travel from HA WebSocket through the system to handler invocation: +WebsocketService (receive) -> EventStreamService (memory channel) -> +BusService (topic expand + filter) -> CommandExecutor (invoke + record) -> handler. +Outbound path: handler -> Api -> ApiResource (REST) / WebsocketService (WS send) -> HA. + +Existing Mermaid diagram from internals.md section 3. Failure behavior table +(WS disconnect, auth failure, handler timeout, DB write failure). + +`StateProxy` priority-100 subscription: cache is always updated before user handlers. ### H2: Service Dependencies #### H3: `depends_on` ClassVar -How services declare startup dependencies. Code example (snippet `index_depends_on.py` moving from Architecture). +How services declare startup dependencies. Code example (snippet `index_depends_on.py`, +moved from Architecture page). Scoping: direct children of `Hassette` only. + #### H3: Wave-Based Ordering -Dependency graph partitioned into levels. Each wave starts/shuts down concurrently; waves execute sequentially. +Dependency graph partitioned into topological levels. Each wave starts concurrently; +waves execute sequentially. Shutdown in reverse. + +#### H3: Framework Dependency Graph +Full Mermaid diagram showing all built-in services by startup wave (moved from +Architecture page). + #### H3: Cycle Detection `ValueError` with full cycle path at construction time. -#### H3: Framework Dependency Graph -Mermaid diagram showing all built-in services by startup wave (moving from Architecture page). -### H2: Event and Data Flow -How events travel from HA WebSocket → EventStreamService → BusService → listener dispatch → handler invocation. +### H2: Component Ownership +Which service owns which state. Resource tree diagram (existing Mermaid from +internals.md section 1). Per-app handles as thin wrappers — cleanup behavior on +app shutdown. ### H2: EventStreamService Constructor-Time Dependency -Structural ordering via child registration order, not `depends_on`. +Structural ordering via child registration order, not `depends_on`. Brief note. ## Snippet Inventory @@ -35,5 +66,5 @@ Structural ordering via child registration order, not `depends_on`. ## Cross-Links -- **Links to:** Per-Service Internals, Lifecycle & Supervision, Architecture (back-link) -- **Linked from:** Architecture (deep dive), each core concept overview +- **Links to:** Lifecycle & Supervision, Per-Service Internals, Architecture (back-link) +- **Linked from:** Architecture (deep dive), Operating overview diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/internals/lifecycle.md b/design/specs/070-doc-overhaul/outlines/core-concepts/internals/lifecycle.md index 2f6f57900..570576d1b 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/internals/lifecycle.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/internals/lifecycle.md @@ -2,39 +2,68 @@ **Status:** New page (content from current `internals.md` section 10) **Voice mode:** Concept — system-as-subject, no "you", contributor/deep-dive audience +**Page type:** Concept (deep-dive) +**Reader's job:** Understand how services start, fail, restart, and die — so they can diagnose service issues or write custom services. + +## What was cut (and where it goes) + +Nothing cut — this page is new, assembled from the lifecycle section of the monolithic +internals.md. The ordering is redesigned: the current page leads with the state machine +diagram (implementation artifact), but the reader's first question is "what happens +when my service fails?" Start with the supervision story, then show the state machine +as supporting detail. + +The `RestartSpec` field reference table stays but moves after the conceptual +explanation, not before it. ## Outline +### H2: What Happens When a Service Fails +Opening: a service that raises an exception transitions to FAILED. `ServiceWatcher` +reads the service's `RestartSpec` and decides what to do next: restart with backoff, +enter a cooldown period, or give up. This is the hook — the reader now knows the +stakes and the actors. + +### H2: Restart Types +Three types, each explained by its exhaustion behavior (the only way they differ): +- `PERMANENT` — system shuts down. Used for structural services (BusService, SchedulerService). +- `TRANSIENT` — enters cooldown, then retries. Used for services with intermittent failures (WebsocketService). +- `TEMPORARY` — stops permanently. Used for optional services (FileWatcherService). + +Table of per-service restart specs (the existing table from internals.md). + +### H2: Restart Budget +Sliding-window model: intensity (max restarts) within a period (window size). Budget +resets on successful recovery. Brief — the reader needs to know the concept, not the +implementation. + +### H2: Error Routing +Three-layer routing, simplest to most severe: +1. Normal errors — restart with backoff +2. `non_retryable_error_names` — skip restart, go to exhaustion +3. `fatal_error_names` / `FatalError` subclass — immediate shutdown + +### H2: RestartSpec Reference +The full field table (existing content from internals.md). Code example snippet. + ### H2: Resource State Machine -State transitions diagram: `NOT_STARTED` → `STARTING` → `RUNNING` → `STOPPING` → `STOPPED`. Error/terminal states: `FAILED`, `CRASHED`, `EXHAUSTED_COOLING`, `EXHAUSTED_DEAD`. +State transition diagram (existing Mermaid). Explanation of each state. This comes +last because it is the reference diagram, not the entry point. ### H2: Readiness vs Running -`mark_ready()` signals readiness; RUNNING is the status. Why these are separate — a service can be running but not yet ready to serve dependents. +`mark_ready()` vs `handle_running()` table. Why they are separate. Brief. ### H2: Wave Startup and Shutdown -How the dependency graph drives startup waves (level 0 first, then level 1, etc.) and shutdown in reverse. - -### H2: Service Supervision -#### H3: RestartSpec -Class attribute on services: restart type, sliding-window budget, backoff parameters, error routing. -#### H3: RestartType -`PERMANENT` (always restart within budget), `TRANSIENT` (restarts within budget; on exhaustion enters `EXHAUSTED_COOLING` with cooldown/recovery cycle), `TEMPORARY` (restarts within budget; on exhaustion goes straight to `EXHAUSTED_DEAD` with no cooldown). All three types restart — they differ in exhaustion behavior. -#### H3: Sliding-Window Budget -Intensity (max restarts) and period (window size). Budget resets on recovery. -#### H3: Error Routing -Fatal vs non-retryable error names. Three-layer routing: handler-level, service-level, framework-level. -#### H3: Exhaustion States -`EXHAUSTED_COOLING` (cooldown period after budget runs out) → either budget resets or transitions to `EXHAUSTED_DEAD` after `max_cooldown_cycles` (0 = infinite). -#### H3: Backoff Parameters -`backoff_base_seconds`, `backoff_multiplier`, `backoff_max_seconds`, `startup_timeout_seconds`, `cooldown_seconds` (duration of EXHAUSTED_COOLING phase for TRANSIENT services). +How dependency graph drives startup waves. Shutdown in reverse. Link back to +Architecture & Data Flow for the full dependency graph diagram. ## Snippet Inventory | Snippet | Status | Notes | |---|---|---| -| `internals_restart_spec.py` | Keep or move from `core-concepts/snippets/` | RestartSpec example | +| `internals_restart_spec.py` | Keep | `RestartSpec` example, stays on this page | ## Cross-Links -- **Links to:** Architecture & Data Flow, Per-Service Internals, Operating/overview (runtime behavior references these mechanics) +- **Links to:** Architecture & Data Flow (dependency graph), Per-Service Internals, Operating/overview (runtime behavior) - **Linked from:** Architecture & Data Flow, Operating overview diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/internals/overview.md b/design/specs/070-doc-overhaul/outlines/core-concepts/internals/overview.md index a8d5a3bc9..752a46556 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/internals/overview.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/internals/overview.md @@ -1,7 +1,12 @@ # Internals — Overview -**Status:** FOLDED into `internals/architecture-data-flow.md` (the index.md). No separate overview page. +**Status:** FOLDED into internals/index.md (Architecture & Data Flow). No separate page. +**Page type:** N/A (absorbed) +**Reader's job:** N/A -The audience declaration ("This section is for contributors...") becomes the opening paragraph of the Architecture & Data Flow page. The 3-line index folds into a "What's in this section" note at the top. +The audience declaration ("This section is for contributors...") becomes the opening +paragraph of the Architecture & Data Flow page. The 3-line section index folds into +a brief list at the top. -See decision in outline audit (2026-06-02): a 5-line index page doesn't justify its own nav entry. +See decision in outline audit (2026-06-02): a 5-line index page does not justify its +own nav entry. diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/internals/service-details.md b/design/specs/070-doc-overhaul/outlines/core-concepts/internals/service-details.md index 18429950e..f22336641 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/internals/service-details.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/internals/service-details.md @@ -2,34 +2,60 @@ **Status:** New page (content from current `internals.md` sections 4-9) **Voice mode:** Concept — system-as-subject, no "you", contributor/deep-dive audience +**Page type:** Reference (deep-dive) +**Reader's job:** Look up how a specific internal service works — its data structures, dispatch logic, and failure behavior. + +## What was cut (and where it goes) + +Nothing cut — this page collects the per-service detail sections from the monolithic +internals.md. The ordering is redesigned: the original followed source-code module +order. The new order follows the event pipeline (the path data takes through the +system), which matches how a reader would trace a problem. ## Outline ### H2: Bus Internals -Event dispatch pipeline: topic matching, listener filtering, handler invocation order. How debounce/throttle/once are implemented. +Event dispatch pipeline: topic matching (exact then glob), listener filtering +(predicate check), handler invocation via `CommandExecutor`. How debounce/throttle/once +are implemented. Topic expansion rule (three topics per state_changed event). Existing +Mermaid diagram. + +Listener behavior options table (debounce, throttle, duration, once, priority). ### H2: Scheduler Internals -Trigger evaluation loop, job heap, execution lifecycle. How `run_in`/`run_every`/`run_cron` translate to trigger objects. +Trigger evaluation loop, min-heap by `next_run`, execution lifecycle. How convenience +methods (`run_in`, `run_every`, `run_cron`) translate to trigger objects (`After`, +`Every`, `Cron`). `Daily` uses cron internally for DST safety. Jitter, job groups, +named jobs. Existing Mermaid diagram. -### H2: Database Internals -SQLite schema, migration system, unified executions table, synchronous registration pattern (why `db_id` is available immediately). +### H2: StateManager and StateProxy +Proxy pattern: `StateProxy` maintains in-memory cache (populated by bus subscription +at priority 100 + periodic poll), `StateManager` provides typed per-app access. +Domain routing, `DomainStates` collection, `context_id` caching. Lock-free reads, +disconnect/reconnect behavior. Existing Mermaid diagram. ### H2: Api Internals -REST and WebSocket interface to HA. Connection management, request routing, timeout handling. +Per-app `Api` delegates to shared `ApiResource` (REST) and `WebsocketService` (WS). +Transport routing table (which method uses which transport). Auth mechanism. Existing +Mermaid diagram. -### H2: StateManager and StateProxy -Proxy pattern: StateProxy wraps the WS state cache, StateManager provides the app-facing typed access. Domain routing, DomainStates collection. +### H2: Database Internals +SQLite schema, `PRAGMA user_version` migration system, unified `executions` table with +`kind` discriminator. Synchronous registration pattern (why `db_id` is available +immediately). Schema version mismatch handling (`SchemaVersionError`). Auto-vacuum +setup on fresh databases. ### H2: Web/UI Layer -Endpoint registration, SSE streaming for live logs, static file serving, CORS configuration. +`WebApiService` starts uvicorn/FastAPI. Two data source services: +`RuntimeQueryService` (live state, event buffer, WS broadcast) and +`TelemetryQueryService` (SQLite queries). SPA catch-all for client-side routing. +`config.run_web_api=False` behavior. Existing Mermaid diagram. ## Snippet Inventory -| Snippet | Status | Notes | -|---|---|---| -| `internals_restart_spec.py` | Review | May move to Lifecycle page instead | +No dedicated snippets — diagrams are inline Mermaid. ## Cross-Links -- **Links to:** Architecture & Data Flow, Lifecycle & Supervision, each concept overview (Bus, Scheduler, etc.) +- **Links to:** Architecture & Data Flow, Lifecycle & Supervision, Bus overview, Scheduler overview, States overview, API overview - **Linked from:** Architecture & Data Flow diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/scheduler/management.md b/design/specs/070-doc-overhaul/outlines/core-concepts/scheduler/management.md index 9dee99a38..f2778b6bd 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/scheduler/management.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/scheduler/management.md @@ -1,35 +1,79 @@ -# Scheduler — Job Management +# Job Management -**Status:** Exists (155 lines), solid content, voice polish needed +**Status:** Rewrite from blank **Voice mode:** Concept — system-as-subject, no "you" +**Page type:** Concept +**Reader's job:** Cancel, inspect, and handle errors for scheduled jobs. + +The existing page is well-structured but mixes two reader jobs: "how do I manage job lifecycle?" (cancel, inspect, automatic cleanup) and "how do I handle errors?" These are distinct concerns. The reader who needs to cancel a job doesn't need error handler registration in the same mental frame. Group lifecycle operations first (the more common need), then error handling. + +The "Best Practices" section feels bolted on. Job naming is an idempotent registration concern (covered on Methods page). Overlapping jobs is a real operational issue — keep it, but as a targeted subsection rather than a numbered best-practices list. + +## What was cut (and where it goes) + +- **Job naming guidance** (the "Name your jobs" best practice) — idempotent registration is now fully covered on the Methods page, including `if_exists` semantics. A brief reminder here that names appear in logs is fine; the full "why name" discussion lives on Methods. +- **Troubleshooting** section — the existing "Job Not Running?" and "Runs Too Often?" sections are good operational content. Keep them as a collapsible section at the end rather than a full H2, since they serve a small subset of readers. ## Outline -### H2: The ScheduledJob Object -What `schedule()` returns. Fields: `db_id`, `name`, `group`, `next_run`, `fire_at` (actual dispatch time after jitter, distinct from `next_run`). Note: no public `cancelled` field — cancellation state is checked via methods. +### H2: The `ScheduledJob` Object +What `schedule()` and all convenience methods return. Attribute table: `name`, `next_run`, `trigger`, `group`, `jitter`, `job_id`. Note: no public `cancelled` attribute — cancellation state is checked via `list_jobs()`. Snippet showing attribute access. ### H2: Cancelling Jobs -`job.cancel()`, `cancel_group()`, `list_jobs()`, checking cancellation state. +`job.cancel()` — immediate removal from the scheduler queue. Snippet. + +#### H3: Cancelling Groups +`cancel_group(group)` — cancel all jobs in a named group. Snippet. + +#### H3: Listing Jobs +`list_jobs()` — all active jobs. `list_jobs(group=)` — jobs in a group. Snippet. + +#### H3: Checking Whether a Job Is Active +No `cancelled` attribute. Check via `list_jobs()`, or store the reference as `None` after cancelling. Snippet showing both patterns. ### H2: Automatic Cleanup -Jobs cancelled automatically on app shutdown. +All jobs created by an app are cancelled automatically when the app stops or reloads. Manual cancellation is only needed to stop a job while the app is running. + +### H2: Self-Cancelling Jobs +Pattern for "poll until condition met" — store the `ScheduledJob` reference on the app, cancel from inside the handler. Note: double-execution cannot occur (dispatch checks dequeue state). Snippet. -### H2: Self-Cancelling Job Pattern -Job that cancels itself based on a condition. +### H2: Avoiding Overlapping Executions +When a job takes longer than its interval, multiple instances run concurrently. Guard with `asyncio.Lock`. Snippet. ### H2: Error Handling -#### H3: App-Level Error Handler — `Scheduler.on_error()` -#### H3: Per-Registration Error Handler — `error_handler=` +#### H3: App-Level Error Handler +`scheduler.on_error(handler)` — all jobs without a per-registration handler. Register first in `on_initialize()`. Snippet. +#### H3: Per-Registration Error Handler +`on_error=` parameter on any scheduling method. Snippet. #### H3: What `SchedulerErrorContext` Contains -Fields: `exception`, `traceback` (from `ErrorContext` base), `job_name`, `job_group`, `args`, `kwargs`. +Table: `job_name`, `job_group`, `args`, `kwargs`, plus `exception` and `traceback` from `ErrorContext` base. + +### Collapsible: Troubleshooting +??? note "Job not running?" +- Wrong time string or interval? +- Unhandled exception? (logged at ERROR, job keeps firing) +- Lost reference? (doesn't stop the job, but prevents cancellation) + +??? note "Job runs too often?" +- Check units: `seconds=5` is 5 seconds, not minutes +- Check cron: `"5 * * * *"` is minute 5 of every hour, not every 5 minutes ## Snippet Inventory -| Snippet | Status | Notes | +| Snippet | Decision | Notes | |---|---|---| -| ~7 files from `scheduler/snippets/` | Review | Management-specific examples | +| `scheduler_job_metadata.py` | Keep | `ScheduledJob` attributes | +| `scheduler_cancel_job.py` | Keep | Basic cancellation | +| `scheduler_management_patterns.py` | Keep | cancel_group, list_jobs, is_running, cancel_null markers | +| `scheduler_self_cancel.py` | Keep | Self-cancelling pattern | +| `scheduler_overlapping_jobs.py` | Keep | asyncio.Lock guard | +| `scheduler_naming.py` | Keep | Job naming (brief reminder) | +| `scheduler_error_handler_app.py` | Keep | App-level error handler | +| `scheduler_error_handler_per_job.py` | Keep | Per-registration error handler | + +No new snippets needed. ## Cross-Links -- **Links to:** Scheduling Methods, Apps lifecycle (shutdown cleanup) +- **Links to:** Scheduling Methods (registration, `if_exists`, per-job options), Apps lifecycle (shutdown cleanup) - **Linked from:** Scheduler overview, Recipes (motion lights — job cancellation) diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/scheduler/methods.md b/design/specs/070-doc-overhaul/outlines/core-concepts/scheduler/methods.md index 24368b22a..525519dd1 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/scheduler/methods.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/scheduler/methods.md @@ -1,46 +1,84 @@ -# Scheduler — Scheduling Methods +# Scheduling Methods -**Status:** Exists (280 lines), comprehensive reference, voice polish needed +**Status:** Rewrite from blank **Voice mode:** Reference — terse, system-as-subject, code-heavy +**Page type:** Reference +**Reader's job:** Find the right method for a scheduling task, understand its parameters, and handle edge cases. + +The existing page is a comprehensive reference and mostly well-organized. The main problems: (1) Job Groups, Jitter, and Idempotent Registration are on both this page and the overview, creating overlap — consolidate them here since they're per-method options. (2) The parameter tables repeat the same 6 common parameters for every method, which is noisy. Extract shared parameters to one table, then show only method-specific parameters per method. (3) `run_minutely` and `run_hourly` are thin wrappers around `run_every` — collapse them into one subsection instead of giving each a full parameter table. + +## What was cut (and where it goes) + +- **Job Groups, Jitter, Idempotent Registration** — removed from Overview, consolidated here. These are per-method `schedule()` parameters. +- **Error handling** (`on_error=`, `timeout=`, `timeout_disabled=`) — stays here as "Per-Job Options" since they're method parameters. The Management page covers the error handler registration pattern (`scheduler.on_error()`) and `SchedulerErrorContext`. ## Outline -### H2: Primary Entry Point — `schedule` -The generic `schedule(func, trigger)` method. All convenience methods are shortcuts for this. +### H2: Shared Parameters +One table listing the parameters common to all scheduling methods: `name`, `group`, `jitter`, `timeout`, `timeout_disabled`, `if_exists`, `args`, `kwargs`. Each with type, default, and one-line description. This table is defined once; individual methods reference it. + +### H2: `schedule(func, trigger)` +The generic entry point. Accepts any `TriggerProtocol`. All convenience methods delegate here. Parameter table showing only `func` and `trigger` (plus a link to shared parameters). Snippet. + +### H2: Delay and One-Shot Methods +#### H3: `run_in(func, delay)` +Run once after N seconds. Method-specific parameter: `delay` (float, seconds). Snippet. -### H2: Convenience Methods -#### H3: `run_in` — run after a delay. Accepts `seconds`, `minutes`, or a `TimeDelta` directly. -#### H3: `run_once` — run at a specific time. Has `if_past=` parameter (`"tomorrow"` or `"error"`, default `"tomorrow"`). For `ZonedDateTime` inputs, `if_past` has no effect (fires immediately). -#### H3: `run_every` — run at a fixed interval. The underlying `Every` trigger exposes `interval_seconds` for introspection. +#### H3: `run_once(func, at)` +Run once at a wall-clock time. Method-specific parameter: `at` (str `"HH:MM"` or `ZonedDateTime`). Note: past `"HH:MM"` times defer to tomorrow with a WARNING; `ZonedDateTime` inputs fire immediately if past. Snippet. -### H2: Convenience Interval Helpers -#### H3: `run_minutely` -#### H3: `run_hourly` -#### H3: `run_daily` +### H2: Repeating Methods +#### H3: `run_every(func, hours, minutes, seconds)` +Fixed interval, drift-resistant. The three time-component parameters are additive. Snippet. -### H2: Cron Scheduling — `run_cron` -Cron expression syntax, examples. +#### H3: `run_minutely` / `run_hourly` +Shorthands for `run_every(minutes=N)` and `run_every(hours=N)`. One combined snippet showing both. -### H2: Per-Job Options -#### H3: `on_error=` — per-registration error handler -#### H3: `timeout=` / `timeout_disabled=` — per-job timeout control -#### H3: `args=` and `kwargs=` — passing arguments to handlers +#### H3: `run_daily(func, at)` +Once per day at a fixed wall-clock time. Cron-backed for DST correctness. Method-specific parameter: `at` (str `"HH:MM"`, default `"00:00"`). Snippet. -Groups, jitter, and idempotent registration are covered on the Scheduler Overview page. +#### H3: `run_cron(func, expression)` +Arbitrary cron schedule. 5-field or 6-field (with seconds). Cron field reference table. Snippet. + +### H2: Job Groups +`group=` parameter for organizing related jobs. `cancel_group()` for bulk cancellation. `list_jobs(group=)` for inspection. Snippet. + +### H2: Jitter +`jitter=` parameter — random offset applied at enqueue time. Affects dispatch order, not the trigger's interval grid. Snippet. + +### H2: Idempotent Registration +`if_exists=` parameter: `"error"` (default), `"skip"` (same config required), `"replace"` (cancel old, register new). Essential for `on_initialize` which re-runs on reload. Snippet showing both `"skip"` and `"replace"`. + +### H2: Passing Arguments to Handlers +`args=` and `kwargs=` — pass data without capturing mutable state in closures. Snippet. ### H2: Synchronous Scheduling -`self.scheduler.sync` (`SchedulerSyncFacade`) — mirrors all methods as blocking calls for `AppSync` hooks. +`self.scheduler.sync` (`SchedulerSyncFacade`) mirrors all methods as blocking calls for `AppSync` hooks. ### H2: Custom Triggers -Implementing `TriggerProtocol` for custom scheduling logic. +Implementing `TriggerProtocol` for scheduling patterns the built-ins don't cover. Six-method protocol table: `first_run_time`, `next_run_time`, `trigger_label`, `trigger_detail`, `trigger_db_type`, `trigger_id`. Snippet showing a custom trigger class and its usage with `schedule()`. ## Snippet Inventory -| Snippet | Status | Notes | +| Snippet | Decision | Notes | |---|---|---| -| ~15 files from `scheduler/snippets/` | Review | Method-specific examples | +| `scheduler_schedule_examples.py` | Keep | `schedule()` usage | +| `scheduler_run_in.py` | Keep | `run_in` example | +| `scheduler_run_once.py` | Keep | `run_once` example | +| `scheduler_run_every.py` | Keep | `run_every` example | +| `scheduler_run_minutely.py` | Keep | Combined with `run_hourly` | +| `scheduler_run_hourly.py` | Keep | Combined with `run_minutely` | +| `scheduler_run_daily.py` | Keep | `run_daily` example | +| `scheduler_run_cron.py` | Keep | `run_cron` with cron syntax | +| `scheduler_job_groups.py` | Keep | Group management | +| `scheduler_jitter.py` | Keep | Jitter usage | +| `scheduler_idempotent_registration.py` | Keep | `if_exists` patterns | +| `scheduler_args_kwargs.py` | Keep | Passing arguments | +| `scheduler_custom_trigger.py` | Keep | `TriggerProtocol` implementation | + +No new snippets needed. ## Cross-Links -- **Links to:** Job Management, Scheduler overview, Custom Triggers (TriggerProtocol) +- **Links to:** Job Management (cancellation, errors, ScheduledJob object), Scheduler overview - **Linked from:** Scheduler overview, Recipes diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/scheduler/overview.md b/design/specs/070-doc-overhaul/outlines/core-concepts/scheduler/overview.md index 28e28c210..07159b932 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/scheduler/overview.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/scheduler/overview.md @@ -1,36 +1,43 @@ -# Scheduler — Overview +# Scheduler -**Status:** Exists (46 lines), brief intro, voice polish needed +**Status:** Rewrite from blank **Voice mode:** Concept — system-as-subject, no "you" +**Page type:** Concept (landing page) +**Reader's job:** Schedule a function to run at a specific time or interval. -## Outline +The existing page leads with trigger objects and the `schedule()` method — the framework author's mental model. The reader doesn't care about trigger internals. They want to run something after a delay, at a time, or on a schedule. Lead with the three most common patterns (`run_in`, `run_every`, `run_daily`) using code, then introduce the concept of triggers as the underlying mechanism for readers who need custom scheduling. -### H2: (Opening) -What the scheduler does: runs functions at specific times or intervals via trigger objects. Available as `self.scheduler` on every app. +## What was cut (and where it goes) -### H2: Trigger Types -Table of built-in triggers (After, Once, Every, Daily, Cron) with one-line descriptions. +- **Job groups, jitter, idempotent registration** — the previous outline had these as H2 sections here. These are operational concerns, not the reader's first job. They belong on the Methods page (groups, jitter, idempotent registration) or Management page (groups for cancellation). A brief mention in a "what else" list at the end is enough for the overview. +- **Trigger type table** — demoted from the lead position to a supporting section. The convenience methods are what readers actually use; triggers are the mechanism underneath. + +## Outline -### H2: Examples -Minimal examples for the most common patterns (run_in, run_every, run_daily). +### H2: Common Patterns +Three snippets showing the most common scheduling tasks. No parameter tables, no trigger theory — just working code: +1. **Run after a delay** — `run_in(self.check_door, 300)` (5 minutes) +2. **Run on a repeating interval** — `run_every(self.poll_sensor, minutes=5)` +3. **Run daily at a fixed time** — `run_daily(self.morning_report, at="07:00")` -### H2: Job Groups -`group=` parameter for organizing related jobs. `cancel_group()` cancels all jobs in a group. `list_jobs(group=)` inspects active jobs. (Moved from Methods — this is a behavioral concept, not a method signature.) +Each gets 1-2 sentences of explanation. -### H2: Jitter -`jitter=` parameter for randomizing execution times to avoid thundering herd. +### H2: Trigger Types +All scheduling methods create a trigger object under the hood. For most cases, the convenience methods are sufficient. The `schedule()` method accepts a trigger directly for advanced use. -### H2: Idempotent Registration -`name=` identifies the job; `if_exists=` (`"error"`, `"skip"`, `"replace"`) controls behavior on duplicate name. +Table of built-in triggers: `After`, `Once`, `Every`, `Daily`, `Cron` — one-line descriptions, one-shot column. ### H2: Next Steps -→ Scheduling Methods (full reference), → Job Management (cancellation, errors) +- Scheduling Methods — full method reference, cron expressions, custom triggers, per-job options +- Job Management — cancelling, grouping, error handling, and the `ScheduledJob` object ## Snippet Inventory -| Snippet | Status | Notes | +| Snippet | Decision | Notes | |---|---|---| -| Relevant files from `scheduler/snippets/` (22 total) | Review | Assign per-page | +| `scheduler_start_examples.py` | Keep | Opening examples (three common patterns) | + +No new snippets needed. ## Cross-Links diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/states/custom-states.md b/design/specs/070-doc-overhaul/outlines/core-concepts/states/custom-states.md index 02a570e90..de02ea7a1 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/states/custom-states.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/states/custom-states.md @@ -1,64 +1,73 @@ -# States — Custom States +# Custom States -**Status:** Stub (3 lines), content moving from Advanced (159 lines) +**Status:** Rewrite from blank **Voice mode:** Concept — system-as-subject, no "you" +**Page type:** Concept +**Reader's job:** Create a typed state class for a Home Assistant domain that Hassette does not cover (custom integrations, third-party add-ons). + +## What was cut (and where it goes) + +- **"Runtime vs Type-Time Access" section** — cut. This distinction (property access vs `states[Class]`) is an implementation detail about the `.pyi` stub. The "Using Custom States" section covers both access patterns naturally without naming the mechanism. +- **Troubleshooting section** — kept but tightened. Three concrete failure modes, one sentence each. +- **"Complete Example" section** — cut as a standalone section. The realistic example in "Adding Custom Attributes" already shows a full class. A second "complete" example adds length without new information. +- **Best Practices** — cut as a section header. The two actionable rules (one domain per class, use `Literal` for domain) are folded into the opening and "Basic Custom State" sections where the reader encounters them naturally. ## Outline -Content source: `docs/pages/advanced/custom-states.md` +### (Opening) +Hassette auto-generates typed state classes for standard HA domains. For custom integrations or third-party add-ons, a custom state class maps an unrecognized domain to a typed Python model. Define the class, and the State Registry picks it up automatically. -### H2: Basic Custom State Class -Defining a state class for a domain Hassette doesn't cover (custom integrations, etc.). +### H2: Defining a Custom State +Minimal example: inherit from a base class, set `domain: Literal["my_domain"]`. One domain per class. Registration is automatic via `__init_subclass__` — no explicit call needed. ### H2: Choosing a Base Class -#### H3: StringBaseState -#### H3: NumericBaseState -#### H3: BoolBaseState -#### H3: DateTimeBaseState -#### H3: TimeBaseState -#### H3: Define Your Own +Each base class determines the Python type of `value`: -### H2: Adding Custom Attributes -Typed attributes beyond the base `value` field. +#### H3: `StringBaseState` — `str` value (most common) +#### H3: `NumericBaseState` — `Decimal` value +#### H3: `BoolBaseState` — `bool` value (auto-converts `"on"`/`"off"`) +#### H3: `DateTimeBaseState` — `ZonedDateTime` / `PlainDateTime` / `Date` +#### H3: `TimeBaseState` — `Time` value +#### H3: Custom value type — inherit `BaseState` directly, set `value_type` ClassVar + +### H2: Adding Typed Attributes +Define an attributes class for domain-specific fields beyond `value`. Show a realistic example with 2-3 typed attribute fields. ### H2: Using Custom States in Apps -#### H3: Via `self.states[CustomStateClass]` -Generic access returns a `DomainStates` collection of the custom type. -#### H3: With Dependency Injection -#### H3: Direct API Access +Two access patterns, simplest first: -### H2: Runtime vs Type-Time Access -How state classes interact with the registry at runtime. +#### H3: Via `self.states[CustomStateClass]` +Returns a `DomainStates` collection typed to the custom class. -### H2: Complete Example -Full custom state class with attributes, registration, and usage in an app. +#### H3: With Dependency Injection +`D.StateNew[CustomState]` in a handler — Hassette converts automatically. ### H2: Troubleshooting -#### H3: State Class Not Registering -#### H3: Type Hints Not Working -#### H3: State Conversion Fails +- **Class not registering** — missing `Literal["domain"]` annotation, or `__init_subclass__` not calling super. +- **Type hints not working** — use `self.states[CustomState]` for full type checking on custom domains. +- **Conversion fails** — base class does not match the entity's actual state value type; check HA's raw data. ## Snippet Inventory -Moving from `advanced/snippets/custom-states/`: | Snippet | Status | Notes | |---|---|---| -| `basic_custom_state.py` | Move | → `states/snippets/` | -| `string_base_state.py` | Move | | -| `numeric_base_state.py` | Move | | -| `bool_base_state.py` | Move | | -| `datetime_base_state.py` | Move | | -| `time_base_state.py` | Move | | -| `define_your_own.py` | Move | | -| `adding_custom_attributes.py` | Move | | -| `via_get_states.py` | Move | | -| `known_domain_access.py` | Move | → DI usage example | -| `custom_domain_typed_access.py` | Move | | -| `custom_domain_runtime_access.py` | Move | | -| `direct_api_access.py` | Move | | -| `complete_example.py` | Move | | +| `basic_custom_state.py` | Move from `advanced/snippets/custom-states/` | H2: Defining a Custom State | +| `string_base_state.py` | Move | H3: StringBaseState | +| `numeric_base_state.py` | Move | H3: NumericBaseState | +| `bool_base_state.py` | Move | H3: BoolBaseState | +| `datetime_base_state.py` | Move | H3: DateTimeBaseState | +| `time_base_state.py` | Move | H3: TimeBaseState | +| `define_your_own.py` | Move | H3: Custom value type | +| `adding_custom_attributes.py` | Move | H2: Adding Typed Attributes | +| `via_get_states.py` | Move | H3: Via self.states[CustomStateClass] | +| `direct_api_access.py` | Drop | Redundant with the self.states example | +| `known_domain_access.py` | Drop | Runtime-vs-type-time distinction cut | +| `custom_domain_typed_access.py` | Drop | Merged into the self.states example | +| `custom_domain_runtime_access.py` | Drop | Merged into the self.states example | +| `complete_example.py` | Drop | Redundant with adding_custom_attributes example | +| New: DI usage example | Create | H3: With Dependency Injection — handler using `D.StateNew[CustomState]` | ## Cross-Links -- **Links to:** State Registry, Type Registry, DI page, DomainStates Reference -- **Linked from:** States overview, DomainStates Reference ("for domains not covered") +- **Links to:** State Registry, Type Registry, DI page, States overview (auto-generated API reference for built-in types) +- **Linked from:** States overview ("for domains not covered") diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/states/domain-states.md b/design/specs/070-doc-overhaul/outlines/core-concepts/states/domain-states.md index 3749596d0..7285e4eb1 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/states/domain-states.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/states/domain-states.md @@ -1,5 +1,9 @@ -# States — DomainStates Reference +# DomainStates Reference -**Status:** REMOVED. The auto-generated API reference (`hassette.models.states` is in `PUBLIC_MODULES`) serves as the authoritative domain state reference. A hand-written table would rot as domains are added. States overview links to the API reference instead. +**Status:** REMOVED +**Page type:** N/A +**Reader's job:** N/A — this page does not exist. The reader's job (look up which state class maps to which HA domain) is served by the auto-generated API reference for `hassette.models.states`. -See decision in outline audit (2026-06-02): killed this page because mkdocstrings already generates per-class reference for all 47 state classes from source. +## Rationale + +A hand-written domain-to-class reference table duplicates what mkdocstrings generates from source. The generated reference stays current as domains are added; a hand-written table rots. The States overview page links to the API reference for the full inventory and shows 2-3 inline examples for orientation. diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/states/overview.md b/design/specs/070-doc-overhaul/outlines/core-concepts/states/overview.md index bba6b59ad..d33d0b7b0 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/states/overview.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/states/overview.md @@ -1,50 +1,75 @@ -# States — Overview +# States -**Status:** Exists (179 lines), solid content, voice polish needed +**Status:** Rewrite from blank **Voice mode:** Concept — system-as-subject, no "you" +**Page type:** Concept +**Reader's job:** Read entity state from Home Assistant in typed, synchronous Python code. + +## What was cut (and where it goes) + +- **DomainStates Collection Interface table** — moved to a collapsible section. Most readers need `get()` and bracket access; the full method inventory is reference detail, not the primary job. +- **Built-in State Types full table** — replaced by 2-3 inline examples + link to auto-generated API reference. The 47-row table duplicates the generated reference and rots. The existing collapsible stays but with a note pointing to the API reference as the canonical source. +- **State Model Properties** (was missing from existing page) — added as a section. Readers who land here after seeing `is_unknown` or `extras` in a recipe need to understand what properties exist on every state. ## Outline ### (Opening) -Functional definition of the StateManager: what it does, `self.states` access. Match the Bus exemplar pattern — prose first. +Functional definition: the `StateManager` keeps a real-time, in-memory copy of all Home Assistant entity states. `self.states` provides synchronous, typed access — no `await`, no API calls. ### Mermaid Diagram -StateManager → StateProxy → DomainStates flow. Comes after the opening prose, not before it. +HA -> WebsocketService -> StateProxy -> self.states flow. Same structure as existing, after the opening prose. + +### H2: Reading State +The reader's core job. Three access patterns, ordered by frequency of use: + +#### H3: Domain Access +`self.states.light`, `self.states.sensor` — the most common pattern. Short entity name without domain prefix. Bracket access raises `KeyError`; `.get()` returns `None`. -### H2: Using the StateManager -#### H3: Domain Access — `self.states.light`, `self.states.sensor` -#### H3: Direct Entity Access — `self.states.get("light.kitchen")` -#### H3: Generic Access — `self.states[CustomState]` -#### H3: Iteration +#### H3: Direct Entity Access +`self.states.get("light.kitchen")` — full entity ID, auto-resolves to the correct typed state class. -### H2: DomainStates Collection Interface -Methods: `get()`, `items()`, `keys()`, `values()`, `to_dict()`, `__iter__`, `__len__`, `__contains__`, `__getitem__`, `__bool__`. +#### H3: Generic Access +`self.states[CustomState]` — for custom integrations or dynamic access. Returns a `DomainStates` collection. + +### H2: What a State Object Contains +Properties available on all state objects (`BaseState` subclasses). Ordered by what the reader reaches for first: + +- `value` — the entity's state (typed: `bool` for switches, `float` for sensors, `str` for selects, etc.) +- `attributes` — typed attribute object with domain-specific fields (e.g., `brightness` on `LightState`) +- `is_unknown` / `is_unavailable` — flags for when HA reports `"unknown"` or `"unavailable"`. In these cases `value` is `None` to preserve type safety. Check before using `value`. +- `is_group` — whether the entity is a group +- `extras` / `extra(key)` — untyped attributes not declared on the typed attributes class +- `attributes.has_feature(flag)` — bitfield check for domain-specific capabilities (e.g., `SUPPORT_BRIGHTNESS`) ### H2: Built-in State Types -Brief introduction: Hassette auto-generates typed state classes for 47 HA domains from HA core source. Show 2-3 examples inline (LightState with brightness, SensorState with numeric value, BinarySensorState with device_class). Explain the pattern: domain → state class → typed `value` + typed attributes. Link to auto-generated API reference (`hassette.models.states`) for the full inventory. For domains not covered or custom attributes, link to Custom States. +Hassette auto-generates typed state classes for 47 HA domains from HA core source. Show 2-3 inline examples: `LightState` (brightness, color), `SensorState` (numeric value, unit), `BinarySensorState` (bool value, device_class). Link to auto-generated API reference for the full inventory. Link to Custom States for domains not covered. -*No hand-written reference table — the API reference auto-generates from source and never rots.* +??? info "Collapsible: full domain-to-class table" (keep existing, add note that API ref is canonical) -### H2: State Model Properties -Properties available on all `BaseState` subclasses beyond `value` and `attributes`: -- `is_unknown` / `is_unavailable` — boolean flags. When HA reports `"unknown"` or `"unavailable"`, the state string is not stored in `value` (which would break strong typing — e.g., `bool` for switches, `float` for sensors). Instead, `value` is set to `None` and the corresponding flag is set to `True`. Check these flags before using `value`. -- `is_group` — whether the entity is a group entity -- `extras` dict and `extra(key)` method — access to untyped attributes not declared on the typed attributes class +### H2: Iterating Over States +Looping over domains: `for entity_id, state in self.states.light`. Brief mention of `.keys()`, `.values()`, `.items()`, `.to_dict()`. -Properties on `AttributesBase`: -- `has_feature(flag)` — bitfield check against `supported_features` for domain-specific capability detection (e.g., `SUPPORT_BRIGHTNESS`) +??? note "Collapsible: DomainStates collection methods" +Full method table (get, items, keys, values, iterkeys, itervalues, to_dict, __iter__, __len__, __contains__, __getitem__, __bool__). Lazy vs eager distinction. ### H2: Good to Know -Edge cases, caching behavior, state freshness. +- Startup and staleness: cache populated at startup, kept current via WebSocket, periodic poll guards against missed events. +- Missing entities: `.get()` vs bracket behavior. + +### H2: See Also (renamed from existing) +Links to Subscribing, Custom States, API Entities, Bus, Cache. ## Snippet Inventory | Snippet | Status | Notes | |---|---|---| -| 4 files in `states/snippets/` | Keep | Basic state access examples | -| Additional snippets from `core-concepts/snippets/` | Review | 3 files — check if states-related | +| `states_domain_access.py` | Keep | H3: Domain Access | +| `states_direct_access.py` | Keep | H3: Direct Entity Access | +| `states_generic_access.py` | Keep | H3: Generic Access | +| `states_iteration.py` | Keep | H2: Iterating Over States | +| `states_import.py` (from `core-concepts/snippets/`) | Keep | H2: Built-in State Types | ## Cross-Links -- **Links to:** Subscribing, Custom States, State Registry, Type Registry -- **Linked from:** Architecture, Apps overview, API/Entities +- **Links to:** Subscribing to State Changes, Custom States, State Registry, Type Registry, API Entities, Bus overview, Cache +- **Linked from:** Architecture, Apps overview, Getting Started diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/states/state-registry.md b/design/specs/070-doc-overhaul/outlines/core-concepts/states/state-registry.md index 14c4a2236..59b8aabc0 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/states/state-registry.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/states/state-registry.md @@ -1,55 +1,81 @@ -# States — State Registry +# State Registry -**Status:** Stub (3 lines), content moving from Advanced (216 lines) -**Voice mode:** Concept/reference hybrid — system-as-subject +**Status:** Rewrite from blank +**Voice mode:** Concept — system-as-subject, no "you" +**Page type:** Concept (advanced depth page) +**Reader's job:** Understand how Hassette maps HA domains to Python state classes, so they can override a mapping or debug unexpected state types. + +## What was cut (and where it goes) + +- **"Integration with Other Components" section** (DI integration, States Resource integration) — cut. These are the callers of the registry, not the registry itself. The reader who lands here already came from one of those callers. Repeating the integration examples doubles the content without adding information. +- **"Advanced Usage — Accessing the Registry"** — folded into a one-liner at the end of "Domain Override." Direct registry access is rare; it does not need its own H2. +- **"State Conversion — Direct Conversion"** — cut. `try_convert_state` is an internal API. The reader's job is "override a mapping" or "debug a type," not "call conversion functions directly." DI and `self.states` handle conversion automatically. +- **"Why Two Registries?" rationale** — kept but tightened to 2 sentences in the relationship section. The existing page spent 20+ lines explaining a design decision most readers do not need. ## Outline -Content source: `docs/pages/advanced/state-registry.md` +### (Opening) +The `StateRegistry` maps Home Assistant domains to Python state model classes. When state data arrives as an untyped dictionary, the registry determines which `BaseState` subclass to use for conversion. Most apps never interact with the registry directly — it works behind `self.states` and the DI system. + +State this page matters when: overriding a default mapping, writing a custom state class, or debugging an unexpected state type. -### H2: What Is the State Registry? -Maps HA entity domains to Python state classes. Automatic registration via `__init_subclass__`. +### H2: How Registration Works +Classes that inherit from `BaseState` with a valid `Literal["domain"]` annotation register automatically at class definition time via `__init_subclass__`. No explicit registration call needed. -### H2: How It Works -#### H3: Automatic Registration -State classes register themselves when defined. -#### H3: Domain Lookup -`StateRegistry.resolve(domain=...)` → state class. +??? note "Collapsible: implementation details" +Show the `__init_subclass__` hook behavior. The `StateRegistry.register()` call, the domain extraction from the `Literal` type. -### H2: Relationship with Type Registry -#### H3: The Complete Flow -Raw HA state string → State Registry (domain → class) → Type Registry (value → typed value). -#### H3: The `value_type` ClassVar -How state classes declare their value type. -#### H3: Why Two Registries? -State Registry = domain mapping, Type Registry = value conversion. Separate concerns. +### H2: Domain Lookup +`StateRegistry.resolve(domain=...)` returns the registered state class for a domain. Falls back to `BaseState` for unregistered domains. -### H2: State Conversion -#### H3: Direct Conversion -#### H3: Via Dependency Injection +### H2: Overriding a Domain Mapping +Define a custom class with the same domain as a built-in, after imports. The registry silently replaces the existing mapping. Mention `self.hassette.state_registry` for direct access when needed. -### H2: Domain Override -Overriding the default state class for a domain. +### H2: The Conversion Flow +Brief walkthrough of what happens when state data arrives: +1. Raw dict from HA +2. StateRegistry resolves domain to class +3. Pydantic validation begins +4. `value_type` ClassVar checked — TypeRegistry converts the raw value string +5. Typed state object produced + +One-sentence relationship note: StateRegistry answers "which class?", TypeRegistry answers "which Python type for the value?" Link to Type Registry page. ### H2: Union Type Support -How the registry handles `D.StateNew[SensorState | BinarySensorState]`. +How the registry handles `D.StateNew[SensorState | BinarySensorState]` — checks each type's domain, selects the match, falls back to `BaseState`. ### H2: Error Handling -#### H3: InvalidDataForStateConversionError -#### H3: InvalidEntityIdError -#### H3: UnableToConvertStateError - -### H2: Advanced Usage — Accessing the Registry -Direct registry access for introspection. +Three specific exceptions, one paragraph each: +- `InvalidDataForStateConversionError` — malformed or missing fields +- `InvalidEntityIdError` — bad entity ID format +- `UnableToConvertStateError` — conversion to target class failed ## Snippet Inventory -Moving from `advanced/snippets/state-registry/` (18 files): +Moving from `advanced/snippets/state-registry/` — trim from 18 to ~8 files: + | Snippet | Status | Notes | |---|---|---| -| All 18 files | Move | → `states/snippets/` (review for voice, trim redundant examples) | +| `raw_data_example.py` | Move | Opening — what raw data looks like | +| `automatic_registration.py` | Move | H2: How Registration Works (collapsible) | +| `domain_lookup.py` | Move | H2: Domain Lookup | +| `domain_override.py` | Move | H2: Overriding a Domain Mapping | +| `flow_raw_input.py` | Move | H2: Conversion Flow | +| `flow_converted_output.py` | Move | H2: Conversion Flow | +| `value_type_example.py` | Move | H2: Conversion Flow | +| `union_type_support.py` | Move | H2: Union Type Support | +| `error_invalid_data.py` | Move | H2: Error Handling | +| `error_invalid_entity_id.py` | Move | H2: Error Handling | +| `error_unable_to_convert.py` | Move | H2: Error Handling | +| `basic_custom_state_usage.py` | Drop | Covered on Custom States page | +| `di_integration.py` | Drop | Covered on DI page | +| `integration_di.py` | Drop | Duplicate of di_integration | +| `integration_states.py` | Drop | Covered on States overview | +| `example_benefits.py` | Drop | Rationale example, not actionable | +| `direct_conversion.py` | Drop | Internal API, not user-facing | +| `accessing_registry.py` | Drop | One-liner folded into Domain Override prose | ## Cross-Links - **Links to:** Type Registry, Custom States, DI page, States overview -- **Linked from:** States overview, DI page, Custom States +- **Linked from:** States overview, Custom States diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/states/subscribing.md b/design/specs/070-doc-overhaul/outlines/core-concepts/states/subscribing.md index 4063b6a82..f0be8689a 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/states/subscribing.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/states/subscribing.md @@ -1,52 +1,82 @@ -# States — Subscribing to State Changes +# Subscribing to State Changes -**Status:** Stub (3 lines), new content needed +**Status:** Rewrite from blank **Voice mode:** Concept — system-as-subject, no "you" +**Page type:** Concept +**Reader's job:** React to entity state changes in a handler — subscribe, filter, and receive typed state data. + +## What was cut (and where it goes) + +- **Full predicate/condition reference** — stays on Bus Filtering page. This page shows predicates in context (state-change-specific examples) and links to the complete reference. +- **`AllOf`/`AnyOf` composition** — kept but brief. One example combining two predicates for a state transition. The full composition API is on the Bus Filtering page. +- **Common Parameters exhaustive list** — replaced with a focused table of the parameters a reader actually needs when setting up a state subscription. The full `**opts: Unpack[Options]` reference belongs on the Bus page or API reference. ## Outline -Bridge page between Bus and States. Covers state-change-specific subscription patterns. Most predicates and conditions are designed for state changes — this is where they're shown in context. +### (Opening) +The Bus delivers state change events to handlers. `on_state_change` and `on_attribute_change` are the two subscription methods for reacting to entity state. Both return a `Subscription` handle. -### H2: Subscribing to State Changes -`on_state_change` and `on_attribute_change` — the two primary state subscription methods. Entity ID patterns (exact, glob, domain wildcard). +### H2: Basic Subscription +`on_state_change(entity_id, handler=..., name=...)` — simplest case. Entity ID patterns: exact match, glob (`"light.*"`), domain wildcard. `name=` is required (raises `ListenerNameRequiredError` if omitted). -### H2: State-Specific DI Annotations -`D.StateNew[T]`, `D.StateOld[T]`, `D.MaybeStateNew[T]`, `D.MaybeStateOld[T]`, `D.TypedStateChangeEvent[T]` — shown in state-change context (links to DI page for full reference). +### H2: Receiving Typed State +How the handler gets state data. DI annotations specific to state changes: -### H2: The `changed` Parameter -Type is `bool | ComparisonCondition`, not just bool. `True` (default), `False`, or a `ComparisonCondition` (e.g., `C.Increased()`) that compares old vs new values. +- `D.StateNew[T]` / `D.StateOld[T]` — new/old state as a typed model +- `D.MaybeStateNew[T]` / `D.MaybeStateOld[T]` — `None`-safe variants +- `D.TypedStateChangeEvent[T]` — the full event with both states -### H2: Matching State Values -#### H3: `changed_to` and `changed_from` — simple value matching -#### H3: Predicates for State Changes -`P.StateFrom`, `P.StateTo` — tracking transitions. (No `P.StateFromTo` — combine with `AllOf`.) -#### H3: Numeric Conditions -`C.Increased`, `C.Decreased` — monitoring numeric changes. (No `C.InRange` — use `C.Comparison` for range checks.) +Link to DI page for the complete annotation reference. + +### H2: Filtering State Changes +Ordered from simplest to most powerful: + +#### H3: `changed_to` and `changed_from` +Simple value matching — fire only when the state transitions to or from a specific value. -### H2: Combining Predicates -`AllOf` and `AnyOf` composition. Examples specific to state-change scenarios. +#### H3: The `changed` Parameter +Type is `bool | ComparisonCondition`. `True` (default) fires on any change. `False` fires on every event even without a change. A `ComparisonCondition` like `C.Increased()` compares old vs new. + +#### H3: Predicates +`P.StateFrom`, `P.StateTo` for tracking transitions. Combine with `AllOf` for from-to pairs (no `P.StateFromTo`). + +#### H3: Numeric Conditions +`C.Increased`, `C.Decreased` for monitoring numeric value changes. ### H2: Attribute Changes -`on_attribute_change(entity_id, attr, ...)` — `attr: str` is a required second positional argument. Monitors a specific attribute rather than the state string. Also has attribute-specific predicates: `P.AttrFrom`, `P.AttrTo`, `P.AttrDidChange`, `P.AttrComparison`. +`on_attribute_change(entity_id, attr, ...)` — `attr` is a required second positional argument. Attribute-specific predicates: `P.AttrFrom`, `P.AttrTo`, `P.AttrDidChange`, `P.AttrComparison`. + +### H2: Subscription Options +Focused table of the parameters most relevant to state subscriptions: + +| Parameter | Purpose | +|---|---| +| `name=` | Required. Identifies the listener in logs and DB. | +| `duration=` | Fire only after the state has been in the new value for N seconds. | +| `immediate=` | Fire immediately on first match, then apply duration. Raises `ValueError` with glob patterns. | +| `debounce=` | Wait N seconds of quiet before firing. | +| `throttle=` | Fire at most once per N seconds. Mutually exclusive with debounce. | +| `once=` | Unsubscribe after the first fire. | +| `on_error=` | Error handler callback. | -### H2: Common Parameters -`name=` (**required** — omitting raises `ListenerNameRequiredError`), `duration=`, `immediate=` (raises `ValueError` with glob patterns), `on_error=`, `where=` (additional predicates), `once=`, `debounce=`, `throttle=` (mutually exclusive with debounce), `timeout=`, `timeout_disabled=`. Note: `timeout`/`timeout_disabled`/`once`/`debounce`/`throttle` are passed via `**opts: Unpack[Options]`. +Brief note: `timeout=`, `timeout_disabled=` also available via `**opts`. ### H2: See Also -→ Bus overview (general subscription), → Bus Filtering (service call filtering, complete predicate/condition reference), → DI page (full annotation reference) +Bus overview, Bus Filtering (complete predicate/condition reference), DI page (full annotation reference), States overview. ## Snippet Inventory -Snippets moving from Bus/Filtering and new: | Snippet | Status | Notes | |---|---|---| -| `filtering_simple_start.py` | Move from filtering/ | `changed_to` example | -| `filtering_simple_stop.py` | Move from filtering/ | `changed_to` example | -| `filtering_state_from_to.py` | Move from filtering/ | State transition tracking | -| `filtering_increased_decreased.py` | Move from filtering/ | Numeric conditions | -| `changed_false.py` | Move from filtering/ | `changed=False` example | -| New: attribute change example | New | `on_attribute_change` with predicate | -| New: combined predicates for state | New | `AllOf`/`AnyOf` composition in state context | +| New: basic subscription | Create | H2: Basic Subscription — `on_state_change` with entity ID and handler | +| New: typed state DI | Create | H2: Receiving Typed State — handler with `D.StateNew[SensorState]` | +| `filtering_simple_start.py` | Move from `bus/snippets/filtering/` | H3: changed_to | +| `filtering_simple_stop.py` | Move from `bus/snippets/filtering/` | H3: changed_to | +| `filtering_state_from_to.py` | Move from `bus/snippets/filtering/` | H3: Predicates | +| `filtering_increased_decreased.py` | Move from `bus/snippets/filtering/` | H3: Numeric Conditions | +| `changed_false.py` | Move from `bus/snippets/filtering/` | H3: changed parameter | +| New: attribute change | Create | H2: Attribute Changes — `on_attribute_change` with predicate | +| New: duration example | Create | Subscription Options — `duration=` hold pattern | ## Cross-Links diff --git a/design/specs/070-doc-overhaul/outlines/core-concepts/states/type-registry.md b/design/specs/070-doc-overhaul/outlines/core-concepts/states/type-registry.md index 8deb9aa3f..9313a7018 100644 --- a/design/specs/070-doc-overhaul/outlines/core-concepts/states/type-registry.md +++ b/design/specs/070-doc-overhaul/outlines/core-concepts/states/type-registry.md @@ -1,68 +1,106 @@ -# States — Type Registry +# Type Registry -**Status:** Stub (3 lines), content moving from Advanced (329 lines) -**Voice mode:** Concept/reference hybrid — system-as-subject +**Status:** Rewrite from blank +**Voice mode:** Concept — system-as-subject, no "you" +**Page type:** Concept (advanced depth page) +**Reader's job:** Register a custom type converter so Hassette can convert raw HA string values to a Python type that the built-in converters do not cover. -## Outline +## What was cut (and where it goes) -Content source: `docs/pages/advanced/type-registry.md` +- **"Integration with State Models" section** — cut as a standalone section. The `value_type` ClassVar and automatic conversion are explained in the Conversion Flow on the State Registry page. Repeating them here is the source-code-mirror anti-pattern — organized by "which internal calls which," not by the reader's job. +- **"Integration with Dependency Injection" section** — cut. Custom extractors and DI type conversion belong on the DI page. The reader who needs a custom converter does not also need to understand extractor internals. +- **"Relationship with StateRegistry" section** — cut as a standalone H2. One sentence in the opening suffices: "The TypeRegistry handles value conversion; the State Registry handles domain-to-class mapping." +- **"Best Practices" section** — cut. The five "best practices" are either already stated in context (define `value_type`, register early) or generic advice (test your code, use type hints). Best practices sections are an AI tell that pads length without serving the reader's job. +- **"Inspection and Debugging" section** — kept as a collapsible. Useful for debugging but not the primary job. +- **"Union Type Performance"** — folded into one sentence in the "How Conversion Works" section. Ordering types by specificity is good advice but does not need its own section. -### H2: Purpose -Converts raw HA values (strings) to typed Python values. The second step after State Registry picks the class. +## Outline -### H2: Core Concepts -#### H3: Registration System — Decorator and Simple Registration -#### H3: Conversion Lookup +### (Opening) +Home Assistant sends nearly all values as strings. The `TypeRegistry` converts those strings to typed Python values — `int`, `float`, `bool`, `ZonedDateTime`, `Decimal`, etc. Most apps never touch the registry directly because the built-in converters handle all standard HA types. -### H2: Integration with State Models -#### H3: The `value_type` ClassVar -#### H3: Automatic Conversion in Models -#### H3: Union Type Handling +This page matters when: a custom state model's `value_type` is a type Hassette does not know how to convert, or a built-in conversion gives unexpected results. -### H2: Integration with Dependency Injection -How type conversion works when DI extracts state data. +### H2: How Conversion Works +The registry maps `(from_type, to_type)` pairs to converter functions. When state data arrives and the raw value does not match the expected `value_type`, the registry looks up a converter for the pair and applies it. If no converter exists, it tries the target type's constructor as a fallback. -### H2: Relationship with State Registry -The workflow: raw string → State Registry → Type Registry → typed value. +For union types (`value_type = (int, float, str)`), conversion attempts each type in order. Put the most specific type first. ### H2: Built-in Converters -#### H3: Numeric Conversions -#### H3: Boolean Conversions -#### H3: DateTime Conversions -#### H3: Conversion Errors -#### H3: Missing Converters -#### H3: Custom Error Messages +What ships out of the box. Grouped by category: + +#### H3: Numeric +`str` to `int`, `float`, `Decimal`; cross-conversions between numeric types. + +#### H3: Boolean +`str` to `bool` — HA-specific: `"on"`/`"true"`/`"yes"`/`"1"` map to `True`. + +#### H3: DateTime +`str` to `ZonedDateTime`, `Date`, `Time`, `OffsetDateTime`, `PlainDateTime`. Cross-conversions between `whenever` types. Stdlib `datetime`/`date`/`time` conversions for boundary compatibility. + +### H2: Registering a Custom Converter +The reader's primary job. Two approaches: -### H2: Inspection and Debugging -Tools for inspecting the registry. +#### H3: Decorator Registration +`@register_type_converter_fn` — define a function with `from_type` and `to_type` annotations. Show a concrete example (e.g., `str` to a custom enum). -### H2: Best Practices -Key rules for custom converters. +#### H3: Simple Type Registration +`register_simple_type_converter(from_type, to_type, func)` — one-liner for straightforward conversions. ### H2: Common Patterns +Concrete examples the reader can adapt: + #### H3: Enum Conversion +Convert HA string values to Python enums. + #### H3: Structured Data -#### H3: Units of Measurement +Convert JSON strings to dataclasses. + +### H2: Error Handling +- Conversion failure wraps the original error with context (source value, types involved). +- Missing converter + failed constructor raises `UnableToConvertValueError`. +- Custom error messages via `error_message` parameter on registration. -### H2: Automatic Type Conversion -Content originally considered for Custom Extractors page — lives here instead. How extractors use the type registry for automatic conversion. +??? note "Collapsible: Inspection and Debugging" +`TypeRegistry` methods for listing converters, checking for specific converters, getting converter details. Primarily useful for debugging. ## Snippet Inventory -Moving from `advanced/snippets/type-registry/` (24 files): +Moving from `advanced/snippets/type-registry/` — trim from 24 to ~10 files: + | Snippet | Status | Notes | |---|---|---| -| All 24 files | Move | → `states/snippets/` (review for voice, trim if 329 lines of content is over-documented) | - -Also moving from `dependency-injection/`: +| `custom_type_converter.py` (from `bus/snippets/dependency-injection/`) | Keep in place, reference | H3: Decorator Registration | +| `simple_registration.py` | Move | H3: Simple Type Registration | +| `lookup_example.py` | Move | H2: How Conversion Works | +| `pattern_enum.py` | Move | H3: Enum Conversion | +| `pattern_structured.py` | Move | H3: Structured Data | +| `conversion_error.py` | Move | H2: Error Handling | +| `missing_converter.py` | Move | H2: Error Handling | +| `custom_error_msg.py` | Move | H2: Error Handling | +| `inspect_list.py` | Move | Collapsible: Inspection | +| `inspect_check.py` | Move | Collapsible: Inspection | +| `inspect_list_output.txt` | Move | Collapsible: Inspection | +| `entry_example.py` | Drop | Implementation detail, not user-facing | +| `state_model_value_type.py` | Drop | Covered on State Registry page | +| `base_state_convert_call.py` | Drop | Internal API detail | +| `typed_model_usage.py` | Drop | Generic usage, covered elsewhere | +| `union_type_order.py` | Drop | Folded into one sentence in How Conversion Works | +| `union_type_performance.py` | Drop | Folded into one sentence | +| `di_custom_extractor.py` | Drop | Belongs on DI page | +| `best_practice_*.py` (5 files) | Drop | Best practices section cut | +| `pattern_units.py` | Drop | Niche pattern, low value | +| `inspect_details.py` | Drop | Low-value inspection detail | + +Also dropping the 4 DI-related snippets that were planned to move here from `dependency-injection/`: | Snippet | Status | Notes | |---|---|---| -| `builtin_conversions_explicit.py` | Move here | Type conversion examples | -| `builtin_conversions_implicit.py` | Move here | | -| `bypass_conversion_any.py` | Move here | | -| `bypass_conversion_custom.py` | Move here | | +| `builtin_conversions_explicit.py` | Stay on DI page | DI context, not type registry context | +| `builtin_conversions_implicit.py` | Stay on DI page | | +| `bypass_conversion_any.py` | Stay on DI page | | +| `bypass_conversion_custom.py` | Stay on DI page | | ## Cross-Links -- **Links to:** State Registry, Custom States, DI page, Custom Extractors -- **Linked from:** States overview, State Registry, DI page +- **Links to:** State Registry, Custom States, DI page +- **Linked from:** States overview, State Registry, Custom States diff --git a/design/specs/070-doc-overhaul/outlines/home.md b/design/specs/070-doc-overhaul/outlines/home.md index cf585f31f..1f0aab636 100644 --- a/design/specs/070-doc-overhaul/outlines/home.md +++ b/design/specs/070-doc-overhaul/outlines/home.md @@ -1,46 +1,78 @@ # Home (index.md) -**Status:** Exists (92 lines), solid landing page, voice polish needed +**Status:** Exists (92 lines), needs JTBD redesign — good content but buries the hook **Voice mode:** Marketing/getting-started hybrid — engaging, "you" allowed +**Page type:** Landing page +**Reader's job:** Decide in 10 seconds whether Hassette is worth their time, then find the right entry point for their situation. + +## What was cut + +The existing page works but the opening is two paragraphs before the reader +sees what Hassette actually does. The "Why Hassette?" bullet list repeats +what was already said. The "What you can build" list is generic enough to +describe any framework. + +Changes: +- Hook tightened: one sentence that says what it is, one that says what makes + it different. No paragraph-length pitch. +- "Why Hassette?" collapsed into the hook — the bullet list was restating the + opening. The distinct value props (DI for events, test harness, type-safe + config) belong in the opening sentence, not a separate section. +- "What you can build" replaced with a concrete code example. A code block + does more in 10 seconds than a bullet list of abstractions. +- "See it in action" videos stay — they're the strongest content on the page. +- "Already using AppDaemon?" tightened to three concrete bullets (already good) + and kept. +- "Next steps" streamlined — remove "Is Hassette right for you?" from the + top-level list (it's linked in the opening) and add Recipes. ## Outline -### H2: What is Hassette? -One-paragraph pitch. FastAPI analogy. "Who it's for" targeting. +### Logo + tagline +One-line tagline under the logo. Current tagline works: "An async-first Python +framework for writing Home Assistant automations as code — with type safety, +dependency injection, and a built-in test harness." -**Update needed:** "Is Hassette Right for You?" link currently points to `hassette-vs-ha-yaml.md` — should also link to the new `evaluator.md` page. +### H2: What is Hassette? +Two sentences max: +1. What it is: write HA automations as Python classes instead of YAML. +2. What makes it different: FastAPI-style DI for event handlers, Pydantic + config, built-in test harness. -### H2: Why Hassette? -Bulleted feature highlights: code, async, type-safe config, DI, test harness, web UI. +"Who it's for" line with link to "Is Hassette Right for You?" ### H2: See It in Action -Code screenshots/examples: autocomplete, event handling, web UI. - -### H2: What You Can Build -Brief examples of automation types. +Videos (autocomplete, event handling) + web UI screenshot. These are the +strongest proof — keep them prominent. Consider adding a minimal code example +alongside or between the videos for readers who prefer reading code to +watching video. ### H2: Quick Start -Three-step teaser linking to Quickstart. +Three-line install command via `--8<--` include. One sentence linking to the +Quickstart guide with time estimate ("running app in about 30 minutes"). ### H2: Already Using AppDaemon? -Link to Migration section. +Three concrete improvements (Pydantic config, test harness, DI). Link to +Migration Guide. Keep as-is — this section is well-written. ### H2: Next Steps -Links to Quickstart, Is Hassette Right for You?, Core Concepts, Recipes. +Streamlined link list: +- Quickstart (local setup) +- Docker Deployment (production) +- Core Concepts (architecture) +- Recipes (copy-paste automations) +- Migration Guide (from AppDaemon) ## Snippet Inventory -The home page is the most-visited page — stale code here is the worst place for it. Every code example must come from a tested snippet file, not inline blocks. - | Snippet | Status | Notes | |---|---|---| -| `getting-started/snippets/install.sh` | Keep (already `--8<--` included) | Quick start install command | -| New: `home_event_handling.py` | New | If the video section is supplemented or replaced with a code example, it must be a snippet | -| New: `home_quick_app.py` | New | If a "see it in action" code block is added alongside/instead of videos | +| `getting-started/snippets/install.sh` | Keep | Quick start install command | -**Rule:** Any code block on this page uses `--8<--` includes. No inline code fences for app examples. Pyright CI catches drift automatically when snippets are external files. +Any code block added to "See It in Action" must be a `--8<--` included snippet +file, not inline. Pyright CI catches drift automatically. ## Cross-Links -- **Links to:** Is Hassette Right for You?, Quickstart, Migration overview, Core Concepts/Architecture, Recipes -- **Linked from:** (entry point — linked from everywhere implicitly) +- **Links to:** Is Hassette Right for You?, Quickstart, Docker Deployment, Core Concepts/Architecture, Recipes, Migration Guide, Web UI +- **Linked from:** (entry point — all pages implicitly) diff --git a/design/specs/070-doc-overhaul/outlines/migration/api.md b/design/specs/070-doc-overhaul/outlines/migration/api.md index 45d51b276..97b7f045d 100644 --- a/design/specs/070-doc-overhaul/outlines/migration/api.md +++ b/design/specs/070-doc-overhaul/outlines/migration/api.md @@ -1,38 +1,47 @@ # Migration — API Calls -**Status:** Exists (130 lines), comparison-driven, voice polish needed +**Page type:** Migration (feature comparison) +**Reader's job:** Convert their AppDaemon state reads, service calls, and logging to Hassette's `self.states`, `self.api`, and `self.logger`. **Voice mode:** Comparison — "you" allowed -## Outline +## What was cut (and where it goes) + +- **Overview section** reduced to a one-sentence intro. The existing overview spent 6 lines restating what the sub-sections demonstrate. The reader learns faster from the first example. +- **Full State Migration Example** section removed. The Getting Entity State section already shows a complete before/after. A standalone "full example" at the end duplicated it. -### H2: Overview -What changes: `self.get_state()` → domain-typed access `self.states.light.get("light.kitchen")` (preferred, returns typed `LightState | None`) or `self.states.get("entity_id")` (generic, returns `BaseState | None`) or `self.api.get_state()` (fresh from HA). +## Outline ### H2: Getting Entity State -#### H3: AppDaemon -#### H3: Hassette: Domain-Typed State Cache (recommended) -`self.states.light.get("light.kitchen")` → `LightState | None`. Prefer over generic `self.states.get()` for typed results. -#### H3: Hassette: Direct API Call +The most common API operation comes first. Three sub-sections, each with a snippet: + +- **AppDaemon** — `self.get_state()` returns strings/dicts +- **Hassette: State Cache (recommended)** — `self.states.light.get("light.kitchen")` returns `LightState | None`. Access pattern table: domain-typed, generic, iteration. No `await` needed. +- **Hassette: Direct API Call** — `await self.api.get_state()` for fresh reads from HA (rare). + +One-paragraph guidance: use `self.states` for reads in handlers and scheduled tasks; use `self.api.get_state()` only when you need to bypass the cache. ### H2: Calling Services -AppDaemon `call_service` vs Hassette `api.call_service`. +Side-by-side tabs: AppDaemon's `self.call_service("domain/service", ...)` (synchronous, slash-separated) vs Hassette's `await self.api.call_service("domain", "service", ...)` (async, separate arguments). Warning: forgetting `await` silently does nothing. ### H2: Setting States -AppDaemon `set_state` vs Hassette `api.set_state`. +Side-by-side tabs: `self.set_state(...)` vs `await self.api.set_state(...)`. Brief. ### H2: Logging -AppDaemon `self.log` vs Hassette `self.logger`. - -### H2: Full State Migration Example -Complete before/after. +Side-by-side tabs: AppDaemon's `self.log()` / `self.error()` vs Hassette's `self.logger.info()` / `.warning()` / `.error()`. Note: Hassette logger includes instance name, method, and line number automatically. Use `%s`-style formatting. ## Snippet Inventory -| Snippet | Status | Notes | +| Snippet | Decision | Notes | |---|---|---| -| ~6 migration/api snippets | Keep | Comparison pairs | +| `api_appdaemon_get_state.py` | Keep | AppDaemon state access | +| `api_hassette_states_cache.py` | Keep | State cache access | +| `api_hassette_get_state_api.py` | Keep | Direct API call | +| `api_hassette_call_service.py` | Keep | Service call | +| `api_hassette_set_state.py` | Keep | Set state | +| `api_logging.py` | Keep | Logging comparison | +| `api_migration_getting_states.py` | Remove | Duplicates the state cache section | ## Cross-Links -- **Links to:** API overview, States overview, API/Entities -- **Linked from:** Migration overview +- **Links to:** API overview, States overview, Entities & States, Services +- **Linked from:** Migration overview, Migration checklist diff --git a/design/specs/070-doc-overhaul/outlines/migration/bus.md b/design/specs/070-doc-overhaul/outlines/migration/bus.md index ba798d7e9..0a561f0d6 100644 --- a/design/specs/070-doc-overhaul/outlines/migration/bus.md +++ b/design/specs/070-doc-overhaul/outlines/migration/bus.md @@ -1,43 +1,58 @@ # Migration — Bus & Events -**Status:** Exists (151 lines), comparison-driven, voice polish needed +**Page type:** Migration (feature comparison) +**Reader's job:** Convert their AppDaemon `listen_state` / `listen_event` calls to Hassette bus subscriptions, with correct syntax and without silent breakage. **Voice mode:** Comparison — tabs for side-by-side, "you" allowed +## What was cut (and where it goes) + +- **Overview section** folded into a one-sentence intro. The reader already knows what the bus is from the migration overview; repeating the mapping wastes their time. +- **Common Migration Patterns** section removed. Each pattern (state change with filter, service call subscription) is already shown as a side-by-side example in its respective section above. The "patterns" section was duplicating those examples without adding new information. + ## Outline -### H2: Overview -What changes: `listen_state` → `on_state_change`, `listen_event` → `on`. +### H2: The `name=` Requirement +Lead with the most common migration breakage. Every `self.bus.on_*()` call requires `name=`. Omitting it raises `ListenerNameRequiredError`. One sentence explaining why (telemetry tracking, log readability). One snippet showing the fix. This goes first because it blocks every other bus migration step. ### H2: State Change Listeners -#### H3: AppDaemon — `listen_state` pattern -#### H3: Hassette: with DI (recommended) — `on_state_change` + `D.StateNew[T]` -#### H3: Hassette: with full event object -#### H3: Filter options — `changed_to`, `changed_from`, predicates +Side-by-side tabs: AppDaemon `listen_state` vs Hassette `on_state_change`. -### H2: Service Call Listeners -#### H3: AppDaemon — `listen_event("call_service")` -#### H3: Hassette: with DI (recommended) -#### H3: Hassette: with full event object +**Sub-sections:** +- AppDaemon pattern (snippet) +- Hassette with DI (recommended) — `D.StateNew[T]` annotation (snippet) +- Hassette with full event object — for readers who want the raw event (snippet) +- Filter argument mapping table: `new=` -> `changed_to=`, `old=` -> `changed_from=`, `attribute=` -> use `on_attribute_change()`. Link to Filtering page for predicates. ### H2: Attribute Change Listeners -AppDaemon `listen_state(..., attribute=...)` → Hassette `on_attribute_change(entity_id, attribute, ...)`. +AppDaemon's `listen_state(..., attribute="battery")` maps to Hassette's `on_attribute_change(entity_id, "battery", ...)`. Brief, one side-by-side example. -### H2: The `name=` Requirement -All bus subscription methods require `name=`. Omitting it raises `ListenerNameRequiredError`. Most common migration breakage point. +### H2: Service Call Listeners +Side-by-side tabs: AppDaemon `listen_event("call_service")` vs Hassette `on_call_service`. -### H2: Canceling Subscriptions -Handle patterns comparison. +**Sub-sections:** +- AppDaemon pattern (snippet) +- Hassette with DI (recommended) — `D.Domain`, `D.EntityId`, etc. (snippet) +- Hassette with full event object (snippet) +- Available DI markers for service call handlers (bullet list) -### H2: Common Migration Patterns -State changes with filter, service call subscriptions. +### H2: Canceling Subscriptions +Side-by-side tabs: `self.cancel_listen_state(handle)` vs `subscription.cancel()`. Note that all three registration methods are async and must be awaited. ## Snippet Inventory -| Snippet | Status | Notes | +| Snippet | Decision | Notes | |---|---|---| -| ~8 migration/bus snippets | Keep | Side-by-side comparison pairs | +| `bus_appdaemon_state_change.py` | Keep | AppDaemon state listener | +| `bus_hassette_state_change_di.py` | Keep | DI-based handler | +| `bus_hassette_state_change_event.py` | Keep | Full event handler | +| `bus_appdaemon_event.py` | Keep | AppDaemon service call listener | +| `bus_hassette_on_call_service_di.py` | Keep | DI-based service call handler | +| `bus_hassette_on_call_service_event.py` | Keep | Full event service call handler | +| `bus_cancel_subscription.py` | Keep | Subscription cancellation | +| `bus_migration_state_changes.py` | Keep | Used in state change side-by-side | +| `bus_migration_service_calls.py` | Keep | Used in service call side-by-side | ## Cross-Links -- **Links to:** Bus overview, DI page, States/Subscribing, Bus/Filtering -- **Linked from:** Migration overview +- **Links to:** Bus overview, Handlers, Filtering, Dependency Injection +- **Linked from:** Migration overview, Migration checklist diff --git a/design/specs/070-doc-overhaul/outlines/migration/concepts.md b/design/specs/070-doc-overhaul/outlines/migration/concepts.md index d39db68b0..8eaba1dac 100644 --- a/design/specs/070-doc-overhaul/outlines/migration/concepts.md +++ b/design/specs/070-doc-overhaul/outlines/migration/concepts.md @@ -1,35 +1,43 @@ # Migration — Mental Model -**Status:** Exists (90 lines), solid content, voice polish needed +**Page type:** Migration (concept comparison) +**Reader's job:** Understand the structural differences between AppDaemon and Hassette so they can write idiomatic Hassette code instead of translating AppDaemon patterns one-for-one. **Voice mode:** Concept — comparison-driven, "you" allowed +## What was cut (and where it goes) + +- Nothing cut. The existing page is well-structured. The rewrite reorders sections by what hits the reader first during migration (class shape, then async, then types) rather than by abstraction level (execution model first). + ## Outline -### H2: Execution Model -Single-threaded async (Hassette) vs multi-threaded (AppDaemon). +### H2: App Structure — Inheritance vs Composition +Lead with the most visible change: the class definition. AppDaemon's `Hass` base class with `initialize()` vs Hassette's `App[Config]` with `async def on_initialize()`. Side-by-side tab snippets. Key differences list: base class, lifecycle hook name, `async` keyword. This is the first thing a migrator edits, so it comes first. -### H2: Access Model -Handles vs global `self.get_state()`. +### H2: Access Model — `self.method()` vs `self.component.method()` +AppDaemon's flat `self.listen_state()` / `self.call_service()` surface vs Hassette's composition: `self.bus`, `self.scheduler`, `self.api`, `self.states`, `self.cache`, `self.logger`. Table mapping each attribute to what it does. -### H2: Inheritance vs Composition -AppDaemon's Hass base class vs Hassette's App[Config] + handles. +### H2: Async vs Sync +Single-threaded async (Hassette) vs multi-threaded (AppDaemon). What this means in practice: `await` on API calls and bus registrations. Mention `AppSync` as the escape hatch for existing sync codebases — one paragraph, link to the full `AppSync` section below. ### H2: Typed vs Untyped -String-based AppDaemon vs typed Pydantic models. +String-based AppDaemon returns vs typed Pydantic models in Hassette. Three areas: entity states (`LightState` vs raw dict), app configuration (`AppConfig` vs `self.args`), API responses (structured models vs dicts). -### H2: Callback Signatures -Raw dicts (AppDaemon) vs DI annotations (Hassette). +### H2: Callback Signatures — Fixed vs Flexible +AppDaemon's rigid `(self, entity, attribute, old, new, **kwargs)` vs Hassette's DI-based signatures. Three options: full event object, DI annotations for specific fields, or no arguments. Link to Bus & Events for the full DI reference. -### H2: Synchronous API -`self.call_service()` (AppDaemon) vs `await self.api.call_service()` (Hassette). +### H2: Synchronous API (`AppSync`) +For codebases with heavy sync logic. `AppSync` runs lifecycle hooks in a managed thread. Bus, scheduler, and API accessed through `.sync` facades. One snippet. Position this as an intermediate step, not the target. ## Snippet Inventory -| Snippet | Status | Notes | +| Snippet | Decision | Notes | |---|---|---| -| Relevant comparison snippets from `migration/snippets/` | Review | Side-by-side examples | +| `concepts_sync_async.py` | Keep | Async vs sync comparison | +| `concepts_appdaemon_app.py` | Keep | AppDaemon app structure | +| `concepts_hassette_app.py` | Keep | Hassette app structure | +| `concepts_appsync.py` | Keep | AppSync example | ## Cross-Links -- **Links to:** Migration overview, Apps overview +- **Links to:** Migration overview, Bus & Events (DI), Apps overview, AppSync reference - **Linked from:** Migration overview diff --git a/design/specs/070-doc-overhaul/outlines/migration/configuration.md b/design/specs/070-doc-overhaul/outlines/migration/configuration.md index ca9fbb13d..29791bddc 100644 --- a/design/specs/070-doc-overhaul/outlines/migration/configuration.md +++ b/design/specs/070-doc-overhaul/outlines/migration/configuration.md @@ -1,34 +1,51 @@ # Migration — Configuration -**Status:** Exists (127 lines), comparison-driven, voice polish needed +**Page type:** Migration (feature comparison) +**Reader's job:** Convert their AppDaemon YAML configuration files to Hassette's `hassette.toml` and typed `AppConfig` models. **Voice mode:** Comparison — "you" allowed -## Outline +## What was cut (and where it goes) + +- **Benefits of Typed Configuration** section removed. The benefits are self-evident from the code examples (IDE autocomplete, validation at startup, defaults with constraints). Listing them as a sales pitch after the reader has already committed to migrating adds nothing. +- **Migration Steps** section merged into the per-app section. The existing page showed the same YAML-to-TOML conversion twice — once in the Global/Per-App sections and again in Migration Steps. One pass is enough. -### H2: Overview -YAML-based (AppDaemon) → TOML + Pydantic (Hassette). +## Outline ### H2: Global Configuration -#### H3: AppDaemon (`appdaemon.yaml`) -#### H3: Hassette (`hassette.toml`) +Lead with the side-by-side conversion. The reader has `appdaemon.yaml` open and wants the TOML equivalent. + +Side-by-side tabs: +- AppDaemon `appdaemon.yaml` snippet (HA URL, token, plugins) +- Hassette `hassette.toml` snippet (connection settings) + +Note: the HA token comes from the `HASSETTE__TOKEN` environment variable or `.env`, not from `hassette.toml`. ### H2: Per-App Configuration -#### H3: AppDaemon (`apps.yaml`) -#### H3: Hassette (`hassette.toml` + `AppConfig`) +The bigger conceptual change: raw dicts become typed models. + +Side-by-side tabs: +- AppDaemon `apps.yaml` snippet (module, class, args dict) +- Hassette `hassette.toml` + `AppConfig` class snippet + +Show the before/after for config access: `self.args["args"]["entity"]` becomes `self.app_config.entity`. What the reader gains: missing fields raise an error at startup, IDE autocomplete works, Pydantic validators catch invalid values. -### H2: Migration Steps -Step-by-step config conversion. +Admonition: `[[double brackets]]` — TOML array-of-tables syntax means multiple instances of the same app class with different configs. -### H2: Benefits of Typed Configuration -Why the change is worth the effort. +### H2: Multi-Instance Apps +Brief: same app class, multiple rooms. AppDaemon repeats the `apps.yaml` block; Hassette repeats the `[[apps.my_app.config]]` block. One TOML snippet showing two instances. Link to App Configuration for the full reference. ## Snippet Inventory -| Snippet | Status | Notes | +| Snippet | Decision | Notes | |---|---|---| -| ~4 migration/config snippets | Keep | YAML vs TOML examples | +| `config_appdaemon_yaml.yaml` | Keep | AppDaemon global config | +| `config_migration_toml.toml` | Keep | Hassette global config | +| `config_apps_yaml.yaml` | Keep | AppDaemon per-app config | +| `config_appdaemon_access.py` | Keep | AppDaemon config access pattern | +| `config_hassette_toml.toml` | Keep | Hassette per-app config | +| `config_hassette_appconfig.py` | Keep | AppConfig class definition | ## Cross-Links -- **Links to:** Configuration overview, Apps/Configuration -- **Linked from:** Migration overview +- **Links to:** App Configuration, Configuration overview, Global Settings, Applications +- **Linked from:** Migration overview, Migration checklist diff --git a/design/specs/070-doc-overhaul/outlines/migration/overview.md b/design/specs/070-doc-overhaul/outlines/migration/overview.md index 42d951549..37cb3ea93 100644 --- a/design/specs/070-doc-overhaul/outlines/migration/overview.md +++ b/design/specs/070-doc-overhaul/outlines/migration/overview.md @@ -1,48 +1,44 @@ # Migration — Overview -**Status:** Exists (92 lines), solid content, voice polish needed +**Page type:** Migration (landing page) +**Reader's job:** Decide whether to migrate from AppDaemon, then find the right page for each piece of their app. **Voice mode:** Getting-started — "you" allowed, comparison-driven -## Outline +## What was cut (and where it goes) -### H2: Is Migration Worth It? -Honest assessment of when migration makes sense. +- **Migration Checklist** page removed. Its content was a thin restatement of the sub-pages. The Quick Reference Table on this page and the sub-page-per-topic structure already serve as the "what to do" checklist. The existing `checklist.md` page remains as a step-by-step per-app checklist for readers who want a linear walkthrough. +- **Guide Structure** section removed. The page's own heading order and the Quick Reference Table make the structure self-evident. A table-of-contents section about the table of contents adds no value. +- **"What Changes"** merged into the Quick Reference Table intro. Four numbered bullets restating the sub-page topics is redundant when the table already maps every operation. +- **Next Steps** section removed. Duplicated the Guide Structure table. -### H2: Known Gaps -What AppDaemon has that Hassette doesn't (yet). +## Outline -### H2: What Changes -High-level summary of differences. +### H2: Quick Reference Table +Lead with this. The reader's first job is lookup: "I use `listen_state` — what's the Hassette version?" The table maps the 10 most common AppDaemon operations to Hassette equivalents. One row per operation, three columns: Action | AppDaemon | Hassette. Each Hassette cell links to the relevant sub-page. -### H2: Quick Start Checklist -Abbreviated migration steps. +One-sentence intro above the table: four areas change (configuration, app structure, event handlers, API calls) and every row links to the full guide. -### H2: Guide Structure -How the migration section is organized. +### H2: Is Migration Worth It? +Two-column table: "You should migrate if..." vs "You might stay with AppDaemon if..." — honest, no selling. The reader who got here from the evaluator page already wants to migrate; this section is for the reader who landed directly. -### H2: Quick Reference Table -AppDaemon method → Hassette equivalent lookup table. +### H2: Known Gaps +Table of AppDaemon features not yet in Hassette. Each row: feature name, status (out of scope / roadmap / workaround). Tells the reader to stop before investing effort if they depend on a missing feature. ### H2: Common Pitfalls -(Moved from deleted checklist) Known gotchas: +The four most common migration breakages, each as a one-liner with the fix: - `name=` required on all bus subscriptions (`ListenerNameRequiredError`) -- `run_daily` signature differs from AppDaemon (takes `at="HH:MM"`, default `"00:00"`, DST-safe) -- Blocking code must use `task_bucket.run_in_thread()`, not `run_in_executor` -- `self.states.light.get()` is the idiomatic typed access, not `self.states.get()` - -## Snippet Inventory - -| Snippet | Status | Notes | -|---|---|---| -| Relevant files from `migration/snippets/` (27 total) | Review | Assign per-page | +- Forgetting `await` on `self.api.*` and `self.bus.on_*` calls +- `changed_to="on"` (string), not `changed_to=True` +- `AppSync` hooks use `.sync` facades, not the async API directly -## Writing Note +### H2: Per-App Migration Checklist +One-paragraph pointer to `checklist.md` — the step-by-step checklist for migrating a single app. -The existing AD migration docs already have AppDaemon-side code examples and side-by-side comparisons. Carry those forward — the AD parts should not be blank stubs. The current docs at `docs/pages/migration/` have these examples; reuse them. +## Snippet Inventory -Migration Checklist has been removed — it was a thin summary of the sub-pages with no unique content. +No code snippets on this page. The Quick Reference Table uses inline code, not snippet files. ## Cross-Links -- **Links to:** All migration sub-pages, Is Hassette Right for You? +- **Links to:** All migration sub-pages, checklist.md, Is Hassette Right for You?, Getting Started - **Linked from:** Home page, Is Hassette Right for You? diff --git a/design/specs/070-doc-overhaul/outlines/migration/scheduler.md b/design/specs/070-doc-overhaul/outlines/migration/scheduler.md index bca4b0f3a..3f8cc0cf7 100644 --- a/design/specs/070-doc-overhaul/outlines/migration/scheduler.md +++ b/design/specs/070-doc-overhaul/outlines/migration/scheduler.md @@ -1,35 +1,40 @@ # Migration — Scheduler -**Status:** Exists (93 lines), comparison-driven, voice polish needed +**Page type:** Migration (feature comparison) +**Reader's job:** Convert their AppDaemon scheduler calls (`run_in`, `run_daily`, `run_every`) to Hassette equivalents with correct syntax and parameters. **Voice mode:** Comparison — "you" allowed -## Outline +## What was cut (and where it goes) -### H2: Overview -What changes: `run_in` stays, `run_daily` stays, `run_every` stays. Callback signature changes. `run_daily` default is `at="00:00"` (midnight) — make this explicit. +- **Overview section** replaced by a one-sentence intro. The method table is the overview. +- **Side-by-Side Comparison** section removed. It duplicated the Migration Example with no additional value. +- **Callback Signatures** moved after the method table. The reader's first question is "what's the equivalent method?" not "how do callback signatures differ?" Signatures matter once they've found the right method. -### H2: Callback Signatures -AppDaemon kwargs dict → Hassette typed params. +## Outline ### H2: Method Equivalents -Table: AppDaemon method → Hassette method. Also note Hassette-only additions with no AppDaemon equivalent: `run_once`, `run_minutely`, `run_hourly`, `run_cron`, `schedule()` (with trigger objects). +Lead with the lookup table. The reader has a specific AppDaemon call and wants the Hassette version. Table: AppDaemon method | Hassette method | Notes. Include `run_in`, `run_once`, `run_every`, `run_minutely`, `run_hourly`, `run_daily`, `cancel_timer`. Note Hassette-only additions: `run_cron`, `schedule()` with trigger objects. -### H2: Side-by-Side Comparison -Full example: daily task in AppDaemon vs Hassette. +Admonition: `run_daily` is now wall-clock-aligned (cron-backed, DST-safe). This is the most common behavioral surprise. + +### H2: Callback Signatures +AppDaemon's `def my_callback(self, **kwargs)` with `kwargs` dict vs Hassette's flexible signatures. Scheduler automatically runs sync callables in a thread pool. Snippet showing a Hassette scheduled handler. ### H2: Migration Example -Complete before/after. +Complete before/after: an app with `run_in`, `run_daily`, `run_every` in AppDaemon converted to Hassette. Side-by-side tabs. Key changes bullet list after the example. -### H2: Blocking Work in Scheduler Callbacks -`task_bucket.run_in_thread()` for blocking code. (There is no `run_in_executor`.) Alternatively, use `AppSync` with sync hooks for apps built around blocking libraries. +### H2: Blocking Work +AppDaemon runs callbacks in threads, so blocking is safe. Hassette: sync callables run in a thread pool automatically. Async callbacks run in the event loop — use `asyncio.to_thread()` or `self.task_bucket.run_in_thread()` for blocking IO inside async handlers. `AppSync` is for apps with heavy sync lifecycle logic, not for individual scheduler callbacks. ## Snippet Inventory -| Snippet | Status | Notes | +| Snippet | Decision | Notes | |---|---|---| -| ~5 migration/scheduler snippets | Keep | Comparison pairs | +| `scheduler_appdaemon.py` | Keep | AppDaemon scheduler example | +| `scheduler_hassette.py` | Keep | Hassette scheduler example | +| `scheduler_migration.py` | Keep | Full migration before/after | ## Cross-Links -- **Links to:** Scheduler overview, Scheduler/Methods -- **Linked from:** Migration overview +- **Links to:** Scheduler overview, Scheduler/Methods, Job Management +- **Linked from:** Migration overview, Migration checklist diff --git a/design/specs/070-doc-overhaul/outlines/migration/testing.md b/design/specs/070-doc-overhaul/outlines/migration/testing.md index 2b0f6d0d3..fa27a529e 100644 --- a/design/specs/070-doc-overhaul/outlines/migration/testing.md +++ b/design/specs/070-doc-overhaul/outlines/migration/testing.md @@ -1,30 +1,38 @@ # Migration — Testing -**Status:** Exists (41 lines), needs reframing — AD doesn't have a real testing story +**Page type:** Migration (feature comparison) +**Reader's job:** Understand that Hassette has built-in testing support (unlike AppDaemon) and get oriented enough to write their first test. **Voice mode:** Comparison — "you" allowed +## What was cut (and where it goes) + +- Nothing cut. This page is intentionally short. AppDaemon has no real testing story, so there is no feature-to-feature mapping. The job is to show the reader that testing exists, give them enough to try it, and link to the full guide. + ## Outline ### H2: The Shift -AppDaemon has no built-in testing support. Third-party tools exist (appdaemontestframework, etc.) but they're limited and community-maintained. Hassette ships a full test harness. +One paragraph: AppDaemon has no official test harness. Third-party tools exist but are fragile and community-maintained. Hassette ships `hassette.test_utils` with `AppTestHarness` — a first-class async test harness that wires your app into a real (but test-grade) environment with `RecordingApi` instead of a live HA connection. -### H2: What Hassette Provides -Brief summary — not a tutorial, just enough to show the migrator what's available: -- `AppTestHarness` for isolated app testing -- State seeding, event simulation, API call assertions -- Time control (`freeze_time`, `advance_time`) -- Concurrency helpers (drain) +### H2: Setup +Two things the reader must do before tests work: +1. `asyncio_mode = "auto"` in `pyproject.toml` — without it, async tests silently pass without running. Warning admonition: this is the most common setup mistake. +2. `set_state()` before `simulate_state_change()` for the same entity — calling it afterward overwrites the simulated state. -### H2: Getting Started with Tests -Link to the Testing section for the full guide. +### H2: What a Test Looks Like +One minimal snippet: `AppTestHarness` context manager, seed state, simulate event, assert API call. Enough for the reader to copy-paste and adapt. + +### H2: Full Reference +One-sentence pointer to the Testing section. List capabilities without explaining them: state seeding, event simulation, API call assertions, time control (`freeze_time`, `advance_time`), concurrency helpers. ## Snippet Inventory -| Snippet | Status | Notes | +| Snippet | Decision | Notes | |---|---|---| -| ~1 migration/testing snippet | Keep | Minimal "here's what a test looks like" example | +| `testing_seed_order.py` | Keep | Demonstrates set_state/simulate ordering | + +One new snippet needed: minimal test example showing `AppTestHarness` usage. ## Cross-Links -- **Links to:** Testing overview -- **Linked from:** Migration overview +- **Links to:** Testing overview, Time Control, Concurrency & pytest-xdist +- **Linked from:** Migration overview, Migration checklist diff --git a/design/specs/070-doc-overhaul/outlines/operating/log-levels.md b/design/specs/070-doc-overhaul/outlines/operating/log-levels.md index 79441a8d9..706664710 100644 --- a/design/specs/070-doc-overhaul/outlines/operating/log-levels.md +++ b/design/specs/070-doc-overhaul/outlines/operating/log-levels.md @@ -1,46 +1,49 @@ # Operating Hassette — Log Level Tuning -**Status:** Stub (3 lines), content moving from Advanced (99 lines) -**Voice mode:** Procedural — "you" allowed, step-by-step -**Content source:** `docs/pages/advanced/log-level-tuning.md` + KI-03 +**Page type:** Operating (procedural reference) +**Reader's job:** Narrow log noise to debug a specific area without flooding output from everything else. +**Voice mode:** Procedural — "you" allowed, action-first -## Outline +## What was cut (and where it goes) -### H2: When to Use This -Debug a specific area without flooding logs. Brief. +- **Full 13-field table** replaced by a symptom-lookup table and a link to the `LoggingConfig` API reference. The existing page listed all 13 fields in a static table that drifts when fields are added. The reader's actual question is "my scheduler is misbehaving, which field do I set?" — a symptom table answers that directly. -### H2: How It Works -`[hassette.logging]` section in hassette.toml. Per-service granularity. +## Outline -### H2: Available Fields -Link to auto-generated `LoggingConfig` reference for the full field list (avoids hardcoding names that may change). Provide 2-3 inline examples of the most common ones (`websocket`, `bus_service`, `scheduler_service`) to orient the reader, but don't enumerate all 13+. +### H2: Symptom Lookup +Lead with the action. Table: Symptom | Field to set. The reader has a specific problem; this table tells them which knob to turn. Cover the 8 most common symptoms (events not firing, jobs not running, app not loading, stale state, WS errors, API latency, noisy file changes, web UI errors). -### H2: Fallback Behavior -Unset fields use global log level. +### H2: How It Works +`[hassette.logging]` section in `hassette.toml`. Set a per-service field to a log level string (`DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL`). Unset fields inherit the global `log_level` (default `INFO`). One basic TOML snippet. ### H2: Debug Flags -`all_events`, `all_hass_events`, `all_hassette_events` — boolean flags for bus debug verbosity. `log_format` (`"auto"`, `"console"`, `"json"`) for output format. `log_persistence_level` — minimum level for log records written to the database (separate from console level). +Boolean flags for bus debug verbosity: `all_events`, `all_hass_events`, `all_hassette_events`. Output format: `log_format` (`"auto"`, `"console"`, `"json"`). Database persistence level: `log_persistence_level`. ### H2: Per-App Log Levels -Set in the app config block under `[hassette.apps..config]`, not in `[hassette.logging]`. +Set in `[hassette.apps..config]`, not in `[hassette.logging]`. The `logging.apps` field sets the default for all apps; per-app config overrides it. One TOML snippet. ### H2: Examples -#### H3: Debugging the Scheduler -#### H3: Quieting the File Watcher -#### H3: Debugging Home Assistant Communication +Three concrete examples, each a TOML snippet with one sentence of explanation: +- Debugging the scheduler +- Quieting the file watcher +- Debugging Home Assistant communication + +### H2: Full Field Reference +Link to auto-generated `LoggingConfig` API reference for the complete field list. Avoids hardcoding names that change. ## Snippet Inventory -Moving from `advanced/snippets/log-level-tuning/`: -| Snippet | Status | Notes | +Moving from `advanced/snippets/log-level-tuning/` to `operating/snippets/`: + +| Snippet | Decision | Notes | |---|---|---| -| `basic_example.toml` | Move | → `operating/snippets/` | -| `debug_scheduler.toml` | Move | | -| `quiet_file_watcher.toml` | Move | | -| `debug_ha_comms.toml` | Move | | -| `per_app_log_level.toml` | Move | | +| `basic_example.toml` | Move | Basic per-service override | +| `debug_scheduler.toml` | Move | Scheduler debugging | +| `quiet_file_watcher.toml` | Move | Suppress file watcher noise | +| `debug_ha_comms.toml` | Move | WebSocket + API debugging | +| `per_app_log_level.toml` | Move | Per-app override | ## Cross-Links -- **Links to:** Operating overview, Configuration/Global (logging settings) -- **Linked from:** Operating overview, Web UI/Logs +- **Links to:** Operating overview, Configuration/Global (logging settings), `LoggingConfig` API reference +- **Linked from:** Operating overview, Web UI/Logs, Troubleshooting diff --git a/design/specs/070-doc-overhaul/outlines/operating/overview.md b/design/specs/070-doc-overhaul/outlines/operating/overview.md index 2eaaba892..64892d980 100644 --- a/design/specs/070-doc-overhaul/outlines/operating/overview.md +++ b/design/specs/070-doc-overhaul/outlines/operating/overview.md @@ -1,48 +1,54 @@ # Operating Hassette — Overview -**Status:** Stub (3 lines), new page -**Voice mode:** Concept/procedural hybrid — system-as-subject for behavior descriptions, "you" for actions +**Page type:** Operating (production reference) +**Reader's job:** Understand what Hassette does automatically at runtime and how to tune it when something goes wrong in production. +**Voice mode:** Concept/procedural hybrid — system-as-subject for behavior descriptions, "you" for tuning actions + +## What was cut (and where it goes) + +- **Database Degraded Mode** moved to a one-sentence mention with a link. The full details belong on the Database & Telemetry page; duplicating them here creates two places to maintain. +- The original outline's structure was already reader-oriented (runtime behaviors grouped by what the operator sees). The rewrite reorders to lead with the most common production concern (reconnection) and adds a "When to Tune" subsection to each behavior instead of a separate tuning guide. ## Outline -### H2: (Opening) -What this section covers: how Hassette behaves at runtime and how to operate it in production. +### H2: WebSocket Reconnection +**Content from KI-01.** The most common production event. Three-layer model, each with its own retry budget: -### H2: Runtime Behavior +1. **Initial connect retries** — 5 attempts, 1s-32s exponential backoff with jitter (inside `_make_connection`) +2. **Early-drop retries** — 5 attempts when connection drops within `early_drop_stable_window_seconds` (30s default), 2s-60s backoff. Handles brief HA restarts. +3. **`ServiceWatcher` restart budget** — 5 restarts / 300s sliding window, 2s-60s backoff, then `EXHAUSTED_COOLING` (300s, configurable via `cooldown_seconds`) -#### H3: WebSocket Reconnection -**Content from KI-01.** Three-layer reconnection model: -1. **Initial connect retries** (inside `_make_connection`): 5 attempts, 1s→32s exponential backoff with jitter -2. **Early-drop retries** (inside `serve()`): 5 attempts when connection drops within `early_drop_stable_window_seconds` (30s default), 2s→60s backoff. This layer handles brief HA restarts. -3. **ServiceWatcher restart budget**: 5 restarts / 300s sliding window, 2s→60s backoff → `EXHAUSTED_COOLING` (300s, configurable via `cooldown_seconds`) +What apps see during reconnection: bus, scheduler, state manager stay active. `Api` and `StateProxy` raise `ResourceNotReadyError`. Handlers resume on reconnect without re-registration. -Bus events use full topic strings: `hassette.event.websocket_disconnected`, `hassette.event.websocket_connected`. App behavior during reconnection: `Api` and `StateProxy` raise `ResourceNotReadyError`, handlers resume automatically on reconnect. Include log signatures. +Bus events: `hassette.event.websocket_disconnected` and `hassette.event.websocket_connected`. -**When to tune** (absorbed from configuration tuning guide): +Log signatures: inline code blocks showing the WARNING, ERROR, INFO, DEBUG lines the operator will see. + +**When to tune:** - Slow HA restarts (>30s): increase `early_drop_stable_window_seconds` - Flaky networks: increase `connect_retry_max_attempts` and `connect_retry_max_wait_seconds` -- Low tolerance for downtime: decrease backoff values -- `max_recovery_seconds` as the total wall-clock cap for the early-drop retry loop - -#### H3: Handler Exception Behavior -**Content from KI-02.** Exceptions caught and swallowed, logged at ERROR, recorded in telemetry with `status='error'`. Include log signature. Matches scheduler behavior. +- Low downtime tolerance: decrease backoff values +- `max_recovery_seconds` as total wall-clock cap for the early-drop loop -#### H3: Timeout Behavior -**Absorbed from configuration tuning guide.** Two global defaults: `scheduler_job_timeout_seconds` (600s) and `event_handler_timeout_seconds` (600s). Per-item overrides via `timeout=` / `timeout_disabled=`. +### H2: Handler Exceptions +**Content from KI-02.** What happens when a handler raises: exception caught, logged at ERROR, swallowed. Does not crash the app or affect other handlers. Telemetry records the invocation with `status='error'`. Log signature showing the ERROR line with traceback. Same behavior for scheduler callbacks. -Enforcement limitations: sync handlers run in a thread executor — the timeout cancels the awaitable wrapper, not the thread. `TimeoutError` swallowing: if a handler catches `TimeoutError` internally, the framework cannot cancel it. `run_sync_timeout_seconds` default (6s). +### H2: Timeouts +Two global defaults from `LifecycleConfig`: `event_handler_timeout_seconds` (600s) and `scheduler_job_timeout_seconds` (600s from `SchedulerConfig`). Per-item overrides via `timeout=` / `timeout_disabled=` on individual registrations. -#### H3: Database Degraded Mode -Brief: what happens when the DB is unavailable. Links to Database & Telemetry page for full details. +Enforcement limitations worth knowing: +- Sync handlers run in a thread executor — timeout cancels the awaitable wrapper, not the thread itself +- If a handler catches `TimeoutError` internally, the framework cannot cancel it +- `run_sync_timeout_seconds` (6s default) for sync facade calls -### H2: See Also -→ Log Level Tuning, → Upgrading, → Troubleshooting +### H2: Database Degraded Mode +One-sentence summary: when the database is unavailable, telemetry stats show zeroed metrics but apps continue running. Link to Database & Telemetry page for full details. ## Snippet Inventory -No code snippets — log signatures are inline code blocks. +No code snippets. Log signatures are inline code blocks. Tuning examples are TOML fragments shown inline. ## Cross-Links -- **Links to:** Log Level Tuning, Upgrading, Troubleshooting, Database & Telemetry, Configuration/Global (WebSocket resilience settings) -- **Linked from:** Architecture, Troubleshooting (cross-reference) +- **Links to:** Log Level Tuning, Upgrading, Troubleshooting, Database & Telemetry, Configuration/Global (WebSocket settings) +- **Linked from:** Architecture, Troubleshooting diff --git a/design/specs/070-doc-overhaul/outlines/operating/upgrading.md b/design/specs/070-doc-overhaul/outlines/operating/upgrading.md index 00b799d75..59fdd864a 100644 --- a/design/specs/070-doc-overhaul/outlines/operating/upgrading.md +++ b/design/specs/070-doc-overhaul/outlines/operating/upgrading.md @@ -1,26 +1,34 @@ # Operating Hassette — Upgrading -**Status:** Stub (3 lines), content extracting from troubleshooting.md +**Page type:** Operating (procedural) +**Reader's job:** Upgrade Hassette to a newer version without breaking their running automations. **Voice mode:** Procedural — "you" allowed, step-by-step -**Content source:** troubleshooting.md lines 111-128 + KI-12, KI-13 + +## What was cut (and where it goes) + +- Nothing cut. This is a new page assembled from KI-12 and KI-13. The outline is already action-first — the reader wants to upgrade, not read about versioning philosophy. ## Outline ### H2: Check Your Current Version -`hassette --version`, `uv pip show hassette` commands. +Two commands: `hassette --version` (CLI) and `uv pip show hassette` (project environment). The reader needs to know where they are before deciding whether to upgrade. + +### H2: Upgrade +Split by install method: +- **pip / uv**: `uv add hassette@latest` +- **Docker**: pull the new image tag, restart the container -### H2: Upgrade to Latest -`uv add hassette@latest`. Docker: pull new image tag. +One-liner each. No explanation needed. ### H2: Reading the Changelog -Where to find it, how breaking changes are flagged. +Where to find it (`CHANGELOG.md` in the repo, GitHub Releases). How breaking changes are flagged: `BREAKING CHANGE:` footer in the changelog entry, `!` in the commit type. What to look for before upgrading. ### H2: Major Version Upgrades -Data directory path includes major version (`~/.local/share/hassette/v0/`). Future `v1/` starts fresh unless `data_dir`/`config_dir` set explicitly. Docker unaffected. +**Content from KI-13.** Bare-metal installs: `data_dir` includes the major version (`~/.local/share/hassette/v0/`). A future `v1/` would start with a fresh database. Set `data_dir` / `config_dir` explicitly to preserve data across major versions. Docker is unaffected — `/data` and `/config` are version-independent mount points. ## Snippet Inventory -No code snippets — shell commands are inline. +No code snippets. Shell commands are inline. ## Cross-Links diff --git a/design/specs/070-doc-overhaul/outlines/recipes/daily-notification.md b/design/specs/070-doc-overhaul/outlines/recipes/daily-notification.md index f4ee67308..10408ba0c 100644 --- a/design/specs/070-doc-overhaul/outlines/recipes/daily-notification.md +++ b/design/specs/070-doc-overhaul/outlines/recipes/daily-notification.md @@ -1,32 +1,64 @@ # Recipes — Daily Notification -**Status:** Exists (47 lines), follows recipe template, voice polish needed -**Voice mode:** Recipe — problem statement, code, How It Works, variations +**Status:** Exists (47 lines), needs JTBD redesign — "How It Works" uses bullet lists with bold lead-ins (anti-pattern) +**Voice mode:** Recipe — problem statement uses "you", "How It Works" uses system-as-subject prose paragraphs +**Page type:** Recipe +**Reader's job:** Send a push notification at a fixed time every day without manually creating HA automations. + +## What was cut + +The existing "How It Works" uses bold-label bullet lists. Content stays, format +changes to flowing prose paragraphs. + +The existing page is missing a "Verify It's Working" section. Adding one. + +The "Different time" variation currently shows `apps.yaml` format. Hassette +uses `hassette.toml` — the example should use TOML config format consistent +with the rest of the docs. ## Outline -### H2: (Problem Statement) -Send a notification at a fixed time every day (weather summary, reminder, etc.). +### H2: (Problem statement) +You want a daily push notification — a morning greeting, a reminder, or a +fixed-schedule alert. Drop it in without touching HA automations. ### H2: The Code -Full app with `run_daily` and `call_service` for notify. +Full app via `--8<--` include of `daily_notification.py`. ### H2: How It Works -Walk through the code decisions. Voice-guide rule #21: system-as-subject, one decision per paragraph. +Flowing prose paragraphs, one decision each: + +1. Config — `DailyNotificationConfig` defines the time as `"HH:MM"`, the + notify service name, and the message body. All overridable per-instance. +2. Scheduling — `on_initialize` calls `run_daily(at=...)` with the configured + time string. The `Daily` trigger is cron-backed and handles DST transitions. +3. Notification — `call_service("notify", , ...)` where the service + name is the part after `notify.` in HA (e.g., `mobile_app_phone`). Extra + kwargs become `service_data` fields forwarded to HA. ### H2: Verify It's Working -`hassette job --app ` to confirm the daily job is scheduled with the correct next-run time. `hassette log --app --since 1d` after the scheduled time to see the notification fire. Expected: one log entry per day at the configured time. +`hassette job --app ` to confirm the daily job is scheduled with the +correct next-run time. `hassette log --app --since 1d` after the +scheduled time to verify the notification fired. Expected: one entry per day. ### H2: Variations -Alternative triggers (cron), different notification services, conditional notifications. +- Different time — change `notify_time` in `hassette.toml`. +- Include sensor data — fetch a sensor value before sending (snippet: + `daily_notification_handler.py:send_notification`). +- Weekdays only — swap `run_daily` for `run_cron` (snippet: + `daily_notification_handler.py:cron_parse`). + +### H2: See Also +Links to Scheduler/Methods (run_daily, run_cron), API overview (call_service). ## Snippet Inventory | Snippet | Status | Notes | |---|---|---| -| `daily_notification.py` (in `recipes/snippets/`) | Keep | Review for voice, DI alignment | +| `daily_notification.py` | Keep | Main app | +| `daily_notification_handler.py` | Keep | Variation fragments (send_notification, cron_parse) | ## Cross-Links -- **Links to:** Scheduler/Methods (run_daily, run_cron), API/Services (call_service), Testing overview (write a test for this pattern) +- **Links to:** Scheduler/Methods (run_daily, run_cron), API overview (call_service), Testing overview - **Linked from:** Recipes overview diff --git a/design/specs/070-doc-overhaul/outlines/recipes/debounce-sensor-changes.md b/design/specs/070-doc-overhaul/outlines/recipes/debounce-sensor-changes.md index 1395ff946..3efd9c5dd 100644 --- a/design/specs/070-doc-overhaul/outlines/recipes/debounce-sensor-changes.md +++ b/design/specs/070-doc-overhaul/outlines/recipes/debounce-sensor-changes.md @@ -1,32 +1,67 @@ # Recipes — Debounce Sensor Changes -**Status:** Exists (28 lines), follows recipe template, voice polish needed -**Voice mode:** Recipe — problem statement, code, How It Works, variations +**Status:** Exists (28 lines), needs JTBD redesign — "How It Works" uses bullet lists with bold lead-ins (anti-pattern) +**Voice mode:** Recipe — problem statement uses "you", "How It Works" uses system-as-subject prose paragraphs +**Page type:** Recipe +**Reader's job:** Stop their automation from reacting to every sensor blip and instead respond only after readings stabilize. + +## What was cut + +The existing "How It Works" section uses bold-label bullet lists — the recipe +template requires flowing prose paragraphs with system-as-subject voice. The +content stays; the format changes. + +The existing page combines debounce with `C.Increased()` filtering. That is a +good realistic example but the prose should walk through debounce first (the +title concept), then the condition filter as a second decision, so the reader +learns the primary concept before the composition. ## Outline -### H2: (Problem Statement) -Sensors emit bursts of near-identical readings. React only after the value stabilizes. +### H2: (Problem statement) +Sensors emit bursts of near-identical readings. You want to react only after a +value stabilizes — and only when it crosses a threshold going up. ### H2: The Code -App with `on_state_change(debounce=10.0)`. +Full app via `--8<--` include of `debounce_sensor.py`. ### H2: How It Works -What debounce does: resets timer on each new event, fires only after quiet period. +Flowing prose paragraphs, one decision each: + +1. `debounce=10.0` on the subscription — the handler does not fire until the + sensor has been quiet for 10 seconds. Each new event resets the timer, so + rapid fluctuations never reach the handler. +2. `changed=C.Increased()` — the debounce timer only starts when the new value + is numerically greater than the old. Decreases and unchanged readings are + dropped before queuing. +3. DI extraction — `D.StateNew[states.SensorState]` delivers a typed state + object. The handler converts the value to float for threshold comparison. +4. Threshold check and action — when the stabilized temperature exceeds the + limit, a single log line fires. +5. Config — threshold, debounce duration, and entity ID are all configurable + without code changes. ### H2: Verify It's Working -`hassette listener --app ` to confirm the handler is registered. `hassette log --app --since 5m` to see handler invocations. Expected: handler fires only after the debounce quiet period, not on every sensor reading. +`hassette listener --app ` to confirm the handler is registered. +`hassette log --app --since 5m` to see invocations. Expected: the handler +fires only after the debounce quiet period, not on every sensor reading. ### H2: Variations -Different debounce values, switching to throttle instead (debounce and throttle are mutually exclusive — `ValueError` if both set), sensor-specific patterns. +- Throttle instead of debounce: `throttle=30.0` fires on the first event then + suppresses for the window. Debounce and throttle are mutually exclusive + (`ValueError` if both set). +- Different sensor types: swap the entity ID and adjust the threshold. + +### H2: See Also +Links to Bus overview (rate control), Filtering (conditions reference). ## Snippet Inventory | Snippet | Status | Notes | |---|---|---| -| `debounce_sensor.py` (in `recipes/snippets/`) | Keep | Review for voice | +| `debounce_sensor.py` | Keep | Review for voice alignment | ## Cross-Links -- **Links to:** Bus overview (rate control section), States/Subscribing, Testing overview (write a test for this pattern) +- **Links to:** Bus overview (rate control section), Bus/Filtering (conditions), Testing overview - **Linked from:** Recipes overview diff --git a/design/specs/070-doc-overhaul/outlines/recipes/motion-lights.md b/design/specs/070-doc-overhaul/outlines/recipes/motion-lights.md index b77d3f17f..10c629ad4 100644 --- a/design/specs/070-doc-overhaul/outlines/recipes/motion-lights.md +++ b/design/specs/070-doc-overhaul/outlines/recipes/motion-lights.md @@ -1,19 +1,49 @@ # Recipes — Motion-Activated Lights -**Status:** REWRITTEN in T03 (exemplar). 62 lines. Done. -**Voice mode:** Recipe — problem statement, code, flowing "How It Works" prose, verify step +**Status:** REWRITTEN in T03 (exemplar). 62 lines. GENUINE — keep as-is. +**Voice mode:** Recipe — problem statement uses "you", "How It Works" uses system-as-subject prose paragraphs +**Page type:** Recipe +**Reader's job:** Build a motion-activated light automation that turns on instantly and turns off after a configurable delay with re-trigger support. + +## What was cut + +Nothing. This page was the exemplar for the recipe template rewrite. It already +follows the correct pattern: problem statement, code, flowing prose "How It +Works" (no bullet lists with bold lead-ins), concrete verify step, variations. ## Outline -Already complete. Covers: problem statement, full app code, How It Works (flowing prose paragraphs), Verify It's Working (`hassette log`, `hassette listener`), Variations (changed_to split handler, adjustable delays). +Already complete and well-structured. Covers: + +### H2: (Problem statement) +Motion sensor scenario with re-trigger requirement. + +### H2: The Code +Full app via `--8<--` include. + +### H2: How It Works +Flowing prose paragraphs, system-as-subject. One decision per paragraph: +subscription strategy, on-handler cancel-then-act, off-handler run_in with +stored job, named job for deduplication, config-driven entity IDs. + +### H2: Verify It's Working +Concrete commands: `hassette log --app motion_lights --since 5m` and +`hassette listener --app motion_lights`. + +### H2: Variations +Config-only timeout change, split handlers with `changed_to` predicates. + +### H2: See Also +Links to Bus, Scheduler, Application Configuration. ## Snippet Inventory -Written and tested in T03: -- `motion_lights.py` — main app -- `motion_lights_split.py` — split handler variation with `changed_to` predicates +| Snippet | Status | Notes | +|---|---|---| +| `motion_lights.py` | Keep | Main app, tested in T03 | +| `motion_lights_split.py` | Keep | Split handler variation, tested in T03 | ## Cross-Links -- **Links to:** Bus overview, Scheduler/Methods (run_in), States/Subscribing (on_state_change patterns), Testing overview (write a test for this pattern) +- **Links to:** Bus overview, Scheduler/Methods (run_in), Application Configuration, Testing overview - **Linked from:** Recipes overview, First Automation (next steps) diff --git a/design/specs/070-doc-overhaul/outlines/recipes/overview.md b/design/specs/070-doc-overhaul/outlines/recipes/overview.md index 452e598f0..2eaffd909 100644 --- a/design/specs/070-doc-overhaul/outlines/recipes/overview.md +++ b/design/specs/070-doc-overhaul/outlines/recipes/overview.md @@ -1,14 +1,39 @@ # Recipes — Overview -**Status:** Exists (24 lines), brief index, voice polish needed +**Status:** Exists (24 lines), needs JTBD redesign **Voice mode:** Getting-started — friendly, "you" allowed +**Page type:** Index +**Reader's job:** Find a recipe that solves their specific automation problem and get to it fast. + +## What was cut + +Nothing — this is an index page. But the current version opens with a definition +of what recipes are, which the reader doesn't need. They need the list. The +definition can be a single sentence above the list, not a full opening paragraph. + +The closing sentence ("Each recipe is a complete, working app...") repeats the +opening. Cut it. ## Outline -### H2: Recipes -List of all recipes with one-line descriptions. Each links to its page. +### Opening line +One sentence: copy-paste-ready automations organized by problem. No paragraph. + +### Recipe list +Each entry: linked title + one sentence describing the problem it solves (not +the Hassette features it uses). Order by how common the problem is, not +alphabetical: + +1. Motion-Activated Lights — turn lights on with motion, off after a delay +2. Debounce Sensor Changes — ignore sensor noise, react only after values stabilize +3. Monitor Sensor Thresholds — alert when a reading crosses a limit +4. Daily Notification — send a push notification at a fixed time each day +5. React to a Service Call — intercept an HA service call and run custom logic +6. Vacation Mode Toggle — switch app behavior with an input_boolean, no redeployment -Brief intro explaining what recipes are: complete, runnable examples that solve real-world problems. +### H2: See Also +Links to Core Concepts and Getting Started — same as current, but trimmed to +two bullets. ## Snippet Inventory @@ -16,5 +41,5 @@ None — index page. ## Cross-Links -- **Links to:** All recipe pages +- **Links to:** All recipe pages, Core Concepts overview, Getting Started - **Linked from:** Home page, Getting Started (next steps), Bus overview, Scheduler overview diff --git a/design/specs/070-doc-overhaul/outlines/recipes/sensor-threshold.md b/design/specs/070-doc-overhaul/outlines/recipes/sensor-threshold.md index cb1af9c6c..d4e91be2b 100644 --- a/design/specs/070-doc-overhaul/outlines/recipes/sensor-threshold.md +++ b/design/specs/070-doc-overhaul/outlines/recipes/sensor-threshold.md @@ -1,32 +1,64 @@ # Recipes — Monitor Sensor Thresholds -**Status:** Exists (31 lines), follows recipe template, voice polish needed -**Voice mode:** Recipe — problem statement, code, How It Works, variations +**Status:** Exists (31 lines), needs JTBD redesign — "How It Works" uses bullet lists with bold lead-ins (anti-pattern) +**Voice mode:** Recipe — problem statement uses "you", "How It Works" uses system-as-subject prose paragraphs +**Page type:** Recipe +**Reader's job:** Get a notification when a sensor value crosses a configured limit, without alert spam when the value hovers near the boundary. + +## What was cut + +The existing "How It Works" uses bold-label bullet lists. The content is good +but the format must change to flowing prose paragraphs with system-as-subject +voice. + +The existing page does not have a "Verify It's Working" section. Adding one. ## Outline -### H2: (Problem Statement) -Take action when a sensor crosses a threshold (temperature above 80°F, humidity below 30%). +### H2: (Problem statement) +You want a notification when temperature rises above a limit — or when any +numeric sensor crosses a threshold. One alert per crossing, not a flood while +the value stays high. ### H2: The Code -App with `on_state_change` + numeric condition or predicate. +Full app via `--8<--` include of `sensor_threshold.py`. ### H2: How It Works -How `C.Increased`/`C.Decreased` or threshold predicates work in this context. +Flowing prose paragraphs, one decision each: + +1. Config — `ThresholdConfig` exposes `entity_id`, `threshold`, and + `notify_target` as environment-backed settings. Override per-instance + without touching code. +2. Threshold filter — `C.Comparison("gt", threshold)` passed to `changed_to` + drops events below the limit before the handler runs. The handler fires only + on crossings, not every reading. +3. DI extraction — `D.StateNew[states.SensorState]` gives a typed state object. + `D.EntityId` provides the entity ID as a plain string for the notification. +4. Notification — `api.call_service("notify", ...)` sends the alert via any + HA notify target. Attributes like `unit_of_measurement` and `friendly_name` + come from the typed model. ### H2: Verify It's Working -`hassette listener --app ` to confirm the handler is registered with the threshold predicate. `hassette log --app --since 1h` to see handler invocations. Expected: handler fires only when the sensor crosses the threshold, not on every reading. +`hassette listener --app ` to confirm the handler is registered with the +threshold condition. `hassette log --app --since 1h` after a threshold +crossing to see the notification fire. Expected: one log entry per crossing. ### H2: Variations -Hysteresis (don't re-trigger until value drops back), multiple thresholds, combining sensors. +- Below-limit alert: change `"gt"` to `"lt"` for battery or pressure sensors. +- Hysteresis: second listener with `C.Comparison("le", threshold)` that sets a + recovery flag, preventing repeated alerts while hovering. +- Multiple sensors: glob pattern (`"sensor.temp_*"`) or loop over a config list. + +### H2: See Also +Links to Filtering (conditions), DI, States (typed models). ## Snippet Inventory | Snippet | Status | Notes | |---|---|---| -| `sensor_threshold.py` (in `recipes/snippets/`) | Keep | Review for voice | +| `sensor_threshold.py` | Keep | Review for voice alignment | ## Cross-Links -- **Links to:** States/Subscribing (numeric conditions), Bus/Filtering (C.Increased, C.Decreased), Testing overview (write a test for this pattern) +- **Links to:** Bus/Filtering (C.Comparison, conditions), Bus/DI (D.StateNew, D.EntityId), States overview - **Linked from:** Recipes overview diff --git a/design/specs/070-doc-overhaul/outlines/recipes/service-call-reaction.md b/design/specs/070-doc-overhaul/outlines/recipes/service-call-reaction.md index 1c746ffb5..2e59aa9d8 100644 --- a/design/specs/070-doc-overhaul/outlines/recipes/service-call-reaction.md +++ b/design/specs/070-doc-overhaul/outlines/recipes/service-call-reaction.md @@ -1,32 +1,65 @@ # Recipes — React to a Service Call -**Status:** Exists (32 lines), follows recipe template, voice polish needed -**Voice mode:** Recipe — problem statement, code, How It Works, variations +**Status:** Exists (32 lines), needs JTBD redesign — "How It Works" uses bullet lists with bold lead-ins (anti-pattern) +**Voice mode:** Recipe — problem statement uses "you", "How It Works" uses system-as-subject prose paragraphs +**Page type:** Recipe +**Reader's job:** Mirror or react to a Home Assistant service call — for example, sync settings from one light to another whenever someone adjusts the primary. + +## What was cut + +The existing "How It Works" uses a bullet-list format. Content stays, format +changes to flowing prose paragraphs. + +Missing a "Verify It's Working" section. Adding one. ## Outline -### H2: (Problem Statement) -React when a specific HA service is called (e.g., log when someone turns on a light via the UI). +### H2: (Problem statement) +You have a primary light and an accent light. Whenever someone adjusts the +primary through HA, the accent should mirror the brightness and color +temperature automatically. ### H2: The Code -App with `on_call_service` subscription. +Full app via `--8<--` include of `service_call_reaction.py`. ### H2: How It Works -Service call events, filtering by domain/service. +Flowing prose paragraphs, one decision each: + +1. Subscription scope — `on_call_service(domain="light", service="turn_on")` + subscribes only to `light.turn_on` calls. No other service types reach the + handler. +2. Predicate narrowing — `P.ServiceDataWhere({"entity_id": ...})` filters + further so the handler fires only when the call targets the configured + primary light. +3. Event payload — `CallServiceEvent.payload.data.service_data` is the dict of + arguments the caller passed. The handler forwards whichever parameters were + present (brightness, color_temp, transition) to the accent light, skipping + keys not in the original call. +4. Config — `primary_light` and `accent_light` are environment-backed fields, + changeable without touching code. ### H2: Verify It's Working -`hassette listener --app ` to confirm the service-call handler is registered. Trigger the service via HA UI, then `hassette log --app --since 5m` to see the handler fire. Expected: one log entry per service call matching the filter. +Adjust the primary light via the HA UI, then: +`hassette log --app --since 5m` to see the handler fire. +`hassette listener --app ` to confirm the service-call handler is +registered. ### H2: Variations -Filtering by entity, combining with state checks. +- Watch any entity in a group: glob pattern in `ServiceDataWhere` (snippet: + `service_call_where.py:where`). +- React to turn-off too: second subscription for `service="turn_off"`. + +### H2: See Also +Links to Bus/Filtering (on_call_service, predicates), Bus overview. ## Snippet Inventory | Snippet | Status | Notes | |---|---|---| -| `service_call_reaction.py` (in `recipes/snippets/`) | Keep | Review for voice | +| `service_call_reaction.py` | Keep | Main app | +| `service_call_where.py` | Keep | Glob pattern variation | ## Cross-Links -- **Links to:** Bus/Handlers (on_call_service), Bus/Filtering (service call filtering), Testing overview (write a test for this pattern) +- **Links to:** Bus/Filtering (on_call_service, P.ServiceDataWhere, P.ServiceMatches), Bus overview - **Linked from:** Recipes overview diff --git a/design/specs/070-doc-overhaul/outlines/recipes/vacation-mode-toggle.md b/design/specs/070-doc-overhaul/outlines/recipes/vacation-mode-toggle.md index 80a431169..c88a28d60 100644 --- a/design/specs/070-doc-overhaul/outlines/recipes/vacation-mode-toggle.md +++ b/design/specs/070-doc-overhaul/outlines/recipes/vacation-mode-toggle.md @@ -1,32 +1,64 @@ # Recipes — Vacation Mode Toggle -**Status:** Exists (29 lines), follows recipe template, voice polish needed -**Voice mode:** Recipe — problem statement, code, How It Works, variations +**Status:** Exists (29 lines), needs JTBD redesign — "How It Works" uses bullet lists with bold lead-ins (anti-pattern) +**Voice mode:** Recipe — problem statement uses "you", "How It Works" uses system-as-subject prose paragraphs +**Page type:** Recipe +**Reader's job:** Toggle an automation's behavior on and off from the HA UI using an input_boolean, without redeploying or restarting Hassette. + +## What was cut + +The existing "How It Works" uses bold-label bullet lists. Content stays, format +changes to flowing prose paragraphs. + +Missing a "Verify It's Working" section. Adding one. ## Outline -### H2: (Problem Statement) -Toggle a set of automations on/off based on an input_boolean (vacation mode, guest mode, etc.). +### H2: (Problem statement) +You're going on vacation and want your lights to simulate presence — random +toggles on a schedule. When you get back, flip a switch in HA to stop it. No +code changes, no restart. ### H2: The Code -App watching an input_boolean, enabling/disabling other behaviors. +Full app via `--8<--` include of `vacation_mode.py`. ### H2: How It Works -Pattern: input_boolean as a mode switch, conditional logic in handlers. +Flowing prose paragraphs, one decision each: + +1. Two subscriptions — one fires when `input_boolean.vacation_mode` turns on, + the other when it turns off. Each does exactly one thing. +2. Starting the loop — when vacation mode activates, `run_every` schedules + `simulate_presence` on a fixed interval. The returned `ScheduledJob` is + stored on the instance for later cancellation. +3. Presence simulation — each tick picks a random light and toggles it. On + becomes off, off becomes on. The irregularity creates a lived-in pattern. +4. Stopping cleanly — when vacation mode deactivates, the stored job is + cancelled and all lights are turned off to restore a known state. +5. Config — entity IDs and interval come from `VacationModeConfig`. Different + houses get different light lists without code changes. ### H2: Verify It's Working -Toggle the input_boolean in HA UI, then `hassette log --app --since 5m` to see the mode-change handler fire. `hassette listener --app ` to confirm the subscription is registered. Expected: handler fires on each toggle with the correct boolean state. +Toggle `input_boolean.vacation_mode` in the HA UI, then: +`hassette log --app --since 5m` to see the mode-change handler fire and +the simulation start. `hassette listener --app ` to confirm both +subscriptions are registered. ### H2: Variations -Multiple modes, time-based auto-toggle, notification on mode change. +- Provision the helper from code — `api.create_input_boolean` in + `on_initialize`. Link to Managing Helpers. +- Schedule vacation windows — replace the manual toggle with `run_cron` for + evening-only simulation. Link to Scheduler/Methods. + +### H2: See Also +Links to Managing Helpers, Bus overview, States overview. ## Snippet Inventory | Snippet | Status | Notes | |---|---|---| -| `vacation_mode.py` (in `recipes/snippets/`) | Keep | Review for voice | +| `vacation_mode.py` | Keep | Review for voice alignment | ## Cross-Links -- **Links to:** States/Subscribing (input_boolean state changes), API/Services (call_service for toggling), Cache (persisting mode state), Testing overview (write a test for this pattern) +- **Links to:** Managing Helpers (create_input_boolean), Bus overview (on_state_change), Scheduler/Methods (run_every, run_cron), States overview - **Linked from:** Recipes overview diff --git a/design/specs/070-doc-overhaul/outlines/testing/concurrency.md b/design/specs/070-doc-overhaul/outlines/testing/concurrency.md index d5c5aa962..95de7eaa0 100644 --- a/design/specs/070-doc-overhaul/outlines/testing/concurrency.md +++ b/design/specs/070-doc-overhaul/outlines/testing/concurrency.md @@ -1,32 +1,70 @@ # Testing — Concurrency & pytest-xdist -**Status:** Exists (52 lines), concise, voice polish needed +**Status:** Exists (52 lines), mostly GENUINE — needs JTBD metadata and minor reorder **Voice mode:** Concept — system-as-subject +**Page type:** Depth page (concept) +**Reader's job:** Understand why their parallel tests deadlock or flake, and fix it. + +## What was cut + +Nothing removed. The existing page is well-structured and covers the three +concurrency concerns clearly. + +Minor reorder: `DrainFailure` exception hierarchy moved up, since readers +who land here from a test failure are most likely hitting a drain exception, +not an xdist configuration issue. The xdist section is more niche (only +relevant when running `-n`). + +The `pytest-asyncio Mode` section is a one-liner that duplicates content from +the Testing index. Keep it as a brief cross-reference, not a full explanation. ## Outline +### Opening line +Two isolation mechanisms. Understanding which applies when prevents deadlocks. + +### H2: DrainFailure Exception Hierarchy +What `DrainFailure` means: a simulate call did not settle cleanly. Two +subclasses: +- `DrainError` — handler tasks raised exceptions. `e.task_exceptions` list. +- `DrainTimeout` — drain did not reach quiescence. Diagnostic message with + pending task names. + +`DrainTimeout` does not inherit from `TimeoutError`. Catch `DrainTimeout` or +`DrainFailure` around `simulate_*` calls, not `TimeoutError`. + +Harness startup timeouts are separate `TimeoutError` — link to Test Harness +Reference. + ### H2: Same-Class Concurrency (Always Applies) -Why tests of the same app class can interfere; harness isolation. +Per-App-class `asyncio.Lock` around manifest read-modify-write. Brief: +- Same class, same loop: safe (reference counter handles it). +- Different classes: no conflict. -### H2: Time-Control Concurrency (`freeze_time` Only) -Global time state means parallel time-control tests conflict. +### H2: Time-Control Concurrency (freeze_time Only) +Process-global non-reentrant lock. Only one harness can hold the time lock. +Second harness raises `RuntimeError`. Released on `async with` exit. ### H2: Parallel Test Suites (pytest-xdist) -`--dist loadscope` requirement, why `-n auto` alone causes flakes. +Each worker = own process = own time lock. The concern is within a worker: +`freeze_time` tests in the same worker can interleave. Fix: `xdist_group` +marker to serialize them. Not needed without `-n`. ### H2: pytest-asyncio Mode -`auto` mode setting. +One sentence: `asyncio_mode = "auto"` is required. Link to Testing index +for setup and false-green warning. -### H2: `DrainFailure` Exception Hierarchy -What DrainFailure means and how to handle it in tests. +### H2: Next Steps +Links to Factories, Time Control, Testing index. ## Snippet Inventory | Snippet | Status | Notes | |---|---|---| -| Relevant files from `testing/snippets/` | Review | Concurrency examples | +| `testing_drain_exceptions.py` | Keep | DrainFailure catch patterns | +| `testing_xdist_group.py` | Keep | xdist group marker | ## Cross-Links -- **Links to:** Testing overview, Time Control -- **Linked from:** Testing overview +- **Links to:** Test Harness Reference (startup failures), Time Control, Factories, Testing index +- **Linked from:** Test Harness Reference (drain link), Time Control (lock interaction) diff --git a/design/specs/070-doc-overhaul/outlines/testing/factories.md b/design/specs/070-doc-overhaul/outlines/testing/factories.md index a35f5ed28..f71d1f544 100644 --- a/design/specs/070-doc-overhaul/outlines/testing/factories.md +++ b/design/specs/070-doc-overhaul/outlines/testing/factories.md @@ -1,44 +1,90 @@ # Testing — Factories & Internals -**Status:** Exists (169 lines), reference-style, voice polish needed +**Status:** Exists (169 lines), needs JTBD redesign — currently a flat API catalog, should lead with the common case **Voice mode:** Reference — terse, system-as-subject +**Page type:** Reference +**Reader's job:** Build custom test data (events, states, mocks) when the harness convenience methods aren't enough. + +## What was cut + +Nothing removed — this is reference material and completeness matters. But +the order changes. The existing page opens with event factories, which most +readers never use directly (the harness `simulate_*` methods handle it). State +factories and `make_mock_hassette` are more commonly needed. + +`RecordingApi` coverage boundary is important reference but belongs after the +factories — readers reach it when they hit a `NotImplementedError` and come +looking for what's supported. + +Tier 2 re-exports section stays as a brief note at the end. ## Outline +### H2: State Factories +Most common need: build state dicts for seeding or assertions. + +#### H3: make_state_dict +Raw HA-format state dict. Parameters and defaults. + +#### H3: make_light_state_dict +Shorthand with brightness, color_temp. Parameter table. + +#### H3: make_sensor_state_dict +Shorthand with unit_of_measurement, device_class. Parameter table. + +#### H3: make_switch_state_dict +Shorthand for switch entities. + ### H2: Event Factories -#### H3: `create_state_change_event` — build a state change event dict -#### H3: `make_full_state_change_event` — build from pre-made state dicts -#### H3: `create_call_service_event` — build a service call event dict -#### H3: `create_component_loaded_event`, `create_service_registered_event` +For building raw events when you need to bypass `simulate_*` or test +lower-level bus methods. -### H2: State Factories -#### H3: `make_state_dict` — raw state dict -#### H3: `make_light_state_dict` — typed light state -#### H3: `make_sensor_state_dict` — typed sensor state -#### H3: `make_switch_state_dict` — typed switch state +#### H3: create_state_change_event +Parameters: entity_id, old_value, new_value (required), rest optional. -### H2: `make_mock_hassette` -Full mock Hassette instance for unit tests. +#### H3: create_call_service_event +Parameters and example. -### H2: `create_hassette_stub` -Web-layer stub that wires a full FastAPI app stack — for testing web routes and WebSocket endpoints. Not an alias for `make_mock_hassette`. Internal helper (not in `__all__`), imported from `hassette.test_utils._internal`. +### H2: make_mock_hassette +Sealed `AsyncMock` with Pydantic-validated config. Standard pattern for unit +tests needing a hassette mock. Parameter table (data_dir, set_ready, set_loop, +sealed, config_overrides). -### H2: `make_test_config` -Test configuration builder. +### H2: make_test_config +`HassetteConfig` without the full harness — for testing config parsing logic +directly. `data_dir` required, all other fields have test defaults. Parameter +table. ### H2: RecordingApi Coverage Boundary -What RecordingApi supports vs what needs mocking. +What's stubbed (write methods), what's redirected (state reads to StateProxy), +what raises `NotImplementedError`. Lists of explicit stubs and redirected +methods. Note about `api.sync` recording facade. ### H2: Internal Helpers -Functions available from `hassette.test_utils._internal` (not in `__all__` — stable but not part of the public API contract). Includes `create_hassette_stub`, `create_component_loaded_event`, `create_service_registered_event`, `make_full_state_change_event`. Document what they do so users of the web layer can find them, but note the internal status. +Brief note: `create_hassette_stub`, `create_component_loaded_event`, +`create_service_registered_event`, `make_full_state_change_event` are available +from `hassette.test_utils._internal` but not in `__all__`. Stable but not +public API. + +### H2: Next Steps +Links to Testing index, Time Control, Concurrency. ## Snippet Inventory | Snippet | Status | Notes | |---|---|---| -| Relevant files from `testing/snippets/` | Review | Factory usage examples | +| `testing_make_state_dict.py` | Keep | State dict factory | +| `testing_make_light_state_dict.py` | Keep | Light state factory | +| `testing_make_sensor_state_dict.py` | Keep | Sensor state factory | +| `testing_make_switch_state_dict.py` | Keep | Switch state factory | +| `testing_factory_imports.py` | Keep | Import example | +| `testing_create_state_change_event.py` | Keep | Event factory | +| `testing_create_call_service_event.py` | Keep | Service event factory | +| `factories_mock_hassette.py` | Keep | Mock hassette example | +| `testing_make_test_config.py` | Keep | Test config builder | +| `testing_sync_facade.py` | Keep | Sync facade note | ## Cross-Links -- **Links to:** Testing overview, API overview (RecordingApi boundary) -- **Linked from:** Testing overview +- **Links to:** Testing index, Test Harness Reference, API overview (RecordingApi boundary) +- **Linked from:** Test Harness Reference (next steps) diff --git a/design/specs/070-doc-overhaul/outlines/testing/overview.md b/design/specs/070-doc-overhaul/outlines/testing/overview.md index a0a87ea3e..d132b1c4f 100644 --- a/design/specs/070-doc-overhaul/outlines/testing/overview.md +++ b/design/specs/070-doc-overhaul/outlines/testing/overview.md @@ -1,54 +1,122 @@ # Testing — Test Harness Reference -**Status:** Exists (243 lines), restructured — Installation and Quick Start moved to `testing/quickstart.md` (now `index.md`). This page becomes `pages/testing/harness.md` ("Test Harness Reference"). +**Status:** Exists (243 lines), needs JTBD redesign — currently structured as API surface tour, should be task-organized **Voice mode:** Reference — system-as-subject, no "you" +**Page type:** Reference +**Reader's job:** Look up how to do a specific testing task: seed state, simulate an event, assert an API call, handle errors. + +## What was cut (and where it goes) + +The existing page is well-written but organized by API surface (constructor, +properties, state seeding, simulating, asserting). A reader who knows they want +to "test that my handler called turn_on" has to scan the whole page to find the +right section. + +Restructured into task-oriented sections. The API surface is the same — the +grouping changes from "what the harness exposes" to "what the reader is trying +to do." + +Installation and Quick Start content stays on the index page (quickstart.md +which is now `pages/testing/index.md`). This page is for readers who already +have a working test and need to do something specific. + +The "Typed dependency injection in handlers" section is moved up to appear right +after state change simulation, since DI is the standard way to write handlers +and most readers need it immediately. ## Outline ### H2: Prerequisites -One-line note: `hassette[test]` extras required. Link to Testing Quickstart for setup walkthrough. +One line: `hassette[test]` extras required. Link to Testing index for setup. -### H2: The Test Harness -#### H3: Constructor — `AppTestHarness(AppClass, config)` parameters -#### H3: Properties — `harness.bus`, `harness.scheduler`, `harness.api_recorder` (not `harness.api`), etc. - -### H2: State Seeding -`harness.set_state()`, `harness.set_states()` (bulk), `harness.seed_helper()` (helper config for CRUD tests). +### H2: Seeding State +`set_state()` for single entities, `set_states()` for bulk. Warning: does not +fire bus events — seed before simulating. Warning: calling `set_state()` after +`simulate_state_change()` silently overwrites. ### H2: Simulating Events +Opening line: all simulate methods wait for handlers to finish before returning. + #### H3: State Changes +`simulate_state_change()` — publishes through the bus, waits for handlers. +Show DI usage inline (D.StateNew) since it is the standard pattern. + #### H3: Attribute Changes -#### H3: Service Call Events -#### H3: Timeouts and Slow Handlers -#### H3: Typed Dependency Injection in Handlers +`simulate_attribute_change()` — changes one attribute, keeps state value. +Warning: can also fire state-change handlers when `changed=False`. + +#### H3: Service Calls +`simulate_call_service()` — publishes a call_service event. Show DI usage +(D.Domain). + #### H3: Hassette Service Events -Also note the full `simulate_*` surface: `simulate_component_loaded`, `simulate_service_registered`, `simulate_websocket_connected/disconnected`, `simulate_homeassistant_restart/start/stop`, `simulate_app_state_changed/running/stopping`. +`simulate_hassette_service_status()` and convenience wrappers for testing +app responses to internal service lifecycle changes. + +#### H3: Timeouts +Default 2-second timeout on all simulate methods. Override with `timeout=`. +Note about task chain draining and `DrainFailure` — link to Concurrency page. ### H2: Asserting API Calls -#### H3: `assert_called` — verify service calls were made -#### H3: `assert_called_partial` — subset match on kwargs -#### H3: `assert_called_exact` — exact kwargs match -#### H3: `assert_not_called` -#### H3: `assert_call_count` -#### H3: `get_calls` -#### H3: `reset` +`harness.api_recorder` records every `self.api` call. + +#### H3: assert_called +Partial match — additional kwargs in the recorded call are allowed. + +#### H3: assert_not_called + +#### H3: assert_call_count + +#### H3: get_calls +Returns `ApiCall` records with `method`, `args`, `kwargs`. -### H2: Configuration Errors -Testing invalid config detection. +#### H3: reset +Clears recorded calls for mid-test isolation. -### H2: Harness Startup Failures -Testing apps that fail during initialization. +Note: `turn_on`, `turn_off`, `toggle_service` record under their own names, +not `call_service`. + +### H2: Testing Configuration Errors +`AppConfigurationError` during setup — the `async with` body never runs. +Attributes: `app_cls`, `original_error`. + +### H2: Testing Startup Failures +`TimeoutError` from harness — distinct from `DrainTimeout`. Check logs for +the real cause (exception in `on_initialize`). + +### H2: Harness Constructor and Properties +Quick-reference tables for constructor parameters and exposed properties. +This is lookup material, placed last because readers need it least often. ### H2: Next Steps -→ Time Control, → Concurrency, → Factories +Links to Time Control, Concurrency, Factories. ## Snippet Inventory +All existing snippets in `testing/snippets/` that are currently included on +the existing `index.md` page stay assigned to this page. No new snippets needed. + | Snippet | Status | Notes | |---|---|---| -| 34 files in `testing/snippets/` | Review | Assign per-page across the 4 testing pages | +| `testing_state_seeding.py` | Keep | State seeding example | +| `testing_simulate_state_change.py` | Keep | State change simulation | +| `testing_simulate_attribute_change.py` | Keep | Attribute change simulation | +| `testing_attribute_change_both_handlers.py` | Keep | Warning example | +| `testing_simulate_call_service.py` | Keep | Service call simulation | +| `testing_simulate_service_failure.py` | Keep | Service lifecycle events | +| `testing_simulate_timeout.py` | Keep | Timeout override | +| `testing_di_state_change.py` | Keep | DI with state changes | +| `testing_di_call_service.py` | Keep | DI with service calls | +| `testing_assert_called.py` | Keep | Assert called | +| `testing_assert_turn_on_off.py` | Keep | Convenience method note | +| `testing_assert_not_called.py` | Keep | Assert not called | +| `testing_assert_call_count.py` | Keep | Assert call count | +| `testing_get_calls.py` | Keep | Get calls | +| `testing_recorder_reset.py` | Keep | Reset recorder | +| `testing_app_configuration_error.py` | Keep | Config error testing | +| `testing_constructor.py` | Keep | Constructor reference | ## Cross-Links -- **Links to:** Testing Quickstart, Time Control, Concurrency, Factories, Apps overview -- **Linked from:** Testing Quickstart, Recipes (see also), Migration/Testing +- **Links to:** Testing index (quickstart), Time Control, Concurrency, Factories, Apps overview +- **Linked from:** Testing index, Recipes (see also), Migration/Testing diff --git a/design/specs/070-doc-overhaul/outlines/testing/quickstart.md b/design/specs/070-doc-overhaul/outlines/testing/quickstart.md index e24e5fad2..c245b86f6 100644 --- a/design/specs/070-doc-overhaul/outlines/testing/quickstart.md +++ b/design/specs/070-doc-overhaul/outlines/testing/quickstart.md @@ -1,38 +1,66 @@ # Testing — Write Your First Test -**Status:** New page (split from testing/overview.md). This is now the section index (`pages/testing/index.md`). +**Status:** New page (split from testing/overview.md). Section index at `pages/testing/index.md`. **Voice mode:** Getting-started — "you" allowed, step-by-step +**Page type:** Getting-started +**Reader's job:** Write and run their first test for a Hassette app, proving it works without a live HA instance. + +## What was cut + +The existing index page mixes getting-started content (installation, quick +start) with full reference content (constructor tables, all simulate methods, +all assert methods). A reader writing their first test doesn't need the +`DrainFailure` hierarchy or `simulate_hassette_service_status()`. + +This page keeps: installation, quick start example, and enough explanation +to get the reader to a passing test. Everything else lives on the Test +Harness Reference page. ## Outline +### Opening paragraph +One sentence: Hassette ships a test harness that runs your app without a live +HA instance — simulate events, assert API calls, control time. + ### H2: What You'll Learn -Write a test for a Hassette app using AppTestHarness. Seed state, simulate events, assert API calls. +Bulleted list: set up the harness, seed entity state, simulate a state change, +assert your app called the right service. -### H2: Prerequisites -pytest + hassette test extras installation. One-liner: `pip install hassette[test]` or `uv add hassette[test]`. +### H2: Install +`pip install hassette pytest pytest-asyncio` (or `uv add`). `asyncio_mode = +"auto"` in `pyproject.toml` — with the false-green warning. -### H2: Step 1: Create a Test File -Minimal test file structure, naming convention (`test_.py`). +### H2: Write the Test +Complete test file for a motion-lights-style app. Four pieces, walked through +in order: -### H2: Step 2: Set Up the Harness -`AppTestHarness(YourApp, config)` — construct and initialize. Show the `async with` pattern. +1. Import and construct `AppTestHarness` with your app class and config dict. +2. `async with` — the app is fully initialized inside the block. +3. `set_state()` to seed the motion sensor as off. +4. `simulate_state_change()` to trigger motion on. +5. `api_recorder.assert_called("turn_on", ...)` to verify the light turned on. -### H2: Step 3: Seed State and Simulate -Seed an entity state, simulate a state change, verify the handler ran. +Show the complete file first, then walk through each piece. -### H2: Step 4: Assert the Result -`harness.api_recorder.assert_called("light/turn_on", ...)` to verify the app called the right service. +### H2: Run It +`pytest test_my_app.py -v` — expected output showing the test pass. ### H2: Next Steps -→ Testing overview (full API reference), → Time Control, → Recipes (write tests for recipe patterns) +- Test Harness Reference — the full API (all simulate methods, all assert + methods, error handling). +- Time Control — test scheduler-driven behavior. +- Recipes — copy a recipe and write a test for it. ## Snippet Inventory | Snippet | Status | Notes | |---|---|---| -| New: `first_test.py` | New | Complete minimal test example | +| `testing_install_pip.sh` | Keep | pip install command | +| `testing_install_uv.sh` | Keep | uv install command | +| `testing_asyncio_mode.toml` | Keep | pytest-asyncio config | +| `testing_quick_start.py` | Keep | Complete first-test example | ## Cross-Links -- **Links to:** Testing overview (reference), Time Control, Concurrency, Factories +- **Links to:** Test Harness Reference, Time Control, Concurrency, Factories, Recipes - **Linked from:** Getting Started/First Automation (next steps), Recipes (see also) diff --git a/design/specs/070-doc-overhaul/outlines/testing/time-control.md b/design/specs/070-doc-overhaul/outlines/testing/time-control.md index c2174376b..21809dc0d 100644 --- a/design/specs/070-doc-overhaul/outlines/testing/time-control.md +++ b/design/specs/070-doc-overhaul/outlines/testing/time-control.md @@ -1,26 +1,55 @@ # Testing — Time Control -**Status:** Exists (48 lines), concise, voice polish needed +**Status:** Exists (48 lines), mostly GENUINE — good structure, needs JTBD metadata **Voice mode:** Concept — system-as-subject +**Page type:** Depth page (concept + reference) +**Reader's job:** Test scheduler-driven behavior by freezing and advancing time deterministically. + +## What was cut + +Nothing. The existing page is well-structured: opens with the canonical +sequence (the pattern readers will copy), then covers each method with a +focused explanation and example. The `advance_time` warning about needing +`trigger_due_jobs` is exactly the kind of trap that earns its admonition. + +The `whenever` note stays — readers will wonder where `Instant` comes from. ## Outline -### H2: `freeze_time(instant)` -Freezing time to a specific instant for deterministic tests. +### Opening line +One sentence: test scheduler-driven behavior by freezing time and advancing it +manually. + +### Canonical sequence +Complete example showing the freeze-advance-trigger pattern. This is the most +important content — readers copy this first. + +### H2: freeze_time(instant) +Freezes `now` at the given time. Accepts `Instant` or `ZonedDateTime` from +`whenever`. Idempotent — calling again replaces the frozen time. Auto-unfrozen +on `async with` exit. + +### H2: advance_time +Advances the frozen clock by a delta. Warning: does not trigger jobs by itself. +Must call `trigger_due_jobs()` after. -### H2: `advance_time` -Moving time forward by a duration. +### H2: trigger_due_jobs +Fires all jobs due at or before the current frozen time. Returns job count. +Re-enqueued repeating jobs are not re-triggered in the same call. -### H2: `trigger_due_jobs` -Manually triggering scheduler jobs that are due at the frozen time. +### H2: Next Steps +Links to Concurrency (time lock interaction with xdist), Testing index. ## Snippet Inventory | Snippet | Status | Notes | |---|---|---| -| Relevant files from `testing/snippets/` | Review | Time control examples | +| `testing_time_control_sequence.py` | Keep | Canonical sequence | +| `testing_freeze_time.py` | Keep | freeze_time example | +| `testing_advance_time.py` | Keep | advance_time example | +| `testing_trigger_due_jobs.py` | Keep | trigger_due_jobs example | ## Cross-Links -- **Links to:** Testing overview, Scheduler/Methods (triggers interact with time) -- **Linked from:** Testing overview +- **Links to:** Concurrency (time lock), Testing index, Scheduler/Methods (trigger types) +- **Linked from:** Test Harness Reference (next steps) diff --git a/design/specs/070-doc-overhaul/outlines/troubleshooting/troubleshooting.md b/design/specs/070-doc-overhaul/outlines/troubleshooting/troubleshooting.md index 09fa3ac6c..02c6a9c08 100644 --- a/design/specs/070-doc-overhaul/outlines/troubleshooting/troubleshooting.md +++ b/design/specs/070-doc-overhaul/outlines/troubleshooting/troubleshooting.md @@ -1,70 +1,119 @@ # Troubleshooting -**Status:** Exists (140 lines), restructuring — operational content moves to Operating, pure symptom-lookup stays -**Voice mode:** Direct, "you" allowed, problem/solution format +**Status:** Exists (140 lines), needs JTBD redesign — good content but mixes symptom-lookup with operational how-to +**Voice mode:** Direct, "you" allowed, symptom/cause/fix format +**Page type:** Troubleshooting +**Reader's job:** Something is broken. Find the symptom, read the cause, apply the fix. -## Outline +## What was cut (and where it goes) -Pure symptom-lookup. Each H2 is a symptom. Each entry: symptom description, likely causes, fixes. No how-to content — that lives in Operating Hassette. +The existing page mixes two reader jobs: "fix this specific problem" (belongs +here) and "understand how the system behaves at runtime" (belongs in Operating). -### H2: Can't Connect to Home Assistant -Token issues, connection refused/timeout. Links to Auth, Docker Troubleshooting. +Moved to Operating: +- "Home Assistant goes offline" section (WebSocket reconnection sequence, + `ServiceWatcher` restart behavior, log signatures) — this is operational + knowledge, not a symptom the reader is troubleshooting. Readers who need + this are not broken; they want to understand normal behavior. +- "Event handler exceptions" section (exception handling behavior, log + format) — same: this is "how does the framework handle errors?" not "I have + a problem." +- "Upgrading Hassette" section — moves to Operating/upgrading.md. -### H2: Apps Not Loading -App not discovered, import errors, precheck failures. **KI-04**: Include all three log signatures and the `allow_startup_if_app_precheck_fails` workaround. +What stays: pure symptom-lookup. Each H2 is a symptom a reader sees. Each +entry has: what to check, the likely cause, the fix. + +Exception Reference added as a final section — readers who see an unfamiliar +exception name in their logs can look it up here. + +## Outline -### H2: Handler Registration Fails — `ListenerNameRequiredError` -`name=` is required on every bus subscription. Most common error for new users and migrators. +Each H2 is a symptom. Format: flat, scannable. No sub-categories. -### H2: Duplicate Handler — `DuplicateListenerError` -Same `(app_key, instance_index, name, topic)` registered twice in a session. +### H2: Can't Connect to Home Assistant +- Token: verify `HASSETTE__TOKEN`. Link to Auth. +- Connection refused/timeout: check `base_url`. Docker: HA network + reachability. Link to Docker Troubleshooting. -### H2: Event Handler Never Runs -**KI-05**: `changed_to` type mismatch (string vs bool). -**KI-06**: `bus_excluded_domains`/`bus_excluded_entities` silently drop events. -**KI-07**: Attribute-only changes — use `on_attribute_change` for dedicated attribute monitoring; `on_state_change(changed=False)` fires on every state event regardless. -Also: entity ID typos, app not enabled. +### H2: Apps Not Loading +Three log signatures: +- Syntax error or bad import — `SyntaxError` / `ModuleNotFoundError` +- Class not found — `class_name` doesn't match actual class +- Invalid configuration — required `AppConfig` field missing + +Workaround: `allow_startup_if_app_precheck_fails = true` temporarily. +Link to Application Configuration. + +### H2: Handler Registration Fails +`ListenerNameRequiredError` — `name=` is required on every bus subscription. +Most common error for new users. Fix: add `name="descriptive_name"`. + +`DuplicateListenerError` — same `(app_key, instance_index, name, topic)` +registered twice. Fix: use unique names or check for double registration +in `on_initialize`. + +### H2: Handler Never Fires +Checklist, ordered by likelihood: +1. Entity ID typo — no error, handler simply never matches. +2. `changed_to` type mismatch — `True` vs `"on"`. HA values are strings. +3. Domain excluded — `bus_excluded_domains`/`bus_excluded_entities` silently + drop events. +4. Attribute-only change — use `on_attribute_change` or `changed=False`. +5. App not enabled — check `enabled = true` in config. ### H2: Scheduler Not Firing -**KI-08**: Past-time behavior for `run_once` and `run_daily`, `seconds` vs `minutes` gotcha, cron expression pitfall. Links to Job Management troubleshooting. +- Past-time: `run_once(at="07:00")` after 7 AM defers to tomorrow. +- Units: `seconds=5` is 5 seconds, not minutes. +- Cron: `"5 * * * *"` is minute 5 of every hour, not every 5 minutes. + Use `"*/5 * * * *"`. +- Exception in task: logged at ERROR, doesn't crash scheduler. + Link to Job Management troubleshooting. ### H2: Database Degraded / Telemetry Missing -**KI-09**: Zeroed stats strip, Docker disk check command, DB file location, safe to delete. Links to Database & Telemetry degraded mode. +Stats strip shows zeros. Check disk space (Docker: `docker compose exec +hassette df -h /data`). DB file at `/data/hassette.db`. Safe to delete — +only loses history. Restart to recreate. Link to Database & Telemetry. ### H2: Cache Not Persisting -**KI-10**: `data_dir` config, Docker volume mount, instance name key prefix. Links to Cache patterns troubleshooting. +Check `data_dir` config and volume mount. Instance name key prefix for +multi-instance apps. Link to Cache patterns. ### H2: Custom State Class Not Registering -**KI-11**: `domain: Literal[...]` field required, `super().__init_subclass__()` call. Links to Custom States troubleshooting. +`domain: Literal["your_domain"]` field required. Call +`super().__init_subclass__()` if overriding. Link to Custom States. ### H2: Web UI Not Accessible -Port/URL, Docker port mapping, `web_api` settings. +- Port/URL: `http://localhost:8126/ui/` +- Docker: `ports: ["8126:8126"]` +- Disabled: check `run` and `run_ui` under `[hassette.web_api]`. + Link to Web UI overview. ### H2: Docker-Specific Issues -Pointer to Docker Troubleshooting page. +Pointer to Docker Troubleshooting page for container startup, dependency +installation, health checks, hot reload, performance. ### H2: Exception Reference -Common exceptions app authors may encounter, organized by category: -- **Connection:** `InvalidAuthError`, `BaseUrlRequiredError`, `CouldNotFindHomeAssistantError`, `ConnectionClosedError` -- **Registration:** `ListenerNameRequiredError` (cross-link to H2 above), `DuplicateListenerError` (cross-link to H2 above) -- **State conversion:** `EntityNotFoundError`, `DomainNotFoundError`, `RegistryNotReadyError` -- **Dependency injection:** `DependencyInjectionError`, `DependencyResolutionError` -- **Lifecycle:** `InvalidLifecycleTransitionError` (includes `from_status`, `to_status`, `resource_name` attributes) +Common exceptions organized by category. Per-entry: what triggers it, what +to do. Not full API reference — link to auto-generated docs for complete list. + +- **Connection:** `InvalidAuthError`, `BaseUrlRequiredError`, + `CouldNotFindHomeAssistantError`, `ConnectionClosedError` +- **Registration:** `ListenerNameRequiredError`, `DuplicateListenerError` +- **State conversion:** `EntityNotFoundError`, `DomainNotFoundError`, + `RegistryNotReadyError` +- **Dependency injection:** `DependencyInjectionError`, + `DependencyResolutionError` +- **Lifecycle:** `InvalidLifecycleTransitionError` (has `from_status`, + `to_status`, `resource_name` attributes) - **Configuration:** `AppPrecheckFailedError` -- **Framework:** `HassetteError` (base), `FatalError` (non-restartable, triggers shutdown) - -Brief per-entry: what triggers it, what to do about it. Not a full API reference — link to auto-generated exception docs for the complete list. - -**Removed from this page (moved to Operating):** -- WebSocket reconnection sequence → Operating/overview.md -- Event handler exception behavior → Operating/overview.md -- Upgrading Hassette → Operating/upgrading.md +- **Framework:** `HassetteError` (base), `FatalError` (non-restartable, + triggers shutdown) ## Snippet Inventory -No code snippets — log signatures and config examples are inline. +No code snippets — log signatures and config examples inline. ## Cross-Links -- **Links to:** Operating (runtime behavior), Docker Troubleshooting, Auth, Configuration, Database & Telemetry, Cache patterns, Custom States -- **Linked from:** Getting Started (next steps), many concept pages +- **Links to:** Operating (runtime behavior), Docker Troubleshooting, Auth, Application Configuration, Database & Telemetry, Cache patterns, Custom States, Web UI overview, Job Management +- **Linked from:** Getting Started (next steps), many concept pages, Home page diff --git a/design/specs/070-doc-overhaul/outlines/web-ui/debug-handler.md b/design/specs/070-doc-overhaul/outlines/web-ui/debug-handler.md index 4c6c26fb8..d12e9bf6f 100644 --- a/design/specs/070-doc-overhaul/outlines/web-ui/debug-handler.md +++ b/design/specs/070-doc-overhaul/outlines/web-ui/debug-handler.md @@ -1,32 +1,68 @@ # Web UI — Debug a Failing Handler -**Status:** Stub (3 lines), new task-oriented page -**Voice mode:** Getting-started feel — "you" allowed, procedural, task-focused +**Status:** Stub (3 lines), needs full JTBD design from scratch +**Voice mode:** Procedural — "you" allowed, task-focused +**Page type:** Procedural (task-oriented, light troubleshooting) +**Reader's job:** Figure out why their handler isn't firing, is throwing errors, or is firing too often — using the web UI to diagnose. -## Outline +## What was cut + +The old outline was organized by UI location (handlers page, app detail +handlers tab, invocation logs). A reader debugging a handler thinks in +symptoms: "it never fires", "it fires but errors", "it fires too often." +Leading with symptoms gets them to the right diagnostic step faster. -Walks through using the Web UI to debug a handler that isn't firing or is throwing errors. Consolidates relevant content from old `handlers.md` and `app-detail/handlers.md`. +The "Common Causes" section from the old outline is the most valuable part +for this reader — promoted to appear early, before the detailed UI walkthrough. -### H2: Symptoms -What "failing handler" looks like: handler never fires, fires but errors, fires too often. +## Outline -### H2: Check the Handlers Page -Global handlers table. How to find your handler, read its status, see error counts. Consolidates from old `handlers.md`. +### Opening paragraph +Three things go wrong with handlers: they don't fire, they fire but error, +or they fire too often. The web UI shows which is happening and why. -### H2: Drill into an App's Handlers -App detail → Handlers tab. Per-handler invocation history, error details. Consolidates from old `app-detail/handlers.md`. +### H2: Quick Diagnosis +Table mapping symptom to where to look: -### H2: Read the Invocation Logs -Execution ID filtering to trace a single invocation through the log stream. +| Symptom | Check | What to look for | +|---|---|---| +| Handler never fires | Handlers page | Missing from list, or zero invocations | +| Handler fires but errors | App detail > Handlers tab | Error count > 0, error details | +| Handler fires too often | App detail > Handlers tab | High invocation count, check predicate/debounce | ### H2: Common Causes -Brief problem/solution list specific to handlers (missing `name=`, wrong entity pattern, DI annotation mismatch). +Flat list, scannable. Each entry: what went wrong, how to fix it. + +- Missing `name=` on subscription — `ListenerNameRequiredError` at registration. + Add `name="descriptive_name"` to the bus call. +- Wrong entity pattern — handler registered for `"light.kitchen"` but the + entity is `"light.kitchen_ceiling"`. Check the listener's topic on the + Handlers page. +- `changed_to` type mismatch — `changed_to=True` vs `changed_to="on"`. HA + state values are strings. +- DI annotation mismatch — handler parameter annotated with the wrong state + type. Check the `DependencyResolutionError` in the error details. +- Domain excluded — entity's domain is in `bus_excluded_domains`. Events + silently dropped before reaching handlers. + +### H2: Using the Handlers Page +Global handlers table: find your handler by app or name, see registration +status, invocation count, error count. How to read the columns. + +### H2: Drilling into Handler History +App detail > Handlers tab shows per-handler invocation history with timestamps, +duration, and error details for each execution. + +### H2: Tracing a Single Execution +Click an execution ID to filter the Logs page to that single invocation. +See every log line the handler emitted during that run. ## Snippet Inventory -No code snippets — screenshots or UI descriptions. +No code snippets — UI documentation. Screenshots would help but are not +snippet files. ## Cross-Links -- **Links to:** Web UI overview, Logs page, Bus/Handlers (handler mechanics), Bus/DI (annotation reference) -- **Linked from:** Web UI overview, Bus/Handlers (troubleshooting) +- **Links to:** Web UI overview, Logs page (execution ID filtering), Bus/Handlers (handler mechanics), Bus/DI (annotation reference), Troubleshooting (handler never runs) +- **Linked from:** Web UI overview, Manage Apps (if app shows errors) diff --git a/design/specs/070-doc-overhaul/outlines/web-ui/inspect-config-code.md b/design/specs/070-doc-overhaul/outlines/web-ui/inspect-config-code.md index 4b513020b..8bf563103 100644 --- a/design/specs/070-doc-overhaul/outlines/web-ui/inspect-config-code.md +++ b/design/specs/070-doc-overhaul/outlines/web-ui/inspect-config-code.md @@ -1,26 +1,49 @@ # Web UI — Inspect Configuration and Code -**Status:** Stub (3 lines), new task-oriented page +**Status:** Stub (3 lines), needs full JTBD design from scratch **Voice mode:** Procedural — "you" allowed, task-focused +**Page type:** Procedural (task-oriented) +**Reader's job:** Verify what configuration values Hassette is actually using at runtime, and read app source code in the browser without SSH access. + +## What was cut + +The old outline was organized by UI location (global config, app config, app +code). Reorganized around the two questions readers actually ask: "what config +is Hassette running with?" and "what does the app code look like right now?" ## Outline -Consolidates content from old `config.md` (45 lines) and `app-detail/config.md`, `app-detail/code.md`. +### H2: Check Running Configuration +Why: you changed a config value and want to confirm Hassette picked it up, or +you need to verify what another user deployed. + +#### H3: Global Configuration +The Configuration page shows all `hassette.toml` values grouped by section +(general, web_api, logging, etc.). Values are formatted for readability — +booleans, paths, lists are displayed with their types. + +#### H3: Per-App Configuration +App detail > Config tab shows the resolved config for that app instance. This +is the Pydantic-validated result — it shows defaults that were applied, env +var overrides that took effect, and type-converted values. -### H2: Global Configuration -Configuration groups view, value formatting. From old `config.md`. +Useful for debugging "I set this in the env but it's not taking effect" — +if the value doesn't appear here, the env var name is wrong or the field +name doesn't match. -### H2: App Configuration -Per-app config view from the app detail page. From old `app-detail/config.md`. +### H2: Read App Source Code +App detail > Code tab shows the Python source of the app as deployed. Syntax +highlighted, read-only. -### H2: App Source Code -Viewing app source code in the UI. From old `app-detail/code.md`. +Use case: verifying what version of the code is running on a remote instance +without SSH access. Particularly useful in Docker deployments where the +container's app directory may differ from the development copy. ## Snippet Inventory -No code snippets — UI feature documentation. +No code snippets — UI documentation. ## Cross-Links -- **Links to:** Web UI overview, Configuration (the actual config system) +- **Links to:** Web UI overview, Configuration (the config system), Apps/Configuration (AppConfig fields) - **Linked from:** Web UI overview diff --git a/design/specs/070-doc-overhaul/outlines/web-ui/logs.md b/design/specs/070-doc-overhaul/outlines/web-ui/logs.md index c5ba8f724..3044865c2 100644 --- a/design/specs/070-doc-overhaul/outlines/web-ui/logs.md +++ b/design/specs/070-doc-overhaul/outlines/web-ui/logs.md @@ -1,33 +1,64 @@ # Web UI — Read and Filter Logs -**Status:** Exists (119 lines), solid content, voice polish needed +**Status:** Exists (119 lines), mostly GENUINE — well-structured, needs JTBD metadata and minor reorder **Voice mode:** Concept/procedural hybrid — system-as-subject for descriptions, "you" for actions +**Page type:** Procedural (task-oriented) +**Reader's job:** Find a specific log entry or watch logs in real time to understand what their automations are doing. + +## What was cut + +Nothing removed. The existing page is well-written and task-oriented already. +The column table, filtering section, detail drawer, and live streaming are all +things a reader actively does. + +Minor reorder: "Execution ID filtering" moved up to right after "Filtering and +search" since it's a filtering task. The column picker and detail drawer are +features the reader discovers while filtering, so they follow naturally. + +Live streaming placed last — it's a passive activity (watching) vs the active +filtering tasks above. ## Outline -### H2: Log Table -Column descriptions, data sources. +### Opening paragraph +Global, filterable, searchable view of all log entries. Real-time streaming. + +Screenshot of logs page. ### H2: Filtering and Search -Text search, level filter, app filter, time range. +The primary task. Level filter, app filter, function text filter, search box. +Footer count and the 500-entry display limit. + +### H2: Trace a Single Execution +`?execution_id=` URL parameter filters to one handler/job execution. +Hassette links here from the Handlers tab. You can construct the URL manually. + +### H2: Log Table Columns +Column reference table: name, sortable, filterable, description. Reference +material for when the reader wants to know what a column means. ### H2: Column Picker -Customizing visible columns. +Customize visible columns. Grid icon in footer. Required columns (Level, +Message). Responsive hiding behavior. Reset to defaults. ### H2: Log Detail Drawer -Expanding a log entry to see full context. +Click any row to see full entry: severity, timestamp, metadata grid, full +message, exception/traceback. Keyboard navigation (arrows, Escape). +Responsive layout (side panel on desktop, bottom sheet on mobile). ### H2: Live Streaming -WebSocket-based live log tail (not SSE). Auto-pause on sort behavior. Runtime log-level control via the WebSocket connection. +Entries appear in real time. Auto-pause when sorted by anything other than +timestamp. Resume button in footer. -### H2: Execution ID Filtering -Tracing a single handler/job execution across log entries. +### H2: Related Pages +Links to app-detail logs tab (per-app view), app-detail handlers tab +(execution history with log links). ## Snippet Inventory -No code snippets — UI feature documentation. +No code snippets — UI documentation. ## Cross-Links - **Links to:** Web UI overview, Debug Handler (execution ID), Database & Telemetry -- **Linked from:** Web UI overview, Operating/Log Levels +- **Linked from:** Web UI overview, Debug Handler, Operating/Log Levels diff --git a/design/specs/070-doc-overhaul/outlines/web-ui/manage-apps.md b/design/specs/070-doc-overhaul/outlines/web-ui/manage-apps.md index a0b1be11c..45c873611 100644 --- a/design/specs/070-doc-overhaul/outlines/web-ui/manage-apps.md +++ b/design/specs/070-doc-overhaul/outlines/web-ui/manage-apps.md @@ -1,32 +1,55 @@ # Web UI — Manage Apps -**Status:** Stub (3 lines), new task-oriented page +**Status:** Stub (3 lines), needs full JTBD design from scratch **Voice mode:** Procedural — "you" allowed, task-focused +**Page type:** Procedural (task-oriented) +**Reader's job:** Check whether their apps are healthy, and start/stop/reload individual apps from the browser. + +## What was cut + +The old outline was organized by UI element (stats strip, table, actions, +detail view, multi-instance, mobile). A reader managing apps doesn't think +in UI components — they think "is my app running?" and "how do I restart it?" + +Reorganized around the two tasks readers actually perform: checking health +and taking action. The detail view is folded into "checking health" since +that's when you drill in. Multi-instance and mobile are notes within the +relevant sections, not standalone headings. ## Outline -Consolidates content from old `apps.md` (118 lines) and `app-detail/overview.md`. +### H2: Check App Health +What the apps dashboard shows at a glance: status badges (RUNNING, STOPPED, +ERROR), handler count, invocation count. How to read the stats strip. + +How to find a specific app: search, status filter, sorting. + +Drilling in: click an app to see its detail view — overview tab with health +indicators and recent activity. + +Note: multi-instance apps show one row per instance with the instance name. -### H2: Apps Dashboard -Stats strip, app table, status filter, sorting, searching. From old `apps.md`. +### H2: Start, Stop, and Reload +Where the action buttons are (apps table and app detail page). What each does: -### H2: App Actions -Start, stop, reload individual apps. From old `apps.md` actions section. +- **Start** — initializes the app and begins processing events +- **Stop** — shuts down gracefully, cancels scheduled jobs +- **Reload** — stops then starts (picks up code and config changes) -### H2: App Detail View -What you see when you click an app: overview tab, health, activity. From old `app-detail/overview.md`. +When to use reload vs restart the whole process. -### H2: Multi-Instance Apps -How multi-instance apps appear in the UI, instance-level actions. From old `apps.md`. +Note: these are the same actions as `hassette app start/stop/reload ` on +the CLI. -### H2: Mobile Layout -Responsive behavior. Brief. +### H2: Understand App States +Brief table of app lifecycle states (INITIALIZING, RUNNING, STOPPED, ERROR) +and what each means. Link to Apps/Lifecycle for the full state machine. ## Snippet Inventory -No code snippets — UI feature documentation. +No code snippets — UI documentation. ## Cross-Links -- **Links to:** Web UI overview, Debug Handler, Apps/Lifecycle (app states) +- **Links to:** Web UI overview, Debug Handler (if app shows errors), Apps/Lifecycle (state machine), CLI commands (app management) - **Linked from:** Web UI overview diff --git a/design/specs/070-doc-overhaul/outlines/web-ui/overview.md b/design/specs/070-doc-overhaul/outlines/web-ui/overview.md index 70281cbff..9b8dfdab2 100644 --- a/design/specs/070-doc-overhaul/outlines/web-ui/overview.md +++ b/design/specs/070-doc-overhaul/outlines/web-ui/overview.md @@ -1,29 +1,63 @@ # Web UI — Overview -**Status:** Exists (56 lines), brief intro, voice polish needed -**Voice mode:** Concept — system-as-subject, no "you" +**Status:** Exists (56 lines), needs JTBD redesign — currently feature-oriented, should orient the reader toward tasks +**Voice mode:** Concept — system-as-subject for descriptions, "you" for actions +**Page type:** Concept (landing page) +**Reader's job:** Figure out what the web UI can do for them and get to the right page for their task. + +## What was cut (and where it goes) + +The existing page has good content (enabling, accessing, config reference) but +ends with "Related pages" that link to old feature-oriented pages (layout.md, +apps.md) instead of the new task-oriented pages. + +Old `layout.md` (sidebar, status bar, command palette, alerts) is absorbed +into this page as a brief "Layout" section — it doesn't warrant its own page +since it's orientation material, not a task. + +The config quick-reference collapsible section stays — it's useful lookup +material for an overview page. ## Outline -### H2: Enabling and Accessing -`web_api.run` and `web_api.run_ui` settings, default port (`web_api.port`), how to access. +### Opening paragraph +What the web UI shows: app health, handler invocation history, structured +logs, system configuration. Runs in the same process as the REST API — nothing +extra to start. -### H2: Layout -Sidebar navigation, status bar, command palette, alert banners. Consolidates content from old `layout.md`. +Screenshot of apps page. + +### H2: Enabling and Accessing +Default URL, default bind address, how to change host/port. Security warning +(no auth, bind to 127.0.0.1 or use reverse proxy). Disabling UI while keeping +REST API. First-run note (empty tables until automations run). -### H2: Related Pages -Links to the four task-oriented pages. +Collapsible config quick reference table. -**Note:** Old `layout.md` (99 lines), `apps.md` (118 lines), `config.md` (45 lines), `handlers.md` (65 lines), and `app-detail/*.md` (6 pages) are being consolidated into the 5 new task-oriented pages. The overview absorbs the layout description; the rest distribute across the task pages. +### H2: Layout +Brief orientation: sidebar navigation, status bar, command palette, alert +banners. Enough to navigate, not a feature tour. Absorbed from old layout.md. + +### H2: What Can I Do Here? +Task-oriented link list — each links to its page with a one-sentence +description of the task: + +- **[Manage Apps](manage-apps.md)** — start, stop, reload apps; check health + and status +- **[Debug a Failing Handler](debug-handler.md)** — find why a handler isn't + firing or is throwing errors +- **[Read and Filter Logs](logs.md)** — search, filter, and stream logs in + real time +- **[Inspect Configuration and Code](inspect-config-code.md)** — view global + and per-app config, read app source ## Snippet Inventory | Snippet | Status | Notes | |---|---|---| -| 1 file in `web-ui/snippets/` | Review | Check if still relevant | -| 1 file in `web-ui/app-detail/snippets/` | Review | Check if still relevant | +| `disable-ui.toml` | Keep | TOML config for disabling UI | ## Cross-Links -- **Links to:** Debug Handler, Logs, Manage Apps, Inspect Config & Code, Configuration/Global (web settings) -- **Linked from:** Architecture, Docker Setup (viewing logs) +- **Links to:** Manage Apps, Debug Handler, Logs, Inspect Config & Code, Configuration/Global (web settings) +- **Linked from:** Architecture, Docker Setup, Home page From bdc214a38171384485407384018e6cb7b4ff9dd1 Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Tue, 2 Jun 2026 15:41:10 -0500 Subject: [PATCH 042/160] docs: rewrite Apps section (overview, lifecycle, task-bucket) Apps Overview: absorbed configuration as H2, restructured capabilities from bullet list to subsection snippets, trimmed scheduler method list, fixed DomainStates syntax in snippet, added allow_only_app_in_prod note, trimmed AppSync to link to lifecycle page. Lifecycle: promoted CannotOverrideFinalError warning, corrected @final attribution (cleanup is final on App not Resource), fixed AppSync override error type. Task Bucket: demoted cross-thread primitives and make_async_adapter to collapsible sections, corrected method signatures (added **kwargs, name= params). --- docs/pages/core-concepts/apps/index.md | 154 +++++++++--------- docs/pages/core-concepts/apps/lifecycle.md | 74 ++++----- .../apps/snippets/apps_check_state.py | 2 +- .../apps/snippets/lifecycle_hooks.py | 9 +- .../apps/snippets/lifecycle_sync.py | 13 ++ docs/pages/core-concepts/apps/task-bucket.md | 72 ++++---- 6 files changed, 172 insertions(+), 152 deletions(-) create mode 100644 docs/pages/core-concepts/apps/snippets/lifecycle_sync.py diff --git a/docs/pages/core-concepts/apps/index.md b/docs/pages/core-concepts/apps/index.md index 824de1919..0fac1090e 100644 --- a/docs/pages/core-concepts/apps/index.md +++ b/docs/pages/core-concepts/apps/index.md @@ -1,10 +1,6 @@ # Apps Overview -Apps are the code you write to respond to events and control your home. Each app has its own behavior, configuration, and internal state. - -Apps can be **asynchronous** (preferred) or **synchronous**. Sync apps are automatically run in threads to prevent blocking the event loop. - -## Structure +An app is a Python class that reacts to Home Assistant events and controls devices. Each app has its own config, state, and a set of handles for interacting with HA. ```mermaid flowchart TD @@ -29,158 +25,168 @@ flowchart TD ## Defining an App -Every app is a Python class that inherits from [`App`][hassette.app.app.App] or [`AppSync`][hassette.app.app.AppSync]. +Every app is a Python class that inherits from [`App`][hassette.app.app.App]. `App` manages handlers, scheduling, and the connection to Home Assistant. The `on_initialize` lifecycle hook runs at startup, before any events arrive. -```python title="example_app.py" +```python --8<-- "pages/core-concepts/apps/snippets/example_app.py" ``` !!! info "What's `D.StateNew[states.LightState]`?" - That annotation is [dependency injection](../bus/handlers.md) — you declare what data you need, and Hassette extracts and types it from the event automatically. The [Writing Handlers](../bus/handlers.md) page covers how it works. For now, just notice the pattern. + That annotation is [dependency injection](../bus/handlers.md) — the handler declares what data it needs, and Hassette extracts and types it from the event automatically. The [Writing Handlers](../bus/handlers.md) page covers how it works. For now, just notice the pattern. -## Dates and Times +## Configuration -Hassette uses the [`whenever`](https://whenever.readthedocs.io/en/latest/) library for timezone-aware date/time handling instead of Python's stdlib `datetime`. Python's `datetime` has a mutable API and makes it easy to accidentally create "naive" (timezone-unaware) objects — a common source of bugs in time-sensitive automations. `whenever` is always timezone-aware and immutable, so incorrect comparisons between naive and aware times become type errors rather than silent failures. Every app provides `self.now()`, which returns a `ZonedDateTime` in your system timezone. +`AppConfig` loads and validates an app's settings from `hassette.toml` and environment variables. A subclass declares typed fields; Hassette populates them at startup. ```python ---8<-- "pages/core-concepts/apps/snippets/apps_whenever_dates.py:imports" +--8<-- "pages/core-concepts/apps/snippets/app_config_definition.py" ``` +`self.app_config` on the app instance is typed as the declared subclass, so the IDE and Pyright know the exact shape. + +### Environment Variables + +`SettingsConfigDict(env_prefix="...")` scopes environment variable injection to a prefix, preventing collisions between multiple apps running in the same process. + ```python ---8<-- "pages/core-concepts/apps/snippets/apps_whenever_dates.py:usage" +--8<-- "pages/core-concepts/apps/snippets/app_config_env_prefix.py" +``` + +With `env_prefix="MYAPP_"`, the field `api_key` reads from `MYAPP_API_KEY`. Fields without a matching environment variable fall back to their declared defaults. Required fields (no default) raise a validation error at startup if absent. + +### Base Fields + +Every `AppConfig` includes two built-in fields: + +- `instance_name` — a string that uniquely identifies one running instance of the app. Defaults to an empty string; Hassette derives a display name from the class name when it is not set. +- `log_level` — controls the logging verbosity for this app's logger. Inherits the process-level default when not set. + +`AppConfig` allows arbitrary extra fields by default. A subclass can tighten this by setting `extra="forbid"` in its own `model_config`. + +### TOML Registration + +The `hassette.toml` file registers each app and supplies its config values. See [Configuration: Applications](../../configuration/applications.md) for the full reference. + +```toml +--8<-- "pages/core-concepts/apps/snippets/app_config.toml" ``` -You'll see `ZonedDateTime` in scheduler parameters, persistent storage examples, and custom state definitions. If you're familiar with `datetime.datetime`, the API is similar but always timezone-aware. +## Dates and Times + +`self.now()` returns the current time as a `ZonedDateTime` from the [`whenever`](https://whenever.readthedocs.io/en/latest/) library. All scheduler parameters, persistent storage examples, and custom state definitions use `whenever` types. -## Core Capabilities +```python +--8<-- "pages/core-concepts/apps/snippets/apps_whenever_dates.py:imports" +``` -Each app receives pre-configured helpers: +```python +--8<-- "pages/core-concepts/apps/snippets/apps_whenever_dates.py:usage" +``` -- **[`self.api`](../api/index.md)** - Interact with Home Assistant. -- **[`self.bus`](../bus/index.md)** - Subscribe to events. -- **[`self.scheduler`](../scheduler/index.md)** - Schedule jobs. -- **[`self.states`](../states/index.md)** - Access entity states. -- **[`self.cache`](../cache/index.md)** - Persistent disk-based storage. -- **`self.logger`** - Dedicated logger instance. -- **[`self.app_config`](configuration.md)** - Typed configuration. -- **[`self.task_bucket`](task-bucket.md)** - Spawn background tasks and offload blocking work to a thread pool. +`whenever` is always timezone-aware and immutable. Mixing naive and aware times is a compile-time error rather than a silent runtime bug. Python's stdlib `datetime` permits that class of mistake; `whenever` does not. -## Common Use Cases +## What an App Can Do -### Reacting to Events +### React to Events -Subscribe to events using [`self.bus`](../bus/index.md) to react to changes in Home Assistant. +[`self.bus`](../bus/index.md) subscribes to Home Assistant state changes, attribute changes, and service calls. The bus delivers each matching event to every registered handler. ```python --8<-- "pages/core-concepts/apps/snippets/apps_subscribe_state_change.py:subscribe_state_change" ``` -### Run Recurring Jobs +See the [Bus](../bus/index.md) page for filtering, predicates, debounce, and throttle options. + +### Schedule Jobs -Use [`self.scheduler`](../scheduler/index.md) to schedule recurring tasks. +[`self.scheduler`](../scheduler/index.md) runs functions on a schedule. ```python --8<-- "pages/core-concepts/apps/snippets/apps_run_hourly.py:run_hourly" ``` -### Check Entity States +See the [Scheduler](../scheduler/index.md) page for triggers, job groups, and jitter. + +### Read Entity States -Use [`self.states`](../states/index.md) to check the current state of entities. +[`self.states`](../states/index.md) provides instant access to the current state of any entity, without an API call. ```python --8<-- "pages/core-concepts/apps/snippets/apps_check_state.py:check_state" ``` +See the [States](../states/index.md) page for typed domain access and custom state models. + ### Call Services -Use [`self.api`](../api/index.md) to call Home Assistant services. +[`self.api`](../api/index.md) calls Home Assistant REST and WebSocket services. ```python --8<-- "pages/core-concepts/apps/snippets/apps_call_service.py:call_service" ``` !!! warning "Forgetting `await` on API calls" - Every `self.api.*` method is a coroutine — it **must** be awaited. Writing `self.api.call_service(...)` without `await` returns a coroutine object and silently does nothing: no error is raised, no service is called, and no log message appears. If an API call seems to have no effect, check that you haven't dropped the `await`. + Every `self.api.*` method is a coroutine — it must be awaited. Writing `self.api.call_service(...)` without `await` returns a coroutine object and silently does nothing: no error is raised, no service is called, and no log message appears. If an API call seems to have no effect, check that `await` is present. + +See the [API](../api/index.md) page for state access, entity management, and more. -### Persist Data Between Restarts +### Persist Data -Use [`self.cache`](../cache/index.md) to store data that should survive app restarts. +[`self.cache`](../cache/index.md) stores values that survive app restarts. Reads and writes go through a disk-backed store scoped to the app instance. ```python --8<-- "pages/core-concepts/apps/snippets/apps_cache_counter.py:cache_counter" ``` -### Run Background Tasks and Blocking Code +See the [Cache](../cache/index.md) page for typed access, TTL, and cache invalidation. -Use [`self.task_bucket`](task-bucket.md) to spawn fire-and-forget coroutines or offload blocking calls to a thread pool. All tracked tasks are cancelled automatically on shutdown. +### Run Background Work + +[`self.task_bucket`](task-bucket.md) spawns fire-and-forget coroutines and offloads blocking calls to a thread pool. All tracked tasks cancel automatically on shutdown. ```python --8<-- "pages/core-concepts/apps/snippets/apps_task_bucket.py:spawn" ``` -See the [Task Bucket](task-bucket.md) page for the full API: `spawn()`, `run_in_thread()`, `make_async_adapter()`, and cross-thread communication. +See the [Task Bucket](task-bucket.md) page for `run_in_thread`, `make_async_adapter`, and cross-thread communication. -## Restricting to a Single App During Development +## Restricting to a Single App -The `@only_app` decorator prevents multiple instances of the same app class from running. Apply it during development or testing when you want to isolate one app without editing your configuration files: +The [`@only_app`][hassette.app.app.only_app] decorator prevents all other apps from loading while the decorated class is present. It is intended for development isolation: one app runs while the rest are silenced, without editing `hassette.toml`. ```python --8<-- "pages/core-concepts/apps/snippets/apps_only_app.py" ``` -If more than one class in your project is decorated with `@only_app`, Hassette raises an error at startup. Remove the decorator before deploying. +Only one class in the project may carry `@only_app` at a time. Hassette raises an error at startup if more than one is found. + +In production mode, the decorator is ignored by default. Set `allow_only_app_in_prod = true` in `hassette.toml` to override this. -## Broadcasting Events Between Apps +## Broadcasting Between Apps -`Bus.emit` broadcasts an event to all apps subscribed to a given topic. The event stays in-process — it never reaches Home Assistant. All apps that called `self.bus.on(topic=...)` for that topic receive it. +[`self.bus.emit()`](../bus/index.md) broadcasts an in-process event to all apps subscribed to a given topic. The event never reaches Home Assistant and is not persisted across restarts. -This is the on/emit symmetry: subscribe with `self.bus.on`, broadcast with `self.bus.emit`. Both live on the same `Bus` instance. +`self.bus.on(topic=...)` subscribes to a named topic. The [`D.EventData[T]`](../bus/dependency-injection.md) annotation extracts and types the payload automatically. ```python --8<-- "pages/core-concepts/apps/snippets/apps_bus_emit.py:sender" ``` -The receiving app subscribes to the same topic and extracts the typed data via `D.EventData[T]` — a [dependency injection](../bus/dependency-injection.md) annotation that Hassette resolves from the event envelope automatically. - ```python --8<-- "pages/core-concepts/apps/snippets/apps_bus_emit.py:receiver" ``` -Broadcast is local and ephemeral — events are not persisted across restarts and do not leave the framework process. - !!! note "Self-delivery" - An app that both subscribes to and emits on the same topic receives its own event. To filter self-emitted events, include a `source` field on the emitted dataclass (as `LightsSyncedData` does above) and guard in the handler: `if data.source == self.instance_name: return`. + An app that both emits and subscribes on the same topic receives its own events. To filter self-emitted events, include a `source` field on the emitted dataclass (as `LightsSyncedData` does above) and guard in the handler: `if data.source == self.instance_name: return`. ## Synchronous Apps ??? note "AppSync — for blocking code" - [`AppSync`][hassette.app.app.AppSync] is a subclass of `App` for automations that must call blocking (non-async) libraries. Instead of overriding `on_initialize` and `on_shutdown`, you override their `_sync`-suffixed counterparts (`on_initialize_sync`, `on_shutdown_sync`, etc.). Hassette runs these methods in a thread pool so they do not block the event loop. - - The bus, scheduler, and API are async. From a `_sync` hook, reach their synchronous facades through `.sync` — `self.bus.sync`, `self.scheduler.sync`, and `self.api.sync`. Each facade method blocks until the underlying async call completes. Calling one from inside the event loop raises `RuntimeError` instead of deadlocking. - - ```python - from hassette import AppSync - - class MyApp(AppSync[MyConfig]): - def on_initialize_sync(self) -> None: - # registration runs through the .sync facades from sync code - self.bus.sync.on_state_change("light.kitchen", handler=self.on_change, name="kitchen") - self.scheduler.sync.run_in(self.cleanup, 60, name="cleanup") - self.api.sync.call_service("light", "turn_on", target={"entity_id": "light.kitchen"}) - - def on_change(self, event) -> None: - ... - - def cleanup(self) -> None: - ... - - def on_shutdown_sync(self) -> None: - ... - ``` + [`AppSync`][hassette.app.app.AppSync] runs automations that depend on blocking (non-async) libraries. Hassette executes the app's lifecycle hooks in a thread pool so they do not block the event loop. The bus, scheduler, and API remain async but expose synchronous facades via `.sync` (`self.bus.sync`, `self.scheduler.sync`, `self.api.sync`). - Prefer async `App` whenever possible. Use `AppSync` only when a third-party library provides no async interface and wrapping it yourself is impractical. + Prefer async `App` whenever possible. See [Lifecycle](lifecycle.md#synchronous-lifecycle) for the sync hook details and a full example. ## Next Steps -- **[Lifecycle](lifecycle.md)**: Understand `on_initialize` and `on_shutdown`. -- **[Configuration](configuration.md)**: Learn how to use typed configuration and secrets. +- **[Lifecycle](lifecycle.md)** — `on_initialize`, `on_shutdown`, and automatic resource cleanup. +- **[Task Bucket](task-bucket.md)** — background tasks, thread offloading, and cross-thread communication. diff --git a/docs/pages/core-concepts/apps/lifecycle.md b/docs/pages/core-concepts/apps/lifecycle.md index 79d7637f2..551637b10 100644 --- a/docs/pages/core-concepts/apps/lifecycle.md +++ b/docs/pages/core-concepts/apps/lifecycle.md @@ -1,80 +1,68 @@ -# App Lifecycle +# Apps — Lifecycle -Every app goes through startup and shutdown phases. You don't need to manage resources yourself — Hassette handles that, so you can focus on your automation logic. +Hassette manages app initialization and shutdown. The app declares what to do at each stage through lifecycle hooks. ## Initialization -During startup, Hassette transitions the app through `STARTING → RUNNING`. +Hassette transitions the app through `STARTING` to `RUNNING` at startup. All core services (API, Bus, Scheduler, and database) are ready before any hook runs. -All core services (API, Bus, Scheduler) are fully ready before your initialization hooks run. - -The initialization hooks are called in this order: +Three hooks fire in order: 1. `before_initialize` -2. `on_initialize` +2. `on_initialize`, the primary hook for handler registration, job scheduling, and startup logic 3. `after_initialize` -Use these to register event handlers, schedule jobs, or perform any startup logic. - ```python --8<-- "pages/core-concepts/apps/snippets/lifecycle_hooks.py" ``` !!! note - You do not need to call `super()` in these hooks as the base implementations are empty. + The base implementations of these hooks are empty. No `super()` call is necessary. ## Shutdown -When shutting down or reloading, Hassette transitions the app through `STOPPING → STOPPED`. +Hassette transitions the app through `STOPPING` to `STOPPED` during shutdown or reload. -The shutdown hooks are called in this order: +Three hooks fire in order: 1. `before_shutdown` 2. `on_shutdown` 3. `after_shutdown` -## Automatic Cleanup - -After the shutdown hooks run, Hassette automatically performs cleanup: +`on_shutdown` is for releasing external resources the app allocated directly: open files, raw sockets, or third-party connections. Bus subscriptions, scheduled jobs, and task bucket tasks are cleaned up automatically. -- Cancels all active subscriptions created by `self.bus`. -- Cancels all scheduled jobs created by `self.scheduler`. -- Cancels any background tasks tracked by the app. +## Automatic Cleanup -This means you do **not** need to manually unsubscribe or cancel jobs in `on_shutdown`. Only implement shutdown logic if you have allocated external resources (like opening a file or a raw socket). +After the shutdown hooks complete, Hassette cancels all bus subscriptions created via `self.bus`, all scheduled jobs created via `self.scheduler`, and all background tasks tracked by `self.task_bucket`. Manual unsubscription or job cancellation in `on_shutdown` is unnecessary. !!! warning - **Do not override** `initialize`, `shutdown`, or `cleanup` methods directly. These are internal methods that manage resource setup, lifecycle ordering, and teardown — they are marked final to prevent accidental overrides that could break the resource contract. + `initialize` and `shutdown` are marked `@final` on `Resource`. `cleanup` is marked `@final` on `App`. Attempting to override any of them raises [`CannotOverrideFinalError`][hassette.exceptions.CannotOverrideFinalError] at class load time. The `before_*`, `on_*`, and `after_*` hooks are the extension points. - Use the `on_initialize` and `on_shutdown` hooks instead — they are called at the correct point within these methods. Attempting to override a final method will raise a [`CannotOverrideFinalError`][hassette.exceptions.CannotOverrideFinalError] when your app class is loaded. +## Synchronous Lifecycle -## AppSync Lifecycle Hooks +??? note "AppSync lifecycle hooks" -If you use `AppSync` instead of `App`, use the `_sync` variants of each lifecycle hook: + `AppSync` provides `_sync`-suffixed variants of each hook. Hassette runs each variant in a thread pool via `task_bucket.run_in_thread`. The `_sync` hooks are synchronous and cannot use `await`. -| `App` (async) | `AppSync` (sync) | -|---|---| -| `on_initialize` | `on_initialize_sync` | -| `on_shutdown` | `on_shutdown_sync` | -| `before_initialize` | `before_initialize_sync` | -| `before_shutdown` | `before_shutdown_sync` | -| `after_initialize` | `after_initialize_sync` | -| `after_shutdown` | `after_shutdown_sync` | + | `App` (async) | `AppSync` (sync) | + |---|---| + | `before_initialize` | `before_initialize_sync` | + | `on_initialize` | `on_initialize_sync` | + | `after_initialize` | `after_initialize_sync` | + | `before_shutdown` | `before_shutdown_sync` | + | `on_shutdown` | `on_shutdown_sync` | + | `after_shutdown` | `after_shutdown_sync` | -`AppSync` runs each lifecycle hook in a thread pool via `run_in_thread`, so the hooks must be synchronous — they cannot use `await`. The async hooks (`on_initialize`, `on_shutdown`, etc.) are marked `@final` on `AppSync` and will raise `NotImplementedError` if you try to override them directly. + The async hooks (`on_initialize`, `on_shutdown`, etc.) are marked `@final` on `AppSync` and delegate to the `_sync` variants via the thread pool. Overriding them raises [`CannotOverrideFinalError`][hassette.exceptions.CannotOverrideFinalError] at class load time. -!!! warning "Use `on_initialize_sync`, not `on_initialize`, in AppSync" - In `AppSync`, overriding `on_initialize` will raise `NotImplementedError` at startup. Override `on_initialize_sync` instead. The bus, scheduler, and API are async — reach them through their `.sync` facades: + The bus, scheduler, and API are async. The `.sync` facades provide synchronous access from `_sync` hooks: `self.bus.sync`, `self.scheduler.sync`, and `self.api.sync`. ```python - class MyApp(AppSync): - def on_initialize_sync(self) -> None: - self.bus.sync.on_state_change("light.kitchen", handler=self.on_light_change, name="kitchen") - self.scheduler.sync.run_in(self.cleanup, 60, name="cleanup") + --8<-- "pages/core-concepts/apps/snippets/lifecycle_sync.py" + ``` - def on_light_change(self): - pass +## See Also - def cleanup(self): - pass - ``` +- [Apps overview](index.md) — app structure and configuration +- [Task Bucket](task-bucket.md) — background task lifecycle and shutdown behavior +- [Bus](../bus/index.md) — handler registration in `on_initialize` diff --git a/docs/pages/core-concepts/apps/snippets/apps_check_state.py b/docs/pages/core-concepts/apps/snippets/apps_check_state.py index 5b30ece8b..381585338 100644 --- a/docs/pages/core-concepts/apps/snippets/apps_check_state.py +++ b/docs/pages/core-concepts/apps/snippets/apps_check_state.py @@ -4,6 +4,6 @@ class CheckStateApp(App[AppConfig]): async def on_initialize(self): # --8<-- [start:check_state] - current_state = self.states.light["light.kitchen"].value + current_state = self.states.light["kitchen"].value self.logger.info("Current state: %s", current_state) # --8<-- [end:check_state] diff --git a/docs/pages/core-concepts/apps/snippets/lifecycle_hooks.py b/docs/pages/core-concepts/apps/snippets/lifecycle_hooks.py index 2fc309269..d91562a27 100644 --- a/docs/pages/core-concepts/apps/snippets/lifecycle_hooks.py +++ b/docs/pages/core-concepts/apps/snippets/lifecycle_hooks.py @@ -2,9 +2,12 @@ class MyApp(App): - async def on_initialize(self): - self.logger.info("App starting up!") + async def on_initialize(self) -> None: await self.bus.on_state_change("sensor.power", handler=self.on_power, name="power_sensor") + await self.scheduler.run_in(self.check_status, 30, name="startup_check") - async def on_power(self, event): + async def on_power(self, event) -> None: + pass + + async def check_status(self) -> None: pass diff --git a/docs/pages/core-concepts/apps/snippets/lifecycle_sync.py b/docs/pages/core-concepts/apps/snippets/lifecycle_sync.py new file mode 100644 index 000000000..604f12a57 --- /dev/null +++ b/docs/pages/core-concepts/apps/snippets/lifecycle_sync.py @@ -0,0 +1,13 @@ +from hassette import AppSync + + +class MyApp(AppSync): + def on_initialize_sync(self) -> None: + self.bus.sync.on_state_change("light.kitchen", handler=self.on_light_change, name="kitchen") + self.scheduler.sync.run_in(self.cleanup_task, 60, name="cleanup") + + def on_light_change(self) -> None: + pass + + def cleanup_task(self) -> None: + pass diff --git a/docs/pages/core-concepts/apps/task-bucket.md b/docs/pages/core-concepts/apps/task-bucket.md index 56a1a5b56..6401ece3b 100644 --- a/docs/pages/core-concepts/apps/task-bucket.md +++ b/docs/pages/core-concepts/apps/task-bucket.md @@ -1,70 +1,80 @@ # Task Bucket -`self.task_bucket` is each app's task manager — it tracks background work, offloads blocking calls to threads, and cleans everything up automatically when the app shuts down. +`self.task_bucket` runs background work and offloads blocking calls to threads. The bucket tracks all spawned tasks and cancels them on shutdown. ## Spawning Background Tasks -Use `spawn()` to fire off a coroutine that runs independently of the current handler. The bucket tracks the task and cancels it on shutdown — you don't need to store the handle yourself: +`spawn(coro, *, name=None)` creates a tracked background task from a coroutine. The bucket owns the task's lifecycle. The returned `asyncio.Task` is available for inspection or cancellation. ```python --8<-- "pages/core-concepts/apps/snippets/apps_task_bucket.py:spawn" ``` -`spawn()` returns the `asyncio.Task` if you need to check its status or cancel it manually. +The polling loop runs indefinitely without blocking the handler that started it. On shutdown, the bucket cancels it. ## Offloading Blocking Code -Use `run_in_thread()` to run a synchronous function in a thread pool without blocking the event loop. Await the result: +`run_in_thread(fn, *args, **kwargs)` runs a synchronous function in a thread pool. The event loop stays unblocked while the thread works. The return value is a coroutine that resolves to the function's result. ```python --8<-- "pages/core-concepts/apps/snippets/apps_task_bucket.py:run_in_thread" ``` -Use this for anything that blocks: HTTP clients without async support, database drivers, file I/O, CPU-bound computation. +`run_in_thread` suits HTTP clients without async support, database drivers, file I/O, and CPU-bound computation. ## Normalizing Sync/Async Callables -`make_async_adapter()` wraps any callable — sync or async — into a consistent async callable. Sync functions are automatically routed through `run_in_thread()`: +??? note "Advanced: make_async_adapter" -```python ---8<-- "pages/core-concepts/apps/snippets/apps_task_bucket_advanced.py:make_async_adapter" -``` + `make_async_adapter(fn)` wraps any callable, sync or async, into a consistent async callable. Sync functions route through `run_in_thread()` automatically. + + ```python + --8<-- "pages/core-concepts/apps/snippets/apps_task_bucket_advanced.py:make_async_adapter" + ``` -This is useful when your app accepts user-provided callbacks that could be either sync or async. + Apps that accept user-provided callbacks benefit from this. The adapter normalizes sync and async callables into one interface. ## Cross-Thread Communication -### Posting to the Event Loop +??? note "Advanced: cross-thread primitives" -`post_to_loop()` schedules a callable on the main event loop from any thread. Use this when code running in `run_in_thread()` needs to trigger an async action: + Four methods handle the narrow case where code in one thread needs to reach into another. Typical automations rarely need them. -```python ---8<-- "pages/core-concepts/apps/snippets/apps_task_bucket_advanced.py:post_to_loop" -``` + ### Posting to the Event Loop -### Running Async from Sync Code + `post_to_loop(fn, *args, **kwargs)` schedules a callable on the main event loop from any thread. The call is non-blocking. It queues the work and returns immediately. -`run_sync()` does the inverse — it runs an async coroutine from synchronous code by submitting it to the event loop and blocking until it completes: + ```python + --8<-- "pages/core-concepts/apps/snippets/apps_task_bucket_advanced.py:post_to_loop" + ``` -```python ---8<-- "pages/core-concepts/apps/snippets/apps_task_bucket_advanced.py:run_sync" -``` + ### Running Async from Sync Code + + `run_sync(coro)` submits a coroutine to the event loop and blocks the calling thread until it completes. It accepts a coroutine object, not a callable. + + ```python + --8<-- "pages/core-concepts/apps/snippets/apps_task_bucket_advanced.py:run_sync" + ``` + + !!! warning + `run_sync()` blocks the calling thread. Calling it from the event loop thread causes a deadlock. It is safe inside `run_in_thread()` callbacks and `AppSync` lifecycle methods only. + + ### Running on the Loop Thread + + `run_on_loop_thread(fn, *args, **kwargs)` runs a synchronous function on the main event loop thread. Loop-affine code that must not run in a worker thread belongs here. -!!! warning - `run_sync()` blocks the calling thread. Never call it from the event loop thread — it will deadlock. It's designed for use inside `run_in_thread()` callbacks or `AppSync` lifecycle methods where you need to make an async API call. + ### Creating Tasks from Any Context -## Shutdown Behavior + `create_task_on_loop(coro, *, name=None)` creates a task on the event loop from any thread context. The bucket tracks it like any other spawned task. -All tasks tracked by the bucket are cancelled when the app shuts down. Hassette: +## Shutdown -1. Cancels every pending task -2. Waits up to `task_cancellation_timeout_seconds` (configurable in [global settings](../configuration/global.md)) for them to finish -3. Logs any tasks that don't respond to cancellation +The bucket cancels all tracked tasks when the app shuts down. Hassette cancels every pending task, waits up to `task_cancellation_timeout_seconds` (configurable in [global settings](../../configuration/global.md)) for them to finish, and logs warnings for any tasks that do not exit within the timeout. -You don't need to clean up spawned tasks manually — the bucket handles it. +Manual cleanup is not required. ## See Also -- [Apps Overview](index.md) — core capabilities and common patterns -- [Lifecycle](lifecycle.md) — when shutdown happens and in what order -- [App Cache](../cache/index.md) — for persisting data across restarts (task bucket is for in-memory work) +- [Apps Overview](index.md) for core capabilities and common patterns +- [Lifecycle](lifecycle.md) for when shutdown happens and in what order +- [App Cache](../../cache/index.md) for persisting data across restarts (the task bucket is for in-memory work only) From dad7dec5ea476f15811c62f445237242afb742ef Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Tue, 2 Jun 2026 15:51:39 -0500 Subject: [PATCH 043/160] docs: rewrite Bus section (6 pages) Overview: subscription methods table, glob patterns, rate control. Handlers: four patterns simplest-first, non-state event tables (all 18 methods verified), error handling, registration mechanics. Fixed raw topic snippet to use Event[Any] instead of RawStateChangeEvent. Filtering: reordered by decision flow (changed_to -> conditions -> predicates -> service filtering -> raw topics). DI: canonical reference page, all 11 D.* types verified, added missing 'If missing' column to Other Extractors table. Custom Extractors: accessors first, then AnnotationDetails, corrected extractor/converter type signatures. Predicate Reference: all 60 symbols verified against source, tables for P (20), C (17), A (23). --- .../core-concepts/bus/custom-extractors.md | 75 +++++- .../core-concepts/bus/dependency-injection.md | 48 ++-- docs/pages/core-concepts/bus/filtering.md | 249 +++++++----------- docs/pages/core-concepts/bus/handlers.md | 220 +++++++++------- docs/pages/core-concepts/bus/index.md | 18 +- .../core-concepts/bus/predicate-reference.md | 210 ++++++++++++++- .../handlers/non_state_call_service.py | 14 + .../snippets/handlers/non_state_internal.py | 19 ++ .../snippets/handlers/non_state_raw_topic.py | 16 ++ 9 files changed, 574 insertions(+), 295 deletions(-) create mode 100644 docs/pages/core-concepts/bus/snippets/handlers/non_state_call_service.py create mode 100644 docs/pages/core-concepts/bus/snippets/handlers/non_state_internal.py create mode 100644 docs/pages/core-concepts/bus/snippets/handlers/non_state_raw_topic.py diff --git a/docs/pages/core-concepts/bus/custom-extractors.md b/docs/pages/core-concepts/bus/custom-extractors.md index f9501eb06..d27b7bbbf 100644 --- a/docs/pages/core-concepts/bus/custom-extractors.md +++ b/docs/pages/core-concepts/bus/custom-extractors.md @@ -1,3 +1,76 @@ # Custom Extractors -*This page is being rewritten as part of the documentation overhaul.* +The built-in [`D.*`](dependency-injection.md) annotations cover state values, entity IDs, domains, event data, and event context. Custom extractors handle everything else: a specific key from `service_data`, a nested attribute, or a value computed from multiple event fields. + +## Accessors (`A`) + +[`A`][hassette.event_handling.accessors] (`from hassette import A`) provides accessor functions that target non-standard event fields. Accessors are the simplest form of custom extraction. They work directly as `Annotated` type metadata, with no additional wrapping. + +`A.get_attr_new("brightness")` returns a callable that extracts `brightness` from the new state's attributes. `A.get_service_data_key("entity_id")` extracts a key from `service_data` on a service call event. `A.get_path("payload.data.new_state.attributes.geolocation.locality")` traverses a dotted path. It returns `MISSING_VALUE` if any segment is absent. + +```python +--8<-- "pages/core-concepts/bus/snippets/filtering/custom_accessors.py" +``` + +Accessors also compose with predicates. `P.ValueIs(source=A.get_service_data_key("entity_id"), condition="light.living_room")` filters a service call subscription to a specific target entity without any handler logic. The full predicate reference is in [Filtering](filtering/index.md). + +## Writing an Extractor + +A custom extractor is a plain callable that receives the raw event and returns a value. `AnnotationDetails` wraps that callable and registers it with the DI system. + +`AnnotationDetails` is a frozen dataclass with two fields: + +| Field | Type | Required | Purpose | +|---|---|---|---| +| `extractor` | `Callable[[T], Any]` | Yes | Extracts the value from the event | +| `converter` | `Callable[[Any, Any], Any] \| None` | No | Converts the extracted value to the declared type | + +Placing an `AnnotationDetails` instance inside `Annotated[T, AnnotationDetails(...)]` completes the setup. `extract_from_signature` in `hassette.bus.extraction` scans handler parameters at registration time. It finds `Annotated` types carrying `AnnotationDetails` and builds the resolution plan automatically. + +```python +--8<-- "pages/core-concepts/bus/snippets/dependency-injection/custom_extractor_own.py" +``` + +`get_friendly_name` receives the raw `RawStateChangeEvent` and returns a string. The `Annotated[str, get_friendly_name]` annotation tells the DI system to call that function for `name` on each invocation. A plain callable in the `Annotated` metadata position is shorthand. `extract_from_annotated` wraps it in `AnnotationDetails` automatically. + +## How Built-In Extractors Work + +??? note "Internals: how `D.StateNew` is defined" + + Every built-in annotation in `D` is an `Annotated` type alias carrying an `AnnotationDetails` instance. `D.StateNew` is defined as: + + ```python + StateNew: TypeAlias = Annotated[ + StateT, + AnnotationDetails["RawStateChangeEvent"](ensure_present(A.get_state_object_new)), + ] + ``` + + `A.get_state_object_new` is an accessor that reads `event.payload.data.new_state` and converts it via the State Registry. `ensure_present` wraps it to raise `DependencyResolutionError` if the value is missing. A missing value skips the handler rather than passing `None`. The `Annotated` wrapper is what `extract_from_annotated` looks for when scanning the handler signature. + + The `A.get_attr_new` pattern used in custom extractors follows the same structure: + + ```python + --8<-- "pages/core-concepts/bus/snippets/dependency-injection/custom_extractor_builtin.py" + ``` + + `extract_from_annotated` accepts either a bare callable or a full `AnnotationDetails` instance in the `Annotated` metadata position. Both produce the same resolution behavior. The bare callable form is a convenience shorthand. + +## Adding Type Conversion + +`AnnotationDetails.converter` accepts a function with the signature `(value: Any, to_type: type) -> Any`. The DI system calls it after extraction to convert the raw value to the declared type. + +```python +--8<-- "pages/core-concepts/bus/snippets/dependency-injection/custom_extractor_converter.py" +``` + +`extract_timestamp` returns an ISO string. `convert_to_datetime` converts that string to a `datetime`. The `LastChanged` type alias bundles both into a reusable annotation. Any handler parameter typed as `LastChanged` receives a `datetime` with no inline parsing. + +The [Type Registry](../states/type-registry.md) provides built-in converters for standard scalar types. `AnnotationDetails.converter` handles conversions specific to a single extractor. It covers types the registry does not handle, or conversions that need context from the extractor itself. + +## See Also + +- [Dependency Injection](dependency-injection.md): built-in `D.*` annotations +- [Filtering](filtering/index.md): composing accessors with predicates +- [Type Registry](../states/type-registry.md): built-in type converters and how to register custom ones +- [State Registry](../states/state-registry.md): domain-to-model mapping diff --git a/docs/pages/core-concepts/bus/dependency-injection.md b/docs/pages/core-concepts/bus/dependency-injection.md index 4a2b741a3..8f1b70259 100644 --- a/docs/pages/core-concepts/bus/dependency-injection.md +++ b/docs/pages/core-concepts/bus/dependency-injection.md @@ -1,6 +1,6 @@ # Dependency Injection -Hassette's dependency injection system extracts typed data from events and passes it directly to handler parameters. Handlers declare what they need via type annotations; Hassette resolves the rest. +Hassette's dependency injection system extracts typed data from events and passes it to handler parameters. Handlers declare what they need via type annotations; Hassette resolves the values before each invocation. ```python --8<-- "pages/core-concepts/bus/snippets/dependency-injection/quick_example.py" @@ -8,18 +8,18 @@ Hassette's dependency injection system extracts typed data from events and passe `D.StateNew[states.LightState]` extracts the new state and converts it to a typed `LightState`. `D.EntityId` extracts the entity ID as a string. The handler receives clean data with no event parsing. -All annotations live in `hassette.dependencies`, available as `D` from the top-level import: `from hassette import D`. +All annotations live in `hassette.dependencies`, imported as `D`: `from hassette import D`. ## Annotation Reference ### State Extractors -Extract typed state objects from state change events. `T` is any state class from `hassette.models.states`. +State extractors resolve typed state objects from state change events. `T` is any state class from `hassette.models.states`. | Annotation | Returns | If missing | |---|---|---| -| `D.StateNew[T]` | `T` | Handler not called | -| `D.StateOld[T]` | `T` | Handler not called | +| `D.StateNew[T]` | `T` | Handler skipped | +| `D.StateOld[T]` | `T` | Handler skipped | | `D.MaybeStateNew[T]` | `T \| None` | `None` | | `D.MaybeStateOld[T]` | `T \| None` | `None` | @@ -27,34 +27,34 @@ Extract typed state objects from state change events. `T` is any state class fro --8<-- "pages/core-concepts/bus/snippets/dependency-injection/state_object_extractors.py" ``` -When a required state is missing, Hassette skips the handler invocation — the exception is caught during resolution, not inside the handler. `MaybeStateOld` is useful for the first event after an entity appears, where there is no previous state. +When a required extractor finds no value, Hassette skips the handler invocation entirely. `MaybeStateOld` returns `None` on the first event for a new entity with no previous state. ### Identity Extractors -Extract entity IDs and domains from events. +Identity extractors resolve entity IDs and domains from events. | Annotation | Returns | If missing | |---|---|---| -| `D.EntityId` | `str` | Handler not called | +| `D.EntityId` | `str` | Handler skipped | | `D.MaybeEntityId` | `str \| MISSING_VALUE` | Falsy sentinel | -| `D.Domain` | `str` | Handler not called | +| `D.Domain` | `str` | Handler skipped | | `D.MaybeDomain` | `str \| MISSING_VALUE` | Falsy sentinel | ```python --8<-- "pages/core-concepts/bus/snippets/dependency-injection/identity_extractors.py" ``` -`MISSING_VALUE` is a falsy sentinel. Test with `if entity_id:` rather than `isinstance` checks. +`MISSING_VALUE` is a falsy sentinel. Testing with `if entity_id:` covers both the present and absent cases. ### Other Extractors -| Annotation | Returns | Use case | -|---|---|---| -| `D.EventData[T]` | `T` | Typed payload from [`Bus.emit`](../apps/index.md#broadcasting-events-between-apps) broadcast events | -| `D.EventContext` | `HassContext` | Home Assistant event context (user ID, parent/origin IDs) | -| `D.TypedStateChangeEvent[T]` | `TypedStateChangeEvent[T]` | Full event object with typed states | +| Annotation | Returns | If missing | Use case | +|---|---|---|---| +| `D.EventData[T]` | `T` | Handler skipped | Typed payload from `Bus.emit` broadcast events | +| `D.EventContext` | `HassContext` | `None` | Home Assistant event context (user ID, parent/origin IDs) | +| `D.TypedStateChangeEvent[T]` | `TypedStateChangeEvent[T]` | Always present | Full event with both old and new states typed | -`EventData[T]` pairs with `Bus.emit`. The sender emits a dataclass; the receiver declares the type: +`D.EventData[T]` pairs with [`Bus.emit`](../apps/index.md#broadcasting-events-between-apps). The emitting app sends a dataclass; the receiving handler annotates its parameter with the same type: ```python --8<-- "pages/core-concepts/bus/snippets/dependency-injection/event_data_extractor.py" @@ -62,7 +62,7 @@ Extract entity IDs and domains from events. ## Combining Annotations -Handlers accept multiple DI parameters. Hassette resolves each independently. +Handlers accept multiple DI parameters. Hassette resolves each independently from the same event. ```python --8<-- "pages/core-concepts/bus/snippets/dependency-injection/multiple_dependencies.py" @@ -76,11 +76,11 @@ State extractors accept union types for handlers that cover multiple entity doma --8<-- "pages/core-concepts/bus/snippets/dependency-injection/union_types.py" ``` -The [State Registry](../states/state-registry.md) determines the correct state class based on the entity's domain. +The [State Registry](../states/state-registry.md) determines the concrete state class from the entity's domain at dispatch time. ## Custom Keyword Arguments -DI composes with custom `kwargs` passed at registration time. DI-annotated parameters are resolved from the event; remaining keyword arguments pass through unchanged. +DI composes with `kwargs=` passed at registration. DI-annotated parameters resolve from the event; remaining keyword arguments pass through unchanged from the registration call. ```python --8<-- "pages/core-concepts/bus/snippets/dependency-injection/mixing_kwargs.py" @@ -88,11 +88,11 @@ DI composes with custom `kwargs` passed at registration time. DI-annotated param ## Handler Signature Restrictions -DI handlers cannot use positional-only parameters (before `/`) or `*args`. Regular parameters and `**kwargs` work fine. All DI parameters require type annotations — Hassette uses the annotation to determine what to extract. +DI handlers do not support positional-only parameters (those before `/`) or `*args`. Regular parameters and `**kwargs` are both valid. Every DI parameter requires a type annotation. Hassette uses the annotation to determine what to extract. ## See Also -- [Custom Extractors](custom-extractors.md) — writing custom extractors, accessors, `AnnotationDetails`, and automatic type conversion -- [Writing Handlers](handlers.md) — raw event and typed event patterns, handler error behavior -- [State Registry](../states/state-registry.md) — domain-to-model mapping -- [Type Registry](../states/type-registry.md) — automatic type conversion +- [Custom Extractors](custom-extractors.md). Writing extractors, accessors, `AnnotationDetails`, and automatic type conversion. +- [Writing Handlers](handlers.md). Raw event and typed event patterns, handler error behavior. +- [State Registry](../states/state-registry.md). Domain-to-model mapping. +- [Type Registry](../states/type-registry.md). Automatic type conversion. diff --git a/docs/pages/core-concepts/bus/filtering.md b/docs/pages/core-concepts/bus/filtering.md index d99532b74..f09b21c0f 100644 --- a/docs/pages/core-concepts/bus/filtering.md +++ b/docs/pages/core-concepts/bus/filtering.md @@ -1,255 +1,184 @@ -# Filtering & Advanced Subscriptions +# Filtering & Predicates -Hassette lets you filter events so your handlers only run when the data actually matters. +Three filtering layers narrow which events reach a handler. Built-in parameters (`changed_to`, `changed_from`, `changed`) cover the common cases directly on `on_state_change` and `on_attribute_change`. Conditions (`C`) express value-level tests: set membership, numeric comparisons, string patterns. Predicates (`P`) handle anything more complex, composing with `where=` on any subscription method. -The filtering system uses three helper modules, imported by alias: +All three are importable from `hassette`. -- **`P`** (Predicates) — event matching logic: `from hassette import P` -- **`C`** (Conditions) — value comparison helpers: `from hassette import C` -- **`A`** (Accessors) — field extraction helpers used with `P.ValueIs` for custom sources: `from hassette import A` - -`P` and `C` cover most filtering needs. `A` is an advanced tool for cases where you need to point a predicate at a non-standard field — for example, extracting a specific key from service data or a deeply nested attribute value. See [Custom Accessors](#custom-accessors-with-a) below. - -## Common State Filtering +```python +from hassette import P, C, A +``` -For `on_state_change`, the most common way to filter is using the `changed_to`, `changed_from`, or `changed` parameters. These allow you to filter based on the new state value, old state value, or general criteria. +## Filtering State Changes -### Simple Value Matching +`on_state_change` accepts three built-in parameters that handle the majority of state-change filtering without predicates. -Pass concrete values to match exact states: +### `changed_to` -Match when a state becomes a specific value with `changed_to`: +`changed_to` restricts the handler to events where the new state matches a value. ```python --8<-- "pages/core-concepts/bus/snippets/filtering_simple_start.py" ``` -Match when a state leaves a specific value with `changed_from`: +The handler fires only when `binary_sensor.front_door` transitions to `"on"`. Transitions from `"on"` to anything else are ignored. + +### `changed_from` + +`changed_from` restricts the handler to events where the previous state matches a value. ```python --8<-- "pages/core-concepts/bus/snippets/filtering_simple_stop.py" ``` -### Predicate Matching +The handler fires only when the sensor leaves `"on"`, meaning when motion stops. -For more logic, you can pass **Predicates** or callables directly to these parameters. This is the recommended way to handle most state change logic. +### `changed=False` -Match against a set of values with `C.IsIn`: +By default, `on_state_change` fires only when the main state value changes. `changed=False` removes that restriction, allowing the handler to fire on attribute-only changes too. ```python ---8<-- "pages/core-concepts/bus/snippets/filtering_predicate_isin.py" +--8<-- "pages/core-concepts/bus/snippets/filtering/changed_false.py:changed_false" ``` -Use a comparison condition with `C.Comparison`: +A light remaining `"on"` while its brightness shifts from 128 to 255 would normally produce no event. With `changed=False`, that attribute update reaches the handler. -```python ---8<-- "pages/core-concepts/bus/snippets/filtering_predicate_lambda.py" -``` +## Conditions + +Conditions are value-level matchers. They work as arguments to `changed_to`, `changed_from`, and `changed`, or as the `condition` argument inside predicates. -## The `changed` Parameter +### Set membership: `C.IsIn` -By default, `on_state_change` only fires when the main state value changes. To also fire on attribute-only changes (e.g., brightness changed but the light is still "on"), pass `changed=False`: +`C.IsIn` fires when the value appears in a collection. ```python ---8<-- "pages/core-concepts/bus/snippets/filtering/changed_false.py:changed_false" +--8<-- "pages/core-concepts/bus/snippets/filtering_predicate_isin.py" ``` -## Advanced Filtering with `where` +The handler runs only when `app_name` becomes `"Home Assistant Lovelace"` or `"Netflix"`. -If `changed_to/from` aren't enough, or if you are filtering other event types (like service calls), use the `where` parameter. +### Numeric comparison: `C.Comparison` -`where` accepts a list of predicates (logical AND) or a customized predicate structure. +`C.Comparison` tests a value against a threshold using an operator string. -### Combining Predicates - -Multiple predicates in a list are treated as logical **AND**. -Use `P.AnyOf` for logical **OR**. - -Logical AND: ```python ---8<-- "pages/core-concepts/bus/snippets/filtering_combined_and.py" +--8<-- "pages/core-concepts/bus/snippets/filtering_predicate_lambda.py" ``` -Logical OR (Using `P.AnyOf`): +Supported operators: `">"`, `"<"`, `">="`, `"<="`, `"=="`, `"!="`, and their named forms (`"gt"`, `"lt"`, `"gte"`, `"lte"`, `"eq"`, `"ne"`). + +### Numeric direction: `C.Increased` and `C.Decreased` + +`C.Increased` and `C.Decreased` fire when a numeric value moves in a particular direction between events. Both work as arguments to `changed=` on `on_state_change` or `on_attribute_change`, and inside `P.StateComparison` / `P.AttrComparison` for attribute-level direction tests. + ```python ---8<-- "pages/core-concepts/bus/snippets/filtering_combined_or.py" +--8<-- "pages/core-concepts/bus/snippets/filtering_increased_decreased.py" ``` -## Filtering Service Calls +The full conditions table, including string matching, collection membership, and null checks, is on the [Predicate Reference](predicate-reference.md) page. -`on_call_service` supports both specific dictionary matching and predicate-based filtering using `where`. +## Predicates and the `where` Parameter -### Dictionary Filtering +When built-in parameters are not enough, `where=` accepts a predicate or a list of predicates. A list is treated as logical AND, so all predicates must match. -A simple dict passed to `where` matches keys and values in the service data. +### `P.StateFrom` and `P.StateTo` -Literal match — all keys and values must match exactly: +`P.StateFrom` tests the previous state. `P.StateTo` tests the new state. Both accept any value, callable, or condition object. They take the same types as `changed_from` and `changed_to`, but are usable inside `where=` for composition. ```python ---8<-- "pages/core-concepts/bus/snippets/filtering_service_literal.py" +--8<-- "pages/core-concepts/bus/snippets/filtering_state_from_to.py" ``` -Key presence — the key must exist, value doesn't matter: +The handler fires only when the light moves from an off-like state into `"on"`. Both predicates must match. -```python ---8<-- "pages/core-concepts/bus/snippets/filtering_service_presence.py" -``` +### Logical AND -Callable values — custom check per key: +A list passed to `where=` applies all predicates as logical AND. ```python ---8<-- "pages/core-concepts/bus/snippets/filtering_service_callable.py" +--8<-- "pages/core-concepts/bus/snippets/filtering_combined_and.py" ``` -### Predicate Filtering +Both `P.AttrTo` predicates must match. The `changed=False` parameter is also required here, since only attributes change, not the main state value. -Use `P.ServiceDataWhere` for structured access to service data fields: +### Logical OR: `P.AnyOf` + +`P.AnyOf` fires when at least one contained predicate matches. ```python ---8<-- "pages/core-concepts/bus/snippets/filtering_service_predicates.py" +--8<-- "pages/core-concepts/bus/snippets/filtering_combined_or.py" ``` -## Advanced Topic Subscriptions - -For scenarios not covered by helper methods, you can subscribe loosely to any event topic using `on`. This method always uses `where` for filtering. +`P.AllOf` and `P.AnyOf` compose freely. `P.Not` negates any predicate. -```python ---8<-- "pages/core-concepts/bus/snippets/filtering_advanced_topics.py" -``` +## Filtering Service Calls -See the [Complete Reference](#complete-reference) below for the full list. +`on_call_service` accepts `domain=` and `service=` for coarse filtering, and `where=` for fine-grained control. `where=` on `on_call_service` also accepts a plain dict, which matches against the service data payload. -## More Filtering Patterns +### Dict filtering -### Tracking State Transitions with `P.StateFrom` / `P.StateTo` +A dict passed to `where=` matches keys and values in the service data. -`P.StateFrom` and `P.StateTo` are the recommended way to filter on the previous or next state value inside a `where` clause. They accept any value, callable, or condition object — the same types accepted by `changed_from` and `changed_to`. +**Literal match.** Every key-value pair must match exactly. ```python ---8<-- "pages/core-concepts/bus/snippets/filtering_state_from_to.py" +--8<-- "pages/core-concepts/bus/snippets/filtering_service_literal.py" ``` -### Monitoring Numeric Changes with `C.Increased` / `C.Decreased` - -`C.Increased` and `C.Decreased` are comparison conditions that fire when a numeric value goes up or down between events. Pass them to the `changed` parameter on `on_state_change` or `on_attribute_change`, or use them inside `P.StateComparison` / `P.AttrComparison` for more control. +**Key presence.** The key must exist, but value does not matter (`ANY_VALUE`). ```python ---8<-- "pages/core-concepts/bus/snippets/filtering_increased_decreased.py" +--8<-- "pages/core-concepts/bus/snippets/filtering_service_presence.py" ``` -### Matching Service Names with `P.ServiceMatches` - -`P.ServiceMatches` filters raw `call_service` events by their service name (e.g. `"light.turn_on"`). Pair it with `P.ServiceDataWhere` to filter on both name and payload. +**Callable per key.** A function receives the value and returns a bool. ```python ---8<-- "pages/core-concepts/bus/snippets/filtering_service_matches.py" +--8<-- "pages/core-concepts/bus/snippets/filtering_service_callable.py" ``` -## Custom Accessors with `A` +### `P.ServiceDataWhere` -Most filtering uses `P` and `C` directly. `A` is useful when you need `P.ValueIs` to read from a field that isn't covered by the higher-level helpers — for instance, filtering a service call by a specific key inside `service_data`, or checking a deeply nested attribute. +`P.ServiceDataWhere` provides structured access to service data fields. `P.ServiceDataWhere.from_kwargs` accepts field names as keyword arguments. ```python ---8<-- "pages/core-concepts/bus/snippets/filtering/custom_accessors.py" +--8<-- "pages/core-concepts/bus/snippets/filtering_service_predicates.py" ``` -You generally won't need `A` unless you're filtering on data that `on_state_change`/`on_call_service` don't expose directly. - -## Complete Reference - -Quick lookup tables for every built-in predicate and condition. Import both from `hassette`: `from hassette import P, C`. - -### Predicates (`P`) - -Predicates are the top-level filters passed to `where`. They receive a full event object. +### `P.ServiceMatches` -#### Logic combinators +`P.ServiceMatches` filters on the service name (e.g., `"scene.turn_on"`). It works on raw `call_service` events via `on()`, where `domain=` and `service=` are not available. -| Class | Description | Works with | -|---|---|---| -| `P.AllOf(predicates)` | True when all contained predicates match (AND). A list passed to `where` is automatically wrapped in `AllOf`. | Any event | -| `P.AnyOf(predicates)` | True when at least one contained predicate matches (OR). | Any event | -| `P.Not(predicate)` | Negates a predicate. | Any event | -| `P.Guard(fn)` | Wraps any plain callable so it can be used in `AllOf`/`AnyOf` combinators. | Any event | - -#### Value / field matching - -| Class | Description | Works with | -|---|---|---| -| `P.ValueIs(source, condition)` | Extracts a value with `source` (an accessor from `A`) and tests it against `condition`. | Any event | -| `P.DidChange(source)` | True when the two values returned by `source(event)` differ (expects a `(old, new)` tuple). | Any event | -| `P.IsPresent(source)` | True when the value extracted by `source` is not `MISSING_VALUE`. | Any event | -| `P.IsMissing(source)` | True when the value extracted by `source` is `MISSING_VALUE`. | Any event | - -#### Entity / domain / service matching - -| Class | Description | Works with | -|---|---|---| -| `P.DomainMatches(domain)` | Matches when the event domain equals `domain`. Supports glob patterns. | `HassEvent` | -| `P.EntityMatches(entity_id)` | Matches when the event entity_id equals `entity_id`. Supports glob patterns. | `HassEvent` | -| `P.ServiceMatches(service)` | Matches when the service name equals `service` (e.g. `"light.turn_on"`). Supports globs. | `call_service` events | -| `P.ServiceDataWhere(spec)` | Matches when all keys in `spec` satisfy their conditions against the service data payload. | `CallServiceEvent` | - -#### State change predicates - -These are typed for `RawStateChangeEvent` and are only valid on `on_state_change` subscriptions (or raw `call_service` events via `on`). - -| Class | Description | Works with | -|---|---|---| -| `P.StateFrom(condition)` | True when the *previous* state satisfies `condition`. Equivalent to `changed_from=condition` but usable in `where`. | `RawStateChangeEvent` | -| `P.StateTo(condition)` | True when the *new* state satisfies `condition`. Equivalent to `changed_to=condition` but usable in `where`. | `RawStateChangeEvent` | -| `P.StateComparison(condition)` | Passes `(old_state, new_state)` to a comparison condition such as `C.Increased()` or `C.Decreased()`. | `RawStateChangeEvent` | -| `P.StateDidChange()` | True when the main state value changed between events. | `RawStateChangeEvent` | -| `P.AttrFrom(attr_name, condition)` | True when the *previous* value of an attribute satisfies `condition`. | `RawStateChangeEvent` | -| `P.AttrTo(attr_name, condition)` | True when the *new* value of an attribute satisfies `condition`. | `RawStateChangeEvent` | -| `P.AttrComparison(attr_name, condition)` | Passes `(old_attr, new_attr)` to a comparison condition. | `RawStateChangeEvent` | -| `P.AttrDidChange(attr_name)` | True when the named attribute changed between events. | `RawStateChangeEvent` | - ---- +```python +--8<-- "pages/core-concepts/bus/snippets/filtering_service_matches.py" +``` -### Conditions (`C`) +## Raw Topic Subscriptions -Conditions are value-level matchers. Pass them to `changed_to`, `changed_from`, `changed`, or as the `condition` argument to predicates like `P.ValueIs`. +`on()` subscribes to any event topic by string. It accepts `where=` predicates but has no built-in `changed_to` / `changed_from` parameters. All filtering goes through predicates. -#### String matching +```python +--8<-- "pages/core-concepts/bus/snippets/filtering_advanced_topics.py" +``` -| Class | Description | -|---|---| -| `C.Glob(pattern)` | Matches if the value matches a glob pattern (e.g. `"light.*"`). | -| `C.StartsWith(prefix)` | Matches if the string value starts with `prefix`. | -| `C.EndsWith(suffix)` | Matches if the string value ends with `suffix`. | -| `C.Contains(substring)` | Matches if the string value contains `substring`. | -| `C.Regex(pattern)` | Matches if the string value matches a regex pattern (anchored at start). | +`on()` covers event types that the typed helper methods do not expose, including custom internal events, raw Home Assistant events, and any future topic the framework adds. -#### Collection membership +## Custom Accessors -| Class | Description | -|---|---| -| `C.IsIn(collection)` | True when the value appears in `collection`. | -| `C.NotIn(collection)` | True when the value does not appear in `collection`. | -| `C.Intersects(collection)` | True when the value (a sequence) shares at least one item with `collection`. | -| `C.NotIntersects(collection)` | True when the value (a sequence) shares no items with `collection`. | -| `C.IsOrContains(item)` | True when the value equals `item`, or when the value is a sequence that contains `item`. | +[`A`](accessors.md) (accessors) point predicates at fields not directly exposed by the helper methods. `P.ValueIs` extracts a value with an accessor, then tests it against a condition. -#### None / missing checks +```python +--8<-- "pages/core-concepts/bus/snippets/filtering/custom_accessors.py" +``` -| Class | Description | -|---|---| -| `C.IsNone()` | True when the value is `None`. | -| `C.IsNotNone()` | True when the value is not `None`. | -| `C.Present()` | True when the value is not the internal `MISSING_VALUE` sentinel. Used for presence checks in state diffs. | -| `C.Missing()` | True when the value is the internal `MISSING_VALUE` sentinel. | +`A.get_service_data_key` extracts a specific key from service data. `A.get_path` follows a dot-separated path through the event payload. Most filtering needs are met by `changed_to`, `changed_from`, and the typed predicates. `A` handles the cases they do not reach. The full accessor guide is at [Custom Extractors](custom-extractors.md). -#### Numeric comparison +## Full Reference -| Class | Description | -|---|---| -| `C.Comparison(op, value)` | Compares a single value using an operator string (`">"`, `"<"`, `">="`, `"<="`, `"=="`, `"!="` or their named forms). | -| `C.Increased()` | Comparison condition: passes `(old, new)` — true when the numeric value increased. Use with `changed=`, `P.StateComparison`, or `P.AttrComparison`. | -| `C.Decreased()` | Comparison condition: passes `(old, new)` — true when the numeric value decreased. Use with `changed=`, `P.StateComparison`, or `P.AttrComparison`. | +The complete `P`, `C`, and `A` lookup tables live on [Predicate Reference](predicate-reference.md). ## See Also -- [Writing Handlers](handlers.md) - Extract data with dependency injection -- [States](../states/index.md) - Access current state in predicates -- [Scheduler](../scheduler/index.md) - Combine event-driven and time-based automation +- [Writing Handlers](handlers.md). Typed data extraction from events with dependency injection. +- [Dependency Injection](dependency-injection.md). How `D.*` annotations work alongside predicates. +- [Predicate Reference](predicate-reference.md). Complete `P`, `C`, and `A` tables. +- [Custom Extractors](custom-extractors.md). Accessors for non-standard fields. diff --git a/docs/pages/core-concepts/bus/handlers.md b/docs/pages/core-concepts/bus/handlers.md index 26a593d1d..c74bae5d3 100644 --- a/docs/pages/core-concepts/bus/handlers.md +++ b/docs/pages/core-concepts/bus/handlers.md @@ -1,177 +1,194 @@ # Writing Event Handlers -Once you've subscribed to an event, you need a handler to process it. Hassette supports dependency injection (DI), custom keyword arguments, and various event patterns — so your handlers can be as simple or detailed as you need. +A handler is an async method that runs when an event matches a subscription. Hassette +supports four handler patterns, from no parameters to fully typed dependency injection. +Each subscription also accepts operational controls for errors, timeouts, and registration. -## Event Model +## Handler Patterns -Every event you receive from the bus is an [`Event`][hassette.events.base.Event] dataclass with two main fields: +### No data needed -- `topic` - a string identifier describing what happened, such as `hass.event.state_changed`. -- `payload` - an untyped object containing event-specific data. +A handler with no parameters runs as a side effect. No event data is extracted or passed. -!!! question "Why is the payload untyped?" - - You may be wondering why the event payload is untyped if Hassette is focused on strong typing. The reason for this is to avoid the overhead of converting every - event payload to a typed object when the majority of payloads will never be used. - - Instead of converting *every* event payload, Hassette converts at the user boundary, such as when using Dependency Injection (DI) or - accessing states through [DomainStates][hassette.state_manager.state_manager.DomainStates] (e.g. `self.states.light`). - - -## Dependency Injection - -Hassette uses dependency injection (DI) to provide event data to your handlers. The type annotations on your handler parameters tell Hassette what data to extract from the event. +```python +--8<-- "pages/core-concepts/bus/snippets/handlers_no_data.py" +``` -### Basic Patterns +### Raw event -**Option 1: Receive the full event in raw form** (simplest): -This gives you the raw event object, with the state data in untyped dicts. The raw state dict mirrors Home Assistant's `state_changed` event — the main state value is in `new_state["state"]`; attributes are in `new_state["attributes"]`. +A handler annotated with `RawStateChangeEvent` receives the untyped event object directly. +The state value lives at `event.payload.data.new_state.get("state")`. ```python --8<-- "pages/core-concepts/bus/snippets/handlers_raw_event.py" ``` -**Option 2: Receive full event with typed state objects** (better): -This gives you typed state objects for easier access to attributes. +This pattern suits exploratory work or event types that [dependency injection](dependency-injection.md) +doesn't cover. -```python ---8<-- "pages/core-concepts/bus/snippets/handlers_typed_event.py" -``` +### Typed state event -**Option 3: Extract specific data** (recommended for production code — if you're new to Hassette, start with Option 1 or 2): +`D.TypedStateChangeEvent[T]` wraps the same event with typed state objects. The new and +old state values are accessible as attributes instead of raw dicts. ```python ---8<-- "pages/core-concepts/bus/snippets/handlers_extract_data.py" +--8<-- "pages/core-concepts/bus/snippets/handlers_typed_event.py" ``` -**Option 4: No event data needed**: - -```python ---8<-- "pages/core-concepts/bus/snippets/handlers_no_data.py" -``` +`D` is an alias for `hassette.dependencies`. `states` is `hassette.models.states`, +which contains typed state classes for each Home Assistant domain. See the +[Dependency Injection](dependency-injection.md) page for the full import reference. -### Passing Custom Arguments +### Extracted data (recommended) -You can pass additional arguments to your handler using `kwargs` when subscribing. These are injected alongside event dependencies. +[`D` annotations](dependency-injection.md) tell Hassette which fields to extract from the +event and pass as individual parameters. No event object is received; only the requested +data arrives. ```python ---8<-- "pages/core-concepts/bus/snippets/handlers_custom_args.py" +--8<-- "pages/core-concepts/bus/snippets/handlers_extract_data.py" ``` -### Available Dependencies +`D.StateNew[T]` delivers the new state converted to type `T`. `D.EntityId` delivers the +entity ID string. The [Dependency Injection](dependency-injection.md) page covers the +full annotation table, union types, and custom extractors. -Dependencies are available via `from hassette import D`. The most common are `StateNew[T]`, `StateOld[T]`, `EntityId`, and `Domain`. +## Non-State Event Types -See the [Dependency Injection guide](dependency-injection.md#available-di-annotations) for the full annotation table, custom extractors, and automatic type conversion. +The bus subscribes to more than state changes. Each method below returns a `Subscription`, +a handle that cancels the listener when called. All accept the same `name=`, `on_error=`, +`timeout=`, `debounce=`, and `throttle=` options as `on_state_change`. -### Restrictions +### Home Assistant events -!!! warning "Handler Signature Rules" - Handlers **cannot** use: +| Method | Fires when | +|---|---| +| `on_state_change(entity)` | An entity's state string changes | +| `on_attribute_change(entity, attr)` | A specific entity attribute changes | +| `on_call_service(domain, service)` | A HA service is called | +| `on_component_loaded(component)` | A HA component finishes loading | +| `on_service_registered(domain, service)` | A new HA service is registered | +| `on_homeassistant_start()` | HA starts up | +| `on_homeassistant_stop()` | HA begins shutting down | +| `on_homeassistant_restart()` | HA restarts | +| `on(topic=...)` | Any raw HA event topic | - - Positional-only parameters (parameters before `/`) - - Variadic positional arguments (`*args`) +An `on_call_service` handler receives the service call's entity ID via `D.EntityId`: - These restrictions ensure unambiguous parameter injection. - -## Combining Multiple Dependencies +```python +--8<-- "pages/core-concepts/bus/snippets/handlers/non_state_call_service.py" +``` -You can extract multiple pieces of data in a single handler: +`on()` subscribes to any raw topic string. The handler receives the full event. ```python ---8<-- "pages/core-concepts/bus/snippets/handlers_multiple_dependencies.py" +--8<-- "pages/core-concepts/bus/snippets/handlers/non_state_raw_topic.py" ``` -## Error Handling +### Cross-app communication + +`emit(topic, data)` broadcasts a payload to all subscribers of a custom topic. Other +apps subscribe to the same topic with `on()`. Handlers annotated with `D.EventData[T]` +receive `data` pre-extracted. -When a listener raises an exception, Hassette logs the error and records it for telemetry. You can also register an error handler to receive a typed [`BusErrorContext`][hassette.bus.error_context.BusErrorContext] with full exception details. +### Hassette-internal events -There are two levels of error handlers: +These fire for framework-level changes within the running instance. -- **App-level**: `bus.on_error(handler)` — applies to all listeners on this bus that don't have a per-registration handler. -- **Per-registration**: `on_error=` option on any `bus.on_state_change()`, `bus.on()`, etc. — takes precedence over the app-level handler. +| Method | Fires when | +|---|---| +| `on_websocket_connected()` | The HA WebSocket connection is established | +| `on_websocket_disconnected()` | The HA WebSocket connection is lost | +| `on_hassette_service_status(status)` | A Hassette service changes status | +| `on_hassette_service_started()` | A Hassette service reaches STARTED | +| `on_hassette_service_failed()` | A Hassette service reaches FAILED | +| `on_hassette_service_crashed()` | A Hassette service reaches CRASHED | +| `on_app_state_changed(app_key, status)` | An app instance changes status | +| `on_app_running(app_key)` | An app instance reaches RUNNING | +| `on_app_stopping(app_key)` | An app instance begins stopping | -Both levels can be sync or async. +```python +--8<-- "pages/core-concepts/bus/snippets/handlers/non_state_internal.py" +``` -!!! warning "Register early — the reload gap" - The app-level handler is resolved at dispatch time, not at listener registration time. This means calling `bus.on_error()` after listeners are registered is valid and the handler will still fire. However, if a listener fires during app startup (before `on_error()` is called), the handler won't be invoked for that event. To avoid this gap, **register `on_error()` as the first statement in `on_initialize()`**. +## Error Handling ### App-level error handler +`bus.on_error(handler)` registers a fallback for all listeners without a per-registration +error handler. The handler receives a +[`BusErrorContext`][hassette.bus.error_context.BusErrorContext] with full exception details. + ```python --8<-- "pages/core-concepts/bus/snippets/handlers/bus_error_handler_app.py" ``` +The handler is resolved at dispatch time, not at listener registration time. Registering +`on_error()` after listeners are already in place is valid; the handler fires for those +listeners too. Any listener that fires before `on_error()` is called has no fallback. +Registering it as the first statement in `on_initialize()` closes that gap. + ### Per-registration error handler +`on_error=` on any subscription method registers a handler for that listener only. +It takes precedence over the app-level handler. + ```python --8<-- "pages/core-concepts/bus/snippets/handlers/bus_error_handler_per_reg.py" ``` +Both sync and async functions are accepted. If the error handler itself raises or times +out, Hassette logs the failure. The executor's error handler failure counter increments, +but the original listener's telemetry record stays unaffected. + ### What `BusErrorContext` contains | Field | Type | Description | -|-------|------|-------------| +|---|---|---| | `exception` | `BaseException` | The raised exception | -| `traceback` | `str` | Full formatted traceback — always present | +| `traceback` | `str` | Full formatted traceback. Always present. | | `topic` | `str` | The event topic the listener was registered on | | `listener_name` | `str` | Human-readable listener identity | | `event` | `Event[Any]` | The event being processed when the exception occurred | -!!! note "Error handler failures" - If the error handler itself raises or times out, the failure is logged at ERROR/WARNING and counted in the executor's error handler failure counter. The original listener's telemetry record is unaffected. +## Timeout Configuration -## Subscription and Registration - -Every `bus.on_*()` method — `on_state_change()`, `on_attribute_change()`, `on_call_service()`, `on_component_loaded()`, and `on()` — is `async` and must be awaited. It returns a `Subscription` object once both routing and database registration are complete. - -| Attribute | Description | -|-----------|-------------| -| `sub.cancel()` | Removes the listener immediately. | -| `sub.listener` | The underlying `Listener` object. | -| `sub.listener.db_id` | Integer database row ID — always set when the awaited call returns. | - -### The `name=` parameter (required) - -All database-registered listeners require a `name=` parameter — a stable string identifier for the listener. The name is part of the natural key `(app_key, instance_index, name, topic)` used for upsert deduplication across restarts. +`timeout=` overrides the global `event_handler_timeout_seconds` setting for one listener. +`timeout_disabled=True` removes enforcement entirely for that listener. ```python -await self.bus.on_state_change( - "light.kitchen", - handler=self.on_light_change, - name="kitchen_light", # required -) +--8<-- "pages/core-concepts/bus/snippets/bus_timeouts.py" ``` -The name must be unique within a single app instance for a given topic. Two listeners with the same name on different topics are distinct — topic is part of the key. - -**`ListenerNameRequiredError`** is raised at call time when `name=` is omitted. The error includes the handler method and topic: +`timeout=` accepts a float in seconds. A handler that exceeds its timeout is cancelled +and the failure is recorded in telemetry. -``` -ListenerNameRequiredError: Listener registration requires a name. +## Registration Mechanics - handler: MyApp.on_light_change - topic: light.kitchen +### The `name=` parameter -Provide a stable name via the `name=` parameter: +All subscription methods require a `name=` parameter, a stable string identifier for the +listener. The name forms part of the natural key `(app_key, instance_index, name, topic)` +used for upsert deduplication across restarts. - await self.bus.on_state_change("light.kitchen", handler=self.on_light_change, name="kitchen_light") +```python +--8<-- "pages/core-concepts/bus/snippets/bus_registration_identity.py:registration_identity" ``` -**`DuplicateListenerError`** is raised when a second listener with the same `(name, topic)` is registered within the same app session. Cross-session registrations with the same name and topic update the existing record via upsert — not an error. +Two listeners on the same topic in the same app instance must carry distinct names. Two +listeners with the same name on different topics are distinct; topic is part of the key. -``` -DuplicateListenerError: A listener named 'kitchen_light' is already registered for topic 'light.kitchen'. +`ListenerNameRequiredError` is raised at call time when `name=` is omitted. The message +includes the handler method and topic. - existing handler: MyApp.on_light_change - duplicate handler: MyApp.on_light_change_v2 - -Use a different name for the second listener, or remove the first registration before re-registering. -``` +`DuplicateListenerError` is raised when a second listener with the same `(name, topic)` +is registered within the same app session. Cross-session registrations with the same name +and topic update the existing record via upsert. This is not an error. -### Registration is complete when the awaited call returns +### Registration completes synchronously -Routing and database persistence both complete before `on_state_change()` returns. `sub.listener.db_id` is a valid integer immediately — no further awaiting or checking is needed. +Routing and database persistence both complete before the awaited call returns. +`sub.listener.db_id` is a valid integer immediately. No background task, no polling. ```python --8<-- "pages/core-concepts/bus/snippets/handlers/bus_subscription_patterns.py:await_persistence" @@ -179,7 +196,8 @@ Routing and database persistence both complete before `on_state_change()` return ### Sequential operations are deterministic -Cancel-then-resubscribe sequences have no race conditions — both routing removal and the new registration complete before the next statement runs: +Cancel-then-resubscribe sequences have no race conditions. The old handler is guaranteed +gone before the new registration begins. ```python --8<-- "pages/core-concepts/bus/snippets/handlers/bus_subscription_patterns.py:resubscribe" @@ -187,6 +205,6 @@ Cancel-then-resubscribe sequences have no race conditions — both routing remov ## See Also -- [Filtering & Predicates](filtering.md) - Filter which events trigger your handlers -- [Dependency Injection](dependency-injection.md) - Full annotation table, custom extractors, and type conversion -- [API](../api/index.md) - Call services in response to events +- [Dependency Injection](dependency-injection.md): full annotation table, custom extractors, and type conversion +- [Filtering & Predicates](filtering.md): filter which events reach a handler +- [Subscribing to State Changes](index.md): state-specific subscription patterns and options diff --git a/docs/pages/core-concepts/bus/index.md b/docs/pages/core-concepts/bus/index.md index be4695bb4..b7a152f6d 100644 --- a/docs/pages/core-concepts/bus/index.md +++ b/docs/pages/core-concepts/bus/index.md @@ -1,6 +1,6 @@ # Bus -The event bus delivers Home Assistant events — state changes, service calls, component loads — to any app handler that subscribes. It also delivers Hassette-internal events. +The event bus delivers Home Assistant events (state changes, service calls, component loads) to any app handler that subscribes. It also delivers Hassette-internal events. `self.bus` is available on every [App](../apps/index.md) instance. Hassette creates it at startup. @@ -30,13 +30,13 @@ flowchart TD ## Subscribing to Events -[`Bus`][hassette.bus.Bus] provides typed subscription methods for common event types. Each returns a `Subscription` handle. +[`Bus`][hassette.bus.Bus] provides typed subscription methods for common event types. Each returns a [`Subscription`][hassette.bus.listeners.Subscription] handle. ```python --8<-- "pages/core-concepts/bus/snippets/bus_basic_subscribe.py" ``` -[`D.StateNew`][hassette.event_handling.dependencies] tells Hassette to extract the new state from the event and pass it as a typed `BinarySensorState`. The handler receives clean, typed data instead of a raw event dictionary. See [Dependency Injection](dependency-injection.md) for the full annotation reference. +[`D.StateNew`][hassette.event_handling.dependencies] tells Hassette to extract the new state from the event and pass it as a typed `BinarySensorState`. The handler receives clean, typed data instead of a raw event dictionary. [Dependency Injection](dependency-injection.md) covers the full annotation reference. Four subscription methods cover the common event types: @@ -47,7 +47,7 @@ Four subscription methods cover the common event types: | `on_call_service` | A Home Assistant service is called | | `on` | Any event on a given topic string | -All registration methods are async. Each requires a `name=` parameter — a stable string identifier for the listener. Additional specialized methods like `on_component_loaded` are covered in [Writing Handlers](handlers.md). +All registration methods are async. Each requires a `name=` parameter, a stable string identifier for the listener. Additional specialized methods like `on_component_loaded` are covered in [Writing Handlers](handlers.md). ## Matching Multiple Entities @@ -87,8 +87,12 @@ Three subscription parameters manage handler invocation frequency. !!! warning "One strategy per subscription" `debounce`, `throttle`, and `once` are mutually exclusive. Combining any two raises `ValueError` at registration. +## Synchronous Usage + +`self.bus.sync` exposes a [`BusSyncFacade`][hassette.bus.sync.BusSyncFacade] that mirrors all subscription methods as blocking calls. It exists for `AppSync` lifecycle hooks, which run outside the async event loop. The [Apps](../apps/index.md) page covers the `AppSync` pattern. + ## Next Steps -- [Writing Handlers](handlers.md) — handler signatures, immediate fire, duration hold, timeouts, and error behavior -- [Filtering & Predicates](filtering.md) — predicates, conditions, and accessors for complex event matching -- [Dependency Injection](dependency-injection.md) — the full `D.*` annotation reference and how Hassette resolves handler parameters +- [Writing Handlers](handlers.md): handler signatures, immediate fire, duration hold, timeouts, and error behavior +- [Filtering & Predicates](filtering.md): predicates, conditions, and accessors for complex event matching +- [Dependency Injection](dependency-injection.md): the full `D.*` annotation reference and how Hassette resolves handler parameters diff --git a/docs/pages/core-concepts/bus/predicate-reference.md b/docs/pages/core-concepts/bus/predicate-reference.md index d6e53e6fa..8922205c8 100644 --- a/docs/pages/core-concepts/bus/predicate-reference.md +++ b/docs/pages/core-concepts/bus/predicate-reference.md @@ -1,3 +1,209 @@ -# Predicate & Condition Reference +# Predicate, Condition & Accessor Reference -*Stub — content coming in Phase 3.* +[`P`][predicates], [`C`][conditions], and [`A`][accessors] are available as top-level imports: + +```python +from hassette import P, C, A +``` + +This page is a lookup reference. For explanations of how predicates, conditions, and accessors compose, see [Filtering](filtering.md). + +[predicates]: filtering.md +[conditions]: filtering.md +[accessors]: filtering.md + +## Predicates (`P`) + +A predicate accepts an event and returns `bool`. Pass predicates to `where=` on any bus subscription, or compose them with other predicates. + +### Logic Combinators + +Works with: any event type. + +| Predicate | Signature | Description | +|---|---|---| +| `P.AllOf` | `AllOf(predicates: tuple[Predicate, ...])` | Returns `True` when all contained predicates return `True`. | +| `P.AnyOf` | `AnyOf(predicates: tuple[Predicate, ...])` | Returns `True` when at least one contained predicate returns `True`. | +| `P.Not` | `Not(predicate: Predicate)` | Negates the result of the wrapped predicate. | +| `P.Guard` | `Guard(fn: Predicate[EventT])` | Wraps any callable as a typed predicate for use in combinators. | + +`AllOf` and `AnyOf` each have a classmethod `ensure_iterable(where)` that wraps a single predicate or sequence into the combinator. + +### Value / Field Matching + +Works with: any event type. + +| Predicate | Signature | Description | +|---|---|---| +| `P.ValueIs` | `ValueIs(source: Callable, condition: ChangeType = ANY_VALUE)` | Extracts a value via `source`, then tests it against `condition`. When `condition` is `ANY_VALUE`, always returns `True`. | +| `P.DidChange` | `DidChange(source: Callable[..., tuple[Any, Any]])` | Returns `True` when the two values returned by `source` differ. | +| `P.IsPresent` | `IsPresent(source: Callable)` | Returns `True` when the value extracted by `source` is not `MISSING_VALUE`. | +| `P.IsMissing` | `IsMissing(source: Callable)` | Returns `True` when the value extracted by `source` is `MISSING_VALUE`. | + +`condition` accepts a literal value, a `C.*` condition instance, a glob string, or any callable `(value) -> bool`. + +### Entity / Domain / Service Matching + +Works with: `HassEvent`, `CallServiceEvent`. + +| Predicate | Signature | Description | +|---|---|---| +| `P.DomainMatches` | `DomainMatches(domain: str)` | Returns `True` when the event domain matches `domain`. Glob patterns are auto-detected. | +| `P.EntityMatches` | `EntityMatches(entity_id: str)` | Returns `True` when the event `entity_id` matches. Glob patterns are auto-detected. | +| `P.ServiceMatches` | `ServiceMatches(service: str)` | Returns `True` when the called service matches. Glob patterns are auto-detected. | +| `P.ServiceDataWhere` | `ServiceDataWhere(spec: Mapping[str, ChangeType], auto_glob: bool = True)` | Returns `True` when each key in `spec` satisfies its condition against the event's `service_data`. | + +`ServiceDataWhere.from_kwargs(*, auto_glob=True, **spec)` is an ergonomic constructor for literal keyword arguments: + +```python +P.ServiceDataWhere.from_kwargs(entity_id="light.*", brightness=200) +``` + +When `auto_glob=True` (the default), bare glob strings in `spec` values are automatically wrapped in `C.Glob`. + +### State Change Predicates + +Works with: `RawStateChangeEvent`. + +| Predicate | Signature | Description | +|---|---|---| +| `P.StateFrom` | `StateFrom(condition: ChangeType)` | Returns `True` when the old state value satisfies `condition`. | +| `P.StateTo` | `StateTo(condition: ChangeType)` | Returns `True` when the new state value satisfies `condition`. | +| `P.StateComparison` | `StateComparison(condition: ComparisonCondition)` | Returns `True` when `condition(old_state_value, new_state_value)` is `True`. Accepts `C.Increased`, `C.Decreased`, or any two-argument callable. | +| `P.StateDidChange` | `StateDidChange()` | Returns `True` when the state string changed. | +| `P.AttrFrom` | `AttrFrom(attr_name: str, condition: ChangeType)` | Returns `True` when the named attribute's old value satisfies `condition`. | +| `P.AttrTo` | `AttrTo(attr_name: str, condition: ChangeType)` | Returns `True` when the named attribute's new value satisfies `condition`. | +| `P.AttrComparison` | `AttrComparison(attr_name: str, condition: ComparisonCondition)` | Returns `True` when `condition(old_attr, new_attr)` is `True` for the named attribute. | +| `P.AttrDidChange` | `AttrDidChange(attr_name: str)` | Returns `True` when the named attribute changed. When `old_state` is `None`, returns `True` if the attribute is present on the new state. | + +No `StateFromTo` predicate exists. For from-to matching, combine `P.StateFrom` and `P.StateTo` inside `P.AllOf`. + +## Conditions (`C`) + +A condition is a single-value callable `(value) -> bool`. Predicates like `P.ValueIs` accept a condition as their `condition` argument. The `changed_to=` and `changed_from=` subscription helpers also accept conditions directly. + +### String Matching + +| Condition | Signature | Description | +|---|---|---| +| `C.Glob` | `Glob(pattern: str)` | Returns `True` when the string value matches the glob pattern. | +| `C.StartsWith` | `StartsWith(prefix: str)` | Returns `True` when the string value starts with `prefix`. | +| `C.EndsWith` | `EndsWith(suffix: str)` | Returns `True` when the string value ends with `suffix`. | +| `C.Contains` | `Contains(substring: str)` | Returns `True` when the string value contains `substring`. | +| `C.Regex` | `Regex(pattern: str)` | Returns `True` when the string value matches the compiled regex pattern. | + +### Collection Membership + +| Condition | Signature | Description | +|---|---|---| +| `C.IsIn` | `IsIn(collection: Sequence[Any])` | Returns `True` when the value is in `collection`. | +| `C.NotIn` | `NotIn(collection: Sequence[Any])` | Returns `True` when the value is not in `collection`. | +| `C.Intersects` | `Intersects(collection: Sequence[Any])` | Returns `True` when the value (itself a sequence) shares at least one element with `collection`. | +| `C.NotIntersects` | `NotIntersects(collection: Sequence[Any])` | Returns `True` when the value (itself a sequence) shares no elements with `collection`. | +| `C.IsOrContains` | `IsOrContains(condition: str)` | Returns `True` when the value equals `condition`, or when the value is a sequence containing it. | + +`collection` must be a sequence, not a string. Passing a string raises `ValueError`. + +### None / Missing Checks + +| Condition | Signature | Description | +|---|---|---| +| `C.IsNone` | `IsNone()` | Returns `True` when the value is `None`. | +| `C.IsNotNone` | `IsNotNone()` | Returns `True` when the value is not `None`. | +| `C.Present` | `Present()` | Returns `True` when the value is not `MISSING_VALUE`. | +| `C.Missing` | `Missing()` | Returns `True` when the value is `MISSING_VALUE`. | + +`MISSING_VALUE` and `None` are distinct. `C.IsNone` / `C.IsNotNone` test for Python `None`; `C.Present` / `C.Missing` test for Hassette's sentinel that indicates a field does not exist on the event. + +### Numeric Comparison + +| Condition | Signature | Description | +|---|---|---| +| `C.Comparison` | `Comparison(op: OPS, value: Any)` | Returns `True` when `extracted_value op value` holds. `op` is one of `">"`, `"<"`, `">="`, `"<="`, `"=="`, `"!="` (or their spelled-out equivalents `"gt"`, `"lt"`, `"ge"`, `"le"`, `"eq"`, `"ne"`). | +| `C.Increased` | `Increased()` | Two-argument condition. Returns `True` when `float(new) > float(old)`. For use with `P.StateComparison` or `P.AttrComparison`. | +| `C.Decreased` | `Decreased()` | Two-argument condition. Returns `True` when `float(new) < float(old)`. For use with `P.StateComparison` or `P.AttrComparison`. | + +No `C.InRange` condition exists. For range checks, combine two `C.Comparison` instances inside `P.AllOf`: + +```python +P.AllOf(( + P.ValueIs(source=A.get_state_value_new, condition=C.Comparison(">=", 18)), + P.ValueIs(source=A.get_state_value_new, condition=C.Comparison("<=", 26)), +)) +``` + +## Accessors (`A`) + +An accessor is a factory function that returns a callable `(event) -> value`. Predicates like `P.ValueIs`, `P.DidChange`, `P.IsPresent`, and `P.IsMissing` accept an accessor as their `source=` argument. Bus helpers use accessors internally. Direct use is needed only when pointing a predicate at a non-standard field. + +### State Value + +Works with: `RawStateChangeEvent`. + +| Accessor | Returns | Description | +|---|---|---| +| `A.get_state_value_old` | `Any \| MISSING_VALUE` | The old state string, or `MISSING_VALUE` when `old_state` is `None`. | +| `A.get_state_value_new` | `Any \| MISSING_VALUE` | The new state string, or `MISSING_VALUE` when `new_state` is `None`. | +| `A.get_state_value_old_new` | `tuple[Any, Any]` | `(old_state_value, new_state_value)` as a tuple. | + +### State Object + +Works with: `RawStateChangeEvent`. + +| Accessor | Returns | Description | +|---|---|---| +| `A.get_state_object_old` | `HassStateDict \| None` | The full old state dict, or `None` when absent. | +| `A.get_state_object_new` | `HassStateDict \| None` | The full new state dict, or `None` when absent. | +| `A.get_state_object_old_new` | `tuple[HassStateDict \| None, HassStateDict \| None]` | Both state objects as a tuple. | + +### Attribute + +Works with: `RawStateChangeEvent`. + +| Accessor | Signature | Returns | Description | +|---|---|---|---| +| `A.get_attr_old` | `get_attr_old(name: str)` | `Any \| MISSING_VALUE` | The named attribute from the old state; `MISSING_VALUE` when absent. | +| `A.get_attr_new` | `get_attr_new(name: str)` | `Any \| MISSING_VALUE` | The named attribute from the new state; `MISSING_VALUE` when absent. | +| `A.get_attr_old_new` | `get_attr_old_new(name: str)` | `tuple[Any, Any]` | `(old_attr, new_attr)` for the named attribute. | +| `A.get_attrs_old` | `get_attrs_old(names: list[str])` | `dict[str, Any]` | A dict of the named attributes from the old state. Missing names map to `MISSING_VALUE`. | +| `A.get_attrs_new` | `get_attrs_new(names: list[str])` | `dict[str, Any]` | A dict of the named attributes from the new state. Missing names map to `MISSING_VALUE`. | +| `A.get_attrs_old_new` | `get_attrs_old_new(names: list[str])` | `tuple[dict, dict]` | Both attribute dicts as a tuple. | +| `A.get_all_attrs_old` | `get_all_attrs_old` | `dict[str, Any] \| MISSING_VALUE` | All attributes from the old state, or `MISSING_VALUE` when `old_state` is `None`. | +| `A.get_all_attrs_new` | `get_all_attrs_new` | `dict[str, Any] \| MISSING_VALUE` | All attributes from the new state, or `MISSING_VALUE` when `new_state` is `None`. | +| `A.get_all_attrs_old_new` | `get_all_attrs_old_new` | `tuple[dict \| MISSING_VALUE, dict \| MISSING_VALUE]` | Both full attribute dicts as a tuple. | + +### Identity + +Works with: `HassEvent` (any event). + +| Accessor | Returns | Description | +|---|---|---| +| `A.get_domain` | `str \| MISSING_VALUE` | The domain portion of the event (e.g., `"light"` from `"light.kitchen"`). | +| `A.get_entity_id` | `str \| MISSING_VALUE` | The `entity_id` from the event payload, or from `service_data` for `CallServiceEvent`. | +| `A.get_context` | `HassContext` | The context dict from the event payload. | + +### Service + +Works with: `CallServiceEvent`. + +| Accessor | Signature | Returns | Description | +|---|---|---|---| +| `A.get_service` | `get_service` | `str \| MISSING_VALUE` | The service name being called. | +| `A.get_service_data` | `get_service_data` | `dict[str, Any] \| MISSING_VALUE` | The full `service_data` dict, or `MISSING_VALUE` when absent. | +| `A.get_service_data_key` | `get_service_data_key(key: str)` | `Any \| MISSING_VALUE` | A specific key from `service_data`; `MISSING_VALUE` when absent. | + +### Other + +| Accessor | Signature | Returns | Description | +|---|---|---|---| +| `A.get_path` | `get_path(path: str)` | `Any \| MISSING_VALUE` | Extracts a nested value by dot-separated glom path; `MISSING_VALUE` on any access failure. | +| `A.get_all_changes` | `get_all_changes(exclude: Sequence[str] = DEFAULT_EXCLUDE)` | `dict[str, Any]` | A recursive diff between old and new state, mapping changed keys to `(old_value, new_value)`. Excludes `last_reported`, `last_updated`, `last_changed`, and `context` by default. | + +`get_path` works with any event type. `get_all_changes` works with `RawStateChangeEvent`. + +Single-value accessors work with `P.ValueIs`, `P.IsPresent`, and `P.IsMissing`. Tuple-returning accessors (the `*_old_new` variants) work with `P.DidChange`. For writing custom accessors, see [Custom Extractors](custom-extractors.md). + +## See Also + +- [Filtering](filtering.md). How predicates, conditions, and accessors compose in practice. +- [Custom Extractors](custom-extractors.md). Writing accessors for non-standard event fields. diff --git a/docs/pages/core-concepts/bus/snippets/handlers/non_state_call_service.py b/docs/pages/core-concepts/bus/snippets/handlers/non_state_call_service.py new file mode 100644 index 000000000..2b85eaf3f --- /dev/null +++ b/docs/pages/core-concepts/bus/snippets/handlers/non_state_call_service.py @@ -0,0 +1,14 @@ +from hassette import App, AppConfig, D + + +class LightControlApp(App[AppConfig]): + async def on_initialize(self) -> None: + await self.bus.on_call_service( + "light", + "turn_on", + handler=self.on_light_turn_on, + name="light_turn_on", + ) + + async def on_light_turn_on(self, entity_id: D.EntityId) -> None: + self.logger.info("Light turned on: %s", entity_id) diff --git a/docs/pages/core-concepts/bus/snippets/handlers/non_state_internal.py b/docs/pages/core-concepts/bus/snippets/handlers/non_state_internal.py new file mode 100644 index 000000000..ca5370d8b --- /dev/null +++ b/docs/pages/core-concepts/bus/snippets/handlers/non_state_internal.py @@ -0,0 +1,19 @@ +from hassette import App, AppConfig + + +class MonitorApp(App[AppConfig]): + async def on_initialize(self) -> None: + await self.bus.on_websocket_connected( + handler=self.on_connected, + name="ws_connected", + ) + await self.bus.on_websocket_disconnected( + handler=self.on_disconnected, + name="ws_disconnected", + ) + + async def on_connected(self) -> None: + self.logger.info("WebSocket connection established") + + async def on_disconnected(self) -> None: + self.logger.warning("WebSocket connection lost") diff --git a/docs/pages/core-concepts/bus/snippets/handlers/non_state_raw_topic.py b/docs/pages/core-concepts/bus/snippets/handlers/non_state_raw_topic.py new file mode 100644 index 000000000..1d589ff3b --- /dev/null +++ b/docs/pages/core-concepts/bus/snippets/handlers/non_state_raw_topic.py @@ -0,0 +1,16 @@ +from typing import Any + +from hassette import App, AppConfig +from hassette.events import Event + + +class ScriptApp(App[AppConfig]): + async def on_initialize(self) -> None: + await self.bus.on( + topic="hass.event.automation_triggered", + handler=self.on_automation, + name="automation_triggered", + ) + + async def on_automation(self, event: Event[Any]) -> None: + self.logger.info("Automation fired: %s", event.topic) From 7f9febe5740a0bc479a9da16fcb10704bb0db8f4 Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Tue, 2 Jun 2026 16:56:56 -0500 Subject: [PATCH 044/160] docs: rewrite Architecture, Internals, Database-Telemetry (T07 complete) Architecture: scoped to app-authors only, four handles model, removed internal service names and dependency graphs from diagram. Internals Architecture & Data Flow: event pipeline with failure table, dependency graph with wave ordering, component ownership. Added missing LoggingService and ServiceWatcher. Fixed TelemetryQueryService wave placement and RuntimeQueryService dependency edges. Internals Lifecycle: leads with 'what happens when a service fails', RestartSpec reference, state machine as supporting material. All enum values and restart fields verified against source. Internals Service Details: six services in pipeline order, all class and method names verified. Fixed reconnect order, write pipeline description, SPA routing, and WebSocket retry count inaccuracies. Database & Telemetry: JTBD reorder (what's tracked -> persistence -> health checking -> config). Execution columns in collapsible section. CLI commands and API endpoints verified. --- .../pages/core-concepts/database-telemetry.md | 144 ++++--- docs/pages/core-concepts/index.md | 238 +++-------- docs/pages/core-concepts/internals/index.md | 224 ++++++++++- .../core-concepts/internals/lifecycle.md | 115 +++++- .../internals/service-details.md | 376 +++++++++++++++++- 5 files changed, 842 insertions(+), 255 deletions(-) diff --git a/docs/pages/core-concepts/database-telemetry.md b/docs/pages/core-concepts/database-telemetry.md index 716ca670f..31ab512f2 100644 --- a/docs/pages/core-concepts/database-telemetry.md +++ b/docs/pages/core-concepts/database-telemetry.md @@ -1,89 +1,68 @@ # Database & Telemetry -Hassette stores operational telemetry in a local SQLite database. This data powers the Apps page stats strip, handler invocations, job executions, and app health metrics in the [web UI](../web-ui/index.md). +Hassette stores operational telemetry in a local SQLite database. The [web UI](../web-ui/index.md) reads this data to display handler invocations, job executions, app health metrics, and the Apps page stats strip. ## What Is Collected -Hassette records two types of telemetry: +Hassette records four types of data automatically, with no configuration required. -- **Handler invocations** — every time an event bus listener fires, the database records the start time, duration, success/failure, and any error details. -- **Job executions** — every time a scheduled job runs, the same metrics are recorded. +**Handler invocations.** Every bus listener firing produces a row in the `executions` table. Each row captures the start time, wall-clock duration, and outcome (`success`, `error`, `cancelled`, or `timed_out`). Failed executions include full exception details. -Telemetry is collected automatically. You do not need to enable it or write any code — it works out of the box. +**Job executions.** Every scheduled job run produces an equivalent row. The same columns apply; a `kind` column distinguishes handler rows from job rows. -### Source Tier - -Framework-internal handlers (telemetry workers, WebSocket service, scheduler services) are included in Apps page stats strip counts alongside your app registrations. Framework errors appear in the unified **Error Spotlight** with a **Framework** badge and the component name (e.g. Service Watcher, App Handler), so you can distinguish them from your app errors at a glance. The **Handler health grid** shows only your apps — framework components do not appear there. - -??? note "Internal detail" - Internally, framework handlers are stored with `source_tier='framework'` and a component-specific `app_key` of the form `__hassette__.` — for example `__hassette__.service_watcher`, `__hassette__.app_handler`, or `__hassette__.core`. This naming identifies which part of the framework produced an error and is used by the web UI to display the component name in the **Framework** badge. The Handler health grid filters out all framework keys, while the stats strip and Error Spotlight include all tiers by default. - -## Execution Columns - -Handler invocations and job executions are stored together in a single `executions` table. A `kind` column (`'handler'` or `'job'`) distinguishes the two. Handler rows carry a `listener_id` foreign key; job rows carry a `job_id` foreign key. Exactly one of the two is non-null per row. +**Listener registrations.** Every registered bus listener is stored by name and topic in the `listeners` table. Counts appear in the Apps page stats strip. -The following columns are present on every row regardless of `kind`. +**Job registrations.** Every scheduled job is stored in the `scheduled_jobs` table. Counts appear alongside listener counts in the stats strip. -| Column | Type | Description | -|--------|------|-------------| -| `kind` | string | `'handler'` for bus listener invocations; `'job'` for scheduled job executions | -| `listener_id` | integer \| null | Foreign key into `listeners`. Set for handler rows; `null` for job rows. | -| `job_id` | integer \| null | Foreign key into `scheduled_jobs`. Set for job rows; `null` for handler rows. | -| `execution_start_ts` | float | Unix timestamp when execution started | -| `duration_ms` | float | Wall-clock time in milliseconds | -| `status` | string | Outcome: `success`, `error`, `cancelled`, or `timed_out` | -| `is_di_failure` | boolean | Whether the execution failed due to a dependency injection error (handler rows only; always `0` for job rows) | -| `source_tier` | string | `app` for user automations, `framework` for internal Hassette components | -| `error_type` | string \| null | Exception class name, if execution raised an error | -| `error_message` | string \| null | Exception message, if execution raised an error | -| `error_traceback` | string \| null | Full Python traceback, if execution raised an error | -| `execution_id` | string \| null | UUID tying this row to a specific trigger delivery or scheduler invocation. `null` for rows written before this feature was added. | -| `trigger_context_id` | string \| null | UUID identifying the event that triggered a handler. For HA events, this is `context.id` from the originating Home Assistant event and is stable across all handlers receiving the same event. For Hassette-internal events, unique per firing. `null` for job rows and for rows written before this feature was added. | -| `trigger_origin` | string \| null | Where the trigger originated: `LOCAL`, `REMOTE`, or `HASSETTE`. `null` for job rows and for rows written before this feature was added. | +### Source Tier -### Pre-migration rows +Framework-internal handlers (telemetry workers, WebSocket service, scheduler services) are counted in the stats strip alongside app registrations. Framework errors appear in the unified Error Spotlight with a **Framework** badge and the component name, for example Service Watcher or App Handler. The Handler health grid shows only app-registered handlers. Framework components are excluded. -The `execution_id`, `trigger_context_id`, and `trigger_origin` columns were added in a schema migration. Rows written before this migration have `null` in all three columns. The web UI renders `null` as "—" in the Trace ID, Trigger, and Origin columns. +??? note "Internal detail" + Framework handlers are stored with `source_tier='framework'` and an `app_key` of the form `__hassette__.`, for example `__hassette__.service_watcher` or `__hassette__.core`. The web UI reads this value to display the component name in the Framework badge. The Handler health grid filters out all framework keys; the stats strip and Error Spotlight include all tiers. ## Configuration -All database settings are optional. The defaults work well out of the box. +All database settings are optional. The defaults work well for most setups. ```toml --8<-- "pages/core-concepts/snippets/database-telemetry/db_config.toml" ``` | Field | Type | Default | Description | -|-------|------|---------|-------------| -| `path` | path or null | `null` | Location of the SQLite database file. When null (default), Hassette stores the database at `{data_dir}/hassette.db`. | -| `retention_days` | integer | `7` | How many days of handler invocation and job execution records to keep. Records older than this are automatically deleted. Minimum: 1 day. | -| `max_size_mb` | float | `500` | Maximum total database file size in megabytes. When exceeded, Hassette deletes the oldest execution records to reclaim space. Set to `0` to disable the size limit. | +|---|---|---|---| +| `path` | path or null | `null` | Location of the SQLite database file. When null, Hassette stores the database at `{data_dir}/hassette.db`. | +| `retention_days` | integer | `7` | Days of execution records to retain. Records older than this value are deleted automatically. Minimum: 1. | +| `max_size_mb` | float | `500` | Maximum database size in megabytes. When exceeded, the oldest execution records are deleted in batches. A value of `0` disables the size limit. | ### How Retention Works -Hassette runs two automatic maintenance routines: +Two maintenance routines run every hour in the background. -1. **Time-based retention** — every hour, records older than `retention_days` are deleted from the `executions` table. Internal bookkeeping records (session tracking) are not affected by retention cleanup. -2. **Size-based failsafe** — every hour, if the total database size (including WAL files) exceeds `max_size_mb`, the oldest execution records are deleted in batches until the database is back under the limit. +Time-based retention deletes execution records older than `retention_days` from the `executions` table. Internal bookkeeping records (session tracking) are not affected. -Both routines run in the background and do not block normal operation. +Size-based retention runs after time-based retention. When the total database size (including WAL files) exceeds `max_size_mb`, the oldest execution records are deleted in batches. Deletion continues until the database is back under the limit. -## Monitoring Telemetry Health +Both routines are non-blocking and do not interrupt automations or telemetry collection. -### `/api/telemetry/status` +## Registration Persistence -This endpoint checks whether the telemetry database is healthy and responding to queries. +Listener and job registrations survive restarts. On startup, Hassette matches existing registrations against the database by natural key. The natural key is the explicit `name=` value, or a key derived from handler name, topic, and predicate signature. Matched registrations are updated in place via upsert semantics. Registrations absent from the new session receive a `retired_at` timestamp rather than deletion. -| Response | Status Code | Meaning | -|----------|-------------|---------| -| `{"degraded": false}` | 200 | Database is healthy | -| `{"degraded": true}` | 503 | Database is unavailable | +The Apps page stats strip shows accurate counts even after a restart because of this persistence. Historical registrations from prior sessions remain visible in the web UI until they age out of the retention window. -Use this endpoint if you want to monitor specifically whether telemetry data collection is working. When degraded, the web UI continues to function but shows zeroed-out metrics. +## Checking Telemetry Health -### `/api/health` +Three commands and their API equivalents cover telemetry and system health. -This is the system-level status view for Hassette as a whole. It reports whether the WebSocket connection to Home Assistant is up, starting, or disconnected. The endpoint returns HTTP 200 in all states while the process can serve — it never returns 503 from the handler itself. +**Telemetry pipeline health.** `hassette telemetry` queries `/api/telemetry/status` and reports whether the database is reachable. + +| Response | HTTP status | Meaning | +|---|---|---| +| `{"degraded": false}` | 200 | Database is healthy | +| `{"degraded": true}` | 503 | Database is unavailable | + +**System-level health.** `hassette status` queries `/api/health`, which reports the overall status of the Hassette process. The endpoint returns HTTP 200 in all states while the process can serve — it never returns 503 from the handler itself: | `status` body field | HTTP | Meaning | |---|---|---| @@ -98,36 +77,55 @@ For container restart automation, use `/api/health/live` or rely on the non-zero !!! note "Choosing the right endpoint" Use `/api/health/live` (or the non-zero exit + restart policy) for restart automation. Use `/api/health/ready` for traffic routing. Use `/api/health` for the aggregate human view. Use `/api/telemetry/status` to monitor specifically whether the telemetry database is functional. -## Registration Persistence - -Listener and job registrations are stored in the database and survive restarts. When Hassette starts, existing registrations are matched by their natural key (handler name, topic, and predicate signature — or the explicit `name=` value) and updated in place via upsert semantics. Registrations that no longer exist in the new session are marked with a `retired_at` timestamp rather than deleted. - -This means the Apps page stats strip shows accurate handler and job counts even for registrations that predate the current startup. Historical registrations from prior startups remain visible in the web UI until they age out of the retention window. +**Execution history.** `hassette log --app ` shows recent log entries for an app. `hassette execution` shows per-execution detail for a specific invocation: trace ID, trigger origin, and error traceback. ## Degraded Mode -When the telemetry database becomes unavailable (disk full, permissions error, corruption), Hassette enters **degraded mode**: +When the database becomes unavailable (disk exhaustion, a permissions error, or corruption), Hassette enters degraded mode. Automations continue to run normally. The telemetry pipeline is an observability layer, not a dependency for app execution. -- The web UI continues to work, but telemetry-backed panels (stats strip, Error Spotlight, handler/job metrics) show empty or zeroed-out data. -- The status bar shows a degraded indicator to alert you. -- Your automations continue to run normally — telemetry is an observability layer, not a dependency for app execution. -- All telemetry is unavailable — including [persisted registrations](#registration-persistence). Because registration data lives in the same SQLite file, handler and job counts will also show zero until the database recovers. +In degraded mode: + +- Telemetry-backed panels (stats strip, Error Spotlight, handler and job metrics) show empty or zeroed-out data. +- The status bar displays a degraded indicator. +- [Registration persistence](#registration-persistence) is also unavailable. Handler and job counts show zero until the database recovers, because registration data lives in the same SQLite file. ### Recovery -To resolve a degraded state: +Three steps resolve most degraded states. -1. **Check disk space** — in Docker: `docker compose exec hassette df -h /data` -2. **Check file permissions** — ensure the Hassette user can write to the database path -3. **Delete and restart** — if the database is corrupted, deleting it is safe. Only telemetry history is lost; your automations are unaffected: +1. **Disk space.** In Docker: `docker compose exec hassette df -h /data`. +2. **File permissions.** The Hassette process must be able to write to the database path. +3. **Delete and restart.** If the database is corrupted, deleting it is safe. Only telemetry history is lost; automations and configuration are unaffected. - ```bash - --8<-- "pages/core-concepts/snippets/database-telemetry/db_recovery.sh" - ``` +```bash +--8<-- "pages/core-concepts/snippets/database-telemetry/db_recovery.sh" +``` - Hassette will recreate the database on next startup. +Hassette recreates the database on next startup. + +??? note "Execution columns reference" + Handler invocations and job executions are stored together in the `executions` table. A `kind` column distinguishes the two types. Handler rows carry a `listener_id` foreign key; job rows carry a `job_id` foreign key. Exactly one is non-null per row. + + | Column | Type | Description | + |---|---|---| + | `kind` | string | `'handler'` for bus listener invocations; `'job'` for scheduled job executions | + | `listener_id` | integer or null | Foreign key into `listeners`. Set for handler rows; null for job rows. | + | `job_id` | integer or null | Foreign key into `scheduled_jobs`. Set for job rows; null for handler rows. | + | `execution_start_ts` | float | Unix timestamp when execution started | + | `duration_ms` | float | Wall-clock time in milliseconds | + | `status` | string | Outcome: `success`, `error`, `cancelled`, or `timed_out` | + | `is_di_failure` | boolean | Whether the execution failed due to a dependency injection error. Always `0` for job rows. | + | `source_tier` | string | `app` for user automations; `framework` for internal Hassette components | + | `error_type` | string or null | Exception class name, if execution raised an error | + | `error_message` | string or null | Exception message, if execution raised an error | + | `error_traceback` | string or null | Full Python traceback, if execution raised an error | + | `execution_id` | string or null | UUID tying this row to a specific trigger delivery or scheduler invocation | + | `trigger_context_id` | string or null | UUID identifying the originating event. For HA events, this matches `context.id` from Home Assistant and is stable across all handlers receiving the same event. | + | `trigger_origin` | string or null | Where the trigger originated: `LOCAL`, `REMOTE`, or `HASSETTE`. Null for job rows. | + + The `execution_id`, `trigger_context_id`, and `trigger_origin` columns were added in a schema migration. Rows written before this migration have null in all three columns. The web UI renders null as "—" in the Trace ID, Trigger, and Origin columns. ## Related Resources -- [Global Configuration](configuration/global.md) — all configuration fields -- [App Cache](cache/index.md) — the disk cache for app data (separate from telemetry) +- [Global Configuration](configuration/global.md), all configuration fields +- [App Cache](cache/index.md), the disk cache for app data (separate from telemetry) diff --git a/docs/pages/core-concepts/index.md b/docs/pages/core-concepts/index.md index 073f18aee..c7370ae2b 100644 --- a/docs/pages/core-concepts/index.md +++ b/docs/pages/core-concepts/index.md @@ -1,46 +1,47 @@ # Architecture -Hassette has a lot of moving parts, but at its core it’s simple: everything revolves around **apps**, **events**, and **resources**. +Hassette connects Home Assistant to the automations app authors write. It receives events over a WebSocket and routes them through an event bus to subscribed handlers. Each app gets typed access to the Home Assistant API, entity states, and a scheduler. -- **Apps** are what you write. They respond to events and manipulate resources. -- **Events** describe what happened—state changes, service calls, lifecycle transitions, or scheduled triggers. -- **Resources** are everything else: API clients, the event bus, the scheduler, etc. +Three concepts underpin everything: apps, events, and resources. -## Hassette Architecture +- Apps run the automation logic. Each app subscribes to events, schedules tasks, and calls Home Assistant services. +- Events describe what happened: a state change, a service call, a scheduled trigger, or a lifecycle transition. +- Resources are the objects apps use to act: the bus, the scheduler, the API client, and the state cache. -At runtime, the `Hassette` class is the entry point. It receives a `HassetteConfig` instance that defines where to find Home Assistant, your apps, and related configuration. +## Per-App Handles -Each app you write receives four lightweight handles — these are the objects you call in your automation code: +Every [`App`](apps/index.md) instance carries four handles. These are the objects automation code calls directly. -- [`Api`](api/index.md) – call Home Assistant services, read entity states, and subscribe to WebSocket messages. -- [`Bus`](bus/index.md) – subscribe to state change events and service call events. -- [`Scheduler`](scheduler/index.md) – schedule one-off and recurring jobs. -- [`States`](states/index.md) – read the current state of any Home Assistant entity, instantly, from local memory. +- [`Api`](api/index.md) calls Home Assistant services, reads and writes entity states, and sends WebSocket commands. +- [`Bus`](bus/index.md) delivers Home Assistant events (state changes, service calls, component loads) to subscribed handlers. +- [`Scheduler`](scheduler/index.md) runs functions at a specified time, after a delay, or on a recurring interval. +- [`States`](states/index.md) returns the current state of any Home Assistant entity from a local in-memory cache. -??? note "Internal services" - Hassette starts several infrastructure services to support your apps. These are not user-facing and do not appear in your app code, but they may appear in debug logs: +Each handle is scoped to the app instance. Listeners registered on one app's `Bus` do not fire for another app. Jobs scheduled on one app's `Scheduler` cancel independently of all others. - - `WebsocketService` – maintains the WebSocket connection and dispatches events. - - `ApiResource` – typed interface to Home Assistant's REST and WebSocket APIs. - - `BusService` – routes events from the socket to subscribed apps. - - `SchedulerService` – runs scheduled jobs. - - `AppHandler` – discovers, loads, and initializes your apps. Configured via [Application Configuration](configuration/applications.md). - - `StateProxy` – tracks state changes and provides a consistent view of Home Assistant states. - - `DatabaseService` – persistent telemetry storage, configurable via `db_*` fields in [global settings](configuration/global.md). - - `WebApiService` – serves the REST API, healthcheck, and web UI. - - `RuntimeQueryService` – provides live runtime data (events, logs, metrics) to the web UI. - - `TelemetryQueryService` – serves historical telemetry (invocations, executions, errors) from the database. - - `EventStreamService` – event delivery pipeline. - - `ServiceWatcher` – monitors and restarts failed services. - - `FileWatcherService` – detects code changes for hot reload. - - `SessionManager` – tracks session lifecycle. - - `CommandExecutor` – dispatches app management commands. +```mermaid +flowchart TD + subgraph app["App Instance"] + APP["App"] + end + + subgraph handles["Handles"] + direction LR + API[Api] + BUS[Bus] + SCHED[Scheduler] + STATES[States] + end + + APP --> API & BUS & SCHED & STATES -### Diagrams + style app fill:#e8f0ff,stroke:#6688cc + style handles fill:#fff0e8,stroke:#cc8844 +``` -These diagrams illustrate the architecture and relationships between the main components. Diagrams 1–2 show what Hassette is made of internally; diagram 3 shows the four handles your app code calls directly. +## How It Fits Together -#### 1) High-level flow +Hassette sits between Home Assistant and the apps it runs. ```mermaid flowchart TD @@ -52,7 +53,7 @@ flowchart TD H["Framework"] end - subgraph apps["Your Apps"] + subgraph apps["Apps"] APPS["Automations"] end @@ -64,7 +65,7 @@ flowchart TD style apps fill:#e8f0ff,stroke:#6688cc ``` -#### 2) Core services inside Hassette +Inside the framework, infrastructure handles the WebSocket connection, persistent telemetry, and the web UI. Core services (the bus, scheduler, API client, and state cache) connect that infrastructure to app code. App management discovers, loads, and initializes each app class. ```mermaid flowchart TD @@ -72,27 +73,27 @@ flowchart TD subgraph infra["Infrastructure"] direction LR - WS[WebsocketService] - DB[DatabaseService] + WS[WebSocket] + DB[Database] end subgraph core["Core"] direction LR - BUS[BusService] - SCHED[SchedulerService] - API[ApiResource] - STATE[StateProxy] + BUS[Bus] + SCHED[Scheduler] + API[Api] + STATE[States] end subgraph web["Web"] direction LR - WEB[WebApiService] - RTQ[RuntimeQueryService] - TQ[TelemetryQueryService] + WEB[Web UI] + RTQ[Runtime Queries] + TQ[Telemetry Queries] end subgraph apps["Apps"] - APPH[AppHandler] + APPH[App Management] end H --- infra & core & web & apps @@ -103,143 +104,24 @@ flowchart TD style apps fill:#e8f0ff,stroke:#6688cc ``` -#### 3) What each app gets (lightweight handles) +## Startup -```mermaid -flowchart TD - subgraph app["App Instance"] - APP["Your App"] - end +Hassette starts services in dependency order. `Api`, `Bus`, `Scheduler`, and `States` are all ready before `on_initialize` runs on any app. [System Internals](internals.md) covers the full startup sequence and service lifecycle. - subgraph handles["Lightweight Handles"] - direction LR - API[Api] - BUS[Bus] - SCHED[Scheduler] - STATES[States] - CACHE[Cache] - end +## Topics - APP --> API & BUS & SCHED & STATES & CACHE - - style app fill:#e8f0ff,stroke:#6688cc - style handles fill:#fff0e8,stroke:#cc8844 -``` - -Learn more about writing apps in the [apps](apps/index.md) section. - -## Service Dependency Graph - -Every internal Hassette service declares which other services it needs to be ready before it can initialize. This declaration drives both startup ordering and shutdown ordering — automatically, without any explicit sequencing code in the services themselves. - -### How `depends_on` works - -Each service class carries a `depends_on` ClassVar that lists the resource types it depends on: - -```python ---8<-- "pages/core-concepts/snippets/index_depends_on.py" -``` - -At startup, Hassette validates the full dependency graph and computes a topological initialization order. When a service initializes, it automatically waits for every service in its `depends_on` list to become ready before any of its own lifecycle hooks (`on_initialize`, etc.) run. You do not need to call `wait_for_ready()` yourself — the framework handles it. - -`depends_on` is scoped to Hassette's direct children — the top-level services registered with the `Hassette` instance. It is not used for child resources inside a service. - -!!! note "Coordinator gate vs. service dependency" - `Hassette.ready_event` is a separate mechanism from `depends_on`. It signals that the coordinator is ready to begin starting services, but does not guarantee that every service has finished starting. Services like `BusService`, `SchedulerService`, and `FileWatcherService` wait on it before processing user-visible work. Do not confuse this coordinator gate with `depends_on`, which expresses readiness ordering between individual services. - -### Initialization and shutdown order - -Both startup and shutdown use **wave-based ordering**. The dependency graph is partitioned into levels: level 0 has services with no dependencies, level 1 has services that depend only on level 0, and so on. Each wave starts (or shuts down) concurrently via `asyncio.gather`, but waves execute sequentially — so all dependencies are guaranteed ready before their dependents begin. - -Shutdown proceeds in reverse wave order. Services that depend on others shut down first; services depended upon (like `DatabaseService`) shut down last. For example, `AppHandler` shuts down before `StateProxy`, and `StateProxy` shuts down before `WebsocketService`. - -### Cycle detection - -Hassette validates the dependency graph at construction time. If a cycle exists — for example, service A declares `depends_on = [B]` and B declares `depends_on = [A]` — startup raises a `ValueError` with the full cycle path before any service starts: - -``` -ValueError: Cycle detected: CommandExecutor → DatabaseService → CommandExecutor -``` - -Fix cycles by restructuring the dependency so one service no longer needs the other to be ready first. - -### Framework dependency graph - -The key built-in services have the following declared dependencies, organized by startup wave: - -```mermaid -graph BT - subgraph wave0["Wave 0 — No Dependencies"] - DB[DatabaseService] - WS[WebsocketService] - BUS[BusService] - SCHED[SchedulerService] - end - - subgraph wave1["Wave 1"] - CMD[CommandExecutor] - API[ApiResource] - end - - subgraph wave2["Wave 2"] - SP[StateProxy] - TQS[TelemetryQueryService] - end - - subgraph wave3["Wave 3"] - AH[AppHandler] - end - - subgraph wave4["Wave 4"] - RQS[RuntimeQueryService] - end - - subgraph wave5["Wave 5 — Last to Start"] - WEB[WebApiService] - end - - CMD --> DB - TQS --> DB - API --> WS - SP --> WS & API & BUS & SCHED - AH --> WS & API & BUS & SCHED & SP - RQS --> BUS & SP & AH - WEB --> RQS & TQS - - style wave0 fill:#e8f0ff,stroke:#6688cc - style wave1 fill:#dde8f8,stroke:#6688cc - style wave2 fill:#d0e0f0,stroke:#6688cc - style wave3 fill:#c4d8e8,stroke:#6688cc - style wave4 fill:#b8d0e0,stroke:#6688cc - style wave5 fill:#acc8d8,stroke:#6688cc -``` - -An arrow from A to B means "A depends on B" — B must be ready before A initializes. Shutdown proceeds in reverse wave order. - -For detailed diagrams of each subsystem's internals, see [System Internals](internals.md). - -!!! note "EventStreamService" - `EventStreamService` has a constructor-time dependency: it passes a receive stream to `BusService` at Hassette construction time, before any service initializes. This structural ordering is enforced by child registration order rather than `depends_on`, which only expresses runtime readiness dependencies. - -## Deep Dive - -For detailed Mermaid diagrams of every subsystem's internals — event routing, scheduler heap, state caching, the resource lifecycle state machine, and more — see [System Internals](internals.md). - -## See Also - -- [Apps](apps/index.md) – how apps fit into the overall architecture. -- [Bus](bus/index.md) – subscribing to and handling events. -- [Scheduler](scheduler/index.md) – scheduling jobs and intervals. -- [API](api/index.md) – interacting with Home Assistant. -- [States](states/index.md) – working with state models. -- [Configuration](configuration/index.md) – Hassette and app configuration. -- [Web UI](../web-ui/index.md) – browser-based monitoring and management. -- [API Reference](../../reference/index.md) – full auto-generated reference for all public modules. +- [Apps](apps/index.md): the `App` base class, lifecycle hooks, and `AppConfig`. +- [Bus](bus/index.md): subscribing to events, filtering, handler options. +- [Scheduler](scheduler/index.md): triggers, job groups, jitter. +- [API](api/index.md): service calls, state reads, WebSocket commands. +- [States](states/index.md): state models, domain access, type conversion. +- [Configuration](configuration/index.md): Hassette and app configuration. +- [Web UI](../web-ui/index.md): browser-based monitoring and management. +- [System Internals](internals.md): service lifecycle, startup sequence, resource hierarchy. +- [API Reference](../../reference/index.md): auto-generated reference for all public modules. ??? note "Advanced Topics" - Once you've written a few automations, these topics give you more control: - - - [Dependency Injection](bus/dependency-injection.md) – automatic event data extraction and type conversion. - - [Type Registry](../advanced/type-registry.md) – automatic value type conversion system. - - [State Registry](../advanced/state-registry.md) – domain to state model mapping. - - [Custom States](../advanced/custom-states.md) – defining your own state classes. + - [Dependency Injection](bus/dependency-injection.md): automatic event data extraction and type conversion. + - [Type Registry](../advanced/type-registry.md): automatic value type conversion system. + - [State Registry](../advanced/state-registry.md): domain to state model mapping. + - [Custom States](../advanced/custom-states.md): defining custom state classes for non-standard entity types. diff --git a/docs/pages/core-concepts/internals/index.md b/docs/pages/core-concepts/internals/index.md index 999525fca..358a5ba0c 100644 --- a/docs/pages/core-concepts/internals/index.md +++ b/docs/pages/core-concepts/internals/index.md @@ -1,3 +1,225 @@ # Architecture & Data Flow -*This page is being rewritten as part of the documentation overhaul.* +This section covers Hassette's internal architecture for contributors and advanced users. App authors do not need this section to build automations. + +Three pages make up the internals section: + +- **Architecture & Data Flow** (this page): event pipeline, service dependencies, component ownership +- [Lifecycle & Supervision](lifecycle.md): state machines, readiness signaling, `ServiceWatcher` restart logic +- [Per-Service Internals](service-details.md): bus routing, scheduler dispatch, database schema, state cache, web layer + +## Event Pipeline + +An event travels through four stages before reaching a handler. + +`WebsocketService` receives raw frames from Home Assistant over a persistent WebSocket connection. It forwards each event to `EventStreamService`, which owns an anyio memory channel that decouples reception from processing. `BusService` reads from that channel and expands each event into a set of topics ordered by specificity. It then filters the topics against registered listeners. `CommandExecutor` invokes the matching handler and writes an execution record to SQLite. + +```mermaid +flowchart TD + accTitle: Event and Data Flow + accDescr: Inbound event pipeline and outbound API calls + + subgraph ha_in["Home Assistant"] + HA_IN(("Inbound
WS events")) + end + + subgraph inbound["Inbound Pipeline"] + WS["WebsocketService
receive loop"] + ESS["EventStreamService
memory channel"] + BS["BusService
topic expand + filter"] + CE["CommandExecutor
invoke + record"] + WS --> ESS --> BS --> CE + end + + subgraph cache["State Cache"] + SP["StateProxy"] + end + + subgraph app["App"] + Handler["on_* handler"] + end + + subgraph outbound["Outbound"] + AR["ApiResource
(REST)"] + WSOut["WebsocketService
(WS send)"] + end + + subgraph ha_out["Home Assistant"] + HA_OUT(("Outbound
WS / REST")) + end + + HA_IN --> WS + WS -. "state_changed
(priority 100)" .-> SP + CE --> Handler + SP -. "self.states.*" .-> Handler + Handler --> AR & WSOut + AR & WSOut --> HA_OUT + + style ha_in fill:#f0f0f0,stroke:#999 + style ha_out fill:#f0f0f0,stroke:#999 + style inbound fill:#fff0e8,stroke:#cc8844 + style cache fill:#f0f8e8,stroke:#88aa66 + style app fill:#e8f0ff,stroke:#6688cc + style outbound fill:#fff0e8,stroke:#cc8844 +``` + +`StateProxy` holds a priority-100 subscription to `state_changed` events. Its cache updates before any app handler sees the event. `self.states.*` always reflects the current state at handler invocation time. + +Outbound calls go through the per-app `Api` handle. Single-entity reads use `ApiResource` over REST. Service calls and bulk state reads use `WebsocketService` over WebSocket. + +### Failure behavior + +| Failure | Behavior | +|---|---| +| WS disconnect | `WebsocketService` retries with exponential jitter. `ServiceWatcher` restarts the service if `serve()` fails, within the TRANSIENT budget (5 restarts / 300 s). | +| Auth failure | `InvalidAuthError` is a `FatalError` subclass. The `Service` base class catches it, calls `handle_crash()`, and `ServiceWatcher` triggers an immediate shutdown. | +| Handler timeout | Logged; invocation recorded as timed-out. | +| DB write failure | `CommandExecutor` retries up to 3 times, then drops the record with a counter increment. | + +## Service Dependencies + +### `depends_on` ClassVar + +Services declare startup dependencies as a class-level `ClassVar`. The framework reads these declarations at construction time and computes a topological startup order. + +```python +--8<-- "pages/core-concepts/snippets/index_depends_on.py" +``` + +`depends_on` scoping is intentional: only direct children of the `Hassette` root participate. Per-app resources (`Bus`, `Scheduler`, `Api`, `StateManager`) are not services and do not declare `depends_on`. + +### Wave-Based Ordering + +The dependency graph partitions into topological levels. All services in a wave start concurrently. The framework waits for every service in a wave to signal readiness before advancing. Shutdown runs in reverse wave order. + +A `ValueError` with the full cycle path raises at construction time if the dependency graph contains a cycle. + +### Framework Dependency Graph + +```mermaid +graph BT + accTitle: Service Dependency Graph + accDescr: Wave-based startup order, wave 0 at the top + + subgraph wave0["Wave 0 — No Dependencies"] + DB[DatabaseService] + WS[WebsocketService] + end + + subgraph wave1["Wave 1"] + BUS[BusService] + SCHED[SchedulerService] + CMD[CommandExecutor] + API[ApiResource] + LOG[LoggingService] + TQS[TelemetryQueryService] + end + + subgraph wave2["Wave 2"] + SP[StateProxy] + SW[ServiceWatcher] + end + + subgraph wave3["Wave 3"] + AH[AppHandler] + end + + subgraph wave4["Wave 4"] + RQS[RuntimeQueryService] + end + + subgraph wave5["Wave 5 — Last to Start"] + WEB[WebApiService] + end + + BUS --> DB + SCHED --> DB + CMD --> DB + LOG --> DB + TQS --> DB + API --> WS + SW --> BUS + SP --> WS & API & BUS & SCHED + AH --> WS & API & BUS & SCHED & SP + RQS --> BUS & SP & AH & LOG + WEB --> RQS & TQS + + style wave0 fill:#e8f0ff,stroke:#6688cc + style wave1 fill:#dde8f8,stroke:#6688cc + style wave2 fill:#d0e0f0,stroke:#6688cc + style wave3 fill:#c4d8e8,stroke:#6688cc + style wave4 fill:#b8d0e0,stroke:#6688cc + style wave5 fill:#acc8d8,stroke:#6688cc +``` + +Shutdown proceeds in reverse wave order. `WebApiService` stops first. `DatabaseService` and `WebsocketService` stop last. + +## Component Ownership + +Every component is a `Resource` in a parent/child tree rooted at the `Hassette` instance. Apps receive four lightweight handles (`Bus`, `Scheduler`, `Api`, `StateManager`) that delegate to shared framework services. + +```mermaid +graph TD + accTitle: Component Ownership Tree + accDescr: Parent-child resource hierarchy from Hassette root to per-app handles + + Hassette + + subgraph infra["Infrastructure Services"] + EventStreamService + DatabaseService + CommandExecutor + WebsocketService + end + + subgraph core["Core Services"] + BusService + SchedulerService + ApiResource + StateProxy + LoggingService + ServiceWatcher + end + + subgraph web["Web Layer"] + WebApiService + RuntimeQueryService + TelemetryQueryService + end + + subgraph apps["App Management"] + AppHandler + AppLifecycleService + AppRegistry + end + + Hassette --- infra + Hassette --- core + Hassette --- web + Hassette --- apps + + AppHandler --> AppLifecycleService + AppHandler --> AppRegistry + + subgraph perapp["Per-App Resources (0..N instances)"] + App + App --> Bus + App --> Scheduler + App --> Api + App --> StateManager + end + + AppLifecycleService --> App + + style infra fill:#f0f8e8,stroke:#88aa66 + style core fill:#fff0e8,stroke:#cc8844 + style web fill:#f0f8e8,stroke:#88aa66 + style apps fill:#fff0e8,stroke:#cc8844 + style perapp fill:#f8f0ff,stroke:#8866cc +``` + +Per-app handles are thin wrappers around the shared services. When an app shuts down, its `Bus` removes listeners from `BusService` and its `Scheduler` removes jobs from `SchedulerService`. Each handle cleans up its own registrations. The shared services continue running for other apps. + +## EventStreamService: Constructor-Time Dependency + +`EventStreamService` has no `depends_on` because its streams are created synchronously in `__init__`, before the lifecycle begins. The `Hassette` root registers `EventStreamService` before `BusService`, ensuring the receive stream exists when `BusService` is constructed. This ordering is structural, not declared through `depends_on`. diff --git a/docs/pages/core-concepts/internals/lifecycle.md b/docs/pages/core-concepts/internals/lifecycle.md index fd0ceb04a..dc0b0e16b 100644 --- a/docs/pages/core-concepts/internals/lifecycle.md +++ b/docs/pages/core-concepts/internals/lifecycle.md @@ -1,3 +1,116 @@ # Resource Lifecycle & Supervision -*This page is being rewritten as part of the documentation overhaul.* +## What Happens When a Service Fails + +When a `Service` raises an unhandled exception, Hassette transitions it to `FAILED` and emits a service status event. `ServiceWatcher` receives that event and consults the service's `restart_spec` to decide what comes next. `restart_spec` is a frozen dataclass attached to the service class as a class attribute. + +The outcome depends on three things: the exception type, how many restarts have already occurred within the current time window, and the service's `restart_type`. Most failures result in an exponential backoff delay followed by a fresh `initialize()` call. Structural failures that no retry will fix skip the backoff and either enter a long cooldown period or shut the system down entirely. + +`ServiceWatcher` tracks restarts in a sliding-window `RestartBudget` keyed per service. Each failed restart records a timestamp. Attempts that fall outside the budget window expire automatically. The budget resets after a successful recovery. A service that runs stably for five minutes after a failure starts fresh. + +## Restart Types + +`RestartType` controls what `ServiceWatcher` does when the restart budget is exhausted. + +**`PERMANENT`** means the service cannot be absent. When the budget runs out, `ServiceWatcher` transitions the service to `CRASHED` and calls `hassette.shutdown()`. `BusService` and `SchedulerService` use this type. Without them, no automations can run. + +**`TRANSIENT`** means the service can tolerate a long outage. When the budget runs out, the service enters `EXHAUSTED_COOLING`, waits for `cooldown_seconds`, resets the budget, and retries. If `max_cooldown_cycles` is set to a non-zero value, the service moves to `EXHAUSTED_DEAD` after that many failed cooldown cycles. `WebsocketService`, `DatabaseService`, and `WebApiService` use this type. + +**`TEMPORARY`** means the service is optional. When the budget runs out, the service transitions to `EXHAUSTED_DEAD` and stops permanently. Hassette continues running without it. `FileWatcherService` and `WebUIWatcherService` use this type. Losing live-reload capability does not impair automation execution. + +### Per-Service Restart Specs + +| Service | `restart_type` | `budget_intensity` | `budget_period_seconds` | Notes | +|---|---|---|---|---| +| `BusService` | `PERMANENT` | 2 | 30 | Core event dispatch | +| `SchedulerService` | `PERMANENT` | 2 | 30 | Core job execution | +| `WebsocketService` | `TRANSIENT` | 5 | 300 | `startup_timeout_seconds=60` | +| `DatabaseService` | `TRANSIENT` | 3 | 120 | `fatal_error_names=("SchemaVersionError",)` | +| `WebApiService` | `TRANSIENT` | 3 | 60 | HTTP API and UI | +| `FileWatcherService` | `TEMPORARY` | 3 | 60 | Config hot-reload | + +## Restart Budget + +The budget uses a sliding window defined by two fields: `budget_intensity` (maximum restarts allowed) and `budget_period_seconds` (the window size in seconds). Timestamps older than `budget_period_seconds` are evicted before each check. + +When `budget.is_exhausted()` returns `True`, `ServiceWatcher` calls `_handle_exhaustion()`. The budget resets on successful recovery. `record_restart()` is not called again until the service fails after being healthy. + +Backoff between restart attempts uses exponential growth: `backoff_base_seconds * (backoff_multiplier ** (attempt - 1))`, capped at `backoff_max_seconds`. The defaults produce delays of 2 s, 4 s, 8 s, and so on up to 60 s. + +## Error Routing + +`ServiceWatcher` checks the exception type name before consulting the budget. Three routing layers apply, from least to most severe. + +**Normal errors.** The exception name appears in neither `fatal_error_names` nor `non_retryable_error_names`. The restart proceeds through the budget check and backoff sequence. + +**Non-retryable errors.** The exception name is in `non_retryable_error_names`. The restart is skipped entirely. `ServiceWatcher` calls `_handle_exhaustion()` directly, as if the budget were already spent. This applies to configuration errors that cannot self-correct. + +**Fatal errors.** The exception name is in `fatal_error_names`, or the exception is a `FatalError` subclass. The service transitions immediately to `CRASHED` and `hassette.shutdown()` is called. `DatabaseService` uses this for `SchemaVersionError`. A schema version mismatch requires human intervention, so no retry is attempted. + +## RestartSpec Reference + +`RestartSpec` is a frozen dataclass. Attach it to a `Service` subclass as a class variable named `restart_spec`. + +```python +--8<-- "pages/core-concepts/snippets/internals_restart_spec.py" +``` + +| Field | Type | Default | Description | +|---|---|---|---| +| `restart_type` | `RestartType` | `TRANSIENT` | Governs behavior when the restart budget is exhausted. | +| `budget_intensity` | `int` | `5` | Maximum restarts allowed within `budget_period_seconds`. | +| `budget_period_seconds` | `float` | `300.0` | Sliding window size in seconds. | +| `backoff_base_seconds` | `float` | `2.0` | Starting delay for exponential backoff. | +| `backoff_multiplier` | `float` | `2.0` | Factor applied on each successive restart attempt. | +| `backoff_max_seconds` | `float` | `60.0` | Maximum backoff delay in seconds. | +| `startup_timeout_seconds` | `float` | `30.0` | How long `ServiceWatcher` waits for `mark_ready()` after a restart. | +| `cooldown_seconds` | `float` | `300.0` | Duration of the long-cooldown phase (`TRANSIENT` only). | +| `max_cooldown_cycles` | `int` | `0` | Maximum cooldown cycles before `EXHAUSTED_DEAD`. `0` means infinite. | +| `non_retryable_error_names` | `tuple[str, ...]` | `()` | Exception names that skip restart and go directly to exhaustion. | +| `fatal_error_names` | `tuple[str, ...]` | `()` | Exception names that trigger immediate shutdown. | + +## Resource State Machine + +Every `Resource` and `Service` tracks its status as a `ResourceStatus` value. + +```mermaid +stateDiagram-v2 + [*] --> NOT_STARTED + NOT_STARTED --> STARTING : initialize() + STARTING --> RUNNING : handle_running() + RUNNING --> STOPPING : shutdown() + RUNNING --> FAILED : unhandled exception + STOPPING --> STOPPED : clean exit + FAILED --> STARTING : ServiceWatcher restart + FAILED --> EXHAUSTED_COOLING : TRANSIENT budget exhausted + FAILED --> EXHAUSTED_DEAD : TEMPORARY budget exhausted + FAILED --> CRASHED : PERMANENT budget exhausted / fatal error + EXHAUSTED_COOLING --> STARTING : cooldown complete, budget reset + EXHAUSTED_COOLING --> EXHAUSTED_DEAD : max_cooldown_cycles exceeded + CRASHED --> [*] + EXHAUSTED_DEAD --> [*] + STOPPED --> [*] +``` + +`NOT_STARTED` is the initial state. `STARTING` covers the period from `initialize()` entry through lifecycle hook execution. `RUNNING` is the normal operating state. For services, it persists for the lifetime of the `serve()` loop. `STOPPING` and `STOPPED` represent clean shutdown. `FAILED` is a transient state. `ServiceWatcher` acts on it immediately and moves the service forward. `CRASHED` and `EXHAUSTED_DEAD` are terminal states from which no recovery occurs. `EXHAUSTED_COOLING` is a waiting state. The service re-enters `STARTING` after the cooldown period completes. + +## Readiness vs Running + +`RUNNING` status and readiness are separate signals. `handle_running()` sets `status = ResourceStatus.RUNNING` and emits a status event. `mark_ready()` sets a readiness `asyncio.Event` that dependents wait on via `_auto_wait_dependencies()`. + +A service enters `RUNNING` at the end of `initialize()`, after all lifecycle hooks complete. It signals readiness by calling `mark_ready()` at whatever internal point it is prepared to serve requests. `WebsocketService` calls `mark_ready()` after the first successful authentication with Home Assistant. `BusService` calls it after the internal event stream is open. + +`depends_on` lists the resource types a service waits for before running its own `on_initialize()`. The wait is on readiness, not on `RUNNING` status. A dependent service does not proceed until all declared dependencies have called `mark_ready()`. + +| Signal | Set by | Waited on by | +|---|---|---| +| `status = RUNNING` | `handle_running()` at end of `initialize()` | Nothing (informational only) | +| `ready_event` | `mark_ready()` at service-defined readiness point | Dependents via `depends_on` auto-wait | + +## Wave Startup and Shutdown + +Hassette starts services in dependency order. Services with no `depends_on` start first. Services that declare `depends_on` start after all their dependencies have signaled readiness. Services at the same dependency depth start concurrently. + +Shutdown runs in reverse order. Services that depended on others stop first. A service in `STOPPING` waits for its children to reach terminal states before completing. `ServiceWatcher` itself depends on `BusService`. It shuts down after `BusService` stops accepting events, so no supervision messages are lost during teardown. + +For the full dependency graph and startup wave diagram, see [Architecture & Data Flow](index.md). diff --git a/docs/pages/core-concepts/internals/service-details.md b/docs/pages/core-concepts/internals/service-details.md index d4ea9d5a4..d7c4fcaf4 100644 --- a/docs/pages/core-concepts/internals/service-details.md +++ b/docs/pages/core-concepts/internals/service-details.md @@ -1,3 +1,375 @@ -# Per-Service Internals +# System Internals: Per-Service Details -*This page is being rewritten as part of the documentation overhaul.* +Each section follows the event pipeline from a Home Assistant WebSocket frame through to a recorded execution. A top-to-bottom pass traces a single inbound event through every service it touches. + +## Bus Internals + +The `Bus` handle translates `on_*()` calls into `Listener` objects. The shared `BusService` indexes those listeners by topic and drives dispatch. + +```mermaid +flowchart TD + accTitle: Bus Event Routing + accDescr: From app subscription through predicate filtering to handler invocation + + subgraph registration["Registration"] + on["Bus.on_*()"] + pca["Predicates (P)
Conditions (C)
Accessors (A)"] + L["Listener"] + on --> pca --> L + end + + subgraph routing["BusService Router"] + exact["Exact topics
light.kitchen"] + glob["Glob topics
light.*"] + end + + subgraph dispatch["Dispatch"] + match["Predicate check"] + exec["CommandExecutor"] + handler["App handler"] + match --> exec --> handler + end + + L -- "add_listener()" --> exact & glob + exact & glob -- "event arrives" --> match + + style registration fill:#e8f0ff,stroke:#6688cc + style routing fill:#f0f8e8,stroke:#88aa66 + style dispatch fill:#fff0e8,stroke:#cc8844 +``` + +### Event Dispatch Pipeline + +`BusService.dispatch()` runs these steps on every inbound event. + +A `state_changed` event for `light.office` expands into three candidate topics in specificity order: + +1. `hass.event.state_changed.light.office` (entity-exact) +2. `hass.event.state_changed.light.*` (domain-glob) +3. `hass.event.state_changed` (base topic) + +Events with other `event_type` values expand to only the base topic. + +`BusService` iterates the three topics in order and collects matching listeners from the `Router`. The router stores two separate indexes: one for exact topics, one for glob topics. A listener is collected at most once, deduplicated by `listener_id`. + +Each collected listener runs `Listener.matches(event)`. Predicates registered via `P.*` and conditions registered via `C.*` are evaluated here. Listeners that fail the predicate are silently skipped. + +Each passing listener spawns a `TaskBucket` task that calls `CommandExecutor.execute()`. All matching listeners for a given event run in parallel. + +### `Listener` Internal Structure + +`Listener` composes four sub-structs: + +| Sub-struct | Holds | +|---|---| +| `ListenerIdentity` | Ownership and telemetry fields (app key, name, topic, source location) | +| `ListenerOptions` | Behavioral timing parameters (debounce, throttle, once, priority, immediate) | +| `HandlerInvoker` | Handler invocation, dispatch, and rate limiting | +| `DurationConfig` | Duration-hold configuration and timer lifecycle | + +Registration is synchronous with the database. `sub.listener.db_id` is a valid integer immediately when the awaited `bus.on_*()` call returns. + +### Rate Limiting + +`HandlerInvoker` delegates to `RateLimiter` when `debounce` or `throttle` is set. + +`RateLimiter.debounced_call()` cancels any pending debounce task before spawning a replacement. Each replacement captures the current event in its closure. Only the most recent event fires after the quiet window elapses. The previous task's closure is discarded entirely. + +`RateLimiter.throttled_call()` records `time.monotonic()` on each call and drops the handler if fewer than `throttle` seconds have elapsed since the last invocation. The check-and-set is atomic under asyncio's single-threaded event loop. + +### Listener Behavior Options + +| Option | Effect | +|---|---| +| `debounce=N` | Events are buffered; the handler fires only after N seconds of quiet | +| `throttle=N` | The handler fires immediately, then further calls are suppressed for N seconds | +| `duration=N` | The handler fires only if the predicate still matches after N seconds | +| `once=True` | The listener auto-cancels after the first successful invocation | +| `priority=N` | Lower values dispatch first; `StateProxy` uses priority 100 | + +## Scheduler Internals + +`Scheduler` wraps convenience methods (`run_in`, `run_once`, `run_every`, `run_daily`, `run_cron`, `schedule`) around trigger objects. All jobs enter a shared min-heap inside `SchedulerService`. + +```mermaid +flowchart TD + accTitle: Scheduler Job Pipeline + accDescr: From convenience methods through triggers to the dispatch loop + + subgraph api["Scheduler API"] + methods["run_*() / schedule()"] + end + + subgraph triggers["Triggers"] + T["Trigger
implements TriggerProtocol"] + end + + subgraph engine["SchedulerService"] + heap["Min-heap
by next_run"] + loop["serve() loop"] + exec["CommandExecutor"] + heap -- "pop due" --> loop --> exec + end + + methods --> T + T -- "ScheduledJob" --> heap + exec -. "re-enqueue
if recurring" .-> heap + + style api fill:#e8f0ff,stroke:#6688cc + style triggers fill:#f0f8e8,stroke:#88aa66 + style engine fill:#fff0e8,stroke:#cc8844 +``` + +### Trigger Evaluation Loop + +`SchedulerService.serve()` loops indefinitely. Each iteration: + +1. Calls `_ScheduledJobQueue.pop_due_and_peek_next(now)`: pops all jobs whose `next_run` is at or before `now` and returns the next scheduled time. +2. Spawns a `TaskBucket` task for each due job via `CommandExecutor`. +3. Sleeps until the next job's `next_run` time, clamped between `min_delay` and `max_delay`. + +When no jobs are queued, the loop sleeps for `default_delay` seconds. The `kick()` method interrupts the sleep immediately. It fires when a new job is registered with an earlier run time than the current sleep target. + +### Trigger-to-Job Translation + +| Convenience method | Trigger object | Behavior | +|---|---|---| +| `run_in(fn, seconds=N)` | `After` | One-shot after N seconds | +| `run_once(fn, at=T)` | `Once` | One-shot at a specific time | +| `run_every(fn, seconds=N)` | `Every` | Recurring every N seconds | +| `run_daily(fn, at="HH:MM")` | `Daily` | Wall-clock daily at HH:MM | +| `run_cron(fn, expression=E)` | `Cron` | Croniter expression | +| `schedule(fn, trigger=T)` | Custom `T` | Implements `TriggerProtocol` | + +`Daily` uses `CronTrigger` internally rather than a 24-hour interval. A naive fixed interval would drift across DST transitions. `CronTrigger` computes `next_run` in the configured timezone and handles fall-back ambiguity by selecting the first occurrence. + +### Missed-Job Handling + +`SchedulerService` does not make up missed executions. A job whose `next_run` passed during a shutdown or restart fires once on the next `pop_due` call, not multiple times for the skipped interval. `Every` triggers call `advance_past(now)` to advance `next_run` past the current time, so the job schedules forward from now rather than from its originally missed time. + +### Jitter and Job Groups + +`jitter=N` adds a random offset drawn from `[0, N]` seconds at enqueue time. Jobs in the same group share a `group=` label. `Scheduler.cancel_group(name)` cancels all jobs with that label. Named jobs (`name=`) support deduplication: `if_exists="skip"` leaves the existing job in place; `if_exists="replace"` cancels the existing job and re-registers. + +## StateManager and StateProxy + +`StateProxy` maintains an in-memory cache of all entity states. `StateManager` provides typed per-app access with Pydantic model validation. + +```mermaid +flowchart TD + accTitle: State Management + accDescr: How entity states flow from HA through the cache to typed app access + + subgraph sources["Cache Population"] + bus_sub["Bus subscription
(priority 100)"] + poll["Periodic poll
(run_every)"] + end + + subgraph proxy["StateProxy"] + cache["In-memory dict
entity_id to HassStateDict"] + end + + subgraph access["StateManager (per-app)"] + attr["self.states.light
DomainStates[LightState]"] + item["self.states[CustomState]
DomainStates[T]"] + get["self.states.get(entity_id)
raw lookup"] + end + + subgraph convert["Type Conversion"] + SR["StateRegistry
domain to model class"] + TR["TypeRegistry
scalar conversion"] + end + + bus_sub --> cache + poll --> cache + cache --> attr & item & get + attr & item --> SR & TR + + style sources fill:#f0f8e8,stroke:#88aa66 + style proxy fill:#fff0e8,stroke:#cc8844 + style access fill:#e8f0ff,stroke:#6688cc + style convert fill:#f8f0ff,stroke:#8866cc +``` + +### Cache Population + +`StateProxy` declares `depends_on: [WebsocketService, ApiResource, BusService, SchedulerService]`. Once all four dependencies are ready, `on_initialize()` runs two setup steps. + +First, `subscribe_to_events()` registers a bus subscription on `Topic.HASS_EVENT_STATE_CHANGED` at priority 100. Priority 100 means `StateProxy`'s handler updates the cache before any user handler sees the event. App handlers always observe current state. + +Second, `_load_cache()` bulk-fetches all entity states via `get_states_raw()` and populates the `states` dict. A periodic `run_every` job re-runs `_load_cache()` at `state_proxy_poll_interval_seconds` intervals to recover from any missed events. + +### Lock-Free Reads + +`StateProxy.get_state()` reads from `self.states` without acquiring a lock. CPython dict reads are safe without locking because dict assignment replaces whole objects atomically. Writers use a `FairAsyncRLock` when updating the dict to prevent concurrent write corruption. Readers never contend with each other. + +### Type Conversion and `context_id` Caching + +`DomainStates` wraps a `StateProxy` and a model class. On each entity access, `DomainStates._validate_or_return_from_cache()` extracts the `context_id` from the raw state dict (a UUID from Home Assistant's event context). If the `context_id` matches the cached `CacheValue`, the previously validated Pydantic model is returned without re-running validation. A new `context_id` triggers a full validation pass and replaces the cached entry. + +`StateManager.__getattr__` caches `DomainStates` instances by model class in `_domain_states_cache`. Accessing `self.states.light` multiple times returns the same `DomainStates` object. + +### Disconnect and Reconnect + +On WebSocket disconnect, `StateProxy` clears `self.states` and calls `mark_not_ready()`. State reads during this window raise `ResourceNotReadyError`. On reconnect, `_load_cache()` bulk-reloads all states, then `subscribe_to_events()` re-registers the bus subscription. `mark_ready()` then unblocks any waiters. + +## Api Internals + +The per-app `Api` handle delegates all network I/O to two shared singletons: `ApiResource` (REST) and `WebsocketService` (WebSocket). + +```mermaid +flowchart TD + accTitle: Api Transport + accDescr: How per-app Api delegates to shared REST and WebSocket transports + + subgraph app["Per-App"] + Api + end + + subgraph transport["Shared Singletons"] + AR["ApiResource
(aiohttp)"] + WS["WebsocketService"] + end + + subgraph ha["Home Assistant"] + REST["REST API"] + WSAPI["WebSocket API"] + end + + Api -- "get_state(id)" --> AR + Api -- "call_service()
get_states()" --> WS + AR -- "HTTP" --> REST + WS -- "WS frame" --> WSAPI + + style app fill:#e8f0ff,stroke:#6688cc + style transport fill:#fff0e8,stroke:#cc8844 + style ha fill:#f0f0f0,stroke:#999 +``` + +### Transport Routing + +| Method | Transport | Pattern | +|---|---|---| +| `get_state(entity_id)` | REST | `GET /api/states/{id}` | +| `get_state_raw(entity_id)` | REST | `GET /api/states/{id}` | +| `get_states()` | WebSocket | `get_states` command | +| `call_service()` | WebSocket | `call_service` command | +| `fire_event()` | WebSocket | `fire_event` command | +| `ws_send_and_wait()` | WebSocket | Raw message, blocks for result | +| `ws_send_json()` | WebSocket | Raw message, fire-and-forget | +| `rest_request()` | REST | Raw `aiohttp` request | + +`Api.rest_request()` and `Api.ws_send_and_wait()` are escape hatches for HA API surface not covered by the typed methods. + +### Authentication + +`HassetteConfig.token` holds a long-lived access token. `ApiResource` injects it as a `Bearer` header on every REST request. The WebSocket auth handshake sends an `auth` frame immediately after connection. Auth failures raise `InvalidAuthError`, a `FatalError` subclass. The system shuts down immediately rather than retrying. + +### Connection Management + +`ApiResource` holds a single `aiohttp.ClientSession`. `WebsocketService` manages the WebSocket connection with tenacity retry logic (default 5 attempts with exponential jitter, configurable via `connect_retry_max_attempts`). On reconnect, `StateProxy` bulk-reloads state and re-registers its subscription. Per-app `Api` instances share the same underlying connections. There is no per-app connection pool. + +## Database Internals + +`DatabaseService` stores all telemetry in a local SQLite file. Schema management uses SQLite's native `PRAGMA user_version` with numbered `.sql` migration files. + +### Schema + +The database has five tables: + +| Table | Purpose | +|---|---| +| `sessions` | One row per Hassette process run; tracks start/stop time and error info | +| `listeners` | One row per registered bus listener; natural key `(app_key, instance_index, name, topic)` | +| `scheduled_jobs` | One row per registered scheduler job; natural key `(app_key, instance_index, job_name)` | +| `executions` | One row per handler invocation or job execution; unified with `kind` discriminator | +| `log_records` | Captured log lines with `execution_id` linkage | + +`executions` unifies what were previously two separate tables. The `kind` column holds `'handler'` or `'job'`. `listener_id` and `job_id` are nullable foreign keys into `listeners` and `scheduled_jobs` respectively. A `CHECK` constraint enforces that exactly one is non-null per row: `CHECK ((listener_id IS NOT NULL) + (job_id IS NOT NULL) = 1)`. + +Six views (`active_listeners`, `active_app_listeners`, `active_framework_listeners`, and their scheduled-job equivalents) pre-filter retired registrations. + +### Migration System + +The migration runner reads `PRAGMA user_version` from the on-disk database and applies each numbered `.sql` file in order. Every migration runs inside `BEGIN IMMEDIATE` / `COMMIT`, with `PRAGMA user_version = N` as the final statement. A crash mid-migration leaves the version at N-1; the next startup retries from that point. + +On a fresh database (`user_version = 0`), the runner sets `auto_vacuum = INCREMENTAL` via a separate connection before any transaction. `PRAGMA auto_vacuum` cannot be changed inside `BEGIN IMMEDIATE`, so it must precede the first transaction. + +`DatabaseService._handle_schema_version()` runs before migrations: + +| On-disk version | Code action | +|---|---| +| Matches expected head | No action | +| Older than expected head | Log warning, delete database, allow migrations to recreate | +| `0` on existing file | Treat as stale (pre-PRAGMA-era schema), delete and recreate | +| Newer than expected head | Raise `SchemaVersionError`; manual intervention required | + +`SchemaVersionError` is declared in `DatabaseService.restart_spec.fatal_error_names`, so a version-ahead database stops the process immediately rather than retrying. + +### Write Pipeline + +`DatabaseService` serializes all writes through an `asyncio.Queue` drained by a single background `_db_write_worker()` task. Callers submit a coroutine to `DatabaseService.submit()` or place a raw item via `enqueue()`. The worker processes items one at a time. Each item is a `(coroutine, future)` pair; when a future is present, the result or exception is delivered through it. + +A dedicated read connection (`_read_db`) runs with `PRAGMA query_only = ON` and a 5-second busy timeout. Read queries never contend with the write worker. + +### Synchronous Registration + +`BusService` and `SchedulerService` declare `depends_on: [DatabaseService]`. The database is ready before any listener or job registration runs. Each `bus.on_*()` call awaits the `DatabaseService.submit()` call inline, so `sub.listener.db_id` is a valid integer when the awaited registration returns. Scheduler methods behave identically. + +### Retention + +A background loop in `DatabaseService.serve()` runs retention cleanup every `_RETENTION_INTERVAL_SECONDS` seconds. `_RETENTION_TABLES` declares each managed table with its retention column. Each entry carries a `retention_days_getter` lambda that reads the configured value from `HassetteConfig`. A separate size-failsafe loop runs on startup and periodically. When the database exceeds a configured size threshold, it deletes old rows in batches and runs incremental vacuum. + +## Web/UI Layer + +`WebApiService` starts a uvicorn/FastAPI server. Two data source services provide live state and historical telemetry to the frontend. + +```mermaid +flowchart TD + accTitle: Web Layer + accDescr: How the frontend connects to backend data sources + + subgraph browser["Browser"] + SPA["Preact SPA"] + end + + subgraph server["WebApiService"] + rest["REST endpoints
/api/health, /api/apps,
/api/telemetry/*, ..."] + ws["/api/ws
WebSocket"] + static["Static files
SPA catch-all"] + end + + subgraph data["Data Sources"] + RQS["RuntimeQueryService
live state, event buffer,
WS broadcast
"] + TQS["TelemetryQueryService
SQLite: listeners, jobs,
errors, sessions
"] + end + + SPA -- "fetch" --> rest + SPA <-- "push events" --> ws + rest --> RQS & TQS + ws --> RQS + + style browser fill:#e8f0ff,stroke:#6688cc + style server fill:#fff0e8,stroke:#cc8844 + style data fill:#f0f8e8,stroke:#88aa66 +``` + +### WebApiService + +`WebApiService.serve()` calls `create_fastapi_app()` and passes the result to `uvicorn.Server`. The uvicorn instance uses `ws="websockets-sansio"` and `lifespan="off"`. On `CancelledError` during shutdown, the service calls `asyncio.shield(server.shutdown())` to give uvicorn a graceful exit window before propagating cancellation. + +When `config.web_api.run` is `False`, `serve()` blocks on `shutdown_event.wait()` without binding a port. The dependency graph remains intact, and services that depend on `WebApiService` being ready still start normally. + +### RuntimeQueryService + +`RuntimeQueryService` subscribes to bus events on initialization and maintains a bounded in-memory event buffer. On each WebSocket-push-worthy event (state changes, app status changes, execution completions), `buffer_and_broadcast()` appends to the buffer and fans out to all registered WebSocket clients. + +Each connected client gets its own `asyncio.Queue` of bounded size (`_WS_CLIENT_QUEUE_MAX`). A slow client that exhausts its queue causes its frames to be dropped with a rate-limited log line. Clients register via `register_ws_client()` and deregister via `unregister_ws_client()`. + +### TelemetryQueryService + +`TelemetryQueryService` serves all historical data: listener registrations, job registrations, execution records, log lines, and session history. Queries run against `DatabaseService.read_db` (the dedicated read connection) to avoid contending with the write worker. + +### SPA Routing + +`create_fastapi_app()` mounts `/assets` and `/fonts` via `StaticFiles` for the built SPA output. A `spa_catch_all` handler covers all remaining paths: it serves root-level static files directly, returns 404 for API paths and filenames matching `_STATIC_EXTENSIONS`, and returns `index.html` for everything else. This enables client-side routing inside the Preact SPA. From 88bf2a24c886573308a19954e0846e82c445f937 Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Tue, 2 Jun 2026 17:06:22 -0500 Subject: [PATCH 045/160] docs: rewrite Scheduler section (overview, methods, management) Overview: leads with 3 common patterns (delay, interval, daily) before trigger types table. Fixed non-existent days param on run_every, fixed Once constructor type. Methods: shared parameters extracted to one table instead of repeating per method. run_minutely/run_hourly collapsed into one subsection. Added missing if_past param on run_once, on_error on schedule(). Fixed TriggerProtocol trigger_db_type return type. Management: lifecycle operations before error handling. Troubleshooting as collapsible sections. Fixed snippet underscore prefix. All ScheduledJob fields verified. --- docs/pages/core-concepts/scheduler/index.md | 66 +++-- .../core-concepts/scheduler/management.md | 136 ++++----- docs/pages/core-concepts/scheduler/methods.md | 279 ++++++++---------- .../snippets/scheduler_self_cancel.py | 13 +- 4 files changed, 220 insertions(+), 274 deletions(-) diff --git a/docs/pages/core-concepts/scheduler/index.md b/docs/pages/core-concepts/scheduler/index.md index f899fff5d..26710a9ee 100644 --- a/docs/pages/core-concepts/scheduler/index.md +++ b/docs/pages/core-concepts/scheduler/index.md @@ -1,46 +1,54 @@ -# Scheduler Overview +# Scheduler -The scheduler lets you run functions at specific times, after a delay, or on a repeating interval. It is available as `self.scheduler` in every app and runs all jobs safely inside Hassette's async event loop. Scheduled handlers can be async or sync — the scheduler wraps sync callables automatically. +The scheduler runs functions after a delay, at a specific time, or on a repeating interval. `self.scheduler` is available on every [App](../apps/index.md) instance. Hassette creates it at startup and runs all jobs in the async event loop. Sync callables are wrapped automatically. -Every scheduling method is backed by a **trigger object** that encapsulates when and how often a job fires. The convenience methods (`run_in`, `run_once`, `run_every`, `run_daily`, `run_cron`) create the appropriate trigger for you. For advanced use cases, pass a trigger directly to `schedule()`. +## Common Patterns -```mermaid -flowchart TD - subgraph app["Your App"] - methods["run_*() / schedule()"] - end +### Run after a delay - subgraph framework["Scheduler"] - SCHED["SchedulerService"] - JOB["ScheduledJob"] - SCHED -- "manages" --> JOB - end +`run_in` schedules a one-shot job that fires after a fixed number of seconds. - methods --> SCHED +```python +--8<-- "pages/core-concepts/scheduler/snippets/scheduler_run_in.py" +``` + +The `delay` parameter accepts seconds as a `float`. The job fires once and does not repeat. + +### Run on a repeating interval + +`run_every` schedules a job that fires repeatedly on a fixed interval. + +```python +--8<-- "pages/core-concepts/scheduler/snippets/scheduler_run_every.py" +``` - style app fill:#e8f0ff,stroke:#6688cc - style framework fill:#fff0e8,stroke:#cc8844 +`seconds`, `minutes`, and `hours` are all accepted. The scheduler is drift-resistant. Each run fires relative to the previous scheduled time, not the previous actual time. + +### Run daily at a fixed time + +`run_daily` schedules a job that fires once per day at a wall-clock time. + +```python +--8<-- "pages/core-concepts/scheduler/snippets/scheduler_run_daily.py" ``` +The `at` parameter accepts `"HH:MM"` strings. `run_daily` is DST-safe. It uses a cron expression internally and fires at the local wall-clock time regardless of clock changes. + ## Trigger Types -All triggers live in `hassette.scheduler.triggers` and are importable from `hassette.scheduler`: +Each convenience method creates a trigger object under the hood. `schedule()` accepts a trigger directly for cases not covered by the convenience methods. -| Trigger | Description | One-shot? | -|---------|-------------|-----------| +| Trigger | Description | One-shot | +|---|---|---| | `After(seconds=N)` | Fixed delay from now | Yes | -| `Once(at="HH:MM")` | Specific wall-clock time | Yes | -| `Every(seconds=N)` | Fixed interval, drift-resistant | No | -| `Daily(at="HH:MM")` | Once per day, DST-safe (cron-backed) | No | +| `Once(at="HH:MM")` | Specific wall-clock time (today, or tomorrow if past) | Yes | +| `Every(seconds=N)` | Fixed recurring interval | No | +| `Daily(at="HH:MM")` | Once per day at a wall-clock time | No | | `Cron("expr")` | Arbitrary cron expression (5- or 6-field) | No | -### Examples - -```python ---8<-- "pages/core-concepts/scheduler/snippets/scheduler_start_examples.py:start_examples" -``` +`hassette.scheduler` exports all five trigger types. ## Next Steps -- **[Scheduling Methods](methods.md)**: Explore `run_in`, `run_every`, `run_cron`, `schedule()`, and convenience helpers. -- **[Job Management](management.md)**: Learn how to name, track, cancel, and group jobs. +- [Scheduling Methods](methods.md): full method reference, cron expressions, custom triggers, and per-job options including `group`, `jitter`, and `if_exists` +- [Job Management](management.md): cancelling, grouping, error handling, and the `ScheduledJob` object diff --git a/docs/pages/core-concepts/scheduler/management.md b/docs/pages/core-concepts/scheduler/management.md index f6c0c51ca..eb44cc470 100644 --- a/docs/pages/core-concepts/scheduler/management.md +++ b/docs/pages/core-concepts/scheduler/management.md @@ -1,17 +1,17 @@ # Job Management -When you schedule a task, you receive a [`ScheduledJob`][hassette.scheduler.classes.ScheduledJob] object. You can use this to manage the job's lifecycle. +`schedule()` and all convenience methods return a [`ScheduledJob`][hassette.scheduler.classes.ScheduledJob]. The object carries metadata about the job and provides the primary cancellation method. -## The ScheduledJob Object +## The `ScheduledJob` Object | Attribute | Type | Description | |---|---|---| -| `name` | `str` | Human-readable name. Auto-generated from the callable and trigger if not provided. Used in logs and for idempotent re-registration. | -| `next_run` | `ZonedDateTime` | Timestamp of the next scheduled execution (unjittered). | -| `trigger` | `TriggerProtocol \| None` | The trigger that drives scheduling. `None` should not occur for jobs created via the public API. | -| `group` | `str \| None` | Group name, if the job was registered with `group=`. Used for bulk cancellation via `cancel_group()`. | -| `jitter` | `float \| None` | Seconds of random offset applied at enqueue time, if specified. | -| `job_id` | `int` | Unique integer identifier assigned at creation. Stable for the lifetime of the job object. | +| `name` | `str` | Human-readable name. Auto-generated from the callable and trigger when not provided. Appears in logs and serves as the key for idempotent re-registration. | +| `next_run` | `ZonedDateTime` | Unjittered logical fire time. Subsequent trigger calculations use this as `previous_run`. | +| `trigger` | `TriggerProtocol \| None` | The trigger that drives scheduling. | +| `group` | `str \| None` | Group name, set when the job was registered with `group=`. `cancel_group()` uses this for bulk cancellation. | +| `jitter` | `float \| None` | Seconds of random offset applied at enqueue time, if configured. | +| `db_id` | `int \| None` | Database row ID assigned after registration. Valid immediately when the scheduling call returns. | ```python --8<-- "pages/core-concepts/scheduler/snippets/scheduler_job_metadata.py" @@ -19,137 +19,117 @@ When you schedule a task, you receive a [`ScheduledJob`][hassette.scheduler.clas ## Cancelling Jobs -To stop a job from running, call `cancel()`. +`job.cancel()` removes the job from the scheduler queue immediately. The job does not fire again. ```python --8<-- "pages/core-concepts/scheduler/snippets/scheduler_cancel_job.py" ``` -### Cancelling Job Groups +Calling `cancel()` on an already-cancelled job is a silent no-op. The scheduler checks dequeue state at entry and returns immediately if the job is already gone. -Cancel all jobs in a named group at once with `cancel_group()`: +### Cancelling Groups + +`cancel_group(group)` cancels every job in a named group. Each member is individually dequeued and recorded as cancelled in the database. The call is a no-op when the group does not exist. ```python --8<-- "pages/core-concepts/scheduler/snippets/scheduler_management_patterns.py:cancel_group" ``` -This cancels each job in the group — removing it from the scheduler queue and recording it as cancelled in the database — then clears the group entry. No-op if the group does not exist. - ### Listing Jobs -Query registered jobs with `list_jobs()`: +`list_jobs()` returns all active jobs on this scheduler. `list_jobs(group=)` filters to a named group. ```python --8<-- "pages/core-concepts/scheduler/snippets/scheduler_management_patterns.py:list_jobs" ``` -### Checking Cancellation State +### Checking Whether a Job Is Active -`ScheduledJob` does not expose a `cancelled` attribute. Once a job is cancelled it is removed from the scheduler's queue, so the canonical way to check whether a job is still active is to query `list_jobs()`: +`ScheduledJob` has no `cancelled` attribute. Cancellation removes the job from the scheduler's internal index, so the canonical check is membership in `list_jobs()`: ```python --8<-- "pages/core-concepts/scheduler/snippets/scheduler_management_patterns.py:is_running" ``` -For the common case of guarding against a double-cancel (for example, when multiple code paths may both call `cancel()`), store the reference as `None` after cancelling and check before calling: +For the common case of guarding against a double-cancel, storing `None` after cancellation is simpler and avoids the `list_jobs()` scan: ```python --8<-- "pages/core-concepts/scheduler/snippets/scheduler_management_patterns.py:cancel_null" ``` -Calling `cancel()` on an already-cancelled job is a silent no-op — Hassette checks the job's internal state at entry and returns immediately if it has already been dequeued. The null-reference pattern above is still recommended when you need to know locally whether you've already cancelled the job. - -### Automatic Cleanup - -Hassette automatically cancels **all** jobs created by an app when that app stops or reloads. You only need to manually cancel jobs if you want to stop them *while the app is running* (e.g., a one-off timeout that is no longer needed). - -## Best Practices +## Automatic Cleanup -1. **Name your jobs**: Use the `name` parameter for better logs and safe reloads. +Hassette cancels all jobs created by an app when that app stops or reloads. Manual cancellation is only necessary to stop a job while the app is still running. - Names serve two purposes beyond readability. First, they appear in every log line that mentions the job — making it easy to correlate scheduler activity with a specific task. Second, names are the key used for idempotent re-registration: using `run_every(..., name="sensor_check", if_exists="skip")` ensures the same logical job is never duplicated even if the scheduling code runs more than once within the same app lifecycle. Use `if_exists="skip"` when the job configuration is stable across reloads. Use `if_exists="replace"` when the callable, trigger, or parameters may change — the old job is cancelled and the new configuration takes effect immediately. +## Self-Cancelling Jobs - ```python - --8<-- "pages/core-concepts/scheduler/snippets/scheduler_naming.py" - ``` - -2. **Avoid Overlapping Jobs**: If a job takes longer than its interval, multiple instances will run concurrently. Use an `asyncio.Lock` to guard the handler body: - ```python - --8<-- "pages/core-concepts/scheduler/snippets/scheduler_overlapping_jobs.py" - ``` - -## Self-Cancelling Job Pattern - -A common pattern for "poll until condition met" automations is a job that cancels itself from inside the handler. Store the `ScheduledJob` reference on the app instance so the handler can reach it: +A job can cancel itself from inside its own handler. The `ScheduledJob` reference is stored on the app instance so the handler can reach it: ```python --8<-- "pages/core-concepts/scheduler/snippets/scheduler_self_cancel.py" ``` -Once `cancel()` is called, the job is immediately removed from the scheduler queue. If the dispatch loop has already picked up the job for execution, it checks for dequeue after acquiring the job and skips the handler — so double-execution cannot occur. No external coordination needed. - -## Troubleshooting +`cancel()` removes the job from the queue immediately. If the dispatch loop has already picked up the job for execution, it checks dequeue state after acquiring the job and skips the handler. Double-execution cannot occur. -### Job Not Running? +## Avoiding Overlapping Executions -1. **Check the schedule**: Did you specify the wrong time string or interval? `run_daily(at="07:00")` fires at 7 AM; `run_once(at="07:00")` fires at 7 AM today or tomorrow if 7 AM has already passed. -2. **Exception in task**: If the task raises an unhandled exception, the scheduler catches it, logs it at `ERROR` level, and continues running — the job is not removed. Look for lines like: - ``` - ERROR hassette.core.command_executor - Job error (job_db_id=42) - Traceback (most recent call last): - ... - ValueError: unexpected sensor value - ``` - The job will keep firing on its normal schedule until you fix the underlying error or cancel the job manually. -3. **Reference Lost**: Losing the `ScheduledJob` variable doesn't stop the job (the scheduler holds a strong reference), but it prevents you from cancelling it later. +When a handler takes longer than its interval, the scheduler launches a new execution before the previous one finishes. An `asyncio.Lock` prevents concurrent runs: -### Runs Too Often? +```python +--8<-- "pages/core-concepts/scheduler/snippets/scheduler_overlapping_jobs.py" +``` -- Check units: `run_every(seconds=5)` is 5 seconds, not minutes. Use `run_every(minutes=5)` for a 5-minute interval. -- Check cron expressions: `run_cron("5 * * * *")` is "at minute 5 of every hour", not "every 5 minutes". Use `run_cron("*/5 * * * *")` for every-5-minutes. +The locked check at entry skips the tick rather than queuing behind it. ## Error Handling -When a scheduled job raises an exception, Hassette logs the error and records it for telemetry. The job continues to run on its normal schedule — it is not cancelled. You can also register an error handler to receive a typed [`SchedulerErrorContext`][hassette.scheduler.error_context.SchedulerErrorContext] with full exception details. - -There are two levels of error handlers: +When a scheduled job raises an exception, Hassette logs the error, records it for telemetry, and keeps the job on its normal schedule. An optional error handler receives a typed [`SchedulerErrorContext`][hassette.scheduler.error_context.SchedulerErrorContext] with full exception details. -- **App-level**: `scheduler.on_error(handler)` — applies to all jobs on this scheduler that don't have a per-registration handler. -- **Per-registration**: `on_error=` parameter on any scheduling method — takes precedence over the app-level handler. +### App-Level Error Handler -Both levels can be sync or async. +`scheduler.on_error(handler)` registers a fallback handler for all jobs on this scheduler that lack a per-registration handler. The handler resolves at dispatch time, not at registration time. -!!! warning "Register early — the reload gap" - The app-level handler is resolved at dispatch time, not at job registration time. To avoid a window where a job fires before `on_error()` is called, **register `on_error()` as the first statement in `on_initialize()`**. - -### App-level error handler +!!! warning "Registration order matters" + `on_error()` should be the first statement in `on_initialize()`. A job that fires before `on_error()` is called has no handler for that execution. ```python --8<-- "pages/core-concepts/scheduler/snippets/scheduler_error_handler_app.py" ``` -### Per-registration error handler +### Per-Registration Error Handler + +The `on_error=` parameter on any scheduling method takes precedence over the app-level handler. ```python --8<-- "pages/core-concepts/scheduler/snippets/scheduler_error_handler_per_job.py" ``` -### What `SchedulerErrorContext` contains +Both levels accept sync or async callables. + +### What `SchedulerErrorContext` Contains | Field | Type | Description | -|-------|------|-------------| -| `exception` | `BaseException` | The raised exception | -| `traceback` | `str` | Full formatted traceback | -| `job_name` | `str` | Human-readable job identity | -| `job_group` | `str \| None` | Group name if the job was registered with `group=` | -| `args` | `tuple[Any, ...]` | Positional arguments the job was scheduled with | -| `kwargs` | `dict[str, Any]` | Keyword arguments the job was scheduled with | +|---|---|---| +| `exception` | `BaseException` | The raised exception. | +| `traceback` | `str` | Full formatted traceback string. | +| `job_name` | `str` | Human-readable job name. | +| `job_group` | `str \| None` | Group name if the job was registered with `group=`. | +| `args` | `tuple[Any, ...]` | Positional arguments the job was scheduled with. | +| `kwargs` | `dict[str, Any]` | Keyword arguments the job was scheduled with. | !!! note "Error handler failures" - If the error handler itself raises or times out, the failure is logged at ERROR/WARNING and counted in the executor's error handler failure counter. The original job's telemetry record is unaffected. + When an error handler itself raises or times out, Hassette logs the failure and counts it against the executor's error handler failure counter. The original job's telemetry record is unaffected. + +??? note "Job not running?" + - **Wrong schedule.** A wrong time string or interval is the most common cause. `run_daily(at="07:00")` fires at 7 AM. `run_once(at="07:00")` fires at 7 AM today, or tomorrow if 7 AM has already passed. + - **Unhandled exception.** When a job raises, the scheduler catches it, logs at `ERROR`, and keeps the job on schedule. The job is not removed. Look for `ERROR hassette.core.command_executor` lines followed by a traceback. + - **Lost reference.** Losing the `ScheduledJob` variable does not stop the job. The scheduler holds a strong reference. Losing the reference only prevents manual cancellation. + +??? note "Job runs too often?" + - **Wrong units.** `run_every(seconds=5)` is 5 seconds. `run_every(minutes=5)` is 5 minutes. + - **Wrong cron expression.** `run_cron("5 * * * *")` fires at minute 5 of every hour. `run_cron("*/5 * * * *")` fires every 5 minutes. ## See Also -- [Scheduling Methods](methods.md) - All available scheduling methods -- [Apps Lifecycle](../apps/lifecycle.md) - Initialize and shutdown jobs properly -- [App Cache](../cache/index.md) - Remember job state across restarts +- [Scheduling Methods](methods.md) for registration options, `if_exists`, and per-job parameters +- [Apps Lifecycle](../apps/lifecycle.md) for how shutdown triggers automatic job cleanup diff --git a/docs/pages/core-concepts/scheduler/methods.md b/docs/pages/core-concepts/scheduler/methods.md index 698831f58..bc9e5ae6c 100644 --- a/docs/pages/core-concepts/scheduler/methods.md +++ b/docs/pages/core-concepts/scheduler/methods.md @@ -1,238 +1,196 @@ # Scheduling Methods -The scheduler provides several methods to run tasks at different times. All methods return a [`ScheduledJob`][hassette.scheduler.classes.ScheduledJob]. +All scheduling methods return a [`ScheduledJob`][hassette.scheduler.classes.ScheduledJob]. Every method is `async` and requires `await`. -## Primary Entry Point +## Shared Parameters -### `schedule` +These parameters are accepted by every scheduling method. Individual method tables list only method-specific parameters; shared parameters always apply. -The primary entry point for scheduling. All convenience methods delegate here. Use it directly when working with trigger objects. +| Parameter | Type | Default | Description | +|---|---|---|---| +| `name` | `str` | `""` | Name for the job. Auto-generated from the callable and trigger when empty. Must be unique within the app instance. | +| `group` | `str \| None` | `None` | Group name for bulk management. See [Job Groups](#job-groups). | +| `jitter` | `float \| None` | `None` | Random offset in seconds applied at enqueue time. See [Jitter](#jitter). | +| `timeout` | `float \| None` | `None` | Per-job timeout in seconds. `None` inherits the global `scheduler_job_timeout_seconds` setting. | +| `timeout_disabled` | `bool` | `False` | Disables timeout enforcement for this job, regardless of the global default. | +| `on_error` | `SchedulerErrorHandlerType \| None` | `None` | Per-job error handler. Overrides the app-level handler set via `scheduler.on_error()`. Invoked on any exception except `CancelledError`. | +| `if_exists` | `"error"` \| `"skip"` \| `"replace"` | `"error"` | Behavior when a job with the same name already exists. See [Idempotent Registration](#idempotent-registration). | +| `args` | `tuple \| None` | `None` | Positional arguments passed to the handler at call time. | +| `kwargs` | `Mapping \| None` | `None` | Keyword arguments passed to the handler at call time. | + +## `schedule(func, trigger)` + +The primary scheduling entry point. All convenience methods delegate here. `schedule()` accepts any object implementing `TriggerProtocol`, including the built-in trigger classes and custom implementations. | Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `func` | callable | *(required)* | The function to run. | -| `trigger` | `TriggerProtocol` | *(required)* | A trigger object that determines the schedule. See [Trigger Types](index.md#trigger-types). | -| `name` | `str` | `""` | Optional name for the job. | -| `group` | `str \| None` | `None` | Optional group name for bulk management. See [Job Groups](#job-groups). | -| `jitter` | `float \| None` | `None` | Optional seconds of random offset applied at enqueue time. See [Jitter](#jitter). | -| `timeout` | `float \| None` | `None` | Per-job timeout in seconds. `None` uses the global `scheduler_job_timeout_seconds` default. A positive `float` overrides it. See [Timeouts](../configuration/global.md#timeouts). | -| `timeout_disabled` | `bool` | `False` | When `True`, timeout enforcement is disabled for this job regardless of the global default. | -| `if_exists` | `"error"` \| `"skip"` \| `"replace"` | `"error"` | Behavior when a job with this name already exists. See [Idempotent Registration](#idempotent-registration). | -| `args` | `tuple` \| `None` | `None` | Positional arguments passed to `func`. | -| `kwargs` | `Mapping` \| `None` | `None` | Keyword arguments passed to `func`. | +|---|---|---|---| +| `func` | callable | *(required)* | The handler to run. | +| `trigger` | `TriggerProtocol` | *(required)* | A trigger object that determines first run time and recurrences. | + +Shared parameters apply ([see above](#shared-parameters)). ```python --8<-- "pages/core-concepts/scheduler/snippets/scheduler_schedule_examples.py" ``` ---- +## Delay and One-Shot Methods -## Convenience Methods +### `run_in(func, delay)` -### `run_in` -Run once after a delay. Useful for timeouts or delayed actions. +The handler runs once after a fixed delay. The `After` trigger fires once and does not repeat. | Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `func` | callable | *(required)* | The function to run. | -| `delay` | `float` | *(required)* | Delay in seconds before running. | -| `name` | `str` | `""` | Optional name for the job. | -| `group` | `str \| None` | `None` | Optional group name. See [Job Groups](#job-groups). | -| `timeout` | `float \| None` | `None` | Per-job timeout in seconds. `None` uses the global default. See [Timeouts](../configuration/global.md#timeouts). | -| `timeout_disabled` | `bool` | `False` | Disable timeout enforcement for this job. | -| `if_exists` | `"error"` \| `"skip"` \| `"replace"` | `"error"` | Behavior when a job with this name already exists. See [Idempotent Registration](#idempotent-registration). | -| `args` | `tuple` \| `None` | `None` | Positional arguments passed to `func`. | -| `kwargs` | `Mapping` \| `None` | `None` | Keyword arguments passed to `func`. | +|---|---|---|---| +| `func` | callable | *(required)* | The handler to run. | +| `delay` | `float` | *(required)* | Seconds to wait before running. | + +Shared parameters apply. ```python --8<-- "pages/core-concepts/scheduler/snippets/scheduler_run_in.py" ``` -### `run_once` -Run once at a specific wall-clock time. Accepts a `"HH:MM"` string or a `ZonedDateTime`. +### `run_once(func, at)` + +The handler runs once at a specific wall-clock time. The `Once` trigger fires once and does not repeat. | Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `func` | callable | *(required)* | The function to run. | -| `at` | `str \| ZonedDateTime` | *(required)* | Target time. `"HH:MM"` is interpreted as today in the system timezone; if already past, defers to tomorrow. | -| `name` | `str` | `""` | Optional name for the job. | -| `group` | `str \| None` | `None` | Optional group name. See [Job Groups](#job-groups). | -| `timeout` | `float \| None` | `None` | Per-job timeout in seconds. `None` uses the global default. See [Timeouts](../configuration/global.md#timeouts). | -| `timeout_disabled` | `bool` | `False` | Disable timeout enforcement for this job. | -| `if_exists` | `"error"` \| `"skip"` \| `"replace"` | `"error"` | Behavior when a job with this name already exists. See [Idempotent Registration](#idempotent-registration). | -| `args` | `tuple` \| `None` | `None` | Positional arguments passed to `func`. | -| `kwargs` | `Mapping` \| `None` | `None` | Keyword arguments passed to `func`. | - -!!! note "Past times defer to tomorrow" - If the `"HH:MM"` time has already passed today, the job is deferred to tomorrow and a WARNING is logged. To fire immediately instead, use `run_in` with a short delay. +|---|---|---|---| +| `func` | callable | *(required)* | The handler to run. | +| `at` | `str \| ZonedDateTime` | *(required)* | Target time. A `"HH:MM"` string is interpreted as today in the system timezone. A `ZonedDateTime` fires at the exact instant specified. | +| `if_past` | `"tomorrow"` \| `"error"` | `"tomorrow"` | Behavior when a `"HH:MM"` target has already passed today. `"tomorrow"` defers by one day and logs a WARNING. `"error"` raises `ValueError` instead. Has no effect on `ZonedDateTime` inputs. | + +Shared parameters apply. + +!!! note "Past `ZonedDateTime` inputs fire immediately" + When `at` is a `ZonedDateTime` in the past, the job fires at the next scheduler tick regardless of `if_past`. Only `"HH:MM"` strings are affected by `if_past`. ```python --8<-- "pages/core-concepts/scheduler/snippets/scheduler_run_once.py" ``` -### `run_every` -Run repeatedly at a fixed interval. Specify the interval using `hours`, `minutes`, and/or `seconds` keyword arguments (they are additive). +## Repeating Methods + +### `run_every(func, hours, minutes, seconds)` + +The handler runs repeatedly at a fixed interval. The `hours`, `minutes`, and `seconds` parameters are additive; at least one must be nonzero. The interval is drift-resistant: each next run is calculated from the previous run time, not from wall-clock time. | Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `func` | callable | *(required)* | The function to run. | +|---|---|---|---| +| `func` | callable | *(required)* | The handler to run. | | `hours` | `float` | `0` | Hours component of the interval. | | `minutes` | `float` | `0` | Minutes component of the interval. | | `seconds` | `float` | `0` | Seconds component of the interval. | -| `name` | `str` | `""` | Optional name for the job. | -| `group` | `str \| None` | `None` | Optional group name. See [Job Groups](#job-groups). | -| `timeout` | `float \| None` | `None` | Per-job timeout in seconds. `None` uses the global default. See [Timeouts](../configuration/global.md#timeouts). | -| `timeout_disabled` | `bool` | `False` | Disable timeout enforcement for this job. | -| `if_exists` | `"error"` \| `"skip"` \| `"replace"` | `"error"` | Behavior when a job with this name already exists. See [Idempotent Registration](#idempotent-registration). | -| `args` | `tuple` \| `None` | `None` | Positional arguments passed to `func`. | -| `kwargs` | `Mapping` \| `None` | `None` | Keyword arguments passed to `func`. | + +Shared parameters apply. ```python --8<-- "pages/core-concepts/scheduler/snippets/scheduler_run_every.py" ``` ---- +### `run_minutely` / `run_hourly` -## Convenience Interval Helpers +`run_minutely` and `run_hourly` are shorthands for `run_every`. They accept a single integer interval parameter and enforce a minimum of 1. -### `run_minutely` -Run every N minutes. Shorthand for `run_every(minutes=N)`. +| Method | Shorthand for | Interval parameter | Minimum | +|---|---|---|---| +| `run_minutely(func, minutes=1)` | `run_every(minutes=N)` | `minutes: int` | 1 | +| `run_hourly(func, hours=1)` | `run_every(hours=N)` | `hours: int` | 1 | -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `func` | callable | *(required)* | The function to run. | -| `minutes` | `int` | `1` | Minute interval. Must be at least 1. | -| `name` | `str` | `""` | Optional name for the job. | -| `group` | `str \| None` | `None` | Optional group name. See [Job Groups](#job-groups). | -| `timeout` | `float \| None` | `None` | Per-job timeout in seconds. `None` uses the global default. See [Timeouts](../configuration/global.md#timeouts). | -| `timeout_disabled` | `bool` | `False` | Disable timeout enforcement for this job. | -| `if_exists` | `"error"` \| `"skip"` \| `"replace"` | `"error"` | Behavior when a job with this name already exists. See [Idempotent Registration](#idempotent-registration). | -| `args` | `tuple` \| `None` | `None` | Positional arguments passed to `func`. | -| `kwargs` | `Mapping` \| `None` | `None` | Keyword arguments passed to `func`. | +Shared parameters apply to both. ```python --8<-- "pages/core-concepts/scheduler/snippets/scheduler_run_minutely.py" ``` -### `run_hourly` -Run every N hours. Shorthand for `run_every(hours=N)`. - -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `func` | callable | *(required)* | The function to run. | -| `hours` | `int` | `1` | Hour interval. Must be at least 1. | -| `name` | `str` | `""` | Optional name for the job. | -| `group` | `str \| None` | `None` | Optional group name. See [Job Groups](#job-groups). | -| `timeout` | `float \| None` | `None` | Per-job timeout in seconds. `None` uses the global default. See [Timeouts](../configuration/global.md#timeouts). | -| `timeout_disabled` | `bool` | `False` | Disable timeout enforcement for this job. | -| `if_exists` | `"error"` \| `"skip"` \| `"replace"` | `"error"` | Behavior when a job with this name already exists. See [Idempotent Registration](#idempotent-registration). | -| `args` | `tuple` \| `None` | `None` | Positional arguments passed to `func`. | -| `kwargs` | `Mapping` \| `None` | `None` | Keyword arguments passed to `func`. | - ```python --8<-- "pages/core-concepts/scheduler/snippets/scheduler_run_hourly.py" ``` -### `run_daily` -Run once per day at a fixed wall-clock time. Uses a cron-based trigger internally for DST-correct, wall-clock-aligned scheduling. +### `run_daily(func, at)` + +The handler runs once per day at a fixed wall-clock time. A cron-based trigger ensures DST-correct, wall-clock-aligned scheduling. Interval-based daily scheduling drifts by one hour on DST transitions; `run_daily` does not. | Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `func` | callable | *(required)* | The function to run. | -| `at` | `str` | `"00:00"` | Target wall-clock time in `"HH:MM"` format. | -| `name` | `str` | `""` | Optional name for the job. | -| `group` | `str \| None` | `None` | Optional group name. See [Job Groups](#job-groups). | -| `timeout` | `float \| None` | `None` | Per-job timeout in seconds. `None` uses the global default. See [Timeouts](../configuration/global.md#timeouts). | -| `timeout_disabled` | `bool` | `False` | Disable timeout enforcement for this job. | -| `if_exists` | `"error"` \| `"skip"` \| `"replace"` | `"error"` | Behavior when a job with this name already exists. See [Idempotent Registration](#idempotent-registration). | -| `args` | `tuple` \| `None` | `None` | Positional arguments passed to `func`. | -| `kwargs` | `Mapping` \| `None` | `None` | Keyword arguments passed to `func`. | +|---|---|---|---| +| `func` | callable | *(required)* | The handler to run. | +| `at` | `str` | `"00:00"` | Wall-clock time in `"HH:MM"` format. | + +Shared parameters apply. ```python --8<-- "pages/core-concepts/scheduler/snippets/scheduler_run_daily.py" ``` ---- +### `run_cron(func, expression)` -## Cron Scheduling - -### `run_cron` -Run on a schedule defined by a cron expression. Accepts both 5-field (standard Unix cron: `minute hour dom month dow`) and 6-field expressions (with seconds appended as a 6th field). +The handler runs on a schedule defined by a cron expression. Both 5-field (standard Unix cron) and 6-field expressions are accepted. An invalid expression raises `ValueError` at registration time. | Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `func` | callable | *(required)* | The function to run. | -| `expression` | `str` | *(required)* | A valid 5- or 6-field cron expression. | -| `name` | `str` | `""` | Optional name for the job. | -| `group` | `str \| None` | `None` | Optional group name. See [Job Groups](#job-groups). | -| `timeout` | `float \| None` | `None` | Per-job timeout in seconds. `None` uses the global default. See [Timeouts](../configuration/global.md#timeouts). | -| `timeout_disabled` | `bool` | `False` | Disable timeout enforcement for this job. | -| `if_exists` | `"error"` \| `"skip"` \| `"replace"` | `"error"` | Behavior when a job with this name already exists. See [Idempotent Registration](#idempotent-registration). | -| `args` | `tuple` \| `None` | `None` | Positional arguments passed to `func`. | -| `kwargs` | `Mapping` \| `None` | `None` | Keyword arguments passed to `func`. | - -**Cron expression fields** (5-field standard): - -| Position | Field | Values | Example | -|----------|-------|--------|---------| -| 1 | minute | 0-59 | `*/15` — every 15 minutes | -| 2 | hour | 0-23 | `9` — 9 AM | -| 3 | day of month | 1-31 | `1,15` — 1st and 15th | -| 4 | month | 1-12 | `6` — June | -| 5 | day of week | 0-6 (Sunday=0) | `1-5` — weekdays | +|---|---|---|---| +| `func` | callable | *(required)* | The handler to run. | +| `expression` | `str` | *(required)* | A 5- or 6-field cron expression. | + +Shared parameters apply. + +**Cron field reference** (5-field standard: `minute hour dom month dow`): + +| Position | Field | Range | Example | +|---|---|---|---| +| 1 | minute | 0–59 | `*/15` (every 15 minutes) | +| 2 | hour | 0–23 | `9` (9 AM) | +| 3 | day of month | 1–31 | `1,15` (1st and 15th) | +| 4 | month | 1–12 | `6` (June) | +| 5 | day of week | 0–6 (Sunday=0) | `1-5` (weekdays) | + +6-field expressions append seconds as a 6th field per the croniter convention: `minute hour dom month dow second`. ```python --8<-- "pages/core-concepts/scheduler/snippets/scheduler_run_cron.py" ``` ---- - ## Job Groups -Schedule related jobs into a named group for bulk management. Pass `group=` to any scheduling method or to `schedule()` directly. +The `group=` parameter assigns a job to a named group. Groups support bulk cancellation and inspection. ```python --8<-- "pages/core-concepts/scheduler/snippets/scheduler_job_groups.py" ``` | Method | Description | -|--------|-------------| -| `cancel_group(group)` | Cancel all jobs in the group. No-op if the group does not exist. | -| `list_jobs(group=group)` | Return all jobs in the group. Without `group=`, returns all jobs. | - ---- +|---|---| +| `scheduler.cancel_group(group)` | Cancels all jobs in the group. No-op when the group does not exist. | +| `scheduler.list_jobs(group=group)` | Returns all jobs in the group. Without `group=`, returns all jobs for the app instance. | ## Jitter -Add random offset to a job's enqueue time with the `jitter=` parameter. This spreads out jobs that would otherwise fire at the exact same instant — useful for avoiding thundering-herd scenarios when many apps schedule work at the same wall-clock time. +The `jitter=` parameter adds a random offset to a job's dispatch time. The offset is drawn uniformly from `[0, jitter]` seconds and applied at enqueue time. + +Jitter affects dispatch order within the heap. The logical `next_run` timestamp on the job remains unchanged, and the trigger's interval grid is not affected. ```python --8<-- "pages/core-concepts/scheduler/snippets/scheduler_jitter.py:jitter" ``` -Jitter is applied to the heap sort index only — the logical `next_run` timestamp remains unjittered. This means the trigger's interval grid is not affected by jitter, only the order in which co-scheduled jobs are dispatched. - ---- +Jitter is useful when several apps schedule work at the same wall-clock time and concurrent execution would cause contention. ## Idempotent Registration -Job names must be unique within each app instance. If you register a job with a name that already exists, the scheduler raises `ValueError` by default. - -All scheduling methods accept an `if_exists` parameter to control this behavior: +Job names must be unique within an app instance. Registering a second job with an existing name raises `ValueError` by default. The `if_exists` parameter controls this behavior. | Value | Behavior | -|-------|----------| -| `"error"` (default) | Raise `ValueError` if a job with the same name already exists. | -| `"skip"` | Return the existing job if its configuration matches. Raises `ValueError` if a job with the same name exists but has a different configuration. Two jobs match when they have the same callable, trigger (by `trigger_id()`), group, jitter, timeout, timeout_disabled, `args`, and `kwargs`. Useful for safe re-registration in `on_initialize` when the job configuration is stable across reloads. | -| `"replace"` | Cancel the existing job (recording it as cancelled in telemetry) and register the new job in its place. Unlike `"skip"`, the new job does not need to match the existing one's configuration. Useful when the callable, trigger, or parameters may change between reloads. | +|---|---| +| `"error"` (default) | Raises `ValueError` when a job with the same name already exists. | +| `"skip"` | Returns the existing job when its configuration matches the new registration. Raises `ValueError` when names match but configurations differ. Two jobs match when they share the same callable, trigger (by `trigger_id()`), group, jitter, timeout, `timeout_disabled`, `args`, and `kwargs`. | +| `"replace"` | Cancels the existing job and registers the new one. The new job's configuration does not need to match the old one. | -This is especially useful in `on_initialize`, which runs again on app reload: +`if_exists` is essential in `on_initialize`, which re-runs on app reload. ```python --8<-- "pages/core-concepts/scheduler/snippets/scheduler_idempotent_registration.py:idempotent_registration" ``` -Without `if_exists="skip"`, a reload would raise `ValueError` because `sensor_check` is already registered from the previous initialization. Use `if_exists="replace"` instead when the job's configuration may change — the old job is cancelled and the new configuration takes effect immediately: +`"skip"` works when the job configuration is stable across reloads. `"replace"` is the right choice when the handler, trigger, or arguments may change between reloads. ```python --8<-- "pages/core-concepts/scheduler/snippets/scheduler_idempotent_registration.py:replace_registration" @@ -240,41 +198,42 @@ Without `if_exists="skip"`, a reload would raise `ValueError` because `sensor_ch ## Passing Arguments to Handlers -All scheduling methods accept `args` and `kwargs` to pass data to the scheduled handler at call time, so you avoid capturing mutable state in closures. +All scheduling methods accept `args` and `kwargs` to supply data to the handler at call time. This avoids capturing mutable state in closures. ```python --8<-- "pages/core-concepts/scheduler/snippets/scheduler_args_kwargs.py" ``` ---- +## Synchronous Scheduling + +`self.scheduler.sync` exposes a `SchedulerSyncFacade` that mirrors all scheduling methods as blocking calls. This is intended for use in `AppSync` lifecycle hooks, which run in a synchronous context. + +All method signatures and parameters are identical to the async versions. The facade blocks until the registration completes. ## Custom Triggers -Implement `TriggerProtocol` to handle scheduling patterns the built-in triggers don't cover — for example, polling based on solar elevation. +`TriggerProtocol` defines the interface for custom scheduling patterns. Any class implementing all six methods can be passed to `schedule()`. ```python --8<-- "pages/core-concepts/scheduler/snippets/scheduler_custom_trigger.py:trigger_class" ``` -Use it with `schedule()`: - ```python --8<-- "pages/core-concepts/scheduler/snippets/scheduler_custom_trigger.py:trigger_usage" ``` -The `TriggerProtocol` requires six methods: +`TriggerProtocol` requires six methods: -| Method | Returns | Description | -|--------|---------|-------------| -| `first_run_time(current_time)` | `ZonedDateTime` | When the job should first fire. | -| `next_run_time(previous_run, current_time)` | `ZonedDateTime \| None` | When to fire next. Return `None` for one-shot triggers. | -| `trigger_label()` | `str` | Short label for logs and the web UI. | -| `trigger_detail()` | `str \| None` | Optional human-readable detail string. | -| `trigger_db_type()` | `str` | Canonical type for database storage. | -| `trigger_id()` | `str` | Stable identifier for deduplication (used by `if_exists="skip"` and auto-generated job names). | +| Method | Signature | Returns | Description | +|---|---|---|---| +| `first_run_time` | `(current_time: ZonedDateTime)` | `ZonedDateTime` | The time for the first execution. | +| `next_run_time` | `(previous_run: ZonedDateTime, current_time: ZonedDateTime)` | `ZonedDateTime \| None` | The time for the next execution. `None` makes the trigger one-shot. | +| `trigger_label` | `()` | `str` | Short label used in logs and the web UI. | +| `trigger_detail` | `()` | `str \| None` | Optional human-readable detail string. | +| `trigger_db_type` | `()` | `Literal["interval", "cron", "once", "after", "custom"]` | Canonical type string for database storage. Application triggers return `"custom"`. | +| `trigger_id` | `()` | `str` | Stable identifier for deduplication. Used by `if_exists="skip"` and auto-generated job names. | ## See Also -- [Job Management](management.md) - Name, track, and cancel scheduled jobs -- [Bus](../bus/index.md) - Combine scheduled tasks with event-driven automation -- [App Cache](../cache/index.md) - Store data between scheduled runs +- [Job Management](management.md): cancelling, inspecting, and handling errors on scheduled jobs +- [Scheduler Overview](index.md): trigger types and the scheduling model diff --git a/docs/pages/core-concepts/scheduler/snippets/scheduler_self_cancel.py b/docs/pages/core-concepts/scheduler/snippets/scheduler_self_cancel.py index 44772679d..2dc07af52 100644 --- a/docs/pages/core-concepts/scheduler/snippets/scheduler_self_cancel.py +++ b/docs/pages/core-concepts/scheduler/snippets/scheduler_self_cancel.py @@ -3,11 +3,10 @@ class PollApp(App[AppConfig]): - _poll_job: ScheduledJob | None = None + poll_job: ScheduledJob | None = None async def on_initialize(self): - # Store a reference so the handler can cancel itself. - self._poll_job = await self.scheduler.run_every( + self.poll_job = await self.scheduler.run_every( self.wait_for_device, seconds=10, name="device_poll", @@ -16,7 +15,7 @@ async def on_initialize(self): async def wait_for_device(self): state = await self.api.get_state_or_none("sensor.device_status") if state is not None and not state.is_unavailable and state.value == "online": - self.logger.info("Device is online — stopping poll") - if self._poll_job is not None: - self._poll_job.cancel() - self._poll_job = None + self.logger.info("Device is online, stopping poll") + if self.poll_job is not None: + self.poll_job.cancel() + self.poll_job = None From 92d56cfdddd26a17d20dff39e444368326e08d8e Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Tue, 2 Jun 2026 17:17:55 -0500 Subject: [PATCH 046/160] docs: rewrite States section (overview, subscribing, custom, registries) Overview: reading state in 3 patterns (domain, direct, generic), state object contents, 55 built-in types (corrected from 47), collapsible domain table. Fixed SensorState value type (str|None not float|None), bool state types (bool|None not bool), color_temp -> color_temp_kelvin. Subscribing: new bridge page between Bus and States. Basic subscription, typed DI annotations, filtering progression (changed_to -> predicates -> conditions). 4 new snippets. Custom States: rehomed from Advanced. Define class, choose base, add attributes, use in app. Trimmed from 14 to 8 snippets. State Registry: rehomed from Advanced. Registration, domain lookup, override, conversion flow. Fixed STATE_REGISTRY access path. Type Registry: rehomed from Advanced. Registration, built-in converters, common patterns, error handling. Structured around single job (register a converter). --- .../core-concepts/states/custom-states.md | 110 +++++++++++- docs/pages/core-concepts/states/index.md | 134 ++++++++------ .../states/snippets/state_attribute_change.py | 31 ++++ .../states/snippets/state_basic_subscribe.py | 16 ++ .../states/snippets/state_duration.py | 19 ++ .../states/snippets/state_typed_di.py | 19 ++ .../core-concepts/states/state-registry.md | 123 ++++++++++++- .../pages/core-concepts/states/subscribing.md | 124 ++++++++++++- .../core-concepts/states/type-registry.md | 168 +++++++++++++++++- 9 files changed, 686 insertions(+), 58 deletions(-) create mode 100644 docs/pages/core-concepts/states/snippets/state_attribute_change.py create mode 100644 docs/pages/core-concepts/states/snippets/state_basic_subscribe.py create mode 100644 docs/pages/core-concepts/states/snippets/state_duration.py create mode 100644 docs/pages/core-concepts/states/snippets/state_typed_di.py diff --git a/docs/pages/core-concepts/states/custom-states.md b/docs/pages/core-concepts/states/custom-states.md index 34b22239f..be8c78a05 100644 --- a/docs/pages/core-concepts/states/custom-states.md +++ b/docs/pages/core-concepts/states/custom-states.md @@ -1,3 +1,111 @@ # Custom States -*This page is being rewritten as part of the documentation overhaul.* +Hassette auto-generates typed state classes for standard Home Assistant domains. For custom integrations or third-party add-ons, a custom state class maps an unrecognized domain to a typed Python model. The State Registry picks up the class automatically at definition time. + +## Defining a Custom State + +A custom state class inherits from one of Hassette's base state classes. The `domain` field takes a `Literal` with the exact domain string from Home Assistant. + +```python +--8<-- "pages/advanced/snippets/custom-states/basic_custom_state.py" +``` + +Registration happens via `__init_subclass__`, so no explicit call is needed. Each class maps to one domain. Assigning the same `Literal` value to two classes overwrites the first registration. + +`Literal["my_custom_domain"]` is required. A plain `str` annotation carries no value at class definition time, so the registry cannot extract the domain name automatically. + +## Choosing a Base Class + +Each base class determines the Python type of `value` on the resulting state object. + +### `StringBaseState`: `str` value + +`StringBaseState` is the most common choice. It passes through the raw HA state string with no conversion. + +```python +--8<-- "pages/advanced/snippets/custom-states/string_base_state.py" +``` + +### `NumericBaseState`: `Decimal` value + +`NumericBaseState` converts the raw state string to `Decimal`. It accepts integer, float, and `Decimal` inputs. + +```python +--8<-- "pages/advanced/snippets/custom-states/numeric_base_state.py" +``` + +### `BoolBaseState`: `bool` value + +`BoolBaseState` converts `"on"` to `True` and `"off"` to `False` automatically. + +```python +--8<-- "pages/advanced/snippets/custom-states/bool_base_state.py" +``` + +### `DateTimeBaseState`: `ZonedDateTime`, `PlainDateTime`, or `Date` value + +`DateTimeBaseState` parses the raw state string into a `whenever` datetime type. The exact type depends on the string format from Home Assistant. + +```python +--8<-- "pages/advanced/snippets/custom-states/datetime_base_state.py" +``` + +### `TimeBaseState`: `Time` value + +`TimeBaseState` parses the raw state string into a `whenever.Time` value. + +```python +--8<-- "pages/advanced/snippets/custom-states/time_base_state.py" +``` + +### Custom value type: inherit `BaseState` directly + +When no built-in base class fits, a class can inherit from `BaseState[T]` directly. The `value_type` class variable declares the accepted types. Hassette validates state values against `value_type` at runtime. + +```python +--8<-- "pages/advanced/snippets/custom-states/define_your_own.py" +``` + +`value_type` should include `type(None)` when the state can be unset. + +## Adding Typed Attributes + +Domain-specific attributes beyond `value` belong in an attributes class that inherits from `AttributesBase`. The `attributes` field on the state class accepts this class, overriding the default. + +```python +--8<-- "pages/advanced/snippets/custom-states/adding_custom_attributes.py" +``` + +Fields on the attributes class are optional by default when typed with `| None`. Hassette passes through any undeclared attribute keys. They remain accessible via `state.attributes.extras`. + +## Using Custom States in Apps + +### Via `self.states[CustomStateClass]` + +`self.states[RedditState]` returns a `DomainStates` collection typed to `RedditState`. Iteration yields `(entity_id, state)` pairs where each `state` is a fully converted `RedditState` instance. + +```python +--8<-- "pages/advanced/snippets/custom-states/via_get_states.py" +``` + +### With Dependency Injection + +`D.StateNew[RedditState]` in a handler parameter tells Hassette to convert the incoming event's new state to a `RedditState` before calling the handler. [Dependency Injection](../../core-concepts/bus/dependency-injection.md) covers the full parameter reference. + +```python +--8<-- "pages/advanced/snippets/state-registry/basic_custom_state_usage.py" +``` + +## Troubleshooting + +**Class not registering.** The `domain` field must use `Literal["domain_name"]`, not `str`. A plain `str` annotation gives the registry no value to register at class creation time. If `__init_subclass__` is overridden, it must call `super().__init_subclass__()` so registration still runs. + +**Type hints not working.** Property-style access (`self.states.my_domain`) is only available for domains declared in Hassette's `.pyi` stub. Custom domains always use `self.states[CustomStateClass]` for full type checking. + +**Conversion fails.** The base class must match the entity's actual value type in Home Assistant. The raw state data is visible via `hassette log --app ` or the HA developer tools, which confirms the format before a base class is selected. + +## See Also + +- [State Registry](state-registry.md): how automatic registration works and how to override it +- [Type Registry](type-registry.md): registering custom converters for individual field types +- [Dependency Injection](../../core-concepts/bus/dependency-injection.md): injecting typed states into event handlers diff --git a/docs/pages/core-concepts/states/index.md b/docs/pages/core-concepts/states/index.md index dfd6b3a0f..970659352 100644 --- a/docs/pages/core-concepts/states/index.md +++ b/docs/pages/core-concepts/states/index.md @@ -1,9 +1,6 @@ # States -Hassette maintains a local, real-time cache of all Home Assistant states. This is available as an instance of [StateManager][hassette.state_manager.state_manager.StateManager], accessible via `self.states` in your apps - - -## Diagram +The `StateManager` keeps a real-time, in-memory copy of all Home Assistant entity states. `self.states` provides synchronous, typed access with no `await` and no API calls. ```mermaid flowchart TD @@ -17,7 +14,7 @@ flowchart TD WS --> SP end - subgraph app["Your App"] + subgraph app["App"] SM["self.states
typed, sync access"] end @@ -29,32 +26,23 @@ flowchart TD style app fill:#e8f0ff,stroke:#6688cc ``` -## Using the StateManager - -Use `self.states` instead of API calls to read entity states. It gives you: - -- **Speed**: Instant access from local memory. -- **Simplicity**: Synchronous access without `await`. -- **Efficiency**: No network overhead or rate limiting concerns. -- **Consistency**: Event-driven updates ensure your app sees the latest state changes. - - The StateManager event handler is prioritized over app event handlers to ensure you always have a consistent view of the latest states. +## Reading State ### Domain Access -The easiest way to access states is via domain properties. +`self.states.light`, `self.states.sensor`, and similar domain properties return a `DomainStates` collection, a typed view of every entity in that domain. ```python --8<-- "pages/core-concepts/states/snippets/states_domain_access.py" ``` -Notice how you do not need to use the domain in the entity ID - since you're already accessing the domain via `self.states.sensor`, you only need to provide the entity name. +The short entity name omits the domain prefix. `self.states.light.get("kitchen")` and `self.states.light.get("light.kitchen")` resolve to the same entity. -!!! note "Bracket access raises `KeyError` for missing entities" - `self.states.light["bedroom"]` raises `KeyError` — not `EntityNotFoundError` — if the entity does not exist. Use `.get("bedroom")` for safe access that returns `None` when the entity is absent. +`.get()` returns `None` for missing entities. Bracket access raises `KeyError`. ### Direct Entity Access -Use `self.states.get(entity_id)` when you have a full entity ID and don't need to specify the domain or state class. It automatically resolves to the correct domain-specific type (e.g., `LightState` for `light.*`), or falls back to `BaseState` for unregistered domains. +`self.states.get(entity_id)` accepts a full entity ID and resolves to the most specific registered type. `LightState` for `light.*`, `SensorState` for `sensor.*`, `BaseState` for anything unregistered. ```python --8<-- "pages/core-concepts/states/snippets/states_direct_access.py" @@ -62,50 +50,57 @@ Use `self.states.get(entity_id)` when you have a full entity ID and don't need t ### Generic Access -For domains that don't have a dedicated helper, or for dynamic access, provide the state class to the `self.states` dictionary-like interface: +`self.states[CustomState]` returns a `DomainStates` collection typed to a custom state class. This pattern covers custom integrations and third-party add-ons whose domain has no built-in class. ```python --8<-- "pages/core-concepts/states/snippets/states_generic_access.py" ``` -### Iteration +Custom state class definition and registration are covered in [Custom States](../../advanced/custom-states.md). -You can iterate over domains to find entities. +## What a State Object Contains -```python ---8<-- "pages/core-concepts/states/snippets/states_iteration.py" -``` +Every state object is a `BaseState` subclass. The following fields and properties are available on all of them. -## DomainStates Collection Interface +**`value`** is the entity's current state, typed for the domain. `SwitchState.value` is `bool | None`, `SensorState.value` is `str | None`, `SelectState.value` is `str | None`. When HA reports `"unknown"` or `"unavailable"`, `value` is `None`. `is_unknown` and `is_unavailable` identify which case applies. -Every domain accessor (e.g., `self.states.light`) returns a `DomainStates` object. Beyond iteration, it supports the following operations: +**`attributes`** is a typed `AttributesBase` subclass with domain-specific fields. `LightState.attributes.brightness` is an integer. `ClimateState.attributes.current_temperature` is a float. Pyright knows the types. -| Operation | Example | Notes | -|---|---|---| -| Bracket access | `self.states.light["bedroom"]` | Raises `KeyError` if absent | -| Safe access | `self.states.light.get("bedroom")` | Returns `None` if absent | -| Containment | `"bedroom" in self.states.light` | | -| Length | `len(self.states.light)` | Number of entities in domain | -| Iteration (items) | `for entity_id, state in self.states.light` | Lazy; same as `.items()` | -| `.items()` | `self.states.light.items()` | Iterator of `(entity_id, state)` pairs | -| `.keys()` | `self.states.light.keys()` | Eager list of entity IDs | -| `.iterkeys()` | `self.states.light.iterkeys()` | Lazy iterator of entity IDs | -| `.values()` | `self.states.light.values()` | Eager list of states | -| `.itervalues()` | `self.states.light.itervalues()` | Lazy iterator of states | -| `.to_dict()` | `self.states.light.to_dict()` | Eager `dict[entity_id, state]` | - -!!! tip "Prefer lazy iteration for large domains" - `.items()`, `.iterkeys()`, and `.itervalues()` are lazy and avoid validating every entity up front. `.keys()`, `.values()`, and `.to_dict()` are eager — they walk the entire domain immediately. +**`is_unknown`** and **`is_unavailable`** are `True` when HA reports the entity as `"unknown"` or `"unavailable"`, respectively. Both flags are `False` for normal states. + +**`is_group`** is `True` when the entity is a group. The `entity_id` attribute on the entity holds a list of member entity IDs. + +**`extras`** and **`extra(key, default=None)`** access untyped state fields not declared on the `BaseState` model. Typed attributes cover the common cases; these handle the rest. + +**`last_changed`**, **`last_updated`**, **`last_reported`** are `ZonedDateTime | None` timestamps from HA. `last_changed` updates only when the state string changes. `last_updated` updates when state or attributes change. `last_reported` updates on every write. + +**`entity_id`** and **`domain`** hold the full entity ID (`"light.kitchen"`) and its domain (`"light"`). + +### Attribute Helpers + +`AttributesBase` exposes two helpers for attributes not declared on the typed model. + +`attributes.extras` returns a `dict[str, Any]` of undeclared fields. `attributes.extra(key, default=None)` fetches a single undeclared field with a fallback. + +`attributes.has_feature(flag)` tests a bit in `supported_features`. Each domain defines its own `IntFlag` enum for feature constants. `LightEntityFeature` has `EFFECT`, `FLASH`, and `TRANSITION`. ## Built-in State Types -Hassette ships typed state classes for every standard Home Assistant domain. Import them from `hassette.models.states` (or via the `states` alias imported from `hassette`): +Hassette auto-generates typed state classes for 55 Home Assistant domains from HA core source. All classes are available from the [`states`](../../api-reference/states.md) module: ```python --8<-- "pages/core-concepts/snippets/states_import.py" ``` -??? info "Full list of built-in state classes" +Three common examples: + +- **`states.LightState`** has `value: bool | None`, `attributes.brightness: int | None`, `attributes.color_temp_kelvin: int | None` +- **`states.SensorState`** has `value: str | None`, `attributes.unit_of_measurement: str | None`, `attributes.device_class: str | None` +- **`states.BinarySensorState`** has `value: bool | None`, `attributes.device_class: str | None` + +The [API reference](../../api-reference/states.md) lists all 55 classes with their full attribute signatures. Domains not covered there are handled by [Custom States](../../advanced/custom-states.md). + +??? info "Full domain-to-class table" | Domain | Class | |---|---| | `ai_task` | `AiTaskState` | @@ -126,7 +121,9 @@ Hassette ships typed state classes for every standard Home Assistant domain. Imp | `device_tracker` | `DeviceTrackerState` | | `event` | `EventState` | | `fan` | `FanState` | + | `geo_location` | `GeoLocationState` | | `humidifier` | `HumidifierState` | + | `image` | `ImageState` | | `image_processing` | `ImageProcessingState` | | `input_boolean` | `InputBooleanState` | | `input_button` | `InputButtonState` | @@ -134,6 +131,7 @@ Hassette ships typed state classes for every standard Home Assistant domain. Imp | `input_number` | `InputNumberState` | | `input_select` | `InputSelectState` | | `input_text` | `InputTextState` | + | `lawn_mower` | `LawnMowerState` | | `light` | `LightState` | | `lock` | `LockState` | | `media_player` | `MediaPlayerState` | @@ -161,19 +159,47 @@ Hassette ships typed state classes for every standard Home Assistant domain. Imp | `weather` | `WeatherState` | | `zone` | `ZoneState` | - For domains not in this list (custom integrations, third-party add-ons), see [Custom State Classes](../../advanced/custom-states.md). + The API reference is the canonical source. This table may lag behind new HA releases. + +## Iterating Over States + +`DomainStates` supports direct iteration over `(entity_id, state)` pairs. + +```python +--8<-- "pages/core-concepts/states/snippets/states_iteration.py" +``` + +Additional collection methods: + +| Method | Returns | Notes | +|---|---|---| +| `for entity_id, state in self.states.light` | `(str, StateT)` pairs | Lazy; same as `.items()` | +| `.items()` | Iterator of `(entity_id, StateT)` | Lazy | +| `.keys()` | `list[str]` | Eager | +| `.iterkeys()` | Iterator of `str` | Lazy | +| `.values()` | `list[StateT]` | Eager | +| `.itervalues()` | Iterator of `StateT` | Lazy | +| `.to_dict()` | `dict[str, StateT]` | Eager | +| `"kitchen" in self.states.light` | `bool` | Containment check | +| `len(self.states.light)` | `int` | Count of entities in domain | + +??? note "Lazy vs. eager" + `.items()`, `.iterkeys()`, and `.itervalues()` are lazy. They validate entities on demand and avoid touching the entire domain up front. `.keys()`, `.values()`, and `.to_dict()` are eager and walk every entity immediately. Lazy iteration performs better for large domains like `sensor`. ## Good to Know -!!! note "Startup and staleness" - The cache is populated once at startup via a full API fetch, then kept current by WebSocket `state_changed` events — so states are available as soon as your app's `on_initialize` hook runs. A periodic background poll (default every 30 seconds) guards against any events that were missed. During a HA reconnect the cache is temporarily cleared; the StateProxy marks itself not-ready and retries reads automatically, so your code does not need to handle this case. +**Startup.** The cache is populated at startup via a full API fetch before `on_initialize` runs. Apps can read current state immediately. + +**Staleness.** WebSocket `state_changed` events keep the cache current. A periodic background poll (default every 30 seconds) guards against missed events. The `StateManager` event handler runs before app handlers, so handlers always see the latest state. + +**Reconnection.** During a HA reconnect the cache is temporarily cleared. The `StateProxy` marks itself not ready and retries reads automatically. -!!! note "Missing entities" - `self.states.light.get("bedroom")` returns `None` when the entity is absent. `self.states.light["bedroom"]` raises `KeyError`. If you are not certain an entity exists, prefer `.get()` and check the result before use. +**Missing entities.** `.get()` returns `None` for absent entities. Bracket access raises `KeyError`. `.get()` with a `None` check is the safe path when entity presence is uncertain. ## See Also -- [API - Entities & States](../api/entities.md) - Retrieve states via API -- [Bus](../bus/index.md) - Subscribe to state change events -- [App Cache](../cache/index.md) - Cache data locally across restarts -- [Custom States](../../advanced/custom-states.md) - Define custom state models +- [Subscribing to State Changes](../bus/index.md): react to state transitions as they happen +- [Custom States](../../advanced/custom-states.md): define typed models for custom integrations +- [API - Entities & States](../api/entities.md): retrieve states via the REST/WebSocket API +- [Bus](../bus/index.md): the event system that delivers state changes to handlers +- [App Cache](../cache/index.md): persist data locally across restarts diff --git a/docs/pages/core-concepts/states/snippets/state_attribute_change.py b/docs/pages/core-concepts/states/snippets/state_attribute_change.py new file mode 100644 index 000000000..8e22ff387 --- /dev/null +++ b/docs/pages/core-concepts/states/snippets/state_attribute_change.py @@ -0,0 +1,31 @@ +from hassette import App, AppConfig, D, P, states + + +class VolumeApp(App[AppConfig]): + async def on_initialize(self) -> None: + # Fire when volume changes on a specific media player + await self.bus.on_attribute_change( + "media_player.living_room", + "volume_level", + handler=self.on_volume_change, + name="living_room_volume", + ) + + # Fire when brightness increases using a predicate + await self.bus.on_attribute_change( + "light.kitchen", + "brightness", + handler=self.on_brightness_up, + where=P.AttrDidChange("brightness"), + name="kitchen_brightness_up", + ) + + async def on_volume_change( + self, + new: D.StateNew[states.MediaPlayerState], + ) -> None: + volume = new.attributes.volume_level + self.logger.info("Volume: %s", volume) + + async def on_brightness_up(self, event) -> None: + pass diff --git a/docs/pages/core-concepts/states/snippets/state_basic_subscribe.py b/docs/pages/core-concepts/states/snippets/state_basic_subscribe.py new file mode 100644 index 000000000..b1c461008 --- /dev/null +++ b/docs/pages/core-concepts/states/snippets/state_basic_subscribe.py @@ -0,0 +1,16 @@ +from hassette import App, AppConfig, D, states + + +class ThermostatApp(App[AppConfig]): + async def on_initialize(self) -> None: + await self.bus.on_state_change( + "sensor.outdoor_temperature", + handler=self.on_temp_change, + name="outdoor_temp", + ) + + async def on_temp_change( + self, + new: D.StateNew[states.SensorState], + ) -> None: + self.logger.info("Temperature: %s", new.value) diff --git a/docs/pages/core-concepts/states/snippets/state_duration.py b/docs/pages/core-concepts/states/snippets/state_duration.py new file mode 100644 index 000000000..e09ab275f --- /dev/null +++ b/docs/pages/core-concepts/states/snippets/state_duration.py @@ -0,0 +1,19 @@ +from hassette import App, AppConfig + + +class PresenceApp(App[AppConfig]): + async def on_initialize(self) -> None: + # --8<-- [start:duration] + # Fire only after a door has stayed open for 5 minutes + await self.bus.on_state_change( + "binary_sensor.front_door", + handler=self.on_door_left_open, + changed_to="on", + duration=300, + once=True, + name="front_door_open_long", + ) + # --8<-- [end:duration] + + async def on_door_left_open(self) -> None: + self.logger.warning("Front door has been open for 5 minutes") diff --git a/docs/pages/core-concepts/states/snippets/state_typed_di.py b/docs/pages/core-concepts/states/snippets/state_typed_di.py new file mode 100644 index 000000000..faf5996fb --- /dev/null +++ b/docs/pages/core-concepts/states/snippets/state_typed_di.py @@ -0,0 +1,19 @@ +from hassette import App, AppConfig, D, states + + +class MotionApp(App[AppConfig]): + async def on_initialize(self) -> None: + await self.bus.on_state_change( + "binary_sensor.front_door", + handler=self.on_door_change, + name="front_door", + ) + + async def on_door_change( + self, + new: D.StateNew[states.BinarySensorState], + old: D.MaybeStateOld[states.BinarySensorState], + ) -> None: + # old is None when the app starts and there is no previous event + previous = old.value if old else "unknown" + self.logger.info("Door: %s -> %s", previous, new.value) diff --git a/docs/pages/core-concepts/states/state-registry.md b/docs/pages/core-concepts/states/state-registry.md index ce78967cf..33dc90edf 100644 --- a/docs/pages/core-concepts/states/state-registry.md +++ b/docs/pages/core-concepts/states/state-registry.md @@ -1,3 +1,124 @@ # State Registry -*This page is being rewritten as part of the documentation overhaul.* +`StateRegistry` maps Home Assistant domains to Python state model classes. When state data arrives as an untyped dictionary, the registry determines which `BaseState` subclass handles conversion. + +Most apps never interact with the registry directly. The [DI system](../bus/dependency-injection.md) and [`self.states`](index.md) use it automatically on every state event. + +This page is relevant when overriding a default domain mapping, writing a custom state class, or debugging an unexpected state type at runtime. + +```python +--8<-- "pages/advanced/snippets/state-registry/raw_data_example.py" +``` + +## How Registration Works + +Any class that inherits from `BaseState` and declares a `domain: Literal["domain_name"]` field annotation registers itself automatically at class definition time. No explicit registration call is needed. + +`BaseState.__init_subclass__` runs when Python evaluates the class body. It calls `get_domain()`, which reads the `Literal` type argument from the `domain` field annotation. If a domain string is found, `register_state_converter` records the class in the registry under that domain. + +Classes without a `domain` annotation are silently skipped. + +??? note "Implementation: `__init_subclass__` and `register_state_converter`" + + `BaseState.__init_subclass__` calls `register_state_converter(cls, domain=cls.get_domain())`, which in turn calls `StateRegistry.register()`. + + `get_domain()` uses `typing.get_args()` to extract the string from a `Literal["domain_name"]` annotation on the `domain` field. A `ClassVar[str]` annotation or a plain `str` annotation will not register. The annotation must be `Literal["domain_name"]`. + + ```python + --8<-- "pages/advanced/snippets/state-registry/automatic_registration.py" + ``` + +## Domain Lookup + +`StateRegistry.resolve(domain=...)` returns the registered state class for a domain, or `None` if no class is registered for that domain. + +```python +--8<-- "pages/advanced/snippets/state-registry/domain_lookup.py" +``` + +The `None` return for an unregistered domain is intentional. `try_convert_state` handles the fallback to `BaseState` when `resolve` returns `None`. + +## Overriding a Domain Mapping + +A custom class declared with the same `Literal` domain as a built-in replaces the existing mapping in the registry. The override takes effect at class definition time, so placing the class after the import of the original is sufficient. + +```python +--8<-- "pages/advanced/snippets/state-registry/domain_override.py" +``` + +The registry silently replaces the previous mapping. All subsequent state events for `sensor` entities produce `CustomSensorState` instances. + +When direct registry access is needed outside an app, `STATE_REGISTRY` is available as a top-level import: `from hassette import STATE_REGISTRY`. + +## The Conversion Flow + +When state data arrives from Home Assistant, `StateRegistry` and `TypeRegistry` cooperate to produce a typed object. + +1. Raw dict arrives from Home Assistant: + ```python + --8<-- "pages/advanced/snippets/state-registry/flow_raw_input.py" + ``` + +2. `StateRegistry.resolve(domain="time")` returns `TimeState`. + +3. Pydantic validation begins on `TimeState`. + +4. The `_validate_domain_and_state` model validator reads `value_type` and delegates to `TypeRegistry`. + +5. `TypeRegistry` converts `"12:01:01"` (str) to `whenever.Time`. + +6. Validation completes with a fully typed state object: + ```python + --8<-- "pages/advanced/snippets/state-registry/flow_converted_output.py" + ``` + +The `value_type` ClassVar declares which Python types the `state` field accepts. `TypeRegistry` performs the actual conversion from raw string to that type. `StateRegistry` answers "which class?"; `TypeRegistry` answers "which type for the value?". [Type Registry](type-registry.md) covers value conversion in detail. + +```python +--8<-- "pages/advanced/snippets/state-registry/value_type_example.py" +``` + +## Union Type Support + +`StateRegistry` resolves union-typed DI annotations by checking each type's domain against the incoming entity's domain. + +```python +--8<-- "pages/advanced/snippets/state-registry/union_type_support.py" +``` + +For `D.StateNew[states.SensorState | states.BinarySensorState]`, the DI system extracts the domain from the entity ID. It checks each type in the union and selects the one whose `Literal` domain matches. When no type in the union matches, conversion falls back to `BaseState`. + +## Error Handling + +`try_convert_state` raises specific exceptions for distinct failure modes. Catching these allows apps to distinguish a malformed payload from a bad entity ID or a type mismatch. + +### `InvalidDataForStateConversionError` + +Raised when the state data is malformed or missing required fields. For example, the input is `None` or contains an `event` key instead of a state dict. + +```python +--8<-- "pages/advanced/snippets/state-registry/error_invalid_data.py" +``` + +### `InvalidEntityIdError` + +Raised when the `entity_id` field is missing, not a string, or lacks a `.` separator between domain and entity name. + +```python +--8<-- "pages/advanced/snippets/state-registry/error_invalid_entity_id.py" +``` + +### `UnableToConvertStateError` + +Raised when Pydantic validation fails for both the resolved state class and the `BaseState` fallback. + +```python +--8<-- "pages/advanced/snippets/state-registry/error_unable_to_convert.py" +``` + +## See Also + +- [Type Registry](type-registry.md): value-level type conversion during state validation +- [Custom States](custom-states.md): defining state classes that register automatically +- [Dependency Injection](../bus/dependency-injection.md): how `D.StateNew[T]` uses the registry +- [States](index.md): the `self.states` cache that sits above the registry diff --git a/docs/pages/core-concepts/states/subscribing.md b/docs/pages/core-concepts/states/subscribing.md index 392530fb0..31eb3c5af 100644 --- a/docs/pages/core-concepts/states/subscribing.md +++ b/docs/pages/core-concepts/states/subscribing.md @@ -1,3 +1,125 @@ # Subscribing to State Changes -*This page is being rewritten as part of the documentation overhaul.* +The [Bus](../bus/index.md) delivers `state_changed` events to handlers each time Home Assistant reports an entity update. `on_state_change` and `on_attribute_change` are the two subscription methods for reacting to entity state. Both are async and return a [`Subscription`][hassette.bus.listeners.Subscription] handle. + +## Basic Subscription + +`on_state_change` accepts an entity ID, a `handler=`, and a required `name=`. The `name=` parameter identifies the listener in logs and the telemetry database. Omitting it raises `ListenerNameRequiredError` at registration time. + +```python +--8<-- "pages/core-concepts/states/snippets/state_basic_subscribe.py" +``` + +Entity IDs accept glob patterns. `"light.*"` matches any entity in the `light` domain. `"sensor.bedroom_*"` matches any sensor with a `bedroom_` prefix. Glob patterns work the same as on the [Bus](../bus/index.md#matching-multiple-entities) overview. + +## Receiving Typed State + +[Dependency injection](../bus/dependency-injection.md) extracts typed state objects from events and passes them as handler parameters. The handler receives converted objects directly, without event dictionary parsing. + +```python +--8<-- "pages/core-concepts/states/snippets/state_typed_di.py" +``` + +Four annotations extract typed state from events. `T` is any class from [`hassette.models.states`](domain-states.md), imported as `states`. + +| Annotation | Returns | When the value is absent | +|---|---|---| +| `D.StateNew[T]` | `T` | Handler is skipped | +| `D.StateOld[T]` | `T` | Handler is skipped | +| `D.MaybeStateNew[T]` | `T \| None` | `None` | +| `D.MaybeStateOld[T]` | `T \| None` | `None` | + +`D.StateOld` is absent on the very first event for an entity, when no previous state exists. `D.MaybeStateOld` returns `None` in that case rather than skipping the handler. + +`D.TypedStateChangeEvent[T]` delivers the full event with both old and new states typed. The [Dependency Injection](../bus/dependency-injection.md) page covers the full annotation reference. + +## Filtering State Changes + +### `changed_to` and `changed_from` + +`changed_to` restricts the handler to events where the new state matches a value. `changed_from` restricts to events where the previous state matches. + +```python +--8<-- "pages/core-concepts/bus/snippets/filtering_simple_start.py" +``` + +```python +--8<-- "pages/core-concepts/bus/snippets/filtering_simple_stop.py" +``` + +Both parameters accept a plain value, a callable, or a condition object from [`C`](../bus/filtering.md#conditions). + +### The `changed` Parameter + +By default, `on_state_change` fires only when the main state value changes. `changed=False` removes that restriction. + +```python +--8<-- "pages/core-concepts/bus/snippets/filtering/changed_false.py:changed_false" +``` + +A light remaining `"on"` while its brightness shifts produces no event by default. With `changed=False`, that attribute update reaches the handler. + +`changed` also accepts a [`ComparisonCondition`](../bus/filtering.md#numeric-direction-cincreased-and-cdecreased). `C.Increased()` fires only when the state value moves upward between events. + +### Predicates + +[`P.StateFrom`][hassette.event_handling.predicates.StateFrom] and [`P.StateTo`][hassette.event_handling.predicates.StateTo] compose inside `where=` for transition matching. A list passed to `where=` applies all predicates as logical AND. + +```python +--8<-- "pages/core-concepts/bus/snippets/filtering_state_from_to.py" +``` + +The handler fires only when the light moves from an off-like state into `"on"`. Both predicates must match. The [Filtering & Predicates](../bus/filtering.md) page covers `P.AnyOf`, `P.Not`, and the complete predicate table. + +### Numeric Conditions + +`C.Increased` and `C.Decreased` fire when a numeric value moves in a particular direction. + +```python +--8<-- "pages/core-concepts/bus/snippets/filtering_increased_decreased.py" +``` + +`changed=C.Increased()` applies directly on `on_state_change`. `P.StateComparison` and `P.AttrComparison` accept the same conditions as the `condition` argument for attribute-level direction tests. + +## Attribute Changes + +`on_attribute_change` takes `entity_id` and `attr` as its first two positional arguments. `attr` is the attribute name string, such as `"volume_level"` or `"brightness"`. + +```python +--8<-- "pages/core-concepts/states/snippets/state_attribute_change.py" +``` + +Attribute-specific predicates compose inside `where=` the same way state predicates do: [`P.AttrFrom`][hassette.event_handling.predicates.AttrFrom], [`P.AttrTo`][hassette.event_handling.predicates.AttrTo], [`P.AttrDidChange`][hassette.event_handling.predicates.AttrDidChange], and [`P.AttrComparison`][hassette.event_handling.predicates.AttrComparison]. + +## Subscription Options + +Both `on_state_change` and `on_attribute_change` accept these parameters beyond `entity_id`, `handler=`, and `name=`. + +| Parameter | Purpose | +|---|---| +| `name=` | Required. Identifies the listener in logs and the telemetry DB. | +| `changed_to=` | Fires only when the new state matches this value. | +| `changed_from=` | Fires only when the previous state matches this value. | +| `changed=` | `True` (default) fires on value changes. `False` fires on every event. A `ComparisonCondition` compares old vs new. | +| `where=` | Predicate or list of predicates for fine-grained filtering. | +| `duration=` | Fires only after the state has held the new value for N seconds. Raises `ValueError` with glob patterns. | +| `immediate=` | When combined with `duration=`, fires at registration if the entity already meets the condition. Raises `ValueError` with glob patterns. | +| `debounce=` | Waits N seconds of quiet before firing. Resets on each new event. | +| `throttle=` | Fires at most once per N seconds. Events during the cooldown are dropped. | +| `once=` | Unsubscribes after the first fire. | +| `on_error=` | Callback invoked when the handler raises an exception. | + +`duration=` and `immediate=` work together for restart-resilient hold patterns: + +```python +--8<-- "pages/core-concepts/states/snippets/state_duration.py:duration" +``` + +`timeout=` and `timeout_disabled=` are also available via `**opts`. The [Writing Handlers](../bus/handlers.md) page covers timeouts and error behavior in detail. + +## See Also + +- [Bus](../bus/index.md): subscription lifecycle, glob patterns, and `Subscription` handles +- [Filtering & Predicates](../bus/filtering.md): complete `P`, `C`, and `A` reference +- [Dependency Injection](../bus/dependency-injection.md): full `D.*` annotation reference +- [States](index.md): reading state without subscribing, `self.states`, and domain access diff --git a/docs/pages/core-concepts/states/type-registry.md b/docs/pages/core-concepts/states/type-registry.md index 4046e840a..cd15c55e5 100644 --- a/docs/pages/core-concepts/states/type-registry.md +++ b/docs/pages/core-concepts/states/type-registry.md @@ -1,3 +1,169 @@ # Type Registry -*This page is being rewritten as part of the documentation overhaul.* +Home Assistant sends nearly all values as strings over its WebSocket API. The `TypeRegistry` converts those strings to typed Python values (`int`, `float`, `bool`, `ZonedDateTime`, `Decimal`, and others) before they reach handler code. + +The `TypeRegistry` handles value conversion. The [State Registry](state-registry.md) handles domain-to-class mapping. Most apps never touch the `TypeRegistry` directly because the built-in converters cover all standard HA types. + +This page is relevant when a custom state model's `value_type` is a type Hassette does not know how to convert, or when a built-in conversion produces unexpected results. + +## How Conversion Works + +The registry maps `(from_type, to_type)` pairs to converter functions. When state data arrives and the raw value does not match the expected `value_type`, the registry looks up a matching converter and applies it. + +If no registered converter exists for the pair, the registry attempts the target type's constructor as a fallback. A successful constructor call auto-registers the conversion for future use. + +```python +--8<-- "pages/advanced/snippets/type-registry/lookup_example.py" +``` + +For union types (`value_type = (int, float, str)`), conversion attempts each member type in order. Placing the most specific type first avoids premature matches. `str` matches everything, so `(int, float, str)` is correct and `(str, int, float)` is not. + +## Built-in Converters + +### Numeric + +| From | To | Notes | +|------|----|----| +| `str` | `int` | Direct parse | +| `str` | `float` | Direct parse | +| `str` | `Decimal` | High-precision parse | +| `float` | `Decimal` | Precision-preserving | +| `Decimal` | `int` | Truncates fractional part | +| `Decimal` | `float` | Precision loss accepted | +| `int` | `float` | Widening conversion | +| `float` | `int` | Truncates fractional part | + +### Boolean + +The `str` → `bool` converter maps HA-specific string values: + +- `True`: `"on"`, `"true"`, `"yes"`, `"1"` +- `False`: `"off"`, `"false"`, `"no"`, `"0"` + +The `bool` to `str` converter produces Python's `"True"` or `"False"`, not HA format. + +### DateTime + +All datetime conversions use the [`whenever`](https://github.com/ariebovenberg/whenever) library. + +**`whenever` types:** + +| From | To | Method | +|------|----|--------| +| `str` | `ZonedDateTime` | Parses ISO, plain, or date-only strings (date-only strings assume the system timezone) | +| `str` | `Date` | `Date.parse_iso` | +| `str` | `Time` | `Time.parse_iso` | +| `str` | `OffsetDateTime` | `OffsetDateTime.parse_iso` | +| `str` | `PlainDateTime` | `PlainDateTime.parse_iso` | +| `ZonedDateTime` | `Instant` | `to_instant()` | +| `ZonedDateTime` | `PlainDateTime` | `to_plain()` | +| `ZonedDateTime` | `str` | `format_iso()` | +| `Time` | `str` | `format_iso()` | + +**Stdlib datetime types** (for boundary compatibility): + +| From | To | Method | +|------|----|--------| +| `str` | `datetime` | Via `ZonedDateTime` then `py_datetime()` | +| `str` | `time` | Via `Time.parse_iso().py_time()` | +| `str` | `date` | Via `Date.parse_iso().py_date()` | +| `Time` | `time` | `py_time()` | + +## Registering a Custom Converter + +### Decorator Registration + +`@register_type_converter_fn` registers a converter by reading the `from_type` and `to_type` directly from the function's type annotations. The parameter must be named `value` and the return annotation determines the target type. + +```python +--8<-- "pages/core-concepts/bus/snippets/dependency-injection/custom_type_converter.py" +``` + +The decorator also accepts keyword arguments for error handling: + +```python +@register_type_converter_fn( + error_message="'{value}' is not a valid Effect", + error_types=(ValueError, KeyError), +) +def str_to_effect(value: str) -> Effect: ... +``` + +`error_message` supports `{value}`, `{from_type}`, and `{to_type}` placeholders. `error_types` controls which exceptions trigger a wrapped `UnableToConvertValueError`; other exceptions propagate as `RuntimeError`. + +### Simple Registration + +`register_simple_type_converter` registers an existing callable (a constructor, a method, or a lambda) without wrapping it in a dedicated function. + +```python +--8<-- "pages/advanced/snippets/type-registry/simple_registration.py" +``` + +When `fn` is omitted, the target type's constructor is used. `error_message` and `error_types` accept the same arguments as the decorator form. + +## Common Patterns + +### Enum Conversion + +```python +--8<-- "pages/advanced/snippets/type-registry/pattern_enum.py" +``` + +The decorator infers `str → FanSpeed` from the function signature. The converter is available immediately at module import time. + +### Structured Data + +```python +--8<-- "pages/advanced/snippets/type-registry/pattern_structured.py" +``` + +`json.loads` raises `json.JSONDecodeError` (a `ValueError` subclass), so the default `error_types=(ValueError,)` catches parse failures automatically. + +## Error Handling + +When a registered converter raises one of its `error_types`, the registry wraps it in `UnableToConvertValueError`. The wrapped exception includes the source value and both types: + +```python +--8<-- "pages/advanced/snippets/type-registry/conversion_error.py" +``` + +When no converter is registered and the target type's constructor also fails, the registry raises `UnableToConvertValueError`: + +```python +--8<-- "pages/advanced/snippets/type-registry/missing_converter.py" +``` + +Custom error messages make failures easier to diagnose. The `{value}` placeholder renders the actual value that failed conversion: + +```python +--8<-- "pages/advanced/snippets/type-registry/custom_error_msg.py" +``` + +??? note "Inspection and debugging" + + `TypeRegistry` exposes methods for listing and inspecting registered converters. These are primarily useful when debugging unexpected conversion behavior. + + **List all registered converters:** + + ```python + --8<-- "pages/advanced/snippets/type-registry/inspect_list.py" + ``` + + Output: + ``` + --8<-- "pages/advanced/snippets/type-registry/inspect_list_output.txt" + ``` + + **Check whether a specific converter is registered:** + + ```python + --8<-- "pages/advanced/snippets/type-registry/inspect_check.py" + ``` + + `TypeRegistry.conversion_map` is a dict keyed by `(from_type, to_type)` tuples. Each value is a `TypeConverterEntry` with `func`, `from_type`, `to_type`, `error_types`, and `error_message` fields. + +## See Also + +- [State Registry](state-registry.md): domain-to-class mapping +- [Custom States](custom-states.md): defining `value_type` on state models +- [Dependency Injection](../../bus/dependency-injection.md): type conversion in custom extractors From 53b5e5e0d2b506301dc1636c1eea19f190fe22c6 Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Tue, 2 Jun 2026 17:29:27 -0500 Subject: [PATCH 047/160] docs: rewrite API section (overview, entities, services, utilities, helpers) Overview: API vs StateManager comparison table, error handling with exception hierarchy, sync usage as collapsible. Fixed broken cross-links. Entities: dissolved Terminology section, introduced value/state/entity inline as each method is shown. Generic type params in collapsible. New api_get_state_value.py snippet. Fixed get_state_value return type. Services: leads with turn_on/turn_off (common case) before call_service. Domain deprecation caveat. Fixed call_service signature (no spurious *). Utilities: descriptive headings per method instead of 'Other Endpoints' grab-bag. Corrected fire_event to positional event_data dict. Managing Helpers: leads with idempotent bootstrap pattern. CRUD via one domain example + 8-domain reference table. All 35 methods verified. Typed models and gotchas in collapsible sections. --- docs/pages/core-concepts/api/entities.md | 77 ++++---- docs/pages/core-concepts/api/index.md | 60 +++--- .../core-concepts/api/managing-helpers.md | 181 +++++++++++++++++- docs/pages/core-concepts/api/services.md | 27 ++- .../api/snippets/api_get_state.py | 5 +- .../api/snippets/api_get_state_value.py | 8 + .../core-concepts/api/snippets/api_helpers.py | 4 +- .../core-concepts/api/snippets/api_history.py | 4 +- .../core-concepts/api/snippets/api_logbook.py | 8 +- .../api/snippets/api_response.py | 8 +- .../api/snippets/api_template.py | 15 +- .../api/snippets/api_utilities.py | 11 +- docs/pages/core-concepts/api/utilities.md | 64 ++++--- 13 files changed, 357 insertions(+), 115 deletions(-) create mode 100644 docs/pages/core-concepts/api/snippets/api_get_state_value.py diff --git a/docs/pages/core-concepts/api/entities.md b/docs/pages/core-concepts/api/entities.md index f779cd8bb..820fb1ba1 100644 --- a/docs/pages/core-concepts/api/entities.md +++ b/docs/pages/core-concepts/api/entities.md @@ -1,72 +1,83 @@ -# Retrieving Entities & States +# Entities & States -Use the API to retrieve the current state of any entity in Home Assistant. +`self.api` retrieves entity state directly from Home Assistant over the network. Three methods cover three levels of detail. The right choice depends on what the calling code does with the result. -## Terminology +The [API overview](index.md) covers when to prefer `self.api` over `self.states`. -Hassette uses precise terminology: +## Get the Value -- **State Value**: The raw value (e.g., `"on"`, `"23.5"`). -- **State**: A snapshot including value, attributes, and last changed time. -- **Entity**: A rich object wrapping the state with helper methods (e.g., `.turn_off()`). - -## Retrieving States - -Use `get_state` to retrieve a typed state object. +`get_state_value(entity_id)` returns the raw state value for an entity. A **state value** is the string Home Assistant stores in its state machine: `"on"`, `"23.5"`, `"above_horizon"`. No attributes, no timestamps, no type conversion. ```python ---8<-- "pages/core-concepts/api/snippets/api_get_state.py" +--8<-- "pages/core-concepts/api/snippets/api_get_state_value.py" ``` -### Raw vs Typed +`get_state_value` is the cheapest call when the value is all the code needs. + +## Get the Full State -Most methods return typed Pydantic models. You can use `get_state_raw` if you want a dict. +`get_state(entity_id)` returns a **state**, a snapshot of an entity at a point in time. A state includes `.value`, `.attributes`, `.last_changed`, `.last_updated`, and `.context`. The `.value` field is coerced to the domain's Python type. Lights and switches produce `bool`. Numeric sensors produce `float`. Most others remain `str`. + +The return type is a `BaseState` subclass matched to the entity's domain. `light.kitchen` returns a `LightState` with typed attribute access. ```python ---8<-- "pages/core-concepts/api/snippets/api_get_state_raw.py" +--8<-- "pages/core-concepts/api/snippets/api_get_state.py" ``` -### Checking Existence +### Optional Lookup -Use `get_state_or_none` to safely check for an entity. +`get_state_or_none(entity_id)` returns `None` instead of raising when the entity does not exist. `entity_exists(entity_id)` returns a plain `bool`. ```python --8<-- "pages/core-concepts/api/snippets/api_check_existence.py" ``` -## Retrieving Multiple States +### Raw Dict -Use `get_states` to fetch all states at once. This is more efficient than calling `get_state` in a loop. +`get_state_raw(entity_id)` returns the untyped `HassStateDict` from the REST response. This suits code that works outside the type registry or that inspects the raw HA payload for debugging. ```python ---8<-- "pages/core-concepts/api/snippets/api_get_states.py" +--8<-- "pages/core-concepts/api/snippets/api_get_state_raw.py" ``` -## Entities +## Get an Entity -Entities wrap the state object. Currently `BaseEntity` and `LightEntity` are available. +`get_entity(entity_id, model)` wraps a state in a `BaseEntity` subclass. An **entity** adds domain-specific action methods to the state snapshot: `turn_on()`, `turn_off()`, `toggle()`, and `refresh()`. The `model` argument specifies which entity class to use. ```python --8<-- "pages/core-concepts/api/snippets/api_get_entity.py" ``` -## API vs StateManager +`get_entity_or_none(entity_id, model)` follows the same pattern as `get_state_or_none`, returning `None` when the entity is not found. + +### Refreshing State + +`entity.refresh()` re-fetches the entity's state from Home Assistant and updates the entity's `.state` in place. The updated state is also returned. + +??? note "Generic Type Parameters" -The API methods above fetch states directly from Home Assistant over the network. Prefer `self.states` instead — it gives you instant, synchronous access from a local cache: + `BaseEntity` is generic over two type variables: `StateT` (the `BaseState` subclass) and `StateValueT` (the Python type of `.value`). For `LightEntity`, `StateT` is `LightState` and `StateValueT` is `bool | None`. These parameters are resolved at class definition time. Call sites supply no type arguments. They matter when creating custom entity types that extend `BaseEntity`. -- `self.states.light["kitchen"]` — Domain-specific typed access -- `self.states.get("light.kitchen")` — Direct lookup by entity ID, no `await` needed +## Fetching Multiple States -!!! warning "Prefer domain access for better typing" +`get_states()` retrieves all entities from Home Assistant in a single call and returns them as a list of typed `BaseState` objects. The method skips states that fail to convert and logs an error for each. `get_states_raw()` returns the untyped list. + +```python +--8<-- "pages/core-concepts/api/snippets/api_get_states.py" +``` - When you know the domain at write time, use `self.states.light` instead of `self.states.get()`. Domain access returns fully typed state objects (e.g., `LightState`) with autocomplete for domain-specific attributes. `get()` returns `BaseState | None`, so you lose attribute-level type safety. +`get_states()` accepts no filtering parameters. Filtering happens in Python after the call. -Use the API when you need guaranteed fresh data from Home Assistant — otherwise, `self.states` is faster and simpler. +## Which Method to Use -See [States](../states/index.md) for full details. +| Need | Method | +|---|---| +| Just the raw state value | `get_state_value` | +| Typed value, attributes, or timestamps | `get_state` | +| Domain action methods (`turn_on`, `turn_off`, `toggle`) | `get_entity` | ## See Also -- [Calling Services](services.md) - Invoke Home Assistant services -- [Utilities & History](utilities.md) - Templates, history, and advanced features -- [States](../states/index.md) - State management and caching +- [States](../states/index.md) — local cache for instant, synchronous state access +- [API overview](index.md) — when to use the API vs the state cache +- [Calling Services](services.md) — invoke Home Assistant services directly diff --git a/docs/pages/core-concepts/api/index.md b/docs/pages/core-concepts/api/index.md index 07eacf456..dde86e2ed 100644 --- a/docs/pages/core-concepts/api/index.md +++ b/docs/pages/core-concepts/api/index.md @@ -1,10 +1,10 @@ -# API Overview +# API -The `Api` resource lets your apps interact with Home Assistant. It wraps the REST and WebSocket APIs with typed Python interfaces and handles authentication, retries, and type conversion automatically. +`self.api` sends commands to Home Assistant and retrieves data from it. It wraps the REST and WebSocket APIs with automatic authentication, retries, and type conversion. Every [`App`](../apps/index.md) instance has one. ```mermaid flowchart TD - subgraph app["Your App"] + subgraph app["App"] APP["self.api"] end @@ -25,46 +25,54 @@ flowchart TD style ha fill:#f0f0f0,stroke:#999 ``` -## Usage +## Quick Example -`self.api` is pre-configured and ready to use in any app: +The two most common operations are reading state and calling a service. ```python --8<-- "pages/core-concepts/api/snippets/api_overview_usage.py" ``` -## Error Handling +`get_state()` fetches the entity from Home Assistant over the network. It returns a typed state object. `call_service()` sends a service call via WebSocket. -The API raises typed exceptions for common failures: +## API vs StateManager -- [`EntityNotFoundError`][hassette.exceptions.EntityNotFoundError] — entity does not exist in Home Assistant -- [`InvalidAuthError`][hassette.exceptions.InvalidAuthError] — authentication failed; check your token -- [`HassetteError`][hassette.exceptions.HassetteError] — generic upstream error +[`self.states`](../states/index.md) covers most state-reading needs. It returns typed state objects from a local cache, with no network call and no `await`. -Network errors are automatically retried. Catch `HassetteError` to handle all API failures in one place. +| | `self.states` | `self.api` | +|---|---|---| +| Access pattern | Synchronous | `async` / `await` | +| Data source | Local cache, updated from HA events | Direct from Home Assistant | +| Latency | Instant | Network round-trip | +| Best for | Reading state in handlers | Writes, fresh data, helpers | -## Synchronous Usage +`self.states` is faster and simpler for reads. `self.api` is the right choice when fresh-from-HA data is needed, or for any write operation: service calls, `set_state()`, helper management. -If you are writing a synchronous app, subclass `AppSync` and override `on_initialize_sync` instead of `on_initialize`. Hassette runs your sync method in a thread, where `self.api.sync` provides blocking versions of all API methods: +## Error Handling -!!! note - `self.api.sync` is only safe to call from **outside the event loop** — specifically from `AppSync` lifecycle methods (`on_initialize_sync`, `on_shutdown_sync`). Calling it from inside an `async def` method will deadlock. +`Api` raises typed exceptions for common failures. -```python ---8<-- "pages/core-concepts/api/snippets/api_sync_usage.py" -``` +- [`EntityNotFoundError`][hassette.exceptions.EntityNotFoundError] if the entity does not exist in Home Assistant. +- [`InvalidAuthError`][hassette.exceptions.InvalidAuthError] if authentication failed (invalid or expired token). +- [`HassetteError`][hassette.exceptions.HassetteError] for any other upstream error from Home Assistant. + +Network errors are retried automatically. Catching [`HassetteError`][hassette.exceptions.HassetteError] handles all API failures in one place. -## API vs. StateManager +## Synchronous Usage -The API fetches state directly from Home Assistant over the network. For reading entity state in most situations, prefer `self.states` — it provides instant synchronous access from a local cache with no network overhead: +??? note "AppSync and self.api.sync" + Apps that subclass `AppSync` override `on_initialize_sync` instead of `on_initialize`. Hassette runs the sync method in a thread. `self.api.sync` provides blocking versions of all async API methods. -- `self.states.light["kitchen"]` — domain-specific typed access, no `await` -- `self.states.get("light.kitchen")` — direct lookup by entity ID, no `await` + ```python + --8<-- "pages/core-concepts/api/snippets/api_sync_usage.py" + ``` -Use `self.api` when you specifically need guaranteed fresh data directly from Home Assistant. + !!! warning + `self.api.sync` is only safe to call from outside the event loop, specifically from `AppSync` lifecycle methods (`on_initialize_sync`, `on_shutdown_sync`). Calling it from inside an `async def` method deadlocks. ## Next Steps -- **[Entities & States](entities.md)** — retrieve state data from Home Assistant -- **[Services](services.md)** — invoke Home Assistant services -- **[Utilities](utilities.md)** — history, logbook, templates, and more +- [Entities & States](entities.md) covers reading state data from Home Assistant. +- [Services](services.md) covers calling Home Assistant services. +- [Managing Helpers](managing-helpers.md) covers creating and managing input helpers (booleans, counters, timers, and more). +- [Utilities](utilities.md) covers history, logbook, templates, and calendars. diff --git a/docs/pages/core-concepts/api/managing-helpers.md b/docs/pages/core-concepts/api/managing-helpers.md index ea8ce8e4d..54520500b 100644 --- a/docs/pages/core-concepts/api/managing-helpers.md +++ b/docs/pages/core-concepts/api/managing-helpers.md @@ -1,3 +1,182 @@ # Managing Helpers -*This page is being rewritten as part of the documentation overhaul.* +Home Assistant helpers (`input_boolean`, `input_number`, `input_text`, `input_select`, +`input_datetime`, `input_button`, `counter`, `timer`) are persistent entities stored in +HA's `.storage/` directory. They survive restarts and appear in the HA UI. The +[`Api`][hassette.api.Api] exposes 32 typed CRUD methods across 8 domains, plus 3 counter +shortcuts. + +## Creating a Helper on Startup + +The most common pattern provisions a helper once during `on_initialize`, then holds the +returned record for the app's lifetime. Because helpers persist across restarts, the +idempotent approach checks for an existing record before creating: + +```python +--8<-- "pages/advanced/snippets/managing-helpers/crud_operations.py:bootstrap" +``` + +`list_input_booleans()` returns all stored `input_boolean` records. The loop exits early +if a matching id is found, so `create_input_boolean` only runs on first startup. + +!!! warning "Concurrent provisioning" + When two apps run the same list-then-create sequence simultaneously, both may pass + the gap between list and create. HA does not raise an error. It silently appends `_2` + to the second helper's id. No error code signals the collision. The correct mitigation + is naming discipline: each helper's name should carry a prefix unique to its owning app + (for example, `motionapp_cycles` rather than `cycles`), and only one app should ever + provision a given helper. + +## CRUD Operations + +The create, list, update, and delete pattern is identical across all 8 domains. The +examples below use `input_boolean`; the same method names apply to every domain in the +[reference table](#all-supported-domains). + +### Create + +```python +--8<-- "pages/advanced/snippets/managing-helpers/create_helper.py" +``` + +The returned `InputBooleanRecord` carries the `id` HA assigned, typically the slugified +form of the `name` passed in, for example `"vacation_mode"`. Storing or logging the `id` +is useful, as `list_input_booleans()` is the only retrieval path if the id is not cached. + +### List + +```python +--8<-- "pages/advanced/snippets/managing-helpers/crud_operations.py:list" +``` + +`list_*` returns all records for the domain, regardless of which app created them. + +### Update + +```python +--8<-- "pages/advanced/snippets/managing-helpers/crud_operations.py:update" +``` + +`update_input_boolean` accepts a `helper_id` string (the stored `id` field, not the +display name) and a partial params object. Only fields present in the params object are +sent to HA; absent fields retain their stored values. A `helper_id` that does not exist +raises `FailedMessageError(code="not_found")`. + +### Delete + +```python +--8<-- "pages/advanced/snippets/managing-helpers/crud_operations.py:delete" +``` + +`delete_*` returns `None`. It raises `FailedMessageError(code="not_found")` if the id +is absent from storage. + +### All Supported Domains + +The pattern above applies to every domain. Method names follow the same convention: + +| Domain | List | Create | Update | Delete | +|---|---|---|---|---| +| `input_boolean` | `list_input_booleans` | `create_input_boolean` | `update_input_boolean` | `delete_input_boolean` | +| `input_number` | `list_input_numbers` | `create_input_number` | `update_input_number` | `delete_input_number` | +| `input_text` | `list_input_texts` | `create_input_text` | `update_input_text` | `delete_input_text` | +| `input_select` | `list_input_selects` | `create_input_select` | `update_input_select` | `delete_input_select` | +| `input_datetime` | `list_input_datetimes` | `create_input_datetime` | `update_input_datetime` | `delete_input_datetime` | +| `input_button` | `list_input_buttons` | `create_input_button` | `update_input_button` | `delete_input_button` | +| `counter` | `list_counters` | `create_counter` | `update_counter` | `delete_counter` | +| `timer` | `list_timers` | `create_timer` | `update_timer` | `delete_timer` | + +## Counter Shortcuts + +`increment_counter`, `decrement_counter`, and `reset_counter` operate on the live entity +state, not stored configuration. They call HA's `counter` service domain and take effect +immediately: + +```python +--8<-- "pages/advanced/snippets/managing-helpers/counter_shortcuts.py" +``` + +Timer actions (`timer.start`, `timer.pause`, `timer.cancel`) are not wrapped as +shortcuts. They go through `call_service` directly: + +```python +--8<-- "pages/advanced/snippets/managing-helpers/timer_call_service.py:timer" +``` + +Counter shortcuts are high-frequency operations. The shorter call site makes a difference +when a handler runs on every motion event. Timer actions are typically one-off; the full +`call_service` signature makes the intent explicit at those call sites. + +## Testing + +`AppTestHarness` exposes a `seed_helper(record)` method that pre-populates the harness's +helper store. The harness derives the domain from the record's class, so no `domain` +parameter is needed. The typed record is sufficient: + +```python +--8<-- "pages/advanced/snippets/managing-helpers/testing_harness.py" +``` + +Seeded records are stored as deep copies. Later mutations to the record passed into +`seed_helper` do not affect harness state. + +## Gotchas + +??? note "Common pitfalls" + + **HA auto-suffixes on name collision.** When `create_*` receives a `name` that + slugifies to an `id` already in storage, HA does not raise an error. It silently + appends `_2`, `_3`, and so on until it finds a free slot. Two concurrent creators of + the same-named helper both succeed, leaving two semantically-duplicate records. There + is no `name_in_use` error code to catch. Each helper's name should carry a prefix + unique to its owning app, and only one app should provision it. + + **`CreateInputDatetimeParams` requires `has_date=True` or `has_time=True`.** Both + fields `False` raises `ValidationError` at construction time, before any network call. + `UpdateInputDatetimeParams` does not enforce this constraint on partial updates, because + the counterpart field retains its stored value. + + **`exclude_unset=True` vs explicit `None`.** All CRUD methods serialize params with + `model_dump(exclude_unset=True)`. A field omitted from the constructor is not sent to + HA; HA keeps its stored value. A field passed as `None` is sent as `null`, which may + clear the value on the HA side. Omitting `icon` and passing `icon=None` produce + different wire payloads. + + **`CounterRecord` and `CounterState` are two different models.** `CounterRecord` + represents stored configuration, returned by `list_counters`, `create_counter`, and + `update_counter`. `CounterState` represents the live runtime value, returned by + `get_state("counter.mycounter")`. Changes to stored config (for example, updating + `initial`) take effect after an HA restart. `increment_counter`, `decrement_counter`, + and `reset_counter` are immediate but do not modify stored config. + + **Helper creation persists across HA restarts.** HA stores helpers in `.storage/`. + A helper created during `on_initialize` is still present on the next run. The + idempotent bootstrap pattern in [Creating a Helper on Startup](#creating-a-helper-on-startup) + exists for this reason. + + **`RetryableConnectionClosedError` is a second exception class callers may receive.** + A WebSocket disconnect mid-CRUD propagates as `RetryableConnectionClosedError`, not + `FailedMessageError`. Exception handlers that target only `FailedMessageError` miss + this case. A broader `except` clause covering both exception types handles it + correctly. + +??? note "Typed model reference" + + Each domain exposes three Pydantic model classes in `hassette.models.helpers`: + + | Model | Purpose | `extra` policy | + |---|---|---| + | `{Domain}Record` | Stored configuration returned by `list_*`, `create_*`, and `update_*` | `"allow"`: unknown HA fields pass through | + | `Create{Domain}Params` | Required and optional fields for a create call | `"forbid"`: typos raise `ValidationError` at construction | + | `Update{Domain}Params` | Partial update payload with all fields optional | `"ignore"`: extra fields from round-tripped records are silently dropped | + + All three CRUD methods that accept a params object serialize it with + `model_dump(exclude_unset=True)`, not `exclude_none`. Omitting a field and explicitly + setting it to `None` produce different wire payloads. + +## See Also + +- [API overview](index.md) and other method categories +- [Services](services.md) for `call_service`, timer actions, and other service calls +- [Testing Your Apps](../../testing/index.md) for full harness documentation +- [Apps](../apps/index.md) for lifecycle hooks including `on_initialize` diff --git a/docs/pages/core-concepts/api/services.md b/docs/pages/core-concepts/api/services.md index 089b0615a..97e4d979a 100644 --- a/docs/pages/core-concepts/api/services.md +++ b/docs/pages/core-concepts/api/services.md @@ -1,28 +1,28 @@ # Calling Services -The API provides methods to invoke Home Assistant services. +`self.api` calls any Home Assistant service: turning devices on and off, sending notifications, running scripts, or firing any action a service domain exposes. -## Basic Service Calls +## Turning Things On and Off -Use `call_service` for generic service invocations. +`turn_on`, `turn_off`, and `toggle_service` cover the most common case. Each accepts an entity ID and dispatches the corresponding `homeassistant.*` service call. ```python ---8<-- "pages/core-concepts/api/snippets/api_call_service.py" +--8<-- "pages/core-concepts/api/snippets/api_helpers.py" ``` -## Convenience Helpers +All three default to the `homeassistant` domain (`homeassistant.turn_on`, `homeassistant.turn_off`, `homeassistant.toggle`). Home Assistant 2024.x deprecated those generic services in favor of domain-specific ones. `domain="light"` routes the call to `light.turn_on` instead. `turn_on` also accepts `**data` keyword arguments, so light-specific fields like `brightness` and `color_name` pass through to the service call unchanged. + +## Generic Service Calls -Common operations like turning entities on/off have dedicated helpers. They save you from specifying the domain and service name separately — `turn_on("light.porch")` is more readable than `call_service("homeassistant", "turn_on", entity_id="light.porch")` and less error-prone. +`call_service(domain, service, target=None, return_response=False, **data)` handles any service. Service data passes through as keyword arguments. `brightness=200` becomes `service_data` on the wire. The `target` parameter accepts an entity ID, area, or device dict for services that support targeting. ```python ---8<-- "pages/core-concepts/api/snippets/api_helpers.py" +--8<-- "pages/core-concepts/api/snippets/api_call_service.py" ``` -These methods forward arguments to `call_service` while providing a cleaner syntax. - -## Service Responses +## Getting a Response -Service calls return a response dictionary (if the service provides one). +Some services return data. `weather.get_forecasts` returns forecast arrays; `conversation.process` returns a reply. Setting `return_response=True` tells Home Assistant to include the response payload. Without it, `call_service` returns `None`. ```python --8<-- "pages/core-concepts/api/snippets/api_response.py" @@ -30,6 +30,5 @@ Service calls return a response dictionary (if the service provides one). ## See Also -- [Retrieving Entities & States](entities.md) - Get entity and state data -- [Utilities & History](utilities.md) - Templates, history, and advanced features -- [Bus](../bus/index.md) - Subscribe to service call events +- [Entities & States](entities.md) for reading state after a service call. +- [Bus](../bus/index.md) for subscribing to service calls from other sources via `on_call_service`. diff --git a/docs/pages/core-concepts/api/snippets/api_get_state.py b/docs/pages/core-concepts/api/snippets/api_get_state.py index aa135fb7e..7df02e958 100644 --- a/docs/pages/core-concepts/api/snippets/api_get_state.py +++ b/docs/pages/core-concepts/api/snippets/api_get_state.py @@ -5,8 +5,9 @@ class LightApp(App): async def on_initialize(self): - # Get typed state (raises EntityNotFoundError if missing) - light = cast("states.LightState", await self.api.get_state("light.kitchen")) + # Raises EntityNotFoundError if missing + state = await self.api.get_state("light.kitchen") + light = cast("states.LightState", state) # Access typed attributes print(light.attributes.brightness) diff --git a/docs/pages/core-concepts/api/snippets/api_get_state_value.py b/docs/pages/core-concepts/api/snippets/api_get_state_value.py new file mode 100644 index 000000000..56673c93c --- /dev/null +++ b/docs/pages/core-concepts/api/snippets/api_get_state_value.py @@ -0,0 +1,8 @@ +from hassette import App + + +class SunApp(App): + async def on_initialize(self): + value = await self.api.get_state_value("sun.sun") + # "above_horizon" or "below_horizon" + self.logger.info("Sun is: %s", value) diff --git a/docs/pages/core-concepts/api/snippets/api_helpers.py b/docs/pages/core-concepts/api/snippets/api_helpers.py index e57a94308..011b8099b 100644 --- a/docs/pages/core-concepts/api/snippets/api_helpers.py +++ b/docs/pages/core-concepts/api/snippets/api_helpers.py @@ -4,7 +4,9 @@ class HelperApp(App): async def on_initialize(self): # Turn on with attributes - await self.api.turn_on("light.kitchen", brightness=255, color_name="blue") + await self.api.turn_on( + "light.kitchen", brightness=255, color_name="blue" + ) # Turn off await self.api.turn_off("switch.fan") diff --git a/docs/pages/core-concepts/api/snippets/api_history.py b/docs/pages/core-concepts/api/snippets/api_history.py index 16cbecabb..24252ac08 100644 --- a/docs/pages/core-concepts/api/snippets/api_history.py +++ b/docs/pages/core-concepts/api/snippets/api_history.py @@ -4,7 +4,9 @@ class HistoryApp(App): async def on_initialize(self): start = self.now().subtract(hours=24) - history = await self.api.get_history("sensor.temperature", start_time=start) + history = await self.api.get_history( + "sensor.temperature", start_time=start, + ) for entry in history: print(f"{entry.last_changed}: {entry.state}") diff --git a/docs/pages/core-concepts/api/snippets/api_logbook.py b/docs/pages/core-concepts/api/snippets/api_logbook.py index 57bc6c561..f6a47d131 100644 --- a/docs/pages/core-concepts/api/snippets/api_logbook.py +++ b/docs/pages/core-concepts/api/snippets/api_logbook.py @@ -3,5 +3,11 @@ class LogbookApp(App): async def on_initialize(self): - events = await self.api.get_logbook(entity_id="automation.morning_routine", start_time=self.now().subtract(hours=1), end_time=self.now()) + end = self.now() + start = end.subtract(hours=1) + events = await self.api.get_logbook( + entity_id="automation.morning_routine", + start_time=start, + end_time=end, + ) self.logger.info("Events: %d", len(events)) diff --git a/docs/pages/core-concepts/api/snippets/api_response.py b/docs/pages/core-concepts/api/snippets/api_response.py index 715af0e84..c2c6b678e 100644 --- a/docs/pages/core-concepts/api/snippets/api_response.py +++ b/docs/pages/core-concepts/api/snippets/api_response.py @@ -3,5 +3,11 @@ class WeatherApp(App): async def on_initialize(self): - response = await self.api.call_service("weather", "get_forecasts", target={"entity_id": "weather.home"}, return_response=True, type="daily") + response = await self.api.call_service( + "weather", + "get_forecasts", + target={"entity_id": "weather.home"}, + return_response=True, + type="daily", + ) print(response) diff --git a/docs/pages/core-concepts/api/snippets/api_template.py b/docs/pages/core-concepts/api/snippets/api_template.py index 44a8734cf..4b8d80470 100644 --- a/docs/pages/core-concepts/api/snippets/api_template.py +++ b/docs/pages/core-concepts/api/snippets/api_template.py @@ -7,9 +7,14 @@ async def on_initialize(self): result = await self.api.render_template("{{ states('sun.sun') }}") self.logger.info("Sun state: %s", result) - # Complex logic - avg_temp = await self.api.render_template(""" - {{ states.sensor | selectattr('attributes.device_class', 'eq', 'temperature') - | map(attribute='state') | map('float', default=0) | average }} - """) + # Complex server-side logic + template = ( + "{{ states.sensor" + " | selectattr('attributes.device_class'," + " 'eq', 'temperature')" + " | map(attribute='state')" + " | map('float', default=0)" + " | average }}" + ) + avg_temp = await self.api.render_template(template) self.logger.info("Average temp: %s", avg_temp) diff --git a/docs/pages/core-concepts/api/snippets/api_utilities.py b/docs/pages/core-concepts/api/snippets/api_utilities.py index c5cbe9958..ecdbc226e 100644 --- a/docs/pages/core-concepts/api/snippets/api_utilities.py +++ b/docs/pages/core-concepts/api/snippets/api_utilities.py @@ -4,14 +4,21 @@ class UtilitiesApp(App[AppConfig]): async def on_initialize(self) -> None: # --8<-- [start:fire_event] - await self.api.fire_event("custom_event", {"source": "my_app", "value": 42}) + await self.api.fire_event( + "custom_event", + {"source": "my_app", "value": 42}, + ) # event_data is optional await self.api.fire_event("hassette_ready") # --8<-- [end:fire_event] # --8<-- [start:set_state] - await self.api.set_state("sensor.custom_score", "87", {"unit_of_measurement": "%"}) + await self.api.set_state( + "sensor.custom_score", + "87", + {"unit_of_measurement": "%"}, + ) # --8<-- [end:set_state] # --8<-- [start:get_calendars] diff --git a/docs/pages/core-concepts/api/utilities.md b/docs/pages/core-concepts/api/utilities.md index ec5121f45..4687dc949 100644 --- a/docs/pages/core-concepts/api/utilities.md +++ b/docs/pages/core-concepts/api/utilities.md @@ -1,81 +1,89 @@ -# Utilities & History +# Utilities -Beyond basic states and services, the API exposes advanced Home Assistant features. +Beyond states and services, the API exposes templates, history, logbook entries, event firing, synthetic state writing, and calendar access. These methods handle the less frequent tasks that don't fit the core state-and-service model. ## Templates -Render Jinja2 templates on the server. Use this when you need to evaluate expressions that HA already knows how to compute — such as averaging across all sensors of a certain class, or evaluating a complex conditional — without pulling all the raw data into Python first. +`render_template` evaluates a Jinja2 template string on the Home Assistant server and returns the result as a string. Template evaluation runs server-side, so the full HA template environment is available: `states`, `is_state`, sensor aggregations, and every other built-in helper. ```python --8<-- "pages/core-concepts/api/snippets/api_template.py" ``` -You can pass a `variables` dict as the second argument to inject values into the template context, keeping the template string reusable across calls. +The optional `variables` parameter injects a dict of values into the template context. The same template string stays reusable across calls with different inputs. + +Template evaluation is most useful when HA already knows how to compute something complex (averaging across a device class, evaluating multi-sensor conditionals) and pulling all the raw data into Python would be wasteful. ## History -Fetch the recorded state changes for an entity over a time window. This is useful for trend analysis, energy reporting, or building automations that depend on what a sensor was doing hours ago, not just its current value. +`get_history` retrieves recorded state changes for a single entity over a time window. The `start_time` parameter is required. The `end_time` parameter is optional; omitting it returns changes from `start_time` to the present. ```python --8<-- "pages/core-concepts/api/snippets/api_history.py" ``` -Use `get_histories` (plural) to fetch multiple entities in one request. Passing a comma-separated string to `get_history` raises a `ValueError`. Both methods accept optional flags: `significant_changes_only` (skip minor attribute-only updates), `minimal_response` (omit attributes from all but the last entry), and `no_attributes` (strip attributes entirely for smaller payloads). +`get_histories` fetches multiple entities in a single request. It accepts a `list[str]` and returns a `dict` mapping each entity ID to its history entries. Passing a comma-separated string to `get_history` raises a `ValueError`. + +Both methods accept three optional flags for reducing payload size: + +- `significant_changes_only` — skips minor attribute-only updates, returning only state-string transitions +- `minimal_response` — omits attributes from all but the last entry in each entity's list +- `no_attributes` — strips attributes entirely from every entry ## Logbook -Retrieve logbook entries for an entity — the human-readable log HA shows in the UI. This captures state changes and automation triggers in a format suited for displaying activity summaries to users. +`get_logbook` retrieves the human-readable log entries that Home Assistant records for an entity. Logbook entries capture state changes and automation triggers in the format HA displays in its UI. They are useful for building activity summaries or audit trails. ```python --8<-- "pages/core-concepts/api/snippets/api_logbook.py" ``` -Unlike `get_history`, both `start_time` and `end_time` are required. - -## Other Endpoints +Both `start_time` and `end_time` are required. This differs from `get_history`, where `end_time` is optional. -### `fire_event` +## Firing Events -`fire_event` sends an event to Home Assistant's event bus. Any HA automation or integration subscribed to that event type receives it. Use it to trigger HA automations from Hassette, or to interoperate with other HA components. +`fire_event` sends an event to Home Assistant's event bus. Any HA automation, integration, or component subscribed to that event type receives it. ```python --8<-- "pages/core-concepts/api/snippets/api_utilities.py:fire_event" ``` -!!! note "Broadcasting between Hassette apps" - `fire_event` leaves the framework — the event travels through Home Assistant and back. For in-process broadcast between apps within Hassette, use [`self.bus.emit(topic, data)`](../apps/index.md#broadcasting-events-between-apps) instead. It stays local, fires faster, and keeps the data typed end-to-end. +The `event_data` parameter is optional. When omitted, the event fires with no payload. -### `set_state` +!!! note "In-process broadcast between apps" + `fire_event` leaves the framework. The event travels to Home Assistant and back. For broadcasting between Hassette apps in the same process, [`self.bus.emit()`](../bus/index.md) stays local, fires faster, and keeps the data typed end-to-end. -Write a synthetic state to HA's state machine. This creates or updates a state entry in HA's UI and REST API, but it does not control a real device — HA integrations do not react to it the way they react to `call_service`. Use it for virtual sensors, exposing computed values to the HA dashboard, or sharing state between apps via HA's state machine. +## Writing Synthetic State + +`set_state` writes a state entry to Home Assistant's in-memory state machine. The entry appears in the HA dashboard and REST API like any other entity state, but it does not control a real device. HA integrations do not react to it the way they react to `call_service`. ```python --8<-- "pages/core-concepts/api/snippets/api_utilities.py:set_state" ``` -Existing attributes are merged: any attributes you pass are overlaid on top of the current ones, so you only need to provide the keys you want to change. +Attributes are merged: `set_state` reads the entity's current attributes and overlays only the keys passed in `attributes`. Existing keys not mentioned in the call are preserved. + +Typical uses include virtual sensors that expose computed values to the dashboard, and sharing derived state between apps via HA's state machine. -!!! note "Synthetic states are not persisted across HA restarts" - States written with `set_state` live in HA's in-memory state machine. They are lost when HA restarts unless you restore them in `on_initialize`. +!!! note "Synthetic states do not survive HA restarts" + States written with `set_state` live in HA's in-memory state machine. They are lost when HA restarts. Apps that need persistence across restarts can re-create them in `on_initialize`. -### `get_calendars` +## Calendars -List all calendar entities registered in Home Assistant. +`get_calendars` returns all calendar entities registered in Home Assistant. `get_calendar_events` fetches events from a specific calendar within a time window. Both `start_time` and `end_time` are required for `get_calendar_events`. ```python --8<-- "pages/core-concepts/api/snippets/api_utilities.py:get_calendars" ``` -### `get_calendar_events` - -Fetch events from a specific calendar within a time window. - ```python --8<-- "pages/core-concepts/api/snippets/api_utilities.py:get_calendar_events" ``` ## See Also -- [Retrieving Entities & States](entities.md) - Get entity and state data -- [Calling Services](services.md) - Invoke Home Assistant services -- [App Cache](../cache/index.md) - Cache data locally across restarts +- [API Overview](index.md) — the full `self.api` surface +- [Retrieving Entities & States](entities.md) — reading current state without history +- [Calling Services](services.md) — controlling real devices +- [Bus](../bus/index.md) — in-process event broadcast between apps +- [App Cache](../cache/index.md) — persisting data across HA restarts From 31b746027d7286b83e33ddd3974ecfc91e163abc Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Tue, 2 Jun 2026 17:39:54 -0500 Subject: [PATCH 048/160] docs: rewrite Cache + Configuration sections (T08 complete) Cache Overview: functional definition, shared cache warning promoted, pickling explanation, configuration table. Verified cache property on Resource base class. Cache Patterns: six patterns with snippets, troubleshooting tightened, Best Practices section cut (redundant with overview). Config Overview: absorbed auth.md (token setup), configuration sources with priority, section map, 7 design notes. Fixed database path independence, FileWatcher field name, database WAL claim. Fixed deprecated TOML keys in snippets. Config Applications: merged register-then-configure flow, AppManifest vs AppConfig as callout, multi-instance with [[double brackets]]. All HassetteConfig and AppManifest fields verified via Serena. --- docs/pages/core-concepts/cache/index.md | 93 +++++--------- docs/pages/core-concepts/cache/patterns.md | 120 +++++------------- .../configuration/applications.md | 74 +++++------ .../core-concepts/configuration/index.md | 99 +++++++++++---- .../configuration/snippets/basic_config.toml | 5 +- .../configuration/snippets/file_discovery.md | 2 +- .../snippets/multiple_instances.toml | 6 +- .../snippets/single_instance.toml | 2 +- 8 files changed, 180 insertions(+), 221 deletions(-) diff --git a/docs/pages/core-concepts/cache/index.md b/docs/pages/core-concepts/cache/index.md index 21df86d31..0f3509559 100644 --- a/docs/pages/core-concepts/cache/index.md +++ b/docs/pages/core-concepts/cache/index.md @@ -1,66 +1,44 @@ # App Cache -Hassette provides a built-in disk-based cache on every app and service via `self.cache`. Data written to the cache persists across restarts and is available immediately the next time your app starts. +`self.cache` provides persistent key-value storage on every app. Data written to the cache survives restarts and is available at the next startup. The cache is a [`diskcache.Cache`](https://grantjenks.com/docs/diskcache/) instance. The full diskcache API is available directly. -## When to Use the Cache - -The cache is the right tool when you need to: - -- **Rate-limit notifications** — record when you last sent a notification so it does not repeat within a cooldown window -- **Remember state across restarts** — counters, timestamps, or preferences that should survive a Hassette restart -- **Cache expensive operations** — store external API responses to avoid rate limits or reduce network calls -- **Aggregate historical data** — keep rolling totals or logs that don't belong in Home Assistant state - -For real-time Home Assistant entity state, use [`self.states`](../states/index.md) instead. The cache is for *your* app data, not HA state. +For real-time Home Assistant entity state, [`self.states`](../states/index.md) is the right tool. The cache is for app data: counters, timestamps, API responses, preferences. ## Basic Usage -The cache behaves like a Python dictionary. You can get, set, check membership, and delete keys: +The cache exposes a dictionary-like API for get, set, delete, and membership checks. No open, flush, or close call is needed. ```python --8<-- "pages/core-concepts/cache/snippets/cache_basic_usage.py" ``` -The cache persists to disk automatically. You do not need to open, flush, or close it — Hassette handles that during startup and shutdown. - -## How It Works - -### Storage Location +Hassette opens the cache at first access and flushes it to disk at shutdown. All reads and writes happen transparently in between. -Each app or service gets its own cache directory under your configured `data_dir`: +## Shared Cache and Multi-Instance Apps -``` -{data_dir}/{ClassName}/cache/ -``` +All instances of the same app class share one cache directory, keyed by class name. Two instances of `WeatherApp` with different configurations read from and write to the same cache. -For example, if your app class is `WeatherApp` and `data_dir` is `/home/user/.hassette`, the cache lives at: +For multi-instance apps, prefixing keys with `self.app_config.instance_name` avoids collisions: +```python +--8<-- "pages/core-concepts/cache/snippets/cache_instance_prefix.py" ``` -/home/user/.hassette/WeatherApp/cache/ -``` - -### Shared Cache - -All instances of the same resource class share the same cache directory. If you run `MyApp` as two separate instances with different configurations, both instances read from and write to the same cache. -!!! warning "Use instance name as a key prefix for multi-instance apps" - If the same app class runs as multiple instances, prefix your keys with `self.app_config.instance_name` to avoid collisions: +## What Can Be Cached - ```python - --8<-- "pages/core-concepts/cache/snippets/cache_instance_prefix.py" - ``` +The cache stores any Python object that supports pickling, Python's built-in serialization format. This includes: -### Lazy Initialization - -The cache directory is created on first access. If your app never uses `self.cache`, no directory is created. - -### Automatic Cleanup +- Primitives: `str`, `int`, `float`, `bool`, `None` +- Collections: `list`, `dict`, `tuple`, `set` +- Timestamps from the `whenever` library: `Instant`, `ZonedDateTime`, `PlainDateTime`, `TimeDelta` +- Pydantic models and dataclasses (if picklable) -Hassette closes and flushes the cache to disk during app shutdown. You do not need to call any cleanup method. +!!! tip "Storing timestamps" + `self.now()` returns the current time as a `ZonedDateTime`. It is timezone-aware, picklable, and supports arithmetic like `self.now().subtract(hours=4)`. ## Configuration -Control the maximum cache size in `hassette.toml`: +`default_cache_size` and `data_dir` are root-level settings in `hassette.toml`: ```toml [hassette] @@ -69,36 +47,23 @@ data_dir = "/path/to/data" ``` | Setting | Type | Default | Description | -|---------|------|---------|-------------| -| `default_cache_size` | integer (bytes) | `104857600` | Maximum size for each resource's cache. When the limit is reached, the least recently used items are evicted. | +|---|---|---|---| +| `default_cache_size` | integer (bytes) | `104857600` | Size limit for each app's cache. Least-recently-used items are evicted when the limit is reached. | | `data_dir` | path | platform-dependent | Root directory for all persistent data. See [Global Settings](../configuration/global.md) for platform defaults. | -## Lifecycle - -The cache is managed automatically through the resource lifecycle: - -1. **First access** — cache directory is created and the cache is opened -2. **Runtime** — reads and writes happen transparently; the cache is thread-safe -3. **Shutdown** — the cache is closed and all pending writes are flushed to disk - -You never need to manually open or close the cache. +## How It Works -## Data Types +**Storage location.** Each app's cache lives at `{data_dir}/{ClassName}/cache/`. A `WeatherApp` with `data_dir = /home/user/.hassette` stores its cache at `/home/user/.hassette/WeatherApp/cache/`. -The cache stores any Python object that supports [pickling](https://docs.python.org/3/library/pickle.html) (Python's built-in serialization format for converting objects to bytes for storage): +**Lazy initialization.** The cache directory is created on first access. Apps that never use `self.cache` produce no directory. -- Primitives: `str`, `int`, `float`, `bool`, `None` -- Collections: `list`, `dict`, `tuple`, `set` -- Timestamps: `ZonedDateTime`, `PlainDateTime`, `Instant`, `TimeDelta` from the `whenever` library -- Hassette models: state instances, event instances -- Custom dataclasses and classes (if they are picklable) +**Lifecycle.** The cache is available from first access through shutdown. Hassette closes and flushes it to disk when the app stops. -!!! tip "Storing timestamps" - Use `self.now()` to get the current time as a `ZonedDateTime`. This is the recommended type for timestamps stored in the cache — it is timezone-aware, picklable, and supports arithmetic like `self.now().subtract(hours=4)`. +**Automatic cleanup.** Entries with a TTL expire silently. When the cache reaches `size_limit`, the least-recently-used items are evicted without raising an error. ## See Also -- [Patterns & Examples](patterns.md) — rate-limiting, counters, complex data, expiring entries, best practices, and troubleshooting -- [Global Settings](../configuration/global.md) — `data_dir` and `default_cache_size` reference -- [States](../states/index.md) — real-time HA entity state (not the same as the cache) -- [diskcache documentation](https://grantjenks.com/docs/diskcache/) — the underlying library +- [Patterns & Examples](patterns.md). Rate-limiting, counters, complex data, expiring entries, and troubleshooting. +- [Global Settings](../configuration/global.md). `data_dir` and `default_cache_size` reference. +- [States](../states/index.md). Real-time HA entity state (not the cache). +- [diskcache documentation](https://grantjenks.com/docs/diskcache/). The underlying library. diff --git a/docs/pages/core-concepts/cache/patterns.md b/docs/pages/core-concepts/cache/patterns.md index f350aa4cd..0f65fbef9 100644 --- a/docs/pages/core-concepts/cache/patterns.md +++ b/docs/pages/core-concepts/cache/patterns.md @@ -1,141 +1,89 @@ # App Cache: Patterns & Examples -This page covers practical patterns for `self.cache`. The [Overview](index.md) covers setup and basic usage. +Practical patterns for `self.cache`. Each pattern addresses a specific problem with a complete, runnable example. The [Overview](index.md) covers setup and basic usage. -## Pattern: API Response Caching +## Rate-Limiting Notifications -Avoid hitting external API rate limits by storing responses with a timestamp and checking freshness before making a new request: - -```python ---8<-- "pages/core-concepts/cache/snippets/cache_api_response.py" -``` - -The pattern: check if the cached entry exists and is within the TTL window, return it if so, otherwise fetch fresh data and update the cache. - -## Pattern: Rate-Limiting Notifications - -Prevent notification spam by recording when the last notification was sent and skipping the call if the cooldown has not elapsed: +A leak or alarm sensor can fire repeatedly during a single incident. Storing a timestamp in the cache prevents duplicate notifications from going out during a cooldown window: ```python --8<-- "pages/core-concepts/cache/snippets/cache_rate_limit.py" ``` -For per-entity rate-limiting (e.g., one cooldown per sensor rather than a single global cooldown), include the entity ID in the cache key: `f"last_notification:{event.data.entity_id}"`. +`self.cache.get(cache_key)` returns `None` on the first call, so the notification goes out immediately. The timestamp is written after sending. On subsequent triggers, the handler compares the stored timestamp against the cooldown threshold. If insufficient time has elapsed, the notification is skipped. For per-entity rate limiting, the entity ID belongs in the key: `f"last_notification:{event.data.entity_id}"`. -## Pattern: Persistent Counters +## Persistent Counters -Track events across restarts by loading the counter from the cache at initialization and writing it back on every increment: +A counter stored only in an instance variable resets to zero whenever Hassette restarts. Loading from the cache at initialization and writing back on every increment makes the counter survive restarts: ```python --8<-- "pages/core-concepts/cache/snippets/cache_counter.py" ``` -The counter is restored from disk the next time the app starts, so `motion_count` accumulates across Hassette restarts. +`self.cache.get("motion_count", 0)` returns the stored value or `0` when no entry exists. Each call to `on_motion` increments the in-memory counter and immediately writes the new value to disk. -## Pattern: Storing Complex Data +## API Response Caching -The cache stores any picklable Python object — including dataclasses with typed fields: +External APIs impose rate limits. Storing the response alongside a timestamp lets the app return a cached copy while the data is still fresh: ```python ---8<-- "pages/core-concepts/cache/snippets/cache_complex_data.py" +--8<-- "pages/core-concepts/cache/snippets/cache_api_response.py" ``` -!!! note "Create new objects instead of mutating" - Use `dataclasses.replace()` to produce a new object rather than modifying the existing one. This keeps your app logic predictable and avoids partially-written state if an error occurs before the cache write. +`get_weather` checks the cache first. The entry holds a tuple of `(timestamp, data)`. When the stored timestamp falls within the 30-minute window, the cached value is returned without a network call. A stale or absent entry triggers a fresh fetch and overwrites the cache entry. + +## Expiring Entries -## Pattern: Expiring Cache Entries +Two approaches exist for expiring cache entries, depending on whether access to the timestamp is needed. -For simple expiration, use `self.cache.set(key, value, expire=seconds)` — diskcache removes the entry automatically once the timeout elapses: +For automatic expiry, `self.cache.set()` accepts an `expire` parameter in seconds. diskcache removes the entry silently once the timeout elapses: ```python --8<-- "pages/core-concepts/cache/snippets/cache_expire.py:expire" ``` -When you need access to the timestamp itself — for example, to display "last fetched" information or to implement custom staleness logic — store a timestamp alongside the value instead: +When the timestamp is needed for display or custom staleness logic, storing it explicitly alongside the value works better: ```python --8<-- "pages/core-concepts/cache/snippets/cache_expiring.py" ``` -## Pattern: Load Once, Write on Shutdown +`get_cached_data` compares the stored timestamp against the configured TTL and returns `None` when the entry is stale. The caller decides whether to re-fetch. -For data that is read frequently during a run but only needs to be persisted at shutdown, load from the cache at initialization into an instance variable and write back at shutdown: +## Storing Complex Data + +The cache stores any picklable Python object. Dataclasses with typed fields work well for structured app state: ```python ---8<-- "pages/core-concepts/cache/snippets/cache_performance.py" +--8<-- "pages/core-concepts/cache/snippets/cache_complex_data.py" ``` -This avoids disk I/O on every access while still persisting the data across restarts. - -## Best Practices - -### What to Cache +`dataclasses.replace()` produces a new `EnergyStats` object rather than modifying the existing one. The cache write only happens after the new object is fully constructed. A runtime error before the write leaves the previous value intact. -**Good uses:** +## Load Once, Write on Shutdown -- Notification timestamps for rate-limiting -- External API responses that have a meaningful TTL -- Computed values that are expensive to recalculate -- Rolling counters and statistics -- User preferences or app settings +Cache access involves disk I/O. For values read many times per second, loading into an instance variable at initialization avoids repeated disk reads: -**Avoid caching:** - -- Real-time Home Assistant entity state — use [`self.states`](../states/index.md) instead -- Large binary files — consider external storage -- Session-only temporary flags — use instance variables - -### Cache vs. StateManager - -| Use Case | Tool | Reason | -|----------|------|--------| -| Current sensor values | [`self.states`](../states/index.md) | Real-time HA state | -| Historical data | `self.cache` | Persists across restarts | -| Computed aggregates | `self.cache` | Not part of HA state | -| External API responses | `self.cache` | Reduce external calls | -| Temporary flags (this run only) | Instance variables | No persistence needed | - -### Performance +```python +--8<-- "pages/core-concepts/cache/snippets/cache_performance.py" +``` -Cache access involves disk I/O and is not instantaneous. For data that is read many times per second within a single run, load into an instance variable at initialization (see the [Load Once, Write on Shutdown](#pattern-load-once-write-on-shutdown) pattern above). The cache is thread-safe and can be accessed from multiple async tasks concurrently. +`on_initialize` reads from disk once. All access during the run uses the in-memory copy. `on_shutdown` writes the final state back to disk. The cache is thread-safe, so concurrent async tasks can write to it directly when the load-once pattern is not needed. ## Troubleshooting ### Cache Not Persisting -If data is not surviving restarts: - -- Confirm you are writing to `self.cache`, not a local variable named `cache` -- Confirm the app completes initialization without raising an exception (a startup error can prevent the shutdown flush) -- Confirm the cache directory has write permissions -- Confirm the value is picklable — unpicklable objects raise a `PicklingError` at write time +If values do not survive a restart, check four common causes. The write may target a local variable instead of `self.cache`. The app may raise an exception during initialization before the write executes. The cache directory may lack write permissions. The stored value may not be picklable. Unpicklable objects raise `PicklingError` at write time. ### Cache Size Exceeded -When the cache reaches `default_cache_size`, the least recently used items are evicted automatically. If you are losing important data: +When the cache reaches `default_cache_size`, diskcache silently evicts the least recently used entries. A larger `default_cache_size` in [Global Settings](../configuration/global.md) raises the ceiling. TTL expiry removes stale entries proactively, and storing large objects externally while caching only their identifiers reduces pressure. -- Increase `default_cache_size` in [Global Settings](../configuration/global.md) -- Implement expiration logic to remove stale entries (see [Expiring Cache Entries](#pattern-expiring-cache-entries)) -- Consider storing large objects externally and caching only references or identifiers - -### Debugging Cache Operations - -Enable debug logging to see cache operations in the logs: - -```toml -[hassette] -log_level = "DEBUG" -``` - -Verify the cache directory exists and contains data: - -```bash -ls -lah ~/.local/share/hassette/v0/MyApp/cache/ -``` +`log_level = "DEBUG"` in `hassette.toml` enables cache operation logging. The cache directory at `~/.local/share/hassette/v0/MyApp/cache/` should contain data after the first successful write. ## See Also -- [App Cache Overview](index.md) — how it works, configuration, lifecycle -- [Global Settings](../configuration/global.md) — `data_dir` and `default_cache_size` -- [Apps Overview](../apps/index.md) — app lifecycle -- [diskcache documentation](https://grantjenks.com/docs/diskcache/) — full cache library reference +- [App Cache Overview](index.md). How it works, configuration, lifecycle. +- [Global Settings](../configuration/global.md). `data_dir` and `default_cache_size`. +- [diskcache documentation](https://grantjenks.com/docs/diskcache/). Full cache library reference. diff --git a/docs/pages/core-concepts/configuration/applications.md b/docs/pages/core-concepts/configuration/applications.md index bb0a60b49..669c0b6e6 100644 --- a/docs/pages/core-concepts/configuration/applications.md +++ b/docs/pages/core-concepts/configuration/applications.md @@ -1,68 +1,64 @@ # Application Configuration -This page covers the **TOML side** of app configuration: registering apps in `hassette.toml`, supplying config values, and running multiple instances. The [App Configuration](../apps/configuration.md) page covers defining typed `AppConfig` models in Python. +Apps are registered in `hassette.toml` under `[hassette.apps.]`. Each block tells Hassette which Python file and class to load, and passes configuration values to the app. -Apps are registered and configured in the `hassette.toml` file under `[hassette.apps.]`. +This page covers the TOML side of app configuration. [App Configuration](../apps/configuration.md) covers defining typed `AppConfig` models in Python. -## App Registration +## Registering an App -Each app block requires: +An app block requires two fields: `filename` and `class_name`. `filename` is the path to the Python file, relative to `apps.directory`. `class_name` is the name of the `App` subclass to load. -- **`filename`** (or `file_name`): Path to the python file relative to `apps.directory`. - - Should include the extension (e.g., `.py`), though Hassette will attempt to guess if missing. - - Supports subdirectories (e.g., `subdir/my_app.py`). +```toml +--8<-- "pages/core-concepts/configuration/snippets/single_instance.toml" +``` -- **`class_name`** (or `class`, `module`, `module_name`): Name of the `App` subclass to load. - - If multiple classes exist in the file, this field disambiguates which one to load. +`enabled` disables the app without removing the config block when set to `false`. `display_name` sets a friendly label for logs; it defaults to the class name. -!!! tip - Prefer `class_name` and `filename` in docs and new configs; the alternative keys exist for compatibility. +!!! note "Alternative field names" + `filename` also accepts `file_name`. `class_name` also accepts `class`, `module`, and `module_name`. `filename` and `class_name` are the recommended names; the alternatives exist for compatibility. -**Optional fields:** +## Passing Configuration -- **`enabled`**: Set to `false` to disable the app without removing the config block. -- **`display_name`**: Friendly name for logs; defaults to the class name. +The `config` field supplies values to the app's `AppConfig` model. Two TOML forms are equivalent for single-instance apps. -### Single Instance +Inline form: ```toml ---8<-- "pages/core-concepts/configuration/snippets/single_instance.toml" +[hassette.apps.presence] +filename = "presence.py" +class_name = "PresenceApp" +config = { motion_sensor = "binary_sensor.hall", lights = ["light.entry"] } ``` -## App Configuration Parameters - -You can pass configuration parameters to your apps using the `config` field. - -- **Single instance**: `config = { key = "value" }` or `[hassette.apps.name.config]` -- **Multiple instances**: `[[hassette.apps.name.config]]` (recommended) +Table form: -!!! note "Paths" - `apps.directory` is resolved to an absolute path at startup. Relative paths are resolved relative to the current working directory. - -!!! note "Filename extension" - If `filename` has no extension, Hassette assumes `.py`. +```toml +[hassette.apps.presence] +filename = "presence.py" +class_name = "PresenceApp" -**Environment Variable Overrides:** +[hassette.apps.presence.config] +motion_sensor = "binary_sensor.hall" +lights = ["light.entry"] +``` -You can override nested config values using environment variables. This merges with any TOML configuration (env vars take precedence). +!!! note "Two TOML paths, two purposes" + `AppManifest` fields (`filename`, `class_name`, `enabled`, `display_name`) live at `[hassette.apps.]`. App configuration fields live at `[hassette.apps..config]`. Placing app config values directly under `[hassette.apps.]` without the `config` sub-key generates a deprecation warning. -- Pattern: `HASSETTE__APPS____CONFIG__` -- Example: `HASSETTE__APPS__MY_APP__CONFIG__SOME_OPTION=true` overrides `some_option` for `my_app`. +Environment variables override individual `config` values at startup. The pattern is `HASSETTE__APPS____CONFIG__`. For example, `HASSETTE__APPS__PRESENCE__CONFIG__MOTION_SENSOR=binary_sensor.hall_v2` overrides `motion_sensor` for the `presence` app. Environment variable values take precedence over TOML. -### Multiple Instances +## Multiple Instances -To run the same app multiple times with different configurations, use `[[hassette.apps..config]]` blocks. +The same app class runs as separate instances by replacing `config = ...` with `[[hassette.apps..config]]` blocks (TOML's array-of-tables syntax). Each block produces one independent app instance with its own state, handlers, and scheduler. ```toml --8<-- "pages/core-concepts/configuration/snippets/multiple_instances.toml" ``` -## Typed Configuration +The `name` field distinguishes instances in logs and the web UI. Without it, Hassette generates a name from the class name and index (e.g., `PresenceApp.0`). -The `config` values supplied in TOML are validated against an `AppConfig` subclass that you define in Python. Hassette raises a configuration error at startup if any required field is missing or has the wrong type. For how to define that model, see [App Configuration](../apps/configuration.md). +Single-instance apps are the default. Most apps never need `[[...]]` blocks. Multiple instances let the same logic run across different rooms, devices, or entity sets without duplicating app code. -## See Also +## Typed Configuration -- [App Configuration](../apps/configuration.md) - Defining typed `AppConfig` models in Python -- [Global Settings](global.md) - Runtime and connection settings -- [Authentication](auth.md) - Tokens and secrets +The values supplied under `config` are validated at startup against an [`AppConfig`][hassette.app.app_config.AppConfig] subclass defined in Python. A missing required field or a type mismatch raises a configuration error before any app starts. [App Configuration](../apps/configuration.md) covers defining the model. diff --git a/docs/pages/core-concepts/configuration/index.md b/docs/pages/core-concepts/configuration/index.md index 3fba28f1c..7cba39962 100644 --- a/docs/pages/core-concepts/configuration/index.md +++ b/docs/pages/core-concepts/configuration/index.md @@ -1,46 +1,99 @@ -# Configuration Overview +# Configuration -Hassette configuration controls how the framework connects to Home Assistant, discovers your app files, manages the web UI, and stores persistent data. All settings live in a single `hassette.toml` file (or can be overridden via environment variables or CLI flags). +All Hassette settings live in `hassette.toml`. Environment variables and CLI flags override TOML values. The configuration controls connection, app discovery, the web UI, storage, and runtime behavior. ## Configuration Sources -Hassette loads configuration from multiple sources, applied in this precedence order (highest to lowest): +Hassette loads settings from four sources, applied in this precedence order (highest wins): -1. **CLI flags** — arguments passed to `hassette` at startup (e.g., `--base-url`, `--token`) -2. **Environment variables** — variables like `HASSETTE__TOKEN` or `HASSETTE__BASE_URL` -3. **`.env` files** — loaded from `.env` files; same key names as environment variables -4. **`hassette.toml`** — the primary configuration file +1. **CLI flags**, arguments passed to `hassette` at startup +2. **Environment variables**, prefixed with `HASSETTE__`, using `__` as the nested delimiter +3. **`.env` files**, same key names as environment variables +4. **`hassette.toml`**, the primary configuration file -When the same setting appears in multiple sources, the higher-precedence source wins. For example, setting `HASSETTE__TOKEN` in the environment overrides `token` in `hassette.toml`. +When the same setting appears in multiple sources, the higher-precedence source wins. ## File Locations --8<-- "pages/core-concepts/configuration/snippets/file_discovery.md" !!! tip "Docker" - In Docker, mount your configuration volume to `/config`. Hassette checks `/config/hassette.toml` first. + In Docker, the configuration volume mounts to `/config`. Hassette checks `/config/hassette.toml` first. + +## Authentication + +The `token` field accepts four aliases: `token`, `hassette__token`, `ha_token`, and `home_assistant_token`. This means the same token can be supplied under any of those names in any source. + +The recommended approach is an environment variable or `.env` file so the token stays out of version control: + +``` +HASSETTE__TOKEN=your_long_lived_access_token +``` + +`verify_ssl` controls certificate validation. Setting it to `false` allows connections to Home Assistant instances with self-signed certificates. [Create a Long-Lived Access Token](../../getting-started/ha-token.md) covers step-by-step token generation. ## Configuration Sections -| Section | Purpose | Reference | -|---------|---------|-----------| -| `[hassette]` | Connection, runtime, storage, web UI, and all global settings | [Global Settings](global.md) | -| `[apps.]` | Register and configure individual apps | [Applications](applications.md) | -| `[[apps..config]]` | Multiple instances of the same app class | [Applications](applications.md) | +`HassetteConfig` organizes settings into named subsections. Each maps to a TOML table: -## Credentials +| TOML section | Controls | +|---|---| +| `[hassette]` | Connection (`base_url`, `verify_ssl`, `token`), runtime flags, data directory | +| `[hassette.apps]` | App discovery, auto-detection, individual app definitions | +| `[hassette.web_api]` | Web UI and API server host, port, and feature flags | +| `[hassette.database]` | Storage path, retention, and write-queue settings | +| `[hassette.websocket]` | Connection, retry, and recovery timing | +| `[hassette.logging]` | Log level, format, queue, and per-service overrides | +| `[hassette.lifecycle]` | Startup, shutdown, and per-operation timeouts | +| `[hassette.file_watcher]` | Debounce, step timing, and enable/disable | +| `[hassette.scheduler]` | Job delay thresholds and execution timeouts | -Your Home Assistant long-lived access token should never be committed to version control. Store it as an environment variable or in a `.env` file: +App definitions live inside `[hassette.apps]` as named subsections: +```toml +--8<-- "pages/core-concepts/configuration/snippets/basic_config.toml" ``` -HASSETTE__TOKEN=your_token_here + +[Applications](applications.md) covers app registration details and multi-instance configuration. + +## Design Notes + +The [auto-generated `HassetteConfig` reference](../../reference/config/index.md) covers every field, its type, and its default. The notes below explain the "why" for fields where the field name alone does not make the intent obvious. + +### Data Directory and Upgrades + +`data_dir` sets the root for all persistent data Hassette writes, including the telemetry database and caches. The default is platform-specific. Changing `data_dir` between major versions requires migrating the existing data manually. No automatic migration runs. `database.path` defaults to a file inside `data_dir` but can be overridden to an independent location. + +### App Discovery + +`apps.directory` is the root from which Hassette loads app modules. Auto-detection (`apps.autodetect`, default `true`) scans that directory recursively for Python files that define an `App` subclass. + +`extend_exclude_dirs` adds directories to the built-in exclusion list. `exclude_dirs` replaces it entirely. Setting `exclude_dirs` directly removes the framework defaults and can cause Hassette to scan directories it would normally skip. + +`run_app_precheck` controls whether Hassette imports and validates all app modules before starting. When a module fails precheck, Hassette refuses to start. `allow_startup_if_app_precheck_fails` overrides that refusal. Development environments may enable it; production environments should not. + +### Event Filtering + +`bus_excluded_domains` and `bus_excluded_entities` drop events before any handler sees them. Both accept glob patterns. + +```toml +--8<-- "pages/core-concepts/configuration/snippets/bus_filter_example.toml" ``` -See [Authentication](auth.md) for all credential options. +Filtering at this level removes the events from every app simultaneously. Per-handler filtering using predicates is more selective. The [Bus](../bus/index.md) page covers handler-level options. + +### Development and Debugging + +`dev_mode` enables additional logging and development features. Hassette sets it automatically when `debugpy` is loaded, when `sys.gettrace()` is non-`None`, or when the interpreter runs with `python -X dev`. Setting it explicitly in `hassette.toml` or via `HASSETTE__DEV_MODE=true` overrides auto-detection. + +`asyncio_debug_mode` enables the asyncio event loop's own debug mode, which logs slow callbacks and unawaited coroutines. It runs independently of `dev_mode`. + +`web_api.ui_hot_reload` pushes live reloads to the browser when web UI static files change. It serves framework contributors working on the UI itself, not app authors. + +### State Proxy Polling + +The StateManager keeps a local cache of entity states. `state_proxy_poll_interval_seconds` controls how often that cache refreshes via a full API pull, supplementing the WebSocket event stream. `disable_state_proxy_polling` turns off the periodic poll entirely, leaving the cache reliant on the event stream alone. -## See Also +## Full Reference -- [**Authentication**](auth.md) — setting up tokens and secrets -- [**Global Settings**](global.md) — connecting to Home Assistant and all runtime options -- [**Applications**](applications.md) — registering and configuring your apps -- [**Getting Started**](../../getting-started/index.md) — a guided first run +The [HassetteConfig API reference](../../reference/config/index.md) lists every field with its type, default, and description. diff --git a/docs/pages/core-concepts/configuration/snippets/basic_config.toml b/docs/pages/core-concepts/configuration/snippets/basic_config.toml index 77c61d420..dcd7a0cea 100644 --- a/docs/pages/core-concepts/configuration/snippets/basic_config.toml +++ b/docs/pages/core-concepts/configuration/snippets/basic_config.toml @@ -2,12 +2,9 @@ base_url = "http://localhost:8123" # Home Assistant URL [hassette.apps] -directory = "src/apps" # path containing your app modules +directory = "src/apps" # path containing app modules [hassette.apps.my_app] filename = "my_app.py" class_name = "MyApp" enabled = true - -[[hassette.apps.my_app.config]] -instance_name = "instance_1" diff --git a/docs/pages/core-concepts/configuration/snippets/file_discovery.md b/docs/pages/core-concepts/configuration/snippets/file_discovery.md index 6248108db..3fe976cc5 100644 --- a/docs/pages/core-concepts/configuration/snippets/file_discovery.md +++ b/docs/pages/core-concepts/configuration/snippets/file_discovery.md @@ -10,4 +10,4 @@ Hassette searches for `hassette.toml` in: 2. `./.env` (current working directory) 3. `./config/.env` -Override either with `--config-file / -c` or `--env-file / -e`. +`--config-file / -c` and `--env-file / -e` override either path. diff --git a/docs/pages/core-concepts/configuration/snippets/multiple_instances.toml b/docs/pages/core-concepts/configuration/snippets/multiple_instances.toml index ad2a17f27..1c8272689 100644 --- a/docs/pages/core-concepts/configuration/snippets/multiple_instances.toml +++ b/docs/pages/core-concepts/configuration/snippets/multiple_instances.toml @@ -1,13 +1,13 @@ -[apps.presence] +[hassette.apps.presence] filename = "presence.py" class_name = "PresenceApp" -[[apps.presence.config]] +[[hassette.apps.presence.config]] name = "upstairs" motion_sensor = "binary_sensor.upstairs_motion" lights = ["light.bedroom", "light.hallway"] -[[apps.presence.config]] +[[hassette.apps.presence.config]] name = "downstairs" motion_sensor = "binary_sensor.downstairs_motion" lights = ["light.living_room", "light.kitchen"] diff --git a/docs/pages/core-concepts/configuration/snippets/single_instance.toml b/docs/pages/core-concepts/configuration/snippets/single_instance.toml index 2da8a0cb3..cf4ff0a6b 100644 --- a/docs/pages/core-concepts/configuration/snippets/single_instance.toml +++ b/docs/pages/core-concepts/configuration/snippets/single_instance.toml @@ -1,4 +1,4 @@ -[apps.presence] +[hassette.apps.presence] filename = "presence.py" class_name = "PresenceApp" config = { motion_sensor = "binary_sensor.hall", lights = ["light.entry"] } From 65d78bd8547f5bf02dd2fa33feb28ed664e51e81 Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Tue, 2 Jun 2026 19:27:23 -0500 Subject: [PATCH 049/160] docs: rewrite Web UI section (5 task-oriented pages) --- design/specs/070-doc-overhaul/followups.md | 38 ++++++ docs/pages/web-ui/debug-handler.md | 65 ++++++++++- docs/pages/web-ui/index.md | 45 +++++--- docs/pages/web-ui/inspect-config-code.md | 36 +++++- docs/pages/web-ui/logs.md | 128 +++++++-------------- docs/pages/web-ui/manage-apps.md | 61 +++++++++- 6 files changed, 267 insertions(+), 106 deletions(-) create mode 100644 design/specs/070-doc-overhaul/followups.md diff --git a/design/specs/070-doc-overhaul/followups.md b/design/specs/070-doc-overhaul/followups.md new file mode 100644 index 000000000..f87771aff --- /dev/null +++ b/design/specs/070-doc-overhaul/followups.md @@ -0,0 +1,38 @@ +# Doc Overhaul Follow-ups + +Issues, follow-ups, and items to discuss before T13 (final sweep). + +## Screenshots Needed + +Web UI pages reference screenshots that may not exist or may be outdated: +- `web_ui_apps.png` — referenced by overview and manage-apps +- `web_ui_logs.png` — referenced by logs page +- Handlers page screenshots — referenced by debug-handler +- Other `_static/web_ui_*` images — need inventory and freshness check + +## Orphaned Pages to Delete + +Old tab-mirroring pages no longer in mkdocs nav (still on disk): +- `docs/pages/web-ui/apps.md` (118 lines) +- `docs/pages/web-ui/handlers.md` (65 lines) +- `docs/pages/web-ui/config.md` (45 lines) +- `docs/pages/web-ui/layout.md` (99 lines) +- `docs/pages/web-ui/app-detail/` (entire directory — 6 files, 583 lines) +- `docs/pages/web-ui/app-detail/snippets/handler_registration.py` + +## Broken Cross-Links (Old Pages Removed from Nav) + +The new task-oriented pages reference UI elements (Handlers page, App Detail) that had their own doc pages in the old structure. Those old pages are no longer in mkdocs nav. Fixed the most obvious link issues: +- `debug-handler.md`: removed link to `handlers.md`, kept text description +- `manage-apps.md`: removed 3 links to `app-detail/index.md`, kept text +- `inspect-config-code.md`: fixed `--` to `:` for consistency + +Decision needed for T13: should any old pages be kept as redirects, or are the inline descriptions sufficient? + +## CLI Start/Stop/Reload + +Reviewer caught that `hassette app start/stop/reload ` CLI commands don't exist. Only REST API endpoints. The manage-apps page now correctly documents this. If CLI commands are added later, the manage-apps page needs updating. + +## Items to Discuss + +(populated as work progresses) diff --git a/docs/pages/web-ui/debug-handler.md b/docs/pages/web-ui/debug-handler.md index 12162be28..7f86f656e 100644 --- a/docs/pages/web-ui/debug-handler.md +++ b/docs/pages/web-ui/debug-handler.md @@ -1,3 +1,66 @@ # Debug a Failing Handler -*This page is being rewritten as part of the documentation overhaul.* +Three things go wrong with handlers: they don't fire, they fire but error, or they fire too often. The web UI shows which is happening and why. + +## Quick Diagnosis + +| Symptom | Check | What to look for | +|---|---|---| +| Handler never fires | Handlers page (sidebar > Handlers) | Missing from list, or zero invocations | +| Handler fires but errors | App detail > Handlers tab | Error count > 0, error details | +| Handler fires too often | App detail > Handlers tab | High invocation count, check predicate or debounce | + +## Common Causes + +**Missing `name=` on subscription.** Bus registration raises `ListenerNameRequiredError` at call time if `name=` is omitted. The handler never appears in the Handlers page. Add `name="descriptive_name"` to the `bus.on_state_change()` call. See [handler registration](../core-concepts/bus/handlers.md) for the full signature. + +**Wrong entity pattern.** The handler is registered for `"light.kitchen"` but the entity is `"light.kitchen_ceiling"`. The handler exists in the Handlers page but shows zero invocations. Check the listener's trigger column for the exact pattern that was registered, then correct it in the source. + +**`changed_to` type mismatch.** Home Assistant state values are strings. `changed_to=True` never matches because the value on the wire is `"on"`, not `True`. Use `changed_to="on"` and `changed_to="off"`. + +**DI annotation mismatch.** A handler annotated with the wrong state type raises `DependencyResolutionError` at invocation time. The error banner in the handler detail panel shows the exception class and message. Check the parameter annotation against the entity's domain and see [dependency injection](../core-concepts/bus/dependency-injection.md) for the available types. + +**Domain excluded.** If the entity's domain appears in `bus_excluded_domains`, Hassette drops all events for that domain before they reach any handler. The handler exists and registers cleanly, but invocations never arrive. Check `bus_excluded_domains` in your `hassette.toml` and remove the domain if the exclusion is unintentional. + +## Using the Handlers Page + +Open **Handlers** in the sidebar. The page shows every registered event handler and scheduled job across all your apps in one table. + +Find your handler by searching the name box or filtering by app with the **App** column dropdown. The table shows: + +- **Runs** — total invocations in the current time window. Zero means the handler has never fired, or the time window is too narrow. +- **Failed** — count of invocations that raised an unhandled exception. Shown in red when non-zero. +- **Error rate** — failed divided by runs, as a percentage. + +Rows with any failure are highlighted in red. Click the handler name to go straight to the handler detail in the app's Handlers tab. + +The time window for all counts comes from the preset selector in the status bar. If you see zero runs for a handler you expect to have fired, widen the window to **Since restart**. A narrow window hides older invocations. + +## Drilling into Handler History + +Open the app from the sidebar, then select the **Handlers** tab. The left panel lists every handler and job for this app. The right panel shows detail for the selected item. + +Select your handler. The detail panel shows: + +- **Registration source** — the exact `bus.on_state_change()` call Hassette recorded at startup, including the entity pattern and any options. +- **Modifier chips** — any behavioral options in effect: `debounce`, `throttle`, `once`, `priority`, `immediate`, or `duration`. A handler with no modifiers shows no chip row. +- **Source location** — the file path and line number where the handler is defined. Click **view in code →** to open the Code tab at that line. +- **Error banner** — appears when the handler has at least one failure. Shows the exception class, the full message, and a **show traceback** toggle that expands the Python traceback inline. +- **Stats grid** — calls, successful, failed, timed out, and min/avg/max duration for the current time window. +- **Invocations table** — the 50 most recent invocations, each with a status indicator, timestamp, duration, and execution ID. The table updates in real time. + +A gray status dot on a handler in the left panel means it has never been invoked. A red dot means at least one invocation has failed. + +## Tracing a Single Execution + +Click an execution ID in the invocations table. Hassette opens the [Logs page](logs.md) filtered to that execution with `?execution_id=` in the URL. Every log line the handler emitted during that run appears together, in order. + +You can also construct the URL manually. Grab the execution ID from a CLI command or another log entry. + +## Related Pages + +- [Web UI overview](index.md) — navigation, layout, and status bar controls +- [Manage Apps](manage-apps.md) — app health, start/stop/reload, and status badges +- [Logs](logs.md) — full log view with execution ID filtering +- [Bus handlers](../core-concepts/bus/handlers.md) — handler registration, `name=` requirement, and options +- [Dependency injection](../core-concepts/bus/dependency-injection.md) — DI annotation reference diff --git a/docs/pages/web-ui/index.md b/docs/pages/web-ui/index.md index 07beeea1e..9c4180ce6 100644 --- a/docs/pages/web-ui/index.md +++ b/docs/pages/web-ui/index.md @@ -1,38 +1,37 @@ # Web UI -The hassette web UI gives you a real-time window into your running automations — app health at a glance, per-handler invocation history, structured logs, and full system configuration. It runs as part of the same process as the REST API, so there's nothing extra to start. +The web UI shows app health, handler invocation history, structured logs, and system configuration. It runs in the same process as the REST API. Nothing extra starts when the UI is enabled. ![Apps page](../../_static/web_ui_apps.png) -## Enabling and accessing +## Enabling and Accessing -The web UI is **enabled by default**. Once Hassette starts, open your browser to: +The web UI is enabled by default. Open your browser to: ``` http://:8126/ui/ ``` -The default bind address is `0.0.0.0:8126`. To change the host or port, set `host` and `port` under `[hassette.web_api]` in your `hassette.toml`. +The default bind address is `0.0.0.0:8126`. The `host` and `port` fields under `[hassette.web_api]` in `hassette.toml` control the bind address. !!! warning "No authentication" - The web UI has no built-in authentication. By default, the server binds to - `0.0.0.0`, making it accessible to anyone on your network — including - endpoints that can start, stop, and reload your automations. + The web UI has no built-in authentication. The default bind address `0.0.0.0` + makes it reachable by anyone on the local network, including endpoints that + start, stop, and reload automations. For local-only access, set `host = "127.0.0.1"` under `[hassette.web_api]`. For remote access, place Hassette behind a reverse proxy with authentication - (e.g., Caddy, nginx, or Traefik with basic auth or SSO). + (Caddy, nginx, and Traefik all work). -To disable the UI while keeping the REST API: +The UI can be disabled independently while the REST API stays active: ```toml title="hassette.toml" --8<-- "pages/web-ui/snippets/disable-ui.toml" ``` !!! note "First run" - A fresh installation shows empty tables and zero counts until automations - run and handlers fire. This is expected — telemetry accumulates as your - apps process events. + A fresh Hassette install shows empty tables and zero counts until automations + run and handlers fire. Telemetry accumulates as apps process events. ??? "Configuration quick reference" @@ -48,9 +47,23 @@ To disable the UI while keeping the REST API: | `[hassette.web_api] job_history_size` | int | `1000` | Job execution records to keep | | `[hassette.web_api] ui_hot_reload` | bool | `false` | Live-reload on static file changes | - See [Global Settings](../core-concepts/configuration/global.md#web-ui-settings) for the full configuration reference. + See [Global Settings](../core-concepts/configuration/global.md#web-ui-settings) for the full reference. -## Related pages +## Layout -- [Layout & Navigation](layout.md) — sidebar, status bar, command palette, and cross-cutting chrome -- [Apps](apps.md) — monitor and manage your automations +The UI has three persistent navigation elements. + +The **sidebar** lists every app grouped by lifecycle status: `FAILING`, `BLOCKED`, `SLOW`, `RUNNING`, `STOPPED`, and `DISABLED`. A search field filters the list by app name. The command palette opens from the search area or with Ctrl+K (Cmd+K on macOS). + +The **status bar** runs across the top. It holds a time-preset selector (Since restart, 1h, 24h, 7d) that scopes all history views. A connection indicator, uptime counter, and theme toggle sit alongside it. + +The **command palette** opens with Ctrl+K or Cmd+K. It jumps to pages, apps, handlers, and actions without navigating through the sidebar. + +**Alert banners** appear below the status bar when something needs attention. Red banners indicate failed apps. Amber banners indicate telemetry backpressure, meaning events or log entries are being dropped to stay within configured buffer limits. + +## Pages + +- **[Manage Apps](manage-apps.md)**: start, stop, and reload apps; check health and status +- **[Debug a Failing Handler](debug-handler.md)**: find why a handler is not firing or is throwing errors +- **[Read and Filter Logs](logs.md)**: search, filter, and stream logs in real time +- **[Inspect Configuration and Code](inspect-config-code.md)**: view global and per-app config, read app source diff --git a/docs/pages/web-ui/inspect-config-code.md b/docs/pages/web-ui/inspect-config-code.md index cadef4acb..a2b68b8ea 100644 --- a/docs/pages/web-ui/inspect-config-code.md +++ b/docs/pages/web-ui/inspect-config-code.md @@ -1,3 +1,37 @@ # Inspect Configuration and Code -*This page is being rewritten as part of the documentation overhaul.* +The web UI shows what configuration Hassette is running with and what the app code looks like. No SSH access needed. + +## Check Running Configuration + +### Global Configuration + +The Configuration page displays all `hassette.toml` values grouped by section. Groups include `web_api`, `logging`, `lifecycle`, `apps`, `scheduler`, and `file_watcher`. Top-level fields like `dev_mode`, `base_url`, and `config_dir` appear outside any group. Each section renders as a key-value card. Booleans appear as `true`/`false`, paths as strings, arrays as comma-separated lists. + +Open it from the sidebar, then click **Config**. + +Values reflect the configuration loaded at the most recent startup or reload. Refresh the browser after a Hassette reload to see updated values. + +See [Configuration](../core-concepts/configuration/index.md) for the full settings reference. + +### Per-App Configuration + +The **Config** tab on an app detail page shows the resolved configuration for that app instance. It merges three sources: `hassette.toml` values, environment variable overrides, and field defaults from the [`AppConfig`](../core-concepts/apps/index.md) class. Pydantic validates the merged result. + +Navigate there from the sidebar, click an app, then open the **Config** tab. + +If a value you set via environment variable is not reflected, the env var name does not match the config class field name. The tab shows exactly what the app received, so it is the fastest way to confirm an override took effect. + +## Read App Source Code + +The **Code** tab on an app detail page displays the Python source of the app as deployed. The view is read-only and syntax-highlighted. + +In Docker deployments, the container's app directory may differ from your local copy. The Code tab shows what is on disk inside the running container, without needing a shell. + +Navigate there from the sidebar, click an app, then open the **Code** tab. + +## See Also + +- [Web UI Overview](index.md): enabling, accessing, and layout +- [Configuration](../core-concepts/configuration/index.md): all available settings and how to change them +- [Apps](../core-concepts/apps/index.md): `AppConfig` fields and environment variable conventions diff --git a/docs/pages/web-ui/logs.md b/docs/pages/web-ui/logs.md index a2f93d40e..0aed3358e 100644 --- a/docs/pages/web-ui/logs.md +++ b/docs/pages/web-ui/logs.md @@ -1,119 +1,73 @@ -# Logs +# Read and Filter Logs -The Logs page provides a global, filterable, searchable view of all log entries -across your Hassette apps and framework internals, with real-time streaming via -WebSocket. +The Logs page streams log entries from every app and framework component in real time. Filter and search controls narrow the view to specific entries. ![Logs page](../../_static/web_ui_logs.png) -## Log table +## Filtering and Search -The log table displays entries from all apps, sorted by timestamp descending by -default. Each row represents a single log entry. +The filter controls narrow the table to the entries you care about. -| Column | Sortable | Filterable | Description | -|--------|----------|------------|-------------| -| **Level** | Yes | Yes (dropdown) | Severity badge: DEBUG, INFO, WARNING, ERROR, or CRITICAL | -| **Timestamp** | Yes (default, descending) | No | Time the entry was recorded | -| **App** | No | Yes (dropdown) | App key for app-generated entries, or `—` for framework logs | -| **Instance** | No | No | Instance name for multi-instance apps. To view logs from a specific instance, use the [App Detail Logs tab](app-detail/logs.md) for that instance. | -| **Execution** | No | No | Execution ID linking the entry to a specific handler invocation | -| **Function** | Yes | Yes (text input) | Name of the Python function that emitted the log entry | -| **Module** | No | No | Module and logger name | -| **Message** | Yes | No | Log message text | - -Click any row to open the [log detail drawer](#log-detail-drawer) with the -complete entry metadata. - -## Filtering and search +**Level** sets the minimum severity shown. It defaults to INFO. Options are: All levels, DEBUG+, INFO+, WARNING+, ERROR+, and CRITICAL only. -Use the column filter controls in the table header to narrow results: +**App** limits entries by source. Toggle between All, Apps, and Framework. The default is Apps. When All or Apps is selected, a dropdown narrows to a specific app key. -- **Level** — sets the minimum level shown. Defaults to INFO. Options: All - levels, DEBUG+, INFO+, WARNING+, ERROR+, CRITICAL only. -- **App** — filter by source. Toggle between All, Apps only, Framework only, - then optionally select a specific app key. -- **Function** — free-text filter on the function name column. +**Function** filters the function name column by substring. Type any part of a function name to match. -The **search box** above the table filters by message content and logger name. +**Search** matches against both message content and logger name. -The footer shows a count of matching entries (e.g. "42 entries"). If the -filtered result exceeds 500 entries, the footer shows "showing 500 of N" — narrow -your filters to see specific entries. +The footer shows how many entries match. When the result exceeds 500, the footer reads "showing 500 of N". Narrow the filters to see the specific entries you need. -## Column picker +## Trace a Single Execution -The column picker lets you control which columns are visible. +Append `?execution_id=` to the URL to filter the table to entries from one handler or job execution. The [Debug Handler](debug-handler.md) page links here automatically from execution history. You can also construct the URL manually if you have an execution ID from logs or the CLI. -![Column picker](../../_static/web_ui_detail_column_picker.png) +When an execution ID filter is active, the other filters use local state. They do not modify the URL, so the execution ID stays intact as you refine your view. -Click the grid icon button in the table footer to open the column visibility -popover. Check or uncheck columns to toggle their visibility. **Level** and -**Message** are required columns and cannot be hidden. +## Log Table Columns -Some columns are automatically hidden at narrow viewport widths. Columns hidden -by the viewport are shown as disabled in the popover with a "Hidden at this -screen size" tooltip — they cannot be toggled while the viewport is too narrow. +The table is sorted by timestamp descending by default. Sortable columns toggle between descending and ascending on click. -Click **Reset to defaults** to restore the default column set for the global -logs view (Level, Timestamp, App, Execution, Function, Module, Message). - -!!! note - The column picker is not shown on mobile viewports, where the table - automatically uses a compact layout. - -## Log detail drawer - -Click any log row to open the detail drawer — a side panel showing the complete -entry. +| Column | Sortable | Filterable | Description | +|---|---|---|---| +| Level | Yes | Yes (dropdown) | Severity badge: DEBUG, INFO, WARNING, ERROR, CRITICAL | +| Timestamp | Yes (default desc) | No | Time the entry was recorded | +| App | Yes | Yes (dropdown) | App key, or blank for framework logs | +| Instance | No | No | Instance name for multi-instance apps | +| Execution | No | No | Execution ID linking to a handler invocation | +| Function | Yes | Yes (text input) | Python function that emitted the log | +| Module | No | No | Python module name | +| Message | Yes | No | Log message text | -![Log detail drawer](../../_static/web_ui_detail_log_drawer.png) +## Column Picker -The drawer contains: +Click the grid icon in the table footer to choose which columns are visible. Check or uncheck any column to toggle it. Level and Message are required and cannot be hidden. -- **Severity and timestamp** — level badge with color coding, full timestamp -- **Metadata grid** — app (link to app detail), instance, execution ID (with - copy button), function name, module, line number, logger name -- **Message** — full message text with a copy button -- **Exception / traceback** — if the entry includes exception info, a scrollable - code block appears with its own copy button +Some columns auto-hide at narrow viewport widths. Those columns appear disabled in the popover with a "Hidden at this screen size" tooltip. They cannot be toggled until the viewport widens. Click **Reset to defaults** to restore the default column set. -Use the arrow buttons in the drawer header, or press the **arrow keys** on your -keyboard, to navigate to the previous or next log entry without closing the -drawer. Press **Escape** to close. +!!! note + The column picker does not appear on mobile viewports. The table uses a compact layout there instead. -On mobile and tablet, the drawer appears as a bottom sheet over the table. On -desktop, it opens as a side panel to the right of the table. +## Log Detail Drawer -## Live streaming +Click any row to open the detail drawer with the complete entry. -New log entries appear in real-time as your automations run. No manual refresh -is needed. +The drawer shows a severity badge, full timestamp, and a metadata grid. The grid includes app (linked to its detail page), instance, execution ID, function name, module, line number, and logger name. The execution ID has a copy button. -### Auto-pause on sort +Below the grid, the full message appears in a scrollable block with a copy button. Entries with exception info show a separate code block beneath the message. -Live streaming is active only when the table is sorted by **Timestamp** (the -default). When you sort by any other column, streaming pauses so the sort order -is not disrupted by incoming entries. +Press the arrow keys to move between entries without closing the drawer. Press Escape to close. -When streaming is paused, a **"paused — click to resume"** button appears in -the table footer. Click it to reset the sort back to timestamp-descending and -resume live updates. +On desktop the drawer opens as a side panel. On mobile and tablet it appears as a bottom sheet. -## Execution ID filtering +## Live Streaming -Append `?execution_id=` to the URL to filter the log table to entries from -a single handler execution. Hassette uses this URL parameter when you navigate -from the Handlers tab's execution history to the associated logs — you can also -construct the URL manually if you have an execution ID from elsewhere. +New entries appear as they arrive. No refresh needed. -When an execution ID filter is active, the log table uses local state instead -of URL query parameters for other filters, so you can refine the results without -clobbering the execution ID in the URL. +Streaming is active only when the table is sorted by timestamp, the default. Sorting by any other column pauses streaming so incoming entries do not disrupt the sort order. A "paused" button appears in the footer. Click it to reset the sort to timestamp-descending and resume live updates. ## Related pages -- [App Detail — Logs Tab](app-detail/logs.md) — the same log table filtered to a - single app; useful when you want to see all logs from one automation -- [App Detail — Handlers Tab](app-detail/handlers.md) — execution history for - individual handlers, with links to filtered logs for each execution +- [Web UI overview](index.md) — layout, navigation, and how to enable the UI +- [Debug a Failing Handler](debug-handler.md) — execution history with links to filtered logs for each run +- [Database and Telemetry](../core-concepts/database-telemetry.md) — how log entries are persisted and retained diff --git a/docs/pages/web-ui/manage-apps.md b/docs/pages/web-ui/manage-apps.md index d1b75468c..06717d3ae 100644 --- a/docs/pages/web-ui/manage-apps.md +++ b/docs/pages/web-ui/manage-apps.md @@ -1,3 +1,62 @@ # Manage Apps -*This page is being rewritten as part of the documentation overhaul.* +The apps dashboard shows every registered automation at a glance. Check health, find problems, and control apps without leaving the browser. + +The apps page is the landing page of the web UI. Navigating to `/` redirects to `/apps`. + +## Check App Health + +The stats strip at the top shows aggregate counts: **TOTAL**, **RUNNING**, **FAILED**, **STOPPED**, **DISABLED**, **HANDLERS**, and **RUNS/HR**. A non-zero **FAILED** count turns that cell red. Zero means all automations are alive. + +Below the strip, the app table shows one row per app with the following columns: + +| Column | What it shows | +|--------|--------------| +| **APP** | Status dot, app key, and class name. An **auto** chip appears for apps discovered by directory scan rather than an explicit `hassette.toml` entry. | +| **STATUS** | Lifecycle state badge: `RUNNING`, `STOPPED`, `FAILED`, `DISABLED`, or `BLOCKED`. | +| **LAST ERROR** | Most recent error message, truncated. Click to expand the full message. Shows `—` when the app is healthy. | +| **RUNS** | An activity sparkline showing invocation frequency over the selected time window, plus the total run count. | +| **LAST FIRED** | Relative timestamp of the most recent handler or job execution, for example "3 min ago". Shows `—` if the app has never fired. | +| **ACTIONS** | Context-sensitive buttons based on current status. See [Start, Stop, and Reload](#start-stop-and-reload) below. | + +### Find a specific app + +The search box above the table filters rows by app key and class name as you type. The status filter popover on the **STATUS** column header narrows the table to one lifecycle state. Per-status counts appear in the popover. Searching and status filtering work together. + +### Drill into an app + +Click any app row to open the App Detail view. The detail view shows health indicators, a handler list, recent activity, and error details across five tabs. + +### Multi-instance apps + +Apps with multiple instances show a parent row with a chevron and an instance count badge (e.g., "2 instances"). Click the chevron to expand into individual instance rows. Each instance row shows its own status dot, badge, last error, and action buttons. Click an instance name to open that instance's detail view. + +## Start, Stop, and Reload + +Action buttons appear in the **ACTIONS** column and in the App Detail header. Which buttons appear depends on the app's current status: + +| Button | Available when | What it does | +|--------|---------------|-------------| +| **Start** | `STOPPED`, `FAILED`, or `DISABLED` | Initializes the app and begins processing events. | +| **Stop** | `RUNNING` | Shuts the app down gracefully and cancels its scheduled jobs. The app stops receiving events until you start it again. | +| **Reload** | `RUNNING` | Stops then starts the app, picking up code and config changes without restarting the Hassette process. | + +Use **Reload** after changing an app's Python file or its config in `hassette.toml`. Reloading one app does not affect other running apps. Restart the Hassette process only for global settings, new integrations, or Hassette updates. + +These actions call the REST API (`POST /apps/{key}/start`, `/stop`, `/reload`). The CLI does not expose start/stop/reload subcommands. See [CLI Commands](../cli/commands.md) for what the CLI offers. + +## Understand App States + +The **STATUS** badge on each row reflects the app's current lifecycle state. + +| State | Meaning | +|-------|---------| +| `STARTING` | The app is running its `on_initialize` hook. | +| `RUNNING` | The app is processing events normally. | +| `STOPPED` | The app was stopped via the UI or REST API. It will not process events until started again. | +| `FAILED` | The app encountered an unhandled error. Check the **LAST ERROR** column or the App Detail error banner for the traceback. | +| `CRASHED` | The app crashed and cannot recover. Check the error details and restart manually. | +| `DISABLED` | The app has `enabled = false` in `hassette.toml`. Use **Start** to enable it for this session. Set `enabled = true` in config for a permanent change. | +| `BLOCKED` | Another app has the `@only_app` decorator, excluding this app from running. The block resolves automatically when the blocking app is removed or reloaded. | + +For the full lifecycle state machine and transition rules, see [Apps lifecycle](../core-concepts/apps/lifecycle.md). From 51ae45b4de943a4b8583355796adfff20b43d5de Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Tue, 2 Jun 2026 19:34:43 -0500 Subject: [PATCH 050/160] docs: rewrite CLI section (4 pages) --- docs/pages/cli/commands.md | 238 +++++++++++++++----------------- docs/pages/cli/configuration.md | 139 ++++++------------- docs/pages/cli/index.md | 45 +++--- docs/pages/cli/workflows.md | 152 +++++++++++++------- 4 files changed, 287 insertions(+), 287 deletions(-) diff --git a/docs/pages/cli/commands.md b/docs/pages/cli/commands.md index dabb6d1e8..c8a348168 100644 --- a/docs/pages/cli/commands.md +++ b/docs/pages/cli/commands.md @@ -1,10 +1,10 @@ # Command Reference -Every command supports `--json` for structured output and `--debug` for verbose error details. See [Configuration & Scripting](configuration.md#output-modes) for details on output modes. +All commands support `--json` for structured output and `--debug` for verbose error details. [Configuration & Scripting](configuration.md#output-modes) covers output modes in detail. ## `hassette run` -Start the Hassette framework server. Run this first — it connects to Home Assistant and starts your automations. +Starts the Hassette framework server, connects to Home Assistant, loads apps, and starts the web API. ```bash hassette run @@ -14,18 +14,16 @@ hassette run | Flag | Description | |---|---| -| `--token`, `-t` | Home Assistant access token (overrides config/env) | -| `--base-url`, `-u`, `--url` | Base URL of the Home Assistant instance | -| `--verify-ssl` | Whether to verify SSL certificates | -| `--dev-mode` | Enable developer mode | +| `--token`, `-t` | Home Assistant access token. Overrides config file and environment. | +| `--base-url`, `-u`, `--url` | Base URL of the Home Assistant instance. | +| `--verify-ssl` | Whether to verify SSL certificates. | +| `--dev-mode` | Enables developer mode. | -All flags are optional — values are resolved from the TOML config file and environment variables when not provided on the command line. - ---- +All flags are optional. Values resolve from the TOML config file and environment variables when not provided on the command line. ## `hassette status` -System health summary: status, WebSocket connection, uptime, app count, entity count, and version. +Reports system health: connection state, uptime, app count, entity count, and version. ```console $ hassette status @@ -41,13 +39,13 @@ $ hassette status ╰──────────────────────────────────────────────────────────────╯ ``` -**API endpoint:** `GET /api/health` +`boot_issues` lists apps that failed to initialize. An empty list means all apps started cleanly. ---- +**API endpoint:** `GET /api/health` ## `hassette app` -List all loaded apps with their key, display name, status, instance count, and recent invocation counts. +Lists all loaded apps with key, display name, status, instance count, and recent invocation counts. ```console $ hassette app @@ -65,15 +63,15 @@ $ hassette app | Subcommand | Description | API endpoint | |---|---|---| -| `hassette app` | List all apps | `GET /api/apps/manifests` | -| `hassette app health ` | Health metrics for one app | `GET /api/telemetry/app/{key}/health` | -| `hassette app activity ` | Recent activity feed | `GET /api/telemetry/app/{key}/activity` | -| `hassette app config ` | Resolved configuration | `GET /api/apps/{key}/config` | -| `hassette app source ` | Source file location | `GET /api/apps/{key}/source` | +| `hassette app` | Lists all apps. | `GET /api/apps/manifests` | +| `hassette app health ` | Health metrics for one app. | `GET /api/telemetry/app/{key}/health` | +| `hassette app activity ` | Recent activity feed. | `GET /api/telemetry/app/{key}/activity` | +| `hassette app config ` | Resolved configuration. | `GET /api/apps/{key}/config` | +| `hassette app source ` | Source file path. | `GET /api/apps/{key}/source` | ### `hassette app health ` -Health metrics for a specific app: error rate, average handler/job duration, and overall health status. +Reports health metrics for an app: error rate, average handler and job duration, and overall health status. ```console $ hassette app health bus_handler_app @@ -87,7 +85,7 @@ $ hassette app health bus_handler_app ╰───────────────────────────────────╯ ``` -Filter by instance or time window: +`--instance` and `--since` scope the metrics window: ```bash hassette app health my-app --instance office --since 6h @@ -95,14 +93,12 @@ hassette app health my-app --instance office --since 6h ### `hassette app activity ` -Recent handler invocations and job executions for an app, shown as a unified activity feed. +Recent handler invocations and job executions for an app, as a unified activity feed. Columns: ID, kind (`handler` or `job`), status, app key, handler name, duration, timestamp, and error type. ```bash hassette app activity my-app --since 30m --limit 20 ``` -The activity table includes columns for kind (handler or job), status, handler name, duration, timestamp, and error type. - ### `hassette app config ` The resolved configuration for an app, as loaded from all sources (TOML, env vars, defaults). @@ -123,101 +119,91 @@ hassette app source my-app | Flag | Applies to | Description | |---|---|---| -| `--instance` | `health`, `activity` | Filter to a specific app instance (index or name) | -| `--since` | `health`, `activity` | Time window for metrics (e.g. `1h`, `7d`) | -| `--limit` | `activity` | Maximum records to return | -| `--json` | all | Output as JSON | - ---- +| `--instance` | `health`, `activity` | Filters to a specific app instance (index or name). | +| `--since` | `health`, `activity` | Time window for metrics. See [formats](#--since-format). | +| `--source-tier` | `health` | Filters by source tier: `app`, `framework`, or `all`. | +| `--limit` | `activity` | Maximum records to return. | +| `--json` | all | Outputs as JSON. | ## `hassette listener` -List all registered event bus listeners, or view invocation history for a specific listener. +Lists all registered event bus listeners, or shows invocation history for a specific listener. ```console $ hassette listener -┏━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━┳━━━━┳━━━━━━┳━━━━━┳━━━━━━┓ -┃ ID ┃ Topic ┃ Handler ┃ Kind ┃ Total ┃ OK ┃ Fail ┃ Avg ┃ Last ┃ -┡━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━╇━━━━╇━━━━━━╇━━━━━╇━━━━━━┩ -│ 10 │ hass.event.state_changed.light.kitchen_… │ BusHandlerApp._… │ state │ 0 │ 0 │ 0 │ 0ms │ │ -│ │ │ │ change │ │ │ │ │ │ -└────┴───────────────────────────────────────────┴───────────────────┴────────┴───────┴────┴──────┴─────┴──────┘ +┏━━━━┳━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━┳━━━━┳━━━━━━┳━━━━━┳━━━━━━┓ +┃ ID ┃ App ┃ Target ┃ Kind ┃ Handler ┃ Total ┃ OK ┃ Fail ┃ Avg ┃ Last ┃ +┡━━━━╇━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━╇━━━━╇━━━━━━╇━━━━━╇━━━━━━┩ +│ 10 │ bus_handler_app │ light.kitchen_main │ state_cha… │ on_light_change │ 0 │ 0 │ 0 │ 0ms │ │ +└────┴──────────────────┴───────────────────────────┴────────────┴──────────────────────┴───────┴────┴──────┴─────┴──────┘ ``` -The table shows each listener's ID, the event topic it subscribes to, the handler method, event kind, invocation counts (total, successful, failed), average duration, and when it was last invoked. - -### Viewing invocation history +Each row shows the listener ID, app key, target entity, listener kind, handler method, invocation counts (total, successful, failed), average duration, and last invocation time. -Pass a listener ID to see its invocation history: +Passing a listener ID shows its invocation history: ```bash hassette listener 10 --since 1h --limit 20 ``` -The invocation table shows status, duration, error details, timestamp, and execution ID for each invocation. +The invocation table shows status, duration, error type, error message, timestamp, and execution ID for each invocation. ### Flags | Flag | Description | |---|---| -| `--app ` | Filter to listeners belonging to this app | -| `--instance ` | Filter to a specific app instance (requires `--app`) | -| `--since ` | Time window for invocation counts and history | -| `--limit ` | Maximum invocation records (when viewing a specific listener) | -| `--source-tier ` | Filter by `app` (user automations) or `framework` (internal). Defaults to `app` | -| `--json` | Output as JSON | +| `--app ` | Filters to listeners belonging to this app. | +| `--instance ` | Filters to a specific app instance. Requires `--app`. | +| `--since ` | Time window for invocation counts and history. | +| `--source-tier ` | Filters by `app`, `framework`, or `all`. | +| `--limit ` | Maximum invocation records (when viewing a specific listener). | +| `--json` | Outputs as JSON. | **API endpoints:** -- `hassette listener` → `GET /api/bus/listeners` -- `hassette listener --app ` → `GET /api/telemetry/app/{key}/listeners` -- `hassette listener ` → `GET /api/telemetry/listener/{id}/executions` - ---- +- `hassette listener` hits `GET /api/bus/listeners` +- `hassette listener --app ` hits `GET /api/telemetry/app/{key}/listeners` +- `hassette listener ` hits `GET /api/telemetry/listener/{id}/executions` ## `hassette job` -List all scheduled jobs, or view execution history for a specific job. +Lists all scheduled jobs, or shows execution history for a specific job. ```console $ hassette job -┏━━━━┳━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━┳━━━━┳━━━━━━┳━━━━━┳━━━━━━━━━━┓ -┃ ID ┃ Handler ┃ Trigger ┃ Schedule ┃ Total ┃ OK ┃ Fail ┃ Avg ┃ Next Run ┃ -┡━━━━╇━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━╇━━━━╇━━━━━━╇━━━━━╇━━━━━━━━━━┩ -│ 1 │ StateProxy.sync_all │ interval │ every │ 0 │ 0 │ 0 │ 0ms │ soon │ -└────┴──────────────────────┴──────────┴──────────┴───────┴────┴──────┴─────┴──────────┘ +┏━━━━┳━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━┳━━━━┳━━━━━━┳━━━━━┳━━━━━━━━━━┓ +┃ ID ┃ App ┃ Handler ┃ Trigger ┃ Schedule ┃ Total ┃ OK ┃ Fail ┃ Avg ┃ Next Run ┃ +┡━━━━╇━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━╇━━━━╇━━━━━━╇━━━━━╇━━━━━━━━━━┩ +│ 1 │ config_app │ StateProxy.sync_all │ interval │ every │ 0 │ 0 │ 0 │ 0ms │ soon │ +└────┴──────────────────┴──────────────────────┴──────────┴──────────┴───────┴────┴──────┴─────┴──────────┘ ``` -The table shows the job ID, handler method, trigger type, schedule label, execution counts, average duration, and when the job will next run. +Each row shows the job ID, app key, handler method, trigger type, schedule label, execution counts, average duration, and next scheduled run time. -### Viewing execution history - -Pass a job ID to see its execution history: +Passing a job ID shows its execution history: ```bash hassette job 1 --limit 20 ``` -The execution table shows status, duration, error details, timestamp, and execution ID. +The execution table shows status, duration, error type, error message, timestamp, and execution ID for each run. ### Flags | Flag | Description | |---|---| -| `--app ` | Filter to jobs belonging to this app | -| `--instance ` | Filter to a specific app instance (requires `--app`) | -| `--since ` | Time window for execution history | -| `--limit ` | Maximum execution records (when viewing a specific job) | -| `--source-tier ` | Filter by `app` (default), `framework`, or `all` | -| `--json` | Output as JSON | +| `--app ` | Filters to jobs belonging to this app. | +| `--instance ` | Filters to a specific app instance. Requires `--app`. | +| `--since ` | Time window for execution history. | +| `--source-tier ` | Filters by `app`, `framework`, or `all`. | +| `--limit ` | Maximum execution records (when viewing a specific job). | +| `--json` | Outputs as JSON. | **API endpoints:** -- `hassette job` → `GET /api/scheduler/jobs` -- `hassette job --app ` → `GET /api/telemetry/app/{key}/jobs` -- `hassette job ` → `GET /api/telemetry/job/{id}/executions` - ---- +- `hassette job` hits `GET /api/scheduler/jobs` +- `hassette job --app ` hits `GET /api/telemetry/app/{key}/jobs` +- `hassette job ` hits `GET /api/telemetry/job/{id}/executions` ## `hassette log` @@ -240,41 +226,41 @@ $ hassette log --limit 5 └─────────┴───────┴─────┴──────────┴─────────────────────┴────────────────────────────┘ ``` +`--instance` is not supported on this command; the CLI exits with a usage error if provided. `--app` filters by app key. + ### Flags | Flag | Description | |---|---| -| `--app ` | Filter to log entries from this app | -| `--since ` | Time window filter (e.g. `1h`, `30m`) | -| `--limit ` | Maximum number of entries to return | -| `--source-tier ` | Filter by `app` or `framework` | -| `--json` | Output as JSON | +| `--app ` | Filters to log entries from this app. | +| `--since ` | Time window filter. | +| `--limit ` | Maximum number of entries to return. | +| `--source-tier ` | Filters by `app`, `framework`, or `all`. | +| `--json` | Outputs as JSON. | **API endpoint:** `GET /api/logs/recent` ---- - ## `hassette execution` -Log entries for a specific execution, identified by its UUID. Use this to see exactly what happened during a single handler invocation or job execution. +Log entries for a specific execution, identified by its UUID. ```bash hassette execution a1b2c3d4-e5f6-7890-abcd-ef1234567890 ``` -You'll typically get the execution UUID from the `listener ` or `job ` invocation/execution tables (the "Execution ID" column), then drill into it here. See [Workflows](workflows.md) for the full drill-down pattern. +The execution UUID appears in the Execution ID column of `hassette listener ` and `hassette job ` output. [Workflows](workflows.md) covers the full drill-down pattern. + +The table shows timestamp, level, function name, line number, and message for each log entry captured during that execution. ### Flags | Flag | Description | |---|---| -| `--limit ` | Maximum number of log entries to return | -| `--json` | Output as JSON | +| `--limit ` | Maximum number of log entries to return. | +| `--json` | Outputs as JSON. | **API endpoint:** `GET /api/executions/{execution_id}` ---- - ## `hassette event` Recent Home Assistant events received by the WebSocket connection. @@ -292,23 +278,20 @@ $ hassette event --limit 5 └────────────────┴────────┴─────────┘ ``` +The Entity column is populated for `state_changed` events. Other event types leave it blank. + ### Flags | Flag | Description | |---|---| -| `--limit ` | Maximum number of events to return | -| `--json` | Output as JSON | +| `--limit ` | Maximum number of events to return. | +| `--json` | Outputs as JSON. | **API endpoint:** `GET /api/events/recent` -!!! note - Event data is from the in-memory buffer and reflects the raw HA event stream. The `Entity` column is populated for `state_changed` events; other event types may leave it blank. - ---- - ## `hassette dashboard` -App grid summary as shown on the web UI dashboard: per-app health status, invocation counts, and error rates. +Per-app health status, invocation counts, error counts, average duration, and last activity. Mirrors the dashboard grid in the web UI. ```console $ hassette dashboard @@ -321,26 +304,18 @@ $ hassette dashboard └─────────────────┴─────────┴───────┴──────┴─────────┴─────────────┴───────────┘ ``` -This shows a health overview of all apps — the same data as the dashboard grid in the web UI. - **API endpoint:** `GET /api/telemetry/dashboard/app-grid` ---- - ## `hassette config` -The resolved Hassette configuration as loaded from all sources (TOML, env vars, defaults). +The resolved Hassette configuration, as loaded from all sources (TOML, env vars, defaults). Renders as a key-value panel showing the full configuration tree, including nested sections like `web_api`, `apps`, and `lifecycle`. ```bash hassette config ``` -Renders as a key-value panel showing the full configuration tree, including nested sections like `web_api`, `apps`, `lifecycle`, etc. - **API endpoint:** `GET /api/config` ---- - ## `hassette telemetry` Telemetry database statistics: record counts, drop rates, and error handler failures. @@ -356,30 +331,39 @@ $ hassette telemetry ╰─────────────────────────────────╯ ``` -When all counters are zero, the telemetry pipeline is healthy and no records have been lost. +All-zero counters indicate the telemetry pipeline is healthy and no records have been lost. **API endpoint:** `GET /api/telemetry/status` ---- - ## Shared Flags -These flags are supported across multiple commands: +These flags appear across multiple commands. | Flag | Format | Commands | Description | |---|---|---|---| -| `--app ` | string | `listener`, `job`, `log` | Filter results to a specific app key | -| `--instance ` | int or string | `listener`, `job`, `app health`, `app activity` | Filter to a specific app instance. Accepts an integer index (`0`, `1`) or an instance name (`office`). Requires an app key context (`--app` flag or positional `` argument). | -| `--since ` | relative or absolute | `listener`, `job`, `log`, `app health`, `app activity` | Time window for filtering. See [formats below](#-since-format). | -| `--limit ` | integer | `log`, `event`, `execution`, `app activity`, and per-ID commands | Maximum number of records to return | -| `--source-tier ` | `app`, `framework`, or `all` | `listener`, `job`, `log`, `app health` | Filter by source tier. `app` (default) returns user automation records; `framework` returns internal Hassette component records; `all` returns both. | -| `--json` | — | all commands | Output as JSON. See [Output Modes](configuration.md#output-modes). | +| `--app ` | string | `listener`, `job`, `log` | Filters results to a specific app key. | +| `--instance ` | int or string | `listener`, `job`, `app health`, `app activity` | Filters to a specific app instance. Requires `--app` or a positional `` argument. | +| `--since ` | relative or absolute | `listener`, `job`, `log`, `app health`, `app activity` | Time window for filtering. See [`--since` format](#--since-format). | +| `--limit ` | integer | `log`, `event`, `execution`, `app activity`, per-ID commands | Maximum number of records to return. | +| `--source-tier ` | `app`, `framework`, or `all` | `listener`, `job`, `log`, `app health` | Filters by source tier. `app` returns user automation records. `framework` returns internal Hassette component records. `all` returns both. | +| `--json` | n/a | all commands | Outputs as JSON. See [Output Modes](configuration.md#output-modes). | + +### Global flags + +These flags apply to every command and are placed before the subcommand name. + +| Flag | Aliases | Description | +|---|---|---| +| `--config-file` | `-c` | Path to the TOML configuration file. | +| `--env-file` | `-e`, `--env` | Path to the `.env` file. | +| `--json` | n/a | Outputs results as JSON. | +| `--debug` | n/a | Shows the full HTTP response on CLI errors. | ### `--since` format -`--since` accepts two formats: +`--since` accepts relative durations and absolute timestamps. -**Relative durations** — a number followed by a unit suffix: +**Relative durations** use a number followed by a unit suffix: | Suffix | Unit | Example | |---|---|---| @@ -389,20 +373,24 @@ These flags are supported across multiple commands: | `d` | days | `7d` | | `w` | weeks | `2w` | -Compound durations (`1h30m`) are not supported. +Compound durations such as `1h30m` are not supported. Month and year units are not supported. -**Absolute timestamps** — ISO 8601 format, interpreted as local time: +**Absolute timestamps** use ISO 8601 format: -- `2026-05-22T14:00:00` — date and time -- `2026-05-22` — date only (midnight local time) +| Format | Example | Interpretation | +|---|---|---| +| Date only | `2026-05-22` | Midnight in local time. | +| Date and time (naive) | `2026-05-22T14:00:00` | Local time. | +| Date and time (UTC) | `2026-05-22T18:00:00Z` | UTC. | +| Date and time (offset) | `2026-05-22T14:00:00-04:00` | Explicit offset. | -Invalid formats exit non-zero with an error listing accepted formats. +Invalid values cause a non-zero exit with an error listing accepted formats. ### `--instance` resolution -`--instance` requires `--app`. It accepts: +`--instance` requires `--app` (or a positional `` argument on `app health` and `app activity`). It accepts: -- **Integer index** — passed directly to the API as `instance_index`. Most apps have a single instance at index `0`. -- **Instance name** — resolved to an index by fetching the app manifest. If no instance matches, the CLI exits non-zero and lists the available instance names. +- **Integer index**, passed directly to the API as `instance_index`. Most apps have a single instance at index `0`. +- **Instance name**, resolved to an index by fetching the app manifest. If no instance matches the name, the CLI exits non-zero and lists available instance names. -`--instance` without `--app` exits non-zero with a usage error. +`--instance` without an app context exits non-zero with a usage error. diff --git a/docs/pages/cli/configuration.md b/docs/pages/cli/configuration.md index 4f464f0b2..9c993b6f0 100644 --- a/docs/pages/cli/configuration.md +++ b/docs/pages/cli/configuration.md @@ -2,17 +2,19 @@ ## Configuration -The CLI reads the same configuration files Hassette uses to find the server address. You don't need to pass the address on every command. +### Discovery Order -### Discovery order +The CLI constructs the server address from the same configuration sources Hassette uses at runtime. +Priority runs highest to lowest: -1. **Environment variable** — `HASSETTE__WEB_API__HOST` and `HASSETTE__WEB_API__PORT` -2. **`.env` file** — loaded from the current directory or the path in `--env-file` -3. **`hassette.toml`** — loaded from the current directory or the path in `--config-file` -4. **Default** — `http://127.0.0.1:8126` +1. **Global flags**: `--config-file` and `--env-file` override which files are loaded +2. **Environment variables**: `HASSETTE__WEB_API__HOST` and `HASSETTE__WEB_API__PORT` +3. **`.env` file**: loaded from the current directory (or the path given to `--env-file`) +4. **`hassette.toml`**: loaded from the current directory (or the path given to `--config-file`) +5. **Default**: `http://127.0.0.1:8126` !!! tip "Remote instances" - To query a remote Hassette instance, set the host in your environment: + To query a remote Hassette instance, set the host in the environment: ```bash HASSETTE__WEB_API__HOST=192.168.1.100 hassette status @@ -27,24 +29,23 @@ The CLI reads the same configuration files Hassette uses to find the server addr ### Token -The access token (`HASSETTE__TOKEN`) is **not required** for CLI query commands. Query commands read from the REST API without a token. The token is only required when starting the server. +The access token (`HASSETTE__TOKEN`) is not required for CLI query commands. Query commands read from the REST API without authentication. Only `hassette run` requires the token to connect to Home Assistant. ## Output Modes -### Human-readable (default) +### Human-Readable (Default) -Tables for collections, key-value panels for single objects. Colors and formatting are applied when stdout is a TTY. +The CLI renders tables for collections and key-value panels for single objects. Colors and formatting apply when stdout is a TTY. -When piped, Rich automatically strips ANSI codes and disables column truncation so the full values are preserved: +When output is piped, Rich automatically strips ANSI codes and disables column truncation: ```bash -# Piped output shows full values, no truncation hassette listener --app my-app | grep error ``` ### JSON (`--json`) -Structured output on stdout. The full response model is serialized — a superset of what the human table shows. +`--json` writes a single JSON document to stdout. The response model is the full server payload, a superset of what the human table displays. ```console $ hassette status --json @@ -65,81 +66,27 @@ $ hassette status --json } ``` -When `--json` is active: +In `--json` mode: -- stdout contains exactly one JSON document — either the success result or an error object -- The exit code distinguishes success (0) from failure (1 for HTTP errors, 2 for network errors) -- No Rich formatting or human-readable text is written to stdout +- stdout contains exactly one JSON document, either the success result or an error object +- Exit code distinguishes success (`0`) from failure (`1` for HTTP errors, `2` for network errors) +- No Rich formatting or human-readable text appears on stdout ### `NO_COLOR` -Set `NO_COLOR=1` to disable all ANSI color output regardless of TTY detection: +`NO_COLOR=1` disables all ANSI color output regardless of TTY detection: ```bash NO_COLOR=1 hassette status ``` -## Scripting with `jq` - -Combine `--json` with `jq` for monitoring scripts and automation: - -```bash -# Extract the status field -hassette status --json | jq -r '.status' - -# List all app keys -hassette app --json | jq -r '.[].app_key' - -# Find listeners with errors -hassette listener --json | jq '.[] | select(.failed > 0)' - -# Get the error rate class for a specific app -hassette app health my-app --json | jq -r '.error_rate_class' - -# Count failed invocations in the last hour -hassette listener 42 --since 1h --json | jq '[.[] | select(.status == "error")] | length' -``` - -### Health check script - -This script checks whether Hassette is reachable. For restart automation, point your container healthcheck at `/api/health/live` instead — a live probe returns 200 whenever the process is up, regardless of whether Home Assistant is connected. A fatal exit sets a non-zero exit code, which `Restart=on-failure` (systemd) and `restart: unless-stopped` (Docker) pick up directly. - -```bash -#!/usr/bin/env bash -set -uo pipefail - -# Liveness: the process is up and the event loop can respond. -# Use /api/health/live for container healthchecks and restart automation. -# Use /api/health/ready to gate traffic routing. -if ! curl -sf http://127.0.0.1:8126/api/health/live > /dev/null 2>&1; then - echo "Hassette is not responding" >&2 - exit 1 -fi - -# Optional: check aggregate status from /api/health (always 200, even when starting or degraded) -OUTPUT=$(hassette status --json 2>/dev/null) || true -STATUS=$(echo "$OUTPUT" | jq -r '.status // empty') -echo "Hassette is up (status: ${STATUS:-unknown})" -``` - -### Alerting on error rate - -```bash -#!/usr/bin/env bash -set -euo pipefail - -hassette dashboard --json | jq -r '.[] | select(.health_status != "excellent") | "\(.app_key): \(.health_status)"' | while read -r line; do - echo "ALERT: $line" >&2 -done -``` - ## Shell Completion -Hassette supports tab completion for commands and subcommand names via [cyclopts](https://github.com/BrianPugh/cyclopts). Two commands are available: +Hassette provides tab completion for commands and flags via [cyclopts](https://github.com/BrianPugh/cyclopts). ### Generate to stdout -`--generate-completion` prints the completion script to stdout so you can pipe it wherever you want: +`--generate-completion` prints the completion script to stdout: ```bash # Zsh @@ -154,25 +101,25 @@ hassette --generate-completion fish > ~/.config/fish/completions/hassette.fish ### Install to default location -`--install-completion` writes the completion script to the shell's default completion directory and prints instructions for adding it to your path: +`--install-completion` writes the completion script to the shell's default completion directory: ```bash hassette --install-completion --shell zsh ``` -If `--shell` is omitted, both commands auto-detect the current shell. After installation, restart your shell or source the relevant config file. Pressing Tab after `hassette ` then shows available subcommands. Subcommand-specific flags are also completed. +Omitting `--shell` from either command triggers auto-detection of the current shell. A shell restart or config re-source is needed after installation. Subcommand-specific flags complete alongside top-level commands. ## Error Handling -### Exit codes +### Exit Codes | Code | Meaning | |---|---| | `0` | Success | -| `1` | Server error (4xx/5xx) or usage error (invalid flag combination, bad `--since` format) | -| `2` | Network error — connection refused or request timed out | +| `1` | Server error (4xx/5xx) or usage error (invalid flag, unknown instance name) | +| `2` | Network error (connection refused or request timed out) | -### Common errors +### Common Errors **Connection refused:** @@ -180,15 +127,15 @@ If `--shell` is omitted, both commands auto-detect the current shell. After inst Network error: Connection refused: http://127.0.0.1:8126 ([Errno 111] Connection refused) ``` -Hassette is not running, or the configured address is wrong. Start the server or check the address via environment variables or config files. +Hassette is not running, or the configured address is wrong. The address comes from environment variables, `.env`, or `hassette.toml`. **Request timed out:** ``` -Network error: Request timed out after 10s: http://127.0.0.1:8126/api/health +Network error: Request timed out after 10s connecting to http://127.0.0.1:8126 ``` -The server is reachable but not responding. Check server logs for blocking operations. +The server is reachable but not responding. Server logs may show blocking operations. **Unknown instance name:** @@ -196,27 +143,29 @@ The server is reachable but not responding. Check server logs for blocking opera Usage error: Instance 'office' not found for app 'my-app'. Available instances: 'default', 'kitchen' ``` -Pass the instance name exactly as it appears in `hassette app`, or use the integer index. +The instance name must match `hassette app` output exactly. The integer index also works. + +### JSON Error Format -### JSON error format +When `--json` is active, errors are written to stdout as a JSON object. Scripts can detect failures without parsing stderr. -When `--json` is active, errors are written to stdout as a JSON object so scripts can detect them without parsing stderr: +Network error: ```json {"error": true, "status": null, "detail": "Connection refused: http://127.0.0.1:8126 ([Errno 111] Connection refused)"} ``` -For server errors with an HTTP status: +Server error with HTTP status: ```json {"error": true, "status": 503, "detail": "Service unavailable"} ``` -### Debug mode (`--debug`) +### Debug Mode (`--debug`) -Add `--debug` to any command to see the full HTTP response when an error occurs. This is useful for diagnosing 500s or unexpected API responses without checking server logs. +`--debug` appends the full HTTP response to error output. It applies to any command and affects only error responses. Successful responses are unchanged. -In human mode, the request method, URL, and full response body are printed below the error: +Human mode prints the request method, URL, and response body below the error message: ``` Error 500: Internal Server Error @@ -224,16 +173,16 @@ Error 500: Internal Server Error Body: {"detail":"Internal Server Error","traceback":"..."} ``` -In JSON mode, a `debug` key is added to the error object: +JSON mode adds a `debug` key to the error object: ```json {"error": true, "status": 500, "detail": "Internal Server Error", "debug": {"url": "http://127.0.0.1:8126/api/health", "method": "GET", "body": "{\"detail\":\"Internal Server Error\"}"}} ``` -`--debug` only affects HTTP error responses. Network errors (connection refused, timeout) already include the target address in the default output. +Network errors always include the target address in the default output. `--debug` does not change their format. ## Related Pages -- [Web UI](../web-ui/index.md) — the browser interface covering the same data -- [Database & Telemetry](../core-concepts/database-telemetry.md) — what telemetry is collected and how it is stored -- [Configuration Overview](../core-concepts/configuration/index.md) — config file locations and precedence +- [CLI Overview](index.md): installation and quick start +- [Commands](commands.md): all commands and flags +- [Workflows](workflows.md): scripting patterns and `jq` recipes diff --git a/docs/pages/cli/index.md b/docs/pages/cli/index.md index da596e709..1ca718fc1 100644 --- a/docs/pages/cli/index.md +++ b/docs/pages/cli/index.md @@ -1,27 +1,28 @@ # CLI -The `hassette` CLI lets you query a running Hassette instance from the terminal. Check system health, inspect app status, browse listener invocations, tail logs, and review scheduled jobs — all without opening a browser or composing raw HTTP requests. +The `hassette` CLI queries a running Hassette instance over HTTP. Check system health, inspect apps, read logs, and trace handler executions from the terminal. No HA credentials needed for read commands. -The CLI queries the same REST API the web UI uses. You get the same data, formatted for the terminal by default or serialized to JSON for scripting. +The default address is `http://localhost:8126`. See [Configuration](configuration.md) to point the CLI at a remote instance. ## Quick Start -With Hassette running, open a second terminal: - ```console $ hassette status -╭──────────────────── SystemStatusResponse ────────────────────╮ -│ status ok │ -│ websocket_connected True │ -│ uptime_seconds 16.57 │ -│ entity_count 103 │ -│ app_count 3 │ -│ services_running ["EventStreamService", ...] │ -│ version 0.32.0 │ -│ boot_issues [] │ -╰──────────────────────────────────────────────────────────────╯ +╭────────────────────── System Status ──────────────────────╮ +│ status ok │ +│ websocket_connected true │ +│ uptime_seconds 17s │ +│ entity_count 103 │ +│ app_count 3 │ +│ services_running EventStreamService, WebApiService, │ +│ BusService, SchedulerService │ +│ version 0.32.0 │ +│ boot_issues — │ +╰───────────────────────────────────────────────────────────╯ ``` +`hassette status` shows connection state, uptime, and app count. `boot_issues` lists any apps that failed to initialize. + ```console $ hassette app ┏━━━━━━━━━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━┓ @@ -34,6 +35,8 @@ $ hassette app └─────────────────┴─────────┴─────────────┴───────────┴──────────┴─────────┴───────────────────┘ ``` +`hassette app` lists every loaded app with its status and invocation count. `Invoc/1h` shows handler firings in the last hour. A count of 0 is normal for apps that react to infrequent events. + ```console $ hassette log --limit 5 ┏━━━━━━━━━┳━━━━━━━┳━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ @@ -51,17 +54,19 @@ $ hassette log --limit 5 └─────────┴───────┴─────┴──────────┴─────────────────────┴────────────────────────────┘ ``` -If Hassette is not running, you'll see a connection error: +`hassette log` shows the most recent log entries. Narrow to a specific app with `--app `, or go back further with `--since 1h`. + +If Hassette isn't running, every command gives the same error: ```console $ hassette status -Network error: Connection refused: http://127.0.0.1:8126 +Could not connect to Hassette at http://localhost:8126 ``` -See [Configuration](configuration.md) for how to point the CLI at a different address. +Start Hassette with `hassette run`, then retry. See [Configuration](configuration.md) to connect to a remote instance. ## Next Steps -- **[Command Reference](commands.md)**: Every command with flags and output examples. -- **[Workflows](workflows.md)**: How to drill down from system status to a specific invocation. -- **[Configuration & Scripting](configuration.md)**: JSON mode, `jq` recipes, shell completion, error handling. +- [Command Reference](commands.md) — every command with flags and output examples +- [Workflows](workflows.md) — drill down from "something is wrong" to root cause +- [Configuration & Scripting](configuration.md) — JSON output, `jq`, shell completion diff --git a/docs/pages/cli/workflows.md b/docs/pages/cli/workflows.md index 98c44b28b..7677559d7 100644 --- a/docs/pages/cli/workflows.md +++ b/docs/pages/cli/workflows.md @@ -1,26 +1,62 @@ # Workflows -The CLI commands are designed to chain together. Start broad, then drill down to the specific data you need. +CLI commands chain together. Start broad, narrow to the problem, then read the full trace. + +## Quick Health Checks + +Four one-liners for fast answers: + +**Is Hassette running?** + +```bash +hassette status +``` + +The output shows `status: ok`, `degraded`, or `starting`, plus uptime and connected app count. + +**Are all apps healthy?** + +```bash +hassette dashboard +``` + +Scan the `Health` and `Errs` columns. Any app showing `warning` or non-zero errors needs attention. + +**Any listeners with errors?** + +```bash +hassette listener --json | jq '.[] | select(.failed > 0) | {id: .listener_id, handler: .handler_method, failed}' +``` + +Returns only the listeners that have failures, with their IDs and handler names. + +**What happened recently?** + +```bash +hassette log --since 1h --limit 50 +``` + +Shows the last 50 log entries from the past hour across all apps. ## Drill-Down: From Status to Root Cause -The most common workflow starts with a system-level check and progressively narrows: +Start at the system level. Each step narrows the scope until you have a single execution to inspect. -### 1. Check system health +**1. Check system health** ```bash hassette status ``` -This tells you whether the system is `ok`, `degraded`, or `starting`, and how many apps are loaded. If something is wrong, the next step is to find which app. +If `status` is `ok`, the framework is healthy. If it's `degraded`, something is wrong at the service level. Move to the next step either way to see which app is affected. -### 2. Find the problem app +**2. Find the problem app** ```bash hassette dashboard ``` -The dashboard shows invocation counts, error counts, and health status for every app at a glance. Look for apps with a non-zero `Errs` column or a health status other than `excellent`: +The dashboard shows every app's invocation count, error count, average duration, and health status. Look for apps with a non-zero `Errs` value or a health status other than `excellent`: ``` ┏━━━━━━━━━━━━━━━━━┳━━━━━━━━━┳━━━━━━━┳━━━━━━┳━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━━━━┓ @@ -32,108 +68,130 @@ The dashboard shows invocation counts, error counts, and health status for every └─────────────────┴─────────┴───────┴──────┴─────────┴─────────────┴───────────┘ ``` -Here `garage_door` has 3 errors. Drill into it. +`garage_door` has 3 errors. Drill into it next. -### 3. Inspect the app's listeners +**3. Inspect the app's listeners** ```bash hassette listener --app garage_door ``` -This shows all listeners registered by the app, with per-listener invocation counts and failure rates. Find the listener with failures. +This lists every listener registered by `garage_door` with per-listener invocation counts and failure rates. Find the row with a non-zero `Fail` value and note its `ID`. -### 4. View invocation history +**4. View invocation history** ```bash hassette listener 42 --since 1h ``` -The invocation table shows the status, duration, error type, and execution ID for each invocation. Find the failed one and grab its execution ID. +Replace `42` with the listener ID from step 3. Each row shows status, duration, error info, and execution ID. Find the failed row and copy its execution ID. -### 5. Read the execution logs +**5. Read the execution logs** ```bash hassette execution a1b2c3d4-e5f6-7890-abcd-ef1234567890 ``` -This shows every log entry emitted during that specific handler invocation — the complete trace of what happened. - ---- +This shows every log entry emitted during that specific handler invocation. The `Function` and `Line` columns tell you exactly where in your code each message came from. ## Monitoring a Specific App -When you know which app you care about, use `--app` filters to scope every command: +Use `--app` to scope any command to one app: ```bash -# Health summary +# Health metrics hassette app health motion_lights --since 6h -# Its listeners and their stats -hassette listener --app motion_lights --since 1h +# Listener stats +hassette listener --app motion_lights + +# Recent invocations across all listeners +hassette app activity motion_lights --since 1h -# Its scheduled jobs +# Scheduled jobs hassette job --app motion_lights -# Its recent logs +# Recent log output hassette log --app motion_lights --limit 30 ``` ### Multi-Instance Apps -For apps with multiple instances (e.g., one per room), add `--instance`: +When an app runs as multiple instances (one per room, for example), add `--instance` to filter further. Use the instance name or its zero-based index: ```bash -# Filter to the "office" instance +# By name hassette listener --app remote_control --instance office -# Or by index +# By index hassette listener --app remote_control --instance 0 ``` ---- +`--instance` requires `--app`. The `log` command does not support `--instance`. -## Quick Health Checks +## Comparing Time Windows -### Is Hassette running? +`--since` accepts relative durations (`30s`, `30m`, `1h`, `7d`, `2w`) and absolute timestamps (`2026-05-22`, `2026-05-22T14:00:00`). Use different windows to separate a spike from a trend: ```bash -hassette status --json | jq -r '.status' -# "ok" -``` +# Is the current error rate elevated? +hassette app health motion_lights --since 1h -### Are all apps healthy? +# What's the baseline? +hassette app health motion_lights --since 24h -```bash -hassette dashboard --json | jq '.[] | select(.health_status != "excellent") | .app_key' +# Any longer trends? +hassette app health motion_lights --since 7d ``` -If this returns nothing, all apps are healthy. +If the 1h error rate is much higher than the 24h rate, the problem started recently. If the rates match, it's been happening all day. -### Any listeners with errors? +Listener invocation history works the same way: ```bash -hassette listener --json | jq '.[] | select(.failed > 0) | {id: .listener_id, handler: .handler_method, failed}' +# Failures in the last hour +hassette listener 42 --since 1h + +# Failures over the past day +hassette listener 42 --since 24h ``` -### What happened in the last hour? +## Scripting with `--json` + +Every command accepts `--json` and writes structured JSON to stdout. Pipe it to `jq` for filtering and scripting. + +**Extract failing apps:** ```bash -hassette log --since 1h --limit 50 +hassette dashboard --json | jq '.[] | select(.health_status != "excellent") | .app_key' ``` ---- +**Count total handler failures across all listeners:** -## Comparing Time Windows +```bash +hassette listener --json | jq '[.[].failed] | add' +``` -Use `--since` to compare different time periods: +**Health check script** (exits non-zero if the system is not `ok`): ```bash -# Last hour — is the current error rate elevated? -hassette app health motion_lights --since 1h +#!/usr/bin/env bash +status=$(hassette status --json | jq -r '.status') +if [ "$status" != "ok" ]; then + echo "Hassette is $status" >&2 + exit 1 +fi +``` -# Last 24 hours — what's the baseline? -hassette app health motion_lights --since 24h +**Alert on error rate** (exits non-zero if any app has more than 5 failures): -# Last week — any trends? -hassette app health motion_lights --since 7d +```bash +#!/usr/bin/env bash +failures=$(hassette listener --json | jq '[.[].failed] | add // 0') +if [ "$failures" -gt 5 ]; then + echo "Total handler failures: $failures" >&2 + exit 1 +fi ``` + +See [Commands](commands.md) for the full flag reference for each command, and [Configuration](configuration.md) for connection settings. The [web UI](../web-ui/index.md) shows the same data if you prefer a browser. From 778416e95c52dd110418f1e5dc805481aece6875 Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Tue, 2 Jun 2026 22:12:34 -0500 Subject: [PATCH 051/160] docs: rewrite Testing section (5 pages) --- docs/pages/testing/concurrency.md | 53 +++--- docs/pages/testing/factories.md | 162 +++++++++++------- docs/pages/testing/harness.md | 255 ++++++++++++++++++++++++++++- docs/pages/testing/index.md | 222 ++++--------------------- docs/pages/testing/time-control.md | 34 ++-- 5 files changed, 432 insertions(+), 294 deletions(-) diff --git a/docs/pages/testing/concurrency.md b/docs/pages/testing/concurrency.md index 298d97171..7f4303328 100644 --- a/docs/pages/testing/concurrency.md +++ b/docs/pages/testing/concurrency.md @@ -1,52 +1,51 @@ # Concurrency & pytest-xdist -The harness has two independent isolation mechanisms. Understanding which applies when prevents confusing deadlocks. +Two isolation mechanisms protect test state. Each targets a different scope. -## Same-Class Concurrency (always applies) +## DrainFailure Exception Hierarchy -`AppTestHarness` uses a **per-App-class `asyncio.Lock`** as a narrow critical section around the `app_manifest` read-modify-write and hermetic config validation. The lock is held only during these synchronous operations — not during app startup or teardown. +`DrainFailure` catches any drain-related failure from a `simulate_*` call that does not settle cleanly. Two concrete subclasses distinguish the failure mode. -- Two harnesses for the **same App class** can run concurrently in the same event loop. Using `asyncio.gather()` with multiple harnesses that share a class is safe — a reference counter ensures `app_manifest` is set on the first entry and restored only when the last harness exits. -- Two harnesses for **different App classes** can also run concurrently without conflict. +`DrainError` fires when handler tasks raise non-cancellation exceptions during drain. Its `task_exceptions` attribute is a `list[tuple[str, BaseException]]`, one entry per failed task. -## Time-Control Concurrency (`freeze_time` only) +`DrainTimeout` fires when the drain does not reach quiescence within the deadline. The exception message includes pending task names and a debounce hint when applicable. -`freeze_time` additionally uses a **process-global non-reentrant lock**. Only one harness at a time may hold the time lock in a process, regardless of which App class it tests. +`DrainTimeout` does not inherit from `TimeoutError`. Test code catches `DrainTimeout` or `DrainFailure` around `simulate_*` calls, not `TimeoutError`. -- Sequential tests in the same worker are safe — the lock is released when the `async with` block exits cleanly. -- If two harnesses compete for the time lock, the second one raises `RuntimeError: freeze_time is already held by another harness`. +```python +--8<-- "pages/testing/snippets/testing_drain_exceptions.py" +``` -## Parallel Test Suites (pytest-xdist) +Harness startup timeouts raise `TimeoutError`, not a `DrainFailure` subclass. A startup timeout fires when `on_initialize()` exceeds its deadline. [Test Harness Reference](harness.md) covers startup lifecycle. -Each xdist worker runs in its own process with its own time lock — workers cannot interfere with each other's frozen clock. The actual concern is within a single worker: `freeze_time` tests that are not grouped may interleave if the worker runs multiple async tests concurrently. Mark all `freeze_time` tests with the same `xdist_group` to serialize them within one worker: +## Same-Class Concurrency (Always Applies) +`AppTestHarness` acquires a per-App-class `asyncio.Lock` around the `app_manifest` read-modify-write. A reference counter sets `app_manifest` on the first entry and restores it only when the last harness exits. Multiple harnesses for the same `App` class can run concurrently via `asyncio.gather()`. Harnesses for different `App` classes never share a lock. +## Time-Control Concurrency (freeze_time Only) -```python ---8<-- "pages/testing/snippets/testing_xdist_group.py" -``` +`freeze_time` acquires a process-global `threading.Lock` (non-reentrant). Only one harness may hold the time lock at a time, regardless of `App` class. The lock releases when the `AppTestHarness` context manager exits. -If you run pytest sequentially (no `-n` flag), you do not need this marker. +A second harness that attempts to acquire the time lock raises `RuntimeError: freeze_time is already held by another harness`. Running `freeze_time` tests serially avoids this, either by avoiding concurrency or by grouping them with `xdist_group` (see below). -## pytest-asyncio Mode - -The `asyncio_mode = "auto"` setting is required — without it, async tests silently pass without running. See [Installation](index.md#installation) for setup and the false-green warning. +## Parallel Test Suites (pytest-xdist) -## `DrainFailure` Exception Hierarchy +Each xdist worker runs in its own process with its own `threading.Lock`. Workers cannot interfere with each other's frozen clock. The risk is within a single worker. `freeze_time` tests assigned to the same worker may interleave during concurrent async execution. -The drain exception hierarchy is rooted at `DrainFailure` so callers can catch any drain-related failure uniformly. +`@pytest.mark.xdist_group("time_control")` routes all marked tests to the same worker and serializes them. Tests that do not call `freeze_time` do not need this marker. -`DrainFailure` has two concrete subclasses: +```python +--8<-- "pages/testing/snippets/testing_xdist_group.py" +``` -- **`DrainError`** — one or more spawned handler tasks raised a non-cancellation exception. `e.task_exceptions` is a list of `(task_name, exception)` pairs. -- **`DrainTimeout`** — the drain did not reach quiescence within the configured timeout. The diagnostic message includes pending task names and a hint to check for debounced handlers. +Without `-n`, pytest runs sequentially in a single process. The marker has no effect there. -`DrainTimeout` deliberately does **not** inherit from `TimeoutError`. Callers should catch `DrainTimeout` or `DrainFailure` — not `TimeoutError` — around `simulate_*` calls. +## pytest-asyncio Mode -Harness startup timeouts (raised if `on_initialize()` takes more than 5 seconds) are a separate `TimeoutError` and are not `DrainFailure` subclasses. See [Harness Startup Failures](index.md#harness-startup-failures) on the Quick Start page. +`asyncio_mode = "auto"` is required. Without it, async tests silently pass without executing. The [Testing index](index.md#installation) covers setup and the false-green warning. ## Next Steps +- **[Time Control](time-control.md)**: Freezing and advancing time in tests - **[Factories & Internals](factories.md)**: Event factories and `RecordingApi` coverage boundary -- **[Time Control](time-control.md)**: How to freeze and advance time -- **[Quick Start](index.md)**: Back to the harness basics +- **[Testing index](index.md)**: Harness setup and quick start diff --git a/docs/pages/testing/factories.md b/docs/pages/testing/factories.md index ab99445f7..9d96598c4 100644 --- a/docs/pages/testing/factories.md +++ b/docs/pages/testing/factories.md @@ -1,46 +1,35 @@ # Factories & Internals -## Event Factories - -`hassette.test_utils` exports six factory functions for building raw event and state dictionaries. These are useful when you need to construct events manually — for example, to pre-populate state before a test or to pass custom event data to lower-level bus methods. +All factory functions listed here are exported from `hassette.test_utils`. ```python --8<-- "pages/testing/snippets/testing_factory_imports.py" ``` -### `create_state_change_event` - -Creates a `state_changed` event object suitable for sending through the bus directly. - -```python ---8<-- "pages/testing/snippets/testing_create_state_change_event.py" -``` - -All parameters except `entity_id`, `old_value`, and `new_value` are optional. - -### `create_call_service_event` - -Creates a `call_service` event object. - -```python ---8<-- "pages/testing/snippets/testing_create_call_service_event.py" -``` - ## State Factories +State factories build raw HA-format state dicts. The harness calls them internally for `set_state()`. Tests that need precise attribute control call them directly. + ### `make_state_dict` -Creates a raw state dictionary in Home Assistant format. The harness uses this internally; you'll use it when constructing test data directly. +`make_state_dict` builds a minimal state dict in Home Assistant wire format. ```python --8<-- "pages/testing/snippets/testing_make_state_dict.py" ``` -All parameters except `entity_id` and `state` are optional. Timestamps default to now. +| Parameter | Default | Description | +|-----------|---------|-------------| +| `entity_id` | required | Entity ID, e.g. `"sensor.temperature"`. | +| `state` | required | State string, e.g. `"on"`, `"25.5"`. | +| `attributes` | `None` | Attributes dict. Defaults to `{}`. | +| `last_changed` | `None` | ISO timestamp string. Defaults to now. | +| `last_updated` | `None` | ISO timestamp string. Defaults to now. | +| `context` | `None` | Context dict. Defaults to a generated UUID context. | ### `make_light_state_dict` -Shorthand for light entity state dicts with common attributes. +`make_light_state_dict` builds a state dict for a light entity with `brightness` and `color_temp` support. ```python --8<-- "pages/testing/snippets/testing_make_light_state_dict.py" @@ -50,13 +39,13 @@ Shorthand for light entity state dicts with common attributes. |-----------|---------|-------------| | `entity_id` | `"light.kitchen"` | Light entity ID. | | `state` | `"on"` | `"on"` or `"off"`. | -| `brightness` | `None` | Brightness 0–255. Omitted if not set. | -| `color_temp` | `None` | Color temperature in mireds. Omitted if not set. | -| `**kwargs` | — | Extra attributes or state dict fields (`last_changed`, `last_updated`, `context`). | +| `brightness` | `None` | Brightness 0–255. Omitted from attributes if not set. | +| `color_temp` | `None` | Color temperature in mireds. Omitted from attributes if not set. | +| `**kwargs` | | Extra attributes or top-level state dict fields (`last_changed`, `last_updated`, `context`). | ### `make_sensor_state_dict` -Shorthand for sensor entity state dicts. +`make_sensor_state_dict` builds a state dict for a sensor entity. ```python --8<-- "pages/testing/snippets/testing_make_sensor_state_dict.py" @@ -66,66 +55,109 @@ Shorthand for sensor entity state dicts. |-----------|---------|-------------| | `entity_id` | `"sensor.temperature"` | Sensor entity ID. | | `state` | `"25.5"` | Sensor value as a string. | -| `unit_of_measurement` | `None` | Unit string, e.g. `"°C"`, `"%"`. | -| `device_class` | `None` | HA device class, e.g. `"temperature"`. | +| `unit_of_measurement` | `None` | Unit string, e.g. `"°C"`, `"%"`. Omitted from attributes if not set. | +| `device_class` | `None` | HA device class, e.g. `"temperature"`, `"humidity"`. Omitted from attributes if not set. | +| `**kwargs` | | Extra attributes or top-level state dict fields. | ### `make_switch_state_dict` -Shorthand for switch entity state dicts. +`make_switch_state_dict` builds a state dict for a switch entity. ```python --8<-- "pages/testing/snippets/testing_make_switch_state_dict.py" ``` +| Parameter | Default | Description | +|-----------|---------|-------------| +| `entity_id` | `"switch.outlet"` | Switch entity ID. | +| `state` | `"on"` | `"on"` or `"off"`. | +| `**kwargs` | | Extra attributes or top-level state dict fields. | + +## Event Factories + +Event factories build typed event objects for direct bus dispatch. Most tests call `harness.simulate_state_change()` or `harness.simulate_call_service()` instead. These factories cover tests that bypass `simulate_*` and exercise lower-level bus methods. + +### `create_state_change_event` + +`create_state_change_event` builds a `RawStateChangeEvent` suitable for direct bus dispatch. + +```python +--8<-- "pages/testing/snippets/testing_create_state_change_event.py" +``` + +| Parameter | Default | Description | +|-----------|---------|-------------| +| `entity_id` | required | Entity ID. | +| `old_value` | required | Old state value. `None` simulates entity creation. | +| `new_value` | required | New state value. `None` simulates entity removal. | +| `old_attrs` | `None` | Attributes for the old state dict. | +| `new_attrs` | `None` | Attributes for the new state dict. | + +When `old_value` or `new_value` is `None`, the corresponding state dict is `None` in the event, not `{"state": None, ...}`. This matches HA's wire format for entity creation and removal. + +### `create_call_service_event` + +`create_call_service_event` builds a `CallServiceEvent`. + +```python +--8<-- "pages/testing/snippets/testing_create_call_service_event.py" +``` + +| Parameter | Default | Description | +|-----------|---------|-------------| +| `domain` | required | Service domain, e.g. `"light"`. | +| `service` | required | Service name, e.g. `"turn_on"`. | +| `service_data` | `None` | Service data dict. Defaults to `{}`. | + ## `make_mock_hassette` -`make_mock_hassette()` builds a sealed `AsyncMock` hassette with real, Pydantic-validated configuration. It is the standard pattern for unit tests that need a hassette mock with validated config — it replaces the pattern of manually setting `.config.*` fields on a raw `AsyncMock`. +`make_mock_hassette` returns a sealed `AsyncMock` with a real, Pydantic-validated `HassetteConfig`. It wires readiness events, scheduler service stubs, bus service stubs, and other standard attributes without running `Hassette.__init__`. ```python --8<-- "pages/testing/snippets/factories_mock_hassette.py" ``` -All `HassetteConfig` fields can be passed as keyword arguments; Pydantic validates them at construction time. Passing an unrecognised field name or an out-of-range value raises `pydantic.ValidationError` immediately. +`HassetteConfig` validates config overrides at construction time. An unrecognized field name or out-of-range value raises `pydantic.ValidationError` immediately. Nested group fields accept dicts or model instances. -Non-config attributes (`ready_event`, `shutdown_event`, `session_id`, `_scheduler_service`, `_bus_service`, `wait_for_ready`, `children`, etc.) are wired automatically. By default the mock is `seal()`-ed — accessing an attribute not set by the factory raises `AttributeError`. Pass `sealed=False` to add extra attributes after construction. +The mock is sealed by default. Accessing any attribute not wired by the factory raises `AttributeError`. `sealed=False` allows adding extra attributes after construction. | Parameter | Default | Description | |-----------|---------|-------------| -| `data_dir` | `tempfile.mkdtemp()` | Directory for Hassette data files. Pass `tmp_path` in integration tests for isolation. | -| `set_ready` | `True` | Pre-set `ready_event` so `wait_for_ready()` resolves immediately. | -| `set_loop` | `True` | Wire `loop` to `asyncio.get_running_loop()`. Pass `False` for session-scoped fixtures that run outside an event loop. | -| `sealed` | `True` | Call `seal()` after wiring; accessing unlisted attributes raises `AttributeError`. | -| `**config_overrides` | — | Any `HassetteConfig` field. Merged on top of test-appropriate defaults. | +| `data_dir` | `tempfile.mkdtemp()` | Directory for Hassette data files. Tests needing DB isolation typically pass `tmp_path`. | +| `set_ready` | `True` | Pre-sets `ready_event` so `wait_for_ready()` resolves immediately. | +| `set_loop` | `True` | Sets `loop` to `asyncio.get_running_loop()`. `False` suits session-scoped fixtures running outside an event loop. | +| `sealed` | `True` | Calls `seal()` after wiring. Unlisted attribute access raises `AttributeError`. | +| `**config_overrides` | | Any `HassetteConfig` field, merged on top of test defaults. | ## `make_test_config` -`AppTestHarness` creates a minimal `HassetteConfig` internally. If you need a `HassetteConfig` without the full harness — for example, to test configuration parsing logic directly — use `make_test_config`: +`make_test_config` builds a `HassetteConfig` without a TOML file, env file, or CLI args. Only the values passed are read. Pydantic validation still runs. ```python --8<-- "pages/testing/snippets/testing_make_test_config.py" ``` -`make_test_config` reads nothing from TOML files, env vars, or the CLI — only the values you pass are used. Pydantic validation still runs. +`AppTestHarness` creates a config internally. `make_test_config` covers tests that need a `HassetteConfig` directly without the full harness, such as config parsing or validation logic. -`data_dir` is **required** — pass a `tmp_path` fixture value in pytest. All other fields have test-appropriate defaults: +`data_dir` is required. All other fields have test-appropriate defaults: | Field | Default | |-------|---------| -| `data_dir` | **required — no default** | +| `data_dir` | required (no default) | | `token` | `"test-token"` | | `base_url` | `"http://test.invalid:8123"` | | `disable_state_proxy_polling` | `True` | -| `app` | `{"autodetect": False}` | +| `apps` | `{"autodetect": False}` | | `web_api` | `{"run": False}` | | `run_app_precheck` | `False` | -Pass `**overrides` to replace any of the defaults. +`**overrides` replace any of the defaults. ## RecordingApi Coverage Boundary -`RecordingApi` stubs write methods and delegates state reads to the seeded `StateProxy`. Anything that requires a live HA connection raises `NotImplementedError`. +`RecordingApi` records write-method calls and delegates read methods to the seeded `StateProxy`. Methods requiring a live HA connection raise `NotImplementedError`. -**Explicit stubs** (raise `NotImplementedError` directly): +**Explicit stubs** that raise `NotImplementedError` directly: - `get_state_raw()` - `get_states_raw()` @@ -136,34 +168,38 @@ Pass `**overrides` to replace any of the defaults. - `rest_request()` - `delete_entity()` -**Redirected via `__getattr__`** (raise `NotImplementedError` with a message pointing to `get_state()`): +**Redirected via `__getattr__`** with a message pointing to `get_state()`: - `get_state_value()` - `get_state_value_typed()` - `get_attribute()` -Any other public method not explicitly defined on `RecordingApi` also falls through to `__getattr__` and raises `NotImplementedError`. +Any other public name not defined on `RecordingApi` also falls through to `__getattr__` and raises `NotImplementedError`. + +`harness.set_state()` seeds data for read methods. Read methods that delegate to `StateProxy` (`get_state()`, `get_states()`, `get_entity()`, `get_entity_or_none()`, `entity_exists()`, `get_state_or_none()`) return seeded values directly. -For all of the above, seed the data you need via `harness.set_state()` and use the read methods that delegate to `StateProxy`: `get_state()`, `get_states()`, `get_entity()`, `get_entity_or_none()`, `entity_exists()`, `get_state_or_none()`. +`harness.api_recorder.sync` is a `RecordingSyncFacade`. Write calls made via `self.api.sync.*` appear in the same `api_recorder.calls` list as their async counterparts. The same assertion API works for both: + +```python +--8<-- "pages/testing/snippets/testing_sync_facade.py" +``` -!!! note "`api.sync` is a recording facade" - `harness.api_recorder.sync` is a `_RecordingSyncFacade` — a recording proxy, not a `Mock`. Write calls made via `self.api.sync.*` appear in the same `api_recorder.calls` list as their async counterparts and can be asserted with the same API: +Methods not covered by the sync facade raise `NotImplementedError` rather than silently succeeding. - ```python - --8<-- "pages/testing/snippets/testing_sync_facade.py" - ``` +## Internal Helpers - Methods not covered by the facade raise `NotImplementedError` rather than silently succeeding. +`hassette.test_utils.helpers` contains several helpers used internally by the test infrastructure but not exported in `__all__`: -!!! note - The list of `NotImplementedError` methods above reflects `RecordingApi` at the time this page was written. If you encounter an unexpected `NotImplementedError`, check `src/hassette/test_utils/recording_api.py` for the current state. +- `make_full_state_change_event` builds a `RawStateChangeEvent` from pre-built state dicts rather than raw values. Also available via the Tier 2 re-export at `hassette.test_utils.make_full_state_change_event`. +- `create_component_loaded_event` builds a `ComponentLoadedEvent` for a given component name. +- `create_service_registered_event` builds a `ServiceRegisteredEvent` for a given domain and service. -## Tier 2 Re-exports +`create_hassette_stub` is available from `hassette.test_utils._internal` and builds a `MagicMock` stub for web and API tests. -`hassette.test_utils` also re-exports a set of Tier 2 symbols — internal utilities used by Hassette's own test suite — for backward compatibility. These are **not in `__all__`** and may change without notice. They are not documented here. Use Tier 1 APIs (`AppTestHarness`, `RecordingApi`, the factory functions, `make_test_config`, and the drain exception types) for all end-user testing. +These are stable in practice but are not part of the documented public API. They may change without notice. ## Next Steps -- **[Quick Start](index.md)**: Back to the harness basics -- **[Time Control](time-control.md)**: Freeze and advance time for scheduler tests -- **[Concurrency & pytest-xdist](concurrency.md)**: Understand the concurrency locks +- [Testing overview](index.md): harness basics and test patterns +- [Time Control](time-control.md): freeze and advance time for scheduler tests +- [Concurrency & pytest-xdist](concurrency.md): concurrency locks and xdist isolation diff --git a/docs/pages/testing/harness.md b/docs/pages/testing/harness.md index 670982f92..f14c53544 100644 --- a/docs/pages/testing/harness.md +++ b/docs/pages/testing/harness.md @@ -1,3 +1,256 @@ # Test Harness Reference -*Stub — content coming in Phase 3.* +`AppTestHarness` wires an `App` subclass into Hassette's test infrastructure +without a live Home Assistant connection. It exposes the app's bus, scheduler, +seeded state, and a `RecordingApi` that records every API call. + +## Prerequisites + +The harness ships in the `hassette[test]` extra. [Write Your First +Test](index.md) covers installation and `pyproject.toml` setup. + +## Seeding State + +`set_state()` seeds a single entity's state into the state proxy. +`set_states()` seeds multiple entities at once. + +```python +--8<-- "pages/testing/snippets/testing_state_seeding.py" +``` + +Both methods write directly to the state proxy. No bus events fire. Handlers +do not run. + +`set_states()` accepts a plain state string or a `(state, attrs)` tuple per +entity. + +!!! warning "Seed state before simulating events" + `set_state()` does not fire bus events. It should be called before + `simulate_state_change()` for the same entity, not after. A later call + silently overwrites the state the simulation wrote. + +## Simulating Events + +Every `simulate_*` method sends an event through the bus and waits for all +triggered handlers to complete before returning. + +### State Changes + +`simulate_state_change()` publishes a `state_changed` event and drains all +handlers. + +```python +--8<-- "pages/testing/snippets/testing_simulate_state_change.py" +``` + +Typed dependency injection via `D.StateNew[T]` +([`hassette.dependencies`](../../reference/dependencies.md)) delivers the new +state as a typed object: + +```python +--8<-- "pages/testing/snippets/testing_di_state_change.py" +``` + +When `old_attrs` or `new_attrs` is omitted, `simulate_state_change()` merges +attributes from the state proxy automatically. Attributes seeded via +`set_state()` appear in the event without being passed again. + +### Attribute Changes + +`simulate_attribute_change()` changes one attribute while keeping the entity's +state value. It delegates to `simulate_state_change()` internally. + +```python +--8<-- "pages/testing/snippets/testing_simulate_attribute_change.py" +``` + +State value resolution order: the explicit `state=` argument, the value cached +in the state proxy, then `"unknown"` as a fallback. Seeding state first avoids +the fallback. + +!!! warning "Attribute changes can fire state-change handlers" + `on_state_change` handlers registered with `changed=False` fire on any + `state_changed` event, including attribute-only changes. When an app + registers such a handler, `simulate_attribute_change()` fires both it and + any `on_attribute_change` handler for the same entity. + +```python +--8<-- "pages/testing/snippets/testing_attribute_change_both_handlers.py" +``` + +### Service Calls + +`simulate_call_service()` publishes a `call_service` event and drains all +handlers. + +```python +--8<-- "pages/testing/snippets/testing_simulate_call_service.py" +``` + +`D.Domain` ([`hassette.dependencies`](../../reference/dependencies.md)) +injects the service domain into handlers the same way `D.StateNew` works for +state changes: + +```python +--8<-- "pages/testing/snippets/testing_di_call_service.py" +``` + +### Hassette Service Events + +`simulate_hassette_service_status()` fires a Hassette-internal service +lifecycle event. Convenience wrappers cover the common cases: + +| Method | Status | `ready` | +|---|---|---| +| `simulate_hassette_service_ready(resource_name)` | `RUNNING` | `True` | +| `simulate_hassette_service_started(resource_name)` | `RUNNING` | `False` | +| `simulate_hassette_service_failed(resource_name)` | `FAILED` | `False` | +| `simulate_hassette_service_crashed(resource_name)` | `CRASHED` | `False` | + +```python +--8<-- "pages/testing/snippets/testing_simulate_service_failure.py" +``` + +`simulate_hassette_service_status()` accepts `previous_status`, `exception`, +and `role` for cases the convenience wrappers do not cover. + +### Timeouts + +All `simulate_*` methods default to a 2-second drain timeout. The `timeout=` +parameter overrides this per call. + +```python +--8<-- "pages/testing/snippets/testing_simulate_timeout.py" +``` + +The drain mechanism waits until both the bus dispatch queue and the app's task +bucket are quiescent. + +| Exception | Meaning | +|---|---| +| `DrainTimeout` | Handlers did not finish within the deadline | +| `DrainError` | One or more handlers raised an exception | +| `DrainFailure` | Base class; catches both of the above | + +When tasks include debounce handlers, the `timeout=` value should exceed the +debounce window. [Concurrency](concurrency.md) covers task chain draining in +detail. + +## Asserting API Calls + +`harness.api_recorder` exposes a `RecordingApi` that records every call the +app makes through `self.api`: `turn_on`, `turn_off`, `call_service`, +`set_state`, `fire_event`, and all helper CRUD methods. + +### assert_called + +`assert_called(method, **kwargs)` passes when at least one recorded call +matches every specified key-value pair. Extra kwargs in the recorded call are +allowed (partial match). + +```python +--8<-- "pages/testing/snippets/testing_assert_called.py" +``` + +`turn_on`, `turn_off`, and `toggle_service` record under their own names, not +under `call_service`: + +```python +--8<-- "pages/testing/snippets/testing_assert_turn_on_off.py" +``` + +### assert_not_called + +`assert_not_called(method, **kwargs)` raises `AssertionError` when a matching +call exists. With `kwargs`, only calls whose recorded kwargs include all given +pairs count as a violation. + +```python +--8<-- "pages/testing/snippets/testing_assert_not_called.py" +``` + +### assert_call_count + +`assert_call_count(method, count, **kwargs)` raises `AssertionError` when the +method was not called exactly `count` times. With `kwargs`, only matching +calls are counted. + +```python +--8<-- "pages/testing/snippets/testing_assert_call_count.py" +``` + +### get_calls + +`get_calls(method)` returns a list of `ApiCall` records for the named method. +Each `ApiCall` has `method`, `args`, and `kwargs` fields. Omitting `method` +returns all recorded calls. + +```python +--8<-- "pages/testing/snippets/testing_get_calls.py" +``` + +### reset + +`reset()` clears all recorded calls and resets helper definitions. Mid-test +isolation is the primary use case: asserting separately on two distinct phases +within one test. + +```python +--8<-- "pages/testing/snippets/testing_recorder_reset.py" +``` + +`reset()` replaces the `calls` list with a new empty list. Any snapshot taken +before the reset (e.g., `saved = harness.api_recorder.calls`) retains the +original calls. + +## Testing Configuration Errors + +`AppConfigurationError` raises during `async with AppTestHarness(...)` entry +when the `config` dict fails validation. The `async with` body never runs. + +```python +--8<-- "pages/testing/snippets/testing_app_configuration_error.py" +``` + +| Attribute | Type | Description | +|---|---|---| +| `app_cls` | `type[App]` | The `App` class whose config failed | +| `original_error` | `pydantic.ValidationError` | The underlying Pydantic error | + +## Testing Startup Failures + +When `on_initialize()` raises, the harness startup times out with a plain +`TimeoutError`. This is distinct from `DrainTimeout`, which only surfaces from +`simulate_*` methods. The exception from `on_initialize()` appears in the log +output. + +## Harness Constructor and Properties + +### Constructor + +```python +--8<-- "pages/testing/snippets/testing_constructor.py" +``` + +| Parameter | Type | Description | +|---|---|---| +| `app_cls` | `type[App]` | The `App` subclass to test | +| `config` | `dict[str, Any]` | Config values validated against the app's `AppConfig` | +| `tmp_path` | `Path \| None` | Directory for Hassette data. Auto-created and cleaned up if omitted. | + +### Properties + +| Property | Type | Description | +|---|---|---| +| `harness.app` | `App` | Fully initialized app instance | +| `harness.bus` | `Bus` | Test bus owned by the app | +| `harness.scheduler` | `Scheduler` | Test scheduler owned by the app | +| `harness.api_recorder` | `RecordingApi` | Records every API call the app makes | +| `harness.states` | `StateManager` | State manager owned by the app | + +## Next Steps + +- [Time Control](time-control.md) — freeze time and trigger scheduled jobs +- [Concurrency & pytest-xdist](concurrency.md) — parallel test execution and + drain failure details +- [Factories & Internals](factories.md) — build custom state dicts and event objects diff --git a/docs/pages/testing/index.md b/docs/pages/testing/index.md index 6fa8030ca..19496054e 100644 --- a/docs/pages/testing/index.md +++ b/docs/pages/testing/index.md @@ -1,12 +1,15 @@ -# Testing Your Apps +# Write Your First Test -Hassette ships with `hassette.test_utils` — a set of utilities for testing your automations in isolation, without a running Home Assistant instance. You can simulate state changes, inspect API calls your app makes, and control time for scheduler tests. +Hassette ships a test harness that runs your app without a live HA instance. Simulate events, assert API calls, control time. -The core idea: `AppTestHarness` runs your app against a test-grade Hassette environment with a `RecordingApi` in place of a live HA connection. When your app calls `self.api.turn_on()`, `self.api.call_service()`, or any other API method, `RecordingApi` records those calls instead of contacting Home Assistant — you then assert on the recorder via `harness.api_recorder`. +## What You'll Learn -## Installation +- Set up the test harness +- Seed entity state before a test +- Simulate a state change through the bus +- Assert your app called the right service -`hassette.test_utils` is part of the main `hassette` package — no extra install required. You only need to add your test runner: +## Install ```bash --8<-- "pages/testing/snippets/testing_install_pip.sh" @@ -18,226 +21,69 @@ Or with uv: --8<-- "pages/testing/snippets/testing_install_uv.sh" ``` -Add this to your `pyproject.toml` to configure pytest-asyncio: +Add this to your `pyproject.toml`: ```toml --8<-- "pages/testing/snippets/testing_asyncio_mode.toml" ``` -With `asyncio_mode = "auto"`, any `async def test_*` function is automatically treated as an async test — no `@pytest.mark.asyncio` decorator required. If you skip this config, your async tests will silently succeed **without actually running** — a silent false-green failure mode. The examples on this page assume `asyncio_mode = "auto"` is set. +`asyncio_mode = "auto"` tells pytest-asyncio to treat every `async def test_*` function as an async test. Without it, async tests silently pass without running. This is the most common cause of false-green test suites. -## Quick Start - -Here's a complete test for an app that turns on a light when motion is detected: - -!!! note "Replace the placeholders with your own app" - Replace `MotionLights` with your app class and `motion_lights` with your module path. The config keys (`motion_entity`, `light_entity`) should match the fields on your app's `AppConfig` subclass. +## Write the Test ```python --8<-- "pages/testing/snippets/testing_quick_start.py" ``` -After `async with`, the app is fully initialized and ready to receive events. The harness tears everything down cleanly when the `async with` block exits. - -## The Test Harness - -`AppTestHarness` wires your app class into a test-grade Hassette environment with a `RecordingApi` instead of a live HA connection. - -### Constructor - -```python ---8<-- "pages/testing/snippets/testing_constructor.py" -``` - -| Parameter | Description | -|-----------|-------------| -| `app_cls` | Your `App` subclass to test. | -| `config` | Dict of config values. Keys must match the fields defined on your app's `AppConfig` subclass (see [App Configuration](../core-concepts/apps/configuration.md)). Invalid or missing fields raise `AppConfigurationError` during harness setup. | -| `tmp_path` | Optional directory for Hassette data files. Created and cleaned up automatically if omitted. In pytest, pass the built-in `tmp_path` fixture to share a directory across multiple harnesses in one test. | - -### Properties - -Once inside the `async with` block, the harness exposes: - -| Property | Type | Description | -|----------|------|-------------| -| `harness.app` | `App` | The fully initialized app instance. | -| `harness.bus` | `Bus` | The test bus your app registered handlers on. | -| `harness.scheduler` | `Scheduler` | The test scheduler your app registered jobs on. | -| `harness.api_recorder` | `RecordingApi` | Records every API call your app makes. Use this for assertions. | -| `harness.states` | `StateManager` | The state manager your app reads from. | - -## State Seeding - -Before simulating events, seed the state of any entities your app reads. Use `set_state()` for a single entity or `set_states()` for multiple at once. - -```python ---8<-- "pages/testing/snippets/testing_state_seeding.py" -``` - -`set_states()` accepts either a plain state string or a `(state, attributes)` tuple. - -!!! warning "`set_state()` does not fire bus events" - `set_state()` is for pre-test setup only. It writes directly to the state proxy without publishing a `state_changed` event, so **no handlers will fire**. Do not use `set_state()` mid-test to simulate a state transition — use [`simulate_state_change()`](#simulating-events) instead. - - A second hazard: calling `set_state()` *after* a `simulate_state_change()` for the same entity will silently overwrite the simulated state with the seeded value, which can make subsequent reads return wrong values. Seed first, simulate second. - -## Simulating Events - -If your handler reads entity state during handling (e.g., `self.states.light.get("light.kitchen")`), seed it first with [`harness.set_state()`](#state-seeding). Simulating an event does not update the state proxy automatically unless your handler writes back via the API. - -### State changes - -`simulate_state_change()` publishes a `state_changed` event through the bus and waits for all triggered handlers to finish before returning. - -```python ---8<-- "pages/testing/snippets/testing_simulate_state_change.py" -``` - -### Attribute changes - -`simulate_attribute_change()` simulates a change to a single attribute while keeping the state value the same. - -```python ---8<-- "pages/testing/snippets/testing_simulate_attribute_change.py" -``` - -The generated event carries the entity's current cached state for the `state` field. If you haven't seeded the entity with `set_state()` first, that field defaults to `"unknown"` — which silently breaks any state-conditional predicates on the same entity. You can pass an explicit `state=` to avoid this, as shown above. - -!!! warning "`simulate_attribute_change` can also fire state-change handlers" - This method delegates to `simulate_state_change` under the hood. With the default `changed=True`, state-change handlers do **not** fire (the state value is unchanged). But if your app registers `on_state_change` with `changed=False`, that handler **will** fire — matching HA's real behavior where `state_changed` events fire even when only attributes change: - - ```python - --8<-- "pages/testing/snippets/testing_attribute_change_both_handlers.py" - ``` - - Use `harness.api_recorder.reset()` between simulate calls, or `get_calls()` for targeted inspection, to isolate which handler made which API call. - -### Service call events - -`simulate_call_service()` publishes a `call_service` event, useful for apps that listen for HA service calls. - -```python ---8<-- "pages/testing/snippets/testing_simulate_call_service.py" -``` - -### Timeouts and slow handlers - -All three simulate methods wait for dispatched handlers to finish before returning. The default timeout is **2 seconds**. Override it with the `timeout=` parameter: +**`AppTestHarness`** runs your app against a test environment. `RecordingApi` replaces the live HA connection and records every API call your app makes. You assert on those recordings via `harness.api_recorder`. ```python ---8<-- "pages/testing/snippets/testing_simulate_timeout.py" +async with AppTestHarness( + MotionLights, + config={"motion_entity": "binary_sensor.hallway", "light_entity": "light.hallway"}, +) as harness: ``` -!!! note "Task chains drain to completion" - The drain is iterative: after the bus dispatch queue clears, any tasks spawned by `self.task_bucket.spawn(...)` inside a handler are awaited in turn, to arbitrary depth. `simulate_*` does not return until the full chain is settled. If a task raises or the drain times out, a `DrainFailure` subclass is raised — see [DrainFailure Exception Hierarchy](concurrency.md#drainfailure-exception-hierarchy) for the full exception hierarchy and catch patterns. +The `async with` block handles the full app lifecycle. Inside it, `on_initialize()` has run and the app is ready to receive events. When the block exits, the harness tears everything down. -### Typed dependency injection in handlers - -Hassette handlers support typed dependency injection via `D.*` annotations. These work seamlessly with `simulate_*` — the harness dispatches the same event objects that production code receives, so DI resolution runs identically. - -**State change with `D.StateNew`** — extract a typed state model from the event: +**`simulate_state_change()`** publishes a `state_changed` event through the bus and waits for all triggered handlers to finish before returning. ```python ---8<-- "pages/testing/snippets/testing_di_state_change.py" +await harness.simulate_state_change("binary_sensor.hallway", old_value="off", new_value="on") ``` -**Service call with `D.Domain`** — extract the service domain from the event: - -```python ---8<-- "pages/testing/snippets/testing_di_call_service.py" -``` - -### Hassette service events - -`simulate_hassette_service_status()` and its convenience wrappers (`simulate_hassette_service_failed`, `simulate_hassette_service_crashed`, `simulate_hassette_service_started`) let you test how your app responds to internal service lifecycle changes. - -```python ---8<-- "pages/testing/snippets/testing_simulate_service_failure.py" -``` - -## Asserting API Calls - -`harness.api_recorder` records every call your app makes to `self.api`. Use it to assert that your app called the right services. - -### `assert_called` - -Passes if the method was called at least once with kwargs that match **all** specified values (partial matching — additional kwargs in the recorded call are allowed). +**`harness.api_recorder.assert_called()`** checks that your app called the named method at least once with the given kwargs. Extra kwargs in the recorded call are allowed, so you only need to specify what you care about. ```python ---8<-- "pages/testing/snippets/testing_assert_called.py" +harness.api_recorder.assert_called("turn_on", entity_id="light.hallway") ``` -!!! note "`turn_on`, `turn_off`, and `toggle_service` record under their own names" - These convenience methods record calls using their own method name — not `call_service`. Assert them directly: - - ```python - --8<-- "pages/testing/snippets/testing_assert_turn_on_off.py" - ``` - - Use `assert_called("call_service", ...)` only for direct `self.api.call_service(...)` calls. - -### `assert_not_called` +If your handler reads entity state during handling, seed it first with `harness.set_state()` before simulating the event. `set_state()` writes directly to the state proxy without publishing a bus event, so no handlers fire. Seed before you simulate. ```python ---8<-- "pages/testing/snippets/testing_assert_not_called.py" +await harness.set_state("binary_sensor.hallway", "off") +await harness.simulate_state_change("binary_sensor.hallway", old_value="off", new_value="on") ``` -### `assert_call_count` +## Run It -```python ---8<-- "pages/testing/snippets/testing_assert_call_count.py" ``` - -### `get_calls` - -Returns a list of `ApiCall` records, optionally filtered by method name. Each `ApiCall` has `method`, `args`, and `kwargs` attributes. - -```python ---8<-- "pages/testing/snippets/testing_get_calls.py" +pytest test_my_app.py -v ``` -### `reset` +Expected output: -Clears all recorded calls. Useful when you want to assert on calls made after a specific point in your test. - -```python ---8<-- "pages/testing/snippets/testing_recorder_reset.py" ``` +collected 1 item -## Configuration Errors +test_my_app.py::test_light_turns_on_when_motion_detected PASSED -If the `config` dict you pass to `AppTestHarness` fails validation against your app's `AppConfig` class, the harness raises `AppConfigurationError` during setup — the `async with` body is never entered, so your test code inside the block does not run. - -```python ---8<-- "pages/testing/snippets/testing_app_configuration_error.py" +1 passed in 0.12s ``` -`AppConfigurationError` has two attributes: -- `app_cls` — the `App` class whose config failed. -- `original_error` — the underlying `pydantic.ValidationError` with full field-level detail. - -Read the error message to find which field is missing or invalid, then fix the `config` dict in your test. - -## Harness Startup Failures - -If the harness raises `TimeoutError: Timed out waiting for RUNNING`, the app's `on_initialize()` either raised an exception or took longer than 5 seconds to complete. - -!!! info "This is a bare `TimeoutError`, not `DrainTimeout`" - Harness startup timeouts are distinct from drain timeouts. The startup wait still raises a plain `TimeoutError` — catch `TimeoutError` here, not `DrainTimeout` or `DrainFailure`. Drain-related failures only happen once the harness is running and you call `simulate_*`. - -Check test output for log lines near the `TimeoutError` — exceptions raised during `on_initialize()` are caught and logged at `WARNING` level during harness cleanup, so the `TimeoutError` is the symptom, not the root cause. - -Common triggers: -- A required config field is present but its value causes a runtime error during initialization (distinct from a missing-field `AppConfigurationError` which fires before entry). -- `on_initialize()` awaits something that never resolves, such as an external call that isn't mocked. -- An `await` inside `on_initialize()` raises an exception that propagates out. - ## Next Steps -- **[Time Control](time-control.md)**: Freeze and advance time to test scheduler-driven behavior -- **[Concurrency & pytest-xdist](concurrency.md)**: Understand the same-class lock and parallel test runners -- **[Factories & Internals](factories.md)**: Event factories, state factories, `make_test_config`, and `RecordingApi` coverage boundary -- **[Bus](../core-concepts/bus/index.md)**: Event subscriptions and handler registration -- **[Scheduler](../core-concepts/scheduler/index.md)**: Timed and recurring job registration -- **[API](../core-concepts/api/index.md)**: The `self.api` methods your app calls +- [Test Harness Reference](harness.md) — full API: all simulate methods, all assert methods, error handling +- [Time Control](time-control.md) — test scheduler-driven behavior +- [Concurrency & pytest-xdist](concurrency.md) — parallel test execution +- [Factories & Internals](factories.md) — build custom test data diff --git a/docs/pages/testing/time-control.md b/docs/pages/testing/time-control.md index 1f3b23acc..23126198e 100644 --- a/docs/pages/testing/time-control.md +++ b/docs/pages/testing/time-control.md @@ -1,48 +1,52 @@ # Time Control -Test scheduler-driven behavior by freezing time and advancing it manually. +The time control API freezes the harness clock and advances it manually. This makes scheduler-driven behavior deterministic in tests. -The canonical sequence for any time-control test is: +!!! note "`whenever` is installed automatically" + Code examples on this page import from [`whenever`](https://whenever.readthedocs.io/en/latest/), Hassette's date/time library. It ships as a direct dependency of `hassette`, so no separate install is needed. + +The canonical sequence is: freeze, advance, trigger. ```python --8<-- "pages/testing/snippets/testing_time_control_sequence.py" ``` -!!! note "`whenever` is installed automatically" - Time control examples on this page import from [`whenever`](https://whenever.readthedocs.io/en/latest/) — Hassette's date/time library. It's a direct dependency of `hassette`, so it's installed automatically. No separate install needed. +`freeze_time` pins the clock at a known point. `advance_time` moves it forward. `trigger_due_jobs` fires every job whose scheduled time is at or before the frozen clock. ## `freeze_time(instant)` -Freezes `hassette.utils.date_utils.now` at the given time. Accepts an `Instant` or `ZonedDateTime` from the [`whenever`](https://whenever.readthedocs.io/en/latest/) library. No stdlib `datetime` — the scheduler uses `whenever` types throughout. +`freeze_time` patches `hassette.utils.date_utils.now` to return a fixed time. The `instant` parameter accepts an `Instant` or `ZonedDateTime` from [`whenever`](https://whenever.readthedocs.io/en/latest/). ```python --8<-- "pages/testing/snippets/testing_freeze_time.py" ``` -`freeze_time` is idempotent — calling it again replaces the frozen time. The clock is automatically unfrozen when the `async with` block exits. +Calling `freeze_time` again replaces the frozen time. The old patchers stop and new ones start. The clock unfreezes automatically when the harness `async with` block exits. -## `advance_time` +## `advance_time(*, seconds, minutes, hours)` -Advances the frozen clock by the given delta. +`advance_time` moves the frozen clock forward by the given delta. The `seconds`, `minutes`, and `hours` keywords combine in a single call. ```python --8<-- "pages/testing/snippets/testing_advance_time.py" ``` -!!! warning "`advance_time` alone has no effect on scheduled jobs" - Moving the clock forward does not trigger any jobs. You must call `trigger_due_jobs()` explicitly after advancing time — otherwise jobs accumulate silently and your assertions will fail. +!!! warning "`advance_time` does not trigger jobs" + Advancing the clock does not dispatch any scheduled jobs. Call `trigger_due_jobs()` explicitly afterward. Without it, jobs accumulate silently and assertions on side effects fail. -## `trigger_due_jobs` +## `trigger_due_jobs()` -Fires all jobs whose scheduled time is at or before the current frozen time. Returns the number of jobs dispatched. +`trigger_due_jobs` fires all jobs whose scheduled time is at or before the current frozen clock. It returns the number of jobs dispatched and completed. ```python --8<-- "pages/testing/snippets/testing_trigger_due_jobs.py" ``` -Jobs re-enqueued during dispatch (repeating jobs) are not re-triggered in the same call — only the snapshot of due jobs at the moment of the call is processed. This prevents infinite loops when the clock is frozen. +`trigger_due_jobs` operates on a snapshot of due jobs taken at the moment of the call. Jobs re-enqueued during dispatch (repeating jobs) are not included in that snapshot and are not re-triggered in the same call. This prevents infinite loops when the clock is frozen. + +If dispatched jobs send events through the bus, downstream handler tasks are spawned but not drained by `trigger_due_jobs`. A `simulate_*` call or `_drain_task_bucket` call afterward drains those handler tasks before assertions run. ## Next Steps -- **[Concurrency & pytest-xdist](concurrency.md)**: Understand how the time-control lock interacts with parallel test runners -- **[Quick Start](index.md)**: Back to the harness basics +- **[Concurrency & pytest-xdist](concurrency.md)**: time-control lock interaction with parallel test workers +- **[Testing index](index.md)**: harness overview and setup From 11abe207cf9fee22c0f19641308cc7d8cfeadf68 Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Tue, 2 Jun 2026 22:20:46 -0500 Subject: [PATCH 052/160] docs: rewrite Recipes section (6 pages, motion-lights exemplar unchanged) --- docs/pages/recipes/daily-notification.md | 64 +++++++++++++------ docs/pages/recipes/debounce-sensor-changes.md | 42 +++++++++--- docs/pages/recipes/index.md | 27 +++----- docs/pages/recipes/sensor-threshold.md | 48 +++++++++----- docs/pages/recipes/service-call-reaction.md | 42 ++++++++---- docs/pages/recipes/vacation-mode-toggle.md | 42 ++++++++---- 6 files changed, 181 insertions(+), 84 deletions(-) diff --git a/docs/pages/recipes/daily-notification.md b/docs/pages/recipes/daily-notification.md index 337b14e14..ce6dc6f3d 100644 --- a/docs/pages/recipes/daily-notification.md +++ b/docs/pages/recipes/daily-notification.md @@ -1,6 +1,6 @@ # Daily Notification -Send a push notification to a mobile device at a configurable time each day. Drop this in to get a morning greeting, a reminder, or any fixed-schedule alert without touching Home Assistant automations. +You want a push notification at the same time every day. A morning greeting, a weather briefing, a reminder. Home Assistant's notify services already handle delivery. This recipe schedules the call from Python, without touching HA automations. ## The Code @@ -10,32 +10,60 @@ Send a push notification to a mobile device at a configurable time each day. Dro ## How It Works -- **`DailyNotificationConfig`** defines three env-backed fields: the time as a `"HH:MM"` string, the notify service name, and the message body — all overridable without changing code. -- **`on_initialize`** calls `self.scheduler.run_daily(...)` with `at=self.app_config.notify_time`, scheduling a wall-clock-aligned daily job. The `Daily` trigger is cron-backed and handles DST transitions correctly. -- **`send_notification`** calls `self.api.call_service("notify", , ...)` — the domain is `notify` and the service name is the part after `notify.` in your Home Assistant instance (e.g., `mobile_app_phone`). -- Extra keyword arguments to `call_service` (`message`, `title`) become `service_data` fields forwarded to Home Assistant. +`DailyNotificationConfig` defines three fields: the wall-clock time as an `"HH:MM"` string, the notify service name, and the message body. All three carry defaults and can be overridden per instance in `hassette.toml`. The `env_prefix` means environment variables (`DAILY_NOTIFICATION_NOTIFY_TIME`, etc.) override config file values. + +`on_initialize` calls `self.scheduler.run_daily(self.send_notification, at=...)` with the configured time string. `run_daily` registers a `Daily` trigger that aligns to wall-clock time and handles DST transitions. The notification fires at 08:00 local time year-round, not at a fixed UTC offset. + +`send_notification` calls `self.api.call_service("notify", self.app_config.notify_service, ...)`. The first argument is the HA domain (`notify`). The second is the service name, the part after `notify.` in the HA instance. For `notify.mobile_app_phone`, pass `"mobile_app_phone"`. Extra keyword arguments (`message`, `title`) become `service_data` fields forwarded to Home Assistant. + +## Verify It's Working + +Confirm the job is registered immediately after startup: + +``` +hassette job --app daily_notification +``` + +Expected output: + +``` +daily_notification (instance 0) + send_notification next: 2026-06-03 08:00:00 local trigger: daily@08:00 +``` + +After the scheduled time fires, check the log to confirm delivery: + +``` +hassette log --app daily_notification --since 1d +``` + +Expected output includes two lines: + +``` +INFO Daily notification scheduled at 08:00 via notify.mobile_app_phone +INFO Daily notification sent. +``` + +If the second line is missing, check the HA logs for a failed service call. ## Variations -**Different time** — Change `notify_time` in your config: +**Different time.** Change `notify_time` in `hassette.toml`: -```yaml -# apps.yaml -daily_notification: - module: daily_notification - class: DailyNotificationApp - notify_time: "20:30" # 8:30 PM - notify_service: mobile_app_tablet - message: "Time to wind down." +```toml +[apps.daily_notification] +notify_time = "20:30" +notify_service = "mobile_app_tablet" +message = "Time to wind down." ``` -**Include sensor data** — Fetch a sensor value before sending: +**Include sensor data.** Fetch a live sensor value before sending. `get_state` returns the current entity state; `.value` holds the state string: ```python --8<-- "pages/recipes/snippets/daily_notification_handler.py:send_notification" ``` -**Weekdays only** — Swap `run_daily` for `run_cron` to skip weekends: +**Weekdays only.** Swap `run_daily` for `run_cron` to skip weekends. `run_cron` accepts a standard cron expression. The fragment below derives the cron fields from the `"HH:MM"` config string: ```python --8<-- "pages/recipes/snippets/daily_notification_handler.py:cron_parse" @@ -43,5 +71,5 @@ daily_notification: ## See Also -- [Scheduler Methods](../core-concepts/scheduler/methods.md) — `run_daily`, `run_cron`, and all scheduling options -- [API Services](../core-concepts/api/index.md) — `call_service` and other Home Assistant API methods +- [Scheduler Methods](../core-concepts/scheduler/methods.md), covering `run_daily`, `run_cron`, and all scheduling options +- [API Overview](../core-concepts/api/index.md), covering `call_service` and other Home Assistant API methods diff --git a/docs/pages/recipes/debounce-sensor-changes.md b/docs/pages/recipes/debounce-sensor-changes.md index cac344957..b8d07d5ea 100644 --- a/docs/pages/recipes/debounce-sensor-changes.md +++ b/docs/pages/recipes/debounce-sensor-changes.md @@ -1,6 +1,6 @@ # Debounce Sensor Changes -Sensors like temperature or humidity often emit bursts of near-identical readings. This recipe waits until a value has been stable for a set period before reacting, and only fires when the temperature has increased and crossed a threshold. +Your outdoor temperature sensor reports a reading every few seconds. On a warm afternoon, it may emit a dozen near-identical values before settling. Reacting to each one produces redundant log entries and wasted service calls. This recipe waits until the sensor has been quiet for a set period before acting. It only fires when the temperature has risen. ## The Code @@ -10,19 +10,41 @@ Sensors like temperature or humidity often emit bursts of near-identical reading ## How It Works -- **`debounce=10.0`** — the handler is not called until the sensor has been quiet for 10 seconds. Any new event during that window resets the timer, so rapid fluctuations are silently discarded. -- **`changed=C.Increased()`** — the debounce timer only starts for events where the new value is numerically greater than the old one. Decreases and unchanged readings never queue the handler. -- The handler uses **dependency injection** (`D.StateNew[states.SensorState]`) to receive the new state as a typed object. The value is converted to a float before comparing to `THRESHOLD`. -- When the temperature is at or above the threshold after stabilising, a single log line is emitted. -- Adjust `THRESHOLD`, `DEBOUNCE_SECONDS`, and the entity ID for your sensor. +`debounce=10.0` tells the bus to hold the handler until the sensor has been quiet for 10 seconds. Each new event that arrives during that window resets the timer. Rapid fluctuations are silently discarded, and the handler fires exactly once when the readings stop. + +`changed=C.Increased()` gates which events start the debounce timer in the first place. `C` is an alias for [`hassette.event_handling.conditions`](../../reference/event_handling/conditions.md), a module of value-comparison predicates. `C.Increased()` passes only when the new state value is numerically greater than the old one. Drops and unchanged readings never start the timer. + +`D.StateNew[states.SensorState]` is a dependency injection annotation. `D` is an alias for [`hassette.event_handling.dependencies`](../../reference/event_handling/dependencies.md), a module of type annotations that tell Hassette what to extract from each event. Hassette reads the annotation, extracts the new state from the event, and converts it to a `SensorState` object. The handler receives the typed state and converts `.value` to a float before comparing it to `THRESHOLD`. + +When the stabilized temperature meets or exceeds `THRESHOLD`, a log line records the crossing, the previous value, and the debounce duration. + +`THRESHOLD`, `DEBOUNCE_SECONDS`, and the entity ID are module-level constants. Swapping them or promoting them to `AppConfig` fields covers multiple sensors with a single class. + +## Verify It's Working + +Confirm the handler registered after startup: + +``` +hassette listener --app debounce_sensor +``` + +Expected output shows one listener named `outdoor_temp_debounced` with `debounce=10.0` and `changed=Increased`. + +After the sensor emits a rising reading and 10 seconds of quiet pass, check invocations: + +``` +hassette log --app debounce_sensor --since 5m +``` + +The log shows one entry per stabilized crossing, not one per raw sensor event. If the sensor fluctuated three times during the quiet window, only the final stable value appears. ## Variations -**Use throttle instead of debounce.** If you want to log at most once every 30 seconds regardless of how many events arrive, replace `debounce=10.0` with `throttle=30.0`. Throttle fires on the first event and then suppresses the rest for the window; debounce waits for a quiet period before firing. +**Throttle instead of debounce.** `throttle=30.0` fires on the first matching event and suppresses the rest for 30 seconds. Debounce waits for quiet; throttle fires immediately then goes silent. The two parameters are mutually exclusive: passing both raises a `ValueError` at registration time. -**Watch a different sensor.** Swap `"sensor.outdoor_temperature"` for any numeric sensor — humidity (`sensor.living_room_humidity`), CO₂ (`sensor.co2_level`), or power draw (`sensor.solar_inverter_power`). Adjust `THRESHOLD` to match the units. +**Different sensor types.** Swap `sensor.outdoor_temperature` for any numeric sensor and adjust `THRESHOLD` to match the units. Humidity (`sensor.living_room_humidity`), CO₂ (`sensor.co2_level`), and power draw (`sensor.solar_inverter_power`) all work the same way. ## See Also -- [Bus Overview](../core-concepts/bus/index.md) — how subscriptions work and what methods are available. -- [Filtering & Advanced Subscriptions](../core-concepts/bus/filtering.md) — full reference for `changed`, `C.Increased`, and other conditions. +- [Bus Overview](../core-concepts/bus/index.md). Subscription model and available methods. +- [Filtering](../core-concepts/bus/filtering.md). Full reference for `changed`, `C.Increased`, debounce, and throttle. diff --git a/docs/pages/recipes/index.md b/docs/pages/recipes/index.md index ed192a9d8..325195ec8 100644 --- a/docs/pages/recipes/index.md +++ b/docs/pages/recipes/index.md @@ -1,24 +1,15 @@ # Recipes -Recipes are complete, working automations that solve common Home Assistant tasks. Each one is a self-contained app you can copy, update the entity IDs for your setup, and run. They are not step-by-step tutorials — they are starting points. +Copy-paste-ready automations, organized by problem. -## Recipes - -**[Motion-Activated Lights](motion-lights.md)** — Turn lights on when a motion sensor triggers and off again after a configurable delay. - -**[Daily Notification](daily-notification.md)** — Send a push notification to a mobile device at a specific time each day. - -**[Debounce Sensor Changes](debounce-sensor-changes.md)** — Avoid reacting to rapid sensor fluctuations by waiting until a value has been stable for a set period. - -**[React to a Service Call](service-call-reaction.md)** — Intercept a Home Assistant service call and run custom logic in response. - -**[Monitor Sensor Thresholds](sensor-threshold.md)** — Alert when a sensor value crosses a configured limit, with hysteresis to prevent alert storms. - -**[Vacation Mode Toggle](vacation-mode-toggle.md)** — Use a Home Assistant input boolean helper to switch app behavior on and off without redeploying. - -Each recipe is a complete, working app. Copy the code, update the entity IDs for your setup, and run it. +- **[Motion-Activated Lights](motion-lights.md)**. Lights that respond to motion and turn themselves off after a quiet period. +- **[Debounce Sensor Changes](debounce-sensor-changes.md)**. Sensors that report noisy, rapid-fire readings you want to ignore until a value stabilizes. +- **[Monitor Sensor Thresholds](sensor-threshold.md)**. A reading crosses a limit and you need to act on it immediately. +- **[Daily Notification](daily-notification.md)**. Recurring tasks that need to fire at the same time every day. +- **[React to a Service Call](service-call-reaction.md)**. You want to run custom logic whenever a specific HA service is called. +- **[Vacation Mode Toggle](vacation-mode-toggle.md)**. Changing app behavior at runtime without redeploying. ## See Also -- [Core Concepts](../core-concepts/index.md) — the building blocks all recipes use: Bus, Scheduler, API, and States. -- [Getting Started](../getting-started/index.md) — installation and your first app. +- [Core Concepts](../core-concepts/index.md). The building blocks all recipes use: Bus, Scheduler, API, and States. +- [Getting Started](../getting-started/index.md). Installation and your first app. diff --git a/docs/pages/recipes/sensor-threshold.md b/docs/pages/recipes/sensor-threshold.md index f51c6c8d5..5ace54a78 100644 --- a/docs/pages/recipes/sensor-threshold.md +++ b/docs/pages/recipes/sensor-threshold.md @@ -1,31 +1,49 @@ # Monitor Sensor Thresholds -Send a notification whenever a sensor value rises above a configured limit — useful for temperature, humidity, CO2, or any numeric sensor in Home Assistant. +Temperature spikes, humidity creeps, CO2 builds up. Numeric sensors in Home Assistant report values continuously. You want a notification when a value crosses a limit, but only once per crossing. Not a flood while the value stays high. -## The code +## The Code ```python --8<-- "pages/recipes/snippets/sensor_threshold.py" ``` -## How it works +## How It Works -- **Typed config** — `ThresholdConfig` exposes `entity_id`, `threshold`, and `notify_target` as environment-backed settings. Override any of them per-instance without touching the code. -- **Threshold filter** — `C.Comparison("gt", threshold)` is passed to `changed_to`, so the handler only fires when the new state value is greater than the configured limit. Events below the threshold are dropped before the handler runs. -- **DI extraction** — `D.StateNew[states.SensorState]` gives a typed state object. `D.EntityId` provides the entity ID as a plain string for logging. -- **Attributes** — `new_state.attributes.unit_of_measurement` and `friendly_name` are read directly from the typed model, keeping the notification message readable without manual string parsing. -- **Notification** — `api.call_service("notify", ...)` sends the alert via any Home Assistant notify target (mobile app, persistent notification, etc.). +`ThresholdConfig` exposes `entity_id`, `threshold`, and `notify_target` as settings. Each instance reads its own values from `hassette.toml`. The same app class watches different sensors in different rooms without code changes. + +`C.Comparison("gt", threshold)` passed to `changed_to` acts as a gate. `C` is an alias for [`hassette.event_handling.conditions`](../core-concepts/bus/filtering.md), a module of value-comparison functions. The bus evaluates the condition before invoking the handler. Events where the new state value is not greater than the threshold are dropped. The handler fires only on the crossing itself, not on every subsequent reading above the limit. + +`D` is an alias for [`hassette.event_handling.dependencies`](../core-concepts/bus/dependency-injection.md), a module of type annotations that tell Hassette what to extract from each event. `D.StateNew[states.SensorState]` delivers the new state as a typed object. `D.EntityId` delivers the entity ID as a plain string. Hassette resolves both from the event automatically. The handler declares what it needs, and the framework fills it in. + +`api.call_service("notify", ...)` sends the alert. `new_state.attributes.unit_of_measurement` and `new_state.attributes.friendly_name` come directly from the typed model, so the message reads naturally without manual attribute dict lookups. + +## Verify It's Working + +Check that the handler registered with the threshold condition: + +``` +hassette listener --app +``` + +After a threshold crossing, confirm the handler fired: + +``` +hassette log --app --since 1h +``` + +The log shows the warning line with the sensor name, value, and unit. ## Variations -**Lower threshold (below-limit alert):** Change `"gt"` to `"lt"` to alert when the value drops below the limit — for example, alerting when battery level or water pressure falls too low. +**Below-limit alert.** Change `"gt"` to `"lt"` to alert when a value drops below the threshold. Battery level and water pressure are natural fits. Alert when either falls too low. -**Hysteresis to prevent alert storms:** Subscribe to a second listener with `changed_to=C.Comparison("le", threshold)` that sets a flag when the sensor recovers. Check the flag in `on_threshold_exceeded` and skip the notification if the sensor has not yet recovered, preventing repeated alerts while the value hovers near the threshold. +**Hysteresis.** A second listener with `changed_to=C.Comparison("le", threshold)` fires when the sensor recovers back to or below the limit. Store a flag on `self` when recovery fires, and check it in `on_threshold_exceeded` before sending. The notification skips if the sensor has not yet cleared since the last alert. -**Multiple sensors:** Register the same handler for several entities using a glob pattern (`"sensor.temp_*"`) or call `on_state_change` once per entity inside a loop over a `list[str]` config field. +**Multiple sensors.** Pass a glob pattern like `"sensor.temp_*"` to `on_state_change` to cover a group of sensors with one registration. Alternatively, loop over a `list[str]` config field and call `on_state_change` once per entity. `D.EntityId` in the handler identifies which sensor triggered each alert. -## See also +## See Also -- [Filtering](../core-concepts/bus/filtering.md) — full reference for `C.Comparison` and all other conditions -- [Dependency Injection](../core-concepts/bus/dependency-injection.md) — how `D.StateNew` and `D.EntityId` work -- [States](../core-concepts/states/index.md) — typed state models and the `SensorState` attributes +- [Filtering](../core-concepts/bus/filtering.md). Full reference for `C.Comparison` and all other conditions. +- [Dependency Injection](../core-concepts/bus/dependency-injection.md). How `D.StateNew` and `D.EntityId` work. +- [States](../core-concepts/states/index.md). Typed state models and the `SensorState` attributes. diff --git a/docs/pages/recipes/service-call-reaction.md b/docs/pages/recipes/service-call-reaction.md index a81d42a42..d5696da24 100644 --- a/docs/pages/recipes/service-call-reaction.md +++ b/docs/pages/recipes/service-call-reaction.md @@ -1,32 +1,50 @@ # React to a Service Call -Intercept a Home Assistant service call and run custom logic in response. This recipe mirrors brightness and color temperature from a primary light to an accent light whenever someone turns the primary light on. +You have a primary light and an accent light. Whenever someone adjusts the primary through Home Assistant, the accent should mirror the brightness and color temperature automatically. Subscribing to the `light.turn_on` service call lets your app intercept every adjustment the moment it happens. The source does not matter: HA dashboard, voice assistant, or another automation. -## The code +## The Code ```python --8<-- "pages/recipes/snippets/service_call_reaction.py" ``` -## How it works +## How It Works -- `on_call_service(domain="light", service="turn_on", ...)` subscribes only to `light.turn_on` calls — no other service types reach the handler. -- `P.ServiceDataWhere({"entity_id": ...})` narrows the subscription further, so the handler only fires when the call targets the configured primary light. -- The handler receives a `CallServiceEvent`. `event.payload.data.service_data` is the dict of arguments the caller passed — brightness, color temperature, transitions, and so on. -- The handler forwards whichever parameters were present to `light.turn_on` on the accent light, leaving out keys that were not set in the original call. -- Config fields (`primary_light`, `accent_light`) let you change entity IDs via environment variables (`LIGHT_GROUP_PRIMARY_LIGHT`, `LIGHT_GROUP_ACCENT_LIGHT`) without touching code. +`on_call_service(domain="light", service="turn_on")` subscribes to one specific service. Only `light.turn_on` calls reach the handler; all other services are filtered before the event leaves the bus. + +`P.ServiceDataWhere({"entity_id": self.app_config.primary_light})` narrows that subscription further. The predicate compares the `entity_id` field in the incoming call's service data against the configured primary light. Calls targeting any other entity are dropped without invoking the handler. + +The handler receives a `CallServiceEvent`, the typed representation of a Home Assistant service call. `event.payload.data.service_data` holds the dict the caller passed to `light.turn_on`. That dict contains whatever combination of `brightness`, `color_temp`, `transition`, and other parameters the caller included. The handler checks each key individually and forwards only the ones present. Keys absent from the original call stay out of the accent call. The accent light keeps its existing values for those attributes. + +`primary_light` and `accent_light` are environment-backed config fields. Changing which entities the app watches requires no code change. Set a different value in `hassette.toml` or the corresponding environment variable. + +## Verify It's Working + +Adjust the primary light from the Home Assistant dashboard, then check the app's log: + +``` +hassette log --app --since 5m +``` + +Each adjustment should produce a log line showing the brightness and color temperature the handler observed. To confirm the subscription fired and was counted, check the listener's invocation history: + +``` +hassette listener --app +``` + +The listener named `primary_light_on` should show a non-zero invocation count after each adjustment. ## Variations -**Watch any entity in a group** — replace the exact entity ID in `ServiceDataWhere` with a glob pattern: +**Watch any entity in a group.** Replace the exact entity ID with a glob pattern. The handler then fires for any light in the room: ```python --8<-- "pages/recipes/snippets/service_call_where.py:where" ``` -**React to turn-off too** — add a second subscription for `service="turn_off"` pointing to its own handler, and call `light.turn_off` on the accent light there. +**React to turn-off too.** Add a second subscription for `service="turn_off"` with its own handler, and call `light.turn_off` on the accent light there. The two subscriptions are independent. Each fires only for its own service type. ## See Also -- [Filtering & Advanced Subscriptions](../core-concepts/bus/filtering.md) — full reference for `on_call_service`, `P.ServiceDataWhere`, and `P.ServiceMatches` -- [Bus Overview](../core-concepts/bus/index.md) — subscription options, debounce, throttle, and `once` +- [Filtering and Advanced Subscriptions](../core-concepts/bus/filtering.md). Full reference for `on_call_service`, `P.ServiceDataWhere`, and `P.ServiceMatches`. +- [Bus Overview](../core-concepts/bus/index.md). Subscription options, debounce, throttle, and `once`. diff --git a/docs/pages/recipes/vacation-mode-toggle.md b/docs/pages/recipes/vacation-mode-toggle.md index c29fa0a6f..fa5e5b535 100644 --- a/docs/pages/recipes/vacation-mode-toggle.md +++ b/docs/pages/recipes/vacation-mode-toggle.md @@ -1,6 +1,6 @@ # Vacation Mode Toggle -Watch an `input_boolean` helper in Home Assistant and use its state to start and stop a presence-simulation loop — no redeployment needed to toggle the behavior. +You're heading out for a week. You want lights to flicker on and off at odd hours so the house looks occupied. When you get back, flip a switch in Home Assistant and it stops. No code changes, no restart. ## The Code @@ -10,20 +10,40 @@ Watch an `input_boolean` helper in Home Assistant and use its state to start and ## How It Works -- Two `on_state_change` subscriptions watch `input_boolean.vacation_mode` — one fires when it turns `on`, the other when it turns `off`. -- When vacation mode turns on, `run_every` schedules `simulate_presence` to run on a fixed interval, and the returned `ScheduledJob` is stored on the instance. -- Each tick, `simulate_presence` picks a random light and toggles it — on if currently off, off if currently on — to create irregular activity. -- When vacation mode turns off, the stored job is cancelled and all lights are turned off to restore a clean state. -- The entity IDs and interval are configurable through `VacationModeConfig`, so you can adjust the light list and simulation frequency without touching the code. +Two `on_state_change` subscriptions watch the same `input_boolean`. The first fires when it turns `on`; the second fires when it turns `off`. Each handler does exactly one thing, so the two paths stay independent and easy to trace. + +When vacation mode activates, `run_every` schedules `simulate_presence` to run on a fixed interval. The returned `ScheduledJob` is stored on the instance so the stop handler can cancel it later. + +Each tick, `simulate_presence` picks a random light from the configured list and reads its current state. If the light is on, it turns it off. If it is off, it turns it on. The random selection is what creates the irregular pattern. Toggling the same light at a fixed interval would look mechanical. Cycling through a random pick each time does not. + +When vacation mode deactivates, the stored job is cancelled and all configured lights are turned off. Turning off the lights explicitly restores a known state. Without that step, whatever lights happened to be on at cancellation time would stay on. + +Entity IDs and the interval come from `VacationModeConfig`. Adjusting the light list or simulation frequency is a config change in `hassette.toml`, not a code change. + +## Verify It's Working + +Toggle `input_boolean.vacation_mode` to on in the Home Assistant UI, then check the log: + +``` +hassette log --app --since 5m +``` + +The app logs when vacation mode enables and each time a light toggles. To confirm the subscriptions registered: + +``` +hassette listener --app +``` + +Both `vacation_start` and `vacation_end` listeners should appear with a non-zero invocation count after each toggle. ## Variations -**Provision the helper from code** — instead of creating `input_boolean.vacation_mode` manually in the HA UI, use `api.create_input_boolean` in `on_initialize` to provision it automatically on first run. See [Managing Helpers](../advanced/managing-helpers.md) for the idempotent-bootstrap pattern. +**Provision the helper from code.** Instead of creating `input_boolean.vacation_mode` manually in the HA UI, call `api.create_input_boolean` in `on_initialize`. The helper is created on first run and left alone on subsequent starts. See [Managing Helpers](../core-concepts/api/managing-helpers.md) for the idempotent-bootstrap pattern. -**Schedule vacation windows** — replace the manual toggle with `run_cron` entries that enable and disable presence simulation at fixed times each day (e.g., evening hours only). See [Scheduler Methods](../core-concepts/scheduler/methods.md) for cron syntax. +**Schedule vacation windows.** Replace the manual toggle with `run_cron` entries that start and stop presence simulation at fixed times each day. Evening-only windows are one common pattern. See [Scheduler Methods](../core-concepts/scheduler/methods.md) for cron syntax. ## See Also -- [Managing Helpers](../advanced/managing-helpers.md) — create and manage `input_boolean` and other helper types from your app -- [Bus](../core-concepts/bus/index.md) — `on_state_change` filtering, debounce, and throttle options -- [States](../core-concepts/states/index.md) — read entity state from the local cache without an API call +- [Managing Helpers](../core-concepts/api/managing-helpers.md). Create and manage `input_boolean` and other helper types from an app. +- [Bus](../core-concepts/bus/index.md). `on_state_change` filtering, debounce, and throttle options. +- [States](../core-concepts/states/index.md). Read entity state from the local cache without an API call. From 62afb28395063fc04c202d43b37547b4205eda4c Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Tue, 2 Jun 2026 22:27:21 -0500 Subject: [PATCH 053/160] docs: rewrite Migration section (8 pages including checklist) --- docs/pages/migration/api.md | 71 +++++--------- docs/pages/migration/bus.md | 136 +++++++++++++++----------- docs/pages/migration/checklist.md | 34 +++---- docs/pages/migration/concepts.md | 123 ++++++++++++++--------- docs/pages/migration/configuration.md | 134 +++++++++---------------- docs/pages/migration/index.md | 130 +++++++++--------------- docs/pages/migration/scheduler.md | 103 ++++++++++--------- docs/pages/migration/testing.md | 36 +++---- 8 files changed, 349 insertions(+), 418 deletions(-) diff --git a/docs/pages/migration/api.md b/docs/pages/migration/api.md index 2ab2f33ec..16ad9c767 100644 --- a/docs/pages/migration/api.md +++ b/docs/pages/migration/api.md @@ -1,62 +1,49 @@ # API Calls -This page covers how to migrate AppDaemon API access to Hassette's `self.api` and `self.states` attributes. - -## Overview - -AppDaemon provides synchronous API access via methods on `self`: `self.get_state()`, `self.call_service()`, `self.set_state()`. Responses are raw strings or dicts. - -Hassette provides two ways to access state: - -1. **`self.states`** — a local state cache that stays up-to-date via WebSocket events. This is the preferred way to read entity state and is most similar to AppDaemon's `self.get_state()` behavior. -2. **`self.api`** — direct async API calls to Home Assistant. Use this for writes (`call_service`, `set_state`) and for cases where you specifically need a fresh read from HA. - ## Getting Entity State -### AppDaemon +AppDaemon's `self.get_state()` reads from an internal cache and returns raw strings or dicts. Hassette provides two options. `self.states` is a local cache for most reads. `self.api.get_state()` forces a fresh read from Home Assistant when the cache is not enough. -AppDaemon maintains an internal cache and `self.get_state()` reads from it. You can get just the state string or the full dict with attributes: +### AppDaemon ```python --8<-- "pages/migration/snippets/api_appdaemon_get_state.py" ``` -Returns a raw dict with string values. No type safety. +Returns a raw dict. No type information. No autocomplete. ### Hassette: State Cache (recommended) -The `self.states` attribute provides immediate access to all entity states without API calls. It is updated automatically via state change events — no `await` needed: +`self.states` holds a local copy of all entity states, kept current via WebSocket events. No `await` needed. The returned object is typed to the entity domain. ```python --8<-- "pages/migration/snippets/api_hassette_states_cache.py" ``` -Access patterns: +Three access patterns: -| Pattern | What it returns | -|---------|----------------| -| `self.states.light.get("light.kitchen")` | A `LightState` object, or `None` if not found | -| `for entity_id, state in self.states.light` | Iterates over all light entities in cache | -| `self.states[states.LightState].get("light.kitchen")` | Typed access for any domain | +| Pattern | Returns | +|---|---| +| `self.states.light.get("light.kitchen")` | `LightState \| None` | +| `self.states[states.LightState].get("light.kitchen")` | `LightState \| None`, for any domain | +| `for entity_id, state in self.states.light` | Iterates all cached lights | + +Use `self.states` for any read inside a handler or scheduled task. The cache is always up-to-date. ### Hassette: Direct API Call -For cases where you need to force a fresh read from Home Assistant (rare): +For a guaranteed fresh read from Home Assistant: ```python --8<-- "pages/migration/snippets/api_hassette_get_state_api.py" ``` -!!! note "Type narrowing" - In Hassette, `get_state()` is annotated as returning `BaseState`. Use type narrowing or casting to tell the type checker the specific state type you expect. - -**When to use each approach:** - -- **`self.states`** (recommended): For reading current state in event handlers, scheduled tasks, or any time you need quick access to entity state. The cache is automatically kept up-to-date via state change events. -- **`self.api.get_state()`**: Only when you specifically need a fresh read from Home Assistant (rare) or if you're outside the normal app lifecycle. +`self.api.get_state()` hits the HA REST API and requires `await`. Use it when the cache is not reliable, such as during initialization before the first state change event. ## Calling Services +AppDaemon uses a single `domain/service` string. Hassette splits them into two arguments. + === "AppDaemon" ```python @@ -67,18 +54,14 @@ For cases where you need to force a fresh read from Home Assistant (rare): self.turn_on("light.kitchen", brightness=200) ``` - AppDaemon uses a `domain/service` string format. The call is synchronous. - === "Hassette" ```python --8<-- "pages/migration/snippets/api_hassette_call_service.py" ``` - Hassette uses separate `domain` and `service` arguments. The call is async. Helpers like `turn_on()` are also available. - !!! warning "Don't forget `await`" - Forgetting `await` on an API call returns a coroutine object instead of executing the call. If your service calls appear to do nothing, check that you have `await` on each one. + Calling `self.api.call_service()` without `await` returns a coroutine object and does nothing. If service calls appear to have no effect, check that every call site has `await`. ## Setting States @@ -96,7 +79,7 @@ For cases where you need to force a fresh read from Home Assistant (rare): ## Logging -AppDaemon provides `self.log()` and `self.error()`. Hassette uses Python's standard `logging` module via `self.logger`: +AppDaemon provides `self.log()` and `self.error()`. Hassette uses Python's standard `logging` module via `self.logger`. === "AppDaemon" @@ -112,19 +95,11 @@ AppDaemon provides `self.log()` and `self.error()`. Hassette uses Python's stand --8<-- "pages/migration/snippets/api_logging.py" ``` -The Hassette logger automatically includes the instance name, calling method, and line number in every log line. Use `%s`-style formatting rather than f-strings to defer string construction until needed. - -## Full State Migration Example - -The following example shows the complete migration of a state-reading pattern: - -```python ---8<-- "pages/migration/snippets/api_migration_getting_states.py" -``` +`self.logger` automatically includes the app instance name, calling method, and line number in every log line. Use `%s`-style formatting rather than f-strings. The string is only constructed if the log level is active. ## See Also -- [API Overview](../core-concepts/api/index.md) — the full API reference -- [Entities & States](../core-concepts/api/entities.md) — typed entity state access -- [Services](../core-concepts/api/services.md) — calling HA services -- [States](../core-concepts/states/index.md) — state cache and state models +- [States](../core-concepts/states/index.md), state cache and state models +- [Entities & States](../core-concepts/api/entities.md), typed entity state access +- [Services](../core-concepts/api/services.md), calling HA services +- [API Overview](../core-concepts/api/index.md), full API reference diff --git a/docs/pages/migration/bus.md b/docs/pages/migration/bus.md index 24b135e90..1c3352842 100644 --- a/docs/pages/migration/bus.md +++ b/docs/pages/migration/bus.md @@ -1,97 +1,117 @@ # Bus & Events -This page covers how to migrate AppDaemon event listeners and state change listeners to Hassette's event bus (`self.bus`). +This page covers migrating AppDaemon event listeners and state change listeners to Hassette's event bus (`self.bus`). -## Overview +## The `name=` Requirement -AppDaemon exposes event subscriptions as methods directly on `self`: `self.listen_state(...)`, `self.listen_event(...)`. You cancel subscriptions using a handle returned by the listen call. +Every `self.bus.on_*()` call requires a `name=` argument. Omitting it raises `ListenerNameRequiredError` at call time. Hassette uses this name for telemetry, log output, and listener deduplication across restarts. -Hassette centralizes event subscriptions on `self.bus`. Each subscription method returns a `Subscription` object. You cancel it by calling `.cancel()` on that object. +=== "Missing name (breaks)" -!!! warning "Handler constraints" - Handlers **cannot** use positional-only parameters (parameters before `/`) or variadic positional arguments (`*args`). This applies to all `self.bus` subscription methods. + ```python + # Raises ListenerNameRequiredError immediately + await self.bus.on_state_change("light.kitchen", handler=self.on_change) + ``` -!!! note "Event payload values are untyped" - Event objects are typed, but the values inside payload dicts (such as `service_data`) are `dict[str, Any]`. Use dependency injection or convert data manually to work with typed objects. +=== "With name (correct)" -## State Change Listeners + ```python + await self.bus.on_state_change("light.kitchen", handler=self.on_change, name="kitchen_light") + ``` -### AppDaemon +This is the most common cause of breakage when porting AppDaemon apps. Add `name=` to every subscription call before running the app. -In AppDaemon, `self.listen_state()` listens for state changes on an entity. Callback signatures must follow a fixed pattern: +## State Change Listeners -```python ---8<-- "pages/migration/snippets/bus_appdaemon_state_change.py" -``` +AppDaemon uses `self.listen_state()` with a fixed four-argument callback signature. Hassette uses `self.bus.on_state_change()`, which is `async` and must be awaited. Handler signatures are flexible. Annotate parameters and Hassette fills them in. + +=== "AppDaemon" -### Hassette: with Dependency Injection (recommended) + ```python + --8<-- "pages/migration/snippets/bus_appdaemon_state_change.py" + ``` -In Hassette, `self.bus.on_state_change()` is an `async` method — it must be awaited. Handler signatures are flexible — use type annotations and Hassette extracts the data for you: +=== "Hassette (dependency injection, recommended)" -```python ---8<-- "pages/migration/snippets/bus_hassette_state_change_di.py" -``` + ```python + --8<-- "pages/migration/snippets/bus_hassette_state_change_di.py" + ``` -### Hassette: with the full event object +=== "Hassette (full event)" -If you prefer to receive the raw event and inspect it yourself: + ```python + --8<-- "pages/migration/snippets/bus_hassette_state_change_event.py" + ``` -```python ---8<-- "pages/migration/snippets/bus_hassette_state_change_event.py" -``` +The dependency injection form is preferred. `D.StateNew[states.InputButtonState]` tells Hassette to extract the new state and convert it to a typed model. Your IDE knows the type; Pyright catches typos. -### Filter options +### Filter argument mapping -`on_state_change()` supports built-in filter arguments: +`on_state_change()` supports built-in filter arguments that replace AppDaemon's `new=` and `old=` kwargs: -| AppDaemon argument | Hassette equivalent | -|-------------------|---------------------| +| AppDaemon | Hassette | +|---|---| | `new="on"` | `changed_to="on"` | | `old="off"` | `changed_from="off"` | | `attribute="battery"` | Use `on_attribute_change()` instead | -For more complex filtering, pass a predicate via the `where` parameter (`where=P.StateTo('on')` for example). See the [Bus filtering docs](../core-concepts/bus/filtering.md) for the full reference. +For more complex filtering, pass a predicate via `where=`. See [Bus filtering](../core-concepts/bus/filtering.md) for the full reference. -## Service Call Listeners - -### AppDaemon +## Attribute Change Listeners -In AppDaemon, you use `self.listen_event("call_service", ...)` to monitor service calls: +AppDaemon uses `self.listen_state(..., attribute="battery")` to watch a specific attribute. Hassette has a dedicated method for this: `on_attribute_change()`. ```python ---8<-- "pages/migration/snippets/bus_appdaemon_event.py" +await self.bus.on_attribute_change( + "sensor.phone", + "battery_level", + handler=self.on_battery, + name="phone_battery", +) ``` -The callback signature must follow `(self, event_name, event_data, **kwargs)`. Extra keyword arguments you passed when subscribing arrive in `**kwargs`. +The method signature is `on_attribute_change(entity_id, attr, *, handler, name, ...)`. The `attribute=` argument on `listen_state()` maps directly to the second positional argument here. -### Hassette: with Dependency Injection (recommended) +## Service Call Listeners -Use `self.bus.on_call_service()` and annotate your handler to extract exactly the fields you need: +AppDaemon uses `self.listen_event("call_service", ...)` with a callback that receives raw dicts. Hassette uses `self.bus.on_call_service()`, which is `async` and must be awaited. -```python ---8<-- "pages/migration/snippets/bus_hassette_on_call_service_di.py" -``` +=== "AppDaemon" -Available dependency markers for service call handlers include: + ```python + --8<-- "pages/migration/snippets/bus_appdaemon_event.py" + ``` -- `D.Domain` — the service domain (e.g., `"light"`) -- `D.EntityId` / `D.MaybeEntityId` — entity ID from the service data -- `D.EventContext` — the HA event context object -- `Annotated[str, A.get_service]` — the service name -- `Annotated[Any, A.get_service_data]` — the full service data dict +=== "Hassette (dependency injection, recommended)" -### Hassette: with the full event object + ```python + --8<-- "pages/migration/snippets/bus_hassette_on_call_service_di.py" + ``` -```python ---8<-- "pages/migration/snippets/bus_hassette_on_call_service_event.py" -``` +=== "Hassette (full event)" + + ```python + --8<-- "pages/migration/snippets/bus_hassette_on_call_service_event.py" + ``` + +Dependency markers available in service call handlers: + +- `D.Domain`, the service domain (e.g., `"light"`) +- `D.EntityId` / `D.MaybeEntityId`, entity ID from the service data +- `D.EventContext`, the HA event context object +- `Annotated[str, A.get_service]`, the service name +- `Annotated[Any, A.get_service_data]`, the full service data dict + +AppDaemon passes extra kwargs from `listen_event()` into the callback via `**kwargs`. Hassette uses `where=` for filtering instead. Pass a dict or predicate to match on domain, service, entity ID, or arbitrary fields. ## Canceling Subscriptions +AppDaemon returns an opaque handle from `listen_state()` and requires a separate cancel call. Hassette returns a `Subscription` object with a `.cancel()` method. + === "AppDaemon" ```python - handle = self.listen_state(...) + handle = self.listen_state(self.on_change, "light.kitchen") self.cancel_listen_state(handle) ``` @@ -101,11 +121,11 @@ Available dependency markers for service call handlers include: --8<-- "pages/migration/snippets/bus_cancel_subscription.py" ``` -In Hassette, the subscription object returned by `on_state_change()`, `on_call_service()`, and `on()` all support `.cancel()`. All three methods are `async` and must be awaited. +All registration methods (`on_state_change`, `on_attribute_change`, `on_call_service`, `on`) are `async` and must be awaited. `.cancel()` on the returned `Subscription` is synchronous. ## Common Migration Patterns -### State changes with a filter +### State change with filter === "AppDaemon" @@ -123,7 +143,7 @@ In Hassette, the subscription object returned by `on_state_change()`, `on_call_s --8<-- "pages/migration/snippets/bus_migration_state_changes.py" ``` -### Service call subscriptions +### Service call subscription === "AppDaemon" @@ -145,7 +165,7 @@ In Hassette, the subscription object returned by `on_state_change()`, `on_call_s ## See Also -- [Bus Overview](../core-concepts/bus/index.md) — the full bus API -- [Writing Handlers](../core-concepts/bus/handlers.md) — handler patterns and DI -- [Filtering & Predicates](../core-concepts/bus/filtering.md) — composable predicate system -- [Dependency Injection](../core-concepts/bus/dependency-injection.md) — full DI reference +- [Bus Overview](../core-concepts/bus/index.md), the full bus API +- [Writing Handlers](../core-concepts/bus/handlers.md), handler patterns and DI +- [Filtering & Predicates](../core-concepts/bus/filtering.md), composable predicate system +- [Dependency Injection](../core-concepts/bus/dependency-injection.md), full DI reference diff --git a/docs/pages/migration/checklist.md b/docs/pages/migration/checklist.md index 4d9c340f3..7ad75cc8b 100644 --- a/docs/pages/migration/checklist.md +++ b/docs/pages/migration/checklist.md @@ -1,18 +1,18 @@ # Migration Checklist -Use this checklist when migrating each app from AppDaemon to Hassette. Work through one app at a time — verify it works before moving to the next. +Use this checklist when migrating each app from AppDaemon to Hassette. Work through one app at a time. Verify it works before moving to the next. The [Migration Guide overview](index.md) covers pre-migration setup (installing Hassette, reviewing the mental model). This checklist picks up from there. ## Before You Start -- [ ] **Requires Python 3.11 or later** — check with `python --version` or `python3 --version`. Hassette will not install on Python 3.10 or earlier. See [python.org/downloads](https://www.python.org/downloads/) to upgrade. +- [ ] **Requires Python 3.11 or later.** Check with `python --version` or `python3 --version`. Hassette will not install on 3.10 or earlier. See [python.org/downloads](https://www.python.org/downloads/) to upgrade. ## Step 1: Configuration - [ ] Convert `appdaemon.yaml` connection settings to `hassette.toml` `[hassette]` section - [ ] Convert each app entry in `apps.yaml` to an `[apps.your_app]` table in `hassette.toml` -- [ ] Create a typed `AppConfig` subclass for each app — move all `self.args["args"]["key"]` accesses to `self.app_config.key` +- [ ] Create a typed `AppConfig` subclass for each app. Move all `self.args["args"]["key"]` accesses to `self.app_config.key` - [ ] Verify required fields raise a clear error if missing (run the app without a required config key) See [Configuration](configuration.md) for the full conversion guide. @@ -21,8 +21,8 @@ See [Configuration](configuration.md) for the full conversion guide. - [ ] Change base class from `Hass` (or `ADAPI`) to `App` (async) or `AppSync` (sync) - [ ] Rename `initialize()` to the correct hook for your base class: - - `App`: `async def on_initialize(self)` — must be `async def` - - `AppSync`: `def on_initialize_sync(self)` — must be a plain synchronous method; do **not** override `on_initialize` on `AppSync` (it is `@final` and raises `NotImplementedError`) + - `App`: `async def on_initialize(self)`. Must be `async def`. + - `AppSync`: `def on_initialize_sync(self)`. Must be a plain synchronous method. Do **not** override `on_initialize` on `AppSync` (it is `@final` and raises `NotImplementedError`). - [ ] If you have `terminate()`, rename it: - `App`: `async def on_shutdown(self)` - `AppSync`: `def on_shutdown_sync(self)` @@ -33,8 +33,8 @@ See [Mental Model](concepts.md) for the lifecycle differences. ## Step 3: Event Listeners - [ ] Convert each `self.listen_state(...)` to `await self.bus.on_state_change(...)` - - [ ] Add `await` — all `self.bus.on_*()` methods are async and must be awaited - - [ ] Add `name=` — a stable string identifier is required on every registration (e.g. `name="kitchen_light"`) + - [ ] Add `await`. All `self.bus.on_*()` methods are async. + - [ ] Add `name=`. A stable string identifier is required on every registration (e.g. `name="kitchen_light"`). - [ ] Move filter arguments: `new=` → `changed_to=`, `old=` → `changed_from=` - [ ] Update callback signatures to use dependency injection or accept an event object - [ ] Replace `self.cancel_listen_state(handle)` with `subscription.cancel()` @@ -48,12 +48,12 @@ See [Bus & Events](bus.md) for side-by-side examples. ## Step 4: Scheduler -- [ ] Convert each `self.run_in(cb, seconds)` to `self.scheduler.run_in(cb, delay=seconds)` -- [ ] Convert each `self.run_once(cb, time(H, M))` to `self.scheduler.run_once(cb, at="HH:MM")` -- [ ] Convert each `self.run_every(cb, "now", interval)` to `self.scheduler.run_every(cb, seconds=interval)` -- [ ] Convert each `self.run_daily(cb, time(H, M))` to `self.scheduler.run_daily(cb, at="HH:MM")` +- [ ] Convert each `self.run_in(cb, seconds)` to `await self.scheduler.run_in(cb, delay=seconds)` +- [ ] Convert each `self.run_once(cb, time(H, M))` to `await self.scheduler.run_once(cb, at="HH:MM")` +- [ ] Convert each `self.run_every(cb, "now", interval)` to `await self.scheduler.run_every(cb, seconds=interval)` +- [ ] Convert each `self.run_daily(cb, time(H, M))` to `await self.scheduler.run_daily(cb, at="HH:MM")` - [ ] Replace `self.cancel_timer(handle)` with `job.cancel()` on the returned `ScheduledJob` -- [ ] Check any blocking work inside callbacks — for apps with heavy sync logic, switch to `AppSync`; for isolated blocking calls inside an `App` handler, use `await self.task_bucket.run_in_thread(...)` +- [ ] Check for blocking work inside callbacks. For apps with heavy sync logic, switch to `AppSync`. For isolated blocking calls inside an `App` handler, use `await self.task_bucket.run_in_thread(...)`. See [Scheduler](scheduler.md) for method equivalents. @@ -61,7 +61,7 @@ See [Scheduler](scheduler.md) for method equivalents. - [ ] Convert `self.get_state(entity_id)` to `self.states.domain.get(entity_id)` for cached reads - [ ] Replace `self.call_service("domain/service", ...)` with `await self.api.call_service("domain", "service", ...)` -- [ ] Add `await` to all `self.api.*` calls — forgetting `await` returns a coroutine without executing the call +- [ ] Add `await` to all `self.api.*` calls. Forgetting `await` returns a coroutine without executing the call. - [ ] Replace `self.set_state(...)` with `await self.api.set_state(...)` - [ ] Replace `self.log(...)` with `self.logger.info(...)` (and `.warning()`, `.error()` as needed) @@ -87,8 +87,8 @@ See [Testing](testing.md) for the test harness guide. !!! warning "Async gotchas" - Forgetting `await` on `self.api.*` calls is the most common migration mistake. The call returns a coroutine object and silently does nothing. - - In `AppSync`, use `.sync` facades for bus, scheduler, and API — `self.bus.sync.on_state_change(...)`, `self.scheduler.sync.run_in(...)`, `self.api.sync.call_service(...)`. Calling the async methods directly from sync hooks returns un-awaited coroutines that silently do nothing. - - Do not use `.sync` facades inside `App` lifecycle methods — use the async API instead, or switch to `AppSync`. + - In `AppSync`, use `.sync` facades for bus, scheduler, and API: `self.bus.sync.on_state_change(...)`, `self.scheduler.sync.run_in(...)`, `self.api.sync.call_service(...)`. Calling the async methods from sync hooks returns un-awaited coroutines that silently do nothing. + - Do not use `.sync` facades inside `App` lifecycle methods. Use the async API instead, or switch to `AppSync`. !!! tip "Configuration access" - AppDaemon: `self.args["args"]["key"]` @@ -97,8 +97,8 @@ See [Testing](testing.md) for the test harness guide. !!! tip "State access" - AppDaemon: `self.get_state()` returns a cached state (string or dict) - - Hassette: `self.states.light.get("light.kitchen")` returns a typed cached state (no `await` needed) (the domain prefix is optional). - - Use `self.api.get_state()` only when you need to force a fresh read from Home Assistant. + - Hassette: `self.states.light.get("light.kitchen")` returns a typed cached state. No `await` needed. The domain prefix is optional. + - Use `self.api.get_state()` only when you need a fresh read from Home Assistant. ## Next Steps diff --git a/docs/pages/migration/concepts.md b/docs/pages/migration/concepts.md index 7dd92dcbd..0c05e2659 100644 --- a/docs/pages/migration/concepts.md +++ b/docs/pages/migration/concepts.md @@ -1,90 +1,117 @@ # Mental Model -This page covers how AppDaemon and Hassette differ at the design level — not just syntax, but philosophy. Understanding these differences helps you write idiomatic Hassette code instead of translating AppDaemon patterns one-for-one. +This page maps the design differences between AppDaemon and Hassette so you can write idiomatic Hassette code instead of translating patterns one-for-one. -## Execution Model +## App Structure -**AppDaemon** runs each app in a separate thread. This means you can write synchronous code without worrying about blocking the event loop — long-running operations work fine because they run in their own thread. +=== "AppDaemon" -**Hassette** runs all apps in a single asyncio event loop. You write `async`/`await` code. If you have blocking or IO-bound operations, you either use `AppSync` (which runs synchronous lifecycle hooks in a managed thread) or offload work to a thread using `self.task_bucket.run_in_thread()`. + ```python + --8<-- "pages/migration/snippets/concepts_appdaemon_app.py" + ``` -```python ---8<-- "pages/migration/snippets/concepts_sync_async.py" -``` +=== "Hassette" + + ```python + --8<-- "pages/migration/snippets/concepts_hassette_app.py" + ``` + +Three things change: + +- **Base class**: `Hass` becomes `App[Config]`. The generic parameter is optional. `App` with no type argument works fine. +- **Lifecycle hook**: `initialize()` becomes `on_initialize()`. +- **Async keyword**: Hassette's hook is `async def`. You write `await` inside it. ## Access Model -**AppDaemon** exposes everything via methods on `self` (the app instance): `self.listen_state(...)`, `self.call_service(...)`, `self.run_in(...)`. All features live on one flat surface. +AppDaemon puts everything on `self`. You call `self.listen_state(...)`, `self.call_service(...)`, `self.run_in(...)` directly. One flat surface. -**Hassette** uses composition: each subsystem is a separate attribute on the app: +Hassette uses composition. Each subsystem is a separate attribute: | Attribute | What it does | -|-----------|--------------| -| `self.bus` | Event subscriptions (state changes, service calls, custom events) | -| `self.scheduler` | Scheduled jobs | -| `self.api` | Home Assistant REST/WebSocket API calls | -| `self.states` | Local state cache (automatically updated) | -| `self.cache` | Persistent disk-backed cache | -| `self.logger` | Standard Python logger | +|---|---| +| `self.bus` | Subscribe to state changes, service calls, and custom events | +| `self.scheduler` | Schedule jobs by delay, interval, time, or cron expression | +| `self.api` | Call Home Assistant REST and WebSocket APIs | +| `self.states` | Read local state cache, automatically kept current | +| `self.cache` | Persistent disk-backed key-value store | +| `self.logger` | Standard Python logger scoped to the app | -## Inheritance vs. Composition +The upside is discoverability. Typing `self.bus.` in your editor gives you the full event API. Typing `self.scheduler.` gives you the scheduler. Nothing is buried. -**AppDaemon** apps inherit from `Hass` (or `ADAPI`) and call inherited methods directly. +## Async vs Sync -**Hassette** apps inherit from `App` (or `AppSync`), but features are accessed via composition (the subsystem attributes above). The base class provides the lifecycle hooks and wires everything together at startup. +AppDaemon is multi-threaded. Each app runs in its own thread, so synchronous code works fine. -=== "AppDaemon" +Hassette runs all apps in a single asyncio event loop. Two things follow: - ```python - --8<-- "pages/migration/snippets/concepts_appdaemon_app.py" - ``` +1. API calls and bus registrations require `await`. +2. Blocking the event loop (a long `time.sleep`, a slow synchronous database call) blocks all apps, not just yours. -=== "Hassette" +```python +--8<-- "pages/migration/snippets/concepts_sync_async.py" +``` - ```python - --8<-- "pages/migration/snippets/concepts_hassette_app.py" - ``` +If you have blocking code that you cannot convert, use `AppSync` (described below). -**Key differences when updating your class definition:** +## Typed vs Untyped -- Change `Hass` to `App` or `AppSync` -- Rename `initialize()` to `on_initialize()` (and add `async` for `App`) -- Use `await` for API calls and other async operations +AppDaemon returns raw strings and dicts. `self.get_state("light.kitchen")` returns `"on"` or `"off"`. Attribute access returns `Any`. Configuration lives in `self.args`, a plain dict. -## Typed vs. Untyped +Hassette uses Pydantic models throughout. -**AppDaemon** returns raw strings or dicts from API calls. State values are strings; attribute access returns `Any`. Configuration arguments come in as a plain dictionary (`self.args["args"]["key"]`). +**Entity states** are typed objects. `self.states.get("light.kitchen")` returns a `LightState` with typed fields. Your IDE knows the shape; Pyright catches typos at development time, not at 2am. -**Hassette** uses Pydantic models throughout: +**App configuration** is a validated Pydantic model. You declare fields with types and defaults; Hassette loads and validates them at startup. A missing required field raises an error before any handler fires. -- Entity states are typed objects (e.g., `LightState`, `BinarySensorState`) with typed attributes -- App configuration is a validated Pydantic model — missing fields raise an error at startup, not at runtime -- API responses return structured models instead of raw dicts +**API responses** return structured models instead of raw dicts. You work with attributes, not string keys. ## Callback Signatures -**AppDaemon** requires specific callback signatures. State change callbacks must be `def my_callback(self, entity, attribute, old, new, **kwargs)`. Event callbacks must be `def my_callback(self, event_name, event_data, **kwargs)`. Extra keyword arguments you passed when subscribing arrive in `**kwargs`. +AppDaemon requires a fixed signature. State change callbacks must be: + +```python +def my_callback(self, entity, attribute, old, new, **kwargs): ... +``` + +You always receive all five arguments, whether you need them or not. + +Hassette handlers can have almost any signature. Three styles work: + +**Full event object.** Receive the raw event and extract what you need: -**Hassette** handlers can have almost any signature. You can: +```python +async def on_light_change(self, event: RawStateChangeEvent): ... +``` + +**Dependency injection.** Annotate parameters with `D.*` types and Hassette fills them in: + +```python +async def on_light_change(self, new_state: D.StateNew[states.LightState]): ... +``` -1. Accept the full event object: `async def handler(self, event: CallServiceEvent)` -2. Use dependency injection to extract only the fields you need: `async def handler(self, domain: D.Domain, entity_id: D.EntityId)` -3. Accept no arguments at all: `async def handler(self)` +**No arguments.** Use when you only care that the event fired: -Hassette inspects your handler's type annotations at subscription time and injects the right data automatically. See [Bus & Events](bus.md) for the full DI reference. +```python +async def on_motion(self): ... +``` -## Synchronous API +Hassette inspects your handler's type annotations at subscription time and injects the right data automatically. See [Dependency Injection](../core-concepts/bus/dependency-injection.md) for the full reference. -If you have existing synchronous code and don't want to add `async`/`await` everywhere, use `AppSync`: +## Synchronous API (AppSync) + +If you have a large synchronous codebase and don't want to convert everything at once, `AppSync` is an intermediate step. It runs lifecycle hooks in a managed thread, letting you write synchronous code as before. ```python --8<-- "pages/migration/snippets/concepts_appsync.py" ``` -`AppSync` runs its lifecycle hooks in a managed thread. Because the bus, scheduler, and API are async, register and call them through their `.sync` facades — `self.bus.sync`, `self.scheduler.sync`, and `self.api.sync`. It is a good intermediate step when migrating apps with heavy synchronous logic. +Because the bus, scheduler, and API are async internally, `AppSync` exposes `.sync` facades: `self.bus.sync`, `self.scheduler.sync`, `self.api.sync`. These block until the async operation completes and return the result to your synchronous code. + +`AppSync` keeps your existing code working while you migrate. As you convert methods to async, you can move them to `App` incrementally. ## See Also -- [Bus & Events](bus.md) — migrating `listen_state` and `listen_event` to `bus.on_state_change` and `bus.on_call_service` +- [Bus & Events](bus.md) — migrating `listen_state` and `listen_event` - [API Calls](api.md) — migrating `get_state`, `call_service`, and `set_state` -- [Dependency Injection](../core-concepts/bus/dependency-injection.md) — the full dependency injection reference +- [Dependency Injection](../core-concepts/bus/dependency-injection.md) — full DI reference diff --git a/docs/pages/migration/configuration.md b/docs/pages/migration/configuration.md index 1c50d7b52..a27c60056 100644 --- a/docs/pages/migration/configuration.md +++ b/docs/pages/migration/configuration.md @@ -1,127 +1,81 @@ # Configuration -This page covers how to migrate AppDaemon configuration files to Hassette's `hassette.toml` and typed `AppConfig` models. - -## Overview - -AppDaemon uses two YAML files: - -- `appdaemon.yaml` — global settings (timezone, HA connection details) -- `apps.yaml` — per-app settings (module, class, arguments) - -Hassette uses a single `hassette.toml` for everything. App arguments are defined as typed Pydantic models instead of raw dictionaries. +AppDaemon splits configuration across two YAML files: `appdaemon.yaml` for global settings and `apps.yaml` for per-app arguments. Hassette uses a single `hassette.toml` for everything, and replaces raw argument dictionaries with typed `AppConfig` models. ## Global Configuration -### AppDaemon (`appdaemon.yaml`) +=== "AppDaemon" -```yaml ---8<-- "pages/migration/snippets/config_appdaemon_yaml.yaml" -``` + ```yaml + --8<-- "pages/migration/snippets/config_appdaemon_yaml.yaml" + ``` -### Hassette (`hassette.toml`) +=== "Hassette" -```toml ---8<-- "pages/migration/snippets/config_migration_toml.toml" -``` + ```toml + --8<-- "pages/migration/snippets/config_migration_toml.toml" + ``` -The Home Assistant token is read from the `HASSETTE__TOKEN` environment variable or from a `.env` file — it is not stored in `hassette.toml`. +The Home Assistant token is read from the `HASSETTE__TOKEN` environment variable or a `.env` file. It does not go in `hassette.toml`. ## Per-App Configuration -### AppDaemon (`apps.yaml`) - -```yaml ---8<-- "pages/migration/snippets/config_apps_yaml.yaml" -``` +This is the bigger change. In AppDaemon, you declare app arguments in `apps.yaml` and read them through a nested dictionary. In Hassette, arguments go in `hassette.toml` and you define an `AppConfig` subclass to describe them. -Arguments are accessible in the app via `self.args["args"]["entity"]` — a nested dictionary with no type information. - -```python ---8<-- "pages/migration/snippets/config_appdaemon_access.py" -``` - -### Hassette (`hassette.toml` + `AppConfig`) - -```toml ---8<-- "pages/migration/snippets/config_hassette_toml.toml" -``` - -In Hassette, you define a subclass of `AppConfig` to declare the expected parameters with types and defaults. You access configuration via the typed `self.app_config` attribute: - -```python ---8<-- "pages/migration/snippets/config_hassette_appconfig.py" -``` - -## Migration Steps - -Convert your `appdaemon.yaml` and `apps.yaml` to a single `hassette.toml`: - -=== "Before (AppDaemon)" +=== "AppDaemon" ```yaml - # appdaemon.yaml - appdaemon: - plugins: - HASS: - type: hass - ha_url: http://192.168.1.179:8123 - token: !env_var HOME_ASSISTANT_TOKEN - - # apps.yaml - my_app: - module: my_app - class: MyApp - args: - entity: light.kitchen - brightness: 200 + --8<-- "pages/migration/snippets/config_apps_yaml.yaml" ``` -=== "After (Hassette)" + Arguments are accessible via `self.args["args"]["key"]`, a nested dictionary with no type information: - ```toml - --8<-- "pages/migration/snippets/config_migration_toml.toml" + ```python + --8<-- "pages/migration/snippets/config_appdaemon_access.py" ``` -!!! note "`[[double brackets]]` — TOML array-of-tables" - The `[[apps.my_app.config]]` syntax uses TOML array-of-tables, which means you can repeat it to run the same app class with multiple independent configurations (for example, one instance per room). Change `[[...]]` to `[...]` and you get a single-item table — the double brackets signal a list. +=== "Hassette" -Then replace dictionary access with typed config access: + ```toml + --8<-- "pages/migration/snippets/config_hassette_toml.toml" + ``` -=== "Before (AppDaemon)" + You define a subclass of `AppConfig` to declare each parameter with a type and optional default. Access configuration through `self.app_config`: ```python - def initialize(self): - entity = self.args["args"]["entity"] - brightness = self.args["args"]["brightness"] + --8<-- "pages/migration/snippets/config_hassette_appconfig.py" ``` -=== "After (Hassette)" +Missing required fields raise a validation error at startup, before any handler runs. `self.app_config.entity` carries a type your IDE can check. - ```python - from pydantic import Field - from hassette import App, AppConfig +!!! note "`[[double brackets]]`: TOML array-of-tables" + `[[apps.my_app.config]]` is a TOML array-of-tables. You can repeat the block to run the same app class with multiple independent configurations. Use `[...]` for a single instance; use `[[...]]` when you want a list. - class MyAppConfig(AppConfig): - entity: str = Field(..., description="The entity to monitor") - brightness: int = Field(100, ge=0, le=255) +## Multi-Instance Apps - class MyApp(App[MyAppConfig]): - async def on_initialize(self): - entity = self.app_config.entity - brightness = self.app_config.brightness - ``` +To run the same class in multiple rooms, add another `[[apps.my_app.config]]` block: -## Benefits of Typed Configuration +```toml +[apps.motion_lights] +filename = "motion_lights.py" +class_name = "MotionLights" + +[[apps.motion_lights.config]] +motion_sensor = "binary_sensor.living_room_motion" +light = "light.living_room" +off_delay = 300 + +[[apps.motion_lights.config]] +motion_sensor = "binary_sensor.bedroom_motion" +light = "light.bedroom" +off_delay = 120 +``` -- **Validation at startup** — missing required fields raise a clear error before your app runs, not at runtime when a handler fires -- **IDE autocomplete** — `self.app_config.entity` gets type hints and autocomplete in any IDE with type-checking support -- **Default values** — use `Field(default_value)` to declare defaults; Hassette applies them if the toml omits the key -- **Constraints** — Pydantic validators like `ge=0, le=255` catch invalid values before your app starts +Each block becomes a separate app instance. Both run the same `MotionLights` class with different config values. See [App Configuration](../core-concepts/apps/configuration.md) for the full reference. ## See Also -- [App Configuration](../core-concepts/apps/configuration.md) — full reference for defining `AppConfig` models +- [App Configuration](../core-concepts/apps/configuration.md) — defining `AppConfig` models - [Configuration Overview](../core-concepts/configuration/index.md) — `hassette.toml` structure - [Global Settings](../core-concepts/configuration/global.md) — all global Hassette settings - [Applications](../core-concepts/configuration/applications.md) — app registration and toml syntax diff --git a/docs/pages/migration/index.md b/docs/pages/migration/index.md index 6ab9e5a88..796bef9b0 100644 --- a/docs/pages/migration/index.md +++ b/docs/pages/migration/index.md @@ -1,92 +1,58 @@ # Migration Guide -This guide helps AppDaemon developers migrate their automations to Hassette. You will find concept-by-concept comparisons, side-by-side code examples, and a final checklist to track your progress. - -!!! note "Coming from Node-RED or pyscript?" - This guide focuses on AppDaemon because the mental model is the closest match to Hassette — both are Python-based, event-driven, and connect to Home Assistant via WebSocket. If you're coming from **Node-RED**, the visual flow model is very different from writing Python classes, so the [Getting Started guide](../getting-started/index.md) is a better starting point than this migration guide. If you're coming from **pyscript**, the transition is closer: you already write Python, but Hassette replaces the decorator-based, script-level approach with a structured class and dependency injection model — skimming this guide alongside Getting Started should orient you. +This guide covers migrating AppDaemon automations to Hassette. + +## Quick Reference + +Four areas change: configuration, app structure, event handlers, and API calls. + +| Action | AppDaemon | Hassette | Guide | +|--------|-----------|----------|-------| +| Define an app | `class MyApp(Hass)` | `class MyApp(App[MyConfig])` | [Configuration](configuration.md) | +| Lifecycle hook | `def initialize(self):` | `async def on_initialize(self):` | [Mental Model](concepts.md) | +| Listen for state changes | `self.listen_state(self.cb, "light.x", new="on")` | `await self.bus.on_state_change("light.x", handler=self.cb, changed_to="on", name="...")` | [Bus & Events](bus.md) | +| Listen for service calls | `self.listen_event(self.cb, "call_service", domain="light")` | `await self.bus.on_call_service(domain="light", handler=self.cb, name="...")` | [Bus & Events](bus.md) | +| Cancel a listener | `self.cancel_listen_state(handle)` | `subscription.cancel()` | [Bus & Events](bus.md) | +| Schedule a timer | `self.run_in(self.cb, 60)` | `await self.scheduler.run_in(self.cb, delay=60)` | [Scheduler](scheduler.md) | +| Cancel a timer | `self.cancel_timer(handle)` | `job.cancel()` | [Scheduler](scheduler.md) | +| Run daily at 07:30 | `self.run_daily(self.cb, time(7, 30, 0))` | `await self.scheduler.run_daily(self.cb, at="07:30")` | [Scheduler](scheduler.md) | +| Call a HA service | `self.call_service("light/turn_on", entity_id="light.x")` | `await self.api.call_service("light", "turn_on", target={"entity_id": "light.x"})` | [API Calls](api.md) | +| Get entity state | `self.get_state("light.x")` | `self.states.light.get("light.x")` or `await self.api.get_state("light.x")` | [API Calls](api.md) | +| Access app config | `self.args["entity"]` | `self.app_config.entity` | [Configuration](configuration.md) | +| Logging | `self.log("message")` | `self.logger.info("message")` | [Mental Model](concepts.md) | ## Is Migration Worth It? -Both AppDaemon and Hassette connect to Home Assistant via WebSocket and let you write Python automations. The decision to migrate comes down to what you value: - -| You should migrate if... | You might stay with AppDaemon if... | -|--------------------------|--------------------------------------| -| You want IDE autocomplete and type errors at development time, not runtime | Your existing apps work and you don't need type safety | -| You want to unit-test your automations with a proper test harness | You prefer synchronous code without `async`/`await` | -| You want Pydantic-validated configuration with defaults and clear error messages | Your team already knows AppDaemon well | -| You want structured logs that include the calling method and line number | You rely on AppDaemon-specific features not yet in Hassette | -| You want a dependency injection model for event handlers | — | +| Migrate if... | Stay with AppDaemon if... | +|---------------|--------------------------| +| You want IDE autocomplete and type errors at write time | Your apps work and you don't need type safety | +| You want to unit-test automations with a real test harness | You prefer synchronous code without `async`/`await` | +| You want Pydantic-validated config with clear error messages | Your team already knows AppDaemon well | +| You want dependency injection in event handlers | You rely on AppDaemon features not yet in Hassette | +| You want structured per-app logs with method and line context | | ## Known Gaps -The following AppDaemon features are not currently in Hassette. If your apps rely on any of these, migration is not yet recommended: - | AppDaemon feature | Status in Hassette | |-------------------|--------------------| -| `listen_log` / log event subscriptions | Out of scope — not planned | -| HADashboard | Out of scope — Hassette has its own web UI for monitoring, not display panels | -| Notification app helpers (`notify`, `call_action`) | Out of scope — call `self.api.call_service("notify", ...)` directly | -| MQTT plugin | Roadmap — not yet supported; use `self.api.call_service` workarounds | -| Global variables / inter-app communication via `AD` | `self.bus.emit(topic, data)` for in-process broadcast — see [Broadcasting Events Between Apps](../core-concepts/apps/index.md#broadcasting-events-between-apps) | - -If a feature you depend on is missing, open an issue or check the [GitHub discussions](https://github.com/NodeJSmith/hassette/discussions). - -## What Changes - -When you migrate from AppDaemon, you change four areas: - -1. **Configuration** — `appdaemon.yaml` + `apps.yaml` become a single `hassette.toml`. App arguments become typed Pydantic models instead of raw dictionaries. -2. **App structure** — `Hass` subclass with `initialize()` becomes an `App` subclass with `async def on_initialize()`. -3. **Event handlers** — `self.listen_state(...)` and `self.listen_event(...)` become `await self.bus.on_state_change(..., name=...)` and `await self.bus.on_call_service(..., name=...)` (async; `name=` required). -4. **API calls** — synchronous `self.call_service(...)` becomes `await self.api.call_service(...)`. - -The scheduler API is similar to AppDaemon's, with named parameters and richer job objects. - -## Quick Start Checklist - -Before you start migrating app by app, complete this setup: - -- [ ] Follow the [Quickstart guide](../getting-started/index.md) to install Hassette and confirm `hassette.toml` connects to Home Assistant -- [ ] Review [Mental Model](concepts.md) — covers the key design differences between AppDaemon and Hassette -- [ ] Pick one small, simple app as your first migration target -- [ ] Follow the [Migration Checklist](checklist.md) for that app -- [ ] Run the [test harness](testing.md) to verify behavior before going live - -## Guide Structure - -| Page | What it covers | -|------|----------------| -| [Mental Model](concepts.md) | How AppDaemon and Hassette differ in design philosophy | -| [Bus & Events](bus.md) | `listen_state` / `listen_event` → `bus.on_state_change` / `bus.on_call_service` | -| [Scheduler](scheduler.md) | `run_in`, `run_daily`, and other scheduler equivalents | -| [API Calls](api.md) | Getting states, calling services, setting states | -| [Configuration](configuration.md) | `appdaemon.yaml` + `apps.yaml` → `hassette.toml` + `AppConfig` | -| [Testing](testing.md) | How to test Hassette apps with `AppTestHarness` | -| [Migration Checklist](checklist.md) | Step-by-step migration checklist | - -## Quick Reference Table - -The table below maps the most common AppDaemon operations to their Hassette equivalents. - -| Action | AppDaemon | Hassette | -|--------|-----------|----------| -| Listen for a state change | `self.listen_state(self.cb, "binary_sensor.door", new="on")` | `await self.bus.on_state_change("binary_sensor.door", handler=self.cb, changed_to="on", name="...")` | -| React on attribute threshold | `self.listen_state(self.cb, "sensor.x", attribute="battery", below=20)` | `await self.bus.on_attribute_change("sensor.x", "battery", handler=self.cb, changed_to=lambda v: v < 20, name="...")` | -| Monitor service calls | `self.listen_event(self.on_service, "call_service", domain="light")` | `await self.bus.on_call_service(domain="light", handler=self.on_service, name="...")` | -| Schedule something in 60 seconds | `self.run_in(self.turn_off, 60)` | `self.scheduler.run_in(self.turn_off, delay=60)` | -| Run every morning at 07:30 | `self.run_daily(self.morning, time(7, 30, 0))` | `self.scheduler.run_daily(self.morning, at="07:30")` | -| Get entity state (cached) | `self.get_state("light.kitchen")` | `self.states.light.get("light.kitchen")` | -| Call a HA service | `self.call_service("light/turn_on", entity_id="light.x", brightness=200)` | `await self.api.call_service("light", "turn_on", target={"entity_id": "light.x"}, brightness=200)` | -| Access app configuration | `self.args["args"]["entity"]` | `self.app_config.entity` | -| Stop a listener | `self.cancel_listen_state(handle)` | `subscription.cancel()` | -| Stop a scheduled job | `self.cancel_timer(handle)` | `job.cancel()` | - -## Next Steps - -- [Mental Model](concepts.md) — how AppDaemon and Hassette differ in design philosophy -- [Bus & Events](bus.md) — `listen_state` / `listen_event` → `bus.on_state_change` / `bus.on_call_service` -- [Scheduler](scheduler.md) — `run_in`, `run_daily`, and other scheduler equivalents -- [API Calls](api.md) — getting states, calling services, setting states -- [Configuration](configuration.md) — `appdaemon.yaml` + `apps.yaml` → `hassette.toml` + `AppConfig` -- [Testing](testing.md) — how to test Hassette apps with `AppTestHarness` -- [Migration Checklist](checklist.md) — step-by-step migration checklist +| `listen_log` / log event subscriptions | Not planned | +| HADashboard | Not planned. Hassette has its own monitoring UI. | +| Notification helpers (`notify`, `call_action`) | Use `await self.api.call_service("notify", ...)` directly | +| MQTT plugin | Not yet supported. No workaround available. | +| Global variables / inter-app communication | Use `await self.bus.emit(topic, data)` for in-process broadcast | + +If a feature you depend on is missing, [open an issue](https://github.com/NodeJSmith/hassette/issues) or check [GitHub discussions](https://github.com/NodeJSmith/hassette/discussions). + +## Common Pitfalls + +**`name=` is required on all bus subscriptions.** Omitting it raises `ListenerNameRequiredError` at runtime. Every `on_state_change`, `on_call_service`, and `on` call needs a stable string name. + +**`self.api.*` and `self.bus.on_*` are async and must be awaited.** Forgetting `await` returns a coroutine object. Nothing is registered or called. + +**`changed_to=` takes the string value, not a bool.** Use `changed_to="on"`, not `changed_to=True`. HA state values are strings. + +**`AppSync` apps use `.sync` facades.** If you subclass `AppSync` for synchronous handlers, use `self.bus.sync.on_state_change(...)` and `self.scheduler.sync.run_in(...)`. The async methods are not available in sync hooks. + +## Per-App Migration Checklist + +The [Migration Checklist](checklist.md) walks through converting a single app from AppDaemon to Hassette. Work through it once for your first app, then use it as a reference for the rest. diff --git a/docs/pages/migration/scheduler.md b/docs/pages/migration/scheduler.md index 4584c3cb7..ef359fbaf 100644 --- a/docs/pages/migration/scheduler.md +++ b/docs/pages/migration/scheduler.md @@ -1,39 +1,41 @@ # Scheduler -This page covers how to migrate AppDaemon scheduler calls to Hassette's `self.scheduler` attribute. +Hassette scheduling lives on `self.scheduler`. All methods are `async` and return a `ScheduledJob` object for cancellation. -## Overview +## Method Equivalents + +| AppDaemon | Hassette | Notes | +|-----------|----------|-------| +| `self.run_in(cb, 60)` | `await self.scheduler.run_in(cb, delay=60)` | Delay in seconds | +| `self.run_once(cb, time(7, 30))` | `await self.scheduler.run_once(cb, at="07:30")` | `"HH:MM"` string or `ZonedDateTime` | +| `self.run_every(cb, "now", 300)` | `await self.scheduler.run_every(cb, seconds=300)` | Use `hours=`, `minutes=`, or `seconds=` | +| `self.run_minutely(cb)` | `await self.scheduler.run_minutely(cb)` | Every 1 minute | +| `self.run_hourly(cb, time(0, 30))` | `await self.scheduler.run_hourly(cb)` | Every 1 hour | +| `self.run_daily(cb, time(7, 30))` | `await self.scheduler.run_daily(cb, at="07:30")` | Wall-clock, DST-safe | +| `self.cancel_timer(handle)` | `job.cancel()` | Cancel via the returned job object | +| — | `await self.scheduler.run_cron(cb, "0 7 * * *")` | Hassette-only; cron expression | +| — | `await self.scheduler.schedule(cb, trigger)` | Hassette-only; custom trigger object | -AppDaemon exposes scheduler helpers as methods directly on `self`: `self.run_in(...)`, `self.run_daily(...)`. They return an opaque handle you pass to `self.cancel_timer(handle)` to cancel the job. +!!! note "`run_daily` is now cron-backed" + Hassette's `run_daily` fires at the specified wall-clock time every day, handling DST transitions correctly. An interval-based approach drifts by an hour across a DST boundary. The cron-backed implementation does not. -Hassette exposes the scheduler as a separate attribute `self.scheduler`. Methods use named parameters, and they return a [`ScheduledJob`][hassette.scheduler.classes.ScheduledJob] object you cancel with `.cancel()`. Handlers can be async or sync, and they don't need to follow a fixed signature. +Every scheduling call returns a [`ScheduledJob`][hassette.scheduler.classes.ScheduledJob]. Call `.cancel()` on it to stop the job. ## Callback Signatures -**AppDaemon** requires schedule callbacks to follow `def my_callback(self, **kwargs)`. The `kwargs` dictionary includes any data you passed when scheduling plus an internal `__thread_id` value. The documentation recommends not using async functions due to the threading model. +AppDaemon requires all schedule callbacks to match `def my_callback(self, **kwargs)`. The `kwargs` dict carries any data you passed at registration, plus an internal `__thread_id` key. -**Hassette** scheduled jobs can be any callable — async or sync, with any parameters. If you pass keyword arguments when scheduling, declare them as parameters on your handler: +Hassette accepts any callable, async or sync, with any parameters. Keyword arguments passed when scheduling arrive as named parameters on the handler: ```python --8<-- "pages/migration/snippets/scheduler_hassette.py" ``` -## Method Equivalents +No fixed signature. No `**kwargs` unwrapping. -| AppDaemon | Hassette | Hassette Notes | -|-----------|----------|----------------| -| `self.run_in(cb, 60)` | `self.scheduler.run_in(cb, delay=60)` | Delay in seconds | -| `self.run_once(cb, time(7, 30))` | `self.scheduler.run_once(cb, at="07:30")` | `"HH:MM"` string or `ZonedDateTime` | -| `self.run_every(cb, "now", 300)` | `self.scheduler.run_every(cb, seconds=300)` | Interval via `hours=`, `minutes=`, `seconds=` | -| `self.run_minutely(cb)` | `self.scheduler.run_minutely(cb)` | Every 1 minute by default | -| `self.run_hourly(cb, time(0, 30))` | `self.scheduler.run_hourly(cb)` | Every 1 hour by default | -| `self.run_daily(cb, time(7, 30))` | `self.scheduler.run_daily(cb, at="07:30")` | Wall-clock, DST-safe (cron-backed) | -| `self.cancel_timer(handle)` | `job.cancel()` | Cancel via the returned job object | - -!!! note "`run_daily` is now wall-clock-aligned" - Hassette's `run_daily` uses a cron-based trigger internally. It fires at the specified wall-clock time every day, correctly handling DST transitions. This is different from the old interval-based approach that could drift by an hour across DST boundaries. +## Migration Example -## Side-by-Side Comparison +The complete before/after for an app that uses `run_in`, `run_daily`, and `run_every`: === "AppDaemon" @@ -44,50 +46,43 @@ Hassette exposes the scheduler as a separate attribute `self.scheduler`. Methods === "Hassette" ```python - --8<-- "pages/migration/snippets/scheduler_hassette.py" + --8<-- "pages/migration/snippets/scheduler_migration.py" ``` -## Migration Example - -The following shows a typical AppDaemon pattern converted to Hassette: - -=== "AppDaemon" - - ```python - from datetime import time +**Key changes:** - def initialize(self): - self.run_in(self.delayed_task, 60) - self.run_daily(self.morning_task, time(7, 30)) - handle = self.run_every(self.periodic_task, "now", 300) - ``` +- Call scheduling methods on `self.scheduler`, not directly on `self` +- `await` every scheduling call +- `run_daily` takes `at="HH:MM"` instead of a `datetime.time` object +- `run_every` takes `hours=`, `minutes=`, or `seconds=` instead of a positional interval +- Jobs return `ScheduledJob` objects; cancel with `job.cancel()` instead of `self.cancel_timer(handle)` -=== "Hassette" +## Blocking Work - ```python - --8<-- "pages/migration/snippets/scheduler_migration.py" - ``` +In AppDaemon, every callback runs in its own thread, so blocking IO is safe anywhere. -**Key changes:** - -- Access via `self.scheduler` instead of calling directly on `self` -- `run_daily` takes an `at="HH:MM"` string instead of a `time` object or `start=` parameter -- `run_every` takes `hours=`, `minutes=`, `seconds=` keyword arguments instead of a positional `interval` -- `run_cron` takes a cron expression string instead of keyword fields (`hour=`, `minute=`, etc.) -- Jobs return rich `ScheduledJob` objects instead of opaque handles -- Cancel with `job.cancel()` instead of `self.cancel_timer(handle)` +In Hassette, sync callables passed to the scheduler run in a thread pool automatically. Write a plain `def` callback and Hassette detects it is not a coroutine. No extra configuration needed. -## Blocking Work in Scheduler Callbacks +```python +def periodic_sync_task(self): + # Runs in a thread pool. Blocking IO is safe here. + data = requests.get("http://example.com/api").json() + ... +``` -In AppDaemon, every callback runs in its own thread, so you can do blocking IO safely. In Hassette, the scheduler automatically runs sync callables in a thread pool, regardless of whether you're using `App` or `AppSync`. This means: +Async callbacks run in the event loop directly. For blocking IO inside an `async def` callback, offload with `asyncio.to_thread()` or `self.task_bucket.run_in_thread()`: -- Write the callback as a plain (non-async) `def` — the scheduler detects that it's not a coroutine and runs it in a thread automatically. -- Use `AppSync` only if you also want sync lifecycle hooks (`on_initialize_sync`, `on_shutdown_sync`, etc.) — not because you need scheduler callbacks to run in threads. +```python +async def periodic_async_task(self): + # Must offload blocking work explicitly + data = await asyncio.to_thread(requests.get, "http://example.com/api") + ... +``` -If your callback is `async def`, it runs in the event loop directly. For blocking IO inside an async callback, use `asyncio.to_thread()` or `self.task_bucket.run_in_thread()`. +`AppSync` is for sync lifecycle hooks (`on_initialize_sync`, `on_shutdown_sync`). Sync scheduler callbacks already run in a thread pool regardless of base class. ## See Also -- [Scheduler Overview](../core-concepts/scheduler/index.md) — the full scheduler API -- [Scheduling Methods](../core-concepts/scheduler/methods.md) — all scheduling helpers with examples -- [Job Management](../core-concepts/scheduler/management.md) — inspecting and canceling jobs +- [Scheduler Overview](../core-concepts/scheduler/index.md). The full scheduler API. +- [Scheduling Methods](../core-concepts/scheduler/methods.md). All helpers with examples. +- [Job Management](../core-concepts/scheduler/management.md). Inspecting and canceling jobs. diff --git a/docs/pages/migration/testing.md b/docs/pages/migration/testing.md index 07dfbbe69..c5f35c377 100644 --- a/docs/pages/migration/testing.md +++ b/docs/pages/migration/testing.md @@ -1,41 +1,35 @@ # Testing -This page covers the key differences in the testing approach when migrating from AppDaemon to Hassette. +AppDaemon has no official test harness. Testing AppDaemon apps means patching the `Hass` runtime, which is fragile and usually tests the mock rather than your code. -## The Mental Model Shift +Hassette ships `hassette.test_utils` with `AppTestHarness`, an async test harness that wires your app into a real Hassette environment. `RecordingApi` replaces the live Home Assistant connection, recording every API call your app makes so you can assert against it. -AppDaemon has no official test harness. Testing AppDaemon apps typically means patching the `Hass` runtime, which is fragile and often ends up testing the mock rather than your code. +## Setup -Hassette ships with `hassette.test_utils` — a first-class async test harness. Instead of patching a runtime, you open an `AppTestHarness` context manager: it wires your app class into a real (but test-grade) Hassette environment with a `RecordingApi` instead of a live Home Assistant connection. - -The other shift is from synchronous to asynchronous tests. AppDaemon apps and tests are synchronous. Hassette apps are async, so your tests are async too. This is handled automatically by `pytest-asyncio`. - -## asyncio_mode = "auto" (Required) - -Add this to your `pyproject.toml`: +**Add `asyncio_mode = "auto"` to your `pyproject.toml`:** ```toml [tool.pytest.ini_options] asyncio_mode = "auto" ``` -!!! warning "Don't skip this config" - If you omit `asyncio_mode = "auto"`, async tests will silently succeed **without actually running** — a false-green failure mode that is especially hard to diagnose after migration. This is the most common setup mistake when migrating from AppDaemon. +!!! warning "Don't skip this" + Without it, async tests silently pass without running. This is the most common setup mistake when migrating from AppDaemon. -## set_state() Order Matters - -Call `set_state()` before `simulate_state_change()` for the same entity. Calling it afterward will overwrite the simulated state with the seeded value, silently corrupting subsequent reads. +**Seed state before simulating events.** Call `set_state()` before `simulate_state_change()` for the same entity. Calling it afterward overwrites the simulated state with the seeded value, silently corrupting subsequent reads. ```python --8<-- "pages/migration/snippets/testing_seed_order.py" ``` -## Full Reference +## What a Test Looks Like -For the complete harness API — seeding state, simulating events, asserting API calls, scheduler time control, and more — see [Testing Your Apps](../testing/index.md). +Open an `AppTestHarness` context manager, seed your state, fire an event, assert the API call. -## See Also +```python +--8<-- "pages/migration/snippets/testing_hassette_example.py" +``` + +## Full Reference -- [Testing Your Apps](../testing/index.md) — full test harness reference -- [Time Control](../testing/time-control.md) — freezing and advancing time in tests -- [Concurrency & pytest-xdist](../testing/concurrency.md) — parallel test execution +The [Testing Your Apps](../testing/index.md) section covers the complete harness API: state seeding, event simulation, API call assertions, scheduler time control, and concurrency helpers. From d198b7518518d892a1d0bc51a21bc7ea437fe413 Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Tue, 2 Jun 2026 22:40:39 -0500 Subject: [PATCH 054/160] docs: rewrite Troubleshooting and Operating sections (4 pages) --- docs/pages/operating/index.md | 109 +++++++- docs/pages/operating/log-levels.md | 91 ++++++- .../operating/snippets/basic_example.toml | 9 + .../operating/snippets/debug_ha_comms.toml | 3 + .../operating/snippets/debug_scheduler.toml | 2 + .../operating/snippets/per_app_log_level.toml | 6 + .../snippets/quiet_file_watcher.toml | 2 + .../operating/snippets/timeout_overrides.py | 28 +++ .../operating/snippets/ws_reconnect_events.py | 26 ++ docs/pages/operating/upgrading.md | 64 ++++- docs/pages/troubleshooting.md | 236 +++++++++++------- 11 files changed, 486 insertions(+), 90 deletions(-) create mode 100644 docs/pages/operating/snippets/basic_example.toml create mode 100644 docs/pages/operating/snippets/debug_ha_comms.toml create mode 100644 docs/pages/operating/snippets/debug_scheduler.toml create mode 100644 docs/pages/operating/snippets/per_app_log_level.toml create mode 100644 docs/pages/operating/snippets/quiet_file_watcher.toml create mode 100644 docs/pages/operating/snippets/timeout_overrides.py create mode 100644 docs/pages/operating/snippets/ws_reconnect_events.py diff --git a/docs/pages/operating/index.md b/docs/pages/operating/index.md index 43e9a3cf8..dec24533b 100644 --- a/docs/pages/operating/index.md +++ b/docs/pages/operating/index.md @@ -1,3 +1,110 @@ # Operating Hassette -*This page is being rewritten as part of the documentation overhaul.* +Hassette runs as a long-lived process. The runtime behaviors that matter in production: how it survives a Home Assistant restart, what happens when a handler raises, how timeouts work, and what degraded-database mode looks like. + +## WebSocket Reconnection + +The WebSocket connection between Hassette and Home Assistant can drop for many reasons: HA restarts, network blips, clean shutdowns. Hassette uses a three-layer retry model to recover automatically. + +### Layer 1: Initial connection retries + +When Hassette first starts (or `WebsocketService` restarts), it attempts to establish the WebSocket connection up to `websocket.connect_retry_max_attempts` times (default: 5). Each retry waits longer than the last, starting at `websocket.connect_retry_initial_wait_seconds` (default: 1s) and capping at `websocket.connect_retry_max_wait_seconds` (default: 32s), with jitter added to each interval. Tenacity logs a WARNING before each sleep: + +``` +Retrying hassette.core.websocket_service.WebsocketService._make_connection.._inner_connect in X.Xs as it raised ... +``` + +If all five attempts fail, the error propagates to layer 3. + +### Layer 2: Early-drop retries + +A connection is considered "early drop" when it falls within `websocket.early_drop_stable_window_seconds` (default: 30s) of becoming connected. This usually means HA accepted the handshake but then immediately disconnected, which often happens during HA restarts. Hassette retries up to `websocket.early_drop_max_retries` times (default: 5), with backoff starting at `websocket.early_drop_backoff_initial_seconds` (default: 2s) and capping at `websocket.early_drop_backoff_max_seconds` (default: 60s). Each early-drop attempt logs: + +``` +WebSocket early drop detected (elapsed=X.Xs, attempt=N/5) — retrying +``` + +Early-drop retries only apply to genuine post-auth disconnects (`ServerDisconnectedError`, `RetryableConnectionClosedError`). Connection-refused errors bypass this layer entirely and go straight to layer 1's retry loop. + +### Layer 3: ServiceWatcher restart budget + +`ServiceWatcher` supervises `WebsocketService` using a sliding-window restart budget: 5 restarts per 300-second window, with 2s–60s exponential backoff between attempts. Once the budget is exhausted, `WebsocketService` enters `EXHAUSTED_COOLING`, a 300-second cooldown, and retries from scratch. The logs show: + +``` +Service 'WebsocketService' restart budget exhausted (TRANSIENT), entering cooldown for 300.0s +``` + +After the cooldown completes, the budget resets and the full retry sequence starts over. This layer ensures Hassette keeps trying through prolonged HA unavailability without spinning. + +### What apps see during reconnection + +The bus, scheduler, and state manager stay active during a disconnect. Subscriptions remain registered. Handlers resume without re-registration when the connection restores. + +`Api` methods (REST calls to HA) and `StateProxy` access raise `ResourceNotReadyError` while the WebSocket is down. Code that calls these during a disconnect must handle that exception or wait for reconnection. + +The bus delivers `hassette.event.websocket_disconnected` when the connection drops and `hassette.event.websocket_connected` when it restores. Apps that need to pause or resume behavior based on HA reachability can subscribe to these topics: + +```python +--8<-- "pages/operating/snippets/ws_reconnect_events.py:subscribe" +``` + +### When to tune + +**Slow HA restarts.** If HA takes longer than 30s to become responsive after a restart, increase `websocket.early_drop_stable_window_seconds` to cover the typical restart duration. Otherwise early-drop retries expire before HA is ready and fall through to the ServiceWatcher layer. + +**Flaky networks.** Increase `websocket.early_drop_max_retries` or `websocket.early_drop_backoff_max_seconds` to give transient network issues more room to recover before escalating. + +**Low downtime tolerance.** Reduce `websocket.connect_retry_initial_wait_seconds` to shorten the backoff floor. The jitter is proportional to the initial wait, so a smaller initial value also tightens the jitter band. + +All fields live under `[hassette.websocket]` in `hassette.toml`. + +## Handler Exceptions + +When a bus handler or scheduler callback raises an unhandled exception, Hassette catches it, logs it at ERROR level, and moves on. The exception does not crash the process, does not affect other handlers running concurrently, and does not prevent future invocations of the same handler. + +The telemetry database records the invocation with `status='error'`, including the exception type, message, and traceback. The Handlers tab in the monitoring UI surfaces these records. + +The log line for a bus handler failure: + +``` +Handler error (topic=, handler=, exec=) + +``` + +The log line for a scheduler job failure: + +``` +Job error (job_db_id=, exec=) + +``` + +If you register an error handler on a subscription or a scheduled job, Hassette calls it after logging. Use it to send alerts, trigger recovery logic, or record additional context. The error handler itself is also subject to a timeout (`lifecycle.error_handler_timeout_seconds`, default 5s) and is not re-raised if it raises. + +## Timeouts + +Two global timeout defaults apply to all user code: + +- **`lifecycle.event_handler_timeout_seconds`** (default: 600s). The maximum wall-clock time for a single bus handler invocation before it is cancelled and recorded as `timed_out`. +- **`scheduler.job_timeout_seconds`** (default: 600s). The maximum wall-clock time for a scheduled job callback. + +Both default to 600 seconds. A handler or job that runs longer than its timeout has its awaitable cancelled; the cancellation is recorded in telemetry and logged at WARNING. + +Individual subscriptions and jobs can override the global default: + +```python +--8<-- "pages/operating/snippets/timeout_overrides.py:overrides" +``` + +### Limitations + +**Synchronous handlers.** Hassette runs synchronous handlers in a thread executor. `asyncio.timeout` cancels the awaitable wrapping the thread, but it cannot stop the thread itself. A sync handler that ignores cancellation may continue running in the background after the timeout fires. If long-running sync work needs reliable cancellation, convert it to `async`. + +**Catching `TimeoutError` internally.** A handler that catches `TimeoutError` before it propagates to Hassette prevents the cancellation from taking effect. The handler continues running; the record shows `status='success'`. Avoid catching `TimeoutError` in handler bodies unless you re-raise it. + +**`lifecycle.run_sync_timeout_seconds`** (default: 6s) is a separate timeout that applies to calls made from synchronous (non-async) contexts into Hassette's event loop via `task_bucket.run_sync()`. This timeout is not related to handler execution. It governs blocking calls made from threads outside the event loop. + +## Database Degraded Mode + +When the telemetry database is unavailable at startup or becomes unreachable at runtime, Hassette continues operating normally. Apps run, handlers fire, and the scheduler works as expected. Telemetry records are silently dropped rather than blocking execution. The monitoring UI shows zero counts for invocations and logs. + +For details on retention, migrations, and the telemetry schema, see [Database & Telemetry](../core-concepts/database-telemetry.md). diff --git a/docs/pages/operating/log-levels.md b/docs/pages/operating/log-levels.md index c8824cbf5..a35b26d91 100644 --- a/docs/pages/operating/log-levels.md +++ b/docs/pages/operating/log-levels.md @@ -1,3 +1,92 @@ # Log Level Tuning -*This page is being rewritten as part of the documentation overhaul.* +Hassette lets you set log verbosity independently for each internal service. Start from the symptom, narrow to the service, and turn up only what you need. + +## Symptom Lookup + +Find your symptom and set the field it maps to. Leave everything else at `INFO`. + +| Symptom | Field to set | +|---------|--------------| +| Events not firing or wrong handlers triggering | `logging.bus_service` | +| Jobs not running or firing at the wrong time | `logging.scheduler_service` | +| App not loading, crashing on start, or not reloading | `logging.app_handler` | +| Unexpected state values or stale cached state | `logging.state_proxy` or `logging.api` | +| WebSocket errors, HA connection drops, reconnection loops | `logging.websocket` | +| High API call latency or HTTP errors from Home Assistant | `logging.api` | +| Noisy file-change messages during development | `logging.file_watcher` | +| Web UI not responding or showing errors | `logging.web_api` | + +## How It Works + +All log level settings live under `[hassette.logging]` in `hassette.toml`. Each service has a dedicated field. Set it to a Python log level string: `DEBUG`, `INFO`, `WARNING`, `ERROR`, or `CRITICAL` (case-insensitive). + +Fields you leave unset inherit the global `log_level`. The global `log_level` defaults to `INFO` when not set. + +```toml +--8<-- "pages/operating/snippets/basic_example.toml" +``` + +Setting `log_level = "DEBUG"` at the top level raises verbosity for every service at once. Per-service fields let you override specific services up or down without touching the rest. + +## Debug Flags + +Three boolean flags control event bus debug output. They are separate from log levels because event volume can be extreme. + +| Field | Default | Effect | +|-------|---------|--------| +| `all_events` | `false` | Log every event the bus processes, both HA and Hassette | +| `all_hass_events` | inherits `all_events` | Log every event received from Home Assistant | +| `all_hassette_events` | inherits `all_events` | Log every internal Hassette framework event | + +Set `all_events = true` to enable both at once. Set `all_hass_events` or `all_hassette_events` individually to target one side. + +`log_format` controls the output structure: + +| Value | Effect | +|-------|--------| +| `"auto"` | Console format when a TTY is attached, JSON when not (default) | +| `"console"` | Human-readable format with colors and alignment | +| `"json"` | Structured JSON, one object per line | + +`log_persistence_level` sets the minimum level for log entries written to the telemetry database. Defaults to `INFO`. Set to `DEBUG` if you want debug output queryable via `hassette log`. + +## Per-App Log Levels + +`logging.apps` sets the default log level for all your automation apps. Override it for a specific app in that app's config section. + +```toml +--8<-- "pages/operating/snippets/per_app_log_level.toml" +``` + +The per-app `log_level` under `[apps.]` takes precedence over `logging.apps`. Apps without an explicit `log_level` fall back to `logging.apps`, which falls back to the global `log_level`. + +## Examples + +### Debugging the Scheduler + +```toml +--8<-- "pages/operating/snippets/debug_scheduler.toml" +``` + +With `scheduler_service = "DEBUG"`, Hassette logs trigger evaluation, next-run calculations, and job execution timing. Other services stay at `INFO`. + +### Quieting the File Watcher + +```toml +--8<-- "pages/operating/snippets/quiet_file_watcher.toml" +``` + +The file watcher logs every detected change at `INFO`. In active development this creates noise. `WARNING` suppresses routine detection messages and only surfaces errors. + +### Debugging HA Communication + +```toml +--8<-- "pages/operating/snippets/debug_ha_comms.toml" +``` + +`websocket = "DEBUG"` shows raw WebSocket message traffic. `api = "DEBUG"` shows each REST call and response. Use both together when troubleshooting connectivity issues or unexpected state values coming from Home Assistant. + +## Full Field Reference + +All fields, their types, and defaults are in the [`LoggingConfig`][hassette.config.models.LoggingConfig] API reference. diff --git a/docs/pages/operating/snippets/basic_example.toml b/docs/pages/operating/snippets/basic_example.toml new file mode 100644 index 000000000..c7f9865f4 --- /dev/null +++ b/docs/pages/operating/snippets/basic_example.toml @@ -0,0 +1,9 @@ +# hassette.toml +[hassette.logging] +log_level = "INFO" # global default + +# Turn up verbosity for the scheduler only +scheduler_service = "DEBUG" + +# Silence noisy file watcher logs +file_watcher = "WARNING" diff --git a/docs/pages/operating/snippets/debug_ha_comms.toml b/docs/pages/operating/snippets/debug_ha_comms.toml new file mode 100644 index 000000000..4b97eb04c --- /dev/null +++ b/docs/pages/operating/snippets/debug_ha_comms.toml @@ -0,0 +1,3 @@ +[hassette.logging] +websocket = "DEBUG" +api = "DEBUG" diff --git a/docs/pages/operating/snippets/debug_scheduler.toml b/docs/pages/operating/snippets/debug_scheduler.toml new file mode 100644 index 000000000..69bb6992d --- /dev/null +++ b/docs/pages/operating/snippets/debug_scheduler.toml @@ -0,0 +1,2 @@ +[hassette.logging] +scheduler_service = "DEBUG" diff --git a/docs/pages/operating/snippets/per_app_log_level.toml b/docs/pages/operating/snippets/per_app_log_level.toml new file mode 100644 index 000000000..26b070303 --- /dev/null +++ b/docs/pages/operating/snippets/per_app_log_level.toml @@ -0,0 +1,6 @@ +# hassette.toml +[hassette.logging] +apps = "INFO" + +[hassette.apps.my_noisy_app] +log_level = "WARNING" diff --git a/docs/pages/operating/snippets/quiet_file_watcher.toml b/docs/pages/operating/snippets/quiet_file_watcher.toml new file mode 100644 index 000000000..fe639dccd --- /dev/null +++ b/docs/pages/operating/snippets/quiet_file_watcher.toml @@ -0,0 +1,2 @@ +[hassette.logging] +file_watcher = "WARNING" diff --git a/docs/pages/operating/snippets/timeout_overrides.py b/docs/pages/operating/snippets/timeout_overrides.py new file mode 100644 index 000000000..2600e775f --- /dev/null +++ b/docs/pages/operating/snippets/timeout_overrides.py @@ -0,0 +1,28 @@ +from hassette import App, AppConfig +from hassette.events import RawStateChangeEvent + + +class TimeoutOverrideApp(App[AppConfig]): + async def on_initialize(self) -> None: + # --8<-- [start:overrides] + # use a tighter timeout for this handler + await self.bus.on_state_change( + "sensor.outdoor_temperature", + handler=self.on_temp_change, + name="my_app.temp", + timeout=30, + ) + + # disable the timeout entirely for a long-running job + await self.scheduler.run_every( + self.rebuild_cache, + minutes=15, + timeout_disabled=True, + ) + # --8<-- [end:overrides] + + async def on_temp_change(self, event: RawStateChangeEvent) -> None: + pass + + async def rebuild_cache(self) -> None: + pass diff --git a/docs/pages/operating/snippets/ws_reconnect_events.py b/docs/pages/operating/snippets/ws_reconnect_events.py new file mode 100644 index 000000000..40a1816ff --- /dev/null +++ b/docs/pages/operating/snippets/ws_reconnect_events.py @@ -0,0 +1,26 @@ +from typing import Any + +from hassette import App, AppConfig +from hassette.events import Event + + +class ReconnectAwareApp(App[AppConfig]): + async def on_initialize(self) -> None: + # --8<-- [start:subscribe] + await self.bus.on( + topic="hassette.event.websocket_disconnected", + handler=self.on_ha_disconnected, + name="my_app.ha_disconnect", + ) + await self.bus.on( + topic="hassette.event.websocket_connected", + handler=self.on_ha_connected, + name="my_app.ha_connect", + ) + # --8<-- [end:subscribe] + + async def on_ha_disconnected(self, event: Event[Any]) -> None: + self.logger.warning("HA disconnected") + + async def on_ha_connected(self, event: Event[Any]) -> None: + self.logger.info("HA reconnected") diff --git a/docs/pages/operating/upgrading.md b/docs/pages/operating/upgrading.md index 54cd1fa73..2b2f8f18c 100644 --- a/docs/pages/operating/upgrading.md +++ b/docs/pages/operating/upgrading.md @@ -1,3 +1,65 @@ # Upgrading Hassette -*This page is being rewritten as part of the documentation overhaul.* +## Check Your Current Version + +Before upgrading, confirm what version you're running. The CLI reports it directly: + +```bash +hassette --version +``` + +To check the version installed in your project: + +```bash +uv pip show hassette +``` + +## Upgrade + +How you upgrade depends on how you installed Hassette. + +**pip / uv (project install)** + +```bash +uv add hassette@latest +``` + +This updates `pyproject.toml` and installs the new version into your project environment. + +**Docker** + +Pull the new image tag and restart your container: + +```bash +docker pull ghcr.io/nodejsmith/hassette:latest +docker compose up -d +``` + +Replace `latest` with a specific version tag if you pin releases. + +## Reading the Changelog + +The changelog lives in two places: `CHANGELOG.md` at the root of the repository, and the [GitHub Releases page](https://github.com/NodeJSmith/hassette/releases). Both contain the same content. GitHub Releases is easier to browse by version; `CHANGELOG.md` is useful if you have the repo checked out. + +Before upgrading, scan the entries between your current version and the target version. Pay attention to two signals: + +- A `BREAKING CHANGE:` footer in a release note means something in your app code may need to change. The footer describes what changed and what you need to do. +- A `!` after the commit type (for example, `feat!:` or `fix!:`) marks a breaking change. If the entry has a `BREAKING CHANGE:` footer, that footer has the details. If it does not, the summary line is all you have. Read it carefully. + +Entries without either signal are safe to take without changes to your app code. + +## Major Version Upgrades + +Hassette includes the major version in its data directory path. The current data directory is `~/.local/share/hassette/v0/`. A future v1 release will use `~/.local/share/hassette/v1/` and start with an empty database. + +If you want to carry your history forward across a major version bump, set `data_dir` and `config_dir` explicitly in your `hassette.toml` before upgrading: + +```toml +[hassette] +data_dir = "/home/youruser/.local/share/hassette/v0" +config_dir = "/home/youruser/.config/hassette/v0" +``` + +With explicit paths set, Hassette uses them regardless of the built-in version default. You control when the data moves. + +Docker installations are unaffected. Mount points are version-independent. Your volume mounts stay the same across major versions. diff --git a/docs/pages/troubleshooting.md b/docs/pages/troubleshooting.md index 58e558e59..40856b42e 100644 --- a/docs/pages/troubleshooting.md +++ b/docs/pages/troubleshooting.md @@ -1,139 +1,201 @@ # Troubleshooting -This page organizes common problems by symptom. Click through to the relevant section for detailed guidance. +## Can't Connect to Home Assistant -## Can't connect to Home Assistant +**Token not accepted.** Set `HASSETTE__TOKEN` in your `.env` file or environment. The value must be a long-lived access token from Home Assistant's profile page. See [Authentication](core-concepts/configuration/auth.md). -- **Token issues**: Verify `HASSETTE__TOKEN` is set correctly in your `.env` file. See [Authentication](core-concepts/configuration/auth.md). -- **Connection refused / timeout**: Check `base_url` in `hassette.toml`. If running in Docker, ensure Hassette can reach Home Assistant's network. See [Docker Troubleshooting](getting-started/docker/troubleshooting.md#cant-reach-home-assistant). +**Connection refused or timeout.** Check `base_url` in `hassette.toml`. The default is `http://127.0.0.1:8123`. Include the scheme and port explicitly. Bare hostnames raise `SchemeRequiredInBaseUrlError` at startup. -## Apps not loading +**Running in Docker.** Hassette must reach Home Assistant's network. If Home Assistant runs in a separate container or on a different host, `base_url` must point to its actual address, not `127.0.0.1`. See [Docker Troubleshooting](getting-started/docker/troubleshooting.md#cant-reach-home-assistant). -- **App not discovered**: Verify `apps.directory` points to the correct directory and your app file is registered in `hassette.toml`. See [Application Configuration](core-concepts/configuration/applications.md). Success: you'll see `INFO hassette..0 ... ─ App initialized` in the logs. -- **Import errors**: Check for missing dependencies or syntax errors in logs. See [Docker Troubleshooting](getting-started/docker/troubleshooting.md#apps-not-loading). -- **App precheck fails**: If an app fails to load, Hassette won't start by default. The precheck runs each app's module through import before starting the WebSocket connection, so any problem is reported immediately. Common causes and their log signatures: +**Invalid token at startup.** Look for `InvalidAuthError` in the startup log. This is fatal. Hassette will not retry. Generate a new long-lived token and update `HASSETTE__TOKEN`. - - **Syntax error or bad import** — a `SyntaxError` or `ModuleNotFoundError` at the top of your app file. Look for: `ERROR hassette.utils.app_utils — Failed to load app 'MyApp': SyntaxError: invalid syntax (at /apps/my_app.py:12)` - - **Class not found** — the `class_name` in `hassette.toml` doesn't match the actual class name in the file. Look for: `AttributeError: Class MyApp not found in module apps.my_app` - - **Invalid configuration** — a required `AppConfig` field has no value and no default. Look for: `ERROR ... Failed to load app 'MyApp' due to bad configuration` +**During reconnection**, your app code keeps running — the bus, scheduler, and state manager remain active. `.call_service()` will raise `ResourceNotReadyError` while the WebSocket is down because it depends on an active connection. `.get_state()` returns the last-known cached value — the data may be stale, but reads do not fail. Call `is_ready()` on the state proxy to check whether data is fresh. The cache is replaced with live data once the WebSocket reconnects. Your handlers registered via the bus will resume receiving events as soon as the WebSocket reconnects; no re-registration is needed. + +## Apps Not Loading - To diagnose without blocking startup, set `allow_startup_if_app_precheck_fails = true` in `hassette.toml` temporarily. This logs the same errors but lets other apps run. Remove it once the problem is fixed — a failing precheck means the broken app won't be loaded either way. +The app precheck runs before the WebSocket connection opens. It imports each app module, resolves the class, and validates config. Any failure blocks startup by default. -## Event handler never runs +**Syntax error or bad import.** Look for this pattern in the log: -- **Entity ID typo**: Double-check the entity ID string — `"binary_sensor.motion"` vs `"binary_sensor.motoin"`. Hassette won't error on a non-existent entity; the handler simply never fires. -- **`changed_to` type mismatch**: Home Assistant state values are strings. `changed_to="on"` works; `changed_to=True` does not — it compares against the Python `bool`, not the HA string `"on"`. -- **Domain excluded**: Check `bus_excluded_domains` and `bus_excluded_entities` in your `hassette.toml` — events from excluded domains are silently dropped before reaching your handlers. -- **App not enabled**: Verify the app's config block has `enabled = true` (the default). A disabled app's handlers are never registered. -- **Attribute-only change**: By default, `on_state_change` only fires when the main state value changes. If only an attribute changed (e.g., brightness), pass `changed=False`. See [Filtering — The `changed` Parameter](core-concepts/bus/filtering.md#the-changed-parameter). +``` +ERROR hassette.utils.app_utils — Failed to load app 'MyApp': SyntaxError: invalid syntax (at /apps/my_app.py:12) +``` -## Home Assistant goes offline +Fix the syntax error or install the missing dependency. -When Home Assistant becomes unreachable or disconnects mid-session, Hassette handles recovery automatically without restarting the process. +**Class not found.** The `class_name` in `hassette.toml` doesn't match the actual class name in the file: -**What happens immediately:** +``` +AttributeError: Class MyApp not found in module apps.my_app +``` -1. The WebSocket receive loop detects the disconnect (closed frame, connection reset, or server disconnect) and raises internally. -2. Hassette fires a `hassette.event.websocket_disconnected` event on the bus — your apps can subscribe to it via `await self.bus.on_websocket_disconnected(handler=..., name="ws_disconnect")` to react (for example, to pause outgoing calls). -3. The `WebsocketService` is marked not-ready and the framework begins reconnecting. +Check for typos in `class_name` in `hassette.toml` and confirm the class is defined at module level. -**Reconnection sequence:** +**Invalid config.** A required `AppConfig` field has no value and no default: -The initial connection itself retries up to 5 times with exponential backoff (starting at 1 second, capping at 32 seconds) before the service is considered failed. If the service fails, the `ServiceWatcher` takes over: +``` +ERROR hassette — Failed to load app 'MyApp' due to bad configuration +``` -- It restarts `WebsocketService` using its `RestartSpec`: up to **5 restarts** within a **300-second sliding window** (TRANSIENT type). -- Each restart waits an exponentially increasing delay starting at **2 seconds**, doubling each attempt, capped at **60 seconds**. -- When Home Assistant comes back and the service recovers to RUNNING and becomes ready, Hassette fires `hassette.event.websocket_connected` and the restart budget resets automatically. +Set the missing field in `hassette.toml` or via an environment variable. -**If the restart budget is exhausted**, `WebsocketService` enters `EXHAUSTED_COOLING` for a 300-second cooldown, then resets its budget and retries. The TRANSIENT restart type means it keeps trying rather than shutting down immediately. +**Diagnosing without blocking startup.** Set `allow_startup_if_app_precheck_fails = true` in `hassette.toml`. This logs the same errors but lets healthy apps start. The broken app still won't run. Remove this setting once the problem is fixed. -**What to look for in logs:** +## Handler Registration Fails +**`ListenerNameRequiredError`.** All bus registration methods require a `name=` parameter. Omitting it raises this error immediately at registration time. Add a stable, descriptive name: + +```python +await self.bus.on_state_change("light.kitchen", handler=self.on_light_change, name="kitchen_light") ``` -WARNING hassette.WebsocketService -- Retrying _inner_connect in Xs as it raised CouldNotFindHomeAssistantError: ... -ERROR hassette.WebsocketService -- Serve() task failed: CouldNotFindHomeAssistantError ... -INFO hassette.ServiceWatcher -- Service 'WebsocketService' restarting (attempt N, waiting Xs) -DEBUG hassette.WebsocketService -- Connected to WebSocket at ws://... -INFO hassette.ServiceWatcher -- Service 'WebsocketService' in cooldown for 300.0s (cycle 1) + +**`DuplicateListenerError`.** Two listeners registered within the same app instance and session share the same `name` and topic. Use a different name for each listener, or remove the first registration before re-registering. Cross-session duplicates (after a restart) are handled by upsert and don't raise this error. + +## Handler Never Fires + +Work through this checklist in order. + +**1. Entity ID typo.** Hassette does not error on a non-existent entity ID. The handler simply never fires. Double-check the entity ID string. Use `hassette status` or the web UI to confirm the entity exists and its exact ID. + +**2. `changed_to` type mismatch.** Home Assistant state values are strings. `changed_to="on"` works. `changed_to=True` does not. It compares a Python `bool` against a string and never matches. Use the string form. + +**3. Domain excluded by config.** Check `bus_excluded_domains` and `bus_excluded_entities` in `hassette.toml`. Events from excluded domains are dropped before reaching any handler. + +**4. Attribute-only change.** `on_state_change` fires when the main state value changes. If only an attribute changed (brightness, temperature, etc.) without the state string changing, the handler won't fire. Pass `changed=False` to receive both state and attribute changes. See [Filtering](core-concepts/bus/filtering.md#the-changed-parameter). + +**5. App not enabled.** Check that the app's config block has `enabled = true` (the default). A disabled app's handlers are never registered. + +## Scheduler Not Firing + +**Job scheduled for the past.** `run_once(at="07:00")` called after 7 AM defers the job to tomorrow and logs a WARNING. `run_daily(at="07:00")` fires at the next 7 AM occurrence. + +**Unit confusion.** `run_every(seconds=5)` fires every 5 seconds. Use named parameters to be explicit: `run_every(minutes=5)`. For `run_cron`, `"5 * * * *"` means "at minute 5 of every hour," not "every 5 minutes." Use `"*/5 * * * *"` for a 5-minute interval. + +**Exception in the task.** Unhandled exceptions inside scheduled tasks are caught, logged at ERROR level, and swallowed. The scheduler keeps running. Check your logs for the traceback. + +See also: [Job Management](core-concepts/scheduler/management.md#troubleshooting). + +## Database Degraded / Telemetry Missing + +**Stats show zeroed-out metrics.** The telemetry database is unavailable. Check disk space and file permissions. + +In Docker, check the data volume: + +```bash +docker compose exec hassette df -h /data ``` -**During reconnection**, your app code keeps running — the bus, scheduler, and state manager remain active. `.call_service()` will raise `ResourceNotReadyError` while the WebSocket is down because it depends on an active connection. `.get_state()` returns the last-known cached value — the data may be stale, but reads do not fail. Call `is_ready()` on the state proxy to check whether data is fresh. The cache is replaced with live data once the WebSocket reconnects. Your handlers registered via the bus will resume receiving events as soon as the WebSocket reconnects; no re-registration is needed. +The database file is at `/data/hassette.db` by default. -## Event handler exceptions +**Safe to delete.** Deleting `hassette.db` only removes telemetry history. Your automations continue to run. Restart Hassette to recreate the database. -Exceptions raised inside a bus handler are caught by the framework, logged, and swallowed — they do not propagate to the caller, do not crash the app, and do not affect other handlers. +**Schema version mismatch.** If the database was created by a newer version of Hassette, startup raises `SchemaVersionError` and halts. Auto-migration is not attempted. Either upgrade Hassette to match the database or delete the database to start fresh. -The specific behavior: +See also: [Database and Telemetry](core-concepts/database-telemetry.md#degraded-mode). -- The exception is logged at `ERROR` level with the full traceback: `Handler error (topic=..., handler=...) \n` -- Hassette records the invocation in the telemetry database with `status='error'` and the error type and message. -- The app continues running normally; subsequent events are dispatched as usual. +## Cache Not Persisting -This matches the scheduler's behavior for jobs — exceptions fail silently (logged to error). +**Data lost after restart.** Verify `data_dir` is correctly configured and writable. In Docker, ensure the `/data` volume is mounted. Cache files live under `data_dir`. -**What to look for in logs:** +**Multi-instance collisions.** All instances of the same app class share one cache namespace. Use `self.app_config.instance_name` as a key prefix to isolate each instance's data: +```python +key = f"{self.app_config.instance_name}:last_seen" ``` -ERROR hassette.CommandExecutor -- Handler error (topic=hass.event.state_changed.light.kitchen, handler=Listener) -Traceback (most recent call last): - ... -AttributeError: 'NoneType' object has no attribute 'brightness' + +See also: [App Cache](core-concepts/cache/patterns.md#troubleshooting). + +## Custom State Class Not Registering + +**Missing `domain` annotation.** Every custom state class needs a `domain: Literal["your_domain"]` field. Without it, the class raises `NoDomainAnnotationError` internally and is not registered. + +**`super().__init_subclass__()` not called.** If you override `__init_subclass__`, call `super().__init_subclass__()` to preserve registration. Omitting it silently prevents the class from being added to the registry. + +See also: [Custom States](advanced/custom-states.md#troubleshooting). + +## Web UI Not Accessible + +**Running locally.** Open `http://localhost:8126/ui/` after starting Hassette. + +**Running in Docker.** Ensure `docker-compose.yml` includes `ports: ["8126:8126"]`. + +**Disabled in config.** Check `hassette.toml`: + +```toml +[hassette.web_api] +run = true +run_ui = true ``` -See also: [Writing Handlers](core-concepts/bus/handlers.md) for how to use dependency injection to avoid common handler errors. +Both must be `true`. `run = false` disables the entire web API, including the health check. `run_ui = false` disables the dashboard while keeping the API and health check active. -## Scheduler not firing +See also: [Web UI](web-ui/index.md). -- **Job scheduled for the past**: `run_once(at="07:00")` called after 7 AM defers the job to tomorrow (with a WARNING log). `run_daily(at="07:00")` fires at the next 7 AM occurrence (today if before 7 AM, tomorrow otherwise). -- **Runs too often or too rarely**: `run_every(seconds=5)` is 5 *seconds*, not minutes — use `run_every(minutes=5)` for a 5-minute interval. For `run_cron`, the expression `"5 * * * *"` means "at minute 5 of every hour", not "every 5 minutes" — use `"*/5 * * * *"` for intervals. -- **Exception in task**: Unhandled exceptions in scheduled tasks are logged at ERROR level but don't crash the scheduler. Check your logs. -- See [Job Management — Troubleshooting](core-concepts/scheduler/management.md#troubleshooting) for more. +## Docker-Specific Issues -## Database degraded / telemetry missing +For container startup failures, dependency installation, health check failures, hot reload issues, and network configuration, see the [Docker Troubleshooting](getting-started/docker/troubleshooting.md) guide. -- **Stats strip shows zeroed-out metrics**: The telemetry database may be unavailable. Check for disk space issues or file permission problems. -- **Docker**: Check the data volume has space: `docker compose exec hassette df -h /data`. The database file is at `/data/hassette.db` by default. -- **Safe to delete**: Deleting `hassette.db` only loses telemetry history — your automations continue to run. Restart Hassette to recreate the database. -- See [Database & Telemetry — Degraded Mode](core-concepts/database-telemetry.md#degraded-mode) for details. +## Exception Reference -## Cache not persisting +### Connection -- **Data lost after restart**: Verify the `data_dir` is correctly configured and writable. In Docker, ensure the `/data` volume is mounted. -- **Cache shared between instances**: All instances of the same app class share one cache directory. Use `self.app_config.instance_name` as a key prefix to avoid collisions. -- See [App Cache — Troubleshooting](core-concepts/cache/patterns.md#troubleshooting) for more. +**`CouldNotFindHomeAssistantError`** Raised when Hassette cannot reach the Home Assistant WebSocket at startup. Extends `FatalError`. Hassette will not retry. Check `base_url` and confirm Home Assistant is running and accessible. -## Custom state class not registering +**`InvalidAuthError`** The token was rejected by Home Assistant. Generate a new long-lived access token and update `HASSETTE__TOKEN`. -- Ensure the class has `domain: Literal["your_domain"]` as a field. -- If overriding `__init_subclass__`, call `super().__init_subclass__()`. -- See [Custom States — Troubleshooting](advanced/custom-states.md#troubleshooting). +**`BaseUrlRequiredError`** `base_url` is missing entirely. Set it in `hassette.toml`. -## Upgrading Hassette +**`SchemeRequiredInBaseUrlError`** `base_url` is set but has no scheme. Use `http://` or `https://`. -**Check your current version:** +**`ResourceNotReadyError`** An API call was made while the WebSocket was disconnected or a service was still initializing. The WebSocket service reconnects automatically. Retry after reconnection. -```bash -hassette --version # if installed as a CLI tool -uv pip show hassette # shows installed version in your project -``` +**`ConnectionClosedError`** The WebSocket closed unexpectedly. Hassette handles this internally and reconnects. You only see this if you catch it explicitly. -**Upgrade to the latest release:** +**`FailedMessageError`** A message sent over the WebSocket returned an error response from Home Assistant. Check `e.code` for the structured error type from HA. `e.code` is `None` for locally-synthesized failures like transport timeouts. -```bash -uv add hassette@latest -``` +### Registration + +**`ListenerNameRequiredError`** `name=` was omitted on a bus registration call. Add a stable `name=` parameter to the registration. + +**`DuplicateListenerError`** Two listeners in the same app instance registered with the same name and topic. Use distinct names. + +### State Conversion + +**`DomainNotFoundError`** No state class is registered for the requested domain. Import the relevant state module or define a custom state class with a matching `domain` annotation. + +**`RegistryNotReadyError`** The state registry was queried before any state classes were imported. Ensure state modules are imported before state conversion is attempted. + +**`NoDomainAnnotationError`** A state class is missing `domain: Literal["..."]`. Add the annotation. + +**`InvalidDataForStateConversionError`** The data passed to state conversion is malformed or `None`. Check the upstream event or API response. + +**`UnableToConvertStateError`** The state dict exists but cannot be coerced to the target state class. Check that the class fields match the entity's actual attributes. + +**`InvalidEntityIdError`** An entity ID string is malformed (wrong format, empty, wrong type). Entity IDs must follow the `domain.object_id` pattern. + +### Dependency Injection + +**`DependencyInjectionError`** The handler signature is invalid. A parameter uses `*args`, is positional-only, or is missing a type annotation. Fix the handler signature. + +**`DependencyResolutionError`** Injection succeeded at inspection time but failed at runtime while extracting or converting a value. Check the event data and the type annotations in the handler. + +### Lifecycle + +**`InvalidLifecycleTransitionError`** A resource attempted an invalid status transition. Only raised when `strict_lifecycle = true` in `hassette.toml`. In non-strict mode, the same condition logs a WARNING. Disable strict mode or investigate the resource initialization order. + +**`AppPrecheckFailedError`** One or more apps failed the precheck. Check the log for the specific app and error. Set `allow_startup_if_app_precheck_fails = true` to let other apps run while you diagnose. + +### Configuration -**Release notes:** See the [Changelog](../CHANGELOG.md) for what changed in each version. Breaking changes are flagged with a `BREAKING CHANGE` footer in commit messages and are called out explicitly in the changelog under a "Breaking Changes" heading. +**`SchemaVersionError`** The database was created by a newer version of Hassette than the one currently running. Either upgrade Hassette or delete `hassette.db` to start fresh. -**Major version upgrades — data directory:** On bare-metal installs (not Docker), Hassette's default `data_dir` and `config_dir` include the major version number (e.g., `~/.local/share/hassette/v0/`). If a future major release changes this to `v1/`, Hassette will start with a fresh data directory. To keep your existing telemetry and cache, set `data_dir` and `config_dir` explicitly in `hassette.toml` or via the `HASSETTE__DATA_DIR` / `HASSETTE__CONFIG_DIR` environment variables. Docker users are unaffected — the `/data` and `/config` paths are version-independent. +**`CannotOverrideFinalError`** An app class overrides a lifecycle method marked as final (such as `initialize`). Use the public hook (`on_initialize`) instead. -## Docker-specific issues +**`InvalidInheritanceError`** An app class inherits from `App` incorrectly. Check the class definition and the error message for details. -For container startup failures, dependency installation problems, health check failures, hot reload issues, and performance tuning, see the dedicated [Docker Troubleshooting](getting-started/docker/troubleshooting.md) guide. +### Framework Base -## Web UI not accessible +**`HassetteError`** The base class for all Hassette exceptions. Catch this to handle any Hassette-raised error generically. -- **Running locally**: Open `http://localhost:8126/ui/` after starting Hassette. -- **Running in Docker**: Ensure your `docker-compose.yml` includes `ports: ["8126:8126"]`. -- **Disabled**: Check that `run` and `run_ui` are both `true` under `[hassette.web_api]` in `hassette.toml`. -- See [Web UI](web-ui/index.md) for configuration options. +**`FatalError`** Extends `HassetteError`. Indicates a condition where the service should not restart. Hassette shuts down when this is raised. Subclasses: `CouldNotFindHomeAssistantError`, `InvalidAuthError`, `BaseUrlRequiredError`, `SchemeRequiredInBaseUrlError`. From 2cb2b7c637b28c59427fcbce195921a4593fce51 Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Tue, 2 Jun 2026 22:45:26 -0500 Subject: [PATCH 055/160] fix: resolve broken links and pyright error in docs --- .claude/handoff.md | 70 +++++++++++++++++++ docs/pages/cli/commands.md | 2 +- docs/pages/core-concepts/apps/index.md | 2 +- docs/pages/core-concepts/apps/task-bucket.md | 4 +- .../core-concepts/bus/custom-extractors.md | 4 +- .../core-concepts/bus/dependency-injection.md | 2 +- docs/pages/core-concepts/bus/filtering.md | 2 +- .../core-concepts/configuration/index.md | 6 +- docs/pages/core-concepts/states/index.md | 4 +- .../core-concepts/states/type-registry.md | 2 +- docs/pages/getting-started/docker/index.md | 2 +- .../docker/snippets/deps-app-using-package.py | 2 +- docs/pages/recipes/debounce-sensor-changes.md | 4 +- docs/pages/testing/concurrency.md | 2 +- docs/pages/testing/harness.md | 4 +- docs/pages/troubleshooting.md | 6 +- 16 files changed, 94 insertions(+), 24 deletions(-) create mode 100644 .claude/handoff.md diff --git a/.claude/handoff.md b/.claude/handoff.md new file mode 100644 index 000000000..a69804971 --- /dev/null +++ b/.claude/handoff.md @@ -0,0 +1,70 @@ +# Handoff: Documentation overhaul T09-T12 + +**Date:** 2026-06-02 +**Project:** hassette +**Directory:** /home/jessica/source/hassette/.claude/worktrees/928 +**Branch:** worktree-928 +**Tmux:** hassette-doc-t10-t12 + +## What We Were Working On + +Full documentation site rewrite for the Hassette project (spec `design/specs/070-doc-overhaul/`). The overhaul has 13 tasks (T01-T13). This session picked up at T09 (Web UI) and completed T09, T10 (CLI + Testing), T11 (Recipes), and T12 (Migration + Troubleshooting + Operating). A draft PR #970 was opened so readthedocs can build a preview. T13 (final sweep, snippet cleanup, merge) is the only remaining task. + +## Approach + +Established pipeline from earlier sessions: read per-page outlines from `design/specs/070-doc-overhaul/outlines/`, dispatch Sonnet writer subagents in parallel (one per page), then Opus reviewer subagents that verify voice rules, fix issues directly, and verify all symbol references against source code. Commit and push each section as a batch. The voice guide at `.claude/rules/voice-guide.md` and the writing prompt template at `design/specs/070-doc-overhaul/writing-prompt-template.md` drive consistency. + +Key constraint: the VPS has 15GB RAM and prior sessions crashed from memory pressure when mkdocs dev server ran alongside many parallel agents. This session skipped mkdocs entirely and capped parallel agents at 6-7 Sonnet writers or 4-5 Opus reviewers at a time. + +## Current State + +### Done +- T01-T08: completed in prior sessions (getting-started, core-concepts, infrastructure) +- T09: Web UI (5 pages) — committed as `b1c1988e` +- T10: CLI (4 pages) + Testing (5 pages) — committed as `c638a6ad` and `6effc366` +- T11: Recipes (6 pages rewritten, motion-lights exemplar unchanged) — committed as `82038842` +- T12: Migration (8 pages including checklist) — committed as `a4363fef` +- T12: Troubleshooting (1 page) + Operating (3 pages) — committed as `dcbe19f0` +- Draft PR #970 opened for readthedocs preview + +### Not Started +- T13: Final sweep, snippet cleanup, and docs branch merge + +## Uncommitted Changes + +None — all changes committed. + +## Decisions Made + +- **Checklist.md kept as voice polish, not full rewrite** — the outline explicitly said "remains as a step-by-step per-app checklist." Reviewer found and fixed a correctness bug (missing `await` on scheduler calls). +- **motion-lights.md kept as-is** — it was the exemplar page from T03, already matches the voice guide perfectly. +- **Operating snippets copied from advanced/** — log-level tuning TOML snippets were at `docs/pages/advanced/snippets/log-level-tuning/` and copied to `docs/pages/operating/snippets/`. The originals were not deleted (that's T13 cleanup work). +- **Operating reviewer created 2 new snippet files** — `ws_reconnect_events.py` and `timeout_overrides.py` were extracted from inline code blocks for Pyright checking. These are new files not in the outlines. +- **Upgrading page: genericized personal paths** — reviewer caught `/home/jessica/...` in a TOML example and changed it to `/home/youruser/...` for public docs. + +## Open Questions + +- `followups.md` has items for T13: screenshot inventory for web-ui pages, orphaned old pages to delete, broken cross-link decisions, CLI command verification +- The operating snippets were copied (not moved) from advanced/ — T13 should clean up the originals if they're no longer referenced +- The readthedocs build from PR #970 may surface broken links or missing images that need fixing in T13 + +## Key Files + +- `design/specs/070-doc-overhaul/followups.md` — tracking file for T13 follow-up items +- `design/specs/070-doc-overhaul/writing-prompt-template.md` — reusable prompts for writer/reviewer subagents +- `design/specs/070-doc-overhaul/docs-context.md` — voice calibration artifact with checklist and violations +- `.claude/rules/voice-guide.md` — voice rules for all doc pages +- `.claude/rules/doc-rules.md` — page structure rules, snippet conventions, admonition policy +- `design/specs/070-doc-overhaul/outlines/` — per-page outlines used as specs for writers +- `design/specs/070-doc-overhaul/tasks/T13-final-sweep.md` — T13 task definition + +## Next Steps + +1. Review the readthedocs preview from PR #970 for rendering issues, broken links, missing images +2. Start T13: run the muffet link checker and snippet orphan check from T02's CI tooling +3. Delete orphaned old pages listed in `followups.md` (old tab-mirroring web-ui pages, etc.) +4. Inventory and refresh web-ui screenshots referenced by the new pages +5. Verify cross-links between sections (migration links to concept pages, recipes link to concept pages) +6. Clean up duplicated snippets (operating/snippets originals still in advanced/snippets) +7. Final voice sweep across all pages (spot-check for drift) +8. Mark T13 complete, merge the docs branch diff --git a/docs/pages/cli/commands.md b/docs/pages/cli/commands.md index c8a348168..f369d8537 100644 --- a/docs/pages/cli/commands.md +++ b/docs/pages/cli/commands.md @@ -359,7 +359,7 @@ These flags apply to every command and are placed before the subcommand name. | `--json` | n/a | Outputs results as JSON. | | `--debug` | n/a | Shows the full HTTP response on CLI errors. | -### `--since` format +### --since format `--since` accepts relative durations and absolute timestamps. diff --git a/docs/pages/core-concepts/apps/index.md b/docs/pages/core-concepts/apps/index.md index 0fac1090e..c155784f6 100644 --- a/docs/pages/core-concepts/apps/index.md +++ b/docs/pages/core-concepts/apps/index.md @@ -65,7 +65,7 @@ Every `AppConfig` includes two built-in fields: ### TOML Registration -The `hassette.toml` file registers each app and supplies its config values. See [Configuration: Applications](../../configuration/applications.md) for the full reference. +The `hassette.toml` file registers each app and supplies its config values. See [Configuration: Applications](../configuration/applications.md) for the full reference. ```toml --8<-- "pages/core-concepts/apps/snippets/app_config.toml" diff --git a/docs/pages/core-concepts/apps/task-bucket.md b/docs/pages/core-concepts/apps/task-bucket.md index 6401ece3b..fe789e0be 100644 --- a/docs/pages/core-concepts/apps/task-bucket.md +++ b/docs/pages/core-concepts/apps/task-bucket.md @@ -69,7 +69,7 @@ The polling loop runs indefinitely without blocking the handler that started it. ## Shutdown -The bucket cancels all tracked tasks when the app shuts down. Hassette cancels every pending task, waits up to `task_cancellation_timeout_seconds` (configurable in [global settings](../../configuration/global.md)) for them to finish, and logs warnings for any tasks that do not exit within the timeout. +The bucket cancels all tracked tasks when the app shuts down. Hassette cancels every pending task, waits up to `task_cancellation_timeout_seconds` (configurable in [global settings](../configuration/global.md)) for them to finish, and logs warnings for any tasks that do not exit within the timeout. Manual cleanup is not required. @@ -77,4 +77,4 @@ Manual cleanup is not required. - [Apps Overview](index.md) for core capabilities and common patterns - [Lifecycle](lifecycle.md) for when shutdown happens and in what order -- [App Cache](../../cache/index.md) for persisting data across restarts (the task bucket is for in-memory work only) +- [App Cache](../cache/index.md) for persisting data across restarts (the task bucket is for in-memory work only) diff --git a/docs/pages/core-concepts/bus/custom-extractors.md b/docs/pages/core-concepts/bus/custom-extractors.md index d27b7bbbf..ca43ebbfd 100644 --- a/docs/pages/core-concepts/bus/custom-extractors.md +++ b/docs/pages/core-concepts/bus/custom-extractors.md @@ -12,7 +12,7 @@ The built-in [`D.*`](dependency-injection.md) annotations cover state values, en --8<-- "pages/core-concepts/bus/snippets/filtering/custom_accessors.py" ``` -Accessors also compose with predicates. `P.ValueIs(source=A.get_service_data_key("entity_id"), condition="light.living_room")` filters a service call subscription to a specific target entity without any handler logic. The full predicate reference is in [Filtering](filtering/index.md). +Accessors also compose with predicates. `P.ValueIs(source=A.get_service_data_key("entity_id"), condition="light.living_room")` filters a service call subscription to a specific target entity without any handler logic. The full predicate reference is in [Filtering](filtering.md). ## Writing an Extractor @@ -71,6 +71,6 @@ The [Type Registry](../states/type-registry.md) provides built-in converters for ## See Also - [Dependency Injection](dependency-injection.md): built-in `D.*` annotations -- [Filtering](filtering/index.md): composing accessors with predicates +- [Filtering](filtering.md): composing accessors with predicates - [Type Registry](../states/type-registry.md): built-in type converters and how to register custom ones - [State Registry](../states/state-registry.md): domain-to-model mapping diff --git a/docs/pages/core-concepts/bus/dependency-injection.md b/docs/pages/core-concepts/bus/dependency-injection.md index 8f1b70259..0c04c42c5 100644 --- a/docs/pages/core-concepts/bus/dependency-injection.md +++ b/docs/pages/core-concepts/bus/dependency-injection.md @@ -54,7 +54,7 @@ Identity extractors resolve entity IDs and domains from events. | `D.EventContext` | `HassContext` | `None` | Home Assistant event context (user ID, parent/origin IDs) | | `D.TypedStateChangeEvent[T]` | `TypedStateChangeEvent[T]` | Always present | Full event with both old and new states typed | -`D.EventData[T]` pairs with [`Bus.emit`](../apps/index.md#broadcasting-events-between-apps). The emitting app sends a dataclass; the receiving handler annotates its parameter with the same type: +`D.EventData[T]` pairs with [`Bus.emit`](../../apps/index.md). The emitting app sends a dataclass; the receiving handler annotates its parameter with the same type: ```python --8<-- "pages/core-concepts/bus/snippets/dependency-injection/event_data_extractor.py" diff --git a/docs/pages/core-concepts/bus/filtering.md b/docs/pages/core-concepts/bus/filtering.md index f09b21c0f..a680f40b6 100644 --- a/docs/pages/core-concepts/bus/filtering.md +++ b/docs/pages/core-concepts/bus/filtering.md @@ -164,7 +164,7 @@ A dict passed to `where=` matches keys and values in the service data. ## Custom Accessors -[`A`](accessors.md) (accessors) point predicates at fields not directly exposed by the helper methods. `P.ValueIs` extracts a value with an accessor, then tests it against a condition. +[`A`](custom-extractors.md) (accessors) point predicates at fields not directly exposed by the helper methods. `P.ValueIs` extracts a value with an accessor, then tests it against a condition. ```python --8<-- "pages/core-concepts/bus/snippets/filtering/custom_accessors.py" diff --git a/docs/pages/core-concepts/configuration/index.md b/docs/pages/core-concepts/configuration/index.md index 7cba39962..4d1501ad6 100644 --- a/docs/pages/core-concepts/configuration/index.md +++ b/docs/pages/core-concepts/configuration/index.md @@ -30,7 +30,7 @@ The recommended approach is an environment variable or `.env` file so the token HASSETTE__TOKEN=your_long_lived_access_token ``` -`verify_ssl` controls certificate validation. Setting it to `false` allows connections to Home Assistant instances with self-signed certificates. [Create a Long-Lived Access Token](../../getting-started/ha-token.md) covers step-by-step token generation. +`verify_ssl` controls certificate validation. Setting it to `false` allows connections to Home Assistant instances with self-signed certificates. [Create a Long-Lived Access Token](../../getting-started/ha_token.md) covers step-by-step token generation. ## Configuration Sections @@ -58,7 +58,7 @@ App definitions live inside `[hassette.apps]` as named subsections: ## Design Notes -The [auto-generated `HassetteConfig` reference](../../reference/config/index.md) covers every field, its type, and its default. The notes below explain the "why" for fields where the field name alone does not make the intent obvious. +The `HassetteConfig` reference covers every field, its type, and its default. The notes below explain the "why" for fields where the field name alone does not make the intent obvious. ### Data Directory and Upgrades @@ -96,4 +96,4 @@ The StateManager keeps a local cache of entity states. `state_proxy_poll_interva ## Full Reference -The [HassetteConfig API reference](../../reference/config/index.md) lists every field with its type, default, and description. +The `HassetteConfig` API reference lists every field with its type, default, and description. diff --git a/docs/pages/core-concepts/states/index.md b/docs/pages/core-concepts/states/index.md index 970659352..a027dd92a 100644 --- a/docs/pages/core-concepts/states/index.md +++ b/docs/pages/core-concepts/states/index.md @@ -86,7 +86,7 @@ Every state object is a `BaseState` subclass. The following fields and propertie ## Built-in State Types -Hassette auto-generates typed state classes for 55 Home Assistant domains from HA core source. All classes are available from the [`states`](../../api-reference/states.md) module: +Hassette auto-generates typed state classes for 55 Home Assistant domains from HA core source. All classes are available from the `states` module: ```python --8<-- "pages/core-concepts/snippets/states_import.py" @@ -98,7 +98,7 @@ Three common examples: - **`states.SensorState`** has `value: str | None`, `attributes.unit_of_measurement: str | None`, `attributes.device_class: str | None` - **`states.BinarySensorState`** has `value: bool | None`, `attributes.device_class: str | None` -The [API reference](../../api-reference/states.md) lists all 55 classes with their full attribute signatures. Domains not covered there are handled by [Custom States](../../advanced/custom-states.md). +The API reference lists all 55 classes with their full attribute signatures. Domains not covered there are handled by [Custom States](../../advanced/custom-states.md). ??? info "Full domain-to-class table" | Domain | Class | diff --git a/docs/pages/core-concepts/states/type-registry.md b/docs/pages/core-concepts/states/type-registry.md index cd15c55e5..63a8b4db8 100644 --- a/docs/pages/core-concepts/states/type-registry.md +++ b/docs/pages/core-concepts/states/type-registry.md @@ -166,4 +166,4 @@ Custom error messages make failures easier to diagnose. The `{value}` placeholde - [State Registry](state-registry.md): domain-to-class mapping - [Custom States](custom-states.md): defining `value_type` on state models -- [Dependency Injection](../../bus/dependency-injection.md): type conversion in custom extractors +- [Dependency Injection](../bus/dependency-injection.md): type conversion in custom extractors diff --git a/docs/pages/getting-started/docker/index.md b/docs/pages/getting-started/docker/index.md index 29499a853..3fe816d97 100644 --- a/docs/pages/getting-started/docker/index.md +++ b/docs/pages/getting-started/docker/index.md @@ -68,7 +68,7 @@ Create `apps/my_app.py`: --8<-- "pages/getting-started/docker/snippets/my_app.py" ``` -[`App`](../core-concepts/apps/index.md) runs your automation logic and gives you access to the bus, scheduler, and API. [`AppConfig`](../core-concepts/apps/configuration.md) loads and validates your app's settings from the environment. `on_initialize` runs once when the app starts. +[`App`](../../core-concepts/apps/index.md) runs your automation logic and gives you access to the bus, scheduler, and API. [`AppConfig`](../../core-concepts/apps/configuration.md) loads and validates your app's settings from the environment. `on_initialize` runs once when the app starts. Restart the container to pick up the new file: diff --git a/docs/pages/getting-started/docker/snippets/deps-app-using-package.py b/docs/pages/getting-started/docker/snippets/deps-app-using-package.py index 2a4c0b772..f51ed9f5c 100644 --- a/docs/pages/getting-started/docker/snippets/deps-app-using-package.py +++ b/docs/pages/getting-started/docker/snippets/deps-app-using-package.py @@ -1,4 +1,4 @@ -import apprise +import apprise # pyright: ignore[reportMissingImports] from hassette import App, AppConfig diff --git a/docs/pages/recipes/debounce-sensor-changes.md b/docs/pages/recipes/debounce-sensor-changes.md index b8d07d5ea..a8107cf93 100644 --- a/docs/pages/recipes/debounce-sensor-changes.md +++ b/docs/pages/recipes/debounce-sensor-changes.md @@ -12,9 +12,9 @@ Your outdoor temperature sensor reports a reading every few seconds. On a warm a `debounce=10.0` tells the bus to hold the handler until the sensor has been quiet for 10 seconds. Each new event that arrives during that window resets the timer. Rapid fluctuations are silently discarded, and the handler fires exactly once when the readings stop. -`changed=C.Increased()` gates which events start the debounce timer in the first place. `C` is an alias for [`hassette.event_handling.conditions`](../../reference/event_handling/conditions.md), a module of value-comparison predicates. `C.Increased()` passes only when the new state value is numerically greater than the old one. Drops and unchanged readings never start the timer. +`changed=C.Increased()` gates which events start the debounce timer in the first place. `C` is an alias for [`hassette.event_handling.conditions`](../core-concepts/bus/filtering.md), a module of value-comparison predicates. `C.Increased()` passes only when the new state value is numerically greater than the old one. Drops and unchanged readings never start the timer. -`D.StateNew[states.SensorState]` is a dependency injection annotation. `D` is an alias for [`hassette.event_handling.dependencies`](../../reference/event_handling/dependencies.md), a module of type annotations that tell Hassette what to extract from each event. Hassette reads the annotation, extracts the new state from the event, and converts it to a `SensorState` object. The handler receives the typed state and converts `.value` to a float before comparing it to `THRESHOLD`. +`D.StateNew[states.SensorState]` is a dependency injection annotation. `D` is an alias for [`hassette.event_handling.dependencies`](../core-concepts/bus/dependency-injection.md), a module of type annotations that tell Hassette what to extract from each event. Hassette reads the annotation, extracts the new state from the event, and converts it to a `SensorState` object. The handler receives the typed state and converts `.value` to a float before comparing it to `THRESHOLD`. When the stabilized temperature meets or exceeds `THRESHOLD`, a log line records the crossing, the previous value, and the debounce duration. diff --git a/docs/pages/testing/concurrency.md b/docs/pages/testing/concurrency.md index 7f4303328..e3f88375c 100644 --- a/docs/pages/testing/concurrency.md +++ b/docs/pages/testing/concurrency.md @@ -42,7 +42,7 @@ Without `-n`, pytest runs sequentially in a single process. The marker has no ef ## pytest-asyncio Mode -`asyncio_mode = "auto"` is required. Without it, async tests silently pass without executing. The [Testing index](index.md#installation) covers setup and the false-green warning. +`asyncio_mode = "auto"` is required. Without it, async tests silently pass without executing. The [Testing index](index.md#install) covers setup and the false-green warning. ## Next Steps diff --git a/docs/pages/testing/harness.md b/docs/pages/testing/harness.md index f14c53544..4ec81cc17 100644 --- a/docs/pages/testing/harness.md +++ b/docs/pages/testing/harness.md @@ -44,7 +44,7 @@ handlers. ``` Typed dependency injection via `D.StateNew[T]` -([`hassette.dependencies`](../../reference/dependencies.md)) delivers the new +([`hassette.dependencies`](../core-concepts/bus/dependency-injection.md)) delivers the new state as a typed object: ```python @@ -87,7 +87,7 @@ handlers. --8<-- "pages/testing/snippets/testing_simulate_call_service.py" ``` -`D.Domain` ([`hassette.dependencies`](../../reference/dependencies.md)) +`D.Domain` ([`hassette.dependencies`](../core-concepts/bus/dependency-injection.md)) injects the service domain into handlers the same way `D.StateNew` works for state changes: diff --git a/docs/pages/troubleshooting.md b/docs/pages/troubleshooting.md index 40856b42e..fa756a241 100644 --- a/docs/pages/troubleshooting.md +++ b/docs/pages/troubleshooting.md @@ -6,7 +6,7 @@ **Connection refused or timeout.** Check `base_url` in `hassette.toml`. The default is `http://127.0.0.1:8123`. Include the scheme and port explicitly. Bare hostnames raise `SchemeRequiredInBaseUrlError` at startup. -**Running in Docker.** Hassette must reach Home Assistant's network. If Home Assistant runs in a separate container or on a different host, `base_url` must point to its actual address, not `127.0.0.1`. See [Docker Troubleshooting](getting-started/docker/troubleshooting.md#cant-reach-home-assistant). +**Running in Docker.** Hassette must reach Home Assistant's network. If Home Assistant runs in a separate container or on a different host, `base_url` must point to its actual address, not `127.0.0.1`. See [Docker Troubleshooting](getting-started/getting-started/docker/troubleshooting.md#cant-access-the-web-ui). **Invalid token at startup.** Look for `InvalidAuthError` in the startup log. This is fatal. Hassette will not retry. Generate a new long-lived token and update `HASSETTE__TOKEN`. @@ -62,7 +62,7 @@ Work through this checklist in order. **3. Domain excluded by config.** Check `bus_excluded_domains` and `bus_excluded_entities` in `hassette.toml`. Events from excluded domains are dropped before reaching any handler. -**4. Attribute-only change.** `on_state_change` fires when the main state value changes. If only an attribute changed (brightness, temperature, etc.) without the state string changing, the handler won't fire. Pass `changed=False` to receive both state and attribute changes. See [Filtering](core-concepts/bus/filtering.md#the-changed-parameter). +**4. Attribute-only change.** `on_state_change` fires when the main state value changes. If only an attribute changed (brightness, temperature, etc.) without the state string changing, the handler won't fire. Pass `changed=False` to receive both state and attribute changes. See [Filtering](core-concepts/core-concepts/bus/filtering.md#changedfalse). **5. App not enabled.** Check that the app's config block has `enabled = true` (the default). A disabled app's handlers are never registered. @@ -74,7 +74,7 @@ Work through this checklist in order. **Exception in the task.** Unhandled exceptions inside scheduled tasks are caught, logged at ERROR level, and swallowed. The scheduler keeps running. Check your logs for the traceback. -See also: [Job Management](core-concepts/scheduler/management.md#troubleshooting). +See also: [Job Management](core-concepts/core-concepts/scheduler/management.md#error-handling). ## Database Degraded / Telemetry Missing From 019b92ac9b0f046d565d2a5f1edb0532cb243a01 Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Wed, 3 Jun 2026 06:25:02 -0500 Subject: [PATCH 056/160] refactor(testing): make drain_task_bucket public MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename _drain_task_bucket → drain_task_bucket on SimulationMixin. The method is safe to call standalone and useful after trigger_due_jobs when dispatched jobs spawn bus events. The _ prefix was unjustified per project style rules. --- src/hassette/test_utils/simulation.py | 30 +++++++++++------------ src/hassette/test_utils/time_control.py | 2 +- tests/integration/test_drain_iterative.py | 10 ++++---- 3 files changed, 21 insertions(+), 21 deletions(-) diff --git a/src/hassette/test_utils/simulation.py b/src/hassette/test_utils/simulation.py index 934f1fb5b..8a9268d8f 100644 --- a/src/hassette/test_utils/simulation.py +++ b/src/hassette/test_utils/simulation.py @@ -1,6 +1,6 @@ """SimulationMixin — event simulation helpers for AppTestHarness. -Contains all ``simulate_*`` methods and the internal ``_drain_task_bucket`` +Contains all ``simulate_*`` methods and the ``drain_task_bucket`` machinery extracted from ``app_harness.py``. """ @@ -113,7 +113,7 @@ async def simulate_state_change( new_attrs=new_attrs, ) await harness.hassette.send_event(event) - await self._drain_task_bucket(timeout=timeout) + await self.drain_task_bucket(timeout=timeout) async def simulate_attribute_change( self, @@ -196,7 +196,7 @@ async def simulate_call_service( event = create_call_service_event(domain=domain, service=service, service_data=data) await harness.hassette.send_event(event) - await self._drain_task_bucket(timeout=timeout) + await self.drain_task_bucket(timeout=timeout) async def simulate_component_loaded( self, @@ -217,7 +217,7 @@ async def simulate_component_loaded( event = create_component_loaded_event(component) await harness.hassette.send_event(event) - await self._drain_task_bucket(timeout=timeout) + await self.drain_task_bucket(timeout=timeout) async def simulate_service_registered( self, @@ -240,7 +240,7 @@ async def simulate_service_registered( event = create_service_registered_event(domain, service) await harness.hassette.send_event(event) - await self._drain_task_bucket(timeout=timeout) + await self.drain_task_bucket(timeout=timeout) async def simulate_hassette_service_status( self, @@ -273,7 +273,7 @@ async def simulate_hassette_service_status( Note: Only ``app.task_bucket`` is drained. If handlers use ``debounce=`` or ``throttle=``, pass a ``timeout=`` larger than the debounce window. - See :meth:`_drain_task_bucket` for details. + See :meth:`drain_task_bucket` for details. """ harness = self.require_harness() @@ -287,7 +287,7 @@ async def simulate_hassette_service_status( ready_phase=ready_phase, ) await harness.hassette.send_event(event) - await self._drain_task_bucket(timeout=timeout) + await self.drain_task_bucket(timeout=timeout) async def simulate_hassette_service_ready( self, @@ -375,13 +375,13 @@ async def simulate_websocket_connected( Note: Only ``app.task_bucket`` is drained. If handlers use ``debounce=`` or ``throttle=``, pass a ``timeout=`` larger than the debounce window. - See :meth:`_drain_task_bucket` for details. + See :meth:`drain_task_bucket` for details. """ harness = self.require_harness() event = HassetteSimpleEvent.create_event(topic=Topic.HASSETTE_EVENT_WEBSOCKET_CONNECTED) await harness.hassette.send_event(event) - await self._drain_task_bucket(timeout=timeout) + await self.drain_task_bucket(timeout=timeout) async def simulate_websocket_disconnected( self, @@ -399,13 +399,13 @@ async def simulate_websocket_disconnected( Note: Only ``app.task_bucket`` is drained. If handlers use ``debounce=`` or ``throttle=``, pass a ``timeout=`` larger than the debounce window. - See :meth:`_drain_task_bucket` for details. + See :meth:`drain_task_bucket` for details. """ harness = self.require_harness() event = HassetteSimpleEvent.create_event(topic=Topic.HASSETTE_EVENT_WEBSOCKET_DISCONNECTED) await harness.hassette.send_event(event) - await self._drain_task_bucket(timeout=timeout) + await self.drain_task_bucket(timeout=timeout) async def simulate_app_state_changed( self, @@ -434,7 +434,7 @@ async def simulate_app_state_changed( Note: Only ``app.task_bucket`` is drained. If handlers use ``debounce=`` or ``throttle=``, pass a ``timeout=`` larger than the debounce window. - See :meth:`_drain_task_bucket` for details. + See :meth:`drain_task_bucket` for details. """ harness = self.require_harness() app = self._app @@ -448,7 +448,7 @@ async def simulate_app_state_changed( exception=exception, ) await harness.hassette.send_event(event) - await self._drain_task_bucket(timeout=timeout) + await self.drain_task_bucket(timeout=timeout) async def simulate_app_running( self, @@ -502,7 +502,7 @@ async def simulate_homeassistant_stop( """ await self.simulate_call_service("homeassistant", "stop", timeout=timeout) - async def _drain_task_bucket(self, *, timeout: float = DEFAULT_SIMULATE_TIMEOUT) -> None: + async def drain_task_bucket(self, *, timeout: float = DEFAULT_SIMULATE_TIMEOUT) -> None: """Wait until bus dispatch queue AND app task_bucket are jointly quiescent. Iterates: wait for bus dispatch idle, wait for task_bucket pending tasks, re-check. @@ -620,7 +620,7 @@ def _recorder(task: asyncio.Task, exc: BaseException) -> None: bus_pending = bus_service.task_bucket.pending_tasks() if bus_pending: LOGGER.warning( - "_drain_task_bucket: drain is quiescent for app.task_bucket, " + "drain_task_bucket: drain is quiescent for app.task_bucket, " "but %d bus-level task(s) are still pending and were NOT drained: %s. " "Register listeners through App.bus to include them in the drain.", len(bus_pending), diff --git a/src/hassette/test_utils/time_control.py b/src/hassette/test_utils/time_control.py index 8b6bece4e..82c820301 100644 --- a/src/hassette/test_utils/time_control.py +++ b/src/hassette/test_utils/time_control.py @@ -215,7 +215,7 @@ async def trigger_due_jobs(self) -> int: If scheduled jobs send events through the bus, downstream handler tasks are spawned but not drained by this method. Call a - ``simulate_*`` method or ``_drain_task_bucket`` afterward to ensure + ``simulate_*`` method or ``drain_task_bucket`` afterward to ensure handler tasks complete before asserting on side effects. Returns: diff --git a/tests/integration/test_drain_iterative.py b/tests/integration/test_drain_iterative.py index 996362a6d..84e53d170 100644 --- a/tests/integration/test_drain_iterative.py +++ b/tests/integration/test_drain_iterative.py @@ -317,19 +317,19 @@ def test_drain_error_message_multiple_exceptions() -> None: def test_drain_uses_public_accessors_not_private_attributes() -> None: """Drain implementation uses only public accessors from WP03, not private fields. - Introspects the source of _drain_task_bucket to confirm no direct access to + Introspects the source of drain_task_bucket to confirm no direct access to private BusService or TaskBucket internals. This acts as a regression guard against future refactoring that re-introduces private attribute access. """ - source = inspect.getsource(AppTestHarness._drain_task_bucket) + source = inspect.getsource(AppTestHarness.drain_task_bucket) assert "_dispatch_pending" not in source, ( - "_drain_task_bucket must not access bus_service._dispatch_pending directly; " + "drain_task_bucket must not access bus_service._dispatch_pending directly; " "use bus_service.dispatch_pending_count" ) assert "_dispatch_idle_event" not in source, ( - "_drain_task_bucket must not access bus_service._dispatch_idle_event directly; " + "drain_task_bucket must not access bus_service._dispatch_idle_event directly; " "use bus_service.is_dispatch_idle or await_dispatch_idle()" ) assert "task_bucket._tasks" not in source, ( - "_drain_task_bucket must not access app.task_bucket._tasks directly; use app.task_bucket.pending_tasks()" + "drain_task_bucket must not access app.task_bucket._tasks directly; use app.task_bucket.pending_tasks()" ) From 42cc5d52b9b0e59b0a1d1d830e3d7c4d100abdd1 Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Wed, 3 Jun 2026 06:25:11 -0500 Subject: [PATCH 057/160] fix(docs): address PR review comments and broken links - Fix 4 broken links: doubled path segments in troubleshooting.md, wrong relative depth in dependency-injection.md - Fix snippet orphan checker glob to find nested subdirectories - Add encoding='utf-8' to snippet orphan checker read_text() - Move class-level ScheduledJob defaults to instance attributes in 5 doc snippets (motion_lights, motion_lights_split, scheduler_self_cancel, scheduler_management_patterns, vacation_mode) - Remove _ prefix from vacation_mode.presence_job - Reference public drain_task_bucket in time-control.md --- docs/pages/core-concepts/bus/dependency-injection.md | 2 +- .../snippets/scheduler_management_patterns.py | 2 +- .../scheduler/snippets/scheduler_self_cancel.py | 2 +- docs/pages/recipes/snippets/motion_lights.py | 3 ++- docs/pages/recipes/snippets/motion_lights_split.py | 3 ++- docs/pages/recipes/snippets/vacation_mode.py | 11 ++++++----- docs/pages/testing/time-control.md | 2 +- docs/pages/troubleshooting.md | 6 +++--- tools/check_snippet_orphans.py | 4 ++-- 9 files changed, 19 insertions(+), 16 deletions(-) diff --git a/docs/pages/core-concepts/bus/dependency-injection.md b/docs/pages/core-concepts/bus/dependency-injection.md index 0c04c42c5..84f467cf4 100644 --- a/docs/pages/core-concepts/bus/dependency-injection.md +++ b/docs/pages/core-concepts/bus/dependency-injection.md @@ -54,7 +54,7 @@ Identity extractors resolve entity IDs and domains from events. | `D.EventContext` | `HassContext` | `None` | Home Assistant event context (user ID, parent/origin IDs) | | `D.TypedStateChangeEvent[T]` | `TypedStateChangeEvent[T]` | Always present | Full event with both old and new states typed | -`D.EventData[T]` pairs with [`Bus.emit`](../../apps/index.md). The emitting app sends a dataclass; the receiving handler annotates its parameter with the same type: +`D.EventData[T]` pairs with [`Bus.emit`](../apps/index.md). The emitting app sends a dataclass; the receiving handler annotates its parameter with the same type: ```python --8<-- "pages/core-concepts/bus/snippets/dependency-injection/event_data_extractor.py" diff --git a/docs/pages/core-concepts/scheduler/snippets/scheduler_management_patterns.py b/docs/pages/core-concepts/scheduler/snippets/scheduler_management_patterns.py index 8c8905f4f..1c70b928e 100644 --- a/docs/pages/core-concepts/scheduler/snippets/scheduler_management_patterns.py +++ b/docs/pages/core-concepts/scheduler/snippets/scheduler_management_patterns.py @@ -3,7 +3,7 @@ class ManagementPatternApp(App[AppConfig]): - my_job: ScheduledJob | None = None + my_job: ScheduledJob | None async def on_initialize(self) -> None: self.my_job = await self.scheduler.run_every(self.check_sensors, minutes=5, group="morning") diff --git a/docs/pages/core-concepts/scheduler/snippets/scheduler_self_cancel.py b/docs/pages/core-concepts/scheduler/snippets/scheduler_self_cancel.py index 2dc07af52..a611fd742 100644 --- a/docs/pages/core-concepts/scheduler/snippets/scheduler_self_cancel.py +++ b/docs/pages/core-concepts/scheduler/snippets/scheduler_self_cancel.py @@ -3,7 +3,7 @@ class PollApp(App[AppConfig]): - poll_job: ScheduledJob | None = None + poll_job: ScheduledJob | None async def on_initialize(self): self.poll_job = await self.scheduler.run_every( diff --git a/docs/pages/recipes/snippets/motion_lights.py b/docs/pages/recipes/snippets/motion_lights.py index 063f8689f..a3878fe74 100644 --- a/docs/pages/recipes/snippets/motion_lights.py +++ b/docs/pages/recipes/snippets/motion_lights.py @@ -11,9 +11,10 @@ class MotionLightsConfig(AppConfig): class MotionLights(App[MotionLightsConfig]): - off_job: ScheduledJob | None = None + off_job: ScheduledJob | None async def on_initialize(self) -> None: + self.off_job = None await self.bus.on_state_change( self.app_config.motion_sensor, handler=self.on_motion, diff --git a/docs/pages/recipes/snippets/motion_lights_split.py b/docs/pages/recipes/snippets/motion_lights_split.py index 4a93581cd..c43e8f59e 100644 --- a/docs/pages/recipes/snippets/motion_lights_split.py +++ b/docs/pages/recipes/snippets/motion_lights_split.py @@ -11,9 +11,10 @@ class MotionLightsConfig(AppConfig): class MotionLights(App[MotionLightsConfig]): - off_job: ScheduledJob | None = None + off_job: ScheduledJob | None async def on_initialize(self) -> None: + self.off_job = None # --8<-- [start:split_handlers] await self.bus.on_state_change( self.app_config.motion_sensor, diff --git a/docs/pages/recipes/snippets/vacation_mode.py b/docs/pages/recipes/snippets/vacation_mode.py index f9d6b4e6e..950667c55 100644 --- a/docs/pages/recipes/snippets/vacation_mode.py +++ b/docs/pages/recipes/snippets/vacation_mode.py @@ -16,9 +16,10 @@ class VacationModeConfig(AppConfig): class VacationMode(App[VacationModeConfig]): - _presence_job: ScheduledJob | None = None + presence_job: ScheduledJob | None async def on_initialize(self) -> None: + self.presence_job = None await self.bus.on_state_change( self.app_config.vacation_toggle, changed_to="on", @@ -34,7 +35,7 @@ async def on_initialize(self) -> None: async def on_vacation_start(self) -> None: self.logger.info("Vacation mode enabled — starting presence simulation") - self._presence_job = await self.scheduler.run_every( + self.presence_job = await self.scheduler.run_every( self.simulate_presence, seconds=self.app_config.check_interval, name=PRESENCE_JOB_NAME, @@ -42,9 +43,9 @@ async def on_vacation_start(self) -> None: async def on_vacation_end(self) -> None: self.logger.info("Vacation mode disabled — stopping presence simulation") - if self._presence_job is not None: - self._presence_job.cancel() - self._presence_job = None + if self.presence_job is not None: + self.presence_job.cancel() + self.presence_job = None for light in self.app_config.lights: await self.api.turn_off(light, domain="light") diff --git a/docs/pages/testing/time-control.md b/docs/pages/testing/time-control.md index 23126198e..112f0ad6d 100644 --- a/docs/pages/testing/time-control.md +++ b/docs/pages/testing/time-control.md @@ -44,7 +44,7 @@ Calling `freeze_time` again replaces the frozen time. The old patchers stop and `trigger_due_jobs` operates on a snapshot of due jobs taken at the moment of the call. Jobs re-enqueued during dispatch (repeating jobs) are not included in that snapshot and are not re-triggered in the same call. This prevents infinite loops when the clock is frozen. -If dispatched jobs send events through the bus, downstream handler tasks are spawned but not drained by `trigger_due_jobs`. A `simulate_*` call or `_drain_task_bucket` call afterward drains those handler tasks before assertions run. +If dispatched jobs send events through the bus, downstream handler tasks are spawned but not drained by `trigger_due_jobs`. Call `drain_task_bucket` afterward to wait for those handler tasks to complete before assertions run. ## Next Steps diff --git a/docs/pages/troubleshooting.md b/docs/pages/troubleshooting.md index fa756a241..eeb6338bf 100644 --- a/docs/pages/troubleshooting.md +++ b/docs/pages/troubleshooting.md @@ -6,7 +6,7 @@ **Connection refused or timeout.** Check `base_url` in `hassette.toml`. The default is `http://127.0.0.1:8123`. Include the scheme and port explicitly. Bare hostnames raise `SchemeRequiredInBaseUrlError` at startup. -**Running in Docker.** Hassette must reach Home Assistant's network. If Home Assistant runs in a separate container or on a different host, `base_url` must point to its actual address, not `127.0.0.1`. See [Docker Troubleshooting](getting-started/getting-started/docker/troubleshooting.md#cant-access-the-web-ui). +**Running in Docker.** Hassette must reach Home Assistant's network. If Home Assistant runs in a separate container or on a different host, `base_url` must point to its actual address, not `127.0.0.1`. See [Docker Troubleshooting](getting-started/docker/troubleshooting.md#cant-access-the-web-ui). **Invalid token at startup.** Look for `InvalidAuthError` in the startup log. This is fatal. Hassette will not retry. Generate a new long-lived token and update `HASSETTE__TOKEN`. @@ -62,7 +62,7 @@ Work through this checklist in order. **3. Domain excluded by config.** Check `bus_excluded_domains` and `bus_excluded_entities` in `hassette.toml`. Events from excluded domains are dropped before reaching any handler. -**4. Attribute-only change.** `on_state_change` fires when the main state value changes. If only an attribute changed (brightness, temperature, etc.) without the state string changing, the handler won't fire. Pass `changed=False` to receive both state and attribute changes. See [Filtering](core-concepts/core-concepts/bus/filtering.md#changedfalse). +**4. Attribute-only change.** `on_state_change` fires when the main state value changes. If only an attribute changed (brightness, temperature, etc.) without the state string changing, the handler won't fire. Pass `changed=False` to receive both state and attribute changes. See [Filtering](core-concepts/bus/filtering.md#changedfalse). **5. App not enabled.** Check that the app's config block has `enabled = true` (the default). A disabled app's handlers are never registered. @@ -74,7 +74,7 @@ Work through this checklist in order. **Exception in the task.** Unhandled exceptions inside scheduled tasks are caught, logged at ERROR level, and swallowed. The scheduler keeps running. Check your logs for the traceback. -See also: [Job Management](core-concepts/core-concepts/scheduler/management.md#error-handling). +See also: [Job Management](core-concepts/scheduler/management.md#error-handling). ## Database Degraded / Telemetry Missing diff --git a/tools/check_snippet_orphans.py b/tools/check_snippet_orphans.py index d5640ee57..22dda5ee0 100755 --- a/tools/check_snippet_orphans.py +++ b/tools/check_snippet_orphans.py @@ -25,7 +25,7 @@ def find_snippet_files() -> set[Path]: results: set[Path] = set() - for path in DOCS_DIR.rglob("*/snippets/*"): + for path in DOCS_DIR.rglob("snippets/**/*"): if path.is_file(): results.add(path) return results @@ -34,7 +34,7 @@ def find_snippet_files() -> set[Path]: def find_referenced_paths() -> set[Path]: referenced: set[Path] = set() for md_file in DOCS_DIR.rglob("*.md"): - for match in INCLUDE_RE.finditer(md_file.read_text()): + for match in INCLUDE_RE.finditer(md_file.read_text(encoding="utf-8")): raw = match.group(1) file_part = raw.split(":")[0] referenced.add((DOCS_DIR / file_part).resolve()) From 7acbfb4727516be542695f021610fa9501142164 Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Wed, 3 Jun 2026 06:28:11 -0500 Subject: [PATCH 058/160] fix(docs): add explicit anchor for --since-format heading MkDocs strips a leading dash from the auto-generated slug, producing #-since-format instead of #--since-format. --- docs/pages/cli/commands.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/pages/cli/commands.md b/docs/pages/cli/commands.md index f369d8537..55690e8c4 100644 --- a/docs/pages/cli/commands.md +++ b/docs/pages/cli/commands.md @@ -359,7 +359,7 @@ These flags apply to every command and are placed before the subcommand name. | `--json` | n/a | Outputs results as JSON. | | `--debug` | n/a | Shows the full HTTP response on CLI errors. | -### --since format +### --since format { #--since-format } `--since` accepts relative durations and absolute timestamps. From f92e274e8b12aadc20bb813436932161f5b62da5 Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Wed, 3 Jun 2026 06:37:56 -0500 Subject: [PATCH 059/160] chore(docs): delete orphaned pages and snippets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove 12 orphaned web-ui pages (old tab-mirroring structure replaced by task-oriented pages in T09), hassette-vs-ha-yaml.md (unreferenced), and 28 orphaned snippet files confirmed unreferenced by any --8<-- include. Redirect index.md link to migration guide. Snippet orphan checker now exits 0 (301→300 referenced, 0 orphaned). mkdocs build --strict passes with zero warnings. --- docs/index.md | 3 +- .../apps/snippets/apps_run_in_thread.py | 12 -- .../bus/snippets/bus_duration_hold.py | 46 ---- .../bus/snippets/bus_immediate_fire.py | 31 --- .../snippets/bus_subscribe_state_change.py | 24 --- .../builtin_conversions_explicit.py | 26 --- .../builtin_conversions_implicit.py | 17 -- .../bypass_conversion_any.py | 14 -- .../bypass_conversion_custom.py | 19 -- .../dependency-injection/error_handling.py | 12 -- .../note_changed_condition.py | 18 -- .../dependency-injection/other_extractors.py | 14 -- .../dependency-injection/pattern1_raw.py | 10 - .../dependency-injection/pattern2_typed.py | 13 -- .../dependency-injection/pattern3_di.py | 7 - .../bus/snippets/handlers_custom_args.py | 25 --- .../handlers_multiple_dependencies.py | 18 -- .../scheduler/snippets/scheduler_naming.py | 13 -- .../snippets/scheduler_start_examples.py | 21 -- .../docker/snippets/hassette.toml | 14 -- .../getting-started/hassette-vs-ha-yaml.md | 77 ------- .../snippets/first_automation_step1.py | 7 - .../snippets/first_automation_step2.py | 10 - .../snippets/first_automation_step3_raw.py | 23 -- .../getting-started/snippets/hassette.toml | 14 -- .../snippets/project_layout.sh | 1 - docs/pages/getting-started/snippets/run.sh | 1 - .../getting-started/snippets/run_explicit.sh | 1 - .../getting-started/snippets/typed_handler.py | 11 - .../snippets/api_migration_getting_states.py | 25 --- docs/pages/web-ui/app-detail/code.md | 62 ------ docs/pages/web-ui/app-detail/config.md | 71 ------ docs/pages/web-ui/app-detail/handlers.md | 202 ------------------ docs/pages/web-ui/app-detail/index.md | 101 --------- docs/pages/web-ui/app-detail/logs.md | 57 ----- docs/pages/web-ui/app-detail/overview.md | 90 -------- .../snippets/handler_registration.py | 11 - docs/pages/web-ui/apps.md | 118 ---------- docs/pages/web-ui/config.md | 45 ---- docs/pages/web-ui/handlers.md | 65 ------ docs/pages/web-ui/layout.md | 99 --------- 41 files changed, 1 insertion(+), 1447 deletions(-) delete mode 100644 docs/pages/core-concepts/apps/snippets/apps_run_in_thread.py delete mode 100644 docs/pages/core-concepts/bus/snippets/bus_duration_hold.py delete mode 100644 docs/pages/core-concepts/bus/snippets/bus_immediate_fire.py delete mode 100644 docs/pages/core-concepts/bus/snippets/bus_subscribe_state_change.py delete mode 100644 docs/pages/core-concepts/bus/snippets/dependency-injection/builtin_conversions_explicit.py delete mode 100644 docs/pages/core-concepts/bus/snippets/dependency-injection/builtin_conversions_implicit.py delete mode 100644 docs/pages/core-concepts/bus/snippets/dependency-injection/bypass_conversion_any.py delete mode 100644 docs/pages/core-concepts/bus/snippets/dependency-injection/bypass_conversion_custom.py delete mode 100644 docs/pages/core-concepts/bus/snippets/dependency-injection/error_handling.py delete mode 100644 docs/pages/core-concepts/bus/snippets/dependency-injection/note_changed_condition.py delete mode 100644 docs/pages/core-concepts/bus/snippets/dependency-injection/other_extractors.py delete mode 100644 docs/pages/core-concepts/bus/snippets/dependency-injection/pattern1_raw.py delete mode 100644 docs/pages/core-concepts/bus/snippets/dependency-injection/pattern2_typed.py delete mode 100644 docs/pages/core-concepts/bus/snippets/dependency-injection/pattern3_di.py delete mode 100644 docs/pages/core-concepts/bus/snippets/handlers_custom_args.py delete mode 100644 docs/pages/core-concepts/bus/snippets/handlers_multiple_dependencies.py delete mode 100644 docs/pages/core-concepts/scheduler/snippets/scheduler_naming.py delete mode 100644 docs/pages/core-concepts/scheduler/snippets/scheduler_start_examples.py delete mode 100644 docs/pages/getting-started/docker/snippets/hassette.toml delete mode 100644 docs/pages/getting-started/hassette-vs-ha-yaml.md delete mode 100644 docs/pages/getting-started/snippets/first_automation_step1.py delete mode 100644 docs/pages/getting-started/snippets/first_automation_step2.py delete mode 100644 docs/pages/getting-started/snippets/first_automation_step3_raw.py delete mode 100644 docs/pages/getting-started/snippets/hassette.toml delete mode 100644 docs/pages/getting-started/snippets/project_layout.sh delete mode 100644 docs/pages/getting-started/snippets/run.sh delete mode 100644 docs/pages/getting-started/snippets/run_explicit.sh delete mode 100644 docs/pages/getting-started/snippets/typed_handler.py delete mode 100644 docs/pages/migration/snippets/api_migration_getting_states.py delete mode 100644 docs/pages/web-ui/app-detail/code.md delete mode 100644 docs/pages/web-ui/app-detail/config.md delete mode 100644 docs/pages/web-ui/app-detail/handlers.md delete mode 100644 docs/pages/web-ui/app-detail/index.md delete mode 100644 docs/pages/web-ui/app-detail/logs.md delete mode 100644 docs/pages/web-ui/app-detail/overview.md delete mode 100644 docs/pages/web-ui/app-detail/snippets/handler_registration.py delete mode 100644 docs/pages/web-ui/apps.md delete mode 100644 docs/pages/web-ui/config.md delete mode 100644 docs/pages/web-ui/handlers.md delete mode 100644 docs/pages/web-ui/layout.md diff --git a/docs/index.md b/docs/index.md index 3e888b995..b2a676bb7 100644 --- a/docs/index.md +++ b/docs/index.md @@ -10,7 +10,7 @@ Hassette lets you write Home Assistant automations as Python classes instead of If you know Python, think of it as FastAPI-style dependency injection for Home Assistant events — handlers declare the data they need, and Hassette extracts it automatically from the event stream. -**Who it's for:** Python developers who have outgrown YAML automations — automations with complex logic, shared state, unit tests, or a need for type safety. Not sure if Hassette is right for you? See [Is Hassette Right for You?](pages/getting-started/hassette-vs-ha-yaml.md) +**Who it's for:** Python developers who have outgrown YAML automations — automations with complex logic, shared state, unit tests, or a need for type safety. Coming from AppDaemon or HA YAML? See the [Migration Guide](pages/migration/index.md). ## Why Hassette? @@ -84,7 +84,6 @@ See the [Migration Guide](pages/migration/index.md) for a concept-by-concept com ## Next steps -- **Is Hassette right for you?** [Is Hassette Right for You?](pages/getting-started/hassette-vs-ha-yaml.md) - **Local setup:** [Quickstart](pages/getting-started/index.md) - **Production:** [Docker Deployment](pages/getting-started/docker/index.md) - **Architecture overview:** [Core Concepts](pages/core-concepts/index.md) diff --git a/docs/pages/core-concepts/apps/snippets/apps_run_in_thread.py b/docs/pages/core-concepts/apps/snippets/apps_run_in_thread.py deleted file mode 100644 index 239d51994..000000000 --- a/docs/pages/core-concepts/apps/snippets/apps_run_in_thread.py +++ /dev/null @@ -1,12 +0,0 @@ -from hassette import App, AppConfig - - -class ThreadApp(App[AppConfig]): - async def on_initialize(self): - # --8<-- [start:run_in_thread] - # Run a blocking function without freezing the event loop - result = await self.task_bucket.run_in_thread(self.expensive_sync_call) - # --8<-- [end:run_in_thread] - - def expensive_sync_call(self) -> str: - return "result" diff --git a/docs/pages/core-concepts/bus/snippets/bus_duration_hold.py b/docs/pages/core-concepts/bus/snippets/bus_duration_hold.py deleted file mode 100644 index f5552b5f1..000000000 --- a/docs/pages/core-concepts/bus/snippets/bus_duration_hold.py +++ /dev/null @@ -1,46 +0,0 @@ -from hassette import App, AppConfig - - -class DurationHoldApp(App[AppConfig]): - async def on_initialize(self): - # --8<-- [start:duration_hold] - # Only fire if motion stays on for 30 continuous seconds - await self.bus.on_state_change( - "binary_sensor.motion", - handler=self.on_sustained_motion, - changed_to="on", - duration=30, - name="motion_sustained", - ) - - # Fire once after the door has been open for 5 minutes - await self.bus.on_state_change( - "binary_sensor.front_door", - handler=self.on_door_left_open, - changed_to="on", - duration=300, - once=True, - name="front_door_open_long", - ) - - # Restart-resilient: if the light was already on when the app started - # and has been on for more than 10 minutes, fire immediately. - # If it has been on for less, start a timer for the remaining time. - await self.bus.on_state_change( - "light.porch", - handler=self.on_porch_on_too_long, - changed_to="on", - duration=600, - immediate=True, - name="porch_on_too_long", - ) - # --8<-- [end:duration_hold] - - async def on_sustained_motion(self): - pass - - async def on_door_left_open(self): - pass - - async def on_porch_on_too_long(self): - pass diff --git a/docs/pages/core-concepts/bus/snippets/bus_immediate_fire.py b/docs/pages/core-concepts/bus/snippets/bus_immediate_fire.py deleted file mode 100644 index 2fe03758e..000000000 --- a/docs/pages/core-concepts/bus/snippets/bus_immediate_fire.py +++ /dev/null @@ -1,31 +0,0 @@ -from hassette import App, AppConfig - - -class ImmediateFireApp(App[AppConfig]): - async def on_initialize(self): - # --8<-- [start:immediate_fire] - # Fire now if the light is already on, then continue listening - await self.bus.on_state_change( - "light.living_room", - handler=self.on_light_on, - changed_to="on", - immediate=True, - name="living_room_light_on", - ) - - # Combine with once=True: fire at most once, immediately if already matching - await self.bus.on_state_change( - "input_boolean.setup_complete", - handler=self.on_setup_done, - changed_to="on", - immediate=True, - once=True, - name="setup_complete", - ) - # --8<-- [end:immediate_fire] - - async def on_light_on(self): - pass - - async def on_setup_done(self): - pass diff --git a/docs/pages/core-concepts/bus/snippets/bus_subscribe_state_change.py b/docs/pages/core-concepts/bus/snippets/bus_subscribe_state_change.py deleted file mode 100644 index b205a83e3..000000000 --- a/docs/pages/core-concepts/bus/snippets/bus_subscribe_state_change.py +++ /dev/null @@ -1,24 +0,0 @@ -from hassette import App, AppConfig -from hassette.bus import Subscription - - -class MotionApp(App[AppConfig]): - sub: Subscription - - async def on_initialize(self): - # --8<-- [start:subscribe] - # Subscribe to state changes - sub = await self.bus.on_state_change( - "binary_sensor.motion", - handler=self.on_motion, - changed_to="on", - name="motion_on", - ) - - # Subscriptions are cleaned up automatically on shutdown. - # Unsubscribe manually only if you need to stop earlier: - # sub.cancel() - # --8<-- [end:subscribe] - - async def on_motion(self): - pass diff --git a/docs/pages/core-concepts/bus/snippets/dependency-injection/builtin_conversions_explicit.py b/docs/pages/core-concepts/bus/snippets/dependency-injection/builtin_conversions_explicit.py deleted file mode 100644 index 8e5084e09..000000000 --- a/docs/pages/core-concepts/bus/snippets/dependency-injection/builtin_conversions_explicit.py +++ /dev/null @@ -1,26 +0,0 @@ -from datetime import datetime -from decimal import Decimal -from typing import Annotated - -from hassette import A, App - - -class SensorApp(App): - async def on_sensor_change( - self, - # String "23.5" → float 23.5 - temperature: Annotated[float, A.get_attr_new("temperature")], - # String "99" → int 99 - battery: Annotated[int | None, A.get_attr_new("battery_level")], - # String "0.1234" → Decimal("0.1234") (high precision) - precise_value: Annotated[Decimal | None, A.get_attr_new("value")], - # ISO string → datetime object - last_seen: Annotated[datetime | None, A.get_attr_new("last_seen")], - ): - self.logger.info( - "Temp: %.1f°C, Battery: %d%%, Precise: %s, Last seen: %s", - temperature, - battery or 0, - precise_value, - last_seen, - ) diff --git a/docs/pages/core-concepts/bus/snippets/dependency-injection/builtin_conversions_implicit.py b/docs/pages/core-concepts/bus/snippets/dependency-injection/builtin_conversions_implicit.py deleted file mode 100644 index 56bb454db..000000000 --- a/docs/pages/core-concepts/bus/snippets/dependency-injection/builtin_conversions_implicit.py +++ /dev/null @@ -1,17 +0,0 @@ -from typing import Annotated - -from hassette import A, App - - -class LightApp(App): - async def on_light_change( - self, - # Extractor returns string "200" from HA - # TypeRegistry automatically converts to int - brightness: Annotated[int | None, A.get_attr_new("brightness")], - # Extractor returns string "on" from HA - # TypeRegistry automatically converts to bool - is_on: Annotated[bool, A.get_state_value_new], - ): - if is_on and brightness and brightness > 200: - self.logger.info("Light is very bright: %d", brightness) diff --git a/docs/pages/core-concepts/bus/snippets/dependency-injection/bypass_conversion_any.py b/docs/pages/core-concepts/bus/snippets/dependency-injection/bypass_conversion_any.py deleted file mode 100644 index 9dfb4c79f..000000000 --- a/docs/pages/core-concepts/bus/snippets/dependency-injection/bypass_conversion_any.py +++ /dev/null @@ -1,14 +0,0 @@ -from typing import Annotated, Any - -from hassette import A, App - - -class RawApp(App): - async def handler( - self, - # No conversion - receive raw value - raw_value: Annotated[Any, A.get_attr_new("brightness")], - ): - # Handle conversion yourself - brightness = int(raw_value) if raw_value else None - self.logger.info("Brightness: %s", brightness) diff --git a/docs/pages/core-concepts/bus/snippets/dependency-injection/bypass_conversion_custom.py b/docs/pages/core-concepts/bus/snippets/dependency-injection/bypass_conversion_custom.py deleted file mode 100644 index 0e5bdffcc..000000000 --- a/docs/pages/core-concepts/bus/snippets/dependency-injection/bypass_conversion_custom.py +++ /dev/null @@ -1,19 +0,0 @@ -from typing import Annotated, Any - -from hassette import A -from hassette.event_handling.dependencies import AnnotationDetails - - -def my_converter(value: Any, _: type) -> int | None: - if value is None: - return None - return int(value) * 100 - - -BrightnessPercent = Annotated[ - int, - AnnotationDetails( - extractor=A.get_attr_new("brightness"), - converter=my_converter, - ), -] diff --git a/docs/pages/core-concepts/bus/snippets/dependency-injection/error_handling.py b/docs/pages/core-concepts/bus/snippets/dependency-injection/error_handling.py deleted file mode 100644 index a63b052fb..000000000 --- a/docs/pages/core-concepts/bus/snippets/dependency-injection/error_handling.py +++ /dev/null @@ -1,12 +0,0 @@ -from typing import Annotated - -from hassette import A, App - - -class ErrorApp(App): - async def handler(self, value: Annotated[int, A.get_attr_new("invalid_field")]): - pass - - # Listener error (topic=hass.event.state_changed): Handler 'my_project.main.ErrorApp.handler' - - # failed to convert parameter 'value' of type 'FalseySentinel' to type 'int': Unable to convert - # to diff --git a/docs/pages/core-concepts/bus/snippets/dependency-injection/note_changed_condition.py b/docs/pages/core-concepts/bus/snippets/dependency-injection/note_changed_condition.py deleted file mode 100644 index 16aef7663..000000000 --- a/docs/pages/core-concepts/bus/snippets/dependency-injection/note_changed_condition.py +++ /dev/null @@ -1,18 +0,0 @@ -from hassette import App - - -class NoteChangedConditionApp(App): - async def on_initialize(self): - # --8<-- [start:note_changed] - # `old` and `new` are the raw state strings (e.g. "on", "off", "unknown"). - # This example only fires when the state was previously known and actually changed. - await self.bus.on_state_change( - entity_id="light.office", - handler=self.on_light_change, - changed=lambda old, new: old is not None and new is not None and old != new, - name="office_light_change", - ) - # --8<-- [end:note_changed] - - async def on_light_change(self): - pass diff --git a/docs/pages/core-concepts/bus/snippets/dependency-injection/other_extractors.py b/docs/pages/core-concepts/bus/snippets/dependency-injection/other_extractors.py deleted file mode 100644 index 789cb1b93..000000000 --- a/docs/pages/core-concepts/bus/snippets/dependency-injection/other_extractors.py +++ /dev/null @@ -1,14 +0,0 @@ -from hassette import App, D, states - - -class LightApp(App): - async def on_light_change( - self, - new_state: D.StateNew[states.LightState], - context: D.EventContext, - ): - self.logger.info( - "Light %s changed by user %s", - new_state.entity_id, - context.user_id or "system", - ) diff --git a/docs/pages/core-concepts/bus/snippets/dependency-injection/pattern1_raw.py b/docs/pages/core-concepts/bus/snippets/dependency-injection/pattern1_raw.py deleted file mode 100644 index 651bf1951..000000000 --- a/docs/pages/core-concepts/bus/snippets/dependency-injection/pattern1_raw.py +++ /dev/null @@ -1,10 +0,0 @@ -from hassette import App -from hassette.events import RawStateChangeEvent - - -class MotionApp(App): - async def on_motion(self, event: RawStateChangeEvent): - entity_id = event.payload.data.entity_id - new_state_dict = event.payload.data.new_state - state_value = new_state_dict.get("state") if new_state_dict else None - self.logger.info("Motion: %s -> %s", entity_id, state_value) diff --git a/docs/pages/core-concepts/bus/snippets/dependency-injection/pattern2_typed.py b/docs/pages/core-concepts/bus/snippets/dependency-injection/pattern2_typed.py deleted file mode 100644 index ba64dfdb1..000000000 --- a/docs/pages/core-concepts/bus/snippets/dependency-injection/pattern2_typed.py +++ /dev/null @@ -1,13 +0,0 @@ -from hassette import App, D, states - - -class MotionApp(App): - async def on_motion( - self, - event: D.TypedStateChangeEvent[states.BinarySensorState], - ): - entity_id = event.payload.data.entity_id - new_state = event.payload.data.new_state - if new_state: - state_value = new_state.value - self.logger.info("Motion: %s -> %s", entity_id, state_value) diff --git a/docs/pages/core-concepts/bus/snippets/dependency-injection/pattern3_di.py b/docs/pages/core-concepts/bus/snippets/dependency-injection/pattern3_di.py deleted file mode 100644 index 20023901a..000000000 --- a/docs/pages/core-concepts/bus/snippets/dependency-injection/pattern3_di.py +++ /dev/null @@ -1,7 +0,0 @@ -from hassette import App, D, states - - -class MotionApp(App): - async def on_motion(self, new_state: D.StateNew[states.BinarySensorState], entity_id: D.EntityId): - friendly_name = new_state.attributes.friendly_name or entity_id - self.logger.info("Motion detected: %s", friendly_name) diff --git a/docs/pages/core-concepts/bus/snippets/handlers_custom_args.py b/docs/pages/core-concepts/bus/snippets/handlers_custom_args.py deleted file mode 100644 index dea7615f0..000000000 --- a/docs/pages/core-concepts/bus/snippets/handlers_custom_args.py +++ /dev/null @@ -1,25 +0,0 @@ -from hassette import App, D, states - - -class TempApp(App): - async def on_initialize(self): - await self.bus.on_attribute_change( - "climate.thermostat", - "temperature", - handler=self.on_temp_change, - kwargs={"threshold": 75.0}, - name="thermostat_temp", - ) - - async def on_temp_change(self, new_state: D.StateNew[states.ClimateState], threshold: float): - """Handle temperature changes and log if above threshold.""" - if new_state.attributes.target_temperature is None: - self.logger.warning("No temperature attribute found") - return - - if new_state.attributes.target_temperature > threshold: - self.logger.warning( - "Temperature %.1f exceeds threshold %.1f", - new_state.attributes.target_temperature, - threshold, - ) diff --git a/docs/pages/core-concepts/bus/snippets/handlers_multiple_dependencies.py b/docs/pages/core-concepts/bus/snippets/handlers_multiple_dependencies.py deleted file mode 100644 index 8af1c449a..000000000 --- a/docs/pages/core-concepts/bus/snippets/handlers_multiple_dependencies.py +++ /dev/null @@ -1,18 +0,0 @@ -from hassette import App, D, states - - -class ClimateApp(App): - async def on_climate_change( - self, - new_state: D.StateNew[states.ClimateState], - old_state: D.MaybeStateOld[states.ClimateState], - entity_id: D.EntityId, - ): - old_temp = old_state.attributes.current_temperature if old_state else "N/A" - new_temp = new_state.attributes.current_temperature - self.logger.info( - "Climate %s temperature changed: %s -> %s", - entity_id, - old_temp, - new_temp, - ) diff --git a/docs/pages/core-concepts/scheduler/snippets/scheduler_naming.py b/docs/pages/core-concepts/scheduler/snippets/scheduler_naming.py deleted file mode 100644 index d321a25f5..000000000 --- a/docs/pages/core-concepts/scheduler/snippets/scheduler_naming.py +++ /dev/null @@ -1,13 +0,0 @@ -from hassette import App, AppConfig - - -class NamedJobApp(App[AppConfig]): - async def on_initialize(self): - await self.scheduler.run_every( - self.tick, - seconds=60, - name="heartbeat_monitor", - ) - - async def tick(self): - pass diff --git a/docs/pages/core-concepts/scheduler/snippets/scheduler_start_examples.py b/docs/pages/core-concepts/scheduler/snippets/scheduler_start_examples.py deleted file mode 100644 index c2c2c42f0..000000000 --- a/docs/pages/core-concepts/scheduler/snippets/scheduler_start_examples.py +++ /dev/null @@ -1,21 +0,0 @@ -from hassette import App, AppConfig - - -class StartParamApp(App[AppConfig]): - async def on_initialize(self): - # --8<-- [start:start_examples] - # Run every hour - await self.scheduler.run_hourly(self.task, name="hourly_task") - - # Run daily at 7:00 AM (wall-clock, DST-safe) - await self.scheduler.run_daily(self.task, at="07:00", name="morning_task") - - # Run once at a calculated future time - from hassette.scheduler import Once - - next_week = self.now().add(days=7) - await self.scheduler.schedule(self.task, Once(at=next_week), name="next_week_task") - # --8<-- [end:start_examples] - - async def task(self): - pass diff --git a/docs/pages/getting-started/docker/snippets/hassette.toml b/docs/pages/getting-started/docker/snippets/hassette.toml deleted file mode 100644 index 2767943f7..000000000 --- a/docs/pages/getting-started/docker/snippets/hassette.toml +++ /dev/null @@ -1,14 +0,0 @@ -[hassette] -base_url = "http://homeassistant:8123" # or your HA URL - -[hassette.apps] -directory = "/apps" # absolute path inside container - -[hassette.apps.my_app] -filename = "my_app.py" -class_name = "MyApp" -enabled = true - -[[hassette.apps.my_app.config]] -# Your app-specific configuration -instance_name = "my_app" diff --git a/docs/pages/getting-started/hassette-vs-ha-yaml.md b/docs/pages/getting-started/hassette-vs-ha-yaml.md deleted file mode 100644 index 6a12868c6..000000000 --- a/docs/pages/getting-started/hassette-vs-ha-yaml.md +++ /dev/null @@ -1,77 +0,0 @@ -# Is Hassette Right for You? - -Home Assistant ships with two built-in automation systems: UI-created automations and YAML automations. Hassette is a third option — Python code running alongside Home Assistant as a separate process. This page helps you decide whether Hassette is worth the extra setup. - -## Quick comparison - -| | HA UI / YAML | Hassette | -|---|---|---| -| **Setup** | None — built in | ~30 min install + config | -| **Language** | YAML + Jinja2 templates | Python 3.11+ | -| **Programming knowledge needed** | None | Basic Python | -| **Editor support** | HA UI or any text editor | Full IDE autocomplete, type checking | -| **Testing** | Manual; limited tooling | `AppTestHarness`, event simulation, time control | -| **Complex logic** | Awkward in YAML/Jinja2 | Native Python: loops, functions, libraries | -| **Code reuse** | Copy-paste across automations | Shared functions, base classes, modules | -| **Persistent state** | Input helpers, custom scripts | Built-in app cache with TTL support | -| **Runs alongside existing automations** | — | Yes — Hassette connects via the same WebSocket API; your YAML automations keep working | -| **Debugging** | HA logbook, traces | Structured logs, web UI, Python debugger | - -## HA UI / YAML automations - -YAML automations are built into Home Assistant and work well for straightforward automation needs. You can create them from the UI without writing any code. - -**When YAML is the right choice:** - -- Simple trigger-action patterns (turn on lights when motion detected, send a notification at sunrise) -- Quick prototyping — iterate fast without leaving the Home Assistant UI -- You prefer visual tools over writing code -- Your automations are stable and don't need testing - -**Where YAML becomes painful:** - -- Complex conditions that need nested logic or state tracking across multiple events -- Reusing the same logic across several automations (there is no real "import" in YAML) -- Jinja2 templates get unwieldy past a few lines -- Debugging requires reading trace logs rather than running code interactively - -## Hassette - -Hassette brings Python's full power to Home Assistant automations. Your automations become ordinary Python classes — you can test them, refactor them, and share code between them. - -**When Hassette is the right choice:** - -- Your YAML automations have grown hard to read or maintain -- You want to write tests for your automations -- You need persistent state without workarounds (input helpers, MQTT retain, etc.) -- You're comfortable with Python — or willing to learn it -- You want IDE support: autocomplete, type checking, and inline documentation - -**What you're committing to:** - -- **Python knowledge** — basic understanding is enough to start; you'll learn more as you go -- **Setup time** — around 30 minutes from install to your first running app ([Quickstart](index.md)) -- **Dependency management** — you own the Python environment and any library dependencies -- **A separate process** — Hassette runs outside Home Assistant, not as an integration - -## What Hassette does not replace - -Hassette does **not** replace the Home Assistant WebSocket or REST API. It uses those APIs internally. Your existing YAML automations, UI automations, scripts, and scenes continue to work exactly as before — Hassette connects as an additional client. - -You can migrate automations incrementally. Start with the one that's most painful in YAML and keep everything else as-is. - -## Making the call - -**Stick with YAML if:** - -- Your automations are simple trigger-action patterns -- You prefer the Home Assistant UI and don't want to write code -- You're new to programming and want the easiest possible path - -**Start with Hassette if:** - -- You're hitting YAML's limits: complex conditions, state tracking across events, or painful Jinja2 -- You want to test and debug your automations like ordinary code -- You need automations to remember state across restarts without using input helpers - -If you're still unsure, the [Quickstart](index.md) guide takes 30 minutes. Try building one automation in Hassette — you'll know quickly whether the model suits you. diff --git a/docs/pages/getting-started/snippets/first_automation_step1.py b/docs/pages/getting-started/snippets/first_automation_step1.py deleted file mode 100644 index aee995494..000000000 --- a/docs/pages/getting-started/snippets/first_automation_step1.py +++ /dev/null @@ -1,7 +0,0 @@ -from hassette import App - - -class MyApp(App): - async def on_initialize(self): - # Called once when the app starts - pass diff --git a/docs/pages/getting-started/snippets/first_automation_step2.py b/docs/pages/getting-started/snippets/first_automation_step2.py deleted file mode 100644 index c8b4c99f4..000000000 --- a/docs/pages/getting-started/snippets/first_automation_step2.py +++ /dev/null @@ -1,10 +0,0 @@ -from hassette import App, AppConfig - - -class MyAppConfig(AppConfig): - greeting: str = "Hello from Hassette!" - - -class MyApp(App[MyAppConfig]): - async def on_initialize(self): - self.logger.info(self.app_config.greeting) diff --git a/docs/pages/getting-started/snippets/first_automation_step3_raw.py b/docs/pages/getting-started/snippets/first_automation_step3_raw.py deleted file mode 100644 index 576f22c23..000000000 --- a/docs/pages/getting-started/snippets/first_automation_step3_raw.py +++ /dev/null @@ -1,23 +0,0 @@ -from hassette import App, AppConfig -from hassette.events import RawStateChangeEvent - - -class MyAppConfig(AppConfig): - greeting: str = "Hello from Hassette!" - - -class MyApp(App[MyAppConfig]): - async def on_initialize(self): - self.logger.info(self.app_config.greeting) - await self.bus.on_state_change("sun.*", handler=self.on_sun_change, name="sun_change") - - # --8<-- [start:raw_handler] - async def on_sun_change(self, event: RawStateChangeEvent): - new_state = event.payload.data.new_state - value = new_state.get("state") if new_state else "unknown" - self.logger.info("Sun changed: %s", value) - - if value == "below_horizon": - await self.api.turn_on("light.porch", domain="light") - self.logger.info("Porch light turned on") - # --8<-- [end:raw_handler] diff --git a/docs/pages/getting-started/snippets/hassette.toml b/docs/pages/getting-started/snippets/hassette.toml deleted file mode 100644 index 467eb6711..000000000 --- a/docs/pages/getting-started/snippets/hassette.toml +++ /dev/null @@ -1,14 +0,0 @@ -[hassette] -base_url = "http://localhost:8123" - -[hassette.apps] -directory = "hassette_apps" - -[hassette.apps.my_app] -filename = "main.py" -class_name = "MyApp" -enabled = true - -# [[double brackets]] = TOML array-of-tables; supports running the same app with multiple configs -[[hassette.apps.my_app.config]] -greeting = "This is from the config file!" diff --git a/docs/pages/getting-started/snippets/project_layout.sh b/docs/pages/getting-started/snippets/project_layout.sh deleted file mode 100644 index 7ef849fda..000000000 --- a/docs/pages/getting-started/snippets/project_layout.sh +++ /dev/null @@ -1 +0,0 @@ -mkdir -p config hassette_apps diff --git a/docs/pages/getting-started/snippets/run.sh b/docs/pages/getting-started/snippets/run.sh deleted file mode 100644 index 9d6d87c41..000000000 --- a/docs/pages/getting-started/snippets/run.sh +++ /dev/null @@ -1 +0,0 @@ -uv run hassette run diff --git a/docs/pages/getting-started/snippets/run_explicit.sh b/docs/pages/getting-started/snippets/run_explicit.sh deleted file mode 100644 index 23bd38889..000000000 --- a/docs/pages/getting-started/snippets/run_explicit.sh +++ /dev/null @@ -1 +0,0 @@ -hassette -c ./config/hassette.toml -e ./config/.env diff --git a/docs/pages/getting-started/snippets/typed_handler.py b/docs/pages/getting-started/snippets/typed_handler.py deleted file mode 100644 index f03ddde1b..000000000 --- a/docs/pages/getting-started/snippets/typed_handler.py +++ /dev/null @@ -1,11 +0,0 @@ -from hassette import App, AppConfig, D, states - - -class MyApp(App[AppConfig]): - async def on_initialize(self): - await self.bus.on_state_change("sun.*", handler=self.on_sun_change, name="sun_change") - - # --8<-- [start:typed-handler] - async def on_sun_change(self, new_state: D.StateNew[states.SunState]): - self.logger.info("Sun changed: %s", new_state.value) - # --8<-- [end:typed-handler] diff --git a/docs/pages/migration/snippets/api_migration_getting_states.py b/docs/pages/migration/snippets/api_migration_getting_states.py deleted file mode 100644 index 306b671c0..000000000 --- a/docs/pages/migration/snippets/api_migration_getting_states.py +++ /dev/null @@ -1,25 +0,0 @@ -from hassette import App, states - - -class MyApp(App): - async def on_initialize(self): - # PREFERRED: Use local state cache (no await, no API call) - # This is most similar to AppDaemon's behavior - light = self.states.light.get("light.kitchen") - if light: - brightness = light.attributes.brightness # Type-safe access - value = light.value # State value as string - - # Iterate over all lights in cache - for entity_id, light in self.states.light: - self.logger.info("%s: %s", entity_id, light.value) - - # Typed access for any domain - my_light = self.states[states.LightState].get("light.kitchen") - - # ALTERNATIVE: Force fresh read from Home Assistant API - fresh_light = await self.api.get_state("light.kitchen") - brightness = fresh_light.attributes.brightness # pyright: ignore[reportAttributeAccessIssue] - - # Or get just the value - value = await self.api.get_state_value("light.kitchen") # Returns string diff --git a/docs/pages/web-ui/app-detail/code.md b/docs/pages/web-ui/app-detail/code.md deleted file mode 100644 index 2a7799d91..000000000 --- a/docs/pages/web-ui/app-detail/code.md +++ /dev/null @@ -1,62 +0,0 @@ -# Code Tab - -The Code tab displays the full Python source file for the app with syntax highlighting, -line numbers, and annotations showing where each handler is registered. Use it to read the -implementation of an automation, trace a handler registration to its exact line, or navigate -here from the [Handlers tab](handlers.md) to see the source in context. - -![Code tab](../../../_static/web_ui_app_detail_code.png) - -## Source header - -The header bar above the source viewer shows: - -- **Source** label with the filename (monospaced) — the path as loaded by Hassette -- **Line count** — total number of lines in the file -- **read-only** badge — the source viewer is display-only; edits are made in your editor -- **copy path** button — copies the full filename to the clipboard - -## Syntax highlighting - -The source is highlighted using [Shiki](https://shiki.style/) with Python grammar. The color -theme follows your current hassette theme: the `github-light` theme is used in light mode, -`github-dark` in dark mode. Switching themes via the [status bar](../layout.md#status-bar) -updates the syntax colors without reloading. - -Each line is numbered in the left gutter. Lines are individually addressable via the -[deep link](#deep-linking-to-a-line) feature. - -## Handler annotations - -Lines where handlers are registered are highlighted in the gutter. When you hover over an -annotated line, a tooltip shows the handler method name (or names, if multiple handlers are -registered on the same line). - -The annotation data comes from the same source locations stored when handlers are registered — -the same file and line number shown in the **Source** field on the -[Handlers tab](handlers.md#source-location). - -!!! note - Handler annotations are only visible on lines that registered a handler. In a typical - app this is a small number of lines, usually inside `on_initialize`. If the - `on_initialize` method is not in view, scroll to find the annotated lines. - -## Deep-linking to a line - -Append `?line=N` to the URL to open the Code tab with line `N` scrolled into view and -highlighted. The page scrolls smoothly to the target line on load. - -The **view in code →** link on the [Handlers tab](handlers.md#source-location) uses this -mechanism: clicking it navigates to the Code tab with the handler's registration line already -focused. - -You can also construct deep links manually to share a specific line with a teammate: - -``` -/ui/apps/climate_controller/code?line=42 -``` - -## Related pages - -- [Handlers tab](handlers.md) — the **view in code →** link on each handler navigates here with the registration line focused -- [App Detail](index.md) — shared elements: breadcrumb, header, instance switcher, and tab strip diff --git a/docs/pages/web-ui/app-detail/config.md b/docs/pages/web-ui/app-detail/config.md deleted file mode 100644 index a2622d2e3..000000000 --- a/docs/pages/web-ui/app-detail/config.md +++ /dev/null @@ -1,71 +0,0 @@ -# Config Tab - -The Config tab shows the configuration loaded for this app: file path, class name, enabled -state, and the full set of config field values with their types. Use it to verify that the -right configuration was loaded, check which values are set versus defaulted, and inspect -the parsed config values as JSON. - -![Config tab](../../../_static/web_ui_app_detail_config.png) - -## Metadata header - -The header shows three fields sourced from the app's registration: - -| Field | Description | -|-------|-------------| -| **File** | Path to the source file containing this app class (monospaced) | -| **Class** | Python class name of the app (monospaced) | -| **Enabled** | `yes` (green) if the app is enabled, `no` if disabled | - -## Configuration table - -The main panel shows a typed table of all configuration fields declared on the app's -`AppConfig` class. Column types are derived from the class schema: - -| Column | Description | -|--------|-------------| -| **Key** | The config field name as defined on the `AppConfig` class | -| **Type** | Python type — `string`, `number`, `boolean`, or a union like `string \| null` | -| **Value** | The loaded value. `—` is shown when the value is `None` or not set. | - -Fields defined in the `AppConfig` schema are shown first, followed by any extra keys present -in the loaded config that don't appear in the schema. - -### Complex values - -Object and array values show a summary (`{N keys}` or `[N items]`). Click the summary to -expand the full JSON inline below the row. - -### Apps with no custom config - -If the app uses the base `AppConfig` without any custom fields, the table shows "no -configuration fields — this app uses the default AppConfig with no custom fields." - -## Raw config panel - -Below the configuration table, the **raw config** section shows the JSON representation of -the config values exactly as loaded at startup. The header shows where these values -originated in `hassette.toml`: - -``` -hassette.toml → apps..config -``` - -This is useful for verifying environment variable overrides, confirming that nested config -values were parsed correctly, or copying a value to use in a test fixture. - -## Multi-instance apps - -Apps with multiple instances configured in `hassette.toml` show each instance in its own -block with an **Instance N** heading (starting at `Instance 0`). Each block has its own -typed configuration table showing the values for that instance. - -The raw config panel below shows the full array of instance configs. - -!!! tip - To navigate between instances, use the instance switcher at the top of the App Detail - page. The Config tab updates to show the selected instance's values. - -## Related pages - -- [App Detail](index.md) — breadcrumb, instance switcher, and tab strip diff --git a/docs/pages/web-ui/app-detail/handlers.md b/docs/pages/web-ui/app-detail/handlers.md deleted file mode 100644 index e10289743..000000000 --- a/docs/pages/web-ui/app-detail/handlers.md +++ /dev/null @@ -1,202 +0,0 @@ -# Handlers Tab - -Use the Handlers tab to troubleshoot a single app. It presents every event handler and -scheduled job in a master-detail layout: the left panel lists all handlers and jobs, and -the right panel shows full detail for whichever item is selected. - -Use it to read invocation history, inspect modifier configuration, locate the registration -source, and trace a failure from the error type through the full traceback to the line in -the source file. - -![Handlers tab](../../../_static/web_ui_app_detail_handlers.png) - -## Stats strip - -A stats strip above the master-detail layout shows aggregate metrics for this app's handlers -and jobs. The numbers are scoped to the [time-preset selector](../layout.md#time-preset-selector) -in the status bar. - -| Stat | Description | -|------|-------------| -| **Handlers** | Total number of registered handlers and scheduled jobs | -| **Invocations** | Total calls and executions across all handlers and jobs in the time window | -| **Success Rate** | Percentage of invocations that completed without error | -| **Failed** | Number of invocations that raised an unhandled exception (highlighted in red when non-zero) | -| **Timed out** | Number of invocations that exceeded their timeout (highlighted in amber when non-zero) | - -## Handler list - -The left panel lists every event handler and scheduled job registered by this app. Handlers -appear before jobs. Failing items are shown with a red "failing" badge and their failed -count in red. - -Each row shows: - -- **Status dot** — green if the handler has executed successfully and has no failures, red if - it has any failures or timeouts, gray if it has never been invoked -- **Type chip** — the handler or trigger type (e.g., `state change`, `interval`, `cron`, - `daily`, `after`, `service call`) -- **Handler or job name** -- **Trigger description** — a human-readable description when available (e.g., the entity - pattern, cron expression, or interval duration) -- **Call or run count** — total invocations (handlers) or executions (jobs) -- **failed / timed out counts** — shown in red or amber when non-zero -- **Next run** — for scheduled jobs, the relative time until the next scheduled execution - -Click any row to select it and load the detail panel on the right. - -On narrow viewports, the list and detail panels stack vertically. When a handler is selected -on mobile, a "← back" button appears to return to the list. - -## Handler detail - -When you select an event handler (state change, service call, or other event type), the -right panel shows the handler detail view. - -### Header - -- **Kind chip** — the handler type label with a status dot (e.g., `state change`, `service call`) -- **Handler name** — the Python method name -- **"failing" badge** — shown in red when the handler has failures or timeouts - -### Registration source - -The registration source shows the exact Python call that registered this handler, as recorded -at startup — for example: - -```python ---8<-- "pages/web-ui/app-detail/snippets/handler_registration.py:register" -``` - -### Modifier chips - -Modifier chips appear when the handler was registered with any of the following options: - -| Chip | Meaning | -|------|---------| -| `debounce ` | Handler invocation is debounced by the specified duration | -| `throttle ` | Handler invocations are throttled to at most one per duration | -| `once` | Handler fires only on the first matching event, then deregisters | -| `priority ` | Handler runs with the specified dispatch priority | -| `immediate` | Handler fires immediately on the initial state, not just on changes | -| `duration ` | Handler requires the trigger condition to hold for the specified duration | - -Chips are only shown for options that are configured. A handler with no modifiers shows no -chip row. - -### Source location - -The source location shows the file path and line number where this handler is defined -(e.g., `apps/climate_controller.py:42`). Click **view in code →** to navigate to the -[Code tab](code.md) with the file scrolled to that line. - -### Error banner - -![Handler error](../../../_static/web_ui_detail_handler_error.png) - -When a handler is failing, a red error banner appears below the source location: - -- **Error type** — the Python exception class (e.g., `TypeError`) -- **Error message** — the full exception message -- **show traceback** — expands the full Python traceback inline - -The error banner is only shown when the handler has at least one failure. It disappears if -the handler subsequently runs successfully. - -### Stats grid - -The stats grid shows aggregated metrics for this handler: - -| Stat | Description | -|------|-------------| -| **Calls** | Total invocations in the time window | -| **Successful** | Invocations that completed without error | -| **Last** | Relative time of the most recent invocation | -| **Failed** | Invocations that raised an unhandled exception (red when non-zero) | -| **Timed out** | Invocations that exceeded their timeout (amber when non-zero) | -| **Cancelled** | Invocations that were cancelled (shown only when non-zero) | -| **Min** | Fastest recorded execution time | -| **Avg** | Mean execution time across all invocations | -| **Max** | Slowest recorded execution time | - -### Invocations table - -Below the stats grid, the **invocations** panel lists the most recent handler invocations: - -| Column | Description | -|--------|-------------| -| **Status** | Green dot for success, or the error type label (e.g., `TypeError`) in red for failure | -| **Timestamp** | When the invocation occurred | -| **Duration** | How long the invocation took | -| **Execution ID** | The unique identifier for this invocation, useful for correlating with log entries | - -Up to 50 of the most recent invocations are shown. The table updates in real time. - -## Job detail - -When you select a scheduled job (interval, cron, daily, or after trigger), the right panel -shows the job detail view. The layout mirrors the handler detail view, with the following -differences. - -### Header - -- **Kind chip** — the trigger type label (e.g., `interval`, `cron`, `daily`, `after`) -- **Job name** — the name assigned when the job was scheduled. An info icon (ⓘ) next to the - name indicates it was auto-generated; pass `name="..."` when scheduling for a descriptive - label. - -### Registration source - -Same as the handler detail — shows the Python call that scheduled this job (e.g., -`self.scheduler.run_every(self.check_temperature, Every(minutes=5))`). - -### Schedule chips - -Schedule chips appear when the job was configured with: - -| Chip | Meaning | -|------|---------| -| `±s jitter` | Each execution fires at a random offset within ±n seconds of the scheduled time | -| `group: ` | The job belongs to a named group (used for bulk cancellation) | - -### Trigger detail - -Below the schedule chips, the job's trigger description and next scheduled run time are shown: - -- **Trigger label** — a human-readable description of the trigger (e.g., "every 5 minutes", - "daily at 07:00", "run once in 30s") -- **Trigger detail** — the formatted trigger specification (e.g., `07:00 America/New_York`) -- **Next run** — relative time until the next scheduled execution (e.g., "next in 3 min"), - or `fire at