Skip to content

Commit e96346a

Browse files
committed
Generate the MCP tools from the Bridge API instead of transcribing them
mcp/tools.py hand-wrote 919 lines that re-declared the signature and re-worded the docstring of methods that already existed, and still only covered 85 of the 127 sub-API methods: there were no rmt_*, i2s_*, watch_* or wifi_link_* tools at all. Tools are now derived from the API. Every public sub-API method becomes <subapi>_<method> carrying that method's own signature and docstring, with bytes travelling as hex in both directions; only the exceptions are written out (SKIP for callbacks and live objects, plus a handful of composites). esp.i2c.read(addr, n) IS the i2c_read tool, so the two can no longer drift, and a new peripheral method is a new tool with nothing to write. 108 -> 136 tools, no name lost. Generated tools return the method's own value, so gpio_write gives the read-back level (1) rather than {"level": 1, "ok": true} and i2c_read gives "010203" rather than {"hex": "010203"}. Deletions that fell out of it: - dac.cosine_stop(): the firmware's DAC_DISABLE already calls stop_cosine(), and so does dac.write() -- the method could only do a subset of disable(). - BridgeManager.note_i2c/forget_i2c/i2c_buses: esp.i2c.buses records the bus configuration where it belongs, and board_status reads it from there. - BridgeManager.open_uart/uart_port/close_uart: esp.uart already cached its ports; it only needed a port() accessor. PinStatus.pwm becomes a field rather than a property, so it survives serialisation, and the loose atten/callback parameters on adc.config and watch.add are annotated. Verified on two ESP32 DevKits: hw_stress 130/130 over 5 iterations with no heap drift, plus the generated tools driven over a real link (gpio, adc, dac, pwm, i2c, nvs, fs, watch, rmt, i2s, radio_off, board_status).
1 parent 91dfc33 commit e96346a

10 files changed

Lines changed: 264 additions & 816 deletions

File tree

docs/MCP.md

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -96,8 +96,9 @@ auto-reconnects on the first tool call if the link drops.
9696

9797
## Tools
9898

99-
100+ tools, grouped by peripheral. The agent reads the per-tool descriptions; the
100-
groups are:
99+
One tool per method of the Python API — `esp.i2c.read(addr, n)` is the `i2c_read`
100+
tool, with that method's own arguments and docstring, so the two never drift.
101+
The agent reads the per-tool descriptions; the groups are:
101102

102103
| prefix | what |
103104
|--------|------|
@@ -111,6 +112,8 @@ groups are:
111112
| `nvs_*` | persistent key/value store (str/int/bytes) |
112113
| `fs_*` | LittleFS / SD files (list, read, write, stat, …) |
113114
| `onewire_*` / `espnow_*` / `can_*` | 1-Wire, ESP-NOW, CAN bus |
115+
| `rmt_*` / `i2s_*` | pulse trains (NeoPixel/IR/DHT timing) and PCM audio in/out |
116+
| `watch_*` | on-device rules: the board samples a pin and acts on it by itself |
114117
| `mcpwm_*` / `eth_*` / `camera_*` / `ota_*` | motor PWM, Ethernet, camera JPEG, firmware update |
115118

116119
Conventions the tools follow:
@@ -127,14 +130,16 @@ Conventions the tools follow:
127130
`camera_capture` returns the JPEG as an MCP image so vision-capable models can
128131
see it directly.
129132

130-
> Streaming / session-oriented APIs (raw TCP/UDP sockets, BLE GATT, I2S audio,
131-
> RMT capture) are intentionally **not** exposed as tools — they don't map onto
132-
> request/response tool calls. Use the Python `Bridge` API for those.
133+
> What is intentionally **not** a tool: anything that hands back a live object
134+
> (raw TCP/UDP sockets, BLE GATT sessions) or takes a host callback (edge
135+
> interrupts, RX handlers, RMT capture) — neither maps onto request/response
136+
> tool calls. Use the Python `Bridge` API for those.
133137
134138
## Live feedback
135139

136-
Every tool reports what it did on the board — e.g. `GPIO2 mode set to output`,
137-
or `pwm_attach(pin=5, freq=1000, resolution_bits=10)`. Each message goes to two
140+
Every tool reports what it did on the board — e.g.
141+
`gpio_mode(pin=2, mode='output')`, or `gpio_write(pin=2, value=1) -> 1`. Each
142+
message goes to two
138143
places:
139144

