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
5 changes: 5 additions & 0 deletions src/hatty/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,11 @@ def binary_state_label(state: str, device_class: str) -> str:
"weather": "weather",
}

# Widget types that can carry the per-slot "show_last_changed" option: every
# single-entity widget. "graph" already plots a time axis; "panel"/"split" hold
# many entities, so there is no single last_changed to show.
LAST_CHANGED_WIDGET_TYPES = frozenset(WIDGET_TYPES) - {"graph", "panel"}

# Entity table columns shown when the config carries no "columns" key.
DEFAULT_COLUMNS = ["name", "value", "last_changed", "in_list"]

Expand Down
24 changes: 15 additions & 9 deletions src/hatty/demo/demo_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,11 @@ def demo_entities() -> list[dict]:
"""The curated entity set, exercising every domain with UI surface: sensor,
light, climate, switch, binary_sensor, cover, lock, media_player, fan,
input_number."""
now = _iso(_now())
now = _now()

def e(entity_id: str, state: str, attributes: dict) -> dict:
return {"entity_id": entity_id, "state": state, "attributes": attributes, "last_changed": now}
def e(entity_id: str, state: str, attributes: dict, changed_min_ago: int = 0) -> dict:
changed = _iso(now - timedelta(minutes=changed_min_ago))
return {"entity_id": entity_id, "state": state, "attributes": attributes, "last_changed": changed}

