gsalmon commited on
Commit
ed13a9f
·
1 Parent(s): e04523a

wip: added radio alarm logic

Browse files
reachy_mini_radio/alarm_manager.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from pathlib import Path
3
+ from datetime import datetime
4
+
5
+
6
+ class AlarmManager:
7
+ def __init__(self, config_path: Path):
8
+ self.config_path = config_path
9
+ self.settings = {
10
+ "enabled": False,
11
+ "alarm_mode": False,
12
+ "time": "08:00", # HH:MM 24h format
13
+
14
+ "station_url": "",
15
+ "station_name": "",
16
+ }
17
+ self.load_settings()
18
+ self._triggered_today = False
19
+ self._last_check_date = None
20
+
21
+ def load_settings(self):
22
+ if self.config_path.exists():
23
+ try:
24
+ data = json.loads(self.config_path.read_text("utf-8"))
25
+ self.settings.update(data)
26
+ except Exception as e:
27
+ print(f"[AlarmManager] Error loading settings: {e}")
28
+
29
+ def save_settings(self, new_settings=None):
30
+ if new_settings:
31
+ self.settings.update(new_settings)
32
+
33
+ try:
34
+ self.config_path.write_text(
35
+ data=json.dumps(self.settings, indent=2),
36
+ encoding="utf-8"
37
+ )
38
+ except Exception as e:
39
+ print(f"[AlarmManager] Error saving settings: {e}")
40
+
41
+ return self.settings
42
+
43
+ def get_settings(self):
44
+ return self.settings
45
+
46
+ def check_alarm(self):
47
+ if not self.settings["enabled"]:
48
+ return False
49
+
50
+ if self._triggered_today:
51
+ return False
52
+
53
+ now = datetime.now()
54
+ current_date = now.date()
55
+
56
+ if self._last_check_date != current_date:
57
+ self._triggered_today = False
58
+ self._last_check_date = current_date
59
+
60
+ if self._triggered_today:
61
+ return False
62
+
63
+ current_time_str = now.strftime("%H:%M")
64
+ if current_time_str == self.settings["time"]:
65
+ self._triggered_today = True
66
+ return True
67
+
68
+ return False
reachy_mini_radio/alarm_settings.json ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "enabled": false,
3
+ "alarm_mode": false,
4
+ "time": "22:41",
5
+ "station_url": "https://cloud.revma.ihrhls.com/zc4366?rj-org=n49b-e2&rj-ttl=5&rj-tok=AAABmtXtGekA9dyvNtHhv-m4mw",
6
+ "station_name": "92.5 The Breeze",
7
+ "timezone": "America/Los_Angeles"
8
+ }
reachy_mini_radio/main.py CHANGED
@@ -6,14 +6,17 @@ import json
6
 
7
  import sounddevice as sd
8
  import numpy as np
 
9
  import av
10
  from reachy_mini import ReachyMini, ReachyMiniApp
11
  from reachy_mini_radio.antenna_button import AntennaButton
 
12
 
13
  # -----------------------------
14
  # Config
15
  # -----------------------------
16
  RADIOS_FILE = Path(__file__).parent / "webradios.json"
 
17
 
18
  sr = 48000
19
  channels = 2
@@ -79,42 +82,48 @@ class RadioDecoder:
79
  self.thread.join()
80
 
81
  def _run(self):
82
- try:
83
- container = av.open(self.url, timeout=5.0)
84
- audio_stream = next(s for s in container.streams if s.type == "audio")
 
85
 
86
- resampler = av.audio.resampler.AudioResampler(
87
- format="flt",
88
- layout="stereo",
89
- rate=self.sr,
90
- )
91
 
92
- for packet in container.demux(audio_stream):
93
- if self.stop_event.is_set():
94
- break
95
-
96
- for frame in packet.decode():
97
- for out_frame in resampler.resample(frame):
98
- arr = out_frame.to_ndarray()
99
- if arr.ndim == 1:
100
- arr = arr[np.newaxis, :]
101
-
102
- pcm = arr.T # (samples, channels)
103
-
104
- i = 0
105
- n = pcm.shape[0]
106
- while i < n and not self.stop_event.is_set():
107
- chunk = pcm[i : i + blocksize, :]
108
- i += blocksize
109
- try:
110
- self.queue.put(chunk, timeout=0.1)
111
- except queue.Full:
112
- # drop if output can't keep up
113
- break
114
- except Exception as e:
115
- print(f"[RadioDecoder] {self.name} error: {e}")
116
- finally:
117
- print(f"[RadioDecoder] {self.name} stopped")
 
 
 
 
 
118
 
119
  def get_samples(self, frames):
120
  out = np.zeros((frames, self.channels), dtype=np.float32)
@@ -268,8 +277,7 @@ def between_angles(angle, _angles):
268
  else:
269
  return sorted_angles[0], sorted_angles[0]
270
 
271
- # Insert angle into sorted list to find position
272
- import bisect
273
 
274
  idx = bisect.bisect_left(sorted_angles, angle)
275
  if idx > 0 and abs(sorted_angles[idx - 1] - angle) < 0.05:
@@ -291,110 +299,44 @@ def between_angles(angle, _angles):
291
 
292
  return prev_angle, next_angle
293
 
 
 
 
 
 
294
 
295
  class ReachyMiniRadio(ReachyMiniApp):
296
  # If your webradio selector serves the UI at this URL:
297
  custom_app_url: str | None = "http://0.0.0.0:8042"
298
 
