diff --git a/README.md b/README.md index ea87916..f1f87d4 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,21 @@ pre-commit run --all-files VS Code 示例配置位于 `VSC_sample_configs/`。贡献流程和提交规范见 [CONTRIBUTING.md](CONTRIBUTING.md)。 +## Codex Skill + +仓库在 [`skills/mambo`](skills/mambo/SKILL.md) 内置了面向 Mambo 的 Agentic Embedded Skill,用于指导 Codex 配置 Mambo/Zephyr 环境、编写最小可验证程序,并通过 DM-MC02、CMSIS-DAP、AresPlot 和串口采集工具进行安全调试。 + +从仓库根目录将 Skill 链接到个人 Codex 技能目录: + +```shell +mkdir -p "${CODEX_HOME:-$HOME/.codex}/skills" +ln -s "$PWD/skills/mambo" "${CODEX_HOME:-$HOME/.codex}/skills/mambo" +``` + +安装后可通过 `$mambo` 调用。默认使用新生模式,以简单中文逐步解释并核对构建、接线和运行证据;明确说“专家模式”或“老手模式”后,才会切换为更精简的批量诊断。两种模式都不会在未授权时烧录固件、连接调试探针或启用执行器电源。 + +Skill 同时提供只读 AresPlot CSV 采集、按时间区间绘图、JSON 配置示例和 DM-MC02 V1.1 使用说明书,详见 [`skills/mambo/references`](skills/mambo/references) 与 [`skills/mambo/scripts`](skills/mambo/scripts)。 + ## 文档 - 文档入口:[Documents/README.md](Documents/README.md) diff --git a/README.md.eng b/README.md.eng index 471eadb..9bfb492 100644 --- a/README.md.eng +++ b/README.md.eng @@ -119,3 +119,18 @@ pre-commit run --all-files ``` VS Code example settings are available in `VSC_sample_configs/`. See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution guidelines. + +## Codex Skill + +The repository includes an Agentic Embedded Skill at [`skills/mambo`](skills/mambo/SKILL.md). It guides Codex through Mambo/Zephyr setup, small verifiable program changes, and safe debugging with the DM-MC02, CMSIS-DAP, AresPlot, and the bundled serial capture tools. + +From the repository root, link the Skill into the personal Codex skill directory: + +```shell +mkdir -p "${CODEX_HOME:-$HOME/.codex}/skills" +ln -s "$PWD/skills/mambo" "${CODEX_HOME:-$HOME/.codex}/skills/mambo" +``` + +Invoke it with `$mambo`. Beginner mode is the default and teaches one evidence-backed step at a time. Explicitly request `expert mode` for concise, batched diagnostics. Both modes require authorization before flashing, attaching a debug probe, or enabling actuator power. + +The Skill also bundles read-only AresPlot CSV capture, time-window rendering, a JSON configuration example, and the DM-MC02 V1.1 manual under [`skills/mambo/references`](skills/mambo/references) and [`skills/mambo/scripts`](skills/mambo/scripts). diff --git a/skills/mambo/SKILL.md b/skills/mambo/SKILL.md new file mode 100644 index 0000000..ec0e528 --- /dev/null +++ b/skills/mambo/SKILL.md @@ -0,0 +1,42 @@ +--- +name: mambo +description: "Configure, build, and debug small robots with the Mambo Zephyr embedded framework, especially the Damiao DM-MC02 board and AresPlot. Use for Mambo/Zephyr environment setup, devicetree and Kconfig work, safe board bring-up, small robot applications, UART diagnostics, and trusted AresPlot capture." +--- + +# Mambo + +Guide a user from evidence to one small, working Mambo robot increment at a time. This is the Agentic Embedded contract: inspect the repository and generated artifacts, teach the reason for each step, make the smallest testable change, and preserve a reproducible record of commands and observations. Prefer the DM-MC02 unless the user names another supported board. Keep all actuator power disabled until the user has confirmed the electrical setup and explicitly asks to enable it. + +## Select Mode + +- Start in beginner mode. Read [beginner.md](references/beginner.md). Use simple Chinese, define a term the first time it matters, state cause and effect, and request or run one objective check before moving on. +- Enter expert mode only when the user explicitly says `专家模式`, `老手模式`, or an unambiguous equivalent. Read [expert.md](references/expert.md). Be concise and batch related diagnostics. +- Return to beginner mode when the user says `新手模式` or equivalent. Also fall back, and say why, when wiring, board identity, build output, flash state, or observed behavior conflicts with an assumption. +- Treat beginner and expert as conversation branches, not Git branches. A mode change alters teaching depth and diagnostic batching; it never weakens evidence or safety requirements. +- Keep the safety gates in [troubleshooting-safety.md](references/troubleshooting-safety.md) in both modes. Never flash, reset through a probe, open a debug probe, or enable motor power unless the user explicitly authorizes it and the board, probe, target, and connection are confirmed. + +## Route The Work + +1. Establish facts first: repository root, `west` workspace, board target, application, toolchain, and the exact physical connection. Read [environment.md](references/environment.md) for host setup and checks. +2. For DM-MC02, read [dm-mc02-wiring.md](references/dm-mc02-wiring.md) before proposing a cable. Treat its unresolved items as blockers, not guesses. The original manual is [dm-mc02-manual-v1.1.pdf](references/dm-mc02-manual-v1.1.pdf). +3. Make the smallest app change that demonstrates one behavior. Inspect the existing sample, board DTS, overlays, Kconfig, and generated build artifacts before editing. Keep motor outputs and controlled XT30 power off during bring-up unless authorized. +4. Build before diagnosing runtime behavior. Report the exact command and result; distinguish a successful build from a flashed image and from an observed board behavior. +5. Use AresPlot only on a trusted debug target. Read [aresplot.md](references/aresplot.md) and [aresplot-config.md](references/aresplot-config.md) before configuring it. + +## AresPlot Loop + +Use this fixed loop: baseline build; add minimal observables; build; obtain flash authorization; connect the dedicated serial port; capture or plot; inspect one selected interval; form one hypothesis; change one thing; repeat. Do not use `SET_VARIABLE`; the bundled capture tool cannot and must not send it. + +- Use the browser workflow at [Web Serial Plotter](https://captainkaz.github.io/web-serial-plotter/) with the matching ELF only on a trusted debug build. +- Use `scripts/aresplot_capture.py` for a read-only, reproducible CSV capture. Require `--trusted-debug-target` and a reviewed JSON config. Start from [aresplot-capture-example.json](references/aresplot-capture-example.json). +- Use `scripts/render_aresplot_csv.py` to inspect a CSV interval offline. Run both scripts with the same Python environment that supplies their optional dependencies. + +## Load References Deliberately + +- Read [beginner.md](references/beginner.md) for guided setup and a first program. +- Read [expert.md](references/expert.md) for concise diagnostics and evidence batching. +- Read [environment.md](references/environment.md) for Linux, macOS, Windows, west, Python, and serial permissions. +- Read [dm-mc02-wiring.md](references/dm-mc02-wiring.md) for the board-side UART and SWD facts and their limits. +- Read [aresplot.md](references/aresplot.md) for protocol, devicetree, Kconfig, ELF, sampling, and bandwidth details. +- Read [aresplot-config.md](references/aresplot-config.md) for capture JSON fields, address windows, types, and CLI use. +- Read [troubleshooting-safety.md](references/troubleshooting-safety.md) when a build, serial link, probe, or observed behavior fails. diff --git a/skills/mambo/agents/openai.yaml b/skills/mambo/agents/openai.yaml new file mode 100644 index 0000000..93ecc9a --- /dev/null +++ b/skills/mambo/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Mambo Embedded Robotics" + short_description: "Build and debug Mambo Zephyr robots safely" + default_prompt: "Use $mambo to build and safely debug my DM-MC02 robot program." diff --git a/skills/mambo/references/aresplot-capture-example.json b/skills/mambo/references/aresplot-capture-example.json new file mode 100644 index 0000000..0da6ad5 --- /dev/null +++ b/skills/mambo/references/aresplot-capture-example.json @@ -0,0 +1,21 @@ +{ + "serial_port": "/dev/ttyUSB0", + "baud_rate": 921600, + "sample_rate_hz": 100, + "duration_seconds": 3, + "output_csv": "capture.csv", + "ack_timeout_seconds": 2, + "max_frame_bytes": 256, + "variables": [ + { + "name": "loop_time_us", + "address": "0x24000100", + "type": "uint32" + }, + { + "name": "setpoint", + "address": "0x24000104", + "type": "float32" + } + ] +} diff --git a/skills/mambo/references/aresplot-capture-schema.json b/skills/mambo/references/aresplot-capture-schema.json new file mode 100644 index 0000000..abfc8bd --- /dev/null +++ b/skills/mambo/references/aresplot-capture-schema.json @@ -0,0 +1,134 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Mambo AresPlot Read-Only Capture Configuration", + "type": "object", + "required": [ + "serial_port", + "baud_rate", + "duration_seconds", + "output_csv", + "variables" + ], + "properties": { + "serial_port": { + "type": "string", + "minLength": 1 + }, + "baud_rate": { + "type": "integer", + "minimum": 1 + }, + "sample_rate_hz": { + "type": "integer", + "minimum": 1, + "maximum": 1000 + }, + "sample_period_ms": { + "type": "integer", + "minimum": 1 + }, + "duration_seconds": { + "type": "number", + "exclusiveMinimum": 0 + }, + "output_csv": { + "type": "string", + "minLength": 1 + }, + "ack_timeout_seconds": { + "type": "number", + "exclusiveMinimum": 0 + }, + "max_frame_bytes": { + "type": "integer", + "minimum": 64, + "maximum": 512 + }, + "max_variables": { + "type": "integer", + "minimum": 1, + "maximum": 50 + }, + "address_ranges": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["start", "end"], + "properties": { + "start": { + "$ref": "#/$defs/address" + }, + "end": { + "$ref": "#/$defs/address" + } + }, + "additionalProperties": false + } + }, + "variables": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["name", "address", "type"], + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "address": { + "$ref": "#/$defs/address" + }, + "type": { + "enum": [ + "int8", + "uint8", + "int16", + "uint16", + "int32", + "uint32", + "float32", + "bool" + ] + } + }, + "additionalProperties": false + } + } + }, + "allOf": [ + { + "oneOf": [ + { + "required": ["sample_rate_hz"], + "not": { + "required": ["sample_period_ms"] + } + }, + { + "required": ["sample_period_ms"], + "not": { + "required": ["sample_rate_hz"] + } + } + ] + } + ], + "additionalProperties": false, + "$defs": { + "address": { + "oneOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string", + "pattern": "^(0x[0-9a-fA-F]+|[0-9]+)$" + } + ] + } + } +} diff --git a/skills/mambo/references/aresplot-config.md b/skills/mambo/references/aresplot-config.md new file mode 100644 index 0000000..6987dae --- /dev/null +++ b/skills/mambo/references/aresplot-config.md @@ -0,0 +1,30 @@ +# AresPlot Capture Config + +Run only on a trusted debug target. The capture CLI sends `SET_SAMPLE_RATE`, then `START_MONITOR`, and finally a zero-variable `START_MONITOR` to stop; it never implements or sends `SET_VARIABLE`. + +## JSON Fields + +Required fields are `serial_port`, `baud_rate`, exactly one of `sample_rate_hz` or `sample_period_ms`, `duration_seconds`, `output_csv`, and `variables`. Use `aresplot-capture-schema.json` for the machine-readable schema and `aresplot-capture-example.json` as the starting configuration. + +Each variable requires `name`, `address`, and `type`. Addresses accept JSON integers or strings such as `"0x24000100"`. Names and addresses must be unique, and each address must be naturally aligned for its type. Accepted CLI types are `int8`, `uint8`, `int16`, `uint16`, `int32`, `uint32`, `float32`, and `bool`. The header also names `float64`, but this CLI rejects it because the current firmware emits a placeholder `0.0`, not a float64 observation. + +## Choose Addresses From Evidence + +Never invent or reuse an address from another build. Build first, preserve `build/zephyr/zephyr.elf`, and resolve each symbol from that exact ELF. The Web Serial Plotter can search the imported ELF. For a command-line check, read `CMAKE_NM:FILEPATH` from the build directory's `CMakeCache.txt`, then run that `arm-zephyr-eabi-nm` executable with `-S -n` on `zephyr.elf` and match the complete symbol name. + +Monitor only stable, naturally aligned global or `static` scalar observables. Prefer a dedicated `static volatile` debug mirror updated by the thread that owns the source state; `volatile` keeps the observation in memory but is not a synchronization primitive. Do not monitor a stack local, a heap object that may be freed, MMIO, a pointer value when the intended data is its target, a multiword snapshot, or an actuator command variable. Rebuild and re-resolve every address after code, Kconfig, devicetree, linker, or toolchain changes. + +Optional fields: `ack_timeout_seconds` (default 2), `max_frame_bytes` (default 256, range 64..512), and `address_ranges`. An omitted range list uses the DM-MC02 Zephyr memory regions: DTCM `0x20000000..0x2001ffff`, SRAM0 `0x24000000..0x2404ffff`, SRAM1/2 `0x30000000..0x30007fff`, and SRAM4 `0x38000000..0x38003fff`. A supplied range list can only narrow the permitted target addresses. The CLI checks that every typed value fits inside one permitted range. + +Start from `aresplot-capture-example.json`. Override config values with `--port`, `--baud`, `--sample-rate-hz`, `--sample-period-ms`, `--duration`, and `--output`. Exactly one rate form is accepted: `sample_period_ms` is converted to a whole `sample_rate_hz` before transmission; the wire payload is always the firmware protocol's `uint32 rate_hz` little-endian value. Pass `--trusted-debug-target` deliberately. Inspect timestamp gaps after every capture and reduce rate or series count when gaps occur. + +```sh +python3 scripts/aresplot_capture.py \ + --config references/aresplot-capture-example.json \ + --trusted-debug-target --duration 3 + +python3 scripts/render_aresplot_csv.py capture.csv \ + --series loop_time_us --start 0 --end 3 --output loop.png +``` + +`--start` and `--end` are seconds in the renderer's unwrapped MCU timeline. The capture summary counts MCU timestamp gaps larger than two expected periods; treat a nonzero count as a reason to reduce rate or channel count. The renderer reports malformed rows and timestamp wraps/resets instead of silently treating them as normal samples. diff --git a/skills/mambo/references/aresplot.md b/skills/mambo/references/aresplot.md new file mode 100644 index 0000000..e34f2e2 --- /dev/null +++ b/skills/mambo/references/aresplot.md @@ -0,0 +1,27 @@ +# AresPlot In Mambo + +Use AresPlot only on a trusted debug target. The protocol accepts raw MCU addresses and the firmware dereferences them. Read-only host tooling does not make an untrusted image or wrong address harmless. + +## Repository Behavior + +- `include/ares/protocol/plotter/aresplot_protocol.h` defines SOP `0xA5`, EOP `0x5A`, command IDs, types, and defaults. +- `lib/ares/protocol/plotter/aresplot_protocol.c` frames as `SOP, CMD, LEN_LE16, payload, XOR checksum, EOP`. The XOR covers CMD, both length bytes, and payload. +- `START_MONITOR` payload is a count byte followed by `address_le32, type_u8` for each variable. A zero count stops monitoring. `SET_SAMPLE_RATE` carries `rate_hz_le32`. ACK payload is `acknowledged_cmd, status`. +- Monitor data is `timestamp_ms_le32` followed by one `float32` per requested variable. The firmware converts supported source types to float32 before sending. +- `CONFIG_PLOTTER=y` creates an automatic UART binding using `DT_ALIAS(plot)`. Define a `plot` alias in the final devicetree or use the manual binding path demonstrated by `plotter_demo`. +- `CONFIG_PLOTTER` selects the protocol, UART interface, and ARES communication library. `CONFIG_ARESPLOT_FREQ` has range 1..1000 and affects the automatic default. `CONFIG_ARESPLOT_MAX_VARS_TO_MONITOR` defaults to 10; `CONFIG_ARESPLOT_SHARED_BUFFER_SIZE` defaults to 256. +- `samples/communication/plotter_demo` shows explicit binding and variable registration. `plotter_auto` shows automatic binding but its board setup must still supply the selected UART and `plot` alias. + +## UART And ELF Workflow + +Use 921600 baud, 8N1 on the dedicated AresPlot UART when the application/overlay configures that rate. Preserve `build/zephyr/zephyr.elf`; import it into [Web Serial Plotter](https://captainkaz.github.io/web-serial-plotter/) to select symbols by name. Treat ELF addresses as valid only for that exact build and trusted board image. Rebuild, re-import, and review the address list after layout-changing code or configuration changes. + +## Types And Limits + +The header defines `int8`, `uint8`, `int16`, `uint16`, `int32`, `uint32`, `float32`, `float64`, and `bool`. The capture CLI accepts every implemented monitoring type except `float64`: it rejects `float64` with a compatibility error because the current sender has no `FLOAT64` case and emits `0.0` through `default`. `bool` is sent as `0.0` or `1.0`. + +For `N` variables, each monitor payload is `4 + 4N` bytes and the full outgoing frame is `10 + 4N` bytes. At 921600 baud, 8N1 carries at most about 92,160 bytes/s before protocol, scheduling, and logging overhead. Approximate required wire bytes/s as `(10 + 4N) * sample_rate_hz`; leave margin and start low. Sampling faster than the variable update rate only repeats values. The firmware's timer drives sends and `send_raw()` calls `uart_tx()` directly without queue/backpressure, so do not claim a lossless 1000 Hz capture. Detect timestamp gaps in the CSV and lower rate or channel count if the UART cannot keep up. Rates above 1000 collapse to a 1 ms period even though the host command accepts a 32-bit rate. + +## Debug Loop + +Build a baseline. Add one or two non-actuating observables. Build again. Obtain flash authorization. Connect the dedicated serial link. Capture/plot a short interval. Inspect one interval, state one hypothesis, change one thing, and repeat. Never use plotter control features to write a variable during this workflow. diff --git a/skills/mambo/references/beginner.md b/skills/mambo/references/beginner.md new file mode 100644 index 0000000..52d095b --- /dev/null +++ b/skills/mambo/references/beginner.md @@ -0,0 +1,25 @@ +# Beginner Workflow + +Use this route by default. Say what is being checked, why it matters, and what result would change the next step. Do not claim that a cable, flash, or sensor works without evidence. + +## One Step At A Time + +1. Ask for the goal in one sentence: for example, "blink the status LED", "read the IMU", or "drive one motor only after a dry-run." Define **board** as the physical controller and **application** as the source directory that becomes its firmware. +2. Identify the board with the user. Recommend DM-MC02 for a new Mambo robot unless a different board is already in use. Confirm its printed label, supply arrangement, and whether motors or controlled XT30 outputs are disconnected. +3. Run the environment checks in `environment.md`. Explain that `west build` creates firmware files; it does not put them on the board. +4. Choose an existing sample or the repository template. Read its `CMakeLists.txt`, `prj.conf`, source, board overlay, and the target board DTS before changing anything. Explain **devicetree** as the hardware map and **Kconfig** as the feature switch list. +5. Implement one visible behavior with all unsafe outputs left off. Use a small state machine or timer, not a busy loop. Add one log line only after its UART route is known. +6. Build the exact application for the exact board. Inspect the build result and generated `zephyr.dts`/`.config` when configuration is in doubt. +7. Ask separately for permission to flash. Before flashing, reconfirm board, runner/probe, target, and power state. A successful flash command is not proof that the program started. +8. Observe one physical or serial result. When it differs from expectation, preserve the result, form one hypothesis, change one thing, and rebuild. + +## Explain Early Terms + +- **Overlay**: an application-local devicetree patch that selects or configures hardware without changing the board definition. +- **UART**: two signal wires for serial data plus a shared ground. TX means data leaving that device; it normally connects to the other device's RX. +- **ELF**: the build artifact that retains symbol names and addresses. AresPlot's web workflow uses it to map selected variables to addresses. +- **Probe**: a debugger such as CMSIS-DAP, DAPLink, J-Link, or ST-Link. It can reset or program a board, so it is never used implicitly. + +## First AresPlot Observation + +First prove the normal program builds. Then add one or two non-actuating `static` observables, preserve the ELF, and use USART1 only after the dedicated UART wiring is confirmed. Capture a short baseline before changing control parameters. See `aresplot.md` and `aresplot-config.md`. diff --git a/skills/mambo/references/dm-mc02-manual-v1.1.pdf b/skills/mambo/references/dm-mc02-manual-v1.1.pdf new file mode 100644 index 0000000..0d07724 Binary files /dev/null and b/skills/mambo/references/dm-mc02-manual-v1.1.pdf differ diff --git a/skills/mambo/references/dm-mc02-wiring.md b/skills/mambo/references/dm-mc02-wiring.md new file mode 100644 index 0000000..d4e1b77 --- /dev/null +++ b/skills/mambo/references/dm-mc02-wiring.md @@ -0,0 +1,38 @@ +# DM-MC02 Wiring Facts + +Use the DM-MC02 for new Mambo work unless another board is already selected. This reference separates board-side facts from probe-side assumptions. + +## Verified Board-Side Facts + +| Function | Board connector pin / MCU pin | Evidence | +| --- | --- | --- | +| USART1 GND | pin 1 / GND | Manual PDF p. 8, printed manual p. 6 | +| USART1 TX | pin 2 / `USART1_TX`, PA9 | Manual PDF p. 8, printed manual p. 6; repository USART1 overlay | +| USART1 RX | pin 3 / `USART1_RX`, PA10 | Manual PDF p. 8, printed manual p. 6; repository USART1 overlay | +| UART10 GND | pin 1 / GND | Manual PDF p. 9, printed manual p. 7 | +| UART10 TX | pin 2 / `UART10_TX`, PE3 | Manual PDF p. 9, printed manual p. 7; board DTS | +| UART10 RX | pin 3 / `UART10_RX`, PE2 | Manual PDF p. 9, printed manual p. 7; board DTS | +| SWD VCC | pin 1 / 3.3 V target reference | Manual PDF p. 9, printed manual p. 7 | +| SWD GND | pin 2 / GND | Manual PDF p. 9, printed manual p. 7 | +| SWCLK | pin 3 / PA14 | Manual PDF p. 9, printed manual p. 7 | +| SWDIO | pin 4 / PA13 | Manual PDF p. 9, printed manual p. 7 | + +`boards/damiao/dm_mc02/dm_mc02.dts` selects USART10 as the console and maps RX PE2, TX PE3 at 115200. `samples/communication/ares_communication/boards/dm_mc02_usart1_921600.overlay` enables USART1 at PA9/PA10, 921600 baud, with DMA. + +## Intended Debug Topology, Pending Probe Confirmation + +Use a CMSIS-DAP/DAPLink-style probe that provides both SWD and a serial bridge. The desired separation is SWD for authorized programming/debug and its serial bridge on USART10 for the 115200 console. Before connecting, inspect the specific probe manual/label and confirm every probe-side pin, voltage tolerance, and whether VTref is a sense input. + +Only after that confirmation, map signal names rather than connector positions: probe SWDIO to board SWDIO, probe SWCLK to board SWCLK, probe GND to board GND, probe UART TX to board UART10 RX, and probe UART RX to board UART10 TX. Treat SWD VCC as VTref/sense by default, not permission to power the target from the probe. The UART10 schematic also depicts a 5 V supply pin: normally connect only common GND and crossed TX/RX, and never tie a probe UART power output to that header unless the complete power scheme is intentionally verified. + +The provided board manual does **not** establish that an arbitrary CMSIS-DAP/DAPLink has a CDC/UART bridge, identify its serial bridge pins, define its VTref direction, or provide a board-to-probe cable. Those facts remain unresolved until the exact probe model's documentation and host device enumeration are checked. + +## Dedicated AresPlot UART + +Use a Damiao debugging tool or USB-to-UART adapter on USART1 as the dedicated 921600 AresPlot link only after confirming the exact adapter model, its 3.3 V TTL compatibility, pinout, and host device enumeration. Cross TX/RX by signal name and share GND. The board manual confirms USART1's board-side pins, but does not document the debugging tool/adapter connector or voltage; treat those as unresolved. + +USART1 is one hardware peripheral. Do not assign it to AresPlot and another UART peripheral simultaneously. Disable or move the competing node first, then verify the final generated `zephyr.dts`. + +## Manual Limits + +The original manual is bundled as `dm-mc02-manual-v1.1.pdf`. Its PDF p. 17 (printed p. 15) contains the board connector map for physical location. It documents the board and its connectors, not the host probe. Never infer a connector's mechanical order, VTref behavior, power direction, or adapter logic level from the MCU pin name alone. diff --git a/skills/mambo/references/environment.md b/skills/mambo/references/environment.md new file mode 100644 index 0000000..65ea44b --- /dev/null +++ b/skills/mambo/references/environment.md @@ -0,0 +1,109 @@ +# Host And Workspace Checks + +Run checks before installing or building. Use the active Python environment for `west`; do not mix a system `west` with a different virtual environment by accident. + +## Choose The Workspace Path + +First run `west topdir`. If it succeeds, preserve that workspace and inspect it with `west list zephyr`; do not initialize over it. If a Mambo checkout exists but no west workspace does, initialize from the local manifest after confirming the intended parent directory: + +```sh +cd +west init -l mambo +west update +west zephyr-export +python3 -m pip install -r zephyr/scripts/requirements.txt +``` + +For a new remote checkout, follow the repository `README.md`. The current documented sequence is below; `west update` and SDK installation download substantial content, so explain that and obtain approval before running them: + +```sh +west init -m https://github.com/ttwards/mambo --mr master my-workspace +cd my-workspace +west update +west zephyr-export +python3 -m pip install -r zephyr/scripts/requirements.txt +west sdk install -t arm-zephyr-eabi +``` + +Run these workspace commands only after creating and activating the host-specific virtual environment below. On Windows, use `py -m pip` where the examples use `python3 -m pip`. Install the capture dependencies in that same environment when AresPlot CSV tools are needed: + +```sh +python3 -m pip install pyserial matplotlib +``` + +## Linux + +Install the repository-documented build prerequisites, then create an isolated Python environment: + +```sh +python3 -m venv .venv +. .venv/bin/activate +python3 -m pip install --upgrade pip wheel +python3 -m pip install west +west --version +west topdir +west list zephyr +``` + +Check the board identifier instead of assuming it: + +```sh +west boards | rg 'dm_mc02|DM_MC02' +west build -b dm_mc02 samples/motor/dm_demo --pristine +``` + +Only run the build after checking that the sample and target exist. The target name may vary with the installed Zephyr version. + +For a serial device, inspect ownership first: + +```sh +ls -l /dev/ttyACM* /dev/ttyUSB* 2>/dev/null +id -nG +udevadm info -q property -n /dev/ttyACM0 +``` + +If the device group is `dialout`, add the current user only after explaining that a new login is needed: `sudo usermod -aG dialout "$USER"`. Do not use `chmod 666`. + +Generate a udev rule only after detecting the specific device VID/PID with `udevadm`. Use a narrow template, substitute the observed lowercase hexadecimal values, and ask before writing under `/etc/udev/rules.d/`: + +```udev +SUBSYSTEM=="tty", ATTRS{idVendor}=="vvvv", ATTRS{idProduct}=="pppp", GROUP="dialout", MODE="0660", TAG+="uaccess" +``` + +Reload and reconnect only after approval: `sudo udevadm control --reload-rules && sudo udevadm trigger`. Do not create a wildcard rule or a `MODE="0666"` rule. + +## macOS + +Use `/dev/cu.*` for an outbound serial connection and inspect devices after reconnecting: + +```sh +ls /dev/cu.* +python3 --version +python3 -m venv .venv +. .venv/bin/activate +python3 -m pip install --upgrade pip wheel +python3 -m pip install west +west --version +west topdir +``` + +macOS does not use Linux `dialout` or udev rules. Resolve serial access through the device driver, user session, and the selected `/dev/cu.*` node. + +## Windows + +Use PowerShell and a Python virtual environment. Confirm the COM port in Device Manager and the board/probe driver before opening it: + +```powershell +py -m venv .venv +.\.venv\Scripts\Activate.ps1 +py -m pip install --upgrade pip wheel +py -m pip install west +west --version +west topdir +``` + +Use `COM3`-style port names in the capture config. Do not use Linux group or udev commands on Windows. + +## Build Facts + +Run `west build` from the Mambo repository or pass explicit paths. Inspect `build/zephyr/zephyr.dts`, `build/zephyr/.config`, and `build/zephyr/zephyr.elf` after a successful build. `west flash` is a separate action requiring explicit approval and confirmed hardware. diff --git a/skills/mambo/references/expert.md b/skills/mambo/references/expert.md new file mode 100644 index 0000000..8591afa --- /dev/null +++ b/skills/mambo/references/expert.md @@ -0,0 +1,18 @@ +# Expert Mode + +Use only after an explicit expert-mode request. Keep facts, commands, logs, and unverified assumptions separate. Retain every safety gate. + +## Batch Intake + +Request or collect: `west topdir`; `west --version`; Python interpreter; exact board target from `west boards`; application path; `prj.conf`; overlays; generated `build/zephyr/zephyr.dts`; generated `.config`; build command and tail of output; probe/serial identity; power and motor state. + +## Focused Routes + +- Build/config: compare source DTS/Kconfig with generated artifacts; clean/pristine only when configuration cache evidence supports it. +- DM-MC02 UART: console is USART10 in board DTS at 115200. The repository's USART1 overlay uses PA9/PA10 at 921600 with async UART DMA. Do not multiplex USART1 between AresPlot and another peripheral. +- AresPlot: verify `DT_ALIAS(plot)` exists for `CONFIG_PLOTTER`, or use the manual protocol binding route. Verify UART async API, DMA, and non-cache memory requirements for the STM32H7 path before blaming the protocol. +- Runtime: baseline, minimal observables, one interval, one hypothesis, one change. Calculate wire budget before raising rate or variable count. + +## Evidence Conflict + +Drop back to beginner mode when a claimed board, UART, flash state, power state, or observation conflicts with the checked artifact. State the conflict, request the smallest discriminating check, then resume only after it resolves. diff --git a/skills/mambo/references/troubleshooting-safety.md b/skills/mambo/references/troubleshooting-safety.md new file mode 100644 index 0000000..bc6b744 --- /dev/null +++ b/skills/mambo/references/troubleshooting-safety.md @@ -0,0 +1,20 @@ +# Troubleshooting And Safety + +## Required Gates + +- Confirm the board, application, build directory, probe, serial adapter, and target before any action that can alter hardware state. +- Obtain explicit user authorization before `west flash`, reset, probe attach, OpenOCD, pyOCD, J-Link, STM32CubeProgrammer, or motor/XT30 power enablement. +- Keep a debug console and AresPlot on separate UART peripherals when possible. On DM-MC02 use USART10 for the console topology and USART1 for the dedicated plotter topology only after wiring verification. +- Do not claim hardware validation unless it was actually observed on the named hardware. + +## Diagnose In Order + +1. Build failure: capture the exact command, first diagnostic, target, overlay list, and generated config. +2. Flash uncertainty: check authorization, runner/probe identity, board power, and target. A completed command proves only its reported result. +3. Silent console: verify selected UART, baud, TX/RX crossing, GND, voltage level, and that no other process owns the host port. +4. Silent AresPlot: verify trusted ELF/build match, USART1 ownership, 921600 setting, `plot` alias or explicit binding, async UART/DMA requirements, and frame/baud budget. +5. Bad plot: first reject a timestamp reset/wrap, timestamp gaps from UART overload, noisy serial data, sample-rate mismatch, and observables updated slower than capture. Lower rate/channel count before assuming a control bug. Then test one source-level hypothesis. + +## Stop Conditions + +Stop and request evidence when the board or probe identity is unknown, voltage/pinout is undocumented, a physical connection conflicts with DTS/manual facts, a serial port cannot be attributed to the intended device, or motor power would be required to continue. Do not bridge the gap with a guess. diff --git a/skills/mambo/scripts/aresplot_capture.py b/skills/mambo/scripts/aresplot_capture.py new file mode 100755 index 0000000..77c2564 --- /dev/null +++ b/skills/mambo/scripts/aresplot_capture.py @@ -0,0 +1,596 @@ +#!/usr/bin/env python3 +"""Capture read-only AresPlot monitor data to timestamped CSV.""" + +from __future__ import annotations + +import argparse +import csv +import datetime as dt +import json +import math +import struct +import sys +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Iterable + +SOP = 0xA5 +EOP = 0x5A +CMD_START_MONITOR = 0x01 +CMD_SET_SAMPLE_RATE = 0x03 +CMD_MONITOR_DATA = 0x81 +CMD_ACK = 0x82 +MAX_PROTOCOL_FRAME = 512 + +TYPE_CODES = { + "int8": 0x00, + "uint8": 0x01, + "int16": 0x02, + "uint16": 0x03, + "int32": 0x04, + "uint32": 0x05, + "float32": 0x06, + "float64": 0x07, + "bool": 0x08, +} +TYPE_SIZES = { + "int8": 1, + "uint8": 1, + "int16": 2, + "uint16": 2, + "int32": 4, + "uint32": 4, + "float32": 4, + "float64": 8, + "bool": 1, +} +ACK_STATUS = { + 0x00: "OK", + 0x01: "ERROR_CHECKSUM", + 0x02: "ERROR_UNKNOWN_CMD", + 0x03: "ERROR_INVALID_PAYLOAD", + 0x04: "ERROR_ADDR_INVALID", + 0x05: "ERROR_TYPE_UNSUPPORTED", + 0x06: "ERROR_RATE_UNACHIEVABLE", + 0x07: "ERROR_MCU_BUSY_OR_LIMIT", + 0xFF: "ERROR_GENERAL_FAIL", +} +DM_MC02_SAFE_RANGES = ( + (0x20000000, 0x2001FFFF), + (0x24000000, 0x2404FFFF), + (0x30000000, 0x30007FFF), + (0x38000000, 0x38003FFF), +) + + +class CaptureError(RuntimeError): + """Report an actionable capture setup or protocol error.""" + + +@dataclass(frozen=True) +class Variable: + name: str + address: int + type_name: str + + +@dataclass(frozen=True) +class CaptureConfig: + serial_port: str + baud_rate: int + sample_rate_hz: int + duration_seconds: float + output_csv: Path + variables: tuple[Variable, ...] + ack_timeout_seconds: float + max_frame_bytes: int + + +@dataclass(frozen=True) +class Frame: + command: int + payload: bytes + + +class FrameParser: + """Incrementally recover valid AresPlot frames from a noisy byte stream.""" + + def __init__(self, max_frame_bytes: int = 256) -> None: + if not 6 <= max_frame_bytes <= MAX_PROTOCOL_FRAME: + raise ValueError("max_frame_bytes must be within 6..512") + self.max_frame_bytes = max_frame_bytes + self.buffer = bytearray() + self.bad_checksum = 0 + self.bad_eop = 0 + self.bad_length = 0 + self.discarded_bytes = 0 + + def feed(self, data: bytes) -> list[Frame]: + self.buffer.extend(data) + frames: list[Frame] = [] + while True: + start = self.buffer.find(bytes((SOP,))) + if start < 0: + self.discarded_bytes += len(self.buffer) + self.buffer.clear() + break + if start: + self.discarded_bytes += start + del self.buffer[:start] + if len(self.buffer) < 4: + break + + command = self.buffer[1] + payload_len = self.buffer[2] | (self.buffer[3] << 8) + frame_len = 6 + payload_len + if frame_len > self.max_frame_bytes: + self.bad_length += 1 + del self.buffer[0] + continue + if len(self.buffer) < frame_len: + break + + payload_end = 4 + payload_len + payload = bytes(self.buffer[4:payload_end]) + received_checksum = self.buffer[payload_end] + received_eop = self.buffer[payload_end + 1] + expected_checksum = checksum(command, payload_len, payload) + if received_eop != EOP: + self.bad_eop += 1 + del self.buffer[0] + continue + if received_checksum != expected_checksum: + self.bad_checksum += 1 + del self.buffer[0] + continue + frames.append(Frame(command, payload)) + del self.buffer[:frame_len] + return frames + + +def checksum(command: int, payload_len: int, payload: bytes) -> int: + value = command ^ (payload_len & 0xFF) ^ ((payload_len >> 8) & 0xFF) + for byte in payload: + value ^= byte + return value + + +def make_frame(command: int, payload: bytes) -> bytes: + if len(payload) > 0xFFFF: + raise CaptureError("AresPlot payload exceeds the uint16 protocol length") + length = len(payload) + return ( + bytes((SOP, command)) + + struct.pack(" int: + if isinstance(value, bool): + raise CaptureError(f"{field} must be an integer, not a boolean") + if isinstance(value, int): + return value + if not isinstance(value, str): + raise CaptureError(f"{field} must be an integer or 0x-prefixed integer string") + try: + parsed = int(value, 16 if value.lower().startswith("0x") else 10) + except ValueError as exc: + raise CaptureError( + f"{field} must be an integer or 0x-prefixed integer string" + ) from exc + return parsed + + +def _inside_ranges(address: int, size: int, ranges: Iterable[tuple[int, int]]) -> bool: + end = address + size - 1 + return any(start <= address and end <= stop for start, stop in ranges) + + +def _parse_narrow_ranges(value: Any) -> tuple[tuple[int, int], ...]: + if value is None: + return DM_MC02_SAFE_RANGES + if not isinstance(value, list) or not value: + raise CaptureError("address_ranges must be a non-empty list when provided") + parsed: list[tuple[int, int]] = [] + for index, item in enumerate(value): + if not isinstance(item, dict): + raise CaptureError(f"address_ranges[{index}] must be an object") + start = _parse_int(item.get("start"), f"address_ranges[{index}].start") + stop = _parse_int(item.get("end"), f"address_ranges[{index}].end") + if start > stop or not _inside_ranges( + start, stop - start + 1, DM_MC02_SAFE_RANGES + ): + raise CaptureError( + f"address_ranges[{index}] must narrow a known DM-MC02 RAM range, not expand it" + ) + parsed.append((start, stop)) + return tuple(parsed) + + +def _load_json(path: Path) -> dict[str, Any]: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError as exc: + raise CaptureError(f"config file not found: {path}") from exc + except UnicodeDecodeError as exc: + raise CaptureError(f"config is not UTF-8 JSON: {path}") from exc + except json.JSONDecodeError as exc: + raise CaptureError( + f"bad JSON in {path}: line {exc.lineno}, column {exc.colno}: {exc.msg}" + ) from exc + if not isinstance(value, dict): + raise CaptureError("config root must be a JSON object") + return value + + +def load_config(path: Path, args: argparse.Namespace) -> CaptureConfig: + raw = _load_json(path) + if args.port is not None: + raw["serial_port"] = args.port + if args.baud is not None: + raw["baud_rate"] = args.baud + if args.duration is not None: + raw["duration_seconds"] = args.duration + if args.output is not None: + raw["output_csv"] = args.output + if args.sample_rate_hz is not None: + raw.pop("sample_period_ms", None) + raw["sample_rate_hz"] = args.sample_rate_hz + if args.sample_period_ms is not None: + raw.pop("sample_rate_hz", None) + raw["sample_period_ms"] = args.sample_period_ms + + required = ( + "serial_port", + "baud_rate", + "duration_seconds", + "output_csv", + "variables", + ) + missing = [field for field in required if field not in raw] + if missing: + raise CaptureError("missing required config field(s): " + ", ".join(missing)) + has_rate = "sample_rate_hz" in raw + has_period = "sample_period_ms" in raw + if has_rate == has_period: + raise CaptureError("provide exactly one of sample_rate_hz or sample_period_ms") + if has_period: + period_ms = _parse_int(raw["sample_period_ms"], "sample_period_ms") + if period_ms <= 0: + raise CaptureError("sample_period_ms must be greater than zero") + sample_rate_hz = max(1, 1000 // period_ms) + else: + sample_rate_hz = _parse_int(raw["sample_rate_hz"], "sample_rate_hz") + if not 1 <= sample_rate_hz <= 1000: + raise CaptureError( + "sample_rate_hz must be within 1..1000 for the current firmware timer" + ) + + port = raw["serial_port"] + if not isinstance(port, str) or not port.strip(): + raise CaptureError("serial_port must be a non-empty string") + baud_rate = _parse_int(raw["baud_rate"], "baud_rate") + if baud_rate <= 0: + raise CaptureError("baud_rate must be greater than zero") + try: + duration_seconds = float(raw["duration_seconds"]) + except (TypeError, ValueError) as exc: + raise CaptureError("duration_seconds must be a number") from exc + if not math.isfinite(duration_seconds) or duration_seconds <= 0: + raise CaptureError("duration_seconds must be a finite number greater than zero") + try: + ack_timeout = float(raw.get("ack_timeout_seconds", 2)) + except (TypeError, ValueError) as exc: + raise CaptureError("ack_timeout_seconds must be a number") from exc + if not math.isfinite(ack_timeout) or ack_timeout <= 0: + raise CaptureError( + "ack_timeout_seconds must be a finite number greater than zero" + ) + max_frame_bytes = _parse_int(raw.get("max_frame_bytes", 256), "max_frame_bytes") + if not 64 <= max_frame_bytes <= MAX_PROTOCOL_FRAME: + raise CaptureError("max_frame_bytes must be within 64..512") + max_variables = _parse_int(raw.get("max_variables", 10), "max_variables") + if not 1 <= max_variables <= 50: + raise CaptureError("max_variables must be within 1..50") + ranges = _parse_narrow_ranges(raw.get("address_ranges")) + output_csv = raw["output_csv"] + if not isinstance(output_csv, str) or not output_csv.strip(): + raise CaptureError("output_csv must be a non-empty string") + + source_variables = raw["variables"] + if not isinstance(source_variables, list) or not source_variables: + raise CaptureError("variables must be a non-empty list") + if len(source_variables) > max_variables: + raise CaptureError( + f"variables has {len(source_variables)} entries but max_variables is {max_variables}" + ) + variables: list[Variable] = [] + names: set[str] = set() + addresses: set[int] = set() + for index, item in enumerate(source_variables): + if not isinstance(item, dict): + raise CaptureError(f"variables[{index}] must be an object") + name = item.get("name") + if not isinstance(name, str) or not name or name in names: + raise CaptureError( + f"variables[{index}].name must be a unique non-empty string" + ) + type_name = item.get("type") + if type_name == "float64": + raise CaptureError( + "float64 is not capture-compatible: current firmware sends 0.0 because it has no FLOAT64 sender case" + ) + if type_name not in TYPE_CODES: + allowed = ", ".join(name for name in TYPE_CODES if name != "float64") + raise CaptureError(f"variables[{index}].type must be one of: {allowed}") + address = _parse_int(item.get("address"), f"variables[{index}].address") + if not 0 <= address <= 0xFFFFFFFF: + raise CaptureError(f"variables[{index}].address is outside uint32 range") + if address in addresses: + raise CaptureError(f"variables[{index}].address duplicates 0x{address:08x}") + if address % TYPE_SIZES[type_name] != 0: + raise CaptureError( + f"variables[{index}].address 0x{address:08x} is not aligned for {type_name}" + ) + if not _inside_ranges(address, TYPE_SIZES[type_name], ranges): + raise CaptureError( + f"variables[{index}].address 0x{address:08x} is unsafe or outside the permitted RAM ranges" + ) + names.add(name) + addresses.add(address) + variables.append(Variable(name, address, type_name)) + + start_frame_size = 7 + 5 * len(variables) + data_frame_size = 10 + 4 * len(variables) + if start_frame_size > max_frame_bytes or data_frame_size > max_frame_bytes: + raise CaptureError( + f"variables exceed max_frame_bytes={max_frame_bytes}: start={start_frame_size}, data={data_frame_size}" + ) + return CaptureConfig( + serial_port=port, + baud_rate=baud_rate, + sample_rate_hz=sample_rate_hz, + duration_seconds=duration_seconds, + output_csv=Path(output_csv), + variables=tuple(variables), + ack_timeout_seconds=ack_timeout, + max_frame_bytes=max_frame_bytes, + ) + + +def start_monitor_payload(variables: Iterable[Variable]) -> bytes: + selected = tuple(variables) + if len(selected) > 255: + raise CaptureError("START_MONITOR supports at most 255 variables") + payload = bytearray((len(selected),)) + for variable in selected: + payload.extend(struct.pack(" None: + try: + written = serial_port.write(frame) + if written != len(frame): + raise CaptureError( + f"serial short write: sent {written!r} of {len(frame)} bytes" + ) + serial_port.flush() + except CaptureError: + raise + except Exception as exc: + raise CaptureError(f"serial write failed: {exc}") from exc + + +def _wait_for_ack( + serial_port: Any, parser: FrameParser, command: int, timeout: float +) -> None: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + data = serial_port.read(256) + except Exception as exc: + raise CaptureError( + f"serial read failed while waiting for ACK: {exc}" + ) from exc + for frame in parser.feed(data): + if frame.command != CMD_ACK: + continue + if len(frame.payload) != 2: + continue + ack_command, status = frame.payload + if ack_command != command: + continue + if status != 0: + raise CaptureError( + f"device rejected command 0x{command:02x}: {ACK_STATUS.get(status, f'0x{status:02x}') }" + ) + return + raise CaptureError( + f"timeout waiting {timeout:g}s for ACK to command 0x{command:02x}" + ) + + +def _monitor_values( + frame: Frame, variable_count: int +) -> tuple[int, tuple[float, ...]] | None: + if frame.command != CMD_MONITOR_DATA: + return None + expected = 4 + 4 * variable_count + if len(frame.payload) != expected: + return None + unpacked = struct.unpack(" tuple[int, int, FrameParser]: + parser = FrameParser(config.max_frame_bytes) + output = config.output_csv + output.parent.mkdir(parents=True, exist_ok=True) + samples = 0 + timestamp_gaps = 0 + previous_timestamp: int | None = None + expected_period_ms = max(1, 1000 // config.sample_rate_hz) + monitoring_started = False + serial_port = _open_serial(config) + try: + try: + serial_port.reset_input_buffer() + except Exception: + pass + _write_frame( + serial_port, + make_frame(CMD_SET_SAMPLE_RATE, struct.pack(" expected_period_ms * 2: + timestamp_gaps += 1 + previous_timestamp = timestamp_ms + writer.writerow( + [ + timestamp_ms, + dt.datetime.now(dt.timezone.utc).isoformat( + timespec="milliseconds" + ), + *values, + ] + ) + samples += 1 + finally: + if monitoring_started: + try: + _write_frame(serial_port, make_frame(CMD_START_MONITOR, b"\x00")) + except CaptureError as exc: + print( + f"warning: failed to stop monitoring cleanly: {exc}", + file=sys.stderr, + ) + try: + serial_port.close() + except Exception as exc: + print( + f"warning: failed to close serial port cleanly: {exc}", file=sys.stderr + ) + return samples, timestamp_gaps, parser + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--config", required=True, type=Path, help="capture JSON config" + ) + parser.add_argument( + "--trusted-debug-target", + action="store_true", + help="confirm trusted target and reviewed addresses", + ) + parser.add_argument("--port", help="override serial_port") + parser.add_argument("--baud", type=int, help="override baud_rate") + rate = parser.add_mutually_exclusive_group() + rate.add_argument( + "--sample-rate-hz", + type=int, + help="override sample rate; encoded as protocol uint32 rate_hz", + ) + rate.add_argument( + "--sample-period-ms", + type=int, + help="override period; converted to whole rate_hz before transmission", + ) + parser.add_argument("--duration", type=float, help="override duration_seconds") + parser.add_argument("--output", help="override output_csv") + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + if not args.trusted_debug_target: + print( + "error: refuse to send raw addresses without --trusted-debug-target", + file=sys.stderr, + ) + return 2 + try: + config = load_config(args.config, args) + samples, timestamp_gaps, parser = capture(config) + except CaptureError as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + except KeyboardInterrupt: + print("capture interrupted; stop command was attempted", file=sys.stderr) + return 130 + print( + f"captured {samples} samples to {config.output_csv}; timestamp_gaps={timestamp_gaps}, " + f"discarded={parser.discarded_bytes}, " + f"bad_checksum={parser.bad_checksum}, bad_eop={parser.bad_eop}, bad_length={parser.bad_length}" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/mambo/scripts/render_aresplot_csv.py b/skills/mambo/scripts/render_aresplot_csv.py new file mode 100755 index 0000000..80c587e --- /dev/null +++ b/skills/mambo/scripts/render_aresplot_csv.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 +"""Render selected AresPlot CSV series to a labeled PNG without a display server.""" + +from __future__ import annotations + +import argparse +import csv +import math +import sys +from dataclasses import dataclass +from pathlib import Path + + +class RenderError(RuntimeError): + """Report a CSV or rendering error without hiding its cause.""" + + +@dataclass(frozen=True) +class Point: + seconds: float + values: dict[str, float] + + +def _unwrapped_seconds( + raw_timestamp: int, previous_raw: int | None, previous_ms: int | None +) -> tuple[int, int]: + if previous_raw is None or previous_ms is None: + return raw_timestamp, raw_timestamp + if raw_timestamp >= previous_raw: + return raw_timestamp, previous_ms + (raw_timestamp - previous_raw) + if previous_raw - raw_timestamp > 0x80000000: + return raw_timestamp, previous_ms + ((1 << 32) - previous_raw + raw_timestamp) + # A target reboot/reset: keep a monotonic plot but record only the smallest plausible gap. + return raw_timestamp, previous_ms + 1 + + +def read_csv( + path: Path, requested: list[str] +) -> tuple[list[Point], list[str], int, int]: + try: + source = path.open(newline="", encoding="utf-8") + except FileNotFoundError as exc: + raise RenderError(f"CSV not found: {path}") from exc + with source: + reader = csv.DictReader(source) + if not reader.fieldnames or "timestamp_ms" not in reader.fieldnames: + raise RenderError("CSV must contain a timestamp_ms column") + default_series = [ + name + for name in reader.fieldnames + if name not in ("timestamp_ms", "host_time_utc") + ] + series = requested or default_series + missing = [name for name in series if name not in reader.fieldnames] + if missing: + raise RenderError("CSV is missing requested series: " + ", ".join(missing)) + if not series: + raise RenderError("CSV has no value columns; select at least one series") + + points: list[Point] = [] + malformed = 0 + resets_or_wraps = 0 + previous_raw: int | None = None + previous_unwrapped: int | None = None + first_ms: int | None = None + for line_number, row in enumerate(reader, start=2): + try: + raw = int(row["timestamp_ms"], 0) + if not 0 <= raw <= 0xFFFFFFFF: + raise ValueError("timestamp outside uint32") + values = {name: float(row[name]) for name in series} + if any(not math.isfinite(value) for value in values.values()): + raise ValueError("non-finite series value") + except (KeyError, TypeError, ValueError): + malformed += 1 + continue + if previous_raw is not None and raw < previous_raw: + resets_or_wraps += 1 + previous_raw, unwrapped = _unwrapped_seconds( + raw, previous_raw, previous_unwrapped + ) + previous_unwrapped = unwrapped + if first_ms is None: + first_ms = unwrapped + points.append(Point((unwrapped - first_ms) / 1000.0, values)) + return points, series, malformed, resets_or_wraps + + +def parse_series(items: list[str]) -> list[str]: + values: list[str] = [] + for item in items: + values.extend(value.strip() for value in item.split(",") if value.strip()) + return list(dict.fromkeys(values)) + + +def render( + input_path: Path, + output_path: Path, + series_items: list[str], + start: float | None, + end: float | None, +) -> tuple[int, int, int]: + if start is not None and end is not None and start > end: + raise RenderError("--start must not be greater than --end") + points, series, malformed, resets = read_csv(input_path, parse_series(series_items)) + selected = [ + point + for point in points + if (start is None or point.seconds >= start) + and (end is None or point.seconds <= end) + ] + if not selected: + raise RenderError("selected time interval contains no valid samples") + try: + import matplotlib + + matplotlib.use("Agg") + from matplotlib import pyplot as plt + except ImportError as exc: + raise RenderError( + "matplotlib is missing; install it with: python3 -m pip install matplotlib" + ) from exc + + figure, axis = plt.subplots(figsize=(11, 5.5), constrained_layout=True) + for name in series: + axis.plot( + [point.seconds for point in selected], + [point.values[name] for point in selected], + label=name, + linewidth=1.2, + ) + axis.set_title(f"AresPlot capture: {input_path.name}") + axis.set_xlabel("MCU time since first sample (s)") + axis.set_ylabel("Value (AresPlot float32 payload)") + axis.grid(True, alpha=0.3) + axis.legend(loc="best") + output_path.parent.mkdir(parents=True, exist_ok=True) + figure.savefig(output_path, dpi=160) + plt.close(figure) + return len(selected), malformed, resets + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("csv", type=Path, help="CSV produced by aresplot_capture.py") + parser.add_argument( + "--series", + action="append", + default=[], + help="series name; repeat or use comma-separated names", + ) + parser.add_argument( + "--start", type=float, help="inclusive start, seconds in unwrapped MCU timeline" + ) + parser.add_argument( + "--end", type=float, help="inclusive end, seconds in unwrapped MCU timeline" + ) + parser.add_argument("--output", required=True, type=Path, help="output PNG path") + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + try: + count, malformed, resets = render( + args.csv, args.output, args.series, args.start, args.end + ) + except RenderError as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + print( + f"rendered {count} samples to {args.output}; malformed_rows={malformed}, timestamp_wraps_or_resets={resets}" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/mambo/tests/test_aresplot_tools.py b/skills/mambo/tests/test_aresplot_tools.py new file mode 100644 index 0000000..160d677 --- /dev/null +++ b/skills/mambo/tests/test_aresplot_tools.py @@ -0,0 +1,276 @@ +#!/usr/bin/env python3 +"""Hardware-free tests for AresPlot stream parsing, config validation, and PNG rendering.""" + +from __future__ import annotations + +import csv +import importlib.util +import struct +import sys +import tempfile +import unittest +from pathlib import Path +from types import SimpleNamespace + +ROOT = Path(__file__).resolve().parents[1] + + +def load_module(name: str, path: Path): + spec = importlib.util.spec_from_file_location(name, path) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +capture = load_module( + "mambo_aresplot_capture", ROOT / "scripts" / "aresplot_capture.py" +) +renderer = load_module( + "mambo_aresplot_renderer", ROOT / "scripts" / "render_aresplot_csv.py" +) + + +class FrameParserTests(unittest.TestCase): + def test_fragmented_noisy_stream_recovers_monitor_frame(self) -> None: + payload = struct.pack(" None: + valid = capture.make_frame( + capture.CMD_ACK, bytes((capture.CMD_START_MONITOR, 0)) + ) + bad_checksum = bytearray(valid) + bad_checksum[-2] ^= 0x01 + bad_eop = bytearray(valid) + bad_eop[-1] = 0x00 + parser = capture.FrameParser() + received = parser.feed(bytes(bad_checksum) + bytes(bad_eop) + valid) + self.assertEqual( + received, + [capture.Frame(capture.CMD_ACK, bytes((capture.CMD_START_MONITOR, 0)))], + ) + self.assertEqual(parser.bad_checksum, 1) + self.assertEqual(parser.bad_eop, 1) + + def test_start_payload_and_rate_wire_encoding(self) -> None: + variable = capture.Variable("x", 0x24000100, "uint32") + self.assertEqual( + capture.start_monitor_payload([variable]), b"\x01\x00\x01\x00\x24\x05" + ) + frame = capture.make_frame(capture.CMD_SET_SAMPLE_RATE, struct.pack(" SimpleNamespace: + return SimpleNamespace( + port=None, + baud=None, + duration=None, + output=None, + sample_rate_hz=None, + sample_period_ms=None, + ) + + def write_config(self, directory: Path, variable_type: str = "float32") -> Path: + path = directory / "capture.json" + path.write_text( + """{ + "serial_port": "/dev/null", + "baud_rate": 921600, + "sample_period_ms": 10, + "duration_seconds": 1, + "output_csv": "capture.csv", + "variables": [{"name": "x", "address": "0x24000100", "type": "%s"}] +}""" + % variable_type, + encoding="utf-8", + ) + return path + + def test_period_converts_to_wire_rate(self) -> None: + with tempfile.TemporaryDirectory() as directory: + config = capture.load_config( + self.write_config(Path(directory)), self.args() + ) + self.assertEqual(config.sample_rate_hz, 100) + + def test_float64_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as directory: + with self.assertRaisesRegex( + capture.CaptureError, "float64 is not capture-compatible" + ): + capture.load_config( + self.write_config(Path(directory), "float64"), self.args() + ) + + def test_nonfinite_duration_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as directory: + path = self.write_config(Path(directory)) + path.write_text( + path.read_text(encoding="utf-8").replace( + '"duration_seconds": 1', '"duration_seconds": NaN' + ), + encoding="utf-8", + ) + with self.assertRaisesRegex(capture.CaptureError, "finite number"): + capture.load_config(path, self.args()) + + def test_unaligned_typed_address_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as directory: + path = self.write_config(Path(directory)) + path.write_text( + path.read_text(encoding="utf-8").replace("0x24000100", "0x24000101"), + encoding="utf-8", + ) + with self.assertRaisesRegex(capture.CaptureError, "not aligned"): + capture.load_config(path, self.args()) + + def test_fractional_integer_field_is_rejected(self) -> None: + with self.assertRaisesRegex(capture.CaptureError, "must be an integer"): + capture._parse_int(1.5, "baud_rate") + + +class CaptureTests(unittest.TestCase): + class FakeSerial: + def __init__(self, acknowledge_start: bool = True) -> None: + self.acknowledge_start = acknowledge_start + self.commands = [] + self.pending = bytearray() + self.closed = False + + def reset_input_buffer(self) -> None: + self.pending.clear() + + def write(self, data: bytes) -> int: + frames = capture.FrameParser().feed(data) + if len(frames) != 1: + raise AssertionError("capture wrote an invalid or incomplete frame") + frame = frames[0] + self.commands.append(frame) + is_stop = ( + frame.command == capture.CMD_START_MONITOR and frame.payload == b"\x00" + ) + if frame.command == capture.CMD_SET_SAMPLE_RATE or ( + frame.command == capture.CMD_START_MONITOR + and self.acknowledge_start + and not is_stop + ): + self.pending.extend( + capture.make_frame(capture.CMD_ACK, bytes((frame.command, 0))) + ) + return len(data) + + def flush(self) -> None: + pass + + def read(self, size: int) -> bytes: + data = bytes(self.pending[:size]) + del self.pending[:size] + return data + + def close(self) -> None: + self.closed = True + + @staticmethod + def config(output: Path, ack_timeout: float = 0.01) -> object: + return capture.CaptureConfig( + serial_port="fake", + baud_rate=921600, + sample_rate_hz=100, + duration_seconds=0.001, + output_csv=output, + variables=(capture.Variable("x", 0x24000100, "float32"),), + ack_timeout_seconds=ack_timeout, + max_frame_bytes=256, + ) + + def test_rate_precedes_start_and_capture_always_stops(self) -> None: + fake = self.FakeSerial() + original = capture._open_serial + capture._open_serial = lambda _config: fake + try: + with tempfile.TemporaryDirectory() as directory: + capture.capture(self.config(Path(directory) / "capture.csv")) + finally: + capture._open_serial = original + self.assertEqual( + [(frame.command, frame.payload) for frame in fake.commands], + [ + (capture.CMD_SET_SAMPLE_RATE, struct.pack(" None: + fake = self.FakeSerial(acknowledge_start=False) + original = capture._open_serial + capture._open_serial = lambda _config: fake + try: + with tempfile.TemporaryDirectory() as directory: + with self.assertRaisesRegex(capture.CaptureError, "timeout waiting"): + capture.capture( + self.config(Path(directory) / "capture.csv", ack_timeout=0.001) + ) + finally: + capture._open_serial = original + self.assertEqual( + fake.commands[-1], capture.Frame(capture.CMD_START_MONITOR, b"\x00") + ) + self.assertTrue(fake.closed) + + def test_short_serial_write_is_rejected(self) -> None: + class ShortWriter: + def write(self, data: bytes) -> int: + return len(data) - 1 + + def flush(self) -> None: + raise AssertionError("flush must not run after a short write") + + with self.assertRaisesRegex(capture.CaptureError, "serial short write"): + capture._write_frame(ShortWriter(), b"123") + + +class RendererTests(unittest.TestCase): + def test_headless_renderer_handles_wrap_and_malformed_row(self) -> None: + try: + import matplotlib # noqa: F401 + except ImportError: + self.skipTest("matplotlib is not installed") + with tempfile.TemporaryDirectory() as directory: + directory_path = Path(directory) + source = directory_path / "capture.csv" + output = directory_path / "plot.png" + with source.open("w", newline="", encoding="utf-8") as file: + writer = csv.writer(file) + writer.writerow(["timestamp_ms", "host_time_utc", "x"]) + writer.writerow([4294967290, "2026-01-01T00:00:00Z", 1.0]) + writer.writerow(["bad", "2026-01-01T00:00:00Z", 2.0]) + writer.writerow([4, "2026-01-01T00:00:00Z", 3.0]) + count, malformed, resets = renderer.render(source, output, ["x"], 0, 1) + self.assertEqual(count, 2) + self.assertEqual(malformed, 1) + self.assertEqual(resets, 1) + self.assertTrue(output.exists()) + self.assertGreater(output.stat().st_size, 0) + + +if __name__ == "__main__": + unittest.main()