return [
# ── Sensors (numeric) ──
Expand Down Expand Up @@ -82,11 +83,13 @@ def e(entity_id: str, state: str, attributes: dict) -> dict:
"hvac_action": "idle", "hvac_modes": ["heat", "cool", "off"],
"min_temp": 7, "max_temp": 35, "target_temp_step": 0.5, "unit_of_measurement": "°C"}),
# ── Binary sensors ──
e("binary_sensor.front_door", "off", {"friendly_name": "Front Door", "device_class": "door"}),
e("binary_sensor.front_door", "off", {"friendly_name": "Front Door", "device_class": "door"},
changed_min_ago=240),
e("binary_sensor.living_room_motion", "off",
{"friendly_name": "Living Room Motion", "device_class": "motion"}),
e("binary_sensor.smoke_detector", "off", {"friendly_name": "Smoke Detector", "device_class": "smoke"}),
e("binary_sensor.washing_machine", "on", {"friendly_name": "Washing Machine", "device_class": "running"}),
e("binary_sensor.washing_machine", "on", {"friendly_name": "Washing Machine", "device_class": "running"},
changed_min_ago=22),
# A Zigbee button has no meaningful state beyond its battery — but the
# device log (`i` then `v`) is reached from an entity row, so it needs
# one to be reachable at all. Its interest is its device events (issue #17):
Expand All @@ -97,7 +100,7 @@ def e(entity_id: str, state: str, attributes: dict) -> dict:
e("cover.living_room_blinds", "open", {"friendly_name": "Living Room Blinds", "current_position": 70}),
e("cover.garage_door", "closed", {"friendly_name": "Garage Door", "current_position": 0}),
# ── Lock ──
e("lock.front_door", "locked", {"friendly_name": "Front Door Lock"}),
e("lock.front_door", "locked", {"friendly_name": "Front Door Lock"}, changed_min_ago=240),
# ── Media player ──
# supported_features 384447 = every MediaPlayerEntityFeature bit this app
# controls (const.MEDIA_FEAT's values summed); not imported here to keep
Expand Down Expand Up @@ -540,7 +543,8 @@ def demo_collections() -> dict:
{"row": 1, "col": 2, "widget_type": "sensor", "entity_id": "sensor.living_room_humidity"},
{"row": 2, "col": 0, "widget_type": "graph", "entity_id": "sensor.living_room_temperature",
"col_span": 2},
{"row": 2, "col": 2, "widget_type": "binary_sensor", "entity_id": "binary_sensor.front_door"},
{"row": 2, "col": 2, "widget_type": "binary_sensor", "entity_id": "binary_sensor.front_door",
"show_last_changed": True},
],
},
"Living Room": {
Expand All @@ -563,8 +567,10 @@ def demo_collections() -> dict:
],
},
},
{"row": 1, "col": 1, "widget_type": "cover", "entity_id": "cover.living_room_blinds"},
{"row": 1, "col": 2, "widget_type": "lock", "entity_id": "lock.front_door"},
{"row": 1, "col": 1, "widget_type": "cover", "entity_id": "cover.living_room_blinds",
"show_last_changed": True},
{"row": 1, "col": 2, "widget_type": "lock", "entity_id": "lock.front_door",
"show_last_changed": True},
{
"row": 2, "col": 0, "widget_type": "panel", "entity_id": None,
"entity_ids": [
Expand Down
21 changes: 15 additions & 6 deletions src/hatty/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,11 @@
- `dashboards`: dict keyed by name, each `{rows, cols, slots: [...]}` — a flat
list of `{row, col, widget_type, entity_id}` for occupied cells only. A
`panel` slot carries `entity_ids` instead of `entity_id`; a `gauge` slot may
add `gauge_min`/`gauge_max`; any slot may add `row_span`/`col_span` (absent =
1); a `split` slot carries a `children` fragment — a nested `{rows, cols,
slots}` mini-grid whose own slots can't span or nest further.
add `gauge_min`/`gauge_max`; any single-entity slot may add
`show_last_changed` (true = show elapsed time since the entity's last state
change); any slot may add `row_span`/`col_span` (absent = 1); a `split` slot
carries a `children` fragment — a nested `{rows, cols, slots}` mini-grid
whose own slots can't span or nest further.
- `saved_graphs`: dict keyed by name, each `{entity_ids, graph_type, hours,
colors}` — `colors` (optional) maps entity_id → plotext color name.
- `manual_lists`: a set of list names currently in manual-sort order rather
Expand Down Expand Up @@ -73,6 +75,7 @@ def _dump_json(value) -> str | None:
gauge_min REAL, gauge_max REAL,
entity_ids TEXT,
children TEXT,
show_last_changed INTEGER,
PRIMARY KEY (dashboard, row, col)
);
CREATE TABLE IF NOT EXISTS saved_graphs (
Expand Down Expand Up @@ -149,6 +152,8 @@ def _migrate(self) -> None:
slot_columns = {r["name"] for r in self._db.execute("PRAGMA table_info(dashboard_slots)")}
if "children" not in slot_columns:
self._db.execute("ALTER TABLE dashboard_slots ADD COLUMN children TEXT")
if "show_last_changed" not in slot_columns:
self._db.execute("ALTER TABLE dashboard_slots ADD COLUMN show_last_changed INTEGER")
dashboard_columns = {r["name"] for r in self._db.execute("PRAGMA table_info(dashboards)")}
if "row_height" not in dashboard_columns:
self._db.execute("ALTER TABLE dashboards ADD COLUMN row_height INTEGER")
Expand Down Expand Up @@ -209,7 +214,8 @@ def _load_dashboards(self) -> dict[str, dict]:
dashboards[row["name"]] = dash
for row in self._db.execute(
"SELECT dashboard, row, col, widget_type, entity_id, row_span, col_span, "
"gauge_min, gauge_max, entity_ids, children FROM dashboard_slots ORDER BY dashboard, row, col"
"gauge_min, gauge_max, entity_ids, children, show_last_changed "
"FROM dashboard_slots ORDER BY dashboard, row, col"
):
dash = dashboards.get(row["dashboard"])
if dash is None:
Expand All @@ -234,6 +240,8 @@ def _load_dashboards(self) -> dict[str, dict]:
children = _load_json(row["children"])
if children is not None:
slot["children"] = children
if row["show_last_changed"]:
slot["show_last_changed"] = True
dash["slots"].append(slot)
return dashboards

Expand Down Expand Up @@ -306,8 +314,8 @@ def _write_dashboards(self, dashboards: dict) -> None:
for slot in dash.get("slots", []):
self._db.execute(
"INSERT INTO dashboard_slots (dashboard, row, col, widget_type, entity_id, "
"row_span, col_span, gauge_min, gauge_max, entity_ids, children) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
"row_span, col_span, gauge_min, gauge_max, entity_ids, children, show_last_changed) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
name,
slot.get("row"),
Expand All @@ -320,6 +328,7 @@ def _write_dashboards(self, dashboards: dict) -> None:
slot.get("gauge_max"),
_dump_json(slot.get("entity_ids")),
_dump_json(slot.get("children")),
1 if slot.get("show_last_changed") else None,
),
)

Expand Down
2 changes: 1 addition & 1 deletion src/hatty/ui/dashboard/screen.py
Original file line number Diff line number Diff line change
Expand Up @@ -641,7 +641,7 @@ def action_edit_slot(self) -> None:
def callback(result: dict | None) -> None:
if result is None:
return
extra = {k: result[k] for k in ("gauge_min", "gauge_max") if k in result}
extra = {k: result[k] for k in ("gauge_min", "gauge_max", "show_last_changed") if k in result}
# Reassigning a slot keeps its footprint (child cells never span).
if slot and parent is None:
for key in ("row_span", "col_span"):
Expand Down
30 changes: 28 additions & 2 deletions src/hatty/ui/dashboard/slot_popup.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,9 @@
from textual.containers import Container, Horizontal, Vertical
from textual.events import Key
from textual.timer import Timer
from textual.widgets import Button, DataTable, Footer, Input, Label, ListItem, ListView, Select
from textual.widgets import Button, Checkbox, DataTable, Footer, Input, Label, ListItem, ListView, Select

from hatty.const import WIDGET_TYPES
from hatty.const import LAST_CHANGED_WIDGET_TYPES, WIDGET_TYPES
from hatty.ui.dashboard.widget_match import compatible_widget_types, entity_matches_widget_type
from hatty.ui.dashboard.widgets.base import build_slot_content
from hatty.ui.entity_table import EntitiesTable, entity_matches, get_display_name
Expand Down Expand Up @@ -132,6 +132,9 @@ class DashboardSlotPopup(PopupScreen):
width: 1fr;
margin-right: 1;
}
#show_last_changed_check {
margin-bottom: 1;
}
#type_step_buttons {
height: 3;
margin-bottom: 1;
Expand All @@ -153,6 +156,7 @@ def __init__(self, slot: dict | None, fill_mode: bool = False):
self._panel_entity_ids: list[str] = list(slot.get("entity_ids", [])) if slot else []
self._gauge_min: float | None = slot.get("gauge_min") if slot else None
self._gauge_max: float | None = slot.get("gauge_max") if slot else None
self._show_last_changed: bool = bool(slot.get("show_last_changed")) if slot else False
self._step = "type"
self._select_initialized = False
# Entity-first order (issue #3): flips step "type"/"entity"'s meaning —
Expand Down Expand Up @@ -196,6 +200,9 @@ def compose(self) -> ComposeResult:
with Horizontal(id="gauge_bounds_row"):
yield Input(placeholder="min (auto)", id="gauge_min_input")
yield Input(placeholder="max (auto)", id="gauge_max_input")
yield Checkbox(
"Show time since last change", value=self._show_last_changed, id="show_last_changed_check"
)
yield EntitiesTable(id="entity_picker_table", cursor_type="row")
yield Label("Selected — Shift+↑/↓ reorder · Del remove", id="panel_added_hint")
yield ListView(id="panel_added_list")
Expand Down Expand Up @@ -233,6 +240,7 @@ def _show_step(self, step: str) -> None:
self.query_one("#panel_added_list").display = False
self.query_one("#btn_panel_done").display = False
self.query_one("#gauge_bounds_row").display = False
self.query_one("#show_last_changed_check").display = False
self._apply_preview_visibility()
self._rebuild_preview()

