servomotor-mcp 0.1.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.
- servomotor_mcp-0.1.0/.github/workflows/publish.yml +42 -0
- servomotor_mcp-0.1.0/.gitignore +7 -0
- servomotor_mcp-0.1.0/LICENSE +21 -0
- servomotor_mcp-0.1.0/PKG-INFO +139 -0
- servomotor_mcp-0.1.0/README.md +108 -0
- servomotor_mcp-0.1.0/examples/claude_desktop_config.json +18 -0
- servomotor_mcp-0.1.0/examples/demo_prompts.md +35 -0
- servomotor_mcp-0.1.0/hardware_tests/README.md +73 -0
- servomotor_mcp-0.1.0/hardware_tests/RESULTS.txt +49 -0
- servomotor_mcp-0.1.0/hardware_tests/demo_live.py +78 -0
- servomotor_mcp-0.1.0/hardware_tests/discover_motor.py +69 -0
- servomotor_mcp-0.1.0/hardware_tests/hw_test_advanced.py +76 -0
- servomotor_mcp-0.1.0/hardware_tests/hw_test_basic.py +87 -0
- servomotor_mcp-0.1.0/hardware_tests/hw_test_endurance.py +63 -0
- servomotor_mcp-0.1.0/hardware_tests/hw_test_extended.py +91 -0
- servomotor_mcp-0.1.0/hardware_tests/hw_test_sequence.py +76 -0
- servomotor_mcp-0.1.0/hardware_tests/hw_test_serialbus.py +87 -0
- servomotor_mcp-0.1.0/mcpb/PACKAGING.md +37 -0
- servomotor_mcp-0.1.0/mcpb/manifest.json +67 -0
- servomotor_mcp-0.1.0/pyproject.toml +50 -0
- servomotor_mcp-0.1.0/server.json +45 -0
- servomotor_mcp-0.1.0/src/servomotor_mcp/__init__.py +20 -0
- servomotor_mcp-0.1.0/src/servomotor_mcp/__main__.py +4 -0
- servomotor_mcp-0.1.0/src/servomotor_mcp/motors.py +333 -0
- servomotor_mcp-0.1.0/src/servomotor_mcp/safety.py +113 -0
- servomotor_mcp-0.1.0/src/servomotor_mcp/sequencer.py +75 -0
- servomotor_mcp-0.1.0/src/servomotor_mcp/server.py +186 -0
- servomotor_mcp-0.1.0/tests/test_mock_bus.py +63 -0
- servomotor_mcp-0.1.0/tests/test_safety.py +66 -0
- servomotor_mcp-0.1.0/tests/test_sequencer.py +62 -0
- servomotor_mcp-0.1.0/uv.lock +1032 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
# Builds and publishes servomotor-mcp to PyPI on a version tag (e.g. v0.1.0),
|
|
4
|
+
# using PyPI Trusted Publishing (OIDC) — no API token needed.
|
|
5
|
+
#
|
|
6
|
+
# ONE-TIME SETUP (do before the first tag):
|
|
7
|
+
# On PyPI → the servomotor-mcp project → Settings → Publishing → add a "pending publisher":
|
|
8
|
+
# Owner: Gearotons
|
|
9
|
+
# Repository: servomotor-mcp
|
|
10
|
+
# Workflow: publish.yml
|
|
11
|
+
# Environment: pypi (matches the `environment:` below; optional but recommended)
|
|
12
|
+
# (If the existing `servomotor` project uses an API token instead, you can swap this for that
|
|
13
|
+
# method — see DEPLOY_INSTRUCTIONS.md. Trusted publishing is preferred.)
|
|
14
|
+
#
|
|
15
|
+
# RELEASE: git tag v0.1.0 && git push --tags (or run this workflow manually)
|
|
16
|
+
|
|
17
|
+
on:
|
|
18
|
+
push:
|
|
19
|
+
tags: ["v*"]
|
|
20
|
+
workflow_dispatch:
|
|
21
|
+
|
|
22
|
+
jobs:
|
|
23
|
+
build-and-publish:
|
|
24
|
+
runs-on: ubuntu-latest
|
|
25
|
+
environment: pypi
|
|
26
|
+
permissions:
|
|
27
|
+
id-token: write # required for trusted publishing
|
|
28
|
+
steps:
|
|
29
|
+
- uses: actions/checkout@v4
|
|
30
|
+
- uses: actions/setup-python@v5
|
|
31
|
+
with:
|
|
32
|
+
python-version: "3.11"
|
|
33
|
+
- name: Build sdist + wheel
|
|
34
|
+
run: |
|
|
35
|
+
python -m pip install --upgrade build
|
|
36
|
+
python -m build
|
|
37
|
+
- name: Verify the build
|
|
38
|
+
run: |
|
|
39
|
+
python -m pip install --upgrade twine
|
|
40
|
+
twine check dist/*
|
|
41
|
+
- name: Publish to PyPI (trusted publishing)
|
|
42
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Gearotons
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: servomotor-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server to drive Gearotons M17 open-source servomotors from natural language (Claude / any MCP client) over RS-485.
|
|
5
|
+
Project-URL: Homepage, https://gearotons.com
|
|
6
|
+
Project-URL: Repository, https://github.com/Gearotons/servomotor-mcp
|
|
7
|
+
Project-URL: Issues, https://github.com/Gearotons/servomotor-mcp/issues
|
|
8
|
+
Project-URL: Documentation, https://github.com/Gearotons/servomotor-mcp#readme
|
|
9
|
+
Author: Gearotons
|
|
10
|
+
License: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: ai,claude,mcp,nema17,open-source,robotics,rs485,servomotor
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Intended Audience :: Manufacturing
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Topic :: Scientific/Engineering
|
|
23
|
+
Classifier: Topic :: System :: Hardware
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Requires-Dist: mcp>=1.2.0
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
28
|
+
Provides-Extra: serial
|
|
29
|
+
Requires-Dist: servomotor; extra == 'serial'
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# servomotor-mcp
|
|
33
|
+
|
|
34
|
+
**Drive open-source [Gearotons M17](https://gearotons.com) servomotors from natural language.**
|
|
35
|
+
|
|
36
|
+
An [MCP](https://modelcontextprotocol.io) server that exposes the M17 — a NEMA-17
|
|
37
|
+
integrated, closed-loop, RS-485 servomotor — as a small set of safe, high-level tools.
|
|
38
|
+
Connect it to Claude Desktop, Claude Code, or any MCP client and control real motors by
|
|
39
|
+
just *asking*:
|
|
40
|
+
|
|
41
|
+
> "Home everything, then draw a square with the X and Y axes."
|
|
42
|
+
|
|
43
|
+
It ships with a **mock backend**, so you can try the whole thing with **no hardware**.
|
|
44
|
+
|
|
45
|
+
> The first servomotor with an official MCP server. Open hardware, open firmware, open
|
|
46
|
+
> software — and now an open, AI-native control interface.
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## Quickstart (no hardware, ~2 minutes)
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
# Run the server directly with uv (recommended):
|
|
54
|
+
uvx --from servomotor-mcp servomotor-mcp
|
|
55
|
+
|
|
56
|
+
# or install it:
|
|
57
|
+
pip install servomotor-mcp
|
|
58
|
+
servomotor-mcp
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Then add it to **Claude Desktop** — copy the block from
|
|
62
|
+
[`examples/claude_desktop_config.json`](examples/claude_desktop_config.json) into your
|
|
63
|
+
`claude_desktop_config.json`, restart Claude Desktop, and ask:
|
|
64
|
+
*"What motors do you have connected?"* See [`examples/demo_prompts.md`](examples/demo_prompts.md)
|
|
65
|
+
for the full scripted demo.
|
|
66
|
+
|
|
67
|
+
## Drive real motors
|
|
68
|
+
|
|
69
|
+
Plug an M17 (or a daisy-chain of them) into a USB↔RS-485 adapter and switch the backend:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
pip install 'servomotor-mcp[serial]' # pulls in the Gearotons servomotor library
|
|
73
|
+
GEAROTONS_MOTOR_BACKEND=serial \
|
|
74
|
+
GEAROTONS_SERIAL_PORT=/dev/ttyUSB0 \
|
|
75
|
+
servomotor-mcp
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Tools
|
|
79
|
+
|
|
80
|
+
| Tool | What it does |
|
|
81
|
+
|---|---|
|
|
82
|
+
| `list_motors` | Discover motors on the bus + positions (call first). |
|
|
83
|
+
| `move_to` | Move a motor to an absolute angle (closed-loop). |
|
|
84
|
+
| `move_relative` | Nudge a motor by a delta. |
|
|
85
|
+
| `trapezoid_move` | Smooth accel/decel move (best for arms/plotters). |
|
|
86
|
+
| `home` | Home one or all motors. |
|
|
87
|
+
| `get_status` | Position / moving / voltage / errors. |
|
|
88
|
+
| `stop` | Halt one or all motors. |
|
|
89
|
+
| `reset` | Recover a motor from a latched fault (firmware system reset, ~2 s). |
|
|
90
|
+
| `run_sequence` | Run a choreographed sequence ("draw a square"). |
|
|
91
|
+
|
|
92
|
+
## Safety rails (in the server, not the model)
|
|
93
|
+
|
|
94
|
+
The LLM can hallucinate a tool call; these make that safe — they run on every request:
|
|
95
|
+
|
|
96
|
+
- **Alias allow-list** — only configured motors can be driven.
|
|
97
|
+
- **Position limits** — absolute/relative targets are clamped to a per-motor software
|
|
98
|
+
range, so a bad command can't drive an axis into a hard stop.
|
|
99
|
+
- **Speed clamp** — requested speeds are capped.
|
|
100
|
+
|
|
101
|
+
Clamps are *reported back* to the model (in `safety_notes`) rather than silently applied,
|
|
102
|
+
so it can see what actually happened and adjust. Configure via env:
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
GEAROTONS_MOTOR_ALIASES="x,y,z"
|
|
106
|
+
GEAROTONS_LIMITS='{"x":{"min_deg":-180,"max_deg":180,"max_speed":300},"z":{"min_deg":0,"max_deg":90}}'
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## How it works
|
|
110
|
+
|
|
111
|
+
```
|
|
112
|
+
natural language → Claude → MCP tool calls → this server → RS-485 → M17 motors
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
The server is a thin, safety-checked wrapper over the Gearotons `servomotor` Python
|
|
116
|
+
library (high-level, unit-aware commands — no DIR/STEP timing). The same intents run
|
|
117
|
+
against the mock backend for development and CI.
|
|
118
|
+
|
|
119
|
+
## Develop / test
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
pip install -e '.[dev]'
|
|
123
|
+
pytest # safety + mock-bus tests, no hardware needed
|
|
124
|
+
GEAROTONS_MOCK_SIM_SECONDS=0.4 servomotor-mcp # lifelike timing for demos
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Status
|
|
128
|
+
|
|
129
|
+
- ✅ MCP server, full tool surface, safety rails, mock backend — done; 16 unit tests pass.
|
|
130
|
+
- ✅ `SerialBus` real-hardware backend — **verified against a physical M17** (fw 0.15.0.0):
|
|
131
|
+
discovery, telemetry, closed-loop moves at **0.000° repeatability**, and a live safety
|
|
132
|
+
clamp (a 200° command on a ±90° motor stopped at 90°). See [`hardware_tests/`](hardware_tests/).
|
|
133
|
+
- 🔜 Live FastMCP-over-stdio run with Claude Desktop needs a Python ≥3.10 host (tool logic
|
|
134
|
+
already hardware-verified); plus the physical demo build + video. See [demo spec](../SPEC.md).
|
|
135
|
+
|
|
136
|
+
## License
|
|
137
|
+
|
|
138
|
+
MIT. Hardware, firmware, and software for the M17 are open-source —
|
|
139
|
+
see [github.com/tomrodinger/servomotor](https://github.com/tomrodinger/servomotor).
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# servomotor-mcp
|
|
2
|
+
|
|
3
|
+
**Drive open-source [Gearotons M17](https://gearotons.com) servomotors from natural language.**
|
|
4
|
+
|
|
5
|
+
An [MCP](https://modelcontextprotocol.io) server that exposes the M17 — a NEMA-17
|
|
6
|
+
integrated, closed-loop, RS-485 servomotor — as a small set of safe, high-level tools.
|
|
7
|
+
Connect it to Claude Desktop, Claude Code, or any MCP client and control real motors by
|
|
8
|
+
just *asking*:
|
|
9
|
+
|
|
10
|
+
> "Home everything, then draw a square with the X and Y axes."
|
|
11
|
+
|
|
12
|
+
It ships with a **mock backend**, so you can try the whole thing with **no hardware**.
|
|
13
|
+
|
|
14
|
+
> The first servomotor with an official MCP server. Open hardware, open firmware, open
|
|
15
|
+
> software — and now an open, AI-native control interface.
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Quickstart (no hardware, ~2 minutes)
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
# Run the server directly with uv (recommended):
|
|
23
|
+
uvx --from servomotor-mcp servomotor-mcp
|
|
24
|
+
|
|
25
|
+
# or install it:
|
|
26
|
+
pip install servomotor-mcp
|
|
27
|
+
servomotor-mcp
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Then add it to **Claude Desktop** — copy the block from
|
|
31
|
+
[`examples/claude_desktop_config.json`](examples/claude_desktop_config.json) into your
|
|
32
|
+
`claude_desktop_config.json`, restart Claude Desktop, and ask:
|
|
33
|
+
*"What motors do you have connected?"* See [`examples/demo_prompts.md`](examples/demo_prompts.md)
|
|
34
|
+
for the full scripted demo.
|
|
35
|
+
|
|
36
|
+
## Drive real motors
|
|
37
|
+
|
|
38
|
+
Plug an M17 (or a daisy-chain of them) into a USB↔RS-485 adapter and switch the backend:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install 'servomotor-mcp[serial]' # pulls in the Gearotons servomotor library
|
|
42
|
+
GEAROTONS_MOTOR_BACKEND=serial \
|
|
43
|
+
GEAROTONS_SERIAL_PORT=/dev/ttyUSB0 \
|
|
44
|
+
servomotor-mcp
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Tools
|
|
48
|
+
|
|
49
|
+
| Tool | What it does |
|
|
50
|
+
|---|---|
|
|
51
|
+
| `list_motors` | Discover motors on the bus + positions (call first). |
|
|
52
|
+
| `move_to` | Move a motor to an absolute angle (closed-loop). |
|
|
53
|
+
| `move_relative` | Nudge a motor by a delta. |
|
|
54
|
+
| `trapezoid_move` | Smooth accel/decel move (best for arms/plotters). |
|
|
55
|
+
| `home` | Home one or all motors. |
|
|
56
|
+
| `get_status` | Position / moving / voltage / errors. |
|
|
57
|
+
| `stop` | Halt one or all motors. |
|
|
58
|
+
| `reset` | Recover a motor from a latched fault (firmware system reset, ~2 s). |
|
|
59
|
+
| `run_sequence` | Run a choreographed sequence ("draw a square"). |
|
|
60
|
+
|
|
61
|
+
## Safety rails (in the server, not the model)
|
|
62
|
+
|
|
63
|
+
The LLM can hallucinate a tool call; these make that safe — they run on every request:
|
|
64
|
+
|
|
65
|
+
- **Alias allow-list** — only configured motors can be driven.
|
|
66
|
+
- **Position limits** — absolute/relative targets are clamped to a per-motor software
|
|
67
|
+
range, so a bad command can't drive an axis into a hard stop.
|
|
68
|
+
- **Speed clamp** — requested speeds are capped.
|
|
69
|
+
|
|
70
|
+
Clamps are *reported back* to the model (in `safety_notes`) rather than silently applied,
|
|
71
|
+
so it can see what actually happened and adjust. Configure via env:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
GEAROTONS_MOTOR_ALIASES="x,y,z"
|
|
75
|
+
GEAROTONS_LIMITS='{"x":{"min_deg":-180,"max_deg":180,"max_speed":300},"z":{"min_deg":0,"max_deg":90}}'
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## How it works
|
|
79
|
+
|
|
80
|
+
```
|
|
81
|
+
natural language → Claude → MCP tool calls → this server → RS-485 → M17 motors
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
The server is a thin, safety-checked wrapper over the Gearotons `servomotor` Python
|
|
85
|
+
library (high-level, unit-aware commands — no DIR/STEP timing). The same intents run
|
|
86
|
+
against the mock backend for development and CI.
|
|
87
|
+
|
|
88
|
+
## Develop / test
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
pip install -e '.[dev]'
|
|
92
|
+
pytest # safety + mock-bus tests, no hardware needed
|
|
93
|
+
GEAROTONS_MOCK_SIM_SECONDS=0.4 servomotor-mcp # lifelike timing for demos
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Status
|
|
97
|
+
|
|
98
|
+
- ✅ MCP server, full tool surface, safety rails, mock backend — done; 16 unit tests pass.
|
|
99
|
+
- ✅ `SerialBus` real-hardware backend — **verified against a physical M17** (fw 0.15.0.0):
|
|
100
|
+
discovery, telemetry, closed-loop moves at **0.000° repeatability**, and a live safety
|
|
101
|
+
clamp (a 200° command on a ±90° motor stopped at 90°). See [`hardware_tests/`](hardware_tests/).
|
|
102
|
+
- 🔜 Live FastMCP-over-stdio run with Claude Desktop needs a Python ≥3.10 host (tool logic
|
|
103
|
+
already hardware-verified); plus the physical demo build + video. See [demo spec](../SPEC.md).
|
|
104
|
+
|
|
105
|
+
## License
|
|
106
|
+
|
|
107
|
+
MIT. Hardware, firmware, and software for the M17 are open-source —
|
|
108
|
+
see [github.com/tomrodinger/servomotor](https://github.com/tomrodinger/servomotor).
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"//": "Add this block to Claude Desktop's MCP config (claude_desktop_config.json).",
|
|
3
|
+
"//1": "macOS: ~/Library/Application Support/Claude/claude_desktop_config.json",
|
|
4
|
+
"//2": "Restart Claude Desktop after editing. Mock backend = no hardware needed.",
|
|
5
|
+
"mcpServers": {
|
|
6
|
+
"gearotons-motor": {
|
|
7
|
+
"command": "uvx",
|
|
8
|
+
"args": ["--from", "servomotor-mcp", "servomotor-mcp"],
|
|
9
|
+
"env": {
|
|
10
|
+
"GEAROTONS_MOTOR_BACKEND": "mock",
|
|
11
|
+
"GEAROTONS_MOTOR_ALIASES": "x,y,z",
|
|
12
|
+
"GEAROTONS_MOCK_SIM_SECONDS": "0.4",
|
|
13
|
+
"GEAROTONS_LIMITS": "{\"x\": {\"min_deg\": -180, \"max_deg\": 180, \"max_speed\": 300}, \"y\": {\"min_deg\": -180, \"max_deg\": 180}, \"z\": {\"min_deg\": 0, \"max_deg\": 90}}"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"//real-hardware": "To drive real motors, set GEAROTONS_MOTOR_BACKEND=serial and GEAROTONS_SERIAL_PORT=/dev/ttyUSB0, and install with the [serial] extra."
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Demo prompts — talking to the M17 motors
|
|
2
|
+
|
|
3
|
+
These are the natural-language prompts for the demo video / live demo, once the server is
|
|
4
|
+
connected to Claude Desktop (or any MCP client). With the mock backend they run with no
|
|
5
|
+
hardware; with the serial backend they drive real motors. The point: **no API knowledge,
|
|
6
|
+
no code — just plain English.**
|
|
7
|
+
|
|
8
|
+
## Warm-up (proves discovery + control)
|
|
9
|
+
1. "What motors do you have connected?"
|
|
10
|
+
→ calls `list_motors`, names x / y / z and their positions.
|
|
11
|
+
2. "Home everything."
|
|
12
|
+
→ `home` (all).
|
|
13
|
+
3. "Point the X axis to 90 degrees, smoothly over 2 seconds."
|
|
14
|
+
→ `trapezoid_move(x, 90, 2)`.
|
|
15
|
+
4. "Where is everything right now?"
|
|
16
|
+
→ `get_status`.
|
|
17
|
+
|
|
18
|
+
## The headline shot — "draw a square"
|
|
19
|
+
5. "Draw a square: move X and Y to trace a 90-degree box, then come back to the start."
|
|
20
|
+
→ Claude composes a `run_sequence` of `move_to`/`trapezoid_move` steps. This is the
|
|
21
|
+
clip that goes in the launch posts — one sentence, real motion.
|
|
22
|
+
|
|
23
|
+
## Show the safety rails (great for the technical audience / HN comments)
|
|
24
|
+
6. "Spin Z to 300 degrees."
|
|
25
|
+
→ Z is limited to 0–90°; the move is **clamped to 90°** and Claude reports the clamp
|
|
26
|
+
from `safety_notes` ("I limited that to 90° because Z's configured range is 0–90").
|
|
27
|
+
Demonstrates that the guardrails live in the server, not the model's goodwill.
|
|
28
|
+
|
|
29
|
+
## The "talk to your hardware" moment
|
|
30
|
+
7. "Wave hello with the Z axis."
|
|
31
|
+
→ Claude improvises a small back-and-forth `run_sequence`. Good, human closer.
|
|
32
|
+
|
|
33
|
+
> Recording notes: pen plotter reads best on camera (see SPEC.md). Keep the terminal /
|
|
34
|
+
> Claude transcript on screen next to the hardware so viewers see prompt → tool call →
|
|
35
|
+
> motion. ~60–90s total. End on the square.
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# Hardware tests — Gearotons M17 MCP server
|
|
2
|
+
|
|
3
|
+
Scripts that validate the MCP server's motor backend against a **real M17** over RS-485.
|
|
4
|
+
All four passed on 2026-06-08 against a physical motor (fw 0.15.0.0) — see results below and
|
|
5
|
+
`../../../logs/hardware-2026-06-08.md`.
|
|
6
|
+
|
|
7
|
+
## Setup
|
|
8
|
+
|
|
9
|
+
`pyserial` is the only runtime dep for these (already present on the test machine). Point
|
|
10
|
+
`PYTHONPATH` at the open-source `servomotor` library + the MCP package `src/`:
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
export SERVOMOTOR=/Users/sandbox1/servomotor/python_programs # or: pip install servomotor
|
|
14
|
+
export PKG=../src
|
|
15
|
+
cd hardware_tests
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
The motor was auto-discovered on `/dev/cu.usbserial-210`, device **alias 88**. Adjust the
|
|
19
|
+
port/alias args if yours differ.
|
|
20
|
+
|
|
21
|
+
## The tests
|
|
22
|
+
|
|
23
|
+
| Script | What it proves | Needs |
|
|
24
|
+
|---|---|---|
|
|
25
|
+
| `discover_motor.py` | Finds which of the connected RS-485 adapters has a motor; reports unique ID + alias. | servomotor |
|
|
26
|
+
| `hw_test_basic.py` | Read-only telemetry (product/fw/status/voltage/temp/position) + one bounded ±18° move. | servomotor |
|
|
27
|
+
| `hw_test_serialbus.py` | The **MCP tool code path** (SafetyPolicy clamp → `SerialBus`), incl. a live safety clamp. | servomotor + `src/` |
|
|
28
|
+
| `hw_test_advanced.py` | `identify` LED blink + 12-target repeatability + a "wave" demo routine. | servomotor + `src/` |
|
|
29
|
+
| `hw_test_extended.py` | PID-error readout, bounded velocity move, throughput timing, enable/disable reliability. | servomotor + `src/` |
|
|
30
|
+
| `hw_test_sequence.py` | The `run_sequence` engine (the "draw"/"wave" path): a draw routine, a sequence-level clamp, and abort-on-disallowed-motor. | servomotor + `src/` |
|
|
31
|
+
| `hw_test_endurance.py` | N bounded moves with temperature/voltage/error sampling — reliability + thermal data. | servomotor + `src/` |
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
PYTHONPATH=$SERVOMOTOR python3 discover_motor.py
|
|
35
|
+
PYTHONPATH=$SERVOMOTOR python3 hw_test_basic.py
|
|
36
|
+
PYTHONPATH=$PKG:$SERVOMOTOR python3 hw_test_serialbus.py
|
|
37
|
+
PYTHONPATH=$PKG:$SERVOMOTOR python3 hw_test_advanced.py
|
|
38
|
+
PYTHONPATH=$PKG:$SERVOMOTOR python3 hw_test_extended.py
|
|
39
|
+
PYTHONPATH=$PKG:$SERVOMOTOR python3 hw_test_sequence.py
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Verified results (2026-06-08, physical M17)
|
|
43
|
+
|
|
44
|
+
- **Discovery:** motor on `/dev/cu.usbserial-210`, unique_id `0x99856389A2B46555`, alias 88;
|
|
45
|
+
other three adapters empty.
|
|
46
|
+
- **Telemetry:** M17, fw **0.15.0.0**, status `[0,0]` (healthy), supply **20.0 V**, **35 °C**.
|
|
47
|
+
- **Bounded move:** +18° → read back 17.9999°, return → 0.000° net drift.
|
|
48
|
+
- **SerialBus + safety:** absolute moves exact; **live clamp** — commanded 200° on a ±90°-
|
|
49
|
+
limited motor → physically stopped at **90.00°** with the clamp note surfaced. ✅
|
|
50
|
+
- **Repeatability:** 12 random targets across ±80° → **0.000° mean & max error** (tol 0.5°).
|
|
51
|
+
- **Demo routine + LED identify:** clean.
|
|
52
|
+
- **Extended:** 20-move throughput 2.87 moves/s @ 0.000° worst error; 5/5 enable/disable
|
|
53
|
+
cycles clean. (Findings: `move_with_velocity` needs unit calibration before exposing a spin
|
|
54
|
+
tool; `get_max_pid_error` returned reset sentinels — read it after a move run for a real
|
|
55
|
+
number. Neither affects the 8 shipped MCP tools.)
|
|
56
|
+
|
|
57
|
+
Every test force-disables the MOSFETs and closes the port in a `finally` block.
|
|
58
|
+
|
|
59
|
+
## Unit tests (no hardware)
|
|
60
|
+
|
|
61
|
+
The mock backend + safety rails + sequencer are covered by `../tests/` (21 tests):
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
PYTHONPATH=../src python3 -m pytest -q ../tests # 21 passed
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Note on the live MCP-over-stdio test
|
|
68
|
+
|
|
69
|
+
Running the actual FastMCP server end-to-end (MCP client → server → motor) needs the `mcp`
|
|
70
|
+
SDK, which requires **Python ≥3.10**; the test machine has 3.9, so that step is deferred to
|
|
71
|
+
a 3.10+ host (e.g. the dev machine). It is low-risk: `hw_test_serialbus.py` already exercises
|
|
72
|
+
the identical tool logic (clamp → `SerialBus`) on hardware — only generic FastMCP transport
|
|
73
|
+
plumbing is left unverified.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# Gearotons M17 MCP server — consolidated test results
|
|
2
|
+
# Generated 2026-06-08 against the real motor on /dev/cu.usbserial-210 (alias 88)
|
|
3
|
+
|
|
4
|
+
## Unit tests (mock + safety; no hardware, no mcp)
|
|
5
|
+
................ [100%]
|
|
6
|
+
16 passed in 0.02s
|
|
7
|
+
|
|
8
|
+
## Hardware: basic telemetry + bounded move
|
|
9
|
+
[ERR] ping (echoes 10-byte payload): SystemExit: 1
|
|
10
|
+
[ok ] product_info: ['M17 ', 3, [0, 5, 1], 1330, 11062357502496892245, 0]
|
|
11
|
+
[ok ] firmware_version: [[0, 0, 15, 0], 0]
|
|
12
|
+
[ok ] product_description: 'Servomotor\x00'
|
|
13
|
+
[ok ] status: [0, 0]
|
|
14
|
+
[ok ] supply_voltage: 20200.0
|
|
15
|
+
[ok ] temperature: 36
|
|
16
|
+
[ok ] position (deg): -14.499975585937499
|
|
17
|
+
[ok ] comprehensive_position: [-14.499975585937499, -10.693981933593749, 0.0]
|
|
18
|
+
[ok ] position after +18 deg: 3.5000244140624996
|
|
19
|
+
[ok ] position after return: -14.499975585937499
|
|
20
|
+
net displacement: +0.000 deg (expect ~0)
|
|
21
|
+
MOTION TEST PASSED
|
|
22
|
+
|
|
23
|
+
## Hardware: SerialBus + safety clamp (MCP tool path)
|
|
24
|
+
move_to(x, 45) -> pos=45.00° v=20.2V
|
|
25
|
+
move_to(x, 0) -> pos=0.00° v=20.2V
|
|
26
|
+
move_to(x, -30) -> pos=-30.00° v=20.2V
|
|
27
|
+
--- move_relative (tool path) ---
|
|
28
|
+
move_relative(+15) -> pos=-15.00°
|
|
29
|
+
--- LIVE SAFETY CLAMP: command 200° on a motor limited to 90° ---
|
|
30
|
+
move_to(x, 200) -> pos=90.00° v=20.2V | ['target 200° clamped to 90° (range -90..90)']
|
|
31
|
+
motor stopped at 90.00° (<=90 expected) -> CLAMP WORKED ✅
|
|
32
|
+
move_to(x, 30) -> pos=30.00° v=20.2V
|
|
33
|
+
move_to(x, -30) -> pos=-30.00° v=20.2V
|
|
34
|
+
move_to(x, 0) -> pos=0.00° v=20.2V
|
|
35
|
+
homed x -> 0.00°
|
|
36
|
+
SERIALBUS INTEGRATION TEST PASSED
|
|
37
|
+
|
|
38
|
+
## Hardware: advanced (identify + repeatability + wave)
|
|
39
|
+
--- 1. identify (LED should blink on the motor) ---
|
|
40
|
+
identify() sent — check the motor LED
|
|
41
|
+
mean error=0.000° max error=0.000° tolerance=0.5°
|
|
42
|
+
ADVANCED TEST PASSED (max err 0.000° vs tol 0.5°)
|
|
43
|
+
|
|
44
|
+
## Hardware: extended (PID err, velocity, throughput, reliability)
|
|
45
|
+
--- 1. enable + get_max_pid_error (closed-loop error, in degrees) ---
|
|
46
|
+
[ok ] max_pid_error [min,max]: [235929.5998901367, -235929.59999999998]
|
|
47
|
+
20 moves in 7.0s = 2.87 moves/s; worst position error 0.000°
|
|
48
|
+
5/5 enable+disable cycles clean
|
|
49
|
+
EXTENDED TEST PASSED
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Watchable demo: drive the real M17 through the MCP server's tool code path.
|
|
3
|
+
|
|
4
|
+
Prints each plain-language intent + the tool it maps to, then moves the motor with a pause
|
|
5
|
+
so you can watch. This is exactly what an AI assistant does via the MCP server — here a
|
|
6
|
+
script plays the role of the AI so you can see the motor respond. Ends with the safety clamp.
|
|
7
|
+
|
|
8
|
+
Run:
|
|
9
|
+
PYTHONPATH=../src:/Users/sandbox1/servomotor/python_programs python3 demo_live.py
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import time
|
|
14
|
+
|
|
15
|
+
from servomotor_mcp.motors import SerialBus
|
|
16
|
+
from servomotor_mcp.safety import MotorLimits, SafetyPolicy
|
|
17
|
+
|
|
18
|
+
NAME = "x"
|
|
19
|
+
PAUSE = 1.2
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def say(intent, tool):
|
|
23
|
+
print(f'\n 🗣 "{intent}"\n -> MCP tool: {tool}')
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def main() -> int:
|
|
27
|
+
bus = SerialBus(port="/dev/cu.usbserial-210", motor_map={NAME: 88})
|
|
28
|
+
policy = SafetyPolicy(allowed=frozenset({NAME}),
|
|
29
|
+
limits={NAME: MotorLimits(min_deg=-90, max_deg=90, max_speed=300)})
|
|
30
|
+
m = bus._m[NAME]
|
|
31
|
+
try:
|
|
32
|
+
print("=" * 64)
|
|
33
|
+
print(" GEAROTONS M17 — live demo (WATCH THE MOTOR)")
|
|
34
|
+
print(" A script is standing in for the AI; it calls the same MCP tools.")
|
|
35
|
+
print("=" * 64)
|
|
36
|
+
|
|
37
|
+
say("Which motor are you? (blink so I can see you)", "identify")
|
|
38
|
+
m.identify(); print(" -> the motor's LED should be blinking now"); time.sleep(2.0)
|
|
39
|
+
|
|
40
|
+
say("Home — go to zero.", "home()")
|
|
41
|
+
bus.home(NAME); print(f" -> position: {bus.get_status(NAME)[0].position_deg:.1f}°"); time.sleep(PAUSE)
|
|
42
|
+
|
|
43
|
+
say("Point to 60 degrees.", "move_to(x, 60)")
|
|
44
|
+
s = bus.move_to(NAME, 60); print(f" -> position: {s.position_deg:.1f}°"); time.sleep(PAUSE)
|
|
45
|
+
|
|
46
|
+
say("Now go to minus 60 degrees.", "move_to(x, -60)")
|
|
47
|
+
s = bus.move_to(NAME, -60); print(f" -> position: {s.position_deg:.1f}°"); time.sleep(PAUSE)
|
|
48
|
+
|
|
49
|
+
say("Wave hello.", "run_sequence([...])")
|
|
50
|
+
for deg in (25, -25, 18, -18, 0):
|
|
51
|
+
bus.move_to(NAME, deg, speed=240)
|
|
52
|
+
print(f" -> back to {bus.get_status(NAME)[0].position_deg:.1f}°"); time.sleep(PAUSE)
|
|
53
|
+
|
|
54
|
+
print("\n" + "-" * 64)
|
|
55
|
+
print(" SAFETY DEMO — the limits live in the software, not the AI's goodwill.")
|
|
56
|
+
print(" This motor is configured with a max of 90°.")
|
|
57
|
+
say("Spin all the way to 300 degrees!", "move_to(x, 300)")
|
|
58
|
+
target, note = policy.clamp_absolute(NAME, 300)
|
|
59
|
+
s = bus.move_to(NAME, target)
|
|
60
|
+
print(f" -> {note}")
|
|
61
|
+
print(f" -> motor STOPPED at {s.position_deg:.1f}° (refused to exceed 90°)")
|
|
62
|
+
time.sleep(PAUSE)
|
|
63
|
+
|
|
64
|
+
say("Okay, home and rest.", "home() + stop()")
|
|
65
|
+
bus.home(NAME); bus.stop(NAME)
|
|
66
|
+
print(f" -> position: {bus.get_status(NAME)[0].position_deg:.1f}°, motor released")
|
|
67
|
+
|
|
68
|
+
print("\n" + "=" * 64)
|
|
69
|
+
print(" Demo done. That's the MCP toolset driving your real motor.")
|
|
70
|
+
print(" With the Python-3.10 setup, Claude calls these same tools from chat.")
|
|
71
|
+
print("=" * 64)
|
|
72
|
+
return 0
|
|
73
|
+
finally:
|
|
74
|
+
bus.close()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
if __name__ == "__main__":
|
|
78
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Probe every RS-485 serial port and report which one(s) have a Gearotons motor.
|
|
3
|
+
|
|
4
|
+
Tom connected ONE M17 via one of four USB-RS485 adapters; the other three are empty.
|
|
5
|
+
This finds it by running the library's device detection on each port and reporting the
|
|
6
|
+
unique ID + alias of whatever answers.
|
|
7
|
+
|
|
8
|
+
Run:
|
|
9
|
+
PYTHONPATH=/Users/sandbox1/servomotor/python_programs python3 discover_motor.py
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import glob
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
import servomotor
|
|
17
|
+
from servomotor import communication
|
|
18
|
+
from servomotor.device_detection import detect_devices_iteratively
|
|
19
|
+
|
|
20
|
+
DEFAULT_PORTS = sorted(glob.glob("/dev/cu.usbserial-*"))
|
|
21
|
+
N_DETECTIONS = 3 # consecutive successful detections the lib requires to trust a result
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def probe(port: str) -> list:
|
|
25
|
+
"""Return the list of Devices found on `port` (empty if none / no answer)."""
|
|
26
|
+
communication.serial_port = port
|
|
27
|
+
# alias 255 = broadcast/ALL; detection enumerates whoever is on the bus
|
|
28
|
+
servomotor.M3(alias_or_unique_id=255, verbose=0)
|
|
29
|
+
servomotor.open_serial_port()
|
|
30
|
+
try:
|
|
31
|
+
return detect_devices_iteratively(N_DETECTIONS, verbose=False) or []
|
|
32
|
+
finally:
|
|
33
|
+
try:
|
|
34
|
+
servomotor.close_serial_port()
|
|
35
|
+
except Exception:
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def main() -> int:
|
|
40
|
+
ports = sys.argv[1:] or DEFAULT_PORTS
|
|
41
|
+
print(f"Probing {len(ports)} port(s): {ports}\n")
|
|
42
|
+
found = {}
|
|
43
|
+
for port in ports:
|
|
44
|
+
try:
|
|
45
|
+
devices = probe(port)
|
|
46
|
+
except Exception as exc: # empty adapter -> timeout/serial error; keep going
|
|
47
|
+
print(f" {port:32s} -> no device ({type(exc).__name__}: {exc})")
|
|
48
|
+
continue
|
|
49
|
+
if devices:
|
|
50
|
+
for d in devices:
|
|
51
|
+
print(f" {port:32s} -> MOTOR unique_id=0x{d.unique_id:016X} alias={d.alias}")
|
|
52
|
+
found[port] = devices
|
|
53
|
+
else:
|
|
54
|
+
print(f" {port:32s} -> no device (no response)")
|
|
55
|
+
|
|
56
|
+
print()
|
|
57
|
+
if not found:
|
|
58
|
+
print("RESULT: no motor detected on any port.")
|
|
59
|
+
return 1
|
|
60
|
+
for port, devices in found.items():
|
|
61
|
+
print(f"RESULT: motor on {port} -> "
|
|
62
|
+
+ ", ".join(f"uid=0x{d.unique_id:016X} alias={d.alias}" for d in devices))
|
|
63
|
+
# Emit the first found port on the last line for easy scripting/capture.
|
|
64
|
+
print(f"MOTOR_PORT={next(iter(found))}")
|
|
65
|
+
return 0
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
if __name__ == "__main__":
|
|
69
|
+
raise SystemExit(main())
|