299
  def run(self, reachy_mini: ReachyMini, stop_event: threading.Event):
 
 
 
300
  # Shared radio set
301
- radioset = RadioSet()
302
-
303
- @self.settings_app.get("/api/webradios")
304
- async def get_webradios():
305
- """Expose the current list of stations to the settings UI."""
306
- return load_stations_from_file(RADIOS_FILE)
307
-
308
- @self.settings_app.post("/api/webradios")
309
- async def save_webradios(payload: list[dict[str, str]]):
310
- """Persist the stations selected in the settings UI."""
311
- cleaned = save_stations_to_file(RADIOS_FILE, payload)
312
- return {"ok": True, "count": len(cleaned)}
313
-
314
  self.antenna_button = AntennaButton(reachy_mini, stop_event)
 
 
 
 
 
 
 
315
 
316
  # Initial load of stations
317
  initial_stations = load_stations_from_file(RADIOS_FILE)
318
- radioset.update(initial_stations)
319
 
320
  # Start watcher thread that reloads stations when JSON changes
321
  watcher_thread = threading.Thread(
322
  target=stations_watcher,
323
- args=(stop_event, radioset, RADIOS_FILE),
324
  daemon=True,
325
  )
326
  watcher_thread.start()
327
 
328
- # Antenna calibration
329
- state = {"angle": 0.0}
330
- # calib = {"center": None, "range": 2 * np.pi} # very rough mapping
331
-
332
- def update_angle():
333
- raw = reachy_mini.get_present_antenna_joint_positions()[1]
334
- return raw % (2 * np.pi)
335
-
336
- reachy_mini.enable_motors(["left_antenna"])
337
- reachy_mini.goto_target(np.eye(4), antennas=[0, 0])
338
- reachy_mini.disable_motors(["left_antenna"])
339
-
340
- rng = np.random.default_rng()
341
-
342
- def audio_callback(outdata, frames, time_info, status):
343
- if status:
344
- print(status, flush=True)
345
-
346
- angle = state["angle"]
347
-
348
- with radioset.lock:
349
- station_angles = radioset.station_angles
350
- decoders = list(radioset.decoders)
351
-
352
- # Always produce some output (static), even with no stations
353
- if station_angles.size == 0 or not decoders:
354
- noise = rng.normal(0.0, noise_std, size=(frames, channels)).astype(
355
- np.float32
356
- )
357
- np.clip(noise, -1.0, 1.0, out=noise)
358
- outdata[:] = noise
359
- return
360
-
361
- # Find nearest station
362
- dists = circular_distance(angle, station_angles)
363
- nearest_idx = int(np.argmin(dists))
364
- d_min = float(dists[nearest_idx])
365
-
366
- # Station gain (0 outside bandwidth, 1 at station)
367
- if d_min >= station_bandwidth:
368
- station_gain = 0.0
369
- else:
370
- station_gain = 1.0 - (d_min / station_bandwidth)
371
-
372
- # Get radio samples for nearest station only
373
- decoder = decoders[nearest_idx]
374
- radio = decoder.get_samples(frames)
375
-
376
- # Noise gain with "sweet spot"
377
- if d_min <= station_sweetspot:
378
- # In sweet spot: no static at all
379
- noise_gain = 0.0
380
- elif d_min >= station_bandwidth:
381
- # Completely off any station: maximum noise
382
- noise_gain = max_noise_gain
383
- else:
384
- # Between sweet spot and bandwidth: ramp from 0 to max_noise_gain
385
- t = (d_min - station_sweetspot) / (
386
- station_bandwidth - station_sweetspot
387
- )
388
- t = max(0.0, min(1.0, t))
389
- noise_gain = t * max_noise_gain
390
-
391
- noise = rng.normal(0.0, noise_std, size=(frames, channels)).astype(
392
- np.float32
393
- )
394
-
395
- mix = station_gain * radio + noise_gain * noise
396
- np.clip(mix, -1.0, 1.0, out=mix)
397
- outdata[:] = mix
398
 
399
  # Start audio output
400
  with sd.OutputStream(
@@ -402,70 +344,16 @@ class ReachyMiniRadio(ReachyMiniApp):
402
  channels=channels,
403
  dtype="float32",
404
  blocksize=blocksize,
405
- callback=audio_callback,
406
  ):
407
  print(
408
  "ReachyMiniRadio running. Move the left antenna to tune stations (hot-reload from webradios.json)."
409
  )
 
410
  try:
411
  while not stop_event.is_set():
412
- state["angle"] = update_angle() # 0 to 2pi range
413
- triggered, direction = self.antenna_button.is_triggered()
414
- if triggered:
415
- print(f"[AntennaButton] Triggered! Direction: {direction}")
416
- # use radioset.station_angles to get the next angle to jump to depending on direction "left" or "right"
417
- with radioset.lock:
418
- if (
419
- radioset.station_angles.size == 0
420
- or radioset.station_angles.size == 1
421
- ):
422
- continue
423
- current_angle = state["angle"] # 0 to 2pi range
424
- before_angle, after_angle = between_angles(
425
- current_angle, radioset.station_angles.copy()
426
- )
427
-
428
- if before_angle > np.pi:
429
- before_angle -= 2 * np.pi
430
- if before_angle < -np.pi:
431
- before_angle += 2 * np.pi
432
- if after_angle > np.pi:
433
- after_angle -= 2 * np.pi
434
- if after_angle < -np.pi:
435
- after_angle += 2 * np.pi
436
- if current_angle > np.pi:
437
- current_angle -= 2 * np.pi
438
- if current_angle < -np.pi:
439
- current_angle += 2 * np.pi
440
-
441
- print(
442
- "before:",
443
- before_angle,
444
- "current angle:",
445
- current_angle,
446
- "after:",
447
- after_angle,
448
- )
449
- if direction == "right":
450
- target_angle = after_angle
451
- print("after")
452
- else:
453
- target_angle = before_angle
454
- print("before")
455
- # target_angle = target_angle % (2 * np.pi)
456
- # target_angle is in -pi to +pi range
457
- # if target_angle > np.pi:
458
- # target_angle -= 2 * np.pi
459
- print(
460
- f"[AntennaButton] Jumping to angle: {target_angle:.2f} rad"
461
- )
462
- reachy_mini.enable_motors(["left_antenna"])
463
- reachy_mini.set_target_antenna_joint_positions(
464
- [0.0, float(target_angle)]
465
- )
466
- time.sleep(0.3) # wait for movement
467
- reachy_mini.disable_motors(["left_antenna"])
468
-
469
  time.sleep(0.01)
