diff --git a/CLAUDE.md b/CLAUDE.md index a3eb8e5..60b2c0e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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(...)`). diff --git a/docs/RELEASE-NOTES-v0.1.0.md b/docs/RELEASE-NOTES-v0.1.0.md index 8b452ff..beff0df 100644 --- a/docs/RELEASE-NOTES-v0.1.0.md +++ b/docs/RELEASE-NOTES-v0.1.0.md @@ -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 diff --git a/src/hatty/controllers/dashboards.py b/src/hatty/controllers/dashboards.py index 16dfd8d..49022c6 100644 --- a/src/hatty/controllers/dashboards.py +++ b/src/hatty/controllers/dashboards.py @@ -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, diff --git a/src/hatty/controllers/logbook.py b/src/hatty/controllers/logbook.py index 7e3aa6a..0073563 100644 --- a/src/hatty/controllers/logbook.py +++ b/src/hatty/controllers/logbook.py @@ -1,8 +1,9 @@ # 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 @@ -10,14 +11,18 @@ 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 @@ -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 @@ -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: @@ -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": @@ -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 @@ -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 diff --git a/src/hatty/main.py b/src/hatty/main.py index aa4836f..46fc733 100644 --- a/src/hatty/main.py +++ b/src/hatty/main.py @@ -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 @@ -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. ── diff --git a/src/hatty/ui/dashboard/screen.py b/src/hatty/ui/dashboard/screen.py index ea12275..a85ea93 100644 --- a/src/hatty/ui/dashboard/screen.py +++ b/src/hatty/ui/dashboard/screen.py @@ -27,6 +27,17 @@ `DashboardController.move_slot_across`. Splits can't nest and can't land inside a child grid. `u` in edit mode unsplits when at most one child is occupied. + +**Activity log** (Use mode only — `a` in Edit mode assigns a slot instead): +`a` opens the docked activity log, scoped to the whole dashboard by default; +`v` previews/picks a narrower scope (the dashboard, its devices, the cursor's +slot entity, or that entity's device); `f` maximizes it into a selectable +entry list. `[`/`]` page older/newer while docked; once maximized the grid is +hidden and `←`/`→` take over paging (mirroring the main screen and the +fullscreen graph). This is a third `LogbookController` host (`app.log_ctl`, +`controllers/logbook.py`) and, like the main screen, live — its live WS +subscription and the main screen's are handed off between whichever screen +is on top (`LogbookController.live_session`). """ import json @@ -44,6 +55,7 @@ from textual_fspicker import FileOpen, FileSave, Filters from hatty.const import CONFIG_KEY_GRAPH_TYPE +from hatty.ui.activity_log_panel import ActivityLogPanel from hatty.ui.confirm_popup import ConfirmPopup from hatty.ui.dashboard.cursor import GridCursor from hatty.ui.dashboard.layout import exceeds_bounds, slot_covering, slot_span @@ -60,6 +72,9 @@ from hatty.ui.list_selection_popup import ListSelectionPopup if TYPE_CHECKING: + from datetime import datetime + + from hatty.controllers.logbook import LogSession from hatty.main import HACLI from hatty.types import Entity @@ -186,6 +201,13 @@ class DashboardScreen(Screen): {"grab_move", "edit_slot", "clear_slot", "resize_slot", "split_slot", "unsplit_slot", "fill_split"} ) + # LogHost identity (LogbookController) — see controllers/logbook.py; the + # log_window/log_title_suffix hooks live below with the rest of the log actions. + LOG_PANEL_ID: str = "dashboard_log_panel" + LOG_SUPPORTS_LIVE: bool = True + _LOG_HINT = "v scope · f maximize · [ / ] older/newer · a close" + _LOG_HINT_MAXIMIZED = "↑/↓ select · f exit · ←/→ older/newer · a close" + IDLE_TIMEOUT: float = 5.0 # Minimum rows a single grid cell gets: when the dashboard has too many rows @@ -214,6 +236,13 @@ class DashboardScreen(Screen): Binding("s", "split_slot", "Split"), Binding("u", "unsplit_slot", "Unsplit", show=False), Binding("f", "fill_split", "Fill"), + # Activity log (Use mode only; `a`/`f` double up with Edit mode above, + # gated apart by check_action like enter's toggle_slot/grab_move split) + Binding("a", "toggle_activity_log", "Activity Log", show=False), + Binding("v", "show_log_scope", "Log Scope", show=False), + Binding("f", "maximize_log", "Maximize Log", show=False), + Binding("left_square_bracket", "log_older", "Older Events", show=False), + Binding("right_square_bracket", "log_newer", "Newer Events", show=False), # Both modes Binding("l", "show_list_popup", "Back to List", show=False), Binding("d", "manage_dashboards", "Dashboards"), @@ -246,6 +275,12 @@ class DashboardScreen(Screen): } ), ), + ( + "Activity log", + frozenset( + {"toggle_activity_log", "show_log_scope", "maximize_log", "log_older", "log_newer"} + ), + ), ) DEFAULT_CSS = """ @@ -296,6 +331,7 @@ def __init__(self): self._widget_active = False self._idle_mode = False self._idle_timer: Timer | None = None + self._log_cursor_timer: Timer | None = None @property def _cursor_path(self) -> list[tuple[int, int]]: @@ -335,6 +371,7 @@ def compose(self) -> ComposeResult: with VerticalScroll(id="dashboard_scroll") as scroll: scroll.can_focus = False yield Grid(id="dashboard_grid") + yield ActivityLogPanel(id="dashboard_log_panel") yield Footer() def on_mount(self) -> None: @@ -349,9 +386,33 @@ def on_unmount(self) -> None: if self._idle_timer is not None: self._idle_timer.stop() self._idle_timer = None + if self._log_cursor_timer is not None: + self._log_cursor_timer.stop() + self._log_cursor_timer = None + # A session is only ever removed by close() — leaving this out would let a + # dismissed screen's live-capable session linger and (via id() reuse) risk + # aliasing a later host. + self.app.log_ctl.close(self) + + def on_screen_resume(self, event) -> None: + # Re-point the singleton WS logbook subscription at this screen's log + # (if any) now that it's the one on top — e.g. popping back here from a + # pushed GraphPreviewScreen. + if self.app.log_ctl.is_open(self): + self.app.spawn(self.app.log_ctl.resync_subscription()) + + def on_screen_suspend(self, event) -> None: + # Mirror of on_screen_resume: hand the subscription to whatever live + # session remains now that this screen is no longer on top. + if self.app.log_ctl.is_open(self): + self.app.spawn(self.app.log_ctl.resync_subscription()) def watch_edit_mode(self, edit_mode: bool) -> None: self.set_class(edit_mode, "-edit") + if edit_mode and self.app.log_ctl.is_open(self): + # The log is a Use-mode affordance; closing it on entering Edit mode + # also keeps the double-bound a/f keys unambiguous. + self._close_log() self.refresh_bindings() # _update_mode_banner queries a widget, so guard against the initial pre-mount call. if self.is_mounted: @@ -368,6 +429,12 @@ def on_key(self, event) -> None: event.stop() def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None: + if action == "toggle_activity_log": + return not self.edit_mode + if action in ("show_log_scope", "maximize_log", "log_older"): + return not self.edit_mode and self.app.log_ctl.is_open(self) + if action == "log_newer": + return not self.edit_mode and self.app.log_ctl.is_open(self) and self.app.log_ctl.paged_back(self) if action in self.USE_ONLY_ACTIONS: return not self.edit_mode if action in self.EDIT_ONLY_ACTIONS: @@ -546,6 +613,7 @@ def _move_selection(self, d_row: int, d_col: int) -> None: for widget in self._top_slot_widgets(): if widget.covers(*self._top_cell()): widget.scroll_visible(animate=False) # keep the cursor on-screen when scrolled + self._schedule_log_follow() def _slot_at_cursor(self) -> dict | None: return self._cursor.slot_at(self._current_dashboard()) @@ -563,10 +631,12 @@ def _content_widget_at_cursor(self) -> Widget | None: def _descend_into_split(self) -> None: self._cursor.descend() self._apply_cursor_highlight() + self._schedule_log_follow() def _ascend_from_split(self) -> None: self._cursor.ascend() self._apply_cursor_highlight() + self._schedule_log_follow() def _split_anchor(self) -> tuple[int, int] | None: """Anchor of the split slot covering the cursor's top cell, if any.""" @@ -587,6 +657,13 @@ def _sync_split_selection(self, split: SplitSlotWidget) -> None: split._selected = None def action_move_cursor(self, d_row: int, d_col: int) -> None: + # A maximized log hides the grid and gives the entry list focus (which + # itself handles up/down); left/right fall through here to page the + # log instead of moving a cursor over a grid the user can't see. + if self._log_maximized(): + if d_col: + self.app.log_ctl.page(self, d_col) + return # Widget interaction (thermostat setpoint, panel cursor) only happens after # explicitly entering the widget with Enter/s — arrows always navigate the grid # otherwise. Edit mode always navigates. @@ -1029,12 +1106,95 @@ def _do_import(path: Path | None) -> None: def action_show_device_tree(self) -> None: self.app.action_show_device_tree() + # ── Activity log (a third LogbookController host — controllers/logbook.py) ── + + def log_window(self, session: "LogSession") -> "tuple[float, datetime | None]": + return self.app.log_hours, session.end + + def log_title_suffix(self, session: "LogSession") -> str: + return self.app.log_ctl.range_suffix(session) + + def _log_maximized(self) -> bool: + return self.query_one("#dashboard_log_panel", ActivityLogPanel).has_class("-maximized") + + def action_toggle_activity_log(self) -> None: + if self.app.log_ctl.is_open(self): + self._close_log() + return + + name = self.app.current_dashboard_name + entity_ids = self.app.dash_ctl.dashboard_entity_ids(name) if name else [] + if not entity_ids: + self.app.notify("No entities on this dashboard to log.", severity="warning") + return + + log_ctl = self.app.log_ctl + options = [ + log_ctl.base_option("dashboard", name, entity_ids, with_devices=False), + log_ctl.base_option("dashboard_devices", name, entity_ids, with_devices=True), + log_ctl.cursor_option("cursor", self._entity_at_cursor, with_device=False), + log_ctl.cursor_option("cursor_device", self._entity_at_cursor, with_device=True), + ] + log_ctl.open(self, options=options, option_id="dashboard", hint=self._LOG_HINT) + + def _close_log(self) -> None: + panel = self.query_one("#dashboard_log_panel", ActivityLogPanel) + if panel.has_class("-maximized"): + panel.set_maximized(False) + # The grid isn't focusable the way the main table is — explicitly blur. + self.set_focus(None) + self.app.log_ctl.close(self) + + def action_show_log_scope(self) -> None: + """`v` — preview and pick the open log's scope. A no-op while the log + is closed (gated by check_action).""" + from hatty.ui.log_scope_popup import LogScopePopup + + session = self.app.log_ctl.session_for(self) + if session is None: + return + entity_names, device_names = self.app.log_ctl.display_names() + resolved = self.app.log_ctl.resolved_options(self) + + def callback(result: str | None) -> None: + self.app.log_ctl.handle_scope_popup_result(self, result) + + self.app.push_screen(LogScopePopup(resolved, session.option_id, entity_names, device_names), callback) + + def action_maximize_log(self) -> None: + panel = self.query_one("#dashboard_log_panel", ActivityLogPanel) + maximizing = not panel.has_class("-maximized") + panel.set_hint(self._LOG_HINT_MAXIMIZED if maximizing else self._LOG_HINT) + panel.set_maximized(maximizing) + if not maximizing: + self.set_focus(None) + + def action_log_older(self) -> None: + self.app.log_ctl.page(self, -1) + + def action_log_newer(self) -> None: + self.app.log_ctl.page(self, 1) + + _LOG_CURSOR_DEBOUNCE = 0.3 # coalesces held arrow-key repeats before a cursor-scoped log refetches + + def _schedule_log_follow(self) -> None: + if not self.app.log_ctl.is_open(self): + return + if self._log_cursor_timer is not None: + self._log_cursor_timer.stop() + self._log_cursor_timer = self.set_timer( + self._LOG_CURSOR_DEBOUNCE, lambda: self.app.log_ctl.follow_cursor(self) + ) + def action_go_back(self) -> None: - # Esc backs out one level: exit active widget, drop a grabbed widget, - # ascend out of a split, leave Edit mode, then dismiss the screen. - # While a widget is grabbed, esc ascends out of a split first (carrying - # the grab along — issue #220) and only releases the grab once back at - # the top level. + # Esc backs out one level: un-maximize the log, exit active widget, drop + # a grabbed widget, ascend out of a split, leave Edit mode, close an open + # log, then dismiss the screen. While a widget is grabbed, esc ascends + # out of a split first (carrying the grab along — issue #220) and only + # releases the grab once back at the top level. + if self._log_maximized(): + self.action_maximize_log() + return if self._grabbed is not None: if len(self._cursor_path) > 1: self._ascend_from_split() @@ -1050,6 +1210,9 @@ def action_go_back(self) -> None: if self.edit_mode: self.edit_mode = False return + if self.app.log_ctl.is_open(self): + self._close_log() + return def _do_leave(confirmed: bool | None) -> None: if confirmed: diff --git a/src/hatty/ui/graph/plot_render.py b/src/hatty/ui/graph/plot_render.py index 40ba5d1..39f798d 100644 --- a/src/hatty/ui/graph/plot_render.py +++ b/src/hatty/ui/graph/plot_render.py @@ -141,26 +141,6 @@ def render_binary(plt, primary: BinarySeries, extras, *, extend_to: str | None) return t0 -def render_event_marks(plt, t0: datetime, event_ts: list[str], *, color: str = "magenta", limit: int = 200) -> None: - """Vertical markers for logbook events at their x-position relative to - `t0` — the fullscreen graph's "events on the graph" overlay (issue #2). - Timestamps before `t0` (outside the plotted window) are skipped; a busy - window is capped at `limit` marks so it doesn't blanket the plot.""" - to_secs = secs_since(t0) - drawn = 0 - for ts in event_ts: - if drawn >= limit: - break - try: - secs = to_secs(ts) - except (ValueError, TypeError): - continue - if secs < 0: - continue - plt.vline(secs, color=color) - drawn += 1 - - def numeric_stats_line(values: list[float], unit: str) -> str: mn, mx = min(values), max(values) avg = sum(values) / len(values) diff --git a/src/hatty/ui/graph/preview_screen.py b/src/hatty/ui/graph/preview_screen.py index 763f303..d7a6e72 100644 --- a/src/hatty/ui/graph/preview_screen.py +++ b/src/hatty/ui/graph/preview_screen.py @@ -37,10 +37,7 @@ into a selectable list with an inline untruncated detail region (issue #22, upgraded by #38 — no separate browse popup anymore); `a` always closes outright even while maximized, while `escape`/`q` restore the normal width -first and only close on a further press. While open, each logged event is -additionally marked on the plot itself (`plot_render.render_event_marks`) — -numeric and binary graphs only; climate graphs still show the log list but -skip the marks. +first and only close on a further press. `ALLOWED_APP_ACTIONS` is this screen's carve-out from `HACLI.check_action`'s "pushed screen" lockdown — only the app-level keys that still do something on @@ -62,7 +59,6 @@ from textual.widgets import Footer, Label from textual_plotext import PlotextPlot -from hatty.logbook import LogEntry from hatty.ui.activity_log_panel import ActivityLogPanel from hatty.ui.entity_table import entity_title, entity_unit, get_display_name from hatty.ui.graph.binary_history import binary_stats, value_to_state @@ -77,7 +73,6 @@ plot_width, render_binary, render_climate, - render_event_marks, render_numeric, ) from hatty.ui.graph.plot_time import secs_since @@ -256,7 +251,6 @@ def __init__( self._active_entity_index = 0 self._cursor_mode = False self._cursor_index = 0 - self._events: list[LogEntry] = [] # Delegating properties over the pure GraphWindow, so existing reads/writes # of these attrs across the screen and tests keep working unchanged. @@ -324,10 +318,6 @@ def log_window(self, session) -> "tuple[float, datetime]": def log_title_suffix(self, session) -> str: return "" - def on_log_entries(self, entries: list[LogEntry]) -> None: - self._events = entries - self._redraw() - def compose(self) -> ComposeResult: yield Label("", id="preview_title") yield PlotextPlot(id="preview_plot") @@ -614,7 +604,6 @@ def _render_plot(self, plt, entity: "Entity | None") -> str | None: if self._cursor_mode: self._cursor_index = max(0, min(len(self._data) - 1, self._cursor_index)) plt.vline(secs_since(t0)(self._data[self._cursor_index][0]), color="white") - self._draw_event_marks(plt, t0) return binary_end_iso extras = [] @@ -636,23 +625,8 @@ def _render_plot(self, plt, entity: "Entity | None") -> str | None: self._plot_width(), cursor_index=cursor_index, ) - self._draw_event_marks(plt, t0) return None - def _draw_event_marks(self, plt, t0: datetime) -> None: - """Mark each logged event's timestamp on the plot, only while the - event log is open (issue #2's graph/log integration). Magenta for a - plotted line's own state changes, cyan for device events (issue #18, - e.g. a zha_event button press).""" - if not self._events: - return - log_panel = self.query_one("#preview_log_panel", ActivityLogPanel) - if not log_panel.has_class("-visible"): - return - state_events = [e for e in self._events if e["kind"] == "state"] - render_event_marks(plt, t0, [e["when"] for e in state_events]) - render_event_marks(plt, t0, [e["when"] for e in self._events if e["kind"] == "event"], color="cyan") - def _stats_text(self, entity: "Entity | None", unit: str, binary_end_iso: str | None) -> str: if self._cursor_mode: cursor_ts, cursor_val = self._data[self._cursor_index] diff --git a/tests/test_dashboard_activity_log.py b/tests/test_dashboard_activity_log.py new file mode 100644 index 0000000..19943b3 --- /dev/null +++ b/tests/test_dashboard_activity_log.py @@ -0,0 +1,259 @@ +# hatty — MIT License. See LICENSE file for details. +"""DashboardScreen as a third LogbookController host (app.log_ctl), alongside +HACLI's docked panel and GraphPreviewScreen's — issue #38's "matter of +wiring" third host. Unlike GraphPreviewScreen, the dashboard log is live, so +the singleton WS subscription can be shared between the main screen's panel +(left `-visible` behind a pushed dashboard) and the dashboard's own.""" + +from textual.widgets import Label, Log, OptionList + +from hatty.ui.activity_log_panel import ActivityLogPanel +from hatty.ui.confirm_popup import ConfirmPopup +from hatty.ui.dashboard.slot_popup import DashboardSlotPopup +from hatty.ui.log_scope_popup import LogScopePopup +from tests.conftest import make_config + +_DASHBOARD_CONFIG = { + **make_config(), + "lists": {}, + "dashboards": { + "Main": { + "rows": 1, + "cols": 2, + "slots": [ + {"row": 0, "col": 0, "widget_type": "switch", "entity_id": "switch.fan"}, + { + "row": 0, + "col": 1, + "widget_type": "panel", + "entity_id": None, + "entity_ids": ["light.living_room_lamp", "light.kitchen_light"], + }, + ], + } + }, +} + + +def _panel(app) -> ActivityLogPanel: + return app.screen.query_one("#dashboard_log_panel", ActivityLogPanel) + + +async def test_a_with_no_entities_on_the_dashboard_notifies_and_stays_hidden(make_app, open_dashboard): + config = {**make_config(), "lists": {}, "dashboards": {"Main": {"rows": 1, "cols": 1, "slots": []}}} + app = make_app(config_data=config) + async with app.run_test() as pilot: + await open_dashboard(pilot) + await pilot.press("a") + await pilot.pause() + assert not _panel(app).has_class("-visible") + + +async def test_a_opens_dashboard_log_scoped_to_the_whole_dashboard_and_a_again_closes_it(make_app, open_dashboard): + app = make_app(config_data=_DASHBOARD_CONFIG) + async with app.run_test() as pilot: + await open_dashboard(pilot) + await pilot.press("a") + await pilot.pause() + + panel = _panel(app) + assert panel.has_class("-visible") + title = str(panel.query_one("#log_title", Label).content) + assert "Main" in title + # dashboard_entity_ids: the switch's own entity_id, plus the panel's entity_ids. + assert app.client.logbook_calls[-1][0] == [ + "switch.fan", + "light.living_room_lamp", + "light.kitchen_light", + ] + + await pilot.press("a") + await pilot.pause() + assert not panel.has_class("-visible") + assert app.log_ctl.session_for(app.screen) is None + + +async def test_a_in_edit_mode_opens_the_slot_popup_not_the_log(make_app, open_dashboard): + app = make_app(config_data=_DASHBOARD_CONFIG) + async with app.run_test() as pilot: + await open_dashboard(pilot) + await pilot.press("E") # edit mode + await pilot.press("a") + await pilot.pause() + assert isinstance(app.screen, DashboardSlotPopup) + + +async def test_entering_edit_mode_closes_an_open_log(make_app, open_dashboard): + app = make_app(config_data=_DASHBOARD_CONFIG) + async with app.run_test() as pilot: + await open_dashboard(pilot) + screen = app.screen + await pilot.press("a") + await pilot.pause() + assert app.log_ctl.is_open(screen) + + await pilot.press("E") + await pilot.pause() + assert not app.log_ctl.is_open(screen) + assert not _panel(app).has_class("-visible") + + +async def test_v_opens_the_log_scope_popup_with_four_options(make_app, open_dashboard): + app = make_app(config_data=_DASHBOARD_CONFIG) + async with app.run_test() as pilot: + await open_dashboard(pilot) + await pilot.press("a", "v") + await pilot.pause() + assert isinstance(app.screen, LogScopePopup) + options = app.screen.query_one("#log_scope_options", OptionList) + assert options.option_count == 4 + + +async def test_f_maximizes_then_arrows_page_instead_of_moving_the_grid_cursor(make_app, open_dashboard): + app = make_app(config_data=_DASHBOARD_CONFIG) + async with app.run_test() as pilot: + await open_dashboard(pilot) + screen = app.screen + cursor_before = (screen.cursor_row, screen.cursor_col) + + await pilot.press("a") + await pilot.pause() + session = app.log_ctl.session_for(screen) + assert session.end is None + + await pilot.press("f") + await pilot.pause() + panel = _panel(app) + assert panel.has_class("-maximized") + + await pilot.press("left") + await pilot.press("left") + await pilot.pause() + paged_back_end = app.log_ctl.session_for(screen).end + assert paged_back_end is not None + assert (screen.cursor_row, screen.cursor_col) == cursor_before # grid cursor untouched + + await pilot.press("right") + await pilot.pause() + assert app.log_ctl.session_for(screen).end > paged_back_end + assert (screen.cursor_row, screen.cursor_col) == cursor_before + + +async def test_bracket_keys_page_while_docked_and_leave_the_cursor_alone(make_app, open_dashboard): + app = make_app(config_data=_DASHBOARD_CONFIG) + async with app.run_test() as pilot: + await open_dashboard(pilot) + screen = app.screen + await pilot.press("right") # move the grid cursor onto the panel slot + await pilot.pause() + cursor_before = (screen.cursor_row, screen.cursor_col) + assert cursor_before == (0, 1) + + await pilot.press("a") + await pilot.pause() + assert app.log_ctl.session_for(screen).end is None + + await pilot.press("[") + await pilot.press("[") + await pilot.pause() + paged_back_end = app.log_ctl.session_for(screen).end + assert paged_back_end is not None + assert (screen.cursor_row, screen.cursor_col) == cursor_before # not moved by paging + + await pilot.press("]") + await pilot.pause() + assert app.log_ctl.session_for(screen).end > paged_back_end + assert (screen.cursor_row, screen.cursor_col) == cursor_before + + +async def test_escape_ladder_unmaximizes_then_closes_the_log_then_confirms_leaving(make_app, open_dashboard): + app = make_app(config_data=_DASHBOARD_CONFIG) + async with app.run_test() as pilot: + await open_dashboard(pilot) + screen = app.screen + await pilot.press("a") + await pilot.press("f") + await pilot.pause() + panel = _panel(app) + assert panel.has_class("-maximized") + + await pilot.press("escape") + await pilot.pause() + # Still open, just restored to normal width — mirrors the main screen/graph. + assert app.screen is screen + assert panel.has_class("-visible") + assert not panel.has_class("-maximized") + + await pilot.press("escape") + await pilot.pause() + assert app.screen is screen + assert not app.log_ctl.is_open(screen) + + await pilot.press("escape") + await pilot.pause() + assert isinstance(app.screen, ConfirmPopup) + + +async def test_live_logbook_event_appends_while_dashboard_log_open(make_app, open_dashboard): + app = make_app(config_data=_DASHBOARD_CONFIG) + async with app.run_test() as pilot: + await open_dashboard(pilot) + await pilot.press("a") + await pilot.pause() + + log_widget = _panel(app).query_one("#log_widget", Log) + count_before = log_widget.line_count + + app.client.inject_logbook_event( + [{"when": "2024-01-15T10:32:00+00:00", "name": "Fan Switch", "state": "off"}] + ) + await pilot.pause() + assert log_widget.line_count > count_before + + +async def test_dashboard_log_takes_over_the_live_subscription_and_hands_it_back(make_app, open_dashboard): + """Pushing the dashboard doesn't close the main screen's log — both stay + `-visible` — so the singleton WS subscription must follow whichever is + on top, and come back when the dashboard's log closes.""" + app = make_app(config_data=_DASHBOARD_CONFIG) + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press("a") # main screen's activity log + await pilot.pause() + main_session = app.log_ctl.session_for(app) + assert app.client.subscribe_logbook_calls[-1][0] == main_session.query_ids + + await open_dashboard(pilot) + screen = app.screen + await pilot.press("a") # dashboard's own log + await pilot.pause() + dash_session = app.log_ctl.session_for(screen) + assert app.client.subscribe_logbook_calls[-1][0] == dash_session.query_ids + assert app.log_ctl.live_session() is dash_session + + await pilot.press("a") # close the dashboard's log + await pilot.pause() + assert app.client.subscribe_logbook_calls[-1][0] == main_session.query_ids + assert app.log_ctl.live_session() is main_session + + +async def test_leaving_the_dashboard_closes_its_log_and_releases_the_subscription(make_app, open_dashboard): + app = make_app(config_data=_DASHBOARD_CONFIG) + async with app.run_test() as pilot: + await open_dashboard(pilot) + screen = app.screen + await pilot.press("a") + await pilot.pause() + assert app.log_ctl.is_open(screen) + assert app.client.logbook_subscription_id is not None + + await pilot.press("escape") # closes the log (docked, not maximized) + await pilot.pause() + await pilot.press("escape") # "Leave dashboard?" confirm + await pilot.pause() + assert isinstance(app.screen, ConfirmPopup) + await pilot.press("y") + await pilot.pause() + + assert app.log_ctl.is_open(screen) is False + assert app.client.logbook_subscription_id is None diff --git a/tests/test_graph_event_log.py b/tests/test_graph_event_log.py index 61cb65a..f448d04 100644 --- a/tests/test_graph_event_log.py +++ b/tests/test_graph_event_log.py @@ -4,16 +4,13 @@ whatever's currently plotted, and closing/escaping tears it back down. `v`'s scope popup (issue #38, replacing the old blind cycle from #21) is -covered in test_log_scope_popup.py; this file keeps only the plot-mark -color assertions, which depend on the device-widened scope but aren't -really about the popup itself — entity-only vs. entity + the plotted -entities' devices' events (issue #18), with device events drawn in a -distinct color.""" +covered in test_log_scope_popup.py; this file covers opening/closing/paging +the panel and the device-widened scope (issue #18, e.g. a zha_event button +press) rendering in the log list.""" from textual.coordinate import Coordinate from textual.widgets import Label, Log -import hatty.ui.graph.preview_screen as preview_screen_module from hatty.ui.activity_log_panel import ActivityLogPanel from hatty.ui.entity_table import EntitiesTable from hatty.ui.graph.preview_screen import GraphPreviewScreen @@ -141,9 +138,7 @@ async def test_capital_a_does_nothing_on_the_graph_screen(make_app, sample_entit assert not log_panel.has_class("-visible") -async def test_device_scoped_event_renders_and_marks_the_plot_in_cyan( - make_app, sample_entities, sample_registry, monkeypatch -): +async def test_device_scoped_event_renders_in_the_log(make_app, sample_entities, sample_registry): app = make_app(entities=sample_entities, config_data=NO_LIST_CONFIG, registry=sample_registry) async with app.run_test() as pilot: await pilot.pause() @@ -158,63 +153,13 @@ async def test_device_scoped_event_renders_and_marks_the_plot_in_cyan( ] preview = await _open_preview_on_temperature(pilot, app) - mark_colors = [] - monkeypatch.setattr( - preview_screen_module, - "render_event_marks", - lambda plt, t0, ts, **kw: mark_colors.append(kw.get("color", "magenta")), - ) - await pilot.press("a", "v") await pilot.pause() await pilot.press("down", "enter") await pilot.pause() - assert any(e["kind"] == "event" for e in preview._events) log_panel = preview.query_one("#preview_log_panel", ActivityLogPanel) assert any("⚡" in line for line in log_panel.query_one("#log_widget", Log).lines) - assert "cyan" in mark_colors - assert "magenta" in mark_colors - - -async def test_neither_view_ever_marks_orange(make_app, sample_entities, sample_registry, monkeypatch): - """Orange marked a non-plotted sibling entity's state change in the old - "device_entities" view (issue #21); that view is gone, so no scope should - ever produce an orange mark — every state event returned now belongs to - a plotted entity.""" - app = make_app(entities=sample_entities, config_data=NO_LIST_CONFIG, registry=sample_registry) - async with app.run_test() as pilot: - await pilot.pause() - app.client._history_data = {"sensor.temperature": [("2024-01-01T12:00:00+00:00", 20.0)]} - app.client._logbook_data = [ - { - "when": "2024-01-01T11:00:00+00:00", - "name": "Temperature Sensor", - "state": "21", - "entity_id": "sensor.temperature", - }, - ] - await _open_preview_on_temperature(pilot, app) - - calls = [] - monkeypatch.setattr( - preview_screen_module, - "render_event_marks", - lambda plt, t0, ts, **kw: calls.append((kw.get("color", "magenta"), len(ts))), - ) - - await pilot.press("a") - await pilot.pause() - colors_with_marks = {color for color, count in calls if count > 0} - assert colors_with_marks == {"magenta"} - - calls.clear() - await pilot.press("v") # opens the scope popup - await pilot.pause() - await pilot.press("down", "enter") # picks the device-widened option - await pilot.pause() - colors_with_marks = {color for color, count in calls if count > 0} - assert "orange" not in colors_with_marks async def test_escape_closes_event_log_before_leaving_graph(make_app, sample_entities): diff --git a/tests/test_help.py b/tests/test_help.py index a05bb05..23dbae8 100644 --- a/tests/test_help.py +++ b/tests/test_help.py @@ -225,9 +225,10 @@ async def test_dashboard_help_page_sectioned_by_mode(make_app): await pilot.pause() rows = _rows_for(app, "Dashboard") headers = [desc for key, desc in rows if not key] - assert headers == ["Use mode", "Edit mode", "Both modes"] + assert headers == ["Use mode", "Edit mode", "Both modes", "Activity log"] descriptions = [desc for key, desc in rows if key] assert "Toggle" in descriptions # use mode assert "Assign" in descriptions # edit mode assert "Dashboards" in descriptions # both modes + assert "Activity Log" in descriptions # activity log diff --git a/tests/unit/test_dashboards_controller.py b/tests/unit/test_dashboards_controller.py index d5e1bf1..a377933 100644 --- a/tests/unit/test_dashboards_controller.py +++ b/tests/unit/test_dashboards_controller.py @@ -454,3 +454,47 @@ def test_import_defaults_slots_when_missing(): payload = {"hatty_dashboard": 1, "name": "A", "dashboard": {"rows": 2, "cols": 2}} final = ctl.import_from_payload(payload) assert ctl.dashboards[final]["slots"] == [] + + +# ── dashboard_entity_ids ───────────────────────────────────────────────────── + + +def test_dashboard_entity_ids_collects_single_and_panel_slots_deduped(): + ctl = _controller() + ctl.create("A", 1, 3) + ctl.dashboards["A"]["slots"] = [ + {"row": 0, "col": 0, "widget_type": "switch", "entity_id": "switch.fan"}, + {"row": 0, "col": 1, "widget_type": "panel", "entity_id": None, "entity_ids": ["light.a", "light.b"]}, + # A duplicate of switch.fan (e.g. also panel-listed elsewhere) shouldn't repeat. + {"row": 0, "col": 2, "widget_type": "switch", "entity_id": "switch.fan"}, + ] + assert ctl.dashboard_entity_ids("A") == ["switch.fan", "light.a", "light.b"] + + +def test_dashboard_entity_ids_recurses_into_split_children(): + ctl = _controller() + ctl.create("A", 1, 1) + ctl.dashboards["A"]["slots"] = [ + { + "row": 0, + "col": 0, + "widget_type": "split", + "entity_id": None, + "children": { + "rows": 1, + "cols": 2, + "slots": [ + {"row": 0, "col": 0, "widget_type": "switch", "entity_id": "switch.fan"}, + {"row": 0, "col": 1, "widget_type": "panel", "entity_id": None, "entity_ids": ["light.a"]}, + ], + }, + } + ] + assert ctl.dashboard_entity_ids("A") == ["switch.fan", "light.a"] + + +def test_dashboard_entity_ids_skips_empty_slots(): + ctl = _controller() + ctl.create("A", 1, 1) + ctl.dashboards["A"]["slots"] = [{"row": 0, "col": 0, "widget_type": "panel", "entity_id": None}] + assert ctl.dashboard_entity_ids("A") == [] diff --git a/tests/unit/test_logbook_controller.py b/tests/unit/test_logbook_controller.py index ba0e816..3b1fa06 100644 --- a/tests/unit/test_logbook_controller.py +++ b/tests/unit/test_logbook_controller.py @@ -85,7 +85,6 @@ class _StubHost: def __init__(self): self.panel_widget = _StubPanel() - self.entries_seen: list = [] def query_one(self, selector, widget_type=None): return self.panel_widget @@ -96,8 +95,29 @@ def log_window(self, session): def log_title_suffix(self, session): return "" - def on_log_entries(self, entries): - self.entries_seen.append(entries) + +class _TornDownHost(_StubHost): + """Mirrors a screen mid-teardown: its children (including the panel) are + already gone by the time on_unmount calls close() (issue: a screen's own + on_unmount must close its session so it can't linger, but Textual tears + children down before the screen's own on_unmount handler runs). Behaves + like a normal host — query_one() resolves — until `tear_down()` flips it + to raise, mirroring the panel disappearing partway through the host's + life.""" + + def __init__(self): + super().__init__() + self._torn_down = False + + def tear_down(self) -> None: + self._torn_down = True + + def query_one(self, selector, widget_type=None): + if self._torn_down: + from textual.css.query import NoMatches + + raise NoMatches(f"No nodes match {selector!r}") + return super().query_one(selector, widget_type) class _StubFetchOnlyHost(_StubHost): @@ -117,6 +137,12 @@ def __init__(self): self.notifications: list = [] self.bindings_refreshes = 0 self.spawned: list = [] + # Which screen is "on top" — live_session() consults this to pick among + # several live-capable, visible sessions. Defaults model a bare HACLI + # with no pushed screen; tests that need two live hosts (e.g. a pushed + # DashboardScreen) override these directly. + self.screen = None + self.screen_stack = [None] def find_entity(self, entity_id): return next((e for e in self.all_entities if e["entity_id"] == entity_id), None) @@ -145,6 +171,28 @@ def _controller() -> tuple[LogbookController, _StubApp]: return LogbookController(app), app +# ── close (torn-down host) ────────────────────────────────────────────────── + + +async def test_close_survives_a_torn_down_host(): + """A screen's own on_unmount closes its session so it can't linger (a + dead host's id() could otherwise be reused and alias a later one) — but + Textual tears a screen's children down before its on_unmount handler + runs, so close() must not blow up trying to reach the (gone) panel.""" + ctl, app = _controller() + host = _TornDownHost() + options = [ctl.base_option("a", "a", ["light.a"], with_devices=False)] + ctl.open(host, options=options, option_id="a", hint="") + await app.run_spawned() + + host.tear_down() + ctl.close(host) + await app.run_spawned() + + assert ctl.is_open(host) is False + assert app.client.unsubscribe_calls >= 1 + + # ── base_option ────────────────────────────────────────────────────────────── @@ -558,6 +606,53 @@ async def test_live_session_requires_live_capable_and_visible_and_not_paged(): assert ctl.live_session() is None # closed +async def test_live_session_prefers_the_active_screen_when_two_qualify(): + """Both the main screen and a pushed DashboardScreen can be live-capable + and `-visible` at once (the main panel isn't closed by pushing `d`) — the + singleton subscription should follow whichever is actually on top.""" + ctl, app = _controller() + main_host = _StubHost() + dashboard_host = _StubHost() + app.screen = main_host + app.screen_stack = [main_host] + options = [ctl.base_option("a", "a", ["light.a"], with_devices=False)] + + ctl.open(main_host, options=list(options), option_id="a", hint="") + await app.run_spawned() + assert ctl.live_session() is ctl.session_for(main_host) + + app.screen = dashboard_host + ctl.open(dashboard_host, options=list(options), option_id="a", hint="") + await app.run_spawned() + assert ctl.live_session() is ctl.session_for(dashboard_host) + + app.screen = main_host # dashboard dismissed, main screen back on top + assert ctl.live_session() is ctl.session_for(main_host) + + +async def test_close_resyncs_subscription_to_the_remaining_live_session(): + ctl, app = _controller() + main_host = _StubHost() + dashboard_host = _StubHost() + app.screen = main_host + app.screen_stack = [main_host] + main_options = [ctl.base_option("a", "a", ["light.a"], with_devices=False)] + dash_options = [ctl.base_option("b", "b", ["light.b"], with_devices=False)] + + ctl.open(main_host, options=main_options, option_id="a", hint="") + await app.run_spawned() + app.screen = dashboard_host + ctl.open(dashboard_host, options=dash_options, option_id="b", hint="") + await app.run_spawned() + assert app.client.subscribe_calls[-1] == (["light.b"], []) + + ctl.close(dashboard_host) + await app.run_spawned() + assert app.client.unsubscribe_calls >= 1 + assert app.client.subscribe_calls[-1] == (["light.a"], []) + assert ctl.live_session() is ctl.session_for(main_host) + + # ── resync_subscription / resubscribe_after_reconnect ─────────────────────── diff --git a/tests/unit/test_plot_render.py b/tests/unit/test_plot_render.py index d8d4be0..c47b381 100644 --- a/tests/unit/test_plot_render.py +++ b/tests/unit/test_plot_render.py @@ -17,7 +17,6 @@ plot_width, render_binary, render_climate, - render_event_marks, render_numeric, set_binary_axis, ) @@ -328,45 +327,3 @@ def test_render_binary_extends_axis_to_extend_to(): # apply_time_axis is fed total_secs; the largest xtick position reflects the span. tick_positions = xticks_call[1][0] assert max(tick_positions) >= 3 * 3600 - 1 - - -# ── render_event_marks (needs plt) ──────────────────────────────────────────── - - -def test_render_event_marks_one_vline_per_event(): - plt = _RecordingPlt() - t0 = datetime.fromisoformat("2026-07-07T10:00:00+00:00") - events = ["2026-07-07T10:30:00+00:00", "2026-07-07T11:00:00+00:00"] - render_event_marks(plt, t0, events) - vlines = [c for c in plt.calls if c[0] == "vline"] - assert len(vlines) == 2 - assert vlines[0][1][0] == 1800.0 - assert vlines[1][1][0] == 3600.0 - assert all(kwargs.get("color") == "magenta" for _, _, kwargs in vlines) - - -def test_render_event_marks_skips_events_before_t0(): - plt = _RecordingPlt() - t0 = datetime.fromisoformat("2026-07-07T10:00:00+00:00") - events = ["2026-07-07T09:00:00+00:00", "2026-07-07T10:30:00+00:00"] - render_event_marks(plt, t0, events) - vlines = [c for c in plt.calls if c[0] == "vline"] - assert len(vlines) == 1 - assert vlines[0][1][0] == 1800.0 - - -def test_render_event_marks_honours_limit(): - plt = _RecordingPlt() - t0 = datetime.fromisoformat("2026-07-07T10:00:00+00:00") - events = [f"2026-07-07T10:{m:02d}:00+00:00" for m in range(0, 10)] - render_event_marks(plt, t0, events, limit=3) - vlines = [c for c in plt.calls if c[0] == "vline"] - assert len(vlines) == 3 - - -def test_render_event_marks_ignores_unparseable_timestamps(): - plt = _RecordingPlt() - t0 = datetime.fromisoformat("2026-07-07T10:00:00+00:00") - render_event_marks(plt, t0, ["not-a-timestamp", "2026-07-07T10:30:00+00:00"]) - vlines = [c for c in plt.calls if c[0] == "vline"] - assert len(vlines) == 1