Expand Down Expand Up @@ -264,6 +272,9 @@ def _is_panel_mode(self) -> bool:
def _is_gauge_mode(self) -> bool:
return self.query_one("#widget_type_select", Select).value == "gauge"

def _supports_last_changed(self) -> bool:
return self.query_one("#widget_type_select", Select).value in LAST_CHANGED_WIDGET_TYPES

def _is_multi_add(self) -> bool:
"""True whenever the entity picker accumulates several entities instead
of dismissing on the first pick: a real "panel" slot, or fill mode
Expand All @@ -285,6 +296,11 @@ def _update_mode_visibility(self) -> None:
self.query_one("#gauge_min_input", Input).value = f"{self._gauge_min:g}"
if self._gauge_max is not None:
self.query_one("#gauge_max_input", Input).value = f"{self._gauge_max:g}"
# Fill mode packs many entities under one type, so there's no single
# entity whose last_changed the checkbox could refer to.
show_check = self.query_one("#show_last_changed_check", Checkbox)
show_check.display = self._supports_last_changed() and not self._fill_mode
show_check.value = self._show_last_changed

def _relevant_entities(self) -> list[dict]:
widget_type = cast(str, self.query_one("#widget_type_select", Select).value)
Expand All @@ -309,6 +325,12 @@ def _update_entity_table(self) -> None:
columns=["name", "entity_id"],
)

def on_checkbox_changed(self, event: Checkbox.Changed) -> None:
if event.checkbox.id != "show_last_changed_check":
return
self._show_last_changed = event.value
self._rebuild_preview()

def on_search_input_search_changed(self, event: SearchInput.SearchChanged) -> None:
event.stop()
self._update_entity_table()
Expand Down Expand Up @@ -365,6 +387,8 @@ def _rebuild_preview(self, entity_id: str | None = None) -> None:
slot["gauge_min"] = self._gauge_min
if self._gauge_max is not None:
slot["gauge_max"] = self._gauge_max
if self._show_last_changed and self._supports_last_changed():
slot["show_last_changed"] = True
preview.remove_children()
preview.mount(build_slot_content(slot))

Expand All @@ -381,6 +405,8 @@ def _submit(self, entity_id: str | None) -> None:
result[key] = float(raw)
except ValueError:
pass
if self._show_last_changed and widget_type in LAST_CHANGED_WIDGET_TYPES:
result["show_last_changed"] = True
self.dismiss(result)

def _entity_label(self, entity_id: str) -> str:
Expand Down
38 changes: 35 additions & 3 deletions src/hatty/ui/dashboard/widgets/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,18 @@
from textual.widget import Widget
from textual.widgets import Label, Static

from hatty.const import LAST_CHANGED_WIDGET_TYPES
from hatty.types import Entity
from hatty.ui.entity_table import format_relative

if TYPE_CHECKING:
from hatty.main import HACLI

# How often an opted-in slot re-renders its "Nm ago" text on its own — nothing
# else on the dashboard ticks on a clock, so slots showing elapsed time own a
# small timer of their own (issue #33).
ELAPSED_TICK_SECONDS = 30


def slot_label(slot: dict | None) -> str:
if not slot:
Expand All @@ -32,13 +39,21 @@ class EntitySlotWidget(Vertical):
background: $accent 30%;
border: round $accent;
}
EntitySlotWidget #slot_elapsed {
color: $text-muted;
}
"""

