reachy-mini-cli 0.9.0__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/__init__.py +13 -0
- reachy/__main__.py +10 -0
- reachy/alive.py +515 -0
- reachy/behavior/__init__.py +29 -0
- reachy/behavior/arbitration.py +111 -0
- reachy/behavior/control.py +160 -0
- reachy/behavior/engine.py +432 -0
- reachy/behavior/library.py +408 -0
- reachy/behavior/model.py +151 -0
- reachy/behavior/sense.py +116 -0
- reachy/behavior/supervisor.py +269 -0
- reachy/cli/__init__.py +149 -0
- reachy/cli/_commands/__init__.py +1 -0
- reachy/cli/_commands/_robot.py +89 -0
- reachy/cli/_commands/app.py +94 -0
- reachy/cli/_commands/behavior.py +494 -0
- reachy/cli/_commands/cli.py +43 -0
- reachy/cli/_commands/daemon.py +165 -0
- reachy/cli/_commands/demo_mode.py +409 -0
- reachy/cli/_commands/device.py +70 -0
- reachy/cli/_commands/doctor.py +122 -0
- reachy/cli/_commands/explain.py +38 -0
- reachy/cli/_commands/learn.py +109 -0
- reachy/cli/_commands/listen.py +433 -0
- reachy/cli/_commands/move.py +127 -0
- reachy/cli/_commands/overview.py +118 -0
- reachy/cli/_commands/whoami.py +106 -0
- reachy/cli/_errors.py +42 -0
- reachy/cli/_output.py +53 -0
- reachy/daemon.py +387 -0
- reachy/demo_config.py +200 -0
- reachy/demo_service.py +188 -0
- reachy/explain/__init__.py +24 -0
- reachy/explain/catalog.py +604 -0
- reachy/looputil.py +66 -0
- reachy/motion/__init__.py +15 -0
- reachy/motion/listen.py +272 -0
- reachy/motion/queue.py +90 -0
- reachy/motion/server.py +157 -0
- reachy/motion/snap.py +93 -0
- reachy/motion/supervisor.py +285 -0
- reachy/robot/__init__.py +29 -0
- reachy/robot/http_transport.py +203 -0
- reachy/robot/sdk_transport.py +264 -0
- reachy/robot/transport.py +184 -0
- reachy_mini_cli-0.9.0.dist-info/METADATA +262 -0
- reachy_mini_cli-0.9.0.dist-info/RECORD +50 -0
- reachy_mini_cli-0.9.0.dist-info/WHEEL +4 -0
- reachy_mini_cli-0.9.0.dist-info/entry_points.txt +3 -0
- reachy_mini_cli-0.9.0.dist-info/licenses/LICENSE +21 -0
reachy/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""reachy-mini-cli — agent-first CLI for an AgentCulture mesh agent."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib.metadata import PackageNotFoundError
|
|
6
|
+
from importlib.metadata import version as _pkg_version
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
__version__ = _pkg_version("reachy-mini-cli")
|
|
10
|
+
except PackageNotFoundError: # pragma: no cover - editable install without metadata
|
|
11
|
+
__version__ = "0.0.0"
|
|
12
|
+
|
|
13
|
+
__all__ = ["__version__"]
|
reachy/__main__.py
ADDED
reachy/alive.py
ADDED
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
"""``demo-mode`` — make the Reachy Mini *feel alive* + manage that as a process.
|
|
2
|
+
|
|
3
|
+
Two halves, mirroring :mod:`reachy.daemon`:
|
|
4
|
+
|
|
5
|
+
* **The engine** — a pure idle-motion generator (:func:`next_pose`) and the
|
|
6
|
+
foreground loop (:func:`run_loop`) that drives a :class:`~reachy.robot.transport.Transport`.
|
|
7
|
+
Each tick it sends a gentle "alive" pose — a slow breathing oscillation, an
|
|
8
|
+
occasional glance to a new gaze target, and a little antenna sway — so a robot
|
|
9
|
+
that is otherwise idle looks like it is quietly present rather than frozen.
|
|
10
|
+
* **The supervisor** — :func:`start` / :func:`stop` / :func:`status` run that loop
|
|
11
|
+
as a detached background process, tracked with a PID file + log file under the
|
|
12
|
+
same per-user state dir the daemon uses. ``start`` re-invokes this very CLI
|
|
13
|
+
(``python -m reachy demo-mode run``) so the loop keeps running after the
|
|
14
|
+
launching command returns.
|
|
15
|
+
|
|
16
|
+
Pure standard library (``random`` / ``math`` / ``subprocess`` / ``signal`` /
|
|
17
|
+
``os``): the "feel alive" behaviour is just a stream of ``move goto`` calls over
|
|
18
|
+
the existing transport, so this adds **no** third-party runtime dependency and
|
|
19
|
+
the slim base install keeps its zero-runtime-deps property. The motion only
|
|
20
|
+
needs *something to talk to* — a running daemon (``reachy daemon start``) for the
|
|
21
|
+
http transport, or the ``[sdk]`` extra for ``--transport sdk``.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import math
|
|
27
|
+
import os
|
|
28
|
+
import random
|
|
29
|
+
import signal
|
|
30
|
+
import subprocess # nosec B404 - only ever re-spawns this trusted CLI (sys.executable -m reachy)
|
|
31
|
+
import sys
|
|
32
|
+
import time
|
|
33
|
+
from dataclasses import dataclass
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
|
|
36
|
+
from reachy.cli._errors import EXIT_ENV_ERROR, CliError
|
|
37
|
+
|
|
38
|
+
# Reuse the daemon's generic process primitives + state dir so demo-mode and the
|
|
39
|
+
# daemon share one bookkeeping location and one definition of "is this pid alive".
|
|
40
|
+
from reachy.daemon import health_ok, is_alive, state_dir
|
|
41
|
+
from reachy.looputil import install_stop_handlers, interruptible_sleep, restore_stop_handlers
|
|
42
|
+
from reachy.robot.transport import DEFAULT_BASE_URL, DEFAULT_TIMEOUT, Transport
|
|
43
|
+
|
|
44
|
+
# Grace window after spawning before we trust the loop came up (vs crashed).
|
|
45
|
+
_START_GRACE = 0.4
|
|
46
|
+
# Seconds to wait after SIGTERM before escalating to SIGKILL.
|
|
47
|
+
DEFAULT_STOP_TIMEOUT = 10.0
|
|
48
|
+
# How finely the loop slices its inter-tick sleep so a stop signal lands fast.
|
|
49
|
+
_SLEEP_SLICE = 0.25
|
|
50
|
+
_STATUS_NOT_RUNNING = "not running"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# --------------------------------------------------------------------------- #
|
|
54
|
+
# Engine: the "feel alive" motion generator #
|
|
55
|
+
# --------------------------------------------------------------------------- #
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class AliveConfig:
|
|
60
|
+
"""Tunables for the idle "alive" motion.
|
|
61
|
+
|
|
62
|
+
Amplitudes are in the CLI's friendly units (millimetres / degrees) and are
|
|
63
|
+
all scaled by ``energy`` (a single 0..n liveliness knob). ``interval`` sets
|
|
64
|
+
the tempo (seconds between poses); each ``goto`` is given a duration just
|
|
65
|
+
under ``interval`` so motion glides continuously rather than stepping.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
interval: float = 2.5
|
|
69
|
+
energy: float = 1.0
|
|
70
|
+
breathe_period: float = 5.0
|
|
71
|
+
breathe_z_mm: float = 3.0
|
|
72
|
+
breathe_pitch_deg: float = 2.0
|
|
73
|
+
gaze_yaw_deg: float = 18.0
|
|
74
|
+
gaze_pitch_deg: float = 10.0
|
|
75
|
+
gaze_roll_deg: float = 4.0
|
|
76
|
+
antenna_deg: float = 18.0
|
|
77
|
+
body_yaw_deg: float = 8.0
|
|
78
|
+
glance_probability: float = 0.5
|
|
79
|
+
interpolation: str = "minjerk"
|
|
80
|
+
seed: int | None = None
|
|
81
|
+
# Give up the loop after this many consecutive failed gotos (daemon gone).
|
|
82
|
+
max_errors: int = 5
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def neutral_pose(config: AliveConfig) -> dict[str, object]:
|
|
86
|
+
"""The centred rest pose demo-mode settles to when it stops."""
|
|
87
|
+
return {
|
|
88
|
+
"head": {"x": 0.0, "y": 0.0, "z": 0.0, "roll": 0.0, "pitch": 0.0, "yaw": 0.0},
|
|
89
|
+
"antennas": (0.0, 0.0),
|
|
90
|
+
"body_yaw": 0.0,
|
|
91
|
+
"duration": max(0.5, config.interval),
|
|
92
|
+
"interpolation": config.interpolation,
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def next_pose(elapsed: float, rng: random.Random, config: AliveConfig) -> dict[str, object]:
|
|
97
|
+
"""Compute the next idle pose at time ``elapsed`` seconds into the loop.
|
|
98
|
+
|
|
99
|
+
Pure and deterministic given ``elapsed`` and ``rng``: breathing is a function
|
|
100
|
+
of ``elapsed`` (continuous), the glance target is drawn from ``rng``. The
|
|
101
|
+
result maps straight onto :meth:`Transport.move_goto` keyword arguments.
|
|
102
|
+
"""
|
|
103
|
+
e = max(0.0, config.energy)
|
|
104
|
+
phase = 2.0 * math.pi * (elapsed / config.breathe_period) if config.breathe_period else 0.0
|
|
105
|
+
|
|
106
|
+
# Breathing: a slow vertical + pitch oscillation, always present.
|
|
107
|
+
z = config.breathe_z_mm * e * math.sin(phase)
|
|
108
|
+
breathe_pitch = config.breathe_pitch_deg * e * math.sin(phase)
|
|
109
|
+
|
|
110
|
+
# Gaze: now and then look somewhere new; otherwise just micro-drift near centre.
|
|
111
|
+
if rng.random() < config.glance_probability:
|
|
112
|
+
scale = 1.0
|
|
113
|
+
body_yaw = rng.uniform(-config.body_yaw_deg, config.body_yaw_deg) * e
|
|
114
|
+
else:
|
|
115
|
+
scale = 0.2
|
|
116
|
+
body_yaw = 0.0
|
|
117
|
+
yaw = rng.uniform(-config.gaze_yaw_deg, config.gaze_yaw_deg) * e * scale
|
|
118
|
+
gaze_pitch = rng.uniform(-config.gaze_pitch_deg, config.gaze_pitch_deg) * e * scale
|
|
119
|
+
roll = rng.uniform(-config.gaze_roll_deg, config.gaze_roll_deg) * e * scale
|
|
120
|
+
|
|
121
|
+
# Antennas: a gentle sway plus a touch of independent jitter.
|
|
122
|
+
sway = config.antenna_deg * e * math.sin(phase * 1.5)
|
|
123
|
+
jitter = rng.uniform(-1.0, 1.0) * config.antenna_deg * 0.3 * e
|
|
124
|
+
right = sway + jitter
|
|
125
|
+
left = -sway + jitter
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
"head": {
|
|
129
|
+
"x": 0.0,
|
|
130
|
+
"y": 0.0,
|
|
131
|
+
"z": z,
|
|
132
|
+
"roll": roll,
|
|
133
|
+
"pitch": breathe_pitch + gaze_pitch,
|
|
134
|
+
"yaw": yaw,
|
|
135
|
+
},
|
|
136
|
+
"antennas": (right, left),
|
|
137
|
+
"body_yaw": body_yaw,
|
|
138
|
+
"duration": max(0.2, config.interval * 0.9),
|
|
139
|
+
"interpolation": config.interpolation,
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _send_pose(transport: Transport, pose: dict[str, object]) -> object:
|
|
144
|
+
return transport.move_goto(
|
|
145
|
+
head=pose["head"], # type: ignore[arg-type]
|
|
146
|
+
antennas=pose["antennas"], # type: ignore[arg-type]
|
|
147
|
+
body_yaw=pose["body_yaw"], # type: ignore[arg-type]
|
|
148
|
+
duration=pose["duration"], # type: ignore[arg-type]
|
|
149
|
+
interpolation=pose["interpolation"], # type: ignore[arg-type]
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _preflight(transport: Transport, config: AliveConfig, wake: bool) -> None:
|
|
154
|
+
"""First robot call — validates the transport. A dead daemon raises CliError."""
|
|
155
|
+
if wake:
|
|
156
|
+
transport.wake()
|
|
157
|
+
else:
|
|
158
|
+
_send_pose(transport, neutral_pose(config))
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _settle(transport: Transport, config: AliveConfig) -> None:
|
|
162
|
+
"""Best-effort ease back to neutral on stop (a dead daemon can't be settled)."""
|
|
163
|
+
try:
|
|
164
|
+
_send_pose(transport, neutral_pose(config))
|
|
165
|
+
except CliError:
|
|
166
|
+
pass
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _send_tick(
|
|
170
|
+
transport: Transport, pose: dict, tick: int, elapsed: float, consecutive: int, max_errors: int
|
|
171
|
+
) -> tuple[dict, int]:
|
|
172
|
+
"""Send one pose; return ``(event, consecutive_errors)``.
|
|
173
|
+
|
|
174
|
+
Re-raises the :class:`CliError` once ``max_errors`` consecutive sends have
|
|
175
|
+
failed, so a sustained daemon outage ends the loop cleanly.
|
|
176
|
+
"""
|
|
177
|
+
base = {"tick": tick, "elapsed": round(elapsed, 3)}
|
|
178
|
+
try:
|
|
179
|
+
_send_pose(transport, pose)
|
|
180
|
+
except CliError as exc:
|
|
181
|
+
consecutive += 1
|
|
182
|
+
if consecutive >= max_errors:
|
|
183
|
+
raise
|
|
184
|
+
return {**base, "ok": False, "error": exc.message}, consecutive
|
|
185
|
+
return {**base, "ok": True, "error": None}, 0
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def run_loop(
|
|
189
|
+
transport: Transport,
|
|
190
|
+
config: AliveConfig,
|
|
191
|
+
*,
|
|
192
|
+
sleep=time.sleep,
|
|
193
|
+
now=time.monotonic,
|
|
194
|
+
on_start=None,
|
|
195
|
+
emit=None,
|
|
196
|
+
max_ticks: int | None = None,
|
|
197
|
+
wake: bool = True,
|
|
198
|
+
settle: bool = True,
|
|
199
|
+
rng: random.Random | None = None,
|
|
200
|
+
) -> int:
|
|
201
|
+
"""Drive the robot with idle "alive" poses until stopped. Returns ticks run.
|
|
202
|
+
|
|
203
|
+
Connectivity is validated up front (the opening ``wake`` — or, with
|
|
204
|
+
``wake=False``, a single neutral ``goto``); if the robot can't be reached the
|
|
205
|
+
underlying :class:`CliError` propagates so the caller exits cleanly. The
|
|
206
|
+
optional ``on_start`` callback runs *after* that preflight succeeds — so a
|
|
207
|
+
caller can emit a "starting" line only once the loop is truly live, never
|
|
208
|
+
polluting the error output of a failed preflight. Once running, transient send
|
|
209
|
+
failures are tolerated up to ``config.max_errors`` consecutive misses before
|
|
210
|
+
giving up. On stop the robot is eased back to neutral (best effort).
|
|
211
|
+
"""
|
|
212
|
+
# nosec B311 - the RNG only shapes idle robot motion; not security-sensitive.
|
|
213
|
+
rng = rng if rng is not None else random.Random(config.seed) # nosec B311
|
|
214
|
+
stop = {"flag": False}
|
|
215
|
+
handlers = install_stop_handlers(stop)
|
|
216
|
+
|
|
217
|
+
_preflight(transport, config, wake)
|
|
218
|
+
if on_start is not None:
|
|
219
|
+
on_start()
|
|
220
|
+
|
|
221
|
+
start_t = now()
|
|
222
|
+
ticks = 0
|
|
223
|
+
consecutive = 0
|
|
224
|
+
try:
|
|
225
|
+
while not stop["flag"]:
|
|
226
|
+
elapsed = now() - start_t
|
|
227
|
+
pose = next_pose(elapsed, rng, config)
|
|
228
|
+
event, consecutive = _send_tick(
|
|
229
|
+
transport, pose, ticks + 1, elapsed, consecutive, config.max_errors
|
|
230
|
+
)
|
|
231
|
+
ticks += 1
|
|
232
|
+
if emit is not None:
|
|
233
|
+
emit(event)
|
|
234
|
+
if max_ticks is not None and ticks >= max_ticks:
|
|
235
|
+
break
|
|
236
|
+
interruptible_sleep(config.interval, stop, sleep)
|
|
237
|
+
finally:
|
|
238
|
+
restore_stop_handlers(handlers)
|
|
239
|
+
if settle:
|
|
240
|
+
_settle(transport, config)
|
|
241
|
+
return ticks
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
# --------------------------------------------------------------------------- #
|
|
245
|
+
# Supervisor: run the loop as a tracked background process #
|
|
246
|
+
# --------------------------------------------------------------------------- #
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def pid_file() -> Path:
|
|
250
|
+
return state_dir() / "demo-mode.pid"
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def log_file() -> Path:
|
|
254
|
+
return state_dir() / "demo-mode.log"
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def read_pid() -> int | None:
|
|
258
|
+
"""Return the tracked PID, or ``None`` if the file is absent or unparseable."""
|
|
259
|
+
try:
|
|
260
|
+
text = pid_file().read_text(encoding="utf-8").strip()
|
|
261
|
+
except OSError:
|
|
262
|
+
return None
|
|
263
|
+
try:
|
|
264
|
+
return int(text)
|
|
265
|
+
except ValueError:
|
|
266
|
+
return None
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _clear_pid() -> None:
|
|
270
|
+
try:
|
|
271
|
+
pid_file().unlink()
|
|
272
|
+
except FileNotFoundError:
|
|
273
|
+
pass
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _wait_gone(pid: int, timeout: float) -> bool:
|
|
277
|
+
"""Poll until ``pid`` is gone or ``timeout`` elapses. Uses this module's
|
|
278
|
+
:func:`is_alive` so tests can pin liveness via ``reachy.alive.is_alive``.
|
|
279
|
+
"""
|
|
280
|
+
deadline = time.monotonic() + timeout
|
|
281
|
+
while time.monotonic() < deadline:
|
|
282
|
+
if not is_alive(pid):
|
|
283
|
+
return True
|
|
284
|
+
time.sleep(_SLEEP_SLICE)
|
|
285
|
+
return not is_alive(pid)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _is_our_process(pid: int) -> bool:
|
|
289
|
+
"""Best-effort guard against PID reuse: is ``pid`` actually a demo-mode loop?
|
|
290
|
+
|
|
291
|
+
Reads ``/proc/<pid>/cmdline`` on Linux (the spawn line contains
|
|
292
|
+
``demo-mode``). If ``/proc`` is unavailable we cannot verify, so we trust the
|
|
293
|
+
pid file. If ``/proc`` exists but the process is gone or clearly isn't ours,
|
|
294
|
+
return False so :func:`stop` never signals an unrelated recycled pid.
|
|
295
|
+
"""
|
|
296
|
+
if not Path("/proc").is_dir():
|
|
297
|
+
return True
|
|
298
|
+
try:
|
|
299
|
+
raw = Path(f"/proc/{pid}/cmdline").read_bytes()
|
|
300
|
+
except OSError:
|
|
301
|
+
return False
|
|
302
|
+
cmdline = raw.replace(b"\x00", b" ").decode("utf-8", "replace")
|
|
303
|
+
return "demo-mode" in cmdline or "demo_mode" in cmdline
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def build_run_command(
|
|
307
|
+
*,
|
|
308
|
+
transport: str,
|
|
309
|
+
base_url: str,
|
|
310
|
+
timeout: float,
|
|
311
|
+
interval: float,
|
|
312
|
+
energy: float,
|
|
313
|
+
interpolation: str,
|
|
314
|
+
seed: int | None,
|
|
315
|
+
wake: bool,
|
|
316
|
+
settle: bool,
|
|
317
|
+
) -> list[str]:
|
|
318
|
+
"""The argv that the background process runs: ``python -m reachy demo-mode run``."""
|
|
319
|
+
cmd = [
|
|
320
|
+
sys.executable,
|
|
321
|
+
"-m",
|
|
322
|
+
"reachy",
|
|
323
|
+
"demo-mode",
|
|
324
|
+
"run",
|
|
325
|
+
"--transport",
|
|
326
|
+
transport,
|
|
327
|
+
"--base-url",
|
|
328
|
+
base_url,
|
|
329
|
+
"--timeout",
|
|
330
|
+
str(timeout),
|
|
331
|
+
"--interval",
|
|
332
|
+
str(interval),
|
|
333
|
+
"--energy",
|
|
334
|
+
str(energy),
|
|
335
|
+
"--interpolation",
|
|
336
|
+
interpolation,
|
|
337
|
+
]
|
|
338
|
+
if seed is not None:
|
|
339
|
+
cmd += ["--seed", str(seed)]
|
|
340
|
+
if not wake:
|
|
341
|
+
cmd.append("--no-wake")
|
|
342
|
+
if not settle:
|
|
343
|
+
cmd.append("--no-settle")
|
|
344
|
+
return cmd
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def start(
|
|
348
|
+
*,
|
|
349
|
+
transport: str = "http",
|
|
350
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
351
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
352
|
+
interval: float = AliveConfig.interval,
|
|
353
|
+
energy: float = AliveConfig.energy,
|
|
354
|
+
interpolation: str = AliveConfig.interpolation,
|
|
355
|
+
seed: int | None = None,
|
|
356
|
+
wake: bool = True,
|
|
357
|
+
settle: bool = True,
|
|
358
|
+
) -> dict[str, object]:
|
|
359
|
+
"""Start the "feel alive" loop in the background (idempotent).
|
|
360
|
+
|
|
361
|
+
If a tracked loop is already alive, report ``already-running``. For the http
|
|
362
|
+
transport, preflight the daemon's health route so we don't spawn a loop with
|
|
363
|
+
nothing to talk to (a dead daemon is reported as a clean environment error).
|
|
364
|
+
Then spawn the loop detached, record its PID + log path, and give it a short
|
|
365
|
+
grace window to confirm it didn't crash on startup.
|
|
366
|
+
"""
|
|
367
|
+
existing = read_pid()
|
|
368
|
+
if existing is not None and is_alive(existing):
|
|
369
|
+
return {
|
|
370
|
+
"status": "already-running",
|
|
371
|
+
"pid": existing,
|
|
372
|
+
"transport": transport,
|
|
373
|
+
"log": str(log_file()),
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if transport == "http" and not health_ok(base_url, timeout):
|
|
377
|
+
raise CliError(
|
|
378
|
+
code=EXIT_ENV_ERROR,
|
|
379
|
+
message=f"no Reachy daemon reachable at {base_url}",
|
|
380
|
+
remediation=(
|
|
381
|
+
"start it first with 'reachy daemon start', or point --base-url / "
|
|
382
|
+
"REACHY_BASE_URL at a running daemon (use --transport sdk to drive "
|
|
383
|
+
"the robot in-process instead)"
|
|
384
|
+
),
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
cmd = build_run_command(
|
|
388
|
+
transport=transport,
|
|
389
|
+
base_url=base_url,
|
|
390
|
+
timeout=timeout,
|
|
391
|
+
interval=interval,
|
|
392
|
+
energy=energy,
|
|
393
|
+
interpolation=interpolation,
|
|
394
|
+
seed=seed,
|
|
395
|
+
wake=wake,
|
|
396
|
+
settle=settle,
|
|
397
|
+
)
|
|
398
|
+
log_path = log_file()
|
|
399
|
+
try:
|
|
400
|
+
with open(log_path, "ab") as logf:
|
|
401
|
+
proc = subprocess.Popen( # nosec B603 - trusted argv (this CLI), no shell
|
|
402
|
+
cmd,
|
|
403
|
+
stdout=logf,
|
|
404
|
+
stderr=subprocess.STDOUT,
|
|
405
|
+
stdin=subprocess.DEVNULL,
|
|
406
|
+
start_new_session=True,
|
|
407
|
+
)
|
|
408
|
+
except OSError as err:
|
|
409
|
+
raise CliError(
|
|
410
|
+
code=EXIT_ENV_ERROR,
|
|
411
|
+
message=f"failed to launch demo-mode ({cmd[0]}): {err}",
|
|
412
|
+
remediation="check the Python interpreter is usable and the state dir is writable",
|
|
413
|
+
) from err
|
|
414
|
+
pid_file().write_text(str(proc.pid), encoding="utf-8")
|
|
415
|
+
|
|
416
|
+
time.sleep(_START_GRACE)
|
|
417
|
+
result: dict[str, object] = {
|
|
418
|
+
"status": "started",
|
|
419
|
+
"pid": proc.pid,
|
|
420
|
+
"transport": transport,
|
|
421
|
+
"log": str(log_path),
|
|
422
|
+
}
|
|
423
|
+
if transport == "http":
|
|
424
|
+
result["url"] = base_url
|
|
425
|
+
if proc.poll() is not None:
|
|
426
|
+
# Exited within the grace window — startup failed (e.g. daemon vanished).
|
|
427
|
+
result["status"] = "exited"
|
|
428
|
+
result["exit_code"] = proc.returncode
|
|
429
|
+
result["note"] = f"demo-mode exited during startup; see {log_path}"
|
|
430
|
+
return result
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def stop(*, timeout: float = DEFAULT_STOP_TIMEOUT) -> dict[str, object]:
|
|
434
|
+
"""Stop the demo-mode loop this CLI started: SIGTERM, then SIGKILL if it lingers.
|
|
435
|
+
|
|
436
|
+
SIGTERM lets the loop ease the robot back to neutral before it exits. Guards
|
|
437
|
+
against PID reuse (never signals a process that isn't our loop) and never
|
|
438
|
+
claims success it can't confirm.
|
|
439
|
+
"""
|
|
440
|
+
pid = read_pid()
|
|
441
|
+
if pid is None:
|
|
442
|
+
return {"status": _STATUS_NOT_RUNNING, "note": "no tracked demo-mode pid"}
|
|
443
|
+
if not is_alive(pid):
|
|
444
|
+
_clear_pid()
|
|
445
|
+
return {"status": _STATUS_NOT_RUNNING, "pid": pid, "note": "stale pid cleared"}
|
|
446
|
+
if not _is_our_process(pid):
|
|
447
|
+
_clear_pid()
|
|
448
|
+
return {
|
|
449
|
+
"status": _STATUS_NOT_RUNNING,
|
|
450
|
+
"pid": pid,
|
|
451
|
+
"note": "tracked pid is no longer a demo-mode loop (reused); left untouched",
|
|
452
|
+
}
|
|
453
|
+
try:
|
|
454
|
+
os.kill(pid, signal.SIGTERM)
|
|
455
|
+
except ProcessLookupError:
|
|
456
|
+
_clear_pid()
|
|
457
|
+
return {"status": _STATUS_NOT_RUNNING, "pid": pid, "note": "process already gone"}
|
|
458
|
+
except PermissionError as err:
|
|
459
|
+
raise CliError(
|
|
460
|
+
code=EXIT_ENV_ERROR,
|
|
461
|
+
message=f"not permitted to stop demo-mode pid {pid}",
|
|
462
|
+
remediation="stop it as the owning user",
|
|
463
|
+
) from err
|
|
464
|
+
signaled = "SIGTERM"
|
|
465
|
+
gone = _wait_gone(pid, timeout)
|
|
466
|
+
if not gone:
|
|
467
|
+
try:
|
|
468
|
+
os.kill(pid, signal.SIGKILL)
|
|
469
|
+
signaled = "SIGKILL"
|
|
470
|
+
except ProcessLookupError:
|
|
471
|
+
gone = True
|
|
472
|
+
if not gone:
|
|
473
|
+
gone = _wait_gone(pid, 2.0)
|
|
474
|
+
if not gone:
|
|
475
|
+
raise CliError(
|
|
476
|
+
code=EXIT_ENV_ERROR,
|
|
477
|
+
message=f"failed to stop demo-mode pid {pid}: still alive after SIGKILL",
|
|
478
|
+
remediation="inspect and terminate the process manually",
|
|
479
|
+
)
|
|
480
|
+
_clear_pid()
|
|
481
|
+
return {"status": "stopped", "pid": pid, "signal": signaled}
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def restart(**start_kwargs) -> dict[str, object]:
|
|
485
|
+
"""Stop the tracked loop (if any) then start a fresh one.
|
|
486
|
+
|
|
487
|
+
The new process re-imports the latest motion code and re-reads config, so
|
|
488
|
+
this is how an update is applied in the ad-hoc (non-service) mode. ``stop``
|
|
489
|
+
is best-effort — a not-running loop is fine; only a genuinely unkillable
|
|
490
|
+
process raises (propagated from :func:`stop`).
|
|
491
|
+
"""
|
|
492
|
+
before = stop()
|
|
493
|
+
result = start(**start_kwargs)
|
|
494
|
+
result["restarted_from"] = before.get("status", "unknown")
|
|
495
|
+
return result
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def status(
|
|
499
|
+
*, base_url: str = DEFAULT_BASE_URL, timeout: float = DEFAULT_TIMEOUT
|
|
500
|
+
) -> dict[str, object]:
|
|
501
|
+
"""Report the demo-mode process state and whether its target daemon answers."""
|
|
502
|
+
pid = read_pid()
|
|
503
|
+
if pid is None:
|
|
504
|
+
process = "stopped"
|
|
505
|
+
elif is_alive(pid):
|
|
506
|
+
process = "running"
|
|
507
|
+
else:
|
|
508
|
+
process = "stale" # pid file points at a dead process
|
|
509
|
+
return {
|
|
510
|
+
"process": process,
|
|
511
|
+
"pid": pid,
|
|
512
|
+
"daemon": "healthy" if health_ok(base_url, timeout) else "unreachable",
|
|
513
|
+
"url": base_url,
|
|
514
|
+
"log": str(log_file()),
|
|
515
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Composable robot behaviors on a 50 Hz control loop.
|
|
2
|
+
|
|
3
|
+
The :mod:`reachy.behavior` package turns the Reachy Mini's three motion channels
|
|
4
|
+
(``head`` / ``antennas`` / ``body_yaw``) into something you can *layer*: a
|
|
5
|
+
persistent 50 Hz engine holds a set of active behaviors, each a pure function of
|
|
6
|
+
time producing a per-channel :class:`~reachy.behavior.model.Contribution`, and
|
|
7
|
+
arbitrates per channel using each behavior's contention class
|
|
8
|
+
(``passive`` / ``stoppable`` / ``unstoppable`` / ``stopping``). The winners are
|
|
9
|
+
composed into one complete pose every tick and streamed to the robot.
|
|
10
|
+
|
|
11
|
+
The pieces:
|
|
12
|
+
|
|
13
|
+
* :mod:`~reachy.behavior.model` — the pure data model (channels, classes,
|
|
14
|
+
lifetimes, the :class:`Behavior` value object).
|
|
15
|
+
* :mod:`~reachy.behavior.arbitration` — the pure contention algorithm
|
|
16
|
+
(:func:`arbitrate` per tick, :func:`admit` on add).
|
|
17
|
+
* :mod:`~reachy.behavior.library` — the built-in parametric behaviors.
|
|
18
|
+
* :mod:`~reachy.behavior.engine` — the 50 Hz compose loop.
|
|
19
|
+
* :mod:`~reachy.behavior.control` — the command-spool / state-file IPC.
|
|
20
|
+
* :mod:`~reachy.behavior.supervisor` — run the engine as a tracked process.
|
|
21
|
+
|
|
22
|
+
Pure standard library throughout; nothing here imports ``reachy_mini``.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from reachy.behavior.model import CHANNELS, Behavior, Contribution, Lifetime, StopClass
|
|
28
|
+
|
|
29
|
+
__all__ = ["CHANNELS", "Behavior", "Contribution", "Lifetime", "StopClass"]
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""The contention core — who owns each channel, and what an add evicts.
|
|
2
|
+
|
|
3
|
+
Two pure functions, no I/O, no clock:
|
|
4
|
+
|
|
5
|
+
* :func:`arbitrate` runs **every tick**: given the live behaviors (in admission
|
|
6
|
+
order, oldest first), it assigns each channel a single owner by
|
|
7
|
+
``(class priority, recency)``.
|
|
8
|
+
* :func:`admit` runs **when a behavior is added**: a ``stopping`` behavior removes
|
|
9
|
+
the ``stoppable`` behaviors it shares a channel with; everything else removes
|
|
10
|
+
nothing. Admission is *total* — every behavior is accepted — so contention a
|
|
11
|
+
newcomer cannot win by removal is simply resolved per tick (it waits, yielding
|
|
12
|
+
the channel to a higher-priority incumbent until that incumbent ends).
|
|
13
|
+
|
|
14
|
+
This encodes the four-class model directly:
|
|
15
|
+
|
|
16
|
+
* **passive** — never removes anything and is only ever the per-tick owner of a
|
|
17
|
+
channel no non-passive behavior claims (lowest priority);
|
|
18
|
+
* **stoppable** — drives, but is removed by a newly-admitted ``stopping`` on a
|
|
19
|
+
shared channel;
|
|
20
|
+
* **unstoppable** — highest priority (owns its channels while alive) and is never
|
|
21
|
+
removed by an add, so it "holds until it finishes itself";
|
|
22
|
+
* **stopping** — on admit, evicts the shared ``stoppable`` behaviors and takes
|
|
23
|
+
over.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
|
|
30
|
+
from reachy.behavior.model import CHANNELS, Behavior, StopClass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def arbitrate(
|
|
34
|
+
behaviors: list[Behavior], contribs: dict | None = None
|
|
35
|
+
) -> dict[str, Behavior | None]:
|
|
36
|
+
"""Assign each channel its owner. ``behaviors`` is oldest-first (recency = later).
|
|
37
|
+
|
|
38
|
+
The owner of a channel is the candidate claiming it with the highest class
|
|
39
|
+
priority, ties broken by most-recently-admitted. A channel no behavior claims
|
|
40
|
+
maps to ``None``. A ``passive`` behavior (priority 0) therefore wins a channel
|
|
41
|
+
only when nothing non-passive claims it.
|
|
42
|
+
|
|
43
|
+
With ``contribs`` (a ``{behavior_id: Contribution}`` for this tick) the result
|
|
44
|
+
is **abstention-aware**: a claimant whose contribution leaves a channel ``None``
|
|
45
|
+
is skipped for that channel, so it falls through to the next-priority claimant
|
|
46
|
+
(a sound-reactive behavior with no sound yields the head back to ``feel-alive``
|
|
47
|
+
rather than freezing it). Without ``contribs`` (the default, used by
|
|
48
|
+
:func:`admit` and as a fallback) selection is purely claim-based, as before.
|
|
49
|
+
"""
|
|
50
|
+
owners: dict[str, Behavior | None] = dict.fromkeys(CHANNELS)
|
|
51
|
+
indexed = list(enumerate(behaviors))
|
|
52
|
+
for channel in CHANNELS:
|
|
53
|
+
candidates = [(i, b) for i, b in indexed if channel in b.channels]
|
|
54
|
+
if contribs is not None:
|
|
55
|
+
# A candidate with no contribution this tick (missing id, or a None for
|
|
56
|
+
# this channel) abstains -> it is skipped and the channel falls through.
|
|
57
|
+
candidates = [
|
|
58
|
+
(i, b)
|
|
59
|
+
for i, b in candidates
|
|
60
|
+
if (c := contribs.get(b.id)) is not None and c.channel(channel) is not None
|
|
61
|
+
]
|
|
62
|
+
if not candidates:
|
|
63
|
+
continue
|
|
64
|
+
_, best = max(candidates, key=lambda ib: (ib[1].stop_class.priority, ib[0]))
|
|
65
|
+
owners[channel] = best
|
|
66
|
+
return owners
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class AdmitResult:
|
|
71
|
+
"""Outcome of admitting a behavior.
|
|
72
|
+
|
|
73
|
+
``evicted`` are the (stoppable) behaviors a ``stopping`` add removed. ``blocked``
|
|
74
|
+
are the new behavior's channels it will *not* own yet because a higher-priority
|
|
75
|
+
incumbent holds them — informational, not a failure (the newcomer stays active
|
|
76
|
+
and takes the channel once the incumbent ends).
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
evicted: list[Behavior] = field(default_factory=list)
|
|
80
|
+
blocked: list[str] = field(default_factory=list)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def admit(new: Behavior, behaviors: list[Behavior]) -> AdmitResult:
|
|
84
|
+
"""Decide what admitting ``new`` removes, and which of its channels it must wait for.
|
|
85
|
+
|
|
86
|
+
``behaviors`` is the current live set (oldest-first). A ``passive`` newcomer
|
|
87
|
+
never removes anything and is expected to yield, so its ``blocked`` is left
|
|
88
|
+
empty. Any other newcomer that is ``stopping`` removes the ``stoppable``
|
|
89
|
+
behaviors it shares a channel with; ``blocked`` is then computed against the
|
|
90
|
+
prospective set with ``new`` as the most-recent entry.
|
|
91
|
+
"""
|
|
92
|
+
if new.stop_class is StopClass.PASSIVE:
|
|
93
|
+
return AdmitResult(evicted=[], blocked=[])
|
|
94
|
+
|
|
95
|
+
evicted: list[Behavior] = []
|
|
96
|
+
if new.stop_class is StopClass.STOPPING:
|
|
97
|
+
evicted = [
|
|
98
|
+
b
|
|
99
|
+
for b in behaviors
|
|
100
|
+
if b.stop_class is StopClass.STOPPABLE and (b.channels & new.channels)
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
evicted_ids = {b.id for b in evicted}
|
|
104
|
+
remaining = [b for b in behaviors if b.id not in evicted_ids]
|
|
105
|
+
owners = arbitrate([*remaining, new]) # new is newest -> wins same-priority ties
|
|
106
|
+
blocked = sorted(
|
|
107
|
+
channel
|
|
108
|
+
for channel in new.channels
|
|
109
|
+
if owners[channel] is None or owners[channel].id != new.id
|
|
110
|
+
)
|
|
111
|
+
return AdmitResult(evicted=evicted, blocked=blocked)
|