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
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
- # If we're running with wireless, we assume systemd service is used
43
- running_on_wireless = self._check_systemd_service_exists()
44
- self.logger.info(f"Running on wireless: {running_on_wireless}")
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 ("gstreamer" if running_on_wireless else "default")
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 _check_systemd_service_exists(service_name: str = "reachy-mini-daemon") -> bool:
72
- """Check if a systemd service exists (Linux only).
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
- service_name: Name of the systemd service to check
75
+ port: Port to check (default: 8000)
76
+ timeout: Connection timeout in seconds
76
77
 
77
78
  Returns:
78
- True if the service exists, False otherwise
79
+ True if daemon responds on localhost, False otherwise
79
80
 
80
81
  """
81
- if platform.system() != "Linux":
82
- return False
82
+ import socket
83
83
 
84
84
  try:
85
- result = subprocess.run(
86
- ["systemctl", "status", service_name],
87
- capture_output=True,
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:
@@ -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 (many libraries write INFO/WARNING to 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
- self.logger.getChild("runner").info(decoded)
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=50&full=true"
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={"custom_app_url": custom_app_url},
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
- logger.error(f"Failed to download from HuggingFace: {e}")
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 = ["uv", "pip", "install", "--python", str(python_path), target]
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 = ["uv", "pip", "uninstall", "--python", sys.executable, app_name]
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
- titleSpan.innerHTML = app.name;
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');