Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 35 additions & 77 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,98 +26,56 @@ to disk). Nothing is published to PyPI yet.
hatty is an independent reimplementation of `../ha-cli`'s BUILD_PLAN.md spec β€” same conventions,
separate codebase, not a port of ha-cli's source.

## Architecture
## Architecture & layout

```
User Keybindings β†’ HACLI (main.py) β†’ HAClient (client.py) ↔ Home Assistant WebSocket
↓
_update_entities_display() β†’ EntitiesTable
```

Domain state lives on controllers instantiated in `HACLI.__init__`, each holding one slice and
taking an injected app reference: `controllers/lists.py` (`app.list_ctl`), `dashboards.py`
(`app.dash_ctl`), `graphs.py` (`app.graph_ctl`), `connection.py` (`app.conn_ctl` β€” the HA websocket
message pump, `handle_ha_message`/`_HA_MESSAGE_HANDLERS`), `notifications.py` (`app.notify_ctl`),
`logbook.py` (`app.log_ctl` β€” the activity log's scope/paging/fetch/subscription state machine,
shared by `HACLI`'s docked panel, `GraphPreviewScreen`'s, and `DashboardScreen`'s), `keybindings.py`
(`app.keys_ctl` β€” owns the user's keybinding overrides and pushes the resulting keymap onto the
running app via `App.set_keymap`; every screen's `BINDINGS` is `bindings_for(scope)` from this
module's `REGISTRY`, the single source of truth for all ~220 bindings in the app, rebindable from
Configuration β–Έ Keybindings), `backup.py` (`app.backup_ctl` β€” Backup & Sync: owns the export-scope
and git prefs, drives `backup.py`/`git_sync.py` against the app's live collections, and fires
pull-on-start / the exit-time commit-and-push). Like `const.py`/`types.py`, `keybindings.py`'s
registry half is cycle-safe (no `hatty.ui`/`hatty.main` imports) since it's imported at
class-definition time by every screen module. **`HACLI` keeps its old attribute surface via
property pairs** (`app.dashboards`, `app.current_list_name`, `app._detail_entity_id`, …) so screens
and tests read/assign through the app unchanged; new UI code should call controllers directly
instead (`self.app.dash_ctl.set_slot(...)`).

**Single-object export/import.** Lists, dashboards, and saved graphs each have a matching pair of
controller methods β€” `to_export_payload(name)` / `import_from_payload(payload)` on
`ListController`/`DashboardController`/`GraphController` β€” producing one small versioned JSON file
per object (`{"hatty_list": 1, ...}` / `{"hatty_dashboard": 1, ...}` / `{"hatty_graph": 1, ...}`),
reachable from each object's popup (`x`/`i`). `src/hatty/backup.py`'s directory export (Configuration
β–Έ Backup & Sync) is built entirely out of these same payloads β€” one file per object under
`lists/`/`dashboards/`/`graphs/` plus a handful of whole-collection files (`entity_names.json`,
`settings.json`, `keybindings.json`) and a `hatty-backup.json` manifest β€” so a file written by one
path is always readable by the other, and dropping a hand-exported object into the backup directory
just works. `src/hatty/git_sync.py` is a separate, git-agnostic layer that shells out to the `git`
CLI (hardened against credential prompts and hangs β€” see its module docstring) to optionally treat
that directory as a repo; neither module imports the other's caller, `controllers/backup.py` wires
them together.

**Two-tier config persistence.** `config.yaml` is lean β€” connection settings and display
preferences only. The user-data collections (`lists`, `entity_names`, `dashboards`, `saved_graphs`,
`manual_lists`, `default_list`, `default_dashboard` β€” the exact set is `storage.COLLECTION_KEYS`)
live in SQLite (`src/hatty/storage.py`, `Storage`) at `<config dir>/hatty.db`. SQLite is
authoritative: on boot the DB's collections are loaded back over the YAML config, and every save
strips collection keys from the YAML while writing them to the DB in one transaction. See
`storage.py`'s module docstring for the collection shapes.

**Test/demo injection seam**: `HACLI._client_factory` is where the test suite's `FakeHAClient` and
`--demo`'s `DemoHAClient` both replace the real `HAClient` β€” `DemoHAClient` is signature-parity-tested
against it.

Entity dicts follow the `Entity`/`EntityAttributes` TypedDicts in `src/hatty/types.py`; pass entity
params typed as `Entity` (not bare `dict`) and read `total=False` fields via `.get(...)`.
`const.py`/`types.py` import nothing from the app, so they stay cycle-safe.

## Layout

- `src/hatty/main.py` β€” the `HACLI` Textual app: keybindings, message routing, entity-table state,
cross-cutting plumbing (`spawn(coro)` for tracked fire-and-forget tasks β€” never bare
`asyncio.create_task`; `persist(*keys)` to mirror + save a collection).
- `src/hatty/controllers/` β€” the controllers above.
- `src/hatty/client.py` β€” `HAClient`: websocket auth/requests, REST history/logbook fetchers (swallow
errors, return `None`).
- `src/hatty/config.py` / `storage.py` β€” YAML config and SQLite collection persistence.
- `src/hatty/const.py` / `types.py` / `service_calls.py` β€” shared constants, entity TypedDicts, and
the pure per-domain functions that build `call_service` data for entity controls.
- `src/hatty/backup.py` β€” Backup & Sync's directory export/import: builds/writes/reads the JSON
files described above, no git involved.
- `src/hatty/git_sync.py` β€” the git CLI layer for Backup & Sync: init/commit/pull/push over the
export directory, every invocation non-interactive and time-bounded.
- `src/hatty/ui/` β€” screens and popups, one module per surface (entity table, dashboard grid +
widgets, device/area tree, graph panel/fullscreen/preview, per-domain control screens, config,
onboarding). Each module's own docstring is the source of truth for its behavior β€” read the file
before describing it.
- `src/hatty/ui/popup_base.py` β€” shared modal scaffolding (`PopupScreen`, `ListPopup`); new popups
should subclass these rather than hand-rolling styling.
Controllers (`src/hatty/controllers/`, instantiated in `HACLI.__init__`; see each docstring):
`lists.py` (`app.list_ctl`, list state), `dashboards.py` (`app.dash_ctl`, dashboard grid/slots),
`graphs.py` (`app.graph_ctl`, history/detail/saved graphs), `connection.py` (`app.conn_ctl`, HA
websocket pump), `notifications.py` (`app.notify_ctl`, change alerts), `logbook.py` (`app.log_ctl`,
shared activity-log state), `keybindings.py` (`app.keys_ctl`, overrides), `backup.py`
(`app.backup_ctl`, Backup & Sync prefs).
`app.keys_ctl` pushes overrides via `App.set_keymap`; every screen's `BINDINGS = bindings_for(scope)`
from `REGISTRY` (single source for ~220 bindings), whose registry half is cycle-safe β€” see its
docstring.

`HACLI` keeps its old attribute surface via `_controller_proxy` property pairs (`app.dashboards`,
`app.current_list_name`, `app._detail_entity_id`, …); new code calls controllers directly
(`self.app.dash_ctl.set_slot(...)`). **Injection seam**: `HACLI._client_factory` swaps in
`FakeHAClient`/`DemoHAClient`.

Lists/dashboards/graphs each expose `to_export_payload`/`import_from_payload`; `backup.py`'s
directory export reuses those payloads and `git_sync.py` optionally treats it as a git repo β€” see
their docstrings. `config.yaml` stays lean; user-data collections (`storage.COLLECTION_KEYS`) live
in SQLite, authoritative over the YAML β€” see `storage.py`. Entity dicts follow the
`Entity`/`EntityAttributes` TypedDicts in `types.py`; read `total=False` fields via `.get(...)`;
`const.py`/`types.py` import nothing from the app (cycle-safe).

- `src/hatty/main.py` β€” `spawn(coro)` for tracked fire-and-forget (never bare
`asyncio.create_task`); `persist(*keys)` to mirror + save a collection.
- `src/hatty/client.py` β€” `HAClient`: websocket auth/requests, REST history/logbook fetchers.
- `src/hatty/config.py`/`storage.py` β€” YAML config + SQLite; `const.py`/`types.py`/
`service_calls.py` β€” constants, TypedDicts, `call_service` builders.
- `src/hatty/backup.py`/`git_sync.py` β€” directory export/import + git layer (above).
- `src/hatty/ui/` β€” one module per surface; module docstrings are the source of truth (read before
describing). `ui/popup_base.py` β€” subclass `PopupScreen`/`ListPopup`, don't hand-roll styling.

## Conventions

- Every source file (`.py` under `src/`/`tests/`, shell scripts under `sbin/`) starts with the
license header `# hatty β€” MIT License. See LICENSE file for details.` as the first line (or right
after a `#!` shebang).
- `uv run pyright` runs in CI (`.gitea/workflows/test.yml`) and must pass before pushing, alongside
`uv run ruff check .`. It's `basic` mode over `src/hatty` with every basic-mode category enabled,
including `reportOptionalMemberAccess`/`reportAttributeAccessIssue`/`reportArgumentType` β€” don't
write code that fires any of them.
`uv run ruff check .`. It's `basic` mode with every category enabled β€” see `[tool.pyright]` in
`pyproject.toml`; don't write code that fires any pyright diagnostic.
- Every popup/widget uses inline `DEFAULT_CSS`, no external stylesheets.
- Commit messages: concise, usually a single line.
- When implementing a plan with multiple milestones: commit (and push, if asked) after each
milestone once its tests pass, and run the full `pytest` suite after the final milestone before
reporting the plan complete.
- Commit messages: concise, usually a single line; after each milestone of a plan, commit (and push
if asked) once its tests pass, and run the full `pytest` suite after the final milestone.

## Testing

Expand Down
15 changes: 5 additions & 10 deletions src/hatty/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,17 @@

RECONNECT_DELAY = 5
MAX_RECONNECT_DELAY = 60
# WS ping keepalive interval (seconds). Without this, a silently-dropped
# network (e.g. WiFi turned off, no TCP FIN/RST) leaves `ws.receive()`
# blocked forever β€” neither `ha_disconnect` nor `ha_connect_failed` is ever
# emitted, so the UI shows stale state indefinitely (issue #250). With a
# heartbeat, aiohttp pings the server and raises a timeout when pongs stop
# arriving, which flows through listen()'s except-Exception path instead.
# WS ping keepalive (seconds): without it a silently-dropped network leaves
# `ws.receive()` blocked forever and the UI stuck on stale state (issue #250).
WS_HEARTBEAT = 30

# How long an awaited WS request (`_request`) waits for its `result` frame
# before giving up β€” see fetch_logbook's WS-first/REST-fallback split (issue #17).
WS_REQUEST_TIMEOUT = 10

# Sentinel distinguishing "argument omitted" from an explicit None (which is a
# meaningful value β€” clearing a device's area or reverting its user-set name).
# Shared so the stand-in clients import the *same* object: the parity test in
# tests/test_fake_client_parity.py compares parameter defaults by identity.
# Sentinel distinguishing "argument omitted" from an explicit None (a meaningful
# value β€” clearing a device's area). Shared so stand-in clients import the same
# object: test_fake_client_parity.py compares parameter defaults by identity.
_UNSET = object()


Expand Down
2 changes: 1 addition & 1 deletion src/hatty/command_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ def _candidates(self) -> list[tuple[str, str, IgnoreReturnCallbackType]]:
("Configuration", "Edit hatty settings", app.action_show_config),
]
result.append(("Lists", "Switch to your last-used or default list", app.action_palette_switch_list))
result.append(("Dashboard", "Open your last-used or default dashboard", app.action_show_dashboard))
result.append(("Dashboard", "Open your default dashboard", app.action_show_dashboard))
result.append(("Setup wizard", "Re-enter the Home Assistant URL and token", app.action_show_onboarding))
return result

