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.
- reachy_mini/apps/app.py +24 -21
- reachy_mini/apps/manager.py +17 -3
- reachy_mini/apps/sources/hf_auth.py +92 -0
- reachy_mini/apps/sources/hf_space.py +1 -1
- reachy_mini/apps/sources/local_common_venv.py +199 -24
- reachy_mini/apps/templates/main.py.j2 +4 -3
- reachy_mini/daemon/app/dashboard/static/js/apps.js +9 -1
- reachy_mini/daemon/app/dashboard/static/js/appstore.js +228 -0
- reachy_mini/daemon/app/dashboard/static/js/logs.js +148 -0
- reachy_mini/daemon/app/dashboard/templates/logs.html +37 -0
- reachy_mini/daemon/app/dashboard/templates/sections/appstore.html +92 -0
- reachy_mini/daemon/app/dashboard/templates/sections/cache.html +82 -0
- reachy_mini/daemon/app/dashboard/templates/sections/daemon.html +5 -0
- reachy_mini/daemon/app/dashboard/templates/settings.html +1 -0
- reachy_mini/daemon/app/main.py +172 -7
- reachy_mini/daemon/app/models.py +8 -0
- reachy_mini/daemon/app/routers/apps.py +56 -0
- reachy_mini/daemon/app/routers/cache.py +58 -0
- reachy_mini/daemon/app/routers/hf_auth.py +57 -0
- reachy_mini/daemon/app/routers/logs.py +124 -0
- reachy_mini/daemon/app/routers/state.py +25 -1
- reachy_mini/daemon/app/routers/wifi_config.py +75 -0
- reachy_mini/daemon/app/services/bluetooth/bluetooth_service.py +1 -1
- reachy_mini/daemon/app/services/bluetooth/commands/WIFI_RESET.sh +8 -0
- reachy_mini/daemon/app/services/wireless/launcher.sh +8 -2
- reachy_mini/daemon/app/services/wireless/reachy-mini-daemon.service +13 -0
- reachy_mini/daemon/backend/abstract.py +29 -9
- reachy_mini/daemon/backend/mockup_sim/__init__.py +12 -0
- reachy_mini/daemon/backend/mockup_sim/backend.py +176 -0
- reachy_mini/daemon/backend/mujoco/backend.py +0 -5
- reachy_mini/daemon/backend/robot/backend.py +78 -5
- reachy_mini/daemon/daemon.py +46 -7
- reachy_mini/daemon/utils.py +71 -15
- reachy_mini/io/zenoh_client.py +26 -0
- reachy_mini/io/zenoh_server.py +10 -6
- reachy_mini/kinematics/nn_kinematics.py +2 -2
- reachy_mini/kinematics/placo_kinematics.py +15 -15
- reachy_mini/media/__init__.py +55 -1
- reachy_mini/media/audio_base.py +185 -13
- reachy_mini/media/audio_control_utils.py +60 -5
- reachy_mini/media/audio_gstreamer.py +97 -16
- reachy_mini/media/audio_sounddevice.py +120 -19
- reachy_mini/media/audio_utils.py +110 -5
- reachy_mini/media/camera_base.py +182 -11
- reachy_mini/media/camera_constants.py +132 -4
- reachy_mini/media/camera_gstreamer.py +42 -2
- reachy_mini/media/camera_opencv.py +83 -5
- reachy_mini/media/camera_utils.py +95 -7
- reachy_mini/media/media_manager.py +139 -6
- reachy_mini/media/webrtc_client_gstreamer.py +142 -13
- reachy_mini/media/webrtc_daemon.py +72 -7
- reachy_mini/motion/recorded_move.py +76 -2
- reachy_mini/reachy_mini.py +196 -40
- reachy_mini/tools/reflash_motors.py +1 -1
- reachy_mini/tools/scan_motors.py +86 -0
- reachy_mini/tools/setup_motor.py +49 -31
- reachy_mini/utils/interpolation.py +1 -1
- reachy_mini/utils/wireless_version/startup_check.py +278 -21
- reachy_mini/utils/wireless_version/update.py +44 -1
- {reachy_mini-1.2.5rc1.dist-info → reachy_mini-1.2.11.dist-info}/METADATA +7 -6
- {reachy_mini-1.2.5rc1.dist-info → reachy_mini-1.2.11.dist-info}/RECORD +65 -53
- {reachy_mini-1.2.5rc1.dist-info → reachy_mini-1.2.11.dist-info}/WHEEL +0 -0
- {reachy_mini-1.2.5rc1.dist-info → reachy_mini-1.2.11.dist-info}/entry_points.txt +0 -0
- {reachy_mini-1.2.5rc1.dist-info → reachy_mini-1.2.11.dist-info}/licenses/LICENSE +0 -0
- {reachy_mini-1.2.5rc1.dist-info → reachy_mini-1.2.11.dist-info}/top_level.txt +0 -0
reachy_mini/daemon/app/main.py
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
|
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(
|
|
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.
|
|
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."
|
reachy_mini/daemon/app/models.py
CHANGED
|
@@ -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
|