reachy-mini 1.2.5rc1__py3-none-any.whl → 1.2.11__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.
Files changed (65) hide show
  1. reachy_mini/apps/app.py +24 -21
  2. reachy_mini/apps/manager.py +17 -3
  3. reachy_mini/apps/sources/hf_auth.py +92 -0
  4. reachy_mini/apps/sources/hf_space.py +1 -1
  5. reachy_mini/apps/sources/local_common_venv.py +199 -24
  6. reachy_mini/apps/templates/main.py.j2 +4 -3
  7. reachy_mini/daemon/app/dashboard/static/js/apps.js +9 -1
  8. reachy_mini/daemon/app/dashboard/static/js/appstore.js +228 -0
  9. reachy_mini/daemon/app/dashboard/static/js/logs.js +148 -0
  10. reachy_mini/daemon/app/dashboard/templates/logs.html +37 -0
  11. reachy_mini/daemon/app/dashboard/templates/sections/appstore.html +92 -0
  12. reachy_mini/daemon/app/dashboard/templates/sections/cache.html +82 -0
  13. reachy_mini/daemon/app/dashboard/templates/sections/daemon.html +5 -0
  14. reachy_mini/daemon/app/dashboard/templates/settings.html +1 -0
  15. reachy_mini/daemon/app/main.py +172 -7
  16. reachy_mini/daemon/app/models.py +8 -0
  17. reachy_mini/daemon/app/routers/apps.py +56 -0
  18. reachy_mini/daemon/app/routers/cache.py +58 -0
  19. reachy_mini/daemon/app/routers/hf_auth.py +57 -0
  20. reachy_mini/daemon/app/routers/logs.py +124 -0
  21. reachy_mini/daemon/app/routers/state.py +25 -1
  22. reachy_mini/daemon/app/routers/wifi_config.py +75 -0
  23. reachy_mini/daemon/app/services/bluetooth/bluetooth_service.py +1 -1
  24. reachy_mini/daemon/app/services/bluetooth/commands/WIFI_RESET.sh +8 -0
  25. reachy_mini/daemon/app/services/wireless/launcher.sh +8 -2
  26. reachy_mini/daemon/app/services/wireless/reachy-mini-daemon.service +13 -0
  27. reachy_mini/daemon/backend/abstract.py +29 -9
  28. reachy_mini/daemon/backend/mockup_sim/__init__.py +12 -0
  29. reachy_mini/daemon/backend/mockup_sim/backend.py +176 -0
  30. reachy_mini/daemon/backend/mujoco/backend.py +0 -5
  31. reachy_mini/daemon/backend/robot/backend.py +78 -5
  32. reachy_mini/daemon/daemon.py +46 -7
  33. reachy_mini/daemon/utils.py +71 -15
  34. reachy_mini/io/zenoh_client.py +26 -0
  35. reachy_mini/io/zenoh_server.py +10 -6
  36. reachy_mini/kinematics/nn_kinematics.py +2 -2
  37. reachy_mini/kinematics/placo_kinematics.py +15 -15
  38. reachy_mini/media/__init__.py +55 -1
  39. reachy_mini/media/audio_base.py +185 -13
  40. reachy_mini/media/audio_control_utils.py +60 -5
  41. reachy_mini/media/audio_gstreamer.py +97 -16
  42. reachy_mini/media/audio_sounddevice.py +120 -19
  43. reachy_mini/media/audio_utils.py +110 -5
  44. reachy_mini/media/camera_base.py +182 -11
  45. reachy_mini/media/camera_constants.py +132 -4
  46. reachy_mini/media/camera_gstreamer.py +42 -2
  47. reachy_mini/media/camera_opencv.py +83 -5
  48. reachy_mini/media/camera_utils.py +95 -7
  49. reachy_mini/media/media_manager.py +139 -6
  50. reachy_mini/media/webrtc_client_gstreamer.py +142 -13
  51. reachy_mini/media/webrtc_daemon.py +72 -7
  52. reachy_mini/motion/recorded_move.py +76 -2
  53. reachy_mini/reachy_mini.py +196 -40
  54. reachy_mini/tools/reflash_motors.py +1 -1
  55. reachy_mini/tools/scan_motors.py +86 -0
  56. reachy_mini/tools/setup_motor.py +49 -31
  57. reachy_mini/utils/interpolation.py +1 -1
  58. reachy_mini/utils/wireless_version/startup_check.py +278 -21
  59. reachy_mini/utils/wireless_version/update.py +44 -1
  60. {reachy_mini-1.2.5rc1.dist-info → reachy_mini-1.2.11.dist-info}/METADATA +7 -6
  61. {reachy_mini-1.2.5rc1.dist-info → reachy_mini-1.2.11.dist-info}/RECORD +65 -53
  62. {reachy_mini-1.2.5rc1.dist-info → reachy_mini-1.2.11.dist-info}/WHEEL +0 -0
  63. {reachy_mini-1.2.5rc1.dist-info → reachy_mini-1.2.11.dist-info}/entry_points.txt +0 -0
  64. {reachy_mini-1.2.5rc1.dist-info → reachy_mini-1.2.11.dist-info}/licenses/LICENSE +0 -0
  65. {reachy_mini-1.2.5rc1.dist-info → reachy_mini-1.2.11.dist-info}/top_level.txt +0 -0
