Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions executor/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# Board B - MicroPython Executor

Board B is an ESP32 (or ESP32-S3) running MicroPython that receives Python scripts from Board A (MimiClaw) via ESP-NOW and executes them.

## Requirements

- ESP32 or ESP32-S3 board
- MicroPython firmware with ESP-NOW support (v1.20+)

## Setup

### 1. Flash MicroPython firmware

Download the latest MicroPython firmware for your board from https://micropython.org/download/

```bash
# Erase flash
esptool.py --chip esp32s3 erase_flash

# Flash MicroPython (adjust firmware filename)
esptool.py --chip esp32s3 write_flash -z 0 ESP32_GENERIC_S3-20240602-v1.23.0.bin
```

### 2. Upload scripts

```bash
# Install mpremote
pip install mpremote

# Upload boot.py and main.py
mpremote cp boot.py :boot.py
mpremote cp main.py :main.py

# Reset the board
mpremote reset
```

### 3. Get Board B MAC address

After uploading and resetting, Board B will print its MAC address:

```
Board B MAC: aa:bb:cc:dd:ee:ff
On Board A, run: set_espnow_peer aa:bb:cc:dd:ee:ff
```

### 4. Configure Board A

On Board A's serial console:

```
set_espnow_peer aa:bb:cc:dd:ee:ff
restart
```
Comment on lines +42 to +54
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add language specifiers to fenced code blocks.

The code blocks at lines 42 and 51 are missing language specifiers. Adding text or console improves rendering consistency and satisfies markdown linting.

📝 Proposed fix
-```
+```text
 Board B MAC: aa:bb:cc:dd:ee:ff
 On Board A, run: set_espnow_peer aa:bb:cc:dd:ee:ff

```diff
-```
+```text
 set_espnow_peer aa:bb:cc:dd:ee:ff
 restart
</details>

<details>
<summary>🧰 Tools</summary>

<details>
<summary>🪛 markdownlint-cli2 (0.21.0)</summary>

[warning] 42-42: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

---

[warning] 51-51: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

</details>

</details>

<details>
<summary>🤖 Prompt for AI Agents</summary>

Verify each finding against the current code and only fix it if needed.

In @executor/README.md around lines 42 - 54, Add language specifiers to the two
fenced code blocks containing the "Board B MAC: aa:bb:cc:dd:ee:ff" / "On Board
A, run: set_espnow_peer aa:bb:cc:dd:ee:ff" example and the "set_espnow_peer
aa:bb:cc:dd:ee:ff" / "restart" commands by changing the opening backticks from
totext (or ```console) so the blocks render consistently; update the
blocks that include the "Board B MAC" line and the block with "set_espnow_peer"
and "restart" accordingly.


</details>

<!-- fingerprinting:phantom:medusa:ocelot -->

<!-- This is an auto-generated comment by CodeRabbit -->


## How It Works

1. Board A's AI agent decides to run a Python script (via `run_python` tool)
2. The script is sent to Board B over ESP-NOW (chunked, 243 bytes per packet)
3. Board B receives and reassembles the script
4. Board B executes the script with `exec()`, capturing stdout
5. The result (stdout + any exceptions) is sent back to Board A
6. Board A returns the result to the AI agent

## Limitations

- ESP-NOW range: ~200m line of sight, ~50m indoors
- MicroPython memory: limited by Board B's available RAM
- No persistent state between script executions (each `exec()` gets a fresh namespace)
- No WiFi connection on Board B (WiFi STA is active but not connected, used only for ESP-NOW)
18 changes: 18 additions & 0 deletions executor/boot.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
"""
MimiClaw Board B (Executor) - boot.py
Initializes WiFi STA interface for ESP-NOW (no connection needed).
Prints MAC address for pairing with Board A.
"""

import network
import ubinascii

# Enable WiFi STA (required for ESP-NOW, but no need to connect)
sta = network.WLAN(network.STA_IF)
sta.active(True)

