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.
@@ -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"
@@ -0,0 +1,6 @@
1
+ """Allow `python -m hapbeat_helper` to invoke the CLI."""
2
+
3
+ from hapbeat_helper.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
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")