470
  finally:
471
  # Stop watcher
@@ -473,14 +361,215 @@ class ReachyMiniRadio(ReachyMiniApp):
473
  watcher_thread.join(timeout=1.0)
474
 
475
  # Stop decoders
476
- with radioset.lock:
477
- decoders = list(radioset.decoders)
478
  for d in decoders:
479
  d.stop()
480
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
481
 
482
  if __name__ == "__main__":
483
- # You can run the app directly from this script
484
  with ReachyMini() as mini:
485
  app = ReachyMiniRadio()
486
  stop = threading.Event()
@@ -492,4 +581,4 @@ if __name__ == "__main__":
492
  print("App has stopped.")
493
  except KeyboardInterrupt:
494
  print("Stopping the app...")
495
- stop.set()
 
6
 
7
  import sounddevice as sd
8
  import numpy as np
9
+ import bisect
10
  import av
11
  from reachy_mini import ReachyMini, ReachyMiniApp
12
  from reachy_mini_radio.antenna_button import AntennaButton
13
+ from reachy_mini_radio.alarm_manager import AlarmManager
14
 
15
  # -----------------------------
16
  # Config
17
  # -----------------------------
18
  RADIOS_FILE = Path(__file__).parent / "webradios.json"
19
+ ALARM_FILE = Path(__file__).parent / "alarm_settings.json"
20
 
21
  sr = 48000
22
  channels = 2
 
82
  self.thread.join()
83
 
84
  def _run(self):
85
+ while not self.stop_event.is_set():
86
+ try:
87
+ container = av.open(self.url, timeout=5.0)
88
+ audio_stream = next(s for s in container.streams if s.type == "audio")
89
 
90
+ resampler = av.audio.resampler.AudioResampler(
91
+ format="flt",
92
+ layout="stereo",
93
+ rate=self.sr,
94
+ )
95
 
96
+ for packet in container.demux(audio_stream):
97
+ if self.stop_event.is_set():
98
+ break
99
+
100
+ for frame in packet.decode():
101
+ for out_frame in resampler.resample(frame):
102
+ arr = out_frame.to_ndarray()
103
+ if arr.ndim == 1:
104
+ arr = arr[np.newaxis, :]
105
+
106
+ pcm = arr.T # (samples, channels)
107
+
108
+ i = 0
109
+ n = pcm.shape[0]
110
+ while i < n and not self.stop_event.is_set():
111
+ chunk = pcm[i : i + blocksize, :]
112
+ i += blocksize
113
+ try:
114
+ self.queue.put(chunk, timeout=0.1)
115
+ except queue.Full:
116
+ # drop if output can't keep up
117
+ break
118
+ except Exception as e:
119
+ if not self.stop_event.is_set():
120
+ print(f"[RadioDecoder] {self.name} error: {e}. Retrying in 5s...")
121
+ time.sleep(5.0)
122
+ finally:
123
+ # Cleanup if needed
124
+ pass
125
+
126
+ print(f"[RadioDecoder] {self.name} stopped")
127
 
128
  def get_samples(self, frames):
129
  out = np.zeros((frames, self.channels), dtype=np.float32)
 
277
  else:
278
  return sorted_angles[0], sorted_angles[0]
279
 
280
+ # Insert angle into sorted list to find positio
 
281
 
282
  idx = bisect.bisect_left(sorted_angles, angle)
283
  if idx > 0 and abs(sorted_angles[idx - 1] - angle) < 0.05:
 
299
 
300
  return prev_angle, next_angle
301
 
302
+ def closest_equivalent_angle(target, current):
303
+ diff = target - current
304
+ # wrap diff to [-pi, pi)
305
+ diff = (diff + np.pi) % (2 * np.pi) - np.pi
306
+ return current + diff
307
 
308
  class ReachyMiniRadio(ReachyMiniApp):
309
  # If your webradio selector serves the UI at this URL:
310
  custom_app_url: str | None = "http://0.0.0.0:8042"
311
 
312
  def run(self, reachy_mini: ReachyMini, stop_event: threading.Event):
313
+ self.reachy_mini = reachy_mini
314
+ self.stop_event = stop_event
315
+
316
  # Shared radio set
317
+ self.radioset = RadioSet()
 
 
 
 
 
 
 
 
 
 
 
 
318
  self.antenna_button = AntennaButton(reachy_mini, stop_event)
319
+ self.alarm_manager = AlarmManager(ALARM_FILE)
320
+ self.rng = np.random.default_rng()
321
+
322
+ # State
323
+ self.state = {"angle": 0.0}
324
+
325
+ self._setup_routes()
326
 
