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 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
9 changes: 3 additions & 6 deletions src/hatty/controllers/logbook.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
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
20 changes: 0 additions & 20 deletions src/hatty/ui/graph/plot_render.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
28 changes: 1 addition & 27 deletions src/hatty/ui/graph/preview_screen.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -77,7 +73,6 @@
plot_width,
render_binary,
render_climate,
render_event_marks,
render_numeric,
)
from hatty.ui.graph.plot_time import secs_since
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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 = []
Expand All @@ -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]
Expand Down
63 changes: 4 additions & 59 deletions tests/test_graph_event_log.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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):
Expand Down
4 changes: 0 additions & 4 deletions tests/unit/test_logbook_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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."""
Expand Down
43 changes: 0 additions & 43 deletions tests/unit/test_plot_render.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
plot_width,
render_binary,
render_climate,
render_event_marks,
render_numeric,
set_binary_axis,
)
Expand Down Expand Up @@ -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
Loading