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/apps/app.py
CHANGED
|
@@ -10,13 +10,11 @@ It uses Jinja2 templates to generate the necessary files for the app project.
|
|
|
10
10
|
import argparse
|
|
11
11
|
import importlib
|
|
12
12
|
import logging
|
|
13
|
-
import platform
|
|
14
|
-
import subprocess
|
|
15
13
|
import threading
|
|
16
14
|
import traceback
|
|
17
15
|
from abc import ABC, abstractmethod
|
|
18
16
|
from pathlib import Path
|
|
19
|
-
from typing import Any
|
|
17
|
+
from typing import Any, Literal
|
|
20
18
|
from urllib.parse import urlparse
|
|
21
19
|
|
|
22
20
|
from fastapi import FastAPI
|
|
@@ -39,14 +37,16 @@ class ReachyMiniApp(ABC):
|
|
|
39
37
|
self.error: str = ""
|
|
40
38
|
self.logger = logging.getLogger("reachy_mini.app")
|
|
41
39
|
|
|
42
|
-
#
|
|
43
|
-
|
|
44
|
-
self.
|
|
40
|
+
# Detect if daemon is available on localhost
|
|
41
|
+
# If yes, use localhost connection. If no, use multicast scouting for remote daemon.
|
|
42
|
+
self.daemon_on_localhost = self._check_daemon_on_localhost()
|
|
43
|
+
self.logger.info(f"Daemon on localhost: {self.daemon_on_localhost}")
|
|
45
44
|
|
|
45
|
+
# Media backend is now auto-detected by ReachyMini, just use "default"
|
|
46
46
|
self.media_backend = (
|
|
47
47
|
self.request_media_backend
|
|
48
48
|
if self.request_media_backend is not None
|
|
49
|
-
else
|
|
49
|
+
else "default"
|
|
50
50
|
)
|
|
51
51
|
|
|
52
52
|
self.settings_app: FastAPI | None = None
|
|
@@ -68,28 +68,23 @@ class ReachyMiniApp(ABC):
|
|
|
68
68
|
return FileResponse(index_file)
|
|
69
69
|
|
|
70
70
|
@staticmethod
|
|
71
|
-
def
|
|
72
|
-
"""Check if
|
|
71
|
+
def _check_daemon_on_localhost(port: int = 8000, timeout: float = 0.5) -> bool:
|
|
72
|
+
"""Check if daemon is reachable on localhost.
|
|
73
73
|
|
|
74
74
|
Args:
|
|
75
|
-
|
|
75
|
+
port: Port to check (default: 8000)
|
|
76
|
+
timeout: Connection timeout in seconds
|
|
76
77
|
|
|
77
78
|
Returns:
|
|
78
|
-
True if
|
|
79
|
+
True if daemon responds on localhost, False otherwise
|
|
79
80
|
|
|
80
81
|
"""
|
|
81
|
-
|
|
82
|
-
return False
|
|
82
|
+
import socket
|
|
83
83
|
|
|
84
84
|
try:
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
timeout=2,
|
|
89
|
-
)
|
|
90
|
-
# Return code 0 = running, 3 = stopped but exists, 4 = doesn't exist
|
|
91
|
-
return result.returncode != 4
|
|
92
|
-
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
85
|
+
with socket.create_connection(("127.0.0.1", port), timeout=timeout):
|
|
86
|
+
return True
|
|
87
|
+
except (socket.timeout, ConnectionRefusedError, OSError):
|
|
93
88
|
return False
|
|
94
89
|
|
|
95
90
|
def wrapped_run(self, *args: Any, **kwargs: Any) -> None:
|
|
@@ -123,8 +118,16 @@ class ReachyMiniApp(ABC):
|
|
|
123
118
|
try:
|
|
124
119
|
self.logger.info("Starting Reachy Mini app...")
|
|
125
120
|
self.logger.info(f"Using media backend: {self.media_backend}")
|
|
121
|
+
self.logger.info(f"Daemon on localhost: {self.daemon_on_localhost}")
|
|
122
|
+
|
|
123
|
+
# Force the connection mode based on daemon location detection
|
|
124
|
+
connection_mode: Literal["localhost_only", "network"] = (
|
|
125
|
+
"localhost_only" if self.daemon_on_localhost else "network"
|
|
126
|
+
)
|
|
127
|
+
|
|
126
128
|
with ReachyMini(
|
|
127
129
|
media_backend=self.media_backend,
|
|
130
|
+
connection_mode=connection_mode,
|
|
128
131
|
*args,
|
|
129
132
|
**kwargs, # type: ignore
|
|
130
133
|
) as reachy_mini:
|
reachy_mini/apps/manager.py
CHANGED
|
@@ -12,6 +12,8 @@ import numpy as np
|
|
|
12
12
|
import psutil
|
|
13
13
|
from pydantic import BaseModel
|
|
14
14
|
|
|
15
|
+
from reachy_mini.daemon.backend.robot import RobotBackend
|
|
16
|
+
|
|
15
17
|
from . import AppInfo, SourceKind
|
|
16
18
|
from .sources import hf_space, local_common_venv
|
|
17
19
|
|
|
@@ -139,7 +141,7 @@ class AppManager:
|
|
|
139
141
|
async for line in process.stdout:
|
|
140
142
|
self.logger.getChild("runner").info(line.decode().rstrip())
|
|
141
143
|
|
|
142
|
-
# Stream stderr
|
|
144
|
+
# Stream stderr - log as warning since it often contains errors/exceptions
|
|
143
145
|
stderr_lines: list[str] = []
|
|
144
146
|
|
|
145
147
|
async def log_stderr() -> None:
|
|
@@ -147,7 +149,15 @@ class AppManager:
|
|
|
147
149
|
async for line in process.stderr:
|
|
148
150
|
decoded = line.decode().rstrip()
|
|
149
151
|
stderr_lines.append(decoded)
|
|
150
|
-
|
|
152
|
+
# Check if line looks like an error or exception
|
|
153
|
+
if any(
|
|
154
|
+
keyword in decoded
|
|
155
|
+
for keyword in ["Error:", "Exception:", "Traceback", "ERROR"]
|
|
156
|
+
):
|
|
157
|
+
self.logger.getChild("runner").error(decoded)
|
|
158
|
+
else:
|
|
159
|
+
# Many libraries write INFO/WARNING to stderr
|
|
160
|
+
self.logger.getChild("runner").warning(decoded)
|
|
151
161
|
|
|
152
162
|
# Run both streams concurrently
|
|
153
163
|
await asyncio.gather(log_stdout(), log_stderr())
|
|
@@ -167,7 +177,8 @@ class AppManager:
|
|
|
167
177
|
f"Process exited with code {returncode}\n{error_msg}"
|
|
168
178
|
)
|
|
169
179
|
self.logger.getChild("runner").error(
|
|
170
|
-
f"App {app_name} exited with code {returncode}"
|
|
180
|
+
f"App {app_name} exited with code {returncode}. "
|
|
181
|
+
f"Last stderr output:\n{error_msg}"
|
|
171
182
|
)
|
|
172
183
|
|
|
173
184
|
monitor_task = asyncio.create_task(monitor_process())
|
|
@@ -229,6 +240,9 @@ class AppManager:
|
|
|
229
240
|
|
|
230
241
|
# Return robot to zero position after app stops
|
|
231
242
|
if self.daemon is not None and self.daemon.backend is not None:
|
|
243
|
+
if isinstance(self.daemon.backend, RobotBackend):
|
|
244
|
+
self.daemon.backend.enable_motors()
|
|
245
|
+
|
|
232
246
|
try:
|
|
233
247
|
from reachy_mini.reachy_mini import INIT_HEAD_POSE
|
|
234
248
|
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""HuggingFace authentication management for private spaces."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Optional
|
|
4
|
+
|
|
5
|
+
from huggingface_hub import HfApi, get_token, login, logout, whoami
|
|
6
|
+
from huggingface_hub.utils import HfHubHTTPError # type: ignore
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def save_hf_token(token: str) -> dict[str, Any]:
|
|
10
|
+
"""Save a HuggingFace access token securely.
|
|
11
|
+
|
|
12
|
+
Validates the token against the Hugging Face API and, if valid,
|
|
13
|
+
stores it using the standard Hugging Face authentication mechanism
|
|
14
|
+
for reuse across sessions.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
token: The HuggingFace access token to save.
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
A dict containing:
|
|
21
|
+
- "status": "success" or "error"
|
|
22
|
+
- "username": the associated Hugging Face username if successful
|
|
23
|
+
- "message": an error description if unsuccessful
|
|
24
|
+
|
|
25
|
+
"""
|
|
26
|
+
try:
|
|
27
|
+
# Validate token first by making an API call
|
|
28
|
+
api = HfApi(token=token)
|
|
29
|
+
user_info = api.whoami()
|
|
30
|
+
|
|
31
|
+
# Persist token for future runs (no prompt since token is provided)
|
|
32
|
+
# add_to_git_credential=False keeps it from touching git credentials.
|
|
33
|
+
login(token=token, add_to_git_credential=False)
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
"status": "success",
|
|
37
|
+
"username": user_info.get("name", ""),
|
|
38
|
+
}
|
|
39
|
+
except (HfHubHTTPError, ValueError):
|
|
40
|
+
# ValueError can be raised by `login()` on invalid token (v1.x behavior)
|
|
41
|
+
return {
|
|
42
|
+
"status": "error",
|
|
43
|
+
"message": "Invalid token or network error",
|
|
44
|
+
}
|
|
45
|
+
except Exception as e:
|
|
46
|
+
return {
|
|
47
|
+
"status": "error",
|
|
48
|
+
"message": str(e),
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def get_hf_token() -> Optional[str]:
|
|
53
|
+
"""Get stored HuggingFace token.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
The stored token, or None if no token is stored.
|
|
57
|
+
|
|
58
|
+
"""
|
|
59
|
+
return get_token()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def delete_hf_token() -> bool:
|
|
63
|
+
"""Delete stored HuggingFace token(s).
|
|
64
|
+
|
|
65
|
+
Note: logout() without arguments logs out from all saved access tokens.
|
|
66
|
+
"""
|
|
67
|
+
try:
|
|
68
|
+
logout()
|
|
69
|
+
return True
|
|
70
|
+
except Exception:
|
|
71
|
+
return False
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def check_token_status() -> dict[str, Any]:
|
|
75
|
+
"""Check if a token is stored and valid.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
Status dict with is_logged_in and username.
|
|
79
|
+
|
|
80
|
+
"""
|
|
81
|
+
token = get_hf_token()
|
|
82
|
+
if not token:
|
|
83
|
+
return {"is_logged_in": False, "username": None}
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
user_info = whoami(token=token)
|
|
87
|
+
return {
|
|
88
|
+
"is_logged_in": True,
|
|
89
|
+
"username": user_info.get("name", ""),
|
|
90
|
+
}
|
|
91
|
+
except Exception:
|
|
92
|
+
return {"is_logged_in": False, "username": None}
|
|
@@ -12,7 +12,7 @@ from .. import AppInfo, SourceKind
|
|
|
12
12
|
AUTHORIZED_APP_LIST_URL = "https://huggingface.co/datasets/pollen-robotics/reachy-mini-official-app-store/raw/main/app-list.json"
|
|
13
13
|
HF_SPACES_API_URL = "https://huggingface.co/api/spaces"
|
|
14
14
|
# TODO look for js apps too (reachy_mini_js_app)
|
|
15
|
-
HF_SPACES_FILTER_URL = "https://huggingface.co/api/spaces?filter=reachy_mini_python_app&sort=likes&direction=-1&limit=
|
|
15
|
+
HF_SPACES_FILTER_URL = "https://huggingface.co/api/spaces?filter=reachy_mini_python_app&sort=likes&direction=-1&limit=500&full=true"
|
|
16
16
|
REQUEST_TIMEOUT = aiohttp.ClientTimeout(total=30)
|
|
17
17
|
|
|
18
18
|
|
|
@@ -240,14 +240,20 @@ async def _list_apps_from_separate_venvs(
|
|
|
240
240
|
custom_app_url = _get_custom_app_url_from_file(
|
|
241
241
|
app_name, wireless_version, desktop_app_daemon
|
|
242
242
|
)
|
|
243
|
+
# Load saved metadata (e.g., private flag)
|
|
244
|
+
metadata = _load_app_metadata(app_name)
|
|
245
|
+
# Merge with current extra data
|
|
246
|
+
extra_data = {
|
|
247
|
+
"custom_app_url": custom_app_url,
|
|
248
|
+
"venv_path": str(apps_venv),
|
|
249
|
+
}
|
|
250
|
+
extra_data.update(metadata)
|
|
251
|
+
|
|
243
252
|
apps.append(
|
|
244
253
|
AppInfo(
|
|
245
254
|
name=app_name,
|
|
246
255
|
source_kind=SourceKind.INSTALLED,
|
|
247
|
-
extra=
|
|
248
|
-
"custom_app_url": custom_app_url,
|
|
249
|
-
"venv_path": str(apps_venv),
|
|
250
|
-
},
|
|
256
|
+
extra=extra_data,
|
|
251
257
|
)
|
|
252
258
|
)
|
|
253
259
|
return apps
|
|
@@ -273,14 +279,20 @@ async def _list_apps_from_separate_venvs(
|
|
|
273
279
|
app_name, wireless_version, desktop_app_daemon
|
|
274
280
|
)
|
|
275
281
|
|
|
282
|
+
# Load saved metadata (e.g., private flag)
|
|
283
|
+
metadata = _load_app_metadata(app_name)
|
|
284
|
+
# Merge with current extra data
|
|
285
|
+
extra_data = {
|
|
286
|
+
"custom_app_url": custom_app_url,
|
|
287
|
+
"venv_path": str(venv_path),
|
|
288
|
+
}
|
|
289
|
+
extra_data.update(metadata)
|
|
290
|
+
|
|
276
291
|
apps.append(
|
|
277
292
|
AppInfo(
|
|
278
293
|
name=app_name,
|
|
279
294
|
source_kind=SourceKind.INSTALLED,
|
|
280
|
-
extra=
|
|
281
|
-
"custom_app_url": custom_app_url,
|
|
282
|
-
"venv_path": str(venv_path),
|
|
283
|
-
},
|
|
295
|
+
extra=extra_data,
|
|
284
296
|
)
|
|
285
297
|
)
|
|
286
298
|
|
|
@@ -301,11 +313,18 @@ async def _list_apps_from_entry_points() -> list[AppInfo]:
|
|
|
301
313
|
logging.getLogger("reachy_mini.apps").warning(
|
|
302
314
|
f"Could not load app '{ep.name}' from entry point: {e}"
|
|
303
315
|
)
|
|
316
|
+
|
|
317
|
+
# Load saved metadata (e.g., private flag)
|
|
318
|
+
metadata = _load_app_metadata(ep.name)
|
|
319
|
+
# Merge with current extra data
|
|
320
|
+
extra_data = {"custom_app_url": custom_app_url}
|
|
321
|
+
extra_data.update(metadata)
|
|
322
|
+
|
|
304
323
|
apps.append(
|
|
305
324
|
AppInfo(
|
|
306
325
|
name=ep.name,
|
|
307
326
|
source_kind=SourceKind.INSTALLED,
|
|
308
|
-
extra=
|
|
327
|
+
extra=extra_data,
|
|
309
328
|
)
|
|
310
329
|
)
|
|
311
330
|
|
|
@@ -324,6 +343,44 @@ async def list_available_apps(
|
|
|
324
343
|
return await _list_apps_from_entry_points()
|
|
325
344
|
|
|
326
345
|
|
|
346
|
+
def _get_app_metadata_path(app_name: str) -> Path:
|
|
347
|
+
"""Get the path to the metadata file for an app."""
|
|
348
|
+
parent_dir = _get_venv_parent_dir()
|
|
349
|
+
metadata_dir = parent_dir / ".app_metadata"
|
|
350
|
+
metadata_dir.mkdir(exist_ok=True)
|
|
351
|
+
return metadata_dir / f"{app_name}.json"
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _save_app_metadata(app_name: str, metadata: dict) -> None: # type: ignore
|
|
355
|
+
"""Save metadata for an app."""
|
|
356
|
+
import json
|
|
357
|
+
|
|
358
|
+
metadata_path = _get_app_metadata_path(app_name)
|
|
359
|
+
with open(metadata_path, "w") as f:
|
|
360
|
+
json.dump(metadata, f)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _load_app_metadata(app_name: str) -> dict: # type: ignore
|
|
364
|
+
"""Load metadata for an app."""
|
|
365
|
+
import json
|
|
366
|
+
|
|
367
|
+
metadata_path = _get_app_metadata_path(app_name)
|
|
368
|
+
if not metadata_path.exists():
|
|
369
|
+
return {}
|
|
370
|
+
try:
|
|
371
|
+
with open(metadata_path, "r") as f:
|
|
372
|
+
return json.load(f) # type: ignore
|
|
373
|
+
except Exception:
|
|
374
|
+
return {}
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _delete_app_metadata(app_name: str) -> None:
|
|
378
|
+
"""Delete metadata for an app."""
|
|
379
|
+
metadata_path = _get_app_metadata_path(app_name)
|
|
380
|
+
if metadata_path.exists():
|
|
381
|
+
metadata_path.unlink()
|
|
382
|
+
|
|
383
|
+
|
|
327
384
|
async def install_package(
|
|
328
385
|
app: AppInfo,
|
|
329
386
|
logger: logging.Logger,
|
|
@@ -338,7 +395,7 @@ async def install_package(
|
|
|
338
395
|
"uv is not installed. Falling back to pip. "
|
|
339
396
|
"Install uv for faster installs: pip install uv"
|
|
340
397
|
)
|
|
341
|
-
|
|
398
|
+
|
|
342
399
|
if app.source_kind == SourceKind.HF_SPACE:
|
|
343
400
|
# Use huggingface_hub to download the repo (handles LFS automatically)
|
|
344
401
|
# This avoids requiring git-lfs to be installed on the system
|
|
@@ -350,15 +407,107 @@ async def install_package(
|
|
|
350
407
|
repo_id = app.name
|
|
351
408
|
|
|
352
409
|
logger.info(f"Downloading HuggingFace Space: {repo_id}")
|
|
410
|
+
|
|
411
|
+
# Check if this is a private space installation
|
|
412
|
+
is_private = app.extra.get("private", False)
|
|
413
|
+
token = None
|
|
414
|
+
|
|
415
|
+
if is_private:
|
|
416
|
+
# Get token for private spaces
|
|
417
|
+
from reachy_mini.apps.sources import hf_auth
|
|
418
|
+
|
|
419
|
+
token = hf_auth.get_hf_token()
|
|
420
|
+
if not token:
|
|
421
|
+
logger.error("Private space requires authentication but no token found")
|
|
422
|
+
return 1
|
|
423
|
+
logger.info("Using stored HF token for private space access")
|
|
424
|
+
|
|
353
425
|
try:
|
|
426
|
+
# First, verify the space exists and we have access
|
|
427
|
+
from huggingface_hub import HfApi
|
|
428
|
+
|
|
429
|
+
try:
|
|
430
|
+
api = HfApi(token=token)
|
|
431
|
+
space_info = api.space_info(repo_id=repo_id)
|
|
432
|
+
logger.info(
|
|
433
|
+
f"Space found: {space_info.id} (private={space_info.private})"
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
# List all files in the space to see what's available
|
|
437
|
+
try:
|
|
438
|
+
files_in_repo = api.list_repo_files(
|
|
439
|
+
repo_id=repo_id, repo_type="space", token=token
|
|
440
|
+
)
|
|
441
|
+
logger.info(f"Files available in space: {', '.join(files_in_repo)}")
|
|
442
|
+
except Exception as list_error:
|
|
443
|
+
logger.warning(f"Could not list files in space: {list_error}")
|
|
444
|
+
except Exception as verify_error:
|
|
445
|
+
logger.error(f"Cannot access space {repo_id}: {verify_error}")
|
|
446
|
+
if "404" in str(verify_error):
|
|
447
|
+
logger.error(
|
|
448
|
+
f"Space '{repo_id}' not found. Please check the space ID and your permissions."
|
|
449
|
+
)
|
|
450
|
+
elif "401" in str(verify_error) or "403" in str(verify_error):
|
|
451
|
+
logger.error(
|
|
452
|
+
f"Access denied to space '{repo_id}'. Please check your HuggingFace token permissions."
|
|
453
|
+
)
|
|
454
|
+
return 1
|
|
455
|
+
|
|
456
|
+
# Download the space
|
|
457
|
+
logger.info("Attempting to download all files from space...")
|
|
458
|
+
# For private spaces, we need to be careful about missing files like .gitattributes
|
|
459
|
+
# snapshot_download can fail on 404 for optional git metadata files
|
|
354
460
|
target = await asyncio.to_thread(
|
|
355
461
|
snapshot_download,
|
|
356
462
|
repo_id=repo_id,
|
|
357
463
|
repo_type="space",
|
|
464
|
+
token=token,
|
|
465
|
+
ignore_patterns=[
|
|
466
|
+
".gitattributes",
|
|
467
|
+
".gitignore",
|
|
468
|
+
], # Skip git metadata that may 404
|
|
469
|
+
allow_patterns=None, # Download all other files
|
|
358
470
|
)
|
|
359
471
|
logger.info(f"Downloaded to: {target}")
|
|
472
|
+
|
|
473
|
+
# Check what files were downloaded to help with debugging
|
|
474
|
+
import os
|
|
475
|
+
|
|
476
|
+
downloaded_files = []
|
|
477
|
+
for root, dirs, files in os.walk(target):
|
|
478
|
+
for file in files:
|
|
479
|
+
rel_path = os.path.relpath(os.path.join(root, file), target)
|
|
480
|
+
downloaded_files.append(rel_path)
|
|
481
|
+
logger.info(f"Downloaded files: {', '.join(downloaded_files)}")
|
|
482
|
+
|
|
483
|
+
# Check if this looks like a Python package
|
|
484
|
+
has_pyproject = os.path.exists(os.path.join(target, "pyproject.toml"))
|
|
485
|
+
has_setup = os.path.exists(os.path.join(target, "setup.py"))
|
|
486
|
+
|
|
487
|
+
if not has_pyproject and not has_setup:
|
|
488
|
+
logger.warning(
|
|
489
|
+
f"Space does not appear to have pyproject.toml or setup.py in the root directory. "
|
|
490
|
+
f"For a Reachy Mini app, you need a proper Python package structure. "
|
|
491
|
+
f"Downloaded files: {', '.join(downloaded_files)}"
|
|
492
|
+
)
|
|
493
|
+
logger.info(
|
|
494
|
+
"If your package files are in a subdirectory, make sure they're in the root of the space. "
|
|
495
|
+
"Check that pyproject.toml or setup.py is committed to your HuggingFace Space."
|
|
496
|
+
)
|
|
360
497
|
except Exception as e:
|
|
361
|
-
|
|
498
|
+
error_msg = str(e)
|
|
499
|
+
if "401" in error_msg or "403" in error_msg:
|
|
500
|
+
logger.error(
|
|
501
|
+
f"Authentication failed: {e}\n"
|
|
502
|
+
"Please check that your HuggingFace token has access to this space."
|
|
503
|
+
)
|
|
504
|
+
elif "404" in error_msg:
|
|
505
|
+
logger.error(
|
|
506
|
+
f"Space not found: {e}\n"
|
|
507
|
+
f"Please verify that '{repo_id}' exists and you have access."
|
|
508
|
+
)
|
|
509
|
+
else:
|
|
510
|
+
logger.error(f"Failed to download from HuggingFace: {e}")
|
|
362
511
|
return 1
|
|
363
512
|
elif app.source_kind == SourceKind.LOCAL:
|
|
364
513
|
target = app.extra.get("path", app.name)
|
|
@@ -397,7 +546,7 @@ async def install_package(
|
|
|
397
546
|
python_path = _get_app_python(
|
|
398
547
|
app_name, wireless_version, desktop_app_daemon
|
|
399
548
|
)
|
|
400
|
-
|
|
549
|
+
|
|
401
550
|
if use_uv:
|
|
402
551
|
install_cmd = [
|
|
403
552
|
"uv",
|
|
@@ -415,7 +564,7 @@ async def install_package(
|
|
|
415
564
|
"install",
|
|
416
565
|
"reachy-mini[gstreamer]",
|
|
417
566
|
]
|
|
418
|
-
|
|
567
|
+
|
|
419
568
|
ret = await running_command(install_cmd, logger=logger)
|
|
420
569
|
if ret != 0:
|
|
421
570
|
logger.warning(
|
|
@@ -428,12 +577,19 @@ async def install_package(
|
|
|
428
577
|
python_path = _get_app_python(
|
|
429
578
|
app_name, wireless_version, desktop_app_daemon
|
|
430
579
|
)
|
|
431
|
-
|
|
580
|
+
|
|
432
581
|
if use_uv:
|
|
433
|
-
install_cmd = [
|
|
582
|
+
install_cmd = [
|
|
583
|
+
"uv",
|
|
584
|
+
"pip",
|
|
585
|
+
"install",
|
|
586
|
+
"--python",
|
|
587
|
+
str(python_path),
|
|
588
|
+
target,
|
|
589
|
+
]
|
|
434
590
|
else:
|
|
435
591
|
install_cmd = [str(python_path), "-m", "pip", "install", target]
|
|
436
|
-
|
|
592
|
+
|
|
437
593
|
ret = await running_command(install_cmd, logger=logger)
|
|
438
594
|
|
|
439
595
|
if ret != 0:
|
|
@@ -441,6 +597,12 @@ async def install_package(
|
|
|
441
597
|
|
|
442
598
|
logger.info(f"Successfully installed '{app_name}' in {venv_path}")
|
|
443
599
|
success = True
|
|
600
|
+
|
|
601
|
+
# Save app metadata (e.g., private flag)
|
|
602
|
+
if app.extra:
|
|
603
|
+
_save_app_metadata(app_name, app.extra)
|
|
604
|
+
logger.info(f"Saved metadata for '{app_name}': {app.extra}")
|
|
605
|
+
|
|
444
606
|
return 0
|
|
445
607
|
finally:
|
|
446
608
|
# Clean up broken venv on any failure (but not shared wireless venv)
|
|
@@ -457,7 +619,7 @@ async def install_package(
|
|
|
457
619
|
install_cmd = ["uv", "pip", "install", "--python", sys.executable, target]
|
|
458
620
|
else:
|
|
459
621
|
install_cmd = [sys.executable, "-m", "pip", "install", target]
|
|
460
|
-
|
|
622
|
+
|
|
461
623
|
return await running_command(install_cmd, logger=logger)
|
|
462
624
|
|
|
463
625
|
|
|
@@ -513,10 +675,10 @@ async def uninstall_package(
|
|
|
513
675
|
python_path = _get_app_python(
|
|
514
676
|
app_name, wireless_version, desktop_app_daemon
|
|
515
677
|
)
|
|
516
|
-
|
|
678
|
+
|
|
517
679
|
# Check if uv is available
|
|
518
680
|
use_uv = _check_uv_available()
|
|
519
|
-
|
|
681
|
+
|
|
520
682
|
if use_uv:
|
|
521
683
|
uninstall_cmd = [
|
|
522
684
|
"uv",
|
|
@@ -535,16 +697,20 @@ async def uninstall_package(
|
|
|
535
697
|
"-y",
|
|
536
698
|
app_name,
|
|
537
699
|
]
|
|
538
|
-
|
|
700
|
+
|
|
539
701
|
ret = await running_command(uninstall_cmd, logger=logger)
|
|
540
702
|
if ret == 0:
|
|
541
703
|
logger.info(f"Successfully uninstalled '{app_name}'")
|
|
704
|
+
# Delete app metadata
|
|
705
|
+
_delete_app_metadata(app_name)
|
|
542
706
|
return ret
|
|
543
707
|
else:
|
|
544
708
|
# Desktop: remove the entire per-app venv directory
|
|
545
709
|
logger.info(f"Removing venv for '{app_name}' at {venv_path}")
|
|
546
710
|
shutil.rmtree(venv_path)
|
|
547
711
|
logger.info(f"Successfully uninstalled '{app_name}'")
|
|
712
|
+
# Delete app metadata
|
|
713
|
+
_delete_app_metadata(app_name)
|
|
548
714
|
return 0
|
|
549
715
|
else:
|
|
550
716
|
existing_apps = await list_available_apps()
|
|
@@ -553,15 +719,24 @@ async def uninstall_package(
|
|
|
553
719
|
|
|
554
720
|
# Check if uv is available
|
|
555
721
|
use_uv = _check_uv_available()
|
|
556
|
-
|
|
722
|
+
|
|
557
723
|
logger.info(f"Removing package {app_name}")
|
|
558
|
-
|
|
724
|
+
|
|
559
725
|
if use_uv:
|
|
560
|
-
uninstall_cmd = [
|
|
726
|
+
uninstall_cmd = [
|
|
727
|
+
"uv",
|
|
728
|
+
"pip",
|
|
729
|
+
"uninstall",
|
|
730
|
+
"--python",
|
|
731
|
+
sys.executable,
|
|
732
|
+
app_name,
|
|
733
|
+
]
|
|
561
734
|
else:
|
|
562
735
|
uninstall_cmd = [sys.executable, "-m", "pip", "uninstall", "-y", app_name]
|
|
563
|
-
|
|
736
|
+
|
|
564
737
|
ret = await running_command(uninstall_cmd, logger=logger)
|
|
565
738
|
if ret == 0:
|
|
566
739
|
logger.info(f"Successfully uninstalled '{app_name}'")
|
|
740
|
+
# Delete app metadata
|
|
741
|
+
_delete_app_metadata(app_name)
|
|
567
742
|
return ret
|
|
@@ -10,7 +10,8 @@ class {{ class_name }}(ReachyMiniApp):
|
|
|
10
10
|
# Optional: URL to a custom configuration page for the app
|
|
11
11
|
# eg. "http://localhost:8042"
|
|
12
12
|
custom_app_url: str | None = "http://0.0.0.0:8042"
|
|
13
|
-
# Optional: specify a media backend ("gstreamer", "default", etc.)
|
|
13
|
+
# Optional: specify a media backend ("gstreamer", "gstreamer_no_video", "default", etc.)
|
|
14
|
+
# On the wireless, use gstreamer_no_video to optimise CPU usage if the app does not use video streaming
|
|
14
15
|
request_media_backend: str | None = None
|
|
15
16
|
|
|
16
17
|
def run(self, reachy_mini: ReachyMini, stop_event: threading.Event):
|
|
@@ -34,7 +35,7 @@ class {{ class_name }}(ReachyMiniApp):
|
|
|
34
35
|
def request_sound_play():
|
|
35
36
|
nonlocal sound_play_requested
|
|
36
37
|
sound_play_requested = True
|
|
37
|
-
|
|
38
|
+
|
|
38
39
|
# === ^^^ ===
|
|
39
40
|
|
|
40
41
|
# Main control loop
|
|
@@ -71,4 +72,4 @@ if __name__ == "__main__":
|
|
|
71
72
|
try:
|
|
72
73
|
app.wrapped_run()
|
|
73
74
|
except KeyboardInterrupt:
|
|
74
|
-
app.stop()
|
|
75
|
+
app.stop()
|
|
@@ -114,7 +114,15 @@ const installedApps = {
|
|
|
114
114
|
const title = document.createElement('div');
|
|
115
115
|
const titleSpan = document.createElement('span');
|
|
116
116
|
titleSpan.className = 'installed-app-title top-1/2 ';
|
|
117
|
-
|
|
117
|
+
|
|
118
|
+
// Add [private] tag if this is a private space
|
|
119
|
+
const isPrivate = app.extra && app.extra.private === true;
|
|
120
|
+
if (isPrivate) {
|
|
121
|
+
titleSpan.innerHTML = app.name + ' <span style="color: #dc2626; font-size: 0.75rem; font-weight: 600; margin-left: 0.25rem;">[private]</span>';
|
|
122
|
+
} else {
|
|
123
|
+
titleSpan.innerHTML = app.name;
|
|
124
|
+
}
|
|
125
|
+
|
|
118
126
|
title.appendChild(titleSpan);
|
|
119
127
|
if (app.extra && app.extra.custom_app_url) {
|
|
120
128
|
const settingsLink = document.createElement('a');
|