Network & Devices was 92 lines and two buttons that opened GNOME. It now scans, joins, and pairs directly through Quickshell.Networking and Quickshell.Bluetooth -- NetworkManager and BlueZ over DBus, no shelling out to nmcli or bluetoothctl. That was the founding requirement for this desktop: never having to drop to a terminal to join a network. Scanning follows the page being visible. Wi-Fi scanning and especially Bluetooth discovery hold the radio, and running either for a list nobody is looking at spends airtime on nothing. Joining a secured network gets a real password field, not the clipboard popover's search box with different placeholder text: a Wi-Fi key typed into a field that echoes it is readable by anyone behind you, and a search glyph in front of a password prompt is simply wrong. Two bugs found by looking at the rendered page, both silent: The device lookups used enum names that do not exist -- NetworkDeviceType.Wifi rather than DeviceType.Wifi -- so both returned null and the page reported "No Wi-Fi adapter" on a machine whose Wi-Fi was connected. Nothing was logged; QML resolves an unknown enum member to undefined and compares happily. signalStrength is 0.0-1.0, not a percentage, so thresholds written for 0-100 put every network including the connected one in the bottom bucket. The labels now use the same buckets as the icons in quicksettings/WifiList.qml so the two cannot disagree. The contract compares what the service resolves against what nmcli reports, rather than only checking that nothing crashed. Also makes the Home Assistant bridge hermetic: resolve_config read the user's private env file even when a caller supplied an explicit environment, so adding a real PANAMA_HOME_ASSISTANT_ENTITIES to that file silently overrode a fixture asserting the legacy fallback. An explicit environment is now the whole environment; production still reads the file. Its live contract skips when no token is configured -- an absent credential is not a defect, and a suite expected to be red stops being read -- while a configured-but-broken bridge still fails. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
497 lines
15 KiB
Python
Executable File
497 lines
15 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
|
|
"""Private Home Assistant REST boundary for the Panama Control Center."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
import json
|
|
import os
|
|
import pathlib
|
|
import re
|
|
import shlex
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
from collections.abc import Callable, Mapping, Sequence
|
|
from dataclasses import dataclass
|
|
|
|
|
|
ENV_KEYS = (
|
|
"PANAMA_HOME_ASSISTANT_URL",
|
|
"PANAMA_HOME_ASSISTANT_TOKEN",
|
|
"PANAMA_HOME_ASSISTANT_ENTITIES",
|
|
)
|
|
ENTITY_ID = re.compile(r"^[a-z_]+\.[a-z0-9_]+$")
|
|
GNOME_EXTENSION = (
|
|
pathlib.Path.home()
|
|
/ ".local/share/gnome-shell/extensions/hass-gshell@geoph9-on-github"
|
|
)
|
|
GNOME_SCHEMA_DIR = GNOME_EXTENSION / "schemas"
|
|
GNOME_SCHEMA = "org.gnome.shell.extensions.hass-data"
|
|
PANAMA_ENV = pathlib.Path(__file__).resolve().parents[3] / "bash/env"
|
|
Runner = Callable[..., subprocess.CompletedProcess[str]]
|
|
|
|
|
|
class BridgeError(RuntimeError):
|
|
"""An error code safe to pass across the shell boundary."""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Config:
|
|
base_url: str
|
|
token: str
|
|
entity_ids: tuple[str, ...]
|
|
|
|
@property
|
|
def configured(self) -> bool:
|
|
return bool(self.base_url and self.token)
|
|
|
|
|
|
def compact_json(value: dict[str, object]) -> str:
|
|
return json.dumps(value, separators=(",", ":"))
|
|
|
|
|
|
def read_panama_env(path: pathlib.Path = PANAMA_ENV) -> dict[str, str]:
|
|
values: dict[str, str] = {}
|
|
try:
|
|
lines = path.read_text(encoding="utf-8").splitlines()
|
|
except OSError:
|
|
return values
|
|
|
|
for line in lines:
|
|
stripped = line.strip()
|
|
if not stripped or stripped.startswith("#"):
|
|
continue
|
|
if stripped.startswith("export "):
|
|
stripped = stripped[7:].lstrip()
|
|
if "=" not in stripped:
|
|
continue
|
|
key, raw_value = stripped.split("=", 1)
|
|
key = key.strip()
|
|
if key not in ENV_KEYS:
|
|
continue
|
|
try:
|
|
parsed = shlex.split(raw_value, comments=True, posix=True)
|
|
except ValueError:
|
|
continue
|
|
if len(parsed) == 1:
|
|
values[key] = parsed[0]
|
|
return values
|
|
|
|
|
|
def normalize_url(value: str) -> str:
|
|
value = value.strip().rstrip("/")
|
|
if not value:
|
|
return ""
|
|
parsed = urllib.parse.urlsplit(value)
|
|
if (
|
|
parsed.scheme not in {"http", "https"}
|
|
or not parsed.hostname
|
|
or parsed.username
|
|
or parsed.password
|
|
):
|
|
raise ValueError("invalid-url")
|
|
return value
|
|
|
|
|
|
def parse_entity_ids(value: str | Sequence[str]) -> tuple[str, ...]:
|
|
candidates = value.split(",") if isinstance(value, str) else value
|
|
result: list[str] = []
|
|
for candidate in candidates:
|
|
entity_id = str(candidate).strip()
|
|
if not entity_id:
|
|
continue
|
|
if not ENTITY_ID.fullmatch(entity_id):
|
|
raise ValueError("invalid-entity")
|
|
if entity_id not in result:
|
|
result.append(entity_id)
|
|
return tuple(result)
|
|
|
|
|
|
def parse_gsettings_string(output: str) -> str:
|
|
try:
|
|
value = ast.literal_eval(output.strip())
|
|
except (SyntaxError, ValueError):
|
|
return ""
|
|
return value if isinstance(value, str) else ""
|
|
|
|
|
|
def parse_gsettings_array(output: str) -> tuple[str, ...]:
|
|
stripped = output.strip()
|
|
if stripped.startswith("@as "):
|
|
stripped = stripped[4:].strip()
|
|
try:
|
|
value = ast.literal_eval(stripped)
|
|
except (SyntaxError, ValueError):
|
|
return ()
|
|
if not isinstance(value, (list, tuple)):
|
|
return ()
|
|
return tuple(item for item in value if isinstance(item, str))
|
|
|
|
|
|
def run_command(
|
|
command: list[str],
|
|
*,
|
|
runner: Runner = subprocess.run,
|
|
timeout: float = 5,
|
|
) -> subprocess.CompletedProcess[str]:
|
|
return runner(
|
|
command,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=timeout,
|
|
check=False,
|
|
)
|
|
|
|
|
|
def gsettings_value(key: str, runner: Runner = subprocess.run) -> str:
|
|
if not GNOME_SCHEMA_DIR.is_dir():
|
|
return ""
|
|
try:
|
|
result = run_command(
|
|
[
|
|
"gsettings",
|
|
"--schemadir",
|
|
str(GNOME_SCHEMA_DIR),
|
|
"get",
|
|
GNOME_SCHEMA,
|
|
key,
|
|
],
|
|
runner=runner,
|
|
)
|
|
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
return ""
|
|
return result.stdout.strip() if result.returncode == 0 else ""
|
|
|
|
|
|
def secret_service_token(runner: Runner = subprocess.run) -> str:
|
|
try:
|
|
result = run_command(
|
|
["secret-tool", "lookup", "token_string", "user_token"],
|
|
runner=runner,
|
|
)
|
|
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
return ""
|
|
return result.stdout.strip() if result.returncode == 0 else ""
|
|
|
|
|
|
def load_legacy_config(runner: Runner = subprocess.run) -> Config:
|
|
base_url = parse_gsettings_string(gsettings_value("hass-url", runner))
|
|
entity_ids = parse_gsettings_array(
|
|
gsettings_value("hass-enabled-entities", runner)
|
|
)
|
|
token = secret_service_token(runner)
|
|
return Config(base_url=base_url, token=token, entity_ids=entity_ids)
|
|
|
|
|
|
def resolve_config(
|
|
env: Mapping[str, str] | None = None,
|
|
legacy: Callable[[], Config] = load_legacy_config,
|
|
) -> Config:
|
|
# An explicitly supplied environment is the WHOLE environment. Reading the
|
|
# user's private env file underneath it makes callers -- tests especially --
|
|
# depend on whatever happens to be in that file: adding a real
|
|
# PANAMA_HOME_ASSISTANT_ENTITIES to it silently overrode a fixture that was
|
|
# asserting the legacy fallback. Production passes env=None and still gets
|
|
# the file.
|
|
if env is None:
|
|
private_env = read_panama_env()
|
|
private_env.update(dict(os.environ))
|
|
else:
|
|
private_env = dict(env)
|
|
|
|
url_value = private_env.get("PANAMA_HOME_ASSISTANT_URL", "").strip()
|
|
token_value = private_env.get("PANAMA_HOME_ASSISTANT_TOKEN", "").strip()
|
|
entities_value = private_env.get("PANAMA_HOME_ASSISTANT_ENTITIES", "").strip()
|
|
|
|
legacy_config = Config("", "", ())
|
|
if not (url_value and token_value and entities_value):
|
|
try:
|
|
legacy_config = legacy()
|
|
except (OSError, subprocess.SubprocessError, ValueError):
|
|
legacy_config = Config("", "", ())
|
|
|
|
base_url = normalize_url(url_value or legacy_config.base_url)
|
|
token = token_value or legacy_config.token
|
|
entity_ids = (
|
|
parse_entity_ids(entities_value)
|
|
if entities_value
|
|
else parse_entity_ids(legacy_config.entity_ids)
|
|
)
|
|
return Config(base_url=base_url, token=token, entity_ids=entity_ids)
|
|
|
|
|
|
def public_http_error(status: int, body: str) -> str:
|
|
del body
|
|
return "authentication-required" if status in {401, 403} else "request-failed"
|
|
|
|
|
|
def request_json(
|
|
config: Config,
|
|
method: str,
|
|
path: str,
|
|
payload: Mapping[str, object] | None = None,
|
|
) -> object:
|
|
if not config.base_url or not config.token:
|
|
raise BridgeError("not-configured")
|
|
body = None
|
|
if payload is not None:
|
|
body = json.dumps(payload, separators=(",", ":")).encode()
|
|
request = urllib.request.Request(
|
|
config.base_url + path,
|
|
data=body,
|
|
method=method,
|
|
headers={
|
|
"Authorization": f"Bearer {config.token}",
|
|
"Content-Type": "application/json",
|
|
},
|
|
)
|
|
try:
|
|
with urllib.request.urlopen(request, timeout=5) as response:
|
|
response_body = response.read()
|
|
except urllib.error.HTTPError as error:
|
|
try:
|
|
error_body = error.read().decode(errors="replace")
|
|
except OSError:
|
|
error_body = ""
|
|
raise BridgeError(public_http_error(error.code, error_body)) from None
|
|
except (TimeoutError, urllib.error.URLError, OSError):
|
|
raise BridgeError("unreachable") from None
|
|
|
|
if not response_body:
|
|
return None
|
|
try:
|
|
return json.loads(response_body)
|
|
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
raise BridgeError("invalid-response") from None
|
|
|
|
|
|
def fallback_name(entity_id: str) -> str:
|
|
return entity_id.split(".", 1)[1].replace("_", " ").title()
|
|
|
|
|
|
def normalize_catalog(raw: Sequence[object]) -> list[dict[str, object]]:
|
|
result: list[dict[str, object]] = []
|
|
for item in raw:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
entity_id = item.get("entity_id")
|
|
attributes = item.get("attributes")
|
|
if not isinstance(entity_id, str) or not entity_id.startswith("light."):
|
|
continue
|
|
if not ENTITY_ID.fullmatch(entity_id) or not isinstance(attributes, dict):
|
|
continue
|
|
state = str(item.get("state", "unavailable"))
|
|
available = state not in {"unknown", "unavailable"}
|
|
active = available and state == "on"
|
|
raw_brightness = attributes.get("brightness")
|
|
brightness_pct = (
|
|
round(max(0, min(255, raw_brightness)) * 100 / 255)
|
|
if active
|
|
and isinstance(raw_brightness, (int, float))
|
|
and not isinstance(raw_brightness, bool)
|
|
else 0
|
|
)
|
|
modes = attributes.get("supported_color_modes", [])
|
|
dimmable = (
|
|
isinstance(modes, list) and any(mode != "onoff" for mode in modes)
|
|
) or isinstance(raw_brightness, (int, float))
|
|
source_name = attributes.get("friendly_name")
|
|
result.append(
|
|
{
|
|
"id": entity_id,
|
|
"sourceName": (
|
|
source_name.strip()
|
|
if isinstance(source_name, str) and source_name.strip()
|
|
else fallback_name(entity_id)
|
|
),
|
|
"state": state,
|
|
"available": available,
|
|
"active": active,
|
|
"dimmable": dimmable,
|
|
"brightnessPct": brightness_pct,
|
|
}
|
|
)
|
|
return result
|
|
|
|
|
|
def collect_catalog(config: Config) -> dict[str, object]:
|
|
legacy_entity_ids = list(config.entity_ids)
|
|
if not config.configured:
|
|
return {
|
|
"ok": False,
|
|
"configured": False,
|
|
"generatedAt": int(time.time()),
|
|
"entities": [],
|
|
"legacyEntityIds": legacy_entity_ids,
|
|
"error": "not-configured",
|
|
}
|
|
try:
|
|
raw = request_json(config, "GET", "/api/states")
|
|
if not isinstance(raw, list):
|
|
raise BridgeError("invalid-response")
|
|
entities = normalize_catalog(raw)
|
|
except BridgeError as error:
|
|
return {
|
|
"ok": False,
|
|
"configured": True,
|
|
"generatedAt": int(time.time()),
|
|
"entities": [],
|
|
"legacyEntityIds": legacy_entity_ids,
|
|
"error": str(error),
|
|
}
|
|
return {
|
|
"ok": True,
|
|
"configured": True,
|
|
"generatedAt": int(time.time()),
|
|
"entities": entities,
|
|
"legacyEntityIds": legacy_entity_ids,
|
|
"error": "",
|
|
}
|
|
|
|
|
|
def collect_snapshot(config: Config) -> dict[str, object]:
|
|
return collect_catalog(config)
|
|
|
|
|
|
def discovered_light_ids(config: Config) -> set[str]:
|
|
raw = request_json(config, "GET", "/api/states")
|
|
if not isinstance(raw, list):
|
|
raise BridgeError("invalid-response")
|
|
return {item["id"] for item in normalize_catalog(raw) if isinstance(item["id"], str)}
|
|
|
|
|
|
def ensure_discovered(config: Config, entity_id: str) -> str:
|
|
if entity_id not in discovered_light_ids(config):
|
|
raise ValueError("entity-not-discovered")
|
|
return entity_id
|
|
|
|
|
|
def probe(config: Config) -> dict[str, object]:
|
|
if not config.configured:
|
|
return {
|
|
"configured": False,
|
|
"reachable": False,
|
|
"entityCount": len(config.entity_ids),
|
|
"error": "not-configured",
|
|
}
|
|
try:
|
|
request_json(config, "GET", "/api/")
|
|
except BridgeError as error:
|
|
return {
|
|
"configured": True,
|
|
"reachable": False,
|
|
"entityCount": len(config.entity_ids),
|
|
"error": str(error),
|
|
}
|
|
return {
|
|
"configured": True,
|
|
"reachable": True,
|
|
"entityCount": len(config.entity_ids),
|
|
"error": "",
|
|
}
|
|
|
|
|
|
def toggle(config: Config, entity_id: str) -> dict[str, object]:
|
|
entity_id = ensure_discovered(config, entity_id)
|
|
request_json(
|
|
config,
|
|
"POST",
|
|
"/api/services/homeassistant/toggle",
|
|
{"entity_id": entity_id},
|
|
)
|
|
return {"ok": True, "entityId": entity_id, "error": ""}
|
|
|
|
|
|
def set_brightness(
|
|
config: Config, entity_id: str, percent: int
|
|
) -> dict[str, object]:
|
|
if isinstance(percent, bool) or not isinstance(percent, int) or not 0 <= percent <= 100:
|
|
raise ValueError("invalid-brightness")
|
|
ensure_discovered(config, entity_id)
|
|
if percent == 0:
|
|
path = "/api/services/light/turn_off"
|
|
payload = {"entity_id": entity_id}
|
|
else:
|
|
path = "/api/services/light/turn_on"
|
|
payload = {"entity_id": entity_id, "brightness_pct": percent}
|
|
request_json(config, "POST", path, payload)
|
|
return {"ok": True, "entityId": entity_id, "brightnessPct": percent, "error": ""}
|
|
|
|
|
|
def parse_brightness(value: str) -> int:
|
|
if not re.fullmatch(r"(?:0|[1-9][0-9]{0,2})", value):
|
|
raise ValueError("invalid-brightness")
|
|
percent = int(value)
|
|
if percent > 100:
|
|
raise ValueError("invalid-brightness")
|
|
return percent
|
|
|
|
|
|
def open_home(config: Config) -> dict[str, object]:
|
|
if not config.base_url:
|
|
return {"ok": False, "error": "not-configured"}
|
|
try:
|
|
subprocess.Popen(
|
|
["xdg-open", config.base_url],
|
|
stdin=subprocess.DEVNULL,
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
start_new_session=True,
|
|
)
|
|
except OSError:
|
|
return {"ok": False, "error": "open-failed"}
|
|
return {"ok": True, "error": ""}
|
|
|
|
|
|
def print_result(value: dict[str, object]) -> None:
|
|
sys.stdout.write(compact_json(value) + "\n")
|
|
|
|
|
|
def main(argv: list[str]) -> int:
|
|
try:
|
|
config = resolve_config()
|
|
except ValueError as error:
|
|
print_result({"ok": False, "configured": False, "error": str(error)})
|
|
return 2
|
|
|
|
command = argv[0] if argv else "probe"
|
|
if command == "probe" and len(argv) <= 1:
|
|
result = probe(config)
|
|
success = bool(result["reachable"])
|
|
elif command in {"catalog", "snapshot"} and len(argv) == 1:
|
|
result = collect_catalog(config)
|
|
success = bool(result["ok"])
|
|
elif command == "toggle" and len(argv) == 2:
|
|
try:
|
|
result = toggle(config, argv[1])
|
|
except ValueError as error:
|
|
result = {"ok": False, "error": str(error)}
|
|
except BridgeError as error:
|
|
result = {"ok": False, "error": str(error)}
|
|
success = bool(result["ok"])
|
|
elif command == "brightness" and len(argv) == 3:
|
|
try:
|
|
result = set_brightness(config, argv[1], parse_brightness(argv[2]))
|
|
except ValueError as error:
|
|
result = {"ok": False, "error": str(error)}
|
|
except BridgeError as error:
|
|
result = {"ok": False, "error": str(error)}
|
|
success = bool(result["ok"])
|
|
elif command == "open" and len(argv) == 1:
|
|
result = open_home(config)
|
|
success = bool(result["ok"])
|
|
else:
|
|
result = {"ok": False, "error": "invalid-command"}
|
|
success = False
|
|
print_result(result)
|
|
return 0 if success else 2
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main(sys.argv[1:]))
|