workable POC
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
secrets.yaml
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -0,0 +1,2 @@
|
||||
# URL where a reverse proxy publishes FastHue. Leave empty when serving at /.
|
||||
url_prefix: /hue
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
Flask>=3.0,<4.0
|
||||
gunicorn>=23.0,<24.0
|
||||
PyYAML>=6.0,<7.0
|
||||
@@ -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
@@ -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);
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user