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
8 changes: 4 additions & 4 deletions src/hatty/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,17 +186,17 @@ def binary_state_label(state: str, device_class: str) -> str:
CONFIG_KEY_ENTITY_NAMES = "entity_names"
CONFIG_KEY_MANUAL_LISTS = "manual_lists"
CONFIG_KEY_NOTIFICATIONS = "notifications"
CONFIG_KEY_NOTIFY_LISTS = "notify_lists"
CONFIG_KEY_TERMINAL_TITLE_ENABLED = "terminal_title_enabled"
CONFIG_KEY_TERMINAL_TITLE = "terminal_title"

# Fallback/default value for the "terminal_title" config key (issue: set tmux
# title to hatty or pref).
DEFAULT_TERMINAL_TITLE = "hatty"

# Reserved list name (issue #224) holding the entities watched for change alerts.
# Reuses the entity_lists schema (space to add/remove, undo/redo, etc.) but can't
# be renamed or deleted (see ListController/ListSelectionPopup guards), and is only
# shown in list_names while notifications are enabled (NotificationController.sync).
# Legacy reserved list name (issue #224). No longer special β€” any list can be
# designated a notification source via `notify_lists` (issue #24) β€” kept only as
# the name storage.migrate_reserved_notify_list looks for on a pre-#24 DB.
NOTIFY_LIST_NAME = "\U0001f514 Notifications"

# Default notification preferences (config key "notifications"), merged over by
Expand Down
17 changes: 6 additions & 11 deletions src/hatty/controllers/lists.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
# hatty β€” MIT License. See LICENSE file for details.
"""List (favorites) state and operations, extracted from HACLI."""

from hatty.const import NOTIFY_LIST_NAME
from hatty.ui.confirm_popup import ConfirmPopup
from hatty.ui.dashboard.screen import DashboardScreen

Expand Down Expand Up @@ -52,9 +51,6 @@ def handle_popup_action(self, result: dict) -> None:
list_name = result.get("list_name")

if action == "delete":
if list_name == NOTIFY_LIST_NAME:
app.notify(f"'{list_name}' cannot be deleted.", severity="information")
return
if list_name not in self.entity_lists:
return

Expand All @@ -66,13 +62,14 @@ def _do_delete(confirmed, _name=list_name):
del self.entity_lists[_name]
self.list_names.remove(_name)
self.manual_lists.discard(_name)
app.notify_ctl.notify_lists.discard(_name)
if self.unlocked_list == _name:
self.unlocked_list = None
if self.current_list_name == _name:
self.current_list_name = None
if self.default_list_name == _name:
self.default_list_name = None
app.persist("lists", "manual_lists", "default_list")
app.persist("lists", "manual_lists", "notify_lists", "default_list")
app.notify(f"List '{_name}' deleted.", title="List Deleted")
app._update_entities_display()

Expand All @@ -93,14 +90,9 @@ def _do_delete(confirmed, _name=list_name):