140145
- the **server log** (stderr) — visible to whoever runs `espbridge-mcp`;

python/espbridge/analog.py

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ class Adc:
2121
def __init__(self, bridge):
2222
self._b = bridge
2323

24-
def config(self, pin: int, atten=11) -> None:
24+
def config(self, pin: int, atten: float = 11) -> None:
2525
"""Set the input attenuation for a pin (0/2.5/6/11 dB; default 11 ≈ 3.3 V)."""
2626
self._b.request(C.ADC_CONFIG, bytes([pin, ATTEN.get(atten, int(atten))]))
2727

@@ -39,8 +39,7 @@ class Dac:
3939
4040
esp.dac.write(25, 128) # ~1.65 V
4141
esp.dac.cosine(25, 1000) # 1 kHz cosine wave
42-
esp.dac.cosine_stop(25)
43-
esp.dac.disable(25)
42+
esp.dac.disable(25) # stops the wave and releases the pin
4443
"""
4544

4645
def __init__(self, bridge):
@@ -60,12 +59,11 @@ def cosine(self, pin: int, freq_hz: int, *, scale: int = 0, offset: int = 0,
6059
self._b.request(C.DAC_COSINE, struct.pack(">BIBbB", pin, freq_hz, scale & 3,
6160
offset, 1 if phase_180 else 0))
6261

63-
def cosine_stop(self, pin: int) -> None:
64-
"""Stop the cosine generator on a pin (the pin stays a DAC output)."""
65-
self._b.request(C.DAC_COS_STOP, bytes([pin]))
66-
6762
def disable(self, pin: int) -> None:
68-
"""Turn the DAC off on a pin and release it."""
63+
"""Stop any cosine generator on the pin, turn the DAC off and release it.
64+
65+
A plain :meth:`write` also stops the generator, so there is nothing else
66+
to call to take the pin back."""
6967
self._b.request(C.DAC_DISABLE, bytes([pin]))
7068

7169

python/espbridge/gpio.py

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,18 +41,14 @@ class PinStatus:
4141
level: int # actual pad level (0 or 1) read back from hardware
4242
mode: str | None # mode last set via the bridge; None if never configured
4343
is_output: bool
44+
pwm: bool # a PWM (LEDC) channel is driving this pin
4445
pwm_freq: int # LEDC carrier frequency in Hz; 0 means no PWM on this pin
4546
pwm_duty: int # LEDC raw duty count; 0 if no PWM
4647

47-
@property
48-
def pwm(self) -> bool:
49-
"""True when a PWM (LEDC) channel is driving this pin."""
50-
return self.pwm_freq > 0
51-
5248

5349
def _pin_status(pin: int, level: int, mode: int, freq: int, duty: int) -> PinStatus:
5450
name = _MODE_NAMES.get(mode)
55-
return PinStatus(pin, level, name, name in _OUTPUT_MODES, freq, duty)
51+
return PinStatus(pin, level, name, name in _OUTPUT_MODES, freq > 0, freq, duty)
5652

5753

5854
class Gpio:

python/espbridge/i2c.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ class I2c:
1818
def __init__(self, bridge):
1919
self._b = bridge
2020
self._max_write: int | None = None
21+
self.buses: dict[int, dict] = {} # bus -> {sda, scl, freq}, as configured
2122

2223
@property
2324
def max_write(self) -> int:
@@ -38,6 +39,7 @@ def init(self, *, sda: int = 21, scl: int = 22, freq: int = 400_000, bus: int =
3839
r = self._b.request(C.I2C_INIT, struct.pack(">BBBI", bus, sda, scl, freq))
3940
if len(r) >= 2: # firmware >= 0.3.0 replies with the Wire TX buffer size as a u16
4041
self._max_write = struct.unpack(">H", r[:2])[0] - 2
42+
self.buses[bus] = {"sda": sda, "scl": scl, "freq": freq}
4143

4244
def scan(self, bus: int = 0) -> list[int]:
4345
"""Addresses (7-bit) that ACK on the bus."""
@@ -83,6 +85,7 @@ def write_reg(self, addr: int, reg: int, data: bytes | int, bus: int = 0) -> Non
8385
def deinit(self, bus: int = 0) -> None:
8486
"""Release the bus and its pins on the firmware."""
8587
self._b.request(C.I2C_DEINIT, bytes([bus]))
88+
self.buses.pop(bus, None)
8689

8790
# House style: begin()/end() work on every peripheral (Arduino-friendly).
8891
begin = init

python/espbridge/mcp/server.py

Lines changed: 10 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,11 @@
3636
Control an ESP32 running the python-esp-bridge firmware over USB or Bluetooth.
3737
3838
Tools are grouped by peripheral: system_*, gpio_*, adc_*/dac_*/touch_*, pwm_*,
39-
i2c_*, spi_*, uart_*, wifi_*, nvs_*, fs_*, onewire_*, espnow_*, can_*, mcpwm_*,
40-
eth_*, camera_*, ota_*. Connection is managed by bridge_connect / bridge_status
41-
/ bridge_disconnect; if you call a peripheral tool before connecting, the server
42-
auto-connects with the settings it was started with.
39+
i2c_*, spi_*, uart_*, wifi_*, nvs_*, fs_*, onewire_*, espnow_*, can_*, rmt_*,
40+
i2s_*, watch_*, mcpwm_*, eth_*, camera_*, ota_*. Connection is managed by
41+
bridge_connect / bridge_status / bridge_disconnect; if you call a peripheral
42+
tool before connecting, the server auto-connects with the settings it was
43+
started with.
4344
4445
Conventions:
4546
- Pins are integers (the chip's GPIO numbers). gpio_mode must be set to
@@ -56,8 +57,8 @@
5657

5758
class BridgeManager(_CoreBridgeManager):
5859
"""The core shared-bridge manager (one thread-safe, auto-reconnecting link)
59-
plus the stateful handles the MCP tools layer on top: mounted filesystem
60-
volumes, opened UART ports, and noted I2C buses (for board_status).
60+
plus the one handle the tools layer adds: the mounted filesystem volumes.
61+
I2C buses and UART ports are remembered by ``esp.i2c``/``esp.uart``.
6162
6263
Thread-safe: FastMCP runs each (synchronous) tool in a worker thread, and
6364
Bridge.request() is itself thread-safe, so concurrent tool calls share one
@@ -67,17 +68,14 @@ class BridgeManager(_CoreBridgeManager):
6768
def __init__(self, **connect_kwargs):
6869
super().__init__(**connect_kwargs)
6970
self._volumes: dict = {} # fs kind -> Volume
70-
self._uart_ports: dict = {} # port number -> UartPort
71-
self._i2c_buses: dict = {} # bus number -> {sda, scl, freq} (for board_status)
7271

