workable POC

This commit is contained in:
2026-08-09 16:32:42 +02:00
commit f0c527f355
10 changed files with 1293 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
.venv/
__pycache__/
*.py[cod]
secrets.yaml
+218
View File
@@ -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
+257
View File
@@ -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/<group_id>/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/<group_id>/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/<group_name>/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()
+2
View File
@@ -0,0 +1,2 @@
# URL where a reverse proxy publishes FastHue. Leave empty when serving at /.
url_prefix: /hue
+282
View File
@@ -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())
+387
View File
@@ -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
- Janines 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: Janines 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
+3
View File
@@ -0,0 +1,3 @@
Flask>=3.0,<4.0
gunicorn>=23.0,<24.0
PyYAML>=6.0,<7.0
+14
View File
@@ -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)); } }
+102
View File
@@ -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);
+24
View File
@@ -0,0 +1,24 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#080a0f">
<title>Hue</title>
<link rel="stylesheet" href="{{ url_for('static', filename='app.css') }}">
</head>
<body data-api-prefix="{{ request.script_root }}/api">
<main class="dashboard" aria-label="Hue controls">
<p id="status" class="status" role="status">Checking lights…</p>
<section class="button-grid">
{% for button in buttons %}
<button class="group-button" data-id="{{ button.id }}" type="button">
<span>{{ button.name }}</span>
<small>Checking scene…</small>
</button>
{% endfor %}
</section>
</main>
<script src="{{ url_for('static', filename='app.js') }}"></script>
</body>
</html>