def rename_list(self, old_name: str | None, new_name: str | None) -> None:
app = self._app
if old_name == NOTIFY_LIST_NAME:
app.notify(f"'{old_name}' cannot be renamed.", severity="information")
return
new_name = (new_name or "").strip()
if not old_name or old_name not in self.entity_lists or not new_name or old_name == new_name:
return
# entity_lists always carries the reserved-list key (even hidden), so this
# also catches (and rejects) renaming a list *to* the reserved name.
if new_name in self.entity_lists:
app.notify(f"A list named '{new_name}' already exists.", title="Rename Error", severity="error")
return
Expand All @@ -117,12 +109,15 @@ def rename_list(self, old_name: str | None, new_name: str | None) -> None:
if old_name in self.manual_lists:
self.manual_lists.discard(old_name)
self.manual_lists.add(new_name)
if old_name in app.notify_ctl.notify_lists:
app.notify_ctl.notify_lists.discard(old_name)
app.notify_ctl.notify_lists.add(new_name)
if self.unlocked_list == old_name:
self.unlocked_list = new_name
for entry in (*self.undo_stack, *self.redo_stack):
if entry["list_name"] == old_name:
entry["list_name"] = new_name
app.persist("lists", "manual_lists", "default_list")
app.persist("lists", "manual_lists", "notify_lists", "default_list")
app.set_title_based_on_focused_ui()
app._update_entities_display()
app.notify(f"Renamed list '{old_name}' to '{new_name}'.", title="List Renamed")
Expand Down
73 changes: 39 additions & 34 deletions src/hatty/controllers/notifications.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
# hatty β€” MIT License. See LICENSE file for details.
"""Entity change alerts, extracted from HACLI (issue #224).

Watched entities live in the reserved list `const.NOTIFY_LIST_NAME` β€” a real
entry in `entity_lists`, so add/remove reuses the existing `space`-to-toggle
membership flow, undo/redo, etc. `ListController`/`ListSelectionPopup` refuse to
rename or delete it (it's not a user list). The reserved list is only *visible*
(present in `list_names`, selectable) while notifications are enabled; disabling
hides it from the list popup but leaves its membership in `entity_lists` intact,
so re-enabling restores exactly what was being watched.
Watched entities are the union of the members of every list named in
`notify_lists` (issue #24) β€” any user list can be designated a notification
source, toggled from the list popup (`ListSelectionPopup`'s `n` binding) rather
than through membership in one reserved list. `notify_lists` is a plain name
set, persisted like `manual_lists`; `ListController` carries a list's
designation across rename and drops it on delete.

The `enabled` preference is a pure global mute β€” it doesn't affect which lists
are designated or what's in them, just whether `handle_state_change` fires.

Preferences (`app_config["notifications"]`) are read fresh on every alert rather
than cached, so toggling a channel in ConfigScreen takes effect immediately.
Expand All @@ -18,7 +20,7 @@

import aiohttp

from hatty.const import CONFIG_KEY_NOTIFICATIONS, DEFAULT_NOTIFICATIONS, NOTIFY_LIST_NAME
from hatty.const import CONFIG_KEY_NOTIFICATIONS, DEFAULT_NOTIFICATIONS
from hatty.types import Entity
from hatty.ui.entity_table import get_display_name

Expand Down Expand Up @@ -72,40 +74,42 @@ async def send_test_ntfy(prefs: dict, title: str, body: str, timeout: float = 5.


class NotificationController:
"""Owns the reserved watch-list's visibility and dispatches change alerts."""
"""Owns which lists are designated as notification sources and dispatches
change alerts."""

def __init__(self, app) -> None:
self._app = app
self.notify_lists: set[str] = set()
# entity_ids currently in their transient post-change highlight window.
self.alerted: set[str] = set()
self._timers: dict = {}

@property
def list_name(self) -> str:
return NOTIFY_LIST_NAME

def _prefs(self) -> dict:
prefs = self._app.app_config.get(CONFIG_KEY_NOTIFICATIONS) or {}
return {**DEFAULT_NOTIFICATIONS, **prefs}

def sync(self) -> None:
"""Reconcile the reserved list's visibility with the `enabled` pref.
Called on boot (_apply_config) and whenever config is saved. Always
ensures the entity_lists entry exists (so watched entities persist
across enable/disable), then shows/hides it in list_names."""
app = self._app
app.entity_lists.setdefault(NOTIFY_LIST_NAME, [])
enabled = self._prefs()["enabled"]
visible = NOTIFY_LIST_NAME in app.list_names
if enabled and not visible:
app.list_names.append(NOTIFY_LIST_NAME)
elif not enabled and visible:
app.list_names.remove(NOTIFY_LIST_NAME)
if app.current_list_name == NOTIFY_LIST_NAME:
app.current_list_name = None
def toggle_list(self, list_name: str) -> bool:
"""Flip `list_name`'s designation and persist. Returns the new state."""
if list_name in self.notify_lists:
self.notify_lists.discard(list_name)
now_on = False
else:
self.notify_lists.add(list_name)
now_on = True
self._app.persist("notify_lists")
return now_on

