Add Home Assistant network control API
This commit is contained in:
parent
0d61eb6311
commit
d9e8fef376
4 changed files with 195 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
81
README.md
81
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`.
|
||||
|
|
|
|||
110
luz-musica.py
110
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}")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue