hapbeat-python-sdk 0.1.0__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/launchpad.py ADDED
@@ -0,0 +1,659 @@
1
+ """Browser launchpad -- try Hapbeat from a single local web page.
2
+
3
+ Browsers cannot send raw UDP, so this serves a tiny local web page backed by
4
+ a stdlib ``http.server`` that relays button presses to the SDK
5
+ (``hapbeat.play`` etc.). Open one page and fire one-shots, run a metronome,
6
+ a breathing pacer, or send Morse -- start and stop them live, no per-example
7
+ launching.
8
+
9
+ Run it from the CLI::
10
+
11
+ hapbeat launchpad # opens http://127.0.0.1:7100
12
+ hapbeat launchpad --port 8000 --no-open
13
+
14
+ Zero dependencies (stdlib only). The continuous activities (metronome /
15
+ breathing / morse) are deliberately compact mirrors of the standalone
16
+ examples; the examples remain the place to read and copy full code.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import threading
23
+ import time
24
+ import webbrowser
25
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
26
+ from typing import Callable, Optional
27
+
28
+ from .hapbeat import Hapbeat, connect
29
+
30
+ # Compact Morse table (a subset; the morse_text example has the full one).
31
+ _MORSE = {
32
+ "A": ".-", "B": "-...", "C": "-.-.", "D": "-..", "E": ".", "F": "..-.",
33
+ "G": "--.", "H": "....", "I": "..", "J": ".---", "K": "-.-", "L": ".-..",
34
+ "M": "--", "N": "-.", "O": "---", "P": ".--.", "Q": "--.-", "R": ".-.",
35
+ "S": "...", "T": "-", "U": "..-", "V": "...-", "W": ".--", "X": "-..-",
36
+ "Y": "-.--", "Z": "--..",
37
+ "0": "-----", "1": ".----", "2": "..---", "3": "...--", "4": "....-",
38
+ "5": ".....", "6": "-....", "7": "--...", "8": "---..", "9": "----.",
39
+ ".": ".-.-.-", ",": "--..--", "?": "..--..", "/": "-..-.",
40
+ }
41
+
42
+
43
+ # ── helpers ──────────────────────────────────────────────────────────
44
+ def _num(d: dict, key: str, default: float,
45
+ lo: Optional[float] = None, hi: Optional[float] = None) -> float:
46
+ try:
47
+ v = float(d.get(key, default))
48
+ except (TypeError, ValueError):
49
+ v = default
50
+ if lo is not None:
51
+ v = max(lo, v)
52
+ if hi is not None:
53
+ v = min(hi, v)
54
+ return v
55
+
56
+
57
+ def _sleep_until(stop: threading.Event, deadline: float) -> bool:
58
+ """Sleep to an absolute perf_counter deadline; True if stopped early."""
59
+ while True:
60
+ remaining = deadline - time.perf_counter()
61
+ if remaining <= 0:
62
+ return False
63
+ if stop.wait(min(remaining, 0.05)):
64
+ return True
65
+
66
+
67
+ # ── single-slot background engine ────────────────────────────────────
68
+ class Engine:
69
+ """Runs at most one continuous activity; starting a new one stops it."""
70
+
71
+ def __init__(self) -> None:
72
+ self._lock = threading.Lock()
73
+ self._stop: Optional[threading.Event] = None
74
+ self._thread: Optional[threading.Thread] = None
75
+ self.current: Optional[str] = None
76
+ self.info: dict = {}
77
+
78
+ def start(self, name: str, fn: Callable[[threading.Event], None],
79
+ info: dict) -> None:
80
+ self.stop()
81
+ stop = threading.Event()
82
+ with self._lock:
83
+ self._stop = stop
84
+ self.current = name
85
+ self.info = info
86
+
87
+ def wrapped() -> None:
88
+ try:
89
+ fn(stop)
90
+ finally:
91
+ with self._lock:
92
+ if self._stop is stop:
93
+ self.current = None
94
+ self.info = {}
95
+
96
+ self._thread = threading.Thread(target=wrapped, name=f"launchpad-{name}",
97
+ daemon=True)
98
+ self._thread.start()
99
+
100
+ def stop(self) -> None:
101
+ with self._lock:
102
+ stop, thread = self._stop, self._thread
103
+ self._stop = self._thread = None
104
+ self.current = None
105
+ self.info = {}
106
+ if stop is not None:
107
+ stop.set()
108
+ if thread is not None and thread is not threading.current_thread():
109
+ thread.join(timeout=1.0)
110
+
111
+ def status(self) -> dict:
112
+ with self._lock:
113
+ return {"running": self.current, "info": dict(self.info)}
114
+
115
+
116
+ # ── application (one shared Hapbeat + one Engine) ────────────────────
117
+ class App:
118
+ def __init__(self, hb: Hapbeat) -> None:
119
+ self.hb = hb
120
+ self.engine = Engine()
121
+
122
+ # one-shots
123
+ def devices(self) -> dict:
124
+ found = self.hb.discover(timeout=1.0)
125
+ return {"devices": [{"ip": d.ip, "address": d.address,
126
+ "name": d.name, "firmware": d.firmware_version}
127
+ for d in found]}
128
+
129
+ def play(self, p: dict) -> dict:
130
+ event = str(p.get("event", "")).strip()
131
+ if not event:
132
+ return {"ok": False, "error": "event id is empty"}
133
+ ok = self.hb.play(event, _num(p, "gain", 0.5, 0, 1),
134
+ target=str(p.get("target", "")))
135
+ return {"ok": bool(ok)}
136
+
137
+ def stop_all(self) -> dict:
138
+ self.engine.stop()
139
+ self.hb.stop_all()
140
+ return {"ok": True}
141
+
142
+ def status(self) -> dict:
143
+ return self.engine.status()
144
+
145
+ # continuous activities
146
+ def metronome(self, p: dict) -> dict:
147
+ event = str(p.get("event", "")).strip()
148
+ if not event:
149
+ return {"ok": False, "error": "event id is empty"}
150
+ accent_event = str(p.get("accent_event", "")).strip() or event
151
+ bpm = _num(p, "bpm", 100, 20, 400)
152
+ beats = int(_num(p, "beats", 4, 0, 32))
153
+ gain = _num(p, "gain", 0.5, 0, 1)
154
+ accent_gain = _num(p, "accent_gain", 1.0, 0, 1)
155
+ target = str(p.get("target", ""))
156
+
157
+ def run(stop: threading.Event) -> None:
158
+ interval = 60.0 / bpm
159
+ t = time.perf_counter() + 0.2
160
+ beat = 0
161
+ while not stop.is_set():
162
+ if _sleep_until(stop, t):
163
+ break
164
+ if time.perf_counter() - t > interval: # re-anchor after a stall
165
+ t = time.perf_counter()
166
+ accented = beats > 0 and beat == 0
167
+ self.hb.play(accent_event if accented else event,
168
+ gain=accent_gain if accented else gain, target=target)
169
+ t += interval
170
+ if beats > 0:
171
+ beat = (beat + 1) % beats
172
+
173
+ self.engine.start("metronome", run, {"bpm": bpm, "beats": beats})
174
+ return {"ok": True}
175
+
176
+ def breathing(self, p: dict) -> dict:
177
+ event = str(p.get("event", "")).strip()
178
+ if not event:
179
+ return {"ok": False, "error": "event id is empty"}
180
+ inhale = _num(p, "inhale", 4, 0.5, 60)
181
+ hold = _num(p, "hold", 0, 0, 60)
182
+ exhale = _num(p, "exhale", 4, 0.5, 60)
183
+ rest = _num(p, "rest", 0, 0, 60)
184
+ peak = _num(p, "peak", 0.8, 0, 1)
185
+ floor = _num(p, "floor", 0.15, 0, 1)
186
+ tick_interval = 1.0 / _num(p, "tick_rate", 2.0, 0.2, 20)
187
+ target = str(p.get("target", ""))
188
+ phases = [("IN", inhale, lambda x: floor + (peak - floor) * x),
189
+ ("HOLD", hold, lambda x: peak),
190
+ ("OUT", exhale, lambda x: peak - (peak - floor) * x),
191
+ ("REST", rest, lambda x: floor)]
192
+
193
+ def run(stop: threading.Event) -> None:
194
+ t = time.perf_counter() + 0.3
195
+ while not stop.is_set():
196
+ for _label, duration, gain_at in phases:
197
+ if duration <= 0:
198
+ continue
199
+ n = max(1, round(duration / tick_interval))
200
+ for i in range(n):
201
+ if _sleep_until(stop, t + i * tick_interval):
202
+ return
203
+ x = i / (n - 1) if n > 1 else 1.0
204
+ self.hb.play(event, gain=gain_at(x), target=target)
205
+ t += duration
206
+
207
+ self.engine.start("breathing", run,
208
+ {"pattern": [inhale, hold, exhale, rest]})
209
+ return {"ok": True}
210
+
211
+ def morse(self, p: dict) -> dict:
212
+ event = str(p.get("event", "")).strip()
213
+ text = str(p.get("text", "")).strip()
214
+ if not event:
215
+ return {"ok": False, "error": "event id is empty"}
216
+ if not text:
217
+ return {"ok": False, "error": "text is empty"}
218
+ unit = 1.2 / _num(p, "wpm", 12, 1, 60)
219
+ gain = _num(p, "gain", 0.7, 0, 1)
220
+ target = str(p.get("target", ""))
221
+
222
+ def run(stop: threading.Event) -> None:
223
+ for wi, word in enumerate(text.upper().split()):
224
+ if wi and _sleep_until(stop, time.perf_counter() + 7 * unit):
225
+ return
226
+ sent = False
227
+ for ch in word:
228
+ code = _MORSE.get(ch)
229
+ if not code:
230
+ continue
231
+ if sent and stop.wait(3 * unit):
232
+ return
233
+ sent = True
234
+ for ei, sym in enumerate(code):
235
+ if ei and stop.wait(unit):
236
+ return
237
+ self.hb.play(event, gain=gain, target=target)
238
+ if stop.wait(unit if sym == "." else 3 * unit):
239
+ self.hb.stop(event)
240
+ return
241
+ self.hb.stop(event)
242
+
243
+ self.engine.start("morse", run, {"text": text})
244
+ return {"ok": True}
245
+
246
+
247
+ # ── HTTP handler ─────────────────────────────────────────────────────
248
+ class _BaseHandler(BaseHTTPRequestHandler):
249
+ app: App # set on the subclass by build_server
250
+
251
+ def log_message(self, *_args) -> None: # keep the console quiet
252
+ pass
253
+
254
+ def _json(self, obj: dict, code: int = 200) -> None:
255
+ body = json.dumps(obj).encode("utf-8")
256
+ self.send_response(code)
257
+ self.send_header("Content-Type", "application/json")
258
+ self.send_header("Content-Length", str(len(body)))
259
+ self.end_headers()
260
+ self.wfile.write(body)
261
+
262
+ def _body(self) -> dict:
263
+ length = int(self.headers.get("Content-Length", 0) or 0)
264
+ raw = self.rfile.read(length) if length else b""
265
+ try:
266
+ return json.loads(raw or b"{}")
267
+ except json.JSONDecodeError:
268
+ return {}
269
+
270
+ def do_GET(self) -> None: # noqa: N802
271
+ if self.path == "/" or self.path.startswith("/?"):
272
+ body = PAGE.encode("utf-8")
273
+ self.send_response(200)
274
+ self.send_header("Content-Type", "text/html; charset=utf-8")
275
+ self.send_header("Content-Length", str(len(body)))
276
+ self.end_headers()
277
+ self.wfile.write(body)
278
+ elif self.path == "/api/devices":
279
+ self._json(self.app.devices())
280
+ elif self.path == "/api/status":
281
+ self._json(self.app.status())
282
+ else:
283
+ self._json({"error": "not found"}, 404)
284
+
285
+ def do_POST(self) -> None: # noqa: N802
286
+ routes = {
287
+ "/api/play": self.app.play,
288
+ "/api/metronome": self.app.metronome,
289
+ "/api/breathing": self.app.breathing,
290
+ "/api/morse": self.app.morse,
291
+ }
292
+ if self.path in routes:
293
+ try:
294
+ self._json(routes[self.path](self._body()))
295
+ except Exception as exc: # noqa: BLE001 -- report, never 500-crash
296
+ self._json({"ok": False, "error": str(exc)}, 200)
297
+ elif self.path == "/api/stop":
298
+ self._json(self.app.stop_all())
299
+ elif self.path == "/api/engine/stop":
300
+ self.app.engine.stop()
301
+ self._json({"ok": True})
302
+ else:
303
+ self._json({"error": "not found"}, 404)
304
+
305
+
306
+ def build_server(app: App, host: str, port: int) -> ThreadingHTTPServer:
307
+ handler = type("LaunchpadHandler", (_BaseHandler,), {"app": app})
308
+ return ThreadingHTTPServer((host, port), handler)
309
+
310
+
311
+ def serve(host: str = "127.0.0.1", port: int = 7100, *,
312
+ udp_port: int = 7700, target: str = "",
313
+ open_browser: bool = True) -> int:
314
+ # bind_port=0: receive on an ephemeral port and leave UDP 7700 to
315
+ # hapbeat-helper, so the launchpad and Hapbeat Studio can run together.
316
+ hb = connect(port=udp_port, app_name="Launchpad", default_target=target,
317
+ bind_port=0)
318
+ app = App(hb)
319
+ server = build_server(app, host, port)
320
+ url = f"http://{host}:{server.server_address[1]}/"
321
+ print(f"Hapbeat launchpad: {url}")
322
+ print("Open it in a browser. Ctrl-C to stop.")
323
+ if open_browser:
324
+ try:
325
+ webbrowser.open(url)
326
+ except Exception: # noqa: BLE001
327
+ pass
328
+ try:
329
+ server.serve_forever()
330
+ except KeyboardInterrupt:
331
+ print("\nshutting down")
332
+ finally:
333
+ app.engine.stop()
334
+ server.shutdown()
335
+ server.server_close()
336
+ hb.close()
337
+ return 0
338
+
339
+
340
+ # ── the single page (ASCII only -- cp932 consoles) ───────────────────
341
+ PAGE = r"""<!doctype html>
342
+ <html lang="en">
343
+ <head>
344
+ <meta charset="utf-8">
345
+ <meta name="viewport" content="width=device-width, initial-scale=1">
346
+ <title>Hapbeat Launchpad</title>
347
+ <style>
348
+ :root {
349
+ --bg: #0e1116; --panel: #171c24; --panel2: #1d242e; --line: #2a323d;
350
+ --text: #e6edf3; --muted: #8b97a6; --accent: #5b8cff; --accent2: #7c5cff;
351
+ --ok: #3fb950; --warn: #d29922; --danger: #f85149;
352
+ }
353
+ * { box-sizing: border-box; }
354
+ body {
355
+ margin: 0; font: 15px/1.5 -apple-system, Segoe UI, Roboto, sans-serif;
356
+ color: var(--text);
357
+ background: radial-gradient(1200px 600px at 70% -10%, #1b2740 0%, var(--bg) 55%);
358
+ min-height: 100vh;
359
+ }
360
+ header {
361
+ padding: 28px 24px 8px; max-width: 1080px; margin: 0 auto;
362
+ }
363
+ header h1 { margin: 0 0 4px; font-size: 26px; letter-spacing: -0.02em; }
364
+ header h1 .dot {
365
+ background: linear-gradient(90deg, var(--accent), var(--accent2));
366
+ -webkit-background-clip: text; background-clip: text; color: transparent;
367
+ }
368
+ header p { margin: 0; color: var(--muted); }
369
+ main { max-width: 1080px; margin: 0 auto; padding: 16px 24px 60px; }
370
+ .bar {
371
+ display: flex; gap: 12px; flex-wrap: wrap; align-items: end;
372
+ background: var(--panel); border: 1px solid var(--line);
373
+ border-radius: 14px; padding: 14px 16px; margin: 14px 0 22px;
374
+ }
375
+ .field { display: flex; flex-direction: column; gap: 4px; }
376
+ .field label { font-size: 12px; color: var(--muted); }
377
+ input, select {
378
+ background: var(--panel2); color: var(--text);
379
+ border: 1px solid var(--line); border-radius: 9px; padding: 8px 10px;
380
+ font: inherit; min-width: 0;
381
+ }
382
+ input:focus, select:focus { outline: 2px solid var(--accent); border-color: transparent; }
383
+ input[type=range] { padding: 0; }
384
+ .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
385
+ .grid {
386
+ display: grid; gap: 18px;
387
+ grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
388
+ }
389
+ .card {
390
+ background: var(--panel); border: 1px solid var(--line);
391
+ border-radius: 16px; padding: 18px; display: flex; flex-direction: column; gap: 12px;
392
+ }
393
+ .card h2 { margin: 0; font-size: 17px; }
394
+ .card .desc { color: var(--muted); font-size: 13px; margin: -6px 0 2px; }
395
+ .row { display: flex; gap: 10px; flex-wrap: wrap; align-items: end; }
396
+ .row .field { flex: 1; }
397
+ button {
398
+ appearance: none; border: 1px solid transparent; border-radius: 10px;
399
+ padding: 10px 14px; font: inherit; font-weight: 600; cursor: pointer;
400
+ color: #fff; background: linear-gradient(180deg, var(--accent), #3f6fe0);
401
+ }
402
+ button:hover { filter: brightness(1.07); }
403
+ button:active { transform: translateY(1px); }
404
+ button.ghost { background: var(--panel2); color: var(--text); border-color: var(--line); }
405
+ button.stop { background: linear-gradient(180deg, #ff6a60, var(--danger)); }
406
+ button.run.active { background: linear-gradient(180deg, #ffb648, var(--warn)); }
407
+ .cmd {
408
+ display: flex; align-items: flex-start; gap: 8px; margin-top: 2px;
409
+ background: #0a0d12; border: 1px solid var(--line); border-radius: 9px;
410
+ padding: 7px 9px;
411
+ }
412
+ .cmd code {
413
+ flex: 1; min-width: 0; white-space: pre-wrap; overflow-wrap: anywhere;
414
+ font-family: ui-monospace, Consolas, monospace; font-size: 12px; color: #9fb0c3;
415
+ }
416
+ .cmd .copy {
417
+ background: var(--panel2); color: var(--muted); border: 1px solid var(--line);
418
+ border-radius: 7px; padding: 4px 9px; font-size: 12px; font-weight: 500;
419
+ flex: none;
420
+ }
421
+ .stopall { margin-left: auto; }
422
+ .pill {
423
+ display: inline-flex; align-items: center; gap: 8px; font-size: 13px;
424
+ background: var(--panel2); border: 1px solid var(--line);
425
+ border-radius: 999px; padding: 6px 12px; color: var(--muted);
426
+ }
427
+ .pill .led { width: 9px; height: 9px; border-radius: 50%; background: var(--muted); }
428
+ .pill.online .led { background: var(--ok); box-shadow: 0 0 8px var(--ok); }
429
+ .pill.offline .led { background: var(--danger); }
430
+ #log {
431
+ margin-top: 22px; background: #0a0d12; border: 1px solid var(--line);
432
+ border-radius: 12px; padding: 12px 14px; height: 130px; overflow: auto;
433
+ font-family: ui-monospace, Consolas, monospace; font-size: 12.5px; color: #9fb0c3;
434
+ }
435
+ #log .err { color: var(--danger); }
436
+ #log .ok { color: var(--ok); }
437
+ .hint { color: var(--muted); font-size: 12.5px; margin-top: 6px; }
438
+ a { color: var(--accent); }
439
+ </style>
440
+ </head>
441
+ <body>
442
+ <header>
443
+ <h1><span class="dot">Hapbeat</span> Launchpad</h1>
444
+ <p>One page to try the SDK. Buttons hit a local Python server that drives the
445
+ device over Wi-Fi UDP. Each card shows the equivalent terminal command --
446
+ the button runs exactly that, so you can copy it and run it in a shell.</p>
447
+ </header>
448
+ <main>
449
+ <div class="bar">
450
+ <div class="field" style="flex:2; min-width:220px;">
451
+ <label>Default event id (used when a card's field is blank)</label>
452
+ <input id="defEvent" class="mono" placeholder="e.g. impact.hit" value="">
453
+ </div>
454
+ <div class="field">
455
+ <label>Target (blank = all)</label>
456
+ <input id="target" class="mono" placeholder="*/chest" value="">
457
+ </div>
458
+ <div class="field">
459
+ <button class="ghost" onclick="scan()">Scan devices</button>
460
+ </div>
461
+ <span id="devpill" class="pill offline"><span class="led"></span><span id="devtext">no scan yet</span></span>
462
+ <button class="stop stopall" onclick="stopAll()">STOP ALL</button>
463
+ </div>
464
+
465
+ <div class="grid">
466
+ <!-- Quick play -->
467
+ <div class="card">
468
+ <h2>Quick play</h2>
469
+ <div class="desc">Fire a single event. Move the slider for intensity.</div>
470
+ <div class="row">
471
+ <div class="field" style="flex:2;"><label>Event id</label>
472
+ <input id="play_event" class="mono" placeholder="(default)"></div>
473
+ <div class="field"><label>Gain <span id="play_gain_v">0.50</span></label>
474
+ <input id="play_gain" type="range" min="0" max="1" step="0.05" value="0.5"
475
+ oninput="play_gain_v.textContent=(+this.value).toFixed(2)"></div>
476
+ </div>
477
+ <button onclick="play()">Play</button>
478
+ <div class="cmd"><code id="play_cmd"></code>
479
+ <button class="copy" onclick="copyCmd('play_cmd')">copy</button></div>
480
+ </div>
481
+
482
+ <!-- Metronome -->
483
+ <div class="card">
484
+ <h2>Metronome</h2>
485
+ <div class="desc">Steady beat with an accented downbeat.</div>
486
+ <div class="row">
487
+ <div class="field"><label>BPM</label>
488
+ <input id="met_bpm" type="number" value="100" min="20" max="400"></div>
489
+ <div class="field"><label>Beats/bar</label>
490
+ <input id="met_beats" type="number" value="4" min="0" max="32"></div>
491
+ </div>
492
+ <div class="field"><label>Event id</label>
493
+ <input id="met_event" class="mono" placeholder="(default)"></div>
494
+ <button id="met_btn" class="run" onclick="toggle('metronome','met_btn',metParams)">Start</button>
495
+ <div class="cmd"><code id="met_cmd"></code>
496
+ <button class="copy" onclick="copyCmd('met_cmd')">copy</button></div>
497
+ </div>
498
+
499
+ <!-- Breathing -->
500
+ <div class="card">
501
+ <h2>Breathing pacer</h2>
502
+ <div class="desc">Intensity ramps up on inhale, down on exhale.</div>
503
+ <div class="row">
504
+ <div class="field"><label>Preset</label>
505
+ <select id="brk_preset" onchange="applyPreset()">
506
+ <option value="4,4,4,4">Box 4-4-4-4</option>
507
+ <option value="4,7,8,0">4-7-8</option>
508
+ <option value="5.5,0,5.5,0">Coherent 5.5</option>
509
+ </select></div>
510
+ <div class="field"><label>Pattern in,hold,out,rest</label>
511
+ <input id="brk_pattern" class="mono" value="4,4,4,4"></div>
512
+ </div>
513
+ <div class="field"><label>Event id</label>
514
+ <input id="brk_event" class="mono" placeholder="(default)"></div>
515
+ <button id="brk_btn" class="run" onclick="toggle('breathing','brk_btn',brkParams)">Start</button>
516
+ <div class="cmd"><code id="brk_cmd"></code>
517
+ <button class="copy" onclick="copyCmd('brk_cmd')">copy</button></div>
518
+ </div>
519
+
520
+ <!-- Morse -->
521
+ <div class="card">
522
+ <h2>Morse</h2>
523
+ <div class="desc">Send text as vibration. Use a looping buzz event.</div>
524
+ <div class="row">
525
+ <div class="field" style="flex:2;"><label>Text</label>
526
+ <input id="mor_text" value="SOS"></div>
527
+ <div class="field"><label>WPM</label>
528
+ <input id="mor_wpm" type="number" value="12" min="1" max="60"></div>
529
+ </div>
530
+ <div class="field"><label>Event id</label>
531
+ <input id="mor_event" class="mono" placeholder="(default)"></div>
532
+ <button id="mor_btn" class="run" onclick="toggle('morse','mor_btn',morParams)">Send</button>
533
+ <div class="cmd"><code id="mor_cmd"></code>
534
+ <button class="copy" onclick="copyCmd('mor_cmd')">copy</button></div>
535
+ </div>
536
+ </div>
537
+
538
+ <p class="hint">Event ids must exist in the kit deployed to the device (via
539
+ Hapbeat Studio). If nothing buzzes but Scan finds the device, the id is
540
+ probably not in the kit.</p>
541
+ <div id="log"></div>
542
+ </main>
543
+
544
+ <script>
545
+ const $ = id => document.getElementById(id);
546
+ function defEvent(){ return $('defEvent').value.trim(); }
547
+ function target(){ return $('target').value.trim(); }
548
+ function evOr(id){ const v = $(id).value.trim(); return v || defEvent(); }
549
+
550
+ function log(msg, cls){
551
+ const el = $('log'); const line = document.createElement('div');
552
+ if (cls) line.className = cls;
553
+ const t = new Date().toLocaleTimeString();
554
+ line.textContent = '[' + t + '] ' + msg; el.appendChild(line);
555
+ el.scrollTop = el.scrollHeight;
556
+ }
557
+ async function api(path, body){
558
+ try {
559
+ const r = await fetch(path, {method:'POST', headers:{'Content-Type':'application/json'},
560
+ body: JSON.stringify(body||{})});
561
+ return await r.json();
562
+ } catch(e){ log('request failed: ' + e, 'err'); return {ok:false, error:String(e)}; }
563
+ }
564
+ function note(res, what){
565
+ if (res && res.ok === false) log(what + ': ' + (res.error||'failed'), 'err');
566
+ else log(what, 'ok');
567
+ }
568
+
569
+ async function scan(){
570
+ log('scanning...');
571
+ try {
572
+ const r = await fetch('/api/devices'); const d = await r.json();
573
+ const n = (d.devices||[]).length;
574
+ const pill = $('devpill');
575
+ pill.className = 'pill ' + (n ? 'online' : 'offline');
576
+ $('devtext').textContent = n ? (n + ' device(s): ' +
577
+ d.devices.map(x => x.address || x.ip).join(', ')) : 'no device replied';
578
+ log(n ? ('found ' + n + ' device(s)') : 'no device replied (UDP still broadcasts)',
579
+ n ? 'ok' : 'err');
580
+ } catch(e){ log('scan failed: ' + e, 'err'); }
581
+ }
582
+ async function play(){
583
+ const ev = evOr('play_event');
584
+ if (!ev) return log('set an event id (card or default)', 'err');
585
+ note(await api('/api/play', {event: ev, gain: +$('play_gain').value, target: target()}),
586
+ 'play ' + ev);
587
+ }
588
+ async function stopAll(){
589
+ note(await api('/api/stop'), 'stop all');
590
+ ['met_btn','brk_btn','mor_btn'].forEach(setStart);
591
+ }
592
+
593
+ function metParams(){ return {event: evOr('met_event'), bpm:+$('met_bpm').value,
594
+ beats:+$('met_beats').value, target: target()}; }
595
+ function brkParams(){ const p=$('brk_pattern').value.split(',').map(Number);
596
+ return {event: evOr('brk_event'), inhale:p[0], hold:p[1]||0, exhale:p[2]||0,
597
+ rest:p[3]||0, target: target()}; }
598
+ function morParams(){ return {event: evOr('mor_event'), text:$('mor_text').value,
599
+ wpm:+$('mor_wpm').value, target: target()}; }
600
+
601
+ function applyPreset(){ $('brk_pattern').value = $('brk_preset').value; renderCmds(); }
602
+
603
+ // The equivalent terminal command per card -- exactly what the button runs.
604
+ function evShow(id){ return evOr(id) || '<event>'; }
605
+ function tgt(){ const t = target(); return t ? ' --target ' + t : ''; }
606
+ function renderCmds(){
607
+ $('play_cmd').textContent =
608
+ 'hapbeat play ' + evShow('play_event') +
609
+ ' --gain ' + (+$('play_gain').value).toFixed(2) + tgt();
610
+ $('met_cmd').textContent =
611
+ 'python examples/metronome.py --event ' + evShow('met_event') +
612
+ ' --bpm ' + (+$('met_bpm').value) + ' --beats ' + (+$('met_beats').value) + tgt();
613
+ $('brk_cmd').textContent =
614
+ 'python examples/breathing_pacer.py --event ' + evShow('brk_event') +
615
+ ' --pattern ' + ($('brk_pattern').value || '4,4,4,4') + tgt();
616
+ $('mor_cmd').textContent =
617
+ 'python examples/morse_text.py "' + ($('mor_text').value || 'TEXT') +
618
+ '" --event ' + evShow('mor_event') + ' --wpm ' + (+$('mor_wpm').value) + tgt();
619
+ }
620
+ async function copyCmd(id){
621
+ try { await navigator.clipboard.writeText($(id).textContent);
622
+ log('copied: ' + $(id).textContent, 'ok'); }
623
+ catch(e){ log('copy failed -- select the command text manually', 'err'); }
624
+ }
625
+ document.addEventListener('input', renderCmds);
626
+ function setStart(id){ const b=$(id); b.classList.remove('active');
627
+ b.textContent = id==='mor_btn' ? 'Send' : 'Start'; }
628
+ function setStop(id){ const b=$(id); b.classList.add('active'); b.textContent='Stop'; }
629
+
630
+ async function toggle(kind, btnId, paramsFn){
631
+ const b = $(btnId);
632
+ if (b.classList.contains('active')){
633
+ note(await api('/api/engine/stop'), 'stop ' + kind);
634
+ setStart(btnId); return;
635
+ }
636
+ const ev = paramsFn().event;
637
+ if (!ev) return log('set an event id (card or default)', 'err');
638
+ const res = await api('/api/' + kind, paramsFn());
639
+ if (res.ok === false){ note(res, kind); return; }
640
+ ['met_btn','brk_btn','mor_btn'].forEach(setStart);
641
+ setStop(btnId); log('start ' + kind, 'ok');
642
+ }
643
+
644
+ // Reflect server truth (auto-stop, morse finishing) every ~1.5s.
645
+ async function poll(){
646
+ try {
647
+ const r = await fetch('/api/status'); const s = await r.json();
648
+ const map = {metronome:'met_btn', breathing:'brk_btn', morse:'mor_btn'};
649
+ Object.values(map).forEach(setStart);
650
+ if (s.running && map[s.running]) setStop(map[s.running]);
651
+ } catch(e){}
652
+ }
653
+ setInterval(poll, 1500);
654
+ renderCmds();
655
+ scan();
656
+ </script>
657
+ </body>
658
+ </html>
659
+ """