def is_watched(self, entity_id: str) -> bool:
return entity_id in (self._app.entity_lists.get(NOTIFY_LIST_NAME) or [])
entity_lists = self._app.entity_lists
return any(entity_id in (entity_lists.get(name) or []) for name in self.notify_lists)

def watched_entities(self) -> set[str]:
"""The de-duplicated union of every designated list's members."""
entity_lists = self._app.entity_lists
result: set[str] = set()
for name in self.notify_lists:
result.update(entity_lists.get(name) or [])
return result

def is_alerted(self, entity_id: str) -> bool:
return entity_id in self.alerted
Expand Down Expand Up @@ -173,8 +177,9 @@ def _clear(self, entity_id: str) -> None:
self._app._update_entities_display()
self._app._refresh_dashboard_widgets(entity_id)

def clear_entities(self) -> None:
"""Empty the watch list (the config-page "Clear watched entities" button)."""
app = self._app
app.entity_lists[NOTIFY_LIST_NAME] = []
app.persist("lists")
def stop_watching_all(self) -> None:
"""Undesignate every notifying list (the config-page "Stop watching all
lists" button). Lists and their contents are untouched β€” only the
notify_lists designation is cleared."""
self.notify_lists.clear()
self._app.persist("notify_lists")
11 changes: 3 additions & 8 deletions src/hatty/demo/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,8 @@ def demo_config() -> dict:
cfg["home_assistant"] = {"url": "demo://home-assistant", "token": "demo"}
cfg["graph_type"] = "line" # dashboard Graph widgets render as a line, not the block sparkline
cfg.update(demo_collections())
# Enable change-alert notifications (issue #224) with a small pre-populated
# watch-list, so the reserved list shows up already in use β€” ntfy stays off
# since demo mode is fully offline.
# Enable change-alert notifications (issue #224) β€” the "Security" list in
# demo_collections() is pre-designated (issue #24) so it shows up already in
# use β€” ntfy stays off since demo mode is fully offline.
cfg[const.CONFIG_KEY_NOTIFICATIONS] = {**const.DEFAULT_NOTIFICATIONS}
cfg["lists"][const.NOTIFY_LIST_NAME] = [
"binary_sensor.smoke_detector",
"binary_sensor.front_door",
"lock.front_door",
]
return cfg
8 changes: 8 additions & 0 deletions src/hatty/demo/demo_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -520,8 +520,16 @@ def demo_collections() -> dict:
"cover.living_room_blinds",
"lock.front_door",
],
# Pre-designated as a notify list below (issue #24) so change alerts
# show up already in use.
"Security": [
"binary_sensor.smoke_detector",
"binary_sensor.front_door",
"lock.front_door",
],
},
"manual_lists": ["Favorites"],
"notify_lists": ["Security"],
"default_list": "Living Room",
"entity_names": {"sensor.internet_speed": "WAN Speed"},
"dashboards": {
Expand Down
19 changes: 8 additions & 11 deletions src/hatty/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
CONFIG_KEY_LISTS,
CONFIG_KEY_LOG_HOURS,
CONFIG_KEY_MANUAL_LISTS,
CONFIG_KEY_NOTIFY_LISTS,
CONFIG_KEY_SAVED_GRAPHS,
CONFIG_KEY_TERMINAL_TITLE,
CONFIG_KEY_TERMINAL_TITLE_ENABLED,
Expand Down Expand Up @@ -213,6 +214,7 @@ def log_hours(self) -> float:
_last_list_name = _controller_proxy("list_ctl", "last_list_name")
default_list_name = _controller_proxy("list_ctl", "default_list_name")
manual_lists = _controller_proxy("list_ctl", "manual_lists")
notify_lists = _controller_proxy("notify_ctl", "notify_lists")
_undo_stack = _controller_proxy("list_ctl", "undo_stack")
_redo_stack = _controller_proxy("list_ctl", "redo_stack")

Expand Down Expand Up @@ -314,6 +316,8 @@ def _open_storage(self, cfg: dict) -> None:
if self.storage.is_empty():
# One-time migration from a legacy all-in-YAML config.
self.storage.save_all({key: cfg.get(key) for key in storage_module.COLLECTION_KEYS})
# One-time migration off the pre-#24 reserved 'πŸ”” Notifications' list.
self.storage.migrate_reserved_notify_list()
loaded = self.storage.load_all()
cfg.update(loaded)
except Exception as e:
Expand All @@ -335,10 +339,7 @@ def _apply_config(self, cfg: dict) -> None:
self.dashboard_names = list(self.dashboards.keys())
self.default_dashboard_name = cfg.get(CONFIG_KEY_DEFAULT_DASHBOARD)
self.saved_graphs = cfg.get(CONFIG_KEY_SAVED_GRAPHS, {})
# Seeds entity_lists[NOTIFY_LIST_NAME] if missing and shows/hides it in
# list_names per the "enabled" pref β€” must run before the auto-select
# fallback below so a freshly-seeded reserved list is never picked.
self.notify_ctl.sync()
self.notify_lists = set(cfg.get(CONFIG_KEY_NOTIFY_LISTS) or [])

saved_theme = cfg.get(CONFIG_KEY_THEME)
if saved_theme and saved_theme in self.available_themes:
Expand All @@ -348,11 +349,10 @@ def _apply_config(self, cfg: dict) -> None:

self.query_one("#detail_panel", EntityDetailPanel).apply_saved_graph_type(cfg.get(CONFIG_KEY_GRAPH_TYPE))

selectable = [n for n in self.list_names if n != self.notify_ctl.list_name]
if self.default_list_name and self.default_list_name in self.entity_lists:
self.current_list_name = self.default_list_name
elif selectable:
self.current_list_name = selectable[0]
elif self.list_names:
self.current_list_name = self.list_names[0]
self._last_list_name = self.current_list_name

ha_config = cfg.get(CONFIG_KEY_HOME_ASSISTANT, {})
Expand Down Expand Up @@ -1340,6 +1340,7 @@ def _collections_snapshot(self) -> dict:
CONFIG_KEY_DASHBOARDS: dashboards,
CONFIG_KEY_SAVED_GRAPHS: self.app_config.get(CONFIG_KEY_SAVED_GRAPHS) or {},
CONFIG_KEY_MANUAL_LISTS: self.app_config.get(CONFIG_KEY_MANUAL_LISTS) or set(),
CONFIG_KEY_NOTIFY_LISTS: self.app_config.get(CONFIG_KEY_NOTIFY_LISTS) or set(),
CONFIG_KEY_DEFAULT_LIST: self.app_config.get(CONFIG_KEY_DEFAULT_LIST),
CONFIG_KEY_DEFAULT_DASHBOARD: self.app_config.get(CONFIG_KEY_DEFAULT_DASHBOARD),
}
Expand Down Expand Up @@ -1819,10 +1820,6 @@ def _on_config_saved(self, result: dict | None) -> None:
self.app_config = result
self.columns = result.get(CONFIG_KEY_COLUMNS, list(DEFAULT_COLUMNS))
self.entity_names = result.get(CONFIG_KEY_ENTITY_NAMES, {})
# Show/hide the reserved notifications list per the (possibly just-toggled)
# "enabled" pref, and refresh the title/table in case current_list_name
# was reset by sync() (e.g. the user disabled notifications while viewing it).
self.notify_ctl.sync()
self.set_title_based_on_focused_ui()
new_graph_type = result.get(CONFIG_KEY_GRAPH_TYPE)
self.query_one("#detail_panel", EntityDetailPanel).apply_saved_graph_type(new_graph_type)
Expand Down
34 changes: 34 additions & 0 deletions src/hatty/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@
colors}` β€” `colors` (optional) maps entity_id β†’ plotext color name.
- `manual_lists`: a set of list names currently in manual-sort order rather
than the default alphabetical-by-display-name sort.
- `notify_lists`: a set of list names designated as change-alert sources (issue
#24) β€” a watched entity is any member of any list in this set.
"""

import json
Expand All @@ -39,7 +41,9 @@
CONFIG_KEY_ENTITY_NAMES,
CONFIG_KEY_LISTS,
CONFIG_KEY_MANUAL_LISTS,
CONFIG_KEY_NOTIFY_LISTS,
CONFIG_KEY_SAVED_GRAPHS,
NOTIFY_LIST_NAME,
)

SCHEMA_VERSION = 1
Expand Down Expand Up @@ -97,6 +101,7 @@ def _dump_json(value) -> str | None:
CONFIG_KEY_SAVED_GRAPHS: ("saved_graphs", "sqlite"),
CONFIG_KEY_ENTITY_NAMES: ("entity_names", "sqlite"),
CONFIG_KEY_MANUAL_LISTS: ("manual_lists", "sqlite"),
CONFIG_KEY_NOTIFY_LISTS: ("notify_lists", "sqlite"),
CONFIG_KEY_COLUMNS: ("columns", "yaml"),
}

Expand Down Expand Up @@ -168,6 +173,33 @@ def is_empty(self) -> bool:
def _mark_imported(self) -> None:
self._db.execute("INSERT OR REPLACE INTO meta (key, value) VALUES ('imported', '1')")

def migrate_reserved_notify_list(self) -> None:
"""One-time migration off the pre-#24 reserved 'πŸ”” Notifications' list: a
non-empty list survives as an ordinary designated list (added to
`notify_lists`); an empty one (never actually used) is dropped outright.
Guarded by a meta flag so a list the user later (re)creates with the same
name is never touched. Call after connect(), before load_all()."""
if self._get_meta("notify_migrated") is not None:
return
with self._lock:
conn = self._conn
if conn is None:
return
with conn:
row = self._db.execute("SELECT 1 FROM lists WHERE name = ?", (NOTIFY_LIST_NAME,)).fetchone()
if row is not None:
has_entities = (
self._db.execute(
"SELECT 1 FROM list_entities WHERE list_name = ? LIMIT 1", (NOTIFY_LIST_NAME,)
).fetchone()
is not None
)
if has_entities:
self._set_meta("notify_lists", _dump_json([NOTIFY_LIST_NAME]))
else:
self._db.execute("DELETE FROM lists WHERE name = ?", (NOTIFY_LIST_NAME,))
self._set_meta("notify_migrated", "1")

# ── Loading ──────────────────────────────────────────────────────────────

def load_all(self) -> dict:
Expand All @@ -178,6 +210,7 @@ def load_all(self) -> dict:
"dashboards": self._load_dashboards(),
"saved_graphs": self._load_saved_graphs(),
"manual_lists": _load_json(self._get_meta("manual_lists")) or [],
"notify_lists": _load_json(self._get_meta("notify_lists")) or [],
"default_list": self._get_meta("default_list"),
"default_dashboard": self._get_meta("default_dashboard"),
}
Expand Down Expand Up @@ -269,6 +302,7 @@ def save_all(self, collections: dict) -> None:
self._write_dashboards(collections.get("dashboards") or {})
self._write_saved_graphs(collections.get("saved_graphs") or {})
self._set_meta("manual_lists", _dump_json(sorted(collections.get("manual_lists") or [])))
self._set_meta("notify_lists", _dump_json(sorted(collections.get("notify_lists") or [])))
self._set_meta("default_list", collections.get("default_list"))
self._set_meta("default_dashboard", collections.get("default_dashboard"))
self._mark_imported()
Expand Down
Loading
Loading