radio-alarm-clock-feature

#1
by gsalmon - opened
README.md CHANGED
@@ -8,4 +8,5 @@ pinned: false
8
  short_description: Listen to the radio with Reachy Mini !
9
  tags:
10
  - reachy_mini
 
11
  ---
 
8
  short_description: Listen to the radio with Reachy Mini !
9
  tags:
10
  - reachy_mini
11
+ - reachy_mini_python_app
12
  ---
reachy_mini_radio/alarm_manager.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ # Force alarm to be disabled on startup
30
+ self.settings["enabled"] = False
31
+ self.settings["alarm_mode"] = False
32
+
33
+ def save_settings(self, new_settings=None):
34
+ if new_settings:
35
+ # Reset trigger flag if we are enabling the alarm or changing the time
36
+ if new_settings.get("enabled") is True:
37
+ self._triggered_today = False
38
+ elif "time" in new_settings and new_settings["time"] != self.settings.get("time"):
39
+ self._triggered_today = False
40
+
41
+ self.settings.update(new_settings)
42
+
43
+ try:
44
+ self.config_path.write_text(
45
+ data=json.dumps(self.settings, indent=2),
46
+ encoding="utf-8"
47
+ )
48
+ except Exception as e:
49
+ print(f"[AlarmManager] Error saving settings: {e}")
50
+
51
+ return self.settings
52
+
53
+ def get_settings(self):
54
+ return self.settings
55
+
56
+ def check_alarm(self):
57
+ if not self.settings["enabled"]:
58
+ return False
59
+
60
+ if self._triggered_today:
61
+ return False
62
+
63
+ now = datetime.now()
64
+ current_date = now.date()
65
+
66
+ if self._last_check_date != current_date:
67
+ self._triggered_today = False
68
+ self._last_check_date = current_date
69
+
70
+ if self._triggered_today:
71
+ return False
72
+
73
+ current_time_str = now.strftime("%H:%M")
74
+ if current_time_str == self.settings["time"]:
75
+ self._triggered_today = True
76
+ return True
77
+
78
+ return False
reachy_mini_radio/alarm_settings.json ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "enabled": false,
3
+ "alarm_mode": false,
4
+ "time": "07:20",
5
+ "station_url": "https://streaming.smartradio.ch:9502/stream",
6
+ "station_name": "A Fine Jazz Gumbo Radio",
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,141 +36,143 @@ 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
  }
69
 
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 +180,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
 
@@ -205,5 +213,135 @@ clearBtn.onclick = () => {
205
  saveStatus.className = "status";
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
+ if (typeof updateAlarmStationDropdown === "function") updateAlarmStationDropdown();
42
+ return;
43
  }
44
  selectedStations.forEach((st, idx) => {
45
+ const item = document.createElement("div");
46
+ item.className = "item";
47
 
48
+ const main = document.createElement("div");
49
+ main.className = "item-main";
50
 
51
+ const name = document.createElement("div");
52
+ name.className = "item-name";
53
+ name.textContent = st.name || "(unnamed station)";
54
 
55
+ const meta = document.createElement("div");
56
+ meta.className = "item-meta";
57
+ meta.textContent = st.url;
58
 
59
+ main.appendChild(name);
60
+ main.appendChild(meta);
61
 
62
+ const btn = document.createElement("button");
63
+ btn.className = "secondary small-btn";
64
+ btn.textContent = "Remove";
65
+ btn.onclick = () => {
66
+ selectedStations.splice(idx, 1);
67
+ renderSelected();
68
+ };
69
 
70
+ item.appendChild(main);
71
+ item.appendChild(btn);
72
+ selectedBox.appendChild(item);
73
  });
74
  updateCountBadge();
75
+ if (typeof updateAlarmStationDropdown === "function") updateAlarmStationDropdown();
76
  }
77
 
78
  function renderResults(stations) {
79
  resultsBox.innerHTML = "";
80
  if (!stations.length) {
81
+ resultsBox.textContent = "No results.";
82
+ return;
83
  }
84
  stations.forEach(st => {
85
+ const url = st.url_resolved || st.url;
86
+ if (!url) return;
87
+
88
+ const item = document.createElement("div");
89
+ item.className = "item";
90
+
91
+ const main = document.createElement("div");
92
+ main.className = "item-main";
93
+
94
+ const name = document.createElement("div");
95
+ name.className = "item-name";
96
+ name.textContent = st.name || "(unnamed)";
97
+
98
+ const meta = document.createElement("div");
99
+ meta.className = "item-meta";
100
+ const parts = [];
101
+ if (st.country) parts.push(st.country);
102
+ if (st.codec) parts.push(st.codec.toUpperCase());
103
+ if (st.bitrate) parts.push(st.bitrate + " kbps");
104
+ meta.textContent = parts.join(" · ") || url;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
 
106
+ main.appendChild(name);
107
+ main.appendChild(meta);
108
+
109
+ const btn = document.createElement("button");
110
+ btn.className = "secondary small-btn";
111
+ btn.textContent = "Add";
112
+ btn.onclick = () => {
113
+ selectedStations.push({ name: st.name || "(unnamed)", url });
114
+ // dedupe on url
115
+ const seen = new Set();
116
+ selectedStations = selectedStations.filter(s => {
117
+ const key = (s.url || "").toLowerCase();
118
+ if (!key || seen.has(key)) return false;
119
+ seen.add(key);
120
+ return true;
121
+ });
122
+ renderSelected();
123
+ saveStatus.textContent = "Not saved yet.";
124
+ saveStatus.className = "status";
125
+ };
126
+
127
+ item.appendChild(main);
128
+ item.appendChild(btn);
129
+ resultsBox.appendChild(item);
130
  });
131
  }
