hapbeat-helper 0.1.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hapbeat_helper/__init__.py +10 -0
- hapbeat_helper/__main__.py +6 -0
- hapbeat_helper/cli.py +328 -0
- hapbeat_helper/device_registry.py +131 -0
- hapbeat_helper/mdns_scanner.py +147 -0
- hapbeat_helper/pack_normalize.py +144 -0
- hapbeat_helper/protocol.py +164 -0
- hapbeat_helper/server.py +2058 -0
- hapbeat_helper/service/__init__.py +24 -0
- hapbeat_helper/service/macos.py +161 -0
- hapbeat_helper/service/windows.py +331 -0
- hapbeat_helper/tcp_client.py +240 -0
- hapbeat_helper/udp_listener.py +196 -0
- hapbeat_helper-0.1.2.dist-info/METADATA +229 -0
- hapbeat_helper-0.1.2.dist-info/RECORD +19 -0
- hapbeat_helper-0.1.2.dist-info/WHEEL +5 -0
- hapbeat_helper-0.1.2.dist-info/entry_points.txt +2 -0
- hapbeat_helper-0.1.2.dist-info/licenses/LICENSE +21 -0
- hapbeat_helper-0.1.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""hapbeat-helper — local daemon bridging Studio (web) to Hapbeat devices."""
|
|
2
|
+
|
|
3
|
+
try:
|
|
4
|
+
# Generated by scripts/gen_version.py (gitignored).
|
|
5
|
+
# Present in local dev installs; absent in PyPI releases.
|
|
6
|
+
from hapbeat_helper._version import __version__
|
|
7
|
+
except ImportError:
|
|
8
|
+
# Fallback: release version embedded at publish time.
|
|
9
|
+
# Keep this in sync with pyproject.toml [project] version.
|
|
10
|
+
__version__ = "0.1.2"
|
hapbeat_helper/cli.py
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
"""Command-line entry for ``hapbeat-helper``.
|
|
2
|
+
|
|
3
|
+
Subcommands:
|
|
4
|
+
|
|
5
|
+
- ``start [--port 7703]`` start the daemon (foreground)
|
|
6
|
+
- ``status`` probe ws://localhost:7703
|
|
7
|
+
- ``version`` print version
|
|
8
|
+
- ``stop`` stop the auto-started helper
|
|
9
|
+
- ``logs [-f] [-n N]`` show log file + tail recent lines
|
|
10
|
+
- ``install-service`` register as OS auto-start service (Task Scheduler on Windows / launchd on macOS)
|
|
11
|
+
- ``uninstall-service`` remove the OS service registration
|
|
12
|
+
- ``service-status`` show OS service registration state
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import asyncio
|
|
19
|
+
import json
|
|
20
|
+
import logging
|
|
21
|
+
import socket
|
|
22
|
+
import sys
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from hapbeat_helper import __version__
|
|
26
|
+
from hapbeat_helper.server import HelperServer, WS_PORT
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger("hapbeat-helper")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _config_dir() -> Path:
|
|
32
|
+
if sys.platform == "win32":
|
|
33
|
+
import os
|
|
34
|
+
base = Path(os.environ.get("APPDATA", str(Path.home())))
|
|
35
|
+
return base / "hapbeat-helper"
|
|
36
|
+
return Path.home() / ".config" / "hapbeat-helper"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _setup_logging(verbose: bool) -> None:
|
|
40
|
+
level = logging.DEBUG if verbose else logging.INFO
|
|
41
|
+
logging.basicConfig(
|
|
42
|
+
level=level,
|
|
43
|
+
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
|
44
|
+
datefmt="%H:%M:%S",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _cmd_start(args: argparse.Namespace) -> int:
|
|
49
|
+
_setup_logging(args.verbose)
|
|
50
|
+
server = HelperServer(port=args.port)
|
|
51
|
+
print(f"hapbeat-helper {__version__} starting on ws://localhost:{args.port}")
|
|
52
|
+
print("Press Ctrl+C to stop.")
|
|
53
|
+
try:
|
|
54
|
+
asyncio.run(server.run())
|
|
55
|
+
except KeyboardInterrupt:
|
|
56
|
+
print("\nshutting down…")
|
|
57
|
+
return 0
|
|
58
|
+
except RuntimeError as exc:
|
|
59
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
60
|
+
return 1
|
|
61
|
+
return 0
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _cmd_status(args: argparse.Namespace) -> int:
|
|
65
|
+
"""TCP-probe the WebSocket port. Cheap reachability check."""
|
|
66
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
67
|
+
sock.settimeout(0.5)
|
|
68
|
+
try:
|
|
69
|
+
sock.connect(("127.0.0.1", args.port))
|
|
70
|
+
print(f"hapbeat-helper: reachable on ws://localhost:{args.port}")
|
|
71
|
+
return 0
|
|
72
|
+
except OSError:
|
|
73
|
+
print(
|
|
74
|
+
f"hapbeat-helper: not running (no listener on {args.port})",
|
|
75
|
+
file=sys.stderr,
|
|
76
|
+
)
|
|
77
|
+
return 1
|
|
78
|
+
finally:
|
|
79
|
+
sock.close()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _cmd_version(_args: argparse.Namespace) -> int:
|
|
83
|
+
print(f"hapbeat-helper {__version__}")
|
|
84
|
+
return 0
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _cmd_stop(_args: argparse.Namespace) -> int:
|
|
88
|
+
try:
|
|
89
|
+
from hapbeat_helper.service import get_service_manager
|
|
90
|
+
mgr = get_service_manager()
|
|
91
|
+
if hasattr(mgr, "stop"):
|
|
92
|
+
mgr.stop()
|
|
93
|
+
return 0
|
|
94
|
+
except NotImplementedError:
|
|
95
|
+
pass
|
|
96
|
+
print(
|
|
97
|
+
"stop: no auto-started instance found. "
|
|
98
|
+
"If you launched in foreground, press Ctrl+C there.",
|
|
99
|
+
file=sys.stderr,
|
|
100
|
+
)
|
|
101
|
+
return 1
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _cmd_logs(args: argparse.Namespace) -> int:
|
|
105
|
+
"""Print the log file path and tail recent lines.
|
|
106
|
+
|
|
107
|
+
With --follow / -f, stream new lines until Ctrl+C.
|
|
108
|
+
"""
|
|
109
|
+
try:
|
|
110
|
+
from hapbeat_helper.service import get_service_manager
|
|
111
|
+
mgr = get_service_manager()
|
|
112
|
+
except NotImplementedError as exc:
|
|
113
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
114
|
+
return 1
|
|
115
|
+
if not hasattr(mgr, "log_path"):
|
|
116
|
+
print(
|
|
117
|
+
"logs: this platform does not redirect stdout to a file. "
|
|
118
|
+
"Run `hapbeat-helper start` to see logs in the "
|
|
119
|
+
"current terminal.",
|
|
120
|
+
file=sys.stderr,
|
|
121
|
+
)
|
|
122
|
+
return 1
|
|
123
|
+
log = mgr.log_path()
|
|
124
|
+
print(f"# {log}")
|
|
125
|
+
if not log.exists():
|
|
126
|
+
print(
|
|
127
|
+
"(log file does not exist yet — has the service been started?)",
|
|
128
|
+
file=sys.stderr,
|
|
129
|
+
)
|
|
130
|
+
return 1
|
|
131
|
+
|
|
132
|
+
# Print last N lines.
|
|
133
|
+
try:
|
|
134
|
+
with log.open("r", encoding="utf-8", errors="replace") as f:
|
|
135
|
+
lines = f.readlines()
|
|
136
|
+
for line in lines[-args.lines:]:
|
|
137
|
+
sys.stdout.write(line)
|
|
138
|
+
except OSError as exc:
|
|
139
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
140
|
+
return 1
|
|
141
|
+
|
|
142
|
+
if not args.follow:
|
|
143
|
+
return 0
|
|
144
|
+
|
|
145
|
+
# Tail mode (cross-platform): poll for new bytes.
|
|
146
|
+
import time
|
|
147
|
+
sys.stdout.flush()
|
|
148
|
+
try:
|
|
149
|
+
with log.open("r", encoding="utf-8", errors="replace") as f:
|
|
150
|
+
f.seek(0, 2) # to EOF
|
|
151
|
+
while True:
|
|
152
|
+
chunk = f.read()
|
|
153
|
+
if chunk:
|
|
154
|
+
sys.stdout.write(chunk)
|
|
155
|
+
sys.stdout.flush()
|
|
156
|
+
else:
|
|
157
|
+
time.sleep(0.5)
|
|
158
|
+
except KeyboardInterrupt:
|
|
159
|
+
return 0
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _cmd_install_service(_args: argparse.Namespace) -> int:
|
|
163
|
+
try:
|
|
164
|
+
from hapbeat_helper.service import get_service_manager
|
|
165
|
+
mgr = get_service_manager()
|
|
166
|
+
mgr.install()
|
|
167
|
+
return 0
|
|
168
|
+
except NotImplementedError as exc:
|
|
169
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
170
|
+
return 2
|
|
171
|
+
except Exception as exc:
|
|
172
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
173
|
+
return 1
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _cmd_uninstall_service(_args: argparse.Namespace) -> int:
|
|
177
|
+
try:
|
|
178
|
+
from hapbeat_helper.service import get_service_manager
|
|
179
|
+
mgr = get_service_manager()
|
|
180
|
+
mgr.uninstall()
|
|
181
|
+
return 0
|
|
182
|
+
except NotImplementedError as exc:
|
|
183
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
184
|
+
return 2
|
|
185
|
+
except Exception as exc:
|
|
186
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
187
|
+
return 1
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _cmd_service_status(_args: argparse.Namespace) -> int:
|
|
191
|
+
try:
|
|
192
|
+
from hapbeat_helper.service import get_service_manager
|
|
193
|
+
mgr = get_service_manager()
|
|
194
|
+
state = mgr.status()
|
|
195
|
+
labels = {
|
|
196
|
+
"not_registered": "not registered",
|
|
197
|
+
"stopped": "registered, stopped",
|
|
198
|
+
"running": "registered, running",
|
|
199
|
+
}
|
|
200
|
+
print(f"hapbeat-helper service: {labels.get(state, state)}")
|
|
201
|
+
return 0 if state == "running" else 1
|
|
202
|
+
except NotImplementedError as exc:
|
|
203
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
204
|
+
return 2
|
|
205
|
+
except Exception as exc:
|
|
206
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
207
|
+
return 1
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _cmd_config_show(_args: argparse.Namespace) -> int:
|
|
211
|
+
cfg = _config_dir()
|
|
212
|
+
print(f"config dir: {cfg}")
|
|
213
|
+
cfg_file = cfg / "config.toml"
|
|
214
|
+
if cfg_file.exists():
|
|
215
|
+
print(cfg_file.read_text(encoding="utf-8"))
|
|
216
|
+
else:
|
|
217
|
+
print("(no config file yet — defaults are in use)")
|
|
218
|
+
return 0
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _add_verbose(parser: argparse.ArgumentParser) -> None:
|
|
222
|
+
parser.add_argument(
|
|
223
|
+
"--verbose", "-v", action="store_true", help="debug logging",
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
228
|
+
p = argparse.ArgumentParser(
|
|
229
|
+
prog="hapbeat-helper",
|
|
230
|
+
description=(
|
|
231
|
+
"Local daemon that bridges Hapbeat Studio (web) to "
|
|
232
|
+
"Hapbeat devices on the local network."
|
|
233
|
+
),
|
|
234
|
+
epilog=(
|
|
235
|
+
"commands:\n"
|
|
236
|
+
" start start in foreground (Ctrl+C to stop)\n"
|
|
237
|
+
" stop stop the running helper\n"
|
|
238
|
+
" macOS: unloads launchd job (restarts at next login)\n"
|
|
239
|
+
" Windows: kills process (task remains registered)\n"
|
|
240
|
+
" status check ws://localhost:7703 reachability\n"
|
|
241
|
+
" version show installed version\n"
|
|
242
|
+
" logs show/follow the auto-start log file\n"
|
|
243
|
+
" install-service register & start at OS login\n"
|
|
244
|
+
" uninstall-service remove registration and stop\n"
|
|
245
|
+
" service-status show registration state\n"
|
|
246
|
+
" config show show config file path and contents\n"
|
|
247
|
+
),
|
|
248
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
249
|
+
)
|
|
250
|
+
_add_verbose(p)
|
|
251
|
+
sub = p.add_subparsers(dest="cmd")
|
|
252
|
+
|
|
253
|
+
p_start = sub.add_parser(
|
|
254
|
+
"start",
|
|
255
|
+
help="start the daemon in the foreground (Ctrl+C to stop)",
|
|
256
|
+
)
|
|
257
|
+
_add_verbose(p_start)
|
|
258
|
+
p_start.add_argument(
|
|
259
|
+
"--port", type=int, default=WS_PORT,
|
|
260
|
+
help=f"WebSocket port (default: {WS_PORT})",
|
|
261
|
+
)
|
|
262
|
+
p_start.set_defaults(func=_cmd_start)
|
|
263
|
+
|
|
264
|
+
p_status = sub.add_parser("status", help="check whether helper is running")
|
|
265
|
+
_add_verbose(p_status)
|
|
266
|
+
p_status.add_argument("--port", type=int, default=WS_PORT)
|
|
267
|
+
p_status.set_defaults(func=_cmd_status)
|
|
268
|
+
|
|
269
|
+
p_version = sub.add_parser("version", help="print version")
|
|
270
|
+
p_version.set_defaults(func=_cmd_version)
|
|
271
|
+
|
|
272
|
+
p_stop = sub.add_parser(
|
|
273
|
+
"stop",
|
|
274
|
+
help="stop the running helper (macOS: bootout; Windows: taskkill)",
|
|
275
|
+
)
|
|
276
|
+
p_stop.set_defaults(func=_cmd_stop)
|
|
277
|
+
|
|
278
|
+
p_logs = sub.add_parser(
|
|
279
|
+
"logs",
|
|
280
|
+
help="show log file path + tail recent lines (-f to follow)",
|
|
281
|
+
)
|
|
282
|
+
p_logs.add_argument(
|
|
283
|
+
"-n", "--lines", type=int, default=50,
|
|
284
|
+
help="number of lines to show from the end (default: 50)",
|
|
285
|
+
)
|
|
286
|
+
p_logs.add_argument(
|
|
287
|
+
"-f", "--follow", action="store_true",
|
|
288
|
+
help="stream new log lines until Ctrl+C",
|
|
289
|
+
)
|
|
290
|
+
p_logs.set_defaults(func=_cmd_logs)
|
|
291
|
+
|
|
292
|
+
p_install = sub.add_parser(
|
|
293
|
+
"install-service",
|
|
294
|
+
help="register hapbeat-helper as an OS auto-start service (launchd on macOS / Startup-folder VBS shim on Windows); starts immediately",
|
|
295
|
+
)
|
|
296
|
+
p_install.set_defaults(func=_cmd_install_service)
|
|
297
|
+
|
|
298
|
+
p_uninstall = sub.add_parser(
|
|
299
|
+
"uninstall-service",
|
|
300
|
+
help="remove the OS service registration",
|
|
301
|
+
)
|
|
302
|
+
p_uninstall.set_defaults(func=_cmd_uninstall_service)
|
|
303
|
+
|
|
304
|
+
p_svc_status = sub.add_parser(
|
|
305
|
+
"service-status",
|
|
306
|
+
help="show OS service registration state (not_registered / stopped / running)",
|
|
307
|
+
)
|
|
308
|
+
p_svc_status.set_defaults(func=_cmd_service_status)
|
|
309
|
+
|
|
310
|
+
p_config = sub.add_parser("config", help="config subcommands (try: config show)")
|
|
311
|
+
sub_cfg = p_config.add_subparsers(dest="config_cmd")
|
|
312
|
+
p_show = sub_cfg.add_parser("show", help="show config path / contents")
|
|
313
|
+
p_show.set_defaults(func=_cmd_config_show)
|
|
314
|
+
|
|
315
|
+
return p
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def main(argv: list[str] | None = None) -> int:
|
|
319
|
+
parser = _build_parser()
|
|
320
|
+
args = parser.parse_args(argv)
|
|
321
|
+
if not getattr(args, "func", None):
|
|
322
|
+
parser.print_help()
|
|
323
|
+
return 1
|
|
324
|
+
return args.func(args)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
if __name__ == "__main__":
|
|
328
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Central device registry — single source of truth for device state.
|
|
2
|
+
|
|
3
|
+
Helper edition: no Qt, no serial. USB serial setup happens in Studio
|
|
4
|
+
(via Web Serial API) and is not surfaced here.
|
|
5
|
+
|
|
6
|
+
Listeners register a callable that is invoked synchronously after every
|
|
7
|
+
mutation; thread-safety is the listener's responsibility (the helper
|
|
8
|
+
WebSocket layer marshals calls onto the asyncio loop).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import Callable, Optional
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
# Liveness window. Scan loop pings every 2s (see server._scan_loop),
|
|
22
|
+
# so a device is treated as offline after ~2 missed PONGs (5s window).
|
|
23
|
+
# Manager parity: Manager uses 2s scan / 8s threshold; we run a touch
|
|
24
|
+
# tighter so power-off shows up in the Studio UI within ~5s instead
|
|
25
|
+
# of the 12s the previous "5s/12s" pair allowed.
|
|
26
|
+
_OFFLINE_THRESHOLD = 5.0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class HapbeatDevice:
|
|
31
|
+
ip: str = ""
|
|
32
|
+
name: str = ""
|
|
33
|
+
mac: str = ""
|
|
34
|
+
address: str = "" # path-based address e.g. "player_1/chest"
|
|
35
|
+
firmware: str = ""
|
|
36
|
+
|
|
37
|
+
last_seen: float = 0.0 # time.monotonic()
|
|
38
|
+
discovered_via: str = "" # "mdns", "udp_broadcast"
|
|
39
|
+
|
|
40
|
+
volume_level: Optional[int] = None
|
|
41
|
+
volume_wiper: Optional[int] = None
|
|
42
|
+
volume_steps: Optional[int] = None
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def is_online(self) -> bool:
|
|
46
|
+
if self.last_seen == 0.0:
|
|
47
|
+
return False
|
|
48
|
+
return (time.monotonic() - self.last_seen) < _OFFLINE_THRESHOLD
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class DeviceRegistry:
|
|
52
|
+
"""Thread-safe registry of known Hapbeat devices."""
|
|
53
|
+
|
|
54
|
+
def __init__(self) -> None:
|
|
55
|
+
self._devices: dict[str, HapbeatDevice] = {}
|
|
56
|
+
self._lock = threading.Lock()
|
|
57
|
+
self._listeners: list[Callable[[], None]] = []
|
|
58
|
+
|
|
59
|
+
# ── Listener API ─────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
def add_change_listener(self, callback: Callable[[], None]) -> None:
|
|
62
|
+
"""Register a callback fired after any device-state mutation.
|
|
63
|
+
|
|
64
|
+
The callback runs on whatever thread caused the mutation, so the
|
|
65
|
+
callback itself must be safe to invoke from background threads.
|
|
66
|
+
"""
|
|
67
|
+
self._listeners.append(callback)
|
|
68
|
+
|
|
69
|
+
def _notify(self) -> None:
|
|
70
|
+
for cb in list(self._listeners):
|
|
71
|
+
try:
|
|
72
|
+
cb()
|
|
73
|
+
except Exception: # noqa: BLE001
|
|
74
|
+
logger.exception("device_registry listener failed")
|
|
75
|
+
|
|
76
|
+
# ── Mutation API ─────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
def upsert_device(self, info: dict) -> None:
|
|
79
|
+
"""Add or update a device from discovery info."""
|
|
80
|
+
ip = info.get("ip", "")
|
|
81
|
+
if not ip:
|
|
82
|
+
return
|
|
83
|
+
|
|
84
|
+
with self._lock:
|
|
85
|
+
dev = self._devices.get(ip)
|
|
86
|
+
if dev is None:
|
|
87
|
+
dev = HapbeatDevice(ip=ip)
|
|
88
|
+
self._devices[ip] = dev
|
|
89
|
+
dev.name = info.get("name", dev.name) or dev.name
|
|
90
|
+
dev.mac = info.get("mac", dev.mac) or dev.mac
|
|
91
|
+
dev.address = info.get("address", dev.address) or dev.address
|
|
92
|
+
dev.firmware = info.get("firmware", dev.firmware) or dev.firmware
|
|
93
|
+
dev.last_seen = time.monotonic()
|
|
94
|
+
dev.discovered_via = info.get("discovered_via", dev.discovered_via)
|
|
95
|
+
|
|
96
|
+
# Optional volume fields (PONG carries these from 2026-04-12+)
|
|
97
|
+
for key in ("volume_level", "volume_wiper", "volume_steps"):
|
|
98
|
+
if key in info and info[key] is not None:
|
|
99
|
+
setattr(dev, key, info[key])
|
|
100
|
+
|
|
101
|
+
self._notify()
|
|
102
|
+
|
|
103
|
+
def update_volume(
|
|
104
|
+
self, ip: str, level: Optional[int], wiper: Optional[int],
|
|
105
|
+
steps: Optional[int],
|
|
106
|
+
) -> None:
|
|
107
|
+
with self._lock:
|
|
108
|
+
dev = self._devices.get(ip)
|
|
109
|
+
if dev is None:
|
|
110
|
+
return
|
|
111
|
+
if level is not None:
|
|
112
|
+
dev.volume_level = level
|
|
113
|
+
if wiper is not None:
|
|
114
|
+
dev.volume_wiper = wiper
|
|
115
|
+
if steps is not None:
|
|
116
|
+
dev.volume_steps = steps
|
|
117
|
+
self._notify()
|
|
118
|
+
|
|
119
|
+
# ── Query API ────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
def get_all_devices(self) -> dict[str, HapbeatDevice]:
|
|
122
|
+
with self._lock:
|
|
123
|
+
return dict(self._devices)
|
|
124
|
+
|
|
125
|
+
def get_device(self, ip: str) -> Optional[HapbeatDevice]:
|
|
126
|
+
with self._lock:
|
|
127
|
+
return self._devices.get(ip)
|
|
128
|
+
|
|
129
|
+
def get_all_ips(self) -> list[str]:
|
|
130
|
+
with self._lock:
|
|
131
|
+
return list(self._devices.keys())
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""mDNS device discovery for Hapbeat devices.
|
|
2
|
+
|
|
3
|
+
Browses ``_hapbeat._udp.local.`` via zeroconf and forwards discovered
|
|
4
|
+
devices to the registered callbacks. The companion :class:`UdpListener`
|
|
5
|
+
handles all UDP/PONG traffic, so this module is mDNS-only.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import threading
|
|
12
|
+
from typing import Callable, Optional
|
|
13
|
+
|
|
14
|
+
from zeroconf import (
|
|
15
|
+
ServiceBrowser, ServiceInfo, ServiceStateChange, Zeroconf,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
HAPBEAT_SERVICE_TYPE = "_hapbeat._udp.local."
|
|
21
|
+
|
|
22
|
+
DeviceCallback = Callable[[dict], None]
|
|
23
|
+
RemovedCallback = Callable[[str], None]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class MdnsScanner:
|
|
27
|
+
"""Continuously browses for Hapbeat services via mDNS."""
|
|
28
|
+
|
|
29
|
+
def __init__(self) -> None:
|
|
30
|
+
self._zeroconf: Optional[Zeroconf] = None
|
|
31
|
+
self._browser: Optional[ServiceBrowser] = None
|
|
32
|
+
self._known: dict[str, dict] = {} # ip -> info
|
|
33
|
+
self._lock = threading.Lock()
|
|
34
|
+
self._found_callbacks: list[DeviceCallback] = []
|
|
35
|
+
self._removed_callbacks: list[RemovedCallback] = []
|
|
36
|
+
|
|
37
|
+
# ── Listener API ─────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
def add_found_listener(self, cb: DeviceCallback) -> None:
|
|
40
|
+
self._found_callbacks.append(cb)
|
|
41
|
+
|
|
42
|
+
def add_removed_listener(self, cb: RemovedCallback) -> None:
|
|
43
|
+
self._removed_callbacks.append(cb)
|
|
44
|
+
|
|
45
|
+
# ── Lifecycle ────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
def start(self) -> None:
|
|
48
|
+
if self._zeroconf is not None:
|
|
49
|
+
return
|
|
50
|
+
self._zeroconf = Zeroconf()
|
|
51
|
+
self._browser = ServiceBrowser(
|
|
52
|
+
self._zeroconf,
|
|
53
|
+
HAPBEAT_SERVICE_TYPE,
|
|
54
|
+
handlers=[self._on_state_change],
|
|
55
|
+
)
|
|
56
|
+
logger.info("mDNS browsing started for %s", HAPBEAT_SERVICE_TYPE)
|
|
57
|
+
|
|
58
|
+
def stop(self) -> None:
|
|
59
|
+
if self._browser is not None:
|
|
60
|
+
self._browser.cancel()
|
|
61
|
+
self._browser = None
|
|
62
|
+
if self._zeroconf is not None:
|
|
63
|
+
self._zeroconf.close()
|
|
64
|
+
self._zeroconf = None
|
|
65
|
+
|
|
66
|
+
# ── Query ────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
def get_devices(self) -> dict[str, dict]:
|
|
69
|
+
with self._lock:
|
|
70
|
+
return dict(self._known)
|
|
71
|
+
|
|
72
|
+
# ── Internal ─────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
def _on_state_change(
|
|
75
|
+
self,
|
|
76
|
+
zeroconf: Zeroconf,
|
|
77
|
+
service_type: str,
|
|
78
|
+
name: str,
|
|
79
|
+
state_change: ServiceStateChange,
|
|
80
|
+
) -> None:
|
|
81
|
+
if state_change == ServiceStateChange.Added:
|
|
82
|
+
info = zeroconf.get_service_info(service_type, name)
|
|
83
|
+
if info is not None:
|
|
84
|
+
self._handle_added(info)
|
|
85
|
+
elif state_change == ServiceStateChange.Removed:
|
|
86
|
+
self._handle_removed(name)
|
|
87
|
+
|
|
88
|
+
def _handle_added(self, info: ServiceInfo) -> None:
|
|
89
|
+
addresses = info.parsed_addresses()
|
|
90
|
+
if not addresses:
|
|
91
|
+
return
|
|
92
|
+
ip = addresses[0]
|
|
93
|
+
|
|
94
|
+
txt: dict[str, str] = {}
|
|
95
|
+
if info.properties:
|
|
96
|
+
for key_bytes, val_bytes in info.properties.items():
|
|
97
|
+
key = (
|
|
98
|
+
key_bytes.decode("utf-8", errors="replace")
|
|
99
|
+
if isinstance(key_bytes, bytes) else str(key_bytes)
|
|
100
|
+
)
|
|
101
|
+
val = (
|
|
102
|
+
val_bytes.decode("utf-8", errors="replace")
|
|
103
|
+
if isinstance(val_bytes, bytes)
|
|
104
|
+
else str(val_bytes) if val_bytes is not None else ""
|
|
105
|
+
)
|
|
106
|
+
txt[key] = val
|
|
107
|
+
|
|
108
|
+
device_info: dict = {
|
|
109
|
+
"ip": ip,
|
|
110
|
+
"port": info.port,
|
|
111
|
+
"name": txt.get("name", info.name),
|
|
112
|
+
"group": int(txt.get("group", "0") or "0"),
|
|
113
|
+
"firmware": txt.get("fw", ""),
|
|
114
|
+
"mac": txt.get("mac", ""),
|
|
115
|
+
"discovered_via": "mdns",
|
|
116
|
+
}
|
|
117
|
+
with self._lock:
|
|
118
|
+
self._known[ip] = device_info
|
|
119
|
+
|
|
120
|
+
logger.info(
|
|
121
|
+
"mDNS: %s @ %s", device_info["name"] or "(unnamed)", ip,
|
|
122
|
+
)
|
|
123
|
+
for cb in list(self._found_callbacks):
|
|
124
|
+
try:
|
|
125
|
+
cb(device_info)
|
|
126
|
+
except Exception: # noqa: BLE001
|
|
127
|
+
logger.exception("mdns found listener failed")
|
|
128
|
+
|
|
129
|
+
def _handle_removed(self, name: str) -> None:
|
|
130
|
+
removed_ip: Optional[str] = None
|
|
131
|
+
with self._lock:
|
|
132
|
+
for ip, dev in self._known.items():
|
|
133
|
+
if dev.get("name") == name or name.startswith(
|
|
134
|
+
dev.get("name", "\x00"),
|
|
135
|
+
):
|
|
136
|
+
removed_ip = ip
|
|
137
|
+
break
|
|
138
|
+
if removed_ip is not None:
|
|
139
|
+
del self._known[removed_ip]
|
|
140
|
+
|
|
141
|
+
if removed_ip is not None:
|
|
142
|
+
logger.info("mDNS removed: %s", name)
|
|
143
|
+
for cb in list(self._removed_callbacks):
|
|
144
|
+
try:
|
|
145
|
+
cb(removed_ip)
|
|
146
|
+
except Exception: # noqa: BLE001
|
|
147
|
+
logger.exception("mdns removed listener failed")
|