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/logbook.py b/src/hatty/controllers/logbook.py index 7e3aa6a..c5b215c 100644 --- a/src/hatty/controllers/logbook.py +++ b/src/hatty/controllers/logbook.py @@ -14,10 +14,9 @@ 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. +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 @@ -111,7 +110,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: @@ -317,7 +315,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 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/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_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/unit/test_logbook_controller.py b/tests/unit/test_logbook_controller.py index ba0e816..52e776e 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,9 +95,6 @@ def log_window(self, session): def log_title_suffix(self, session): return "" - def on_log_entries(self, entries): - self.entries_seen.append(entries) - class _StubFetchOnlyHost(_StubHost): """Mirrors GraphPreviewScreen: no live subscription.""" 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