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
6 changes: 4 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,12 @@ User Keybindings → HACLI (main.py) → HAClient (client.py) ↔ Home Assistant
_update_entities_display() → EntitiesTable
```

Domain state lives on four controllers instantiated in `HACLI.__init__`, each holding one slice and
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`). **`HACLI` keeps its old attribute surface
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 and `GraphPreviewScreen`'s). **`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(...)`).
Expand Down
111 changes: 111 additions & 0 deletions docs/RELEASE-NOTES-v0.1.0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# hatty v0.1.0

The first release of **hatty — a TTY for your HA.** Your whole smart home, live, in a terminal:
search and control every entity, sparkline or fullscreen graph its history, and lay out widget
dashboards, all over Home Assistant's WebSocket API.

Never used it? `uv run hatty --demo` boots fully offline against a curated fake home — no HA
required. See `docs/demo.gif` for a scripted tour.

## The entity table

A live-updating table over every entity in your home, with nine columns you can mix and match
(`c`): icon, name, state, value, unit, device class, entity ID, last-changed, list membership.
`/` searches incrementally with `n`/`N` to step matches; `enter` toggles a switch/light/fan or
play-pauses a media player; `r` renames an entity, locally or by writing straight through to
Home Assistant's own entity registry.

Favorite entities into named **lists** (`space` to add, `l` to manage) — alphabetical or hand-
ordered per list, lockable against accidental edits, with full undo/redo on membership changes.

## Graphs

`g` opens an inline sparkline, `G` a fullscreen graph — sparkline, line, or scatter, comparing
several entities at once with per-line colors you can cycle or pick freely. `enter` drops into
inspect mode and reads every plotted series at one timestamp; paging keeps a live anchor so
scrolling back and forward again lands you exactly where "now" still is. Save a comparison as a
named graph and reload it later.

Dense series get downsampled min/max-per-bucket rather than by stride or averaging, so a spike in
your power sensor survives the render instead of getting smoothed away. Inspect-mode's keys are
literal twin bindings of the paging keys — the footer always shows what a key does *right now*,
never what it does in the other mode.

## Dashboards

Build named grid dashboards out of 13 widget types — graph, gauge, switch, light, fan,
thermostat, cover, lock, media player, sensor, binary sensor, weather, panel — plus nested split
panes for finer layouts. Use mode drives the entities directly; Edit mode (`E`) resizes slots with
`ctrl+arrows`, grab-moves or swaps them, and splits/unsplits panes. Some widgets repurpose the
arrow keys entirely: a thermostat slot adjusts setpoint, a fan slot adjusts speed, a media player
slot adjusts volume and skips tracks.

The slot picker works either direction — pick a widget type first and then a matching entity, or
pick an entity first and see only the widget types it actually supports. A grab-move can cross a
split pane's boundary without letting go of what you're carrying.

## Device / area tree

`D` opens a full registry tree, grouped by device, area, or integration (`v` cycles), with a
filter scoped to just one of those levels. It's not read-only: reassign a device to a different
area, create or rename areas, or spin up a new dashboard from everything in an area with one key.

## Activity log

A dockable logbook panel, on both the entity table (`a`/`i`) and the fullscreen graph (`a`), with
scope cycling, time-window paging, and live streaming as events happen. Device events (a Zigbee
button press, say) get marked `⚡` and drawn directly on the graph.

Home Assistant's own logbook quietly omits continuous sensors (temperature, humidity, power) — so
hatty synthesizes their log entries from history instead. The REST and WebSocket logbook APIs
disagree about entry shape; one normalizer resolves that so nothing downstream has to care which
one answered.

## Controls

`e` opens dedicated live-apply screens for lights (brightness, kelvin, color swatches/picker,
effects) and media players (volume, transport, source), plus a lighter field popup for fans,
climate, covers, locks, and number inputs. Every control is capability-gated against what the
entity actually reports, so the light's tabs don't flicker as it turns on and off, and the media
player's footer never shows a button the device doesn't have.

Weather entities get a fullscreen multi-day forecast with tabs for whatever ranges the entity
supports (daily/twice-daily/hourly) — fetched live via `weather.get_forecasts`, since modern Home
Assistant no longer keeps a forecast attribute populated on the entity itself.

## Alerts

Watch any entity for changes via the reserved `🔔 Notifications` list (or promote any list you
already have into a notification source). Channels: in-app toast, terminal beep, desktop
notification, ntfy push, and a highlighted row in the table — configurable independently.

## Setup & storage

