From f0c527f3553d95a3a992c9ba7ff66b2e6471ea60 Mon Sep 17 00:00:00 2001 From: Ignace Date: Sun, 9 Aug 2026 16:32:42 +0200 Subject: [PATCH] workable POC --- .gitignore | 4 + README.md | 218 ++++++++++++++++++++++++ app.py | 257 ++++++++++++++++++++++++++++ config.yaml | 2 + hue.py | 282 +++++++++++++++++++++++++++++++ hue.yaml | 387 +++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 3 + static/app.css | 14 ++ static/app.js | 102 ++++++++++++ templates/index.html | 24 +++ 10 files changed, 1293 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app.py create mode 100644 config.yaml create mode 100644 hue.py create mode 100644 hue.yaml create mode 100644 requirements.txt create mode 100644 static/app.css create mode 100644 static/app.js create mode 100644 templates/index.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..134fcd0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.venv/ +__pycache__/ +*.py[cod] +secrets.yaml diff --git a/README.md b/README.md new file mode 100644 index 0000000..3585881 --- /dev/null +++ b/README.md @@ -0,0 +1,218 @@ +# FastHue + +A local, iPhone-first Philips Hue dashboard and command-line client. It works +with a Hue Bridge or Hue Bridge Pro on the same network. + +## Dashboard + +The dashboard fills an iPhone screen with eight dark controls in a 2 × 4 grid. + +- A blue edge means at least one light in that room or zone is on. +- The subtitle is the active Hue scene, when one is active. +- Tap a button to activate the next configured scene. +- Press and hold a button for about 0.65 seconds to turn its lights off. + +### Start it + +First register the app with the bridge (you only need to do this once): + +```sh +python3 hue.py register +``` + +Press the physical button on the bridge when prompted. Then install the small +web app dependencies and run it: + +```sh +python3 -m venv .venv +.venv/bin/pip install -r requirements.txt +.venv/bin/python app.py +``` + +On the iPhone, open `http://YOUR-COMPUTER-IP:5000/?key=YOUR_API_KEY` in Safari. +The key is saved only for that browser session and removed from the displayed +URL. The iPhone and computer must be on the same local network. + +## Configure buttons and scenes + +`hue.yaml` controls the dashboard. The `buttons` entries define button order, +the target room/zone, and the ordered scenes to cycle through: + +```yaml +buttons: + 1: + name: Ground floor + scenes: + - Sunset allure + - Relax +``` + +Each button name must match a `rooms` or `zones` entry. Scene names must match +scenes for that same group in the main `scenes` catalog. The app expects exactly +eight buttons. + +## Command line client + +```sh +# Find local bridges +python3 hue.py discover + +# List rooms, zones, and scenes +python3 hue.py list + +# Activate a scene or control a group directly +python3 hue.py scene "Evening relax" +python3 hue.py on "Living room" +python3 hue.py off "zone:Downstairs" +``` + +Credentials are saved with owner-only permissions in +`secrets.yaml`: + +```yaml +bridge: 192.168.1.10 +app_key: your-hue-application-key. # the hue bride api key +api_key: your-dashboard-api-key # the key for this services api +``` + +The file is ignored by Git and should not be shared. `hue.py register` creates +or updates it, including a random dashboard API key. You can override values +for either tool with environment variables: + +```sh +FASTHUE_API_KEY=YOUR_DASHBOARD_KEY HUE_BRIDGE=192.168.1.10 HUE_APP_KEY=YOUR_HUE_KEY .venv/bin/python app.py +``` + +The reverse-proxy path is configured separately in `config.yaml`: + +```yaml +url_prefix: /hue +``` + +Set it to an empty value when FastHue is served at `/`. + +## HTTP API + +Every API request needs the single `api_key` from `secrets.yaml` as a Bearer +token. Override it with `FASTHUE_API_KEY` or `--api-key` when necessary. + +```sh +export FASTHUE_API_KEY='the-api_key-from-secrets.yaml' +curl -H "Authorization: Bearer $FASTHUE_API_KEY" http://localhost:5000/api/groups +curl -X POST -H "Authorization: Bearer $FASTHUE_API_KEY" \ + http://localhost:5000/api/groups/GROUP_UUID/next-scene +curl -X POST -H "Authorization: Bearer $FASTHUE_API_KEY" \ + http://localhost:5000/api/groups/GROUP_UUID/off +curl -X POST -H "Authorization: Bearer $FASTHUE_API_KEY" \ + -H 'Content-Type: application/json' \ + -d '{"name":"Relax"}' \ + 'http://localhost:5000/api/groups/Ground%20floor/scene' +``` + +`GET /api/groups` returns each configured group ID, its power state, and its +active scene. Use those IDs for the `next-scene` and `off` endpoints. The +`scene` endpoint uses the URL-encoded room or zone name (not its UUID), and +activates the named scene only when it belongs to that group. Keep this service on +a trusted network; the included Flask server uses HTTP, so place it behind an +HTTPS reverse proxy if it needs to cross an untrusted network. + +## Deploy with Gunicorn and Nginx + +These instructions target a Linux host running systemd. Gunicorn listens on a +Unix socket; Nginx is the only public-facing process. + +Install the project and dependencies under a service account, then place its +credentials outside the web root: + +```sh +sudo useradd --system --create-home --shell /usr/sbin/nologin fasthue +sudo mkdir -p /opt/fasthue /etc/fasthue +sudo chown fasthue:fasthue /opt/fasthue /etc/fasthue +# Copy this project into /opt/fasthue, then: +sudo -u fasthue python3 -m venv /opt/fasthue/.venv +sudo -u fasthue /opt/fasthue/.venv/bin/pip install -r /opt/fasthue/requirements.txt +sudo install -o fasthue -g fasthue -m 600 secrets.yaml /etc/fasthue/secrets.yaml +``` + +Create `/etc/systemd/system/fasthue.service`: + +```ini +[Unit] +Description=FastHue dashboard +After=network-online.target +Wants=network-online.target + +[Service] +Type=exec +User=fasthue +# Use your Nginx worker group (commonly www-data; use nginx on some distros). +Group=www-data +WorkingDirectory=/opt/fasthue +Environment=FASTHUE_SECRETS_FILE=/etc/fasthue/secrets.yaml +RuntimeDirectory=fasthue +RuntimeDirectoryMode=0750 +ExecStart=/opt/fasthue/.venv/bin/gunicorn --workers 1 --umask 007 --bind unix:/run/fasthue/fasthue.sock --factory app:create_gunicorn_app +Restart=on-failure +RestartSec=3 + +[Install] +WantedBy=multi-user.target +``` + +Enable it: + +```sh +sudo systemctl daemon-reload +sudo systemctl enable --now fasthue +sudo systemctl status fasthue +``` + +Configure Nginx with a TLS-enabled virtual host (replace the hostname and +certificate paths). This example publishes FastHue at `/hue`: + +```nginx +server { + listen 443 ssl http2; + server_name hue.example.com; + + ssl_certificate /etc/letsencrypt/live/hue.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/hue.example.com/privkey.pem; + + location = /hue { + return 302 /hue/; + } + + location /hue/ { + # The trailing slash strips /hue before proxying to Flask. + proxy_pass http://unix:/run/fasthue/fasthue.sock:/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Prefix /hue; + proxy_set_header Authorization $http_authorization; + } +} +``` + +The `www-data` group in the systemd unit and `--umask 007` make the socket +readable by Nginx. If your Nginx workers use another group, replace `www-data` +in the unit with that group. Test and reload Nginx with +`sudo nginx -t && sudo systemctl reload nginx`. +Use the HTTPS URL on the iPhone with `/hue/?key=YOUR_API_KEY` once per browser +session. Set `url_prefix` in `config.yaml` to another leading-slash prefix when +needed, and use the same value in Nginx's `location` and +`X-Forwarded-Prefix`. Keep +`secrets.yaml` readable only by the service account. + +The bridge typically has a locally issued TLS certificate, so certificate +verification is disabled by default. Add `--verify-tls` only when your computer +trusts that certificate. + +## Files + +- `app.py` — Flask dashboard and Hue API integration +- `hue.py` — dependency-free command-line client and registration tool +- `config.yaml` — reverse-proxy settings +- `hue.yaml` — button, group, and scene configuration +- `static/` and `templates/` — phone interface diff --git a/app.py b/app.py new file mode 100644 index 0000000..936f397 --- /dev/null +++ b/app.py @@ -0,0 +1,257 @@ +#!/usr/bin/env python3 +"""Phone-first Hue room and zone dashboard.""" + +from __future__ import annotations + +import argparse +import hmac +import os +from pathlib import Path +from typing import Any + +import yaml +from flask import Flask, jsonify, render_template, request +from werkzeug.middleware.proxy_fix import ProxyFix + +from hue import DEFAULT_CONFIG, HueBridge, HueError, load_config + + +ROOT = Path(__file__).resolve().parent +APP_CONFIG = ROOT / "config.yaml" + + +def read_dashboard(path: Path) -> list[dict[str, Any]]: + try: + data = yaml.safe_load(path.read_text()) + buttons = data["buttons"] + rooms = {item["name"]: {**item, "kind": "room"} for item in data["rooms"]} + zones = {item["name"]: {**item, "kind": "zone"} for item in data["zones"]} + except (OSError, KeyError, TypeError, yaml.YAMLError) as exc: + raise RuntimeError(f"Could not read dashboard configuration {path}: {exc}") from exc + + result = [] + for position, button in sorted(buttons.items(), key=lambda item: int(item[0])): + # A simple name is retained as a backwards-compatible configuration. + name = button["name"] if isinstance(button, dict) else button + group = rooms.get(name) or zones.get(name) + if not group: + raise RuntimeError(f"Button {position} refers to unknown room or zone {name!r}") + result.append({**group, "scenes": button.get("scenes", []) if isinstance(button, dict) else []}) + if len(result) != 8: + raise RuntimeError(f"Expected exactly 8 buttons, found {len(result)}") + return result + + +def make_bridge(verify_tls: bool) -> HueBridge: + config = load_config(Path(os.environ.get("FASTHUE_SECRETS_FILE", DEFAULT_CONFIG)).expanduser()) + host = os.environ.get("HUE_BRIDGE", config.get("bridge")) + app_key = os.environ.get("HUE_APP_KEY", config.get("app_key")) + if not host or not app_key: + raise HueError("No bridge/key available. Run `python hue.py register` first.") + return HueBridge(host, app_key, verify_tls) + + +def grouped_light_id(group: dict[str, Any]) -> str: + # Current bridges put the group's control service in `services`; older + # resource shapes exposed it among `children`. + for resource in group.get("services", []) + group.get("children", []): + if resource.get("rtype") == "grouped_light": + return resource["rid"] + raise HueError(f"{group['metadata']['name']!r} has no grouped-light service") + + +def light_ids(group: dict[str, Any], devices: dict[str, dict[str, Any]]) -> set[str]: + """Find actual lights, so an indicator means *any* light is on.""" + ids: set[str] = set() + for child in group.get("children", []): + if child.get("rtype") == "light": + ids.add(child["rid"]) + elif child.get("rtype") == "device": + device = devices.get(child.get("rid"), {}) + ids.update(service["rid"] for service in device.get("services", []) if service.get("rtype") == "light") + return ids + + +def scene_is_active(scene: dict[str, Any]) -> bool: + """Hue returns `inactive`, `static`, or a dynamic scene mode in status.active.""" + active = scene.get("status", {}).get("active") + return active is True or (active not in (None, False, "inactive")) + + +def dashboard_state(buttons: list[dict[str, str]], verify_tls: bool) -> list[dict[str, Any]]: + bridge = make_bridge(verify_tls) + groups = bridge.resources("room") + bridge.resources("zone") + group_by_id = {group["id"]: group for group in groups} + devices = {device["id"]: device for device in bridge.resources("device")} + lights = {light["id"]: light for light in bridge.resources("light")} + grouped = {item["id"]: item for item in bridge.resources("grouped_light")} + scenes = bridge.resources("scene") + + state = [] + for button in buttons: + group = group_by_id.get(button["id"]) + if not group: + raise HueError(f"{button['kind'].title()} {button['name']!r} is no longer present on the bridge") + ids = light_ids(group, devices) + if ids: + any_on = any(lights.get(light_id, {}).get("on", {}).get("on", False) for light_id in ids) + else: + # Some bridges expose only the grouped-light service for a group. + any_on = grouped.get(grouped_light_id(group), {}).get("on", {}).get("on", False) + active_scene = next( + (scene.get("metadata", {}).get("name") for scene in scenes + if scene.get("group", {}).get("rid") == button["id"] and scene_is_active(scene)), + None, + ) + state.append({"id": button["id"], "name": button["name"], "on": any_on, "scene": active_scene}) + return state + + +def next_scene(button: dict[str, Any], bridge: HueBridge) -> dict[str, Any]: + """Recall the scene after the currently active configured scene.""" + catalog = [scene for scene in bridge.resources("scene") if scene.get("group", {}).get("rid") == button["id"]] + by_name = {scene.get("metadata", {}).get("name"): scene for scene in catalog} + try: + ordered = [by_name[name] for name in button["scenes"]] + except KeyError as exc: + raise HueError(f"Configured scene {exc.args[0]!r} is no longer on the bridge") from exc + if not ordered: + raise HueError(f"No scenes are configured for {button['name']!r}") + + active_index = next((index for index, scene in enumerate(ordered) if scene_is_active(scene)), None) + selected = ordered[0] if active_index is None else ordered[(active_index + 1) % len(ordered)] + bridge.v2(f"/scene/{selected['id']}", "PUT", {"recall": {"action": "active"}}) + return {"name": selected.get("metadata", {}).get("name", "scene")} + + +def create_app(yaml_path: Path, verify_tls: bool, api_key: str, url_prefix: str = "") -> Flask: + app = Flask(__name__) + if url_prefix: + # Gunicorn only receives traffic over its private socket from Nginx. + # Nginx strips this prefix and supplies it in X-Forwarded-Prefix. + app.wsgi_app = ProxyFix(app.wsgi_app, x_prefix=1) + buttons = read_dashboard(yaml_path) + + def api_key_required(view: Any) -> Any: + def protected(*args: Any, **kwargs: Any) -> Any: + authorization = request.headers.get("Authorization", "") + expected = f"Bearer {api_key}" + if not hmac.compare_digest(authorization, expected): + return jsonify({"error": "Unauthorized"}), 401 + return view(*args, **kwargs) + protected.__name__ = view.__name__ + return protected + + @app.get("/") + def index() -> str: + return render_template("index.html", buttons=buttons) + + @app.get("/api/groups") + @api_key_required + def groups() -> Any: + try: + return jsonify({"groups": dashboard_state(buttons, verify_tls)}) + except (HueError, RuntimeError) as exc: + return jsonify({"error": str(exc)}), 503 + + @app.post("/api/groups//next-scene") + @api_key_required + def activate_next_scene(group_id: str) -> Any: + try: + button = next(item for item in buttons if item["id"] == group_id) + bridge = make_bridge(verify_tls) + scene = next_scene(button, bridge) + return jsonify({"id": group_id, "scene": scene["name"]}) + except StopIteration: + return jsonify({"error": "Unknown dashboard group"}), 404 + except (HueError, RuntimeError) as exc: + return jsonify({"error": str(exc)}), 503 + + @app.post("/api/groups//off") + @api_key_required + def turn_group_off(group_id: str) -> Any: + try: + button = next(item for item in buttons if item["id"] == group_id) + bridge = make_bridge(verify_tls) + group = next(item for item in bridge.resources(button["kind"]) if item["id"] == group_id) + bridge.v2(f"/grouped_light/{grouped_light_id(group)}", "PUT", {"on": {"on": False}}) + return jsonify({"id": group_id, "on": False}) + except StopIteration: + return jsonify({"error": "Unknown dashboard group"}), 404 + except (HueError, RuntimeError) as exc: + return jsonify({"error": str(exc)}), 503 + + @app.post("/api/groups//scene") + @api_key_required + def activate_group_scene(group_name: str) -> Any: + payload = request.get_json(silent=True) or {} + scene_name = payload.get("name") + if not isinstance(scene_name, str) or not scene_name.strip(): + return jsonify({"error": "JSON body must include a non-empty scene name"}), 400 + try: + bridge = make_bridge(verify_tls) + groups = bridge.resources("room") + bridge.resources("zone") + matching_groups = [ + group for group in groups + if group.get("metadata", {}).get("name", "").casefold() == group_name.casefold() + ] + if not matching_groups: + return jsonify({"error": f"No room or zone named {group_name!r}"}), 404 + if len(matching_groups) > 1: + return jsonify({"error": f"{group_name!r} matches more than one room or zone"}), 409 + group = matching_groups[0] + scene = next( + item for item in bridge.resources("scene") + if item.get("group", {}).get("rid") == group["id"] + and item.get("metadata", {}).get("name", "").casefold() == scene_name.casefold() + ) + bridge.v2(f"/scene/{scene['id']}", "PUT", {"recall": {"action": "active"}}) + return jsonify({"group": group["metadata"]["name"], "scene": scene["metadata"]["name"]}) + except StopIteration: + return jsonify({"error": f"No scene named {scene_name!r} belongs to this room or zone"}), 404 + except (HueError, RuntimeError) as exc: + return jsonify({"error": str(exc)}), 503 + + return app + + +def configured_url_prefix(path: Path = APP_CONFIG) -> str: + """Read and validate the reverse-proxy URL prefix from config.yaml.""" + url_prefix = load_config(path).get("url_prefix", "").rstrip("/") + if url_prefix and not url_prefix.startswith("/"): + raise RuntimeError("config.yaml url_prefix must start with /, for example /hue") + return url_prefix + + +def create_gunicorn_app() -> Flask: + """Gunicorn application factory; configuration is read from environment/files.""" + config_path = Path(os.environ.get("FASTHUE_DASHBOARD_CONFIG", ROOT / "hue.yaml")) + secrets_config = load_config(Path(os.environ.get("FASTHUE_SECRETS_FILE", DEFAULT_CONFIG)).expanduser()) + api_key = os.environ.get("FASTHUE_API_KEY") or secrets_config.get("api_key") + if not api_key: + raise RuntimeError("add api_key to secrets.yaml or set FASTHUE_API_KEY") + verify_tls = os.environ.get("FASTHUE_VERIFY_TLS", "").casefold() in {"1", "true", "yes"} + return create_app(config_path, verify_tls, api_key, configured_url_prefix()) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--config", type=Path, default=ROOT / "hue.yaml", help="Dashboard YAML file") + parser.add_argument("--verify-tls", action="store_true", help="Verify the Hue bridge certificate") + parser.add_argument("--api-key", help="Required Bearer token for the dashboard API") + parser.add_argument("--url-prefix", help="Override the reverse-proxy URL prefix from config.yaml") + parser.add_argument("--host", default="0.0.0.0", help="Listen address") + parser.add_argument("--port", type=int, default=5000, help="Listen port") + args = parser.parse_args() + secrets_config = load_config(Path(os.environ.get("FASTHUE_SECRETS_FILE", DEFAULT_CONFIG)).expanduser()) + api_key = args.api_key or os.environ.get("FASTHUE_API_KEY") or secrets_config.get("api_key") + if not api_key: + parser.error("add api_key to secrets.yaml, set FASTHUE_API_KEY, or supply --api-key") + url_prefix = args.url_prefix.rstrip("/") if args.url_prefix is not None else configured_url_prefix() + if url_prefix and not url_prefix.startswith("/"): + parser.error("--url-prefix must start with /, for example /hue") + create_app(args.config, args.verify_tls, api_key, url_prefix).run(host=args.host, port=args.port, debug=False) + + +if __name__ == "__main__": + main() diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..7f042cd --- /dev/null +++ b/config.yaml @@ -0,0 +1,2 @@ +# URL where a reverse proxy publishes FastHue. Leave empty when serving at /. +url_prefix: /hue diff --git a/hue.py b/hue.py new file mode 100644 index 0000000..11aeed4 --- /dev/null +++ b/hue.py @@ -0,0 +1,282 @@ +#!/usr/bin/env python3 +"""A small, dependency-free command line client for a local Philips Hue bridge. + +The first time you use it, run ``python hue.py register`` while on the same +network as the bridge. Press the physical button on the bridge when prompted. +""" + +from __future__ import annotations + +import argparse +import json +import secrets +import ssl +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Any +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen + + +DISCOVERY_URL = "https://discovery.meethue.com/" +DEFAULT_CONFIG = Path(__file__).resolve().parent / "secrets.yaml" + + +class HueError(RuntimeError): + pass + + +def request_json( + url: str, + method: str = "GET", + payload: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + verify_tls: bool = False, +) -> Any: + """Make a JSON request. Hue bridges commonly use a local self-signed cert.""" + body = json.dumps(payload).encode() if payload is not None else None + request = Request(url, data=body, method=method) + request.add_header("Accept", "application/json") + if payload is not None: + request.add_header("Content-Type", "application/json") + for name, value in (headers or {}).items(): + request.add_header(name, value) + context = ssl.create_default_context() if verify_tls else ssl._create_unverified_context() + try: + with urlopen(request, timeout=10, context=context) as response: + return json.loads(response.read().decode()) + except HTTPError as exc: + detail = exc.read().decode(errors="replace") + raise HueError(f"Bridge returned HTTP {exc.code}: {detail}") from exc + except (URLError, TimeoutError) as exc: + raise HueError(f"Could not reach {url}: {exc}") from exc + except json.JSONDecodeError as exc: + raise HueError(f"{url} did not return JSON") from exc + + +def discover(verify_tls: bool) -> list[dict[str, Any]]: + result = request_json(DISCOVERY_URL, verify_tls=verify_tls) + if not isinstance(result, list): + raise HueError("Unexpected response from Hue discovery service") + return result + + +def load_config(path: Path) -> dict[str, str]: + if not path.exists(): + return {} + try: + contents = path.read_text() + if path.suffix in {".yaml", ".yml"}: + data = {} + for line in contents.splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + key, separator, value = line.partition(":") + if not separator or not key.strip(): + raise ValueError("expected simple key: value entries") + data[key.strip()] = value.strip().strip("'\"") + else: + data = json.loads(contents) + return {key: str(value) for key, value in data.items()} + except (OSError, ValueError, json.JSONDecodeError) as exc: + raise HueError(f"Could not read {path}: {exc}") from exc + + +def save_config(path: Path, data: dict[str, str]) -> None: + path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + if path.suffix in {".yaml", ".yml"}: + path.write_text("".join(f"{key}: {value!r}\n" for key, value in data.items())) + else: + path.write_text(json.dumps(data, indent=2) + "\n") + path.chmod(0o600) + + +@dataclass +class HueBridge: + host: str + app_key: str | None + verify_tls: bool + + @property + def api_url(self) -> str: + return f"https://{self.host}/api" + + @property + def v2_url(self) -> str: + return f"https://{self.host}/clip/v2/resource" + + def v2(self, path: str, method: str = "GET", payload: dict[str, Any] | None = None) -> Any: + if not self.app_key: + raise HueError("No application key. Run `python hue.py register` first.") + return request_json( + self.v2_url + path, + method=method, + payload=payload, + headers={"hue-application-key": self.app_key}, + verify_tls=self.verify_tls, + ) + + def resources(self, resource_type: str) -> list[dict[str, Any]]: + result = self.v2("/" + resource_type) + data = result.get("data") if isinstance(result, dict) else None + if not isinstance(data, list): + raise HueError(f"Unexpected {resource_type} response from bridge") + return data + + +def bridge_from_args(args: argparse.Namespace, require_key: bool = True) -> tuple[HueBridge, Path]: + config_path = Path(args.config).expanduser() + config = load_config(config_path) + host = args.bridge or config.get("bridge") + if not host: + bridges = discover(args.verify_tls) + if len(bridges) == 1: + host = bridges[0].get("internalipaddress") + if host: + print(f"Discovered bridge at {host}", file=sys.stderr) + elif not bridges: + raise HueError("No Hue bridge found. Supply --bridge IP_OR_HOSTNAME.") + else: + choices = ", ".join(str(item.get("internalipaddress")) for item in bridges) + raise HueError(f"Found multiple bridges ({choices}). Supply --bridge IP_OR_HOSTNAME.") + key = args.app_key or config.get("app_key") + if require_key and not key: + raise HueError("No application key. Run `python hue.py register` first.") + return HueBridge(str(host), key, args.verify_tls), config_path + + +def display_resources(bridge: HueBridge) -> None: + rooms = bridge.resources("room") + zones = bridge.resources("zone") + groups = {item["id"]: item.get("metadata", {}).get("name", "unnamed") for item in rooms + zones} + print("Rooms:") + for item in rooms: + print(f" {item.get('metadata', {}).get('name', 'unnamed')} [{item['id']}]") + print("Zones:") + for item in zones: + print(f" {item.get('metadata', {}).get('name', 'unnamed')} [{item['id']}]") + print("Scenes:") + for item in bridge.resources("scene"): + name = item.get("metadata", {}).get("name", "unnamed") + group = item.get("group", {}) + group_name = groups.get(group.get("rid"), group.get("rid", "unknown group")) + print(f" {name} ({group_name}) [{item['id']}]") + + +def select_named(resources: list[dict[str, Any]], name: str, label: str) -> dict[str, Any]: + matches = [item for item in resources if item.get("metadata", {}).get("name", "").casefold() == name.casefold()] + if not matches: + raise HueError(f"No {label} named {name!r}.") + if len(matches) > 1: + ids = ", ".join(item["id"] for item in matches) + raise HueError(f"More than one {label} is named {name!r}: {ids}") + return matches[0] + + +def child_grouped_light(group: dict[str, Any]) -> str: + for resource in group.get("services", []) + group.get("children", []): + if resource.get("rtype") == "grouped_light" and resource.get("rid"): + return str(resource["rid"]) + raise HueError(f"{group.get('metadata', {}).get('name', 'Group')!r} has no grouped-light service") + + +def set_group_power(bridge: HueBridge, target: str, on: bool) -> None: + rooms, zones = bridge.resources("room"), bridge.resources("zone") + kind, separator, name = target.partition(":") + if separator: + if kind.casefold() == "room": + group = select_named(rooms, name, "room") + elif kind.casefold() == "zone": + group = select_named(zones, name, "zone") + else: + raise HueError("Prefix a target with room: or zone:, or use an unambiguous name.") + else: + matches = [item for item in rooms + zones if item.get("metadata", {}).get("name", "").casefold() == target.casefold()] + if not matches: + raise HueError(f"No room or zone named {target!r}.") + if len(matches) > 1: + raise HueError(f"{target!r} is ambiguous; use room:{target} or zone:{target}.") + group = matches[0] + bridge.v2(f"/grouped_light/{child_grouped_light(group)}", "PUT", {"on": {"on": on}}) + print(f"Turned {'on' if on else 'off'}: {group.get('metadata', {}).get('name', target)}") + + +def activate_scene(bridge: HueBridge, name: str) -> None: + scene = select_named(bridge.resources("scene"), name, "scene") + bridge.v2(f"/scene/{scene['id']}", "PUT", {"recall": {"action": "active"}}) + print(f"Activated scene: {scene.get('metadata', {}).get('name', name)}") + + +def register(args: argparse.Namespace) -> None: + bridge, config_path = bridge_from_args(args, require_key=False) + print("Press the round button on the Hue Bridge now, then press Enter here.") + input() + result = request_json( + bridge.api_url, + method="POST", + payload={"devicetype": args.device_type, "generateclientkey": True}, + verify_tls=args.verify_tls, + ) + try: + success = next(item["success"] for item in result if "success" in item) + app_key = success["username"] + except (KeyError, StopIteration, TypeError) as exc: + raise HueError(f"Registration was not accepted: {result}") from exc + existing = load_config(config_path) + save_config(config_path, { + **existing, + "bridge": bridge.host, + "app_key": app_key, + "api_key": existing.get("api_key", secrets.token_urlsafe(32)), + }) + print(f"Saved bridge address, application key, and dashboard API key in {config_path}") + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--bridge", help="Bridge IP address or hostname; auto-discovered if omitted") + parser.add_argument("--app-key", help="Hue application key; defaults to saved key") + parser.add_argument("--config", default=str(DEFAULT_CONFIG), help="Credential file (default: %(default)s)") + parser.add_argument("--verify-tls", action="store_true", help="Verify the bridge TLS certificate") + commands = parser.add_subparsers(dest="command", required=True) + registration = commands.add_parser("register", help="Create and save an application key") + registration.add_argument("--device-type", default="fasthue#python", help="Visible app name on the bridge") + commands.add_parser("discover", help="Show bridges found via Hue discovery") + commands.add_parser("list", help="List rooms, zones, and scenes") + scene = commands.add_parser("scene", help="Activate a scene by name") + scene.add_argument("name") + for action in ("on", "off"): + power = commands.add_parser(action, help=f"Turn a room or zone {action}") + power.add_argument("target", help="Name, or room:NAME / zone:NAME when ambiguous") + return parser + + +def main() -> int: + args = build_parser().parse_args() + try: + if args.command == "discover": + for item in discover(args.verify_tls): + print(f"{item.get('internalipaddress', 'unknown')} id={item.get('id', 'unknown')}") + elif args.command == "register": + register(args) + else: + bridge, _ = bridge_from_args(args) + if args.command == "list": + display_resources(bridge) + elif args.command == "scene": + activate_scene(bridge, args.name) + else: + set_group_power(bridge, args.target, args.command == "on") + return 0 + except HueError as exc: + print(f"Error: {exc}", file=sys.stderr) + return 2 + except KeyboardInterrupt: + print("Cancelled.", file=sys.stderr) + return 130 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/hue.yaml b/hue.yaml new file mode 100644 index 0000000..54d6d18 --- /dev/null +++ b/hue.yaml @@ -0,0 +1,387 @@ +--- +buttons: + 1: + name: Ground floor + scenes: + - Storybook + - Sunset allure + - Sfeervolle zonsondergang + - Relax + - Pumpkin patch + - Dreamy dusk + - Cancun + - Coastal nights + - Dreamy dusk 2 + 2: + name: Garden + scenes: + - Lily + - Forest adventure + - Janine’s pedestals + - Fuchsiavorst + - sauna + 3: + name: Stairs + scenes: + - Walk + 4: + name: Lounge + scenes: + - Honolulu + - City of love + - Halloween + - Rolling hills + - Narcissa + 5: + name: Guest room + scenes: + - Nightlight + - Dreamy dusk + - Savanna sunset + - Relax + 6: + name: Study + scenes: + - Relax + - Crocus + - Nightlight + - Vapor wave + - Read + 7: + name: Bedroom + scenes: + - Scarlet dream + - Scarlet dream plus + - Scarlet dream minimal + - Fall harvest + - whites + 8: + name: Loop + scenes: + - Walk + - Nightlight +rooms: +- name: Lounge + id: 27513e90-35d3-4340-88ff-981e7ca8a42b +- name: Bedroom + id: 32c35c37-a2c1-4621-a2a3-5135d746f429 +- name: Loop + id: 47f2e288-9968-463a-9f1c-4e9bf841ab04 +- name: Front door + id: 7979011f-5787-41d1-bcc7-c9b50584ab69 +- name: Ground floor + id: 902e68ef-5c1d-4099-a879-4d7304525895 +- name: Garden + id: cbd2539d-a806-460c-b69d-74657915732e +- name: Study + id: dbcabe9d-048a-4c23-a6c6-e8b427e8048d +- name: Guest room + id: f6d38890-b747-4974-be0b-beb46dd9cc82 +zones: +- name: Dining + id: 19456464-2e0c-4c80-a034-b73bd83807c2 +- name: Kitchen + id: 4015f570-f95d-4c4d-88b0-9c93898e42bd +- name: Xmas + id: 429ec728-9c9f-467b-9ae8-3a937f2c8689 +- name: Hobby + id: 61a8d5fd-875d-44bf-ac2e-3b306d9e2089 +- name: Stairs + id: 9d0b0c68-c571-48ba-8923-1249793663ff +- name: Living + id: afb26e2d-5741-4274-a577-42d60ce46307 +scenes: +- name: Dromerige schemering + group: Living + id: '00651873-f6a0-478a-ab5b-cbd2e33df722' +- name: Arctic aurora + group: Ground floor + id: 026d4345-0790-46e9-a9e3-19985268e8d4 +- name: Lily + group: Garden + id: 05223c39-b93c-4946-860c-7403ebb41fb6 +- name: Pensive + group: Ground floor + id: '08a10a27-34f8-479b-bbf0-ddd085678a2b' +- name: Nightlight + group: Guest room + id: 0c202f41-1dc5-4aa2-a544-417c6afde16d +- name: Honolulu + group: Lounge + id: 1157cf2d-f9fb-4870-b1ff-e9a3d376fa83 +- name: Forest adventure + group: Garden + id: 14931ece-258a-4c93-8c87-e92aa78c95de +- name: Relax + group: Bedroom + id: 15e61423-3407-42a7-9f3a-c6d10cb5be7e +- name: Bright + group: Ground floor + id: 171b507f-3e19-4993-a637-1a3941733bae +- name: Nachtlampje + group: Living + id: 19739e0a-ea04-427e-8b9a-c8573e34b368 +- name: Bright + group: Lounge + id: 1a55b1ee-a8c4-4705-a1fa-1c421259e08b +- name: Dreamy dusk + group: Guest room + id: 1a8fcc95-dc6b-40c8-a739-bb13bb74c404 +- name: Nightlight + group: Garden + id: 1ea99a0c-e631-432c-aa0d-8f556a7fd14f +- name: Energize + group: Bedroom + id: 23fb1f70-e4ca-425f-801a-833796278d41 +- name: Unwind + group: Ground floor + id: 292146b6-1a3b-4273-a467-b9a24542a280 +- name: Read + group: Guest room + id: 2972c74b-b6cc-496a-83b3-87bb8e891fad +- name: Dimmed + group: Lounge + id: 3047a1e2-2dda-4b9c-9199-32d4576c71a3 +- name: Read + group: Garden + id: 340e7b86-3a48-4ec6-8d2c-db7996b81a94 +- name: Concentrate + group: Study + id: 35bc6a53-524b-4e5f-80eb-64f7d6f69cf1 +- name: Janine’s pedestals + group: Garden + id: 37db94d6-f79c-476d-90c4-1ffd131fc817 +- name: Ontladen + group: Kitchen + id: 3adcb5d8-f52e-47a4-b8e2-158374fadc38 +- name: Read + group: Bedroom + id: 3b686161-5798-4c19-90fd-3527d004ac14 +- name: Ontspannen + group: Kitchen + id: 3c67782c-a239-48fb-bdb1-55e6d3866b53 +- name: Dimmed + group: Front door + id: 3e48dcd8-b10e-478c-9a41-b2ff7aecca16 +- name: sparkle + group: Front door + id: 3eb159e1-6bf7-4fdb-8c69-d5d9e86d1edb +- name: Scarlet dream + group: Bedroom + id: 3f751ace-453c-4c0f-9660-ebbc4a0a32b9 +- name: Energize + group: Loop + id: 40c32f76-30cf-4a69-9364-321e47239ea1 +- name: Energize + group: Study + id: 42b0dffb-5aaf-4f2a-bb3f-94562b57ad44 +- name: Scarlet dream plus + group: Bedroom + id: 443736ec-bb21-4eca-a9b2-dd10460c77c3 +- name: Halloween + group: Ground floor + id: 454fce0d-3a37-4ee2-a8b6-1fd9d82bf0d5 +- name: Hobby + group: Hobby + id: 464d0bbc-06bd-4bc6-b641-bf580ac0cb0e +- name: Bright + group: Front door + id: 487579fe-64b6-4a2c-840c-28aaed3f3294 +- name: Nieuwe scène + group: Garden + id: 4ba22440-75ee-4580-bb80-2cb2ab5d540a +- name: Glooiende heuvels + group: Living + id: 4cbb5f23-7dda-4f8e-ac59-aec4de81da36 +- name: Nightlight + group: Ground floor + id: 4f6f1fc3-4aa4-4813-9414-82d0d97cc407 +- name: Walk + group: Loop + id: 5957c383-f231-4ee6-9d08-4c6045396255 +- name: Savanna sunset + group: Guest room + id: 5ca4667d-d468-4ed9-a80a-c38e556ca813 +- name: Sfeervolle zonsondergang + group: Ground floor + id: 5e844cce-ec02-49ca-b5bb-5f397cabe448 +- name: Baby's breath + group: Ground floor + id: 6044bac5-482a-4394-ada1-455577b5d44e +- name: Concentrate + group: Bedroom + id: 65ed101b-c0d4-480b-8172-1e9b8da46a26 +- name: Rest + group: Front door + id: 67bbe2e3-e472-4b58-b4cf-bd70c1381e91 +- name: Honolulu + group: Dining + id: 6a920a61-6f8a-406d-a732-cdfaeb61be18 +- name: Shine + group: Ground floor + id: 75a90a65-a6e1-435a-85d9-bc8c7bb5dd02 +- name: Scarlet dream minimal + group: Bedroom + id: 78b1e511-dfb8-4006-8121-d457354c008a +- name: Walk + group: Stairs + id: 7e5f5204-ba02-4b86-8dde-fb3d82469e23 +- name: Concentrate + group: Ground floor + id: 7e74b74b-2a6e-43db-9b7b-6781f45f6660 +- name: Relax + group: Study + id: 82200649-82b0-45e7-a899-cd62f619e1d0 +- name: Storybook + group: Ground floor + id: 858faa7d-dbaf-458a-854d-0ea231587afc +- name: Relax + group: Garden + id: 8a071f00-fe08-456a-a712-5ff29415413c +- name: Relax + group: Ground floor + id: 8db299c2-9b6f-4632-825c-e5ef21bcacd1 +- name: Concentrate + group: Garden + id: 8ff1a45a-7f03-403d-b651-ac0053f40af1 +- name: Pumpkin patch + group: Ground floor + id: 92c17c5f-5b5e-490a-8048-aef080646c68 +- name: Crocus + group: Study + id: 93c0b589-070b-449e-a4ec-27eb6624e7ba +- name: Nightlight + group: Study + id: 994c045f-a1b2-4aa6-a35e-67f4e711e092 +- name: Nachtlampje + group: Dining + id: 9bbf60e7-14b0-4c42-a664-f7c9900f3221 +- name: Energize + group: Ground floor + id: 9dee99c6-8400-40ba-9bf9-dc192e7fd57f +- name: Blossom + group: Ground floor + id: 9f6acf4f-f317-4507-96c1-5b2afe762d3a +- name: Vapor wave + group: Study + id: 9ff34c5d-52b7-452a-9db1-da5b0ed880f2 +- name: Fall harvest + group: Bedroom + id: a0680854-1918-4650-9521-850ae81d31d6 +- name: Dreamy dusk + group: Ground floor + id: a20b314b-cdda-4323-b84b-7356f5e8da7a +- name: Sunset allure + group: Ground floor + id: a88be19a-e3c9-4885-9c07-2e8c09ed4d14 +- name: Rest + group: Ground floor + id: adee9d8d-8743-4378-888e-dd82604bc182 +- name: Cancun + group: Ground floor + id: b487392b-7f6a-4fca-a036-8714a19ba6a8 +- name: Dimmed + group: Front door + id: b4ea129a-b91d-439b-b85f-de11e8bf5d32 +- name: Slaperig + group: Garden + id: b55bd309-1338-4c27-a3ca-a09dfef951fd +- name: whites + group: Bedroom + id: b97425f1-9352-4cd2-86cb-000b2fd36838 +- name: Dimmed + group: Ground floor + id: bb07d92e-e708-4925-9227-15896c4c3cf3 +- name: Read + group: Ground floor + id: bb48ed9a-1e1d-4e79-b6a9-50e64b375143 +- name: Sprookjesboek + group: Kitchen + id: bcbb8b9d-ee8c-4ff2-b638-edd85bdcb8a7 +- name: Fuchsiavorst + group: Garden + id: bdb37560-92c6-4586-9e9e-76391a2365be +- name: Concentrate + group: Guest room + id: bdd8c7f5-fbec-452c-99b7-50a1fcc23825 +- name: Nighttime + group: Ground floor + id: be78321a-92dc-407e-8821-f15f1bce7daf +- name: Nightlight + group: Lounge + id: c1ef0643-df76-4a6f-ad4a-849482d8cdd5 +- name: Sleepy + group: Ground floor + id: c3e5cae8-7353-4e24-af81-de1a67082521 +- name: City of love + group: Lounge + id: c4cd2468-1a27-4d7b-9cb0-b60131a13c89 +- name: Relax + group: Guest room + id: c515525a-3fe1-4c37-b68f-c859070fc388 +- name: Amber robin + group: Front door + id: c54295de-7730-4f04-86d9-6c1d8a24a0c4 +- name: Opkomen + group: Kitchen + id: c6b5db40-73c5-4e0d-b883-940faac3116d +- name: Energize + group: Garden + id: cc9fafa4-34a0-415d-b830-240fa16758b3 +- name: Slaperig + group: Living + id: d1e4a3fa-b7d0-4de4-b197-4878ed30fa9f +- name: Stralen + group: Kitchen + id: d3ca4cfe-172a-4546-9996-82a179b4783a +- name: sauna + group: Garden + id: db05dee5-4f2a-4f51-a6c8-f1aa179a72bc +- name: "'s Nachts" + group: Kitchen + id: dd27ef15-1427-45a1-bd21-698669e62aae +- name: Nightlight + group: Loop + id: e195e1f8-c771-48d1-91fd-974f0d23f94d +- name: Read + group: Study + id: e4de0aca-eb57-4dde-8448-e3b291f17b1d +- name: Amber bloom + group: Garden + id: e79c1a8d-9934-4be7-b203-8d23ae993206 +- name: Halloween + group: Lounge + id: e91abc30-86e9-4b33-a0f6-9282beedad37 +- name: Rolling hills + group: Lounge + id: ea21e64d-bca6-49c4-8a81-7f58d8e39b63 +- name: Energize + group: Guest room + id: eb702125-33b2-4f6a-b4c6-0b0c2bb822da +- name: Arise + group: Ground floor + id: ed5c5742-baaf-4585-969b-ccd0a7255b07 +- name: Amber bloom + group: Ground floor + id: f0c18d0b-4af7-4599-81d3-554d601161bb +- name: Slaperig + group: Kitchen + id: f211df68-4af2-454a-b106-5bdcd43dd22c +- name: Coastal nights + group: Ground floor + id: f272eeed-002f-4606-9be7-a4e202759166 +- name: Nightlight + group: Bedroom + id: f7c6f0b1-ece1-49b5-a049-38c16a01611c +- name: Dreamy dusk 2 + group: Ground floor + id: f9fd43ad-bedb-4b00-9a20-5d6989fb4d3d +- name: Sfeervolle zonsondergang + group: Living + id: fbc11a37-c9f1-4718-8d93-525ecce30fd8 +- name: Narcissa + group: Lounge + id: fc7d30cb-2106-469e-ad3a-1b5e1a4a05ea +- name: Ontspannen + group: Dining + id: ffe8f288-845c-44c6-b176-a400f72385bd diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..be16837 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +Flask>=3.0,<4.0 +gunicorn>=23.0,<24.0 +PyYAML>=6.0,<7.0 diff --git a/static/app.css b/static/app.css new file mode 100644 index 0000000..26801ef --- /dev/null +++ b/static/app.css @@ -0,0 +1,14 @@ +:root { color-scheme: dark; background: #080a0f; font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", sans-serif; } +* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; } +body { margin: 0; background: #080a0f; color: #f7f8fc; } +.dashboard { min-height: 100vh; min-height: 100dvh; padding: max(12px, env(safe-area-inset-top)) max(12px, env(safe-area-inset-right)) max(12px, env(safe-area-inset-bottom)) max(12px, env(safe-area-inset-left)); } +.button-grid { height: calc(100dvh - max(24px, env(safe-area-inset-top)) - max(24px, env(safe-area-inset-bottom))); display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-rows: repeat(4, minmax(0, 1fr)); gap: 12px; } +.group-button { appearance: none; border: 1px solid #252a36; border-radius: 20px; background: linear-gradient(145deg, #1a1e28, #10131b); color: inherit; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; font: inherit; font-size: clamp(1.05rem, 5vw, 1.5rem); font-weight: 650; letter-spacing: -.02em; touch-action: manipulation; transition: transform .12s ease, background .2s ease, border-color .2s ease, box-shadow .2s ease; } +.group-button small { color: #8c94a6; font-size: .76rem; font-weight: 600; line-height: 1.2; text-align: center; } +.group-button.is-on { border-color: #3196ff; box-shadow: 0 0 0 2px #1677d8, 0 0 20px #1264ba55; } +.group-button:active { transform: scale(.975); background: #222938; } +.group-button:focus-visible { outline: 3px solid #f3f7ff; outline-offset: 3px; } +.group-button[disabled] { opacity: .5; } +.status { position: fixed; z-index: 1; bottom: calc(env(safe-area-inset-bottom) + 10px); left: 50%; margin: 0; padding: 6px 10px; border-radius: 999px; background: #252a36dd; color: #cbd2df; font-size: .75rem; transform: translateX(-50%); opacity: 0; transition: opacity .2s; pointer-events: none; white-space: nowrap; } +.status.visible { opacity: 1; } +@media (orientation: landscape) { .button-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-rows: repeat(2, minmax(0, 1fr)); } } diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..cd7ad36 --- /dev/null +++ b/static/app.js @@ -0,0 +1,102 @@ +const status = document.querySelector('#status'); +const buttons = [...document.querySelectorAll('.group-button')]; +const apiPrefix = document.body.dataset.apiPrefix; +const url = new URL(window.location.href); +const suppliedKey = url.searchParams.get('key'); +if (suppliedKey) { + sessionStorage.setItem('fasthue-api-key', suppliedKey); + url.searchParams.delete('key'); + history.replaceState({}, '', url); +} +const apiKey = sessionStorage.getItem('fasthue-api-key'); + +function apiFetch(path, options = {}) { + return fetch(path, { + ...options, + headers: { ...options.headers, Authorization: `Bearer ${apiKey || ''}` }, + }); +} + +function showStatus(message, persistent = false) { + status.textContent = message; + status.classList.add('visible'); + if (!persistent) window.setTimeout(() => status.classList.remove('visible'), 1800); +} + +async function refresh() { + try { + const response = await apiFetch(`${apiPrefix}/groups`, { cache: 'no-store' }); + const body = await response.json(); + if (!response.ok) throw new Error(body.error || 'Could not check the bridge'); + const states = new Map(body.groups.map(group => [group.id, group])); + buttons.forEach(button => { + const group = states.get(button.dataset.id); + button.classList.toggle('is-on', group?.on === true); + button.querySelector('small').textContent = group?.scene || ''; + }); + status.classList.remove('visible'); + } catch (error) { + showStatus(error.message, true); + } +} + +async function activateNextScene(button) { + button.disabled = true; + try { + const response = await apiFetch(`${apiPrefix}/groups/${button.dataset.id}/next-scene`, { method: 'POST' }); + const body = await response.json(); + if (!response.ok) throw new Error(body.error || 'Could not control the bridge'); + showStatus(`${button.querySelector('span').textContent}: ${body.scene}`); + button.querySelector('small').textContent = body.scene; + window.setTimeout(refresh, 350); + } catch (error) { + showStatus(error.message, true); + } finally { + button.disabled = false; + } +} + +async function turnOff(button) { + button.disabled = true; + try { + const response = await apiFetch(`${apiPrefix}/groups/${button.dataset.id}/off`, { method: 'POST' }); + const body = await response.json(); + if (!response.ok) throw new Error(body.error || 'Could not turn off the lights'); + button.classList.remove('is-on'); + button.querySelector('small').textContent = 'Lights off'; + showStatus(`${button.querySelector('span').textContent}: off`); + window.setTimeout(refresh, 350); + } catch (error) { + showStatus(error.message, true); + } finally { + button.disabled = false; + } +} + +buttons.forEach(button => { + let pressTimer; + let longPressHandled = false; + + const clearPressTimer = () => window.clearTimeout(pressTimer); + button.addEventListener('pointerdown', () => { + longPressHandled = false; + pressTimer = window.setTimeout(() => { + longPressHandled = true; + turnOff(button); + }, 650); + }); + button.addEventListener('pointerup', clearPressTimer); + button.addEventListener('pointercancel', clearPressTimer); + button.addEventListener('pointerleave', clearPressTimer); + button.addEventListener('click', event => { + if (longPressHandled) { + event.preventDefault(); + longPressHandled = false; + return; + } + activateNextScene(button); + }); +}); + +refresh(); +window.setInterval(refresh, 15000); diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..458f033 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,24 @@ + + + + + + + Hue + + + +
+

Checking lights…

+
+ {% for button in buttons %} + + {% endfor %} +
+
+ + +