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
4 changes: 3 additions & 1 deletion src/hatty/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,9 @@ async def fetch_logbook(
url = f"{self.base_url}/api/logbook/{start}"
params: dict = {"end_time": end.isoformat()}
if entity_ids:
params["entity_id"] = ",".join(entity_ids)
# HA's logbook endpoint reads the filter from `entity` (comma-separated) —
# `entity_id` is silently ignored and returns the whole instance (issue #13).
params["entity"] = ",".join(entity_ids)
data = await self._get_json(url, params, "fetch_logbook")
if data is None:
return None
Expand Down
46 changes: 43 additions & 3 deletions src/hatty/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -838,6 +838,26 @@ def _set_log_title(self) -> None:
log_panel = self.query_one("#activity_log_panel", ActivityLogPanel)
log_panel.set_title(self._log_title_base + self._log_range_suffix())

def _graph_entity_ids(self) -> list[str]:
"""The graphed entity plus its `+` comparison lines, primary first."""
entity_id = self._detail_entity_id
if not entity_id:
return []
return [entity_id] + [e for e in self._graph_extra_ids if e != entity_id]

def _open_graph_log_ids(self) -> list[str]:
"""The graphed entities, but only while the inline graph panel is open."""
if not self.query_one("#detail_panel", EntityDetailPanel).has_class("-visible"):
return []
return self._graph_entity_ids()

def _graph_log_title(self, graph_ids: list[str], prefix: str) -> str:
entity = self.find_entity(graph_ids[0])
label = get_display_name(entity) if entity else graph_ids[0]
if len(graph_ids) > 1:
label += f" +{len(graph_ids) - 1} more"
return f"{prefix} — {label}"

def _open_log_panel(self, entity_ids: list[str], title: str) -> None:
log_panel = self.query_one("#activity_log_panel", ActivityLogPanel)
self._log_entity_ids = set(entity_ids)
Expand Down Expand Up @@ -871,7 +891,7 @@ def action_log_newer(self) -> None:
self._reload_log()

# A device log covering a whole list can expand to many sibling entities; cap
# the set so a single logbook GET's entity_id= param can't blow up.
# the set so a single logbook GET's entity= param can't blow up.
_DEVICE_LOG_MAX_ENTITIES = 200

def _get_device_entity_ids(self, entity_id: str) -> tuple[list[str], str, bool]:
Expand Down Expand Up @@ -936,9 +956,15 @@ def action_toggle_activity_log(self) -> None:
self._close_log_panel()
return

graph_ids = self._open_graph_log_ids()
if self.query_one("#detail_panel", EntityDetailPanel).has_class("-visible"):
self.graph_ctl.close_panel()

if graph_ids:
self._log_mode = "entity"
self._open_log_panel(graph_ids, self._graph_log_title(graph_ids, "Activity Log"))
return

if self.current_list_name:
entity_ids = list(self.entity_lists.get(self.current_list_name, []))
title = f"Activity Log — {self.current_list_name}"
Expand Down Expand Up @@ -966,11 +992,24 @@ def action_toggle_device_log(self) -> None:
self._close_log_panel()
return

graph_ids = self._open_graph_log_ids()
if self.query_one("#detail_panel", EntityDetailPanel).has_class("-visible"):
self.graph_ctl.close_panel()

self._log_mode = "device"

# A graphed entity takes priority over the active list: the device log
# covers the graphed entity's device, not the whole list's devices.
if graph_ids:
entity_ids, label, device_found = self._get_device_entity_ids(graph_ids[0])
if not device_found:
self.notify(
f"No device found for {graph_ids[0]}. Showing single entity log.",
title="Device Log",
)
self._open_log_panel(entity_ids, f"Device Log — {label}")
return

# With a list active, the device log covers every device backing every
# entity in the list (all sibling channels), not just the selected row.
if self.current_list_name:
Expand Down Expand Up @@ -1012,10 +1051,11 @@ def action_toggle_entity_log(self) -> None:
self._close_log_panel()
return

graph_ids = self._open_graph_log_ids()
if self.query_one("#detail_panel", EntityDetailPanel).has_class("-visible"):
self.graph_ctl.close_panel()

entity_id = self._selected_entity_id()
entity_id = graph_ids[0] if graph_ids else self._selected_entity_id()
if not entity_id:
self.notify("No entity selected.", title="Activity Log", severity="warning")
return
Expand Down Expand Up @@ -1118,7 +1158,7 @@ def action_graph_fullscreen(self) -> None:
if entity and not self.graph_ctl.is_graphable(entity):
self.notify("No graph available for this entity type.", severity="warning")
return
entity_ids = [entity_id] + [e for e in self._graph_extra_ids if e != entity_id]
entity_ids = self._graph_entity_ids() if self._detail_entity_id else [entity_id]
self.push_screen(
GraphPreviewScreen(
entity_ids,
Expand Down
5 changes: 3 additions & 2 deletions src/hatty/ui/activity_log_panel.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
# hatty — MIT License. See LICENSE file for details.
"""The activity log side panel: a docked, togglable log of Home Assistant
logbook entries, hosted both on the main entity table (`a`/`A`/`i` — list,
device, single-entity scope) and on the fullscreen graph screen (`a`, its
events additionally marked on the plot).
device, single-entity scope; scoped to the graphed entity/entities instead
when the inline graph panel is open) and on the fullscreen graph screen
(`a`, its events additionally marked on the plot).

The panel itself is dumb — a title, a scrolling `Log`, and a bottom hint line
(`set_hint`) the host screen fills in with its own keys, since the two hosts
Expand Down
82 changes: 82 additions & 0 deletions tests/test_activity_log.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,88 @@ async def test_opening_graph_closes_activity_log(make_app, sample_entities):
assert app.query_one("#detail_panel", EntityDetailPanel).has_class("-visible")


_GRAPHED_LIST_CONFIG = {
"home_assistant": {"url": "http://fake.ha.local:8123", "token": "fake_token_abc"},
"default_list": "my_list",
"lists": {"my_list": ["sensor.temperature"]},
}

_TWO_SENSOR_ENTITIES = [
{
"entity_id": "sensor.temperature",
"state": "21.5",
"attributes": {"friendly_name": "Temperature Sensor", "unit_of_measurement": "°C"},
"last_changed": "2024-01-15T10:30:00.000000+00:00",
},
{
"entity_id": "sensor.humidity",
"state": "40",
"attributes": {"friendly_name": "Humidity Sensor", "unit_of_measurement": "%"},
"last_changed": "2024-01-15T10:30:00.000000+00:00",
},
]


async def test_a_scopes_to_graphed_entity_over_list_scope(make_app, sample_entities):
"""`a` with the inline graph open logs the graphed entity, not the active
list — opening the log from a graph used to silently switch scope (issue #14)."""
app = make_app(entities=sample_entities, config_data=_GRAPHED_LIST_CONFIG)
async with app.run_test() as pilot:
await pilot.pause()
table = app.query_one(EntitiesTable)
table.jump_cursor_to_row_key("sensor.temperature")
await pilot.pause()
await pilot.press("g")
await pilot.pause()
assert app.query_one("#detail_panel", EntityDetailPanel).has_class("-visible")

await pilot.press("a")
await pilot.pause()
assert app._log_entity_ids == {"sensor.temperature"}
title = str(app.query_one("#activity_log_panel", ActivityLogPanel).query_one("#log_title", Label).content)
assert "Temperature Sensor" in title
assert "my_list" not in title


async def test_a_includes_comparison_entities_when_graphed(make_app):
"""A `+` comparison line stays in scope too — the log should cover
everything currently plotted, titled with a "+N more" suffix."""
app = make_app(entities=_TWO_SENSOR_ENTITIES, config_data=NO_LIST_CONFIG)
async with app.run_test() as pilot:
await pilot.pause()
table = app.query_one(EntitiesTable)
table.jump_cursor_to_row_key("sensor.temperature")
await pilot.pause()
await pilot.press("g")
await pilot.pause()
app._graph_extra_ids = ["sensor.humidity"]

await pilot.press("a")
await pilot.pause()
assert app._log_entity_ids == {"sensor.temperature", "sensor.humidity"}
assert app.client.logbook_calls[-1][0] == ["sensor.temperature", "sensor.humidity"]
title = str(app.query_one("#activity_log_panel", ActivityLogPanel).query_one("#log_title", Label).content)
assert "Temperature Sensor" in title
assert "+1 more" in title


async def test_i_scopes_to_graphed_entity_when_graph_panel_open(make_app, sample_entities):
app = make_app(entities=sample_entities, config_data=_GRAPHED_LIST_CONFIG)
async with app.run_test() as pilot:
await pilot.pause()
table = app.query_one(EntitiesTable)
table.jump_cursor_to_row_key("sensor.temperature")
await pilot.pause()
await pilot.press("g")
await pilot.pause()

await pilot.press("i")
await pilot.pause()
assert app._log_entity_ids == {"sensor.temperature"}
title = str(app.query_one("#activity_log_panel", ActivityLogPanel).query_one("#log_title", Label).content)
assert "my_list" not in title


async def test_i_opens_single_entity_activity_log_and_i_again_closes_it(make_app, sample_entities):
app = make_app(entities=sample_entities, config_data=NO_LIST_CONFIG)
async with app.run_test() as pilot:
Expand Down
27 changes: 27 additions & 0 deletions tests/test_device_log.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,3 +142,30 @@ async def test_A_opens_device_log_for_entity_with_different_device(make_app, sam
await pilot.press("A")
await pilot.pause()
assert app._log_entity_ids == {"sensor.temperature"}


async def test_A_scopes_to_graphed_entity_over_list_device_expansion(make_app, sample_entities, sample_registry):
"""A graphed entity's device takes priority over expanding the whole
active list's devices (issue #14) — sensor.temperature (dev_xyz, solo)
graphed while `my_list` (light.living_room_lamp + sensor.temperature,
spanning dev_abc and dev_xyz) is active should log only dev_xyz."""
config = {
"home_assistant": {"url": "http://fake.ha.local:8123", "token": "fake_token_abc"},
"default_list": "my_list",
"lists": {"my_list": ["sensor.temperature", "light.living_room_lamp"]},
}
app = make_app(entities=sample_entities, config_data=config, registry=sample_registry)
async with app.run_test() as pilot:
await pilot.pause()
table = app.query_one("EntitiesTable")
table.jump_cursor_to_row_key("sensor.temperature")
await pilot.pause()
await pilot.press("g")
await pilot.pause()

await pilot.press("A")
await pilot.pause()
assert app._log_entity_ids == {"sensor.temperature"}
title = str(app.query_one("#activity_log_panel", ActivityLogPanel).query_one("#log_title", Label).content)
assert "Temperature Sensor" in title
assert "devices)" not in title
31 changes: 31 additions & 0 deletions tests/unit/test_client_logbook.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,34 @@ async def fake_get_json(url, params, label):
assert captured["params"]["end_time"] == end.isoformat()
start = end - timedelta(hours=4)
assert start.isoformat() in captured["url"]


async def test_fetch_logbook_filters_on_entity_param(monkeypatch):
"""HA's logbook REST endpoint reads the filter from `entity`, not `entity_id`
(issue #13) — `entity_id` is silently ignored and returns the whole instance."""
client = _make_client()
captured = {}

async def fake_get_json(url, params, label):
captured["params"] = params
return []

monkeypatch.setattr(client, "_get_json", fake_get_json)
await client.fetch_logbook(["light.x", "switch.y"], hours=1)

assert captured["params"]["entity"] == "light.x,switch.y"
assert "entity_id" not in captured["params"]


async def test_fetch_logbook_omits_filter_when_no_entities(monkeypatch):
client = _make_client()
captured = {}

async def fake_get_json(url, params, label):
captured["params"] = params
return []

monkeypatch.setattr(client, "_get_json", fake_get_json)
await client.fetch_logbook([], hours=1)

assert "entity" not in captured["params"]
Loading