Merge Home & Phone into a three-tab Home that knows your house
Home is now Overview | My Home | Phone. Overview leads with quick-action tiles (focus, Do Not Disturb, health, snapshots, storage), keeps the findings card — updates fold in, the reclaim-space prompt is gone on purpose — and adds glance cards, the next calendar event, and weather. My Home groups every light by Home Assistant area: the helper gained an `areas` command (one REST template render, no websocket), and the rooms degrade to a flat list on setups without areas. The favorites editor and connection card moved intact. Phone gains a vitals strip — battery and cell signal read from KDE Connect's plugin D-Bus objects, where absence is data, not an error — beside ring, clipboard, send-a-file, and the BlueBubbles handoff. The retired home-phone id resolves to my-home forever via a new alias map in SettingsRoutes (with a hasOwnProperty guard so prototype names cannot leak into settingsPage). Storage no longer claims 0 B free — the old page read a field the disks helper never emitted. Contracts updated alongside; per the new workflow, the full suite runs once at the end of the redesign (see the test backlog note). Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
@@ -26,6 +26,18 @@ ENV_KEYS = (
|
||||
"PANAMA_HOME_ASSISTANT_ENTITIES",
|
||||
)
|
||||
ENTITY_ID = re.compile(r"^[a-z_]+\.[a-z0-9_]+$")
|
||||
# One template renders the whole area list, so a room grouping costs a single
|
||||
# request. Home Assistant answers /api/template with the rendered text, which
|
||||
# `tojson` makes a JSON document the caller can parse like any other response.
|
||||
AREAS_TEMPLATE = (
|
||||
"{% set ns = namespace(items=[]) %}"
|
||||
"{% for area in areas() %}"
|
||||
"{% set ns.items = ns.items + ["
|
||||
"{'id': area, 'name': area_name(area), 'entities': area_entities(area)}"
|
||||
"] %}"
|
||||
"{% endfor %}"
|
||||
"{{ ns.items | tojson }}"
|
||||
)
|
||||
GNOME_EXTENSION = (
|
||||
pathlib.Path.home()
|
||||
/ ".local/share/gnome-shell/extensions/hass-gshell@geoph9-on-github"
|
||||
@@ -258,6 +270,8 @@ def request_json(
|
||||
error_body = error.read().decode(errors="replace")
|
||||
except OSError:
|
||||
error_body = ""
|
||||
finally:
|
||||
error.close()
|
||||
raise BridgeError(public_http_error(error.code, error_body)) from None
|
||||
except (TimeoutError, urllib.error.URLError, OSError):
|
||||
raise BridgeError("unreachable") from None
|
||||
@@ -358,6 +372,53 @@ def collect_snapshot(config: Config) -> dict[str, object]:
|
||||
return collect_catalog(config)
|
||||
|
||||
|
||||
def normalize_areas(raw: Sequence[object]) -> list[dict[str, object]]:
|
||||
# A template renders whatever Home Assistant happens to hold, so every entry
|
||||
# is checked on its own: one malformed area is dropped rather than costing
|
||||
# the caller the whole grouping. Only lights are kept, because lights are
|
||||
# all the catalog holds today, and an area left with none is not a room the
|
||||
# UI can draw.
|
||||
result: list[dict[str, object]] = []
|
||||
for item in raw:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
area_id = item.get("id")
|
||||
name = item.get("name")
|
||||
entities = item.get("entities")
|
||||
if not isinstance(area_id, str) or not isinstance(name, str):
|
||||
continue
|
||||
if not area_id.strip() or not name.strip() or not isinstance(entities, list):
|
||||
continue
|
||||
lights = [
|
||||
entity_id
|
||||
for entity_id in entities
|
||||
if isinstance(entity_id, str)
|
||||
and entity_id.startswith("light.")
|
||||
and ENTITY_ID.fullmatch(entity_id)
|
||||
]
|
||||
if not lights:
|
||||
continue
|
||||
result.append(
|
||||
{"id": area_id.strip(), "name": name.strip(), "entities": lights}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def collect_areas(config: Config) -> dict[str, object]:
|
||||
if not config.configured:
|
||||
return {"ok": False, "areas": [], "error": "not-configured"}
|
||||
try:
|
||||
raw = request_json(
|
||||
config, "POST", "/api/template", {"template": AREAS_TEMPLATE}
|
||||
)
|
||||
if not isinstance(raw, list):
|
||||
raise BridgeError("invalid-response")
|
||||
areas = normalize_areas(raw)
|
||||
except BridgeError as error:
|
||||
return {"ok": False, "areas": [], "error": str(error)}
|
||||
return {"ok": True, "areas": areas, "error": ""}
|
||||
|
||||
|
||||
def discovered_light_ids(config: Config) -> set[str]:
|
||||
raw = request_json(config, "GET", "/api/states")
|
||||
if not isinstance(raw, list):
|
||||
@@ -466,6 +527,9 @@ def main(argv: list[str]) -> int:
|
||||
elif command in {"catalog", "snapshot"} and len(argv) == 1:
|
||||
result = collect_catalog(config)
|
||||
success = bool(result["ok"])
|
||||
elif command == "areas" and len(argv) == 1:
|
||||
result = collect_areas(config)
|
||||
success = bool(result["ok"])
|
||||
elif command == "toggle" and len(argv) == 2:
|
||||
try:
|
||||
result = toggle(config, argv[1])
|
||||
|
||||
Reference in New Issue
Block a user