7372
def _close_locked(self) -> None:
74-
# Tear down the link, then drop the per-connection handles built on it.
73+
# Tear down the link, then drop the volumes mounted on it.
7574
super()._close_locked()
7675
self._volumes.clear()
77-
self._uart_ports.clear()
78-
self._i2c_buses.clear()
7976

80-
def volume(self, kind: str, *, remount: bool = False, **mount_kwargs):
77+
def volume(self, kind: str = "littlefs", *, remount: bool = False,
78+
**mount_kwargs):
8179
"""Mount (and cache) a filesystem volume of the given kind.
8280
8381
Lazy callers that just need *a* mount pass no kwargs and get the cached
@@ -92,35 +90,6 @@ def volume(self, kind: str, *, remount: bool = False, **mount_kwargs):
9290
self._volumes[kind] = vol
9391
return vol
9492

95-
@property
96-
def i2c_buses(self) -> dict:
97-
with self._lock:
98-
return dict(self._i2c_buses)
99-
100-
def note_i2c(self, bus: int, sda: int, scl: int, freq: int) -> None:
101-
with self._lock:
102-
self._i2c_buses[bus] = {"sda": sda, "scl": scl, "freq": freq}
103-
104-
def forget_i2c(self, bus: int) -> None:
105-
with self._lock:
106-
self._i2c_buses.pop(bus, None)
107-
108-
def uart_port(self, port: int):
109-
with self._lock:
110-
return self._uart_ports.get(port)
111-
112-
def open_uart(self, *, port: int, tx: int, rx: int, baud: int):
113-
with self._lock:
114-
p = self.bridge().uart.init(port=port, tx=tx, rx=rx, baud=baud)
115-
self._uart_ports[port] = p
116-
return p
117-
118-
def close_uart(self, port: int) -> None:
119-
with self._lock:
120-
p = self._uart_ports.pop(port, None)
121-
if p is not None:
122-
p.close()
123-
12493

12594
def _register_connection(mcp: FastMCP, mgr: BridgeManager) -> None:
12695
@mcp.tool

0 commit comments

Comments
 (0)