def __init__(self, entity_id: str | None):
def __init__(self, entity_id: str | None, *, show_last_changed: bool = False):
super().__init__()
self.entity_id = entity_id
self.show_last_changed = show_last_changed
self._last_changed = ""

def on_mount(self) -> None:
if self.show_last_changed:
self.mount(Label("", id="slot_elapsed"))
self.set_interval(ELAPSED_TICK_SECONDS, self._refresh_elapsed)
entity = self.app.find_entity(self.entity_id) if self.entity_id else None
self.update_entity(entity)

Expand All @@ -50,8 +65,16 @@ def update_entity(self, entity: Entity | None, pending: str | None = None) -> No
if not self.entity_id or entity is None:
self._render_empty()
return
if self.show_last_changed:
self._last_changed = entity.get("last_changed", "")
self._refresh_elapsed()
self._render_entity(entity, pending)

def _refresh_elapsed(self) -> None:
elapsed = self.query("#slot_elapsed")
if elapsed:
elapsed.first(Label).update(format_relative(self._last_changed))

def _render_entity(self, entity: Entity, pending: str | None) -> None:
raise NotImplementedError

Expand All @@ -70,20 +93,29 @@ def _render_empty(self) -> None:
# module-level import here would be circular.


def _wants_elapsed(slot: dict) -> bool:
return bool(slot.get("show_last_changed")) and slot["widget_type"] in LAST_CHANGED_WIDGET_TYPES


