From d9e8fef376cfb35d4dd7650393cc4f592885e076 Mon Sep 17 00:00:00 2001 From: ltadeu6 Date: Mon, 11 May 2026 18:54:44 -0300 Subject: [PATCH] Add Home Assistant network control API --- AGENTS.md | 2 + README.md | 81 +++++++++++++++++++++++++++++ luz-musica.py | 110 +++++++++++++++++++++++++++++++++++++++- music-light.env.example | 3 ++ 4 files changed, 195 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 802bec9..e8f3476 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,10 +33,12 @@ Then run: - The service loads optional environment overrides from `~/.config/music-light/env`. - Use `make status-service`, `make logs-service`, `make restart-service`, and `make uninstall-service`. - Keep it as a user service because `playerctl` needs access to the logged-in desktop user's MPRIS session. +- The running script exposes an HTTP control API for Home Assistant: `GET /status`, `POST /pause`, `POST /resume`, `POST /toggle`. ## Configuration Edit constants at the top of `luz-musica.py`: - `HA_URL`, `HA_TOKEN`, `LIGHT_ENTITY` +- control API: `CONTROL_HOST`, `CONTROL_PORT`, `CONTROL_TOKEN` - tuning: `BRIGHTNESS_PCT`, `SATURATION_BOOST`, `MIN_SATURATION`, `MIN_VALUE`, `REQUEST_TIMEOUT` ## Expected behavior / troubleshooting diff --git a/README.md b/README.md index d732e26..417e8a2 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,12 @@ You can also tune: - `MIN_VALUE` - `REQUEST_TIMEOUT` +Network control settings: + +- `CONTROL_HOST`: bind address for the control API, default `0.0.0.0` +- `CONTROL_PORT`: bind port for the control API, default `8765` +- `CONTROL_TOKEN`: optional shared token required for control requests + ## Run ```bash @@ -96,9 +102,84 @@ make uninstall-service This is installed as a user service because `playerctl` needs access to the desktop user's MPRIS session. +## Home Assistant Control + +The running script exposes a small HTTP API that Home Assistant can call over the local network: + +- `GET /status` +- `POST /pause` +- `POST /resume` +- `POST /toggle` + +When paused, the systemd service stays running but the script stops changing the light. When resumed, it forces an update from the current track. + +Example requests from another machine on the same network: + +```bash +curl http://ubuntu-host:8765/status +curl -X POST http://ubuntu-host:8765/pause +curl -X POST http://ubuntu-host:8765/resume +curl -X POST http://ubuntu-host:8765/toggle +``` + +If you set `CONTROL_TOKEN`, include it as a header: + +```bash +curl -X POST -H "X-Music-Light-Token: your-token" http://ubuntu-host:8765/toggle +``` + +Home Assistant `configuration.yaml` example: + +```yaml +rest_command: + music_light_pause: + url: "http://ubuntu-host:8765/pause" + method: post + music_light_resume: + url: "http://ubuntu-host:8765/resume" + method: post + music_light_toggle: + url: "http://ubuntu-host:8765/toggle" + method: post +``` + +With `CONTROL_TOKEN`: + +```yaml +rest_command: + music_light_toggle: + url: "http://ubuntu-host:8765/toggle" + method: post + headers: + X-Music-Light-Token: "your-token" +``` + +Create Home Assistant scripts so they can be shown as dashboard buttons: + +```yaml +script: + pause_music_light: + alias: Pause Music Light + sequence: + - service: rest_command.music_light_pause + + resume_music_light: + alias: Resume Music Light + sequence: + - service: rest_command.music_light_resume + + toggle_music_light: + alias: Toggle Music Light + sequence: + - service: rest_command.music_light_toggle +``` + +Then add `script.pause_music_light`, `script.resume_music_light`, or `script.toggle_music_light` to a Home Assistant dashboard as button cards. + ## Troubleshooting - If nothing is playing, or `playerctl` cannot read metadata, the loop sleeps and retries. - If `mpris:artUrl` is missing, the script logs it and waits for the next track change. - If Home Assistant is unreachable or returns an error, the script logs the exception and retries. - If the service cannot see the media player, make sure it is running under the same logged-in desktop user. +- If Home Assistant cannot reach the control API, check the Ubuntu host firewall and confirm `CONTROL_HOST=0.0.0.0`. diff --git a/luz-musica.py b/luz-musica.py index 93bf128..2ac8aef 100644 --- a/luz-musica.py +++ b/luz-musica.py @@ -2,10 +2,13 @@ from __future__ import annotations import colorsys +import json import math import os import subprocess +import threading import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from io import BytesIO from urllib.parse import parse_qs, urlparse @@ -31,6 +34,9 @@ OKLCH_LIGHTNESS = 0.72 OKLCH_CHROMA = 0.24 MAX_BRIGHTNESS_STEP = 0.10 PLAYERCTL_BIN = os.environ.get("PLAYERCTL_BIN", "playerctl") +CONTROL_HOST = os.environ.get("CONTROL_HOST", "0.0.0.0") +CONTROL_PORT = int(os.environ.get("CONTROL_PORT", "8765")) +CONTROL_TOKEN = os.environ.get("CONTROL_TOKEN", "") YELLOW_HUE_START = 0.12 YELLOW_HUE_END = 0.18 AMBER_TARGET_HUE = 0.10 @@ -41,6 +47,101 @@ NEUTRAL_SATURATION_THRESHOLD = 0.18 PURPLE_FALLBACK_HUE = 0.78 +class ControlState: + def __init__(self) -> None: + self._lock = threading.Lock() + self._paused = False + self._force_update = False + + def pause(self) -> None: + with self._lock: + self._paused = True + + def resume(self) -> None: + with self._lock: + self._paused = False + self._force_update = True + + def toggle(self) -> bool: + with self._lock: + self._paused = not self._paused + if not self._paused: + self._force_update = True + return self._paused + + def is_paused(self) -> bool: + with self._lock: + return self._paused + + def consume_force_update(self) -> bool: + with self._lock: + force_update = self._force_update + self._force_update = False + return force_update + + def snapshot(self) -> dict[str, object]: + with self._lock: + return { + "paused": self._paused, + "running": True, + } + + +CONTROL_STATE = ControlState() + + +class ControlHandler(BaseHTTPRequestHandler): + def log_message(self, format: str, *args: object) -> None: + return + + def do_GET(self) -> None: + if self.path == "/status": + self.write_json(200, CONTROL_STATE.snapshot()) + return + self.write_json(404, {"error": "not found"}) + + def do_POST(self) -> None: + if not self.authorized(): + self.write_json(401, {"error": "unauthorized"}) + return + + if self.path == "/pause": + CONTROL_STATE.pause() + elif self.path == "/resume": + CONTROL_STATE.resume() + elif self.path == "/toggle": + CONTROL_STATE.toggle() + else: + self.write_json(404, {"error": "not found"}) + return + + self.write_json(200, CONTROL_STATE.snapshot()) + + def authorized(self) -> bool: + if not CONTROL_TOKEN: + return True + + header_token = self.headers.get("X-Music-Light-Token", "") + bearer_token = self.headers.get("Authorization", "").removeprefix("Bearer ") + return CONTROL_TOKEN in {header_token, bearer_token} + + def write_json(self, status: int, payload: dict[str, object]) -> None: + body = json.dumps(payload).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + +def start_control_server() -> ThreadingHTTPServer: + server = ThreadingHTTPServer((CONTROL_HOST, CONTROL_PORT), ControlHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + print(f"Controle HTTP ouvindo em {CONTROL_HOST}:{CONTROL_PORT}") + return server + + def run_playerctl(field: str, player: str | None = None) -> str: command = [PLAYERCTL_BIN] if player: @@ -399,11 +500,16 @@ def set_light_color(rgb: tuple[int, int, int]) -> None: def main() -> None: print("Monitorando música atual. Ctrl+C para sair.") + control_server = start_control_server() last_track = "" last_rgb: tuple[int, int, int] | None = None while True: try: + if CONTROL_STATE.is_paused(): + time.sleep(2) + continue + player = choose_active_player() if not player: time.sleep(2) @@ -415,7 +521,7 @@ def main() -> None: time.sleep(2) continue - if track_id != last_track: + if CONTROL_STATE.consume_force_update() or track_id != last_track: art_url = get_album_art_url(player) if not art_url: print("Sem capa disponível para a faixa atual.") @@ -438,6 +544,8 @@ def main() -> None: except KeyboardInterrupt: print("\nEncerrado.") + control_server.shutdown() + control_server.server_close() break except Exception as exc: print(f"Erro: {exc}") diff --git a/music-light.env.example b/music-light.env.example index 2cef63e..411f87c 100644 --- a/music-light.env.example +++ b/music-light.env.example @@ -3,3 +3,6 @@ # HA_TOKEN=replace-with-your-home-assistant-token # LIGHT_ENTITY=light.luzsala_luz_sala # PLAYERCTL_BIN=/usr/bin/playerctl +# CONTROL_HOST=0.0.0.0 +# CONTROL_PORT=8765 +# CONTROL_TOKEN=optional-shared-token