@@ -10,10 +10,11 @@ managing the robot's state.
10
10
  import argparse
11
11
  import asyncio
12
12
  import logging
13
+ import types
13
14
  from contextlib import asynccontextmanager
14
15
  from dataclasses import dataclass
15
16
  from pathlib import Path
16
- from typing import AsyncGenerator
17
+ from typing import Any, AsyncGenerator
17
18
 
18
19
  import uvicorn
19
20
  from fastapi import APIRouter, FastAPI, Request
@@ -26,7 +27,9 @@ from reachy_mini.apps.manager import AppManager
26
27
  from reachy_mini.daemon.app.routers import (
27
28
  apps,
28
29
  daemon,
30
+ hf_auth,
29
31
  kinematics,
32
+ logs,
30
33
  motors,
31
34
  move,
32
35
  state,
@@ -37,9 +40,13 @@ from reachy_mini.media.audio_utils import (
37
40
  check_reachymini_asoundrc,
38
41
  write_asoundrc_to_home,
39
42
  )
43
+ from reachy_mini.motion.recorded_move import preload_default_datasets
40
44
  from reachy_mini.utils.wireless_version.startup_check import (
45
+ check_and_fix_restore_venv,
41
46
  check_and_fix_venvs_ownership,
47
+ check_and_sync_apps_venv_sdk,
42
48
  check_and_update_bluetooth_service,
49
+ check_and_update_wireless_launcher,
43
50
  )
44
51
 
45
52
 
@@ -57,6 +64,7 @@ class Args:
57
64
  hardware_config_filepath: str | None = None
58
65
 
59
66
  sim: bool = False
67
+ mockup_sim: bool = False
60
68
  scene: str = "empty"
61
69
  headless: bool = False
62
70
  websocket_uri: str | None = None
@@ -71,6 +79,8 @@ class Args:
71
79
 
72
80
  wake_up_on_start: bool = True
73
81
  goto_sleep_on_stop: bool = True
82
+ preload_datasets: bool = False
83
+ dataset_update_interval_hours: float = 24.0 # 0 to disable periodic updates
74
84
 
75
85
  robot_name: str = "reachy_mini"
76
86
 
@@ -92,12 +102,52 @@ def create_app(args: Args, health_check_event: asyncio.Event | None = None) -> F
92
102
  async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
93
103
  """Lifespan context manager for the FastAPI application."""
94
104
  args = app.state.args # type: Args
105
+ dataset_updater_task: asyncio.Task[None] | None = None
106
+
107
+ def preload_with_logging() -> None:
108
+ """Download datasets with logging."""
109
+ try:
110
+ preload_default_datasets()
111
+ logging.info("Recorded move datasets pre-loaded successfully")
112
+ except Exception as e:
113
+ logging.warning(f"Failed to pre-load some datasets: {e}")
114
+
115
+ async def dataset_updater(interval_hours: float) -> None:
116
+ """Background task that periodically checks for dataset updates."""
117
+ interval_seconds = interval_hours * 3600
118
+ while True:
119
+ try:
120
+ await asyncio.sleep(interval_seconds)
121
+ logging.info("Checking for dataset updates...")
122
+ loop = asyncio.get_event_loop()
123
+ await loop.run_in_executor(None, preload_with_logging)
124
+ except asyncio.CancelledError:
125
+ logging.info("Dataset updater task cancelled")
126
+ break
127
+ except Exception as e:
128
+ logging.warning(f"Error in dataset updater: {e}")
129
+
130
+ # Pre-download recorded move datasets in background to avoid delays on first play
131
+ # This runs in asyncio's default ThreadPoolExecutor (fire and forget)
132
+ if args.preload_datasets:
133
+ loop = asyncio.get_event_loop()
134
+ loop.run_in_executor(None, preload_with_logging)
135
+
136
+ # Start periodic dataset updater if enabled (interval > 0)
137
+ if args.dataset_update_interval_hours > 0:
138
+ dataset_updater_task = asyncio.create_task(
139
+ dataset_updater(args.dataset_update_interval_hours)
140
+ )
141
+ logging.info(
142
+ f"Dataset updater started (interval: {args.dataset_update_interval_hours}h)"
143
+ )
95
144
 
96
145
  try:
97
146
  if args.autostart:
98
147
  await app.state.daemon.start(
99
148
  serialport=args.serialport,
100
149
  sim=args.sim,
150
+ mockup_sim=args.mockup_sim,
101
151
  scene=args.scene,
102
152
  headless=args.headless,
103
153
  websocket_uri=args.websocket_uri,
@@ -109,14 +159,23 @@ def create_app(args: Args, health_check_event: asyncio.Event | None = None) -> F
109
159
  localhost_only=localhost_only,
110
160
  hardware_config_filepath=args.hardware_config_filepath,
111
161
  )
162
+
112
163
  yield
113
164
  finally:
165
+ # Cancel dataset updater task if running
166
+ if dataset_updater_task and not dataset_updater_task.done():
167
+ dataset_updater_task.cancel()
168
+ try:
169
+ await dataset_updater_task
170
+ except asyncio.CancelledError:
171
+ pass
172
+
114
173
  # Ensure cleanup happens even if there's an exception
115
174
  try:
116
175
  logging.info("Shutting down app manager...")
117
176
  await app.state.app_manager.close()
118
177
  except Exception as e:
119
- logging.error(f"Error closing app manager: {e}")
178
+ logging.exception(f"Error closing app manager: {e}")
120
179
 
121
180
  try:
122
181
  logging.info("Shutting down daemon...")
@@ -124,7 +183,7 @@ def create_app(args: Args, health_check_event: asyncio.Event | None = None) -> F
124
183
  goto_sleep_on_stop=args.goto_sleep_on_stop,
125
184
  )