327
  # Initial load of stations
328
  initial_stations = load_stations_from_file(RADIOS_FILE)
329
+ self.radioset.update(initial_stations)
330
 
331
  # Start watcher thread that reloads stations when JSON changes
332
  watcher_thread = threading.Thread(
333
  target=stations_watcher,
334
+ args=(stop_event, self.radioset, RADIOS_FILE),
335
  daemon=True,
336
  )
337
  watcher_thread.start()
338
 
339
+ self._calibrate_antenna()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
340
 
341
  # Start audio output
342
  with sd.OutputStream(
 
344
  channels=channels,
345
  dtype="float32",
346
  blocksize=blocksize,
347
+ callback=self._audio_callback,
348
  ):
349
  print(
350
  "ReachyMiniRadio running. Move the left antenna to tune stations (hot-reload from webradios.json)."
351
  )
352
+
353
  try:
354
  while not stop_event.is_set():
355
+ self._handle_alarm()
356
+ self._handle_antenna_button()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
357
  time.sleep(0.01)
358
  finally:
359
  # Stop watcher
 
361
  watcher_thread.join(timeout=1.0)
362
 
363
  # Stop decoders
364
+ with self.radioset.lock:
365
+ decoders = list(self.radioset.decoders)
366
  for d in decoders:
367
  d.stop()
368
 
