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:
Gabriel Brown
2026-08-23 22:06:18 -04:00
parent 5490fd285d
commit 7578348db1
40 changed files with 2578 additions and 703 deletions
@@ -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])