126
185
  except Exception as e:
127
- logging.error(f"Error stopping daemon: {e}")
186
+ logging.exception(f"Error stopping daemon: {e}")
128
187
 
129
188
  app = FastAPI(
130
189
  lifespan=lifespan,
@@ -145,6 +204,7 @@ def create_app(args: Args, health_check_event: asyncio.Event | None = None) -> F
145
204
  router = APIRouter(prefix="/api")
146
205
  router.include_router(apps.router)
147
206
  router.include_router(daemon.router)
207
+ router.include_router(hf_auth.router)
148
208
  router.include_router(kinematics.router)
149
209
  router.include_router(motors.router)
150
210
  router.include_router(move.router)
@@ -152,8 +212,10 @@ def create_app(args: Args, health_check_event: asyncio.Event | None = None) -> F
152
212
  router.include_router(volume.router)
153
213
 
154
214
  if args.wireless_version:
155
- from .routers import update, wifi_config
215
+ from .routers import cache, update, wifi_config
156
216
 
217
+ app.include_router(cache.router)
218
+ app.include_router(logs.router)
157
219
  app.include_router(update.router)
158
220
  app.include_router(wifi_config.router)
159
221
 
@@ -194,18 +256,83 @@ def create_app(args: Args, health_check_event: asyncio.Event | None = None) -> F
194
256
  """Render the settings page."""
195
257
  return templates.TemplateResponse("settings.html", {"request": request})
196
258
 
259
+ @app.get("/logs")
260
+ async def logs_page(request: Request) -> HTMLResponse:
261
+ """Render the logs page."""
262
+ return templates.TemplateResponse("logs.html", {"request": request})
263
+
197
264
  return app
198
265
 
199
266
 
200
267
  def run_app(args: Args) -> None:
201
268
  """Run the FastAPI app with Uvicorn."""
202
- logging.basicConfig(level=logging.INFO)
269
+ # Configure logging to ensure all logs go to stderr (captured by systemd)
270
+ import sys
271
+
272
+ root_logger = logging.getLogger()
273
+ root_logger.setLevel(args.log_level)
274
+
275
+ # Create handler that writes to stderr with immediate flush
276
+ handler = logging.StreamHandler(sys.stderr)
277
+ handler.setLevel(args.log_level)
278
+ handler.setFormatter(
279
+ logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
280
+ )
281
+ root_logger.addHandler(handler)
282
+
283
+ # Explicitly configure the apps.manager logger to ensure propagation
284
+ apps_logger = logging.getLogger("reachy_mini.apps.manager")
285
+ apps_logger.setLevel(args.log_level)
286
+ apps_logger.propagate = True # Ensure it propagates to root logger
287
+
288
+ # Install exception hook to catch uncaught exceptions
289
+ def exception_hook(
290
+ exc_type: type[BaseException],
291
+ exc_value: BaseException,
292
+ exc_traceback: types.TracebackType | None,
293
+ ) -> None:
294
+ """Log uncaught exceptions with full traceback."""
295
+ if issubclass(exc_type, KeyboardInterrupt):
296
+ # Allow KeyboardInterrupt to exit normally
297
+ sys.__excepthook__(exc_type, exc_value, exc_traceback)
298
+ return
299
+
300
+ root_logger.critical(
301
+ "Uncaught exception", exc_info=(exc_type, exc_value, exc_traceback)
302
+ )
303
+ sys.stderr.flush()
304
+
305
+ sys.excepthook = exception_hook
203
306
 
204
307
  async def run_server() -> None:
308
+ # Set up asyncio exception handler to catch unhandled task exceptions
309
+ loop = asyncio.get_running_loop()
310
+
311
+ def asyncio_exception_handler(
312
+ loop: asyncio.AbstractEventLoop, context: dict[str, Any]
313
+ ) -> None:
314
+ """Handle exceptions in asyncio tasks."""
315
+ exception = context.get("exception")
316
+ if exception:
317
+ root_logger.error(
318
+ f"Unhandled exception in asyncio task: {context.get('message', 'No message')}",
319
+ exc_info=(type(exception), exception, exception.__traceback__),
320
+ )
321
+ else:
322
+ root_logger.error(f"Asyncio error: {context}")
323
+ sys.stderr.flush()
324
+
325
+ loop.set_exception_handler(asyncio_exception_handler)
326
+
205
327
  health_check_event = asyncio.Event()
206
328
  app = create_app(args, health_check_event)
207
329
 
208
- config = uvicorn.Config(app, host=args.fastapi_host, port=args.fastapi_port)
330
+ config = uvicorn.Config(
331
+ app,
332
+ host=args.fastapi_host,
333
+ port=args.fastapi_port,
334
+ log_config=None, # Don't override Python logging configuration
335
+ )
209
336
  server = uvicorn.Server(config)
210
337
 
211
338
  health_check_task = None
@@ -234,6 +361,9 @@ def run_app(args: Args) -> None:
234
361
  await server.serve()
235
362
  except KeyboardInterrupt:
236
363
  logging.info("Received Ctrl-C, shutting down gracefully.")
364
+ except Exception as e:
365
+ logging.exception(f"Error during server operation: {e}")
366
+ raise
237
367
  finally:
238
368
  # Cancel health check task if it exists
239
369
  if health_check_task and not health_check_task.done():
@@ -248,7 +378,8 @@ def run_app(args: Args) -> None:
248
378
  except KeyboardInterrupt:
249
379
  logging.info("Shutdown complete.")
250
380
  except Exception as e:
251
- logging.error(f"Error during shutdown: {e}")
381
+ logging.exception(f"Error during shutdown: {e}")
382
+ sys.stderr.flush()
252
383
  raise
253
384
 
254
385
 
@@ -306,6 +437,12 @@ def main() -> None:
306
437
  default=default_args.sim,
307
438
  help="Run in simulation mode using Mujoco.",
308
439
  )