132
 
133
  async function doSearch() {
134
  const q = searchInput.value.trim();
135
  if (!q) {
136
+ searchStatus.textContent = "Type something to search.";
137
+ searchStatus.className = "status err";
138
+ resultsBox.innerHTML = "";
139
+ return;
140
  }
141
  searchStatus.textContent = "Searching…";
142
  searchStatus.className = "status";
143
  resultsBox.innerHTML = "";
144
 
145
  try {
146
+ const url = "https://de1.api.radio-browser.info/json/stations/search?name=" +
147
+ encodeURIComponent(q) +
148
+ "&limit=25&hidebroken=true";
149
+ const res = await fetch(url);
150
+ if (!res.ok) throw new Error("HTTP " + res.status);
151
+ const data = await res.json();
152
+ renderResults(data);
153
+ searchStatus.textContent = "Found " + data.length + " stations.";
154
+ searchStatus.className = "status ok";
155
  } catch (err) {
156
+ console.error(err);
157
+ searchStatus.textContent = "Search failed.";
158
+ searchStatus.className = "status err";
159
  }
160
  }
161
 
162
  async function loadSelectedFromServer() {
163
  try {
164
+ const res = await fetch(buildApiUrl("api/webradios"));
165
+ if (!res.ok) return;
166
+ const data = await res.json();
167
+ if (Array.isArray(data)) {
168
+ selectedStations = data.map(s => ({
169
+ name: s.name || "",
170
+ url: s.url || ""
171
+ }));
172
+ renderSelected();
173
+ }
174
  } catch (err) {
175
+ console.error(err);
176
  }
177
  }
178
 
 
180
  saveStatus.textContent = "Saving…";
181
  saveStatus.className = "status";
182
  try {
183
+ const res = await fetch(buildApiUrl("api/webradios"), {
184
+ method: "POST",
185
+ headers: { "Content-Type": "application/json" },
186
+ body: JSON.stringify(selectedStations)
187
+ });
188
+ if (!res.ok) throw new Error("HTTP " + res.status);
189
+ const data = await res.json();
190
+ if (!data.ok) throw new Error("Server error");
191
+ saveStatus.textContent = "Saved (" + data.count + " stations).";
192
+ saveStatus.className = "status ok";
193
  } catch (err) {
194
+ console.error(err);
195
+ saveStatus.textContent = "Save failed.";
196
+ saveStatus.className = "status err";
197
  }
198
  }
199
 
200
  searchBtn.onclick = () => doSearch();
201
  searchInput.addEventListener("keydown", e => {
202
  if (e.key === "Enter") {
203
+ e.preventDefault();
204
+ doSearch();
205
  }
206
  });
207
 
 
213
  saveStatus.className = "status";
214
  };
215
 