Expand Down
7 changes: 3 additions & 4 deletions src/hatty/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,10 +144,9 @@ def save_config(config: dict, config_path: str | None = None) -> None:
if not path:
raise ValueError("Configuration file path not found, cannot save config.")

# The config holds the long-lived HA token in cleartext, so keep the dir and
# file private (issue #156). mkdir's mode= is masked by umask, so chmod it
# explicitly; write the file via os.open with 0o600 (no world-readable window
# for a fresh file) and chmod afterward to tighten any pre-existing config.
# The config holds the HA token in cleartext, so keep dir/file private (#156).
# mkdir's mode= is masked by umask, so chmod explicitly; os.open with 0o600
# avoids a world-readable window for a fresh file.
path.parent.mkdir(parents=True, exist_ok=True)
os.chmod(path.parent, 0o700)
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
Expand Down
52 changes: 21 additions & 31 deletions src/hatty/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,13 @@
must not import anything from the app so it stays cycle-safe.
"""

# Domains whose entities can be flipped with a plain homeassistant.toggle-style
# turn_on/turn_off pair (enter on the entities table). "media_player" is also here,
# but its enter behavior is media_play_pause, not turn_on/turn_off β€” see the
# media_player carve-out at the top of HACLI.toggle_entity.
# Domains flippable with a plain turn_on/turn_off pair (enter on the entities
# table). "media_player" is here too, but its enter behavior is media_play_pause
# β€” see the carve-out at the top of HACLI.toggle_entity.
TOGGLABLE_DOMAINS = {"switch", "light", "fan", "media_player"}

# Domains with an attribute-editing UI. "light" and "media_player" are routed to
# their own dedicated live-apply screens (ui/controls/light_screen.py,
# ui/controls/media_player_screen.py) by main.py; the EntityControlPopup handles
# the remaining simple field-based domains.
# Domains with an attribute-editing UI. "light"/"media_player" route to their own
# live-apply screens (ui/controls/); EntityControlPopup handles the rest.
CONTROLLABLE_DOMAINS = {"light", "fan", "climate", "cover", "input_number", "lock", "media_player"}

# Home Assistant MediaPlayerEntityFeature bitmask (only the flags we gate on).
Expand Down Expand Up @@ -44,8 +41,7 @@ def media_supports(features: int | None, flag: str) -> bool:


# Home Assistant WeatherEntityFeature bitmask β€” which weather.get_forecasts
# `type` values (used verbatim as the service call's "type" field) an entity
# supports. Order here doubles as the preferred default when several are set.
# `type` values an entity supports; order doubles as the preferred default.
WEATHER_FEAT = {
"forecast_daily": 1,
"forecast_hourly": 2,
Expand Down Expand Up @@ -140,9 +136,9 @@ def binary_state_label(state: str, device_class: str) -> str:
"panel",
]

# widget_type -> domain its entity picker should be restricted to; absent = unrestricted (panel),
# "graph"/"gauge" are handled separately since they filter by numeric state rather than domain.
# Every new WIDGET_TYPES entry must get a mapping here or an explicit carve-out above.
# widget_type -> domain its entity picker restricts to; absent = unrestricted (panel).
# "graph"/"gauge" filter by numeric state instead, so they're not mapped here. Every
# new WIDGET_TYPES entry needs a mapping here or an explicit carve-out above.
WIDGET_TYPE_DOMAINS = {
"switch": "switch",
"light": "light",
Expand All @@ -156,9 +152,8 @@ def binary_state_label(state: str, device_class: str) -> str:
"weather": "weather",
}

# Widget types that can carry the per-slot "show_last_changed" option: every
# single-entity widget. "graph" already plots a time axis; "panel"/"split" hold
# many entities, so there is no single last_changed to show.
# Widget types that can carry "show_last_changed": every single-entity widget.
# "graph" plots its own time axis; "panel"/"split" hold many entities, no single one.
LAST_CHANGED_WIDGET_TYPES = frozenset(WIDGET_TYPES) - {"graph", "panel"}

# Entity table columns shown when the config carries no "columns" key.
Expand All @@ -170,16 +165,14 @@ def binary_state_label(state: str, device_class: str) -> str:
# Fallback for the global "log_hours" config value (the activity log's window size).
DEFAULT_LOG_HOURS = 24

# GraphPreviewScreen's shift+left/shift+right "fast page" multiplier over the
# normal left/right page. Lives here (not preview_screen.py) so the keybinding
# registry can reference it in a binding description without an import cycle.
# GraphPreviewScreen's shift+left/right "fast page" multiplier. Lives here (not
# preview_screen.py) so the keybinding registry can reference it without a cycle.
FAST_PAGE_MULTIPLIER = 6

# Canonical names for the top-level app_config keys, so a rename is one edit and a
# typo is a NameError instead of a silent None. config.default_config() and
# storage.PERSISTED reference these, keeping them the single literal definition.
# NOTE: "graph_type"/"hours" also appear as keys *inside* saved-graph entry dicts
# (a different namespace β€” storage.py, controllers/graphs.py); do NOT reuse
# typo is a NameError instead of a silent None (config.default_config() and
# storage.PERSISTED reference these). NOTE: "graph_type"/"hours" also appear as
# keys *inside* saved-graph entry dicts (a different namespace) β€” don't reuse
# CONFIG_KEY_GRAPH_TYPE there.
CONFIG_KEY_HOME_ASSISTANT = "home_assistant"
CONFIG_KEY_URL = "url"
Expand All @@ -203,13 +196,11 @@ def binary_state_label(state: str, device_class: str) -> str:
CONFIG_KEY_KEYBINDINGS = "keybindings"
CONFIG_KEY_BACKUP = "backup"

# Fallback/default value for the "terminal_title" config key (issue: set tmux
# title to hatty or pref).
# Fallback for the "terminal_title" config key.
DEFAULT_TERMINAL_TITLE = "hatty"

# Legacy reserved list name (issue #224). No longer special β€” any list can be
# designated a notification source via `notify_lists` (issue #24) β€” kept only as
# the name storage.migrate_reserved_notify_list looks for on a pre-#24 DB.
# Legacy reserved list name (#224). No longer special β€” any list can be a
# notification source via `notify_lists` (#24); kept only for migration lookup.
NOTIFY_LIST_NAME = "\U0001f514 Notifications"

# Default notification preferences (config key "notifications"), merged over by
Expand All @@ -228,9 +219,8 @@ def binary_state_label(state: str, device_class: str) -> str:
}

# Default Backup & Sync preferences (config key "backup"), merged over by
# BackupController whenever a config predates a given key. "sections" is
# spelled out literally (matching backup.SECTIONS) rather than imported, so
# const.py stays free of imports from the rest of the app.
# BackupController for a config that predates a key. "sections" is spelled out
# literally (matching backup.SECTIONS) so const.py stays import-free.
DEFAULT_BACKUP = {
"path": "", # export directory; "" = feature idle
"sections": ["lists", "dashboards", "saved_graphs", "entity_names", "settings", "keybindings"],
Expand Down
10 changes: 4 additions & 6 deletions src/hatty/controllers/backup.py
Original file line number Diff line number Diff line change
Expand Up @@ -214,12 +214,10 @@ async def pull_on_start(self) -> None:
async def sync_on_exit(
self, status: Callable[[str], None] | None = None, timeout: float = 75.0
) -> tuple[bool, str]:
# 75s: room for git_sync's own NETWORK_TIMEOUT (60s) on the push plus a
# buffer for the local commit and export, as a belt-and-suspenders cap
# so a stalled network can't hang the exit-sync overlay indefinitely.
# `status`, if given, is called before each phase β€” ExitSyncScreen
# passes its own label so a slow push doesn't look identical to a
# slow commit (issue: show what's happening during a slow exit).
# 75s: room for git_sync's NETWORK_TIMEOUT (60s) plus a buffer for commit/
# export, so a stalled network can't hang the exit-sync overlay indefinitely.
# `status`, if given, is called before each phase so a slow push doesn't
# look identical to a slow commit.
if not self.exit_sync_pending():
return True, ""
path = self.prefs.get("path") or ""
Expand Down
Loading
Loading