440
+ parser.add_argument(
441
+ "--mockup-sim",
442
+ action="store_true",
443
+ default=default_args.mockup_sim,
444
+ help="Run in mockup simulation mode (no MuJoCo required).",
445
+ )
309
446
  parser.add_argument(
310
447
  "--scene",
311
448
  type=str,
@@ -380,6 +517,25 @@ def main() -> None:
380
517
  dest="goto_sleep_on_stop",
381
518
  help="Do not put the robot to sleep on daemon stop (default: False).",
382
519
  )
520
+ parser.add_argument(
521
+ "--preload-datasets",
522
+ action="store_true",
523
+ default=default_args.preload_datasets,
524
+ help="Pre-download recorded move datasets (emotions, dances) at startup (default: False).",
525
+ )
526
+ parser.add_argument(
527
+ "--no-preload-datasets",
528
+ action="store_false",
529
+ dest="preload_datasets",
530
+ help="Do not pre-download datasets at startup (default: False).",
531
+ )
532
+ parser.add_argument(
533
+ "--dataset-update-interval",
534
+ type=float,
535
+ default=default_args.dataset_update_interval_hours,
536
+ dest="dataset_update_interval_hours",
537
+ help="Interval in hours for background dataset update checks (default: 24.0, 0 to disable).",
538
+ )
383
539
  # Zenoh server options