First run walks you through a connection wizard with a live test; after that, an in-app config
screen covers connection, theme, graph defaults, columns, terminal/tmux title, and ntfy. Storage
is two-tier: a lean, hand-editable `config.yaml` for connection and display settings, and a SQLite
database next to it for the things that actually grow — lists, dashboards, saved graphs, entity
name overrides. Lost connection to HA reconnects automatically. `ctrl+p` opens a command palette,
`?` a live cheat-sheet of every key on the current screen.

## Install

From source, with [uv](https://docs.astral.sh/uv/):

```bash
git clone <this-repo> && cd hatty
uv sync
uv run hatty --demo # or: cp config.example.yaml config.yaml, then uv run hatty
```

This release also attaches an sdist, a wheel, and standalone Linux binaries
(`hatty-linux-x86_64`, `hatty-linux-aarch64`) — no Python required for the binaries. **Not yet
on PyPI**: `uv tool install hatty` and `pipx install hatty` don't work yet.

## Rough edges

- Pre-release (`v0.x`) — expect breaking changes to config/storage shape between minor versions.
- No PyPI package yet; source or the attached binaries only.
- Release binaries are Linux-only (x86_64, aarch64).
- Split dashboard panes nest one level deep.
- Alerts fire on state changes only — no threshold or duration conditions yet.
47 changes: 4 additions & 43 deletions src/hatty/controllers/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,6 @@
on ``HACLI``; this controller reaches it through the app reference.
"""

from datetime import datetime, timezone

from hatty.logbook import normalize_entry
from hatty.ui.activity_log_panel import ActivityLogPanel
from hatty.ui.entity_table import get_display_name


class ConnectionController:
"""Owns the HA websocket message pump. UI plumbing (notify, sub_title,
Expand Down Expand Up @@ -72,10 +66,7 @@ def _on_ha_connected(self, msg: dict) -> None:
app.notify("Reconnected to Home Assistant.", title="Reconnected", severity="information")
# The logbook/event_stream subscription (if any) died with the old
# socket — re-arm it so a live-open log doesn't go silent post-reconnect.
if app._log_end is None:
log_panel = app.query_one("#activity_log_panel", ActivityLogPanel)
if log_panel.has_class("-visible"):
app.spawn(app.client.subscribe_logbook(app._log_query_ids, app._log_device_ids))
app.log_ctl.resubscribe_after_reconnect()
# Warn once per run when the token travels over cleartext http:// (issue #158).
if not self.http_warned and (app.ha_url or "").lower().startswith("http://"):
self.http_warned = True
Expand Down Expand Up @@ -229,27 +220,7 @@ def _handle_event_message(self, msg: dict) -> None:
# While a logbook/event_stream subscription is active, it already
# carries this same state change (plus device events state_changed
# can never see) — appending here too would double the line (issue #19).
if (
app._log_entity_ids
and entity_id in app._log_entity_ids
and app._log_end is None
and app.client.logbook_subscription_id is None
):
log_panel = app.query_one("#activity_log_panel", ActivityLogPanel)
if log_panel.has_class("-visible"):
device_class = new_state.get("attributes", {}).get("device_class") or ""
raw = {
"when": datetime.now(timezone.utc).isoformat(),
"state": new_state.get("state", ""),
"entity_id": entity_id,
"name": get_display_name(new_state),
}
# name is always set above, so entity_names/device_names can stay
# empty — resolve_name short-circuits on it (issue #25's transport
# consistency: this now shares format_log_line/state_detail with
# the fetched path instead of writing a raw, unlabeled string).
entry = normalize_entry(raw, {}, {}, {entity_id: device_class})
app.call_later(log_panel.add_log_entry, entry)
app.log_ctl.handle_state_change(entity_id, new_state)
app._clear_pending_call(entity_id)
if app._detail_entity_id == entity_id:
app.call_later(app.graph_ctl.refresh_detail_panel)
Expand All @@ -261,15 +232,5 @@ def _handle_event_message(self, msg: dict) -> None:
def _handle_logbook_stream_message(self, msg: dict) -> None:
"""Live logbook/event_stream frames (issue #19) — device-scoped events
(a zha_event button press, a ping) never fire state_changed, so this is
the only way they can appear without reloading the log. The panel's own
dedupe (ActivityLogPanel.add_log_entry) absorbs the boundary overlap
between the fetched window and the first live push."""
app = self._app
raw_entries = msg.get("event", {}).get("events") or []
if not raw_entries:
return
log_panel = app.query_one("#activity_log_panel", ActivityLogPanel)
if not log_panel.has_class("-visible") or app._log_end is not None:
return
for entry in app.normalize_log_entries(raw_entries):
app.call_later(log_panel.add_log_entry, entry)
the only way they can appear without reloading the log."""
self._app.log_ctl.handle_stream_frame(msg.get("event", {}).get("events") or [])
Loading
Loading