369
+ def _setup_routes(self):
370
+ @self.settings_app.get("/api/webradios")
371
+ def get_webradios():
372
+ """Expose the current list of stations to the settings UI."""
373
+ return load_stations_from_file(RADIOS_FILE)
374
+
375
+ @self.settings_app.post("/api/webradios")
376
+ def save_webradios(payload: list[dict[str, str]]):
377
+ """Persist the stations selected in the settings UI."""
378
+ cleaned = save_stations_to_file(RADIOS_FILE, payload)
379
+ return {"ok": True, "count": len(cleaned)}
380
+
381
+ @self.settings_app.get("/api/alarm")
382
+ def get_alarm():
383
+ return self.alarm_manager.get_settings()
384
+
385
+ @self.settings_app.post("/api/alarm")
386
+ def save_alarm(payload: dict):
387
+ return self.alarm_manager.save_settings(payload)
388
+
389
+ def _calibrate_antenna(self):
390
+ self.reachy_mini.enable_motors(["left_antenna"])
391
+ self.reachy_mini.goto_target(np.eye(4), antennas=[0, 0])
392
+ self.reachy_mini.disable_motors(["left_antenna"])
393
+
394
+ def _update_angle(self):
395
+ raw = self.reachy_mini.get_present_antenna_joint_positions()[1]
396
+ return raw % (2 * np.pi)
397
+
398
+ def _audio_callback(self, outdata, frames, time_info, status):
399
+ if status:
400
+ print(status, flush=True)
401
+
402
+ settings = self.alarm_manager.get_settings()
403
+ if settings.get("enabled") and settings.get("alarm_mode"):
404
+ outdata.fill(0)
405
+ return
406
+
407
+ angle = self.state["angle"]
408
+
409
+ with self.radioset.lock:
410
+ station_angles = self.radioset.station_angles
411
+ decoders = list(self.radioset.decoders)
412
+
413
+ # Always produce some output (static), even with no stations
414
+ if station_angles.size == 0 or not decoders:
415
+ noise = self.rng.normal(0.0, noise_std, size=(frames, channels)).astype(
416
+ np.float32
417
+ )
418
+ np.clip(noise, -1.0, 1.0, out=noise)
419
+ outdata[:] = noise
420
+ return
421
+
422
+ # Find nearest station
423
+ dists = circular_distance(angle, station_angles)
424
+ nearest_idx = int(np.argmin(dists))
425
+ d_min = float(dists[nearest_idx])
426
+
427
+ # Station gain (0 outside bandwidth, 1 at station)
428
+ if d_min >= station_bandwidth:
429
+ station_gain = 0.0
430
+ else:
431
+ station_gain = 1.0 - (d_min / station_bandwidth)
432
+
433
+ # Get radio samples for nearest station only
434
+ decoder = decoders[nearest_idx]
435
+ radio = decoder.get_samples(frames)
436
+
437
+ # Noise gain with "sweet spot"
438
+ if d_min <= station_sweetspot:
439
+ # In sweet spot: no static at all
440
+ noise_gain = 0.0
441
+ elif d_min >= station_bandwidth:
442
+ # Completely off any station: maximum noise
443
+ noise_gain = max_noise_gain
444
+ else:
445
+ # Between sweet spot and bandwidth: ramp from 0 to max_noise_gain
446
+ t = (d_min - station_sweetspot) / (
447
+ station_bandwidth - station_sweetspot
448
+ )
449
+ t = max(0.0, min(1.0, t))
450
+ noise_gain = t * max_noise_gain
451
+
452
+ noise = self.rng.normal(0.0, noise_std, size=(frames, channels)).astype(
453
+ np.float32
454
+ )
455
+
456
+ mix = station_gain * radio + noise_gain * noise
457
+ np.clip(mix, -1.0, 1.0, out=mix)
458
+ outdata[:] = mix
459
+
460
+ def _move_antenna(self, target_angle):
461
+ print(f"[Tuning] Jumping to angle: {target_angle:.2f} rad")
462
+ self.reachy_mini.enable_motors(["left_antenna"])
463
+ self.reachy_mini.set_target_antenna_joint_positions(
464
+ [0.0, float(target_angle)]
465
+ )
466
+ time.sleep(0.3) # wait for movement
467
+ self.reachy_mini.disable_motors(["left_antenna"])
468
+
469
+ def _handle_alarm(self):
470
+ is_alarm_triggered = self.alarm_manager.check_alarm()
471
+ settings = self.alarm_manager.get_settings()
472
+
473
+ if settings.get("enabled") and settings.get("alarm_mode"):
474
+ if not self.state.get("is_sleeping", False):
475
+ print("[AlarmManager] Entering Alarm Mode: Going to sleep...")
476
+ self.reachy_mini.goto_sleep()
477
+ self.state["is_sleeping"] = True
478
+ self.state["ignore_antenna"] = True
479
+
480
+ # Check if alarm was cancelled while sleeping
481
+ if self.state.get("is_sleeping", False) and not settings.get("alarm_mode"):
482
+ print("[AlarmManager] Alarm cancelled. Waking up...")
483
+ self.reachy_mini.wake_up()
484
+ self.state["is_sleeping"] = False
485
+ self.state["ignore_antenna"] = False
486
+
487
+ if is_alarm_triggered:
488
+ print("[AlarmManager] Alarm Triggered!")
489
+ self.state["ignore_antenna"] = True
490
+
491
+ if self.state.get("is_sleeping", False):
492
+ print("[AlarmManager] Waking up...")
493
+ self.reachy_mini.wake_up()
494
+ self.state["is_sleeping"] = False
495
+
496
+ self.alarm_manager.save_settings({"alarm_mode": False})
497
+ settings = self.alarm_manager.get_settings()
498
+ target_url = settings.get("station_url")
499
+
500
+ if target_url:
501
+ found_angle = None
502
+ with self.radioset.lock:
503
+ for i, decoder in enumerate(self.radioset.decoders):
504
+ if decoder.url == target_url:
505
+ found_angle = self.radioset.station_angles[i]
506
+ break
507
+
508
+ if found_angle is not None:
509
+ print(f"[AlarmManager] Found target station at angle: {found_angle:.2f}")
510
+
511
+ # Use closest equivalent angle logic but execute with _move_antenna
512
+ current_pos = self.reachy_mini.get_present_antenna_joint_positions()[1]
513
+ target_angle = closest_equivalent_angle(float(found_angle), current_pos)
514
+ print(f"[AlarmManager] Current pos: {current_pos:.2f}, Target: {target_angle:.2f}")
515
+
516
+ self._move_antenna(target_angle)
517
+ else:
518
+ print(f"[AlarmManager] Station URL {target_url} not found in current radio set.")
519
+ else:
520
+ print("[AlarmManager] No target URL found.")
521
+
522
+ self.state["ignore_antenna"] = False
523
+
524
+ def _handle_antenna_button(self):
525
+ if not self.state.get("ignore_antenna", False):
526
+ self.state["angle"] = self._update_angle() # 0 to 2pi range
527
+ triggered, direction = self.antenna_button.is_triggered()
528
+ if triggered:
529
+ print(f"[AntennaButton] Triggered! Direction: {direction}")
530
+ # use radioset.station_angles to get the next angle to jump to depending on direction "left" or "right"
531
+ with self.radioset.lock:
532
+ if (
533
+ self.radioset.station_angles.size == 0
534
+ or self.radioset.station_angles.size == 1
535
+ ):
536
+ return
537
+ current_angle = self.state["angle"] # 0 to 2pi range
538
+ before_angle, after_angle = between_angles(
539
+ current_angle, self.radioset.station_angles.copy()
540
+ )
541
+
542
+ if before_angle > np.pi:
543
+ before_angle -= 2 * np.pi
544
+ if before_angle < -np.pi:
545
+ before_angle += 2 * np.pi
546
+ if after_angle > np.pi:
547
+ after_angle -= 2 * np.pi
548
+ if after_angle < -np.pi:
549
+ after_angle += 2 * np.pi
550
+ if current_angle > np.pi:
551
+ current_angle -= 2 * np.pi
552
+ if current_angle < -np.pi:
553
+ current_angle += 2 * np.pi
554
+
555
+ print(
556
+ "before:",
557
+ before_angle,
558
+ "current angle:",
559
+ current_angle,
560
+ "after:",
561
+ after_angle,
562
+ )
563
+ if direction == "right":
564
+ target_angle = after_angle
565
+ print("after")
566
+ else:
567
+ target_angle = before_angle
568
+ print("before")
569
+
570
+ self._move_antenna(target_angle)
571
 
572
  if __name__ == "__main__":
 
573
  with ReachyMini() as mini:
574
  app = ReachyMiniRadio()
575
  stop = threading.Event()
 
581
  print("App has stopped.")
582
  except KeyboardInterrupt:
583
  print("Stopping the app...")
584
+ stop.set()
reachy_mini_radio/static/index.html CHANGED
@@ -37,6 +37,25 @@
37
  <button id="saveBtn" style="margin-left: auto;">Save</button>
38
  </div>
39
  <div class="status" id="saveStatus"></div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  </div>
41
  </div>
42
  </div>
 
37
  <button id="saveBtn" style="margin-left: auto;">Save</button>
38
  </div>
39
  <div class="status" id="saveStatus"></div>
