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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ taking an injected app reference: `controllers/lists.py` (`app.list_ctl`), `dash
(`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 and `GraphPreviewScreen`'s). **`HACLI` keeps its old attribute surface
shared by `HACLI`'s docked panel, `GraphPreviewScreen`'s, and `DashboardScreen`'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
2 changes: 1 addition & 1 deletion docs/RELEASE-NOTES-v0.1.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ area, create or rename areas, or spin up a new dashboard from everything in an a

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.
button press, say) get marked `⚡` in the log list.

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
Expand Down
22 changes: 22 additions & 0 deletions src/hatty/controllers/dashboards.py
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,28 @@ def grid_ctx(self, dashboard_name: str, parent: tuple[int, int] | None) -> tuple
children = split.setdefault("children", {"rows": 1, "cols": 1, "slots": []})
return children.setdefault("slots", []), children.get("rows", 1), children.get("cols", 1)

def dashboard_entity_ids(self, name: str) -> list[str]:
"""Every entity on `name`, grid order, deduped — a slot's `entity_id`,
a panel slot's `entity_ids`, and the slots inside a split's `children`
(splits can't nest, so one level of recursion covers every slot)."""
entity_ids: list[str] = []
seen: set[str] = set()

def _collect(slots: list[dict]) -> None:
for slot in slots:
panel_ids = slot.get("entity_ids")
ids = panel_ids if panel_ids is not None else [slot.get("entity_id")]
for entity_id in ids:
if entity_id and entity_id not in seen:
seen.add(entity_id)
entity_ids.append(entity_id)
children = slot.get("children")
if children:
_collect(children.get("slots", []))

_collect(self.dashboards[name]["slots"])
return entity_ids