216
+
217
+
218
+ function updateAlarmStationDropdown() {
219
+ // Save current selection
220
+ const currentVal = alarmStation.value;
221
+
222
+ alarmStation.innerHTML = "";
223
+ const defaultOpt = document.createElement("option");
224
+ defaultOpt.value = "";
225
+ defaultOpt.textContent = "-- Select Station --";
226
+ alarmStation.appendChild(defaultOpt);
227
+
228
+ selectedStations.forEach(st => {
229
+ const opt = document.createElement("option");
230
+ opt.value = st.url;
231
+ opt.textContent = st.name || st.url;
232
+ alarmStation.appendChild(opt);
233
+ });
234
+
235
+ // Restore selection if possible
236
+ if (currentVal) {
237
+ alarmStation.value = currentVal;
238
+ }
239
+ }
240
+
241
+
242
+
243
+ function updateAlarmUI(enabled) {
244
+ if (enabled) {
245
+ setAlarmBtn.style.display = "none";
246
+ cancelAlarmBtn.style.display = "inline-block";
247
+ alarmStatus.textContent = "Alarm is set. Reachy is sleeping.";
248
+ alarmStatus.className = "status ok";
249
+ // Disable inputs
250
+ alarmTime.disabled = true;
251
+
252
+ alarmStation.disabled = true;
253
+ } else {
254
+ setAlarmBtn.style.display = "inline-block";
255
+ cancelAlarmBtn.style.display = "none";
256
+ alarmStatus.textContent = "Alarm not set.";
257
+ alarmStatus.className = "status";
258
+ // Enable inputs
259
+ alarmTime.disabled = false;
260
+
261
+ alarmStation.disabled = false;
262
+ }
263
+ }
264
+
265
+ async function loadAlarmSettings() {
266
+ try {
267
+ const res = await fetch(buildApiUrl("api/alarm"));
268
+ if (!res.ok) return;
269
+ const data = await res.json();
270
+
271
+ if (data.time) alarmTime.value = data.time;
272
+
273
+ if (data.station_url) alarmStation.value = data.station_url;
274
+
275
+ updateAlarmUI(data.enabled);
276
+
277
+ } catch (err) {
278
+ console.error("Failed to load alarm settings", err);
279
+ }
280
+ }
281
+
282
+ async function setAlarm() {
283
+ if (!alarmTime.value || !alarmStation.value) {
284
+ alarmStatus.textContent = "Please select time and station.";
285
+ alarmStatus.className = "status err";
286
+ return;
287
+ }
288
+
289
+ alarmStatus.textContent = "Setting alarm...";
290
+ alarmStatus.className = "status";
291
+
292
+ const settings = {
293
+ enabled: true,
294
+ alarm_mode: true, // Always enable sleep mode when setting alarm this way
295
+ time: alarmTime.value,
296
+
297
+ station_url: alarmStation.value,
298
+ station_name: alarmStation.options[alarmStation.selectedIndex]?.textContent || ""
299
+ };
300
+
301
+ try {
302
+ const res = await fetch(buildApiUrl("api/alarm"), {
303
+ method: "POST",
304
+ headers: { "Content-Type": "application/json" },
305
+ body: JSON.stringify(settings)
306
+ });
307
+ if (!res.ok) throw new Error("HTTP " + res.status);
308
+
309
+ updateAlarmUI(true);
310
+ } catch (err) {
311
+ console.error(err);
312
+ alarmStatus.textContent = "Failed to set alarm.";
313
+ alarmStatus.className = "status err";
314
+ }
315
+ }
316
+
317
+ async function cancelAlarm() {
318
+ alarmStatus.textContent = "Cancelling...";
319
+
320
+ const settings = {
321
+ enabled: false,
322
+ alarm_mode: false
323
+ };
324
+
325
+ try {
326
+ const res = await fetch(buildApiUrl("api/alarm"), {
327
+ method: "POST",
328
+ headers: { "Content-Type": "application/json" },
329
+ body: JSON.stringify(settings)
330
+ });
331
+ if (!res.ok) throw new Error("HTTP " + res.status);
332
+
333
+ updateAlarmUI(false);
334
+ } catch (err) {
335
+ console.error(err);
336
+ alarmStatus.textContent = "Failed to cancel alarm.";
337
+ alarmStatus.className = "status err";
338
+ }
339
+ }
340
+
341
+ setAlarmBtn.onclick = setAlarm;
342
+ cancelAlarmBtn.onclick = cancelAlarm;
343
+
344
  // Initial load
345
+ loadSelectedFromServer().then(() => {
346
+ loadAlarmSettings();
347
+ });
reachy_mini_radio/static/style.css CHANGED
@@ -138,6 +138,63 @@ button:active {
138
  border-radius: 999px;
139
  }
140
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
141
  .status {
142
  margin-top: 4px;
143
  font-size: 12px;
@@ -164,4 +221,14 @@ button:active {
164
  .badge {
165
  font-size: 11px;
166
  color: #9ca3c7;
 
 
 
 
 
 
 
 
 
 
167
  }
 
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
+ background: #080c1a;
190
+ color: #f5f5f8;
191
+ border: 1px solid #272f4a;
192
+ border-radius: 8px;
193
+ padding: 8px 10px;
194
+ outline: none;
195
+ font-size: 0.9rem;
196
+ }
197
+
198
  .status {
199
  margin-top: 4px;
200
  font-size: 12px;
 
221
  .badge {
222
  font-size: 11px;
223
  color: #9ca3c7;
224
+ }
225
+
226
+ input:disabled,
227
+ select:disabled {
228
+ opacity: 1 !important;
229
+ color: #ffffff !important;
230
+ -webkit-text-fill-color: #ffffff !important;
231
+ background: #262c45 !important;
232
+ border-color: #475569 !important;
233
+ cursor: not-allowed;
234
  }
reachy_mini_radio/webradios.json CHANGED
@@ -1,14 +1,14 @@
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": "A Fine Jazz Gumbo Radio",
4
+ "url": "https://streaming.smartradio.ch:9502/stream"
5
  },
6
  {
7
+ "name": "100.5 The Fox",
8
+ "url": "https://cloud-proxy-hls.revma.ihrhls.com/zc2938/hls.m3u8?rj-org=n0bb-e2&rj-ttl=5&rj-tok=AAABmuU8gY4AmS-J09808GxN2g"
9
  },
10
  {
11
+ "name": "92.5 The Breeze",
12
+ "url": "https://cloud.revma.ihrhls.com/zc4366?rj-org=n2eb-e2&rj-ttl=5&rj-tok=AAABmuV3vVIAOF5UGGE1WgcYeA"
13
  }
14
  ]
uv.lock ADDED
The diff for this file is too large to render. See raw diff