def _single_entity_factory(module_name: str, class_name: str):
def factory(slot: dict):
import importlib

cls = getattr(importlib.import_module(module_name), class_name)
return cls(slot.get("entity_id"))
return cls(slot.get("entity_id"), show_last_changed=_wants_elapsed(slot))

return factory


def _gauge_factory(slot: dict) -> Widget:
from hatty.ui.dashboard.widgets.gauge import GaugeSlotWidget

return GaugeSlotWidget(slot.get("entity_id"), gauge_min=slot.get("gauge_min"), gauge_max=slot.get("gauge_max"))
return GaugeSlotWidget(
slot.get("entity_id"),
gauge_min=slot.get("gauge_min"),
gauge_max=slot.get("gauge_max"),
show_last_changed=_wants_elapsed(slot),
)


def _panel_factory(slot: dict) -> Widget:
Expand Down
4 changes: 2 additions & 2 deletions src/hatty/ui/dashboard/widgets/fan.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ class FanSlotWidget(EntitySlotWidget):
}
"""

def __init__(self, entity_id: str | None):
super().__init__(entity_id)
def __init__(self, entity_id: str | None, *, show_last_changed: bool = False):
super().__init__(entity_id, show_last_changed=show_last_changed)
self._attrs: EntityAttributes = {}

def compose(self) -> ComposeResult:
Expand Down
11 changes: 9 additions & 2 deletions src/hatty/ui/dashboard/widgets/gauge.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,15 @@ class GaugeSlotWidget(EntitySlotWidget):
}
"""

def __init__(self, entity_id: str | None, gauge_min: float | None = None, gauge_max: float | None = None):
super().__init__(entity_id)
def __init__(
self,
entity_id: str | None,
gauge_min: float | None = None,
gauge_max: float | None = None,
*,
show_last_changed: bool = False,
):
super().__init__(entity_id, show_last_changed=show_last_changed)
self._gauge_min = gauge_min
self._gauge_max = gauge_max

Expand Down
4 changes: 2 additions & 2 deletions src/hatty/ui/dashboard/widgets/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ class GraphSlotWidget(EntitySlotWidget):
}
"""

def __init__(self, entity_id: str | None):
super().__init__(entity_id)
def __init__(self, entity_id: str | None, *, show_last_changed: bool = False):
super().__init__(entity_id, show_last_changed=show_last_changed)
self._data: list[tuple[str, float]] = []
self._mode_index = 0

Expand Down
4 changes: 2 additions & 2 deletions src/hatty/ui/dashboard/widgets/media_player.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,8 @@ class MediaPlayerSlotWidget(EntitySlotWidget):
}
"""

def __init__(self, entity_id: str | None):
super().__init__(entity_id)
def __init__(self, entity_id: str | None, *, show_last_changed: bool = False):
super().__init__(entity_id, show_last_changed=show_last_changed)
self._attrs: EntityAttributes = {}

def compose(self) -> ComposeResult:
Expand Down
Loading
Loading