# Print MAC address for Board A configuration
mac = sta.config('mac')
mac_str = ubinascii.hexlify(mac, ':').decode()
print('Board B MAC:', mac_str)
print('On Board A, run: set_espnow_peer', mac_str)
157 changes: 157 additions & 0 deletions executor/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
"""
MimiClaw Board B (Executor) - main.py
Listens for Python scripts via ESP-NOW from Board A, executes them,
and sends back the result (stdout + exceptions).

Protocol:
Header (7 bytes): [type(1) | seq(2 LE) | total_len(4 LE)]
Types: 0x01=SCRIPT_START, 0x02=SCRIPT_CHUNK, 0x03=SCRIPT_END
0x11=RESULT_START, 0x12=RESULT_CHUNK, 0x13=RESULT_END
"""

import espnow
import network
import sys
import io
import struct
import time

# --- Constants ---
MSG_SCRIPT_START = 0x01
MSG_SCRIPT_CHUNK = 0x02
MSG_SCRIPT_END = 0x03
MSG_RESULT_START = 0x11
MSG_RESULT_CHUNK = 0x12
MSG_RESULT_END = 0x13

HEADER_SIZE = 7
MAX_PAYLOAD = 250
CHUNK_DATA = MAX_PAYLOAD - HEADER_SIZE

# --- ESP-NOW Setup ---
sta = network.WLAN(network.STA_IF)
sta.active(True)

e = espnow.ESPNow()
e.active(True)

# Board A peer - will be set on first received message
peer_mac = None


def build_header(msg_type, seq, total_len=0):
return struct.pack('<BHI', msg_type, seq, total_len)


def send_result(mac, result_text):
"""Send result back to Board A in chunks."""
data = result_text.encode('utf-8')
total = len(data)
offset = 0
seq = 0

while offset < total:
chunk_size = min(CHUNK_DATA, total - offset)
is_first = (seq == 0)
is_last = (offset + chunk_size >= total)

if is_first:
msg_type = MSG_RESULT_START
elif is_last:
msg_type = MSG_RESULT_END
else:
msg_type = MSG_RESULT_CHUNK

header = build_header(msg_type, seq, total if is_first else 0)
pkt = header + data[offset:offset + chunk_size]

try:
e.send(mac, pkt)
except Exception as ex:
print('ESP-NOW send error:', ex)
return

offset += chunk_size
seq += 1
if not is_last:
time.sleep_ms(5)

# Handle empty result
if total == 0:
header = build_header(MSG_RESULT_START, 0, 0)
e.send(mac, header)
header = build_header(MSG_RESULT_END, 1, 0)
e.send(mac, header)


def execute_script(code):
"""Execute Python code and capture stdout + exceptions."""
old_stdout = sys.stdout
captured = io.StringIO()
sys.stdout = captured

try:
exec(code, {'__name__': '__main__'})
except Exception as ex:
print('Error:', type(ex).__name__, '-', ex)
finally:
sys.stdout = old_stdout

return captured.getvalue()


# --- Main Loop ---
print('Board B executor ready. Waiting for scripts...')

script_buf = bytearray()
script_total = 0
script_seq = 0
receiving = False

while True:
mac, msg = e.recv(timeout_ms=1000)
if msg is None:
continue

if len(msg) < HEADER_SIZE:
continue

msg_type, seq, total_len = struct.unpack('<BHI', msg[:HEADER_SIZE])
payload = msg[HEADER_SIZE:]

# Remember the peer
if peer_mac is None and mac is not None:
peer_mac = bytes(mac)
try:
e.add_peer(peer_mac)
except Exception:
pass # peer may already exist
print('Paired with Board A:', ':'.join('%02x' % b for b in peer_mac))

if msg_type == MSG_SCRIPT_START:
script_total = total_len
script_buf = bytearray(payload)
script_seq = seq + 1
receiving = True