def set_slot(
self,
dashboard_name: str,
Expand Down
71 changes: 47 additions & 24 deletions src/hatty/controllers/logbook.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,28 @@
# hatty — MIT License. See LICENSE file for details.
"""Shared activity-log state machine for both log hosts (HACLI's docked panel
and GraphPreviewScreen's fullscreen-graph panel), extracted so issue #28 (a
third, device-tree-scoped host) is a matter of wiring, not another copy of
this file (issue #38).
"""Shared activity-log state machine for three log hosts: HACLI's docked
panel, GraphPreviewScreen's fullscreen-graph panel, and DashboardScreen's
docked panel — extracted (issue #38) so a new host is a matter of wiring,
not another copy of this file. (A fourth, device-tree-scoped host is issue
#28, still open.)

One LogbookController (app.log_ctl) holds a LogSession per open host, keyed
by id(host) — not a single global session, since the main screen's log and a
pushed GraphPreviewScreen's log can both be `-visible` at once (opening a
fullscreen graph via `G` does not close the main log; only the docked-panel
toggle does that mutual-exclusion dance). HAClient has exactly one
logbook_subscription_id, though, so a *live* WS subscription is a singleton
resource — `live_session()` picks the one session (if any) allowed to hold
it, unambiguous by construction since only one host is live-capable.

Only HACLI is live-capable (LOG_SUPPORTS_LIVE = True). GraphPreviewScreen
stays fetch-only on purpose: its plot event marks are driven by the entries
list `load()` hands back via `host.on_log_entries`, and a live append
wouldn't route through that — subscribing would silently desync the marks
from the list.
resource — `live_session()` picks among the sessions allowed to hold it.

HACLI and DashboardScreen are both live-capable (LOG_SUPPORTS_LIVE = True);
either's panel can be `-visible` while the other is hidden behind it (the
main screen's panel stays `-visible` when `d` pushes the dashboard on top).
When more than one live-capable session is visible and now-anchored,
`live_session()` prefers whichever host is the screen currently on top —
the singleton subscription always follows what the user is looking at, and
`close()`/screen-transition hooks resync it to whatever remains live.
GraphPreviewScreen stays fetch-only on purpose: its log window follows the
graph's own paged/zoomed span, and a live WS append is always anchored to
"now" — it would inject entries outside whatever span is currently plotted.

LogScopeOption.resolve is pure (never notifies) so every option can be
resolved just to preview it (the `v` scope popup, issue #38) without side
Expand All @@ -36,6 +41,8 @@
from datetime import datetime, timedelta, timezone
from typing import Protocol

from textual.css.query import NoMatches

from hatty.logbook import LogEntry, entry_when_iso, is_continuous_sensor, normalize_entries, normalize_entry
from hatty.types import Entity
from hatty.ui.activity_log_panel import ActivityLogPanel
Expand Down Expand Up @@ -111,7 +118,6 @@ class LogHost(Protocol):
def query_one(self, selector: str, expect_type: type) -> ActivityLogPanel: ...
def log_window(self, session: LogSession) -> "tuple[float, datetime | None]": ...
def log_title_suffix(self, session: LogSession) -> str: ...
def on_log_entries(self, entries: list[LogEntry]) -> None: ...


class LogbookController:
Expand Down Expand Up @@ -153,11 +159,22 @@ def close(self, host: LogHost) -> None:
session = self._sessions.pop(id(host), None)
if session is None:
return
panel = session.panel()
panel.remove_class("-visible")
panel.remove_class("-maximized")
try:
panel = session.panel()
except NoMatches:
# The host is mid-teardown (e.g. a screen's on_unmount closing its
# own session so it can't linger — see LogHost's docstring) and its
# children, including the panel, are already gone. Nothing left to
# un-visible/un-maximize; still resync the subscription below.
panel = None
if panel is not None:
panel.remove_class("-visible")
panel.remove_class("-maximized")
if session.supports_live:
self._app.spawn(self._app.client.unsubscribe_logbook())
# Don't just drop the subscription — another live session (e.g. the
# main screen's, left `-visible` behind a dismissed dashboard) may
# still want it.
self._app.spawn(self.resync_subscription())
self._app.refresh_bindings()

def session_for(self, host: LogHost) -> "LogSession | None":
Expand Down Expand Up @@ -317,7 +334,6 @@ async def load(self, session: LogSession) -> None:
else:
normalized = self.normalize(entries)
panel.load_history(normalized)
host.on_log_entries(normalized)

def page(self, host: LogHost, direction: int) -> None:
"""direction<0 pages older, >0 pages newer (snapping back to live at
Expand Down Expand Up @@ -493,13 +509,20 @@ def _format_log_hours(hours: float) -> str:

def live_session(self) -> "LogSession | None":
"""The one session that may own the WS subscription: live-capable
host, window anchored to now, panel actually visible. At most one
exists (GraphPreviewScreen is fetch-only), so no stack/priority is
needed to pick among sessions."""
for session in self._sessions.values():
if session.supports_live and session.end is None and session.is_visible():
host, window anchored to now, panel actually visible. Two can
qualify at once (the main screen's panel stays `-visible` behind a
pushed DashboardScreen) — in that case prefer whichever host is the
screen currently on top, since that's the panel the user can
actually see."""
candidates = [s for s in self._sessions.values() if s.supports_live and s.end is None and s.is_visible()]
if len(candidates) <= 1:
return candidates[0] if candidates else None
screen = self._app.screen
base = self._app.screen_stack[0]
for session in candidates:
if session.host is screen or (session.host is self._app and screen is base):
return session
return None
return candidates[0]

async def resync_subscription(self) -> None:
"""Realign the live logbook/event_stream subscription with whichever
Expand Down
4 changes: 0 additions & 4 deletions src/hatty/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,6 @@
from hatty.controllers.lists import ListController
from hatty.controllers.logbook import LogbookController
from hatty.controllers.notifications import NotificationController
from hatty.logbook import LogEntry
from hatty.service_calls import _CONTROL_SERVICE_BUILDERS
from hatty.types import Entity
from hatty.ui.activity_log_panel import ActivityLogPanel
Expand Down Expand Up @@ -202,9 +201,6 @@ def log_window(self, session) -> tuple[float, "datetime | None"]:
def log_title_suffix(self, session) -> str:
return self.log_ctl.range_suffix(session)

def on_log_entries(self, entries: list[LogEntry]) -> None:
pass

# ── Domain state lives on the controllers; these proxies preserve the app's
# historical surface — screens and tests read *and assign* these directly.
# Each is a real property, so assignment still routes to the controller. ──
Expand Down
Loading
Loading