40
+
41
+ <div class="panel-title" style="margin-top: 2rem;">Alarm Clock</div>
42
+ <div class="alarm-box">
43
+ <div class="form-row">
44
+ <label>Time</label>
45
+ <input type="time" id="alarmTime" />
46
+ </div>
47
+
48
+ <div class="form-row">
49
+ <label>Station</label>
50
+ <select id="alarmStation"></select>
51
+ </div>
52
+ <div class="search-row" style="margin-top: 16px; gap: 10px;">
53
+ <button id="setAlarmBtn" class="primary" style="flex: 1;">Set Alarm & Sleep</button>
54
+ <button id="cancelAlarmBtn" class="secondary" style="flex: 1; display: none;">Cancel
55
+ Alarm</button>
56
+ </div>
57
+ <div class="status" id="alarmStatus"></div>
58
+ </div>
59
  </div>
60
  </div>
61
  </div>
reachy_mini_radio/static/main.js CHANGED
@@ -9,6 +9,12 @@ const saveBtn = document.getElementById("saveBtn");
9
  const clearBtn = document.getElementById("clearBtn");
10
  const countBadge = document.getElementById("countBadge");
11
 
 
 
 
 
 
 
12
  const currentUrl = new URL(window.location.href);
13
  if (!currentUrl.pathname.endsWith("/")) {
14
  currentUrl.pathname += "/";
@@ -30,39 +36,39 @@ function updateCountBadge() {
30
  function renderSelected() {
31
  selectedBox.innerHTML = "";
32
  if (!selectedStations.length) {
33
- selectedBox.textContent = "No stations selected yet.";
34
- updateCountBadge();
35
- return;
36
  }
37
  selectedStations.forEach((st, idx) => {
38
- const item = document.createElement("div");
39
- item.className = "item";
40
-
41
- const main = document.createElement("div");
42
- main.className = "item-main";
43
-
44
- const name = document.createElement("div");
45
- name.className = "item-name";
46
- name.textContent = st.name || "(unnamed station)";
47
-
48
- const meta = document.createElement("div");
49
- meta.className = "item-meta";
50
- meta.textContent = st.url;
51
-
52
- main.appendChild(name);
53
- main.appendChild(meta);
54
-
55
- const btn = document.createElement("button");
56
- btn.className = "secondary small-btn";
57
- btn.textContent = "Remove";
58
- btn.onclick = () => {
59
- selectedStations.splice(idx, 1);
60
- renderSelected();
61
- };
62
-
63
- item.appendChild(main);
64
- item.appendChild(btn);
65
- selectedBox.appendChild(item);
66
  });
67
  updateCountBadge();
68
  }
@@ -70,101 +76,101 @@ function renderSelected() {
70
  function renderResults(stations) {
71
  resultsBox.innerHTML = "";
72
  if (!stations.length) {
73
- resultsBox.textContent = "No results.";
74
- return;
75
  }
76
  stations.forEach(st => {
77
- const url = st.url_resolved || st.url;
78
- if (!url) return;
79
-
80
- const item = document.createElement("div");
81
- item.className = "item";
82
-
83
- const main = document.createElement("div");
84
- main.className = "item-main";
85
-
86
- const name = document.createElement("div");
87
- name.className = "item-name";
88
- name.textContent = st.name || "(unnamed)";
89
-
90
- const meta = document.createElement("div");
91
- meta.className = "item-meta";
92
- const parts = [];
93
- if (st.country) parts.push(st.country);
94
- if (st.codec) parts.push(st.codec.toUpperCase());
95
- if (st.bitrate) parts.push(st.bitrate + " kbps");
96
- meta.textContent = parts.join(" · ") || url;
97
-
98
- main.appendChild(name);
99
- main.appendChild(meta);
100
-
101
- const btn = document.createElement("button");
102
- btn.className = "secondary small-btn";
103
- btn.textContent = "Add";
104
- btn.onclick = () => {
105
- selectedStations.push({ name: st.name || "(unnamed)", url });
106
- // dedupe on url
107
- const seen = new Set();
108
- selectedStations = selectedStations.filter(s => {
109
- const key = (s.url || "").toLowerCase();
110
- if (!key || seen.has(key)) return false;
111
- seen.add(key);
112
- return true;
113
- });
114
- renderSelected();
115
- saveStatus.textContent = "Not saved yet.";
116
- saveStatus.className = "status";
117
- };
118
-
119
- item.appendChild(main);
120
- item.appendChild(btn);
121
- resultsBox.appendChild(item);
122
  });
123
  }
124
 
125
  async function doSearch() {
126
  const q = searchInput.value.trim();
127
  if (!q) {
128
- searchStatus.textContent = "Type something to search.";
129
- searchStatus.className = "status err";
130
- resultsBox.innerHTML = "";
131
- return;
132
  }
133
  searchStatus.textContent = "Searching…";
134
  searchStatus.className = "status";
135
  resultsBox.innerHTML = "";
136
 
137
  try {
138
- const url = "https://de1.api.radio-browser.info/json/stations/search?name=" +
139
- encodeURIComponent(q) +
140
- "&limit=25&hidebroken=true";
141
- const res = await fetch(url);
142
- if (!res.ok) throw new Error("HTTP " + res.status);
143
- const data = await res.json();
144
- renderResults(data);
145
- searchStatus.textContent = "Found " + data.length + " stations.";
146
- searchStatus.className = "status ok";
147
  } catch (err) {
148
- console.error(err);
149
- searchStatus.textContent = "Search failed.";
150
- searchStatus.className = "status err";
151
  }
152
  }
153
 
154
  async function loadSelectedFromServer() {
155
  try {
156
- const res = await fetch(buildApiUrl("api/webradios"));
157
- if (!res.ok) return;
158
- const data = await res.json();
159
- if (Array.isArray(data)) {
160
- selectedStations = data.map(s => ({
161
- name: s.name || "",
162
- url: s.url || ""
163
- }));
164
- renderSelected();
165
- }
166
  } catch (err) {
167
- console.error(err);
168
  }
169
  }
170
 
@@ -172,28 +178,28 @@ async function saveToServer() {
172
  saveStatus.textContent = "Saving…";
173
  saveStatus.className = "status";
174
  try {
175
- const res = await fetch(buildApiUrl("api/webradios"), {
176
- method: "POST",
177
- headers: { "Content-Type": "application/json" },
178
- body: JSON.stringify(selectedStations)
179
- });
180
- if (!res.ok) throw new Error("HTTP " + res.status);
181
- const data = await res.json();
182
- if (!data.ok) throw new Error("Server error");
183
- saveStatus.textContent = "Saved (" + data.count + " stations).";
184
- saveStatus.className = "status ok";
185
  } catch (err) {
186
- console.error(err);
187
- saveStatus.textContent = "Save failed.";
188
- saveStatus.className = "status err";
189
  }
190
  }
191
 
192
  searchBtn.onclick = () => doSearch();
193
  searchInput.addEventListener("keydown", e => {
194
  if (e.key === "Enter") {
195
- e.preventDefault();
196
- doSearch();
197
  }
198
  });
199
 
@@ -206,4 +212,6 @@ clearBtn.onclick = () => {
206
  };
207
 
208
  // Initial load
209
- loadSelectedFromServer();
 
 
 
9
  const clearBtn = document.getElementById("clearBtn");
10
  const countBadge = document.getElementById("countBadge");
11
 
12
+ const alarmTime = document.getElementById("alarmTime");
13
+ const alarmStation = document.getElementById("alarmStation");
14
+ const setAlarmBtn = document.getElementById("setAlarmBtn");
15
+ const cancelAlarmBtn = document.getElementById("cancelAlarmBtn");
16
+ const alarmStatus = document.getElementById("alarmStatus");
17
+
18
  const currentUrl = new URL(window.location.href);
19
  if (!currentUrl.pathname.endsWith("/")) {
20
  currentUrl.pathname += "/";
 
36
  function renderSelected() {
37
  selectedBox.innerHTML = "";
38
  if (!selectedStations.length) {
39
+ selectedBox.textContent = "No stations selected yet.";
40
+ updateCountBadge();
41
+ return;
42
  }
43
  selectedStations.forEach((st, idx) => {
44
+ const item = document.createElement("div");
45
+ item.className = "item";
46
+
47
+ const main = document.createElement("div");
48
+ main.className = "item-main";
49
+
50
+ const name = document.createElement("div");
51
+ name.className = "item-name";
52
+ name.textContent = st.name || "(unnamed station)";
53
+
54
+ const meta = document.createElement("div");
55
+ meta.className = "item-meta";
56
+ meta.textContent = st.url;
57
+
58
+ main.appendChild(name);
59
+ main.appendChild(meta);
60
+
61
+ const btn = document.createElement("button");
62
+ btn.className = "secondary small-btn";
63
+ btn.textContent = "Remove";
64
+ btn.onclick = () => {
65
+ selectedStations.splice(idx, 1);
66
+ renderSelected();
67
+ };
68
+
69
+ item.appendChild(main);
70
+ item.appendChild(btn);
71
+ selectedBox.appendChild(item);
72
  });
73
  updateCountBadge();
74
  }
 
76
  function renderResults(stations) {
77
  resultsBox.innerHTML = "";
78
  if (!stations.length) {
79
+ resultsBox.textContent = "No results.";
80
+ return;
81
  }
82
  stations.forEach(st => {
83
+ const url = st.url_resolved || st.url;
84
+ if (!url) return;
85
+
86
+ const item = document.createElement("div");
87
+ item.className = "item";
88
+
89
+ const main = document.createElement("div");
90
+ main.className = "item-main";
91
+
92
+ const name = document.createElement("div");
93
+ name.className = "item-name";
94
+ name.textContent = st.name || "(unnamed)";
95
+
96
+ const meta = document.createElement("div");
97
+ meta.className = "item-meta";
98
+ const parts = [];
99
+ if (st.country) parts.push(st.country);
100
+ if (st.codec) parts.push(st.codec.toUpperCase());
101
+ if (st.bitrate) parts.push(st.bitrate + " kbps");
102
+ meta.textContent = parts.join(" · ") || url;
103
+
104
+ main.appendChild(name);
105
+ main.appendChild(meta);
106
+
107
+ const btn = document.createElement("button");
108
+ btn.className = "secondary small-btn";
109
+ btn.textContent = "Add";
110
+ btn.onclick = () => {
111
+ selectedStations.push({ name: st.name || "(unnamed)", url });
112
+ // dedupe on url
113
+ const seen = new Set();
114
+ selectedStations = selectedStations.filter(s => {
115
+ const key = (s.url || "").toLowerCase();
116
+ if (!key || seen.has(key)) return false;
117
+ seen.add(key);
118
+ return true;
119
+ });
120
+ renderSelected();
121
+ saveStatus.textContent = "Not saved yet.";
122
+ saveStatus.className = "status";
123
+ };
124
+
125
+ item.appendChild(main);
126
+ item.appendChild(btn);
127
+ resultsBox.appendChild(item);
128
  });
129
  }
130
 
131
  async function doSearch() {
132
  const q = searchInput.value.trim();
133
  if (!q) {
134
+ searchStatus.textContent = "Type something to search.";
135
+ searchStatus.className = "status err";
136
+ resultsBox.innerHTML = "";
137
+ return;
138
  }
139
  searchStatus.textContent = "Searching…";
140
  searchStatus.className = "status";
141
  resultsBox.innerHTML = "";
142
 
143
  try {
144
+ const url = "https://de1.api.radio-browser.info/json/stations/search?name=" +
145
+ encodeURIComponent(q) +
146
+ "&limit=25&hidebroken=true";
147
+ const res = await fetch(url);
148
+ if (!res.ok) throw new Error("HTTP " + res.status);
149
+ const data = await res.json();
150
+ renderResults(data);
151
+ searchStatus.textContent = "Found " + data.length + " stations.";
152
+ searchStatus.className = "status ok";
153
  } catch (err) {
154
+ console.error(err);
155
+ searchStatus.textContent = "Search failed.";
156
+ searchStatus.className = "status err";
157
  }
158
  }
159
 
160
  async function loadSelectedFromServer() {
161
  try {
162
+ const res = await fetch(buildApiUrl("api/webradios"));
163
+ if (!res.ok) return;
164
+ const data = await res.json();
165
+ if (Array.isArray(data)) {
166
+ selectedStations = data.map(s => ({
167
+ name: s.name || "",
168
+ url: s.url || ""
169
+ }));
170
+ renderSelected();
171
+ }
172
  } catch (err) {
173
+ console.error(err);
174
  }
175
  }
176
 
 
178
  saveStatus.textContent = "Saving…";
179
  saveStatus.className = "status";
180
  try {
181
+ const res = await fetch(buildApiUrl("api/webradios"), {
182
+ method: "POST",
183
+ headers: { "Content-Type": "application/json" },
184
+ body: JSON.stringify(selectedStations)
185
+ });
186
+ if (!res.ok) throw new Error("HTTP " + res.status);
187
+ const data = await res.json();
188
+ if (!data.ok) throw new Error("Server error");
189
+ saveStatus.textContent = "Saved (" + data.count + " stations).";
190
+ saveStatus.className = "status ok";
191
  } catch (err) {
192
+ console.error(err);
193
+ saveStatus.textContent = "Save failed.";
194
+ saveStatus.className = "status err";
195
  }
196
  }
197
 
198
  searchBtn.onclick = () => doSearch();
199
  searchInput.addEventListener("keydown", e => {
200
  if (e.key === "Enter") {
201
+ e.preventDefault();
202
+ doSearch();
203
  }
204
  });
205
 
 
212
  };
213
 
214
  // Initial load
215
+ loadSelectedFromServer().then(() => {
216
+ loadAlarmSettings();
217
+ });
reachy_mini_radio/static/style.css CHANGED
@@ -138,6 +138,60 @@ button:active {
138
  border-radius: 999px;
139
  }
140
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
141
  .status {
142
  margin-top: 4px;
143
  font-size: 12px;
 
138
  border-radius: 999px;
139
  }
140
 
141
+ @media (max-width: 768px) {
142
+ .hero {
143
+ padding: 2rem 1rem;
144
+ }
145
+
146
+ .hero h1 {
147
+ font-size: 2rem;
148
+ }
149
+
150
+ .container {
151
+ padding: 0 1rem;
152
+ }
153
+
154
+ .app-details,
155
+ .download-card {
156
+ padding: 2rem;
157
+ }
158
+
159
+ .features-grid {
160
+ grid-template-columns: 1fr;
161
+ }
162
+
163
+ .download-options {
164
+ grid-template-columns: 1fr;
165
+ }
166
+ }
167
+
168
+ .alarm-box {
169
+ background: #090d1c;
170
+ padding: 1rem;
171
+ border-radius: 8px;
172
+ border: 1px solid #272f4a;
173
+ }
174
+
175
+ .form-row {
176
+ display: flex;
177
+ align-items: center;
178
+ justify-content: space-between;
179
+ margin-bottom: 0.75rem;
180
+ }
181
+
182
+ .form-row label {
183
+ font-weight: 500;
184
+ color: #475569;
185
+ }
186
+
187
+ .form-row input[type="time"],
188
+ .form-row select {
189
+ padding: 0.5rem;
190
+ border: 1px solid #cbd5e1;
191
+ border-radius: 4px;
192
+ font-size: 0.9rem;
193
+ }
194
+
195
  .status {
196
  margin-top: 4px;
197
  font-size: 12px;
reachy_mini_radio/webradios.json CHANGED
@@ -1,14 +1,6 @@
1
  [
2
  {
3
- "name": "FIP",
4
- "url": "http://icecast.radiofrance.fr/fip-hifi.aac"
5
- },
6
- {
7
- "name": "FIP Rock",
8
- "url": "http://icecast.radiofrance.fr/fiprock-hifi.aac"
9
- },
10
- {
11
- "name": "FIP JAZZ",
12
- "url": "http://icecast.radiofrance.fr/fipjazz-hifi.aac"
13
  }
14
  ]
 
1
  [
2
  {
3
+ "name": "92.5 The Breeze",
4
+ "url": "https://cloud.revma.ihrhls.com/zc4366?rj-org=n49b-e2&rj-ttl=5&rj-tok=AAABmtXtGekA9dyvNtHhv-m4mw"
 
 
 
 
 
 
 
 
5
  }
6
  ]
uv.lock ADDED
The diff for this file is too large to render. See raw diff