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.
Files changed (105) hide show
  1. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/CHANGELOG.md +25 -0
  2. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/PKG-INFO +7 -5
  3. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/README.md +4 -2
  4. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/arm101/cli/__init__.py +6 -0
  5. arm101_cli-0.6.0/arm101/cli/_commands/calibrate_motor.py +329 -0
  6. arm101_cli-0.6.0/arm101/cli/_commands/center_motor.py +182 -0
  7. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/arm101/cli/_commands/learn.py +42 -0
  8. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/arm101/cli/_commands/overview.py +3 -0
  9. arm101_cli-0.6.0/arm101/cli/_commands/set_motor_id.py +191 -0
  10. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/arm101/explain/catalog.py +85 -0
  11. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/arm101/hardware/__init__.py +1 -1
  12. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/arm101/hardware/bus.py +351 -15
  13. arm101_cli-0.6.0/arm101/hardware/motor_catalog.py +117 -0
  14. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/docs/hardware-validation.md +5 -4
  15. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/pyproject.toml +9 -4
  16. arm101_cli-0.6.0/tests/test_bus.py +374 -0
  17. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/tests/test_calibrate.py +1 -1
  18. arm101_cli-0.6.0/tests/test_calibrate_motor.py +249 -0
  19. arm101_cli-0.6.0/tests/test_center_motor.py +412 -0
  20. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/tests/test_cli.py +18 -0
  21. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/tests/test_hardware_verbs.py +19 -7
  22. arm101_cli-0.6.0/tests/test_motor_catalog.py +55 -0
  23. arm101_cli-0.6.0/tests/test_set_motor_id.py +408 -0
  24. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/uv.lock +6 -6
  25. arm101_cli-0.5.0/tests/test_bus.py +0 -196
  26. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills/agent-config/SKILL.md +0 -0
  27. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills/agent-config/data/backend-fingerprints.yaml +0 -0
  28. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills/agent-config/scripts/show.sh +0 -0
  29. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills/ask-colleague/SKILL.md +0 -0
  30. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills/ask-colleague/prompts/explore.md +0 -0
  31. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills/ask-colleague/prompts/review.md +0 -0
  32. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills/ask-colleague/prompts/write.md +0 -0
  33. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills/ask-colleague/scripts/ask-colleague.sh +0 -0
  34. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills/assign-to-workforce/SKILL.md +0 -0
  35. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills/assign-to-workforce/scripts/assign-to-workforce.sh +0 -0
  36. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills/cicd/SKILL.md +0 -0
  37. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills/cicd/scripts/_resolve-nick.sh +0 -0
  38. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills/cicd/scripts/portability-lint.sh +0 -0
  39. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills/cicd/scripts/pr-reply.sh +0 -0
  40. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills/cicd/scripts/pr-status.sh +0 -0
  41. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills/cicd/scripts/workflow.sh +0 -0
  42. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills/communicate/SKILL.md +0 -0
  43. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills/communicate/scripts/fetch-issues.sh +0 -0
  44. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills/communicate/scripts/mesh-message.sh +0 -0
  45. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills/communicate/scripts/post-comment.sh +0 -0
  46. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills/communicate/scripts/post-issue.sh +0 -0
  47. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills/communicate/scripts/templates/skill-new-brief.md +0 -0
  48. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills/communicate/scripts/templates/skill-update-brief.md +0 -0
  49. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills/doc-test-alignment/SKILL.md +0 -0
  50. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills/doc-test-alignment/scripts/check.sh +0 -0
  51. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills/pypi-maintainer/SKILL.md +0 -0
  52. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills/pypi-maintainer/scripts/switch-source.sh +0 -0
  53. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills/recall/SKILL.md +0 -0
  54. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills/recall/scripts/recall.sh +0 -0
  55. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills/remember/SKILL.md +0 -0
  56. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills/remember/scripts/remember.sh +0 -0
  57. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills/run-tests/SKILL.md +0 -0
  58. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills/run-tests/scripts/test.sh +0 -0
  59. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills/sonarclaude/SKILL.md +0 -0
  60. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills/sonarclaude/scripts/sonar.sh +0 -0
  61. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills/spec-to-plan/SKILL.md +0 -0
  62. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills/spec-to-plan/scripts/spec-to-plan.sh +0 -0
  63. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills/think/SKILL.md +0 -0
  64. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills/think/scripts/think.sh +0 -0
  65. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills/version-bump/SKILL.md +0 -0
  66. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills/version-bump/scripts/bump.py +0 -0
  67. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.claude/skills.local.yaml.example +0 -0
  68. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.devague/current +0 -0
  69. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.devague/current_plan +0 -0
  70. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.devague/frames/arm101-cli-grows-its-first-hardware-verbs-arm101-f.json +0 -0
  71. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.devague/plans/arm101-cli-grows-its-first-hardware-verbs-arm101-f.json +0 -0
  72. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.flake8 +0 -0
  73. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.github/workflows/publish.yml +0 -0
  74. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.github/workflows/tests.yml +0 -0
  75. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.gitignore +0 -0
  76. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/.markdownlint-cli2.yaml +0 -0
  77. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/AGENTS.colleague.md +0 -0
  78. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/CLAUDE.md +0 -0
  79. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/LICENSE +0 -0
  80. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/arm101/__init__.py +0 -0
  81. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/arm101/__main__.py +0 -0
  82. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/arm101/cli/_commands/__init__.py +0 -0
  83. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/arm101/cli/_commands/calibrate.py +0 -0
  84. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/arm101/cli/_commands/cli.py +0 -0
  85. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/arm101/cli/_commands/doctor.py +0 -0
  86. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/arm101/cli/_commands/explain.py +0 -0
  87. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/arm101/cli/_commands/find_port.py +0 -0
  88. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/arm101/cli/_commands/setup_motors.py +0 -0
  89. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/arm101/cli/_commands/whoami.py +0 -0
  90. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/arm101/cli/_errors.py +0 -0
  91. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/arm101/cli/_output.py +0 -0
  92. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/arm101/explain/__init__.py +0 -0
  93. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/arm101/hardware/ports.py +0 -0
  94. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/arm101/hardware/profiles.py +0 -0
  95. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/culture.yaml +0 -0
  96. {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
  97. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/docs/skill-sources.md +0 -0
  98. {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
  99. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/sonar-project.properties +0 -0
  100. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/tests/__init__.py +0 -0
  101. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/tests/test_cli_introspection.py +0 -0
  102. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/tests/test_find_port.py +0 -0
  103. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/tests/test_ports.py +0 -0
  104. {arm101_cli-0.5.0 → arm101_cli-0.6.0}/tests/test_profiles.py +0 -0
  105. {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.5.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 `[hardware]` extra:
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 hardware # or: pip install 'arm101-cli[hardware]'
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 `[hardware]` extra:
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 hardware # or: pip install 'arm101-cli[hardware]'
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)