arm101-cli 0.5.0__tar.gz → 0.6.0__tar.gz
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.
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/CHANGELOG.md +25 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/PKG-INFO +7 -5
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/README.md +4 -2
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/arm101/cli/__init__.py +6 -0
- arm101_cli-0.6.0/arm101/cli/_commands/calibrate_motor.py +329 -0
- arm101_cli-0.6.0/arm101/cli/_commands/center_motor.py +182 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/arm101/cli/_commands/learn.py +42 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/arm101/cli/_commands/overview.py +3 -0
- arm101_cli-0.6.0/arm101/cli/_commands/set_motor_id.py +191 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/arm101/explain/catalog.py +85 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/arm101/hardware/__init__.py +1 -1
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/arm101/hardware/bus.py +351 -15
- arm101_cli-0.6.0/arm101/hardware/motor_catalog.py +117 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/docs/hardware-validation.md +5 -4
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/pyproject.toml +9 -4
- arm101_cli-0.6.0/tests/test_bus.py +374 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/tests/test_calibrate.py +1 -1
- arm101_cli-0.6.0/tests/test_calibrate_motor.py +249 -0
- arm101_cli-0.6.0/tests/test_center_motor.py +412 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/tests/test_cli.py +18 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/tests/test_hardware_verbs.py +19 -7
- arm101_cli-0.6.0/tests/test_motor_catalog.py +55 -0
- arm101_cli-0.6.0/tests/test_set_motor_id.py +408 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/uv.lock +6 -6
- arm101_cli-0.5.0/tests/test_bus.py +0 -196
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills/agent-config/SKILL.md +0 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills/agent-config/data/backend-fingerprints.yaml +0 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills/agent-config/scripts/show.sh +0 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills/ask-colleague/SKILL.md +0 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills/ask-colleague/prompts/explore.md +0 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills/ask-colleague/prompts/review.md +0 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills/ask-colleague/prompts/write.md +0 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills/ask-colleague/scripts/ask-colleague.sh +0 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills/assign-to-workforce/SKILL.md +0 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills/assign-to-workforce/scripts/assign-to-workforce.sh +0 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills/cicd/SKILL.md +0 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills/cicd/scripts/_resolve-nick.sh +0 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills/cicd/scripts/portability-lint.sh +0 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills/cicd/scripts/pr-reply.sh +0 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills/cicd/scripts/pr-status.sh +0 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills/cicd/scripts/workflow.sh +0 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills/communicate/SKILL.md +0 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills/communicate/scripts/fetch-issues.sh +0 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills/communicate/scripts/mesh-message.sh +0 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills/communicate/scripts/post-comment.sh +0 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills/communicate/scripts/post-issue.sh +0 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills/communicate/scripts/templates/skill-new-brief.md +0 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills/communicate/scripts/templates/skill-update-brief.md +0 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills/doc-test-alignment/SKILL.md +0 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills/doc-test-alignment/scripts/check.sh +0 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills/pypi-maintainer/SKILL.md +0 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills/pypi-maintainer/scripts/switch-source.sh +0 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills/recall/SKILL.md +0 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills/recall/scripts/recall.sh +0 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills/remember/SKILL.md +0 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills/remember/scripts/remember.sh +0 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills/run-tests/SKILL.md +0 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills/run-tests/scripts/test.sh +0 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills/sonarclaude/SKILL.md +0 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills/sonarclaude/scripts/sonar.sh +0 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills/spec-to-plan/SKILL.md +0 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills/spec-to-plan/scripts/spec-to-plan.sh +0 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills/think/SKILL.md +0 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills/think/scripts/think.sh +0 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills/version-bump/SKILL.md +0 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills/version-bump/scripts/bump.py +0 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills.local.yaml.example +0 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.devague/current +0 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.devague/current_plan +0 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.devague/frames/arm101-cli-grows-its-first-hardware-verbs-arm101-f.json +0 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.devague/plans/arm101-cli-grows-its-first-hardware-verbs-arm101-f.json +0 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.flake8 +0 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.github/workflows/publish.yml +0 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.github/workflows/tests.yml +0 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.gitignore +0 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.markdownlint-cli2.yaml +0 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/AGENTS.colleague.md +0 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/CLAUDE.md +0 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/LICENSE +0 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/arm101/__init__.py +0 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/arm101/__main__.py +0 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/arm101/cli/_commands/__init__.py +0 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/arm101/cli/_commands/calibrate.py +0 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/arm101/cli/_commands/cli.py +0 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/arm101/cli/_commands/doctor.py +0 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/arm101/cli/_commands/explain.py +0 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/arm101/cli/_commands/find_port.py +0 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/arm101/cli/_commands/setup_motors.py +0 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/arm101/cli/_commands/whoami.py +0 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/arm101/cli/_errors.py +0 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/arm101/cli/_output.py +0 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/arm101/explain/__init__.py +0 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/arm101/hardware/ports.py +0 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/arm101/hardware/profiles.py +0 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/culture.yaml +0 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/docs/plans/2026-06-26-arm101-cli-grows-its-first-hardware-verbs-arm101-f.md +0 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/docs/skill-sources.md +0 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/docs/specs/2026-06-26-arm101-cli-grows-its-first-hardware-verbs-arm101-f.md +0 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/sonar-project.properties +0 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/tests/__init__.py +0 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/tests/test_cli_introspection.py +0 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/tests/test_find_port.py +0 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/tests/test_ports.py +0 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/tests/test_profiles.py +0 -0
- {arm101_cli-0.5.0 → arm101_cli-0.6.0}/tests/test_setup_motors.py +0 -0
|
@@ -5,6 +5,31 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
Format follows [Keep a Changelog](https://keepachangelog.com/). This project
|
|
6
6
|
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.6.0] - 2026-06-27
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- calibrate-motor verb: identify the single connected Feetech servo before assembly and catalog it — auto-detects the one motor (skipping busy/non-motor ports so it never grabs an unrelated device such as a Reachy daemon), verifies it is a Feetech STS3215 (model 777), shows its full read-only register snapshot, then records Servo Model / Gear Ratio / Corresponding Joint keyed by a motor label (F1..F6, L1..L6) into an XDG motor catalog. Read-only on the motor (no torque, motion, or EEPROM writes); manual and --auto (walk F1..F6 then L1..L6) modes. Validated live against a physical F1 follower motor.
|
|
13
|
+
- set-motor-id verb: assign a new EEPROM id (1-253) to the single connected motor — the SO-101 pre-assembly step of connecting motors one at a time to give each its joint's id. Hard-gated: requires a typed `yes`, and a non-interactive stdin (EOF) refuses the persistent write unconditionally (CliError exit 2). Reuses the existing FeetechBus.write_id_baudrate primitive.
|
|
14
|
+
- center-motor verb: drive the single connected motor to a known home position (default encoder tick 2048) for horn mounting, then relax torque (--keep-torque to leave it engaged). Commanded motion, hard-gated the same way (typed `yes`; EOF refuses to move). Adds enable_torque / write_goal_position primitives to the MotorBus interface (FeetechBus real impl at registers 40/42; FakeBus records torque/position writes in order for tests).
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- Renamed the optional install extra [hardware] → [seeed] (named after the Seeed Studio SO-101 kit; the kit currently ships Feetech servos, verified at runtime by model 777 so a future kit revision with a different servo vendor only updates the extra). Touches pyproject, README, docs, and the SDK install hint.
|
|
19
|
+
- Registered set-motor-id/center-motor and updated the explain catalog, overview verb list, and learn prompt in lockstep so the documentation surfaces agree.
|
|
20
|
+
- learn now documents the hardware prerequisite — the `[seeed]` SDK extra (`pip install 'arm101-cli[seeed]'`) and that set-motor-id/center-motor/setup-motors are gated destructive ops needing an interactive terminal — in both the text and `--json` (a `hardware` key), so a fresh install is self-sufficient from `learn` alone.
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- set-motor-id/center-motor now reject a non-TTY stdin up front (before opening the bus), so a piped `yes` can no longer drive a persistent EEPROM write or commanded motion non-interactively (Qodo: gated write/motion needs an interactive terminal).
|
|
25
|
+
- center-motor relaxes torque in a `finally` (unless `--keep-torque`) so a failed goal-position write never leaves the servo holding torque (Qodo: torque could remain enabled after an aborted move).
|
|
26
|
+
- write_id_baudrate writes the baud register before the id register, both at the motor's current id — writing id first changed the device address mid-call, so the later baud write hit a now-unreachable id (Qodo).
|
|
27
|
+
- set-motor-id/center-motor abort paths honour `--json` (emit a structured `aborted` payload instead of plain text) (Qodo).
|
|
28
|
+
- FeetechBus.scan sweeps the full 1–253 id space by default (no broadcastPing in this SDK build) so a motor previously re-id'd above 12 is still detected (Qodo).
|
|
29
|
+
- load_catalog rejects a non-object JSON root with a clean CliError instead of crashing on `.items()` (Qodo).
|
|
30
|
+
- Corrected the SDK install hint to the real distribution name `arm101-cli[seeed]` (was `arm101[seeed]`) (Qodo).
|
|
31
|
+
- Extracted three repeated CliError strings in bus.py to module constants (SonarCloud python:S1192).
|
|
32
|
+
|
|
8
33
|
## [0.5.0] - 2026-06-27
|
|
9
34
|
|
|
10
35
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: arm101-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.0
|
|
4
4
|
Summary: Agent and CLI for controlling SO-ARM101 robotic arm grippers
|
|
5
5
|
Project-URL: Homepage, https://github.com/agentculture/arm101-cli
|
|
6
6
|
Project-URL: Issues, https://github.com/agentculture/arm101-cli/issues
|
|
@@ -13,10 +13,10 @@ Classifier: License :: OSI Approved :: MIT License
|
|
|
13
13
|
Classifier: Programming Language :: Python :: 3.12
|
|
14
14
|
Classifier: Topic :: Software Development
|
|
15
15
|
Requires-Python: >=3.12
|
|
16
|
-
Provides-Extra: hardware
|
|
17
|
-
Requires-Dist: feetech-servo-sdk>=1.0; extra == 'hardware'
|
|
18
16
|
Provides-Extra: mac
|
|
19
17
|
Requires-Dist: pyserial>=3.5; extra == 'mac'
|
|
18
|
+
Provides-Extra: seeed
|
|
19
|
+
Requires-Dist: feetech-servo-sdk>=1.0; extra == 'seeed'
|
|
20
20
|
Provides-Extra: win
|
|
21
21
|
Requires-Dist: pyserial>=3.5; extra == 'win'
|
|
22
22
|
Description-Content-Type: text/markdown
|
|
@@ -44,10 +44,12 @@ Base install (zero third-party runtime dependencies — introspection only):
|
|
|
44
44
|
uv sync # or: pip install arm101-cli
|
|
45
45
|
```
|
|
46
46
|
|
|
47
|
-
For real Feetech STS3215 motor I/O, add the
|
|
47
|
+
For real Feetech STS3215 motor I/O on the Seeed Studio SO-101 kit, add the
|
|
48
|
+
`[seeed]` extra (named by the kit provider; the CLI verifies each connected
|
|
49
|
+
motor really is a Feetech STS3215 at runtime):
|
|
48
50
|
|
|
49
51
|
```bash
|
|
50
|
-
uv sync --extra
|
|
52
|
+
uv sync --extra seeed # or: pip install 'arm101-cli[seeed]'
|
|
51
53
|
```
|
|
52
54
|
|
|
53
55
|
This pulls in `feetech-servo-sdk` (import module `scservo_sdk`), which the bus
|
|
@@ -21,10 +21,12 @@ Base install (zero third-party runtime dependencies — introspection only):
|
|
|
21
21
|
uv sync # or: pip install arm101-cli
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
-
For real Feetech STS3215 motor I/O, add the
|
|
24
|
+
For real Feetech STS3215 motor I/O on the Seeed Studio SO-101 kit, add the
|
|
25
|
+
`[seeed]` extra (named by the kit provider; the CLI verifies each connected
|
|
26
|
+
motor really is a Feetech STS3215 at runtime):
|
|
25
27
|
|
|
26
28
|
```bash
|
|
27
|
-
uv sync --extra
|
|
29
|
+
uv sync --extra seeed # or: pip install 'arm101-cli[seeed]'
|
|
28
30
|
```
|
|
29
31
|
|
|
30
32
|
This pulls in `feetech-servo-sdk` (import module `scservo_sdk`), which the bus
|
|
@@ -63,12 +63,15 @@ def _argv_has_json(argv: list[str] | None) -> bool:
|
|
|
63
63
|
|
|
64
64
|
def _build_parser() -> argparse.ArgumentParser:
|
|
65
65
|
from arm101.cli._commands import calibrate as _calibrate_cmd
|
|
66
|
+
from arm101.cli._commands import calibrate_motor as _calibrate_motor_cmd
|
|
67
|
+
from arm101.cli._commands import center_motor as _center_motor_cmd
|
|
66
68
|
from arm101.cli._commands import cli as _cli_group
|
|
67
69
|
from arm101.cli._commands import doctor as _doctor_cmd
|
|
68
70
|
from arm101.cli._commands import explain as _explain_cmd
|
|
69
71
|
from arm101.cli._commands import find_port as _find_port_cmd
|
|
70
72
|
from arm101.cli._commands import learn as _learn_cmd
|
|
71
73
|
from arm101.cli._commands import overview as _overview_cmd
|
|
74
|
+
from arm101.cli._commands import set_motor_id as _set_motor_id_cmd
|
|
72
75
|
from arm101.cli._commands import setup_motors as _setup_motors_cmd
|
|
73
76
|
from arm101.cli._commands import whoami as _whoami_cmd
|
|
74
77
|
|
|
@@ -92,6 +95,9 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
92
95
|
_doctor_cmd.register(sub)
|
|
93
96
|
_find_port_cmd.register(sub)
|
|
94
97
|
_calibrate_cmd.register(sub)
|
|
98
|
+
_calibrate_motor_cmd.register(sub)
|
|
99
|
+
_set_motor_id_cmd.register(sub)
|
|
100
|
+
_center_motor_cmd.register(sub)
|
|
95
101
|
_setup_motors_cmd.register(sub)
|
|
96
102
|
_cli_group.register(sub)
|
|
97
103
|
# Register your own noun groups here:
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
"""``arm101 calibrate-motor`` — identify a single connected motor and catalog it.
|
|
2
|
+
|
|
3
|
+
Before an SO-101 is assembled, each Feetech servo is connected to the computer
|
|
4
|
+
**one at a time** and identified. This verb detects the single connected
|
|
5
|
+
STS3215 (auto-skipping busy or non-motor serial ports so it never grabs an
|
|
6
|
+
unrelated device such as a Reachy daemon), shows the operator the motor's full
|
|
7
|
+
read-only register snapshot, then records three operator-supplied spec fields —
|
|
8
|
+
**Servo Model**, **Gear Ratio**, and **Corresponding Joint** — keyed by a motor
|
|
9
|
+
label (``F1``..``F6`` follower, ``L1``..``L6`` leader) into the motor catalog
|
|
10
|
+
(:mod:`arm101.hardware.motor_catalog`).
|
|
11
|
+
|
|
12
|
+
It is **read-only on the motor**: it pings and reads registers, never enabling
|
|
13
|
+
torque, commanding motion, or writing EEPROM. The human is gated at every input
|
|
14
|
+
(label + the three fields); in ``--auto`` mode the CLI also gates on connecting
|
|
15
|
+
each motor in turn.
|
|
16
|
+
|
|
17
|
+
Two modes
|
|
18
|
+
---------
|
|
19
|
+
* **manual** (default): register the one motor currently connected. The label
|
|
20
|
+
is taken from the optional positional argument or prompted for.
|
|
21
|
+
* **automatic** (``--auto``): walk ``F1``..``F6`` then ``L1``..``L6``, prompting
|
|
22
|
+
the operator to connect each motor before registering it.
|
|
23
|
+
|
|
24
|
+
Bus injection seam
|
|
25
|
+
------------------
|
|
26
|
+
:func:`_open_bus` is monkeypatched in tests to inject a
|
|
27
|
+
:class:`~arm101.hardware.bus.FakeBus` without physical hardware.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import argparse
|
|
33
|
+
import os
|
|
34
|
+
import sys
|
|
35
|
+
|
|
36
|
+
from arm101.cli._errors import EXIT_ENV_ERROR, EXIT_USER_ERROR, CliError
|
|
37
|
+
from arm101.cli._output import emit_diagnostic, emit_result
|
|
38
|
+
from arm101.hardware import ports
|
|
39
|
+
from arm101.hardware.bus import FeetechBus, MotorBus
|
|
40
|
+
from arm101.hardware.motor_catalog import MotorEntry, catalog_path, save_entry
|
|
41
|
+
|
|
42
|
+
#: STS3215 model number — used to confirm a responding device is really a servo.
|
|
43
|
+
_STS3215_MODEL = 777
|
|
44
|
+
|
|
45
|
+
#: Automatic-mode walk order: follower F1..F6 then leader L1..L6.
|
|
46
|
+
_AUTO_LABELS = [f"F{i}" for i in range(1, 7)] + [f"L{i}" for i in range(1, 7)]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
# Bus injection seam (monkeypatched in tests)
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _open_bus(port: str) -> MotorBus:
|
|
55
|
+
"""Open and return a :class:`FeetechBus` for *port* (tests patch this)."""
|
|
56
|
+
bus = FeetechBus(port)
|
|
57
|
+
bus.open()
|
|
58
|
+
return bus
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
# Stdin prompting
|
|
63
|
+
# ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _prompt(message: str, *, default: str | None = None, required: bool = False) -> str:
|
|
67
|
+
"""Write *message* to stderr, read one line from stdin, return the answer.
|
|
68
|
+
|
|
69
|
+
EOF (no input) raises ``CliError(EXIT_ENV_ERROR)`` — this command is
|
|
70
|
+
interactive and must not hang or silently proceed. A blank answer falls
|
|
71
|
+
back to *default*; if *required* and still blank, raises a user error.
|
|
72
|
+
"""
|
|
73
|
+
suffix = f" [{default}]" if default else ""
|
|
74
|
+
emit_diagnostic(f"{message}{suffix}: ")
|
|
75
|
+
line = sys.stdin.readline()
|
|
76
|
+
if line == "": # EOF
|
|
77
|
+
raise CliError(
|
|
78
|
+
code=EXIT_ENV_ERROR,
|
|
79
|
+
message="No input available; calibrate-motor is interactive.",
|
|
80
|
+
remediation="Run it in a terminal and answer the prompts (or pipe answers via stdin).",
|
|
81
|
+
)
|
|
82
|
+
answer = line.strip()
|
|
83
|
+
if not answer and default is not None:
|
|
84
|
+
return default
|
|
85
|
+
if not answer and required:
|
|
86
|
+
raise CliError(
|
|
87
|
+
code=EXIT_USER_ERROR,
|
|
88
|
+
message=f"{message} is required.",
|
|
89
|
+
remediation="Re-run and provide a value.",
|
|
90
|
+
)
|
|
91
|
+
return answer
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ---------------------------------------------------------------------------
|
|
95
|
+
# Port + motor detection
|
|
96
|
+
# ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _candidate_ports() -> list[str]:
|
|
100
|
+
"""Real (symlink-resolved, de-duplicated) candidate serial ports."""
|
|
101
|
+
canon: list[str] = []
|
|
102
|
+
seen: set[str] = set()
|
|
103
|
+
for p in ports.enumerate_ports(): # raises CliError on macOS/Windows
|
|
104
|
+
real = os.path.realpath(p)
|
|
105
|
+
if real not in seen:
|
|
106
|
+
seen.add(real)
|
|
107
|
+
canon.append(real)
|
|
108
|
+
return canon
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _model_of(bus: MotorBus, motor: int) -> int | None:
|
|
112
|
+
"""Return *motor*'s model number, or None if it cannot be read."""
|
|
113
|
+
try:
|
|
114
|
+
return int(bus.read_info(motor).get("model", -1))
|
|
115
|
+
except CliError:
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _detect_one_motor(args: argparse.Namespace) -> tuple[MotorBus, str, int]:
|
|
120
|
+
"""Return an open bus, its port, and the single STS3215 motor ID found.
|
|
121
|
+
|
|
122
|
+
Ports that cannot be opened (busy — e.g. another robot's daemon) are
|
|
123
|
+
skipped, so an unrelated device never blocks detection. Exactly one motor
|
|
124
|
+
on exactly one port is required; anything else is a clear CliError.
|
|
125
|
+
"""
|
|
126
|
+
target = getattr(args, "port", None)
|
|
127
|
+
ports_to_try = [os.path.realpath(target)] if target else _candidate_ports()
|
|
128
|
+
|
|
129
|
+
matches: list[tuple[MotorBus, str, list[int]]] = []
|
|
130
|
+
for port in ports_to_try:
|
|
131
|
+
try:
|
|
132
|
+
bus = _open_bus(port)
|
|
133
|
+
except CliError:
|
|
134
|
+
continue # busy / unopenable — skip (ignores other devices)
|
|
135
|
+
sts_ids = [i for i in bus.scan() if _model_of(bus, i) == _STS3215_MODEL]
|
|
136
|
+
if sts_ids:
|
|
137
|
+
matches.append((bus, port, sts_ids))
|
|
138
|
+
else:
|
|
139
|
+
bus.close()
|
|
140
|
+
|
|
141
|
+
if not matches:
|
|
142
|
+
raise CliError(
|
|
143
|
+
code=EXIT_ENV_ERROR,
|
|
144
|
+
message="No STS3215 servo detected on any serial port.",
|
|
145
|
+
remediation=(
|
|
146
|
+
"Connect the motor and its external power (STS3215 needs V+, not USB "
|
|
147
|
+
"alone), then retry. Use --port to target a specific device."
|
|
148
|
+
),
|
|
149
|
+
)
|
|
150
|
+
if len(matches) > 1:
|
|
151
|
+
found_ports = [p for _, p, _ in matches]
|
|
152
|
+
for bus, _, _ in matches:
|
|
153
|
+
bus.close()
|
|
154
|
+
raise CliError(
|
|
155
|
+
code=EXIT_USER_ERROR,
|
|
156
|
+
message=f"Motors found on multiple ports: {found_ports}.",
|
|
157
|
+
remediation="Connect one motor at a time, or pass --port to choose.",
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
bus, port, ids = matches[0]
|
|
161
|
+
if len(ids) != 1:
|
|
162
|
+
bus.close()
|
|
163
|
+
raise CliError(
|
|
164
|
+
code=EXIT_USER_ERROR,
|
|
165
|
+
message=f"Expected exactly one motor on {port}, found ids {ids}.",
|
|
166
|
+
remediation="Connect a single motor at a time for per-motor registration.",
|
|
167
|
+
)
|
|
168
|
+
return bus, port, ids[0]
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# ---------------------------------------------------------------------------
|
|
172
|
+
# Presentation
|
|
173
|
+
# ---------------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _show_info(info: dict[str, int], port: str) -> None:
|
|
177
|
+
"""Emit the full read-only register snapshot to stderr for operator review."""
|
|
178
|
+
pos = info["present_position"]
|
|
179
|
+
verified = (
|
|
180
|
+
"Feetech STS3215 (model 777) ✓"
|
|
181
|
+
if info["model"] == _STS3215_MODEL
|
|
182
|
+
else f"UNKNOWN servo (model {info['model']}) ✗"
|
|
183
|
+
)
|
|
184
|
+
lines = [
|
|
185
|
+
"Detected motor (read-only — no torque, motion, or EEPROM writes):",
|
|
186
|
+
f" port : {port}",
|
|
187
|
+
f" verified : {verified}",
|
|
188
|
+
f" id : {info['id']}",
|
|
189
|
+
f" model : {info['model']}",
|
|
190
|
+
f" firmware : {info['firmware_major']}.{info['firmware_minor']}",
|
|
191
|
+
f" baud index : {info['baud_index']}",
|
|
192
|
+
f" present position : {pos} (~{pos * 360.0 / 4096.0:.1f} deg)",
|
|
193
|
+
f" angle limits : {info['min_angle']}..{info['max_angle']}",
|
|
194
|
+
f" torque enable : {'ON' if info['torque_enable'] else 'OFF'}",
|
|
195
|
+
f" voltage : {info['present_voltage'] / 10.0:.1f} V",
|
|
196
|
+
f" temperature : {info['present_temperature']} C",
|
|
197
|
+
f" load / speed : {info['present_load']} / {info['present_speed']}",
|
|
198
|
+
]
|
|
199
|
+
emit_diagnostic("\n".join(lines))
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _entry_payload(entry: MotorEntry, info: dict[str, int]) -> dict:
|
|
203
|
+
return {
|
|
204
|
+
"label": entry.label,
|
|
205
|
+
"servo_model": entry.servo_model,
|
|
206
|
+
"gear_ratio": entry.gear_ratio,
|
|
207
|
+
"joint": entry.joint,
|
|
208
|
+
"port": entry.port,
|
|
209
|
+
"recorded": entry.recorded,
|
|
210
|
+
"detected": {
|
|
211
|
+
"id": info["id"],
|
|
212
|
+
"model": info["model"],
|
|
213
|
+
"firmware": f"{info['firmware_major']}.{info['firmware_minor']}",
|
|
214
|
+
"voltage_v": round(info["present_voltage"] / 10.0, 1),
|
|
215
|
+
"temperature_c": info["present_temperature"],
|
|
216
|
+
"present_position": info["present_position"],
|
|
217
|
+
},
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
# ---------------------------------------------------------------------------
|
|
222
|
+
# Registration of one motor
|
|
223
|
+
# ---------------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _register_one(args: argparse.Namespace, label: str | None) -> dict:
|
|
227
|
+
"""Detect, display, gate on operator input, and catalog one motor."""
|
|
228
|
+
bus, port, motor_id = _detect_one_motor(args)
|
|
229
|
+
try:
|
|
230
|
+
info = bus.read_info(motor_id)
|
|
231
|
+
finally:
|
|
232
|
+
bus.close()
|
|
233
|
+
|
|
234
|
+
_show_info(info, port)
|
|
235
|
+
|
|
236
|
+
if not label:
|
|
237
|
+
label = _prompt("Which motor is this? (e.g. F1, L2)", required=True)
|
|
238
|
+
|
|
239
|
+
detected_model = f"STS3215 (model {info['model']})"
|
|
240
|
+
servo_model = _prompt("Servo Model", default=detected_model)
|
|
241
|
+
gear_ratio = _prompt("Gear Ratio (e.g. 1:191)", required=True)
|
|
242
|
+
joint = _prompt("Corresponding Joint (e.g. shoulder_pan)", required=True)
|
|
243
|
+
|
|
244
|
+
entry = save_entry(
|
|
245
|
+
MotorEntry(
|
|
246
|
+
label=label,
|
|
247
|
+
servo_model=servo_model,
|
|
248
|
+
gear_ratio=gear_ratio,
|
|
249
|
+
joint=joint,
|
|
250
|
+
detected_id=int(info["id"]),
|
|
251
|
+
detected_model=int(info["model"]),
|
|
252
|
+
port=port,
|
|
253
|
+
)
|
|
254
|
+
)
|
|
255
|
+
return _entry_payload(entry, info)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
# ---------------------------------------------------------------------------
|
|
259
|
+
# Command handler
|
|
260
|
+
# ---------------------------------------------------------------------------
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def cmd_calibrate_motor(args: argparse.Namespace) -> None:
|
|
264
|
+
"""Manual (one motor) or automatic (``--auto``, F1..F6 then L1..L6) registration."""
|
|
265
|
+
json_mode = bool(getattr(args, "json", False))
|
|
266
|
+
results: list[dict] = []
|
|
267
|
+
|
|
268
|
+
if getattr(args, "auto", False):
|
|
269
|
+
for label in _AUTO_LABELS:
|
|
270
|
+
answer = _prompt(
|
|
271
|
+
f"Connect the {label} motor ONLY, then press Enter (or 's' to skip, 'q' to finish)"
|
|
272
|
+
)
|
|
273
|
+
if answer.lower() == "q":
|
|
274
|
+
break
|
|
275
|
+
if answer.lower() == "s":
|
|
276
|
+
emit_diagnostic(f"Skipped {label}.")
|
|
277
|
+
continue
|
|
278
|
+
results.append(_register_one(args, label))
|
|
279
|
+
else:
|
|
280
|
+
results.append(_register_one(args, getattr(args, "label", None)))
|
|
281
|
+
|
|
282
|
+
_emit_results(results, json_mode=json_mode)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _emit_results(results: list[dict], *, json_mode: bool) -> None:
|
|
286
|
+
path = str(catalog_path())
|
|
287
|
+
if json_mode:
|
|
288
|
+
emit_result({"motors": results, "catalog": path}, json_mode=True)
|
|
289
|
+
return
|
|
290
|
+
lines = []
|
|
291
|
+
for r in results:
|
|
292
|
+
lines.append(
|
|
293
|
+
f"Registered {r['label']}: {r['servo_model']} | gear {r['gear_ratio']} "
|
|
294
|
+
f"| joint {r['joint']} (motor id {r['detected']['id']})"
|
|
295
|
+
)
|
|
296
|
+
if not lines:
|
|
297
|
+
lines.append("No motors registered.")
|
|
298
|
+
lines.append(f"Catalog: {path}")
|
|
299
|
+
emit_result("\n".join(lines), json_mode=False)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
# ---------------------------------------------------------------------------
|
|
303
|
+
# Registration
|
|
304
|
+
# ---------------------------------------------------------------------------
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def register(sub: "argparse._SubParsersAction") -> None:
|
|
308
|
+
"""Register the ``calibrate-motor`` subcommand on *sub*."""
|
|
309
|
+
p = sub.add_parser(
|
|
310
|
+
"calibrate-motor",
|
|
311
|
+
help="Identify a single connected motor (read-only) and record its model/gear/joint.",
|
|
312
|
+
)
|
|
313
|
+
p.add_argument(
|
|
314
|
+
"label",
|
|
315
|
+
nargs="?",
|
|
316
|
+
help="Motor label, e.g. F1 or L2 (manual mode). Omit to be prompted.",
|
|
317
|
+
)
|
|
318
|
+
p.add_argument(
|
|
319
|
+
"--auto",
|
|
320
|
+
action="store_true",
|
|
321
|
+
help="Automatic mode: walk F1..F6 then L1..L6, prompting to connect each.",
|
|
322
|
+
)
|
|
323
|
+
p.add_argument(
|
|
324
|
+
"--port",
|
|
325
|
+
default=None,
|
|
326
|
+
help="Serial port of the motor (default: auto-detect, skipping busy/non-motor ports).",
|
|
327
|
+
)
|
|
328
|
+
p.add_argument("--json", action="store_true", help="Emit structured JSON.")
|
|
329
|
+
p.set_defaults(func=cmd_calibrate_motor)
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""``arm101 center-motor`` — drive a single connected STS3215 servo to home position.
|
|
2
|
+
|
|
3
|
+
Before mounting a horn on a Feetech STS3215 servo, the motor must be parked at
|
|
4
|
+
its centre (default encoder tick 2048, mid-range of the 12-bit ``[0, 4095]``
|
|
5
|
+
scale, ≈180°) so the horn aligns with the known zero point. This verb detects
|
|
6
|
+
the single connected servo (reusing the calibrate-motor seam so busy or
|
|
7
|
+
non-motor ports are never grabbed), shows the operator a read-only snapshot,
|
|
8
|
+
then **gates hard** — a typed ``yes`` is required before any bus write occurs.
|
|
9
|
+
|
|
10
|
+
On confirmation the sequence is:
|
|
11
|
+
|
|
12
|
+
1. Enable torque (Torque_Enable register 40).
|
|
13
|
+
2. Command the goal position (Goal_Position register 42).
|
|
14
|
+
3. Relax torque (Torque_Enable → 0) — unless ``--keep-torque`` is given.
|
|
15
|
+
|
|
16
|
+
This is **commanded motion**. Running it without a clear workspace or without
|
|
17
|
+
the motor secured is a hardware risk. The gate guarantees that a non-interactive
|
|
18
|
+
invocation (e.g. a CI run with no stdin) can never silently move the motor.
|
|
19
|
+
|
|
20
|
+
Bus injection seam
|
|
21
|
+
------------------
|
|
22
|
+
:func:`~arm101.cli._commands.calibrate_motor._open_bus` and
|
|
23
|
+
:func:`~arm101.cli._commands.calibrate_motor._candidate_ports` are
|
|
24
|
+
monkeypatched on ``calibrate_motor`` in tests. Because
|
|
25
|
+
:func:`~arm101.cli._commands.calibrate_motor._detect_one_motor` (imported
|
|
26
|
+
here) resolves those names in its own module scope, patching ``calibrate_motor``
|
|
27
|
+
is sufficient — no re-export from this module is needed.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import argparse
|
|
33
|
+
import sys
|
|
34
|
+
|
|
35
|
+
from arm101.cli._commands.calibrate_motor import (
|
|
36
|
+
_detect_one_motor,
|
|
37
|
+
_prompt,
|
|
38
|
+
_show_info,
|
|
39
|
+
)
|
|
40
|
+
from arm101.cli._errors import EXIT_ENV_ERROR, CliError
|
|
41
|
+
from arm101.cli._output import emit_diagnostic, emit_result
|
|
42
|
+
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
# Gate helper
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _require_tty() -> None:
|
|
49
|
+
"""Refuse to run unless stdin is an interactive terminal.
|
|
50
|
+
|
|
51
|
+
Commanded motion must never be driven by piped/redirected input: a non-TTY
|
|
52
|
+
stdin could otherwise feed ``yes`` and satisfy the confirmation gate
|
|
53
|
+
non-interactively (a CI run or ``echo yes | …``). Checked before the bus is
|
|
54
|
+
opened so a non-interactive invocation touches no hardware.
|
|
55
|
+
"""
|
|
56
|
+
if not sys.stdin.isatty():
|
|
57
|
+
raise CliError(
|
|
58
|
+
code=EXIT_ENV_ERROR,
|
|
59
|
+
message="center-motor requires an interactive terminal (stdin is not a TTY).",
|
|
60
|
+
remediation=(
|
|
61
|
+
"This verb commands motor motion — run it without pipes or "
|
|
62
|
+
"redirects so the confirmation is answered by a human."
|
|
63
|
+
),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _confirm(question: str) -> bool:
|
|
68
|
+
"""Ask *question* via :func:`_prompt` and return True iff the answer is ``yes``.
|
|
69
|
+
|
|
70
|
+
A non-interactive stdin (EOF) causes :func:`_prompt` to raise
|
|
71
|
+
``CliError(EXIT_ENV_ERROR)`` — the motor is never moved silently.
|
|
72
|
+
"""
|
|
73
|
+
answer = _prompt(question)
|
|
74
|
+
return answer.strip().lower() == "yes"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
# Command handler
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def cmd_center_motor(args: argparse.Namespace) -> None:
|
|
83
|
+
"""Park the single connected STS3215 at the home position for horn mounting."""
|
|
84
|
+
json_mode = bool(getattr(args, "json", False))
|
|
85
|
+
target = int(getattr(args, "position", 2048))
|
|
86
|
+
keep_torque = bool(getattr(args, "keep_torque", False))
|
|
87
|
+
|
|
88
|
+
_require_tty()
|
|
89
|
+
|
|
90
|
+
bus, port, motor_id = _detect_one_motor(args)
|
|
91
|
+
try:
|
|
92
|
+
info = bus.read_info(motor_id)
|
|
93
|
+
_show_info(info, port)
|
|
94
|
+
|
|
95
|
+
deg = target * 360.0 / 4096.0
|
|
96
|
+
emit_diagnostic(
|
|
97
|
+
f"⚠ This will ENABLE TORQUE and MOVE motor {motor_id} on {port}"
|
|
98
|
+
f" to position {target} (~{deg:.0f}°). Clear the workspace."
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
confirmed = _confirm("Type 'yes' to proceed, anything else to abort")
|
|
102
|
+
if not confirmed:
|
|
103
|
+
if json_mode:
|
|
104
|
+
emit_result(
|
|
105
|
+
{
|
|
106
|
+
"aborted": True,
|
|
107
|
+
"motor": motor_id,
|
|
108
|
+
"port": port,
|
|
109
|
+
"position": target,
|
|
110
|
+
"moved": False,
|
|
111
|
+
},
|
|
112
|
+
json_mode=True,
|
|
113
|
+
)
|
|
114
|
+
else:
|
|
115
|
+
emit_result("Aborted; motor not moved.", json_mode=False)
|
|
116
|
+
return
|
|
117
|
+
|
|
118
|
+
bus.enable_torque(motor_id, True)
|
|
119
|
+
# Once torque is on, always relax it (unless --keep-torque) even if the
|
|
120
|
+
# goal-position write raises — otherwise a failed/aborted move would
|
|
121
|
+
# leave the servo holding torque.
|
|
122
|
+
try:
|
|
123
|
+
bus.write_goal_position(motor_id, target)
|
|
124
|
+
finally:
|
|
125
|
+
if not keep_torque:
|
|
126
|
+
bus.enable_torque(motor_id, False)
|
|
127
|
+
torque_relaxed = not keep_torque
|
|
128
|
+
|
|
129
|
+
finally:
|
|
130
|
+
bus.close()
|
|
131
|
+
|
|
132
|
+
torque_state = "relaxed" if torque_relaxed else "still enabled"
|
|
133
|
+
if json_mode:
|
|
134
|
+
emit_result(
|
|
135
|
+
{
|
|
136
|
+
"motor": motor_id,
|
|
137
|
+
"port": port,
|
|
138
|
+
"position": target,
|
|
139
|
+
"torque_relaxed": torque_relaxed,
|
|
140
|
+
},
|
|
141
|
+
json_mode=True,
|
|
142
|
+
)
|
|
143
|
+
else:
|
|
144
|
+
emit_result(
|
|
145
|
+
f"Centered motor {motor_id} to {target} on {port} (torque {torque_state}).",
|
|
146
|
+
json_mode=False,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# ---------------------------------------------------------------------------
|
|
151
|
+
# Registration
|
|
152
|
+
# ---------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def register(sub: "argparse._SubParsersAction") -> None:
|
|
156
|
+
"""Register the ``center-motor`` subcommand on *sub*."""
|
|
157
|
+
p = sub.add_parser(
|
|
158
|
+
"center-motor",
|
|
159
|
+
help=(
|
|
160
|
+
"Drive a single connected motor to the home position (default 2048) "
|
|
161
|
+
"for horn mounting. Gated: requires typed confirmation before moving."
|
|
162
|
+
),
|
|
163
|
+
)
|
|
164
|
+
p.add_argument(
|
|
165
|
+
"--port",
|
|
166
|
+
default=None,
|
|
167
|
+
help="Serial port of the motor (default: auto-detect, skipping busy/non-motor ports).",
|
|
168
|
+
)
|
|
169
|
+
p.add_argument(
|
|
170
|
+
"--position",
|
|
171
|
+
type=int,
|
|
172
|
+
default=2048,
|
|
173
|
+
help="Target encoder tick [0–4095] (default: 2048 = mid-range / home).",
|
|
174
|
+
)
|
|
175
|
+
p.add_argument(
|
|
176
|
+
"--keep-torque",
|
|
177
|
+
action="store_true",
|
|
178
|
+
default=False,
|
|
179
|
+
help="Leave torque enabled after centering (default: relax after move).",
|
|
180
|
+
)
|
|
181
|
+
p.add_argument("--json", action="store_true", help="Emit structured JSON.")
|
|
182
|
+
p.set_defaults(func=cmd_center_motor)
|