elif msg_type == MSG_SCRIPT_CHUNK and receiving:
if seq == script_seq:
script_buf.extend(payload)
script_seq = seq + 1

elif msg_type == MSG_SCRIPT_END:
if payload:
script_buf.extend(payload)

code = bytes(script_buf).decode('utf-8')
print('Received script ({} bytes), executing...'.format(len(code)))

result = execute_script(code)
print('Result ({} bytes): {}'.format(len(result), result[:100]))

if peer_mac:
send_result(peer_mac, result)

# Reset state
script_buf = bytearray()
receiving = False
Comment on lines +142 to +157
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add receiving check for SCRIPT_END to prevent spurious execution.

SCRIPT_START and SCRIPT_CHUNK check the receiving flag, but SCRIPT_END does not. A stray SCRIPT_END packet could trigger execution of an empty or stale buffer.

🛡️ Proposed fix
-    elif msg_type == MSG_SCRIPT_END:
+    elif msg_type == MSG_SCRIPT_END and receiving:
         if payload:
             script_buf.extend(payload)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
elif msg_type == MSG_SCRIPT_END:
if payload:
script_buf.extend(payload)
code = bytes(script_buf).decode('utf-8')
print('Received script ({} bytes), executing...'.format(len(code)))
result = execute_script(code)
print('Result ({} bytes): {}'.format(len(result), result[:100]))
if peer_mac:
send_result(peer_mac, result)
# Reset state
script_buf = bytearray()
receiving = False
elif msg_type == MSG_SCRIPT_END and receiving:
if payload:
script_buf.extend(payload)
code = bytes(script_buf).decode('utf-8')
print('Received script ({} bytes), executing...'.format(len(code)))
result = execute_script(code)
print('Result ({} bytes): {}'.format(len(result), result[:100]))
if peer_mac:
send_result(peer_mac, result)
# Reset state
script_buf = bytearray()
receiving = False
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@executor/main.py` around lines 142 - 157, The MSG_SCRIPT_END branch should
guard against spurious packets by checking the receiving flag before processing;
modify the MSG_SCRIPT_END handling (where script_buf is used and
execute_script/send_result are called) to only append payload, decode, call
execute_script, send_result, and reset script_buf/receiving when receiving is
True, otherwise ignore the packet so you don't execute an empty or stale script
buffer.

2 changes: 2 additions & 0 deletions main/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ idf_component_register(
"tools/tool_web_search.c"
"tools/tool_get_time.c"
"tools/tool_files.c"
"tools/tool_run_python.c"
"espnow/espnow_manager.c"
"skills/skill_loader.c"
INCLUDE_DIRS
"."
Expand Down
73 changes: 71 additions & 2 deletions main/cli/serial_cli.c
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
#include "cron/cron_service.h"
#include "heartbeat/heartbeat.h"
#include "skills/skill_loader.h"
#include "espnow/espnow_manager.h"

#include <string.h>
#include <stdio.h>
Expand Down Expand Up @@ -541,6 +542,24 @@ static int cmd_config_show(int argc, char **argv)
print_config("Proxy Port", MIMI_NVS_PROXY, MIMI_NVS_KEY_PROXY_PORT, MIMI_SECRET_PROXY_PORT, false);
print_config("Search Key", MIMI_NVS_SEARCH, MIMI_NVS_KEY_API_KEY, MIMI_SECRET_SEARCH_KEY, true);
print_config("Tavily Key", MIMI_NVS_SEARCH, MIMI_NVS_KEY_TAVILY_KEY, MIMI_SECRET_TAVILY_KEY, true);

/* ESP-NOW peer MAC */
{
uint8_t mac[6] = {0};
nvs_handle_t nvs;
if (nvs_open(MIMI_NVS_ESPNOW, NVS_READONLY, &nvs) == ESP_OK) {
size_t len = 6;
if (nvs_get_blob(nvs, MIMI_NVS_KEY_PEER_MAC, mac, &len) == ESP_OK && len == 6) {
printf(" %-14s: %02x:%02x:%02x:%02x:%02x:%02x [NVS]\n", "ESP-NOW Peer",
mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
} else {
printf(" %-14s: (not set)\n", "ESP-NOW Peer");
}
nvs_close(nvs);
} else {
printf(" %-14s: (not set)\n", "ESP-NOW Peer");
}
}
printf("=============================\n");
return 0;
}
Expand All @@ -549,9 +568,9 @@ static int cmd_config_show(int argc, char **argv)
static int cmd_config_reset(int argc, char **argv)
{
const char *namespaces[] = {
MIMI_NVS_WIFI, MIMI_NVS_TG, MIMI_NVS_LLM, MIMI_NVS_PROXY, MIMI_NVS_SEARCH
MIMI_NVS_WIFI, MIMI_NVS_TG, MIMI_NVS_FEISHU, MIMI_NVS_LLM, MIMI_NVS_PROXY, MIMI_NVS_SEARCH, MIMI_NVS_ESPNOW
};
for (int i = 0; i < 5; i++) {
for (int i = 0; i < 7; i++) {
nvs_handle_t nvs;
if (nvs_open(namespaces[i], NVS_READWRITE, &nvs) == ESP_OK) {
nvs_erase_all(nvs);
Expand Down Expand Up @@ -742,6 +761,45 @@ static int cmd_web_search(int argc, char **argv)
return (err == ESP_OK) ? 0 : 1;
}

/* --- set_espnow_peer command --- */
static struct {
struct arg_str *mac;
struct arg_end *end;
} espnow_peer_args;

static int cmd_set_espnow_peer(int argc, char **argv)
{
int nerrors = arg_parse(argc, argv, (void **)&espnow_peer_args);
if (nerrors != 0) {
arg_print_errors(stderr, espnow_peer_args.end, argv[0]);
return 1;
}

const char *mac_str = espnow_peer_args.mac->sval[0];
uint8_t mac[6];
int parsed = sscanf(mac_str, "%hhx:%hhx:%hhx:%hhx:%hhx:%hhx",
&mac[0], &mac[1], &mac[2], &mac[3], &mac[4], &mac[5]);
if (parsed != 6) {
printf("Invalid MAC format. Use XX:XX:XX:XX:XX:XX\n");
return 1;
}

nvs_handle_t nvs;
esp_err_t err = nvs_open(MIMI_NVS_ESPNOW, NVS_READWRITE, &nvs);
if (err != ESP_OK) {
printf("NVS open failed: %s\n", esp_err_to_name(err));
return 1;
}
nvs_set_blob(nvs, MIMI_NVS_KEY_PEER_MAC, mac, 6);
nvs_commit(nvs);
nvs_close(nvs);

printf("ESP-NOW peer MAC saved: %02x:%02x:%02x:%02x:%02x:%02x\n",
mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
printf("Restart to apply.\n");
return 0;
}

/* --- restart command --- */
static int cmd_restart(int argc, char **argv)
{
Expand Down Expand Up @@ -1041,6 +1099,17 @@ esp_err_t serial_cli_init(void)
};
esp_console_cmd_register(&web_search_cmd);

/* set_espnow_peer */
espnow_peer_args.mac = arg_str1(NULL, NULL, "<mac>", "Board B MAC (XX:XX:XX:XX:XX:XX)");
espnow_peer_args.end = arg_end(1);
esp_console_cmd_t espnow_peer_cmd = {
.command = "set_espnow_peer",
.help = "Set ESP-NOW peer MAC for Board B (e.g. set_espnow_peer AA:BB:CC:DD:EE:FF)",
.func = &cmd_set_espnow_peer,
.argtable = &espnow_peer_args,
};
esp_console_cmd_register(&espnow_peer_cmd);

/* restart */
esp_console_cmd_t restart_cmd = {
.command = "restart",
Expand Down
Loading