commit f0c527f3553d95a3a992c9ba7ff66b2e6471ea60 Author: Ignace Date: Sun Aug 9 16:32:42 2026 +0200 workable POC 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 %} +
+
+ + +