Files
Gabriel Brown 7578348db1 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
2026-08-23 22:06:18 -04:00

571 lines
18 KiB
Python
Executable File

#!/usr/bin/env python3
"""Capability-aware KDE Connect boundary for the Panama shell."""
from __future__ import annotations
import json
import pathlib
import re
import subprocess
import sys
from collections.abc import Callable
DEVICE_ID = re.compile(r"^[A-Fa-f0-9]{32,64}$")
DEVICE_LINE = re.compile(
r"^-\s+(?P<name>.+):\s+(?P<id>[A-Fa-f0-9]{32,64})"
r"(?:\s+on\s+.+?)?\s+\((?P<state>[^)]+)\)\s*$"
)
PLUGIN_ACTIONS = {
"kdeconnect_clipboard": "clipboard",
"kdeconnect_findmyphone": "ring",
"kdeconnect_ping": "ping",
"kdeconnect_share": "share",
}
DEVICE_OBJECT_PREFIX = "/modules/kdeconnect/devices"
DEVICE_OBJECT_LINE = re.compile(
rf"(?P<path>{re.escape(DEVICE_OBJECT_PREFIX)}/(?P<id>[A-Fa-f0-9]{{32,64}}))$"
)
BATTERY_INTERFACE = "org.kde.kdeconnect.device.battery"
CONNECTIVITY_INTERFACE = "org.kde.kdeconnect.device.connectivity_report"
# busctl spells every integer width with its own type code; a property that is
# an int32 today may be read back as a uint32 by another kdeconnectd build.
INT_PROPERTY_TYPES = frozenset({"y", "n", "q", "i", "u", "x", "t"})
# Vitals are enrichment, not the answer: a phone that has just gone out of range
# can leave a plugin object that blocks, and the status read must not stall
# behind it. Shorter than the eight seconds the identity reads are allowed.
VITALS_TIMEOUT = 3
# Every device carries both keys so the shell never has to tell "not read" from
# "no such field"; absent vitals are null, not missing.
EMPTY_VITALS: dict[str, object] = {"battery": None, "signal": None}
Runner = Callable[..., subprocess.CompletedProcess[str]]
def compact_json(value: dict[str, object]) -> str:
return json.dumps(value, separators=(",", ":"))
def validate_device_id(value: str) -> str:
if not DEVICE_ID.fullmatch(value):
raise ValueError("invalid-device")
return value
def validate_file(value: pathlib.Path) -> pathlib.Path:
try:
resolved = value.expanduser().resolve(strict=True)
except OSError as error:
raise ValueError("invalid-file") from error
if not resolved.is_file():
raise ValueError("invalid-file")
return resolved
def inferred_type(name: str) -> str:
normalized = name.casefold()
if "iphone" in normalized or "phone" in normalized:
return "phone"
if "tablet" in normalized or "ipad" in normalized:
return "tablet"
return "device"
def normalize_device_line(
line: str,
plugins: list[str],
device_type: str = "",
) -> dict[str, object]:
match = DEVICE_LINE.fullmatch(line.strip())
if match is None:
raise ValueError("invalid-device-line")
state = match.group("state").casefold()
actions = sorted(
{
action
for plugin, action in PLUGIN_ACTIONS.items()
if plugin in plugins
}
)
name = match.group("name").strip()
return {
"id": match.group("id"),
"name": name,
"type": device_type or inferred_type(name),
"paired": "paired" in state,
"reachable": "reachable" in state,
"actions": actions,
}
# busctl properties are read with --json=short, not in its default text form.
#
# The text form escapes every non-ASCII byte in octal, and it escapes them into
# the OUTPUT rather than into a quoted string a shell-style parser can undo -- so
# a phone named "Gib's iPhone" with a typographic apostrophe arrived as the
# literal characters "Gib\342\200\231s iPhone" and was displayed that way.
# That is not specific to apostrophes: any name with an accent, an emoji, a
# quote, or a backslash was affected the same way.
#
# The JSON form returns real UTF-8 and needs no unescaping, which is why these
# parse a document rather than splitting words.
def property_payload(output: str | None) -> dict[str, object] | None:
"""The decoded document of a busctl --json=short read, or None when the
output is missing or is not a property document at all."""
try:
payload = json.loads(output)
except (json.JSONDecodeError, TypeError):
return None
return payload if isinstance(payload, dict) else None
def parse_property(output: str | None, expected: str) -> object | None:
"""The value of a busctl --json=short property read, or None if it is not
the type asked for."""
payload = property_payload(output)
if payload is None or payload.get("type") != expected:
return None
return payload.get("data")
def parse_loaded_plugins(output: str) -> list[str]:
data = parse_property(output, "as")
if not isinstance(data, list):
return []
return [str(item) for item in data]
def parse_string_property(output: str | None) -> str:
data = parse_property(output, "s")
return data if isinstance(data, str) else ""
def parse_bool_property(output: str | None) -> bool | None:
data = parse_property(output, "b")
return data if isinstance(data, bool) else None
def parse_int_property(output: str | None) -> int | None:
"""The integer value of a busctl --json=short property read, or None.
JSON has one number type, so booleans are rejected explicitly: `true` is an
int to isinstance and would otherwise read back as a charge of 1.
"""
payload = property_payload(output)
if payload is None or payload.get("type") not in INT_PROPERTY_TYPES:
return None
data = payload.get("data")
if isinstance(data, bool) or not isinstance(data, int):
return None
return data
def run_command(
command: list[str],
*,
runner: Runner = subprocess.run,
timeout: float = 8,
) -> subprocess.CompletedProcess[str]:
return runner(
command,
capture_output=True,
text=True,
timeout=timeout,
check=False,
)
def device_object(device_id: str) -> str:
return f"{DEVICE_OBJECT_PREFIX}/{validate_device_id(device_id)}"
def loaded_plugins(device_id: str, runner: Runner = subprocess.run) -> list[str]:
result = run_command(
[
"busctl",
"--user",
"--json=short",
"call",
"org.kde.kdeconnect",
device_object(device_id),
"org.kde.kdeconnect.device",
"loadedPlugins",
],
runner=runner,
)
return parse_loaded_plugins(result.stdout) if result.returncode == 0 else []
def supported_plugins(device_id: str, runner: Runner = subprocess.run) -> list[str]:
result = run_command(
[
"busctl",
"--user",
"--json=short",
"get-property",
"org.kde.kdeconnect",
device_object(device_id),
"org.kde.kdeconnect.device",
"supportedPlugins",
],
runner=runner,
)
return parse_loaded_plugins(result.stdout) if result.returncode == 0 else []
def device_plugins(device_id: str, runner: Runner = subprocess.run) -> list[str]:
active = loaded_plugins(device_id, runner)
return active if active else supported_plugins(device_id, runner)
def reported_type(device_id: str, runner: Runner = subprocess.run) -> str:
result = run_command(
[
"busctl",
"--user",
"--json=short",
"get-property",
"org.kde.kdeconnect",
device_object(device_id),
"org.kde.kdeconnect.device",
"type",
],
runner=runner,
)
return parse_string_property(result.stdout) if result.returncode == 0 else ""
def device_property(
device_id: str,
member: str,
runner: Runner = subprocess.run,
) -> subprocess.CompletedProcess[str]:
return run_command(
[
"busctl",
"--user",
"--json=short",
"get-property",
"org.kde.kdeconnect",
device_object(device_id),
"org.kde.kdeconnect.device",
member,
],
runner=runner,
)
# Each KDE Connect plugin hangs its own object off the device path, and those
# objects exist only while the device is paired, reachable AND the plugin is
# loaded. A phone with the battery plugin turned off, or one that just walked
# out of range, makes busctl exit non-zero -- that is an absence of data, never
# a fault to report, so every failure mode here collapses to None.
def plugin_property(
device_id: str,
plugin: str,
interface: str,
member: str,
runner: Runner = subprocess.run,
) -> str | None:
"""A plugin object's property read as raw busctl output, or None when the
object, the plugin, or the daemon is not there."""
try:
result = run_command(
[
"busctl",
"--user",
"--json=short",
"get-property",
"org.kde.kdeconnect",
f"{device_object(device_id)}/{plugin}",
interface,
member,
],
runner=runner,
timeout=VITALS_TIMEOUT,
)
except (FileNotFoundError, subprocess.TimeoutExpired):
return None
return result.stdout if result.returncode == 0 else None
def device_battery(
device_id: str,
runner: Runner = subprocess.run,
) -> dict[str, object] | None:
"""Charge and charging state, or None when the phone has no battery to
report.
Charge is read first because it is the cheapest proof the plugin object
exists at all: when it is missing the other two reads are skipped, which is
the common case for a device that is merely paired. kdeconnectd reports -1
for "not known yet", and anything outside a percentage is not a charge.
"""
charge = parse_int_property(
plugin_property(device_id, "battery", BATTERY_INTERFACE, "charge", runner)
)
if charge is None or not 0 <= charge <= 100:
return None
has_battery = parse_bool_property(
plugin_property(device_id, "battery", BATTERY_INTERFACE, "hasBattery", runner)
)
if has_battery is False:
return None
charging = parse_bool_property(
plugin_property(device_id, "battery", BATTERY_INTERFACE, "isCharging", runner)
)
return {"charge": charge, "charging": charging is True}
def device_signal(
device_id: str,
runner: Runner = subprocess.run,
) -> dict[str, object] | None:
"""Cell network type and bar count, or None when there is no cellular
report -- strength is -1 on a phone with no modem or no service."""
strength = parse_int_property(
plugin_property(
device_id,
"connectivity_report",
CONNECTIVITY_INTERFACE,
"cellularNetworkStrength",
runner,
)
)
if strength is None or strength < 0:
return None
network_type = parse_string_property(
plugin_property(
device_id,
"connectivity_report",
CONNECTIVITY_INTERFACE,
"cellularNetworkType",
runner,
)
)
return {"networkType": network_type, "strength": strength}
def device_vitals(
device: dict[str, object],
runner: Runner = subprocess.run,
) -> dict[str, object]:
"""The battery and signal fields for a device entry.
Only a paired, reachable device is queried; for anything else the plugin
objects cannot exist, so asking would spend busctl calls to learn nothing.
"""
if not (device.get("paired") and device.get("reachable")):
return dict(EMPTY_VITALS)
device_id = str(device["id"])
return {
"battery": device_battery(device_id, runner),
"signal": device_signal(device_id, runner),
}
def dbus_device_ids(runner: Runner = subprocess.run) -> list[str]:
try:
result = run_command(
["busctl", "--user", "tree", "org.kde.kdeconnect"],
runner=runner,
)
except (FileNotFoundError, subprocess.TimeoutExpired):
return []
if result.returncode != 0:
return []
return [
match.group("id")
for line in result.stdout.splitlines()
if (match := DEVICE_OBJECT_LINE.search(line.strip())) is not None
]
def dbus_devices(
runner: Runner = subprocess.run,
*,
vitals: bool = True,
) -> list[dict[str, object]]:
devices: list[dict[str, object]] = []
for device_id in dbus_device_ids(runner):
try:
name_result = device_property(device_id, "name", runner)
type_result = device_property(device_id, "type", runner)
paired_result = device_property(device_id, "isPaired", runner)
reachable_result = device_property(device_id, "isReachable", runner)
except (FileNotFoundError, subprocess.TimeoutExpired):
continue
name = parse_string_property(name_result.stdout) if name_result.returncode == 0 else ""
device_type = parse_string_property(type_result.stdout) if type_result.returncode == 0 else ""
paired = parse_bool_property(paired_result.stdout) if paired_result.returncode == 0 else None
reachable = parse_bool_property(reachable_result.stdout) if reachable_result.returncode == 0 else None
if not name or paired is not True or reachable is None:
continue
try:
plugins = device_plugins(device_id, runner)
except (FileNotFoundError, subprocess.TimeoutExpired):
plugins = []
actions = sorted(
{
action
for plugin, action in PLUGIN_ACTIONS.items()
if plugin in plugins
}
)
device = {
"id": device_id,
"name": name,
"type": device_type or inferred_type(name),
"paired": paired,
"reachable": reachable,
"actions": actions,
}
device.update(device_vitals(device, runner) if vitals else EMPTY_VITALS)
devices.append(device)
return devices
# `vitals` is off for the read that guards an action: ringing a phone needs its
# identity and its action list, not its charge, and the extra busctl round trips
# would sit between the tap and the ring.
def collect_status(
runner: Runner = subprocess.run,
*,
vitals: bool = True,
) -> dict[str, object]:
try:
listing = run_command(
["kdeconnect-cli", "--list-devices"],
runner=runner,
)
except (FileNotFoundError, subprocess.TimeoutExpired):
return {"available": False, "devices": [], "error": "unavailable"}
if listing.returncode != 0:
return {"available": False, "devices": [], "error": "unavailable"}
devices: list[dict[str, object]] = []
for line in listing.stdout.splitlines():
if not line.lstrip().startswith("-"):
continue
match = DEVICE_LINE.fullmatch(line.strip())
if match is None:
continue
device_id = match.group("id")
try:
plugins = device_plugins(device_id, runner)
device_type = reported_type(device_id, runner)
except (FileNotFoundError, subprocess.TimeoutExpired):
plugins = []
device_type = ""
try:
device = normalize_device_line(line, plugins, device_type)
except ValueError:
continue
device.update(device_vitals(device, runner) if vitals else EMPTY_VITALS)
devices.append(device)
known_ids = {str(device["id"]) for device in devices}
devices.extend(
device
for device in dbus_devices(runner, vitals=vitals)
if str(device["id"]) not in known_ids
)
devices.sort(
key=lambda device: (
not bool(device["reachable"]),
not bool(device["paired"]),
str(device["name"]).casefold(),
)
)
return {"available": True, "devices": devices, "error": ""}
def action_command(
action: str,
device_id: str,
file_path: pathlib.Path | None = None,
) -> list[str]:
device_id = validate_device_id(device_id)
options = {
"ring": ["--ring"],
"clipboard": ["--send-clipboard"],
}
if action == "share" and file_path is not None:
return [
"kdeconnect-cli",
"-d",
device_id,
"--share",
str(validate_file(file_path)),
]
if action not in options:
raise ValueError("unsupported-action")
return ["kdeconnect-cli", "-d", device_id, *options[action]]
def invoke_action(
action: str,
device_id: str,
file_path: pathlib.Path | None = None,
runner: Runner = subprocess.run,
) -> dict[str, object]:
device_id = validate_device_id(device_id)
status = collect_status(runner, vitals=False)
device = next(
(
item
for item in status["devices"]
if isinstance(item, dict) and item.get("id") == device_id
),
None,
)
if not device or not device.get("paired") or not device.get("reachable"):
return {"ok": False, "action": action, "error": "device-offline"}
if action not in device.get("actions", []):
return {"ok": False, "action": action, "error": "unsupported-action"}
command = action_command(action, device_id, file_path)
try:
result = run_command(command, runner=runner, timeout=3600)
except (FileNotFoundError, subprocess.TimeoutExpired):
return {"ok": False, "action": action, "error": "action-failed"}
if result.returncode != 0:
return {"ok": False, "action": action, "error": "action-failed"}
response: dict[str, object] = {"ok": True, "action": action, "error": ""}
if action == "share" and file_path is not None:
response["fileName"] = validate_file(file_path).name
return response
def print_result(value: dict[str, object]) -> None:
sys.stdout.write(compact_json(value) + "\n")
def main(argv: list[str]) -> int:
if not argv or argv[0] == "status":
print_result(collect_status())
return 0
command = argv[0]
try:
if command == "ring" and len(argv) == 2:
result = invoke_action("ring", argv[1])
elif command == "send-clipboard" and len(argv) == 2:
result = invoke_action("clipboard", argv[1])
elif command == "send-file" and len(argv) == 3:
result = invoke_action("share", argv[1], pathlib.Path(argv[2]))
else:
result = {"ok": False, "action": command, "error": "invalid-command"}
except ValueError as error:
result = {"ok": False, "action": command, "error": str(error)}
print_result(result)
return 0 if bool(result.get("ok")) else 2
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))