384
540
  parser.add_argument(
385
541
  "--localhost-only",
@@ -451,6 +607,15 @@ def main() -> None:
451
607
  # Check and update bluetooth service if needed
452
608
  check_and_update_bluetooth_service()
453
609
 
610
+ # Check and update wireless launcher if needed
611
+ check_and_update_wireless_launcher()
612
+
613
+ # Check and sync apps_venv SDK version with daemon
614
+ check_and_sync_apps_venv_sdk()
615
+
616
+ # Check and fix restore venv if it has legacy editable install
617
+ check_and_fix_restore_venv()
618
+
454
619
  if check_reachymini_asoundrc():
455
620
  logging.getLogger().info(
456
621
  "~/.asoundrc correctly configured for Reachy Mini Audio."
@@ -137,6 +137,13 @@ class FullBodyTarget(BaseModel):
137
137
  }
138
138
 
139
139
 
140
+ class DoAInfo(BaseModel):
141
+ """Direction of Arrival info from the microphone array."""
142
+
143
+ angle: float # Angle in radians (0=left, π/2=front, π=right)
144
+ speech_detected: bool
145
+
146
+
140
147
  class FullState(BaseModel):
141
148
  """Represent the full state of the robot including all joint positions and poses."""
142
149
 
@@ -147,3 +154,4 @@ class FullState(BaseModel):
147
154
  antennas_position: list[float] | None = None
148
155
  timestamp: datetime | None = None
149
156
  passive_joints: list[float] | None = None
157
+ doa: DoAInfo | None = None
@@ -6,6 +6,7 @@ from fastapi import (
6
6
  HTTPException,
7
7
  WebSocket,
8
8
  )
9
+ from pydantic import BaseModel
9
10
 
10
11
  from reachy_mini.apps import AppInfo, SourceKind
11
12
  from reachy_mini.apps.manager import AppManager, AppStatus
@@ -112,3 +113,58 @@ async def current_app_status(
112
113
  ) -> AppStatus | None:
113
114
  """Get the status of the currently running app, if any."""
114
115
  return await app_manager.current_app_status()
116
+
117
+
118
+ class PrivateSpaceInstallRequest(BaseModel):
119
+ """Request model for installing a private HuggingFace space."""
120
+
121
+ space_id: str
122
+
123
+
124
+ @router.post("/install-private-space")
125
+ async def install_private_space(
126
+ request: PrivateSpaceInstallRequest,
127
+ app_manager: "AppManager" = Depends(get_app_manager),
128
+ ) -> dict[str, str]:
129
+ """Install a private HuggingFace space.
130
+
131
+ Only available on wireless version.
132
+ Requires HF token to be stored via /api/hf-auth/save-token first.
133
+ """
134
+ if not app_manager.wireless_version:
135
+ raise HTTPException(
136
+ status_code=403,
137
+ detail="Private space installation only available on wireless version",
138
+ )
139
+
140
+ from reachy_mini.apps.sources import hf_auth
141
+
142
+ # Check if token is available
143
+ token = hf_auth.get_hf_token()
144
+ if not token:
145
+ raise HTTPException(
146
+ status_code=401,
147
+ detail="No HuggingFace token found. Please authenticate first.",
148
+ )
149
+
150
+ # Create AppInfo for the private space
151
+ space_name = request.space_id.split("/")[-1]
152
+ app_info = AppInfo(
153
+ name=space_name,
154
+ description=f"Private space: {request.space_id}",
155
+ url=f"https://huggingface.co/spaces/{request.space_id}",
156
+ source_kind=SourceKind.HF_SPACE,
157
+ extra={
158
+ "id": request.space_id,
159
+ "private": True,
160
+ "cardData": {
161
+ "title": space_name,
162
+ "short_description": f"Private space: {request.space_id}",
163
+ },
164
+ },
165
+ )
166
+
167
+ job_id = bg_job_register.run_command(
168
+ "install", app_manager.install_new_app, app_info
169
+ )
170
+ return {"job_id": job_id}
@@ -0,0 +1,58 @@
1
+ """Cache management router for Reachy Mini Daemon API."""
2
+
3
+ import logging
4
+ import shutil
5
+ from pathlib import Path
6
+
7
+ from fastapi import APIRouter, HTTPException
8
+
9
+ router = APIRouter(prefix="/cache")
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ @router.post("/clear-hf")
14
+ def clear_huggingface_cache() -> dict[str, str]:
15
+ """Clear HuggingFace cache directory."""
16
+ try:
17
+ cache_path = Path("/home/pollen/.cache/huggingface")
18
+
19
+ if cache_path.exists():
20
+ shutil.rmtree(cache_path)
21
+ logger.info(f"Cleared HuggingFace cache at {cache_path}")
22
+ return {"status": "success", "message": "HuggingFace cache cleared"}
23
+ else:
24
+ logger.info(f"HuggingFace cache directory does not exist: {cache_path}")
25
+ return {"status": "success", "message": "Cache directory already empty"}
26
+
27
+ except Exception as e:
28
+ logger.error(f"Failed to clear HuggingFace cache: {e}")
29
+ raise HTTPException(status_code=500, detail=f"Failed to clear cache: {str(e)}")
30
+
31
+
32
+ @router.post("/reset-apps")
33
+ def reset_apps() -> dict[str, str]:
34
+ """Remove applications virtual environment directory."""
35
+ try:
36
+ venv_path = Path("/venvs/apps_venv/")
37
+
38
+ if venv_path.exists():
39
+ shutil.rmtree(venv_path)
40
+ logger.info(f"Removed applications virtual environment at {venv_path}")
41
+ return {
42
+ "status": "success",
43
+ "message": "Applications virtual environment removed",
44
+ }
45
+ else:
46
+ logger.info(
47
+ f"Applications virtual environment directory does not exist: {venv_path}"
48
+ )
49
+ return {
50
+ "status": "success",
51
+ "message": "Virtual environment directory already empty",
52
+ }
53
+
54
+ except Exception as e:
55
+ logger.error(f"Failed to clear applications virtual environment: {e}")
56
+ raise HTTPException(
57
+ status_code=500, detail=f"Failed to clear virtual environment: {str(e)}"
58
+ )
@@ -0,0 +1,57 @@
1
+ """HuggingFace authentication API routes."""
2
+
3
+ from typing import Any
4
+
5
+ from fastapi import APIRouter, HTTPException
6
+ from pydantic import BaseModel
7
+
8
+ from reachy_mini.apps.sources import hf_auth
9
+
10
+ router = APIRouter(prefix="/hf-auth")
11
+
12
+
13
+ class TokenRequest(BaseModel):
14
+ """Request model for saving a HuggingFace token."""
15
+
16
+ token: str
17
+
18
+
19
+ class TokenResponse(BaseModel):
20
+ """Response model for token operations."""
21
+
22
+ status: str
23
+ username: str | None = None
24
+ message: str | None = None
25
+
26
+
27
+ @router.post("/save-token")
28
+ async def save_token(request: TokenRequest) -> TokenResponse:
29
+ """Save HuggingFace token after validation."""
30
+ result = hf_auth.save_hf_token(request.token)
31
+
32
+ if result["status"] == "error":
33
+ raise HTTPException(
34
+ status_code=400, detail=result.get("message", "Invalid token")
35
+ )
36
+
37
+ return TokenResponse(
38
+ status="success",
39
+ username=result.get("username"),
40
+ )
41
+
42
+
43
+ @router.get("/status")
44
+ async def get_auth_status() -> dict[str, Any]:
45
+ """Check if user is authenticated with HuggingFace."""
46
+ return hf_auth.check_token_status()
47
+
48
+
49
+ @router.delete("/token")
50
+ async def delete_token() -> dict[str, str]:
51
+ """Delete stored HuggingFace token."""
52
+ success = hf_auth.delete_hf_token()
53
+
54
+ if not success:
55
+ raise HTTPException(status_code=500, detail="Failed to delete token")
56
+
57
+ return {"status": "success"}
@@ -0,0 +1,124 @@
1
+ """Logs router for Reachy Mini Daemon API.
2
+
3
+ This module provides a WebSocket endpoint to stream journalctl logs for the daemon service.
4
+ """
5
+
6
+ import asyncio
7
+ import logging
8
+
9
+ from fastapi import APIRouter, WebSocket, WebSocketDisconnect
10
+
11
+ router = APIRouter(prefix="/logs")
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ @router.websocket("/ws/daemon")
16
+ async def websocket_daemon_logs(websocket: WebSocket) -> None:
17
+ """WebSocket endpoint to stream journalctl logs for reachy-mini-daemon service in real time."""
18
+ await websocket.accept()
19
+
20
+ process = None
21
+ stderr_task = None
22
+
23
+ try:
24
+ # Start journalctl subprocess to stream daemon logs
25
+ process = await asyncio.create_subprocess_exec(
26
+ "journalctl",
27
+ "-u",
28
+ "reachy-mini-daemon",
29
+ "-b", # current boot only
30
+ "-f", # follow mode (tail)
31
+ "-n",
32
+ "100", # start with last 100 lines
33
+ "--output",
34
+ "short-iso", # ISO timestamp format
35
+ stdout=asyncio.subprocess.PIPE,
36
+ stderr=asyncio.subprocess.PIPE,
37
+ )
38
+
39
+ logger.info("journalctl process started")
40
+
41
+ # Task to read and log stderr
42
+ async def log_stderr() -> None:
43
+ try:
44
+ while True:
45
+ err_line = await process.stderr.readline() # type: ignore
46
+ if not err_line:
47
+ break
48
+ logger.error(f"journalctl stderr: {err_line.decode().strip()}")
49
+ except Exception as e:
50
+ logger.error(f"Error reading stderr: {e}")
51
+
52
+ stderr_task = asyncio.create_task(log_stderr())
53
+
54
+ # Stream lines to WebSocket
55
+ while True:
56
+ try:
57
+ line = await asyncio.wait_for(process.stdout.readline(), timeout=1.0) # type: ignore
58
+ except asyncio.TimeoutError:
59
+ # No data available, check if process is still running
60
+ if process.returncode is not None:
61
+ logger.error(
62
+ f"journalctl process exited with code {process.returncode}"
63
+ )
64
+ break
65
+ # Check if WebSocket is still connected
66
+ try:
67
+ await websocket.send_text("") # Keepalive ping
68
+ except Exception:
69
+ break
70
+ continue
71
+
72
+ if not line:
73
+ # EOF reached
74
+ logger.info("journalctl process stdout closed")
75
+ break
76
+
77
+ # Send line to client
78
+ decoded_line = line.decode().strip()
79
+ if decoded_line: # Only send non-empty lines
80
+ await websocket.send_text(decoded_line)
81
+
82
+ except WebSocketDisconnect:
83
+ logger.info("WebSocket disconnected")
84
+ except FileNotFoundError:
85
+ # journalctl not available
86
+ error_msg = (
87
+ "ERROR: journalctl command not found. This feature requires systemd."
88
+ )
89
+ logger.error(error_msg)
90
+ try:
91
+ await websocket.send_text(error_msg)
92
+ except Exception:
93
+ pass
94
+ except Exception as e:
95
+ error_msg = f"ERROR: Failed to stream logs: {str(e)}"
96
+ logger.error(error_msg)
97
+ try:
98
+ await websocket.send_text(error_msg)
99
+ except Exception:
100
+ pass
101
+ finally:
102
+ # Cancel stderr task if running
103
+ if stderr_task and not stderr_task.done():
104
+ stderr_task.cancel()
105
+ try:
106
+ await stderr_task
107
+ except asyncio.CancelledError:
108
+ pass
109
+
110
+ # Terminate journalctl process if still running
111
+ if process and process.returncode is None:
112
+ try:
113
+ process.terminate()
114
+ await asyncio.wait_for(process.wait(), timeout=2.0)
115
+ except asyncio.TimeoutError:
116
+ process.kill()
117
+ except Exception as e:
118
+ logger.error(f"Error terminating journalctl process: {e}")
119
+
120
+ # Close WebSocket connection
121
+ try:
122
+ await websocket.close()
123
+ except Exception:
124
+ pass