Repository navigation
Expand file tree
/
Copy pathstop-server.py
More file actions
executable file
·145 lines (118 loc) · 4.97 KB
/
Copy pathstop-server.py
File metadata and controls
executable file
·145 lines (118 loc) · 4.97 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
#!/usr/bin/env python3
"""
Smart Display control server.
Endpoints:
GET /stop — kill any running aplay (stops TTS mid-sentence)
GET /tts-volume?level=N — set voice/TTS playback volume to N% (0-100)
GET /media-volume?level=N — set music/media playback volume to N% (0-100)
GET /brightness?level=N — set DSI display brightness to N% (0-100)
TTS and media volumes are independent ALSA softvol controls so they can be
adjusted separately from Home Assistant without affecting each other.
The WM8960 hardware speaker level is fixed; only the per-stream software
volumes are exposed here.
"""
import os, signal, subprocess, sys
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import parse_qs, urlparse
PORT = 12345
TTS_VOLUME_STATE_FILE = os.path.expanduser("~/.smart-display-tts-volume")
MEDIA_VOLUME_STATE_FILE = os.path.expanduser("~/.smart-display-media-volume")
BACKLIGHT_DIR = "/sys/class/backlight/10-0045"
BACKLIGHT_PATH = f"{BACKLIGHT_DIR}/brightness"
try:
with open(f"{BACKLIGHT_DIR}/max_brightness") as _f:
BACKLIGHT_MAX = int(_f.read().strip())
except OSError:
BACKLIGHT_MAX = 255
def _set_alsa_volume(control: str, level: int) -> tuple[bool, str]:
"""Set an ALSA softvol control on the seeed card. Returns (ok, error_msg).
softvol controls are raw mixer elements — they are not visible to sset
(simple mixer). cset with name= reaches them directly.
"""
result = subprocess.run(
["/usr/bin/amixer", "-c", "seeed2micvoicec", "cset", f"name={control}", f"{level}%"],
capture_output=True, text=True,
)
if result.returncode != 0:
return False, result.stderr.strip()
return True, ""
def _save_state(path: str, value: int) -> None:
try:
with open(path, "w") as f:
f.write(str(value))
except OSError:
pass
def _parse_level(params: dict) -> int | None:
"""Parse ?level=N from query params. Returns int 0-100 or None on error."""
try:
return max(0, min(100, int(float(params.get("level", [""])[0]))))
except (ValueError, IndexError):
return None
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
parsed = urlparse(self.path)
path = parsed.path
params = parse_qs(parsed.query)
if path == "/stop":
self._handle_stop()
elif path == "/tts-volume":
level = _parse_level(params)
if level is None:
self._respond(400, "bad request — use /tts-volume?level=0-100")
return
ok, err = _set_alsa_volume("TTS Volume", level)
if not ok:
print(f"[tts-volume] amixer error: {err}")
_save_state(TTS_VOLUME_STATE_FILE, level)
print(f"[tts-volume] Set to {level}%.")
self._respond(200, str(level))
elif path == "/media-volume":
level = _parse_level(params)
if level is None:
self._respond(400, "bad request — use /media-volume?level=0-100")
return
ok, err = _set_alsa_volume("Media Volume", level)
if not ok:
print(f"[media-volume] amixer error: {err}")
_save_state(MEDIA_VOLUME_STATE_FILE, level)
print(f"[media-volume] Set to {level}%.")
self._respond(200, str(level))
elif path == "/brightness":
level = _parse_level(params)
if level is None:
self._respond(400, "bad request — use /brightness?level=0-100")
return
raw = int(level * BACKLIGHT_MAX / 100)
try:
with open(BACKLIGHT_PATH, "w") as f:
f.write(str(raw))
print(f"[brightness] Set to {level}% ({raw}/{BACKLIGHT_MAX}).")
self._respond(200, str(level))
except OSError as e:
print(f"[brightness] Error writing backlight: {e}")
self._respond(500, f"error: {e}")
else:
self._respond(404, "not found")
def _handle_stop(self):
result = subprocess.run(["pkill", "-f", "aplay"], capture_output=True)
if result.returncode == 0:
print("[stop] aplay killed — TTS interrupted.")
self._respond(200, "stopped")
else:
print("[stop] nothing playing.")
self._respond(200, "idle")
def _respond(self, code: int, body: str) -> None:
self.send_response(code)
self.send_header("Content-Type", "text/plain")
self.end_headers()
self.wfile.write(body.encode())
def log_message(self, fmt, *args):
print(f"[http] {self.address_string()} {fmt % args}")
def _exit(sig, frame):
print("[exit] Stop server shutting down.")
sys.exit(0)
signal.signal(signal.SIGTERM, _exit)
signal.signal(signal.SIGINT, _exit)
server = HTTPServer(("0.0.0.0", PORT), Handler)
print(f"[ready] Smart Display control server listening on port {PORT}.")
server.serve_forever()