diff --git a/.claude/rules/doc-rules.md b/.claude/rules/doc-rules.md index d76f03187..5332fac64 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. @@ -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 @@ -87,7 +88,7 @@ The voice and approachability rules above are hard requirements. The structures API reference is auto-generated by mkdocstrings from docstrings. When adding a new public module or class: -- Add it to the `PUBLIC_MODULES` allowlist in `tools/gen_ref_pages.py` +- Add it to the `PUBLIC_MODULES` allowlist in `tools/docs/gen_ref_pages.py` - Write clear docstrings on the class and its public methods (one-liner summary, parameter descriptions via type hints, usage example if the method is non-obvious) - Don't duplicate reference content in concept pages — link to it instead @@ -143,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/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock deleted file mode 100644 index 4fd058e9a..000000000 --- a/.claude/scheduled_tasks.lock +++ /dev/null @@ -1 +0,0 @@ -{"sessionId":"337eb2df-344c-42d3-86ec-dc11b93a908e","pid":378746,"acquiredAt":1774722868277} diff --git a/.claude/skills/doc-accuracy-review/SKILL.md b/.claude/skills/doc-accuracy-review/SKILL.md new file mode 100644 index 000000000..8bdc3c9bb --- /dev/null +++ b/.claude/skills/doc-accuracy-review/SKILL.md @@ -0,0 +1,140 @@ +# Doc Accuracy Review + +Verify documentation pages against the actual source code. Each verification subagent reads one page, inventories its checkable claims (signatures, defaults, behaviors, exceptions, config keys, CLI flags), and confirms or refutes each one against `src/hassette/`. + +Sibling of `doc-persona-review`: that skill tests whether a page is *followable*; this one tests whether it is *true*. Snippets are Pyright-checked in CI, but nothing guards prose claims — they drift silently after every `src/` change. + +## Arguments + +The page or section to verify. Examples: +- `core-concepts/bus` (verifies all pages in the section) +- `core-concepts/scheduler/methods` +- `cli` (verifies all CLI pages) + +If empty, ask what to verify. + +## Phase 1: Resolve Pages + +If the argument is a section, expand to all pages in that directory (excluding `snippets/`). If it's a single page, use that page. + +No persona selection — every page gets the same verification treatment. + +## Phase 2: Extract and Assemble + +Two scripts handle all file preparation. Run them, don't read their output. Do NOT read the page content or briefing files yourself — the heavy content belongs only in subagent contexts. + +### Step 1: Get a tmp directory + +```bash +get-skill-tmpdir doc-accuracy-review +``` + +Use the printed path as `$TMPDIR` below. + +### Step 2: Build docs and extract pages + +```bash +uv run mkdocs build --strict +uv run tools/docs/extract_doc_page.py --section
--output-dir $TMPDIR/pages +# or for a single page: +uv run tools/docs/extract_doc_page.py --output-dir $TMPDIR/pages +``` + +### Step 3: Assemble briefing files + +One command per page (no persona argument — the template is fixed): + +```bash +uv run tools/docs/assemble_accuracy_briefing.py $TMPDIR/pages/.txt $TMPDIR/briefings +``` + +Chain with `&&` for multi-page sections. Each briefing lands at `$TMPDIR/briefings/accuracy--.md`. + +## Phase 3: Dispatch Verification Agents + +For each briefing file, dispatch a **Sonnet** subagent with this prompt: + +``` +Read the file at {briefing_path} and follow the instructions inside. You have full read access to the repository — verify claims against the source code in src/hassette/. Return the JSON result exactly as specified. +``` + +Unlike persona walkthroughs, these agents actively explore the repo (Read, Grep, Glob), so they run longer per page. Cap at 5 concurrent subagents. + +Use `schema` on the agent call to enforce the JSON structure: + +```json +{ + "type": "object", + "properties": { + "page": {"type": "string"}, + "claims_checked": {"type": "integer"}, + "findings": { + "type": "array", + "items": { + "type": "object", + "properties": { + "line": {"type": "integer"}, + "claim_type": {"type": "string", "enum": ["api-signature", "default-value", "behavior", "exception", "config", "cli", "import-path", "version", "file-path"]}, + "verdict": {"type": "string", "enum": ["WRONG", "OUTDATED_API", "UNVERIFIABLE"]}, + "severity": {"type": "string", "enum": ["high", "low"]}, + "doc_quote": {"type": "string"}, + "code_evidence": {"type": "string"}, + "explanation": {"type": "string"}, + "suggested_fix": {"type": "string"} + }, + "required": ["line", "claim_type", "verdict", "severity", "doc_quote", "code_evidence", "explanation", "suggested_fix"] + } + }, + "summary": {"type": "string"} + }, + "required": ["page", "claims_checked", "findings", "summary"] +} +``` + +## Phase 4: Triage and Present + +### Triage before trusting + +Verification agents make mistakes in both directions, and for accuracy work a wrong "fix" is worse than the original error — it makes a true sentence false. Before presenting or fixing anything: + +1. For each `WRONG` and `OUTDATED_API` finding, open the cited `code_evidence` location and confirm the contradiction is real and that the cited code is the code path the page describes. +2. Findings with no usable code citation are discarded. +3. `UNVERIFIABLE` findings get a quick independent grep — agents sometimes miss a symbol that one targeted search finds. If you find it and the claim holds, drop the finding; if you find it and the claim is false, upgrade to `WRONG`. + +### Sanity-check coverage + +A page with `claims_checked: 3` that plainly contains dozens of API references got a lazy pass — re-dispatch it. Use judgment, not a fixed threshold: reference-heavy pages (methods, triggers, predicate tables) should report high counts; conceptual index pages legitimately report low ones. + +### Output format + +Group confirmed findings by page, worst first (`high` severity, then `WRONG` before `OUTDATED_API` before `UNVERIFIABLE`): + +``` +## page-name.md — N findings (M claims checked) + +- **[verdict / claim_type / severity]** L{line}: "{doc_quote}" + Code: {code_evidence} + {explanation} + Fix: {suggested_fix} +``` + +End with a summary table: + +| Page | Claims checked | WRONG | OUTDATED_API | UNVERIFIABLE | +|------|----------------|-------|--------------|--------------| + +Pages with zero findings appear in the table only — that's the success case, not a gap. + +### Fixing + +When the user asks for fixes (or pre-authorized auto-fixing), edit the markdown source in `docs/pages/`, not the rendered output. After fixes, run `uv run mkdocs build --strict` to verify the build. If a fix changes a snippet file, re-run Pyright on snippets per the docs CI. + +## Design Decisions + +**Why a separate skill from doc-persona-review?** Opposite stances toward the repo. The persona reviewer is deliberately blind — it must not know more than the persona does. The accuracy reviewer is the opposite: it greps and reads source freely. One briefing template can't serve both without contradicting itself. + +**Why no second adversarial verification pass?** The evidence requirement (every finding cites `file:line`) plus main-agent triage against the cited code catches fabricated findings at a fraction of the cost of doubling the agent count. If triage starts rejecting a large share of findings, revisit this. + +**Why `claims_checked`?** Zero findings is the expected result for an accurate page, which makes it indistinguishable from a lazy agent that checked nothing. The count is the cheap signal that separates the two. + +**Why Sonnet?** Same reasoning as doc-persona-review — findings drive editing decisions directly, so they need to be trustworthy without per-finding human review. Verification additionally requires multi-step code navigation, which Haiku does less reliably. diff --git a/.claude/skills/doc-accuracy-review/references/briefing-template.md b/.claude/skills/doc-accuracy-review/references/briefing-template.md new file mode 100644 index 000000000..b655ae223 --- /dev/null +++ b/.claude/skills/doc-accuracy-review/references/briefing-template.md @@ -0,0 +1,115 @@ +# Documentation Accuracy Verification Briefing + +You are a technical fact-checker verifying one Hassette documentation page against the framework's actual source code. Hassette is an async-first Python framework for building Home Assistant automations. The repository root is your current working directory; the source lives in `src/hassette/`. + +The governing principle: **a page is accurate when every checkable claim is true of the code as it exists right now** — not as it was designed, not as a docstring describes it, not as another doc page restates it. + +Style, clarity, voice, and structure are explicitly out of scope. Other reviews cover those. If a sentence is confusing but true, it is not a finding. + +## What counts as a checkable claim + +Check every instance of these claim types. Skip everything else. + +| Type | Examples | +|---|---| +| `api-signature` | Method/function/class names, parameter names, return types mentioned in prose ("`on_state_change` returns a `Subscription`", "pass `jitter=` to any trigger") | +| `default-value` | Any stated default: parameter defaults ("`run_daily()` defaults to midnight"), config defaults, timeouts, retention periods, ports | +| `behavior` | Assertions about runtime behavior ("the timer resets on re-trigger", "registration is synchronous with the DB", "events during the window are discarded") | +| `exception` | Exception class names and the conditions that raise them ("omitting `name=` raises `ListenerNameRequiredError`") | +| `config` | Config keys, section names, file names, env prefixes | +| `cli` | Commands, subcommands, flags, example invocations ("`hassette listener --app --since 1h`") | +| `import-path` | Module paths and aliases ("`D` is `hassette.dependencies`", "triggers live in `hassette.scheduler.triggers`") | +| `version` | Version requirements ("Python 3.11+") | +| `file-path` | Repo or runtime file paths referenced in prose | + +Code examples on the page come from snippet files that CI type-checks with Pyright — do not re-verify that they compile. DO check that prose claims *about* an example match what the example and the underlying API actually do (e.g., prose says "waits 10 seconds" but the snippet passes `debounce=5`). + +## How to verify + +1. Read the page content at the bottom of this file and inventory the checkable claims as you go. Line numbers are marked with `LINE N:` prefixes — use them in findings. +2. For each claim, locate the relevant source and confirm or refute it. Start from the source map below; grep from there. +3. Verify against code, not against docstrings or other doc pages. Docstrings drift exactly like docs do — a docstring is evidence only for a claim about what the docstring says. Read the actual signature, the actual default, the actual raise statement. +4. Report only claims that FAIL verification. Confirmed claims are not findings — count them and move on. A page where everything checks out returns zero findings, and that is a successful review, not a thin one. Do not pad. + +The trap in this task is confirmation laziness: a claim that *sounds* like the code you just read gets waved through. Parameter names and defaults are where this bites — "defaults to 30 seconds" feels right until you read the signature and it says `60`. For every `default-value` and `api-signature` claim, put eyes on the actual line of code before counting it confirmed. + +The inverse trap is the plausible-but-wrong finding: flagging a claim as WRONG because you found *a* function that contradicts it, when the page was describing a different overload, wrapper, or layer. Before reporting, confirm the code you cite is the code path the page is actually describing. + +## Source map + +| Docs section | Primary source | +|---|---| +| `core-concepts/bus/*` | `src/hassette/bus/`, `src/hassette/event_handling/`, `src/hassette/events/` | +| `core-concepts/scheduler/*` | `src/hassette/scheduler/` | +| `core-concepts/api/*` | `src/hassette/api/`, `src/hassette/models/` | +| `core-concepts/states/*` | `src/hassette/state_manager/`, `src/hassette/models/states/`, `src/hassette/conversion/` | +| `core-concepts/apps/*` | `src/hassette/app/`, `src/hassette/task_bucket/`, `src/hassette/config/` | +| `core-concepts/configuration/*` | `src/hassette/config/` | +| `core-concepts/cache/*` | `src/hassette/app/` (cache property; backed by the `diskcache` library) | +| `core-concepts/internals/*` | `src/hassette/core/`, `src/hassette/resources/` | +| `core-concepts/database-telemetry` | `src/hassette/core/database_service.py`, `src/hassette/migrations_sql/` | +| `cli/*` | `src/hassette/cli/` | +| `web-ui/*` | `src/hassette/web/` | +| `testing/*` | `src/hassette/test_utils/` | +| `operating/*` | `src/hassette/logging_.py`, `src/hassette/core/` | +| `getting-started/*`, `recipes/*`, `migration/*`, `troubleshooting` | Cross-cutting — grep `src/hassette/` for the symbols the page mentions | + +Cross-cutting locations regardless of section: + +- Exceptions: `src/hassette/exceptions.py` +- Public API surface and aliases: `src/hassette/__init__.py` +- Event payloads: `src/hassette/events/` +- Enums and shared types: `src/hassette/types/` + +## Verdicts + +| Verdict | Meaning | +|---|---| +| `WRONG` | The claim contradicts the code: wrong default, wrong parameter name, wrong behavior, wrong condition | +| `OUTDATED_API` | The claim references a symbol, flag, config key, or path that no longer exists (renamed or removed) | +| `UNVERIFIABLE` | A specific, checkable claim whose implementation you could not locate after a genuine search | + +Severity: `high` if acting on the claim would break user code or send a user down a wrong path (wrong API usage, wrong exception to catch, wrong config key); `low` if the error is real but harmless (a name misspelled in prose with correct usage in the adjacent example). + +## Evidence rules + +Every finding must carry its evidence: + +- `doc_quote` — the exact sentence or phrase from the page making the claim +- `code_evidence` — `file:line` plus a short quote of the contradicting code; for `UNVERIFIABLE`, list where you searched (paths and grep patterns) + +A finding without a code citation will be discarded during triage, so do the lookup now. + +## Output + +Return your results as a JSON object: + +```json +{ + "page": "{{PAGE_PATH}}", + "claims_checked": 0, + "findings": [ + { + "line": 0, + "claim_type": "default-value", + "verdict": "WRONG", + "severity": "high", + "doc_quote": "", + "code_evidence": "", + "explanation": "", + "suggested_fix": "" + } + ], + "summary": "<2-3 sentences: overall accuracy of the page, where the errors cluster>" +} +``` + +`claims_checked` is the total number of checkable claims you verified, including the ones that passed. It is how the reviewer distinguishes "all true" from "didn't look." + +## Page content + +Page: {{PAGE_PATH}} + +--- +{{PAGE_CONTENT}} +--- diff --git a/.claude/skills/doc-coverage-review/REFERENCE.md b/.claude/skills/doc-coverage-review/REFERENCE.md new file mode 100644 index 000000000..c6d7645ef --- /dev/null +++ b/.claude/skills/doc-coverage-review/REFERENCE.md @@ -0,0 +1,43 @@ +# Doc Coverage Review — Area Prompts + +One block per area. Each defines the inventory scope for that area's agent; append the shared instructions from SKILL.md after it. Do not reference further files from here. + +## bus + +Your area is the event bus and event handling. Inventory: every public registration method on `Bus` (src/hassette/bus/bus.py) and each of its parameters with user-visible behavior (handler, name, changed, changed_from/to, where, immediate, duration, debounce, throttle, once, timeout, timeout_disabled, priority, on_error, kwargs); `Subscription` and its methods; `BusErrorContext` fields; the predicate (`P`), condition (`C`), accessor (`A`), and dependency (`D`) modules in src/hassette/event_handling/ — every public class/alias an app author can use; event types and topic strings in src/hassette/events/ and src/hassette/types/enums.py that handlers subscribe to; `bus.emit` and `BusSyncFacade`. + +## scheduler + +Your area is scheduling. Inventory: every public method on `Scheduler` (src/hassette/scheduler/scheduler.py) and its parameters (name, group, jitter, timeout, timeout_disabled, on_error, if_exists, args, kwargs); all trigger classes in triggers.py and `TriggerProtocol`; `ScheduledJob` public attributes and methods; `SchedulerErrorContext`; `SchedulerSyncFacade`; scheduler-related config keys (`[hassette.scheduler]`, job_timeout_seconds). + +## api + +Your area is the Home Assistant API surface. Inventory: every public method on `Api` (src/hassette/api/api.py) including the helper CRUD families and counter shortcuts; `ApiSyncFacade`; entity models in src/hassette/models/entities/ that `get_entity` accepts; `ServiceResponse`; the exceptions Api methods raise that callers would catch. + +## states + +Your area is state access and conversion. Inventory: `StateManager` access patterns (domain properties, `[CustomState]`, get/bracket access, iteration methods) in src/hassette/state_manager/; the state model base classes and `BaseState`/`AttributesBase` public fields and helpers (is_unknown, is_unavailable, is_group, extras, extra, has_feature) in src/hassette/models/states/base.py; `STATE_REGISTRY`/`TYPE_REGISTRY`, `register_type_converter_fn`, `register_simple_type_converter`, `TypeConverterEntry` in src/hassette/conversion/; sentinels in src/hassette/const.py (ANY_VALUE, MISSING_VALUE, NOT_PROVIDED). + +## app + +Your area is the App base classes. Inventory: `App`/`AppSync` lifecycle hooks and public attributes/handles (logger, bus, scheduler, api, states, cache, task_bucket, app_config, now(), instance_name, app_key) in src/hassette/app/; `AppConfig` built-in fields and settings behavior (env_prefix, extra policy); `only_app`; `TaskBucket` public methods (src/hassette/task_bucket/); the `.sync` facades reachable from AppSync. + +## config + +Your area is configuration. Inventory: every field on `HassetteConfig` (src/hassette/config/config.py) and on each nested config model in src/hassette/config/models.py (AppsConfig, LoggingConfig, WebApiConfig, DatabaseConfig, WebsocketConfig, LifecycleConfig, FileWatcherConfig, SchedulerConfig, and any others), with its TOML section; env var mechanics (prefix, nested delimiter, token aliases); file discovery locations in defaults.py. A config field a user could set but never learn about is the canonical gap for this area. + +## cli + +Your area is the `hassette` CLI (src/hassette/cli/). Inventory: every command and subcommand, every flag and alias, accepted value formats (--since formats, --instance resolution), env vars the CLI reads, exit codes, --json output mode, shell completion. + +## exceptions + +Your area is src/hassette/exceptions.py. Inventory: every exception class an app author might catch, see in logs, or trigger from their own code or config. For each: is it documented anywhere a user would find it (troubleshooting page, concept page, docstring-only)? Framework-internal exceptions that user code can never observe are excluded — justify exclusions by where they are raised. + +## test-utils + +Your area is src/hassette/test_utils/. Inventory: everything in `__all__` — harness classes, factory functions, recording API, simulation methods (set_state, simulate_*, drain_task_bucket, freeze_time, advance_time, trigger_due_jobs), drain exceptions, config helpers. The testing docs section (docs/pages/testing/) is the expected home. + +## web + +Your area is the web API surface in src/hassette/web/routes/. Inventory: every REST endpoint and the WebSocket endpoint an operator might script against (path, method, what it returns, error statuses), plus web-related config the routes honor (CORS, buffers). The web-ui docs section and cli/configuration pages are the expected homes. Frontend component internals are out of scope — the accuracy review owns those. diff --git a/.claude/skills/doc-coverage-review/SKILL.md b/.claude/skills/doc-coverage-review/SKILL.md new file mode 100644 index 000000000..ff7ec4f19 --- /dev/null +++ b/.claude/skills/doc-coverage-review/SKILL.md @@ -0,0 +1,121 @@ +--- +name: doc-coverage-review +description: "Use when the user says: 'doc coverage review', 'what's undocumented', 'is everything surfaced in the docs', or 'find documentation gaps'. Inventories the user-facing surface of src/hassette area by area and reports what the docs never mention or never explain." +user-invocable: true +--- + +# Doc Coverage Review + +Verify that everything user-facing in the source code is surfaced in the documentation. Each verification subagent owns one source area, inventories its public surface (methods, parameters, config keys, CLI flags, exceptions, exported helpers), and searches `docs/pages/` for each item. + +Sibling of `doc-accuracy-review`: that skill tests whether what the docs *say* is true; this one tests whether the docs *say enough*. The two directions miss different failures — a page can be 100% accurate while an entire feature ships undocumented. + +The generative rule: **a feature a user can only discover by reading the source is undocumented, no matter how public the symbol is.** + +## Arguments + +The source area(s) to verify. One or more of: `bus`, `scheduler`, `api`, `states`, `app`, `config`, `cli`, `exceptions`, `test-utils`, `web`. If empty, run all ten. + +## Phase 1: Dispatch Inventory Agents + +For each area, dispatch a **Sonnet** subagent (cap at 5 concurrent) with the area prompt from `REFERENCE.md` plus these shared instructions: + +``` +You are auditing documentation coverage for the Hassette framework +(repo root: ). Work in two passes. + +PASS 1 — INVENTORY. Build the list of user-facing items for your area +(defined below). User-facing means: an app author or operator would +call it, configure it, catch it, or see it. Exclude internal plumbing +(modules not exported via hassette/__init__.py and not reachable from +App's handles), private helpers, and framework-only machinery. For each +item record its source location. + +PASS 2 — COVERAGE. For each item, search docs/pages/ (Grep, case +insensitive). Search at least twice: once for the exact symbol, once +for how a doc would describe it in prose (synonym, behavior, the +parameter's effect). Classify: +- "covered": a concept, recipe, getting-started, or operating page + explains it (not just lists it). +- "reference-only": it appears only via the auto-generated API + reference (its module is in PUBLIC_MODULES in + tools/docs/gen_ref_pages.py) or only as an unexplained table entry. +- "missing": no docs presence at all. + +Severity: "high" for a missing capability a user would want and cannot +discover (a method, config key, CLI command, or behavior with zero +presence); "low" for reference-only items that deserve prose, or +parameters/variants of an otherwise-documented feature. + +Report every missing/reference-only item as a gap with the searches you +ran. Do NOT pad the report: an item that is genuinely internal is not a +gap — say so by excluding it from the inventory, not by listing it as +low severity. Return the JSON result exactly as specified. +``` + +Use `schema` on the agent call: + +```json +{ + "type": "object", + "properties": { + "area": {"type": "string"}, + "items_inventoried": {"type": "integer"}, + "gaps": { + "type": "array", + "items": { + "type": "object", + "properties": { + "item": {"type": "string"}, + "source_location": {"type": "string"}, + "status": {"type": "string", "enum": ["missing", "reference-only"]}, + "severity": {"type": "string", "enum": ["high", "low"]}, + "searches_tried": {"type": "array", "items": {"type": "string"}}, + "why_user_facing": {"type": "string"}, + "suggested_home": {"type": "string"} + }, + "required": ["item", "source_location", "status", "severity", "searches_tried", "why_user_facing", "suggested_home"] + } + }, + "summary": {"type": "string"} + }, + "required": ["area", "items_inventoried", "gaps", "summary"] +} +``` + +## Phase 2: Triage + +Inventory agents fail in both directions; triage before trusting. + +1. **False gaps.** Agents grep for the symbol name and miss prose that documents the behavior under different words. Before accepting any `missing` gap, run one independent docs search yourself using a term the agent didn't try. If you find coverage, drop the gap. +2. **Lazy inventories.** An area reporting `items_inventoried: 8` for a module with 40 public methods got a shallow pass — re-dispatch it. Compare the count against your own quick `grep -c "def \|class "` of the area's public modules; large mismatches mean re-run, not trust. +3. **Scope creep.** Agents pad reports with internals to look thorough. For each gap, check `why_user_facing` against the test: would an app author or operator ever type this name? If not, discard. + +## Phase 3: Present and Fix + +Group confirmed gaps by area, high severity first: + +``` +## bus — N gaps (M items inventoried) + +- **[missing / high]** `Bus.on_attribute_change(attr=...)` glob support + Source: src/hassette/bus/bus.py:812 + Why: app authors filtering attributes need to know globs are rejected here + Suggested home: core-concepts/bus/methods.md +``` + +End with a summary table (Area | Items | Missing | Reference-only). Areas with zero gaps appear in the table only. + +Fixing is scoped: small gaps (a missing parameter, an unexplained exception) get edited into the suggested home directly, following `.claude/rules/voice-guide.md` and `doc-rules.md` (snippets live in tested `snippets/` files). Whole missing pages are larger work — list them as recommendations instead of writing them inline, unless the user pre-authorized full fixes. + +After edits: `uv run mkdocs build --strict`, and Pyright if any snippet changed. + +## Design Decisions + +**Why area agents instead of page agents?** Coverage is a property of the whole doc set, not a page — only a source-side inventory can find what no page mentions. Pages are the unit for accuracy; modules are the unit for coverage. + +**Why two search passes per item?** The dominant failure mode is the false gap: docs explain a feature in prose without naming the symbol. Exact-match grep alone systematically over-reports. + +**Why `items_inventoried`?** Same reason as `claims_checked` in doc-accuracy-review: zero gaps from a thorough agent and zero gaps from a lazy one look identical without the denominator. + +**Why no script to extract the public surface?** The inventory requires judgment ("is this user-facing?") that a symbol dump can't make, and the area list is stable. If inventories prove unreliable, build the extractor then — not speculatively. diff --git a/.claude/skills/doc-persona-review/SKILL.md b/.claude/skills/doc-persona-review/SKILL.md new file mode 100644 index 000000000..e37dec99b --- /dev/null +++ b/.claude/skills/doc-persona-review/SKILL.md @@ -0,0 +1,172 @@ +# Doc Persona Review + +Evaluate documentation pages from the perspective of beginner personas. Each persona does a cognitive walkthrough, flagging where a real reader with that background would get confused, lost, or stuck. + +Based on cognitive walkthrough methodology (Wharton/Lewis) and the UW 2025 synthetic heuristic evaluation study showing LLM evaluators catch 73-77% of usability issues vs 57-63% for human experts. + +## Arguments + +The page or section to review. Examples: +- `getting-started/first-automation` +- `recipes/motion-lights` +- `core-concepts/bus` (reviews all pages in the section) +- `migration` (reviews all migration pages) + +If empty, ask what to review. + +## Phase 1: Select Pages and Personas + +### Resolve pages + +If the argument is a section, expand to all `.md` files in that directory (excluding `snippets/`). If it's a single page, use that page. + +### Pick personas + +Select which personas to run based on page type: + +| Page type | Personas | +|-----------|----------| +| `getting-started/*` | Alex (fresh Python dev) | +| `migration/*` | Sam (AppDaemon migrator) | +| `core-concepts/*` | Jordan (experienced dev) | +| `recipes/*` | Alex + Sam + Jordan (all three) | +| `testing/*` | Jordan (experienced dev) | +| `cli/*`, `operating/*`, `web-ui/*` | Alex + Jordan | + +Do NOT read `references/personas.md`, `voice-guide.md`, or `doc-rules.md` yourself. The assembler script handles all of that. + +## Phase 2: Extract and Assemble + +Two scripts handle all file preparation. Run them, don't read their output. + +### Step 1: Get a tmp directory + +```bash +get-skill-tmpdir doc-persona-review +``` + +This prints a path like `/tmp/claude-doc-persona-review-a8Kx3Q`. Use it as `$TMPDIR` below. + +### Step 2: Build docs and extract pages + +```bash +uv run mkdocs build --strict +uv run tools/docs/extract_doc_page.py --section
--output-dir $TMPDIR/pages +# or for a single page: +uv run tools/docs/extract_doc_page.py --output-dir $TMPDIR/pages +``` + +This writes one `.txt` file per page to `$TMPDIR/pages/`. The output lists the files created. Note the filenames — you'll need them for the next step. + +### Step 3: Assemble briefing files + +For each (page, persona) pair, run the assembler: + +```bash +uv run tools/docs/assemble_persona_briefing.py $TMPDIR/pages/.txt $TMPDIR/briefings +``` + +This writes a complete briefing file to `$TMPDIR/briefings/--.md` containing the task instructions, persona definition, voice guide, doc rules, and page content. The assembler pulls all reference files from their known repo paths. + +Run one command per (page, persona) pair. For efficiency, chain them: + +```bash +uv run tools/docs/assemble_persona_briefing.py Jordan $TMPDIR/pages/core-concepts--bus--index.txt $TMPDIR/briefings && \ +uv run tools/docs/assemble_persona_briefing.py Jordan $TMPDIR/pages/core-concepts--bus--handlers.txt $TMPDIR/briefings && \ +uv run tools/docs/assemble_persona_briefing.py Jordan $TMPDIR/pages/core-concepts--bus--filtering.txt $TMPDIR/briefings +``` + +## Phase 3: Dispatch Persona Walkthroughs + +For each briefing file, dispatch a **Sonnet** subagent with this prompt: + +``` +Read the file at {briefing_path} and follow the instructions inside. Return the JSON result exactly as specified. +``` + +Use `schema` on the agent call to enforce the JSON structure: + +```json +{ + "type": "object", + "properties": { + "persona": {"type": "string"}, + "page": {"type": "string"}, + "overall_verdict": {"type": "string", "pattern": "^(followable|followable-with-effort|stuck-at-step-\\d+|lost)$"}, + "findings": { + "type": "array", + "items": { + "type": "object", + "properties": { + "line": {"type": "integer"}, + "section": {"type": "string"}, + "type": {"type": "string", "enum": ["undefined-term", "missing-prerequisite", "unclear-next-step", "no-verification", "assumed-knowledge", "unmotivated-content", "missing-import", "jargon"]}, + "quote": {"type": "string"}, + "confusion": {"type": "string"}, + "suggestion": {"type": "string"} + }, + "required": ["line", "section", "type", "quote", "confusion", "suggestion"] + } + }, + "stopped_at": {"type": ["string", "null"]}, + "summary": {"type": "string"} + }, + "required": ["persona", "page", "overall_verdict", "findings", "stopped_at", "summary"] +} +``` + +### Parallelism + +- Single page with 1 persona: one subagent. +- Single page with 3 personas: three subagents in parallel. +- Multi-page section: batch by page, all personas for each page in parallel. Cap at 5 concurrent subagents. + +## Phase 4: Collate and Present + +### Per-page summary + +For each page, merge findings across personas. Group by section heading. When multiple personas flag the same line/section, note the overlap (higher confidence). + +### Severity classification + +| Verdict | Meaning | +|---------|---------| +| **lost** | Persona would abandon the page. At least one blocking undefined term or missing prerequisite with no path forward. | +| **stuck-at-step-N** | Persona would stall at a specific step. Could recover with external help (Google, asking someone). | +| **followable-with-effort** | Persona could finish but would need to re-read sections, guess at meanings, or make assumptions. | +| **followable** | Persona could follow the page start to finish without confusion. | + +### Output format + +Present findings to the user grouped by page, then by section within each page. Lead with the worst verdicts. For each finding, show: + +``` +## page-name.md — verdict (persona) + +### Section: + +- **[type]** L{line}: "{quote}" + {persona_name}: {confusion} + Suggestion: {suggestion} +``` + +### Summary table + +End with a summary table: + +| Page | Alex | Sam | Jordan | +|------|------|-----|--------| +| first-automation.md | followable-with-effort | — | — | +| motion-lights.md | stuck-at-step-3 | followable | followable | + +(`—` means that persona was not run on that page.) + +## Design Decisions + +**Why Sonnet for personas?** Haiku finds roughly the same issues but with weaker reasoning and less precise suggestions. Sonnet produces findings that can be trusted without human verification of each one. The 2x cost difference is worth it since the output directly drives editing decisions. Tested on `first-automation.md`: both models returned the same verdict (followable-with-effort) and overlapping findings, but Sonnet caught unexpanded jargon ("DI parameters"), missing starting-state context, and produced actionable suggestions ("Stop Hassette with Ctrl+C, then run `hassette run` again") where Haiku gave vague pointers. + +**Why briefing files?** The main agent only needs page names, persona assignments, and returned findings. All heavy content (page HTML, persona definitions, voice rules, doc rules) is assembled into files by `tools/docs/assemble_persona_briefing.py` and read only by the subagents. This keeps the main context small enough to handle large section audits without compaction. + +**Why not a Python script?** The cognitive walkthrough requires genuine language comprehension (is this term defined? would this step confuse someone?). Pattern matching can't do that. The voice audit script handles mechanical rules; this handles semantic ones. + +**Why structured JSON output?** Freeform prose findings are hard to compare across personas, hard to track across runs, and hard to act on. Structured findings with line numbers, types, and quotes are directly actionable. diff --git a/.claude/skills/doc-persona-review/references/briefing-template.md b/.claude/skills/doc-persona-review/references/briefing-template.md new file mode 100644 index 000000000..9db7d697f --- /dev/null +++ b/.claude/skills/doc-persona-review/references/briefing-template.md @@ -0,0 +1,63 @@ +# Persona Walkthrough Briefing + +You are {{PERSONA_NAME}}, reading Hassette documentation for the first time. + +## Your Persona + +{{PERSONA_DEFINITION}} + +IMPORTANT: You must genuinely adopt this persona's knowledge boundaries. When the persona "does NOT know" something, you must flag it as confusing even if you (the LLM) understand it. The value of this review is simulating real confusion, not demonstrating comprehension. + +## Voice and Structure Context + +The following rules describe how Hassette documentation SHOULD be written. Use them to calibrate expectations — if the page violates these rules in a way that would confuse your persona, flag it. + +### Voice Guide + +{{VOICE_GUIDE}} + +### Documentation Rules + +{{DOC_RULES}} + +## Your Task + +Read the documentation page below and walk through it as {{PERSONA_NAME}} would, step by step. For each section or paragraph: + +1. **Can I follow this?** Would {{PERSONA_NAME}} understand what this section is saying, given ONLY what they know? Flag every term, concept, or syntax element that falls outside their knowledge boundary. + +2. **Do I know what to do next?** At each step or code example, would {{PERSONA_NAME}} know what action to take? Flag missing commands, unclear "where do I put this?" moments, and steps that assume setup not covered on this page. + +3. **Can I connect this to my goal?** Would {{PERSONA_NAME}} understand WHY this section matters for their reading goal? Flag sections that feel like detours or unmotivated technical detail. + +4. **Can I tell it worked?** After following a step or example, would {{PERSONA_NAME}} know whether they succeeded? Flag missing verification steps, expected output, or "you should now see..." moments. + +Return your findings as a JSON object: + +```json +{ + "persona": "{{PERSONA_NAME}}", + "page": "{{PAGE_PATH}}", + "overall_verdict": "followable", + "findings": [ + { + "line": 0, + "section": "", + "type": "undefined-term", + "quote": "", + "confusion": "", + "suggestion": "" + } + ], + "stopped_at": "
", + "summary": "<2-3 sentences: would this persona succeed with this page?>" +} +``` + +## Page Content + +Page: {{PAGE_PATH}} + +--- +{{PAGE_CONTENT}} +--- diff --git a/.claude/skills/doc-persona-review/references/personas.md b/.claude/skills/doc-persona-review/references/personas.md new file mode 100644 index 000000000..485f8d729 --- /dev/null +++ b/.claude/skills/doc-persona-review/references/personas.md @@ -0,0 +1,83 @@ +# Persona Definitions + +Each persona has explicit knowledge boundaries. The subagent prompt must include the full persona definition so the LLM knows exactly what to pretend it does and does not know. + +## Persona 1: Fresh Python Developer + +**Name:** Alex +**Background:** 1-2 years of Python experience. Comfortable with classes, imports, pip/uv, and basic type hints. Has built a Flask app and some CLI scripts. Uses VS Code with Pylance. + +**Knows:** +- Python basics: classes, functions, decorators, list comprehensions, f-strings +- `pip install` / `uv add` for package management +- Basic type hints (`str`, `int`, `list[str]`) +- How to run pytest +- What Home Assistant is (has a running instance, uses the UI) +- YAML automations exist in HA but has never written one + +**Does NOT know:** +- `async`/`await` or event loops +- What an event bus is or how pub/sub works +- Dependency injection (the term or the pattern) +- What `D.StateNew[states.LightState]` means +- Pydantic or `BaseModel` / `SettingsConfigDict` +- What AppDaemon is +- How WebSocket connections work +- What a "handler" is in an event-driven context + +**Reading goal:** Follow the getting-started guide from zero to a working automation. +**Failure signals:** Undefined terms, missing imports in examples, steps that assume async knowledge, "what do I type next?" moments. + +--- + +## Persona 2: AppDaemon Migrator + +**Name:** Sam +**Background:** 3-5 years running Home Assistant. Has 15-20 AppDaemon automations in production. Comfortable Python developer but learned Python through AppDaemon, not formally. Knows HA entities, services, and state inside out. + +**Knows:** +- Home Assistant deeply: entities, services, states, attributes, automations, YAML config +- AppDaemon: `self.listen_state()`, `self.call_service()`, `self.run_in()`, `self.get_state()`, `self.args` +- Python: classes, inheritance, dicts, string formatting +- How to read logs and debug HA automations +- What callbacks are (from AppDaemon's model) + +**Does NOT know:** +- `async`/`await` (AppDaemon apps are synchronous) +- Type hints beyond basic ones +- Pydantic models or validation +- What dependency injection means +- The difference between `self.bus.on_state_change()` and `self.listen_state()` +- Why `await` is needed on API calls +- What `AppConfig` does differently from `self.args` + +**Reading goal:** Migrate existing AppDaemon automations to Hassette without breaking anything. +**Failure signals:** Unclear mapping from AppDaemon concepts, async gotchas not flagged early enough, config migration steps that skip details, "where did my self.args go?" moments. + +--- + +## Persona 3: Experienced Developer, New to Hassette + +**Name:** Jordan +**Background:** 5+ years Python. Has built FastAPI services, worked with SQLAlchemy, written async code. Understands dependency injection from FastAPI's `Depends()`. Runs Home Assistant at home and is comfortable with the HA UI, entities, services, and automations. New to writing Python automations for HA. + +**Knows:** +- Python deeply: async/await, type hints, generics, protocols, dataclasses +- Pydantic v2 (models, validation, settings) +- FastAPI patterns: dependency injection, route handlers, middleware +- Event-driven architecture conceptually +- How to read API docs and reference pages efficiently +- pytest, fixtures, mocking +- Home Assistant: entities, services, domains, states, attributes, the `domain.name` entity ID format, the HA developer tools UI + +**Does NOT know:** +- Hassette's API surface (`self.bus`, `self.scheduler`, `self.api`, `self.states`, `self.cache`, `self.task_bucket`) +- How Hassette maps to HA concepts (e.g., `on_state_change` vs HA automations) +- Hassette-specific types and patterns (`D.StateNew`, `AppConfig`, `Resource`, `AppSync`) +- How WebSocket event streams from HA are structured at the protocol level +- What AppDaemon is (and doesn't care) +- The difference between `self.api.get_state()` and `self.states.get()` +- Hassette's project layout, config file (`hassette.toml`), or CLI + +**Reading goal:** Understand Hassette's architecture and write a well-structured automation. +**Failure signals:** Hassette-specific terms used without definition, concept pages that assume Hassette familiarity, missing "how does this map to what I know from FastAPI/HA?" context, architecture descriptions that don't map to familiar patterns. diff --git a/.claude/skills/docs/SKILL.md b/.claude/skills/docs/SKILL.md new file mode 100644 index 000000000..b2bd8102c --- /dev/null +++ b/.claude/skills/docs/SKILL.md @@ -0,0 +1,175 @@ +--- +name: docs +description: "Use when the user says: \"write docs for X\", \"update the docs\", \"add docs for this feature\", \"rewrite the testing docs\", \"outline these pages\", \"review this doc page\". Scale-aware doc writing with JTBD outlines, voice calibration, and writer/reviewer subagent pairs." +user-invocable: true +references: + - references/docs-context-example.md + - references/writing-prompt-template.md + - references/retrospective.md + - references/prior-art-doc-ia.md +--- + +# Docs + +Write, rewrite, or review documentation pages at any scale. Runs the same process whether the scope is one page or an entire section: JTBD outline per page, voice-calibrated Sonnet writer, Opus reviewer, mechanical quality gates. + +The process was proven on the PR #970 overhaul (76 pages, 267 snippets). See `references/retrospective.md` for what made it work and `references/prior-art-doc-ia.md` for the Diataxis/JTBD research behind it. + +## Arguments + +$ARGUMENTS -- what to write or update. Examples: +- `write docs for the new cache feature` +- `rewrite the testing section` +- `add a recipe for presence detection` +- `outline the scheduler pages` (outline only, skip writing) +- `review docs/pages/core-concepts/bus/index.md` (review only, skip writing) + +If empty, ask what needs documenting. + +## Pre-flight: Calibrate Voice (always runs) + +Read these files at the start of every invocation, regardless of mode or scope: + +1. `.claude/rules/voice-guide.md` -- the 22 voice rules +2. `.claude/rules/doc-rules.md` -- page templates, snippet conventions +3. `references/docs-context-example.md` -- voice calibration artifact with exemplar paths, pass/fail checklist, and top violation patterns +4. The exemplar page(s) matching the page type(s) being worked on: + - Concept: `docs/pages/core-concepts/bus/index.md` + - Recipe: `docs/pages/recipes/motion-lights.md` + - Reference: `docs/pages/core-concepts/bus/dependency-injection.md` + +For mixed batches, read all three exemplars. + +## Phase 1: Scope + +### Detect mode from arguments + +Parse the arguments and announce the detected mode to the user before proceeding: + +> Detected mode: **** (). Running phases: . + +| Trigger | Mode | Phases | +|---|---|---| +| "outline" in args, no "write"/"update" | **Outline only** | Phase 2 | +| "review" in args, no "write"/"update" | **Review only** | Phase 3 (review step only) | +| Everything else | **Full pipeline** | Phases 2, 3, 4 | + +If the args are ambiguous (e.g., "outline and then write"), default to full pipeline and say so. + +### Determine page list + +**If the user named specific pages:** use those. + +**If the user named a feature or topic:** read `.claude/rules/design-completeness.md` to determine what pages are needed. Explore the codebase to understand the feature's scope. Propose a page list with: + +- Page type for each (concept, recipe, getting-started, reference, migration, troubleshooting, operating, web-ui, cli) +- Nav placement in `mkdocs.yml` +- Whether existing pages need updates alongside new ones + +Present the page list via AskUserQuestion for confirmation before proceeding, regardless of page count. + +## Phase 2: Outline + +For each page, produce a JTBD outline before writing. The outline prevents the codebase-mirror anti-pattern (see `references/prior-art-doc-ia.md`). + +### Per-page outline process + +Answer these five questions (from the Diataxis + JTBD framework): + +1. **Page type:** concept / recipe / getting-started / reference / troubleshooting / migration / operating / web-ui / cli +2. **Reader's job:** one sentence. What is the reader trying to do when they land here? +3. **What the reader needs:** list only what is required to complete that job. Nothing else. +4. **Complexity ordering:** simplest case first, advanced in collapsible sections or linked pages. +5. **Anti-mirror check:** would a user organize this page this way, or only someone who has read the source? + +### Outline format + +```markdown +# + +**Page type:** +**Reader's job:** + +## H2:
+<1-2 sentence description of content> + +## H2:
+<1-2 sentence description> + +## Snippet inventory +- `snippet_name.py` -- what it demonstrates (new / keep / rewrite) + +## Cross-links +- Links to: +- Linked from: +``` + +For rewrites, note what changes from the existing page and why. + +### Knowledge inventory (rewrites of operational pages only) + +When rewriting troubleshooting, operating, or migration pages, extract every log signature, timing value, error message, and runbook command before overwriting. These exist nowhere else in the codebase. Write the inventory to a scratch file and cross-reference it against the new outline. + +### Present outlines for approval + +Show outlines to the user. For 1-2 pages, present inline. For 3+, summarize with one-line descriptions and offer to show any outline in detail. + +If in **outline only** mode, stop here. + +## Phase 3: Write and Review + +### Write + +For each page, use the writer prompt from `references/writing-prompt-template.md`. + +1. Fill `{{variables}}` from the outline, snippet inventory, and page type. +2. For `{{technical_facts}}`: read the relevant source files listed in the writer prompt's "Key source files" section. Extract method signatures, parameter names, and behavioral notes. Pass these as the value. +3. Dispatch to a **Sonnet** writer subagent. Use `get-skill-tmpdir docs` for the output directory. +4. Read the output. Verify snippet files were created and `--8<--` includes match. +5. Copy pages and snippets to their final locations in `docs/pages/` (overwrite for rewrites; create for new pages). + +For batches of 3+ pages in the same section, dispatch writer subagents in parallel. + +**Nav and stub management:** +- **New pages:** add to `mkdocs.yml` nav. Create stub files first if other pages cross-link to them, so `mkdocs build --strict` stays green. +- **Rewrites:** overwrite in place. +- **New snippet files:** create alongside the page (`check_paths: true` requires simultaneous existence). + +### Review + +Send each written page to an **Opus** reviewer subagent using the reviewer prompt from `references/writing-prompt-template.md`. Fill `{{page_type_checklist}}` with the matching page-type checklist from the same file. + +**Fix loop:** +1. Apply MUST FIX items. Re-review if any were found. +2. Apply SHOULD FIX items where they improve the page. +3. Note CONSIDER items but don't act unless clearly better. +4. Max 2 review iterations per page. + +If in **review only** mode: run the Opus reviewer on the specified page(s), present findings to the user, and stop. Review-only mode does not run Phase 4 verification. Run `uv run mkdocs build --strict` manually if build issues are suspected. + +## Phase 4: Verify + +Run mechanical checks scaled to the scope of work. + +### Always run + +- `uv run mkdocs build --strict` (0 warnings) +- Pyright on new/modified snippet files + +### For 3+ pages, also run + +- Snippet orphan check: `uv run python tools/docs/check_snippet_orphans.py` +- Bare symbol check: `uv run python tools/docs/check_bare_symbols.py` + +### For 5+ pages, also run + +- Cross-reference coverage: `uv run python tools/docs/check_xref_coverage.py` +- Link checker: build site then run muffet + +### Voice spot-check (5+ pages) + +Sample 2-3 pages and check against the page-type checklists in `references/writing-prompt-template.md`. If >1 finding per sampled page, sweep the rest. + +### Commit + +After verification passes, commit all doc changes together. Use `docs:` commit type for user-facing documentation. Use `chore:` for internal-only changes. diff --git a/.claude/skills/docs/references/docs-context-example.md b/.claude/skills/docs/references/docs-context-example.md new file mode 100644 index 000000000..86c1f2214 --- /dev/null +++ b/.claude/skills/docs/references/docs-context-example.md @@ -0,0 +1,132 @@ +# 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)* + +### 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 + +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 + +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 + +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) + +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 + +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) + +### 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. +``` + +### 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/.claude/skills/docs/references/prior-art-doc-ia.md b/.claude/skills/docs/references/prior-art-doc-ia.md new file mode 100644 index 000000000..943ad6d68 --- /dev/null +++ b/.claude/skills/docs/references/prior-art-doc-ia.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/.claude/skills/docs/references/retrospective.md b/.claude/skills/docs/references/retrospective.md new file mode 100644 index 000000000..bd888e9af --- /dev/null +++ b/.claude/skills/docs/references/retrospective.md @@ -0,0 +1,23 @@ +# What Made This Process Work + +Lessons from the PR #970 execution (76 pages, 267 snippets, 13 task files across ~10 sessions). + +## Key Lessons + +1. **Phase 1 is load-bearing.** The site outline determines everything downstream. Spending disproportionate time here pays off across all writing sessions. + +2. **Exemplars anchor voice.** Three reviewed pages set the voice before bulk writing begins. Without them, drift accumulates across sessions. + +3. **The calibration artifact prevents session amnesia.** A single `docs-context.md` file read at session start keeps voice consistent across compactions and session boundaries. + +4. **Per-page outlines prevent scope drift.** Writers with an outline produce pages that fit the site structure. Writers without outlines produce standalone pages that don't compose. + +5. **Stubs from day one.** Creating stub files for every page before writing any of them means `mkdocs build --strict` stays green throughout. No broken cross-link periods. + +6. **Knowledge inventory for operational pages.** Log signatures, timing values, and error messages exist only in docs. Blank-slate rewrites lose them without an explicit extraction step. + +7. **Writer + reviewer subagent pairs.** The writer subagent follows the outline and voice rules. A separate reviewer subagent catches violations the writer missed. Separation of concerns matters. + +8. **Mechanical checks script the quality gate.** mkdocs strict, Pyright, snippet orphans, link checker, xref coverage, bare symbols. Each is a CI-ready script, not a subjective scan. + +9. **Symbol verification is non-negotiable.** Pages that reference non-existent methods or parameters are worse than pages that omit them. Verify every symbol against the actual source. diff --git a/.claude/skills/docs/references/writing-prompt-template.md b/.claude/skills/docs/references/writing-prompt-template.md new file mode 100644 index 000000000..baec2e2ba --- /dev/null +++ b/.claude/skills/docs/references/writing-prompt-template.md @@ -0,0 +1,290 @@ +# Writing Prompt Template + +Template for briefing subagents that write documentation pages. Fill in the `{{variables}}` for each page. + +## How to use + +The orchestrating agent fills `{{variables}}` from each page's outline entry and dispatches the filled prompt to a Sonnet writer subagent. The reviewer prompt is dispatched after the writer completes. Use `get-skill-tmpdir docs` for `{{output_dir}}`. + +**Variables to fill:** +- `{{page_title}}` -- the page's H1 heading +- `{{output_dir}}` -- from `get-skill-tmpdir docs` +- `{{filename}}` -- the target markdown filename +- `{{section}}` -- the nav section path (e.g., `core-concepts/bus`) +- `{{outline}}` -- the full outline from Phase 2 +- `{{snippet_inventory}}` -- the snippet list from the outline +- `{{page_type}}` -- `getting-started`, `concept`, `recipe`, or `reference` +- `{{voice_rules_block}}` -- the matching block from "Voice Rules Blocks" below +- `{{cross_links}}` -- the cross-link list from the outline +- `{{technical_facts}}` -- relevant technical details from the codebase +- `{{page_path}}` -- path to the written page (reviewer prompt only) +- `{{page_type_checklist}}` -- the matching block from "Page-Type Checklists" below (reviewer prompt only) + +## 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}} + +## 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}} + +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}} + +### 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" +- 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. +``` + +--- + +## Page-Type Checklists (for {{page_type_checklist}}) + +Paste the matching block into the reviewer prompt's `{{page_type_checklist}}` slot. + +### Getting-started pages + +``` +7. "you" and "your" used throughout — direct address. NOT system-as-subject. +8. Code shown FIRST, then explained. Code block immediately after the H2 heading. No introductory sentences before code. +9. Concrete CLI output shown. When a step runs a command, show the exact terminal output. +10. No motivational preamble before code. Don't explain WHY before showing WHAT. +11. Link module aliases inline at first use (D, states, self.bus, self.scheduler, self.api). +``` + +### Concept and API reference pages + +``` +7. System-as-subject throughout — no "you" or "your." "The bus delivers events" not "you receive events." +8. No imperative mood. No "Use X", "Pass Y." Use declarative: "X provides", "Y accepts." +9. Concept introductions follow name -> define -> show -> constrain. +10. Reference sections use tables before prose. Terse functional definitions in table cells. +11. No admonitions inside reference tables. +``` + +### Recipe pages + +``` +7. "How It Works" uses system-as-subject. The code is the subject. No "you" in this section. +8. "How It Works" uses flowing prose paragraphs, NOT bullet lists with bolded lead-ins. Each paragraph covers one decision. +9. "Verify it's working" names a concrete command or UI action with expected output. +10. Problem statement and Variations sections may use "you" and "your." +``` diff --git a/.github/workflows/build_and_publish_image.yml b/.github/workflows/build_and_publish_image.yml index e06032878..dfc8c3dbb 100644 --- a/.github/workflows/build_and_publish_image.yml +++ b/.github/workflows/build_and_publish_image.yml @@ -76,7 +76,7 @@ jobs: TAG_NAME: ${{ inputs.tag_name || github.ref_name }} run: | PROJECT_VERSION=$(uv version --short) - uv run ./tools/check_tag_version.py --ref-name "$TAG_NAME" --project-version "$PROJECT_VERSION" + uv run ./tools/release/check_tag_version.py --ref-name "$TAG_NAME" --project-version "$PROJECT_VERSION" - name: Verify VERSION and IS_STABLE_RELEASE are set for releases if: env.IS_RELEASE == 'true' diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 2374f2509..633d9e024 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -6,12 +6,14 @@ on: - "docs/**" - "mkdocs.yml" - "pyproject.toml" + - "tools/docs/check_snippet_orphans.py" - ".github/workflows/docs.yml" pull_request: paths: - "docs/**" - "mkdocs.yml" - "pyproject.toml" + - "tools/docs/check_snippet_orphans.py" - ".github/workflows/docs.yml" workflow_dispatch: @@ -41,6 +43,54 @@ 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/docs/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 + + 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/.github/workflows/lint.yml b/.github/workflows/lint.yml index 2955737a3..87a84f60d 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -142,22 +142,22 @@ jobs: working-directory: frontend - name: Check CSS module :global() correctness - run: uv run python tools/check_css_module_globals.py + run: uv run python tools/frontend/check_css_module_globals.py - name: Smoke test CSS allowlist logic - run: uv run python tools/check_global_css_allowlist.py --smoke-test + run: uv run python tools/frontend/check_global_css_allowlist.py --smoke-test - name: Check global CSS allowlist (full file) - run: uv run python tools/check_global_css_allowlist.py + run: uv run python tools/frontend/check_global_css_allowlist.py - name: Check dead global CSS (blocking) - run: uv run python tools/check_dead_global_css.py + run: uv run python tools/frontend/check_dead_global_css.py - name: Check undefined CSS refs in TSX (blocking) - run: uv run python tools/check_undefined_css_refs.py + run: uv run python tools/frontend/check_undefined_css_refs.py - name: Check breakpoint drift (JS constants vs CSS media queries) - run: uv run python tools/check_breakpoint_drift.py + run: uv run python tools/frontend/check_breakpoint_drift.py codegen-freshness: needs: changes diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index aec8dbd5f..69492bdeb 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -127,13 +127,13 @@ jobs: TAG_NAME: ${{ needs.release-please.outputs.tag_name || inputs.tag_name }} run: | PROJECT_VERSION=$(uv version --short) - uv run ./tools/check_tag_version.py --ref-name "$TAG_NAME" --project-version "$PROJECT_VERSION" + uv run ./tools/release/check_tag_version.py --ref-name "$TAG_NAME" --project-version "$PROJECT_VERSION" - name: Build a binary wheel and a source tarball run: uv build - name: Verify SPA assets in wheel - run: uv run ./tools/check_wheel_spa.py + run: uv run ./tools/release/check_wheel_spa.py - name: Publish build artifacts uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 @@ -253,11 +253,11 @@ jobs: uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: ref: ${{ needs.release-please.outputs.tag_name || inputs.tag_name }} - sparse-checkout: tools/check_pypi_wheel.py + sparse-checkout: tools/release/check_pypi_wheel.py persist-credentials: false - name: Run PyPI smoke test if: needs.publish-pypi.result == 'success' env: TAG_NAME: ${{ needs.release-please.outputs.tag_name || inputs.tag_name }} - run: python3 ./tools/check_pypi_wheel.py --version "${TAG_NAME#v}" + run: python3 ./tools/release/check_pypi_wheel.py --version "${TAG_NAME#v}" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a95833fbb..865d8212d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -72,7 +72,7 @@ repos: - id: check-css-refs name: Check undefined CSS refs in TSX language: system - entry: uv run python tools/check_undefined_css_refs.py + entry: uv run python tools/frontend/check_undefined_css_refs.py types_or: [ts, tsx, css] files: ^frontend/src/ pass_filenames: false diff --git a/CLAUDE.md b/CLAUDE.md index e2ec2c220..1bf5612d3 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. @@ -329,10 +329,10 @@ Do NOT use bare class names (`.ht-table`) in module CSS — they will be scoped Three scripts enforce CSS hygiene, all wired into `.github/workflows/lint.yml`: -- **`tools/check_global_css_allowlist.py`** — blocks any `.ht-*` selector not on the allowlist from entering shared CSS (`styles/*.css`). Run locally: `uv run python tools/check_global_css_allowlist.py`. Add new shared prefixes to `ALLOWLIST` in that file. -- **`tools/check_dead_global_css.py`** — blocks unreferenced class selectors in shared CSS (`styles/*.css`). Run locally: `uv run python tools/check_dead_global_css.py`. Add dynamically-assembled class prefixes to `EXEMPTIONS` in that file. -- **`tools/check_css_module_globals.py`** — validates that `:global()` usage in module CSS is correct. -- **`tools/check_undefined_css_refs.py`** — blocks raw `ht-*` class references in TSX that have no matching CSS definition in `global.css` or `styles/*.css`. The inverse of the dead-CSS checker. Run locally: `uv run python tools/check_undefined_css_refs.py`. Add false positives (ARIA IDs, test selectors, JS-only classes) to `EXEMPTIONS` in that file. +- **`tools/frontend/check_global_css_allowlist.py`** — blocks any `.ht-*` selector not on the allowlist from entering shared CSS (`styles/*.css`). Run locally: `uv run python tools/frontend/check_global_css_allowlist.py`. Add new shared prefixes to `ALLOWLIST` in that file. +- **`tools/frontend/check_dead_global_css.py`** — blocks unreferenced class selectors in shared CSS (`styles/*.css`). Run locally: `uv run python tools/frontend/check_dead_global_css.py`. Add dynamically-assembled class prefixes to `EXEMPTIONS` in that file. +- **`tools/frontend/check_css_module_globals.py`** — validates that `:global()` usage in module CSS is correct. +- **`tools/frontend/check_undefined_css_refs.py`** — blocks raw `ht-*` class references in TSX that have no matching CSS definition in `global.css` or `styles/*.css`. The inverse of the dead-CSS checker. Run locally: `uv run python tools/frontend/check_undefined_css_refs.py`. Add false positives (ARIA IDs, test selectors, JS-only classes) to `EXEMPTIONS` in that file. ### Adding a new shared class @@ -340,8 +340,8 @@ For classes that don't warrant a component (layout utilities, typography helpers 1. Confirm it is used in 3+ unrelated files (not just BEM descendants of one component) 2. Add it to the appropriate file in `frontend/src/styles/` -3. Add its prefix to `ALLOWLIST` in `tools/check_global_css_allowlist.py` -4. Run `uv run python tools/check_global_css_allowlist.py` to verify +3. Add its prefix to `ALLOWLIST` in `tools/frontend/check_global_css_allowlist.py` +4. Run `uv run python tools/frontend/check_global_css_allowlist.py` to verify For new reusable visual elements (like buttons, badges), create a shared component with a co-located `.module.css` file in `components/shared/` instead of adding global classes. diff --git a/Dockerfile b/Dockerfile index 9dc6df0dd..26384dea9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -53,7 +53,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \ # Generate constraints file from declared dependency ranges (not lockfile pins). # Must use the venv Python so importlib.metadata can find the installed hassette version. -RUN /app/.venv/bin/python tools/generate_constraints.py > /app/constraints.txt \ +RUN /app/.venv/bin/python scripts/generate_constraints.py > /app/constraints.txt \ && /app/.venv/bin/python -c "\ import tomllib; \ lines=[l for l in open('/app/constraints.txt').read().splitlines() if l and not l.startswith('#')]; \ diff --git a/README.md b/README.md index be11d439f..86242d21d 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ Hassette includes a web UI for monitoring and managing your automations: **Apps* Hassette Web UI — Apps page

-The web UI is enabled by default at `http://:8126/ui/`. See the [Web UI documentation](https://hassette.readthedocs.io/en/stable/pages/web-ui/) for details. +The web UI is enabled by default at `http://:8126/`. See the [Web UI documentation](https://hassette.readthedocs.io/en/stable/pages/web-ui/) for details. ## Terminal CLI diff --git a/codegen/src/hassette_codegen/output.py b/codegen/src/hassette_codegen/output.py index 68efa5f2c..98209406e 100644 --- a/codegen/src/hassette_codegen/output.py +++ b/codegen/src/hassette_codegen/output.py @@ -41,7 +41,7 @@ def format_via_ruff(content: str) -> str: tmp_path = tmp.name run_ruff_step(["ruff", "format", tmp_path], "format") - run_ruff_step(["ruff", "check", "--fix", tmp_path], "fix") + run_ruff_step(["ruff", "check", "--fix", "--ignore", "S105", tmp_path], "fix") return Path(tmp_path).read_text(encoding="utf-8") finally: @@ -53,7 +53,7 @@ def format_via_ruff(content: str) -> str: def run_ruff(path: Path) -> None: """Run ruff format + fix all auto-fixable violations, then validate.""" run_ruff_step(["ruff", "format", str(path)], "format") - run_ruff_step(["ruff", "check", "--fix", str(path)], "fix") + run_ruff_step(["ruff", "check", "--fix", "--ignore", "S105", str(path)], "fix") def atomic_write(out_path: Path, content: str) -> bool: diff --git a/design/audits/2026-04-11-docs-nav-audit/audit.md b/design/audits/2026-04-11-docs-nav-audit/audit.md index 04d4bb438..4dbe013ba 100644 --- a/design/audits/2026-04-11-docs-nav-audit/audit.md +++ b/design/audits/2026-04-11-docs-nav-audit/audit.md @@ -230,7 +230,7 @@ nav: ### Tier C Amendments (API reference) -The following modules were added to `PUBLIC_MODULES` Tier C in `tools/gen_ref_pages.py` beyond the nav audit's explicit allowlist. These are autoref targets in narrative docs that require generated reference pages. +The following modules were added to `PUBLIC_MODULES` Tier C in `tools/docs/gen_ref_pages.py` beyond the nav audit's explicit allowlist. These are autoref targets in narrative docs that require generated reference pages. | Module | Reason | |--------|--------| @@ -426,7 +426,7 @@ These pages need all inline fenced code blocks converted to `--8<--` snippet fil ### Current gen-files behavior -`tools/gen_ref_pages.py` walks `src/` with no public/internal filter, emitting `::: module.path` stubs for all discovered modules. After the rewrite (WP10), it should emit stubs only for modules on the allowlist below. +`tools/docs/gen_ref_pages.py` walks `src/` with no public/internal filter, emitting `::: module.path` stubs for all discovered modules. After the rewrite (WP10), it should emit stubs only for modules on the allowlist below. ### Public reference allowlist diff --git a/design/audits/2026-06-07-jtbd-docs-audit.md b/design/audits/2026-06-07-jtbd-docs-audit.md new file mode 100644 index 000000000..9b84e7ea3 --- /dev/null +++ b/design/audits/2026-06-07-jtbd-docs-audit.md @@ -0,0 +1,423 @@ +# JTBD Documentation Audit Report + +## Executive Summary + +**71 pages audited.** Compliance breakdown: +- **Compliant:** 32 pages +- **Partial:** 37 pages +- **Non-compliant:** 2 pages (`getting-started/is-hassette-right-for-you.md`, `web-ui/logs.md`) + +**Top systemic issues:** + +1. **Inline code blocks instead of `--8<--` snippet includes** — affects 15+ pages across migration, testing, and operating sections +2. **Rule 10/15 violations in concept pages** — "you"/"your" and imperative mood bleed into pages that should use system-as-subject; affects 20+ pages +3. **Missing `--8<--` snippet includes in "AppDaemon" comparison tabs** — migration pages consistently use snippet includes for Hassette examples but inline blocks for AppDaemon examples +4. **Missing recipe template sections** — "Variations" and "See Also" headings absent in several recipes despite content being present +5. **Missing concept page scaffolding** — basic example, "How It Works" walkthrough, and "Next Steps" sections absent across most concept pages + +--- + +## Systemic Issues + +### 1. Inline code blocks instead of `--8<--` snippet includes + +**Rule:** All code examples must come from snippet files using `--8<--` includes. No inline fenced blocks for Python, YAML, or TOML examples. + +**Reality:** Migration, testing, operating, and some concept pages use inline code blocks throughout — primarily in comparison tabs, warning admonitions, and variation blocks. + +**Affected pages:** +- `migration/api.md` — AppDaemon tabs at lines 47-55, 67-68, 83-88 +- `migration/bus.md` — AppDaemon tabs at lines 64-73, 113-115, 130-138, 148-158 +- `migration/concepts.md` — teaching examples at lines 51-53, 66-68, 83-97 +- `migration/configuration.md` — inline TOML block at lines 58-73 +- `migration/scheduler.md` — Blocking Work section at lines 67-79 +- `testing/index.md` — inline snippets in prose explanation section +- `troubleshooting.md` — cache key prefix, bus registration, web UI TOML examples +- `core-concepts/apps/configuration.md` — TOML comparison blocks lines 28-43 +- `core-concepts/bus/predicate-reference.md` — P.AllOf range-check example, ServiceDataWhere.from_kwargs example +- `core-concepts/bus/custom-extractors.md` — one inline block inside collapsible +- `operating/upgrading.md` — inline TOML block with placeholder path + +**Concrete examples:** + +From `migration/scheduler.md` lines 67-70: a teaching example that will drift if the API changes — needs to be a snippet file. + +From `core-concepts/apps/configuration.md`, the TOML "Inline form" vs "Table form" comparison blocks appear raw in the markdown rather than via snippet includes. + +--- + +### 2. Rule 10/15 violations: "you"/"your" and imperative mood in concept pages + +**Rule 10:** Do not use "you" in concept or API reference pages. Make the system the subject. **Rule 15:** Do not use imperative mood in concept pages. + +**Reality:** Concept pages across most sections slip into addressing the reader directly, especially in warning admonitions, "how it works" explanations, and practical tips. + +**Affected pages:** +- `getting-started/is-hassette-right-for-you.md` — pervasive throughout (non-compliant) +- `web-ui/logs.md` — seven or more instances (non-compliant) +- `web-ui/manage-apps.md` — intro paragraph and reload instructions +- `web-ui/inspect-config-code.md` — three imperative navigation blocks +- `operating/index.md` — "Use it to send alerts", "Avoid catching TimeoutError" +- `core-concepts/states/conversion.md` — "Place the most specific type first" +- `core-concepts/configuration/index.md` — "production environments should not" +- `testing/time-control.md` — "Call trigger_due_jobs() explicitly afterward" +- `testing/harness.md` — "Seed before you simulate", "If your handler reads..." +- `testing/factories.md` — "Tests that need precise attribute control call them directly" +- `testing/concurrency.md` — "Test code catches DrainTimeout or DrainFailure" +- `core-concepts/bus/filtering.md` — "your handler" +- `core-concepts/internals/index.md` — dense prose block, minor + +**Concrete examples:** + +From `getting-started/is-hassette-right-for-you.md`: +> "Use YAML when your automations are straightforward trigger-action rules." + +Should be: "YAML is the right tool when automations follow straightforward trigger-action patterns." + +From `testing/time-control.md` warning admonition: +> "Call trigger_due_jobs() explicitly afterward. Without it, jobs accumulate silently." + +Should be: "trigger_due_jobs() must be called explicitly after advancing the clock. Without it, jobs accumulate and side-effect assertions fail." + +From `operating/index.md`: +> "If you register an error handler on a subscription or a scheduled job, Hassette calls it after logging. Use it to send alerts..." + +Should be: "Registered error handlers fire after Hassette logs the exception. They are the right place for alerting integrations." + +--- + +### 3. Migration pages use inline blocks for AppDaemon comparison tabs + +**Rule:** All code examples from snippet files. The pattern appears consistent: Hassette tabs correctly use `--8<--` includes; AppDaemon tabs use inline blocks. + +**Affected pages:** `migration/api.md`, `migration/bus.md`, `migration/concepts.md`, `migration/configuration.md`, `migration/scheduler.md` + +**Why this matters separately from issue 1:** These are comparison examples. When AppDaemon's calling convention is documented inline, there is no CI check to catch when the example stops matching reality. + +--- + +### 4. Missing recipe template sections without headings + +**Rule:** Recipes require: problem statement, the code, how it works, verify it's working, variations, see also. + +**Reality:** Several recipes have the variation and see-also content but omit the `## Variations` and `## See Also` headings, making them unfindable by scanning. + +**Affected pages:** +- `recipes/debounce-sensor-changes.md` — "Throttle instead of debounce" appears after Verify with no heading +- `recipes/daily-notification.md` — "Different time", "Include sensor data", "Weekdays only" blocks appear with bold headers but no `## Variations` parent + +--- + +### 5. Missing concept page scaffolding: basic example, "How It Works", "Next Steps" + +**Rule:** Concept pages follow: opening line -> basic example -> how it works -> common patterns -> depth -> next steps. + +**Reality:** Most concept pages skip the basic example and "How It Works" walkthrough, jumping directly from the opening to a reference catalog or method-by-method breakdown. + +**Affected pages:** +- `core-concepts/apps/configuration.md` — no basic example, no how-it-works, no next steps +- `core-concepts/apps/lifecycle.md` — code example present but never walked through +- `core-concepts/scheduler/index.md` — jumps from definition to multi-pattern survey +- `core-concepts/states/conversion.md` — jumps to pipeline mechanism without user-benefit framing +- `core-concepts/configuration/index.md` — no minimal `hassette.toml` example +- `core-concepts/bus/handlers.md` — no walkthrough after the pattern taxonomy +- `core-concepts/internals/lifecycle.md` — state machine diagram appears after failure tables; no page-level intro +- `testing/harness.md` — drops into full API reference without a basic pattern example +- `testing/time-control.md` — method reference without "why this sequence exists" framing + +--- + +## Section-by-Section Results + +### core-concepts/api + +**Overall: Compliant.** Strong section. + +| Page | Status | Key issues | +|---|---|---| +| `index.md` | Compliant | Missing labeled "Common Patterns" and "Next Steps" headers — minor | +| `methods.md` | Compliant | One passive construction: "should be compared against MISSING_VALUE" | +| `managing-helpers.md` | Partial | Common Pitfalls section appears before the CRUD walkthrough (inverts concept order); warning block duplicates the first pitfall entry | + +--- + +### core-concepts/apps + +**Overall: Partial.** Good voice but structural scaffolding missing. + +| Page | Status | Key issues | +|---|---|---| +| `index.md` | Partial | No "How It Works" narrative tying capability catalog together; minor passive voice | +| `configuration.md` | Partial | No basic example; no How It Works; no Next Steps; two inline TOML blocks | +| `lifecycle.md` | Partial | Code example at line 15 not walked through; no Common Patterns; no outcome descriptions after examples | +| `task-bucket.md` | Compliant | Minor: no outcome prose after examples | + +--- + +### core-concepts/bus + +**Overall: Mostly compliant.** Best section overall. + +| Page | Status | Key issues | +|---|---|---| +| `index.md` | Compliant | — | +| `handlers.md` | Partial | No How It Works after basic example; no minimal example before pattern taxonomy; synonym cycling (handler/callback/function) | +| `dependency-injection.md` | Compliant | Minor: no outcome sentences after code blocks | +| `filtering.md` | Partial | "your handler" Rule 10 violation; dict-filtering section uses inline-header list anti-pattern; no minimal example at opening | +| `methods.md` | Compliant | RuntimeError on sync-from-event-loop lacks explicit path forward | +| `predicate-reference.md` | Compliant | Two inline code blocks; footnote links resolve to filtering.md rather than in-page anchors | +| `custom-extractors.md` | Partial | Missing minimal-first progression; "Adding Type Conversion" ordered after internals; one inline block in collapsible | + +--- + +### core-concepts/cache + +**Overall: Compliant.** + +| Page | Status | Key issues | +|---|---|---| +| `index.md` | Compliant | "Configuration" section appears before "How It Works" — inverts concept order | +| `patterns.md` | Compliant | Troubleshooting section should use collapsible `??? note` format | + +--- + +### core-concepts/configuration + +**Overall: Partial.** + +| Page | Status | Key issues | +|---|---|---| +| `index.md` | Partial | No basic example (minimal `hassette.toml`); Rule 15 violation: "production environments should not"; "Full Reference" is too thin as a next-steps section | + +--- + +### core-concepts/states + +**Overall: Partial.** + +| Page | Status | Key issues | +|---|---|---| +| `index.md` | Compliant | "Good to Know" heading is informal; diagram slightly front-loaded | +| `conversion.md` | Partial | Rule 15 violation: "Place the most specific type first"; gatekeeping sentence ("This page is relevant when..."); wide scope | +| `custom-states.md` | Partial | Troubleshooting in prose rather than `??? note` collapsibles; missing outcome prose; Rule 15: "The base class must match..." | + +--- + +### core-concepts/scheduler + +**Overall: Compliant.** Strong section. + +| Page | Status | Key issues | +|---|---|---| +| `index.md` | Compliant | No minimal basic example before multi-pattern survey | +| `management.md` | Compliant | One implied "you" in cancel_null section; minor | +| `methods.md` | Compliant | Missing behavioral outcome sentences after run_cron and schedule blocks | +| `triggers.md` | Compliant | Missing outcome description after custom trigger usage snippet | + +--- + +### core-concepts/internals + +**Overall: Partial.** + +| Page | Status | Key issues | +|---|---|---| +| `index.md` | Partial | Overloaded (both index and concept); event pipeline explanation dense; missing Next Steps | +| `lifecycle.md` | Partial | No page-level introduction; state machine diagram buried after failure tables; `RestartSpec` code snippet has no outcome prose | +| `service-details.md` | Partial | Opening sentence violates Rule 1 (meta-structural description); missing See Also; some passive constructions | + +--- + +### getting-started + +**Overall: Mixed.** Docker index and ha_token are good; is-hassette-right-for-you needs significant rework. + +| Page | Status | Key issues | +|---|---|---| +| `index.md` | Compliant | Missing "What you'll build" preamble; no definition of Hassette for brand-new visitors | +| `is-hassette-right-for-you.md` | **Non-compliant** | Pervasive Rule 10 violations; Rule 15 violations; `AppTestHarness` used without definition | +| `first-automation.md` | Compliant | Missing "What you'll learn" and Prerequisites sections | +| `ha_token.md` | Compliant | Missing "What you'll learn" and Next Steps | +| `docker/index.md` | Compliant | Missing "What you'll learn" preamble | +| `docker/dependencies.md` | Partial | Missing "What you'll learn", Prerequisites, Next Steps | +| `docker/image-tags.md` | Partial | No opening context; no outcome descriptions after code blocks; effectively a stub | +| `docker/troubleshooting.md` | Compliant | Two inline code blocks | + +--- + +### recipes + +**Overall: Mostly compliant.** Best section in the docs. + +| Page | Status | Key issues | +|---|---|---| +| `index.md` | Compliant | — | +| `motion-lights.md` | Compliant | Verify section lacks sample output block | +| `vacation-mode-toggle.md` | Compliant | `` placeholder in Verify CLI commands | +| `service-call-reaction.md` | Compliant | `` placeholder in Verify CLI commands | +| `sensor-threshold.md` | Partial | `` placeholders in Verify section; hysteresis variation lacks code snippet | +| `debounce-sensor-changes.md` | Partial | Missing `## Variations` heading; Verify shows expected output in prose rather than code block | +| `daily-notification.md` | Partial | Missing `## Variations` heading; How It Works lists facts rather than walking one decision per paragraph | + +--- + +### migration + +**Overall: Compliant structure, inline code block problem throughout.** + +| Page | Status | Key issues | +|---|---|---| +| `index.md` | Partial | Overloaded for an index page; async methods limitation lacks path forward | +| `checklist.md` | Partial | Three stacked admonitions in "Common Pitfalls" — explicit doc-rules violation | +| `concepts.md` | Partial | Structurally inconsistent (starts as comparison, shifts to concept midway); multiple inline code blocks | +| `api.md` | Compliant | AppDaemon tab inline blocks | +| `bus.md` | Compliant | Weak opening sentence ("This page covers..."); AppDaemon tab inline blocks | +| `configuration.md` | Compliant | Inline TOML block at lines 58-73 | +| `scheduler.md` | Compliant | Two inline code blocks in Blocking Work section | +| `testing.md` | Partial | Seed order warning appears before the test example (context-free) | + +--- + +### cli + +**Overall: Compliant.** + +| Page | Status | Key issues | +|---|---|---| +| `index.md` | Compliant | Minor: imperative "Start Hassette with `hassette run`, then retry" | +| `commands.md` | Compliant | Console output examples are inline — acceptable for CLI reference | +| `configuration.md` | Compliant | One passive construction; one "you" in tip admonition (borderline) | +| `workflows.md` | Compliant | A few borderline "you" constructions | + +--- + +### operating + +**Overall: Partial.** + +| Page | Status | Key issues | +|---|---|---| +| `index.md` | Partial | Rule 10/15 violations; four distinct topics should be split to sibling pages; missing Next Steps | +| `log-levels.md` | Compliant | Several imperative constructions in opening sentence | +| `upgrading.md` | Partial | Inline TOML block with `youruser` placeholder; several "you" constructions | + +--- + +### testing + +**Overall: Partial.** Good content, voice and scaffolding issues. + +| Page | Status | Key issues | +|---|---|---| +| `index.md` | Compliant | Inline code snippets in prose explanation; missing Prerequisites section | +| `harness.md` | Partial | No basic example before API reference; multiple Rule 10/15 violations in admonitions | +| `time-control.md` | Partial | Imperative admonitions; no "why this sequence" framing; missing outcome descriptions | +| `factories.md` | Compliant | "Tests that need..." Rule 10 framing; hedging in Internal Helpers section | +| `concurrency.md` | Compliant | Minor Rule 10/15 in admonitions | + +--- + +### web-ui + +**Overall: Mixed.** Reference pages good; UI procedure pages need voice work. + +| Page | Status | Key issues | +|---|---|---| +| `index.md` | Compliant | Minor setup imperatives acceptable on index page | +| `health-endpoints.md` | Compliant | Minor: "belong at this endpoint" recommendation framing | +| `debug-handler.md` | Compliant | — | +| `manage-apps.md` | Partial | Rule 10/15 violations in intro paragraph and reload instructions; missing Next Steps | +| `inspect-config-code.md` | Partial | Three imperative navigation blocks; "you" in env var tip | +| `logs.md` | **Non-compliant** | Seven or more Rule 10 violations; imperative UI instructions throughout; page voice is fundamentally inconsistent | + +--- + +### troubleshooting + +**Overall: Compliant.** + +| Page | Status | Key issues | +|---|---|---| +| `troubleshooting.md` | Compliant | Three inline code blocks; "will not" should be "does not" (future vs present tense) | + +--- + +## Compliant Pages + +These pages passed with no significant issues: + +- `core-concepts/api/index.md` +- `core-concepts/api/methods.md` +- `core-concepts/apps/task-bucket.md` +- `core-concepts/bus/index.md` +- `core-concepts/bus/dependency-injection.md` +- `core-concepts/bus/methods.md` +- `core-concepts/bus/predicate-reference.md` +- `core-concepts/cache/index.md` +- `core-concepts/cache/patterns.md` +- `core-concepts/database-telemetry.md` +- `core-concepts/index.md` +- `core-concepts/scheduler/index.md` +- `core-concepts/scheduler/management.md` +- `core-concepts/scheduler/methods.md` +- `core-concepts/scheduler/triggers.md` +- `core-concepts/states/index.md` +- `getting-started/index.md` +- `getting-started/first-automation.md` +- `getting-started/ha_token.md` +- `getting-started/docker/index.md` +- `getting-started/docker/troubleshooting.md` +- `recipes/index.md` +- `recipes/motion-lights.md` +- `recipes/vacation-mode-toggle.md` +- `recipes/service-call-reaction.md` +- `migration/api.md` +- `migration/bus.md` +- `migration/configuration.md` +- `migration/scheduler.md` +- `cli/index.md` +- `cli/commands.md` +- `cli/configuration.md` +- `cli/workflows.md` +- `operating/log-levels.md` +- `testing/index.md` +- `testing/factories.md` +- `testing/concurrency.md` +- `web-ui/index.md` +- `web-ui/health-endpoints.md` +- `web-ui/debug-handler.md` + +--- + +## Recommended Priority + +### Priority 1 — Fix the two non-compliant pages + +**`getting-started/is-hassette-right-for-you.md`:** Rewrite every "you" and imperative sentence to make Hassette or HA YAML the subject. Remove `AppTestHarness` from the comparison table or add a functional definition. + +**`web-ui/logs.md`:** Decide its register: how-to (imperatives and "you" fine) or reference (system-as-subject throughout). Given it lists UI controls by behavior, reference-page voice is the right call. Rewrite the seven+ violations consistently. + +### Priority 2 — Migrate AppDaemon tab inline blocks to snippet files (fixes 5 migration pages) + +Mechanical: for each inline block in an AppDaemon tab, extract to a snippet file and replace with `--8<--`. Fixes systemic issues 1 and 3 simultaneously. + +### Priority 3 — Fix Rule 10/15 violations in testing section (4 pages, same pattern) + +Rewrite admonitions and explanatory prose from imperative/second-person to declarative/system-as-subject. + +### Priority 4 — Fix stacked admonitions and missing recipe headings (quick wins) + +- `migration/checklist.md`: Merge three stacked admonitions +- `recipes/debounce-sensor-changes.md` and `recipes/daily-notification.md`: Add `## Variations` and `## See Also` headings + +### Priority 5 — Add basic examples to concept pages missing them (~8 pages) + +Add 3-8 line minimal code examples immediately after opening paragraphs. Moves these pages from partial to compliant. + +### Priority 6 — Fix operating/index.md scope and voice + +Rewrite Rule 10/15 violations. Evaluate splitting four distinct topics into sibling pages. + +### Priority 7 — Remaining inline block fixes (dispersed, lower impact per page) + +Fix standalone inline blocks in `core-concepts/apps/configuration.md`, `core-concepts/bus/predicate-reference.md`, `troubleshooting.md`, `operating/upgrading.md`, and `getting-started/docker/troubleshooting.md`. diff --git a/design/audits/2026-06-08-persona-review-getting-started.md b/design/audits/2026-06-08-persona-review-getting-started.md new file mode 100644 index 000000000..d12a5735c --- /dev/null +++ b/design/audits/2026-06-08-persona-review-getting-started.md @@ -0,0 +1,165 @@ +# Persona Review: Getting Started Section + +**Date:** 2026-06-08 +**Persona:** Alex (fresh Python developer, 1-2 years experience, no async/HA automation background) +**Pages reviewed:** Is Hassette Right for You?, Quickstart, HA Token, First Automation +**Two review modes compared:** reading-path (all 4 pages in sequence) vs per-page (each page in isolation) + +--- + +## Mode Comparison: Path vs Per-Page + +### Verdict table + +| Page | Path mode | Per-page mode | +|------|-----------|---------------| +| Is Hassette Right for You? | (part of path: followable-with-effort) | followable-with-effort (11 findings) | +| Quickstart | (part of path) | followable-with-effort (12 findings) | +| HA Token | (part of path) | followable (5 findings) | +| First Automation | (part of path) | followable-with-effort (14 findings) | +| **Full path** | **followable-with-effort (11 findings + 3 cross-page)** | N/A | + +### Which mode was better? + +**The path review was markedly better.** It caught three cross-page issues that per-page reviews cannot detect by design, and it correctly filtered out per-page false positives (things that seem confusing in isolation but were already covered on a prior page). + +**What the path review caught that per-page missed:** + +1. **Accumulated async confusion.** async/await is flagged as a requirement on page 1, appears without explanation in code on page 2, and is used throughout page 4. Each page adds more async patterns without accumulated explanation. Per-page reviews flagged async on each page independently, but the *compounding* nature of the confusion — and the specific suggestion to add a 3-5 sentence explainer early in the journey — only emerged from the path review. + +2. **self.bus/self.scheduler/self.api appear from thin air.** The quickstart uses self.logger with no explanation, and first-automation adds three more magic attributes. A Flask developer would expect to see these created somewhere. Per-page reviews flagged each individually; the path review identified the pattern and suggested one paragraph covering all four. + +3. **"dependency injection" is seeded across pages before being defined.** The term appears in the quickstart next-steps, reappears in the first-automation intro, and is only explained several lines into first-automation's body. The path review caught the three-page accumulation of an undefined term. + +**What per-page caught that path missed:** + +- More granular findings per page (12 on quickstart vs ~3 for quickstart in the path review) +- Page-specific issues like "how do I find Developer Tools in HA?" and "what does Invoc/1h mean in the CLI table?" +- The HA token page's lack of verification step (the path review barely mentioned it since it's a short page) + +**Recommendation:** Run path reviews for sequential sections (getting-started, migration). Run per-page reviews for standalone pages (concept pages, recipes, reference pages). The path review is the primary tool; per-page fills in detail afterward. + +--- + +## Cross-Page Issues (from path review) + +### 1. async/await is never explained across the entire journey + +**Pages:** is-hassette-right-for-you → quickstart → first-automation +**Problem:** Page 1 flags "async basics" as recommended. Page 2 uses `async def on_initialize` without explanation. Page 4 uses `await` on every bus/scheduler/api call. Alex copies patterns on faith but never learns the rule. +**Suggestion:** Add a 3-5 sentence async/await explainer early — either as a callout on page 1 or a brief section in the quickstart: "Write `async def` for all your app methods. Add `await` before any call to `self.bus`, `self.scheduler`, or `self.api`. That's it for now." + +### 2. self.bus, self.scheduler, self.api appear without introduction + +**Pages:** quickstart → first-automation +**Problem:** `self.logger` is used on page 2, then `self.bus`, `self.scheduler`, `self.api` appear on page 4 — all without explanation of where they come from. A Flask developer expects to see objects created or injected. +**Suggestion:** One paragraph, one time: "Every Hassette app inherits four objects: `self.logger` (Python logger), `self.bus` (event subscriptions), `self.scheduler` (timed jobs), and `self.api` (Home Assistant calls). Hassette creates them — you just use them." + +### 3. "dependency injection" accumulates as undefined jargon + +**Pages:** quickstart (next-steps link) → first-automation (intro bullet, body explanation) +**Problem:** The term appears twice before being defined. Alex builds anxiety about an unknown concept. +**Suggestion:** Remove "dependency injection" from the quickstart next-steps blurb. Let first-automation introduce and explain it cleanly on first use. Or add a parenthetical on first mention: "dependency injection (Hassette extracts typed values from events and passes them to your handler automatically)." + +--- + +## Per-Page Findings + +### Page 1: Is Hassette Right for You? (followable-with-effort, 11 findings) + +| Line | Type | Quote | Confusion | Suggestion | +|------|------|-------|-----------|------------| +| 2 | undefined-term | "connects over the WebSocket API" | "WebSocket means nothing to me. Do I need to set something up?" | Drop or parenthesize: "connects to Home Assistant directly (no extra setup needed)" | +| 6 | undefined-term | "Hassette apps run in a test harness" | "'Test harness' is a new term. Is this just pytest?" | Replace with "a pytest-based setup" | +| 6 | undefined-term | "simulate events, advance time" | "What is an 'event' here?" | Use concrete language: "simulate sensor triggers, fast-forward timers" | +| 8 | assumed-knowledge | Jinja2 template debugging reference | "I've never written a Jinja2 template for HA" | Add a more universal signal first | +| 17 | undefined-term | "AppTestHarness" in comparison table | "Is this a Hassette thing? A separate package?" | Simplify to "pytest integration" | +| 19 | assumed-knowledge | "Medium (Python + async basics)" | "How much async do I need before starting?" | Add: "the quickstart introduces what you need" | +| 24 | undefined-term | "The Docker Setup guide" | "Is this a link? Where do I find it?" | Ensure it's a hyperlink in rendered output | +| 25 | unclear-next-step | "token you generate in your profile settings" | "Which profile settings? HA has a lot of settings" | Add navigation path or link to HA docs | +| 26 | undefined-term | "AppSync for writing synchronous apps" | "Should I use AppSync instead? I don't know async..." | Cut this mention or add: "start with the async API — most docs use it" | +| 26 | assumed-knowledge | "await and async def appear in every example" | "I've never written async code. Is this a blocker?" | Add: "the Quickstart walks through the pattern as you go" | +| 29 | assumed-knowledge | "Coming from AppDaemon?" | "I don't know what AppDaemon is" | Add a third option: "No prior HA automation framework? The Quickstart is the right starting point." | + +### Page 2: Quickstart (followable-with-effort, 12 findings) + +| Line | Type | Quote | Confusion | Suggestion | +|------|------|-------|-----------|------------| +| 2 | undefined-term | "one-file automation" | "What will the automation actually do?" | State the goal: "log a message when Hassette starts" | +| 6 | missing-prerequisite | "a long-lived access token" | "I've never heard this term" | Add parenthetical with HA navigation path | +| 12 | unclear-next-step | "Create a long-lived access token from the HA UI" | "I open HA. Now what? No menu path given" | Add: "Profile → Security → Long-Lived Access Tokens → Create Token" | +| 19 | unclear-next-step | .env file content shown | "The step never tells me to create the file" | Add explicit "Create a .env file" instruction | +| 19 | assumed-knowledge | `HASSETTE__BASE_URL` | "Is 8123 always right? What if different port?" | Add: "Use the URL you normally open in your browser" | +| 24 | undefined-term | `class MyApp(App[MyAppConfig])` | "I've never seen square brackets on a class definition" | Brief note about Python generics | +| 24 | undefined-term | `async def on_initialize(self)` | "I've never written async code" | Reassurance: "Hassette calls it for you" | +| 24 | undefined-term | `self.app_config.greeting` | "Where does self.app_config come from?" | Note that Hassette provides it automatically | +| 26 | undefined-term | "Hassette discovers app classes automatically" | "How does it know MyApp is the class to run?" | Explain: "scans apps/ for any App subclass" | +| 35 | no-verification | "Hassette loaded your config" | "Which line proves MY code ran vs Hassette booting?" | Make causal link explicit | +| 46 | assumed-knowledge | "Invoc/1h" column | "What does this mean? Is 0 normal?" | Explain the column briefly | +| 52 | unmotivated-content | "dependency injection" in next-steps | "I've never heard this term" | Soften: "react to real HA events" | + +### Page 3: HA Token (followable, 5 findings) + +| Line | Type | Quote | Confusion | Suggestion | +|------|------|-------|-----------|------------| +| 2 | undefined-term | "long-lived access token" | "Why 'long-lived'? Different from a regular token?" | Add: "a token that doesn't expire" | +| 9 | missing-prerequisite | "Add the token to your .env file" | "What .env file? I haven't created one" | Add: "In your project directory, open or create a file named .env" | +| 11 | assumed-knowledge | `HASSETTE__TOKEN` double underscore | "Unusual. Is this a typo?" | Note: "The double underscore is required" | +| 13 | unclear-next-step | "The Quickstart covers the full .env setup" | "Should I go there now or was I supposed to do it first?" | Reframe as: "If you haven't done the Quickstart yet, start there" | +| — | no-verification | (page overall) | "How do I confirm the token works?" | Add a verification step | + +### Page 4: First Automation (followable-with-effort, 14 findings) + +| Line | Type | Quote | Confusion | Suggestion | +|------|------|-------|-----------|------------| +| 2 | missing-prerequisite | "the app from the Quickstart" | "I landed from search. No link, no fallback." | Add prerequisite with link | +| 3 | undefined-term | "dependency injection" | "Never heard this in Python context" | Replace with "Hassette fills in typed state data automatically" | +| 7 | undefined-term | `async def on_initialize` | "Why async? What breaks if I forget it?" | One-sentence async explainer | +| 7 | undefined-term | `self.bus.on_state_change(` | "What is 'bus'? Where did it come from?" | "self.bus is Hassette's event bus, created automatically on every App" | +| 7 | undefined-term | `handler=self.on_sun_change` | "What is a 'handler'?" | Add: "handler= is the method Hassette calls each time the event fires" | +| 9 | undefined-term | "the web UI" | "There's a web UI? Do I need to set it up?" | Brief mention or defer | +| 10 | unmotivated-content | "extract from the event" | "What event? What's being extracted?" | Reframe: "tells Hassette: pass me the new state as a SunState object" | +| 14 | unclear-next-step | `domain="light"` routing | "What is a HA service? How do I find others?" | Add: "find available services in HA Developer Tools → Services" | +| 16 | assumed-knowledge | `self.scheduler` | "Where did this come from?" | Introduce alongside self.bus | +| 19 | no-verification | "Hassette tracks the job..." | "What if I forget await?" | Note the consequence | +| 20 | undefined-term | "DI parameters" | "DI" abbreviation never introduced | Spell out "dependency injection parameters" | +| 22 | missing-prerequisite | "restart Hassette" | "How? No command shown" | Show: "Stop with Ctrl+C, then run `hassette run`" | +| 26 | no-verification | "lines appear at the next sunset" | "Could be 12 hours away" | Promote the collapsed test tip to main content | +| 27 | unclear-next-step | "open Home Assistant Developer Tools" | "Where is that in the HA UI?" | Add: "click Developer Tools ( icon) in the sidebar" | + +--- + +## Path Review: Knowledge State After Each Page + +| After page | Alex knows | Alex still confused about | +|------------|-----------|--------------------------| +| Is Hassette Right for You? | Hassette = Python automations, needs separate process, needs token, async is involved | What async means in practice, whether to learn it first | +| Quickstart | Has installed Hassette, created project, written minimal app, seen it run | Why `async def`, where `self.app_config` comes from, "dependency injection" term planted | +| HA Token | Where to create token in HA UI, token security | (no new confusions, clear page) | +| First Automation | Has working sunset + heartbeat app, broad shape of bus/scheduler/api | Why everything needs `await`, what self.bus is, `D.StateNew[T]` bracket syntax, how to verify sunset handler | + +## Path Review Summary + +> "The four-page journey is followable for Alex but requires copying patterns on faith more than understanding them. Pages 1 and 3 are clear and well-scoped. Page 2 (quickstart) successfully delivers a win — Alex gets code running — but plants async confusion that compounds through page 4. Page 4 is where the debt comes due: async is everywhere, new objects appear from thin air, and the bracket-type syntax is unexplained. Alex will finish with a working app but with a lingering sense of 'I don't really know why this works.' The biggest single fix is a 3-5 sentence async/await explainer early in the journey; the second biggest is one sentence explaining where self.bus/self.scheduler/self.api come from." + +--- + +## Recommended Priority for Fixes + +### Priority 1 — Add async/await explainer (cross-page fix) +3-5 sentences in the Quickstart, immediately after the first `async def` code block. Covers the entire journey. + +### Priority 2 — Introduce self.bus/self.scheduler/self.api +One paragraph in the Quickstart or early in First Automation. Eliminates "where did this come from?" confusion. + +### Priority 3 — Remove premature "dependency injection" mentions +Drop from Quickstart next-steps and First Automation intro bullet. Let the body explanation stand on its own. + +### Priority 4 — Promote the sunset test tip +Move from collapsed to visible content. All three personas (in the earlier three-persona test) flagged this independently. + +### Priority 5 — Add HA navigation paths +Token creation steps, Developer Tools location, service browser. Small additions that prevent 5-10 minute detours. + +### Priority 6 — Explain `HASSETTE__` double underscore convention +One sentence. Prevents a silent misconfiguration. diff --git a/design/research/2026-04-03-code-screenshot-tools/research.md b/design/research/2026-04-03-code-screenshot-tools/research.md index 55be48017..d282502c5 100644 --- a/design/research/2026-04-03-code-screenshot-tools/research.md +++ b/design/research/2026-04-03-code-screenshot-tools/research.md @@ -53,7 +53,7 @@ The `mkdocs.yml` already has `pymdownx.superfences` with custom fence support (c - `pymdownx.superfences` custom fences could wrap a code-to-image tool - `pymdownx.snippets` already supports including code from files -- the same source files could feed both inline code blocks and image generation - MkDocs `gen-files` plugin is already installed and could run a pre-build script -- The `tools/gen_ref_pages.py` pattern shows the project already uses build-time code generation +- The `tools/docs/gen_ref_pages.py` pattern shows the project already uses build-time code generation ### What works against this 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/research/2026-06-08-beginner-persona-testing/research.md b/design/research/2026-06-08-beginner-persona-testing/research.md new file mode 100644 index 000000000..b01a2fb17 --- /dev/null +++ b/design/research/2026-06-08-beginner-persona-testing/research.md @@ -0,0 +1,150 @@ +--- +topic: "beginner-persona-doc-testing" +date: 2026-06-08 +status: Draft +--- + +# Prior Art: Beginner Persona Testing for Developer Documentation + +## The Problem + +Documentation that passes style checks and structural audits can still be incomprehensible to a newcomer. The curse of knowledge makes expert reviewers unreliable judges of beginner experience: they unconsciously fill in gaps, assume context, and approve docs that would lose a first-time reader at step 3. + +The question is whether LLMs, prompted with a beginner persona, can simulate that "fresh eyes" evaluation at CI-friendly speed and cost, or whether this is a category of problem that requires real humans. + +## How We Do It Today + +Hassette has a comprehensive voice audit tool (`tools/docs/check_doc_voice.py`) that enforces 15+ prose and structural rules across 71 pages in CI. It catches em dashes, copula avoidance, pronoun violations, stacked admonitions, and missing recipe sections. Code snippets are Pyright-tested via `--8<--` includes. What's missing is any evaluation of whether a page is *followable* by someone who doesn't already know the system. + +## Patterns Found + +### Pattern 1: Automated Style Linting (Vale) + +**Used by**: GitLab, Datadog, Spectro Cloud, Elastic +**How it works**: CLI tools like Vale run configurable prose rules in CI: sentence length, banned jargon, passive voice, readability scores. Teams start with industry rulesets (Microsoft, Google) and add project-specific rules. +**Strengths**: Fast, deterministic, scales to any team size, runs on every commit. +**Weaknesses**: Cannot evaluate conceptual comprehension. A perfectly Vale-clean page can still confuse a beginner if the conceptual progression is wrong or prerequisites are missing. +**Example**: https://docs.gitlab.com/development/documentation/testing/vale/ + +### Pattern 2: Cognitive Walkthrough (Human Expert Simulation) + +**Used by**: UX teams broadly, adapted for docs by technical writing teams +**How it works**: An evaluator walks through a tutorial step by step as a first-time user, asking four questions at each step: (1) Will the user know what to do? (2) Will they notice the correct action? (3) Will they connect the action to the goal? (4) Will they see progress? +**Strengths**: Low cost (no real users), structured methodology, specifically targets learnability. +**Weaknesses**: Experts struggle to suppress their own knowledge. The "curse of knowledge" is the whole reason this is hard. Time-intensive for long doc sets. +**Example**: https://www.nngroup.com/articles/cognitive-walkthroughs/ + +### Pattern 3: Task-Based Usability Testing with Real Users + +**Used by**: Sendbird, Stripe, Twilio +**How it works**: Real developers matching the target persona follow docs to complete tasks while observed. Single-blind design prevents bias. Measures time-to-completion, error count, satisfaction. Think-aloud captures reasoning. +**Strengths**: Highest fidelity. Captures failure modes no automated tool finds ("I assumed the import was in the previous step"). Sendbird's study identified broken copy-paste code as the #1 friction point. +**Weaknesses**: Expensive ($500-2000/participant), slow (weeks of recruitment), small samples, point-in-time snapshots. +**Example**: https://sendbird.com/blog/evaluating-developers-onboarding-experience-ux-benchmarking-study + +### Pattern 4: Documentation-as-Tests (Executable Docs) + +**Used by**: Doc Detective users, Stripe (internal tooling) +**How it works**: Code samples and commands in docs are extracted and executed against the real product in CI. Catches documentation drift when the product changes but docs don't. +**Strengths**: Catches the #1 onboarding killer (broken code samples). Deterministic. Hassette already partially does this via Pyright-checked snippet files. +**Weaknesses**: Only tests "does this work?" not "does this make sense?" +**Example**: https://docs.doc-detective.com/ + +### Pattern 5: LLM Persona-Based Documentation Review + +**Used by**: Early adopters, no standardized framework. Google officially recommends it. UW research validated it empirically. +**How it works**: An LLM is prompted with a detailed persona (skill level, domain knowledge, goals) and asked to read docs as that persona, flagging confusion points. The prompt includes cognitive walkthrough questions or a quality rubric. Multiple personas run in parallel (complete beginner, intermediate dev new to the domain, experienced dev skimming). + +The UW synthetic heuristic evaluation study (2025) found LLM evaluators identified 73-77% of usability issues vs 57-63% for experienced human evaluators. + +**Strengths**: Cheap (pennies per review), fast (minutes), repeatable on every PR, produces reasoning traces showing where and why confusion occurs. Avoids the curse of knowledge that plagues expert walkthroughs. Can run multiple personas in parallel. +**Weaknesses**: LLMs don't genuinely lack knowledge, they simulate lacking it. May miss confusion arising from real knowledge gaps. No established benchmarks for how well findings correlate with real beginner findings. Risk of false positives and false negatives. +**Example**: https://developers.google.com/tech-writing/two/llms (Google guidance), https://arxiv.org/abs/2507.02306 (UW empirical validation) + +### Pattern 6: Quality Rubric / Checklist Assessment + +**Used by**: Tom Johnson (idratherbewriting), API documentation teams +**How it works**: A 50-70+ item checklist covering Findability, Accuracy, Clarity, Completeness, Readability is applied systematically. Each item gets pass/fail/partial. Originally for human reviewers; increasingly given to LLMs as evaluation criteria. +**Strengths**: Structured, comprehensive, produces actionable findings. The checklist itself documents quality standards. +**Weaknesses**: Time-intensive for humans. Can become compliance exercise rather than genuine comprehension check. +**Example**: https://idratherbewriting.com/learnapidoc/docapis_quality_checklist.html + +### Pattern 7: Metrics-Based Health Tracking + +**Used by**: Stripe, Twilio, DX-focused platforms +**How it works**: Track time-to-first-API-call, support ticket categories, page analytics, search queries with no results. DX research found 2.4x delivery performance correlation with doc quality. +**Strengths**: Objective, continuous, tied to business outcomes. +**Weaknesses**: Lagging indicators. Requires traffic volume Hassette doesn't have yet. +**Example**: https://getdx.com/blog/developer-documentation/ + +## Anti-Patterns + +- **Readability scores as quality measure**: Flesch-Kincaid measures syllable count, not conceptual clarity. Short sentences with undefined prerequisites still confuse beginners. +- **User surveys as primary signal**: Low response rates, vague feedback, self-blame bias. Not actionable. +- **Expert review without persona adoption**: Domain experts unconsciously fill gaps. Without structured walkthrough questions, they approve docs that confuse newcomers. +- **Comprehension testing without code correctness testing**: Sendbird found broken copy-paste code was the #1 friction point, outweighing all prose quality concerns. Both layers are needed. + +## Emerging Trends + +**LLM personas as cognitive walkthrough automation** is the most active area. It combines the structured methodology of cognitive walkthroughs with LLM persona simulation to address the curse-of-knowledge problem. No standardized tool exists yet; teams are building bespoke prompts. The UW 2025 study provides early empirical validation. + +**Multi-layer pipelines** are forming: Vale for style (every commit), Doc Detective for code correctness (every commit), LLM persona review for comprehension (periodic or on significant changes), real user testing for validation (quarterly). + +## Relevance to Us + +Hassette already covers layers 1 and 2 well: `check_doc_voice.py` handles style enforcement and structural rules, and Pyright-checked snippet files handle code correctness. The missing layer is comprehension testing. + +Pattern 5 (LLM persona review) is the best fit because: +- It addresses the exact gap (is this followable by a beginner?) +- It can reuse the cognitive walkthrough methodology (structured per-step questions) +- It integrates with our existing subagent patterns (dispatch persona agents in parallel) +- It's cheap enough to run on every significant docs change +- The voice guide and doc-rules already define page types and expectations, which can serve as evaluation criteria + +The main risk: the LLM simulates lacking knowledge rather than genuinely lacking it. Mitigation: constrain the persona tightly ("you do NOT know what dependency injection is, you have never seen the D.StateNew syntax") and look for specific failure classes (undefined terms, missing imports, unclear next steps) rather than vague "this is confusing" feedback. + +## Recommendation + +Build an LLM persona reviewer as a new tool or skill. The design would be: + +1. **Persona definitions**: 2-3 personas with explicit knowledge boundaries (e.g., "Python developer, 2 years, no HA experience, no async event systems"; "Node.js developer, HA user, first time writing Python automations") +2. **Cognitive walkthrough prompt**: At each step, answer the four questions (will the reader know what to do? notice the action? connect it to the goal? see progress?) +3. **Page-type awareness**: Getting-started pages get the full walkthrough; concept pages get a "can you explain back what this does?" check; recipes get "could you modify this for a different sensor?" +4. **Structured output**: Findings as a JSON list with line numbers, confusion type, and severity + +Start with getting-started pages (highest beginner traffic) and recipes (task-oriented, most testable). Run it manually first before CI integration. + +## Sources + +### Academic research +- https://arxiv.org/abs/2507.02306 -- UW synthetic heuristic evaluation study (LLM evaluators outperformed human experts) +- https://arxiv.org/pdf/2312.02586 -- Documentation experience study (exploration/comprehension/application phases) +- https://www.emergentmind.com/topics/personallm -- PersonaLLM research aggregator + +### Reference implementations +- https://docs.gitlab.com/development/documentation/testing/vale/ -- GitLab Vale CI integration +- https://www.datadoghq.com/blog/engineering/how-we-use-vale-to-improve-our-documentation-editing-process/ -- Datadog Vale usage +- https://docs.doc-detective.com/ -- Doc Detective executable documentation testing + +### Industry case studies +- https://sendbird.com/blog/evaluating-developers-onboarding-experience-ux-benchmarking-study -- Sendbird single-blind onboarding study +- https://sendbird.com/blog/qualitative-evaluation-of-onboarding-new-developers-a-ux-benchmarking-study-part-2 -- Sendbird qualitative findings + +### Methodologies and guides +- https://developers.google.com/tech-writing/two/llms -- Google's guidance on LLM persona prompting for doc review +- https://www.nngroup.com/articles/cognitive-walkthroughs/ -- NN/g cognitive walkthrough methodology +- https://en.wikipedia.org/wiki/Cognitive_walkthrough -- Cognitive walkthrough overview +- https://idratherbewriting.com/learnapidoc/docapis_quality_checklist.html -- Tom Johnson's quality checklist +- https://idratherbewriting.com/blog/measuring-documentation-quality-rubric-developer-docs/ -- Documentation quality rubric + +### Tools +- https://vale.sh/library -- Vale prose linter +- https://hemingwayapp.com/articles/readability/readability-score -- Hemingway readability scoring +- https://www.docsastests.com/validate-api-with-doc-detective -- Docs as tests methodology + +### Industry research +- https://getdx.com/blog/developer-documentation/ -- DX documentation impact (2.4x delivery performance) +- https://aws.amazon.com/blogs/machine-learning/simulate-realistic-users-to-evaluate-multi-turn-ai-agents-in-strands-evals/ -- AWS Strands persona simulation +- https://buildwithfern.com/post/docs-linting-guide -- Fern docs linting guide (2026) +- https://klariti.com/2025/02/11/comparing-5-llms-to-review-long-documents-a-technical-writers-experiment/ -- Multi-LLM doc review experiment +- https://www.spectrocloud.com/blog/how-we-use-vale-to-enforce-better-writing-in-docs-and-beyond -- Spectro Cloud Vale usage diff --git a/design/research/2026-06-08-beginner-persona-testing/web-research.md b/design/research/2026-06-08-beginner-persona-testing/web-research.md new file mode 100644 index 000000000..a57ee6e64 --- /dev/null +++ b/design/research/2026-06-08-beginner-persona-testing/web-research.md @@ -0,0 +1,255 @@ +## Sources Found + +### Synthetic Heuristic Evaluation (University of Washington, 2025) +- **URL**: https://arxiv.org/abs/2507.02306 +- **Type**: Academic research paper +- **Key takeaway**: Multimodal LLMs performing heuristic evaluation against Nielsen's 10 heuristics identified 73-77% of usability issues, outperforming 5 experienced human UX evaluators (57-63%). Synthetic evaluation excelled at detecting layout issues but struggled with recognizing UI component conventions and cross-screen violations. +- **Relevance**: Directly demonstrates that LLMs can simulate expert evaluator personas for usability review. The same technique applies to documentation: prompt an LLM with a persona and heuristics, have it evaluate docs, compare against human reviewer findings. + +### Google Technical Writing Course: Using LLMs +- **URL**: https://developers.google.com/tech-writing/two/llms +- **Type**: Official documentation / training material +- **Key takeaway**: Google recommends assigning personas to LLMs for better review output (e.g., "You are a patient senior software engineer talking to a junior software engineer") and specifying the target audience explicitly. They warn that LLM responses require careful checking for factual errors and tend to be too long. +- **Relevance**: Google's official guidance on using LLMs for technical writing review, including persona-based prompting for audience-appropriate feedback. Practical and authoritative. + +### Comparing 5 LLMs to Review Long Documents (Klariti, 2025) +- **URL**: https://klariti.com/2025/02/11/comparing-5-llms-to-review-long-documents-a-technical-writers-experiment/ +- **Type**: Blog post / practitioner experiment +- **Key takeaway**: A technical writer compared 5 LLMs reviewing the same long document, evaluating on clarity, accuracy, organization, completeness, actionability, and writing style. Different LLMs had different strengths; splitting review tasks across models yielded better results. Only one LLM (AI Studio) found actual factual mistakes. +- **Relevance**: Practical evidence that LLM-based doc review works but has blind spots. The multi-model approach mirrors the idea of using multiple personas (beginner, expert, etc.) for coverage. + +### I'd Rather Be Writing: Documentation Quality Rubric +- **URL**: https://idratherbewriting.com/blog/measuring-documentation-quality-rubric-developer-docs/ +- **Type**: Blog post / industry framework +- **Key takeaway**: Tom Johnson (Amazon/Google tech writer) developed a 70+ item quality checklist for API documentation spanning Findability, Accuracy, Relevance, Clarity, Completeness, and Readability. He found that checklist-based assessment is more actionable than user surveys or numerical scoring, which felt arbitrary. +- **Relevance**: The most comprehensive rubric for developer doc quality. A structured checklist like this could be given to an LLM persona as evaluation criteria, combining human-authored standards with AI-powered review. + +### I'd Rather Be Writing: Measuring Documentation Quality Through User Feedback +- **URL**: https://idratherbewriting.com/learnapidoc/docapis_measuring_impact.html +- **Type**: Blog post / course material +- **Key takeaway**: User surveys for documentation quality are problematic -- learning that 30% of users would recommend your docs provides no actionable specifics. More effective: assess against a detailed quality checklist, track support ticket deflection, and measure time-to-first-success. +- **Relevance**: Explains why traditional user feedback fails for docs quality and motivates the need for systematic evaluation methods (which LLM personas could augment). + +### I'd Rather Be Writing: Different Approaches for Assessing Information Quality +- **URL**: https://idratherbewriting.com/learnapidoc/docapis_metrics_assessing_information_quality.html +- **Type**: Blog post / course material +- **Key takeaway**: Covers multiple assessment approaches: expert review, user testing, analytics-based measurement, and rubric-based self-assessment. Each has tradeoffs between cost, coverage, and actionability. +- **Relevance**: Provides the landscape of traditional documentation quality assessment methods that AI-driven approaches aim to augment or replace. + +### I'd Rather Be Writing: Quality Checklist for API Documentation +- **URL**: https://idratherbewriting.com/learnapidoc/docapis_quality_checklist.html +- **Type**: Reference checklist +- **Key takeaway**: A detailed checklist covering accuracy (code samples work, parameters documented), clarity (jargon defined, sentences short), completeness (error codes listed, auth explained), and readability (scannable headings, progressive disclosure). +- **Relevance**: A ready-made rubric that could be fed to an LLM-as-beginner-reviewer to ground its evaluation in specific, checkable criteria rather than vague impressions. + +### Sendbird Developer Onboarding UX Benchmarking Study +- **URL**: https://sendbird.com/blog/evaluating-developers-onboarding-experience-ux-benchmarking-study +- **Type**: Industry case study (two-part series) +- **Key takeaway**: Sendbird ran a single-blind study comparing their developer onboarding docs against a competitor (Stream). Real developers followed docs to complete tasks while being observed. Key finding: copy-paste code correctness and working examples were the strongest predictors of satisfaction. The single-blind design (participants didn't know who commissioned the study) prevented social desirability bias. +- **Relevance**: Gold standard for human-based documentation usability testing. The study design (task-based, timed, single-blind, with think-aloud) is what AI persona testing attempts to approximate at lower cost. + +### Sendbird Qualitative Evaluation (Part 2) +- **URL**: https://sendbird.com/blog/qualitative-evaluation-of-onboarding-new-developers-a-ux-benchmarking-study-part-2 +- **Type**: Industry case study +- **Key takeaway**: Critical onboarding elements: solid how-to steps with troubleshooting, working sample apps, and a dedicated resource site. The biggest friction point was incorrect copy-paste code in documentation. +- **Relevance**: Concrete evidence of what breaks onboarding. An LLM persona test could check for these specific failure modes (do code samples parse? are imports included? is error handling shown?). + +### Vale Prose Linter +- **URL**: https://vale.sh/library +- **Type**: Open source tool +- **Key takeaway**: Vale is a CLI prose linter that checks documentation against configurable style rules (Microsoft Style Guide, Google Developer Documentation Style Guide, custom rules). It understands markup formats (Markdown, AsciiDoc, RST), integrates into CI/CD, and provides editor plugins. Readability scoring is built in. +- **Relevance**: The standard tool for automated style enforcement in docs-as-code. Catches mechanical issues (passive voice, jargon, sentence length) but cannot evaluate whether a beginner would understand the conceptual flow. + +### GitLab's Use of Vale +- **URL**: https://docs.gitlab.com/development/documentation/testing/vale/ +- **Type**: Reference implementation +- **Key takeaway**: GitLab runs Vale in CI against all documentation changes with custom rules enforcing their style guide. They maintain a comprehensive set of rules covering word choice, sentence structure, and terminology consistency. +- **Relevance**: Production-scale example of automated prose linting in a major open source project's docs pipeline. + +### Datadog's Use of Vale +- **URL**: https://www.datadoghq.com/blog/engineering/how-we-use-vale-to-improve-our-documentation-editing-process/ +- **Type**: Blog post / reference implementation +- **Key takeaway**: Datadog integrated Vale into their documentation workflow to enforce style consistency across a large team of contributors. They found it most effective for catching mechanical issues but still relied on human review for conceptual clarity. +- **Relevance**: Shows the boundary of what automated linting can catch vs. what requires human (or simulated human) comprehension testing. + +### Spectro Cloud's Use of Vale +- **URL**: https://www.spectrocloud.com/blog/how-we-use-vale-to-enforce-better-writing-in-docs-and-beyond +- **Type**: Blog post / reference implementation +- **Key takeaway**: Spectro Cloud extended Vale beyond documentation to enforce writing quality in code comments, PR descriptions, and internal comms. They found that scaling writing practices across teams required automated enforcement rather than style guide documents alone. +- **Relevance**: Demonstrates that automated prose quality enforcement scales better than manual review -- the same argument for using LLM personas at the comprehension layer. + +### Fern Docs Linting Guide (January 2026) +- **URL**: https://buildwithfern.com/post/docs-linting-guide +- **Type**: Industry guide +- **Key takeaway**: Comprehensive guide to docs linting tools and approaches in 2026, covering Vale, custom linting rules, and integration strategies for docs-as-code workflows. +- **Relevance**: Current state-of-the-art overview of documentation quality tooling. + +### Doc Detective +- **URL**: https://docs.doc-detective.com/ +- **Type**: Open source tool +- **Key takeaway**: Doc Detective is a documentation testing framework that executes code samples and UI instructions from documentation against the actual product, verifying that documented steps work. Supports Markdown, AsciiDoc, DITA. Runs in CI/CD. A Claude Code skill also exists for it. +- **Relevance**: Solves a different but complementary problem: not "can a beginner understand this?" but "does this actually work?" Both are necessary -- the Sendbird study showed that incorrect code samples are the #1 onboarding killer. + +### Docs as Tests +- **URL**: https://www.docsastests.com/validate-api-with-doc-detective +- **Type**: Methodology / documentation +- **Key takeaway**: The "docs as tests" methodology treats documentation as executable specifications. Every code sample, API call, and UI step in docs is tested against the live product. This catches drift between docs and product. +- **Relevance**: Automated verification of factual correctness in docs -- the mechanical complement to comprehension testing. A beginner persona test that says "this is clear" is useless if the documented steps don't work. + +### Cognitive Walkthrough (Wikipedia / NN/g) +- **URL**: https://en.wikipedia.org/wiki/Cognitive_walkthrough +- **Type**: Academic methodology / standard +- **Key takeaway**: A cognitive walkthrough is a task-based usability inspection where reviewers walk through each step of a task from a new user's perspective, asking: (1) Will the user try to achieve the right effect? (2) Will the user notice the correct action is available? (3) Will the user associate the correct action with the desired effect? (4) Will the user see progress after the action? +- **Relevance**: The foundational methodology that LLM persona testing attempts to automate. These four questions could be given to an LLM simulating a beginner following a tutorial. + +### NN/g: Evaluate Interface Learnability with Cognitive Walkthroughs +- **URL**: https://www.nngroup.com/articles/cognitive-walkthroughs/ +- **Type**: Industry standard / methodology guide +- **Key takeaway**: Nielsen Norman Group's guide to cognitive walkthroughs emphasizes that the method specifically evaluates learnability (first-time use) rather than efficiency. The evaluator must adopt the mindset of a user who has never seen the interface, which is cognitively difficult for experts. +- **Relevance**: Names the core challenge: experts cannot reliably simulate beginner confusion. This is precisely where LLM personas might help -- an LLM prompted to "forget" domain knowledge and evaluate step-by-step may be less susceptible to the curse of knowledge than a human expert. + +### DX (getdx.com): Developer Documentation Impact +- **URL**: https://getdx.com/blog/developer-documentation/ +- **Type**: Industry research / blog post +- **Key takeaway**: Teams with higher-quality documentation were 2.4x more likely to experience better software delivery performance. Key metrics: time-to-first-commit, time-to-productive-velocity, and 30/60/90-day new hire surveys asking "what information did you need but couldn't find?" +- **Relevance**: Establishes the business case for documentation quality investment and names concrete metrics that documentation testing (human or AI) should aim to improve. + +### AWS: Simulate Realistic Users to Evaluate Multi-Turn AI Agents +- **URL**: https://aws.amazon.com/blogs/machine-learning/simulate-realistic-users-to-evaluate-multi-turn-ai-agents-in-strands-evals/ +- **Type**: Technical blog / reference implementation +- **Key takeaway**: AWS Strands Evals creates simulated user personas from test cases (e.g., "budget-conscious traveler with beginner-level experience"). The simulated users can express confusion, ask follow-ups, and redirect conversations -- providing reasoning traces that show where interactions succeed or fail. +- **Relevance**: Direct precedent for LLM-simulated personas with reasoning traces. The technique of generating reasoning traces showing confusion points translates directly to documentation persona testing. + +### PersonaLLM and LLM-Based Persona Simulation +- **URL**: https://www.emergentmind.com/topics/personallm +- **Type**: Academic research aggregator +- **Key takeaway**: LLM persona simulation conditions language models on detailed persona attributes (skill level, domain knowledge, communication style) to mimic individual behaviors. Research shows LLMs can replicate human survey responses and social science experiments with consistency comparable to real participants. +- **Relevance**: Theoretical foundation for the idea that LLMs can meaningfully simulate different skill levels reading documentation. The research validates that persona conditioning produces behaviorally distinct outputs. + +### Mapping the Information Journey: Documentation Experience of Software Developers in China +- **URL**: https://arxiv.org/pdf/2312.02586 +- **Type**: Academic research paper +- **Key takeaway**: Research on documentation quality from the perspective of users (developers) has been limited. The paper found that developers' information journey through documentation involves exploration, comprehension, and application phases -- each with distinct failure modes. +- **Relevance**: Frames documentation evaluation as a multi-phase process. A beginner persona test should cover all three phases: can they find the right page (exploration), understand it (comprehension), and apply it (application)? + +### Hemingway Editor +- **URL**: https://hemingwayapp.com/articles/readability/readability-score +- **Type**: Tool +- **Key takeaway**: Hemingway Editor highlights specific prose problems (adverbs, passive voice, complex sentences) and provides a grade-level readability score using the Automated Readability Index. It operates at the sentence level -- useful for catching dense prose but not for evaluating conceptual comprehension or task flow. +- **Relevance**: Complementary to persona testing. Catches surface-level readability issues (sentence complexity, word choice) but cannot evaluate whether a beginner would understand the conceptual progression or complete the task. + +--- + +## Patterns Found + +### Pattern 1: Automated Style Linting (Vale, Hemingway, Custom Rules) + +**Used by**: GitLab, Datadog, Spectro Cloud, Elastic, many docs-as-code teams +**How it works**: A CLI tool (typically Vale) runs against documentation files in CI/CD, checking prose against configurable style rules. Rules encode style guide requirements: maximum sentence length, banned words (jargon, hedging), passive voice detection, readability score thresholds, terminology consistency. The tool understands markup formats and excludes code blocks from prose rules. + +Teams typically start with an industry standard ruleset (Microsoft Style Guide, Google Developer Documentation Style Guide) and add custom rules for project-specific terminology and patterns. Violations block merges or generate warnings in PR reviews. + +**Strengths**: Fast, deterministic, scales to any team size, catches mechanical issues consistently, runs in CI without human involvement. Provides immediate feedback to writers in their editor via LSP integration. +**Weaknesses**: Cannot evaluate conceptual comprehension. A perfectly Vale-clean document can still be incomprehensible to a beginner if the conceptual progression is wrong, prerequisites are missing, or the mental model is never established. Style rules catch symptoms (long sentences, passive voice) but not the underlying disease (unclear thinking). +**Example**: https://docs.gitlab.com/development/documentation/testing/vale/ + +### Pattern 2: Cognitive Walkthrough (Human Expert Simulation) + +**Used by**: UX teams broadly; adapted for documentation by technical writing teams +**How it works**: An evaluator (or team) walks through a tutorial or getting-started guide step by step, adopting the persona of a first-time user. At each step, they ask four questions derived from the Wharton/Lewis cognitive walkthrough methodology: (1) Will the user know what to do? (2) Will they notice the correct action? (3) Will they understand the connection between action and goal? (4) Will they see that progress was made? + +The evaluator documents every point where the answer is "no" or "uncertain," noting the specific knowledge gap or confusion. Results are a prioritized list of friction points. + +**Strengths**: Low cost (no real users needed), can be done early before release, structured methodology prevents evaluators from drifting into personal preferences, specifically targets learnability rather than efficiency. +**Weaknesses**: Experts struggle to simulate beginner confusion (curse of knowledge). The evaluator knows how the system works and unconsciously fills in gaps that a real beginner would stumble on. Results depend heavily on the evaluator's ability to suppress their expertise. Time-intensive for long documentation sets. +**Example**: https://www.nngroup.com/articles/cognitive-walkthroughs/ + +### Pattern 3: Task-Based Usability Testing with Real Users + +**Used by**: Sendbird, Stripe (known for doc quality), Twilio, major API platforms +**How it works**: Real developers who match the target persona (e.g., "3 years experience, no prior exposure to this API") follow the documentation to complete a defined task while being observed. Researchers measure time-to-completion, error count, task success rate, and satisfaction. Think-aloud protocols capture reasoning. Studies are single-blind (participants don't know the sponsor) to prevent bias. + +The Sendbird study compared their onboarding docs against a competitor's, using matched participants who completed tasks on both platforms. This controlled design isolated documentation quality from product quality. + +**Strengths**: Highest fidelity -- real humans encountering real confusion. Captures failure modes that no automated tool can find (e.g., "I assumed the import was included in the previous step"). Provides both quantitative metrics (time, success rate) and qualitative insights (think-aloud transcripts). +**Weaknesses**: Expensive ($500-2000+ per participant), slow to organize (weeks of recruitment and scheduling), small sample sizes (5-8 participants typical), and results are point-in-time snapshots that don't scale to every documentation change. Cannot be run in CI. +**Example**: https://sendbird.com/blog/evaluating-developers-onboarding-experience-ux-benchmarking-study + +### Pattern 4: Documentation-as-Tests (Executable Documentation) + +**Used by**: Doc Detective users, Stripe (internal tooling), projects with docs CI pipelines +**How it works**: Code samples and procedural steps in documentation are extracted and executed against the actual product in CI. Doc Detective scans Markdown/AsciiDoc for code blocks and commands, runs them in a real environment, and reports failures. API calls are sent to real endpoints; UI steps are executed via browser automation; CLI commands are run in a shell. + +This catches documentation drift -- when the product changes but the docs don't. It complements comprehension testing: a tutorial that is perfectly clear but contains a broken code sample will still fail a new user. + +**Strengths**: Catches factual incorrectness automatically, runs in CI on every change, prevents the #1 onboarding friction point (broken code samples, per Sendbird study). Deterministic -- a code sample either works or it doesn't. +**Weaknesses**: Only tests "does this work?" not "does this make sense?" Cannot evaluate conceptual explanations, mental model building, or information architecture. Setup cost is nontrivial (requires a test environment matching what users have). Does not test prose quality at all. +**Example**: https://docs.doc-detective.com/ + +### Pattern 5: LLM Persona-Based Documentation Review + +**Used by**: Early adopters; no widely-adopted standardized framework yet. Google recommends the technique in their technical writing course. Individual tech writers experimenting (Klariti experiment). AWS Strands Evals uses the persona simulation technique for agent testing. +**How it works**: An LLM is prompted with a detailed persona definition (skill level, domain knowledge, goals, communication style) and asked to read documentation as if it were that persona. The prompt typically includes: + +1. A persona description: "You are a Python developer with 2 years of experience. You have never used Home Assistant or any home automation framework. You know async/await basics but have never built an event-driven system." +2. A task: "Follow this getting-started guide and note every point where you would be confused, lost, or unsure what to do next." +3. Evaluation criteria: Either freeform ("flag confusion points") or structured (a checklist derived from cognitive walkthrough questions or a documentation quality rubric). + +The LLM reads the documentation and produces a report of friction points, questions a beginner would have, undefined terms, missing prerequisites, and unclear steps. Multiple personas can be run in parallel (complete beginner, intermediate developer new to the domain, experienced developer skimming for reference). + +The synthetic heuristic evaluation research (UW, 2025) validated a closely related approach for UI usability, finding that LLM evaluators identified more issues than experienced human evaluators (73-77% vs 57-63%). + +**Strengths**: Cheap (pennies per review), fast (minutes not weeks), repeatable on every PR, can simulate multiple personas in parallel, produces reasoning traces showing where and why confusion occurs. Avoids the curse of knowledge that plagues expert cognitive walkthroughs. Can be given structured rubrics (like the idratherbewriting quality checklist) for grounded evaluation. +**Weaknesses**: LLMs don't actually experience confusion -- they simulate it. An LLM "pretending" to be a beginner still has access to its training data about the domain. The simulation may miss confusion points that arise from genuine knowledge gaps (the LLM knows what async/await does even when told to pretend it doesn't). No established benchmarks for how well LLM persona findings correlate with real beginner findings. Risk of false positives (flagging things that real beginners handle fine) and false negatives (missing things that genuinely confuse people). The UW synthetic heuristic evaluation study found LLMs struggled with cross-screen violations -- analogously, LLM doc reviewers may miss issues that only emerge from reading multiple pages in sequence. +**Example**: https://developers.google.com/tech-writing/two/llms (Google's guidance on persona prompting for doc review). AWS Strands Evals for the persona simulation framework: https://aws.amazon.com/blogs/machine-learning/simulate-realistic-users-to-evaluate-multi-turn-ai-agents-in-strands-evals/ + +### Pattern 6: Quality Rubric / Checklist Assessment + +**Used by**: Tom Johnson (idratherbewriting), API documentation teams, technical writing organizations +**How it works**: A detailed checklist of 50-70+ quality criteria is applied systematically to documentation. Criteria span multiple dimensions: Findability (can users locate the right page?), Accuracy (do code samples work?), Relevance (does the content match the user's task?), Clarity (is jargon defined? are sentences short?), Completeness (are error codes documented? is auth explained?), and Readability (scannable headings? progressive disclosure?). + +The checklist is applied by a human reviewer or, increasingly, by an LLM given the checklist as evaluation criteria. Each item gets a pass/fail/partial, and the results prioritize areas for improvement. This avoids the vagueness of user surveys ("the docs are confusing") by forcing specific, actionable findings. + +**Strengths**: Structured and comprehensive. Avoids the arbitrariness of numerical scoring. Produces actionable findings ("error codes are not documented for the /users endpoint"). Can be applied by different reviewers with reasonable consistency. The checklist itself serves as documentation of the team's quality standards. +**Weaknesses**: Still requires human judgment for many items (is the explanation "clear"?). Time-intensive for large doc sets. The checklist can become a compliance exercise rather than a genuine comprehension check. Does not test the reader's actual experience -- a doc can pass every checklist item and still confuse a beginner because the conceptual ordering is wrong. +**Example**: https://idratherbewriting.com/learnapidoc/docapis_quality_checklist.html + +### Pattern 7: Metrics-Based Documentation Health Tracking + +**Used by**: Developer platforms (Stripe, Twilio, etc.), DX-focused teams +**How it works**: Quantitative metrics serve as proxies for documentation quality. Common metrics include: time-to-first-API-call (from new account creation), support ticket volume and categorization (documentation gaps show up as repeated questions), page analytics (bounce rate, time-on-page, exit pages), search queries with no results, and new hire onboarding surveys (30/60/90 day). + +DX research found that teams with higher-quality documentation were 2.4x more likely to experience better software delivery performance. The metrics don't directly measure documentation quality, but they measure its downstream effects. + +**Strengths**: Objective, continuous, and tied to business outcomes. Reveals systemic problems (high bounce rate on a page indicates confusion). Doesn't require organizing usability studies. Can be dashboarded and tracked over time. +**Weaknesses**: Lagging indicators -- by the time metrics show a problem, users have already been confused. Cannot pinpoint the specific sentence or paragraph causing confusion. Confounded by many variables (product complexity, API design quality, user skill distribution). Requires enough traffic to be statistically meaningful, which excludes smaller projects. +**Example**: https://getdx.com/blog/developer-documentation/ + +--- + +## Anti-Patterns + +### Relying solely on readability scores +Flesch-Kincaid and similar formulas measure sentence length and syllable count. A document can score well (grade 8 reading level) while being conceptually impenetrable because the sentences are short but the concepts build on undefined prerequisites. Readability scores are a useful floor check, not a quality measure. [Source: https://hemingwayapp.com/articles/readability/readability-score, https://idratherbewriting.com/learnapidoc/docapis_metrics_assessing_information_quality.html] + +### User satisfaction surveys as the primary quality signal +Asking users "how would you rate this documentation?" yields low response rates and vague feedback. Users who successfully used the docs don't respond; users who failed often blame themselves rather than the docs. The signal is noisy and not actionable. [Source: https://idratherbewriting.com/learnapidoc/docapis_measuring_impact.html] + +### Expert review without persona adoption +Having a domain expert review documentation for "clarity" without explicitly adopting a beginner mindset misses the curse of knowledge problem entirely. The expert fills in gaps unconsciously and approves documentation that would confuse a newcomer. The cognitive walkthrough's structured questions exist specifically to counter this bias. [Source: https://www.nngroup.com/articles/cognitive-walkthroughs/] + +### Testing prose quality without testing code correctness +A beautifully written tutorial with a broken code sample in step 3 is worse than an ugly tutorial that works. The Sendbird study found that incorrect copy-paste code was the #1 friction point, outweighing all prose quality concerns. Comprehension testing without executable testing is half the picture. [Source: https://sendbird.com/blog/qualitative-evaluation-of-onboarding-new-developers-a-ux-benchmarking-study, https://docs.doc-detective.com/] + +--- + +## Emerging Trends + +### LLM personas as cognitive walkthrough automation +The most promising emerging pattern combines the cognitive walkthrough methodology (structured questions per step) with LLM persona simulation (an LLM conditioned to lack domain knowledge). This addresses the core weakness of expert cognitive walkthroughs (curse of knowledge) at the cost of introducing a new weakness (LLMs don't genuinely lack knowledge, they simulate lacking it). The UW synthetic heuristic evaluation study (2025) provides early empirical evidence that this approach finds more issues than human experts in at least some domains. No standardized tool or framework exists yet -- teams are building bespoke prompts. [Source: https://arxiv.org/abs/2507.02306] + +### Multi-layer documentation testing pipelines +Leading teams are stacking complementary tools: Vale for style enforcement (CI, every commit), Doc Detective for code sample correctness (CI, every commit), LLM persona review for comprehension (periodic or on significant changes), and real user testing for validation (quarterly or for major releases). Each layer catches what the others miss. No single tool covers the full spectrum from "is this grammatically correct?" to "can a beginner follow this?" [no source found -- synthesized from multiple sources above] + +### Agent-ready documentation +Documentation in 2026 is increasingly consumed by AI agents (coding assistants reading docs to generate integration code), not just humans. This creates a dual audience: documentation must be comprehensible to both human beginners and LLM-based coding agents. Some teams are beginning to test documentation with LLMs not as persona-simulated readers but as literal consumers -- "can Claude read these docs and produce working integration code?" This is a different axis of quality from human comprehension but increasingly important. [Source: https://buildwithfern.com/post/docs-linting-guide] diff --git a/design/specs/030-docs-rewrite/design.md b/design/specs/030-docs-rewrite/design.md index ea37f24e0..33a85b763 100644 --- a/design/specs/030-docs-rewrite/design.md +++ b/design/specs/030-docs-rewrite/design.md @@ -72,7 +72,7 @@ Dead snippet files to delete during the inline-to-snippet WPs: ### API reference scoping -`tools/gen_ref_pages.py` currently walks all of `src/` with no public/internal filter. After WP10, it is rewritten to emit `::: module.path` stubs only for modules in the nav audit's allowlist. The allowlist is seeded from `hassette.__all__` and `hassette.test_utils.__all__` (Tier 1 only), then extended by the nav audit WP with curated additions for types users commonly reference via mkdocstrings autorefs. Any `[Symbol][module.path]` autoref in the narrative docs that points to a non-allowlisted module is either updated to point to an allowlisted equivalent or converted to plain text. +`tools/docs/gen_ref_pages.py` currently walks all of `src/` with no public/internal filter. After WP10, it is rewritten to emit `::: module.path` stubs only for modules in the nav audit's allowlist. The allowlist is seeded from `hassette.__all__` and `hassette.test_utils.__all__` (Tier 1 only), then extended by the nav audit WP with curated additions for types users commonly reference via mkdocstrings autorefs. Any `[Symbol][module.path]` autoref in the narrative docs that points to a non-allowlisted module is either updated to point to an allowlisted equivalent or converted to plain text. ### CSS and theme @@ -146,7 +146,7 @@ This is a documentation project — there is no application code to unit test. V **Files directly modified:** - `mkdocs.yml` — nav restructure, `extra_css:` removal (dead CSS), possibly new extensions - `docs/_static/style.css` — deleted -- `tools/gen_ref_pages.py` — public-API filter added +- `tools/docs/gen_ref_pages.py` — public-API filter added - `docs/pages/appdaemon-comparison.md` — deleted; replaced by `docs/pages/migration/` section (~6–8 files) - `docs/pages/testing/index.md` — restructured; 3–4 subpages added under `docs/pages/testing/` - ~30 Markdown pages with inline code blocks — inline blocks extracted to snippet files diff --git a/design/specs/055-css-modules-migration/design.md b/design/specs/055-css-modules-migration/design.md index 8e032bbde..5fa4fe7c6 100644 --- a/design/specs/055-css-modules-migration/design.md +++ b/design/specs/055-css-modules-migration/design.md @@ -151,10 +151,10 @@ import styles from './sidebar.module.css';
``` -**CI lint guard** (`tools/check_global_css_allowlist.py`): +**CI lint guard** (`tools/frontend/check_global_css_allowlist.py`): A Python script that extracts all `.ht-*` selectors from `global.css`, compares against an allowlist of shared class prefixes, and fails if unknown selectors are found. Added to CI pipeline (`lint.yml`). The allowlist is maintained in the script. Includes a smoke test fixture (known-allowed + known-disallowed selectors) that runs in CI. -**`:global()` correctness check** (`tools/check_css_module_globals.py`): +**`:global()` correctness check** (`tools/frontend/check_css_module_globals.py`): A Python script that greps `.module.css` files for bare state modifier patterns (e.g., `.className.is-active` without `:global()`) and exits non-zero on match. Catches the silent failure where state modifier CSS is scoped instead of global. **CI wiring** (`lint.yml` additions in Batch 1): @@ -165,15 +165,15 @@ The frontend job in `lint.yml` must be updated to include: - name: Type check run: npx tsc --noEmit - name: Check CSS module :global() correctness - run: uv run python tools/check_css_module_globals.py + run: uv run python tools/frontend/check_css_module_globals.py - name: Check global CSS allowlist - run: uv run python tools/check_global_css_allowlist.py + run: uv run python tools/frontend/check_global_css_allowlist.py - name: Check dead global CSS (warning) - run: uv run python tools/check_dead_global_css.py || true + run: uv run python tools/frontend/check_dead_global_css.py || true ``` The `npm run build` step must precede `tsc --noEmit` so generated `.d.ts` files exist when type checking runs. The allowlist guard runs in diff-only mode during migration (checks `git diff` against allowlist, not the full file) and upgrades to full-file mode after migration completes. -**Dead CSS detection** (`tools/check_dead_global_css.py`): +**Dead CSS detection** (`tools/frontend/check_dead_global_css.py`): A Python script that extracts all class selectors from `global.css` and checks each against `.tsx` files. Reports any selector not referenced in any component. Maintains an annotated exemption list for known dynamically-assembled class families (`ht-badge--*`, `ht-chip--kind-*`, `ht-detail-stats-row__value--*`, `ht-stats-strip__value--*`) and third-party injected classes (`shiki`, `line`, `line--*`). Runs in CI as a warning during migration, upgraded to blocking after migration completes. ### Module file pattern @@ -378,9 +378,9 @@ Running `nox -s e2e` alone will test the previously-built SPA, not the current C - `frontend/tsconfig.json` or new `frontend/src/css-modules.d.ts` (ambient type declaration) **Files created (tooling):** -- `tools/check_global_css_allowlist.py` (CI lint guard) -- `tools/check_dead_global_css.py` (dead CSS detection) -- `tools/check_css_module_globals.py` (`:global()` correctness check) +- `tools/frontend/check_global_css_allowlist.py` (CI lint guard) +- `tools/frontend/check_dead_global_css.py` (dead CSS detection) +- `tools/frontend/check_css_module_globals.py` (`:global()` correctness check) **Files modified (CI):** - `.github/workflows/lint.yml` (add `vite build` before `tsc`, add CSS guard steps) diff --git a/design/specs/056-shared-ui-components/design.md b/design/specs/056-shared-ui-components/design.md index 195db378a..eaadb05c7 100644 --- a/design/specs/056-shared-ui-components/design.md +++ b/design/specs/056-shared-ui-components/design.md @@ -245,7 +245,7 @@ Each consumer file is updated to: **Files updated (infrastructure):** - `frontend/src/global.css` — remove 4 `@import` lines -- `tools/check_global_css_allowlist.py` — remove deleted prefixes from allowlist +- `tools/frontend/check_global_css_allowlist.py` — remove deleted prefixes from allowlist - `frontend/src/components/app-detail/job-executions.test.tsx` — update badge selectors - `frontend/src/components/layout/error-boundary.test.tsx` — update error-card selector diff --git a/design/specs/070-doc-overhaul/brief.md b/design/specs/070-doc-overhaul/brief.md new file mode 100644 index 000000000..4316412f2 --- /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/docs/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. diff --git a/design/specs/070-doc-overhaul/design.md b/design/specs/070-doc-overhaul/design.md new file mode 100644 index 000000000..dabb395a8 --- /dev/null +++ b/design/specs/070-doc-overhaul/design.md @@ -0,0 +1,395 @@ +# Design: Documentation Overhaul + +**Date:** 2026-06-01 +**Status:** archived +**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/docs/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/frontend/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 + +- 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/071-doc-writing-skill/brief.md b/design/specs/071-doc-writing-skill/brief.md new file mode 100644 index 000000000..58492d359 --- /dev/null +++ b/design/specs/071-doc-writing-skill/brief.md @@ -0,0 +1,57 @@ +# Brief: Generalize doc-overhaul into a scale-aware doc-writing skill + +**Date:** 2026-06-03 +**Status:** explored + +## Idea + +Replace the single-purpose `hassette.doc-overhaul` skill with a general-purpose doc-writing skill that runs the same proven process (JTBD outline, voice-calibrated writing, Opus review, mechanical quality gates) at any scale: one page, a section rewrite, or docs for a new feature. The full overhaul mode disappears since we won't do another full rewrite. The skill keeps the process opinionated but adapts its weight to the scope. + +## Key Decisions Made + +- **Hassette-scoped for now.** Not trying to make this portable to other projects. Hassette-specific content (source file paths, import conventions, module aliases, exemplar paths) stays in the reference files. +- **Always calibrate.** Read the voice calibration artifact every time, even for a single page. Voice consistency matters more than saving a few seconds of context loading. +- **Always outline first.** The JTBD outline step runs even for a single page. It's cheap and catches structural problems before writing starts. +- **Smart default with escape hatches.** Full pipeline (outline, write, review, verify) by default. "Just outline these pages" or "just review this page" invoke individual steps. +- **Nav placement depends on scale.** For 1-2 pages, the user specifies where they go. For a new section, the skill proposes placement. +- **One skill, not split.** The individual steps (outline, write, review) are robust standalone, but splitting into separate skills fragments the quality guarantees. One skill with mode detection keeps the process coherent. +- **Process is robust enough for partial invocation.** The JTBD outline, the voice-calibrated writer prompt, and the Opus reviewer each produce good output independently. The pipeline is the default, not the only mode. + +## Open Questions + +- **Skill name and triggers.** "Write docs for X" and "update the docs" both feel natural. Name candidates: `docs`, `doc-write`, `write-docs`. Needs to not collide with the `docs:` commit type or `mkdocs` commands in muscle memory. +- **What happens to the existing docs-context-example.md?** It has the PR #970 exemplar paths baked in. Should it become the live calibration artifact (updated when exemplars change), or stay as an example with a separate live version? +- **Mechanical quality gates at small scale.** The full sweep (snippet orphans, xref coverage, bare symbols, link checker) makes sense for 5+ pages. For a single page, running all six tools is overkill. Should the skill run a subset based on scope, or always run everything? +- **How does the skill discover what pages are needed?** For "write docs for the new cache feature," something needs to figure out which pages to create (concept page? recipe? API reference updates?). The design-completeness rule already lists triggers for when docs are needed. The skill could read that rule and propose a page list. + +## Scope Boundaries + +**In scope:** +- Rewrite SKILL.md to be scale-aware (detect scope from arguments, adjust process weight) +- Update writing-prompt-template.md to support incremental work (not just "write from blank") +- Keep all reference files (prior-art, retrospective, docs-context-example, writing-prompt-template) +- Rename skill from `hassette.doc-overhaul` to something that signals general doc work + +**Explicitly out:** +- Making the skill portable to other projects (stays hassette-scoped) +- Changing the voice-guide.md or doc-rules.md +- Adding new mechanical quality gate scripts +- Changing the existing docs + +**Deferred:** +- Extracting this to Claudefiles as a cross-project skill (only after it proves itself on hassette incremental work) + +## Risks and Concerns + +- **Dilution risk.** The overhaul skill worked because it was opinionated about a specific process. Generalizing could turn it into "a skill that does doc stuff" with too many modes. Mitigate by keeping the pipeline as the default and treating escape hatches as exceptions, not equal modes. +- **Untested at small scale.** The process was proven on a 76-page rewrite. A single-page invocation hasn't been tested. The JTBD outline step may feel like overhead for one page, even if it's objectively cheap. Worth trying before committing to the design. +- **Calibration artifact staleness.** The docs-context-example.md points to PR #970 exemplars. If those pages change significantly, the calibration drifts. Need a lightweight way to verify exemplar paths are still valid. + +## Codebase Context + +- Current skill: `.claude/skills/doc-overhaul/` (106-line SKILL.md + 4 reference files, 708 lines total) +- Voice rules: `.claude/rules/voice-guide.md` (22 rules with before/after examples) +- Doc rules: `.claude/rules/doc-rules.md` (page templates, snippet conventions, layering) +- Design completeness rule: `.claude/rules/design-completeness.md` (triggers for when docs are needed) +- Quality gate scripts: `tools/docs/check_snippet_orphans.py`, `tools/docs/check_xref_coverage.py`, `tools/docs/check_bare_symbols.py` +- Hassette-specific references in writing-prompt-template.md: 17 occurrences (source file paths, import conventions) diff --git a/docs/_static/hassette.css b/docs/_static/hassette.css index af2041cbe..c8124c869 100644 --- a/docs/_static/hassette.css +++ b/docs/_static/hassette.css @@ -25,8 +25,19 @@ video.hero-video { } } +/* Widen the content grid from the default ~61rem */ +.md-grid { + max-width: 1600px; +} + +/* Tables: fill the content area instead of shrinking to inline-block */ +.md-typeset table:not([class]) { + display: table; + width: 100%; +} + /* Reduce sidebar width at mid-range viewports to prevent content squeeze */ -@media screen and (min-width: 1220px) and (max-width: 1440px) { +@media screen and (min-width: 1220px) and (max-width: 1600px) { .md-sidebar--primary { width: 220px; } diff --git a/docs/_static/web_ui_detail_column_picker.png b/docs/_static/web_ui_detail_column_picker.png deleted file mode 100644 index 089874974..000000000 Binary files a/docs/_static/web_ui_detail_column_picker.png and /dev/null differ diff --git a/docs/_static/web_ui_detail_instance_switcher.png b/docs/_static/web_ui_detail_instance_switcher.png deleted file mode 100644 index 558711018..000000000 Binary files a/docs/_static/web_ui_detail_instance_switcher.png and /dev/null differ 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/advanced/custom-states.md b/docs/pages/advanced/custom-states.md deleted file mode 100644 index f8d4d0211..000000000 --- a/docs/pages/advanced/custom-states.md +++ /dev/null @@ -1,159 +0,0 @@ -# Custom State Classes - -You can define custom state classes for domains that aren't included in the core framework. This is useful for: - -- Custom integrations and components in your Home Assistant instance -- Third-party integrations not yet supported by Hassette -- Specialized state handling with custom attributes or methods - -## Basic Custom State Class - -To create a custom state class, inherit from one of the base state classes and define a `domain` field with a `Literal` type: - -```python ---8<-- "pages/advanced/snippets/custom-states/basic_custom_state.py" -``` - -That's it! The state class notifies the registry upon creation and is immediately available for use. This happens automatically via Python's `__init_subclass__` hook — no explicit registration call is required. See [State Registry](state-registry.md) for how automatic registration works. - -## Choosing a Base Class - -Hassette provides several base classes to inherit from, depending on your entity's state value type: - -### StringBaseState -For entities with string state values (most common): - -```python ---8<-- "pages/advanced/snippets/custom-states/string_base_state.py" -``` - -### NumericBaseState -For entities with numeric state values - stored as `Decimal` internally (supports int, float, Decimal): - -```python ---8<-- "pages/advanced/snippets/custom-states/numeric_base_state.py" -``` - -### BoolBaseState -For entities with boolean state values (`True`/`False`, automatically converts `"on"`/`"off"`): - -```python ---8<-- "pages/advanced/snippets/custom-states/bool_base_state.py" -``` - -### DateTimeBaseState -For entities with datetime state values (supports `ZonedDateTime`, `PlainDateTime`, `Date`): - -```python ---8<-- "pages/advanced/snippets/custom-states/datetime_base_state.py" -``` - -### TimeBaseState -For entities with time-only state values: - -```python ---8<-- "pages/advanced/snippets/custom-states/time_base_state.py" -``` - -### Define your own -For entities with state values that don't fit the predefined base classes, you can inherit directly from BaseState and provide the type parameter for the state value and `value_type` class variable: - -```python ---8<-- "pages/advanced/snippets/custom-states/define_your_own.py" -``` - -The `value_type` class variable is used by Hassette to validate state values at runtime. It should include all acceptable types for the state value, including `None` if the state can be unset. - -## Adding Custom Attributes - -You can define custom attributes specific to your domain by creating an attributes class: - -```python ---8<-- "pages/advanced/snippets/custom-states/adding_custom_attributes.py" -``` - -## Using Custom States in Apps - -Once defined, custom state classes work with all of Hassette's APIs: - -### Via get_states() - -```python ---8<-- "pages/advanced/snippets/custom-states/via_get_states.py" -``` - -### With Dependency Injection - -```python ---8<-- "pages/advanced/snippets/state-registry/basic_custom_state_usage.py" -``` - -### Direct API Access - -```python ---8<-- "pages/advanced/snippets/custom-states/direct_api_access.py" -``` - -## Runtime vs Type-Time Access - -For known domains (defined in Hassette or in the `.pyi` stub), you can use property-style access: - -```python ---8<-- "pages/advanced/snippets/custom-states/known_domain_access.py" -``` - -For custom domains, use `states[]` for full type checking: - -```python ---8<-- "pages/advanced/snippets/custom-states/custom_domain_typed_access.py" -``` - -```python ---8<-- "pages/advanced/snippets/custom-states/custom_domain_runtime_access.py" -``` - -## Complete Example - -Here's a complete example with a custom integration: - -```python ---8<-- "pages/advanced/snippets/custom-states/complete_example.py" -``` - -## Best Practices - -1. **One domain per state class** - Each state class should handle exactly one domain. Mixing domains in one class breaks the registry lookup, which maps one domain string to exactly one class. -2. **Use Literal for domain** - Always use `Literal["domain_name"]` to enable auto-registration. A plain `str` annotation does not carry a value at class definition time, so the registry cannot extract the domain name automatically. -3. **Choose the right base class** - Match the base class to your entity's state value type -4. **Document your attributes** - Add docstrings to custom attribute classes -5. **Use typing** - Use type hints throughout for better IDE support and type checking - -## Troubleshooting - -### State class not registering - -If your custom state class isn't being recognized: - -1. **Check the domain field** - Ensure you have `domain: Literal["your_domain"]` -2. **Call `super().__init_subclass__()`** - If you override `__init_subclass__`, call `super().__init_subclass__()` so registration still happens -3. **Check for errors** - Look for registration errors in debug logs - -### Type hints not working - -If IDE autocomplete isn't working: - -1. **Use `states[]`** - For custom domains, use `self.states[CustomState]` - -### State conversion fails - -If state conversion is failing: - -1. **Check the base class** - Ensure it matches your entity's state value type -2. **Validate attributes** - Make sure custom attributes use proper Pydantic field types -3. **Check Home Assistant data** - Verify the actual state data structure from Home Assistant - -## See Also - -- [State Registry](state-registry.md) — how automatic registration works -- [Type Registry](type-registry.md) — register custom type converters for field values -- [Dependency Injection](../core-concepts/bus/dependency-injection.md) — inject typed states into event handlers diff --git a/docs/pages/advanced/index.md b/docs/pages/advanced/index.md deleted file mode 100644 index 6157ee2bb..000000000 --- a/docs/pages/advanced/index.md +++ /dev/null @@ -1,23 +0,0 @@ -# Advanced - -Most Hassette apps work entirely within the Core Concepts: the Bus, Scheduler, API, and States. The pages in this section are for situations where those building blocks are not enough — when you need to teach Hassette about a new Home Assistant domain, control exactly what type a value converts to, or tune logging to isolate a specific service. - -Hassette's entity models are generated from Home Assistant **2026.5.1**. See `codegen/ha-version.txt` for the exact pinned version. - -## Topics - -**[Custom States](custom-states.md)** — How to define a typed state class for a Home Assistant domain that Hassette does not know about yet. This is the entry point: define the class, and the State Registry picks it up automatically. - -**[State Registry](state-registry.md)** — How Hassette maps domains to state model classes and converts raw Home Assistant state dictionaries to typed Pydantic models. Read this if you need to understand or override the mapping, or if you are seeing unexpected state types at runtime. - -**[Type Registry](type-registry.md)** — How Hassette converts raw string values from Home Assistant to Python types (`int`, `bool`, `datetime`, custom types, etc.). Read this when the built-in conversions do not cover your case or you need to register a converter for a custom type. - -**[Log Level Tuning](log-level-tuning.md)** — How to set log verbosity independently for each Hassette service. Useful for debugging one component without flooding logs with noise from the rest of the system. - -## Prerequisites - -These pages build on [Apps](../core-concepts/apps/index.md) (lifecycle, handlers) and the [Bus](../core-concepts/bus/index.md) (event subscriptions, dependency injection). Custom States and the State Registry are closely related — if you are reading one, you will likely need the others. - -## See Also - -- [API Reference](../../reference/index.md) — full auto-generated reference for all public modules, including the event handling annotations (`A`, `C`, `D`, `P`) and state models. diff --git a/docs/pages/advanced/log-level-tuning.md b/docs/pages/advanced/log-level-tuning.md deleted file mode 100644 index 7f577682a..000000000 --- a/docs/pages/advanced/log-level-tuning.md +++ /dev/null @@ -1,99 +0,0 @@ -# Log Level Tuning - -Hassette lets you set log verbosity independently for each internal service. This is useful when you need to debug a specific area (e.g. the scheduler) without flooding your logs with noise from everything else. - -## When to Use This - -If something isn't working as expected, narrow the noise before enabling global `DEBUG`. Start from the symptom: - -| Symptom | Service to tune | -|---------|----------------| -| Events not firing, wrong filters | `logging.bus_service` | -| Jobs not running, wrong timing | `logging.scheduler_service` | -| App not loading or crashing on start | `logging.app_handler` | -| Unexpected state values, stale data | `logging.state_proxy` or `logging.api` | -| HA connection drops, WebSocket errors | `logging.websocket` | -| High API call latency, HTTP errors | `logging.api` | -| Noisy file-change messages in development | `logging.file_watcher` | -| Web UI not responding, errors visible in the web UI | `logging.web_api` | - -## How It Works - -Every service in Hassette has a dedicated log level field under `[hassette.logging]`. When set, that service uses the specified level instead of the global `log_level`. When not set, the service inherits the global `log_level` (which defaults to `INFO`). - -```toml ---8<-- "pages/advanced/snippets/log-level-tuning/basic_example.toml" -``` - -## Available Fields - -Hassette provides 13 per-service log level fields: - -| TOML Key | Controls | -|----------|----------| -| `logging.database_service` | Database service (telemetry storage, retention, heartbeats) | -| `logging.bus_service` | Event bus service (event dispatch, listener management) | -| `logging.scheduler_service` | Scheduler service (job scheduling, trigger evaluation) | -| `logging.app_handler` | App handler (app lifecycle, loading, starting, stopping) | -| `logging.web_api` | Web API service (HTTP endpoints, web UI) | -| `logging.websocket` | WebSocket service (Home Assistant connection) | -| `logging.service_watcher` | Service watcher (monitors service health, restarts) | -| `logging.file_watcher` | File watcher (detects code changes for hot reload) | -| `logging.task_bucket` | Task buckets (async task execution pools) | -| `logging.command_executor` | Command executor (app action dispatch) | -| `logging.apps` | Default level for all apps (can be overridden per-app) | -| `logging.state_proxy` | State proxy (Home Assistant state cache) | -| `logging.api` | API client (REST and WebSocket calls to Home Assistant) | - -All fields accept standard Python log levels: `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL` (case-insensitive). - -## Fallback Behavior - -Each per-service log level field follows this precedence: - -1. **Explicit value** — if you set the field in `hassette.toml`, that value is used. -2. **Global `log_level`** — if the field is not set, it inherits the global `log_level`. -3. **Default** — if neither is set, the level is `INFO`. - -This means setting `log_level = "DEBUG"` under `[hassette.logging]` raises the verbosity of every service at once, while individual fields let you override specific services up or down. - -## Per-App Log Levels - -The `logging.apps` field sets the default log level for all your automation apps. You can also override the log level for a specific app in its configuration: - -```toml ---8<-- "pages/advanced/snippets/log-level-tuning/per_app_log_level.toml" -``` - -See [App Configuration](../core-concepts/apps/configuration.md) for details on per-app settings. - -## Examples - -### Debugging the Scheduler - -```toml ---8<-- "pages/advanced/snippets/log-level-tuning/debug_scheduler.toml" -``` - -This produces detailed output about job trigger evaluation, next-run calculations, and execution timing without affecting other services. - -### Quieting the File Watcher - -```toml ---8<-- "pages/advanced/snippets/log-level-tuning/quiet_file_watcher.toml" -``` - -The file watcher logs every detected file change at `INFO` level. In development with frequent saves, this can be noisy. Setting it to `WARNING` suppresses routine change detection messages. - -### Debugging Home Assistant Communication - -```toml ---8<-- "pages/advanced/snippets/log-level-tuning/debug_ha_comms.toml" -``` - -This shows detailed WebSocket message traffic and REST API call/response details. Useful when troubleshooting connectivity issues or unexpected state values. - -## See Also - -- [Global Configuration](../core-concepts/configuration/global.md) — all configuration fields including `log_level` -- [App Configuration](../core-concepts/apps/configuration.md) — per-app log level overrides diff --git a/docs/pages/advanced/managing-helpers.md b/docs/pages/advanced/managing-helpers.md deleted file mode 100644 index 7d03c9f75..000000000 --- a/docs/pages/advanced/managing-helpers.md +++ /dev/null @@ -1,168 +0,0 @@ -# Managing Helpers - -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 are visible in the HA UI. Apps that -want to self-provision their own helpers (a vacation-mode toggle, a motion-event cycle -counter, a user-facing mode selector) can create and manage them directly through typed -`Api` methods. The full API is 32 CRUD methods covering 8 domains, plus 3 counter -service-call shortcuts. - -## Typed Models - -Each helper 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 (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`. This means omitting a field and -explicitly setting it to `None` produce different wire payloads — see -[Gotchas](#gotchas) for the full implications. - -## Creating a Helper - -```python ---8<-- "pages/advanced/snippets/managing-helpers/create_helper.py" -``` - -The returned `InputBooleanRecord` carries the `id` HA assigned (usually the slugified -form of the `name` you passed, e.g. `"vacation_mode"`). Store or log it if you need it -later — `list_input_booleans()` is the way to retrieve it again. - -## Listing Helpers - -```python ---8<-- "pages/advanced/snippets/managing-helpers/crud_operations.py:list" -``` - -## Updating a Helper - -`update_*` accepts a `helper_id` (the stored `id` field, not the display name) and a -partial params object. Only the fields you pass are sent to HA: - -```python ---8<-- "pages/advanced/snippets/managing-helpers/crud_operations.py:update" -``` - -Passing `helper_id` that does not exist raises `FailedMessageError(code="not_found")`. - -## Deleting a Helper - -```python ---8<-- "pages/advanced/snippets/managing-helpers/crud_operations.py:delete" -``` - -Returns `None`. Raises `FailedMessageError(code="not_found")` if the id is absent. - -## Idempotent Bootstrap (the Simple Pattern) - -Your app might not know whether it has been run before and whether its helper already -exists. The correct pattern is a short list-then-create loop: - -```python ---8<-- "pages/advanced/snippets/managing-helpers/crud_operations.py:bootstrap" -``` - -This pattern is correct when **one app in the deployment owns provisioning** for this -helper — which is the recommended topology. Call it from `on_initialize` and keep the -returned record for the rest of the app's lifetime. - -!!! warning "Concurrent provisioning" - If two apps can run `_ensure_vacation_mode` simultaneously, both may pass the - list-then-create gap and both will succeed — but HA will silently auto-suffix the - second helper's id to `vacation_mode_2`. There is no error code to catch; see - [Gotchas](#gotchas) for the full explanation and the recommended mitigation (naming - discipline, not retry logic). - -## Counter Service-Call 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. Call them through `api.call_service` directly: - -```python ---8<-- "pages/advanced/snippets/managing-helpers/timer_call_service.py:timer" -``` - -The asymmetry is intentional. Counter increment/decrement/reset are high-frequency -operations that benefit from short, readable call sites. Timer actions are typically -one-off and the full `call_service` signature makes the intent explicit. - -## Testing with the Harness - -`AppTestHarness` exposes a `seed_helper(record)` method that pre-populates the harness's -helper store. The harness derives the helper domain from the record's class, so there is -no `domain` parameter — just pass the typed record. - -```python ---8<-- "pages/advanced/snippets/managing-helpers/testing_harness.py" -``` - -Seeded records are stored as deep copies, so later mutations to the record you passed -in won't leak into harness state. - -## Gotchas - -- **HA auto-suffixes on name collision.** When you call `create_input_boolean` (or any - `create_*`) with a `name` that slugifies to an `id` already in storage, HA does **not** - raise an error. Home Assistant's collection storage silently appends `_2`, `_3`, and so - on until it finds a free slot. Two concurrent creators of the same-named helper will - both succeed, leaving two semantically-duplicate records in storage. There is no - `name_in_use` error code to catch. The correct mitigation is **naming discipline**: - prefix every helper with an identifier unique to its owning app (e.g., `motionapp_cycles` - rather than `cycles`) so collisions cannot happen in the first place, and ensure only - one app ever provisions any given helper. - -- **`CreateInputDatetimeParams` requires `has_date=True` or `has_time=True`.** Both - `False` raises `ValidationError` at construction time — before any network call is - made. `UpdateInputDatetimeParams` does **not** enforce this constraint on partial - updates because the counterpart field stays at its stored value. - -- **`exclude_unset=True` vs explicit `None`.** All CRUD methods serialize params with - `model_dump(exclude_unset=True)`. A field you omit entirely is not sent to HA (HA keeps - its stored value). A field you pass as `None` is sent as `null`, which may clear the - value. These produce different behavior: if you want to leave `icon` unchanged, omit it - from the constructor; if you want to clear it, pass `icon=None`. - -- **`CounterRecord` and `CounterState` are two different models.** Reading the current - counter value at runtime uses `await self.api.get_state("counter.mycounter")`, which - returns a `CounterState`. Changing the counter's configured `initial` value uses - `await self.api.update_counter("mycounter", UpdateCounterParams(initial=0))`, which - returns a `CounterRecord`. Changes to stored config take effect on the next HA restart; - `increment_counter` / `decrement_counter` / `reset_counter` are immediate but do not - change stored config. - -- **Helper creation persists across HA restarts.** HA stores helpers in `.storage/`, - unlike volatile entity state. A helper you create in `on_initialize` today will still - be there next week. The idempotent-bootstrap pattern above exists precisely because of - this: on the second run your helper is already there. - -- **`RetryableConnectionClosedError` is a second exception class callers may receive.** - A WebSocket disconnect mid-CRUD propagates as `RetryableConnectionClosedError`, not - `FailedMessageError`. Callers whose `except FailedMessageError` block contains cleanup - logic should add a separate `except (FailedMessageError, RetryableConnectionClosedError):` - or wrap in a broader `except Exception:` where appropriate. - -## Not Included / Out of Scope - -- **Subscribe commands.** Hassette does not currently expose a typed wrapper for HA's - helper config-change subscribe commands. Apps that need to react to stored-config - changes in real time should subscribe to entity state changes instead, or fall back to - raw `ws_send_and_wait()`. - -## See Also - -- [API Reference — `hassette.api.Api`][hassette.api.Api] — full method signatures for all - 32 CRUD methods and 3 counter shortcuts -- [Testing Your Apps](../testing/index.md) — general harness documentation diff --git a/docs/pages/advanced/snippets/custom-states/complete_example.py b/docs/pages/advanced/snippets/custom-states/complete_example.py deleted file mode 100644 index 921310b35..000000000 --- a/docs/pages/advanced/snippets/custom-states/complete_example.py +++ /dev/null @@ -1,47 +0,0 @@ -# my_states.py -from typing import Literal - -from pydantic import Field - -from hassette import App, D -from hassette.models.states.base import AttributesBase, StringBaseState - - -class ImageAttributes(AttributesBase): - """Attributes for image entities.""" - - url: str | None = Field(default=None) - width: int | None = Field(default=None) - height: int | None = Field(default=None) - content_type: str | None = Field(default=None) - - -class ImageState(StringBaseState): - """State class for image domain.""" - - domain: Literal["image"] - attributes: ImageAttributes - - -class ImageMonitorApp(App): - async def on_initialize(self): - # Monitor all image entities - await self.bus.on_state_change( - entity_id="image.*", - handler=self.on_image_change, # Glob pattern - name="image_monitor", - ) - - async def on_image_change( - self, - new_state: D.StateNew[ImageState], - entity_id: D.EntityId, - ): - attrs = new_state.attributes - self.logger.info( - "Image %s updated: %dx%d, %s", - entity_id, - attrs.width or 0, - attrs.height or 0, - attrs.content_type or "unknown", - ) diff --git a/docs/pages/advanced/snippets/custom-states/custom_domain_runtime_access.py b/docs/pages/advanced/snippets/custom-states/custom_domain_runtime_access.py deleted file mode 100644 index ac42c4ecf..000000000 --- a/docs/pages/advanced/snippets/custom-states/custom_domain_runtime_access.py +++ /dev/null @@ -1,8 +0,0 @@ -from hassette import App - - -class MyApp(App): - async def on_initialize(self): - # Works at runtime but static analysis sees BaseState - for entity_id, state in self.states.my_custom_domain: - print(state.value) # state is typed as BaseState diff --git a/docs/pages/advanced/snippets/custom-states/custom_domain_typed_access.py b/docs/pages/advanced/snippets/custom-states/custom_domain_typed_access.py deleted file mode 100644 index 79667dfaa..000000000 --- a/docs/pages/advanced/snippets/custom-states/custom_domain_typed_access.py +++ /dev/null @@ -1,16 +0,0 @@ -from typing import Literal - -from hassette import App -from hassette.models.states.base import StringBaseState - - -class MyCustomState(StringBaseState): - domain: Literal["my_custom_domain"] - - -class MyApp(App): - async def on_initialize(self): - # Custom domains (use states[] for typing) - custom_states = self.states[MyCustomState] - for entity_id, state in custom_states: - print(state.value) diff --git a/docs/pages/advanced/snippets/custom-states/direct_api_access.py b/docs/pages/advanced/snippets/custom-states/direct_api_access.py deleted file mode 100644 index 4e5ff2b6a..000000000 --- a/docs/pages/advanced/snippets/custom-states/direct_api_access.py +++ /dev/null @@ -1,11 +0,0 @@ -from hassette import App - -from .my_states import RedditState # pyright: ignore[reportMissingImports] - - -class MyApp(App): - async def on_initialize(self): - reddit_state = await self.api.get_state("reddit.my_account") - assert isinstance(reddit_state, RedditState) - if reddit_state.attributes.subreddit: - print(f"Subreddit: {reddit_state.attributes.subreddit}") diff --git a/docs/pages/advanced/snippets/custom-states/known_domain_access.py b/docs/pages/advanced/snippets/custom-states/known_domain_access.py deleted file mode 100644 index d9fd771c5..000000000 --- a/docs/pages/advanced/snippets/custom-states/known_domain_access.py +++ /dev/null @@ -1,8 +0,0 @@ -from hassette import App - - -class MyApp(App): - async def on_initialize(self): - # Known domains (autocomplete works) - for entity_id, light in self.states.light: - print(light.attributes.brightness) diff --git a/docs/pages/advanced/snippets/log-level-tuning/per_app_log_level.toml b/docs/pages/advanced/snippets/log-level-tuning/per_app_log_level.toml deleted file mode 100644 index 0180da48d..000000000 --- a/docs/pages/advanced/snippets/log-level-tuning/per_app_log_level.toml +++ /dev/null @@ -1,6 +0,0 @@ -# hassette.toml -[hassette.logging] -apps = "INFO" - -[apps.my_noisy_app] -log_level = "WARNING" diff --git a/docs/pages/advanced/snippets/state-registry/accessing_registry.py b/docs/pages/advanced/snippets/state-registry/accessing_registry.py deleted file mode 100644 index 3458eece0..000000000 --- a/docs/pages/advanced/snippets/state-registry/accessing_registry.py +++ /dev/null @@ -1,3 +0,0 @@ -from hassette import STATE_REGISTRY - -registry = STATE_REGISTRY diff --git a/docs/pages/advanced/snippets/state-registry/di_integration.py b/docs/pages/advanced/snippets/state-registry/di_integration.py deleted file mode 100644 index 8061b4c42..000000000 --- a/docs/pages/advanced/snippets/state-registry/di_integration.py +++ /dev/null @@ -1,7 +0,0 @@ -from hassette import App, D, states - - -class MyApp(App): - async def on_light_change(self, new_state: D.StateNew[states.LightState]): - # new_state is already a LightState instance - pass diff --git a/docs/pages/advanced/snippets/state-registry/direct_conversion.py b/docs/pages/advanced/snippets/state-registry/direct_conversion.py deleted file mode 100644 index db6e5a40a..000000000 --- a/docs/pages/advanced/snippets/state-registry/direct_conversion.py +++ /dev/null @@ -1,13 +0,0 @@ -from hassette import STATE_REGISTRY - -# Raw state data from Home Assistant -state_dict = { - "entity_id": "light.bedroom", - "state": "on", - "attributes": {"brightness": 200}, - # ... more fields -} - -# Convert to typed model -light_state = STATE_REGISTRY.try_convert_state(state_dict) -# Returns: LightState instance diff --git a/docs/pages/advanced/snippets/state-registry/example_benefits.py b/docs/pages/advanced/snippets/state-registry/example_benefits.py deleted file mode 100644 index dad73d6e1..000000000 --- a/docs/pages/advanced/snippets/state-registry/example_benefits.py +++ /dev/null @@ -1,10 +0,0 @@ -from typing import Annotated - -from hassette import A - - -# TypeRegistry also works in dependency injection -# and converts attribute values too -async def handler(brightness: Annotated[int, A.get_attr_new("brightness")]): - # brightness is int, not string, thanks to TypeRegistry - pass diff --git a/docs/pages/advanced/snippets/state-registry/flow_converted_output.py b/docs/pages/advanced/snippets/state-registry/flow_converted_output.py deleted file mode 100644 index 81916d29c..000000000 --- a/docs/pages/advanced/snippets/state-registry/flow_converted_output.py +++ /dev/null @@ -1,8 +0,0 @@ -from hassette import STATE_REGISTRY - -state_dict = { - "entity_id": "time.current", - "state": "12:01:01", -} -time_state = STATE_REGISTRY.try_convert_state(state_dict) -# Result: TimeState with state=whenever.Time diff --git a/docs/pages/advanced/snippets/state-registry/flow_raw_input.py b/docs/pages/advanced/snippets/state-registry/flow_raw_input.py deleted file mode 100644 index 5adb582ed..000000000 --- a/docs/pages/advanced/snippets/state-registry/flow_raw_input.py +++ /dev/null @@ -1,4 +0,0 @@ -state_dict = { - "entity_id": "time.current", - "state": "12:01:01", # String from HA -} diff --git a/docs/pages/advanced/snippets/state-registry/integration_di.py b/docs/pages/advanced/snippets/state-registry/integration_di.py deleted file mode 100644 index b21eeacd3..000000000 --- a/docs/pages/advanced/snippets/state-registry/integration_di.py +++ /dev/null @@ -1,4 +0,0 @@ -from hassette import D, states - -# DI annotation uses StateRegistry internally -new_state: D.StateNew[states.LightState] diff --git a/docs/pages/advanced/snippets/state-registry/integration_states.py b/docs/pages/advanced/snippets/state-registry/integration_states.py deleted file mode 100644 index 31d32288d..000000000 --- a/docs/pages/advanced/snippets/state-registry/integration_states.py +++ /dev/null @@ -1,8 +0,0 @@ -from hassette import App - - -class StatesUsage(App): - async def usage(self): - # Returns typed LightState instance - light = self.states.light.get("light.bedroom") - self.logger.info(light) diff --git a/docs/pages/advanced/snippets/state-registry/raw_data_example.py b/docs/pages/advanced/snippets/state-registry/raw_data_example.py deleted file mode 100644 index 11ef8d32a..000000000 --- a/docs/pages/advanced/snippets/state-registry/raw_data_example.py +++ /dev/null @@ -1,13 +0,0 @@ -# Raw data from Home Assistant (untyped dict) -raw_data = { - "entity_id": "light.bedroom", - "state": "on", - "attributes": {"brightness": 200, "color_temp": 370}, -} - -# After StateRegistry conversion (typed model) -# LightState( -# entity_id="light.bedroom", -# state="on", -# attributes=LightAttributes(brightness=200, color_temp=370), -# ) diff --git a/docs/pages/advanced/snippets/state-registry/value_type_example.py b/docs/pages/advanced/snippets/state-registry/value_type_example.py deleted file mode 100644 index dbe061f14..000000000 --- a/docs/pages/advanced/snippets/state-registry/value_type_example.py +++ /dev/null @@ -1,23 +0,0 @@ -from typing import Any, ClassVar, Literal - -from whenever import Time - -from hassette.models.states import BaseState - - -class TimeBaseState(BaseState[Time | None]): - """Base class for Time states. - - Valid state values are Time or None. - """ - - value_type: ClassVar[type[Any] | tuple[type[Any], ...]] = (Time, type(None)) - - -class TimeState(TimeBaseState): - """Representation of a Home Assistant time state. - - See: https://www.home-assistant.io/integrations/time/ - """ - - domain: Literal["time"] diff --git a/docs/pages/advanced/snippets/type-registry/base_state_convert_call.py b/docs/pages/advanced/snippets/type-registry/base_state_convert_call.py deleted file mode 100644 index bacfbaa5b..000000000 --- a/docs/pages/advanced/snippets/type-registry/base_state_convert_call.py +++ /dev/null @@ -1,2 +0,0 @@ -# In BaseState._validate_domain_and_state -values["state"] = TYPE_REGISTRY.convert(state, cls.value_type) diff --git a/docs/pages/advanced/snippets/type-registry/best_practice_error_msg.py b/docs/pages/advanced/snippets/type-registry/best_practice_error_msg.py deleted file mode 100644 index 7ab86b3b7..000000000 --- a/docs/pages/advanced/snippets/type-registry/best_practice_error_msg.py +++ /dev/null @@ -1,15 +0,0 @@ -from hassette import register_type_converter_fn - - -class MyType: - """Placeholder for a custom type.""" - - -@register_type_converter_fn(error_message="Cannot convert '{value}' to MyType. Expected format: X,Y,Z") -def str_to_mytype(value: str) -> MyType: - """Convert string to MyType with clear error handling. - - Types inferred from signature: str → MyType - """ - # ... conversion logic with helpful ValueError messages - raise ValueError(f"Cannot parse '{value}' as MyType") diff --git a/docs/pages/advanced/snippets/type-registry/best_practice_register_early.py b/docs/pages/advanced/snippets/type-registry/best_practice_register_early.py deleted file mode 100644 index 8c644bcf3..000000000 --- a/docs/pages/advanced/snippets/type-registry/best_practice_register_early.py +++ /dev/null @@ -1,10 +0,0 @@ -# my_converters.py -from hassette import register_type_converter_fn - - -class MyType: - """Placeholder for a custom type.""" - - -@register_type_converter_fn # Registered when module is imported -def str_to_mytype(value: str) -> MyType: ... diff --git a/docs/pages/advanced/snippets/type-registry/best_practice_test_converter.py b/docs/pages/advanced/snippets/type-registry/best_practice_test_converter.py deleted file mode 100644 index 4945ea641..000000000 --- a/docs/pages/advanced/snippets/type-registry/best_practice_test_converter.py +++ /dev/null @@ -1,28 +0,0 @@ -import pytest - -from hassette import TYPE_REGISTRY - - -class RGBColor: - """Placeholder for a custom RGB color type.""" - - red: int - green: int - blue: int - - -def test_custom_converter(): - """Test custom RGB converter.""" - # Valid conversion - result = TYPE_REGISTRY.convert("255,128,0", RGBColor) - assert result.red == 255 - assert result.green == 128 - assert result.blue == 0 - - # Invalid format - with pytest.raises(ValueError, match="Invalid RGB format"): - TYPE_REGISTRY.convert("not_rgb", RGBColor) - - # Out of range - with pytest.raises(ValueError, match="must be between 0 and 255"): - TYPE_REGISTRY.convert("300,128,0", RGBColor) diff --git a/docs/pages/advanced/snippets/type-registry/best_practice_type_hints.py b/docs/pages/advanced/snippets/type-registry/best_practice_type_hints.py deleted file mode 100644 index 629767830..000000000 --- a/docs/pages/advanced/snippets/type-registry/best_practice_type_hints.py +++ /dev/null @@ -1,12 +0,0 @@ -from typing import Annotated - -from hassette import A - - -# TypeRegistry converts automatically based on type hint -async def handler( - temperature: Annotated[float, A.get_attr_new("temperature")], - humidity: Annotated[int, A.get_attr_new("humidity")], -): - # temperature and humidity are already the correct types - pass diff --git a/docs/pages/advanced/snippets/type-registry/best_practice_value_type.py b/docs/pages/advanced/snippets/type-registry/best_practice_value_type.py deleted file mode 100644 index a56daead1..000000000 --- a/docs/pages/advanced/snippets/type-registry/best_practice_value_type.py +++ /dev/null @@ -1,8 +0,0 @@ -from typing import ClassVar - -from hassette.models.states import BaseState - - -class CustomState(BaseState): - # Explicitly define expected types - value_type: ClassVar[type | tuple[type, ...]] = int diff --git a/docs/pages/advanced/snippets/type-registry/di_custom_extractor.py b/docs/pages/advanced/snippets/type-registry/di_custom_extractor.py deleted file mode 100644 index 458282b8c..000000000 --- a/docs/pages/advanced/snippets/type-registry/di_custom_extractor.py +++ /dev/null @@ -1,15 +0,0 @@ -from typing import Annotated - -from hassette import A, App, D - - -class MyExtApp(App): - async def handler( - self, - # Brightness is returned as a string from HA, but TypeRegistry - # automatically converts it to int based on the type hint - brightness: Annotated[int | None, A.get_attr_new("brightness")], - entity_id: D.EntityId, - ): - if brightness and brightness > 200: - self.logger.info("%s is very bright: %d", entity_id, brightness) diff --git a/docs/pages/advanced/snippets/type-registry/entry_example.py b/docs/pages/advanced/snippets/type-registry/entry_example.py deleted file mode 100644 index 24858b7df..000000000 --- a/docs/pages/advanced/snippets/type-registry/entry_example.py +++ /dev/null @@ -1,8 +0,0 @@ -from hassette import TypeConverterEntry - -entry = TypeConverterEntry( - func=int, - from_type=str, - to_type=int, - error_message="Cannot convert '{value}' to integer", -) diff --git a/docs/pages/advanced/snippets/type-registry/inspect_details.py b/docs/pages/advanced/snippets/type-registry/inspect_details.py deleted file mode 100644 index 843a27410..000000000 --- a/docs/pages/advanced/snippets/type-registry/inspect_details.py +++ /dev/null @@ -1,7 +0,0 @@ -from hassette import TYPE_REGISTRY - -# Get details about a specific converter -entry = TYPE_REGISTRY.conversion_map.get((str, bool)) -if entry: - print(f"Error message: {entry.error_message}") - print(f"Converter: {entry.func}") diff --git a/docs/pages/advanced/snippets/type-registry/pattern_units.py b/docs/pages/advanced/snippets/type-registry/pattern_units.py deleted file mode 100644 index d99a8e1c0..000000000 --- a/docs/pages/advanced/snippets/type-registry/pattern_units.py +++ /dev/null @@ -1,16 +0,0 @@ -import re - -from hassette import register_type_converter_fn - - -@register_type_converter_fn -def str_with_units_to_float(value: str) -> float: - """Extract numeric value from string with units. - - Example: '23.5 °C' → 23.5 - Types inferred from signature: str → float - """ - match = re.match(r"^([-+]?[0-9]*\.?[0-9]+)", value.strip()) - if match: - return float(match.group(1)) - raise ValueError(f"Cannot extract number from '{value}'") diff --git a/docs/pages/advanced/snippets/type-registry/state_model_value_type.py b/docs/pages/advanced/snippets/type-registry/state_model_value_type.py deleted file mode 100644 index 85cf6b1e2..000000000 --- a/docs/pages/advanced/snippets/type-registry/state_model_value_type.py +++ /dev/null @@ -1,9 +0,0 @@ -from typing import ClassVar - -from hassette.models.states.base import BaseState - - -class SensorState(BaseState): - """State model for sensor entities.""" - - value_type: ClassVar[type | tuple[type, ...]] = (str, int, float) diff --git a/docs/pages/advanced/snippets/type-registry/typed_model_usage.py b/docs/pages/advanced/snippets/type-registry/typed_model_usage.py deleted file mode 100644 index dde92eeba..000000000 --- a/docs/pages/advanced/snippets/type-registry/typed_model_usage.py +++ /dev/null @@ -1,13 +0,0 @@ -from hassette import states - -# Raw state data from Home Assistant -raw_data = { - "entity_id": "sensor.temperature", - "state": "23.5", # String from HA - "attributes": {"unit_of_measurement": "°C"}, - "context": {"id": "12345", "user_id": "user_1"}, -} - -# Creating a typed state model automatically converts the value -sensor_state = states.SensorState(**raw_data) -print(type(sensor_state.value)) # - automatically converted! diff --git a/docs/pages/advanced/snippets/type-registry/union_type_order.py b/docs/pages/advanced/snippets/type-registry/union_type_order.py deleted file mode 100644 index b12601643..000000000 --- a/docs/pages/advanced/snippets/type-registry/union_type_order.py +++ /dev/null @@ -1,4 +0,0 @@ -from typing import Union - -# value_type = (int, float, str) becomes Union[int, float, str] -# TypeRegistry tries: str → int, then str → float, then keeps as str diff --git a/docs/pages/advanced/snippets/type-registry/union_type_performance.py b/docs/pages/advanced/snippets/type-registry/union_type_performance.py deleted file mode 100644 index 62b694b66..000000000 --- a/docs/pages/advanced/snippets/type-registry/union_type_performance.py +++ /dev/null @@ -1,4 +0,0 @@ -# For Union[int, float, str] -# 1. Try str → int -# 2. If that fails, try str → float -# 3. If that fails, try str → str (identity) diff --git a/docs/pages/advanced/state-registry.md b/docs/pages/advanced/state-registry.md deleted file mode 100644 index 748ced159..000000000 --- a/docs/pages/advanced/state-registry.md +++ /dev/null @@ -1,216 +0,0 @@ -# State Registry - -The **StateRegistry** maps Home Assistant domains (like `light`, `sensor`, `switch`) to Pydantic state model classes, so raw dictionaries from HA become typed Python objects automatically. If you haven't defined a [custom state class](custom-states.md) yet, that page covers the basics of creating one. - -## When Do I Need This? - -**Most apps never need to touch the StateRegistry directly.** The built-in state classes cover all standard Home Assistant domains, and the DI system and `self.states` cache use the registry automatically. - -You need this page when: - -- You are writing a [custom state class](custom-states.md) for a domain Hassette does not yet know about. -- You want to override the default state class for an existing domain (e.g., to add custom attributes). -- You are seeing unexpected state types at runtime and need to understand how the mapping works. - -## What is the State Registry? - -When Home Assistant sends state change events, the state data arrives as untyped dictionaries. The StateRegistry converts these dictionaries into typed Pydantic models based on the entity's domain: - -```python ---8<-- "pages/advanced/snippets/state-registry/raw_data_example.py" -``` - -## How It Works - -### Automatic Registration - -All classes that inherit from `BaseState` — the root model class that all Hassette state types extend — are registered automatically at class creation time if they have a valid domain. You do not need to call any registration function — defining the class is sufficient. - -??? note "Implementation details: `__init_subclass__` hook" - Registration happens via the `__init_subclass__` hook in `BaseState`, which adds the class to the global `StateRegistry` as soon as the class body is evaluated. - - ```python - --8<-- "pages/advanced/snippets/state-registry/automatic_registration.py" - ``` - -### Domain Lookup - -When you need to convert state data, the registry provides lookup functions: - -```python ---8<-- "pages/advanced/snippets/state-registry/domain_lookup.py" -``` - -## Relationship with TypeRegistry - -The StateRegistry and [TypeRegistry](type-registry.md) handle different parts of type conversion for Home Assistant state data: - -- **StateRegistry** → Determines which state model class to use based on domain -- **TypeRegistry** → Converts raw values to proper Python types during model validation - -### The Complete Flow - -When state data arrives from Home Assistant, both registries cooperate: - -1. **Raw data arrives** from Home Assistant: - ```python - --8<-- "pages/advanced/snippets/state-registry/flow_raw_input.py" - ``` - -2. **StateRegistry** determines the model class based on the `time` domain → returns `TimeState` - -3. **Pydantic validation** begins on the `TimeState` model - -4. **BaseState._validate_domain_and_state** checks the `value_type` ClassVar - -5. **TypeRegistry** converts `"12:01:01"` (str) → `whenever.Time` - -6. **Validation completes** with the properly typed value: - ```python - --8<-- "pages/advanced/snippets/state-registry/flow_converted_output.py" - ``` - -### The value_type ClassVar - -State model classes use the `value_type` ClassVar to declare expected state value types: - -```python ---8<-- "pages/advanced/snippets/state-registry/value_type_example.py" -``` - -During validation, if the raw state value doesn't match `value_type`, the TypeRegistry automatically converts it. - -This means when you work with state models, numeric values, booleans, and datetimes are automatically the correct Python type, not strings. - -### Why Two Registries? - -Each registry answers a different question: - -- StateRegistry: **"What model class?"** (domain → model mapping) -- TypeRegistry: **"What type?"** (value → type conversion) - -Splitting them means the TypeRegistry can be reused throughout the framework (DI system, custom extractors) and you can extend either one without touching the other. - -**Example Benefits:** -```python ---8<-- "pages/advanced/snippets/state-registry/example_benefits.py" -``` - -See [TypeRegistry](type-registry.md) for more details on automatic value conversion. - -## State Conversion - -The primary use of the StateRegistry is converting raw state dictionaries to typed models: - -### Direct Conversion - -```python ---8<-- "pages/advanced/snippets/state-registry/direct_conversion.py" -``` - -The `try_convert_state` method: - -- Extracts the domain from the entity_id -- Looks up the corresponding state class -- Converts the dictionary to a Pydantic model instance -- Falls back to `BaseState` for unknown domains - -### Via Dependency Injection - -The StateRegistry works with [dependency injection](../core-concepts/bus/dependency-injection.md) automatically: - -```python ---8<-- "pages/advanced/snippets/state-registry/di_integration.py" -``` - -Behind the scenes, the DI system uses `convert_state_dict_to_model()` which calls the StateRegistry. - -## Domain Override - -If you want to override the default state class for a domain (for example, to add custom attributes), define your class after imports: - -```python ---8<-- "pages/advanced/snippets/state-registry/domain_override.py" -``` - -The StateRegistry silently replaces the existing class with your custom one. - -## Union Type Support - -The StateRegistry works with Union types, automatically selecting the correct state class: - -```python ---8<-- "pages/advanced/snippets/state-registry/union_type_support.py" -``` - -The conversion logic: -1. Extracts the domain from the entity_id -2. Checks each type in the Union -3. Uses the state class whose domain matches -4. Falls back to `BaseState` if no match - -## Error Handling - -The StateRegistry raises specific exceptions for different error conditions: - -### InvalidDataForStateConversionError - -Raised when state data is malformed or missing required fields: - -```python ---8<-- "pages/advanced/snippets/state-registry/error_invalid_data.py" -``` - -### InvalidEntityIdError - -Raised when the entity_id format is invalid: - -```python ---8<-- "pages/advanced/snippets/state-registry/error_invalid_entity_id.py" -``` - -### UnableToConvertStateError - -Raised when conversion to the target state class fails: - -```python ---8<-- "pages/advanced/snippets/state-registry/error_unable_to_convert.py" -``` - -## Integration with Other Components - -### With Dependency Injection - -The StateRegistry powers all state type conversions in [dependency injection](../core-concepts/bus/dependency-injection.md): - -```python ---8<-- "pages/advanced/snippets/state-registry/integration_di.py" -``` - -### With States Resource - -The States cache uses the StateRegistry for all state lookups: - -```python ---8<-- "pages/advanced/snippets/state-registry/integration_states.py" -``` - -## Advanced Usage - -### Accessing the Registry - -The StateRegistry can be imported from Hassette directly: - -```python ---8<-- "pages/advanced/snippets/state-registry/accessing_registry.py" -``` - -In apps, you don't need direct access — the DI system and API methods handle conversions for you. - -If you do need to access it, it is accessible through `self.hassette.state_registry`. - -## See Also - -- [Type Registry](type-registry.md) - automatic value type conversion -- [Dependency Injection](../core-concepts/bus/dependency-injection.md) - using StateRegistry via DI annotations -- [Custom States](custom-states.md) - defining your own state classes diff --git a/docs/pages/advanced/type-registry.md b/docs/pages/advanced/type-registry.md deleted file mode 100644 index 93fe821e9..000000000 --- a/docs/pages/advanced/type-registry.md +++ /dev/null @@ -1,329 +0,0 @@ -# TypeRegistry - -Home Assistant sends nearly all values as strings over its API — even numbers and booleans. The TypeRegistry converts those strings to the correct Python types (integers, floats, booleans, datetimes, etc.) before they reach your code. - -## When Do I Need This? - -**Most apps never need to touch the TypeRegistry.** The built-in converters handle all standard Home Assistant types automatically. - -You need this page when: - -- You have a custom state model whose `value_type` is a type Hassette does not know how to convert (e.g., a third-party type or an enum). -- You need to register a converter for a custom extractor in the dependency injection system. -- A built-in conversion is giving unexpected results and you need to understand or override it. - -## Purpose - -Home Assistant's WebSocket API and state system primarily work with string representations of values. For example: - -- A temperature sensor might report `"23.5"` as a string -- A boolean sensor reports `"on"` or `"off"` rather than `True`/`False` -- Timestamps arrive as ISO 8601 strings - -The TypeRegistry automatically converts these string values to their proper Python types, making your code cleaner and more type-safe. - -## Core Concepts - -??? note "Implementation details: TypeConverterEntry" - Each registered converter is stored as a `TypeConverterEntry` dataclass containing: - - - **func**: The actual conversion function - - **from_type**: Source type (e.g., `str`) - - **to_type**: Target type (e.g., `int`) - - **error_types**: Tuple of exception types to catch (defaults to `(ValueError,)`) - - **error_message**: Optional custom error message template (uses `{value}`, `{from_type}`, `{to_type}` placeholders) - - ```python - --8<-- "pages/advanced/snippets/type-registry/entry_example.py" - ``` - -### Registration System - -The TypeRegistry provides two ways to register converters: - -#### Decorator Registration - -Use `@register_type_converter_fn` to register a conversion function: - -```python ---8<-- "pages/core-concepts/bus/snippets/dependency-injection/custom_type_converter.py" -``` - -#### Simple Type Registration - -Use `register_simple_type_converter` for simple conversions: - -```python ---8<-- "pages/advanced/snippets/type-registry/simple_registration.py" -``` - -### Conversion Lookup - -The TypeRegistry uses a dictionary with `(from_type, to_type)` tuples as keys for O(1) lookup performance: - -```python ---8<-- "pages/advanced/snippets/type-registry/lookup_example.py" -``` - -## Integration with State Models - -The TypeRegistry works with Hassette's state model system through the `value_type` ClassVar. - -### The value_type ClassVar - -Each state model class can declare a `value_type` ClassVar to specify the expected type(s) of the state value: - -```python ---8<-- "pages/advanced/snippets/type-registry/state_model_value_type.py" -``` - -The `value_type` defines what types are valid for the `state` field. It can be: - -- A single type: `value_type = int` -- A tuple of types: `value_type = (str, int, float)` -- Defaults to `str` if not specified - -### Automatic Conversion in Models - -When a state is created or validated, the `BaseState._validate_domain_and_state` model validator automatically uses the TypeRegistry to convert the raw state value: - -```python ---8<-- "pages/advanced/snippets/type-registry/base_state_convert_call.py" -``` - -This means when you work with typed state models, values are automatically converted: - -```python ---8<-- "pages/advanced/snippets/type-registry/typed_model_usage.py" -``` - -### Union Type Handling - -The TypeRegistry intelligently handles Union types (including `value_type` tuples) by trying conversions in order: - -```python ---8<-- "pages/advanced/snippets/type-registry/union_type_order.py" -``` - -The conversion attempts each type in the Union until one succeeds, preserving the original value if no conversion works. - -## Integration with Dependency Injection - -The TypeRegistry handles automatic type conversion in the dependency injection system, particularly for custom extractors. - -### Type Conversion in Custom Extractors - -When you use `Annotated` with custom extractors from `hassette.event_handling.accessors`, the TypeRegistry automatically converts extracted values: - -```python ---8<-- "pages/advanced/snippets/type-registry/di_custom_extractor.py" -``` - -When a custom extractor returns a value, if the value type doesn't match the annotated type, the TypeRegistry is called to perform the conversion automatically. - -## Relationship with StateRegistry - -The TypeRegistry and StateRegistry work together but serve different purposes: - -**StateRegistry**: Maps Home Assistant domains to Pydantic state model classes - -- Purpose: Determines which model class to use for a given entity -- Example: `"sensor.temperature"` → `SensorState` class - -**TypeRegistry**: Converts raw values to proper Python types - -- Purpose: Ensures state values match expected types -- Example: `"23.5"` (string) → `23.5` (float) - -### The Workflow - -1. **StateRegistry** determines the model class based on domain -2. Pydantic validation begins with raw state data -3. `BaseState._validate_domain_and_state` checks the `value_type` ClassVar -4. **TypeRegistry** converts the state value to match `value_type` -5. Pydantic continues validation with the properly typed value - - -## Built-in Converters - -Hassette includes built-in converters for all standard HA types: - -### Numeric Conversions - -- `str` ↔ `int`: Basic integer conversion -- `str` ↔ `float`: Floating-point conversion -- `str` → `Decimal`: High-precision decimal parsing -- `float` → `Decimal`: Floating-point to high-precision decimal -- `Decimal` → `int` / `float`: Precision-loss conversion -- `int` → `float`: Integer to float conversion -- `float` → `int`: Float to integer (truncation) - -### Boolean Conversions - -- `str` → `bool`: Handles Home Assistant boolean strings - - True values: `"on"`, `"true"`, `"yes"`, `"1"` - - False values: `"off"`, `"false"`, `"no"`, `"0"` -- `bool` → `str`: Converts to `"True"` or `"False"` (Python `str()` — not HA format) - -### DateTime Conversions - -Uses the `whenever` library for robust datetime handling: - -**`whenever` types:** - -- `str` → `ZonedDateTime`: Parse HA datetime strings (ISO, plain, or date-only — assumed system timezone) -- `str` → `Date`: ISO date string via `Date.parse_iso` -- `str` → `Time`: ISO time string via `Time.parse_iso` -- `str` → `OffsetDateTime`: ISO datetime with UTC offset via `OffsetDateTime.parse_iso` -- `str` → `PlainDateTime`: ISO datetime without timezone via `PlainDateTime.parse_iso` -- `ZonedDateTime` → `Instant`: Strip timezone info (`to_instant`) -- `ZonedDateTime` → `PlainDateTime`: Drop timezone (`to_plain`) -- `ZonedDateTime` → `str`: ISO format (`format_iso`) -- `Time` → `str`: ISO format (`format_iso`) - -**Stdlib datetime types:** - -- `str` → `datetime`: Parse via `ZonedDateTime.py_datetime()` -- `str` → `time`: Parse via `Time.parse_iso().py_time()` -- `str` → `date`: Parse via `Date.parse_iso().py_date()` -- `Time` → `time`: Convert via `py_time()` - -### Conversion Errors - -When a conversion fails, the TypeRegistry wraps the error with context: - -```python ---8<-- "pages/advanced/snippets/type-registry/conversion_error.py" -``` - -### Missing Converters - -If no converter is registered for a type pair and the type's constructor also fails, an `UnableToConvertValueError` is raised: - -```python ---8<-- "pages/advanced/snippets/type-registry/missing_converter.py" -``` - -### Custom Error Messages - -Provide helpful error messages in your custom converters: - -```python ---8<-- "pages/advanced/snippets/type-registry/custom_error_msg.py" -``` - -## Inspection and Debugging - -??? note "Implementation details: inspection API" - The TypeRegistry provides methods to inspect registered converters. These are primarily useful for Hassette core developers or for debugging unexpected conversion behavior. - - ### List All Conversions - - ```python - --8<-- "pages/advanced/snippets/type-registry/inspect_list.py" - ``` - - Output example: - ``` - --8<-- "pages/advanced/snippets/type-registry/inspect_list_output.txt" - ``` - - ### Check for Specific Converter - - ```python - --8<-- "pages/advanced/snippets/type-registry/inspect_check.py" - ``` - - ### Get Converter Details - - ```python - --8<-- "pages/advanced/snippets/type-registry/inspect_details.py" - ``` - -### Union Type Performance - -When converting to Union types, the TypeRegistry tries each type in order until one succeeds: - -```python ---8<-- "pages/advanced/snippets/type-registry/union_type_performance.py" -``` - -For better performance with Union types, order the types from most specific to least specific: - -- ✅ Good: `Union[int, float, str]` (tries int first, most specific) -- ❌ Less optimal: `Union[str, int, float]` (str matches everything) - -## Best Practices - -### 1. Define value_type in State Models - -Always specify `value_type` in custom state models: - -```python ---8<-- "pages/advanced/snippets/type-registry/best_practice_value_type.py" -``` - -### 2. Use Type Hints with Custom Extractors - -Use type hints for automatic conversion in dependency injection: - -```python ---8<-- "pages/advanced/snippets/type-registry/best_practice_type_hints.py" -``` - -### 3. Provide Clear Error Messages - -When creating custom converters, write helpful error messages: - -```python ---8<-- "pages/advanced/snippets/type-registry/best_practice_error_msg.py" -``` - -### 4. Register Converters Early - -Register custom converters at module import time using decorators: - -```python ---8<-- "pages/advanced/snippets/type-registry/best_practice_register_early.py" -``` - -Then import your converters module in your app's `__init__.py` or before first use. - -### 5. Test Custom Converters - -Always test custom converters with edge cases: - -```python ---8<-- "pages/advanced/snippets/type-registry/best_practice_test_converter.py" -``` -## Common Patterns - -### Pattern 1: Enum Conversion - -Convert Home Assistant string values to Python enums: - -```python ---8<-- "pages/advanced/snippets/type-registry/pattern_enum.py" -``` - -### Pattern 2: Structured Data - -Convert JSON strings to dataclasses: - -```python ---8<-- "pages/advanced/snippets/type-registry/pattern_structured.py" -``` - -### Pattern 3: Units of Measurement - -Convert strings with units to numeric values: - -```python ---8<-- "pages/advanced/snippets/type-registry/pattern_units.py" -``` - -## See Also - -- [State Registry](state-registry.md) - Domain to model class mapping -- [Dependency Injection](../core-concepts/bus/dependency-injection.md) - Using TypeRegistry with custom extractors -- [State Models](../core-concepts/states/index.md) - State model reference diff --git a/docs/pages/cli/commands.md b/docs/pages/cli/commands.md index dabb6d1e8..5ad4dd1f2 100644 --- a/docs/pages/cli/commands.md +++ b/docs/pages/cli/commands.md @@ -1,10 +1,14 @@ # 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. `hassette --version` (or `-v`) prints the installed version. [Configuration & Scripting](configuration.md#output-modes) covers output modes in detail. + +Every command except `run` queries a running instance — start the server with `hassette run` first. Each command wraps a REST endpoint, noted per command for scripting and direct HTTP access. + +Several commands take `--app `. The app key is the `[hassette.apps.]` section name from `hassette.toml`, and an instance is one running copy of an app class — [`hassette app`](#hassette-app) lists both. ## `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. The process runs in the foreground — keep the terminal open, or use a process manager like systemd or Docker. Press `Ctrl+C` to stop. ```bash hassette run @@ -12,26 +16,26 @@ hassette run ### Flags -| 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 | +| Flag | Description | +| --------------------------- | ------------------------------------------------------------------- | +| `--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 `hassette.toml` (see [Configuration](../core-concepts/configuration/index.md)) and environment variables when not provided on the command line. ---- +`run` exits with code 1 when startup fails: an app fails its precheck (`AppPrecheckFailedError`), a fatal error fires (bad token, unreachable HA — see [Troubleshooting](../troubleshooting.md)), or the web API port is already taken (`Port 8126 is already in use — is another hassette instance running?`). Process managers can treat exit 1 as a startup error rather than a crash. ## `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 -╭──────────────────── SystemStatusResponse ────────────────────╮ +╭──────────────────── System Status ───────────────────────────╮ │ status ok │ -│ websocket_connected True │ +│ websocket_connected true │ │ uptime_seconds 16.57 │ │ entity_count 103 │ │ app_count 3 │ @@ -41,13 +45,13 @@ $ hassette status ╰──────────────────────────────────────────────────────────────╯ ``` -**API endpoint:** `GET /api/health` +`boot_issues` lists apps that failed to initialize. An empty list means all apps started cleanly. When an app appears here, check `hassette log --app ` for the error. ---- +**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. The app key is the `[hassette.apps.]` section name from `hassette.toml` — the identifier every `--app` flag takes. An instance is one running copy of an app class; most apps run a single instance at index 0, but the same class can run multiple times with different configs. ```console $ hassette app @@ -63,17 +67,17 @@ $ hassette app ### Subcommands -| 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` | +| Subcommand | Description | API endpoint | +| ----------------------------- | --------------------------- | --------------------------------------- | +| `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 +91,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 +99,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). @@ -121,103 +123,93 @@ hassette app source my-app ### Flags -| 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 | - ---- +| Flag | Applies to | Description | +| --------------- | -------------------- | -------------------------------------------------------- | +| `--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` is your code, `framework` is Hassette internals. See [Shared Flags](#shared-flags). | +| `--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. A listener is the registered subscription that connects a handler (a function in your app) to an event — see [Bus](../core-concepts/bus/index.md). ```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 | +| Flag | Description | +| ---------------------- | -------------------------------------------------------------- | +| `--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. A job is a function registered with the [Scheduler](../core-concepts/scheduler/index.md) to run at a time or interval. ```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. - -### Viewing execution history +Each row shows the job ID, app key, handler method, trigger type, schedule label, execution counts, average duration, and next scheduled run time. -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 | +| Flag | Description | +| ---------------------- | -------------------------------------------------------- | +| `--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 +232,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 | +| Flag | Description | +| ---------------------- | ---------------------------------------- | +| `--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 — a single run of a handler or job, identified by its UUID. Get the UUID from the `Execution ID` column of `hassette listener ` or `hassette job ` output. ```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 | +| Flag | Description | +| ------------- | ---------------------------------------- | +| `--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 +284,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 | +| Flag | Description | +| ------------- | ----------------------------------- | +| `--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,29 +310,21 @@ $ 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. +Hassette records every handler invocation and job execution to an internal database. This command shows its statistics: record counts, whether any records were dropped under load or shutdown, and error handler failures. ```console $ hassette telemetry @@ -356,53 +337,66 @@ $ 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` | 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 | 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). | +| 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 format { #--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 | -|---|---|---| -| `s` | seconds | `30s` | -| `m` | minutes | `15m` | -| `h` | hours | `1h` | -| `d` | days | `7d` | -| `w` | weeks | `2w` | +| Suffix | Unit | Example | +| ------ | ------- | ------- | +| `s` | seconds | `30s` | +| `m` | minutes | `15m` | +| `h` | hours | `1h` | +| `d` | days | `7d` | +| `w` | weeks | `2w` | -Compound durations (`1h30m`) are not supported. +Compound durations such as `1h30m` are not supported — use the closest single unit instead, like `--since 90m`. Month and year units are not supported; use days. -**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..5a5e18bdb 100644 --- a/docs/pages/cli/configuration.md +++ b/docs/pages/cli/configuration.md @@ -2,17 +2,21 @@ ## 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 is a client that queries a running Hassette server. It constructs the server address from the same configuration sources Hassette uses at runtime — the `__` double underscore in variable names separates nested config sections, so `HASSETTE__WEB_API__HOST` sets `web_api.host`. +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`](../core-concepts/configuration/index.md)** (the server's main config file): loaded from the current directory (or the path given to `--config-file`) +5. **Default**: `http://127.0.0.1:8126` + +Bind-all addresses are rewritten for the client connection: when `web_api.host` resolves to `0.0.0.0` the CLI connects to `127.0.0.1`, and `::` becomes `::1`. The server listens on all interfaces; the CLI talks to it over loopback. !!! 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 +31,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 the long-lived HA token that `hassette run` uses to connect to Home Assistant. Query commands (`status`, `app`, `listener`, and the rest) talk to Hassette's own web API instead and need no token. ## 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 output goes to a terminal. -When piped, Rich automatically strips ANSI codes and disables column truncation so the full values are preserved: +When output is piped to another command or a file, color codes are stripped and full untruncated values are shown: ```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 full data from the server, a superset of what the human table displays. ```console $ hassette status --json @@ -65,81 +68,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 +103,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. Reload your shell config afterward (`source ~/.zshrc` for Zsh, `source ~/.bashrc` for Bash) or restart the terminal. To confirm it works, type `hassette ` and press Tab — available commands appear. 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 | +| Code | Meaning | +| ---- | --------------------------------------------------------------------------- | +| `0` | Success | +| `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 +129,23 @@ 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` — see [Discovery Order](#discovery-order) for which wins. **Request timed out:** ``` -Network error: Request timed out after 10s: http://127.0.0.1:8126/api/health +Network error: Request timed out after 10.0s 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. The 10-second request timeout is fixed; it is not configurable. + +**Port already in use (on `hassette run`):** + +``` +Port 8126 is already in use — is another hassette instance running? +``` + +Another process — usually a second Hassette instance — holds the web API port. Stop it, or change `port` under `[hassette.web_api]`. `run` exits with code 1. **Unknown instance name:** @@ -196,27 +153,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 — `--instance 0` selects the first instance. + +### 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 +183,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..1d683d68c 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 — the CLI talks to Hassette's web API, not Home Assistant. Only `hassette run` itself needs your HA token. -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. `websocket_connected` shows whether Hassette has a live connection to Home Assistant — when `false`, no events fire. `services_running` lists Hassette's internal services. `boot_issues` lists any apps that failed to initialize; check `hassette log --app ` for the error. + ```console $ hassette app ┏━━━━━━━━━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━┓ @@ -34,6 +35,8 @@ $ hassette app └─────────────────┴─────────┴─────────────┴───────────┴──────────┴─────────┴───────────────────┘ ``` +`hassette app` lists every loaded app. The `App Key` column is the identifier other commands take via `--app` — it comes from the `[hassette.apps.]` section name in `hassette.toml`. `Instances` counts running copies of the app; most apps run one. `Invoc/1h` counts how many times the app's handlers ran in the last hour — 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. Rows with blank `App` and `Instance` columns are framework-level logs; app entries fill both. Narrow to a specific app with `--app ` (the App Key from the table above), 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 +Network error: Connection refused: http://127.0.0.1:8126 (All connection attempts failed) ``` -See [Configuration](configuration.md) for how to point the CLI at a different address. +Start Hassette with `hassette run` (covered in [Getting Started](../getting-started/index.md)), 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..a606f5913 100644 --- a/docs/pages/cli/workflows.md +++ b/docs/pages/cli/workflows.md @@ -1,26 +1,66 @@ # 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. + +Three terms appear throughout: a **handler** is a Python function in your app that runs in response to a Home Assistant event (e.g., a motion sensor firing). A **listener** is the registered subscription that connects a handler to an event. An **invocation** is a single execution of that handler — one time it ran. The CLI calls each invocation an *execution* and gives it an execution ID; the two words name the same thing. + +The examples pipe `--json` output to [`jq`](https://jqlang.org), a command-line JSON filter — install it with `apt install jq` or `brew install jq`, or skip those one-liners. + +## 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 +72,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 the value in its `Execution ID` column. -### 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 -# Its scheduled jobs +# Recent invocations across all listeners +hassette app activity motion_lights --since 1h + +# Scheduled jobs (functions registered with the scheduler) 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 — declared in `hassette.toml`, see [App Configuration](../core-concepts/apps/configuration.md)), 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 — the JSON contains every field the server returns; see [Commands](commands.md) for each command's output. The scripts below are bash; adapt the pattern to whatever runs your monitoring. + +**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. diff --git a/docs/pages/core-concepts/api/entities.md b/docs/pages/core-concepts/api/entities.md deleted file mode 100644 index f779cd8bb..000000000 --- a/docs/pages/core-concepts/api/entities.md +++ /dev/null @@ -1,72 +0,0 @@ -# Retrieving Entities & States - -Use the API to retrieve the current state of any entity in Home Assistant. - -## Terminology - -Hassette uses precise terminology: - -- **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. - -```python ---8<-- "pages/core-concepts/api/snippets/api_get_state.py" -``` - -### Raw vs Typed - -Most methods return typed Pydantic models. You can use `get_state_raw` if you want a dict. - -```python ---8<-- "pages/core-concepts/api/snippets/api_get_state_raw.py" -``` - -### Checking Existence - -Use `get_state_or_none` to safely check for an entity. - -```python ---8<-- "pages/core-concepts/api/snippets/api_check_existence.py" -``` - -## Retrieving Multiple States - -Use `get_states` to fetch all states at once. This is more efficient than calling `get_state` in a loop. - -```python ---8<-- "pages/core-concepts/api/snippets/api_get_states.py" -``` - -## Entities - -Entities wrap the state object. Currently `BaseEntity` and `LightEntity` are available. - -```python ---8<-- "pages/core-concepts/api/snippets/api_get_entity.py" -``` - -## API vs StateManager - -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: - -- `self.states.light["kitchen"]` — Domain-specific typed access -- `self.states.get("light.kitchen")` — Direct lookup by entity ID, no `await` needed - -!!! warning "Prefer domain access for better typing" - - 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. - -Use the API when you need guaranteed fresh data from Home Assistant — otherwise, `self.states` is faster and simpler. - -See [States](../states/index.md) for full details. - -## 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 diff --git a/docs/pages/core-concepts/api/index.md b/docs/pages/core-concepts/api/index.md index 07eacf456..7bf0df607 100644 --- a/docs/pages/core-concepts/api/index.md +++ b/docs/pages/core-concepts/api/index.md @@ -1,70 +1,44 @@ -# 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"] - APP["self.api"] - end +## Quick Example - subgraph framework["Api Client"] - REST["REST
get_state()"] - WS["WebSocket
call_service()"] - end - - subgraph ha["Home Assistant"] - HA["HA API"] - end - - APP --> REST & WS - REST & WS --> HA - - style app fill:#e8f0ff,stroke:#6688cc - style framework fill:#fff0e8,stroke:#cc8844 - style ha fill:#f0f0f0,stroke:#999 -``` - -## Usage - -`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 - -The API raises typed exceptions for common failures: - -- [`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 +`get_state()` fetches the entity from Home Assistant over the network. It returns a typed state object with `.value` (the state string) and `.attributes` (domain-specific fields). `call_service()` sends a service call via WebSocket. -Network errors are automatically retried. Catch `HassetteError` to handle all API failures in one place. +## API vs StateManager -## Synchronous Usage +[`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`. -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: +| | `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 | -!!! 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. +`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()`, and [managing HA helpers](managing-helpers.md) (`input_boolean`, `counter`, `timer`, etc.). -```python ---8<-- "pages/core-concepts/api/snippets/api_sync_usage.py" -``` +## Error Handling -## API vs. StateManager +[Api][hassette.api.api.Api] raises typed exceptions for common failures. -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: +- [`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. -- `self.states.light["kitchen"]` — domain-specific typed access, no `await` -- `self.states.get("light.kitchen")` — direct lookup by entity ID, no `await` +Network errors are retried automatically. Catching [`HassetteError`][hassette.exceptions.HassetteError] handles all API failures in one place. -Use `self.api` when you specifically need guaranteed fresh data directly from Home Assistant. +??? note "Synchronous usage (AppSync only)" + `self.api.sync` exposes an [`ApiSyncFacade`][hassette.api.sync.ApiSyncFacade] that mirrors all API methods as blocking calls. It exists for [`AppSync`][hassette.app.app.AppSync] lifecycle hooks, which run outside the async event loop. The [Apps](../apps/index.md) page covers the `AppSync` pattern. ## 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 +- [API Methods](methods.md): all `self.api` methods organized by task: reading state, calling services, history, templates, and more. +- [Managing Helpers](managing-helpers.md): creating and managing input helpers (booleans, counters, timers, and more). 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..774365a56 --- /dev/null +++ b/docs/pages/core-concepts/api/managing-helpers.md @@ -0,0 +1,179 @@ +# Managing Helpers + +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`](../apps/lifecycle.md) (the app startup hook), then holds the +returned record — a Pydantic model with the helper's `id`, `name`, and configuration — for the app's lifetime. Because helpers persist across restarts, the +idempotent approach checks for an existing record before creating: + +```python +--8<-- "pages/core-concepts/api/snippets/managing-helpers/crud_operations.py:bootstrap" +``` + +`list_input_booleans()` fetches all `input_boolean` records from Home Assistant. 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. + +## 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`][hassette.models.states.counter.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`][hassette.exceptions.RetryableConnectionClosedError] is a second exception class callers may receive.** +A WebSocket disconnect mid-CRUD propagates as `RetryableConnectionClosedError`, not +[`FailedMessageError`][hassette.exceptions.FailedMessageError]. Exception handlers that target only `FailedMessageError` miss +this case. A broader `except` clause covering both exception types handles it +correctly. + +## 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/core-concepts/api/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/core-concepts/api/snippets/managing-helpers/crud_operations.py:list" +``` + +`list_*` returns all records for the domain, regardless of which app created them. + +### Update + +```python +--8<-- "pages/core-concepts/api/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/core-concepts/api/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/core-concepts/api/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/core-concepts/api/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/core-concepts/api/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. + +??? 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 | + + The two CRUD methods that accept a params object (`create_*` and `update_*`) 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): when to use `self.api` vs `self.states` +- [API Methods](methods.md): `call_service` for timer actions and other service calls +- [Testing Apps](../../testing/index.md): full harness documentation +- [Apps](../apps/index.md): lifecycle hooks including `on_initialize` diff --git a/docs/pages/core-concepts/api/methods.md b/docs/pages/core-concepts/api/methods.md new file mode 100644 index 000000000..057595ce1 --- /dev/null +++ b/docs/pages/core-concepts/api/methods.md @@ -0,0 +1,460 @@ +# API Methods + +[`Api`][hassette.api.Api] methods organized by task. Every method is `async` and requires `await`. See the [API overview](index.md) for when-to-use guidance and error handling. + +--- + +## Reading State + +### Which method to use + +| Need | Method | +|---|---| +| Just the raw state string | `get_state_value` | +| Typed value without a full state object | `get_state_value_typed` | +| Typed value, attributes, and timestamps | `get_state` | +| Domain action methods (`turn_on`, `turn_off`, `toggle`) | `get_entity` | +| Check whether an entity exists | `get_state_or_none` or `entity_exists` | +| Raw HA payload dict | `get_state_raw` | +| Single attribute by name | `get_attribute` | +| All entities at once | `get_states` | + +### `get_state_value(entity_id)` + +Returns the raw state string for an entity. This is the value 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_value.py" +``` + +This is the cheapest state call when the value string is all the code needs. + +### `get_state(entity_id)` + +Looks up the entity in the state registry and returns the domain-specific +[`BaseState`][hassette.models.states.base.BaseState] subclass: `LightState` for lights, +`SensorState` for sensors, and so on. Each subclass defines its own `.value` type +(`bool`, `float`, `str`, etc.), along with `.attributes`, `.last_changed`, +`.last_updated`, and `.context`. + +The return annotation is `BaseState`, so type checkers see the base type. +To narrow it, assign through a cast or use `get_state_value_typed()`. + +!!! warning "`.value` is typed Python, not the raw HA string" + For toggle domains (`light`, `switch`, `binary_sensor`), `.value` is `True`/`False`, not `"on"`/`"off"` — `state.value == "on"` is always `False`. `get_state_value()` returns the raw HA string when string comparison is the goal. See [States](../states/index.md#what-a-state-object-contains) for the full conversion rules. + +```python +--8<-- "pages/core-concepts/api/snippets/api_get_state.py" +``` + +Raises `EntityNotFoundError` when the entity does not exist. + +### `get_state_or_none(entity_id)` + +Same as `get_state`, but returns `None` instead of raising when the entity is absent. + +```python +--8<-- "pages/core-concepts/api/snippets/api_check_existence.py" +``` + +### `entity_exists(entity_id)` + +Returns `True` if the entity exists, `False` otherwise. + +```python +--8<-- "pages/core-concepts/api/snippets/api_entity_exists.py" +``` + +### `get_state_raw(entity_id)` + +Returns the raw Home Assistant state payload as an untyped dict (`HassStateDict`). Use this when working outside the type registry or inspecting the raw HA payload for debugging. + +```python +--8<-- "pages/core-concepts/api/snippets/api_get_state_raw.py" +``` + +### `get_state_value_typed(entity_id)` + +Equivalent to `(await self.api.get_state(entity_id)).value`. Returns just the +converted value without attributes or timestamps. + +```python +--8<-- "pages/core-concepts/api/snippets/api_get_state_value_typed.py" +``` + +!!! note "Return type is `Any`" + The domain's Python type is only known at runtime, so the return annotation + is `Any`. Cast or assert the type if your type checker needs it narrowed. + +### `get_attribute(entity_id, attribute)` + +Returns a single attribute value. `attribute` supports dot-path notation for nested fields +(`"color_modes.0"`). Returns [`MISSING_VALUE`](../bus/dependency-injection.md#identity-extractors) — a falsy sentinel from `hassette.const` — when the attribute is absent, rather than raising. + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `entity_id` | `str` | — | Entity ID. | +| `attribute` | `str` | — | Attribute name, or dot-separated path for nested access. | + +```python +--8<-- "pages/core-concepts/api/snippets/api_get_attribute.py" +``` + +Compare against `MISSING_VALUE` with `is`, not truthiness — some valid attribute values are falsy (`0`, `False`, `""`). + +### `get_entity(entity_id, model)` + +Returns a [`BaseEntity`][hassette.models.entities.base.BaseEntity] subclass with +domain-specific action methods (`turn_on()`, `turn_off()`, `toggle()`, and +`refresh()`) along with the entity's current state. The `model` argument +specifies which entity class to return. + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `entity_id` | `str` | — | Entity ID. | +| `model` | `type[EntityT]` | — | A `BaseEntity` subclass (e.g., `entities.LightEntity`). | + +Entity classes live in [`hassette.models.entities`][hassette.models.entities] — one per domain, named `{Domain}Entity`: `LightEntity`, `SwitchEntity`, `ClimateEntity`, `MediaPlayerEntity`, `VacuumEntity`, `CoverEntity`, `FanEntity`, `LockEntity`, and so on for 30 domains. The API reference lists them all. + +```python +--8<-- "pages/core-concepts/api/snippets/api_get_entity.py" +``` + +`entity.refresh()` re-fetches the entity's state from Home Assistant and replaces `.state` +with the new snapshot. The updated state is also returned. + +### `get_entity_or_none(entity_id, model)` + +Same as `get_entity`, but returns `None` when the entity is not found. + +```python +--8<-- "pages/core-concepts/api/snippets/api_get_entity_or_none.py" +``` + +### `get_states()` + +Retrieves all entities in a single call and returns them as a list of typed +`BaseState` objects. States that fail to convert are skipped and logged as errors. + +```python +--8<-- "pages/core-concepts/api/snippets/api_get_states.py" +``` + +Neither `get_states` nor `get_states_raw` accepts filtering parameters. Filtering +happens in Python after the call. + +### `get_states_raw()` + +Same as `get_states`, but returns a list of untyped `HassStateDict` dicts instead of +`BaseState` objects. + +```python +--8<-- "pages/core-concepts/api/snippets/api_get_states_raw.py" +``` + +--- + +## Calling Services + +### `call_service(domain, service, ...)` + +The generic service call method. Service data passes as keyword arguments. They become +`service_data` on the wire. + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `domain` | `str` | — | Service domain (e.g., `"light"`). | +| `service` | `str` | — | Service name (e.g., `"turn_on"`). | +| `target` | `dict \| None` | `None` | Target entity IDs, areas, or devices. | +| `return_response` | `bool` | `False` | When `True`, returns the service response payload. | +| `**data` | `Any` | — | Service data fields passed as keyword arguments. | + +```python +--8<-- "pages/core-concepts/api/snippets/api_call_service.py" +``` + +### `turn_on(entity_id, domain, **data)` + +Shorthand for `call_service(domain, "turn_on", ...)`. Extra keyword arguments pass +through as service data, so light-specific fields like `brightness` and `color_name` +work directly. + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `entity_id` | `str \| StrEnum` | — | Entity to target. | +| `domain` | `str` | `"homeassistant"` | Service domain to route the call to. | + +```python +--8<-- "pages/core-concepts/api/snippets/api_helpers.py:turn_on" +``` + +!!! warning "HA 2024.x deprecated `homeassistant.*` generic services" + The default `domain="homeassistant"` routes to `homeassistant.turn_on`, which Home Assistant + deprecated in 2024.x. Pass `domain="light"`, `domain="switch"`, or the appropriate domain to + route to the domain-specific service instead. + +### `turn_off(entity_id, domain)` + +Shorthand for `call_service(domain, "turn_off", ...)`. Does not accept extra data. + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `entity_id` | `str \| StrEnum` | — | Entity to target. | +| `domain` | `str` | `"homeassistant"` | Service domain to route the call to. | + +```python +--8<-- "pages/core-concepts/api/snippets/api_helpers.py:turn_off" +``` + +### `toggle_service(entity_id, domain)` + +Shorthand for `call_service(domain, "toggle", ...)`. Reverses the entity's current +on/off state. Does not accept extra data. + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `entity_id` | `str \| StrEnum` | — | Entity to target. | +| `domain` | `str` | `"homeassistant"` | Service domain to route the call to. | + +```python +--8<-- "pages/core-concepts/api/snippets/api_helpers.py:toggle" +``` + +### Getting a response + +Some services return data. `weather.get_forecasts` returns forecast arrays; `conversation.process` +returns a reply. Set `return_response=True` to include the response payload. Without it, +`call_service` returns `None`. + +```python +--8<-- "pages/core-concepts/api/snippets/api_response.py" +``` + +With `return_response=True`, `call_service` returns a [`ServiceResponse`][hassette.models.services.ServiceResponse] with two fields: `response` (the service's payload as a dict, empty when the service returned nothing) and `context` (the HA event context for the call). + +--- + +## History & Logbook + +### `get_history(entity_id, start_time, end_time=None, ...)` + +Returns recorded state changes for a single entity over a time window. `start_time` is required. +Omitting `end_time` returns changes from `start_time` to the present. + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `entity_id` | `str` | — | Single entity ID. Comma-separated strings raise `ValueError`. | +| `start_time` | `PlainDateTime \| ZonedDateTime \| Date \| str` | — | Start of the time window. Accepts [`whenever`](https://whenever.readthedocs.io/) types or an ISO 8601 string. | +| `end_time` | `PlainDateTime \| ZonedDateTime \| Date \| str \| None` | `None` | End of the time window. `None` means now. | +| `significant_changes_only` | `bool` | `False` | Skips attribute-only updates; returns state-string transitions only. | +| `minimal_response` | `bool` | `False` | Omits attributes from all but the last entry per entity. | +| `no_attributes` | `bool` | `False` | Strips attributes entirely from every entry. | + +```python +--8<-- "pages/core-concepts/api/snippets/api_history.py" +``` + +The three payload flags (`significant_changes_only`, `minimal_response`, `no_attributes`) reduce +response size for long time windows or large attribute sets. + +Both `get_history` and `get_histories` return [`HistoryEntry`][hassette.models.history.HistoryEntry] objects. Each carries `entity_id`, `state` (the raw value at that point), `attributes` (a dict, or `None` when stripped by a payload flag), and `last_changed`/`last_updated` timestamps as `whenever.Instant`. + +### `get_histories(entity_ids, start_time, end_time=None, ...)` + +Fetches multiple entities in a single request. `entity_ids` is `list[str]`. Returns a `dict` +mapping each entity ID to its list of history entries. Accepts the same payload-reduction flags +as `get_history`. + +`entity_ids` must be a list. Passing a comma-separated string to `get_history` raises `ValueError`. + +### `get_logbook(entity_id, start_time, end_time)` + +Returns human-readable log entries that Home Assistant records for an entity. Logbook entries +capture state changes and automation triggers in the format the HA UI displays. + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `entity_id` | `str` | — | Entity ID. | +| `start_time` | `PlainDateTime \| ZonedDateTime \| Date \| str` | — | Start of the time window. | +| `end_time` | `PlainDateTime \| ZonedDateTime \| Date \| str` | — | End of the time window. Required (unlike `get_history`). | + +```python +--8<-- "pages/core-concepts/api/snippets/api_logbook.py" +``` + +Both `start_time` and `end_time` are required. `get_history` makes `end_time` optional; +`get_logbook` does not. + +--- + +## Templates + +### `render_template(template, variables=None)` + +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. + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `template` | `str` | — | Jinja2 template string. | +| `variables` | `dict \| None` | `None` | Values injected into the template context. | + +```python +--8<-- "pages/core-concepts/api/snippets/api_template.py" +``` + +The `variables` parameter keeps the template string reusable across calls with different inputs. +`render_template` is most useful when HA already knows how to compute something complex +(averaging across a device class, evaluating multi-sensor conditionals). Pulling all the +raw data into Python would be wasteful. + +--- + +## Events & Synthetic State + +### `fire_event(event_type, event_data=None)` + +Sends an event to Home Assistant's event bus. Any HA automation, integration, or component +subscribed to that event type receives it. + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `event_type` | `str` | — | Event type string (e.g., `"my_custom_event"`). | +| `event_data` | `dict \| None` | `None` | Payload attached to the event. | + +```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 to Home Assistant and back. For + broadcasting between apps in the same Hassette process, + [`self.bus.emit()`](../bus/handlers.md#cross-app-communication) stays local, fires + faster, and keeps the data typed end-to-end. + +### `set_state(entity_id, state, attributes=None)` + +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. + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `entity_id` | `str \| StrEnum` | — | Entity ID to write. | +| `state` | `Any` | — | New state value. | +| `attributes` | `dict \| None` | `None` | Attributes to overlay on the current attribute set. | + +```python +--8<-- "pages/core-concepts/api/snippets/api_utilities.py:set_state" +``` + +Attributes are merged: the method reads the entity's current attributes and overlays only the +keys passed in `attributes`. Keys not mentioned in the call are preserved. + +!!! 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 can re-create them in `on_initialize`. + +--- + +## Calendars & Camera + +### `get_calendars()` + +Returns all calendar entities registered in Home Assistant as a list of dicts. + +```python +--8<-- "pages/core-concepts/api/snippets/api_utilities.py:get_calendars" +``` + +### `get_calendar_events(calendar_id, start_time, end_time)` + +Returns events from a specific calendar within a time window. Both `start_time` and `end_time` +are required. + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `calendar_id` | `str` | — | Calendar entity ID (e.g., `"calendar.work"`). | +| `start_time` | `PlainDateTime \| ZonedDateTime \| Date \| str` | — | Start of the window. | +| `end_time` | `PlainDateTime \| ZonedDateTime \| Date \| str` | — | End of the window. Required. | + +```python +--8<-- "pages/core-concepts/api/snippets/api_utilities.py:get_calendar_events" +``` + +### `get_camera_image(entity_id, timestamp=None)` + +Returns the camera image as `bytes`. Omitting `timestamp` returns the latest image. A +`timestamp` argument retrieves a historical snapshot. + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `entity_id` | `str` | — | Camera entity ID (e.g., `"camera.front_door"`). | +| `timestamp` | `PlainDateTime \| ZonedDateTime \| Date \| str \| None` | `None` | Snapshot time. `None` returns the latest image. | + +```python +--8<-- "pages/core-concepts/api/snippets/api_get_camera_image.py" +``` + +--- + +## System + +### `get_config()` + +Returns the Home Assistant configuration dict: version, location, unit system, +time zone, and installed components. + +```python +--8<-- "pages/core-concepts/api/snippets/api_system.py:get_config" +``` + +### `get_services()` + +Returns all registered services, keyed by domain then service name. + +```python +--8<-- "pages/core-concepts/api/snippets/api_system.py:get_services" +``` + +### `get_panels()` + +Returns all registered frontend panels, keyed by panel URL path. + +```python +--8<-- "pages/core-concepts/api/snippets/api_system.py:get_panels" +``` + +### `delete_entity(entity_id)` + +Removes an entity from the Home Assistant state machine. Raises `RuntimeError` when deletion fails. + +```python +--8<-- "pages/core-concepts/api/snippets/api_system.py:delete_entity" +``` + +`delete_entity` removes the entity from the HA REST state machine. It does not remove a +device or integration-backed entity from the HA entity registry. The HA UI or the registry +WebSocket API handles that. + +### Low-level access + +For HA endpoints without a typed method — the device registry, area registry, or custom integration APIs — the escape hatches below send raw requests. Prefer the typed methods when one exists; the escape hatches skip Hassette's model conversion entirely. + +| Method | Sends | Returns | +|---|---|---| +| `ws_send_and_wait(**data)` | A WebSocket command (e.g., `type="config/device_registry/list"`) | The command's result | +| `ws_send_json(**data)` | A WebSocket command, without waiting | Nothing | +| `rest_request(method, url, ...)` | A request to any REST path | The raw `aiohttp` response | +| `get_rest_request` / `post_rest_request` / `delete_rest_request` | Method-specific wrappers around `rest_request` | The raw `aiohttp` response | + +--- + +## See Also + +- [API Overview](index.md): when to use `self.api` vs `self.states`, error handling +- [Managing Helpers](managing-helpers.md): create and update HA helpers via the API +- [States](../states/index.md): synchronous state cache for instant lookups without a network call +- [Bus](../bus/index.md): subscribing to state changes and service call events diff --git a/docs/pages/core-concepts/api/services.md b/docs/pages/core-concepts/api/services.md deleted file mode 100644 index 089b0615a..000000000 --- a/docs/pages/core-concepts/api/services.md +++ /dev/null @@ -1,35 +0,0 @@ -# Calling Services - -The API provides methods to invoke Home Assistant services. - -## Basic Service Calls - -Use `call_service` for generic service invocations. - -```python ---8<-- "pages/core-concepts/api/snippets/api_call_service.py" -``` - -## Convenience Helpers - -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. - -```python ---8<-- "pages/core-concepts/api/snippets/api_helpers.py" -``` - -These methods forward arguments to `call_service` while providing a cleaner syntax. - -## Service Responses - -Service calls return a response dictionary (if the service provides one). - -```python ---8<-- "pages/core-concepts/api/snippets/api_response.py" -``` - -## 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 diff --git a/docs/pages/core-concepts/api/snippets/api_entity_exists.py b/docs/pages/core-concepts/api/snippets/api_entity_exists.py new file mode 100644 index 000000000..a76ec64da --- /dev/null +++ b/docs/pages/core-concepts/api/snippets/api_entity_exists.py @@ -0,0 +1,7 @@ +from hassette import App + + +class CheckApp(App): + async def on_initialize(self): + if await self.api.entity_exists("light.kitchen"): + print("Kitchen light is registered") diff --git a/docs/pages/core-concepts/api/snippets/api_get_attribute.py b/docs/pages/core-concepts/api/snippets/api_get_attribute.py new file mode 100644 index 000000000..81ad27fc7 --- /dev/null +++ b/docs/pages/core-concepts/api/snippets/api_get_attribute.py @@ -0,0 +1,12 @@ +from hassette import App, MISSING_VALUE + + +class BrightnessApp(App): + async def on_initialize(self): + brightness = await self.api.get_attribute("light.kitchen", "brightness") + if brightness is not MISSING_VALUE: + self.logger.info("Brightness: %s", brightness) + + # Dot-path for nested attributes + color_mode = await self.api.get_attribute("light.kitchen", "color_mode") + self.logger.info("Color mode: %s", color_mode) diff --git a/docs/pages/core-concepts/api/snippets/api_get_camera_image.py b/docs/pages/core-concepts/api/snippets/api_get_camera_image.py new file mode 100644 index 000000000..134320c42 --- /dev/null +++ b/docs/pages/core-concepts/api/snippets/api_get_camera_image.py @@ -0,0 +1,16 @@ +from hassette import App + + +class CameraApp(App): + async def on_initialize(self): + # Latest image + image_bytes = await self.api.get_camera_image("camera.front_door") + self.logger.info("Image size: %d bytes", len(image_bytes)) + + # Image at a specific time + snapshot_time = self.now().subtract(minutes=5) + past_image = await self.api.get_camera_image( + "camera.front_door", + timestamp=snapshot_time, + ) + self.logger.info("Past image size: %d bytes", len(past_image)) diff --git a/docs/pages/core-concepts/api/snippets/api_get_entity_or_none.py b/docs/pages/core-concepts/api/snippets/api_get_entity_or_none.py new file mode 100644 index 000000000..fc5e39cf6 --- /dev/null +++ b/docs/pages/core-concepts/api/snippets/api_get_entity_or_none.py @@ -0,0 +1,11 @@ +from hassette import App +from hassette.models import entities + + +class EntityApp(App): + async def on_initialize(self): + light = await self.api.get_entity_or_none( + "light.kitchen", entities.LightEntity + ) + if light: + await light.turn_off() 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_get_state_value_typed.py b/docs/pages/core-concepts/api/snippets/api_get_state_value_typed.py new file mode 100644 index 000000000..6ff7d5e70 --- /dev/null +++ b/docs/pages/core-concepts/api/snippets/api_get_state_value_typed.py @@ -0,0 +1,17 @@ +from hassette import App + + +class MotionApp(App): + async def on_initialize(self): + # binary_sensor returns bool at runtime + motion = await self.api.get_state_value_typed("binary_sensor.front_door") + self.logger.info("Motion detected: %s", motion) + + # light returns bool at runtime + light_on = await self.api.get_state_value_typed("light.kitchen") + self.logger.info("Light on: %s", light_on) + + # sensor returns str — convert manually if numeric + raw = await self.api.get_state_value_typed("sensor.outdoor_temperature") + temp = float(raw) + self.logger.info("Temperature: %.1f", temp) diff --git a/docs/pages/core-concepts/api/snippets/api_get_states_raw.py b/docs/pages/core-concepts/api/snippets/api_get_states_raw.py new file mode 100644 index 000000000..29ccb01f9 --- /dev/null +++ b/docs/pages/core-concepts/api/snippets/api_get_states_raw.py @@ -0,0 +1,8 @@ +from hassette import App + + +class StateApp(App): + async def on_initialize(self): + raw_states = await self.api.get_states_raw() + for s in raw_states: + self.logger.info("%s: %s", s["entity_id"], s["state"]) diff --git a/docs/pages/core-concepts/api/snippets/api_helpers.py b/docs/pages/core-concepts/api/snippets/api_helpers.py index e57a94308..1eefc656e 100644 --- a/docs/pages/core-concepts/api/snippets/api_helpers.py +++ b/docs/pages/core-concepts/api/snippets/api_helpers.py @@ -3,11 +3,16 @@ class HelperApp(App): async def on_initialize(self): - # Turn on with attributes - await self.api.turn_on("light.kitchen", brightness=255, color_name="blue") + # --8<-- [start:turn_on] + await self.api.turn_on( + "light.kitchen", brightness=255, color_name="blue" + ) + # --8<-- [end:turn_on] - # Turn off + # --8<-- [start:turn_off] await self.api.turn_off("switch.fan") + # --8<-- [end:turn_off] - # Toggle (reverses current on/off state) + # --8<-- [start:toggle] await self.api.toggle_service("light.bedroom") + # --8<-- [end:toggle] 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_sync_usage.py b/docs/pages/core-concepts/api/snippets/api_sync_usage.py deleted file mode 100644 index ca502a4a1..000000000 --- a/docs/pages/core-concepts/api/snippets/api_sync_usage.py +++ /dev/null @@ -1,7 +0,0 @@ -from hassette import AppSync - - -class SyncApp(AppSync): - def on_initialize_sync(self): - # Use .sync to access blocking versions of all async methods - self.api.sync.turn_on("light.office") diff --git a/docs/pages/core-concepts/api/snippets/api_system.py b/docs/pages/core-concepts/api/snippets/api_system.py new file mode 100644 index 000000000..5c6cd5571 --- /dev/null +++ b/docs/pages/core-concepts/api/snippets/api_system.py @@ -0,0 +1,25 @@ +from hassette import App + + +class SystemApp(App): + async def on_initialize(self): + # --8<-- [start:get_config] + config = await self.api.get_config() + self.logger.info("HA version: %s", config.get("version")) + self.logger.info("Location: %s", config.get("location_name")) + # --8<-- [end:get_config] + + # --8<-- [start:get_services] + services = await self.api.get_services() + light_services = list(services.get("light", {}).keys()) + self.logger.info("Light services: %s", light_services) + # --8<-- [end:get_services] + + # --8<-- [start:get_panels] + panels = await self.api.get_panels() + self.logger.info("Available panels: %s", list(panels.keys())) + # --8<-- [end:get_panels] + + # --8<-- [start:delete_entity] + await self.api.delete_entity("sensor.stale_custom_sensor") + # --8<-- [end:delete_entity] 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/advanced/snippets/managing-helpers/counter_shortcuts.py b/docs/pages/core-concepts/api/snippets/managing-helpers/counter_shortcuts.py similarity index 100% rename from docs/pages/advanced/snippets/managing-helpers/counter_shortcuts.py rename to docs/pages/core-concepts/api/snippets/managing-helpers/counter_shortcuts.py diff --git a/docs/pages/advanced/snippets/managing-helpers/create_helper.py b/docs/pages/core-concepts/api/snippets/managing-helpers/create_helper.py similarity index 100% rename from docs/pages/advanced/snippets/managing-helpers/create_helper.py rename to docs/pages/core-concepts/api/snippets/managing-helpers/create_helper.py diff --git a/docs/pages/advanced/snippets/managing-helpers/crud_operations.py b/docs/pages/core-concepts/api/snippets/managing-helpers/crud_operations.py similarity index 100% rename from docs/pages/advanced/snippets/managing-helpers/crud_operations.py rename to docs/pages/core-concepts/api/snippets/managing-helpers/crud_operations.py diff --git a/docs/pages/advanced/snippets/managing-helpers/testing_harness.py b/docs/pages/core-concepts/api/snippets/managing-helpers/testing_harness.py similarity index 90% rename from docs/pages/advanced/snippets/managing-helpers/testing_harness.py rename to docs/pages/core-concepts/api/snippets/managing-helpers/testing_harness.py index cb804b9a4..fca7650b6 100644 --- a/docs/pages/advanced/snippets/managing-helpers/testing_harness.py +++ b/docs/pages/core-concepts/api/snippets/managing-helpers/testing_harness.py @@ -1,7 +1,7 @@ from hassette.models.helpers import InputBooleanRecord from hassette.test_utils import AppTestHarness -from myapp import VacationModeApp # pyright: ignore[reportMissingImports] +from myapp import VacationModeApp async def test_vacation_mode_creates_helper_on_first_run(): diff --git a/docs/pages/advanced/snippets/managing-helpers/timer_call_service.py b/docs/pages/core-concepts/api/snippets/managing-helpers/timer_call_service.py similarity index 100% rename from docs/pages/advanced/snippets/managing-helpers/timer_call_service.py rename to docs/pages/core-concepts/api/snippets/managing-helpers/timer_call_service.py diff --git a/docs/pages/core-concepts/api/utilities.md b/docs/pages/core-concepts/api/utilities.md deleted file mode 100644 index ec5121f45..000000000 --- a/docs/pages/core-concepts/api/utilities.md +++ /dev/null @@ -1,81 +0,0 @@ -# Utilities & History - -Beyond basic states and services, the API exposes advanced Home Assistant features. - -## 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. - -```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. - -## 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. - -```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). - -## 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. - -```python ---8<-- "pages/core-concepts/api/snippets/api_logbook.py" -``` - -Unlike `get_history`, both `start_time` and `end_time` are required. - -## Other Endpoints - -### `fire_event` - -`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. - -```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. - -### `set_state` - -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. - -```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. - -!!! 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`. - -### `get_calendars` - -List all calendar entities registered in Home Assistant. - -```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 diff --git a/docs/pages/core-concepts/apps/configuration.md b/docs/pages/core-concepts/apps/configuration.md index e3e9ba4c4..1f4e30086 100644 --- a/docs/pages/core-concepts/apps/configuration.md +++ b/docs/pages/core-concepts/apps/configuration.md @@ -1,34 +1,70 @@ -# App Configuration +# Application Configuration -This page covers the **Python side** of app configuration: defining `AppConfig` subclasses, typed fields, defaults, and environment variable injection. For how to register apps and supply config values in `hassette.toml`, see [Application Configuration](../configuration/applications.md). +Apps are registered in `hassette.toml` (at the project root — the same directory you run `hassette run` from) under `[hassette.apps.]`. Each block tells Hassette which Python file and class to load, and passes configuration values to the app. -## Defining Config Models +This page covers the TOML side of app configuration. [Apps](../apps/index.md) covers defining typed [`AppConfig`][hassette.app.app_config.AppConfig] models in Python — the class that declares and validates the fields your app accepts. -Inherit from [`AppConfig`][hassette.app.app_config.AppConfig] to define your configuration schema. `AppConfig` extends Pydantic's [`BaseSettings`](https://pydantic.dev/docs/validation/latest/concepts/pydantic_settings/) (from the `pydantic-settings` package), which adds environment variable injection on top of standard Pydantic validation. If you've used Pydantic's `BaseModel`, the syntax is the same — `BaseSettings` just adds env var support. +## Registering an App -```python ---8<-- "pages/core-concepts/apps/snippets/app_config_definition.py" +An app block requires two fields: `filename` and `class_name`. `filename` is the path to the Python file, relative to [`apps.directory`](../configuration/index.md) (the root directory for app source files, configured in `hassette.toml`). `class_name` is the name of the [App][hassette.app.app.App] subclass to load. + +```toml +--8<-- "pages/core-concepts/configuration/snippets/single_instance.toml" ``` -The `App` generic parameter (`App[MyAppConfig]`) tells Hassette which config class to instantiate. Inside your app, `self.app_config` is typed as `MyAppConfig`, giving you full IDE completion and type checking. +`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. + +!!! 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. -## Base Fields +## Passing Configuration -The base `AppConfig` includes standard fields available to all apps: +The `config` field supplies values to the app's `AppConfig` model. Two TOML forms are equivalent for single-instance apps. -- `instance_name: str = ""` - Used for logging and identification. -- `log_level: LOG_LEVEL_TYPE` - Log-level override for this app instance; defaults to `INFO` or the global `log_level` setting. +Inline form: + +```toml +[hassette.apps.presence] +filename = "presence.py" +class_name = "PresenceApp" +config = { motion_sensor = "binary_sensor.hall", lights = ["light.entry"] } +``` -## Secrets & Environment Variables +Table form: -`AppConfig` inherits from Pydantic's `BaseSettings`, so it supports environment variable injection out of the box. Define a custom `env_prefix` on your config class to control which environment variables it reads: +```toml +[hassette.apps.presence] +filename = "presence.py" +class_name = "PresenceApp" -```python ---8<-- "pages/core-concepts/apps/snippets/app_config_env_prefix.py" +[hassette.apps.presence.config] +motion_sensor = "binary_sensor.hall" +lights = ["light.entry"] ``` -Now you can set `MYAPP_API_KEY` in your environment or `.env` file. TOML values and environment variables are merged; environment variables take precedence. +!!! note "Two TOML paths, two purposes" + App registration 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 warning in the startup logs. + +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 + +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" +``` + +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`). + +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. + +## Typed Configuration + +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 Pydantic `ValidationError` before any app starts, showing the field name and expected type. [Apps](../apps/index.md) covers defining the model. -## See Also +## Next Steps -- [Application Configuration](../configuration/applications.md) - Registering apps and supplying config values in `hassette.toml` +- [Apps overview](index.md): defining `AppConfig` models, accessing config values, and app structure +- [Global Configuration](../configuration/index.md): `hassette.toml` settings outside the `[apps]` section +- [Lifecycle](lifecycle.md): what happens after Hassette loads and validates the app config diff --git a/docs/pages/core-concepts/apps/index.md b/docs/pages/core-concepts/apps/index.md index 824de1919..ba2ccff3f 100644 --- a/docs/pages/core-concepts/apps/index.md +++ b/docs/pages/core-concepts/apps/index.md @@ -1,46 +1,61 @@ # 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. +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 typed accessors — `self.bus`, `self.scheduler`, `self.api`, `self.states`, `self.cache`, and `self.task_bucket` — for interacting with HA. -Apps can be **asynchronous** (preferred) or **synchronous**. Sync apps are automatically run in threads to prevent blocking the event loop. +## Defining an App + +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 +--8<-- "pages/core-concepts/apps/snippets/example_app.py" +``` -## Structure +!!! info "What's `D.StateNew[states.LightState]`?" + That annotation is [dependency injection](../bus/dependency-injection.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. -```mermaid -flowchart TD - subgraph app["Your App"] - A["App"] - end +Two more things to notice in the example. Every method is `async def`, and the registration call is awaited — that pattern holds for all bus, scheduler, and API calls, and a missing `await` silently does nothing (see [Call Services](#call-services) below) — [Async Basics](../../migration/async-basics.md) explains why. The `name=` parameter is required on every subscription; it labels the listener in logs and the [web UI](../../web-ui/index.md). - subgraph resources["Resources"] - direction LR - Api - Bus - Scheduler - States - Cache - end +## Configuration - A --> Api & Bus & Scheduler & States & Cache +[`AppConfig`][hassette.app.app_config.AppConfig] loads and validates an app's settings from `hassette.toml` and environment variables. A subclass declares typed fields; Hassette populates them at startup. - style app fill:#e8f0ff,stroke:#6688cc - style resources fill:#fff0e8,stroke:#cc8844 +```python +--8<-- "pages/core-concepts/apps/snippets/app_config_definition.py" ``` -## Defining an App +`self.app_config` on the app instance is typed as the declared subclass, so the IDE and Pyright know the exact shape. -Every app is a Python class that inherits from [`App`][hassette.app.app.App] or [`AppSync`][hassette.app.app.AppSync]. +### Environment Variables -```python title="example_app.py" ---8<-- "pages/core-concepts/apps/snippets/example_app.py" +`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/app_config_env_prefix.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. +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 three 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. +- `app_key`: the app's key from `hassette.toml`, set by the framework. Don't set it directly; framework-reserved values are rejected with a validation error. Read it at runtime via `self.app_key` — useful for logging or cross-app messaging. + +`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 [App Configuration](configuration.md) for the full reference. + +```toml +--8<-- "pages/core-concepts/apps/snippets/app_config.toml" +``` ## Dates and Times -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. +`self.now()` returns the current time as a `ZonedDateTime` from the [`whenever`](https://whenever.readthedocs.io/en/latest/) library, which ships with Hassette — no separate install needed. All scheduler parameters, persistent storage examples, and custom state definitions use `whenever` types. ```python --8<-- "pages/core-concepts/apps/snippets/apps_whenever_dates.py:imports" @@ -50,137 +65,109 @@ Hassette uses the [`whenever`](https://whenever.readthedocs.io/en/latest/) libra --8<-- "pages/core-concepts/apps/snippets/apps_whenever_dates.py:usage" ``` -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. - -## Core Capabilities - -Each app receives pre-configured helpers: - -- **[`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 type error that Pyright catches before the code runs. 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. -Use [`self.scheduler`](../scheduler/index.md) to schedule recurring tasks. +### Schedule Jobs + +[`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. -### Persist Data Between Restarts +See the [API](../api/index.md) page for state access, entity management, and more. -Use [`self.cache`](../cache/index.md) to store data that should survive app restarts. +### Persist Data + +[`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. + +### Run Background Work -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. +[`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. -## Broadcasting Events Between Apps +In production mode, the decorator is ignored by default. `allow_only_app_in_prod = true` in `hassette.toml` overrides this behavior. -`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. +## Broadcasting Between Apps -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.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. + +`self.bus.on(topic=...)` subscribes to a named topic. [`D.EventData[T]`](../bus/dependency-injection.md) follows the same dependency injection pattern as `D.StateNew` — replace `T` with the payload class, so `D.EventData[LightsSyncedData]` delivers the payload as a `LightsSyncedData` object. ```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 written without `async`/`await`. Hassette executes the app's lifecycle hooks in a thread pool so blocking code does not stall the event loop. The bus, scheduler, and API expose synchronous facades via `.sync` (`self.bus.sync`, `self.scheduler.sync`, `self.api.sync`), so registrations and calls work without `await`. - Prefer async `App` whenever possible. Use `AppSync` only when a third-party library provides no async interface and wrapping it yourself is impractical. +`AppSync` fits apps built on blocking libraries and migrations from synchronous frameworks. Prefer async `App` for new code. 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..458f8c664 100644 --- a/docs/pages/core-concepts/apps/lifecycle.md +++ b/docs/pages/core-concepts/apps/lifecycle.md @@ -1,80 +1,72 @@ -# 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 the internal SQLite [telemetry database](../database-telemetry.md)) 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 where the app subscribes to HA events and schedules recurring tasks 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" ``` +`on_initialize` is where most apps do their setup. `self.bus.on_state_change` registers a handler that fires on entity state changes. `self.scheduler.run_in` schedules a one-shot job after a fixed delay. Both calls are `async` and must be awaited. By the time `on_initialize` runs, the bus, scheduler, API, and database are all ready. + +`before_initialize` and `after_initialize` exist for setup that must happen strictly before or after the main registration. Most apps only need `on_initialize`. + !!! 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 +`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`](task-bucket.md) tasks are cleaned up automatically. -After the shutdown hooks run, Hassette automatically performs cleanup: - -- 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`, `shutdown`, and `cleanup` are marked `@final` — 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`][hassette.app.app.AppSync] is for apps that wrap blocking (non-async) third-party libraries. It provides `_sync`-suffixed variants of each hook. Hassette runs each variant in a thread pool via `task_bucket.run_in_thread`, so blocking calls do not stall the event loop. 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/app_config.toml b/docs/pages/core-concepts/apps/snippets/app_config.toml index f91a971e5..669033d01 100644 --- a/docs/pages/core-concepts/apps/snippets/app_config.toml +++ b/docs/pages/core-concepts/apps/snippets/app_config.toml @@ -1,9 +1,9 @@ -[apps.my_monitor] +[hassette.apps.my_monitor] filename = "my_monitor.py" class_name = "MyApp" enabled = true # Configuration matches your AppConfig model -[[apps.my_monitor.config]] +[[hassette.apps.my_monitor.config]] location_name = "Kitchen" threshold = 30.0 diff --git a/docs/pages/core-concepts/apps/snippets/apps_bus_emit.py b/docs/pages/core-concepts/apps/snippets/apps_bus_emit.py index aa70415d1..27ca59016 100644 --- a/docs/pages/core-concepts/apps/snippets/apps_bus_emit.py +++ b/docs/pages/core-concepts/apps/snippets/apps_bus_emit.py @@ -4,12 +4,12 @@ from hassette.models import states +# --8<-- [start:sender] @dataclass(frozen=True, slots=True) class LightsSyncedData: source: str -# --8<-- [start:sender] class LightManagerApp(App[AppConfig]): async def on_initialize(self) -> None: await self.bus.on_state_change( diff --git a/docs/pages/core-concepts/apps/snippets/apps_cache_counter.py b/docs/pages/core-concepts/apps/snippets/apps_cache_counter.py index fecaa294c..e8720dcb9 100644 --- a/docs/pages/core-concepts/apps/snippets/apps_cache_counter.py +++ b/docs/pages/core-concepts/apps/snippets/apps_cache_counter.py @@ -7,7 +7,7 @@ class CacheApp(App[AppConfig]): async def on_initialize(self): # --8<-- [start:cache_counter] # Load counter from cache, defaulting to 0 - self.counter = self.cache.get("counter", 0) # pyright: ignore[reportAttributeAccessIssue] + self.counter = self.cache.get("counter", 0) # Increment and save back self.counter += 1 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/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/apps/snippets/apps_task_bucket.py b/docs/pages/core-concepts/apps/snippets/apps_task_bucket.py index bed9c403c..d99b1ba77 100644 --- a/docs/pages/core-concepts/apps/snippets/apps_task_bucket.py +++ b/docs/pages/core-concepts/apps/snippets/apps_task_bucket.py @@ -16,9 +16,12 @@ async def on_initialize(self): self.logger.info("Got: %s", data) # --8<-- [end:run_in_thread] + # --8<-- [start:poll] async def poll_sensor(self): while True: await asyncio.sleep(60) + # ... read the sensor, update state, etc. + # --8<-- [end:poll] def expensive_sync_call(self) -> str: return "result" diff --git a/docs/pages/core-concepts/apps/snippets/lifecycle_hooks.py b/docs/pages/core-concepts/apps/snippets/lifecycle_hooks.py index 2fc309269..96568c1da 100644 --- a/docs/pages/core-concepts/apps/snippets/lifecycle_hooks.py +++ b/docs/pages/core-concepts/apps/snippets/lifecycle_hooks.py @@ -1,10 +1,14 @@ -from hassette import App +from hassette import App, D +from hassette.models import states 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, new_state: D.StateNew[states.SensorState]) -> 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..1f51b417f 100644 --- a/docs/pages/core-concepts/apps/task-bucket.md +++ b/docs/pages/core-concepts/apps/task-bucket.md @@ -1,70 +1,93 @@ # 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` is available on every [`App`](../apps/index.md) instance. It runs background work and offloads blocking calls to threads. Handlers run on Hassette's event loop ([Async Basics](../../migration/async-basics.md) covers the event loop model), so anything slow that cannot be awaited — an HTTP library without async support, file I/O, heavy computation — goes through the task bucket instead of blocking every other handler. The bucket tracks all spawned tasks and cancels them on shutdown; no manual cleanup is required. ## 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: +`self.task_bucket.spawn(coro, *, name=None)` creates a tracked background task from a coroutine — an `async def` method *called with parentheses* but not awaited. `self.poll_sensor()` creates the coroutine; `spawn` schedules it to run. The task bucket owns the task's lifecycle. The returned `asyncio.Task` can be stored for inspection or cancellation; most apps ignore it. ```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 spawned method is an ordinary `async def` loop: + +```python +--8<-- "pages/core-concepts/apps/snippets/apps_task_bucket.py:poll" +``` + +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: +A blocking call made directly in a handler — `requests.get(...)`, a database driver, heavy file I/O — freezes every other handler until it finishes. `run_in_thread(fn, *args, **kwargs)` moves the call to a thread pool so the event loop keeps running. Awaiting it pauses only the calling handler: `await` waits for the thread to finish and returns 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 wrap third-party integrations often receive callables of unknown type — a config-provided callback or a library hook that may or may not be async. The adapter normalizes them 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. Apps that only use `spawn()` and `run_in_thread()` never need these. -```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(fn)` submits a coroutine to the event loop and blocks the calling thread until it completes. It accepts a coroutine object, not a callable — `run_sync(self.api.get_state("sensor.x"))` works because the call expression creates the coroutine that `run_sync` then executes. + + ```python + --8<-- "pages/core-concepts/apps/snippets/apps_task_bucket_advanced.py:run_sync" + ``` + + !!! warning + `run_sync()` is safe only inside `run_in_thread()` callbacks and [`AppSync`][hassette.app.app.AppSync] lifecycle methods. Calling it from a regular `async` handler (the event loop thread) raises `RuntimeError` — `await` the async method directly there instead. + + ### 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. + + ### Creating Tasks from Any Context + + `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. + +## Shutdown -!!! 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. +The bucket cancels all tracked tasks when the app shuts down. Hassette cancels every pending task, waits up to `task_cancellation_timeout_seconds` (default: 5s, configurable in [global settings](../configuration/index.md)) for them to finish, and logs warnings for any tasks that do not exit within the timeout. -## Shutdown Behavior +Manual cleanup is not required. -All tasks tracked by the bucket are cancelled when the app shuts down. Hassette: +## Inspecting and Cancelling Tasks -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 +Apps rarely need these directly — shutdown calls them automatically. `pending_tasks()` returns the set of tasks the bucket currently tracks. `cancel_all()` cancels every tracked task and awaits their completion; `cancel_all_sync()` is the fire-and-forget variant for sync contexts. Custom teardown sequences and the [test harness](../../testing/harness.md) drain helpers use them. -You don't need to clean up spawned tasks manually — the bucket handles it. +??? note "Advanced: collecting task exceptions in test infrastructure" + `install_exception_recorder(fn)` registers a callback that receives every exception raised by a bucket task; `uninstall_exception_recorder()` removes it. The [test harness](../../testing/harness.md) uses this to surface handler failures as `DrainError`. Custom harnesses can do the same. ## 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) 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..cfd3cbbd0 --- /dev/null +++ b/docs/pages/core-concepts/bus/custom-extractors.md @@ -0,0 +1,52 @@ +# Custom Extractors + +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` (the dict of parameters passed to a service call). `A.get_path("payload.data.new_state.attributes.geolocation.locality")` traverses a dotted path. It returns [`MISSING_VALUE`](dependency-injection.md#identity-extractors) — a falsy sentinel — 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.md). + +## Writing an Extractor + +A custom extractor is a plain callable that receives the raw event and returns a value. [`AnnotationDetails`][hassette.event_handling.dependencies.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. Hassette discovers `AnnotationDetails` in `Annotated` metadata automatically at registration time — no explicit registration step needed. + +```python +--8<-- "pages/core-concepts/bus/snippets/dependency-injection/custom_extractor_own.py" +``` + +`get_friendly_name` receives the raw [`RawStateChangeEvent`][hassette.events.hass.hass.RawStateChangeEvent] (which has `event.payload.data.new_state`, `event.payload.data.old_state`, and `event.payload.data.entity_id`) 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 the simplest form — Hassette wraps it in `AnnotationDetails` automatically. The explicit `AnnotationDetails` form above is needed only when adding a type converter. + +## 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. + +Hassette converts standard scalar types (`int`, `float`, `bool`, `str`) automatically — no converter needed for those. `AnnotationDetails.converter` handles conversions specific to a single extractor, covering types the built-in registry does not handle. See [State Conversion](../states/conversion.md) for the full type registry. + +## See Also + +- [Dependency Injection](dependency-injection.md): built-in `D.*` annotations +- [Filtering](filtering.md): composing accessors with predicates +- [State Conversion](../states/conversion.md): domain-to-model mapping, built-in type converters, and custom converters diff --git a/docs/pages/core-concepts/bus/dependency-injection.md b/docs/pages/core-concepts/bus/dependency-injection.md index 54239578c..5a8503992 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 to handler parameters. Like FastAPI's `Depends()`, Hassette resolves handler parameters at call time — but instead of a dependency function, type annotations from the `D` module declare what to extract. All annotations live in `hassette.event_handling.dependencies`, imported as `D`: `from hassette import D`. ```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 - -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" -``` - -**Use when:** You want type safety but need access to the full event structure (context, timestamps, metadata, etc.). - - -### Raw Event (Untyped) - -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 +`D.StateNew[states.LightState]` extracts the new state and converts it to a typed [`LightState`][hassette.models.states.light.LightState]. `D.EntityId` extracts the entity ID as a string. The handler receives clean data with no event parsing. -All dependency injection annotations are available in the `hassette.dependencies` module (commonly imported as `D`). +## Annotation Reference -### State Object Extractors +### State Extractors -Extract typed state objects from state change events: +State extractors resolve typed state objects from state change events. `T` is any state class from `hassette.models.states` — a full list is at [State Conversion](../states/conversion.md). -| 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 skipped | +| `D.StateOld[T]` | `T` | Handler skipped | +| `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 extractor finds no value, Hassette skips the handler invocation and logs the failure at ERROR level — no exception propagates to your app. `MaybeStateOld` returns `None` on the first event for a new entity with no previous state (typically on startup or when an entity first appears). ### Identity Extractors -Extract entity IDs and domains from events: +Identity extractors resolve 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 skipped | +| `D.MaybeEntityId` | `str \| MISSING_VALUE` | Falsy sentinel | +| `D.Domain` | `str` | Handler skipped | +| `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`][hassette.const.MISSING_VALUE] is a falsy sentinel from `hassette.const` indicating a field does not exist on the event. It is not `None` — `None` means the field exists with a null value. Testing with `if entity_id:` covers both the present and absent cases. `D.MaybeEntityId` is useful in generic handlers registered via `on()` where the event may not have an `entity_id` field. ### 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 | If missing | Use case | +|---|---|---|---| +| `D.EventData[T]` | `T` | Handler skipped | Typed payload from `Bus.emit` broadcast events | +| `D.EventContext` | `HassContext` | Handler skipped | 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]` 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: +`D.EventData[T]` pairs with [`Bus.emit`](../apps/index.md) for cross-app communication — one app sends a typed payload, and other apps subscribe to receive it. 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" ``` -## 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 from the same event. ```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 +## Union Types -Any callable that accepts an event and returns a value can be used as an extractor: +State extractors accept union types for handlers that cover multiple entity domains. ```python ---8<-- "pages/core-concepts/bus/snippets/dependency-injection/custom_extractor_own.py" -``` - -### Advanced: Extractor + Converter Pattern - -For more complex scenarios, you can use the `AnnotationDetails` class to combine extraction and type conversion: - -```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: +Hassette determines the concrete state class from the entity's domain at dispatch time — see [State Conversion](../states/conversion.md) for details. - ```python - --8<-- "pages/core-concepts/bus/snippets/dependency-injection/bypass_conversion_any.py" - ``` +## Custom Keyword Arguments -2. **Provide a custom converter** in `AnnotationDetails`: - - ```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 `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/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: +Any handler with at least one `D.*` annotation is a DI handler. 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. -- [`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 +Not all `D.*` annotations work with every subscription method. [Subscription Methods](methods.md) lists the compatible annotations for each method. ## 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 extractors, accessors, [`AnnotationDetails`][hassette.event_handling.dependencies.AnnotationDetails], and automatic type conversion. +- [Writing Handlers](handlers.md). Handler signature patterns. +- [Subscription Methods](methods.md). Which `D.*` annotations each method supports. +- [State Conversion](../states/conversion.md). Domain-to-model mapping and automatic type conversion. diff --git a/docs/pages/core-concepts/bus/filtering.md b/docs/pages/core-concepts/bus/filtering.md index d99532b74..a85a0184e 100644 --- a/docs/pages/core-concepts/bus/filtering.md +++ b/docs/pages/core-concepts/bus/filtering.md @@ -1,255 +1,189 @@ -# 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` +```python +from hassette import P, C, 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. +`P` contains predicates for filtering events. `C` contains conditions for matching values. `A` contains accessors for extracting specific fields from events — covered at the [end of this page](#custom-accessors). -## Common State Filtering +## Filtering State Changes -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. +Filters and predicates compare against **raw Home Assistant state strings**, not typed state models. HA reports state values as strings: `"on"`, `"off"`, `"unavailable"`, `"72.5"`. Filtering runs before type conversion for performance. Hassette avoids deserializing every event into a Pydantic model when most events will be filtered out. This means `changed_to="on"` is correct, not `changed_to=True`, even though `LightState.value` is a `bool` after dependency injection delivers it to the handler. -### 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 -## The `changed` Parameter +Conditions are value-level matchers. They work as arguments to `changed_to`, `changed_from`, and `changed`, or as the `condition` argument inside predicates. -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`: +### Set membership: `C.IsIn` + +`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` - -If `changed_to/from` aren't enough, or if you are filtering other event types (like service calls), use the `where` parameter. +The handler runs only when `app_name` becomes `"Home Assistant Lovelace"` or `"Netflix"`. -`where` accepts a list of predicates (logical AND) or a customized predicate structure. +### Numeric comparison: `C.Comparison` -### Combining Predicates +`C.Comparison` tests a value against a threshold using an operator string. -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"`, `"ge"`, `"le"`, `"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 exist as predicate objects so they can be nested inside `P.AnyOf`, `P.AllOf`, or `P.Not` — something the bare `changed_from`/`changed_to` parameters cannot do. ```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: +`P.AttrTo` tests that a specific attribute's new value matches a condition — the attribute equivalent of `P.StateTo`. 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. + +### Logical OR: `P.AnyOf` -Use `P.ServiceDataWhere` for structured access to service data fields: +`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 +`P.AllOf` is an explicit AND combinator — it does the same thing as passing a list to `where=`, but can be nested inside `P.AnyOf` or `P.Not` where a plain list is not accepted. `P.Not` negates any predicate: `P.Not(P.StateTo("on"))` fires when the new state is anything except `"on"`. All three compose freely. -For scenarios not covered by helper methods, you can subscribe loosely to any event topic using `on`. This method always uses `where` for filtering. - -```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. Two forms: the dict form (`P.ServiceDataWhere({"entity_id": "scene.evening"})`) does literal key matching; `P.ServiceDataWhere.from_kwargs` accepts callables and conditions per field. ```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 bare service name (e.g., `"turn_on"`); the domain is a separate field, matched by `P.DomainMatches`. `on_call_service` has `domain=` and `service=` built in. When subscribing via `on(topic="hass.event.call_service")` instead — for raw event-level access — those parameters are not available, so `P.DomainMatches` and `P.ServiceMatches` fill that role. -| 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`](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. -#### 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). Handler signature patterns and dependency injection. +- [Subscription Methods](methods.md). Method reference, parameters, and registration. +- [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..b2ba001d1 100644 --- a/docs/pages/core-concepts/bus/handlers.md +++ b/docs/pages/core-concepts/bus/handlers.md @@ -1,192 +1,98 @@ # 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 on an [`App`](../apps/index.md) that runs when an event matches a subscription. +Hassette supports four handler patterns: no parameters, extracted data via +dependency injection, raw events, and typed events. [Choosing a Pattern](#choosing-a-pattern) summarizes when to use each. -## 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. - -!!! 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. - -### Basic Patterns - -**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 no-parameter handler fires as a side effect. Hassette passes no event data. +This pattern works with all [subscription methods](methods.md). ```python ---8<-- "pages/core-concepts/bus/snippets/handlers_raw_event.py" +--8<-- "pages/core-concepts/bus/snippets/handlers_no_data.py" ``` -**Option 2: Receive full event with typed state objects** (better): -This gives you typed state objects for easier access to attributes. - -```python ---8<-- "pages/core-concepts/bus/snippets/handlers_typed_event.py" -``` +### Extracted data (recommended) -**Option 3: Extract specific data** (recommended for production code — if you're new to Hassette, start with Option 1 or 2): +[`D`](dependency-injection.md) (`hassette.event_handling.dependencies`) is a module of type annotations that tell Hassette what to extract from each event — similar to FastAPI's `Depends()`, but using type annotations instead of wrapper calls. The handler receives only the requested data, not the event object. ```python --8<-- "pages/core-concepts/bus/snippets/handlers_extract_data.py" ``` -**Option 4: No event data needed**: +[`D.StateNew[T]`](dependency-injection.md) delivers the new state converted +to type `T`. [`D.EntityId`](dependency-injection.md) delivers the entity ID +string. -```python ---8<-- "pages/core-concepts/bus/snippets/handlers_no_data.py" -``` - -### Passing Custom Arguments - -You can pass additional arguments to your handler using `kwargs` when subscribing. These are injected alongside event dependencies. +The same pattern works with `on_call_service`. `D.EntityId` extracts the +entity the service call targeted. ```python ---8<-- "pages/core-concepts/bus/snippets/handlers_custom_args.py" +--8<-- "pages/core-concepts/bus/snippets/handlers_service_extract.py" ``` -### Available Dependencies - -Dependencies are available via `from hassette import D`. The most common are `StateNew[T]`, `StateOld[T]`, `EntityId`, and `Domain`. - -See the [Dependency Injection guide](dependency-injection.md#available-di-annotations) for the full annotation table, custom extractors, and automatic type conversion. +[`states`][hassette.models.states] is `hassette.models.states`, typed state classes for each Home Assistant domain. The [Dependency Injection](dependency-injection.md) page covers the full annotation table, `D.StateOld`, `D.EventContext`, union types, and custom extractors. -### Restrictions +### Raw event -!!! warning "Handler Signature Rules" - Handlers **cannot** use: - - - Positional-only parameters (parameters before `/`) - - Variadic positional arguments (`*args`) - - These restrictions ensure unambiguous parameter injection. - -## Combining Multiple Dependencies - -You can extract multiple pieces of data in a single handler: +State change events arrive as +[`RawStateChangeEvent`][hassette.events.hass.hass.RawStateChangeEvent]. +The state value lives at `event.payload.data.new_state.get("state")`. ```python ---8<-- "pages/core-concepts/bus/snippets/handlers_multiple_dependencies.py" -``` - -## Error Handling - -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. - -There are two levels of error handlers: - -- **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. - -Both levels can be sync or async. - -!!! 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()`**. - -### App-level error handler - -```python ---8<-- "pages/core-concepts/bus/snippets/handlers/bus_error_handler_app.py" +--8<-- "pages/core-concepts/bus/snippets/handlers_raw_event.py" ``` -### Per-registration error handler +Raw topic subscriptions via `on()` deliver `Event[Any]` instead. The handler +receives the full event object with `event.topic` and `event.payload`. ```python ---8<-- "pages/core-concepts/bus/snippets/handlers/bus_error_handler_per_reg.py" +--8<-- "pages/core-concepts/bus/snippets/handlers_raw_topic.py" ``` -### What `BusErrorContext` contains - -| Field | Type | Description | -|-------|------|-------------| -| `exception` | `BaseException` | The raised exception | -| `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. - -## 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) +### Typed state event -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. +[`D.TypedStateChangeEvent[T]`][hassette.event_handling.dependencies] converts +a raw state change event into a typed version with both old and new states as typed objects. `D.StateNew[T]` extracts just the new state; `D.TypedStateChangeEvent[T]` gives the full event — useful when comparing before/after values. ```python -await self.bus.on_state_change( - "light.kitchen", - handler=self.on_light_change, - name="kitchen_light", # required -) +--8<-- "pages/core-concepts/bus/snippets/handlers_typed_event.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. +This pattern works only with `on_state_change` and `on_attribute_change`. +Service call handlers use the [extracted data](#extracted-data-recommended) +pattern with [`D.EntityId`](dependency-injection.md) instead. -**`ListenerNameRequiredError`** is raised at call time when `name=` is omitted. The error includes the handler method and topic: +## Choosing a Pattern -``` -ListenerNameRequiredError: Listener registration requires a name. +`D` annotations are the default for most handlers. They deliver only the fields the handler needs. The signature stays readable, and Hassette handles parsing and type conversion. - handler: MyApp.on_light_change - topic: light.kitchen +Raw events deliver the full event structure, which suits event-forwarding or generic logging. Typed state events provide the same structure but with typed state objects instead of raw dicts. -Provide a stable name via the `name=` parameter: +No-parameter handlers work when the event itself does not matter. The subscription filters to the right entity and transition, so the handler just acts. - await self.bus.on_state_change("light.kitchen", handler=self.on_light_change, name="kitchen_light") -``` +## Cross-app Communication -**`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. - -``` -DuplicateListenerError: A listener named 'kitchen_light' is already registered for topic 'light.kitchen'. - - 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. -``` - -### Registration is complete when the awaited call returns - -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. +Apps can broadcast data to other apps through custom topics. +`Bus.emit(topic, data)` publishes a payload. Other apps subscribe to the same +topic with `on()`. [`D.EventData[T]`](dependency-injection.md) delivers the +payload pre-extracted and typed. ```python ---8<-- "pages/core-concepts/bus/snippets/handlers/bus_subscription_patterns.py:await_persistence" +--8<-- "pages/core-concepts/bus/snippets/handlers_cross_app.py:sender" ``` -### 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: - ```python ---8<-- "pages/core-concepts/bus/snippets/handlers/bus_subscription_patterns.py:resubscribe" +--8<-- "pages/core-concepts/bus/snippets/handlers_cross_app.py:receiver" ``` +A frozen dataclass or Pydantic model works well for `T` — the type is passed in-process, not persisted, but keeping it immutable prevents accidental cross-app state mutation. Any type passed as `data` to `emit()` can be received via `D.EventData[T]`. `self.instance_name` is the app's instance identifier, set in [`hassette.toml`](../configuration/index.md). + ## 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 +- [Subscription Methods](methods.md): method reference, parameters, error handling, registration +- [Dependency Injection](dependency-injection.md): full `D.*` annotation table, custom extractors +- [Filtering & Predicates](filtering.md): predicates, conditions, `where=` usage diff --git a/docs/pages/core-concepts/bus/index.md b/docs/pages/core-concepts/bus/index.md index c48e47b21..bd70e8032 100644 --- a/docs/pages/core-concepts/bus/index.md +++ b/docs/pages/core-concepts/bus/index.md @@ -1,134 +1,46 @@ -# 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. - -```mermaid -flowchart TD - subgraph ha["Home Assistant"] - HA["Events"] - end - - subgraph framework["Framework"] - WS["WebsocketService"] - BUS["BusService"] - WS --> BUS - end - - subgraph handlers["App Handlers"] - APP1["Handler 1
state_changed"] - APP2["Handler 2
call_service"] - APP3["Handler 3
custom_event"] - end - - HA --> WS - BUS --> APP1 & APP2 & APP3 - - style ha fill:#f0f0f0,stroke:#999 - style framework fill:#fff0e8,stroke:#cc8844 - style handlers fill:#e8f0ff,stroke:#6688cc -``` +`self.bus` is available on every [App](../apps/index.md) instance. Hassette creates it at startup. ## Subscribing to Events -The `Bus` provides helper methods for common subscriptions. Each returns a [`Subscription`][hassette.bus.listeners.Subscription] handle. - -### Common Methods - -- `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. - -### Example - -```python ---8<-- "pages/core-concepts/bus/snippets/bus_subscribe_state_change.py:subscribe" -``` - -## Matching Multiple Entities - -Most methods accept glob patterns for `entity_id`, `domain`, and `service`. - -```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 - -You can rate-limit your handlers directly in the subscription call to handle noisy events. - -```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`. - -## Immediate Fire - -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. - -```python ---8<-- "pages/core-concepts/bus/snippets/bus_immediate_fire.py:immediate_fire" -``` - -`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. +[`Bus`][hassette.bus.Bus] provides typed subscription methods for common event types. Each returns a [`Subscription`][hassette.bus.listeners.Subscription] handle — call `sub.cancel()` to unregister the handler. ```python ---8<-- "pages/core-concepts/bus/snippets/bus_duration_hold.py:duration_hold" +--8<-- "pages/core-concepts/bus/snippets/bus_basic_subscribe.py" ``` -`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. +[`D`](dependency-injection.md) is `hassette.event_handling.dependencies`, a module of type annotations that tell Hassette what to extract from each event. [`states`][hassette.models.states] is `hassette.models.states`, typed state classes for each Home Assistant domain. `D.StateNew[states.BinarySensorState]` extracts the new state and passes it as a typed [`BinarySensorState`][hassette.models.states.binary_sensor.BinarySensorState]. The handler receives clean, typed data instead of a raw event dictionary. [Dependency Injection](dependency-injection.md) covers the full annotation reference. -**Validation rules:** +`name=` is required on every subscription — it identifies the listener in logs and the monitoring UI. Omitting it raises `ListenerNameRequiredError` at call time. -- `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`. +[Subscription Methods](methods.md) covers each method, its parameters, and compatible DI annotations. -## 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. +## Matching Multiple Entities -| 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. | +`Subscription` methods accept glob patterns for entity matching. ```python ---8<-- "pages/core-concepts/bus/snippets/bus_timeouts.py" +--8<-- "pages/core-concepts/bus/snippets/bus_glob_patterns.py:glob_patterns" ``` -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. +`"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`. -This is the same behavior as scheduled jobs: unhandled exceptions are logged to error but do not crash anything. +!!! warning "Glob patterns match identifiers only" + Glob patterns do not match attribute names or data values. Predicates (functions that decide whether to run the handler — see [Filtering](filtering.md)) handle those cases. -??? 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`. +??? note "Synchronous usage (AppSync only)" + `self.bus.sync` exposes a [`BusSyncFacade`][hassette.bus.sync.BusSyncFacade] that mirrors all subscription methods as blocking calls. It exists for [`AppSync`][hassette.app.app.AppSync] lifecycle hooks, which run in a worker thread outside the async event loop. The [Apps](../apps/index.md) page covers the `AppSync` pattern. - ```python - --8<-- "pages/core-concepts/bus/snippets/bus_registration_identity.py:registration_identity" - ``` +## Verify It's Working - See [Subscription and Registration](handlers.md#subscription-and-registration) in the Handlers guide for the full error details and upsert semantics across restarts. +Run `hassette listener --app ` to see registered listeners and invocation counts, where `` is the app identifier from `hassette.toml` (e.g., `motion_lights`). Run `hassette log --app --since 5m` to see handler log output. The [monitoring UI's](../../web-ui/index.md) Handlers tab shows invocation history and last-seen timestamps. ## 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): start here — handler signature patterns and choosing the right one +- [Subscription Methods](methods.md): full method reference, parameters, error handling, timeouts, and registration +- [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/methods.md b/docs/pages/core-concepts/bus/methods.md new file mode 100644 index 000000000..9289e01ae --- /dev/null +++ b/docs/pages/core-concepts/bus/methods.md @@ -0,0 +1,314 @@ +# Subscription Methods + +[`Bus`][hassette.bus.Bus] provides typed subscription methods for each event category Home Assistant and Hassette emit. Each method returns a [`Subscription`][hassette.bus.listeners.Subscription] handle. Calling `sub.cancel()` removes the listener. + +All registration methods are `async` and must be awaited. See [Registration](#registration) for what that guarantees. + +!!! warning "Forgetting `await` registers nothing" + A subscription call without `await` returns a coroutine object and registers no listener — the handler never fires, and no error is raised at the call site. Python logs `RuntimeWarning: coroutine 'Bus.on_state_change' was never awaited` when the coroutine is garbage-collected, but the message is easy to miss. When a handler never fires, check the registration is awaited, then confirm the listener exists with `hassette listener --app `. + +## Shared Parameters + +Every subscription method accepts these parameters. Individual method tables below list only method-specific parameters. + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `handler` | `HandlerType` | — | The function called when the event matches. See [Writing Handlers](handlers.md). | +| `name` | `str \| None` | `None` | Required. Identifies this listener in logs and the monitoring UI. Must be unique per app instance and topic. Omitting raises `ListenerNameRequiredError`. | +| `on_error` | `BusErrorHandlerType \| None` | `None` | Per-listener error handler. Overrides the app-level handler set via `bus.on_error()`. Available on `on_state_change`, `on_attribute_change`, `on_call_service`, `on_service_registered`, `on_component_loaded`, `on_app_state_changed`, and `on()`. | +| `timeout` | `float \| None` | `None` | Per-listener timeout in seconds. If the handler runs longer, it is cancelled. `None` inherits `event_handler_timeout_seconds` from [`hassette.toml`](../configuration/index.md). | +| `timeout_disabled` | `bool` | `False` | Disables timeout enforcement for this listener regardless of config. | +| `debounce` | `float \| None` | `None` | Delays the handler until events have been quiet for N seconds. Each new event resets the timer. | +| `throttle` | `float \| None` | `None` | Limits the handler to one invocation per N seconds. Events during the cooldown are dropped. | +| `once` | `bool` | `False` | Fires the handler exactly once, then cancels the subscription. | +| `kwargs` | `Mapping \| None` | `None` | Keyword arguments passed to the handler at invocation time. | + +`debounce`, `throttle`, and `once` are mutually exclusive. Combining any two raises `ValueError`. + +## `on_state_change(entity_id)` + +Fires when a Home Assistant entity's state changes. `entity_id` accepts glob patterns (`"light.*kitchen*"`). + +```python +--8<-- "pages/core-concepts/bus/snippets/methods/on_state_change.py:basic" +``` + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `entity_id` | `str` | — | Entity ID or glob pattern to match. | +| `changed` | `bool \| ComparisonCondition` | `True` | `True` fires only when the state value changes. `False` fires on attribute-only updates too. A [`ComparisonCondition`](filtering.md) (e.g., `C.Increased()`) compares old and new values. | +| `changed_from` | `ChangeType` | not set | Filters on the previous state value. Accepts a raw value, callable, or condition. Compares raw HA state strings. | +| `changed_to` | `ChangeType` | not set | Filters on the new state value. Accepts a raw value, callable, or condition. Compares raw HA state strings. | +| `where` | `Predicate \| Sequence[Predicate] \| None` | `None` | Additional predicates applied after value filters. See [Filtering & Predicates](filtering.md). | +| `immediate` | `bool` | `False` | Fires with the current state on registration, then on every subsequent change. Not supported with glob patterns. | +| `duration` | `float \| None` | `None` | Fires only after the state has held for N seconds continuously. Not supported with glob patterns. | + +`changed_from` and `changed_to` compare **raw HA state strings** (`"on"`, `"off"`, `"72.5"`), not typed values from the state registry. + +`immediate=True` and `duration` both raise `ValueError` when `entity_id` contains glob characters. + +**Compatible [DI annotations](dependency-injection.md)** + +`D` is the dependency-injection module (`from hassette import D`). Annotating a handler parameter with one of these types makes Hassette extract and convert that piece of the event automatically. Each method section below lists the annotations its events support. + +| Annotation | Provides | +|---|---| +| `D.StateNew[T]` | New state object, converted to type `T`. Raises if absent. | +| `D.StateOld[T]` | Previous state object, converted to type `T`. Raises if absent. | +| `D.MaybeStateNew[T]` | New state object or `None` if not present. | +| `D.MaybeStateOld[T]` | Previous state object or `None` if not present. | +| `D.EntityId` | Entity ID string. Raises if absent. | +| `D.MaybeEntityId` | Entity ID string or missing-value sentinel. | +| `D.Domain` | Domain string (e.g., `"light"`). Raises if absent. | +| `D.MaybeDomain` | Domain string or missing-value sentinel. | +| `D.TypedStateChangeEvent[T]` | Full event with new/old states converted to type `T`. | +| `D.EventContext` | HA event context (user_id, parent_id, etc.). | + +Fire with the current value on registration, then on each subsequent change: + +```python +--8<-- "pages/core-concepts/bus/snippets/methods/on_state_change.py:immediate" +``` + +Fire only after the state has held for a set duration: + +```python +--8<-- "pages/core-concepts/bus/snippets/methods/on_state_change.py:duration" +``` + +Fire only on a specific state transition: + +```python +--8<-- "pages/core-concepts/bus/snippets/methods/on_state_change.py:changed_to" +``` + +## `on_attribute_change(entity_id, attr)` + +Fires when a specific attribute of an entity changes. `entity_id` accepts glob patterns. + +!!! warning "`attr` does not support glob patterns" + The `attr` parameter matches a single attribute name exactly. Glob characters in `attr` are treated as literal characters, not patterns. [Predicates](filtering.md) handle multi-attribute matching. + +```python +--8<-- "pages/core-concepts/bus/snippets/methods/on_attribute_change.py:basic" +``` + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `entity_id` | `str` | — | Entity ID or glob pattern to match. | +| `attr` | `str` | — | Attribute name to monitor (e.g., `"volume_level"`). | +| `changed` | `bool \| ComparisonCondition` | `True` | `True` fires only when the attribute value changes. `False` fires on any state event for the entity. | +| `changed_from` | `ChangeType` | not set | Filters on the previous attribute value. | +| `changed_to` | `ChangeType` | not set | Filters on the new attribute value. | +| `where` | `Predicate \| Sequence[Predicate] \| None` | `None` | Additional predicates. | +| `immediate` | `bool` | `False` | Fires with the current attribute value on registration. Not supported with glob patterns. | +| `duration` | `float \| None` | `None` | Fires only after the attribute has held the value for N seconds. Not supported with glob patterns. | + +`changed_from` and `changed_to` compare the **attribute value**, not the entity's main state string. + +`changed=False` fires on every state event for the entity, even when the monitored attribute did not change. `on_state_change` with `changed=False` provides that broader behavior. + +**Compatible [DI annotations](dependency-injection.md)** + +Same as [`on_state_change`](#on_state_changeentity_id). + +```python +--8<-- "pages/core-concepts/bus/snippets/methods/on_attribute_change.py:changed_from_to" +``` + +```python +--8<-- "pages/core-concepts/bus/snippets/methods/on_attribute_change.py:immediate" +``` + +## `on_call_service(domain, service)` + +Fires when Home Assistant calls a service. + +```python +--8<-- "pages/core-concepts/bus/snippets/handlers/non_state_call_service.py" +``` + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `domain` | `str \| None` | `None` | Service domain to match (e.g., `"light"`). `None` matches all domains. | +| `service` | `str \| None` | `None` | Service name to match (e.g., `"turn_on"`). `None` matches all services in the domain. | +| `where` | `Predicate \| Sequence[Predicate] \| Mapping[str, ChangeType] \| None` | `None` | Additional predicates, or a dict for service data matching. | + +`where=` accepts a plain `dict` mapping service data fields to expected values. `{"entity_id": "light.kitchen"}` matches only calls targeting `light.kitchen`. This dict form is unique to `on_call_service`. `on_service_registered` does not support it. + +No `changed`, `changed_from`, `changed_to`, `immediate`, or `duration` parameters. + +**Compatible [DI annotations](dependency-injection.md)** + +| Annotation | Provides | +|---|---| +| `D.EntityId` | Entity ID from the service call. Raises if absent. | +| `D.MaybeEntityId` | Entity ID or missing-value sentinel. | +| `D.EventContext` | HA event context. | + +## `on_service_registered(domain, service)` + +Fires when Home Assistant registers a new service. Same parameter shape as `on_call_service`, with one difference. `where=` accepts only predicates, not a dict. + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `domain` | `str \| None` | `None` | Domain to match. | +| `service` | `str \| None` | `None` | Service name to match. | +| `where` | `Predicate \| Sequence[Predicate] \| None` | `None` | Additional predicates. | + +## `on_component_loaded(component)` + +Fires when Home Assistant finishes loading a component. + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `component` | `str \| None` | `None` | Component name to match (e.g., `"light"`). `None` matches all components. | +| `where` | `Predicate \| Sequence[Predicate] \| None` | `None` | Additional predicates. | + +## Home Assistant Lifecycle Methods + +Three shorthands delegate to `on_call_service("homeassistant", ...)`. + +| Method | Equivalent | +|---|---| +| `on_homeassistant_start(handler, ...)` | `on_call_service("homeassistant", "start", ...)` | +| `on_homeassistant_stop(handler, ...)` | `on_call_service("homeassistant", "stop", ...)` | +| `on_homeassistant_restart(handler, ...)` | `on_call_service("homeassistant", "restart", ...)` | + +All three accept `handler`, `where`, `kwargs`, `name`, and the [shared parameters](#shared-parameters) (`debounce`, `throttle`, `once`, `timeout`, `timeout_disabled`). They do not expose `on_error` directly. Per-registration error handling requires `on_call_service` directly. + +## `on(topic)` + +Subscribes to any raw event topic string. + +```python +--8<-- "pages/core-concepts/bus/snippets/handlers/non_state_raw_topic.py" +``` + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `topic` | `str` | — | The exact event topic string to subscribe to. | +| `where` | `Predicate \| Sequence[Predicate] \| None` | `None` | Additional predicates. | + +`on()` does not support `immediate`, `duration`, `changed`, `changed_from`, or `changed_to`. All shared timing parameters (`debounce`, `throttle`, `once`, `timeout`, `timeout_disabled`) are accepted. Internal topics used by Hassette shorthands (WebSocket events, app state events) are also accessible via `on()` for raw topic access. + +## App and Connection Events + +### `on_app_state_changed` and shorthands + +`on_app_state_changed` fires when any app instance transitions to a new [`ResourceStatus`][hassette.types.enums.ResourceStatus] (e.g., `RUNNING`, `STOPPING`, `STOPPED`, `FAILED`). Two shorthands cover the most common cases. + +```python +--8<-- "pages/core-concepts/bus/snippets/methods/on_app_events.py:app_state_changed" +``` + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `app_key` | `str \| None` | `None` | Filters to a specific app (the identifier from [`hassette.toml`](../configuration/index.md)). `None` matches all apps. | +| `status` | `ResourceStatus \| None` | `None` | Filters to a specific status. `None` matches all status transitions. | +| `where` | `Predicate \| Sequence[Predicate] \| None` | `None` | Additional predicates. | + +`on_app_running(app_key=...)` delegates to `on_app_state_changed(status=ResourceStatus.RUNNING)`. +`on_app_stopping(app_key=...)` delegates to `on_app_state_changed(status=ResourceStatus.STOPPING)`. + +The shorthands do not expose `on_error` directly. Per-listener error handling requires `on_app_state_changed` with `on_error=` directly. + +### `on_websocket_connected` and `on_websocket_disconnected` + +Fire when the Hassette WebSocket connection to Home Assistant opens or closes. + +```python +--8<-- "pages/core-concepts/bus/snippets/methods/on_app_events.py:websocket" +``` + +Both methods accept `handler`, `where`, `kwargs`, `name`, and `**opts`. Neither exposes `on_error`. Both delegate to `on()` internally. + +### `on_hassette_service_status` and shorthands + +`on_hassette_service_status` fires when a Hassette background service (WebSocket, database, bus, scheduler) transitions to a new [`ResourceStatus`][hassette.types.enums.ResourceStatus]. Most apps never need this — Hassette restarts failed services on its own. It exists for apps that pause work or alert when a service goes down. Three shorthands cover the common cases: `on_hassette_service_failed` (status `FAILED`), `on_hassette_service_crashed` (status `CRASHED`), and `on_hassette_service_started` (status `RUNNING`). + +```python +--8<-- "pages/core-concepts/bus/snippets/methods/on_service_events.py:service" +``` + +All four accept `handler`, `where`, `kwargs`, `name`, and `**opts`. [Service supervision](../internals/lifecycle.md) explains when each status fires. + +## Error Handling + +### App-level handler + +`bus.on_error(handler)` registers a fallback called when any listener on the bus raises. This call is synchronous — no `await` needed. The handler receives a [`BusErrorContext`][hassette.bus.error_context.BusErrorContext]. + +```python +--8<-- "pages/core-concepts/bus/snippets/handlers/bus_error_handler_app.py" +``` + +### Per-registration handler + +`on_error=` on a registration overrides the app-level fallback for that listener only. + +```python +--8<-- "pages/core-concepts/bus/snippets/handlers/bus_error_handler_per_reg.py" +``` + +**`BusErrorContext` fields** + +| Field | Type | Description | +|---|---|---| +| `exception` | `BaseException` | The raised exception, with `__traceback__` chain intact. | +| `traceback` | `str` | Full formatted traceback string. Always non-empty. | +| `topic` | `str` | The event topic the listener was registered on. | +| `listener_name` | `str` | Human-readable listener identity string. | +| `event` | `Event[Any]` | The event being processed when the exception occurred. | +| `execution_id` | `str \| None` | UUIDv7 identifying the execution that failed, or `None`. | + +Error handlers run as fire-and-forget tasks. Handlers that start near app shutdown may be cancelled before they complete. Error handlers are not a reliable delivery channel during system teardown. + +`on_error` is not available on `on_homeassistant_start`, `on_homeassistant_stop`, `on_homeassistant_restart`, `on_app_running`, `on_app_stopping`, `on_websocket_connected`, or `on_websocket_disconnected`. Per-registration error handling on these events requires the underlying method (`on_call_service`, `on_app_state_changed`, or `on()`) directly. + +## Timeout Configuration + +`timeout=` overrides the global `event_handler_timeout_seconds` for a single listener. `timeout_disabled=True` removes timeout enforcement entirely for that listener. + +```python +--8<-- "pages/core-concepts/bus/snippets/bus_timeouts.py" +``` + +The global default comes from `event_handler_timeout_seconds` in `hassette.toml`. A listener with `timeout=None` (the default) inherits that value. Setting `timeout=30.0` overrides the global only for that listener. Other listeners are unaffected. + +`timeout_disabled=True` is appropriate for handlers that legitimately run longer than the global limit. A backup job triggered by a boolean is a typical case. `timeout=` is appropriate when a specific handler needs a tighter or looser bound than the global. + +## Registration + +### `name=` requirement + +Every registration method requires `name=`. Omitting it raises `ListenerNameRequiredError` at call time. + +```python +--8<-- "pages/core-concepts/bus/snippets/bus_registration_identity.py:registration_identity" +``` + +The `name` forms a natural key together with the app identifier, instance index, and topic. Two registrations with the same name on the same topic within a session raise `DuplicateListenerError`. Across sessions (app restart), the same name and topic performs an upsert — Hassette persists listener metadata to a local SQLite [telemetry database](../database-telemetry.md), and the existing record is updated, not duplicated. + +### Synchronous completion + +Registration completes before the awaited call returns. `sub.listener.db_id` is a valid integer immediately. + +```python +--8<-- "pages/core-concepts/bus/snippets/handlers/bus_subscription_patterns.py:await_persistence" +``` + +### Cancel-then-resubscribe + +Cancelling a subscription and registering a new one is deterministic. The old handler is removed before the new registration begins. No overlap, no gap. + +```python +--8<-- "pages/core-concepts/bus/snippets/handlers/bus_subscription_patterns.py:resubscribe" +``` + +## See Also + +- [Writing Handlers](handlers.md): handler signature patterns and DI annotation usage +- [Filtering & Predicates](filtering.md): `where=`, `P.*` predicates, and `C.*` conditions +- [Dependency Injection](dependency-injection.md): full `D.*` annotation reference +- [Bus Overview](index.md): bus overview and getting started 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..7124cc8cc --- /dev/null +++ b/docs/pages/core-concepts/bus/predicate-reference.md @@ -0,0 +1,211 @@ +# Predicate, Condition & Accessor Reference + +!!! note "Prerequisite" + This page is a lookup reference. Read [Filtering](filtering.md) first for explanations of how predicates, conditions, and accessors compose — this page assumes that context. + +`P` (predicates) filter events on the [bus](index.md). `C` (conditions) match individual values. `A` (accessors) extract specific fields from events. All three are top-level imports: + +```python +from hassette import P, C, A +``` + +**Event types:** Tables below reference three event types. [`RawStateChangeEvent`][hassette.events.hass.hass.RawStateChangeEvent] fires on entity state changes. [`CallServiceEvent`][hassette.events.hass.hass.CallServiceEvent] fires on service calls. [`HassEvent`][hassette.events.hass.hass.HassEvent] is the base type for all Home Assistant events. + +**Key types in signatures:** `ChangeType` is a union of literal value, `C.*` condition instance, glob string, or callable `(value) -> bool`. `ComparisonCondition` is a two-argument callable `(old_value, new_value) -> bool` — accepts `C.Increased`, `C.Decreased`, or a custom function. [`MISSING_VALUE`][hassette.const.MISSING_VALUE] is a falsy sentinel from `hassette.const` indicating a field does not exist on the event (distinct from `None`). Its sibling [`NOT_PROVIDED`][hassette.const.NOT_PROVIDED] is the default for the `changed_from=`/`changed_to=` parameters on [`on_state_change()`](methods.md#on_state_changeentity_id) — it marks an argument the caller did not pass, so `None` stays usable as a real filter value. + +## 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`][hassette.events.hass.hass.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`][hassette.events.hass.hass.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`: + +```python +P.AllOf((P.StateFrom("off"), P.StateTo("on"))) +``` + +## 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`][hassette.const.MISSING_VALUE] and `None` are distinct. `C.IsNone` / `C.IsNotNone` test for Python `None`; `C.Present` / `C.Missing` test for Hassette's falsy sentinel that indicates a field does not exist on the event. See [Identity Extractors](dependency-injection.md#identity-extractors) for more on `MISSING_VALUE`. + +### 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 +--8<-- "pages/core-concepts/bus/snippets/filtering/range_check.py:range_check" +``` + +## Accessors (`A`) + +An accessor is a factory function that returns a callable `(event) -> value`. Single-value accessors work with `P.ValueIs`, `P.IsPresent`, and `P.IsMissing`. Tuple-returning accessors (the `*_old_new` variants) work with `P.DidChange`. Some accessors are used directly (e.g., `A.get_state_value_new`); others are factories that require a call first (e.g., `A.get_attr_new("brightness")`). `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 path (e.g., `"new_state.attributes.brightness"`); `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`. + +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/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_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_rate_control.py b/docs/pages/core-concepts/bus/snippets/bus_rate_control.py deleted file mode 100644 index 62238b2b5..000000000 --- a/docs/pages/core-concepts/bus/snippets/bus_rate_control.py +++ /dev/null @@ -1,39 +0,0 @@ -from hassette import App, AppConfig - - -class RateControlApp(App[AppConfig]): - async def on_initialize(self): - # --8<-- [start:rate_control] - # Debounce: wait for 2s of silence before calling - await self.bus.on_state_change( - "binary_sensor.motion", - handler=self.on_settled, - debounce=2.0, - name="motion_debounced", - ) - - # Throttle: call at most once every 5s - await self.bus.on_state_change( - "sensor.temperature", - handler=self.on_temp_log, - throttle=5.0, - name="temp_throttled", - ) - - # Once: unsubscribe automatically after first trigger - await self.bus.on_component_loaded( - "hue", - handler=self.on_hue_ready, - once=True, - name="hue_ready", - ) - # --8<-- [end:rate_control] - - async def on_settled(self): - pass - - async def on_temp_log(self): - pass - - async def on_hue_ready(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/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/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/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/bus/snippets/filtering/range_check.py b/docs/pages/core-concepts/bus/snippets/filtering/range_check.py new file mode 100644 index 000000000..f723d5d0a --- /dev/null +++ b/docs/pages/core-concepts/bus/snippets/filtering/range_check.py @@ -0,0 +1,8 @@ +from hassette import A, C, P + +# --8<-- [start:range_check] +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)), +)) +# --8<-- [end:range_check] 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..b8a7f9880 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: isinstance(v, int) and v > 200}, handler=self.on_bright_lights, name="bright_lights", ) diff --git a/docs/pages/core-concepts/bus/snippets/filtering_service_matches.py b/docs/pages/core-concepts/bus/snippets/filtering_service_matches.py index 4d5ce5eeb..0eca6aacd 100644 --- a/docs/pages/core-concepts/bus/snippets/filtering_service_matches.py +++ b/docs/pages/core-concepts/bus/snippets/filtering_service_matches.py @@ -4,14 +4,20 @@ class SceneApp(App): async def on_initialize(self): # Match any scene.turn_on call, regardless of service data - await self.bus.on(topic="call_service", handler=self.on_any_scene, where=P.ServiceMatches("scene.turn_on"), name="any_scene") + await self.bus.on( + topic="hass.event.call_service", + handler=self.on_any_scene, + where=[P.DomainMatches("scene"), P.ServiceMatches("turn_on")], + name="any_scene", + ) # Combine with ServiceDataWhere for full filtering await self.bus.on( - topic="call_service", + topic="hass.event.call_service", handler=self.on_evening_scene, where=[ - P.ServiceMatches("scene.turn_on"), + P.DomainMatches("scene"), + P.ServiceMatches("turn_on"), P.ServiceDataWhere({"entity_id": "scene.evening"}), ], name="evening_scene", 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_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) diff --git a/docs/pages/core-concepts/bus/snippets/handlers_cross_app.py b/docs/pages/core-concepts/bus/snippets/handlers_cross_app.py new file mode 100644 index 000000000..4ce71d096 --- /dev/null +++ b/docs/pages/core-concepts/bus/snippets/handlers_cross_app.py @@ -0,0 +1,45 @@ +from dataclasses import dataclass + +from hassette import App, AppConfig, D, states + + +# --8<-- [start:sender] +@dataclass(frozen=True) +class LightsSyncedData: + source: str + + +class SenderApp(App[AppConfig]): + async def on_initialize(self) -> None: + await self.bus.on_state_change( + "light.kitchen", + handler=self.on_kitchen_change, + name="kitchen_light", + ) + + async def on_kitchen_change( + self, + state: D.StateNew[states.LightState], + ) -> None: + await self.bus.emit( + "lights_synced", + LightsSyncedData(source=self.instance_name), + ) +# --8<-- [end:sender] + + +# --8<-- [start:receiver] +class ReceiverApp(App[AppConfig]): + async def on_initialize(self) -> None: + await self.bus.on( + topic="lights_synced", + handler=self.on_lights_synced, + name="lights_synced_log", + ) + + async def on_lights_synced( + self, + data: D.EventData[LightsSyncedData], + ) -> None: + self.logger.info("Synced by %s", data.source) +# --8<-- [end:receiver] 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/bus/snippets/handlers_raw_topic.py b/docs/pages/core-concepts/bus/snippets/handlers_raw_topic.py new file mode 100644 index 000000000..2c2e31443 --- /dev/null +++ b/docs/pages/core-concepts/bus/snippets/handlers_raw_topic.py @@ -0,0 +1,18 @@ +from typing import Any + +from hassette import App, AppConfig +from hassette.events import Event + + +class TopicApp(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("Topic: %s", event.topic) diff --git a/docs/pages/core-concepts/bus/snippets/handlers_service_extract.py b/docs/pages/core-concepts/bus/snippets/handlers_service_extract.py new file mode 100644 index 000000000..31618bb91 --- /dev/null +++ b/docs/pages/core-concepts/bus/snippets/handlers_service_extract.py @@ -0,0 +1,16 @@ +from hassette import App, AppConfig, D + + +class LightAuditApp(App[AppConfig]): + async def on_initialize(self) -> None: + await self.bus.on_call_service( + "light", + handler=self.on_light_service, + name="light_service_audit", + ) + + async def on_light_service( + self, + entity_id: D.EntityId, + ) -> None: + self.logger.info("Light service called on: %s", entity_id) diff --git a/docs/pages/core-concepts/bus/snippets/methods/on_app_events.py b/docs/pages/core-concepts/bus/snippets/methods/on_app_events.py new file mode 100644 index 000000000..e227f5a78 --- /dev/null +++ b/docs/pages/core-concepts/bus/snippets/methods/on_app_events.py @@ -0,0 +1,52 @@ +from hassette import App, AppConfig + + +class OrchestratorApp(App[AppConfig]): + async def on_initialize(self) -> None: + # --8<-- [start:app_state_changed] + # Fire whenever any app's status changes. + await self.bus.on_app_state_changed( + handler=self.on_any_app_change, + name="any_app_status", + ) + + # Fire only when the sensor app reaches RUNNING. + await self.bus.on_app_running( + app_key="sensor_monitor", + handler=self.on_sensor_ready, + name="sensor_monitor_running", + ) + + # Fire when the sensor app begins stopping. + await self.bus.on_app_stopping( + app_key="sensor_monitor", + handler=self.on_sensor_stopping, + name="sensor_monitor_stopping", + ) + # --8<-- [end:app_state_changed] + + # --8<-- [start:websocket] + await self.bus.on_websocket_connected( + handler=self.on_connected, + name="ha_ws_connected", + ) + await self.bus.on_websocket_disconnected( + handler=self.on_disconnected, + name="ha_ws_disconnected", + ) + # --8<-- [end:websocket] + + async def on_any_app_change(self) -> None: + self.logger.info("An app changed status") + + async def on_sensor_ready(self) -> None: + self.logger.info("Sensor monitor is running") + + async def on_sensor_stopping(self) -> None: + self.logger.warning("Sensor monitor is stopping") + + async def on_connected(self) -> None: + self.logger.info("Connected to Home Assistant") + + async def on_disconnected(self) -> None: + self.logger.warning("Lost connection to Home Assistant") diff --git a/docs/pages/core-concepts/bus/snippets/methods/on_attribute_change.py b/docs/pages/core-concepts/bus/snippets/methods/on_attribute_change.py new file mode 100644 index 000000000..9a64eb7b3 --- /dev/null +++ b/docs/pages/core-concepts/bus/snippets/methods/on_attribute_change.py @@ -0,0 +1,51 @@ +from hassette import App, AppConfig, C, D, states + + +class VolumeMonitorApp(App[AppConfig]): + async def on_initialize(self) -> None: + # --8<-- [start:basic] + await self.bus.on_attribute_change( + "media_player.living_room", + "volume_level", + handler=self.on_volume_change, + name="living_room_volume", + ) + # --8<-- [end:basic] + + # --8<-- [start:changed_from_to] + await self.bus.on_attribute_change( + "sensor.phone_battery", + "battery_level", + changed_from=C.Comparison(">", 20), + changed_to=C.Comparison("<=", 20), + handler=self.on_battery_low, + name="phone_battery_low", + ) + # --8<-- [end:changed_from_to] + + # --8<-- [start:immediate] + await self.bus.on_attribute_change( + "climate.living_room", + "current_temperature", + handler=self.on_temp_change, + immediate=True, + name="climate_temp_init", + ) + # --8<-- [end:immediate] + + async def on_volume_change( + self, new: D.StateNew[states.MediaPlayerState] + ) -> None: + vol = new.attributes.volume_level + self.logger.info("Volume changed to %s", vol) + + async def on_battery_low( + self, new: D.StateNew[states.SensorState] + ) -> None: + self.logger.warning("Battery low: %s", new.value) + + async def on_temp_change( + self, new: D.StateNew[states.ClimateState] + ) -> None: + temp = new.attributes.current_temperature + self.logger.info("Room temperature: %s", temp) diff --git a/docs/pages/core-concepts/bus/snippets/methods/on_service_events.py b/docs/pages/core-concepts/bus/snippets/methods/on_service_events.py new file mode 100644 index 000000000..e773eace9 --- /dev/null +++ b/docs/pages/core-concepts/bus/snippets/methods/on_service_events.py @@ -0,0 +1,14 @@ +from hassette import App + + +class ServiceWatchdogApp(App): + async def on_initialize(self): + # --8<-- [start:service] + await self.bus.on_hassette_service_failed( + handler=self.on_service_failed, + name="service_watchdog", + ) + # --8<-- [end:service] + + async def on_service_failed(self): + self.logger.warning("A Hassette service failed and is being restarted") diff --git a/docs/pages/core-concepts/bus/snippets/methods/on_state_change.py b/docs/pages/core-concepts/bus/snippets/methods/on_state_change.py new file mode 100644 index 000000000..ed09d1269 --- /dev/null +++ b/docs/pages/core-concepts/bus/snippets/methods/on_state_change.py @@ -0,0 +1,58 @@ +from hassette import App, AppConfig, C, D, states + + +class LightMonitorApp(App[AppConfig]): + async def on_initialize(self) -> None: + # --8<-- [start:basic] + await self.bus.on_state_change( + "light.kitchen", + handler=self.on_light_change, + name="kitchen_light", + ) + # --8<-- [end:basic] + + # --8<-- [start:immediate] + await self.bus.on_state_change( + "sensor.outdoor_temperature", + handler=self.on_temp, + immediate=True, + name="outdoor_temp_init", + ) + # --8<-- [end:immediate] + + # --8<-- [start:duration] + await self.bus.on_state_change( + "light.kitchen", + changed_to="on", + handler=self.on_light_on_long, + duration=1800.0, + name="kitchen_light_duration", + ) + # --8<-- [end:duration] + + # --8<-- [start:changed_to] + await self.bus.on_state_change( + "sensor.outdoor_temperature", + changed_to=C.Comparison(">", 25), + handler=self.on_temp_high, + name="outdoor_temp_high", + ) + # --8<-- [end:changed_to] + + async def on_light_change( + self, new: D.StateNew[states.LightState] + ) -> None: + self.logger.info("Light is now: %s", new.value) + + async def on_temp( + self, new: D.StateNew[states.SensorState] + ) -> None: + self.logger.info("Temperature: %s", new.value) + + async def on_light_on_long(self) -> None: + self.logger.info("Kitchen light on for 30 minutes") + + async def on_temp_high( + self, new: D.StateNew[states.SensorState] + ) -> None: + self.logger.warning("High temperature: %s", new.value) diff --git a/docs/pages/core-concepts/cache/index.md b/docs/pages/core-concepts/cache/index.md index 21df86d31..85e629941 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`](../apps/index.md) instance — no setup required. 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 backed by a third-party disk-based storage library. 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) (the local state cache) is the right tool. `self.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. +Hassette opens the cache at first access and flushes it to disk at shutdown. All reads and writes happen transparently in between. -## How It Works +## Shared Cache and Multi-Instance Apps -### Storage Location - -Each app or service gets its own cache directory under your configured `data_dir`: - -``` -{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: +Hassette can run the same app class multiple times with different configs (see [App Instances](../apps/index.md)). For multi-instance apps, prefixing keys with `self.app_config.instance_name` (set per `[[hassette.apps..config]]` block in `hassette.toml`; defaults to `ClassName.0`, `ClassName.1`, ... when omitted) 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: - - ```python - --8<-- "pages/core-concepts/cache/snippets/cache_instance_prefix.py" - ``` -### Lazy Initialization +## What Can Be Cached -The cache directory is created on first access. If your app never uses `self.cache`, no directory is created. +The cache stores any Python object that supports pickling, Python's built-in serialization format. This includes: -### Automatic Cleanup +- Primitives: `str`, `int`, `float`, `bool`, `None` +- Collections: `list`, `dict`, `tuple`, `set` +- Timestamps from the [`whenever`](https://whenever.readthedocs.io/) library (Hassette's date/time 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()` — a built-in `App` method returning the current time as a timezone-aware [`ZonedDateTime`](https://whenever.readthedocs.io/) — and all `whenever` types are picklable. Store them directly in the cache without conversion. ## 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,37 @@ 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. | -| `data_dir` | path | platform-dependent | Root directory for all persistent data. See [Global Settings](../configuration/global.md) for platform defaults. | +|---|---|---|---| +| `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/index.md) for platform defaults. | -## Lifecycle +## How It Works -The cache is managed automatically through the resource lifecycle: +**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/`. -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 +**Lazy initialization.** The cache directory is created on first access. Apps that never use `self.cache` produce no directory. -You never need to manually open or close the cache. +**Lifecycle.** The cache is available from first access through shutdown. Hassette closes and flushes it to disk when the app stops. -## Data Types +**Automatic cleanup.** Entries with a TTL expire silently. When the cache reaches its size limit (the `default_cache_size` setting), the least-recently-used items are evicted without raising an error. To set a TTL, use `self.cache.set()` instead of bracket assignment: -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): +```python +self.cache.set("weather_data", payload, expire=3600) # expires after 1 hour +``` -- 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) +## Verify It Works -!!! 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)`. +Check that cache data persists across restarts with `hassette log`: + +``` +hassette log --app my_app --since 1h +``` + +Set `log_level = "DEBUG"` in `hassette.toml` first — cache reads and writes only appear in the log at that level. The cache directory at `{data_dir}/{ClassName}/cache/` contains SQLite files managed by diskcache after the first successful write; the filenames are internal, not meant for inspection. ## 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/index.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..d1deb5fdd 100644 --- a/docs/pages/core-concepts/cache/patterns.md +++ b/docs/pages/core-concepts/cache/patterns.md @@ -1,141 +1,101 @@ # 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`, the persistent disk-backed key-value store available on every `App` instance. Each pattern addresses a specific problem with a complete, runnable example. The [Overview](index.md) covers setup and basic usage. -## Pattern: API Response Caching +`self.now()` returns the current time as a [`ZonedDateTime`](https://whenever.readthedocs.io/) from the `whenever` library. It is timezone-aware, picklable, and supports arithmetic — all time-based patterns below use it for timestamp comparisons. -Avoid hitting external API rate limits by storing responses with a timestamp and checking freshness before making a new request: +## Rate-Limiting Notifications -```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}"`. +`P` is `hassette.event_handling.predicates` — helper functions that filter which events trigger a handler (see [Predicates](../bus/filtering.md)). `P.StateTo("on")` fires the handler only when the entity transitions to `"on"`. -## Pattern: Persistent Counters +`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 — `last_sent > self.now().subtract(hours=4)` is true when the last notification was sent less than 4 hours ago. For per-entity rate limiting, include the entity ID in the key: `f"last_notification:{entity_id}"`. -Track events across restarts by loading the counter from the cache at initialization and writing it back on every increment: +## Persistent Counters + +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. Restart Hassette and check the logs — `"Motion count restored: N"` confirms the counter survived. -## 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. + +!!! note "Why the `# pyright: ignore` comments?" + `cache.get()` returns untyped values — the type checker can't know what was stored under a key. The examples suppress the resulting warnings; production code can do the same, or narrow the value with a cast or an `isinstance` check after reading. -## Pattern: Expiring Cache Entries +## Expiring Entries -For simple expiration, use `self.cache.set(key, value, expire=seconds)` — diskcache removes the entry automatically once the timeout elapses: +Two approaches exist for expiring cache entries, depending on whether access to the timestamp is needed. + +For automatic expiry, `self.cache.set()` accepts an `expire` parameter in seconds. The underlying diskcache library 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. + +## Storing Complex Data -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: +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 +`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. -### What to Cache +## Load Once, Write on Shutdown -**Good uses:** +Cache access involves disk I/O. For values read many times per second, loading into an instance variable at initialization avoids repeated disk reads: -- 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 - -**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` — the lifecycle hook that mirrors `on_initialize`, called when Hassette stops cleanly — writes the final state back to disk. diskcache is thread-safe for multi-threaded access; within Hassette's single async event loop, two handlers that touch the same key between `await` points cannot interleave, but when an `await` falls between a read and a write, the last write wins — for counters or accumulators, use instance variables as shown in the [Persistent Counters](#persistent-counters) pattern. ## Troubleshooting ### Cache Not Persisting -If data is not surviving restarts: +If values do not survive a restart, check four common causes: -- 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 +- **Write targets a local variable instead of `self.cache`.** Verify the assignment uses `self.cache["key"]`, not a local dict. +- **Exception during initialization.** The app may raise before the write executes. Check `hassette log --app ` for errors. +- **Cache directory lacks write permissions.** Check `ls -la {data_dir}/{ClassName}/cache/` — the Hassette process must own the directory. +- **Stored value is not picklable.** Unpicklable objects raise `PicklingError` at write time. Enable `log_level = "DEBUG"` under `[hassette.logging]` in `hassette.toml` to see the error. ### 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 stored entries (oldest writes first — its default policy). A larger `default_cache_size` in [Global Settings](../configuration/index.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/ -``` +Set `log_level = "DEBUG"` under `[hassette.logging]` in `hassette.toml` to enable cache operation logging. The cache directory at `~/.local/share/hassette/v0/{ClassName}/cache/` (where `{ClassName}` matches the app's class name, e.g. `WaterLeakAlertApp`) should contain data files 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/index.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/cache/snippets/cache_api_response.py b/docs/pages/core-concepts/cache/snippets/cache_api_response.py index d574514e5..743063422 100644 --- a/docs/pages/core-concepts/cache/snippets/cache_api_response.py +++ b/docs/pages/core-concepts/cache/snippets/cache_api_response.py @@ -3,7 +3,7 @@ class WeatherApp(App[AppConfig]): async def on_initialize(self): - await self.scheduler.run_every(self.update_weather, 60) + await self.scheduler.run_every(self.update_weather, seconds=60) async def get_weather(self, location: str) -> dict: cache_key = f"weather:{location}" @@ -14,7 +14,7 @@ async def get_weather(self, location: str) -> dict: # Return cached data if less than 30 minutes old if cached_time > self.now().subtract(minutes=30): self.logger.info("Using cached weather for %s", location) - return data # pyright: ignore[reportReturnType] + return data # Fetch fresh data from API self.logger.info("Fetching fresh weather for %s", location) diff --git a/docs/pages/core-concepts/cache/snippets/cache_complex_data.py b/docs/pages/core-concepts/cache/snippets/cache_complex_data.py index 168fdc96a..e58fc3c52 100644 --- a/docs/pages/core-concepts/cache/snippets/cache_complex_data.py +++ b/docs/pages/core-concepts/cache/snippets/cache_complex_data.py @@ -15,7 +15,7 @@ class EnergyStats: class EnergyTrackerApp(App[AppConfig]): async def on_initialize(self): # Load previous stats or create new ones - self.stats: EnergyStats = self.cache.get( # pyright: ignore[reportAttributeAccessIssue] + self.stats: EnergyStats = self.cache.get( "energy_stats", EnergyStats(0.0, 0.0, self.now()), ) diff --git a/docs/pages/core-concepts/cache/snippets/cache_performance.py b/docs/pages/core-concepts/cache/snippets/cache_performance.py index e14c96b3d..a185b9149 100644 --- a/docs/pages/core-concepts/cache/snippets/cache_performance.py +++ b/docs/pages/core-concepts/cache/snippets/cache_performance.py @@ -4,7 +4,7 @@ class OptimizedApp(App[AppConfig]): async def on_initialize(self): # Load from disk cache once into an instance variable - self.config_data: dict = self.cache.get("config", {}) # pyright: ignore[reportAttributeAccessIssue] + self.config_data: dict = self.cache.get("config", {}) # Use the in-memory copy throughout the app's lifetime setting = self.config_data.get("some_setting") diff --git a/docs/pages/core-concepts/configuration/applications.md b/docs/pages/core-concepts/configuration/applications.md deleted file mode 100644 index bb0a60b49..000000000 --- a/docs/pages/core-concepts/configuration/applications.md +++ /dev/null @@ -1,68 +0,0 @@ -# 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 and configured in the `hassette.toml` file under `[hassette.apps.]`. - -## App Registration - -Each app block requires: - -- **`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`). - -- **`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. - -!!! tip - Prefer `class_name` and `filename` in docs and new configs; the alternative keys exist for compatibility. - -**Optional fields:** - -- **`enabled`**: Set to `false` to disable the app without removing the config block. -- **`display_name`**: Friendly name for logs; defaults to the class name. - -### Single Instance - -```toml ---8<-- "pages/core-concepts/configuration/snippets/single_instance.toml" -``` - -## 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) - -!!! 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`. - -**Environment Variable Overrides:** - -You can override nested config values using environment variables. This merges with any TOML configuration (env vars take precedence). - -- Pattern: `HASSETTE__APPS____CONFIG__` -- Example: `HASSETTE__APPS__MY_APP__CONFIG__SOME_OPTION=true` overrides `some_option` for `my_app`. - -### Multiple Instances - -To run the same app multiple times with different configurations, use `[[hassette.apps..config]]` blocks. - -```toml ---8<-- "pages/core-concepts/configuration/snippets/multiple_instances.toml" -``` - -## Typed Configuration - -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). - -## See Also - -- [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 diff --git a/docs/pages/core-concepts/configuration/auth.md b/docs/pages/core-concepts/configuration/auth.md deleted file mode 100644 index d10ecd134..000000000 --- a/docs/pages/core-concepts/configuration/auth.md +++ /dev/null @@ -1,43 +0,0 @@ -# Authentication & Secrets - -Hassette needs a long-lived access token to authenticate with your Home Assistant instance. This page covers how to supply that token securely. - -## Home Assistant Token - -Create a long-lived access token in Home Assistant under your user profile, then supply it to Hassette using one of these methods: - -| Method | How | -|--------|-----| -| Environment variable (recommended) | `HASSETTE__TOKEN=your_token_here` | -| `.env` file | Add `HASSETTE__TOKEN=your_token_here` to `.env` in your config directory | -| CLI flag | `hassette run --token your_token_here` | - -!!! note "Compatibility aliases" - Hassette also accepts `HOME_ASSISTANT_TOKEN` and `HA_TOKEN` for compatibility with other tools, but `HASSETTE__TOKEN` is the canonical name and is recommended for new installations. - -!!! warning "Never commit your token to version control" - Store the token in an environment variable or a `.env` file that is listed in `.gitignore`. If you accidentally commit a token, rotate it immediately in Home Assistant. - -If you do not have a token yet, follow the [Creating a Home Assistant Token](../../getting-started/ha_token.md) guide. - -## SSL Verification - -By default, Hassette verifies SSL certificates when connecting to Home Assistant. If you use a self-signed certificate or an internal CA that your system does not trust, disable verification in `hassette.toml`: - -```toml -[hassette] -verify_ssl = false -``` - -!!! warning "Only disable SSL verification on trusted internal networks" - Disabling SSL verification removes protection against man-in-the-middle attacks. Use it only when connecting to a trusted Home Assistant instance on a private network. - -## File Locations - -For details on where Hassette looks for configuration and `.env` files, see [Configuration Overview — File Locations](index.md#file-locations). - -## See Also - -- [Global Settings](global.md) — connection and runtime settings -- [Applications](applications.md) — app registration and configuration -- [Creating a Token](../../getting-started/ha_token.md) — step-by-step token creation diff --git a/docs/pages/core-concepts/configuration/global.md b/docs/pages/core-concepts/configuration/global.md deleted file mode 100644 index c5ff60b90..000000000 --- a/docs/pages/core-concepts/configuration/global.md +++ /dev/null @@ -1,313 +0,0 @@ -# Global Settings - -Global settings control how Hassette runs and connects to Home Assistant. These are defined under the `[hassette]` table in `hassette.toml`. - -**Most users only need the first few sections.** The settings are organized from most to least commonly configured: - -- **Common** — [Connection](#connection-settings), [Runtime](#runtime-settings), [Storage](#storage-settings), [Web UI](#web-ui-settings), [Database](#database-settings) -- **Advanced** — [Timeouts](#timeout-settings), [WebSocket Resilience](#websocket-resilience), [Scheduler](#scheduler-settings), [Logging](#logging-settings), [Bus Filtering](#bus-filtering-settings), [Production](#production-settings), [App Detection](#app-detection-settings), [Other Advanced](#other-advanced-settings) - ---- - -## Connection Settings - -- **`base_url`** (string): Home Assistant URL. - - Default: `http://127.0.0.1:8123` - - Must include the scheme (`http://` or `https://`) and port. - -- **`verify_ssl`** (boolean): Whether to verify SSL certificates when connecting to Home Assistant. - - Default: `true` - - Set to `false` if using self-signed certificates. - -- **`import_dot_env_files`** (boolean): Whether to load `.env` file contents into `os.environ`. - - This is useful to allow apps to access these values without needing to import the file. - - Default: `true` - -## Runtime Settings - -- **`apps.directory`** (string): Directory containing your app modules. - - Default: `apps` (relative to the current working directory) - - Example: `src/apps` - - Lives under `[hassette.apps]` in `hassette.toml`. - -- **`dev_mode`** (boolean): Enable development features. - - **Heuristics**: If not explicitly set, Hassette detects dev mode by checking for: - - `debugpy` or `pydevd` in `sys.modules` - - `sys.gettrace()` being set - - `sys.flags.dev_mode` being enabled - - **Features Enabled**: - - Automatic file watching and hot reloading. - - Extended timeouts for tasks and connections. - - Skipping some strict startup pre-checks. - -## Storage Settings - -- **`data_dir`** (string): Directory where Hassette stores persistent data. - - Default: platform-dependent. Docker: `/data`. Linux: `~/.local/share/hassette/vN/`. macOS: `~/Library/Application Support/hassette/vN/`. Where `N` is the installed major version. - - Override with `HASSETTE__DATA_DIR` environment variable for a stable path across upgrades. - - Used for [app cache](../cache/index.md) storage and other data files. - - Each resource class gets its own subdirectory: `{data_dir}/{ClassName}/cache/` - - !!! warning "Major version upgrades" - The default path includes the major version number. Upgrading to a new major version changes the path, which means the telemetry database and cache data appear to "disappear." Set an explicit `data_dir` if you need persistence across major upgrades. - -- **`default_cache_size`** (integer): Maximum size in bytes for each resource's disk cache. - - Default: `104857600` (100 MiB) - - When the limit is reached, least recently used items are automatically evicted. - - See [App Cache](../cache/index.md) for usage details. - -**Example:** - -```toml ---8<-- "pages/core-concepts/configuration/snippets/storage_example.toml" -``` - -## Web UI Settings - -These settings live under `[hassette.web_api]` and control the [web UI](../../web-ui/index.md) and the underlying web API service. - -- **`run`** (boolean): Whether to run the web API service (REST API, healthcheck, and UI backend). - - Default: `true` - -- **`run_ui`** (boolean): Whether to serve the web UI. Only used when `run` is `true`. - - Default: `true` - -- **`host`** (string): Host to bind the web API server to. - - Default: `0.0.0.0` - -- **`port`** (integer): Port to run the web API server on. - - Default: `8126` - - The UI is accessible at `http://:/ui/` - -- **`cors_origins`** (tuple): Allowed CORS origins for the web API. - - Default: `("http://localhost:3000", "http://localhost:5173")` - -- **`event_buffer_size`** (integer): Maximum number of recent events to keep in the ring buffer. - - Default: `500` - -- **`log_buffer_size`** (integer): Maximum number of log entries to keep in the ring buffer. - - Default: `2000` - -- **`job_history_size`** (integer): Maximum number of job execution records to keep in memory. - - Default: `1000` - -- **`ui_hot_reload`** (boolean): Watch web UI static files for changes and push live reloads to the browser via WebSocket. CSS changes are hot-swapped without a page reload; JS changes trigger a full reload. - - Default: `false` - -!!! note "Legacy aliases" - Older configs may use flat key names under `[hassette]` (e.g., `run_web_api`, `web_api_host`, `web_ui_hot_reload`). These are still accepted for backward compatibility but are deprecated. Use the `[hassette.web_api]` nested form shown here. - -**Example:** - -```toml ---8<-- "pages/core-concepts/configuration/snippets/web_ui_example.toml" -``` - -## Database Settings - -These settings live under `[hassette.database]` and control the persistent telemetry database. See [Database & Telemetry](../database-telemetry.md) for details on what is stored. - -| Setting | Type | Default | Description | -|---------|------|---------|-------------| -| `path` | path or null | `null` | Path to the SQLite database file. Defaults to `{data_dir}/hassette.db` when not set. | -| `retention_days` | integer | `7` | Number of days to retain execution records (handler invocations, job executions). Minimum: 1. | -| `max_size_mb` | float | `500` | Maximum database file size in MB. When exceeded, oldest execution records are deleted. Set to `0` to disable the size limit. | - -**Example:** - -```toml ---8<-- "pages/core-concepts/configuration/snippets/database_example.toml" -``` - -## Timeout Settings - -These settings control how long Hassette waits for various operations before giving up. - -| Setting | Type | Default | Description | -|---------|------|---------|-------------| -| `startup_timeout_seconds` | integer | `10` | Time to wait for all Hassette resources to start. | -| `app_startup_timeout_seconds` | integer | `20` | Time to wait for an individual app to start. | -| `app_shutdown_timeout_seconds` | integer | `10` | Time to wait for an individual app to shut down. | -| `total_shutdown_timeout_seconds` | integer | `30` | Maximum wall-clock seconds for the entire Hassette shutdown process. | -| `websocket_authentication_timeout_seconds` | integer | `10` | Time to wait for WebSocket authentication to complete. | -| `websocket_response_timeout_seconds` | integer | `5` | Time to wait for a response from the WebSocket. | -| `websocket_connection_timeout_seconds` | integer | `5` | Time to wait for the WebSocket connection to establish. | -| `websocket_total_timeout_seconds` | integer | `30` | Total time for WebSocket operations to complete. | -| `websocket_heartbeat_interval_seconds` | integer | `30` | Interval for WebSocket keepalive pings. | - -### WebSocket Resilience - -Hassette uses a three-layer retry model to keep the connection to Home Assistant stable across brief disruptions: - -| Layer | What it handles | Config prefix | -|-------|-----------------|---------------| -| **Connection retry** | Initial TCP connect, authentication, and event subscription failures | `websocket_connect_retry_*` | -| **Early-drop retry** | Post-authentication drops that happen within a short window after connect (e.g. HA restarting while Hassette is running) | `websocket_early_drop_*` | -| **Service restart** | Persistent failures after the inner retries are exhausted — Hassette restarts the WebSocket service entirely | Per-service `RestartSpec` (see [Service Supervision](../internals.md#service-supervision)) | - -Each layer is independently configurable. The inner layer must exhaust all its attempts before the next layer takes over. - -**Connection-level retry** (tunes how Hassette reconnects when the initial connection attempt fails): - -| Setting | Type | Default | Description | -|---------|------|---------|-------------| -| `websocket_connect_retry_max_attempts` | integer | `5` | Maximum number of connection attempts before giving up and escalating to the service restart layer. | -| `websocket_connect_retry_initial_wait_seconds` | float | `1.0` | Initial wait between connection retry attempts. Doubles after each failure (exponential backoff with jitter). | -| `websocket_connect_retry_max_wait_seconds` | float | `32.0` | Maximum wait between connection retry attempts. Caps the exponential growth. | - -**Early-drop retry** (tunes how Hassette handles connections that drop shortly after being established): - -| Setting | Type | Default | Description | -|---------|------|---------|-------------| -| `websocket_early_drop_stable_window_seconds` | float | `30.0` | How long (in seconds) after a successful connect a disconnect is considered an "early drop" and eligible for fast retry. Drops outside this window are treated as genuine failures. | -| `websocket_early_drop_max_retries` | integer | `5` | Maximum number of early-drop retries before escalating to the service restart layer. | -| `websocket_early_drop_backoff_initial_seconds` | float | `2.0` | Initial wait between early-drop retry attempts. Jitter (up to this value) is added to each wait to prevent synchronized reconnection storms. | -| `websocket_early_drop_backoff_max_seconds` | float | `60.0` | Maximum wait between early-drop retry attempts (before jitter). | -| `websocket_max_recovery_seconds` | float | `300.0` | Total wall-clock cap (in seconds) for the early-drop retry loop. When this budget is exceeded, the current failure escalates to the service restart layer regardless of remaining per-retry attempts. This prevents the worst-case scenario where many retries each at maximum backoff add up to an unreasonably long recovery window. | - -**When to tune these settings:** - -- **Slow HA restarts** (>30 seconds): Increase `websocket_early_drop_stable_window_seconds` so drops during HA startup are still treated as early-drops eligible for fast retry. -- **Flaky networks**: Increase `websocket_connect_retry_max_attempts` and `websocket_connect_retry_max_wait_seconds` to tolerate longer transient outages. -- **Low tolerance for downtime**: Decrease `websocket_early_drop_backoff_initial_seconds` and `websocket_early_drop_backoff_max_seconds` to retry more aggressively. -- **Large service restart budgets**: Increase `websocket_max_recovery_seconds` so Hassette keeps retrying rather than handing off to the slower service-restart layer. - -For the service restart layer behavior, see [Service Supervision](../internals.md#service-supervision). - -### Timeouts - -Hassette enforces execution timeouts on scheduled jobs and event handlers to prevent runaway tasks from blocking your apps. Two global config fields control the defaults: - -| Setting | Type | Default | Description | -|---------|------|---------|-------------| -| `scheduler_job_timeout_seconds` | float or null | `600.0` | Default timeout in seconds for scheduled job execution. `null` disables the default timeout globally. | -| `event_handler_timeout_seconds` | float or null | `600.0` | Default timeout in seconds for event handler execution. `null` disables the default timeout globally. | - -#### Per-item overrides - -Individual jobs and listeners can override the global default using two parameters available on all scheduling and subscription methods: - -| Parameter | Effect | -|-----------|--------| -| `timeout=None` | Use the global default from config. | -| `timeout=30.0` | Override with an explicit timeout (30 seconds in this example). | -| `timeout_disabled=True` | Disable timeout enforcement entirely for this item, regardless of the global default. | - -When a job or handler exceeds its timeout, Hassette raises `TimeoutError` to cancel the execution and records it as `timed_out` in the telemetry database. - -#### Disabling timeouts globally - -Setting either config field to `null` disables timeout enforcement for all jobs or handlers that do not set an explicit per-item `timeout`. A startup WARNING is emitted when this is detected: - -``` -WARNING — scheduler_job_timeout_seconds is None — execution timeout enforcement is disabled globally — framework components are unprotected -``` - -#### Limitations - -**`TimeoutError` swallowing**: If a handler or job catches `TimeoutError` (or the broader `BaseException` / `Exception`) internally, the timeout mechanism cannot cancel it. The framework records the timeout in telemetry, but the handler continues running. Avoid catching `TimeoutError` unless you have a specific reason to handle it. - -**Sync handlers**: Synchronous handlers wrapped by Hassette run in a thread executor. The timeout applies to the awaitable wrapper, not the underlying thread. If the sync function blocks on I/O or a long computation, cancelling the awaitable does not interrupt the thread — the thread runs to completion while the framework moves on. Use async handlers for operations that need reliable timeout cancellation. - -```toml -[hassette] -# Reduce the default timeout to 30 seconds -scheduler_job_timeout_seconds = 30.0 -event_handler_timeout_seconds = 30.0 - -# Or disable globally (not recommended for production) -# scheduler_job_timeout_seconds = null -# event_handler_timeout_seconds = null -``` - -## Scheduler Settings - -| Setting | Type | Default | Description | -|---------|------|---------|-------------| -| `scheduler_min_delay_seconds` | integer | `1` | Minimum sleep interval for the scheduler loop. Prevents busy-waiting when jobs fire in rapid succession. | -| `scheduler_max_delay_seconds` | integer | `30` | Maximum sleep interval for the scheduler loop. Bounds how long the scheduler may wait before checking for due jobs. | -| `scheduler_default_delay_seconds` | integer | `15` | Default sleep interval used when no jobs are imminently due. | - -## Logging Settings - -These settings live under `[hassette.logging]`. - -Hassette supports per-service log levels for each of its 13 internal services. Each field falls back to the global `log_level` setting (default: `INFO`). - -See [Log Level Tuning](../../advanced/log-level-tuning.md) for the full field list, precedence rules, and examples. - -## Bus Filtering Settings - -Filter out noisy events at the bus level before they reach your apps. - -| Setting | Type | Default | Description | -|---------|------|---------|-------------| -| `bus_excluded_domains` | tuple of strings | `()` | Domains whose events are skipped; supports glob patterns (e.g. `"sensor"`, `"media_*"`). | -| `bus_excluded_entities` | tuple of strings | `()` | Entity IDs whose events are skipped; supports glob patterns. | - -**Example:** - -```toml ---8<-- "pages/core-concepts/configuration/snippets/bus_filter_example.toml" -``` - -## Production Settings - -| Setting | Type | Default | Description | -|---------|------|---------|-------------| -| `allow_reload_in_prod` | boolean | `false` | Enable file watching and automatic app reloads in production mode. Manual app management (start/stop/reload via API) is always available. | -| `allow_only_app_in_prod` | boolean | `false` | Allow the `only_app` decorator in production mode. | - -## App Detection Settings - -| Setting | Table | Type | Default | Description | -|---------|-------|------|---------|-------------| -| `autodetect` | `[hassette.apps]` | boolean | `true` | Automatically discover apps in the app directory. | -| `run_app_precheck` | `[hassette]` | boolean | `true` | Run app precheck before starting. If any apps fail to load, Hassette does not start. | -| `allow_startup_if_app_precheck_fails` | `[hassette]` | boolean | `false` | Allow Hassette to start even if the app precheck fails. Not recommended — failed prechecks usually indicate broken apps that will crash at runtime. | -| `extend_exclude_dirs` | `[hassette.apps]` | tuple of strings | `()` | Additional directories to exclude from app auto-detection. **Use this instead of `exclude_dirs`** — it adds to the defaults rather than replacing them. | -| `exclude_dirs` | `[hassette.apps]` | tuple of strings | *(built-in list)* | Full list of excluded directories. Setting this directly **replaces** the defaults (`.git`, `__pycache__`, `.venv`, etc.), which is usually not what you want. | - -!!! warning - If you need to exclude additional directories from app auto-detection, always use `extend_exclude_dirs`. Setting `exclude_dirs` directly will remove the default exclusions, causing Hassette to scan `.git`, `__pycache__`, virtual environments, and other directories that should be ignored. - -## Advanced Settings - -| Setting | Type | Default | Description | -|---------|------|---------|-------------| -| `hassette_event_buffer_size` | integer | `1000` | Buffer capacity of the internal event channel used to route events to the bus. | -| `asyncio_debug_mode` | boolean | `false` | Enable asyncio debug mode. | -| `watch_files` | boolean | `true` | Watch files for changes and reload apps automatically. | - -## Service Restart Policy - -!!! warning "Removed" - The five global `service_restart_*` config fields (`service_restart_max_attempts`, `service_restart_backoff_seconds`, `service_restart_max_backoff_seconds`, `service_restart_backoff_multiplier`, `service_restart_readiness_timeout_seconds`) have been removed. Remove them from your `hassette.toml` if present — they are no longer read. - - Restart behavior is now declared per-service via a `RestartSpec` class attribute. Each built-in service ships with sensible defaults. See [Service Supervision](../internals.md#service-supervision) for the full model. - -## Other Advanced Settings - -| Setting | Type | Default | Description | -|---------|------|---------|-------------| -| `resource_shutdown_timeout_seconds` | integer | *(same as `app_shutdown_timeout_seconds`)* | Per-phase timeout for resource shutdown. | -| `state_proxy_poll_interval_seconds` | integer | `30` | Interval to poll Home Assistant for state updates (supplements WebSocket events). | -| `disable_state_proxy_polling` | boolean | `false` | Disable state polling entirely (rely only on WebSocket events). | -| `db_migration_timeout_seconds` | integer | `120` | Maximum seconds to wait for database migrations at startup. | -| `file_watcher_debounce_milliseconds` | integer | `3000` | Debounce time for file watcher events. | -| `file_watcher_step_milliseconds` | integer | `500` | Time to wait for additional file changes before emitting an event. Works with the debounce to batch rapid saves. | -| `task_cancellation_timeout_seconds` | integer | `5` | Time to wait for tasks to cancel before forcing. | -| `scheduler_behind_schedule_threshold_seconds` | integer | `5` | Threshold before a "behind schedule" warning is logged. | -| `run_sync_timeout_seconds` | integer | `6` | Default timeout for synchronous function calls. | - -## Basic Example - -```toml ---8<-- "pages/core-concepts/configuration/snippets/basic_config.toml" -``` - -## See Also - -- [Authentication](auth.md) - Tokens and secrets -- [Applications](applications.md) - App registration and configuration -- [App Cache](../cache/index.md) - Using the disk cache diff --git a/docs/pages/core-concepts/configuration/index.md b/docs/pages/core-concepts/configuration/index.md index 3fba28f1c..feda1e802 100644 --- a/docs/pages/core-concepts/configuration/index.md +++ b/docs/pages/core-concepts/configuration/index.md @@ -1,46 +1,129 @@ -# 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 (finding and loading your automation classes), the web UI, storage, and runtime behavior. + +A minimal `hassette.toml` declares the Home Assistant URL, the apps directory, and one app: + +```toml +--8<-- "pages/core-concepts/configuration/snippets/basic_config.toml" +``` + +The token is the only required credential. `HASSETTE__TOKEN` supplies it via environment variable — put it in a `.env` file next to `hassette.toml` (the recommended setup, covered under Configuration Sources below), keeping the credential out of version control. [Create a Long-Lived Access Token](../../getting-started/ha_token.md) covers token generation. ## 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 +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 -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 +When the same setting appears in multiple sources, the higher-precedence source wins. -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`. +`.env` files do double duty: they feed settings resolution and are loaded into `os.environ`, so other libraries see those variables too. `import_dot_env_files = false` limits them to settings resolution only — useful when the process environment is managed externally (a container orchestrator, systemd) and `.env` should not leak into it. ## 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`][hassette.config.HassetteConfig] is the Pydantic settings model that backs `hassette.toml`. It organizes settings into named subsections, each mapping to a TOML table: + +| 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 | + +App definitions live inside `[hassette.apps]` as named subsections, as shown in the opening example. [App Configuration](../apps/configuration.md) covers registration details and multi-instance configuration. + +## Design Notes + +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 + +`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`](../apps/index.md) subclass — the base class for all Hassette automations. -## Credentials +`extend_exclude_dirs` adds directories to the built-in exclusion list (`.venv`, `venv`, `__pycache__`, `.pytest_cache`, `.mypy_cache`, `.git`). `exclude_dirs` replaces it entirely. Setting `exclude_dirs` directly removes the framework defaults and can cause Hassette to scan directories it would normally skip. -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: +`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 benefit from leaving it disabled. + +### Event Filtering + +`bus_excluded_domains` and `bus_excluded_entities` drop events before any handler sees them — the [bus](../bus/index.md) is Hassette's event delivery system, and handlers are the app functions subscribed to it. Both settings accept glob patterns. + +```toml +--8<-- "pages/core-concepts/configuration/snippets/bus_filter_example.toml" +``` + +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. + +`hassette_event_buffer_size` (default 1000) sets the capacity of the internal channel that carries events from the WebSocket to the bus. When the buffer fills, event intake pauses until handlers catch up — events are delayed, not dropped. Raising the buffer absorbs longer bursts; excluding noisy domains is usually the better first move. + +### 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. + +`strict_lifecycle` makes internal lifecycle problems raise exceptions instead of logging warnings. The default (`false`) is right for production; the [test harness](../../testing/harness.md) enables it by default so tests fail loudly. The exceptions it raises (`InvalidLifecycleTransitionError`, `RegistryValidationError`) are covered in [Troubleshooting](../../troubleshooting.md). + +`allow_reload_in_prod` enables the file watcher's automatic app reloads outside `dev_mode`. Manual app management (start/stop/reload via the API or web UI) works regardless of this setting. + +### File Watcher + +The file watcher reloads apps when their source files change (in `dev_mode`, or with `allow_reload_in_prod`). `[hassette.file_watcher]` tunes it: `debounce_milliseconds` (default 3000) is the quiet period required after the last change before a reload fires, `step_milliseconds` (default 500) is how long the watcher waits for additional changes to batch into the same reload, and `watch_files = false` disables watching entirely. + +### State Proxy Polling + +The [`StateManager`](../states/index.md) — the local entity-state cache apps access via `self.states` — keeps a copy of all 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. + +## Verify the Configuration + +Run `hassette status` to confirm Hassette can reach Home Assistant with the current config: ``` -HASSETTE__TOKEN=your_token_here +hassette status ``` -See [Authentication](auth.md) for all credential options. +A successful connection shows `status: ok` and the installed Hassette version. Auth failures show `websocket_connected: false` with a `degraded` or `starting` status — check the token value and `verify_ssl` setting. + +## Full Reference + +The [`HassetteConfig`][hassette.config.HassetteConfig] API reference lists every field with its type, default, and description. -## See Also +## Next Steps -- [**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 +- [App Configuration](../apps/configuration.md): registering apps, passing config values, and multi-instance setup +- [Apps overview](../apps/index.md): defining `AppConfig` models and accessing config values in Python +- [CLI Configuration](../../cli/configuration.md): CLI flags and environment variables for runtime overrides 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/database_example.toml b/docs/pages/core-concepts/configuration/snippets/database_example.toml deleted file mode 100644 index b707c660f..000000000 --- a/docs/pages/core-concepts/configuration/snippets/database_example.toml +++ /dev/null @@ -1,4 +0,0 @@ -[hassette.database] -path = "/var/lib/hassette/telemetry.db" -retention_days = 14 -max_size_mb = 1000 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"] } diff --git a/docs/pages/core-concepts/configuration/snippets/storage_example.toml b/docs/pages/core-concepts/configuration/snippets/storage_example.toml deleted file mode 100644 index 64708aa02..000000000 --- a/docs/pages/core-concepts/configuration/snippets/storage_example.toml +++ /dev/null @@ -1,3 +0,0 @@ -[hassette] -data_dir = "/var/lib/hassette" -default_cache_size = 209715200 # 200 MiB diff --git a/docs/pages/core-concepts/configuration/snippets/web_ui_example.toml b/docs/pages/core-concepts/configuration/snippets/web_ui_example.toml deleted file mode 100644 index 21c4ee36a..000000000 --- a/docs/pages/core-concepts/configuration/snippets/web_ui_example.toml +++ /dev/null @@ -1,3 +0,0 @@ -[hassette.web_api] -run_ui = true -port = 8126 diff --git a/docs/pages/core-concepts/database-telemetry.md b/docs/pages/core-concepts/database-telemetry.md index 716ca670f..aec9a1844 100644 --- a/docs/pages/core-concepts/database-telemetry.md +++ b/docs/pages/core-concepts/database-telemetry.md @@ -1,89 +1,77 @@ # 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: every [bus](bus/index.md) handler invocation, every [scheduled job](scheduler/index.md) execution, and app health metrics. The [web UI](../web-ui/index.md) reads this data for its panels — the stats strip on the Apps page, the Error Spotlight, and the handler health grid all draw from it. ## 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. +**Listener registrations.** Every registered bus listener is stored by name and topic in the `listeners` table. Counts appear in the Apps page stats strip. -## Execution Columns +**Job registrations.** Every scheduled job is stored in the `scheduled_jobs` table. Counts appear alongside listener counts in the stats strip. -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. - -The following columns are present on every row regardless of `kind`. - -| 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 and live in `hassette.toml` (see [Configuration](configuration/index.md)). 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` (`~/.local/share/hassette/v0/hassette.db` on Linux). | +| `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. | + +??? note "Advanced: queue, interval, and failsafe tuning" + The remaining `[hassette.database]` fields tune internals. They rarely need changing; the symptoms below name the cases that do. + + **Write queues.** `write_queue_max` (default 2000) bounds the pending write queue; when full, some telemetry writes are silently dropped — automations are not affected. `telemetry_write_queue_max` (default 1000) bounds the telemetry record queue the same way. `max_flush_interval_seconds` (default 5.0) forces a batch flush even when the batch-size threshold has not been reached. Raise the queue bounds when sustained event bursts log dropped-record warnings and memory headroom exists. + + **Health and reads.** `heartbeat_interval_seconds` (default 300) is the gap between database health checks; `max_consecutive_heartbeat_failures` (default 3) failures put the service in [degraded mode](#degraded-mode). `read_timeout_seconds` (default 10.0) caps telemetry read queries before `TimeoutError`. `migration_timeout_seconds` (default 120) caps schema migrations at startup — raise it on slow storage with a large database. + + **Retention cadence.** `retention_interval_seconds` (default 3600) and `size_failsafe_interval_seconds` (default 3600) set how often the two maintenance routines run. The size failsafe deletes `size_failsafe_delete_batch` rows per batch (default 1000), up to `size_failsafe_max_iterations` batches per run (default 10), then vacuums `size_failsafe_vacuum_pages` pages (default 100). Lower the intervals when the database overshoots `max_size_mb` between runs. ### 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 runs two deletes: execution records older than `retention_days` (default: 7) from the `executions` table, and log records older than `logging.log_retention_days` (default: 3) from the `log_records` 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 combines the app key, instance index, `name=` value, and topic. Predicate configuration is stored as display metadata and does not affect matching. 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. During development, renaming a handler or changing its topic leaves the old registration visible until it ages out (default 7 days). + +## Checking Telemetry Health -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. +Three commands and their API equivalents cover telemetry and system health. -### `/api/health` +**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 | -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. +**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 | |---|---|---| @@ -93,41 +81,38 @@ This is the system-level status view for Hassette as a whole. It reports whether A fatal crash (a PERMANENT service exhausting its restart budget, or a startup failure) records a `failure` status to the current telemetry session before Hassette exits with a non-zero exit code. A clean operator shutdown (SIGTERM / `docker stop`) exits 0. -For container restart automation, use `/api/health/live` or rely on the non-zero exit and a restart policy. Use `/api/health` for the human-readable aggregate view and use `/api/health/ready` for load-balancer routing. See [Health Endpoints](../web-ui/health-endpoints.md) for the full reference. +For container restart automation, use `/api/health/live` or rely on the non-zero exit and a restart policy. Use `/api/health` for the human-readable aggregate view and use `/api/health/ready` for load-balancer routing. See [Configure Health Checks](../web-ui/health-endpoints.md) for the full reference. !!! 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 the log lines emitted during a specific invocation. Execution metadata (trigger origin, error traceback) appears in `hassette listener ` and `hassette job ` output. The UUID comes from the `Execution ID` column of `hassette listener ` or `hassette job ` output. ## 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. + +In degraded mode: -- 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. +- 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. ## 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/index.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..f432b07fc 100644 --- a/docs/pages/core-concepts/index.md +++ b/docs/pages/core-concepts/index.md @@ -1,245 +1,57 @@ # 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 (`States`). -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 of these resources as handles — 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: - - - `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. - -### Diagrams - -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. - -#### 1) High-level flow - -```mermaid -flowchart TD - subgraph ha["Home Assistant"] - HA["Events + API"] - end - - subgraph hassette["Hassette"] - H["Framework"] - end - - subgraph apps["Your Apps"] - APPS["Automations"] - end - - HA <--> H - H <--> APPS - - style ha fill:#f0f0f0,stroke:#999 - style hassette fill:#fff0e8,stroke:#cc8844 - style apps fill:#e8f0ff,stroke:#6688cc -``` - -#### 2) Core services inside Hassette - -```mermaid -flowchart TD - H[Hassette] - - subgraph infra["Infrastructure"] - direction LR - WS[WebsocketService] - DB[DatabaseService] - end - - subgraph core["Core"] - direction LR - BUS[BusService] - SCHED[SchedulerService] - API[ApiResource] - STATE[StateProxy] - end - - subgraph web["Web"] - direction LR - WEB[WebApiService] - RTQ[RuntimeQueryService] - TQ[TelemetryQueryService] - end - - subgraph apps["Apps"] - APPH[AppHandler] - end - - H --- infra & core & web & apps - - style infra fill:#f0f0f0,stroke:#999 - style core fill:#fff0e8,stroke:#cc8844 - style web fill:#f0f8e8,stroke:#88aa66 - style apps fill:#e8f0ff,stroke:#6688cc -``` - -#### 3) What each app gets (lightweight handles) +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. ```mermaid flowchart TD subgraph app["App Instance"] - APP["Your App"] + APP["App"] end - subgraph handles["Lightweight Handles"] + subgraph handles["Handles"] direction LR API[Api] BUS[Bus] SCHED[Scheduler] STATES[States] - CACHE[Cache] end - APP --> API & BUS & SCHED & STATES & CACHE + APP --> API & BUS & SCHED & STATES 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 +## Topics -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 (`on_initialize`, `on_shutdown`), and [`AppConfig`][hassette.app.app_config.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/lifecycle.md): service lifecycle, startup sequence, and the internal tree of resources that backs the per-app handles. +- [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. + - [State Conversion](states/conversion.md): domain-to-class mapping and value type conversion. + - [Custom States](states/custom-states.md): defining custom state classes for non-standard entity types. diff --git a/docs/pages/core-concepts/internals.md b/docs/pages/core-concepts/internals.md deleted file mode 100644 index 267e1fbca..000000000 --- a/docs/pages/core-concepts/internals.md +++ /dev/null @@ -1,574 +0,0 @@ ---- -hide: - - toc ---- - -# Hassette Architecture Overview - -Hassette is an async-first Python framework for building Home Assistant automations. It connects to Home Assistant over WebSocket, routes incoming events through a typed pub/sub bus, dispatches them to user-defined App classes, and provides a web UI for monitoring the running system. - ---- - -## 1. Component Ownership - -Every component is a `Resource` in a parent/child tree rooted at the `Hassette` instance. Apps get 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 - 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 - App --> Cache - end - - AppLifecycleService --> App - - style infra fill:#f0f0f0,stroke:#999 - style core fill:#e8f0ff,stroke:#6688cc - style web fill:#f0f8e8,stroke:#88aa66 - style apps fill:#fff0e8,stroke:#cc8844 - style perapp fill:#f8f0ff,stroke:#8866cc -``` - -Per-app handles are thin wrappers. When an app shuts down, its `Bus` removes its listeners from `BusService`, its `Scheduler` removes its jobs from `SchedulerService`, and so on. The shared services continue running for other apps. - ---- - -## 2. Service Dependencies - -Services declare `depends_on` at the class level. The framework computes wave-based startup order from these declarations. Arrows point from dependents down to their dependencies — services at the top start last. - -```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] - 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 - - BUS --> DB - SCHED --> DB - 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 -``` - -Shutdown proceeds in reverse wave order — `WebApiService` stops first, `DatabaseService` and `WebsocketService` stop last. - ---- - -## 3. Event and Data Flow - -Events flow from Home Assistant through a four-stage inbound pipeline. Outbound calls go through the `Api` handle back to HA via REST or WebSocket. - -```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:#e8f0ff,stroke:#6688cc - style cache fill:#f0f8e8,stroke:#88aa66 - style app fill:#fff0e8,stroke:#cc8844 - style outbound fill:#f8f0ff,stroke:#8866cc -``` - -`StateProxy` subscribes to state_changed events at priority 100, so its cache is always updated before any user handler sees the event. The `CommandExecutor` records every invocation to SQLite for the telemetry UI. - -| Failure | Behavior | -|---|---| -| WS disconnect | `_make_connection` retries up to 5 times (tenacity, exponential jitter). If `serve()` still fails, `ServiceWatcher` restarts the service per its `RestartSpec` (TRANSIENT, budget 5/300s). | -| Auth failure | `InvalidAuthError` is a `FatalError` subclass — it bypasses `ServiceWatcher` entirely. `_serve_wrapper` catches it and calls `handle_crash()`, setting the service to CRASHED. Hassette shuts down immediately. | -| Handler timeout | Logged, invocation recorded as timed-out | -| DB write failure | 3 retries, then dropped with counter increment | - ---- - -## 4. Bus Internals - -The `Bus` handle translates `on_*()` calls into `Listener` objects, which the shared `BusService` indexes by topic for fast 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 -``` - -**Topic expansion.** A `state_changed` event for `light.office` produces three topics in specificity order: `hass.event.state_changed.light.office`, `hass.event.state_changed.light.*`, `hass.event.state_changed`. - -**Listener behaviors:** - -| Option | Effect | -|---|---| -| `debounce=N` | Buffer events, fire only if quiet for N seconds | -| `throttle=N` | Fire immediately, suppress for N seconds | -| `duration=N` | Fire only if predicate still matches after N seconds | -| `once=True` | Auto-remove after first invocation | -| `priority=N` | Lower values dispatch first (StateProxy uses 100) | - ---- - -## 5. Scheduler Internals - -The `Scheduler` handle wraps convenience methods (`run_in`, `run_once`, `run_every`, `run_daily`, `run_cron`, `schedule`) around trigger objects. All jobs end up in 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 -``` - -Built-in triggers: `After` (one-shot delay), `Once` (one-shot at time), `Every` (recurring interval), `Daily` (DST-safe cron), `Cron` (croniter expression). Custom triggers implement `TriggerProtocol`. - -- `Daily` uses cron internally for DST-safe wall-clock scheduling. A naive 24-hour interval would drift across DST transitions. -- `jitter` adds random offset at enqueue time to spread concurrent starts. -- Job groups (`group=`) enable bulk cancellation. Named jobs (`name=`) support deduplication via `if_exists="skip"`. - ---- - -## 6. Database Internals - -Hassette stores all telemetry in a local SQLite database managed by `DatabaseService`. Schema migrations use SQLite's native `PRAGMA user_version` — no external migration tool. - -### Schema and Migrations - -The migration runner reads `PRAGMA user_version` on startup 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 database at the previous version; the next startup retries from where it left off. - -When the on-disk schema version is older than the code expects, the runner applies forward migrations. When it is newer (database created by a newer binary), `DatabaseService` raises `SchemaVersionError` — a fatal error that stops startup and requires manual intervention rather than automatic deletion. When the database is corrupt or otherwise unrecoverable, deleting it is safe: telemetry is observability data and does not affect app execution. Hassette recreates an empty database on the next startup. - -On a fresh database (`user_version = 0`), the runner configures `auto_vacuum = INCREMENTAL` via a separate `sqlite3.Connection` before any transaction, because `PRAGMA auto_vacuum` cannot be set inside `BEGIN IMMEDIATE`. - -### Unified Executions Table - -Handler invocations and scheduled job executions are stored in a single `executions` table with a `kind` discriminator (`'handler'` or `'job'`). Two nullable foreign keys — `listener_id` and `job_id` — point to the registration tables (`listeners` and `scheduled_jobs` respectively). A `CHECK` constraint enforces that exactly one is non-null per row. - -Registration tables remain separate: `listeners` stores bus listener registrations with their natural key `(app_key, instance_index, name, topic)`; `scheduled_jobs` stores scheduled job registrations. - -### Synchronous Registration - -`BusService` and `SchedulerService` both declare `depends_on: [DatabaseService]`, so the database is always ready before any listener or job registration can occur. - -Each `bus.on_state_change()` (and all other `bus.on_*()` methods) awaits the database INSERT inline before returning. The listener's `db_id` is a valid integer immediately when the awaited call returns — there is no background registration task or deferred persistence. - -The same applies to scheduler methods: `scheduler.run_every()` and all other `scheduler.run_*()` methods await the job registration before returning. - ---- - -## 7. Api Internals - -The per-app `Api` handle delegates all transport to shared singletons. Single-entity reads use REST; bulk reads and service calls use 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 -``` - -| Method | Transport | Pattern | -|---|---|---| -| `get_state(entity_id)` | REST | `GET /api/states/{id}` | -| `get_states()` | WebSocket | `get_states` command | -| `call_service()` | WebSocket | fire-and-forget or `send_and_wait` | -| `fire_event()` | WebSocket | fire-and-forget | - -Auth: long-lived access token from `HassetteConfig.token`. Injected as `Bearer` header (REST) and `auth` handshake (WebSocket). - ---- - -## 8. StateManager and StateProxy - -`StateProxy` maintains an in-memory cache of all entity states. `StateManager` provides typed per-app access with Pydantic model validation and caching. - -```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 -``` - -- Read access is lock-free — CPython dict assignment is atomic; the proxy replaces whole objects rather than mutating. -- `DomainStates` caches validated Pydantic models keyed by `context_id` (a UUID from HA). Same context ID = return cached model without re-validating. -- On disconnect, `StateProxy` clears the cache and marks itself not-ready. On reconnect, it bulk-reloads via `get_states_raw()`. - ---- - -## 9. Web/UI Layer - -The web layer is opt-in. `WebApiService` starts a uvicorn/FastAPI server. The frontend is a Preact SPA. Two data source services provide live and historical data. - -```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 -``` - -- `RuntimeQueryService` subscribes to bus events and fan-out broadcasts to all connected WebSocket clients via `asyncio.Queue` per client. -- The SPA catch-all returns `index.html` for all non-asset paths, enabling client-side routing. -- When `config.run_web_api` is False, the service blocks on `shutdown_event.wait()` without binding a port, preserving the dependency graph. - ---- - -## 10. Resource Lifecycle - -Every component extends `Resource` (synchronous init) or `Service` (long-running `serve()` loop). The `LifecycleMixin` provides status transitions and readiness signaling. - -### State Transitions - -```mermaid -flowchart TD - accTitle: Resource Lifecycle States - accDescr: Status transitions for all framework components - - NOT_STARTED:::neutral -- "start()" --> STARTING:::active - STARTING -- "handle_running()" --> RUNNING:::active - RUNNING -- "shutdown()" --> STOPPING:::active - STOPPING -- "handle_stop()" --> STOPPED:::neutral - - STARTING -- "error" --> FAILED:::error - RUNNING -- "error" --> FAILED - RUNNING -- "FatalError" --> CRASHED:::error - FAILED -- "restart()" --> STARTING - FAILED -- "PERMANENT\nexhausted" --> CRASHED - FAILED -- "TEMPORARY\nexhausted" --> EXHAUSTED_DEAD:::error - FAILED -- "TRANSIENT\nexhausted" --> EXHAUSTED_COOLING:::error - EXHAUSTED_COOLING -- "after cooldown" --> STARTING - EXHAUSTED_COOLING -- "cooldown limit\nexceeded" --> EXHAUSTED_DEAD - - classDef neutral fill:#f0f0f0,stroke:#999,color:#333 - classDef active fill:#e8f0ff,stroke:#6688cc,color:#333 - classDef error fill:#ffe8e8,stroke:#cc6666,color:#333 -``` - -### Readiness vs. Running - -These are **separate concerns** that are easy to confuse: - -| Concept | Method | What it does | Who calls it | -|---|---|---|---| -| **Status** | `handle_running()` | Sets RUNNING, emits event | Framework (automatic) | -| **Readiness** | `mark_ready()` | Unblocks `depends_on` waiters | Resource: end of `on_initialize()`. Service: inside `serve()` once processing | - -A component can be RUNNING but not ready (still initializing internal state), or ready but not yet RUNNING (edge case during transition). - -### Wave Startup and Shutdown - -Dependencies are computed into topological levels. Within a wave, the framework calls `start()` on each child so their initialization can proceed concurrently, then waits for all to become ready before starting the next wave. - -Shutdown proceeds in reverse wave order. A per-wave timeout triggers `_force_terminal()` on non-compliant children, which recursively force-stops without running hooks (accepted risk for stuck services). - -### Service Supervision - -When a `Service` transitions to FAILED, `ServiceWatcher` reads that service's `restart_spec` class attribute and drives the restart decision. Every `Service` subclass declares a `RestartSpec` as a class-level attribute; services that don't declare one inherit the default (`TRANSIENT`, budget 5/300s). - -#### RestartSpec - -`RestartSpec` is a frozen dataclass in `hassette.resources.restart`: - -| Field | Type | Default | Description | -|---|---|---|---| -| `restart_type` | `RestartType` | `TRANSIENT` | Strategy governing restart and exhaustion behavior | -| `non_retryable_error_names` | `tuple[str, ...]` | `()` | Exception names that skip restart and go straight to exhaustion handling | -| `fatal_error_names` | `tuple[str, ...]` | `()` | Exception names that trigger immediate system shutdown | -| `backoff_base_seconds` | `float` | `2.0` | Initial delay before first restart attempt | -| `backoff_multiplier` | `float` | `2.0` | Factor applied to backoff on each successive attempt | -| `backoff_max_seconds` | `float` | `60.0` | Maximum backoff delay | -| `budget_intensity` | `int` | `5` | Maximum restarts allowed within the sliding window | -| `budget_period_seconds` | `float` | `300.0` | Sliding window size in seconds | -| `startup_timeout_seconds` | `float` | `30.0` | How long to wait for `mark_ready()` after a restart | -| `cooldown_seconds` | `float` | `300.0` | Duration of the long-cooldown phase (TRANSIENT services only) | -| `max_cooldown_cycles` | `int` | `0` | Maximum cooldown cycles before transitioning to EXHAUSTED_DEAD; `0` means infinite | - -**Usage:** - -```python ---8<-- "pages/core-concepts/snippets/internals_restart_spec.py" -``` - -#### RestartType - -`RestartType` is a `StrEnum` with three values: - -| Value | Behavior when budget is exhausted | -|---|---| -| `PERMANENT` | Transitions to CRASHED and triggers system shutdown. Used for services that are structurally required (BusService, SchedulerService). | -| `TRANSIENT` | Enters a long cooldown (`EXHAUSTED_COOLING`), then resets the budget and retries. Useful for services with intermittent failures (WebsocketService, DatabaseService). | -| `TEMPORARY` | Transitions to EXHAUSTED_DEAD — no further restarts. Used for optional background services (FileWatcherService, WebUiWatcherService). | - -#### Sliding-Window Budget - -`RestartBudget` tracks restart timestamps within a rolling time window. When the number of recorded restarts within the window reaches `budget_intensity`, the budget is exhausted. - -The window slides continuously: a restart from 10 minutes ago no longer counts against the budget if `budget_period_seconds` is 300. When a service successfully reaches RUNNING and signals readiness, the budget resets automatically — brief instability followed by a successful recovery doesn't accumulate permanently toward exhaustion. - -#### Three-Layer Error Routing - -`ServiceWatcher.restart_service()` evaluates each FAILED event through three layers before deciding to restart: - -1. **FatalError subclasses** — raised inside `serve()`, caught by the service wrapper, route directly to CRASHED status and shutdown. These bypass `ServiceWatcher` entirely. -2. **`fatal_error_names`** — exception type names checked by `ServiceWatcher` on FAILED events. Triggers immediate system shutdown even if restarts remain in the budget. -3. **`non_retryable_error_names`** — exception type names checked by `ServiceWatcher`. Skips the restart entirely and jumps directly to exhaustion handling. - -Errors that don't match any of the above proceed through the normal restart flow: budget check → exponential backoff → restart. - -#### New Statuses - -Two statuses represent exhaustion states specific to services: - -| Status | Meaning | -|---|---| -| `EXHAUSTED_DEAD` | Budget exhausted, no further restarts will occur. Terminal state. | -| `EXHAUSTED_COOLING` | Budget exhausted; service is in long-cooldown before budget reset and retry. | - -#### Per-Service Restart Specs - -| Service | Type | Budget (intensity/period) | Notes | -|---|---|---|---| -| `BusService` | `PERMANENT` | 2 / 30s | Structural — shutdown if it can't stay up | -| `SchedulerService` | `PERMANENT` | 2 / 30s | Structural — shutdown if it can't stay up | -| `WebsocketService` | `TRANSIENT` | 5 / 300s | startup_timeout=60s — HA may take time to come back | -| `DatabaseService` | `TRANSIENT` | 3 / 120s | `fatal_error_names=("SchemaVersionError",)` | -| `WebApiService` | `TRANSIENT` | 3 / 60s | | -| `CommandExecutor` | `TRANSIENT` | 3 / 120s | | -| `FileWatcherService` | `TEMPORARY` | 3 / 60s | Optional — stops permanently on exhaustion | -| `WebUiWatcherService` | `TEMPORARY` | 3 / 60s | Optional — stops permanently on exhaustion | diff --git a/docs/pages/core-concepts/internals/index.md b/docs/pages/core-concepts/internals/index.md new file mode 100644 index 000000000..9d0c8db77 --- /dev/null +++ b/docs/pages/core-concepts/internals/index.md @@ -0,0 +1,225 @@ +# Architecture & Data Flow + +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`][hassette.core.service_watcher.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`][hassette.core.websocket_service.WebsocketService] receives raw frames from Home Assistant over a persistent WebSocket connection. It forwards each event to [`EventStreamService`][hassette.core.event_stream_service.EventStreamService], which owns an anyio memory channel (an in-process bounded queue) that decouples reception from processing. [`BusService`][hassette.core.bus_service.BusService] reads from that channel and expands each event into a set of topic strings ordered by specificity — for example, a `state_changed` event for `light.office` produces `hass.event.state_changed.light.office`, `hass.event.state_changed.light.*`, and `hass.event.state_changed`. Handlers subscribed to any matching topic fire. [`CommandExecutor`][hassette.core.command_executor.CommandExecutor] invokes the matching handler and writes a telemetry 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`][hassette.core.state_proxy.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][hassette.api.api.Api] handle, which delegates to shared framework services: single-entity reads use [`ApiResource`][hassette.core.api_resource.ApiResource] over REST, while 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`][hassette.exceptions.InvalidAuthError] is a [`FatalError`][hassette.exceptions.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`][hassette.core.core.Hassette] root participate. Per-app resources ([`Bus`][hassette.bus.bus.Bus], [`Scheduler`][hassette.scheduler.scheduler.Scheduler], `Api`, [`StateManager`][hassette.state_manager.state_manager.StateManager]) are `Resource` instances managed by `AppHandler`, not `Service` subclasses. They initialize during app startup, after the service graph is fully ready, so they 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`][hassette.core.web_api_service.WebApiService] stops first. [`DatabaseService`][hassette.core.database_service.DatabaseService] and `WebsocketService` stop last. + +## Component Ownership + +Every component is a [Resource][hassette.resources.base.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`][hassette.core.scheduler_service.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 new file mode 100644 index 000000000..7c6c7d5ea --- /dev/null +++ b/docs/pages/core-concepts/internals/lifecycle.md @@ -0,0 +1,119 @@ +# Resource Lifecycle & Supervision + +A [`Resource`][hassette.resources.base.Resource] is any component with a managed lifecycle — Hassette initializes and shuts it down in dependency order. A [`Service`][hassette.resources.service.Service] is a long-running background Resource. Unlike plain resources that initialize once, services can be restarted if they fail. Each service declares a restart policy that controls backoff timing, budget limits, and recovery-failure behavior. This page covers the supervision model, the [service state machine](#resource-state-machine), and readiness signaling. + +## 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`][hassette.core.service_watcher.ServiceWatcher] — an internal supervisor component with no user-facing API — receives that event and consults the service's `restart_spec` (a policy object declaring retry behavior) to decide what comes next. + +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`][hassette.types.enums.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`][hassette.core.bus_service.BusService] and [`SchedulerService`][hassette.core.scheduler_service.SchedulerService] — the shared services behind every app's `self.bus` and `self.scheduler` — 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`][hassette.core.websocket_service.WebsocketService], [`DatabaseService`][hassette.core.database_service.DatabaseService], and [`WebApiService`][hassette.core.web_api_service.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 | +| `WebUiWatcherService` | `TEMPORARY` | 3 | 60 | Web UI live-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`. The service transitions immediately to `CRASHED` and `hassette.shutdown()` is called. `DatabaseService` uses this for [`SchemaVersionError`][hassette.exceptions.SchemaVersionError]. A schema version mismatch requires human intervention, so no retry is attempted. [`FatalError`][hassette.exceptions.FatalError] subclasses take a separate path: the service catches them itself in `_serve_wrapper()` and calls `handle_crash()` directly, going to `CRASHED` without ever emitting the `FAILED` event that this routing reads. + +## RestartSpec Reference + +[`RestartSpec`][hassette.resources.restart.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][hassette.resources.base.Resource] and `Service` tracks its status as a [`ResourceStatus`][hassette.types.enums.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` when its `serve()` loop begins. `initialize()` returns while the service is still `STARTING`; the spawned `_serve_wrapper()` task calls `handle_running()` once `serve()` starts executing. A service signals readiness by calling `mark_ready()` at whatever internal point it is prepared to serve requests. `WebsocketService` calls `mark_ready()` after the first successful connection, authentication, and event subscription 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()` when `serve()` begins | 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 new file mode 100644 index 000000000..c688cb91b --- /dev/null +++ b/docs/pages/core-concepts/internals/service-details.md @@ -0,0 +1,375 @@ +# System Internals: Per-Service Details + +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 + +Each app gets a [`Bus`][hassette.bus.bus.Bus] handle — a lightweight per-app object that delegates to the shared [`BusService`][hassette.core.bus_service.BusService] singleton. The `Bus` translates `on_*()` calls into [`Listener`][hassette.bus.listeners.Listener] objects. `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`][hassette.task_bucket.task_bucket.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`][hassette.bus.listeners.ListenerIdentity] | Ownership and telemetry fields (app key, name, topic, source location) | +| [`ListenerOptions`][hassette.bus.listeners.ListenerOptions] | Behavioral timing parameters (debounce, throttle, once, priority, immediate) | +| [`HandlerInvoker`][hassette.bus.listeners.HandlerInvoker] | Handler invocation, dispatch, and rate limiting | +| [`DurationConfig`][hassette.bus.listeners.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` | Higher values dispatch first; [`StateProxy`][hassette.core.state_proxy.StateProxy] uses priority 100 | + +## `Scheduler` Internals + +[`Scheduler`][hassette.scheduler.scheduler.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`][hassette.core.scheduler_service.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`][hassette.core.command_executor.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, delay=N)` | [`After`][hassette.scheduler.triggers.After] | One-shot after N seconds | +| `run_once(fn, at=T)` | [`Once`][hassette.scheduler.triggers.Once] | One-shot at a specific time | +| `run_every(fn, seconds=N)` | [`Every`][hassette.scheduler.triggers.Every] | Recurring every N seconds | +| `run_daily(fn, at="HH:MM")` | [`Daily`][hassette.scheduler.triggers.Daily] | Wall-clock daily at HH:MM | +| `run_cron(fn, expression=E)` | [`Cron`][hassette.scheduler.triggers.Cron] | Croniter expression | +| `schedule(fn, trigger=T)` | Custom `T` | Implements [`TriggerProtocol`][hassette.types.types.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 second (post-transition) 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` is a shared singleton maintaining an in-memory cache of all entity states. [`StateManager`][hassette.state_manager.state_manager.StateManager] is the per-app interface over it — the `self.states` handle — providing typed access with Pydantic model validation. App code never touches `StateProxy` directly. + +```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`][hassette.state_manager.state_manager.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`][hassette.exceptions.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][hassette.api.api.Api] handle delegates all network I/O to two shared singletons: [`ApiResource`][hassette.core.api_resource.ApiResource] (REST) and [`WebsocketService`][hassette.core.websocket_service.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`][hassette.exceptions.InvalidAuthError], a [`FatalError`][hassette.exceptions.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`][hassette.core.database_service.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` stores one row per handler invocation or job execution. 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 unversioned legacy schema, delete and recreate | +| Newer than expected head | Raise [`SchemaVersionError`][hassette.exceptions.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`][hassette.core.web_api_service.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. diff --git a/docs/pages/core-concepts/scheduler/index.md b/docs/pages/core-concepts/scheduler/index.md index f899fff5d..8a2417fde 100644 --- a/docs/pages/core-concepts/scheduler/index.md +++ b/docs/pages/core-concepts/scheduler/index.md @@ -1,46 +1,56 @@ -# 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()`. +## How It Works -```mermaid -flowchart TD - subgraph app["Your App"] - methods["run_*() / schedule()"] - end +All scheduling methods delegate to `schedule(func, trigger)`, which pairs a callable with a trigger object (a value like `After(seconds=5)` or `Daily(at="07:00")` that describes the schedule). Sync callables (plain `def`) are wrapped in a thread pool automatically, so blocking I/O is safe without extra setup. - subgraph framework["Scheduler"] - SCHED["SchedulerService"] - JOB["ScheduledJob"] - SCHED -- "manages" --> JOB - end +Each call returns a [`ScheduledJob`][hassette.scheduler.classes.ScheduledJob] handle. The handle cancels the job, inspects its next fire time, or checks whether it has already run. [Job Management](management.md) covers the full handle API. - methods --> SCHED +## Common Patterns - style app fill:#e8f0ff,stroke:#6688cc - style framework fill:#fff0e8,stroke:#cc8844 +### Run after a delay + +`run_in` schedules a one-shot job that fires after a fixed number of seconds. + +```python +--8<-- "pages/core-concepts/scheduler/snippets/scheduler_run_in.py" ``` -## Trigger Types +The `delay` parameter accepts seconds as a `float`. The job fires once and does not repeat. + +### Run on a repeating interval -All triggers live in `hassette.scheduler.triggers` and are importable from `hassette.scheduler`: +`run_every` schedules a job that fires repeatedly on a fixed interval. + +```python +--8<-- "pages/core-concepts/scheduler/snippets/scheduler_run_every.py" +``` -| 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 | -| `Cron("expr")` | Arbitrary cron expression (5- or 6-field) | No | +`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. -### Examples +### 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_start_examples.py:start_examples" +--8<-- "pages/core-concepts/scheduler/snippets/scheduler_run_daily.py" ``` +The `at` parameter accepts `"HH:MM"` strings. Without `at=`, the job fires at midnight local time. `run_daily` is DST-safe — it fires at the local wall-clock time regardless of clock changes. + +??? note "Synchronous usage (AppSync only)" + [`AppSync`][hassette.app.app.AppSync] is an alternative base class for automations that must call blocking libraries. Its lifecycle hooks run in a worker thread outside the async event loop, so `self.scheduler.sync` exposes a [`SchedulerSyncFacade`][hassette.scheduler.sync.SchedulerSyncFacade] that mirrors all scheduling methods as blocking calls. The [Apps](../apps/index.md) page covers the `AppSync` pattern. + +`name=` identifies each job in logs and the [monitoring UI](../../web-ui/index.md). It must be unique within the app instance — duplicates raise `ValueError`. See [Scheduling Methods](methods.md) for details. + +## Verify It's Working + +Run `hassette job` to see all scheduled jobs for your running instance, where `` is the app identifier from [`hassette.toml`](../configuration/index.md) (e.g., `delay_app`). Run `hassette log --app --since 5m` to see job execution output. + ## 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, and per-job options including `group`, `jitter`, and `if_exists` +- [Triggers](triggers.md): built-in trigger types, `TriggerProtocol`, and writing custom triggers +- [Job Management](management.md): cancelling, grouping, error handling, and the [`ScheduledJob`][hassette.scheduler.classes.ScheduledJob] object diff --git a/docs/pages/core-concepts/scheduler/management.md b/docs/pages/core-concepts/scheduler/management.md index f6c0c51ca..ef2b9816a 100644 --- a/docs/pages/core-concepts/scheduler/management.md +++ b/docs/pages/core-concepts/scheduler/management.md @@ -1,155 +1,156 @@ # 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]. This page covers cancellation, groups, jitter, error handling, and job metadata for jobs already scheduled. -## The ScheduledJob Object +## Cancel a job -| 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. | +`job.cancel()` removes the job from the scheduler queue immediately. The job does not fire again. ```python ---8<-- "pages/core-concepts/scheduler/snippets/scheduler_job_metadata.py" +--8<-- "pages/core-concepts/scheduler/snippets/scheduler_cancel_job.py" ``` -## Cancelling Jobs +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. + +## Check whether a job is active -To stop a job from running, call `cancel()`. +`ScheduledJob` has no `cancelled` attribute. Cancellation removes the job from the scheduler's internal index. The canonical check is membership in `list_jobs()`: ```python ---8<-- "pages/core-concepts/scheduler/snippets/scheduler_cancel_job.py" +--8<-- "pages/core-concepts/scheduler/snippets/scheduler_management_patterns.py:is_running" ``` -### Cancelling Job Groups - -Cancel all jobs in a named group at once with `cancel_group()`: +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_group" +--8<-- "pages/core-concepts/scheduler/snippets/scheduler_management_patterns.py:cancel_null" ``` -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. +## Jobs stop automatically when the app stops -### Listing Jobs +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. -Query registered jobs with `list_jobs()`: +## Group related jobs + +The `group=` parameter assigns a job to a named group at registration time. A named group can be cancelled or listed as a unit. ```python ---8<-- "pages/core-concepts/scheduler/snippets/scheduler_management_patterns.py:list_jobs" +--8<-- "pages/core-concepts/scheduler/snippets/scheduler_job_groups.py" ``` -### Checking Cancellation State - -`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()`: +`list_jobs(group=group)` returns all active jobs in the group. `list_jobs()` without `group=` returns all jobs for the app instance. ```python ---8<-- "pages/core-concepts/scheduler/snippets/scheduler_management_patterns.py:is_running" +--8<-- "pages/core-concepts/scheduler/snippets/scheduler_management_patterns.py:list_jobs" ``` -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: +`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. + +## Stop a job from inside its handler + +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_management_patterns.py:cancel_null" +--8<-- "pages/core-concepts/scheduler/snippets/scheduler_self_cancel.py" ``` -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. +`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. -### Automatic Cleanup +## Prevent overlapping executions -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). +The scheduler fires each tick independently — it does not track whether the previous execution has finished. When a handler takes longer than its interval, a new execution starts before the previous one finishes. An `asyncio.Lock` prevents concurrent runs: -## Best Practices +```python +--8<-- "pages/core-concepts/scheduler/snippets/scheduler_overlapping_jobs.py" +``` -1. **Name your jobs**: Use the `name` parameter for better logs and safe reloads. +The locked check at entry skips the tick rather than queuing behind it. - 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. +## Handle errors - ```python - --8<-- "pages/core-concepts/scheduler/snippets/scheduler_naming.py" - ``` +On 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. -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" - ``` +### App-level handler -## Self-Cancelling Job Pattern +`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. -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: +!!! warning "Registration order matters" + `on_error()` must run before any job is registered in `on_initialize()`. For example, if you call `run_in(handler, delay=1)` before `on_error()`, and the job fires within that 1-second window while `on_initialize()` is still running, no error handler is registered for that execution. ```python ---8<-- "pages/core-concepts/scheduler/snippets/scheduler_self_cancel.py" +--8<-- "pages/core-concepts/scheduler/snippets/scheduler_error_handler_app.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. +### Per-job handler -## Troubleshooting +The `on_error=` parameter on any scheduling method takes precedence over the app-level handler. -### Job Not Running? +```python +--8<-- "pages/core-concepts/scheduler/snippets/scheduler_error_handler_per_job.py" +``` -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. +Both levels accept sync or async callables. -### Runs Too Often? +### Fields in the error handler -- 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. +| Field | Type | Description | +|---|---|---| +| `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. | -## Error Handling +!!! note "Error handler failures" + 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. -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. +## Tune dispatch with jitter -There are two levels of error handlers: +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. -- **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. +Jitter affects dispatch order within the heap. The logical `next_run` timestamp on the job remains unchanged — a job scheduled every 60 seconds targets T+60, T+120, T+180 regardless of jitter. The random offset shifts the actual dispatch within each window but does not compound across runs. -Both levels can be sync or async. +```python +--8<-- "pages/core-concepts/scheduler/snippets/scheduler_jitter.py:jitter" +``` -!!! 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()`**. +Jitter is useful when several apps schedule work at the same wall-clock time and concurrent execution would cause contention. -### App-level error handler +## Inspect a job's metadata -```python ---8<-- "pages/core-concepts/scheduler/snippets/scheduler_error_handler_app.py" -``` +`ScheduledJob` exposes read-only metadata set at registration time and updated by the scheduler as the job runs. -### Per-registration error handler +| Attribute | Type | Description | +|---|---|---| +| `name` | `str` | Human-readable name. Auto-generated from the callable and trigger when not provided. Appears in logs; idempotent re-registration matches on this name. | +| `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. | +| `fire_at` | `ZonedDateTime` | Actual dispatch time including the jitter offset. Equals `next_run` when `jitter` is not set. | +| `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_error_handler_per_job.py" +--8<-- "pages/core-concepts/scheduler/snippets/scheduler_job_metadata.py" ``` -### What `SchedulerErrorContext` contains +## Troubleshooting -| 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 | +??? note "Troubleshooting scheduled jobs" + ### Job not running? -!!! 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. + - **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.CommandExecutor` 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. + + ### 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 +- [Triggers](triggers.md) for built-in trigger types and writing custom triggers +- [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..1901db417 100644 --- a/docs/pages/core-concepts/scheduler/methods.md +++ b/docs/pages/core-concepts/scheduler/methods.md @@ -1,280 +1,199 @@ # Scheduling Methods -The scheduler provides several methods to run tasks at different times. All methods return a [`ScheduledJob`][hassette.scheduler.classes.ScheduledJob]. +The scheduler runs handlers at times defined by trigger objects. The convenience methods below cover the common cases so most apps never need to construct a trigger directly. Every method is `async`, requires `await`, and returns a [`ScheduledJob`][hassette.scheduler.classes.ScheduledJob]. -## Primary Entry Point +!!! warning "Forgetting `await` schedules nothing" + A scheduling call without `await` returns a coroutine object and schedules no job — the handler never runs, and no error is raised at the call site. Python logs a `RuntimeWarning: coroutine ... was never awaited` when the coroutine is garbage-collected, but the message is easy to miss. When a job never fires, check the registration is awaited, then confirm the job exists with `hassette job`. -### `schedule` +## Which method should I use? -The primary entry point for scheduling. All convenience methods delegate here. Use it directly when working with trigger objects. +| Timing need | Method | +|---|---| +| Run once, N seconds from now | `run_in` | +| Repeat on a fixed interval | `run_every` (or `run_minutely` / `run_hourly`) | +| Run at the same time every day | `run_daily` | +| Run on a complex or calendar schedule | `run_cron` | +| Run once at a specific wall-clock time | `run_once` | +| Use a custom trigger | `schedule` | -| 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`. | - -```python ---8<-- "pages/core-concepts/scheduler/snippets/scheduler_schedule_examples.py" -``` - ---- +## Run once after a delay: `run_in` -## Convenience Methods - -### `run_in` -Run once after a delay. Useful for timeouts or delayed actions. +The handler runs once after a fixed delay. The underlying [`After`][hassette.scheduler.triggers.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 (see [Parameters every method accepts](#parameters-every-method-accepts)). ```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`. - -| 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. - -```python ---8<-- "pages/core-concepts/scheduler/snippets/scheduler_run_once.py" -``` +## Repeat on an interval: `run_every` -### `run_every` -Run repeatedly at a fixed interval. Specify the interval using `hours`, `minutes`, and/or `seconds` keyword arguments (they are additive). +The handler runs repeatedly at a fixed interval. The `hours`, `minutes`, and `seconds` parameters are additive; at least one must be nonzero. Each next run is calculated from the previous run time, not from wall-clock time. The interval stays drift-resistant under load. | 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" ``` ---- +### Shorthands: `run_minutely` and `run_hourly` -## Convenience Interval Helpers +`run_minutely` and `run_hourly` are shorthands for `run_every` with a single integer interval parameter. Both 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 at the same time every day: `run_daily` + +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 on a cron schedule: `run_cron` -## 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. | -```python ---8<-- "pages/core-concepts/scheduler/snippets/scheduler_run_cron.py" -``` +Shared parameters apply. ---- +**Cron field reference** (5-field standard: `minute hour dom month dow`): -## Job Groups +| 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) | -Schedule related jobs into a named group for bulk management. Pass `group=` to any scheduling method or to `schedule()` directly. +6-field expressions append seconds as a 6th field per the croniter library convention: `minute hour dom month dow second`. ```python ---8<-- "pages/core-concepts/scheduler/snippets/scheduler_job_groups.py" +--8<-- "pages/core-concepts/scheduler/snippets/scheduler_run_cron.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. | +## Run once at a specific time: `run_once` + +The handler runs once at a specific wall-clock time. The [`Once`][hassette.scheduler.triggers.Once] trigger fires once and does not repeat. ---- +| Parameter | Type | Default | Description | +|---|---|---|---| +| `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`](https://whenever.readthedocs.io/) (from the `whenever` library, which ships with Hassette — `from whenever import ZonedDateTime`) fires at the exact instant specified. | +| `if_past` | `"tomorrow"` \| `"error"` | `"tomorrow"` | Behavior when the target is already in the past. `"tomorrow"` defers by one day and logs a WARNING. `"error"` raises `ValueError` for both input types. | -## Jitter +Shared parameters apply. -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. +!!! note "Past `ZonedDateTime` inputs fire immediately" + When `at` is a `ZonedDateTime` in the past and `if_past="tomorrow"` (the default), the job fires at the next scheduler tick — there is no "tomorrow" for an absolute instant. `if_past="error"` still raises `ValueError`. ```python ---8<-- "pages/core-concepts/scheduler/snippets/scheduler_jitter.py:jitter" +--8<-- "pages/core-concepts/scheduler/snippets/scheduler_run_once.py" ``` -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. - ---- +## Use a custom trigger: `schedule` -## Idempotent Registration +`schedule` is the base method all convenience methods delegate to. Most apps never call it directly. `schedule` is the right choice when a built-in convenience method cannot express the required timing, such as a custom trigger that implements [`TriggerProtocol`][hassette.types.types.TriggerProtocol]. See [Triggers](triggers.md) for the built-in trigger types and the protocol definition. -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: - -| 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. | +| Parameter | Type | Default | Description | +|---|---|---|---| +| `func` | callable | *(required)* | The handler to run. | +| `trigger` | `TriggerProtocol` | *(required)* | A trigger object that determines first run time and recurrences. | -This is especially useful in `on_initialize`, which runs again on app reload: +Shared parameters apply. ```python ---8<-- "pages/core-concepts/scheduler/snippets/scheduler_idempotent_registration.py:idempotent_registration" +--8<-- "pages/core-concepts/scheduler/snippets/scheduler_schedule_examples.py" ``` -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: +??? note "Advanced: `add_job`" + Below `schedule` sits `add_job(job, if_exists="error")`, which registers a pre-built [`ScheduledJob`][hassette.scheduler.classes.ScheduledJob] directly — for code that constructs job objects programmatically, such as a job factory or framework extension. Most apps never call it. `if_exists` accepts the same `"error"` / `"skip"` / `"replace"` values described under [Idempotent registration](#idempotent-registration). -```python ---8<-- "pages/core-concepts/scheduler/snippets/scheduler_idempotent_registration.py:replace_registration" -``` +## Parameters every method accepts + +These parameters are accepted by every scheduling method. Individual method tables list only method-specific parameters. + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `name` | `str` | `""` | Identifies the job in logs and the monitoring UI. Auto-generated from the callable and trigger when empty. Must be unique within the app instance — see [Idempotent Registration](#idempotent-registration). | +| `group` | `str \| None` | `None` | Group name for bulk management. See [Job Management](management.md) for grouping. | +| `jitter` | `float \| None` | `None` | Random offset in seconds applied at enqueue time. See [Job Management](management.md) for jitter. | +| `timeout` | `float \| None` | `None` | Per-job timeout in seconds. `None` inherits the global `scheduler.job_timeout_seconds` from [`hassette.toml`](../configuration/index.md). | +| `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. | -## Passing Arguments to Handlers +## 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" ``` ---- +## Idempotent registration -## Custom Triggers +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. -Implement `TriggerProtocol` to handle scheduling patterns the built-in triggers don't cover — for example, polling based on solar elevation. +| Value | Behavior | +|---|---| +| `"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`, `kwargs`, and `on_error` handler. | +| `"replace"` | Cancels the existing job and registers the new one. The new job's configuration does not need to match the old one. | + +`if_exists` matters most in `on_initialize`, which re-runs on app reload (triggered by config changes or `hassette reload`). ```python ---8<-- "pages/core-concepts/scheduler/snippets/scheduler_custom_trigger.py:trigger_class" +--8<-- "pages/core-concepts/scheduler/snippets/scheduler_idempotent_registration.py:idempotent_registration" ``` -Use it with `schedule()`: +`"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_custom_trigger.py:trigger_usage" +--8<-- "pages/core-concepts/scheduler/snippets/scheduler_idempotent_registration.py:replace_registration" ``` -The `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). | - ## 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 +- [Triggers](triggers.md): built-in trigger types, `TriggerProtocol`, and writing custom triggers +- [Job Management](management.md): cancelling, inspecting, grouping, jitter, and error handling for scheduled jobs +- [Scheduler Overview](index.md): getting started with the scheduler 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..165e1c1b9 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") @@ -16,9 +16,9 @@ async def cancel_morning_jobs(self) -> None: # --8<-- [start:list_jobs] async def show_jobs(self) -> None: - all_jobs = self.scheduler.list_jobs() # pyright: ignore[reportUnusedVariable] + all_jobs = self.scheduler.list_jobs() - morning_jobs = self.scheduler.list_jobs(group="morning") # pyright: ignore[reportUnusedVariable] + morning_jobs = self.scheduler.list_jobs(group="morning") # --8<-- [end:list_jobs] 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_schedule_examples.py b/docs/pages/core-concepts/scheduler/snippets/scheduler_schedule_examples.py index d5d20eeb6..42a4ab343 100644 --- a/docs/pages/core-concepts/scheduler/snippets/scheduler_schedule_examples.py +++ b/docs/pages/core-concepts/scheduler/snippets/scheduler_schedule_examples.py @@ -5,13 +5,13 @@ class ScheduleExampleApp(App[AppConfig]): async def on_initialize(self) -> None: # Fixed interval - job = await self.scheduler.schedule(self.check_sensors, Every(minutes=5)) # pyright: ignore[reportUnusedVariable] + job = await self.scheduler.schedule(self.check_sensors, Every(minutes=5)) # Daily at a specific time - job = await self.scheduler.schedule(self.morning_routine, Daily(at="07:00"), group="morning") # pyright: ignore[reportUnusedVariable] + job = await self.scheduler.schedule(self.morning_routine, Daily(at="07:00"), group="morning") # Cron expression - job = await self.scheduler.schedule(self.workday_task, Cron("0 9 * * 1-5")) # pyright: ignore[reportUnusedVariable] + job = await self.scheduler.schedule(self.workday_task, Cron("0 9 * * 1-5")) async def check_sensors(self) -> None: ... async def morning_routine(self) -> None: ... 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 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/core-concepts/scheduler/triggers.md b/docs/pages/core-concepts/scheduler/triggers.md new file mode 100644 index 000000000..89e16f3f6 --- /dev/null +++ b/docs/pages/core-concepts/scheduler/triggers.md @@ -0,0 +1,76 @@ +# Triggers + +A trigger determines when a scheduled job fires. Each built-in scheduling method creates a trigger internally. `schedule()` accepts a trigger directly for patterns the convenience methods do not cover. + +All five built-in trigger types and [`TriggerProtocol`][hassette.types.types.TriggerProtocol] are importable from `hassette.scheduler`: + +```python +from hassette.scheduler import After, Once, Every, Daily, Cron, TriggerProtocol +``` + +## Built-in Triggers + +| Trigger | Fires | One-shot | +|---|---|---| +| `After(seconds=N)` | Once, after a fixed delay | Yes | +| `Once(at="HH:MM")` | Once, at a specific wall-clock time | Yes | +| `Every(seconds=N)` | Repeatedly on a fixed interval | No | +| `Daily(at="HH:MM")` | Once per day at a wall-clock time (DST-safe) | No | +| `Cron("expr")` | On a cron schedule (5- or 6-field) | No | + +`After` also accepts `minutes=` or a `whenever.TimeDelta` via `timedelta=` — `After(minutes=5)` reads better than `After(seconds=300)`. `Every` accepts an optional `start=` anchor (a `ZonedDateTime` from the [`whenever`](https://whenever.readthedocs.io/) library, which ships with Hassette): with `Every(minutes=15, start=anchor)`, runs align to the anchor's minute marks (`:00`, `:15`, `:30`, `:45`) instead of starting from registration time. + +!!! warning "Wall-clock times use the process timezone" + `Once(at="07:00")` and `Daily(at="07:00")` interpret the time in the *process* timezone. Docker containers commonly run with `TZ=UTC` while Home Assistant uses a local zone — the job then fires at 07:00 UTC with no warning. Set the `TZ` environment variable where Hassette runs — on the container (the [Docker guide](../../getting-started/docker/index.md) compose file does this), or in the host environment for non-Docker installs — so wall-clock times mean local time. + +Each convenience method on the scheduler maps to one trigger: + +| Method | Creates | +|---|---| +| `run_in(func, delay)` | `After(seconds=delay)` | +| `run_once(func, at)` | `Once(at=at)` | +| `run_every(func, ...)` | `Every(...)` | +| `run_daily(func, at)` | `Daily(at=at)` | +| `run_cron(func, expr)` | `Cron(expr)` | + +Triggers are passed to `schedule()` when the convenience methods do not fit. See [Scheduling Methods](methods.md) for the full method reference. + +## Custom Triggers + +A custom trigger expresses a timing pattern the built-in types cannot: phase-locked schedules, adaptive intervals, or schedules driven by external state. [`TriggerProtocol`][hassette.types.types.TriggerProtocol] defines the interface. Any class implementing all six methods can be passed to `schedule()`. Inheriting `TriggerProtocol` is optional — duck typing works — but it lets Pyright catch missing methods. + +Trigger methods use [`ZonedDateTime`](https://whenever.readthedocs.io/) from the `whenever` library (`from whenever import ZonedDateTime`) — Hassette's date/time type for timezone-safe scheduling. + +```python +--8<-- "pages/core-concepts/scheduler/snippets/scheduler_custom_trigger.py:trigger_class" +``` + +```python +--8<-- "pages/core-concepts/scheduler/snippets/scheduler_custom_trigger.py:trigger_usage" +``` + +### What to implement + +Two methods control when the job fires. They are the load-bearing part of any custom trigger. + +| 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. | + +`first_run_time` receives the current time at registration. `next_run_time` receives both the previous scheduled run and the current time, allowing drift-resistant or wall-clock-aligned strategies. A trigger that returns `None` from `next_run_time` fires once. A trigger that always returns a future time repeats indefinitely. + +The remaining four methods cover display and deduplication. + +| Method | Signature | Returns | Description | +|---|---|---|---| +| `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 string for database storage. Application triggers return `"custom"`. | +| `trigger_id` | `()` | `str` | Stable identifier for deduplication. [`if_exists="skip"`](methods.md#idempotent-registration) and auto-generated job names both rely on this value. | + +## See Also + +- [Scheduling Methods](methods.md): `schedule()` and the convenience methods that create triggers +- [Job Management](management.md): cancelling, inspecting, and handling errors on scheduled jobs +- [Scheduler Overview](index.md): getting started with the scheduler diff --git a/docs/pages/core-concepts/snippets/states_import.py b/docs/pages/core-concepts/snippets/states_import.py index bb08d67f5..d7541d489 100644 --- a/docs/pages/core-concepts/snippets/states_import.py +++ b/docs/pages/core-concepts/snippets/states_import.py @@ -1,3 +1,3 @@ -from hassette import states # pyright: ignore[reportUnusedImport] +from hassette import states # e.g. states.LightState, states.SunState, states.BinarySensorState diff --git a/docs/pages/core-concepts/states/conversion.md b/docs/pages/core-concepts/states/conversion.md new file mode 100644 index 000000000..4aad5387e --- /dev/null +++ b/docs/pages/core-concepts/states/conversion.md @@ -0,0 +1,316 @@ +# State Conversion + +Home Assistant sends state data as untyped dicts with string values. Two registries cooperate to produce typed Python objects: the [`StateRegistry`][hassette.conversion.state_registry.StateRegistry] maps domains to state classes, and the [`TypeRegistry`][hassette.conversion.type_registry.TypeRegistry] converts string values to typed Python values. This conversion runs automatically whenever a handler receives state via [dependency injection](../bus/dependency-injection.md) — the mechanism that fills in handler parameters like `D.StateNew[T]` from the event. Most apps benefit from it without touching either registry directly. + +The registries become relevant when overriding domain mappings, registering custom converters, or debugging unexpected types. + +## The Conversion Pipeline + +When state data arrives from Home Assistant, `StateRegistry.try_convert_state()` runs the full pipeline. Dependency injection calls it automatically; direct calls are only needed when converting raw dicts outside a handler, such as in tests or data scripts. Given this raw input: + +```python +--8<-- "pages/core-concepts/states/snippets/state-registry/flow_raw_input.py" +``` + +The pipeline runs five steps: + +1. `StateRegistry.resolve(domain="binary_sensor")` looks up the registered class for the domain. + It returns [`BinarySensorState`][hassette.models.states.binary_sensor.BinarySensorState]. + +2. Pydantic validation begins on `BinarySensorState`. + +3. The `_validate_domain_and_state` model validator reads `value_type` from the class and + delegates to `TypeRegistry`. + +4. `TypeRegistry` looks up the `(str, bool)` converter and converts `"on"` to `True`. + +5. Validation completes. The result is a fully typed state object: + +```python +--8<-- "pages/core-concepts/states/snippets/state-registry/flow_converted_output.py" +``` + +`StateRegistry` answers "which class?". `TypeRegistry` answers "which type for the value?". +Each state class declares a `value_type` class variable — the type (or tuple of types) the `value` field should hold. `TypeRegistry` reads this and selects the right converter: + +```python +--8<-- "pages/core-concepts/states/snippets/state-registry/value_type_example.py" +``` + +When `resolve` returns `None` for an unregistered domain, `try_convert_state` falls back +to [`BaseState`][hassette.models.states.base.BaseState]. + +## Domain-to-Class Mapping + +### How Registration Works + +Any class that inherits from `BaseState` and declares a `domain: Literal["domain_name"]` +field registers itself automatically at class definition time. No explicit 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` annotation, +and records the class under that domain. Classes without a `Literal["..."]` annotation +on `domain` are silently skipped. + +```python +--8<-- "pages/core-concepts/states/snippets/state-registry/automatic_registration.py" +``` + +### Domain Lookup + +`StateRegistry.resolve(domain=...)` returns the registered class for a domain, or `None` +when no class is registered. + +```python +--8<-- "pages/core-concepts/states/snippets/state-registry/domain_lookup.py" +``` + +The `None` return is intentional. `try_convert_state` handles the fallback to `BaseState` +when `resolve` returns `None`. + +### Overriding a Domain Mapping + +A custom class with the same `Literal` domain as a built-in replaces the existing mapping. +Overriding is how custom attributes get typed — for example, a sensor integration that +reports a calibration field not present on the built-in `SensorState`. The override takes +effect at class definition time. + +```python +--8<-- "pages/core-concepts/states/snippets/state-registry/domain_override.py" +``` + +The registry replaces the previous mapping silently and globally — a typo in the `Literal` +domain overrides a built-in with no warning. `STATE_REGISTRY.resolve(domain="sensor")` +confirms which class is registered. All subsequent state events for `sensor` entities +produce `CustomSensorState` instances. + +For classes that can't declare a `Literal` domain — built dynamically, or registered conditionally at runtime — [`register_state_converter`][hassette.conversion.register_state_converter] registers a class with the registry explicitly. It is the imperative equivalent of the `Literal`-based auto-registration. + +`STATE_REGISTRY` is available as a top-level import for direct access outside an app: +`from hassette import STATE_REGISTRY`. + +### Union Type Support + +A handler can accept multiple entity types at once with a union annotation. `StateRegistry` +resolves the union by matching each type's domain against the incoming entity's domain. + +```python +--8<-- "pages/core-concepts/states/snippets/state-registry/union_type_support.py" +``` + +For `D.StateNew[states.SensorState | states.BinarySensorState]`, the DI system extracts +the domain from the entity ID, checks each type in the union, and selects the one whose +`Literal` domain matches. When no type matches, conversion falls back to `BaseState`. + +## Value Conversion + +### How It Works + +`TypeRegistry` maps `(from_type, to_type)` pairs to converter functions. When a raw value +does not match the expected `value_type`, the registry looks up a matching converter and +applies it. + +```python +--8<-- "pages/core-concepts/states/snippets/type-registry/lookup_example.py" +``` + +When no registered converter exists, the registry tries the target type's constructor as a +fallback. A successful constructor call auto-registers the pair for future calls. + +For union `value_type` declarations (`value_type = (int, float, str)`), conversion is attempted in order and the first success wins. `str` succeeds trivially (no conversion needed), so placing it first would always short-circuit before attempting `int` or `float`. The most specific type must come first: `(int, float, str)` is correct; `(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 Home Assistant string values: + +- `True`: `"on"`, `"true"`, `"yes"`, `"1"` +- `False`: `"off"`, `"false"`, `"no"`, `"0"` + +The `bool` → `str` converter produces Python's `"True"` or `"False"`, not HA format. + +#### DateTime + +All datetime conversions use the [`whenever`](https://github.com/ariebovenberg/whenever) +library, which ships with Hassette. + +**`whenever` types:** + +| From | To | Method | +|------|----|--------| +| `str` | `ZonedDateTime` | Parses ISO, plain, or date-only strings (date-only assumes 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()` | + +## Custom Converters + +### Decorator Registration + +`@register_type_converter_fn` registers a converter by reading `from_type` and `to_type` +from the function's type annotations. The parameter must be named `value`; the return +annotation determines the target type. + +```python +--8<-- "pages/core-concepts/bus/snippets/dependency-injection/custom_type_converter.py" +``` + +The decorator accepts keyword arguments for error handling: + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `error_message` | `str \| None` | `None` | Message on conversion failure. Supports `{value}`, `{from_type}`, `{to_type}` placeholders. | +| `error_types` | `tuple[type[BaseException], ...]` | `(ValueError,)` | Exceptions that 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/core-concepts/states/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/core-concepts/states/snippets/type-registry/pattern_enum.py" +``` + +The decorator infers `str → FanSpeed` from the function signature. The converter is +available at module import time. + +#### Structured Data + +```python +--8<-- "pages/core-concepts/states/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 + +### State Conversion Errors + +`try_convert_state` raises specific exceptions for distinct failure modes. + +#### `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/core-concepts/states/snippets/state-registry/error_invalid_data.py" +``` + +#### `InvalidEntityIdError` + +Raised when `entity_id` is missing, not a string, or lacks a `.` separator between domain +and entity name. + +```python +--8<-- "pages/core-concepts/states/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/core-concepts/states/snippets/state-registry/error_unable_to_convert.py" +``` + +### Value Conversion Errors + +#### `UnableToConvertValueError` + +When a registered converter raises one of its `error_types`, the registry wraps it in +`UnableToConvertValueError`: + +```python +--8<-- "pages/core-concepts/states/snippets/type-registry/conversion_error.py" +``` + +When no converter is registered and the target type's constructor also fails: + +```python +--8<-- "pages/core-concepts/states/snippets/type-registry/missing_converter.py" +``` + +Custom error messages with `{value}` make failures easier to diagnose: + +```python +--8<-- "pages/core-concepts/states/snippets/type-registry/custom_error_msg.py" +``` + +## Inspection and Debugging + +`TYPE_REGISTRY` and `STATE_REGISTRY` are both available as top-level imports. + +**List all registered value converters:** + +```python +--8<-- "pages/core-concepts/states/snippets/type-registry/inspect_list.py" +``` + +Output: + +``` +--8<-- "pages/core-concepts/states/snippets/type-registry/inspect_list_output.txt" +``` + +**Check whether a specific converter is registered:** + +```python +--8<-- "pages/core-concepts/states/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. + +!!! tip "Unexpected state type at runtime?" + `STATE_REGISTRY.resolve(domain="the_domain")` confirms which class is registered. + If a custom class override does not take effect, import order is the likely cause. + The override class must be imported after the module that defines the original. + +## See Also + +- [Custom States](custom-states.md): defining state classes for custom integrations +- [Dependency Injection](../bus/dependency-injection.md): how `D.StateNew[T]` uses the registries +- [States Overview](index.md): the `self.states` cache that sits above the registries 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..75ab3f234 --- /dev/null +++ b/docs/pages/core-concepts/states/custom-states.md @@ -0,0 +1,110 @@ +# Custom States + +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](conversion.md) — Hassette's internal mapping from domain strings to state classes — picks up the class automatically at definition time via `__init_subclass__`. + +## 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/core-concepts/states/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`][hassette.models.states.base.StringBaseState] is the most common choice. It passes through the raw HA state string with no conversion. + +```python +--8<-- "pages/core-concepts/states/snippets/custom-states/string_base_state.py" +``` + +### `NumericBaseState`: numeric value + +[`NumericBaseState`][hassette.models.states.base.NumericBaseState] converts the raw state string to a numeric type — whole-number strings become `int`, decimal strings become `float`. It accepts `int`, `float`, and `Decimal` inputs directly. + +```python +--8<-- "pages/core-concepts/states/snippets/custom-states/numeric_base_state.py" +``` + +### `BoolBaseState`: `bool` value + +[`BoolBaseState`][hassette.models.states.base.BoolBaseState] converts `"on"` to `True` and `"off"` to `False` automatically. + +```python +--8<-- "pages/core-concepts/states/snippets/custom-states/bool_base_state.py" +``` + +### `DateTimeBaseState`: `ZonedDateTime`, `PlainDateTime`, or `Date` value + +[`DateTimeBaseState`][hassette.models.states.base.DateTimeBaseState] parses the raw state string into a [`whenever`](https://whenever.readthedocs.io/) datetime type (`from whenever import ZonedDateTime` — Hassette's date/time library). The exact type depends on the string format from Home Assistant. + +```python +--8<-- "pages/core-concepts/states/snippets/custom-states/datetime_base_state.py" +``` + +### `TimeBaseState`: `Time` value + +[`TimeBaseState`][hassette.models.states.base.TimeBaseState] parses the raw state string into a `whenever.Time` value. + +```python +--8<-- "pages/core-concepts/states/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/core-concepts/states/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`][hassette.models.states.base.AttributesBase] — a Pydantic model subclass where fields map to HA attribute keys by name. The `attributes` field on the state class accepts this class, overriding the default. + +```python +--8<-- "pages/core-concepts/states/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`][hassette.state_manager.state_manager.DomainStates] collection typed to `RedditState`. Iteration yields `(entity_id, state)` pairs where each `state` is a fully converted `RedditState` instance. + +```python +--8<-- "pages/core-concepts/states/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/core-concepts/states/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 Conversion](conversion.md): how automatic registration works, domain overrides, and custom value converters +- [Dependency Injection](../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 9bd228122..bb8b0a2ae 100644 --- a/docs/pages/core-concepts/states/index.md +++ b/docs/pages/core-concepts/states/index.md @@ -1,9 +1,8 @@ # 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 +The [`StateManager`][hassette.state_manager.state_manager.StateManager] keeps a real-time, in-memory copy of all Home Assistant entity states. `self.states` is a `StateManager` instance available on every [`App`](../apps/index.md) — it provides synchronous, typed access with no `await` and no API calls. - -## Diagram +
```mermaid flowchart TD @@ -17,7 +16,7 @@ flowchart TD WS --> SP end - subgraph app["Your App"] + subgraph app["App"] SM["self.states
typed, sync access"] end @@ -29,32 +28,25 @@ 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`][hassette.state_manager.state_manager.DomainStates] collection — a dict-like view keyed by entity name, typed to that domain's state class. ```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 built-in type for that domain. [`LightState`][hassette.models.states.light.LightState] for `light.*`, [`SensorState`][hassette.models.states.sensor.SensorState] for `sensor.*`, `BaseState` for any domain without a built-in class. ```python --8<-- "pages/core-concepts/states/snippets/states_direct_access.py" @@ -62,118 +54,87 @@ 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](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`][hassette.models.states.base.BaseState] subclass. The following fields and properties are available on all of them. + +**`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. + +!!! warning "`value` is typed Python, not the raw HA string" + Home Assistant stores `"on"`/`"off"` strings; [state conversion](conversion.md) turns them into `True`/`False` for toggle domains like `light`, `switch`, and `binary_sensor`. `state.value == "on"` is always `False` — compare against `True` instead. Code ported from AppDaemon or HA templates that compares against `"on"` silently never matches. The `changed_to=`/`changed_from=` filters on [`on_state_change()`](../bus/methods.md#on_state_changeentity_id) are the exception: they compare raw HA strings. + +**`attributes`** is a typed [`AttributesBase`][hassette.models.states.base.AttributesBase] subclass with domain-specific fields. `LightState.attributes.brightness` is an integer. `ClimateState.attributes.current_temperature` is a float. Pyright knows the types. + +**`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. For group entities, the `entity_id` attribute holds a list of member entity IDs rather than the group's own ID. + +**`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. `ZonedDateTime` is from the [`whenever`](https://whenever.readthedocs.io/) library, which Hassette uses for all date/time operations — it behaves like a timezone-aware `datetime` and converts via `.to_stdlib()` when a library requires it. `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"`). -## DomainStates Collection Interface +**`context`** holds the HA event context that produced this state: `context.id`, `context.parent_id`, and `context.user_id`. It traces which automation or user triggered the change. -Every domain accessor (e.g., `self.states.light`) returns a `DomainStates` object. Beyond iteration, it supports the following operations: +### Attribute Helpers -| 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]` | +`AttributesBase` exposes two helpers for attributes not declared on the typed model. -!!! 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. +`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` module: ```python --8<-- "pages/core-concepts/snippets/states_import.py" ``` -??? info "Full list of built-in state classes" - | Domain | Class | - |---|---| - | `ai_task` | `AiTaskState` | - | `air_quality` | `AirQualityState` | - | `alarm_control_panel` | `AlarmControlPanelState` | - | `assist_satellite` | `AssistSatelliteState` | - | `automation` | `AutomationState` | - | `binary_sensor` | `BinarySensorState` | - | `button` | `ButtonState` | - | `calendar` | `CalendarState` | - | `camera` | `CameraState` | - | `climate` | `ClimateState` | - | `conversation` | `ConversationState` | - | `counter` | `CounterState` | - | `cover` | `CoverState` | - | `date` | `DateState` | - | `datetime` | `DateTimeState` | - | `device_tracker` | `DeviceTrackerState` | - | `event` | `EventState` | - | `fan` | `FanState` | - | `humidifier` | `HumidifierState` | - | `image_processing` | `ImageProcessingState` | - | `input_boolean` | `InputBooleanState` | - | `input_button` | `InputButtonState` | - | `input_datetime` | `InputDatetimeState` | - | `input_number` | `InputNumberState` | - | `input_select` | `InputSelectState` | - | `input_text` | `InputTextState` | - | `light` | `LightState` | - | `lock` | `LockState` | - | `media_player` | `MediaPlayerState` | - | `notify` | `NotifyState` | - | `number` | `NumberState` | - | `person` | `PersonState` | - | `remote` | `RemoteState` | - | `scene` | `SceneState` | - | `script` | `ScriptState` | - | `select` | `SelectState` | - | `sensor` | `SensorState` | - | `siren` | `SirenState` | - | `stt` | `SttState` | - | `sun` | `SunState` | - | `switch` | `SwitchState` | - | `text` | `TextState` | - | `time` | `TimeState` | - | `timer` | `TimerState` | - | `todo` | `TodoState` | - | `tts` | `TtsState` | - | `update` | `UpdateState` | - | `vacuum` | `VacuumState` | - | `valve` | `ValveState` | - | `water_heater` | `WaterHeaterState` | - | `weather` | `WeatherState` | - | `zone` | `ZoneState` | - - For domains not in this list (custom integrations, third-party add-ons), see [Custom State Classes](../../advanced/custom-states.md). +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 lists all 55 classes with their full attribute signatures. Domains not covered there are handled by [Custom States](custom-states.md). + +## Iterating Over States + +`DomainStates` supports direct iteration over `(entity_id, state)` pairs — `for entity_id, state in self.states.sensor` yields tuples, unlike a plain `dict` which yields keys. `.keys()`, `.values()`, `.to_dict()`, containment checks (`"kitchen" in self.states.light`), and `len()` also work. + +```python +--8<-- "pages/core-concepts/states/snippets/states_iteration.py" +``` + +`.items()`, `.iterkeys()`, and `.itervalues()` are lazy — they parse raw HA state dicts into typed objects on demand. `.keys()`, `.values()`, and `.to_dict()` are eager and parse all entities up front. Lazy iteration performs better for large domains like `sensor`. + +`StateManager` itself is also iterable: `self.states.items()` yields `(key, DomainStates)` pairs for every registered state class, and `MyState in self.states` checks whether a class is registered. Useful for diagnostics and generic helpers that sweep all domains. ## 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. +**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 disconnect the cache is retained — `self.states.get()` returns the last known (stale) values while Hassette reconnects. Once the reconnect completes, a fresh API fetch replaces the cache atomically. -!!! 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 +- [Subscription Methods](../bus/methods.md): `on_state_change`, `on_attribute_change`, and their parameters +- [Custom States](custom-states.md): define typed models for custom integrations +- [State Conversion](conversion.md): how raw HA dicts become typed Python objects +- [API Methods](../api/methods.md): retrieve states via the REST/WebSocket API +- [App Cache](../cache/index.md): persist data locally across restarts diff --git a/docs/pages/advanced/snippets/custom-states/adding_custom_attributes.py b/docs/pages/core-concepts/states/snippets/custom-states/adding_custom_attributes.py similarity index 100% rename from docs/pages/advanced/snippets/custom-states/adding_custom_attributes.py rename to docs/pages/core-concepts/states/snippets/custom-states/adding_custom_attributes.py diff --git a/docs/pages/advanced/snippets/custom-states/basic_custom_state.py b/docs/pages/core-concepts/states/snippets/custom-states/basic_custom_state.py similarity index 100% rename from docs/pages/advanced/snippets/custom-states/basic_custom_state.py rename to docs/pages/core-concepts/states/snippets/custom-states/basic_custom_state.py diff --git a/docs/pages/advanced/snippets/custom-states/bool_base_state.py b/docs/pages/core-concepts/states/snippets/custom-states/bool_base_state.py similarity index 100% rename from docs/pages/advanced/snippets/custom-states/bool_base_state.py rename to docs/pages/core-concepts/states/snippets/custom-states/bool_base_state.py diff --git a/docs/pages/advanced/snippets/custom-states/datetime_base_state.py b/docs/pages/core-concepts/states/snippets/custom-states/datetime_base_state.py similarity index 100% rename from docs/pages/advanced/snippets/custom-states/datetime_base_state.py rename to docs/pages/core-concepts/states/snippets/custom-states/datetime_base_state.py diff --git a/docs/pages/advanced/snippets/custom-states/define_your_own.py b/docs/pages/core-concepts/states/snippets/custom-states/define_your_own.py similarity index 100% rename from docs/pages/advanced/snippets/custom-states/define_your_own.py rename to docs/pages/core-concepts/states/snippets/custom-states/define_your_own.py diff --git a/docs/pages/advanced/snippets/custom-states/numeric_base_state.py b/docs/pages/core-concepts/states/snippets/custom-states/numeric_base_state.py similarity index 100% rename from docs/pages/advanced/snippets/custom-states/numeric_base_state.py rename to docs/pages/core-concepts/states/snippets/custom-states/numeric_base_state.py diff --git a/docs/pages/advanced/snippets/custom-states/string_base_state.py b/docs/pages/core-concepts/states/snippets/custom-states/string_base_state.py similarity index 100% rename from docs/pages/advanced/snippets/custom-states/string_base_state.py rename to docs/pages/core-concepts/states/snippets/custom-states/string_base_state.py diff --git a/docs/pages/advanced/snippets/custom-states/time_base_state.py b/docs/pages/core-concepts/states/snippets/custom-states/time_base_state.py similarity index 100% rename from docs/pages/advanced/snippets/custom-states/time_base_state.py rename to docs/pages/core-concepts/states/snippets/custom-states/time_base_state.py diff --git a/docs/pages/advanced/snippets/custom-states/via_get_states.py b/docs/pages/core-concepts/states/snippets/custom-states/via_get_states.py similarity index 82% rename from docs/pages/advanced/snippets/custom-states/via_get_states.py rename to docs/pages/core-concepts/states/snippets/custom-states/via_get_states.py index 9e99f9c6b..2fcfc5e13 100644 --- a/docs/pages/advanced/snippets/custom-states/via_get_states.py +++ b/docs/pages/core-concepts/states/snippets/custom-states/via_get_states.py @@ -1,6 +1,6 @@ from hassette import App -from .my_states import RedditState # pyright: ignore[reportMissingImports] +from .my_states import RedditState class MyApp(App): diff --git a/docs/pages/advanced/snippets/state-registry/automatic_registration.py b/docs/pages/core-concepts/states/snippets/state-registry/automatic_registration.py similarity index 77% rename from docs/pages/advanced/snippets/state-registry/automatic_registration.py rename to docs/pages/core-concepts/states/snippets/state-registry/automatic_registration.py index 56c329460..26181e87a 100644 --- a/docs/pages/advanced/snippets/state-registry/automatic_registration.py +++ b/docs/pages/core-concepts/states/snippets/state-registry/automatic_registration.py @@ -1,4 +1,4 @@ -from typing import ClassVar +from typing import Literal from hassette.models.states import BaseState @@ -10,5 +10,5 @@ class LightAttributes(BaseState): # simplified for example class LightState(BaseState): """State model for light entities.""" - domain: ClassVar[str] = "light" + domain: Literal["light"] attributes: LightAttributes diff --git a/docs/pages/advanced/snippets/state-registry/basic_custom_state_usage.py b/docs/pages/core-concepts/states/snippets/state-registry/basic_custom_state_usage.py similarity index 84% rename from docs/pages/advanced/snippets/state-registry/basic_custom_state_usage.py rename to docs/pages/core-concepts/states/snippets/state-registry/basic_custom_state_usage.py index 94b0993a5..94e6a17dd 100644 --- a/docs/pages/advanced/snippets/state-registry/basic_custom_state_usage.py +++ b/docs/pages/core-concepts/states/snippets/state-registry/basic_custom_state_usage.py @@ -2,7 +2,7 @@ from hassette import A, App, D -from .my_states import RedditState # pyright: ignore[reportMissingImports] +from .my_states import RedditState class MyApp(App): diff --git a/docs/pages/advanced/snippets/state-registry/domain_lookup.py b/docs/pages/core-concepts/states/snippets/state-registry/domain_lookup.py similarity index 100% rename from docs/pages/advanced/snippets/state-registry/domain_lookup.py rename to docs/pages/core-concepts/states/snippets/state-registry/domain_lookup.py diff --git a/docs/pages/advanced/snippets/state-registry/domain_override.py b/docs/pages/core-concepts/states/snippets/state-registry/domain_override.py similarity index 81% rename from docs/pages/advanced/snippets/state-registry/domain_override.py rename to docs/pages/core-concepts/states/snippets/state-registry/domain_override.py index 0c9de5ad8..27e7b769e 100644 --- a/docs/pages/advanced/snippets/state-registry/domain_override.py +++ b/docs/pages/core-concepts/states/snippets/state-registry/domain_override.py @@ -1,4 +1,4 @@ -from typing import ClassVar +from typing import Literal from hassette.models.states import SensorAttributes, SensorState @@ -10,5 +10,5 @@ class CustomSensorAttributes(SensorAttributes): class CustomSensorState(SensorState): """Extended sensor state with custom attributes.""" - domain: ClassVar[str] = "sensor" + domain: Literal["sensor"] attributes: CustomSensorAttributes diff --git a/docs/pages/advanced/snippets/state-registry/error_invalid_data.py b/docs/pages/core-concepts/states/snippets/state-registry/error_invalid_data.py similarity index 74% rename from docs/pages/advanced/snippets/state-registry/error_invalid_data.py rename to docs/pages/core-concepts/states/snippets/state-registry/error_invalid_data.py index f51858f1a..c8337054a 100644 --- a/docs/pages/advanced/snippets/state-registry/error_invalid_data.py +++ b/docs/pages/core-concepts/states/snippets/state-registry/error_invalid_data.py @@ -2,6 +2,6 @@ from hassette.exceptions import InvalidDataForStateConversionError try: - state = STATE_REGISTRY.try_convert_state(None) # Invalid data + state = STATE_REGISTRY.try_convert_state(None) except InvalidDataForStateConversionError as e: print(f"Invalid state data: {e}") diff --git a/docs/pages/advanced/snippets/state-registry/error_invalid_entity_id.py b/docs/pages/core-concepts/states/snippets/state-registry/error_invalid_entity_id.py similarity index 100% rename from docs/pages/advanced/snippets/state-registry/error_invalid_entity_id.py rename to docs/pages/core-concepts/states/snippets/state-registry/error_invalid_entity_id.py diff --git a/docs/pages/advanced/snippets/state-registry/error_unable_to_convert.py b/docs/pages/core-concepts/states/snippets/state-registry/error_unable_to_convert.py similarity index 77% rename from docs/pages/advanced/snippets/state-registry/error_unable_to_convert.py rename to docs/pages/core-concepts/states/snippets/state-registry/error_unable_to_convert.py index 07fe3b247..2e37ebb85 100644 --- a/docs/pages/advanced/snippets/state-registry/error_unable_to_convert.py +++ b/docs/pages/core-concepts/states/snippets/state-registry/error_unable_to_convert.py @@ -6,4 +6,4 @@ state = STATE_REGISTRY.try_convert_state(data) except UnableToConvertStateError as e: print(f"Conversion failed: {e}") - # Falls back to BaseState or re-raises depending on context + # This exception means both the resolved class and the BaseState fallback failed diff --git a/docs/pages/core-concepts/states/snippets/state-registry/flow_converted_output.py b/docs/pages/core-concepts/states/snippets/state-registry/flow_converted_output.py new file mode 100644 index 000000000..d9b35a53f --- /dev/null +++ b/docs/pages/core-concepts/states/snippets/state-registry/flow_converted_output.py @@ -0,0 +1,8 @@ +from hassette import STATE_REGISTRY + +state_dict = { + "entity_id": "binary_sensor.front_door", + "state": "on", +} +door_state = STATE_REGISTRY.try_convert_state(state_dict) +# Result: BinarySensorState with value=True diff --git a/docs/pages/core-concepts/states/snippets/state-registry/flow_raw_input.py b/docs/pages/core-concepts/states/snippets/state-registry/flow_raw_input.py new file mode 100644 index 000000000..a2cfb911d --- /dev/null +++ b/docs/pages/core-concepts/states/snippets/state-registry/flow_raw_input.py @@ -0,0 +1,4 @@ +state_dict = { + "entity_id": "binary_sensor.front_door", + "state": "on", # String from HA +} diff --git a/docs/pages/advanced/snippets/state-registry/union_type_support.py b/docs/pages/core-concepts/states/snippets/state-registry/union_type_support.py similarity index 100% rename from docs/pages/advanced/snippets/state-registry/union_type_support.py rename to docs/pages/core-concepts/states/snippets/state-registry/union_type_support.py diff --git a/docs/pages/core-concepts/states/snippets/state-registry/value_type_example.py b/docs/pages/core-concepts/states/snippets/state-registry/value_type_example.py new file mode 100644 index 000000000..84560af2b --- /dev/null +++ b/docs/pages/core-concepts/states/snippets/state-registry/value_type_example.py @@ -0,0 +1,22 @@ +from typing import Any, ClassVar, Literal + +from hassette.models.states import BaseState + + +class BoolBaseState(BaseState[bool | None]): + """Base class for boolean states. + + Valid state values are True, False, or None. + Converts the strings "on" and "off" to True and False. + """ + + value_type: ClassVar[type[Any] | tuple[type[Any], ...]] = (bool, type(None)) + + +class BinarySensorState(BoolBaseState): + """Representation of a Home Assistant binary_sensor state. + + See: https://www.home-assistant.io/integrations/binary_sensor/ + """ + + domain: Literal["binary_sensor"] diff --git a/docs/pages/core-concepts/states/snippets/states_iteration.py b/docs/pages/core-concepts/states/snippets/states_iteration.py index 7f7ca561d..1466751d1 100644 --- a/docs/pages/core-concepts/states/snippets/states_iteration.py +++ b/docs/pages/core-concepts/states/snippets/states_iteration.py @@ -5,9 +5,8 @@ class IteratorApp(App): async def on_initialize(self): # Find all low battery sensors for entity_id, sensor in self.states.sensor: - # sensor.attributes is a plain Pydantic model; unrecognised fields are - # not declared on the class, so access them via hasattr/getattr. - if not hasattr(sensor.attributes, "battery_level"): - continue - if sensor.attributes.battery_level < 20: # pyright: ignore[reportAttributeAccessIssue] + # battery_level is not declared on the typed attributes model, + # so read it via .extra(), which returns None when absent + battery = sensor.attributes.extra("battery_level") + if battery is not None and battery < 20: self.logger.warning("Low battery: %s", entity_id) diff --git a/docs/pages/advanced/snippets/type-registry/conversion_error.py b/docs/pages/core-concepts/states/snippets/type-registry/conversion_error.py similarity index 100% rename from docs/pages/advanced/snippets/type-registry/conversion_error.py rename to docs/pages/core-concepts/states/snippets/type-registry/conversion_error.py diff --git a/docs/pages/advanced/snippets/type-registry/custom_error_msg.py b/docs/pages/core-concepts/states/snippets/type-registry/custom_error_msg.py similarity index 100% rename from docs/pages/advanced/snippets/type-registry/custom_error_msg.py rename to docs/pages/core-concepts/states/snippets/type-registry/custom_error_msg.py diff --git a/docs/pages/advanced/snippets/type-registry/inspect_check.py b/docs/pages/core-concepts/states/snippets/type-registry/inspect_check.py similarity index 100% rename from docs/pages/advanced/snippets/type-registry/inspect_check.py rename to docs/pages/core-concepts/states/snippets/type-registry/inspect_check.py diff --git a/docs/pages/advanced/snippets/type-registry/inspect_list.py b/docs/pages/core-concepts/states/snippets/type-registry/inspect_list.py similarity index 100% rename from docs/pages/advanced/snippets/type-registry/inspect_list.py rename to docs/pages/core-concepts/states/snippets/type-registry/inspect_list.py diff --git a/docs/pages/advanced/snippets/type-registry/inspect_list_output.txt b/docs/pages/core-concepts/states/snippets/type-registry/inspect_list_output.txt similarity index 100% rename from docs/pages/advanced/snippets/type-registry/inspect_list_output.txt rename to docs/pages/core-concepts/states/snippets/type-registry/inspect_list_output.txt diff --git a/docs/pages/advanced/snippets/type-registry/lookup_example.py b/docs/pages/core-concepts/states/snippets/type-registry/lookup_example.py similarity index 100% rename from docs/pages/advanced/snippets/type-registry/lookup_example.py rename to docs/pages/core-concepts/states/snippets/type-registry/lookup_example.py diff --git a/docs/pages/advanced/snippets/type-registry/missing_converter.py b/docs/pages/core-concepts/states/snippets/type-registry/missing_converter.py similarity index 100% rename from docs/pages/advanced/snippets/type-registry/missing_converter.py rename to docs/pages/core-concepts/states/snippets/type-registry/missing_converter.py diff --git a/docs/pages/advanced/snippets/type-registry/pattern_enum.py b/docs/pages/core-concepts/states/snippets/type-registry/pattern_enum.py similarity index 100% rename from docs/pages/advanced/snippets/type-registry/pattern_enum.py rename to docs/pages/core-concepts/states/snippets/type-registry/pattern_enum.py diff --git a/docs/pages/advanced/snippets/type-registry/pattern_structured.py b/docs/pages/core-concepts/states/snippets/type-registry/pattern_structured.py similarity index 100% rename from docs/pages/advanced/snippets/type-registry/pattern_structured.py rename to docs/pages/core-concepts/states/snippets/type-registry/pattern_structured.py diff --git a/docs/pages/advanced/snippets/type-registry/simple_registration.py b/docs/pages/core-concepts/states/snippets/type-registry/simple_registration.py similarity index 100% rename from docs/pages/advanced/snippets/type-registry/simple_registration.py rename to docs/pages/core-concepts/states/snippets/type-registry/simple_registration.py diff --git a/docs/pages/getting-started/docker/dependencies.md b/docs/pages/getting-started/docker/dependencies.md index e96c40129..51d5b12b6 100644 --- a/docs/pages/getting-started/docker/dependencies.md +++ b/docs/pages/getting-started/docker/dependencies.md @@ -1,193 +1,94 @@ # Managing Dependencies -This guide explains how to install Python packages for your Hassette apps when running in Docker. +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. -## 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) - -### 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. - -## How the Startup Script Works - -When the container starts, the [startup script](https://github.com/NodeJSmith/hassette/blob/main/scripts/docker_start.sh) performs these steps in order: +## Using requirements.txt -```mermaid ---8<-- "pages/getting-started/docker/snippets/deps-startup-flow.mmd" +```txt +--8<-- "pages/getting-started/docker/snippets/requirements-example.txt" ``` -### 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. - -## Understanding APP_DIR vs PROJECT_DIR - -These two environment variables 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 | - -!!! 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. - -## 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" -``` +Place this file at `config/requirements.txt` on your host. That maps to +`/config/requirements.txt` inside the container. -**docker-compose.yml:** +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" ``` -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. - -### Traditional src/ Layout - -For projects using the standard Python `src/` layout: - -``` ---8<-- "pages/getting-started/docker/snippets/deps-src-dir-structure.txt" -``` +Without `HASSETTE__INSTALL_DEPS`, Hassette skips installation entirely. +The `uv_cache` volume keeps downloaded packages across restarts. +Only the first startup is slow. -**docker-compose.yml:** +Restart the container — the install runs during startup, and you can watch it with `docker compose logs -f hassette`. Your packages are then available: -```yaml ---8<-- "pages/getting-started/docker/snippets/deps-src-compose.yml" +```python +--8<-- "pages/getting-started/docker/snippets/deps-app-using-package.py" ``` -In this setup: +The app imports `apprise` directly. No extra configuration needed. (The `# pyright: ignore` comment in the example quiets an editor warning when the package isn't installed on your local machine — your own code doesn't need it.) -- 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 +!!! 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 -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: +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 — +a file recording the exact version of every package, so the container +installs the same versions you tested locally. Generate one by running +this in your `apps/` directory before starting the container: ```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. - -!!! 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. - -## 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`. - -## 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" +uv lock ``` -### Pre-building a Custom Image - -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: - -```dockerfile ---8<-- "pages/getting-started/docker/snippets/custom-image.dockerfile" -``` - -Then in `docker-compose.yml`: +Your compose file stays the same as the [Docker Setup](index.md) page. No extra +environment variables are needed: ```yaml ---8<-- "pages/getting-started/docker/snippets/custom-image-compose.yml" +--8<-- "pages/getting-started/docker/snippets/deps-pyproject-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. - -## Complete Examples +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. -### Example 1: Simple Flat Structure +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. -```yaml ---8<-- "pages/getting-started/docker/snippets/deps-example1-compose.yml" -``` +Hassette pins its own dependencies via a constraints file. Your packages +cannot conflict with packages Hassette depends on. If a conflict occurs, +the install fails at startup — see +[Troubleshooting](troubleshooting.md#dependencies-wont-install). -``` ---8<-- "pages/getting-started/docker/snippets/deps-example1-requirements.txt" -``` +!!! note + Commit `uv.lock` to version control. Hassette uses it to reproduce the + exact package versions you tested locally. -### Example 2: src/ Layout with Lock File +## Known Limitations -```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. Mount the shared +code as a volume with a relative path that matches the container layout, +or publish it as a package. -## See Also +**First startup is slower with new dependencies.** Hassette runs +`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 Overview](index.md) — Quick start guide -- [Troubleshooting](troubleshooting.md) — Common issues and solutions +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 9938dc368..9738e52eb 100644 --- a/docs/pages/getting-started/docker/image-tags.md +++ b/docs/pages/getting-started/docker/image-tags.md @@ -1,151 +1,35 @@ -# Docker Image Tags +# Image Tags -Hassette publishes Docker images for multiple Python versions. All tags explicitly include the Python version to avoid ambiguity. +Hassette ships as a Docker image hosted at `ghcr.io/nodejsmith/hassette` (GitHub Container Registry). Each tag combines a Hassette version and a Python version — for example, `v0.39.0-py3.13`. -## Tag Format +The tag goes on the `image:` line of your `docker-compose.yml` from [Docker Setup](index.md). -### Recommended: Pin Both Version and Python +## Which Tag to Use -For reproducible production builds, pin both the Hassette version and Python version: - -``` ---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. - -!!! 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. - -!!! 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" -``` - -**Example:** - -- `ghcr.io/nodejsmith/hassette:main-py3.13` - -!!! 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" -``` - -## Supported Python Versions - -Each release is built for multiple Python versions: - -| 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: +For production, use a tag with a specific version number: ```yaml --8<-- "pages/getting-started/docker/snippets/tag-pinned-compose.yml" ``` -### For Development +A version-specific tag never changes — Docker downloads the same code every time. -Use the latest stable release: +For development, use `latest-py3.13`: ```yaml --8<-- "pages/getting-started/docker/snippets/tag-latest-compose.yml" ``` -### For Testing Pre-release Features - -Use a specific pre-release version: +`latest-py3.*` tags track the most recent stable release. New features arrive on the next pull. -```yaml ---8<-- "pages/getting-started/docker/snippets/tag-prerelease-compose.yml" -``` +Python 3.11, 3.12, 3.13, and 3.14 are all supported. Replace `py3.13` in the tag with your preferred version. -## Updating Images +## Updating -### Pull Latest +Run this in the directory containing your `docker-compose.yml`: -```bash +```sh --8<-- "pages/getting-started/docker/snippets/docker-pull-update.sh" ``` -### Check Current Version - -```bash ---8<-- "pages/getting-started/docker/snippets/docker-version-check.sh" -``` +`pull` fetches the new image. `up -d` restarts the container with it. Docker prints the download progress, then a `Started` line — check the logs afterward to confirm Hassette reconnected. diff --git a/docs/pages/getting-started/docker/index.md b/docs/pages/getting-started/docker/index.md index 390086674..5e0b5c878 100644 --- a/docs/pages/getting-started/docker/index.md +++ b/docs/pages/getting-started/docker/index.md @@ -1,173 +1,97 @@ # 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. +Run Hassette in a container with Docker Compose. ## Prerequisites -- Docker and Docker Compose 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. +- **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 -The fastest path from zero to a running Hassette instance: - -**1. Create a project directory** +### Step 1: Create the project ```bash --8<-- "pages/getting-started/docker/snippets/mkdir-project.sh" ``` -**2. Create the Docker Compose file** +`project_dir` is a placeholder — name the directory whatever you like. `config/` holds your token and settings. `apps/` holds your automation code. -Create `docker-compose.yml` in `project_dir`: +### Step 2: Create docker-compose.yml ```yaml --8<-- "pages/getting-started/docker/snippets/docker-compose.yml" ``` -**3. Create your configuration** +The `image:` line pulls Hassette from GitHub Container Registry (`ghcr.io`) — Docker downloads it automatically on first run. The volumes break down like this: -Create `config/.env` with your Home Assistant token: +- `./config` and `./apps` mount your local directories into the container. +- `data` and `uv_cache` are named volumes for persistent data and the package cache. Docker Compose creates them automatically — no action needed. + +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. + +### Step 3: Create config/.env ```bash --8<-- "pages/getting-started/docker/snippets/env-file.sh" ``` -Create `config/hassette.toml`: +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` — when in doubt, use the IP address. The container-name form (`http://homeassistant:8123`) only works when HA also runs in Docker on the same Docker network. -```toml ---8<-- "pages/getting-started/docker/snippets/hassette.toml" -``` +The `__` double underscore is how Hassette maps environment variables to nested settings — `HASSETTE__TOKEN` sets `token`. Hassette reads `/config/.env` automatically on startup; you do not need an `env_file:` directive in the compose file. -Create `apps/my_app.py`: - -```python ---8<-- "pages/getting-started/docker/snippets/my_app.py" -``` - -**4. Start Hassette** +### Step 4: Start it ```bash --8<-- "pages/getting-started/docker/snippets/docker-compose-up.sh" ``` -After a few seconds, check the logs: +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 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. - -## Directory Structure - -Hassette expects the following directory structure when running in Docker: +You see output like: ``` ---8<-- "pages/getting-started/docker/snippets/dir-structure.txt" +INFO hassette ... ─ Hassette is running. ``` -The Docker image uses four volumes: - -| 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) | +Hassette is running, and the web UI is available at `http://localhost:8126`. If you see an error instead of this line, head to [Troubleshooting](troubleshooting.md). -!!! note "Package Structure" - For simple setups, put `.py` files directly in `./apps`. For projects with external Python dependencies, see [Managing Dependencies](dependencies.md). +## Write Your First App -## Configuration - -### Home Assistant Token - -Create `config/.env` with your Home Assistant token: +Create `apps/my_app.py`: -```bash ---8<-- "pages/getting-started/docker/snippets/env-file.sh" +```python +--8<-- "pages/getting-started/docker/snippets/my_app.py" ``` -!!! warning "Security" - Never commit `.env` files to version control. Add `config/.env` to your `.gitignore`. - -### Environment Variables Reference - -Override any configuration via environment variables using the `HASSETTE__` prefix: - -| 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`) | +[`App`](../../core-concepts/apps/index.md) runs your automation logic and gives you access to the bus (subscribes to HA events), the scheduler (runs code on a timer), and the API (calls HA services). [`AppConfig`](../../core-concepts/apps/configuration.md) loads and validates your app's settings from the environment, including `config/.env`. `on_initialize` runs once when the app starts. -See [Managing Dependencies](dependencies.md) for details on `HASSETTE__APPS__DIRECTORY` and `HASSETTE__PROJECT_DIR`. +Two pieces of syntax worth knowing: `App[MyAppConfig]` pairs your app with its config class — that's how `self.app_config` knows its type. And lifecycle hooks like `on_initialize` are `async def` — Hassette runs the event loop for you, so you can follow the pattern without prior async experience. -## Production Deployment +Restart the container to pick up the new file: -### 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" +```bash +docker compose restart hassette ``` -With this configuration, Hassette restarts apps when you change files in `./apps/`. - -!!! warning "Performance" - File watching adds overhead. Only enable if you need it. - -### Graceful Shutdown +Check the logs again. You see `Hello from Docker!` from your app: -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. - -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. - -!!! 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. - -## Viewing Logs - -### Docker Compose Logs - -```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`. If your app doesn't show up in the logs, see [Troubleshooting](troubleshooting.md) for app-loading and other common issues. -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. +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) — Choose the right Docker image -- [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 +- [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-compose.yml b/docs/pages/getting-started/docker/snippets/docker-compose.yml index 1ce0481e1..121337850 100644 --- a/docs/pages/getting-started/docker/snippets/docker-compose.yml +++ b/docs/pages/getting-started/docker/snippets/docker-compose.yml @@ -3,7 +3,7 @@ services: image: ghcr.io/nodejsmith/hassette:latest-py3.13 container_name: hassette restart: unless-stopped - stop_grace_period: 45s + stop_grace_period: 45s # time for Hassette to shut down apps cleanly before Docker force-kills it volumes: - ./config:/config - ./apps:/apps @@ -15,9 +15,8 @@ services: - HASSETTE__LOG_LEVEL=info - TZ=America/New_York # Set your timezone healthcheck: - # Liveness, not readiness: /api/health/live ignores Home Assistant connectivity, so an HA - # outage does not mark the container unhealthy. Pointing a restart-triggering healthcheck at - # /api/health (or /api/health/ready) causes a restart loop whenever HA restarts. + # Keep this pointed at /api/health/live — it stays healthy during HA outages. + # Using /api/health/ready here causes a restart loop whenever HA restarts. test: ["CMD", "curl", "-sf", "http://127.0.0.1:8126/api/health/live"] interval: 30s timeout: 5s 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/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/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 99dc7fd27..12a574bc0 100644 --- a/docs/pages/getting-started/docker/troubleshooting.md +++ b/docs/pages/getting-started/docker/troubleshooting.md @@ -1,364 +1,126 @@ -# Troubleshooting +# Docker Troubleshooting -This guide covers common issues when running Hassette in Docker and how to resolve them. +Each section below covers one symptom. Jump to the one that matches your situation. -## Container Won't Start +## Container Exits Immediately -### Check the Logs - -Always start by checking the logs: +The container starts and stops within a few seconds. Check the logs first: ```bash --8<-- "pages/getting-started/docker/snippets/ts-check-logs.sh" ``` -For more detail: - -```bash ---8<-- "pages/getting-started/docker/snippets/ts-check-logs-tail.sh" -``` - -### Common Startup Issues +The two most common causes are a missing token and an unreachable Home Assistant instance. -#### Token Not Set +**Missing token.** Hassette reads `HASSETTE__TOKEN` from `/config/.env` inside the container — that's the `./config/.env` file on your host. If that value is absent, Hassette exits at startup. Open your `config/.env` file and confirm the line is present: -**Symptom:** Error about missing or invalid token - -**Solution:** Ensure `HASSETTE__TOKEN` is set in `config/.env`: - -```bash ---8<-- "pages/getting-started/docker/snippets/env-file.sh" +``` +HASSETTE__TOKEN=your_long_lived_token_here ``` -#### Can't Reach Home Assistant - -**Symptom:** Connection refused or timeout errors - -**Solutions:** +**Wrong base URL.** `HASSETTE__BASE_URL` must point to Home Assistant's HTTP interface. Use `http://homeassistant:8123` when HA runs as a container on the same Docker network (for example, in the same compose file); otherwise use your HA instance's IP address. Match the scheme to the URL you use for HA in your browser — `http://` or `https://`. A trailing slash, or `https://` when HA serves plain HTTP, causes a connection failure. -1. Verify `base_url` in `hassette.toml` is correct -2. Check network configuration -3. Test connectivity from the container: +**Network not reachable.** If the URL looks correct, test the connection from inside the container (substitute the same token value from your `config/.env`): ```bash --8<-- "pages/getting-started/docker/snippets/ts-curl-ha.sh" ``` -#### Permission Errors - -**Symptom:** Permission denied when reading files - -**Solution:** The container runs as user `hassette`. Ensure mounted files are readable: +A healthy response looks like `{"message": "API running."}`. An empty response or connection error means the container can't reach HA on that address. -```bash ---8<-- "pages/getting-started/docker/snippets/ts-chmod.sh" -``` +## "Connected" But Apps Don't Load -## Apps Not Loading +Hassette reports a successful connection in the logs, but your apps never initialize. -### 1. 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" ``` -### 2. Verify App Directory Configuration - -Ensure `apps.directory` in `hassette.toml` matches the container path: - -```toml ---8<-- "pages/getting-started/docker/snippets/ts-app-dir-toml.toml" -``` - -If using `src/` layout: +If this returns an empty directory or an error, check your `volumes:` block in `compose.yml`. It should include a line like `./apps:/apps`. -```yaml ---8<-- "pages/getting-started/docker/snippets/ts-app-dir-src-env.yml" -``` - -### 3. Check for Python Errors - -Look for syntax or import errors 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" ``` -### 4. Verify App Configuration - -Ensure your app is configured in `hassette.toml`: - -```toml ---8<-- "pages/getting-started/docker/snippets/ts-app-config.toml" -``` - -## Dependency Installation Fails - -### Check Installation Output - -Look for installation errors in the logs: - -```bash ---8<-- "pages/getting-started/docker/snippets/ts-dep-install-logs.sh" -``` - -### Dependency Conflicts - -**Symptom:** Container exits at startup with a `DEPENDENCY CONFLICT` banner followed by a uv resolver error like: - -``` ---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. - -**How to fix it:** - -For project-based installs (`pyproject.toml` + `uv.lock`): - -```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: - -```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: +Look for a `SyntaxError` or `ImportError` with a file path. Fix the error in that file, restart with `docker compose restart hassette`, then check the logs again to confirm the error is gone. -```toml ---8<-- "pages/getting-started/docker/snippets/ts-pin-hassette-pyproject.toml" -``` - -Re-run `uv lock` after changing the pin, then commit both files. - -### pyproject.toml Not Found +## Dependencies Won't Install -**Symptom:** "No pyproject.toml found" or dependencies not installing +Your app imports a third-party package, but Hassette reports an `ImportError` at startup. -**Solution:** Check `HASSETTE__PROJECT_DIR` points to the right location: +Hassette only installs from `requirements.txt` when `HASSETTE__INSTALL_DEPS=1` is set in the compose `environment:` block. Check your `compose.yml`: ```yaml ---8<-- "pages/getting-started/docker/snippets/ts-project-dir-env.yml" +environment: + HASSETTE__INSTALL_DEPS: "1" ``` -Verify the file exists: +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-cat-pyproject.sh" +docker compose exec hassette ls /config/requirements.txt ``` -### 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" - -**Solution:** Generate a lockfile locally and commit it: +Then check whether the install ran at startup: ```bash ---8<-- "pages/getting-started/docker/snippets/uv-lock.sh" +--8<-- "pages/getting-started/docker/snippets/ts-dep-install-logs.sh" ``` -If you cannot run `uv` locally, use the `requirements.txt` approach with `HASSETTE__INSTALL_DEPS=1` instead. - -### requirements.txt Not Found +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 — `down` stops and removes the container (data in your mounted volumes is safe), and `up -d` recreates it from the current compose file. -**Symptom:** `requirements.txt` files are not being installed +## Can't Access the Web UI -**Solution:** Check these in order: +You navigate to `http://your-host:8126` and get a connection refused error. -1. **Confirm `HASSETTE__INSTALL_DEPS=1` is set** — requirements discovery is disabled by default. Without this variable, no requirements files are scanned. +The port is not published unless your `compose.yml` includes a `ports:` mapping for the Hassette service: ```yaml ---8<-- "pages/getting-started/docker/snippets/deps-install-deps-env.yml" +services: + hassette: + ports: + - "8126:8126" ``` -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. +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 the host machine itself. Use `"8126:8126"` to accept connections from other devices on your network, like your phone. -3. **Verify the file is under `/config` or `/apps`** and is not empty. +After updating `compose.yml`, run `docker compose up -d` to apply the change. -Check what the container sees: +## Changes to Apps Don't Take Effect -```bash ---8<-- "pages/getting-started/docker/snippets/ts-find-requirements.sh" -``` +You edit an app file on disk, but Hassette continues running the old version. -### Version Conflicts - -**Symptom:** Package version conflicts during installation - -**Solutions:** - -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: +The file watcher is off in production mode by default. Restart the container to pick up your changes: ```bash ---8<-- "pages/getting-started/docker/snippets/ts-check-constraints.sh" +docker compose restart hassette ``` -### Import Errors at Runtime - -If apps fail to import installed packages: - -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 +To apply edits without restarting, set `allow_reload_in_prod = true` under `[hassette]` in your `hassette.toml` (in your `config/` directory). File watching itself is already on by default. ## Hassette Restarts Whenever Home Assistant Goes Down **Symptom:** Hassette keeps restarting in a loop whenever Home Assistant restarts or goes offline, even though Hassette itself is healthy. -**Cause:** A Docker healthcheck or an autoheal tool (e.g. `willfarrell/autoheal`) is pointed at `/api/health/ready`. That endpoint returns HTTP 503 when Hassette cannot reach Home Assistant, which looks unhealthy to Docker and triggers a restart. The container is marked unhealthy during every HA outage — including routine HA restarts — so autoheal keeps killing and restarting Hassette unnecessarily. +**Cause:** Your `compose.yml` has a `healthcheck:` section (or an autoheal tool such as `willfarrell/autoheal`) pointed at `/api/health/ready`. If you have no `healthcheck:` configured, this section doesn't apply to you. That endpoint returns HTTP 503 when Hassette cannot reach Home Assistant, which looks unhealthy to Docker and triggers a restart. The container is marked unhealthy during every HA outage, including routine HA restarts, so autoheal keeps killing and restarting Hassette unnecessarily. -**Fix:** Point your healthcheck at `/api/health/live` instead. The liveness endpoint returns 200 whenever the Hassette event loop can respond, regardless of Home Assistant connectivity. Only a true process failure (wedged event loop, container crash, non-zero exit) makes a liveness probe fail. +**Fix:** Point your healthcheck at `/api/health/live` instead. The liveness endpoint returns 200 whenever the Hassette event loop can respond, regardless of Home Assistant connectivity. Only a true process failure — Hassette itself crashed or stopped responding — makes a liveness probe fail. ```yaml --8<-- "pages/getting-started/docker/snippets/ts-healthcheck-live.yml" ``` -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. - -### Symptoms - -- Container marked as unhealthy -- Container keeps restarting - -### Solutions - -1. **Check if Hassette is starting successfully:** - -```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: - -```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. - -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: - -```yaml ---8<-- "pages/getting-started/docker/snippets/ts-health-check-long-start.yml" -``` - -## Hot Reload Not Working - -### Requirements - -For hot reload to work: - -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` - -### Configuration - -```toml ---8<-- "pages/getting-started/docker/snippets/ts-hot-reload.toml" -``` - -### Verify Volume Mounts - -Ensure files are mounted, not copied: - -```yaml ---8<-- "pages/getting-started/docker/snippets/ts-vol-mount.yml" -``` - -## Import Errors - -### Package Not Found - -**Symptom:** `ModuleNotFoundError: No module named 'xyz'` - -**Solutions:** - -1. Verify the package is in your dependencies: - -```toml ---8<-- "pages/getting-started/docker/snippets/ts-pyproject-dep.toml" -``` - -2. Check installation logs for errors - -3. Verify the correct Python environment is active - -### 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: - -```bash ---8<-- "pages/getting-started/docker/snippets/docker-pull-update.sh" -``` - -## Performance Issues - -### Slow Container Startup - -**Causes:** - -- Installing many dependencies on each start -- No package cache - -**Solutions:** - -1. Use `uv.lock` for faster resolution (packages are already pinned, no resolution needed) -2. Mount a persistent cache volume: - -```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) - -### High Memory Usage - -**Solutions:** - -1. Check for memory leaks in your apps -2. Limit container memory: - -```yaml ---8<-- "pages/getting-started/docker/snippets/ts-memory-limit.yml" -``` +If a reverse proxy or load balancer needs to know when Hassette is fully connected, point it at `/api/health/ready` — but keep that endpoint out of any healthcheck that triggers restarts. See [Configure Health Checks](../../web-ui/health-endpoints.md) for the full reference. ## Getting Help -If you can't resolve an issue: - -1. **Search existing issues:** [GitHub Issues](https://github.com/NodeJSmith/hassette/issues) - -2. **Collect diagnostic information:** - -```bash ---8<-- "pages/getting-started/docker/snippets/ts-diagnostics.sh" -``` - -3. **Open a new issue** with the diagnostic information +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 Overview](index.md) — Quick start guide -- [Managing Dependencies](dependencies.md) — Dependency installation details +For problems not specific to Docker (app logic, bus subscriptions, scheduler behavior), see the main [Troubleshooting](../../troubleshooting.md) page. diff --git a/docs/pages/getting-started/first-automation.md b/docs/pages/getting-started/first-automation.md index 8dd57e4a4..81b996461 100644 --- a/docs/pages/getting-started/first-automation.md +++ b/docs/pages/getting-started/first-automation.md @@ -1,115 +1,59 @@ # 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 filled in automatically +- **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.bus.on_state_change()`](../core-concepts/bus/index.md) registers a handler that fires whenever an entity's state changes, like a light switching on or a sensor reporting a new reading. `"sun.*"` is a glob pattern that 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](../web-ui/index.md) (a monitoring dashboard Hassette includes). -`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. +The handler parameter `new_state: D.StateNew[states.SunState]` is a type annotation that tells Hassette: when this handler fires, pass in the new state as a `SunState` object. The bracket syntax works like `list[str]` in standard Python generics. `D.StateNew[T]` means "a new-state value, typed as `T`." Here is what the two imports do: -??? 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: +- **[`D`](../core-concepts/bus/dependency-injection.md)** is `hassette.event_handling.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"`. - ```python - --8<-- "pages/getting-started/snippets/first_automation_step3_raw.py:raw_handler" - ``` +This is a Hassette feature. Standard Python ignores type annotations at runtime, but Hassette inspects them to know what to pass into each handler. No event dict parsing needed. Your IDE knows the type, and Pyright (a Python type checker, optional but useful in VS Code) catches typos. - Note that raw state dicts use `new_state["state"]` (the key Home Assistant uses in its event payload), while typed state objects use `.value`. +[`self.api.turn_on()`](../core-concepts/api/index.md) calls the `light.turn_on` service in Home Assistant, the same action as toggling a light from the HA UI. The `domain="light"` parameter tells HA which service domain to use. You can find available services in **Developer Tools → Services** in your Home Assistant instance. -## Step 4: Schedule a recurring job +## Schedule a Recurring Job -Use `self.scheduler.run_minutely()` to run a handler every minute: - -```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`). - -## Step 5: Run it +[`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. -With this code in place as `hassette_apps/main.py`, start Hassette: +`log_heartbeat` has no `D.*` annotations in its signature. Not every handler needs them. See [`Scheduler` Methods](../core-concepts/scheduler/methods.md) for `run_daily`, `run_cron`, `run_once`, and more. -```bash -uv run hassette run -``` +## Run It -You should see output like: +Replace your `apps/main.py` with the complete app from the previous snippet. Stop Hassette with `Ctrl+C` and run `hassette run -e .env` again. 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. +The `Sun changed` and `Porch light turned on` lines appear at the next sunset. To test the handler now without waiting, trigger a state change manually: -## Next steps +1. In Home Assistant, go to **Settings → Developer Tools**. +2. Go to the **States** tab and find `sun.sun`. +3. Change the state value to `below_horizon` and click **Set State**. -- **[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 +The handler fires within milliseconds. You see `Sun changed: below_horizon` and `Porch light turned on` in the logs. ---- +## 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..ff6466ddc 100644 --- a/docs/pages/getting-started/ha_token.md +++ b/docs/pages/getting-started/ha_token.md @@ -1,36 +1,36 @@ # 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 (one that does not expire). You generate it once in the Home Assistant UI and store it in your project's `.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`][hassette.core.core.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`: +In your project directory, open (or create) a file named `.env` and add the token: ```bash --8<-- "pages/getting-started/snippets/env_token.sh" ``` -The [Quickstart](index.md) guide covers the rest of the configuration. +If you have not started the [Quickstart](index.md) yet, head there next for the full `.env` setup and first run. The [Docker Setup](docker/index.md) covers container-specific configuration. + +To verify the token works, complete the Quickstart and run `hassette status`. If the token is valid, you see `websocket_connected: true`. An authentication error means the token was not copied in full or has been revoked. -!!! 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/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/index.md b/docs/pages/getting-started/index.md index f6b9835cd..ffbffadf5 100644 --- a/docs/pages/getting-started/index.md +++ b/docs/pages/getting-started/index.md @@ -1,117 +1,97 @@ # 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 app that logs a greeting on startup, 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 (a token that does not expire, generated in HA). -## 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 (Profile → Security → Long-Lived Access Tokens). -## 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 an `apps/` folder, then create a `.env` file inside it: ```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. +Replace the token value with the one you created in step 2. Update `HASSETTE__BASE_URL` to the URL you normally open in your browser, typically `http://homeassistant.local:8123` or `http://:8123`. The double underscores in `HASSETTE__TOKEN` and `HASSETTE__BASE_URL` are required. Hassette uses them to separate configuration namespaces. -!!! 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. +## 4. Create your first app ---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`: +Your app methods use `async def`, and calls to Hassette services need `await` in front of them. The rule: write `async def` for all your app methods, and add `await` before any call to `self.bus`, `self.scheduler`, or `self.api`. Regular calls like `self.logger.info()` do not need `await`. That covers everything in this guide. ```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**. +Save this as `apps/main.py`. Hassette scans `apps/` for any class that inherits from `App` and runs all of them. -!!! 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: +`MyAppConfig` declares your app's settings as typed class attributes. Hassette loads values from environment variables and [`hassette.toml`](../core-concepts/configuration/index.md) automatically. `App[MyAppConfig]` ties the config to the app so `self.app_config` is always the right type. - Add to your imports: `from hassette import D, states` +Every app inherits five objects from Hassette: `self.logger` (Python logger), `self.bus` (listens for things happening in Home Assistant, like a light turning on), `self.scheduler` (runs functions on a timer), `self.api` (sends commands to Home Assistant), and `self.states` (reads current entity states from a local cache). Hassette creates them at startup. You just use them. - ```python - --8<-- "pages/getting-started/snippets/typed_handler.py:typed-handler" - ``` - -## 7. Run Hassette - -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. +The second line (`Hello from Hassette!`) comes from your `on_initialize` method. If you change the `greeting` field in `MyAppConfig` and restart, you see your new text. 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`. `Invoc/1h` counts how many times your app's handlers have fired in the last hour. Zero is normal — the app logs a greeting at startup but does not react to anything in Home Assistant yet. The next guide covers that. + !!! 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): react to state changes, get typed event data automatically, and schedule recurring 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 new file mode 100644 index 000000000..649f5c940 --- /dev/null +++ b/docs/pages/getting-started/is-hassette-right-for-you.md @@ -0,0 +1,54 @@ +# Is Hassette Right for You? + +Hassette is a Python framework for writing Home Assistant automations. It runs as a separate process and connects to Home Assistant directly (no extra setup needed on the HA side). 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 pytest with a built-in test helper. You can simulate sensor triggers, fast-forward timers, 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 wished you could write a normal Python `if`/`else` instead of a YAML condition, or debugged a Jinja2 template by adding `{{ "got here" }}` in the middle, Hassette is built for you. + +## 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 | pytest integration, event simulation, time control | +| **Version control** | Text files | Text files | +| **Learning curve** | Low to medium | Medium (Python + async basics, introduced in the Quickstart) | +| **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 (one that does not expire) you generate in your HA profile settings (Profile → Security → Long-Lived Access Tokens). 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. The [Quickstart](index.md) introduces the pattern as you go. If async feels unfamiliar, start there rather than studying it separately first. + +## 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. + +New to Home Assistant automation frameworks entirely? The [Quickstart](index.md) is the right starting point. 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_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.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_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/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/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/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! 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/api.md b/docs/pages/migration/api.md index 2ab2f33ec..fd98f4edd 100644 --- a/docs/pages/migration/api.md +++ b/docs/pages/migration/api.md @@ -1,91 +1,75 @@ # 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. +!!! note "Coming from synchronous AppDaemon?" + Every Hassette lifecycle method and handler is `async def`, and calls to `self.api` need `await` in front — `await` pauses the handler until the network call finishes. Reads from `self.states` (the local cache) are plain synchronous calls. [Migration Concepts](concepts.md#async-vs-sync) covers the model. ## 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 - def my_callback(self, **kwargs): - self.call_service("light/turn_on", entity_id="light.kitchen", brightness=200) - - # or use the helper - self.turn_on("light.kitchen", brightness=200) + --8<-- "pages/migration/snippets/api_appdaemon_call_service.py" ``` - 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. + Without `await`, the call appears to succeed but the service never runs — Python just hands back an unexecuted coroutine. If service calls have no effect, check that every call site has `await`. + +Two signature differences from AppDaemon: the entity belongs in the `target` dict (`target={"entity_id": "light.kitchen"}`) rather than AppDaemon's bare `entity_id=` keyword, and Hassette handlers don't take `**kwargs` — event data arrives through typed parameters instead (see [Bus & Events](bus.md)). ## Setting States === "AppDaemon" ```python - self.set_state("sensor.custom", state="42", attributes={"unit": "widgets"}) + --8<-- "pages/migration/snippets/api_appdaemon_set_state.py" ``` === "Hassette" @@ -94,37 +78,42 @@ For cases where you need to force a fresh read from Home Assistant (rare): --8<-- "pages/migration/snippets/api_hassette_set_state.py" ``` -## Logging - -AppDaemon provides `self.log()` and `self.error()`. Hassette uses Python's standard `logging` module via `self.logger`: +## Firing Events === "AppDaemon" ```python - self.log("This is a log message") - self.log(f"Value: {value}") - self.error("Something went wrong") + self.fire_event("custom_event", entity_id="sensor.test", value=42) ``` === "Hassette" ```python - --8<-- "pages/migration/snippets/api_logging.py" + await self.api.fire_event("custom_event", {"entity_id": "sensor.test", "value": 42}) ``` -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. +`fire_event` sends an event to Home Assistant's event bus. The event data is a dict in Hassette (AppDaemon accepts kwargs). For broadcasting between apps in the same Hassette process without leaving the framework, use [`self.bus.emit()`](../core-concepts/bus/handlers.md#cross-app-communication) instead. -## Full State Migration Example +## Logging -The following example shows the complete migration of a state-reading pattern: +AppDaemon provides `self.log()` and `self.error()`. Hassette uses Python's standard `logging` module via `self.logger`. -```python ---8<-- "pages/migration/snippets/api_migration_getting_states.py" -``` +=== "AppDaemon" + + ```python + --8<-- "pages/migration/snippets/api_appdaemon_logging.py" + ``` + +=== "Hassette" + + ```python + --8<-- "pages/migration/snippets/api_logging.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 +- [API Methods](../core-concepts/api/methods.md), reading state and calling services +- [API Overview](../core-concepts/api/index.md), full API reference diff --git a/docs/pages/migration/async-basics.md b/docs/pages/migration/async-basics.md new file mode 100644 index 000000000..ff287a7bb --- /dev/null +++ b/docs/pages/migration/async-basics.md @@ -0,0 +1,82 @@ +# Async Basics + +AppDaemon runs each app in its own thread, so synchronous code that blocks is harmless. Hassette runs every app in a single asyncio *event loop* — one thread that runs every handler, switching between them at `await` points. Two things follow: calls into the bus, scheduler, and API need `await`, and a blocking call in one app freezes all of them. This page builds the mental model behind both rules — what a coroutine is, what `await` does, and how to recognize the failure when one goes missing. + +If you have a large synchronous codebase and aren't ready to convert it, [`AppSync`][hassette.app.app.AppSync] runs your app in a managed thread instead — see [Mental Model](concepts.md#synchronous-api-appsync). + +## What a Coroutine Is + +`async def` declares a coroutine function. Calling one does not run its body. The call returns a *coroutine object* — a description of work that hasn't started yet. `await` is what actually runs it. + +This is the root cause of the most common migration bug. A call that looks complete is actually a no-op: + +```python +--8<-- "pages/migration/snippets/async_coroutine_basics.py:unawaited" +``` + +The handler finishes without error. The coroutine object is created, never started, and discarded. No service is called, nothing is logged, and Hassette can't catch it for you — creating a coroutine without running it is legal Python. A type checker can catch it: Pyright flags this exact code with `reportUnusedCoroutine`, which is a strong reason to run Pyright over migrated apps. + +Adding `await` runs the call: + +```python +--8<-- "pages/migration/snippets/async_coroutine_basics.py:awaited" +``` + +`await` does two things: it starts the coroutine and pauses the current handler until it finishes. While this handler is paused, the event loop runs other handlers — yours and other apps'. That cooperative handoff is how one thread serves every app. + +Python eventually notices a discarded coroutine and emits `RuntimeWarning: coroutine '...' was never awaited`. In test output, that warning is the clearest sign of a missing `await` — see [Testing](testing.md). + +## Which Calls Need `await` + +Anything that talks to Home Assistant or registers future work is async. Reads from the local state cache are not. + +| Call | Needs `await`? | +|---|---| +| `self.api.call_service(...)` | Yes | +| `self.api.get_state(...)` | Yes | +| `self.bus.on_state_change(...)` and all `on_*` methods | Yes | +| `self.scheduler.run_in(...)` and all scheduling methods | Yes | +| `self.task_bucket.run_in_thread(...)` | Yes | +| `self.states.light.get(...)` | No — synchronous | +| `self.states.get(...)` | No — synchronous | +| `subscription.cancel()` / `job.cancel()` | No — synchronous | + +`await` only works inside an `async def` method, so converting a call usually means converting the method that contains it too — AppDaemon's `def on_motion(self, ...):` becomes `async def on_motion(self):`. (`self.task_bucket`, like `self.bus` and `self.api`, is available on every `App` instance — see [Task Bucket](../core-concepts/apps/task-bucket.md).) + +In `AppSync` apps, none of this applies — use the `.sync` facades (`self.api.sync.call_service(...)`) with no `await`. See [Mental Model](concepts.md#synchronous-api-appsync). + +## Why Blocking Calls Freeze Every App + +The event loop moves between handlers only at `await` points. A synchronous call that takes five seconds — `time.sleep(5)`, `requests.get(...)`, a slow database query — holds that thread for five seconds. No `await` point, no handoff: every handler in every app waits until it returns. + +```python +--8<-- "pages/migration/snippets/async_blocking.py:blocking" +``` + +The fix is to move the blocking call to a thread, where it can take as long as it likes without holding the loop: + +```python +--8<-- "pages/migration/snippets/async_blocking.py:offload" +``` + +`run_in_thread` runs the function in a thread pool and the `await` pauses only this handler until the result is ready. See [Task Bucket](../core-concepts/apps/task-bucket.md) for the full reference, including `spawn()` for background loops. + +If most of an app's code blocks, don't wrap every call — use [`AppSync`][hassette.app.app.AppSync] and migrate incrementally. + +## Spotting a Missing `await` + +A missing `await` never raises at the call site, so it shows up as something else looking broken. The symptoms map directly to the call that was skipped: + +- **A service call has no effect** — no light turns on, no error in the logs. Check the `self.api.*` call for a missing `await`. +- **A listener never fires** — the handler is defined but nothing reaches it. Check the `self.bus.on_*` registration in `on_initialize`. +- **A scheduled job never runs** — check the `self.scheduler.*` call that was supposed to create it. +- **`RuntimeWarning: coroutine '...' was never awaited`** appears in logs or pytest output — the warning names the coroutine, which points you at the exact call. + +The first three fail silently in live operation, which is why tests catch this class of bug faster than log-watching does — the `RuntimeWarning` surfaces in pytest output even when every assertion passes. The [Migration Checklist](checklist.md) folds these checks into the step-by-step workflow. + +## See Also + +- [Mental Model](concepts.md) — app structure, the access model, and `AppSync` +- [Bus & Events](bus.md) — migrating listeners, where every registration is awaited +- [Task Bucket](../core-concepts/apps/task-bucket.md) — `run_in_thread` and background tasks +- [Migration Checklist](checklist.md) — the step-by-step migration workflow diff --git a/docs/pages/migration/bus.md b/docs/pages/migration/bus.md index 24b135e90..f1468acee 100644 --- a/docs/pages/migration/bus.md +++ b/docs/pages/migration/bus.md @@ -1,98 +1,114 @@ # 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 +!!! note "Coming from synchronous AppDaemon?" + All registration methods (`on_state_change`, `on_attribute_change`, `on_call_service`, `on`) are `async` and must be awaited — see [Async Basics](async-basics.md) if that shift is new to you. -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. +## The `name=` Requirement -Hassette centralizes event subscriptions on `self.bus`. Each subscription method returns a `Subscription` object. You cancel it by calling `.cancel()` on that object. +Every `self.bus.on_*()` call requires a `name=` argument. Omitting it raises [`ListenerNameRequiredError`][hassette.exceptions.ListenerNameRequiredError] at call time. Hassette uses this name in log output and the monitoring UI, and to avoid registering the same listener twice after a reload. -!!! warning "Handler constraints" - Handlers **cannot** use positional-only parameters (parameters before `/`) or variadic positional arguments (`*args`). This applies to all `self.bus` subscription methods. +=== "Missing name (breaks)" -!!! 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. + ```python + --8<-- "pages/migration/snippets/bus_name_missing.py" + ``` -## State Change Listeners +=== "With name (correct)" -### AppDaemon + ```python + --8<-- "pages/migration/snippets/bus_name_correct.py" + ``` -In AppDaemon, `self.listen_state()` listens for state changes on an entity. Callback signatures must follow a fixed pattern: +This is the most common cause of breakage when porting AppDaemon apps. Add `name=` to every subscription call before running the app. -```python ---8<-- "pages/migration/snippets/bus_appdaemon_state_change.py" -``` +## State Change Listeners -### Hassette: with Dependency Injection (recommended) +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: instead of AppDaemon's fixed `(entity, attribute, old, new, kwargs)`, declare only the parameters the handler needs and give them type hints — Hassette reads the hints and passes the matching values in. This pattern is called dependency injection. -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: +=== "AppDaemon" -```python ---8<-- "pages/migration/snippets/bus_hassette_state_change_di.py" -``` + ```python + --8<-- "pages/migration/snippets/bus_appdaemon_state_change.py" + ``` -### Hassette: with the full event object +=== "Hassette (dependency injection, recommended)" -If you prefer to receive the raw event and inspect it yourself: + ```python + --8<-- "pages/migration/snippets/bus_hassette_state_change_di.py" + ``` -```python ---8<-- "pages/migration/snippets/bus_hassette_state_change_event.py" -``` +=== "Hassette (full event)" -### Filter options + ```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 — `D` is `hassette.event_handling.dependencies`, `states` is the module of typed state classes. `AppConfig` in the example replaces AppDaemon's `self.args`; fields declared on it are set in `hassette.toml` (see [Configuration](configuration.md)). If your editor runs a type checker, it knows the state's type and catches typos. -`on_state_change()` supports built-in filter arguments: +### Filter argument mapping -| AppDaemon argument | Hassette equivalent | -|-------------------|---------------------| +`on_state_change()` supports built-in filter arguments that replace AppDaemon's `new=` and `old=` kwargs: + +| 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=` — a function that receives the event and returns `True` or `False`. See [`Bus` filtering](../core-concepts/bus/filtering.md) for the full reference. -## Service Call Listeners +## Attribute Change Listeners -### AppDaemon - -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" +--8<-- "pages/migration/snippets/bus_attribute_change.py:attribute_change" ``` -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" + ``` + +These are the values Hassette can inject into service-call handler parameters — declare the ones the handler needs. `A` is `hassette.event_handling.accessors`, field extractors; `Annotated[str, A.get_service]` means "a `str`, extracted by `A.get_service`": + +- `D.Domain`, the service domain (e.g., `"light"`) +- `D.EntityId` / `D.MaybeEntityId`, entity ID from the service data (`Maybe` allows calls where it's absent) +- `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`][hassette.bus.listeners.Subscription] object with a `.cancel()` method. + === "AppDaemon" ```python - handle = self.listen_state(...) - self.cancel_listen_state(handle) + --8<-- "pages/migration/snippets/bus_cancel_appdaemon.py" ``` === "Hassette" @@ -101,20 +117,16 @@ 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" ```python - def initialize(self): - self.listen_state(self.on_motion, "binary_sensor.motion", new="on") - - def on_motion(self, entity, attribute, old, new, **kwargs): - self.log(f"Motion detected on {entity}") + --8<-- "pages/migration/snippets/bus_migration_state_appdaemon.py" ``` === "Hassette" @@ -123,18 +135,12 @@ 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" ```python - def initialize(self): - self.listen_event( - self.on_service, - "call_service", - domain="light", - service="turn_on", - ) + --8<-- "pages/migration/snippets/bus_migration_service_appdaemon.py" ``` === "Hassette" @@ -143,9 +149,13 @@ In Hassette, the subscription object returned by `on_state_change()`, `on_call_s --8<-- "pages/migration/snippets/bus_migration_service_calls.py" ``` +## Verify the Migration + +Run `hassette listener --app ` to confirm each subscription registered under its `name=`, then trigger the entity and watch `hassette log --app ` for the handler's log line. + ## 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..643301875 100644 --- a/docs/pages/migration/checklist.md +++ b/docs/pages/migration/checklist.md @@ -1,28 +1,29 @@ # 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`][hassette.app.app_config.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. ## Step 2: App Structure -- [ ] Change base class from `Hass` (or `ADAPI`) to `App` (async) or `AppSync` (sync) +- [ ] Change base class from `Hass` (or `ADAPI`) to [App][hassette.app.app.App] (async) or [`AppSync`][hassette.app.app.AppSync] (sync). Rule of thumb: if your callbacks never block (no `time.sleep`, no `requests.get`), use `App`; if they do and you can't convert them yet, use `AppSync`. +- [ ] If you chose `AppSync`: use the `.sync` facades everywhere in steps 3–5 — `self.bus.sync.on_state_change(...)`, `self.scheduler.sync.run_in(...)`, `self.api.sync.call_service(...)` — with no `await`. Calling the async methods from sync hooks silently does nothing. - [ ] 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`; overriding raises `CannotOverrideFinalError` at class definition time). - [ ] If you have `terminate()`, rename it: - `App`: `async def on_shutdown(self)` - `AppSync`: `def on_shutdown_sync(self)` @@ -33,35 +34,35 @@ 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 — `await` only works inside `async def` methods, so check the surrounding method too. + - [ ] 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()` + - [ ] Replace `self.cancel_listen_state(handle)` with `subscription.cancel()` — registration returns a `Subscription`; store it (`sub = await self.bus.on_state_change(...)`) to cancel later - [ ] Convert each `self.listen_event("call_service", ...)` to `await self.bus.on_call_service(...)` - [ ] Add `await` and `name=` - [ ] Update callback signatures - [ ] Replace `self.cancel_listen_event(handle)` with `subscription.cancel()` - [ ] For attribute-level subscriptions, switch to `await self.bus.on_attribute_change(...)` -See [Bus & Events](bus.md) for side-by-side examples. +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")` -- [ ] 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(...)` +- [ ] 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()` — each scheduling call returns a [`ScheduledJob`][hassette.scheduler.classes.ScheduledJob]; store it (`job = await self.scheduler.run_in(...)`) to cancel later +- [ ] 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. +See [`Scheduler`](scheduler.md) for method equivalents. ## Step 5: API Calls -- [ ] Convert `self.get_state(entity_id)` to `self.states.domain.get(entity_id)` for cached reads +- [ ] Convert `self.get_state(entity_id)` to domain access on the state cache for cached reads — `self.get_state("light.kitchen")` becomes `self.states.light.get("light.kitchen")` - [ ] 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` means the call never runs — no error, just silence. If a service call appears to do nothing, check for a missing `await`. - [ ] Replace `self.set_state(...)` with `await self.api.set_state(...)` - [ ] Replace `self.log(...)` with `self.logger.info(...)` (and `.warning()`, `.error()` as needed) @@ -69,6 +70,8 @@ See [API Calls](api.md) for the full guide. ## Step 6: Test +This step is optional for the initial migration but worth it — AppDaemon has no testing story, so this is new ground. The [Testing](testing.md) page walks through it from scratch. + - [ ] Write at least one test using `AppTestHarness` - [ ] Seed entity state before simulating events - [ ] Simulate the key events your app responds to @@ -86,24 +89,30 @@ See [Testing](testing.md) for the test harness guide. ## Common Pitfalls !!! 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`. + - Forgetting `await` on `self.api.*` calls is the most common migration mistake. The call returns a coroutine object and silently does nothing — see [Async Basics](async-basics.md) for the full explanation. + - Every `self.bus.on_*()` call requires `name=`. Omitting it raises `ListenerNameRequiredError` at runtime. + - 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`. + +The two APIs you'll use most often after migration are configuration and state access. Both are shorter than their AppDaemon equivalents. + +!!! tip "Quick-reference: configuration and state access" + ### Configuration -!!! tip "Configuration access" - AppDaemon: `self.args["args"]["key"]` - Hassette: `self.app_config.key` - Define all config keys in your `AppConfig` model for validation and autocomplete. -!!! tip "State access" + ### State + - 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 After migrating all your apps: - Review the [Core Concepts](../core-concepts/index.md) to learn the full Hassette feature set -- Explore [Dependency Injection](../core-concepts/bus/dependency-injection.md), [Custom States](../advanced/custom-states.md), and [Type Registries](../advanced/type-registry.md) +- Explore [Dependency Injection](../core-concepts/bus/dependency-injection.md), [Custom States](../core-concepts/states/custom-states.md), and [State Conversion](../core-concepts/states/conversion.md) - Set up the [Web UI](../web-ui/index.md) for live monitoring of your automations diff --git a/docs/pages/migration/concepts.md b/docs/pages/migration/concepts.md index 7dd92dcbd..d11d539a6 100644 --- a/docs/pages/migration/concepts.md +++ b/docs/pages/migration/concepts.md @@ -1,90 +1,119 @@ # 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][hassette.app.app.App] with no type argument works fine. +- **Lifecycle hook**: `initialize()` becomes `on_initialize()`. +- **Async keyword**: Hassette's hook is `async def`. The body uses `await`. ## 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`. `self.listen_state(...)`, `self.call_service(...)`, `self.run_in(...)` all live on 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 rules follow: - ```python - --8<-- "pages/migration/snippets/concepts_appdaemon_app.py" - ``` +1. Put `await` in front of any call to `self.api`, `self.bus`, or `self.scheduler`, and declare the surrounding method `async def`. Reads from `self.states` are synchronous. +2. Blocking the event loop (a long `time.sleep`, a slow synchronous database call) blocks all apps, not just yours. -=== "Hassette" +[Async Basics](async-basics.md) explains the model behind both rules — what `await` actually does, and how to spot the silent failure when it goes missing. - ```python - --8<-- "pages/migration/snippets/concepts_hassette_app.py" - ``` +```python +--8<-- "pages/migration/snippets/concepts_sync_async.py" +``` + +The example's `self.task_bucket.run_in_thread(...)` is a helper on every `App` instance that runs blocking code in a thread without stalling other apps. If most of your code is blocking and you cannot convert it, use [`AppSync`][hassette.app.app.AppSync] ([described below](#synchronous-api-appsync)). -**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 typed models throughout — objects with named, validated fields instead of raw dicts (powered by Pydantic). -**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`][hassette.models.states.light.LightState] with typed fields. Your IDE knows the shape, and a type checker like 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: -**Hassette** handlers can have almost any signature. You can: +```python +def my_callback(self, entity, attribute, old, new, **kwargs): ... +``` -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)` +You always receive all five arguments, whether you need them or not. -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. +Hassette handlers can have almost any signature. Three styles work: -## Synchronous API +**Full event object.** Receive the raw event and extract what you need: -If you have existing synchronous code and don't want to add `async`/`await` everywhere, use `AppSync`: +```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]): ... +``` + +**No arguments.** Use when you only care that the event fired: + +```python +async def on_motion(self): ... +``` + +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. + +## 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 synchronous wrappers: `self.bus.sync`, `self.scheduler.sync`, `self.api.sync`. Each one waits for the async operation to finish and returns 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` -- [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 +- [`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): full DI reference diff --git a/docs/pages/migration/configuration.md b/docs/pages/migration/configuration.md index 1c50d7b52..d51979b86 100644 --- a/docs/pages/migration/configuration.md +++ b/docs/pages/migration/configuration.md @@ -1,127 +1,79 @@ # 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`][hassette.app.app_config.AppConfig] models — `AppConfig` is a class you define once per app, declaring each setting's name and type; Hassette fills it from `hassette.toml` and hands it to your app as `self.app_config`. ## 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 — the error names the missing key, so a failed config migration surfaces immediately rather than as a `KeyError` mid-automation. `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" + `[[hassette.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 `[[hassette.apps.my_app.config]]` block: -## Benefits of Typed Configuration +```toml +[hassette.apps.motion_lights] +filename = "motion_lights.py" +class_name = "MotionLights" + +[[hassette.apps.motion_lights.config]] +motion_sensor = "binary_sensor.living_room_motion" +light = "light.living_room" +off_delay = 300 + +[[hassette.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, and each instance's `self.app_config` holds the values from its own block — the Python class needs no changes. 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 -- [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 +- [App Configuration](../core-concepts/apps/configuration.md): app registration, multi-instance config, and TOML syntax +- [Configuration Overview](../core-concepts/configuration/index.md): `hassette.toml` structure diff --git a/docs/pages/migration/index.md b/docs/pages/migration/index.md index 6ab9e5a88..4b146549d 100644 --- a/docs/pages/migration/index.md +++ b/docs/pages/migration/index.md @@ -1,92 +1,60 @@ # 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. +This guide covers migrating AppDaemon automations to Hassette. -!!! 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. +## Quick Reference -## Is Migration Worth It? +Four areas change: configuration, app structure, event handlers, and API calls. Hassette splits AppDaemon's flat `self.*` surface into typed handles: `self.bus` (event subscriptions), `self.scheduler` (timed jobs), `self.api` (HA service calls), and `self.states` (entity state cache). -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: +| 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) | -| 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 | — | +`name=` in the bus rows above is **required** — it identifies the listener in logs and the monitoring UI. Use a descriptive string like `"kitchen_motion"`. Omitting it raises `ListenerNameRequiredError` at runtime. AppDaemon has no equivalent. -## Known Gaps +All Hassette bus, scheduler, and API calls are `async` and need `await`. In AppDaemon, `self.listen_state` registers immediately. In Hassette, forgetting `await` means nothing registers — no error, no warning, just silence. + +## Is Migration Worth It? -The following AppDaemon features are not currently in Hassette. If your apps rely on any of these, migration is not yet recommended: +| 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 | 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 | +| 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`][hassette.exceptions.ListenerNameRequiredError] at runtime. Every `on_state_change`, `on_call_service`, and `on` call needs a stable string name. + +**`self.api.*`, `self.bus.on_*`, and `self.scheduler.*` are async and must be awaited.** Forgetting `await` returns a coroutine object. Nothing is registered or called. + +**[`AppSync`][hassette.app.app.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..06e13c7d6 100644 --- a/docs/pages/migration/scheduler.md +++ b/docs/pages/migration/scheduler.md @@ -1,39 +1,44 @@ # 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`][hassette.scheduler.classes.ScheduledJob] object for cancellation. -## Overview +!!! note "Coming from synchronous AppDaemon?" + The mechanical rule: declare `on_initialize` as `async def` and put `await` in front of every scheduling call. Omitting `await` means the job is never scheduled — no error, just silence. [Migration Concepts](concepts.md#async-vs-sync) covers the async model. -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. +## 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` (from the [`whenever`](https://whenever.readthedocs.io/) library, which ships with Hassette) | +| `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](../core-concepts/scheduler/triggers.md) | + +!!! 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. To pass data to the handler, give the scheduling call `args=` or `kwargs=` — the values arrive as parameters on the handler. `App[MyConfig]` in the example pairs the app with its config class; `self.app_config` replaces AppDaemon's `self.args` (see [Configuration](configuration.md)): ```python --8<-- "pages/migration/snippets/scheduler_hassette.py" ``` -## Method Equivalents - -| 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 | +No fixed signature. No `**kwargs` unwrapping. -!!! 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 +49,41 @@ 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" +**Key changes:** - ```python - from datetime import time +- 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)` - 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) - ``` +## Blocking Work -=== "Hassette" +In AppDaemon, every callback runs in its own thread, so blocking IO is safe anywhere. - ```python - --8<-- "pages/migration/snippets/scheduler_migration.py" - ``` +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. -**Key changes:** +```python +--8<-- "pages/migration/snippets/scheduler_blocking.py:sync" +``` -- 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)` +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()` — `self.task_bucket` is a helper on every `App` instance for running blocking code without stalling other apps: -## Blocking Work in Scheduler Callbacks +```python +--8<-- "pages/migration/snippets/scheduler_blocking.py:async" +``` -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: +[`AppSync`][hassette.app.app.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 — for migrating scheduling alone, `App` is the right choice. -- 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. +## Verify the Migration -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()`. +Run `hassette job --app ` to confirm the jobs registered with the expected next-run times, and `hassette log --app ` to watch callbacks fire. ## 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/snippets/api_appdaemon_call_service.py b/docs/pages/migration/snippets/api_appdaemon_call_service.py new file mode 100644 index 000000000..97fa96789 --- /dev/null +++ b/docs/pages/migration/snippets/api_appdaemon_call_service.py @@ -0,0 +1,5 @@ +def my_callback(self, **kwargs): + self.call_service("light/turn_on", entity_id="light.kitchen", brightness=200) + + # or use the helper + self.turn_on("light.kitchen", brightness=200) diff --git a/docs/pages/migration/snippets/api_appdaemon_logging.py b/docs/pages/migration/snippets/api_appdaemon_logging.py new file mode 100644 index 000000000..f882aa506 --- /dev/null +++ b/docs/pages/migration/snippets/api_appdaemon_logging.py @@ -0,0 +1,3 @@ +self.log("This is a log message") +self.log(f"Value: {value}") +self.error("Something went wrong") diff --git a/docs/pages/migration/snippets/api_appdaemon_set_state.py b/docs/pages/migration/snippets/api_appdaemon_set_state.py new file mode 100644 index 000000000..92d944157 --- /dev/null +++ b/docs/pages/migration/snippets/api_appdaemon_set_state.py @@ -0,0 +1 @@ +self.set_state("sensor.custom", state="42", attributes={"unit": "widgets"}) 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/migration/snippets/async_blocking.py b/docs/pages/migration/snippets/async_blocking.py new file mode 100644 index 000000000..602982313 --- /dev/null +++ b/docs/pages/migration/snippets/async_blocking.py @@ -0,0 +1,27 @@ +import requests + +from hassette import App + + +class BlockingWeatherApp(App): + # --8<-- [start:blocking] + async def update_forecast(self): + # Holds the event loop until the request returns. + # Every app is frozen for the duration. + resp = requests.get("http://example.com/forecast", timeout=10) + self.logger.info("Forecast: %s", resp.json()) + # --8<-- [end:blocking] + + +class OffloadedWeatherApp(App): + # --8<-- [start:offload] + async def update_forecast(self): + # Runs in a thread pool; only this handler waits + data = await self.task_bucket.run_in_thread(self.fetch_forecast) + self.logger.info("Forecast: %s", data) + + def fetch_forecast(self): + # A plain def holding the blocking call + resp = requests.get("http://example.com/forecast", timeout=10) + return resp.json() + # --8<-- [end:offload] diff --git a/docs/pages/migration/snippets/async_coroutine_basics.py b/docs/pages/migration/snippets/async_coroutine_basics.py new file mode 100644 index 000000000..7f97cdac3 --- /dev/null +++ b/docs/pages/migration/snippets/async_coroutine_basics.py @@ -0,0 +1,22 @@ +from hassette import App + + +class MissingAwaitApp(App): + # --8<-- [start:unawaited] + async def on_motion(self): + # Creates a coroutine object and throws it away. + # The light never turns on. No error is raised. + self.api.call_service( + "light", "turn_on", target={"entity_id": "light.kitchen"} + ) + # --8<-- [end:unawaited] + + +class CorrectAwaitApp(App): + # --8<-- [start:awaited] + async def on_motion(self): + # await runs the coroutine to completion + await self.api.call_service( + "light", "turn_on", target={"entity_id": "light.kitchen"} + ) + # --8<-- [end:awaited] diff --git a/docs/pages/migration/snippets/bus_attribute_change.py b/docs/pages/migration/snippets/bus_attribute_change.py new file mode 100644 index 000000000..a8cfac8e2 --- /dev/null +++ b/docs/pages/migration/snippets/bus_attribute_change.py @@ -0,0 +1,20 @@ +from hassette import App, AppConfig + + +class MyConfig(AppConfig): + phone_entity: str = "sensor.phone" + + +class MyApp(App[MyConfig]): + async def on_initialize(self): + # --8<-- [start:attribute_change] + await self.bus.on_attribute_change( + "sensor.phone", + "battery_level", + handler=self.on_battery, + name="phone_battery", + ) + # --8<-- [end:attribute_change] + + async def on_battery(self) -> None: + pass diff --git a/docs/pages/migration/snippets/bus_cancel_appdaemon.py b/docs/pages/migration/snippets/bus_cancel_appdaemon.py new file mode 100644 index 000000000..7a91a1c76 --- /dev/null +++ b/docs/pages/migration/snippets/bus_cancel_appdaemon.py @@ -0,0 +1,2 @@ +handle = self.listen_state(self.on_change, "light.kitchen") +self.cancel_listen_state(handle) diff --git a/docs/pages/migration/snippets/bus_migration_service_appdaemon.py b/docs/pages/migration/snippets/bus_migration_service_appdaemon.py new file mode 100644 index 000000000..3d20985f5 --- /dev/null +++ b/docs/pages/migration/snippets/bus_migration_service_appdaemon.py @@ -0,0 +1,7 @@ +def initialize(self): + self.listen_event( + self.on_service, + "call_service", + domain="light", + service="turn_on", + ) diff --git a/docs/pages/migration/snippets/bus_migration_state_appdaemon.py b/docs/pages/migration/snippets/bus_migration_state_appdaemon.py new file mode 100644 index 000000000..8519340ac --- /dev/null +++ b/docs/pages/migration/snippets/bus_migration_state_appdaemon.py @@ -0,0 +1,5 @@ +def initialize(self): + self.listen_state(self.on_motion, "binary_sensor.motion", new="on") + +def on_motion(self, entity, attribute, old, new, **kwargs): + self.log(f"Motion detected on {entity}") diff --git a/docs/pages/migration/snippets/bus_name_correct.py b/docs/pages/migration/snippets/bus_name_correct.py new file mode 100644 index 000000000..d53a186ac --- /dev/null +++ b/docs/pages/migration/snippets/bus_name_correct.py @@ -0,0 +1 @@ +await self.bus.on_state_change("light.kitchen", handler=self.on_change, name="kitchen_light") diff --git a/docs/pages/migration/snippets/bus_name_missing.py b/docs/pages/migration/snippets/bus_name_missing.py new file mode 100644 index 000000000..cbcac41e9 --- /dev/null +++ b/docs/pages/migration/snippets/bus_name_missing.py @@ -0,0 +1,2 @@ +# Raises ListenerNameRequiredError immediately +await self.bus.on_state_change("light.kitchen", handler=self.on_change) diff --git a/docs/pages/migration/snippets/concepts_sync_async.py b/docs/pages/migration/snippets/concepts_sync_async.py index f90f04ccb..5be90bc75 100644 --- a/docs/pages/migration/snippets/concepts_sync_async.py +++ b/docs/pages/migration/snippets/concepts_sync_async.py @@ -15,7 +15,7 @@ def on_initialize_sync(self): self.bus.sync.on_state_change("light.kitchen", handler=self.on_change, name="kitchen") self.scheduler.sync.run_in(self.cleanup, 60, name="cleanup") - def on_change(self, event): ... # pyright: ignore[reportUnusedParameter] + def on_change(self, event): ... def cleanup(self): ... @@ -28,4 +28,4 @@ async def on_initialize(self): def blocking_work(self): # This runs in a thread pool - return expensive_computation() # pyright: ignore[reportUndefinedVariable] + return expensive_computation() diff --git a/docs/pages/migration/snippets/config_hassette_toml.toml b/docs/pages/migration/snippets/config_hassette_toml.toml index 48ea13adc..17838815b 100644 --- a/docs/pages/migration/snippets/config_hassette_toml.toml +++ b/docs/pages/migration/snippets/config_hassette_toml.toml @@ -1,10 +1,10 @@ [hassette] base_url = "http://127.0.0.1:8123" -[apps.my_app] +[hassette.apps.my_app] filename = "my_app.py" class_name = "MyApp" -[[apps.my_app.config]] +[[hassette.apps.my_app.config]] entity = "light.kitchen" brightness = 200 diff --git a/docs/pages/migration/snippets/config_migration_toml.toml b/docs/pages/migration/snippets/config_migration_toml.toml index ebfb8a641..3a6445d29 100644 --- a/docs/pages/migration/snippets/config_migration_toml.toml +++ b/docs/pages/migration/snippets/config_migration_toml.toml @@ -2,11 +2,11 @@ base_url = "http://192.168.1.179:8123" # Token read from HASSETTE__TOKEN env var or .env file -[apps.my_app] +[hassette.apps.my_app] filename = "my_app.py" class_name = "MyApp" # [[double brackets]] = TOML array-of-tables; supports running the same app with multiple configs -[[apps.my_app.config]] +[[hassette.apps.my_app.config]] entity = "light.kitchen" brightness = 200 diff --git a/docs/pages/migration/snippets/scheduler_blocking.py b/docs/pages/migration/snippets/scheduler_blocking.py new file mode 100644 index 000000000..341b4f48e --- /dev/null +++ b/docs/pages/migration/snippets/scheduler_blocking.py @@ -0,0 +1,25 @@ +import asyncio + +import requests + +from hassette import App, AppConfig + + +class MyConfig(AppConfig): + api_url: str = "http://example.com/api" + + +class MyApp(App[MyConfig]): + # --8<-- [start:sync] + def periodic_sync_task(self): + data = requests.get("http://example.com/api").json() + ... + # --8<-- [end:sync] + + # --8<-- [start:async] + async def periodic_async_task(self): + data = await asyncio.to_thread( + requests.get, "http://example.com/api" + ) + ... + # --8<-- [end:async] diff --git a/docs/pages/migration/snippets/testing_hassette_example.py b/docs/pages/migration/snippets/testing_hassette_example.py index c071a35ef..a5c502f99 100644 --- a/docs/pages/migration/snippets/testing_hassette_example.py +++ b/docs/pages/migration/snippets/testing_hassette_example.py @@ -1,5 +1,3 @@ -from whenever import Instant - from hassette import App, AppConfig, D, states from hassette.test_utils import AppTestHarness diff --git a/docs/pages/migration/testing.md b/docs/pages/migration/testing.md index 07dfbbe69..fef8e1b41 100644 --- a/docs/pages/migration/testing.md +++ b/docs/pages/migration/testing.md @@ -1,41 +1,43 @@ # 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`, a test harness that wires your app into a real Hassette environment. Because Hassette apps are async, tests are async too — test functions are declared `async def`, and that's the main difference from testing synchronous code. `RecordingApi` replaces the live Home Assistant connection, recording every API call your app makes so you can assert against it — it's available in tests as `harness.api_recorder`. -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. +**Install test dependencies:** -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) +```bash +pip install pytest pytest-asyncio # or: uv add --dev pytest pytest-asyncio +``` -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. - -## set_state() Order Matters +!!! warning "Don't skip this" + `asyncio_mode = "auto"` tells pytest to actually run `async def` test functions. Without it, pytest skips the test body and reports a false pass. This is the most common setup mistake when migrating from AppDaemon. -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.** `set_state()` and `simulate_state_change()` are harness methods — the full example below shows them in context. 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 the harness in an `async with` block, seed your state, fire an event, assert the API call. -## See Also +```python +--8<-- "pages/migration/snippets/testing_hassette_example.py" +``` + +Run it with `pytest -v`. A passing test prints `PASSED`; if pytest reports 0 tests or skips the body, check that `asyncio_mode = "auto"` made it into `pyproject.toml`. + +## 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. diff --git a/docs/pages/operating/index.md b/docs/pages/operating/index.md new file mode 100644 index 000000000..c3ffd3f1d --- /dev/null +++ b/docs/pages/operating/index.md @@ -0,0 +1,142 @@ +# Operating Hassette + +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. + +The defaults cover typical deployments — nothing here is required reading before deploying. This page works best as a reference: jump to the section that matches a symptom rather than reading top to bottom. + +## WebSocket Reconnection + +Hassette talks to Home Assistant over a WebSocket — a persistent connection that lets HA push events instantly instead of being polled. That connection can drop for many reasons: HA restarts, network blips, clean shutdowns. Hassette recovers automatically using a three-layer retry model; each layer handles a different failure mode — failures while connecting, quick disconnects right after connecting, and sustained outages. All WebSocket settings live under `[hassette.websocket]` in [`hassette.toml`](../core-concepts/configuration/index.md). + +### Layer 1: Initial connection retries + +When Hassette first starts (or [`WebsocketService`][hassette.core.websocket_service.WebsocketService] restarts), it tries the WebSocket connection up to `websocket.connect_retry_max_attempts` times (default: 5). Each retry waits longer than the last. Backoff starts at `websocket.connect_retry_initial_wait_seconds` (default: 1s), caps at `websocket.connect_retry_max_wait_seconds` (default: 32s), with jitter added (a small random offset so retries don't fire in lockstep). Tenacity — the retry library Hassette uses internally — logs a WARNING before each sleep, where `...` is the exception that triggered the retry: + +``` +Retrying hassette.core.websocket_service.WebsocketService._make_connection.._inner_connect in X.Xs as it raised ... +``` + +If all five attempts fail, the error reaches layer 3. + +### Layer 2: Early-drop retries + +An "early drop" is a connection that disconnects within `websocket.early_drop_stable_window_seconds` (default: 30s) of being established. 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 disconnects that happen after the authentication handshake with HA succeeded (`ServerDisconnectedError`, [`RetryableConnectionClosedError`][hassette.exceptions.RetryableConnectionClosedError]). Connection-refused errors bypass this layer entirely and go straight to layer 1's retry loop. + +### Layer 3: ServiceWatcher restart budget + +[`ServiceWatcher`][hassette.core.service_watcher.ServiceWatcher] is Hassette's internal watchdog — when a service keeps failing, it limits how often the service restarts to avoid an infinite crash loop. It supervises `WebsocketService` with 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 a cooldown state (`EXHAUSTED_COOLING` in logs) for 300 seconds, then retries from scratch. The logs show (`retry_at` is a Unix timestamp): + +``` +Service 'WebsocketService' restart budget exhausted (TRANSIENT), entering cooldown for 300.0s (retry_at=1749567890) +``` + +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][hassette.api.api.Api] methods (REST calls to HA) and [`StateProxy`][hassette.core.state_proxy.StateProxy] access raise [`ResourceNotReadyError`][hassette.exceptions.ResourceNotReadyError] while the WebSocket is down. Code that calls these during a disconnect must catch that exception (skip the work, or retry after reconnection) — or subscribe to the connection events below and pause itself while HA is unreachable. + +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 subscribe to these topics in `on_initialize` via [`on()`](../core-concepts/bus/methods.md), the generic topic subscription: + +```python +--8<-- "pages/operating/snippets/ws_reconnect_events.py" +``` + +### 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. + +**Recovery ceiling.** `websocket.max_recovery_seconds` (default: 300s) caps the total wall-clock time layer 2 spends on early-drop retries. When the ceiling is hit, the failure stops being treated as an early drop and escalates to layer 3's restart budget. Raise it when extended HA outages should be ridden out by early-drop retries instead. + +### Per-operation timeouts + +Four further `[hassette.websocket]` fields cap individual operations rather than retry behavior: `connection_timeout_seconds` (default: 5s) for establishing the connection, `authentication_timeout_seconds` (default: 10s) for the HA auth handshake, `response_timeout_seconds` (default: 15s) for a reply to a single WebSocket command, and `total_timeout_seconds` (default: 30s) as the overall ceiling for a single operation. Slow or high-latency HA hosts (remote instances, constrained hardware) are the usual reason to raise them. + +All fields live under `[hassette.websocket]` in `hassette.toml`. + +## Handler Exceptions + +When a bus handler or scheduled-job handler 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=) + +``` + +Registered error handlers on subscriptions or scheduled jobs fire after Hassette logs the exception. They are the right place for alerting integrations, recovery logic, or additional context recording. The error handler itself is 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 handler. + +Both default to 600 seconds. A handler or job that runs longer than its timeout is 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.** A synchronous (non-`async`) handler may keep running in the background after its timeout fires. Hassette runs sync handlers in a thread, and the timeout cancels the wrapper around the thread — Python cannot stop the thread itself. Long-running sync work that needs reliable cancellation requires an `async` implementation. + +**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'`. Catching `TimeoutError` in handler bodies without re-raising it defeats the timeout mechanism. + +**`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. + +## Startup and Shutdown Timeouts + +Hassette starts its internal components — database, WebSocket connection, bus, scheduler, then apps — in dependency order, so each one is ready before anything that depends on it. Shutdown runs in reverse. Each phase has its own `[hassette.lifecycle]` ceiling: + +| Field | Default | Caps | +|---|---|---| +| `startup_timeout_seconds` | 30s | Each startup wave. Must be ≥ `app_startup_timeout_seconds`, since app readiness is part of a wave. | +| `app_startup_timeout_seconds` | 20s | A single app's `on_initialize`. A slow app times out individually without failing the whole wave budget. | +| `app_shutdown_timeout_seconds` | 10s | A single app's `on_shutdown`. | +| `resource_shutdown_timeout_seconds` | = app shutdown | Each non-app resource's shutdown phase. | +| `total_shutdown_timeout_seconds` | 30s | The entire shutdown, hooks and propagation included. | +| `registration_await_timeout` | 30s | Waiting for pending listener/job database registrations to flush before post-ready reconciliation. | +| `task_cancellation_timeout_seconds` | 5s | Waiting for cancelled tasks to finish before they are abandoned. | + +Apps that fetch external data or open slow connections in `on_initialize` are the common reason to raise `app_startup_timeout_seconds` — and `startup_timeout_seconds` with it. The shutdown ceilings matter on constrained hardware where cleanup runs slowly; raising them trades slower restarts for cleaner teardown. + +## Scheduler Cadence + +The scheduler checks for due jobs on a sleep-wake loop. It sleeps until the next due job, bounded between `scheduler.min_delay_seconds` (default 1) and `scheduler.max_delay_seconds` (default 30), and sleeps `scheduler.default_delay_seconds` (default 15) when no jobs are queued. + +A job that fires more than `scheduler.behind_schedule_threshold_seconds` (default 5) after its scheduled time logs a "behind schedule" WARNING. That warning means too many handlers are running at once or the host is overloaded. + +These bounds do not affect timing accuracy for already-queued jobs — only how often the loop re-checks. Lowering `max_delay_seconds` only matters when a newly registered job needs to start within 30 seconds of registration. + +## 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 new file mode 100644 index 000000000..2dc17d17d --- /dev/null +++ b/docs/pages/operating/log-levels.md @@ -0,0 +1,110 @@ +# Log Level Tuning + +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 in `[hassette.logging]` control event bus debug output. The bus processes every state change and service call from Home Assistant — hundreds of events per minute in an active home — which is why its debug output is gated separately from log levels. + +| 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 running in a terminal, JSON when running as a service or in a container (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](../core-concepts/database-telemetry.md) — the local store the `hassette log` CLI command queries. Defaults to `INFO`. Set to `DEBUG` if you want debug output queryable via `hassette log`. + +`log_retention_days` (default 3) sets how long persisted records live before the hourly retention pass deletes them. It must be ≤ `retention_days` under `[hassette.database]` (see [Database & Telemetry](../core-concepts/database-telemetry.md)). + +`log_queue_max` (default 2000) caps how many records can wait for persistence at once. When the queue is full, new records are dropped rather than blocking the app. Raise it only if sustained `DEBUG` persistence reports drops. + +## Per-App Log Levels + +Each app sets its own log level with `log_level` in that app's config section. + +```toml +--8<-- "pages/operating/snippets/per_app_log_level.toml" +``` + +The per-app `log_level` lives under `[hassette.apps.]` — `` is the app's section name in `hassette.toml`, the same key `hassette app` lists. Apps without an explicit `log_level` default to `INFO` (or the `HASSETTE__LOG_LEVEL` environment variable when set). + +## Runtime Log Level Changes + +`PUT /api/logs/level` changes a logger's effective level on a running instance — no restart, no `hassette.toml` edit. The change applies immediately to both structlog and stdlib callers on that logger: + +```bash +curl -X PUT http://127.0.0.1:8126/api/logs/level \ + -H "Content-Type: application/json" \ + -d '{"logger": "hassette.scheduler", "level": "DEBUG"}' +``` + +The response reports the logger's new effective level: `{"logger": "hassette.scheduler", "effective_level": "DEBUG"}`. Unknown levels and empty logger names return `422`. + +The change lives in process memory only — a restart reverts to the configured levels. Use it to capture debug output from a live issue, then turn the level back down the same way. + +## 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/advanced/snippets/log-level-tuning/basic_example.toml b/docs/pages/operating/snippets/basic_example.toml similarity index 100% rename from docs/pages/advanced/snippets/log-level-tuning/basic_example.toml rename to docs/pages/operating/snippets/basic_example.toml diff --git a/docs/pages/advanced/snippets/log-level-tuning/debug_ha_comms.toml b/docs/pages/operating/snippets/debug_ha_comms.toml similarity index 100% rename from docs/pages/advanced/snippets/log-level-tuning/debug_ha_comms.toml rename to docs/pages/operating/snippets/debug_ha_comms.toml diff --git a/docs/pages/advanced/snippets/log-level-tuning/debug_scheduler.toml b/docs/pages/operating/snippets/debug_scheduler.toml similarity index 100% rename from docs/pages/advanced/snippets/log-level-tuning/debug_scheduler.toml rename to docs/pages/operating/snippets/debug_scheduler.toml 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..9a54c4857 --- /dev/null +++ b/docs/pages/operating/snippets/per_app_log_level.toml @@ -0,0 +1,3 @@ +# hassette.toml +[hassette.apps.my_noisy_app] +log_level = "WARNING" diff --git a/docs/pages/advanced/snippets/log-level-tuning/quiet_file_watcher.toml b/docs/pages/operating/snippets/quiet_file_watcher.toml similarity index 100% rename from docs/pages/advanced/snippets/log-level-tuning/quiet_file_watcher.toml rename to docs/pages/operating/snippets/quiet_file_watcher.toml 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 new file mode 100644 index 000000000..8fa852c55 --- /dev/null +++ b/docs/pages/operating/upgrading.md @@ -0,0 +1,74 @@ +# Upgrading Hassette + +## 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** + +```bash +pip install --upgrade hassette +``` + +**uv (project dependency)** + +```bash +uv add hassette@latest +``` + +This updates `pyproject.toml` and installs the new version into your project environment. If you installed Hassette as a uv tool rather than a project dependency, run `uv tool upgrade hassette` instead. + +**Docker** + +Pull the latest image and restart your container: + +```bash +docker compose pull +docker compose up -d +``` + +This pulls whatever tag is configured in your `docker-compose.yml`. To pin a specific version, change the `image:` tag there. + +## 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. + +!!! warning "Back up before major upgrades" + Copy your data directory (`~/.local/share/hassette/v0/` on Linux) to a safe location before upgrading across major versions. The new version starts with an empty database if paths are not explicitly set. + +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/recipes/daily-notification.md b/docs/pages/recipes/daily-notification.md index 337b14e14..de3418389 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 @@ -8,34 +8,76 @@ Send a push notification to a mobile device at a configurable time each day. Dro --8<-- "pages/recipes/snippets/daily_notification.py" ``` +## Run It + +Save the code as `daily_notification.py` in your apps directory and register it in `hassette.toml`: + +```toml +[hassette.apps.daily_notification] +filename = "daily_notification.py" +class_name = "DailyNotificationApp" +``` + +The section name (`daily_notification`) is the app key — the same key the `hassette job` and `hassette log` commands below take via `--app`. [App Configuration](../core-concepts/apps/configuration.md) covers registration in full. + ## 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. +Every `App` instance carries `self.scheduler` (runs functions on a schedule), `self.api` (calls HA services), and `self.app_config` (the validated config) — Hassette provides them at startup. Lifecycle hooks and handlers are `async def`; Hassette runs the event loop, so the pattern works without prior async experience. + +`DailyNotificationConfig` defines three fields: the wall-clock time as an `"HH:MM"` string (24-hour local time), the notify service name, and the message body. All three carry defaults and can be overridden per instance in [`hassette.toml`](../core-concepts/configuration/index.md). Find your notify service name in HA under **Developer Tools → Services**, filter for `notify` — it looks like `mobile_app_your_device_name`. The `model_config = SettingsConfigDict(...)` line is standard boilerplate from `pydantic-settings` (installed with Hassette — nothing extra to install); its `env_prefix` means environment variables like `DAILY_NOTIFICATION_MESSAGE=...` also 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`][hassette.scheduler.triggers.Daily] trigger that recalculates the next fire time after each trigger, so clock-forward and clock-back DST transitions do not cause double-fires or skips. The notification fires at 08:00 local time year-round. + +`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`) are sent to the service as its data fields — `service_data` in HA terms. + +## Verify It's Working + +Run these from your project directory while Hassette is running. Confirm the job is registered immediately after startup: + +``` +hassette job --app daily_notification +``` + +Expected output (`instance 0` is the default when one copy of the app runs): + +``` +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 Home Assistant logs (**Settings → System → Logs**) for a failed service call. ## Variations -**Different time** — Change `notify_time` in your config: +**Different time.** Change `notify_time` in the app's config block 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 +[hassette.apps.daily_notification.config] +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 — replace `send_notification` with this version. `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 — fields are minute first, so `f"{m} {h} * * 1-5"` means "at `h:m` on days 1–5 (Monday–Friday)". 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 +85,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..b6cae1a13 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 @@ -8,21 +8,59 @@ Sensors like temperature or humidity often emit bursts of near-identical reading --8<-- "pages/recipes/snippets/debounce_sensor.py" ``` +## Run It + +Save the code as `debounce_sensor.py` in your apps directory and register it in `hassette.toml`: + +```toml +[hassette.apps.debounce_sensor] +filename = "debounce_sensor.py" +class_name = "DebounceSensorApp" +``` + +The section name (`debounce_sensor`) is the app key the `hassette` CLI commands below take via `--app`. [App Configuration](../core-concepts/apps/configuration.md) covers registration in full. + ## 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. +The bus (`self.bus`) delivers Home Assistant events to subscribed handlers — every `App` gets one, alongside `self.api` and `self.logger`. Handlers are `async def`; Hassette runs the event loop. + +`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`](../core-concepts/bus/filtering.md), a module of ready-made value checks. `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](../core-concepts/bus/dependency-injection.md) annotation. `D` is an alias for `hassette.event_handling.dependencies` — Hassette inspects the handler's parameter types at registration and passes the extracted value in automatically. `D.StateNew` delivers the new state, already converted to a [`SensorState`][hassette.models.states.sensor.SensorState] object. `SensorState.value` is `str | None` — the framework sets it to `None` when the entity is `"unavailable"` or `"unknown"` — so the handler converts it to a `float` before comparing against `THRESHOLD`. The `try`/`except` guards against the `None` and non-numeric values that HA sensors report during startup. + +`name=` on `on_state_change` is required — it labels the listener in logs and in `hassette listener` output. Omitting it raises `ListenerNameRequiredError` at registration time. + +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. Promoting them to typed fields on an [`AppConfig`][hassette.app.app_config.AppConfig] subclass — set per instance in `hassette.toml` — covers multiple sensors with a single class. + +## Verify It's Working + +Run these from your project directory while Hassette is running. 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.** Pass `throttle=30.0` to `on_state_change` in place of `debounce=` — it 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..b56496669 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. Each recipe stands alone — pick the one that matches your situation. New to Hassette? [Getting Started](../getting-started/index.md) covers installing and running your first app. -## 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)**. Turning a behavior on or off while the app keeps running — no restart needed. ## 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: the `Bus` (event subscriptions), `Scheduler` (timers), `Api` (HA service calls), and `States` (entity state access). +- [Getting Started](../getting-started/index.md). Installation and your first app. diff --git a/docs/pages/recipes/motion-lights.md b/docs/pages/recipes/motion-lights.md index 54091d9ca..693001ed8 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 @@ -8,29 +8,77 @@ Turns a light on when a motion sensor detects movement, then turns it off automa --8<-- "pages/recipes/snippets/motion_lights.py" ``` +## Run It + +Save the code as `motion_lights.py` in your apps directory and register it in `hassette.toml`: + +```toml +[hassette.apps.motion_lights] +filename = "motion_lights.py" +class_name = "MotionLights" +``` + +The section name (`motion_lights`) is the app key the `hassette` CLI commands below take via `--app`. [App Configuration](../core-concepts/apps/configuration.md) covers registration in full. + ## 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`. +`self.bus.on_state_change` subscribes to every state transition on the motion sensor. The `name=` parameter is required on all bus registrations — it identifies the listener in the database and in CLI output. + +[`D.StateNew[states.BinarySensorState]`](../core-concepts/bus/dependency-injection.md) is a [dependency injection](../core-concepts/bus/dependency-injection.md) annotation — Hassette inspects the handler's parameter types at registration and passes the extracted value in automatically. `D.StateNew` delivers the new state, already converted to a [`BinarySensorState`](../core-concepts/states/index.md) object. `BinarySensorState.value` is `bool | None`: `True` when the sensor is on (motion detected), `False` when off (motion cleared), `None` when the state is unknown or unavailable — not the raw HA strings `"on"` and `"off"`. The handler covers both 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, `self.scheduler.run_in` schedules `turn_off_light` for `off_delay_seconds` seconds later. The returned [`ScheduledJob`][hassette.scheduler.classes.ScheduledJob] handle exposes a `.cancel()` method — storing it on `self.off_job` lets the on-handler cancel the pending job on re-trigger. + +`OFF_JOB_NAME` gives the scheduled job a stable name for log readability. + +`motion_sensor`, `light`, and `off_delay_seconds` all come from config via [`hassette.toml`](../core-concepts/configuration/index.md). 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: + +``` +INFO [motion_lights] on_motion triggered — new state: True +``` + +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 +[hassette.apps.hallway_motion] +filename = "motion_lights.py" +class_name = "MotionLights" + +[hassette.apps.hallway_motion.config] +off_delay_seconds = 60 +``` + +**Split handlers with `changed_to`.** `changed_to` is a filter on `on_state_change` — the handler fires only when the state becomes the specified value. When using `changed_to`, the handler does not need a state parameter since the filter already ensures which transition occurred: + +```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/apps/configuration.md): per-instance config in `hassette.toml` diff --git a/docs/pages/recipes/sensor-threshold.md b/docs/pages/recipes/sensor-threshold.md index f51c6c8d5..5187c1596 100644 --- a/docs/pages/recipes/sensor-threshold.md +++ b/docs/pages/recipes/sensor-threshold.md @@ -1,31 +1,71 @@ # 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 +## Run It -- **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.). +Save the code as `sensor_threshold.py` in your apps directory and register it in `hassette.toml`, overriding any config defaults in the `.config` block: + +```toml +[hassette.apps.sensor_threshold] +filename = "sensor_threshold.py" +class_name = "SensorThresholdApp" + +[hassette.apps.sensor_threshold.config] +entity_id = "sensor.living_room_temperature" +threshold = 28.0 +``` + +The section name (`sensor_threshold`) is the app key the `hassette` CLI commands below take via `--app`. [App Configuration](../core-concepts/apps/configuration.md) covers registration in full. + +## How It Works + +Every `App` instance carries `self.bus` (delivers HA events to handlers), `self.api` (calls HA services), and `self.app_config` (the validated config) — Hassette provides them at startup. Handlers are `async def`; Hassette runs the event loop. + +`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` and `C.Comparison("le", threshold)` passed to `changed_from` form a gate pair. `C` is an alias for [`hassette.event_handling.conditions`](../core-concepts/bus/filtering.md), a module of value-comparison functions. The bus evaluates both conditions before invoking the handler. HA delivers state values as strings, so numeric comparisons coerce the value to a float first; values that can't convert (like `"unavailable"`) evaluate to `False` and are dropped. The pair makes the handler fire only on the crossing itself — the old value at or below the threshold, the new value above it — not on every subsequent reading above the limit. + +`D` is an alias for [`hassette.event_handling.dependencies`](../core-concepts/bus/dependency-injection.md) — Hassette inspects handler parameter types at registration and passes the extracted values in automatically. `D.StateNew[states.SensorState]` delivers the new state as a typed object. `SensorState.value` is `str | None` — `None` when the entity is unavailable or unknown, though those events never pass the comparison gate; the typed model provides `.attributes` with fields like `unit_of_measurement` and `friendly_name`. `D.EntityId` delivers the entity ID as a plain string. The handler declares what it needs, and the framework fills it in. + +`name=` on `on_state_change` is required — it labels the listener in logs and in `hassette listener` output. Omitting it raises `ListenerNameRequiredError` at registration time. + +`self.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 + +Run these from your project directory while Hassette is running. Check that the handler registered with the threshold condition: + +``` +hassette listener --app sensor_threshold +``` + +Expected output shows one listener named `threshold_monitor` with the `> 28.0` condition. + +To force a crossing without waiting for the real sensor, set the value by hand in HA under **Developer Tools → States**. Then confirm the handler fired: + +``` +hassette log --app sensor_threshold --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. +**Alert once until recovery (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`][hassette.models.states.sensor.SensorState] attributes. diff --git a/docs/pages/recipes/service-call-reaction.md b/docs/pages/recipes/service-call-reaction.md index a81d42a42..7b770a56e 100644 --- a/docs/pages/recipes/service-call-reaction.md +++ b/docs/pages/recipes/service-call-reaction.md @@ -1,32 +1,70 @@ # 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 +## Run It -- `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. +Save the code as `service_call_reaction.py` in your apps directory and register it in `hassette.toml`, setting your own entities in the `.config` block: + +```toml +[hassette.apps.light_group] +filename = "service_call_reaction.py" +class_name = "LightGroupApp" + +[hassette.apps.light_group.config] +primary_light = "light.living_room_main" +accent_light = "light.living_room_shelf" +``` + +The section name (`light_group`) is the app key the `hassette` CLI commands below take via `--app`. [App Configuration](../core-concepts/apps/configuration.md) covers registration in full. + +## How It Works + +The bus (`self.bus`) delivers Home Assistant events — including service calls — to subscribed handlers. Every `App` gets one, alongside `self.api` and `self.app_config`. Handlers are `async def`; Hassette runs the event loop. + +`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` is an alias for [`hassette.event_handling.predicates`](../core-concepts/bus/filtering.md), a module of event-filtering functions. `P.ServiceDataWhere({"entity_id": self.app_config.primary_light})` narrows the 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. + +`name=` on the subscription is required — it labels the listener in logs and in `hassette listener` output. Omitting it raises `ListenerNameRequiredError` at registration time. + +The handler receives a [`CallServiceEvent`][hassette.events.hass.hass.CallServiceEvent], the Python object Hassette builds from the raw service call. `event.payload.data.service_data` holds the dict the caller passed to `light.turn_on` — for example, if someone set brightness to 200, `service_data` is `{"brightness": 200, "entity_id": "light.living_room_main"}`. 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 + +Run these from your project directory while Hassette is running. Adjust the primary light from the Home Assistant dashboard, then check the app's log: + +``` +hassette log --app light_group --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 light_group +``` + +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/snippets/motion_lights.py b/docs/pages/recipes/snippets/motion_lights.py index f21f8c259..a3878fe74 100644 --- a/docs/pages/recipes/snippets/motion_lights.py +++ b/docs/pages/recipes/snippets/motion_lights.py @@ -1,44 +1,42 @@ 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 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, 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..c43e8f59e --- /dev/null +++ b/docs/pages/recipes/snippets/motion_lights_split.py @@ -0,0 +1,48 @@ +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 + + 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, + 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/recipes/snippets/sensor_threshold.py b/docs/pages/recipes/snippets/sensor_threshold.py index f0949a11a..1e3f91592 100644 --- a/docs/pages/recipes/snippets/sensor_threshold.py +++ b/docs/pages/recipes/snippets/sensor_threshold.py @@ -14,6 +14,7 @@ async def on_initialize(self) -> None: await self.bus.on_state_change( self.app_config.entity_id, handler=self.on_threshold_exceeded, + changed_from=C.Comparison("le", self.app_config.threshold), changed_to=C.Comparison("gt", self.app_config.threshold), name="threshold_monitor", ) diff --git a/docs/pages/recipes/snippets/service_call_reaction.py b/docs/pages/recipes/snippets/service_call_reaction.py index b387e0354..846814ab4 100644 --- a/docs/pages/recipes/snippets/service_call_reaction.py +++ b/docs/pages/recipes/snippets/service_call_reaction.py @@ -1,3 +1,5 @@ +from typing import Any + from pydantic_settings import SettingsConfigDict from hassette import App, AppConfig, P @@ -34,10 +36,10 @@ async def on_primary_turned_on(self, event: CallServiceEvent) -> None: color_temp, ) - call_data: dict[str, object] = {"entity_id": self.app_config.accent_light} + call_data: dict[str, Any] = {"entity_id": self.app_config.accent_light} if brightness is not None: call_data["brightness"] = brightness if color_temp is not None: call_data["color_temp"] = color_temp - await self.api.call_service("light", "turn_on", service_data=call_data) + await self.api.call_service("light", "turn_on", **call_data) diff --git a/docs/pages/recipes/snippets/vacation_mode.py b/docs/pages/recipes/snippets/vacation_mode.py index f9d6b4e6e..0e2546d6b 100644 --- a/docs/pages/recipes/snippets/vacation_mode.py +++ b/docs/pages/recipes/snippets/vacation_mode.py @@ -16,7 +16,7 @@ class VacationModeConfig(AppConfig): class VacationMode(App[VacationModeConfig]): - _presence_job: ScheduledJob | None = None + presence_job: ScheduledJob | None = None async def on_initialize(self) -> None: await self.bus.on_state_change( @@ -34,7 +34,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,15 +42,15 @@ 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") async def simulate_presence(self) -> None: light = random.choice(self.app_config.lights) - state = await self.api.get_state(f"{light}") + state = await self.api.get_state(light) if state.value is True: await self.api.turn_off(light, domain="light") self.logger.debug("Presence sim: turned off %s", light) diff --git a/docs/pages/recipes/vacation-mode-toggle.md b/docs/pages/recipes/vacation-mode-toggle.md index c29fa0a6f..465aae0fc 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 @@ -8,22 +8,56 @@ Watch an `input_boolean` helper in Home Assistant and use its state to start and --8<-- "pages/recipes/snippets/vacation_mode.py" ``` +## Run It + +This recipe needs an `input_boolean` helper named `vacation_mode` — create one in HA under **Settings → Devices & Services → Helpers → Create Helper → Toggle**. Then save the code as `vacation_mode.py` in your apps directory and register it in `hassette.toml`: + +```toml +[hassette.apps.vacation_mode] +filename = "vacation_mode.py" +class_name = "VacationMode" +``` + +The section name (`vacation_mode`) is the app key the `hassette` CLI commands below take via `--app`. [App Configuration](../core-concepts/apps/configuration.md) covers registration in full. + ## 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. +Every `App` instance carries `self.bus` (delivers HA events to handlers), `self.scheduler` (runs functions on a schedule), `self.api` (calls HA services), and `self.app_config` (the validated config) — Hassette provides them at startup. Handlers are `async def`; Hassette runs the event loop. + +Two `on_state_change` subscriptions watch the same `input_boolean`. `changed_to` filters each subscription to one transition: `"on"` or `"off"`. Each handler does exactly one thing, so the two paths stay independent and easy to trace. `name=` on each subscription is required — it labels the listener in logs and in `hassette listener` output; omitting it raises `ListenerNameRequiredError` at registration time. + +When vacation mode activates, `run_every` schedules `simulate_presence` to run on a fixed interval. `run_every` returns a [`ScheduledJob`][hassette.scheduler.classes.ScheduledJob] — a handle for the running job. It is stored on the instance so `on_vacation_end` can cancel it later. The class-level `presence_job: ScheduledJob | None = None` line declares and defaults that attribute; it is a type annotation, not framework boilerplate. + +Each tick, `simulate_presence` picks a random light from the configured list and reads its current state via `self.api.get_state`. Hassette converts light state to a `bool`: `.value` is `True` for on and `False` for off — not the raw HA strings `"on"` and `"off"`, so `state.value == "on"` never matches. 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 + +Run these from your project directory while Hassette is running. Toggle `input_boolean.vacation_mode` to on in the Home Assistant UI, then check the log: + +``` +hassette log --app vacation_mode --since 5m +``` + +The app logs when vacation mode enables and each time a light toggles. To confirm the subscriptions registered, toggle the boolean on and then off again, then run: + +``` +hassette listener --app vacation_mode +``` + +Both `vacation_start` and `vacation_end` listeners should appear with an invocation count of 1 or higher. ## 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 `self.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 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. diff --git a/docs/pages/testing/concurrency.md b/docs/pages/testing/concurrency.md index 298d97171..ec1710318 100644 --- a/docs/pages/testing/concurrency.md +++ b/docs/pages/testing/concurrency.md @@ -1,52 +1,57 @@ # 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][hassette.app.app.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 +## Parallel Test Suites (pytest-xdist) -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. +Install `pytest-xdist` to enable parallel test execution: -## `DrainFailure` Exception Hierarchy +```bash +pip install pytest-xdist # or: uv add --dev pytest-xdist +``` -The drain exception hierarchy is rooted at `DrainFailure` so callers can catch any drain-related failure uniformly. +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. -`DrainFailure` has two concrete subclasses: +`@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. -- **`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. +```python +--8<-- "pages/testing/snippets/testing_xdist_group.py" +``` + +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#install) covers setup and the false-green warning. ## Next Steps -- **[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 +- **[Time Control](time-control.md)**: Freezing and advancing time in tests +- **[Factories](factories.md)**: Event factories and `RecordingApi` coverage boundary +- **[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..5835954a2 100644 --- a/docs/pages/testing/factories.md +++ b/docs/pages/testing/factories.md @@ -1,46 +1,35 @@ -# Factories & Internals +# Factories -## 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`][hassette.events.hass.hass.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`][hassette.events.hass.hass.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`][hassette.config.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`][hassette.core.state_proxy.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,26 @@ 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`. - -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()`. - -!!! 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: +Any other public name not defined on `RecordingApi` also falls through to `__getattr__` and raises `NotImplementedError`. - ```python - --8<-- "pages/testing/snippets/testing_sync_facade.py" - ``` +`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. - Methods not covered by the facade raise `NotImplementedError` rather than silently succeeding. +`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: -!!! 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. - -## Tier 2 Re-exports +```python +--8<-- "pages/testing/snippets/testing_sync_facade.py" +``` -`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. +Methods not covered by the sync facade raise `NotImplementedError` rather than silently succeeding. ## 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 new file mode 100644 index 000000000..38805bf36 --- /dev/null +++ b/docs/pages/testing/harness.md @@ -0,0 +1,308 @@ +# Test Harness Reference + +`AppTestHarness` wires an [App][hassette.app.app.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. + +## Basic Pattern + +A typical test creates a harness, simulates an event, and asserts on the API calls the app made: + +```python +--8<-- "pages/testing/snippets/testing_quick_start.py" +``` + +The `async with` block initializes the app (running `on_initialize`), then tears it down on exit. `simulate_state_change` publishes an event through the bus and waits for all handlers to finish. `api_recorder.assert_called` verifies the app called the expected service. + +The sections below cover each piece of this pattern in detail. + +## 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()` pre-populates a single entity's state into the state proxy (the in-process state store that app code reads via `self.states`). +`set_states()` pre-populates 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 must precede + `simulate_state_change()` for the same entity, not follow it. A later + `set_state()` 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. + +!!! warning "Forgetting `await` fires nothing" + Every `simulate_*` method is a coroutine. A call without `await` publishes + no event — handlers never run, and a following `assert_called` fails even + though the app code is correct. A `RuntimeWarning: coroutine ... was never + awaited` in the pytest output is the tell (see + [Async Basics](../migration/async-basics.md) for the underlying cause). + +### 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]` +(`from hassette import D` — see +[Dependency Injection](../core-concepts/bus/dependency-injection.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.event_handling.dependencies`](../core-concepts/bus/dependency-injection.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. + +### Other Framework Events + +The harness simulates every event type the bus can deliver, so handlers +registered for connection, app-lifecycle, and HA-lifecycle events are testable +without a live instance: + +| Method | Fires the event for | +|---|---| +| `simulate_websocket_connected()` / `simulate_websocket_disconnected()` | `on_websocket_connected` / `on_websocket_disconnected` — reconnection logic | +| `simulate_app_state_changed(app_key, status, ...)` | `on_app_state_changed` — inter-app coordination | +| `simulate_app_running(app_key)` / `simulate_app_stopping(app_key)` | the matching shorthands | +| `simulate_homeassistant_restart()` / `_start()` / `_stop()` | `on_homeassistant_restart` / `_start` / `_stop` | +| `simulate_component_loaded(component)` | `on_component_loaded` | +| `simulate_service_registered(domain, service)` | `on_service_registered` | + +All drain triggered handlers before returning, like the other `simulate_*` +methods. + +### Draining Manually + +`drain_task_bucket(timeout=2.0)` waits for the bus dispatch queue and the +app's task bucket to go quiescent without firing an event. Call it after +[`trigger_due_jobs()`](time-control.md) when dispatched jobs emit bus events — +the job trigger does not drain the downstream handler tasks itself. Raises the +same `DrainTimeout`/`DrainError` exceptions as the `simulate_*` methods; when +debounced handlers are involved, pass a `timeout=` larger than the debounce +window. + +### 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" +``` + +For strict assertions, `assert_called_exact(method, **kwargs)` requires the +recorded kwargs to match exactly — extra recorded kwargs fail the assertion. +Use it to prove no unexpected arguments were forwarded. +`assert_called_partial` is an explicit-name alias for `assert_called` when the +partial-match intent should be visible in the test. + +### 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][hassette.app.app_config.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][hassette.bus.bus.Bus] | Test bus owned by the app | +| `harness.scheduler` | [Scheduler][hassette.scheduler.scheduler.Scheduler] | Test scheduler owned by the app | +| `harness.api_recorder` | `RecordingApi` | Records every API call the app makes | +| `harness.states` | [StateManager][hassette.state_manager.state_manager.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](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..71fde191f 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,65 @@ 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_*` 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`][hassette.test_utils.AppTestHarness] runs your app against a test environment. The harness wires in a `RecordingApi` automatically — it replaces the live HA connection and records every API call your app makes. You assert on those recordings via `harness.api_recorder`. The `config` dict maps to your `AppConfig` fields — the same keys you would set in `hassette.toml`. ```python ---8<-- "pages/testing/snippets/testing_simulate_timeout.py" +--8<-- "pages/testing/snippets/testing_quick_start.py:harness_setup" ``` -!!! 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: it calls `on_initialize()`, waits for all listeners to register with the bus (Hassette's event pub/sub system), then yields. When the block exits, the harness calls `on_shutdown()` and cancels any running tasks. -### 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" +--8<-- "pages/testing/snippets/testing_quick_start.py:simulate" ``` -**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. Only the specified kwargs need to match. ```python ---8<-- "pages/testing/snippets/testing_assert_called.py" +--8<-- "pages/testing/snippets/testing_quick_start.py:assert_called" ``` -!!! 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 (e.g., checking whether a light is already on before toggling it), seed it first with `harness.set_state()` before simulating the event. `set_state()` writes directly to the in-process entity state cache that `self.states` reads from, without publishing a bus event, so no handlers fire. Seed before you simulate. ```python ---8<-- "pages/testing/snippets/testing_assert_not_called.py" +--8<-- "pages/testing/snippets/testing_seed_state.py:seed" ``` -### `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 `AppTestHarness` API — all simulate methods, all assert methods, error handling +- [Time Control](time-control.md): freeze or advance the scheduler clock to test delayed and recurring jobs +- [Concurrency & pytest-xdist](concurrency.md): parallel test execution with `pytest-xdist` and concurrent harness patterns +- [Factories](factories.md): factory functions for building test state dicts, events, and helper records diff --git a/docs/pages/testing/snippets/factories_mock_hassette.py b/docs/pages/testing/snippets/factories_mock_hassette.py index e5861decb..9d45836a7 100644 --- a/docs/pages/testing/snippets/factories_mock_hassette.py +++ b/docs/pages/testing/snippets/factories_mock_hassette.py @@ -2,7 +2,7 @@ from hassette.test_utils import make_mock_hassette -tmp_path = Path("/tmp/test") # pyright: ignore[reportUnusedVariable] +tmp_path = Path("/tmp/test") # Minimal — real config defaults, sealed against phantom attributes hassette = make_mock_hassette() diff --git a/docs/pages/testing/snippets/testing_quick_start.py b/docs/pages/testing/snippets/testing_quick_start.py index 5b5874a98..8b0d617bd 100644 --- a/docs/pages/testing/snippets/testing_quick_start.py +++ b/docs/pages/testing/snippets/testing_quick_start.py @@ -4,12 +4,18 @@ async def test_light_turns_on_when_motion_detected(): + # --8<-- [start:harness_setup] async with AppTestHarness( MotionLights, config={"motion_entity": "binary_sensor.hallway", "light_entity": "light.hallway"}, ) as harness: + # --8<-- [end:harness_setup] + # --8<-- [start:simulate] await harness.simulate_state_change("binary_sensor.hallway", old_value="off", new_value="on") + # --8<-- [end:simulate] + # --8<-- [start:assert_called] harness.api_recorder.assert_called( "turn_on", entity_id="light.hallway", ) + # --8<-- [end:assert_called] diff --git a/docs/pages/testing/snippets/testing_seed_state.py b/docs/pages/testing/snippets/testing_seed_state.py new file mode 100644 index 000000000..3e32bcafe --- /dev/null +++ b/docs/pages/testing/snippets/testing_seed_state.py @@ -0,0 +1,15 @@ +from hassette.test_utils import AppTestHarness + +from my_apps.motion_lights import MotionLights + + +async def test_seed_then_simulate(): + async with AppTestHarness( + MotionLights, + config={"motion_entity": "binary_sensor.hallway", "light_entity": "light.hallway"}, + ) as harness: + # --8<-- [start:seed] + await harness.set_state("binary_sensor.hallway", "off") + await harness.simulate_state_change("binary_sensor.hallway", old_value="off", new_value="on") + # --8<-- [end:seed] + harness.api_recorder.assert_called("turn_on", entity_id="light.hallway") diff --git a/docs/pages/testing/snippets/testing_time_control_sequence.py b/docs/pages/testing/snippets/testing_time_control_sequence.py index 818cd5b09..ab88e883e 100644 --- a/docs/pages/testing/snippets/testing_time_control_sequence.py +++ b/docs/pages/testing/snippets/testing_time_control_sequence.py @@ -11,8 +11,7 @@ async def test_reminder_fires_after_one_hour(): start = Instant.from_utc(2024, 1, 15, 9, 0, 0) # 2024-01-15 09:00 UTC harness.freeze_time(start) - # 2. Schedule the job (app registers it in on_initialize, but you - # can also trigger registration logic via simulate_* here) + # 2. The app registered its job in on_initialize when the harness started # 3. Advance the frozen clock harness.advance_time(hours=1) diff --git a/docs/pages/testing/time-control.md b/docs/pages/testing/time-control.md index 1f3b23acc..b753ca12b 100644 --- a/docs/pages/testing/time-control.md +++ b/docs/pages/testing/time-control.md @@ -1,48 +1,54 @@ # Time Control -Test scheduler-driven behavior by freezing time and advancing it manually. +The time control API on [`AppTestHarness`](harness.md) freezes the harness clock and advances it manually. This makes scheduler-driven behavior deterministic in tests — without it, tests that wait on scheduled jobs depend on real wall-clock time. The examples assert with `harness.api_recorder`, the recorder that tracks every HA API call (covered in [Test Harness](harness.md)). -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. + +The three steps are separate because advancing time and dispatching jobs are distinct operations. A test that advances the clock by 30 minutes may only care about the first job. Remaining due jobs stay untriggered until an explicit `trigger_due_jobs` call. This separation gives each test precise control over which jobs fire and when. ## `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. `trigger_due_jobs()` must be called explicitly afterward. Without it, jobs accumulate silently and side-effect assertions 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`. `await harness.drain_task_bucket()` waits for those handler tasks to complete before assertions run — see [Test Harness](harness.md) for the full method reference. ## 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 diff --git a/docs/pages/troubleshooting.md b/docs/pages/troubleshooting.md index 58e558e59..4f38ec29e 100644 --- a/docs/pages/troubleshooting.md +++ b/docs/pages/troubleshooting.md @@ -1,139 +1,205 @@ # 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](getting-started/ha_token.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 (`http://`) and port explicitly — `homeassistant.local` without the scheme raises [`SchemeRequiredInBaseUrlError`][hassette.exceptions.SchemeRequiredInBaseUrlError] at startup. Use `http://homeassistant.local:8123` instead. -## 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-access-the-web-ui). -- **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`][hassette.exceptions.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 `ConnectionClosedError` 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. To react to connection loss, subscribe to `on_websocket_disconnected` / `on_websocket_connected` on the bus. 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. - 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. +## Apps Not Loading -## Event handler never runs +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. -- **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). +**Syntax error or bad import.** Look for this pattern in the log: -## Home Assistant goes offline +``` +ERROR hassette.utils.app_utils — Failed to load app 'MyApp': SyntaxError: invalid syntax (at /apps/my_app.py:12) +``` + +Fix the syntax error or install the missing dependency. + +**Class not found.** The `class_name` in `hassette.toml` doesn't match the actual class name in the file: -When Home Assistant becomes unreachable or disconnects mid-session, Hassette handles recovery automatically without restarting the process. +``` +AttributeError: Class MyApp not found in module apps.my_app +``` -**What happens immediately:** +Check for typos in `class_name` in `hassette.toml` and confirm the class is defined at module level. -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. +**Invalid config.** A required [`AppConfig`][hassette.app.app_config.AppConfig] field has no value and no default: -**Reconnection sequence:** +``` +ERROR hassette — Failed to load app 'MyApp' due to bad configuration +``` -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: +Set the missing field in `hassette.toml` or via an environment variable. -- 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. +**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. -**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. +## Handler Registration Fails -**What to look for in logs:** +**[`ListenerNameRequiredError`][hassette.exceptions.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`][hassette.exceptions.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 replaced automatically 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#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. + +## 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#handle-errors). + +## 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`][hassette.exceptions.SchemaVersionError] and halts. Hassette will not try to update the old database automatically. 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](core-concepts/states/custom-states.md#troubleshooting). + +## Web UI Not Accessible + +**Running locally.** Open `http://localhost:8126/` 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.exceptions.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:** +**`IPV6NotSupportedError`** `base_url` contains an IPv6 address, which Hassette does not support. Use a hostname or IPv4 address instead. -```bash -hassette --version # if installed as a CLI tool -uv pip show hassette # shows installed version in your project -``` +**[`ResourceNotReadyError`][hassette.exceptions.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. -**Upgrade to the latest release:** +**`ConnectionClosedError`** The WebSocket closed unexpectedly. Hassette handles this internally and reconnects. You only see this if you catch it explicitly. -```bash -uv add hassette@latest -``` +**[`FailedMessageError`][hassette.exceptions.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. + +### 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`][hassette.exceptions.InvalidDataForStateConversionError]** The data passed to state conversion is malformed or `None`. Check the upstream event or API response. + +**[`UnableToConvertStateError`][hassette.exceptions.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`][hassette.exceptions.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` or is positional-only. Fix the handler signature. (Parameters without type annotations are skipped by injection, not rejected.) + +**[`DependencyResolutionError`][hassette.exceptions.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. + +**`RegistryValidationError`** Startup registry validation found error-level issues (for example, a malformed custom state class). Only raised when `strict_lifecycle = true`; the exception message lists each issue found. In non-strict mode, the same issues log as warnings. + +**`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][hassette.app.app.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`][hassette.exceptions.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`, `IPV6NotSupportedError`. 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