mirror of
https://github.com/alexhopeoconnor/firmware.git
synced 2026-10-04 11:28:11 +10:00
Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9dc76944c1 | ||
|
|
64fd61706d | ||
|
|
8877608858 | ||
|
|
dfcb685963 | ||
|
|
9bc25b34fd | ||
|
|
33319aa4e2 | ||
|
|
d79e62fd2a | ||
|
|
f6a954b97e | ||
|
|
10a7f1042b | ||
|
|
b4234b7f11 | ||
|
|
5512185cfe | ||
|
|
a8a785bbb7 | ||
|
|
0f854862e7 | ||
|
|
b246bcd72e | ||
|
|
33e2bb70e6 | ||
|
|
220bb4d186 | ||
|
|
6e810741f3 | ||
|
|
603cce2988 | ||
|
|
d559af8477 |
@@ -49,11 +49,17 @@ Call the meshtastic MCP tool bundle and format a structured health report for on
|
||||
- Do the LoRa configs match? (region, channel_num, modem_preset should all agree; mismatch = no mesh)
|
||||
- Do the primary channel NAMES match? Mismatch = different PSK = no decode.
|
||||
|
||||
7. **Suggest next actions only for specific, recognisable failure modes**:
|
||||
7. **Recorder slice (cheap, always available).** The mcp-server runs an autouse log recorder that's been collecting from every connected device. Pull two short slices to surface anything weird that's already happened:
|
||||
- `mcp__meshtastic__logs_window(start="-2m", level="WARN|ERROR|CRIT", max_lines=20)` — recent firmware errors. If empty, say "no recent errors"; don't manufacture concern.
|
||||
- `mcp__meshtastic__telemetry_timeline(window="1h", field="free_heap", max_points=60)` — heap trend. If `slope_per_min < -50`, flag it and recommend `/leakhunt window=6h` for a deeper read; otherwise just note the current free heap.
|
||||
- If `recorder_status` shows `running:false` or `files.telemetry.last_ts` is null, note "recorder has no telemetry yet — enable `set_debug_log_api(True)` to populate" and skip this step gracefully.
|
||||
|
||||
8. **Suggest next actions only for specific, recognisable failure modes**:
|
||||
- Stale PKI pubkey one-way → "run `/test tests/mesh/test_direct_with_ack.py` — the retry + nodeinfo-ping heals this in the test path."
|
||||
- Region mismatch → "re-bake one side via `./mcp-server/run-tests.sh --force-bake`."
|
||||
- Device unreachable, reachable via DFU → `touch_1200bps(port=...)` + `pio_flash`. If not even DFU responds AND the device is on a PPPS hub, escalate to `uhubctl_cycle(role=..., confirm=True)`.
|
||||
- CP2102-wedged-driver on macOS → see the note in `run-tests.sh`.
|
||||
- Heap slope strongly negative → "run `/leakhunt window=6h` for a full timeline + classification."
|
||||
|
||||
## What NOT to do
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
---
|
||||
description: Hunt for memory leaks (and other slow degradations) by reading the persistent recorder's heap timeline + log slice over a window
|
||||
argument-hint: [window=1h] [field=free_heap] [variant=local]
|
||||
---
|
||||
|
||||
<!-- markdownlint-disable MD029 -->
|
||||
|
||||
# `/leakhunt` — read the recorder, classify a memory leak
|
||||
|
||||
Use the always-on recorder (`mcp-server/.mtlog/`) to read a heap timeline plus the matching log slice and produce a one-page verdict: **steady / slow leak / fragmentation / OOM-imminent**. No firmware changes, no special build flags — the LocalStats telemetry packet that the firmware already broadcasts every ~60 s carries `heap_free_bytes` and `heap_total_bytes`.
|
||||
|
||||
## Two signal paths — pick the right one
|
||||
|
||||
| Path | Build flag | Cadence | Per-thread attribution | Cost |
|
||||
| --------------------- | ---------------- | -------------- | ---------------------- | ------------------------- |
|
||||
| LocalStats packet | (default) | ~60 s | No | Free — always on |
|
||||
| `[heap N]` log prefix | `-DDEBUG_HEAP=1` | every log line | Yes (Thread X leaked) | Bigger flash + log volume |
|
||||
|
||||
Both feed the same `telemetry_timeline(field="free_heap")` query — when DEBUG_HEAP is on, the recorder synthesizes telemetry rows from log prefixes (tagged `source: debug_heap`), so a single timeline call gets whichever signal is available. **For a slow leak diagnosis, the default path is plenty** (60 s cadence over 6 h = 360 points; linear regression over that nails sub-100-byte/min slopes). **DEBUG_HEAP is for attribution** — when the slope is real and you need to know which thread is leaking.
|
||||
|
||||
## What to do
|
||||
|
||||
1. **Parse `$ARGUMENTS`**: optional `window` (default `1h`, accepts `30m`/`6h`/`-3d`/etc.), optional `field` (default `free_heap`; alternates: `total_heap`, `battery_level`, anything in the LocalStats variant), optional `variant` (default `local`; alternates: `device`, `environment`, `power`, `airQuality`, `health`).
|
||||
|
||||
2. **Verify the recorder is alive** — call `mcp__meshtastic__recorder_status`. Check:
|
||||
- `running == True`
|
||||
- `files.telemetry.lines > 0` (at least one telemetry packet recorded — if zero, the device hasn't broadcast LocalStats yet OR `set_debug_log_api` has never been on; tell the operator to run `mcp__meshtastic__set_debug_log_api(enabled=True)` and wait one device-update interval)
|
||||
- `files.telemetry.last_ts` within the last 5 minutes (if older, the device is silent — log that, not "leak detected")
|
||||
|
||||
3. **Detect whether DEBUG_HEAP is active** — `mcp__meshtastic__logs_window(start="-2m", grep=r"\\[heap \\d+\\]", max_lines=3)`. If any line matches, the firmware has the prefix → DEBUG_HEAP is on, expect higher-cadence data and `heap_event` rows. If zero matches over the last 2 minutes, you're on the LocalStats-only path.
|
||||
|
||||
4. **Pull the timeline** — `mcp__meshtastic__telemetry_timeline(window=$window, variant=$variant, field=$field, max_points=200)`. Read:
|
||||
- `samples` — how many raw points contributed
|
||||
- `min`, `max` — total swing
|
||||
- `slope_per_min` — units per minute (linear regression over the whole window)
|
||||
|
||||
5. **Pull the log context for the same window** — `mcp__meshtastic__logs_window(start="-${window}", grep="Heap status|leaked heap|freed heap|out of memory|Alloc an err|panic|abort", max_lines=200)`. These are the strings the firmware emits when something memory-related happens (`DEBUG_HEAP` builds emit `"Heap status:"` and `"leaked heap"` lines; production builds emit `"Alloc an err"` on failure and `"out of memory"` on OOM).
|
||||
|
||||
6. **Pull marker events** so we know if the operator labeled phases — `mcp__meshtastic__events_window(start="-${window}", kind="mark|connection_lost|connection_established")`. If a `connection_lost` overlaps a sharp drop, that's not a leak; that's a reboot.
|
||||
|
||||
6a. **(DEBUG_HEAP only) Per-thread attribution** — `mcp__meshtastic__logs_window(start="-${window}", grep="leaked heap", max_lines=200)`. Each row has a structured `heap_event` field with `{kind, thread, before, after, delta}`. Aggregate by thread: sum the `delta` over the window per thread name. The thread with the largest cumulative negative delta is your suspect. Note the count too — a thread with 50× small leaks is different from 1× big leak.
|
||||
|
||||
7. **Classify** based on what the data says, NOT on what you wish it said. Use these rules in order:
|
||||
- **Insufficient data** (< 5 samples): say so. Suggest a longer window or longer wait. Stop.
|
||||
- **Reboot mid-window**: if any `connection_lost` event is present AND `free_heap` jumped UP at that timestamp, the device rebooted. Note it; pre-reboot trend may be a leak but you only have part of the curve.
|
||||
- **OOM-imminent**: any `Alloc an err=` or `out of memory` line in the log slice. This trumps everything; flag urgently.
|
||||
- **Slow leak**: `slope_per_min < -50` AND `max - min > 1000` AND no reboot. The heap is monotonically (or near-monotonically) declining. Estimate time-to-zero: `min / -slope_per_min` minutes. Surface it.
|
||||
- **Fragmentation suspect**: `slope_per_min` close to zero (|x| < 50) BUT min trends down across the window AND the log slice shows `Alloc an err` warnings WITHOUT total OOM. Means free total is OK but largest contiguous block is shrinking. Recommend a `DEBUG_HEAP` build to confirm.
|
||||
- **Steady**: |slope_per_min| < 50, no error lines. Heap is fine.
|
||||
- **Recovery curve**: slope is POSITIVE — heap recovered. Either a workload completed or GC fired. Note it; not a leak.
|
||||
|
||||
8. **Report**:
|
||||
|
||||
```text
|
||||
/leakhunt window=6h field=free_heap variant=local
|
||||
────────────────────────────────────────────────────
|
||||
recorder : running, telem last_ts 8s ago
|
||||
build : DEBUG_HEAP=ON (per-line prefix detected)
|
||||
samples : 14,200 over 6h (cadence ~1.5s, log-line synth)
|
||||
free_heap : min 92,344 / max 124,008 / range 31,664
|
||||
slope : -82 bytes/min (negative — heap declining)
|
||||
reboots : none in window
|
||||
OOM events : none
|
||||
error lines : 3× "Alloc an err=ESP_ERR_NO_MEM" at +4h12m, +5h08m, +5h44m
|
||||
thread leaks : (DEBUG_HEAP) MeshPacket -3,124 B over 18 events
|
||||
Router -1,408 B over 4 events
|
||||
others -240 B
|
||||
verdict : SLOW LEAK — primary suspect MeshPacket thread
|
||||
est. time-to-OOM: ~1,127 min (~18.8 h) at current slope
|
||||
evidence : (3 log line citations with uptimes)
|
||||
```
|
||||
|
||||
Then: **what to do next.**
|
||||
- SLOW LEAK, **DEBUG_HEAP off** → recommend rebuilding with the flag and re-running this skill. Concrete one-liner the operator can copy:
|
||||
```text
|
||||
mcp__meshtastic__build(env="<env>", build_flags={"DEBUG_HEAP": 1})
|
||||
mcp__meshtastic__pio_flash(env="<env>", port="<port>", confirm=True)
|
||||
```
|
||||
After flash, set debug_log_api back on and wait one window; re-run `/leakhunt`.
|
||||
- SLOW LEAK, **DEBUG_HEAP on** → cite the top-leaking thread name from step 6a. Point at the corresponding source file (`grep -rn "ThreadName(\"<name>\")" src/`); the operator decides what to fix.
|
||||
- FRAGMENTATION SUSPECT → propose pre-allocating any per-packet buffers; or rebuilding with `CONFIG_HEAP_TASK_TRACKING=y` on ESP32 to see who's holding the largest blocks.
|
||||
- OOM-IMMINENT → flag for immediate attention; don't wait for the next telemetry interval.
|
||||
- STEADY → say so; stop. Don't invent problems.
|
||||
|
||||
## What NOT to do
|
||||
|
||||
- Don't assume a leak from a single dip. LocalStats fires every ~60 s and the firmware naturally allocates+frees on each broadcast cycle; one packet sees the trough. Look at the slope, not the deltas.
|
||||
- Don't recommend code changes. This skill diagnoses; the operator decides what to fix.
|
||||
- Don't enable `set_debug_log_api` automatically — if it's off, telemetry isn't reaching pubsub anyway, and the recorder will be empty. Tell the operator to flip it on and wait, then re-run.
|
||||
- Don't run heavy workloads to "trigger the leak." The recorder is passive; we read what's there.
|
||||
|
||||
## Companion: `mark_event` for stress runs
|
||||
|
||||
If the operator wants to test under stimulus (e.g. blast 50 broadcasts and see what the heap does), they can frame the experiment with markers:
|
||||
|
||||
```text
|
||||
mark_event("burst-start")
|
||||
… run the workload …
|
||||
mark_event("burst-end")
|
||||
/leakhunt window=15m
|
||||
```
|
||||
|
||||
The markers land in both `events.jsonl` and `logs.jsonl`, so the report can show "free_heap dipped 8 KB during the burst window, recovered to baseline within 2 LocalStats cycles" → not a leak.
|
||||
@@ -3,6 +3,8 @@ description: Re-run a specific test N times in isolation to triage flakes, diff
|
||||
argument-hint: <test-node-id> [count=5]
|
||||
---
|
||||
|
||||
<!-- markdownlint-disable MD029 -->
|
||||
|
||||
# `/repro` — flakiness triage for one test
|
||||
|
||||
Re-run a single pytest node ID N times in isolation, track pass rate, and surface what's _different_ in the firmware logs between the passing attempts and the failing ones. Turns "it's flaky, I guess" into "it fails when X, passes when Y."
|
||||
@@ -40,6 +42,8 @@ Re-run a single pytest node ID N times in isolation, track pass rate, and surfac
|
||||
|
||||
Surface the top 3 differences as a "passes when / fails when" table. Don't dump full logs — pull specific lines with uptime timestamps.
|
||||
|
||||
5a. **Archive recorder slices per attempt** (no extra device interaction; the recorder runs autouse). Right after each attempt finishes, capture its `(start_ts, end_ts)` and call `mcp__meshtastic__recorder_export(start=<start>, end=<end>, dest_dir="mcp-server/tests/repro_artifacts/<safe-test-id>/attempt_<n>/")`. This drops a `logs.jsonl`, `telemetry.jsonl`, `packets.jsonl`, and `events.jsonl` snapshot scoped to the attempt window. Use these for cross-attempt diffs in step 5: `jq '.line' logs.jsonl` is faster than re-running the test, and the telemetry slice lets you compare heap behavior across attempts.
|
||||
|
||||
6. **Classify the flake** into one of:
|
||||
- **LoRa airtime collision** → pass rate improves with fewer concurrent transmitters; propose a `time.sleep` gap or retry bump in the test body.
|
||||
- **PKI key staleness** → fails on first attempt, passes after self-heal; existing retry loop in `test_direct_with_ack.py` handles this.
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
# firmware Development Guidelines
|
||||
|
||||
Auto-generated from all feature plans. Last updated: 2026-03-25
|
||||
|
||||
## Active Technologies
|
||||
|
||||
- Python 3.x for workflow tooling, Markdown for generated artifacts, existing C/C++/PlatformIO repository conventions for downstream scaffold targets + Python standard library for inventory/intake tooling, existing Spec Kit artifacts, repository-local Copilot prompt/agent files, PlatformIO environment metadata conventions already in `variants/**/platformio.ini` (129-hardware-support-agent)
|
||||
|
||||
## Project Structure
|
||||
|
||||
```text
|
||||
src/
|
||||
tests/
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
cd src [ONLY COMMANDS FOR ACTIVE TECHNOLOGIES][ONLY COMMANDS FOR ACTIVE TECHNOLOGIES] pytest [ONLY COMMANDS FOR ACTIVE TECHNOLOGIES][ONLY COMMANDS FOR ACTIVE TECHNOLOGIES] ruff check .
|
||||
|
||||
## Code Style
|
||||
|
||||
Python 3.x for workflow tooling, Markdown for generated artifacts, existing C/C++/PlatformIO repository conventions for downstream scaffold targets: Follow standard conventions
|
||||
|
||||
## Recent Changes
|
||||
|
||||
- 129-hardware-support-agent: Added Python 3.x for workflow tooling, Markdown for generated artifacts, existing C/C++/PlatformIO repository conventions for downstream scaffold targets + Python standard library for inventory/intake tooling, existing Spec Kit artifacts, repository-local Copilot prompt/agent files, PlatformIO environment metadata conventions already in `variants/**/platformio.ini`
|
||||
|
||||
<!-- MANUAL ADDITIONS START -->
|
||||
<!-- MANUAL ADDITIONS END -->
|
||||
@@ -1,124 +0,0 @@
|
||||
---
|
||||
description: Guide maintainers through Meshtastic hardware support context generation, board intake assessment, and optional scaffold generation.
|
||||
---
|
||||
|
||||
# Hardware Support Workflow
|
||||
|
||||
Use this workflow when a maintainer wants to add support for a new board variant in the Meshtastic firmware repository.
|
||||
|
||||
## Goals
|
||||
|
||||
- Reuse repository-backed hardware patterns before drafting new board files.
|
||||
- Keep all generated output scoped to the requested architecture and board.
|
||||
- Stop and surface evidence gaps instead of inventing pin mappings or metadata.
|
||||
|
||||
## Required Inputs
|
||||
|
||||
Collect or confirm these fields before scaffold generation:
|
||||
|
||||
- PlatformIO environment name
|
||||
- hardware model identifier
|
||||
- display name
|
||||
- architecture
|
||||
|
||||
Recommended additional inputs:
|
||||
|
||||
- hardware model slug
|
||||
- actively supported flag
|
||||
- support level
|
||||
- source materials such as schematic, pinout, or datasheet links
|
||||
- board notes covering revision scope and known uncertainty
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Refresh Repository Context
|
||||
|
||||
Run from the repository root:
|
||||
|
||||
```bash
|
||||
python3 bin/generate_hardware_support_context.py
|
||||
```
|
||||
|
||||
Review [docs/hardware-support-context.md](../../docs/hardware-support-context.md) for architecture-specific examples, metadata keys, and inherited-default notes.
|
||||
|
||||
### 2. Capture The Intake Request
|
||||
|
||||
Create or update a JSON file matching the contract in [specs/129-hardware-support-agent/contracts/board-intake-contract.md](../../specs/129-hardware-support-agent/contracts/board-intake-contract.md).
|
||||
|
||||
### 3. Assess Intake Readiness
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
python3 bin/board_intake.py path/to/intake.json
|
||||
```
|
||||
|
||||
What to look for:
|
||||
|
||||
- expected artifacts
|
||||
- required metadata
|
||||
- matched repository patterns
|
||||
- evidence gaps
|
||||
- risk flags
|
||||
- next actions
|
||||
- scaffold readiness decision
|
||||
|
||||
For CI-style gating, use:
|
||||
|
||||
```bash
|
||||
python3 bin/board_intake.py path/to/intake.json --validate
|
||||
```
|
||||
|
||||
If the assessment is not scaffold-ready, stop and resolve the blocking gaps before continuing.
|
||||
|
||||
### 4. Generate Scaffold Output When Ready
|
||||
|
||||
Only run this when the intake assessment reports `Scaffold ready: Yes`.
|
||||
|
||||
```bash
|
||||
python3 bin/board_scaffold.py path/to/intake.json --output-dir generated/hardware-support
|
||||
```
|
||||
|
||||
Expected outputs:
|
||||
|
||||
- draft `variant.h`
|
||||
- draft `platformio.ini`
|
||||
- optional `variant.cpp` for ESP32-family targets
|
||||
|
||||
Review all `// TODO: verify — ...` annotations before treating the scaffold as merge-ready.
|
||||
|
||||
### 5. Compile-Gate The Target Environment (Required)
|
||||
|
||||
The end stage must always validate that the target environment is at least compilable.
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pio run -e <environment_name>
|
||||
```
|
||||
|
||||
Expected behavior:
|
||||
|
||||
- If compile succeeds, include a "compile check passed" note in the review summary.
|
||||
- If compile fails, treat it as a blocking issue and report the exact failing error.
|
||||
- Do not mark the workflow complete while compile is failing.
|
||||
|
||||
Common first-pass blocker for new scaffolds:
|
||||
|
||||
- Missing or placeholder `board = ...` in `platformio.ini` causes `BoardConfig: Board is not defined`.
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Do not change live firmware runtime code under `src/` as part of this workflow.
|
||||
- Do not modify existing board definitions under `variants/` automatically.
|
||||
- Do not guess unresolved radio, display, GPS, power, or input pin mappings.
|
||||
- Treat multi-revision or multi-option board notes as blocking until the revision scope is explicit.
|
||||
- Check inherited BSP defaults for `nrf52840`, `rp2040`, `stm32`, and `native` targets before declaring a missing define.
|
||||
|
||||
## Validation Expectations
|
||||
|
||||
- Run `trunk fmt --force` on touched Python workflow files.
|
||||
- Re-run `python3 bin/generate_hardware_support_context.py` after changes affecting context output.
|
||||
- Use fixture-driven smoke tests in `bin/fixtures/` for intake and scaffold workflows.
|
||||
- Run `pio run -e <environment_name>` as a required final compile gate for the generated board environment.
|
||||
- Report any skipped validation or remaining TODO annotations in the review notes.
|
||||
@@ -1,184 +0,0 @@
|
||||
---
|
||||
description: Perform a non-destructive cross-artifact consistency and quality analysis across spec.md, plan.md, and tasks.md after task generation.
|
||||
---
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Goal
|
||||
|
||||
Identify inconsistencies, duplications, ambiguities, and underspecified items across the three core artifacts (`spec.md`, `plan.md`, `tasks.md`) before implementation. This command MUST run only after `/speckit.tasks` has successfully produced a complete `tasks.md`.
|
||||
|
||||
## Operating Constraints
|
||||
|
||||
**STRICTLY READ-ONLY**: Do **not** modify any files. Output a structured analysis report. Offer an optional remediation plan (user must explicitly approve before any follow-up editing commands would be invoked manually).
|
||||
|
||||
**Constitution Authority**: The project constitution (`.specify/memory/constitution.md`) is **non-negotiable** within this analysis scope. Constitution conflicts are automatically CRITICAL and require adjustment of the spec, plan, or tasks—not dilution, reinterpretation, or silent ignoring of the principle. If a principle itself needs to change, that must occur in a separate, explicit constitution update outside `/speckit.analyze`.
|
||||
|
||||
## Execution Steps
|
||||
|
||||
### 1. Initialize Analysis Context
|
||||
|
||||
Run `.specify/scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks` once from repo root and parse JSON for FEATURE_DIR and AVAILABLE_DOCS. Derive absolute paths:
|
||||
|
||||
- SPEC = FEATURE_DIR/spec.md
|
||||
- PLAN = FEATURE_DIR/plan.md
|
||||
- TASKS = FEATURE_DIR/tasks.md
|
||||
|
||||
Abort with an error message if any required file is missing (instruct the user to run missing prerequisite command).
|
||||
For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
|
||||
### 2. Load Artifacts (Progressive Disclosure)
|
||||
|
||||
Load only the minimal necessary context from each artifact:
|
||||
|
||||
**From spec.md:**
|
||||
|
||||
- Overview/Context
|
||||
- Functional Requirements
|
||||
- Success Criteria (measurable outcomes — e.g., performance, security, availability, user success, business impact)
|
||||
- User Stories
|
||||
- Edge Cases (if present)
|
||||
|
||||
**From plan.md:**
|
||||
|
||||
- Architecture/stack choices
|
||||
- Data Model references
|
||||
- Phases
|
||||
- Technical constraints
|
||||
|
||||
**From tasks.md:**
|
||||
|
||||
- Task IDs
|
||||
- Descriptions
|
||||
- Phase grouping
|
||||
- Parallel markers [P]
|
||||
- Referenced file paths
|
||||
|
||||
**From constitution:**
|
||||
|
||||
- Load `.specify/memory/constitution.md` for principle validation
|
||||
|
||||
### 3. Build Semantic Models
|
||||
|
||||
Create internal representations (do not include raw artifacts in output):
|
||||
|
||||
- **Requirements inventory**: For each Functional Requirement (FR-###) and Success Criterion (SC-###), record a stable key. Use the explicit FR-/SC- identifier as the primary key when present, and optionally also derive an imperative-phrase slug for readability (e.g., "User can upload file" → `user-can-upload-file`). Include only Success Criteria items that require buildable work (e.g., load-testing infrastructure, security audit tooling), and exclude post-launch outcome metrics and business KPIs (e.g., "Reduce support tickets by 50%").
|
||||
- **User story/action inventory**: Discrete user actions with acceptance criteria
|
||||
- **Task coverage mapping**: Map each task to one or more requirements or stories (inference by keyword / explicit reference patterns like IDs or key phrases)
|
||||
- **Constitution rule set**: Extract principle names and MUST/SHOULD normative statements
|
||||
|
||||
### 4. Detection Passes (Token-Efficient Analysis)
|
||||
|
||||
Focus on high-signal findings. Limit to 50 findings total; aggregate remainder in overflow summary.
|
||||
|
||||
#### A. Duplication Detection
|
||||
|
||||
- Identify near-duplicate requirements
|
||||
- Mark lower-quality phrasing for consolidation
|
||||
|
||||
#### B. Ambiguity Detection
|
||||
|
||||
- Flag vague adjectives (fast, scalable, secure, intuitive, robust) lacking measurable criteria
|
||||
- Flag unresolved placeholders (TODO, TKTK, ???, `<placeholder>`, etc.)
|
||||
|
||||
#### C. Underspecification
|
||||
|
||||
- Requirements with verbs but missing object or measurable outcome
|
||||
- User stories missing acceptance criteria alignment
|
||||
- Tasks referencing files or components not defined in spec/plan
|
||||
|
||||
#### D. Constitution Alignment
|
||||
|
||||
- Any requirement or plan element conflicting with a MUST principle
|
||||
- Missing mandated sections or quality gates from constitution
|
||||
|
||||
#### E. Coverage Gaps
|
||||
|
||||
- Requirements with zero associated tasks
|
||||
- Tasks with no mapped requirement/story
|
||||
- Success Criteria requiring buildable work (performance, security, availability) not reflected in tasks
|
||||
|
||||
#### F. Inconsistency
|
||||
|
||||
- Terminology drift (same concept named differently across files)
|
||||
- Data entities referenced in plan but absent in spec (or vice versa)
|
||||
- Task ordering contradictions (e.g., integration tasks before foundational setup tasks without dependency note)
|
||||
- Conflicting requirements (e.g., one requires Next.js while other specifies Vue)
|
||||
|
||||
### 5. Severity Assignment
|
||||
|
||||
Use this heuristic to prioritize findings:
|
||||
|
||||
- **CRITICAL**: Violates constitution MUST, missing core spec artifact, or requirement with zero coverage that blocks baseline functionality
|
||||
- **HIGH**: Duplicate or conflicting requirement, ambiguous security/performance attribute, untestable acceptance criterion
|
||||
- **MEDIUM**: Terminology drift, missing non-functional task coverage, underspecified edge case
|
||||
- **LOW**: Style/wording improvements, minor redundancy not affecting execution order
|
||||
|
||||
### 6. Produce Compact Analysis Report
|
||||
|
||||
Output a Markdown report (no file writes) with the following structure:
|
||||
|
||||
## Specification Analysis Report
|
||||
|
||||
| ID | Category | Severity | Location(s) | Summary | Recommendation |
|
||||
| --- | ----------- | -------- | ---------------- | ---------------------------- | ------------------------------------ |
|
||||
| A1 | Duplication | HIGH | spec.md:L120-134 | Two similar requirements ... | Merge phrasing; keep clearer version |
|
||||
|
||||
(Add one row per finding; generate stable IDs prefixed by category initial.)
|
||||
|
||||
**Coverage Summary Table:**
|
||||
|
||||
| Requirement Key | Has Task? | Task IDs | Notes |
|
||||
| --------------- | --------- | -------- | ----- |
|
||||
|
||||
**Constitution Alignment Issues:** (if any)
|
||||
|
||||
**Unmapped Tasks:** (if any)
|
||||
|
||||
**Metrics:**
|
||||
|
||||
- Total Requirements
|
||||
- Total Tasks
|
||||
- Coverage % (requirements with >=1 task)
|
||||
- Ambiguity Count
|
||||
- Duplication Count
|
||||
- Critical Issues Count
|
||||
|
||||
### 7. Provide Next Actions
|
||||
|
||||
At end of report, output a concise Next Actions block:
|
||||
|
||||
- If CRITICAL issues exist: Recommend resolving before `/speckit.implement`
|
||||
- If only LOW/MEDIUM: User may proceed, but provide improvement suggestions
|
||||
- Provide explicit command suggestions: e.g., "Run /speckit.specify with refinement", "Run /speckit.plan to adjust architecture", "Manually edit tasks.md to add coverage for 'performance-metrics'"
|
||||
|
||||
### 8. Offer Remediation
|
||||
|
||||
Ask the user: "Would you like me to suggest concrete remediation edits for the top N issues?" (Do NOT apply them automatically.)
|
||||
|
||||
## Operating Principles
|
||||
|
||||
### Context Efficiency
|
||||
|
||||
- **Minimal high-signal tokens**: Focus on actionable findings, not exhaustive documentation
|
||||
- **Progressive disclosure**: Load artifacts incrementally; don't dump all content into analysis
|
||||
- **Token-efficient output**: Limit findings table to 50 rows; summarize overflow
|
||||
- **Deterministic results**: Rerunning without changes should produce consistent IDs and counts
|
||||
|
||||
### Analysis Guidelines
|
||||
|
||||
- **NEVER modify files** (this is read-only analysis)
|
||||
- **NEVER hallucinate missing sections** (if absent, report them accurately)
|
||||
- **Prioritize constitution violations** (these are always CRITICAL)
|
||||
- **Use examples over exhaustive rules** (cite specific instances, not generic patterns)
|
||||
- **Report zero issues gracefully** (emit success report with coverage statistics)
|
||||
|
||||
## Context
|
||||
|
||||
$ARGUMENTS
|
||||
@@ -1,295 +0,0 @@
|
||||
---
|
||||
description: Generate a custom checklist for the current feature based on user requirements.
|
||||
---
|
||||
|
||||
## Checklist Purpose: "Unit Tests for English"
|
||||
|
||||
**CRITICAL CONCEPT**: Checklists are **UNIT TESTS FOR REQUIREMENTS WRITING** - they validate the quality, clarity, and completeness of requirements in a given domain.
|
||||
|
||||
**NOT for verification/testing**:
|
||||
|
||||
- ❌ NOT "Verify the button clicks correctly"
|
||||
- ❌ NOT "Test error handling works"
|
||||
- ❌ NOT "Confirm the API returns 200"
|
||||
- ❌ NOT checking if code/implementation matches the spec
|
||||
|
||||
**FOR requirements quality validation**:
|
||||
|
||||
- ✅ "Are visual hierarchy requirements defined for all card types?" (completeness)
|
||||
- ✅ "Is 'prominent display' quantified with specific sizing/positioning?" (clarity)
|
||||
- ✅ "Are hover state requirements consistent across all interactive elements?" (consistency)
|
||||
- ✅ "Are accessibility requirements defined for keyboard navigation?" (coverage)
|
||||
- ✅ "Does the spec define what happens when logo image fails to load?" (edge cases)
|
||||
|
||||
**Metaphor**: If your spec is code written in English, the checklist is its unit test suite. You're testing whether the requirements are well-written, complete, unambiguous, and ready for implementation - NOT whether the implementation works.
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Execution Steps
|
||||
|
||||
1. **Setup**: Run `.specify/scripts/bash/check-prerequisites.sh --json` from repo root and parse JSON for FEATURE_DIR and AVAILABLE_DOCS list.
|
||||
- All file paths must be absolute.
|
||||
- For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
|
||||
2. **Clarify intent (dynamic)**: Derive up to THREE initial contextual clarifying questions (no pre-baked catalog). They MUST:
|
||||
- Be generated from the user's phrasing + extracted signals from spec/plan/tasks
|
||||
- Only ask about information that materially changes checklist content
|
||||
- Be skipped individually if already unambiguous in `$ARGUMENTS`
|
||||
- Prefer precision over breadth
|
||||
|
||||
Generation algorithm:
|
||||
1. Extract signals: feature domain keywords (e.g., auth, latency, UX, API), risk indicators ("critical", "must", "compliance"), stakeholder hints ("QA", "review", "security team"), and explicit deliverables ("a11y", "rollback", "contracts").
|
||||
2. Cluster signals into candidate focus areas (max 4) ranked by relevance.
|
||||
3. Identify probable audience & timing (author, reviewer, QA, release) if not explicit.
|
||||
4. Detect missing dimensions: scope breadth, depth/rigor, risk emphasis, exclusion boundaries, measurable acceptance criteria.
|
||||
5. Formulate questions chosen from these archetypes:
|
||||
- Scope refinement (e.g., "Should this include integration touchpoints with X and Y or stay limited to local module correctness?")
|
||||
- Risk prioritization (e.g., "Which of these potential risk areas should receive mandatory gating checks?")
|
||||
- Depth calibration (e.g., "Is this a lightweight pre-commit sanity list or a formal release gate?")
|
||||
- Audience framing (e.g., "Will this be used by the author only or peers during PR review?")
|
||||
- Boundary exclusion (e.g., "Should we explicitly exclude performance tuning items this round?")
|
||||
- Scenario class gap (e.g., "No recovery flows detected—are rollback / partial failure paths in scope?")
|
||||
|
||||
Question formatting rules:
|
||||
- If presenting options, generate a compact table with columns: Option | Candidate | Why It Matters
|
||||
- Limit to A–E options maximum; omit table if a free-form answer is clearer
|
||||
- Never ask the user to restate what they already said
|
||||
- Avoid speculative categories (no hallucination). If uncertain, ask explicitly: "Confirm whether X belongs in scope."
|
||||
|
||||
Defaults when interaction impossible:
|
||||
- Depth: Standard
|
||||
- Audience: Reviewer (PR) if code-related; Author otherwise
|
||||
- Focus: Top 2 relevance clusters
|
||||
|
||||
Output the questions (label Q1/Q2/Q3). After answers: if ≥2 scenario classes (Alternate / Exception / Recovery / Non-Functional domain) remain unclear, you MAY ask up to TWO more targeted follow‑ups (Q4/Q5) with a one-line justification each (e.g., "Unresolved recovery path risk"). Do not exceed five total questions. Skip escalation if user explicitly declines more.
|
||||
|
||||
3. **Understand user request**: Combine `$ARGUMENTS` + clarifying answers:
|
||||
- Derive checklist theme (e.g., security, review, deploy, ux)
|
||||
- Consolidate explicit must-have items mentioned by user
|
||||
- Map focus selections to category scaffolding
|
||||
- Infer any missing context from spec/plan/tasks (do NOT hallucinate)
|
||||
|
||||
4. **Load feature context**: Read from FEATURE_DIR:
|
||||
- spec.md: Feature requirements and scope
|
||||
- plan.md (if exists): Technical details, dependencies
|
||||
- tasks.md (if exists): Implementation tasks
|
||||
|
||||
**Context Loading Strategy**:
|
||||
- Load only necessary portions relevant to active focus areas (avoid full-file dumping)
|
||||
- Prefer summarizing long sections into concise scenario/requirement bullets
|
||||
- Use progressive disclosure: add follow-on retrieval only if gaps detected
|
||||
- If source docs are large, generate interim summary items instead of embedding raw text
|
||||
|
||||
5. **Generate checklist** - Create "Unit Tests for Requirements":
|
||||
- Create `FEATURE_DIR/checklists/` directory if it doesn't exist
|
||||
- Generate unique checklist filename:
|
||||
- Use short, descriptive name based on domain (e.g., `ux.md`, `api.md`, `security.md`)
|
||||
- Format: `[domain].md`
|
||||
- File handling behavior:
|
||||
- If file does NOT exist: Create new file and number items starting from CHK001
|
||||
- If file exists: Append new items to existing file, continuing from the last CHK ID (e.g., if last item is CHK015, start new items at CHK016)
|
||||
- Never delete or replace existing checklist content - always preserve and append
|
||||
|
||||
**CORE PRINCIPLE - Test the Requirements, Not the Implementation**:
|
||||
Every checklist item MUST evaluate the REQUIREMENTS THEMSELVES for:
|
||||
- **Completeness**: Are all necessary requirements present?
|
||||
- **Clarity**: Are requirements unambiguous and specific?
|
||||
- **Consistency**: Do requirements align with each other?
|
||||
- **Measurability**: Can requirements be objectively verified?
|
||||
- **Coverage**: Are all scenarios/edge cases addressed?
|
||||
|
||||
**Category Structure** - Group items by requirement quality dimensions:
|
||||
- **Requirement Completeness** (Are all necessary requirements documented?)
|
||||
- **Requirement Clarity** (Are requirements specific and unambiguous?)
|
||||
- **Requirement Consistency** (Do requirements align without conflicts?)
|
||||
- **Acceptance Criteria Quality** (Are success criteria measurable?)
|
||||
- **Scenario Coverage** (Are all flows/cases addressed?)
|
||||
- **Edge Case Coverage** (Are boundary conditions defined?)
|
||||
- **Non-Functional Requirements** (Performance, Security, Accessibility, etc. - are they specified?)
|
||||
- **Dependencies & Assumptions** (Are they documented and validated?)
|
||||
- **Ambiguities & Conflicts** (What needs clarification?)
|
||||
|
||||
**HOW TO WRITE CHECKLIST ITEMS - "Unit Tests for English"**:
|
||||
|
||||
❌ **WRONG** (Testing implementation):
|
||||
- "Verify landing page displays 3 episode cards"
|
||||
- "Test hover states work on desktop"
|
||||
- "Confirm logo click navigates home"
|
||||
|
||||
✅ **CORRECT** (Testing requirements quality):
|
||||
- "Are the exact number and layout of featured episodes specified?" [Completeness]
|
||||
- "Is 'prominent display' quantified with specific sizing/positioning?" [Clarity]
|
||||
- "Are hover state requirements consistent across all interactive elements?" [Consistency]
|
||||
- "Are keyboard navigation requirements defined for all interactive UI?" [Coverage]
|
||||
- "Is the fallback behavior specified when logo image fails to load?" [Edge Cases]
|
||||
- "Are loading states defined for asynchronous episode data?" [Completeness]
|
||||
- "Does the spec define visual hierarchy for competing UI elements?" [Clarity]
|
||||
|
||||
**ITEM STRUCTURE**:
|
||||
Each item should follow this pattern:
|
||||
- Question format asking about requirement quality
|
||||
- Focus on what's WRITTEN (or not written) in the spec/plan
|
||||
- Include quality dimension in brackets [Completeness/Clarity/Consistency/etc.]
|
||||
- Reference spec section `[Spec §X.Y]` when checking existing requirements
|
||||
- Use `[Gap]` marker when checking for missing requirements
|
||||
|
||||
**EXAMPLES BY QUALITY DIMENSION**:
|
||||
|
||||
Completeness:
|
||||
- "Are error handling requirements defined for all API failure modes? [Gap]"
|
||||
- "Are accessibility requirements specified for all interactive elements? [Completeness]"
|
||||
- "Are mobile breakpoint requirements defined for responsive layouts? [Gap]"
|
||||
|
||||
Clarity:
|
||||
- "Is 'fast loading' quantified with specific timing thresholds? [Clarity, Spec §NFR-2]"
|
||||
- "Are 'related episodes' selection criteria explicitly defined? [Clarity, Spec §FR-5]"
|
||||
- "Is 'prominent' defined with measurable visual properties? [Ambiguity, Spec §FR-4]"
|
||||
|
||||
Consistency:
|
||||
- "Do navigation requirements align across all pages? [Consistency, Spec §FR-10]"
|
||||
- "Are card component requirements consistent between landing and detail pages? [Consistency]"
|
||||
|
||||
Coverage:
|
||||
- "Are requirements defined for zero-state scenarios (no episodes)? [Coverage, Edge Case]"
|
||||
- "Are concurrent user interaction scenarios addressed? [Coverage, Gap]"
|
||||
- "Are requirements specified for partial data loading failures? [Coverage, Exception Flow]"
|
||||
|
||||
Measurability:
|
||||
- "Are visual hierarchy requirements measurable/testable? [Acceptance Criteria, Spec §FR-1]"
|
||||
- "Can 'balanced visual weight' be objectively verified? [Measurability, Spec §FR-2]"
|
||||
|
||||
**Scenario Classification & Coverage** (Requirements Quality Focus):
|
||||
- Check if requirements exist for: Primary, Alternate, Exception/Error, Recovery, Non-Functional scenarios
|
||||
- For each scenario class, ask: "Are [scenario type] requirements complete, clear, and consistent?"
|
||||
- If scenario class missing: "Are [scenario type] requirements intentionally excluded or missing? [Gap]"
|
||||
- Include resilience/rollback when state mutation occurs: "Are rollback requirements defined for migration failures? [Gap]"
|
||||
|
||||
**Traceability Requirements**:
|
||||
- MINIMUM: ≥80% of items MUST include at least one traceability reference
|
||||
- Each item should reference: spec section `[Spec §X.Y]`, or use markers: `[Gap]`, `[Ambiguity]`, `[Conflict]`, `[Assumption]`
|
||||
- If no ID system exists: "Is a requirement & acceptance criteria ID scheme established? [Traceability]"
|
||||
|
||||
**Surface & Resolve Issues** (Requirements Quality Problems):
|
||||
Ask questions about the requirements themselves:
|
||||
- Ambiguities: "Is the term 'fast' quantified with specific metrics? [Ambiguity, Spec §NFR-1]"
|
||||
- Conflicts: "Do navigation requirements conflict between §FR-10 and §FR-10a? [Conflict]"
|
||||
- Assumptions: "Is the assumption of 'always available podcast API' validated? [Assumption]"
|
||||
- Dependencies: "Are external podcast API requirements documented? [Dependency, Gap]"
|
||||
- Missing definitions: "Is 'visual hierarchy' defined with measurable criteria? [Gap]"
|
||||
|
||||
**Content Consolidation**:
|
||||
- Soft cap: If raw candidate items > 40, prioritize by risk/impact
|
||||
- Merge near-duplicates checking the same requirement aspect
|
||||
- If >5 low-impact edge cases, create one item: "Are edge cases X, Y, Z addressed in requirements? [Coverage]"
|
||||
|
||||
**🚫 ABSOLUTELY PROHIBITED** - These make it an implementation test, not a requirements test:
|
||||
- ❌ Any item starting with "Verify", "Test", "Confirm", "Check" + implementation behavior
|
||||
- ❌ References to code execution, user actions, system behavior
|
||||
- ❌ "Displays correctly", "works properly", "functions as expected"
|
||||
- ❌ "Click", "navigate", "render", "load", "execute"
|
||||
- ❌ Test cases, test plans, QA procedures
|
||||
- ❌ Implementation details (frameworks, APIs, algorithms)
|
||||
|
||||
**✅ REQUIRED PATTERNS** - These test requirements quality:
|
||||
- ✅ "Are [requirement type] defined/specified/documented for [scenario]?"
|
||||
- ✅ "Is [vague term] quantified/clarified with specific criteria?"
|
||||
- ✅ "Are requirements consistent between [section A] and [section B]?"
|
||||
- ✅ "Can [requirement] be objectively measured/verified?"
|
||||
- ✅ "Are [edge cases/scenarios] addressed in requirements?"
|
||||
- ✅ "Does the spec define [missing aspect]?"
|
||||
|
||||
6. **Structure Reference**: Generate the checklist following the canonical template in `.specify/templates/checklist-template.md` for title, meta section, category headings, and ID formatting. If template is unavailable, use: H1 title, purpose/created meta lines, `##` category sections containing `- [ ] CHK### <requirement item>` lines with globally incrementing IDs starting at CHK001.
|
||||
|
||||
7. **Report**: Output full path to checklist file, item count, and summarize whether the run created a new file or appended to an existing one. Summarize:
|
||||
- Focus areas selected
|
||||
- Depth level
|
||||
- Actor/timing
|
||||
- Any explicit user-specified must-have items incorporated
|
||||
|
||||
**Important**: Each `/speckit.checklist` command invocation uses a short, descriptive checklist filename and either creates a new file or appends to an existing one. This allows:
|
||||
|
||||
- Multiple checklists of different types (e.g., `ux.md`, `test.md`, `security.md`)
|
||||
- Simple, memorable filenames that indicate checklist purpose
|
||||
- Easy identification and navigation in the `checklists/` folder
|
||||
|
||||
To avoid clutter, use descriptive types and clean up obsolete checklists when done.
|
||||
|
||||
## Example Checklist Types & Sample Items
|
||||
|
||||
**UX Requirements Quality:** `ux.md`
|
||||
|
||||
Sample items (testing the requirements, NOT the implementation):
|
||||
|
||||
- "Are visual hierarchy requirements defined with measurable criteria? [Clarity, Spec §FR-1]"
|
||||
- "Is the number and positioning of UI elements explicitly specified? [Completeness, Spec §FR-1]"
|
||||
- "Are interaction state requirements (hover, focus, active) consistently defined? [Consistency]"
|
||||
- "Are accessibility requirements specified for all interactive elements? [Coverage, Gap]"
|
||||
- "Is fallback behavior defined when images fail to load? [Edge Case, Gap]"
|
||||
- "Can 'prominent display' be objectively measured? [Measurability, Spec §FR-4]"
|
||||
|
||||
**API Requirements Quality:** `api.md`
|
||||
|
||||
Sample items:
|
||||
|
||||
- "Are error response formats specified for all failure scenarios? [Completeness]"
|
||||
- "Are rate limiting requirements quantified with specific thresholds? [Clarity]"
|
||||
- "Are authentication requirements consistent across all endpoints? [Consistency]"
|
||||
- "Are retry/timeout requirements defined for external dependencies? [Coverage, Gap]"
|
||||
- "Is versioning strategy documented in requirements? [Gap]"
|
||||
|
||||
**Performance Requirements Quality:** `performance.md`
|
||||
|
||||
Sample items:
|
||||
|
||||
- "Are performance requirements quantified with specific metrics? [Clarity]"
|
||||
- "Are performance targets defined for all critical user journeys? [Coverage]"
|
||||
- "Are performance requirements under different load conditions specified? [Completeness]"
|
||||
- "Can performance requirements be objectively measured? [Measurability]"
|
||||
- "Are degradation requirements defined for high-load scenarios? [Edge Case, Gap]"
|
||||
|
||||
**Security Requirements Quality:** `security.md`
|
||||
|
||||
Sample items:
|
||||
|
||||
- "Are authentication requirements specified for all protected resources? [Coverage]"
|
||||
- "Are data protection requirements defined for sensitive information? [Completeness]"
|
||||
- "Is the threat model documented and requirements aligned to it? [Traceability]"
|
||||
- "Are security requirements consistent with compliance obligations? [Consistency]"
|
||||
- "Are security failure/breach response requirements defined? [Gap, Exception Flow]"
|
||||
|
||||
## Anti-Examples: What NOT To Do
|
||||
|
||||
**❌ WRONG - These test implementation, not requirements:**
|
||||
|
||||
```markdown
|
||||
- [ ] CHK001 - Verify landing page displays 3 episode cards [Spec §FR-001]
|
||||
- [ ] CHK002 - Test hover states work correctly on desktop [Spec §FR-003]
|
||||
- [ ] CHK003 - Confirm logo click navigates to home page [Spec §FR-010]
|
||||
- [ ] CHK004 - Check that related episodes section shows 3-5 items [Spec §FR-005]
|
||||
```
|
||||
|
||||
**✅ CORRECT - These test requirements quality:**
|
||||
|
||||
```markdown
|
||||
- [ ] CHK001 - Are the number and layout of featured episodes explicitly specified? [Completeness, Spec §FR-001]
|
||||
- [ ] CHK002 - Are hover state requirements consistently defined for all interactive elements? [Consistency, Spec §FR-003]
|
||||
- [ ] CHK003 - Are navigation requirements clear for all clickable brand elements? [Clarity, Spec §FR-010]
|
||||
- [ ] CHK004 - Is the selection criteria for related episodes documented? [Gap, Spec §FR-005]
|
||||
- [ ] CHK005 - Are loading state requirements defined for asynchronous episode data? [Gap]
|
||||
- [ ] CHK006 - Can "visual hierarchy" requirements be objectively measured? [Measurability, Spec §FR-001]
|
||||
```
|
||||
|
||||
**Key Differences:**
|
||||
|
||||
- Wrong: Tests if the system works correctly
|
||||
- Correct: Tests if the requirements are written correctly
|
||||
- Wrong: Verification of behavior
|
||||
- Correct: Validation of requirement quality
|
||||
- Wrong: "Does it do X?"
|
||||
- Correct: "Is X clearly specified?"
|
||||
@@ -1,181 +0,0 @@
|
||||
---
|
||||
description: Identify underspecified areas in the current feature spec by asking up to 5 highly targeted clarification questions and encoding answers back into the spec.
|
||||
handoffs:
|
||||
- label: Build Technical Plan
|
||||
agent: speckit.plan
|
||||
prompt: Create a plan for the spec. I am building with...
|
||||
---
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Outline
|
||||
|
||||
Goal: Detect and reduce ambiguity or missing decision points in the active feature specification and record the clarifications directly in the spec file.
|
||||
|
||||
Note: This clarification workflow is expected to run (and be completed) BEFORE invoking `/speckit.plan`. If the user explicitly states they are skipping clarification (e.g., exploratory spike), you may proceed, but must warn that downstream rework risk increases.
|
||||
|
||||
Execution steps:
|
||||
|
||||
1. Run `.specify/scripts/bash/check-prerequisites.sh --json --paths-only` from repo root **once** (combined `--json --paths-only` mode / `-Json -PathsOnly`). Parse minimal JSON payload fields:
|
||||
- `FEATURE_DIR`
|
||||
- `FEATURE_SPEC`
|
||||
- (Optionally capture `IMPL_PLAN`, `TASKS` for future chained flows.)
|
||||
- If JSON parsing fails, abort and instruct user to re-run `/speckit.specify` or verify feature branch environment.
|
||||
- For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
|
||||
2. Load the current spec file. Perform a structured ambiguity & coverage scan using this taxonomy. For each category, mark status: Clear / Partial / Missing. Produce an internal coverage map used for prioritization (do not output raw map unless no questions will be asked).
|
||||
|
||||
Functional Scope & Behavior:
|
||||
- Core user goals & success criteria
|
||||
- Explicit out-of-scope declarations
|
||||
- User roles / personas differentiation
|
||||
|
||||
Domain & Data Model:
|
||||
- Entities, attributes, relationships
|
||||
- Identity & uniqueness rules
|
||||
- Lifecycle/state transitions
|
||||
- Data volume / scale assumptions
|
||||
|
||||
Interaction & UX Flow:
|
||||
- Critical user journeys / sequences
|
||||
- Error/empty/loading states
|
||||
- Accessibility or localization notes
|
||||
|
||||
Non-Functional Quality Attributes:
|
||||
- Performance (latency, throughput targets)
|
||||
- Scalability (horizontal/vertical, limits)
|
||||
- Reliability & availability (uptime, recovery expectations)
|
||||
- Observability (logging, metrics, tracing signals)
|
||||
- Security & privacy (authN/Z, data protection, threat assumptions)
|
||||
- Compliance / regulatory constraints (if any)
|
||||
|
||||
Integration & External Dependencies:
|
||||
- External services/APIs and failure modes
|
||||
- Data import/export formats
|
||||
- Protocol/versioning assumptions
|
||||
|
||||
Edge Cases & Failure Handling:
|
||||
- Negative scenarios
|
||||
- Rate limiting / throttling
|
||||
- Conflict resolution (e.g., concurrent edits)
|
||||
|
||||
Constraints & Tradeoffs:
|
||||
- Technical constraints (language, storage, hosting)
|
||||
- Explicit tradeoffs or rejected alternatives
|
||||
|
||||
Terminology & Consistency:
|
||||
- Canonical glossary terms
|
||||
- Avoided synonyms / deprecated terms
|
||||
|
||||
Completion Signals:
|
||||
- Acceptance criteria testability
|
||||
- Measurable Definition of Done style indicators
|
||||
|
||||
Misc / Placeholders:
|
||||
- TODO markers / unresolved decisions
|
||||
- Ambiguous adjectives ("robust", "intuitive") lacking quantification
|
||||
|
||||
For each category with Partial or Missing status, add a candidate question opportunity unless:
|
||||
- Clarification would not materially change implementation or validation strategy
|
||||
- Information is better deferred to planning phase (note internally)
|
||||
|
||||
3. Generate (internally) a prioritized queue of candidate clarification questions (maximum 5). Do NOT output them all at once. Apply these constraints:
|
||||
- Maximum of 5 total questions across the whole session.
|
||||
- Each question must be answerable with EITHER:
|
||||
- A short multiple‑choice selection (2–5 distinct, mutually exclusive options), OR
|
||||
- A one-word / short‑phrase answer (explicitly constrain: "Answer in <=5 words").
|
||||
- Only include questions whose answers materially impact architecture, data modeling, task decomposition, test design, UX behavior, operational readiness, or compliance validation.
|
||||
- Ensure category coverage balance: attempt to cover the highest impact unresolved categories first; avoid asking two low-impact questions when a single high-impact area (e.g., security posture) is unresolved.
|
||||
- Exclude questions already answered, trivial stylistic preferences, or plan-level execution details (unless blocking correctness).
|
||||
- Favor clarifications that reduce downstream rework risk or prevent misaligned acceptance tests.
|
||||
- If more than 5 categories remain unresolved, select the top 5 by (Impact \* Uncertainty) heuristic.
|
||||
|
||||
4. Sequential questioning loop (interactive):
|
||||
- Present EXACTLY ONE question at a time.
|
||||
- For multiple‑choice questions:
|
||||
- **Analyze all options** and determine the **most suitable option** based on:
|
||||
- Best practices for the project type
|
||||
- Common patterns in similar implementations
|
||||
- Risk reduction (security, performance, maintainability)
|
||||
- Alignment with any explicit project goals or constraints visible in the spec
|
||||
- Present your **recommended option prominently** at the top with clear reasoning (1-2 sentences explaining why this is the best choice).
|
||||
- Format as: `**Recommended:** Option [X] - <reasoning>`
|
||||
- Then render all options as a Markdown table:
|
||||
|
||||
| Option | Description |
|
||||
| ------ | --------------------------------------------------------------------------------------------------- |
|
||||
| A | <Option A description> |
|
||||
| B | <Option B description> |
|
||||
| C | <Option C description> (add D/E as needed up to 5) |
|
||||
| Short | Provide a different short answer (<=5 words) (Include only if free-form alternative is appropriate) |
|
||||
- After the table, add: `You can reply with the option letter (e.g., "A"), accept the recommendation by saying "yes" or "recommended", or provide your own short answer.`
|
||||
|
||||
- For short‑answer style (no meaningful discrete options):
|
||||
- Provide your **suggested answer** based on best practices and context.
|
||||
- Format as: `**Suggested:** <your proposed answer> - <brief reasoning>`
|
||||
- Then output: `Format: Short answer (<=5 words). You can accept the suggestion by saying "yes" or "suggested", or provide your own answer.`
|
||||
- After the user answers:
|
||||
- If the user replies with "yes", "recommended", or "suggested", use your previously stated recommendation/suggestion as the answer.
|
||||
- Otherwise, validate the answer maps to one option or fits the <=5 word constraint.
|
||||
- If ambiguous, ask for a quick disambiguation (count still belongs to same question; do not advance).
|
||||
- Once satisfactory, record it in working memory (do not yet write to disk) and move to the next queued question.
|
||||
- Stop asking further questions when:
|
||||
- All critical ambiguities resolved early (remaining queued items become unnecessary), OR
|
||||
- User signals completion ("done", "good", "no more"), OR
|
||||
- You reach 5 asked questions.
|
||||
- Never reveal future queued questions in advance.
|
||||
- If no valid questions exist at start, immediately report no critical ambiguities.
|
||||
|
||||
5. Integration after EACH accepted answer (incremental update approach):
|
||||
- Maintain in-memory representation of the spec (loaded once at start) plus the raw file contents.
|
||||
- For the first integrated answer in this session:
|
||||
- Ensure a `## Clarifications` section exists (create it just after the highest-level contextual/overview section per the spec template if missing).
|
||||
- Under it, create (if not present) a `### Session YYYY-MM-DD` subheading for today.
|
||||
- Append a bullet line immediately after acceptance: `- Q: <question> → A: <final answer>`.
|
||||
- Then immediately apply the clarification to the most appropriate section(s):
|
||||
- Functional ambiguity → Update or add a bullet in Functional Requirements.
|
||||
- User interaction / actor distinction → Update User Stories or Actors subsection (if present) with clarified role, constraint, or scenario.
|
||||
- Data shape / entities → Update Data Model (add fields, types, relationships) preserving ordering; note added constraints succinctly.
|
||||
- Non-functional constraint → Add/modify measurable criteria in Success Criteria > Measurable Outcomes (convert vague adjective to metric or explicit target).
|
||||
- Edge case / negative flow → Add a new bullet under Edge Cases / Error Handling (or create such subsection if template provides placeholder for it).
|
||||
- Terminology conflict → Normalize term across spec; retain original only if necessary by adding `(formerly referred to as "X")` once.
|
||||
- If the clarification invalidates an earlier ambiguous statement, replace that statement instead of duplicating; leave no obsolete contradictory text.
|
||||
- Save the spec file AFTER each integration to minimize risk of context loss (atomic overwrite).
|
||||
- Preserve formatting: do not reorder unrelated sections; keep heading hierarchy intact.
|
||||
- Keep each inserted clarification minimal and testable (avoid narrative drift).
|
||||
|
||||
6. Validation (performed after EACH write plus final pass):
|
||||
- Clarifications session contains exactly one bullet per accepted answer (no duplicates).
|
||||
- Total asked (accepted) questions ≤ 5.
|
||||
- Updated sections contain no lingering vague placeholders the new answer was meant to resolve.
|
||||
- No contradictory earlier statement remains (scan for now-invalid alternative choices removed).
|
||||
- Markdown structure valid; only allowed new headings: `## Clarifications`, `### Session YYYY-MM-DD`.
|
||||
- Terminology consistency: same canonical term used across all updated sections.
|
||||
|
||||
7. Write the updated spec back to `FEATURE_SPEC`.
|
||||
|
||||
8. Report completion (after questioning loop ends or early termination):
|
||||
- Number of questions asked & answered.
|
||||
- Path to updated spec.
|
||||
- Sections touched (list names).
|
||||
- Coverage summary table listing each taxonomy category with Status: Resolved (was Partial/Missing and addressed), Deferred (exceeds question quota or better suited for planning), Clear (already sufficient), Outstanding (still Partial/Missing but low impact).
|
||||
- If any Outstanding or Deferred remain, recommend whether to proceed to `/speckit.plan` or run `/speckit.clarify` again later post-plan.
|
||||
- Suggested next command.
|
||||
|
||||
Behavior rules:
|
||||
|
||||
- If no meaningful ambiguities found (or all potential questions would be low-impact), respond: "No critical ambiguities detected worth formal clarification." and suggest proceeding.
|
||||
- If spec file missing, instruct user to run `/speckit.specify` first (do not create a new spec here).
|
||||
- Never exceed 5 total asked questions (clarification retries for a single question do not count as new questions).
|
||||
- Avoid speculative tech stack questions unless the absence blocks functional clarity.
|
||||
- Respect user early termination signals ("stop", "done", "proceed").
|
||||
- If no questions asked due to full coverage, output a compact coverage summary (all categories Clear) then suggest advancing.
|
||||
- If quota reached with unresolved high-impact categories remaining, explicitly flag them under Deferred with rationale.
|
||||
|
||||
Context for prioritization: $ARGUMENTS
|
||||
@@ -1,84 +0,0 @@
|
||||
---
|
||||
description: Create or update the project constitution from interactive or provided principle inputs, ensuring all dependent templates stay in sync.
|
||||
handoffs:
|
||||
- label: Build Specification
|
||||
agent: speckit.specify
|
||||
prompt: Implement the feature specification based on the updated constitution. I want to build...
|
||||
---
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Outline
|
||||
|
||||
You are updating the project constitution at `.specify/memory/constitution.md`. This file is a TEMPLATE containing placeholder tokens in square brackets (e.g. `[PROJECT_NAME]`, `[PRINCIPLE_1_NAME]`). Your job is to (a) collect/derive concrete values, (b) fill the template precisely, and (c) propagate any amendments across dependent artifacts.
|
||||
|
||||
**Note**: If `.specify/memory/constitution.md` does not exist yet, it should have been initialized from `.specify/templates/constitution-template.md` during project setup. If it's missing, copy the template first.
|
||||
|
||||
Follow this execution flow:
|
||||
|
||||
1. Load the existing constitution at `.specify/memory/constitution.md`.
|
||||
- Identify every placeholder token of the form `[ALL_CAPS_IDENTIFIER]`.
|
||||
**IMPORTANT**: The user might require less or more principles than the ones used in the template. If a number is specified, respect that - follow the general template. You will update the doc accordingly.
|
||||
|
||||
2. Collect/derive values for placeholders:
|
||||
- If user input (conversation) supplies a value, use it.
|
||||
- Otherwise infer from existing repo context (README, docs, prior constitution versions if embedded).
|
||||
- For governance dates: `RATIFICATION_DATE` is the original adoption date (if unknown ask or mark TODO), `LAST_AMENDED_DATE` is today if changes are made, otherwise keep previous.
|
||||
- `CONSTITUTION_VERSION` must increment according to semantic versioning rules:
|
||||
- MAJOR: Backward incompatible governance/principle removals or redefinitions.
|
||||
- MINOR: New principle/section added or materially expanded guidance.
|
||||
- PATCH: Clarifications, wording, typo fixes, non-semantic refinements.
|
||||
- If version bump type ambiguous, propose reasoning before finalizing.
|
||||
|
||||
3. Draft the updated constitution content:
|
||||
- Replace every placeholder with concrete text (no bracketed tokens left except intentionally retained template slots that the project has chosen not to define yet—explicitly justify any left).
|
||||
- Preserve heading hierarchy and comments can be removed once replaced unless they still add clarifying guidance.
|
||||
- Ensure each Principle section: succinct name line, paragraph (or bullet list) capturing non‑negotiable rules, explicit rationale if not obvious.
|
||||
- Ensure Governance section lists amendment procedure, versioning policy, and compliance review expectations.
|
||||
|
||||
4. Consistency propagation checklist (convert prior checklist into active validations):
|
||||
- Read `.specify/templates/plan-template.md` and ensure any "Constitution Check" or rules align with updated principles.
|
||||
- Read `.specify/templates/spec-template.md` for scope/requirements alignment—update if constitution adds/removes mandatory sections or constraints.
|
||||
- Read `.specify/templates/tasks-template.md` and ensure task categorization reflects new or removed principle-driven task types (e.g., observability, versioning, testing discipline).
|
||||
- Read each command file in `.specify/templates/commands/*.md` (including this one) to verify no outdated references (agent-specific names like CLAUDE only) remain when generic guidance is required.
|
||||
- Read any runtime guidance docs (e.g., `README.md`, `docs/quickstart.md`, or agent-specific guidance files if present). Update references to principles changed.
|
||||
|
||||
5. Produce a Sync Impact Report (prepend as an HTML comment at top of the constitution file after update):
|
||||
- Version change: old → new
|
||||
- List of modified principles (old title → new title if renamed)
|
||||
- Added sections
|
||||
- Removed sections
|
||||
- Templates requiring updates (✅ updated / ⚠ pending) with file paths
|
||||
- Follow-up TODOs if any placeholders intentionally deferred.
|
||||
|
||||
6. Validation before final output:
|
||||
- No remaining unexplained bracket tokens.
|
||||
- Version line matches report.
|
||||
- Dates ISO format YYYY-MM-DD.
|
||||
- Principles are declarative, testable, and free of vague language ("should" → replace with MUST/SHOULD rationale where appropriate).
|
||||
|
||||
7. Write the completed constitution back to `.specify/memory/constitution.md` (overwrite).
|
||||
|
||||
8. Output a final summary to the user with:
|
||||
- New version and bump rationale.
|
||||
- Any files flagged for manual follow-up.
|
||||
- Suggested commit message (e.g., `docs: amend constitution to vX.Y.Z (principle additions + governance update)`).
|
||||
|
||||
Formatting & Style Requirements:
|
||||
|
||||
- Use Markdown headings exactly as in the template (do not demote/promote levels).
|
||||
- Wrap long rationale lines to keep readability (<100 chars ideally) but do not hard enforce with awkward breaks.
|
||||
- Keep a single blank line between sections.
|
||||
- Avoid trailing whitespace.
|
||||
|
||||
If the user supplies partial updates (e.g., only one principle revision), still perform validation and version decision steps.
|
||||
|
||||
If critical info missing (e.g., ratification date truly unknown), insert `TODO(<FIELD_NAME>): explanation` and include in the Sync Impact Report under deferred items.
|
||||
|
||||
Do not create a new template; always operate on the existing `.specify/memory/constitution.md` file.
|
||||
@@ -1,207 +0,0 @@
|
||||
---
|
||||
description: Execute the implementation plan by processing and executing all tasks defined in tasks.md
|
||||
---
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Pre-Execution Checks
|
||||
|
||||
**Check for extension hooks (before implementation)**:
|
||||
|
||||
- Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.before_implement` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Pre-Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Pre-Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
|
||||
Wait for the result of the hook command before proceeding to the Outline.
|
||||
```
|
||||
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
## Outline
|
||||
|
||||
1. Run `.specify/scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
|
||||
2. **Check checklists status** (if FEATURE_DIR/checklists/ exists):
|
||||
- Scan all checklist files in the checklists/ directory
|
||||
- For each checklist, count:
|
||||
- Total items: All lines matching `- [ ]` or `- [X]` or `- [x]`
|
||||
- Completed items: Lines matching `- [X]` or `- [x]`
|
||||
- Incomplete items: Lines matching `- [ ]`
|
||||
- Create a status table:
|
||||
|
||||
```text
|
||||
| Checklist | Total | Completed | Incomplete | Status |
|
||||
|-----------|-------|-----------|------------|--------|
|
||||
| ux.md | 12 | 12 | 0 | ✓ PASS |
|
||||
| test.md | 8 | 5 | 3 | ✗ FAIL |
|
||||
| security.md | 6 | 6 | 0 | ✓ PASS |
|
||||
```
|
||||
|
||||
- Calculate overall status:
|
||||
- **PASS**: All checklists have 0 incomplete items
|
||||
- **FAIL**: One or more checklists have incomplete items
|
||||
|
||||
- **If any checklist is incomplete**:
|
||||
- Display the table with incomplete item counts
|
||||
- **STOP** and ask: "Some checklists are incomplete. Do you want to proceed with implementation anyway? (yes/no)"
|
||||
- Wait for user response before continuing
|
||||
- If user says "no" or "wait" or "stop", halt execution
|
||||
- If user says "yes" or "proceed" or "continue", proceed to step 3
|
||||
|
||||
- **If all checklists are complete**:
|
||||
- Display the table showing all checklists passed
|
||||
- Automatically proceed to step 3
|
||||
|
||||
3. Load and analyze the implementation context:
|
||||
- **REQUIRED**: Read tasks.md for the complete task list and execution plan
|
||||
- **REQUIRED**: Read plan.md for tech stack, architecture, and file structure
|
||||
- **IF EXISTS**: Read data-model.md for entities and relationships
|
||||
- **IF EXISTS**: Read contracts/ for API specifications and test requirements
|
||||
- **IF EXISTS**: Read research.md for technical decisions and constraints
|
||||
- **IF EXISTS**: Read quickstart.md for integration scenarios
|
||||
|
||||
4. **Project Setup Verification**:
|
||||
- **REQUIRED**: Create/verify ignore files based on actual project setup:
|
||||
|
||||
**Detection & Creation Logic**:
|
||||
- Check if the following command succeeds to determine if the repository is a git repo (create/verify .gitignore if so):
|
||||
|
||||
```sh
|
||||
git rev-parse --git-dir 2>/dev/null
|
||||
```
|
||||
|
||||
- Check if Dockerfile\* exists or Docker in plan.md → create/verify .dockerignore
|
||||
- Check if .eslintrc\* exists → create/verify .eslintignore
|
||||
- Check if eslint.config.\* exists → ensure the config's `ignores` entries cover required patterns
|
||||
- Check if .prettierrc\* exists → create/verify .prettierignore
|
||||
- Check if .npmrc or package.json exists → create/verify .npmignore (if publishing)
|
||||
- Check if terraform files (\*.tf) exist → create/verify .terraformignore
|
||||
- Check if .helmignore needed (helm charts present) → create/verify .helmignore
|
||||
|
||||
**If ignore file already exists**: Verify it contains essential patterns, append missing critical patterns only
|
||||
**If ignore file missing**: Create with full pattern set for detected technology
|
||||
|
||||
**Common Patterns by Technology** (from plan.md tech stack):
|
||||
- **Node.js/JavaScript/TypeScript**: `node_modules/`, `dist/`, `build/`, `*.log`, `.env*`
|
||||
- **Python**: `__pycache__/`, `*.pyc`, `.venv/`, `venv/`, `dist/`, `*.egg-info/`
|
||||
- **Java**: `target/`, `*.class`, `*.jar`, `.gradle/`, `build/`
|
||||
- **C#/.NET**: `bin/`, `obj/`, `*.user`, `*.suo`, `packages/`
|
||||
- **Go**: `*.exe`, `*.test`, `vendor/`, `*.out`
|
||||
- **Ruby**: `.bundle/`, `log/`, `tmp/`, `*.gem`, `vendor/bundle/`
|
||||
- **PHP**: `vendor/`, `*.log`, `*.cache`, `*.env`
|
||||
- **Rust**: `target/`, `debug/`, `release/`, `*.rs.bk`, `*.rlib`, `*.prof*`, `.idea/`, `*.log`, `.env*`
|
||||
- **Kotlin**: `build/`, `out/`, `.gradle/`, `.idea/`, `*.class`, `*.jar`, `*.iml`, `*.log`, `.env*`
|
||||
- **C++**: `build/`, `bin/`, `obj/`, `out/`, `*.o`, `*.so`, `*.a`, `*.exe`, `*.dll`, `.idea/`, `*.log`, `.env*`
|
||||
- **C**: `build/`, `bin/`, `obj/`, `out/`, `*.o`, `*.a`, `*.so`, `*.exe`, `*.dll`, `autom4te.cache/`, `config.status`, `config.log`, `.idea/`, `*.log`, `.env*`
|
||||
- **Swift**: `.build/`, `DerivedData/`, `*.swiftpm/`, `Packages/`
|
||||
- **R**: `.Rproj.user/`, `.Rhistory`, `.RData`, `.Ruserdata`, `*.Rproj`, `packrat/`, `renv/`
|
||||
- **Universal**: `.DS_Store`, `Thumbs.db`, `*.tmp`, `*.swp`, `.vscode/`, `.idea/`
|
||||
|
||||
**Tool-Specific Patterns**:
|
||||
- **Docker**: `node_modules/`, `.git/`, `Dockerfile*`, `.dockerignore`, `*.log*`, `.env*`, `coverage/`
|
||||
- **ESLint**: `node_modules/`, `dist/`, `build/`, `coverage/`, `*.min.js`
|
||||
- **Prettier**: `node_modules/`, `dist/`, `build/`, `coverage/`, `package-lock.json`, `yarn.lock`, `pnpm-lock.yaml`
|
||||
- **Terraform**: `.terraform/`, `*.tfstate*`, `*.tfvars`, `.terraform.lock.hcl`
|
||||
- **Kubernetes/k8s**: `*.secret.yaml`, `secrets/`, `.kube/`, `kubeconfig*`, `*.key`, `*.crt`
|
||||
|
||||
5. Parse tasks.md structure and extract:
|
||||
- **Task phases**: Setup, Tests, Core, Integration, Polish
|
||||
- **Task dependencies**: Sequential vs parallel execution rules
|
||||
- **Task details**: ID, description, file paths, parallel markers [P]
|
||||
- **Execution flow**: Order and dependency requirements
|
||||
|
||||
6. Execute implementation following the task plan:
|
||||
- **Phase-by-phase execution**: Complete each phase before moving to the next
|
||||
- **Respect dependencies**: Run sequential tasks in order, parallel tasks [P] can run together
|
||||
- **Follow TDD approach**: Execute test tasks before their corresponding implementation tasks
|
||||
- **File-based coordination**: Tasks affecting the same files must run sequentially
|
||||
- **Validation checkpoints**: Verify each phase completion before proceeding
|
||||
|
||||
7. Implementation execution rules:
|
||||
- **Setup first**: Initialize project structure, dependencies, configuration
|
||||
- **Tests before code**: If you need to write tests for contracts, entities, and integration scenarios
|
||||
- **Core development**: Implement models, services, CLI commands, endpoints
|
||||
- **Integration work**: Database connections, middleware, logging, external services
|
||||
- **Polish and validation**: Unit tests, performance optimization, documentation
|
||||
|
||||
8. Progress tracking and error handling:
|
||||
- Report progress after each completed task
|
||||
- Halt execution if any non-parallel task fails
|
||||
- For parallel tasks [P], continue with successful tasks, report failed ones
|
||||
- Provide clear error messages with context for debugging
|
||||
- Suggest next steps if implementation cannot proceed
|
||||
- **IMPORTANT** For completed tasks, make sure to mark the task off as [X] in the tasks file.
|
||||
|
||||
9. Completion validation:
|
||||
- Verify all required tasks are completed
|
||||
- Check that implemented features match the original specification
|
||||
- Validate that tests pass and coverage meets requirements
|
||||
- Confirm the implementation follows the technical plan
|
||||
- Report final status with summary of completed work
|
||||
|
||||
Note: This command assumes a complete task breakdown exists in tasks.md. If tasks are incomplete or missing, suggest running `/speckit.tasks` first to regenerate the task list.
|
||||
|
||||
10. **Check for extension hooks**: After completion validation, check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.after_implement` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
```
|
||||
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
@@ -1,162 +0,0 @@
|
||||
---
|
||||
description: Execute the implementation planning workflow using the plan template to generate design artifacts.
|
||||
handoffs:
|
||||
- label: Create Tasks
|
||||
agent: speckit.tasks
|
||||
prompt: Break the plan into tasks
|
||||
send: true
|
||||
- label: Create Checklist
|
||||
agent: speckit.checklist
|
||||
prompt: Create a checklist for the following domain...
|
||||
---
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Pre-Execution Checks
|
||||
|
||||
**Check for extension hooks (before planning)**:
|
||||
|
||||
- Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.before_plan` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Pre-Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Pre-Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
|
||||
Wait for the result of the hook command before proceeding to the Outline.
|
||||
```
|
||||
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
## Outline
|
||||
|
||||
1. **Setup**: Run `.specify/scripts/bash/setup-plan.sh --json` from repo root and parse JSON for FEATURE_SPEC, IMPL_PLAN, SPECS_DIR, BRANCH. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
|
||||
2. **Load context**: Read FEATURE_SPEC and `.specify/memory/constitution.md`. Load IMPL_PLAN template (already copied).
|
||||
|
||||
3. **Execute plan workflow**: Follow the structure in IMPL_PLAN template to:
|
||||
- Fill Technical Context (mark unknowns as "NEEDS CLARIFICATION")
|
||||
- Fill Constitution Check section from constitution
|
||||
- Evaluate gates (ERROR if violations unjustified)
|
||||
- Phase 0: Generate research.md (resolve all NEEDS CLARIFICATION)
|
||||
- Phase 1: Generate data-model.md, contracts/, quickstart.md
|
||||
- Phase 1: Update agent context by running the agent script
|
||||
- Re-evaluate Constitution Check post-design
|
||||
|
||||
4. **Stop and report**: Command ends after Phase 2 planning. Report branch, IMPL_PLAN path, and generated artifacts.
|
||||
|
||||
5. **Check for extension hooks**: After reporting, check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.after_plan` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
```
|
||||
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
## Phases
|
||||
|
||||
### Phase 0: Outline & Research
|
||||
|
||||
1. **Extract unknowns from Technical Context** above:
|
||||
- For each NEEDS CLARIFICATION → research task
|
||||
- For each dependency → best practices task
|
||||
- For each integration → patterns task
|
||||
|
||||
2. **Generate and dispatch research agents**:
|
||||
|
||||
```text
|
||||
For each unknown in Technical Context:
|
||||
Task: "Research {unknown} for {feature context}"
|
||||
For each technology choice:
|
||||
Task: "Find best practices for {tech} in {domain}"
|
||||
```
|
||||
|
||||
3. **Consolidate findings** in `research.md` using format:
|
||||
- Decision: [what was chosen]
|
||||
- Rationale: [why chosen]
|
||||
- Alternatives considered: [what else evaluated]
|
||||
|
||||
**Output**: research.md with all NEEDS CLARIFICATION resolved
|
||||
|
||||
### Phase 1: Design & Contracts
|
||||
|
||||
**Prerequisites:** `research.md` complete
|
||||
|
||||
1. **Extract entities from feature spec** → `data-model.md`:
|
||||
- Entity name, fields, relationships
|
||||
- Validation rules from requirements
|
||||
- State transitions if applicable
|
||||
|
||||
2. **Define interface contracts** (if project has external interfaces) → `/contracts/`:
|
||||
- Identify what interfaces the project exposes to users or other systems
|
||||
- Document the contract format appropriate for the project type
|
||||
- Examples: public APIs for libraries, command schemas for CLI tools, endpoints for web services, grammars for parsers, UI contracts for applications
|
||||
- Skip if project is purely internal (build scripts, one-off tools, etc.)
|
||||
|
||||
3. **Agent context update**:
|
||||
- Run `.specify/scripts/bash/update-agent-context.sh copilot`
|
||||
- These scripts detect which AI agent is in use
|
||||
- Update the appropriate agent-specific context file
|
||||
- Add only new technology from current plan
|
||||
- Preserve manual additions between markers
|
||||
|
||||
**Output**: data-model.md, /contracts/\*, quickstart.md, agent-specific file
|
||||
|
||||
## Key rules
|
||||
|
||||
- Use absolute paths
|
||||
- ERROR on gate failures or unresolved clarifications
|
||||
@@ -1,313 +0,0 @@
|
||||
---
|
||||
description: Create or update the feature specification from a natural language feature description.
|
||||
handoffs:
|
||||
- label: Build Technical Plan
|
||||
agent: speckit.plan
|
||||
prompt: Create a plan for the spec. I am building with...
|
||||
- label: Clarify Spec Requirements
|
||||
agent: speckit.clarify
|
||||
prompt: Clarify specification requirements
|
||||
send: true
|
||||
---
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Pre-Execution Checks
|
||||
|
||||
**Check for extension hooks (before specification)**:
|
||||
|
||||
- Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.before_specify` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Pre-Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Pre-Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
|
||||
Wait for the result of the hook command before proceeding to the Outline.
|
||||
```
|
||||
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
## Outline
|
||||
|
||||
The text the user typed after `/speckit.specify` in the triggering message **is** the feature description. Assume you always have it available in this conversation even if `$ARGUMENTS` appears literally below. Do not ask the user to repeat it unless they provided an empty command.
|
||||
|
||||
Given that feature description, do this:
|
||||
|
||||
1. **Generate a concise short name** (2-4 words) for the branch:
|
||||
- Analyze the feature description and extract the most meaningful keywords
|
||||
- Create a 2-4 word short name that captures the essence of the feature
|
||||
- Use action-noun format when possible (e.g., "add-user-auth", "fix-payment-bug")
|
||||
- Preserve technical terms and acronyms (OAuth2, API, JWT, etc.)
|
||||
- Keep it concise but descriptive enough to understand the feature at a glance
|
||||
- Examples:
|
||||
- "I want to add user authentication" → "user-auth"
|
||||
- "Implement OAuth2 integration for the API" → "oauth2-api-integration"
|
||||
- "Create a dashboard for analytics" → "analytics-dashboard"
|
||||
- "Fix payment processing timeout bug" → "fix-payment-timeout"
|
||||
|
||||
2. **Create the feature branch** by running the script with `--short-name` (and `--json`). In sequential mode, do NOT pass `--number` — the script auto-detects the next available number. In timestamp mode, the script generates a `YYYYMMDD-HHMMSS` prefix automatically:
|
||||
|
||||
**Branch numbering mode**: Before running the script, check if `.specify/init-options.json` exists and read the `branch_numbering` value.
|
||||
- If `"timestamp"`, add `--timestamp` (Bash) or `-Timestamp` (PowerShell) to the script invocation
|
||||
- If `"sequential"` or absent, do not add any extra flag (default behavior)
|
||||
|
||||
- Bash example: `.specify/scripts/bash/create-new-feature.sh "$ARGUMENTS" --json --short-name "user-auth" "Add user authentication"`
|
||||
- Bash (timestamp): `.specify/scripts/bash/create-new-feature.sh "$ARGUMENTS" --json --timestamp --short-name "user-auth" "Add user authentication"`
|
||||
- PowerShell example: `.specify/scripts/bash/create-new-feature.sh "$ARGUMENTS" -Json -ShortName "user-auth" "Add user authentication"`
|
||||
- PowerShell (timestamp): `.specify/scripts/bash/create-new-feature.sh "$ARGUMENTS" -Json -Timestamp -ShortName "user-auth" "Add user authentication"`
|
||||
|
||||
**IMPORTANT**:
|
||||
- Do NOT pass `--number` — the script determines the correct next number automatically
|
||||
- Always include the JSON flag (`--json` for Bash, `-Json` for PowerShell) so the output can be parsed reliably
|
||||
- You must only ever run this script once per feature
|
||||
- The JSON is provided in the terminal as output - always refer to it to get the actual content you're looking for
|
||||
- The JSON output will contain BRANCH_NAME and SPEC_FILE paths
|
||||
- For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot")
|
||||
|
||||
3. Load `.specify/templates/spec-template.md` to understand required sections.
|
||||
|
||||
4. Follow this execution flow:
|
||||
1. Parse user description from Input
|
||||
If empty: ERROR "No feature description provided"
|
||||
2. Extract key concepts from description
|
||||
Identify: actors, actions, data, constraints
|
||||
3. For unclear aspects:
|
||||
- Make informed guesses based on context and industry standards
|
||||
- Only mark with [NEEDS CLARIFICATION: specific question] if:
|
||||
- The choice significantly impacts feature scope or user experience
|
||||
- Multiple reasonable interpretations exist with different implications
|
||||
- No reasonable default exists
|
||||
- **LIMIT: Maximum 3 [NEEDS CLARIFICATION] markers total**
|
||||
- Prioritize clarifications by impact: scope > security/privacy > user experience > technical details
|
||||
4. Fill User Scenarios & Testing section
|
||||
If no clear user flow: ERROR "Cannot determine user scenarios"
|
||||
5. Generate Functional Requirements
|
||||
Each requirement must be testable
|
||||
Use reasonable defaults for unspecified details (document assumptions in Assumptions section)
|
||||
6. Define Success Criteria
|
||||
Create measurable, technology-agnostic outcomes
|
||||
Include both quantitative metrics (time, performance, volume) and qualitative measures (user satisfaction, task completion)
|
||||
Each criterion must be verifiable without implementation details
|
||||
7. Identify Key Entities (if data involved)
|
||||
8. Return: SUCCESS (spec ready for planning)
|
||||
|
||||
5. Write the specification to SPEC_FILE using the template structure, replacing placeholders with concrete details derived from the feature description (arguments) while preserving section order and headings.
|
||||
|
||||
6. **Specification Quality Validation**: After writing the initial spec, validate it against quality criteria:
|
||||
|
||||
a. **Create Spec Quality Checklist**: Generate a checklist file at `FEATURE_DIR/checklists/requirements.md` using the checklist template structure with these validation items:
|
||||
|
||||
```markdown
|
||||
# Specification Quality Checklist: [FEATURE NAME]
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: [DATE]
|
||||
**Feature**: [Link to spec.md]
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [ ] No implementation details (languages, frameworks, APIs)
|
||||
- [ ] Focused on user value and business needs
|
||||
- [ ] Written for non-technical stakeholders
|
||||
- [ ] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [ ] No [NEEDS CLARIFICATION] markers remain
|
||||
- [ ] Requirements are testable and unambiguous
|
||||
- [ ] Success criteria are measurable
|
||||
- [ ] Success criteria are technology-agnostic (no implementation details)
|
||||
- [ ] All acceptance scenarios are defined
|
||||
- [ ] Edge cases are identified
|
||||
- [ ] Scope is clearly bounded
|
||||
- [ ] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [ ] All functional requirements have clear acceptance criteria
|
||||
- [ ] User scenarios cover primary flows
|
||||
- [ ] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [ ] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- Items marked incomplete require spec updates before `/speckit.clarify` or `/speckit.plan`
|
||||
```
|
||||
|
||||
b. **Run Validation Check**: Review the spec against each checklist item:
|
||||
- For each item, determine if it passes or fails
|
||||
- Document specific issues found (quote relevant spec sections)
|
||||
|
||||
c. **Handle Validation Results**:
|
||||
- **If all items pass**: Mark checklist complete and proceed to step 7
|
||||
|
||||
- **If items fail (excluding [NEEDS CLARIFICATION])**:
|
||||
1. List the failing items and specific issues
|
||||
2. Update the spec to address each issue
|
||||
3. Re-run validation until all items pass (max 3 iterations)
|
||||
4. If still failing after 3 iterations, document remaining issues in checklist notes and warn user
|
||||
|
||||
- **If [NEEDS CLARIFICATION] markers remain**:
|
||||
1. Extract all [NEEDS CLARIFICATION: ...] markers from the spec
|
||||
2. **LIMIT CHECK**: If more than 3 markers exist, keep only the 3 most critical (by scope/security/UX impact) and make informed guesses for the rest
|
||||
3. For each clarification needed (max 3), present options to user in this format:
|
||||
|
||||
```markdown
|
||||
## Question [N]: [Topic]
|
||||
|
||||
**Context**: [Quote relevant spec section]
|
||||
|
||||
**What we need to know**: [Specific question from NEEDS CLARIFICATION marker]
|
||||
|
||||
**Suggested Answers**:
|
||||
|
||||
| Option | Answer | Implications |
|
||||
| ------ | ------------------------- | ------------------------------------- |
|
||||
| A | [First suggested answer] | [What this means for the feature] |
|
||||
| B | [Second suggested answer] | [What this means for the feature] |
|
||||
| C | [Third suggested answer] | [What this means for the feature] |
|
||||
| Custom | Provide your own answer | [Explain how to provide custom input] |
|
||||
|
||||
**Your choice**: _[Wait for user response]_
|
||||
```
|
||||
|
||||
4. **CRITICAL - Table Formatting**: Ensure markdown tables are properly formatted:
|
||||
- Use consistent spacing with pipes aligned
|
||||
- Each cell should have spaces around content: `| Content |` not `|Content|`
|
||||
- Header separator must have at least 3 dashes: `|--------|`
|
||||
- Test that the table renders correctly in markdown preview
|
||||
5. Number questions sequentially (Q1, Q2, Q3 - max 3 total)
|
||||
6. Present all questions together before waiting for responses
|
||||
7. Wait for user to respond with their choices for all questions (e.g., "Q1: A, Q2: Custom - [details], Q3: B")
|
||||
8. Update the spec by replacing each [NEEDS CLARIFICATION] marker with the user's selected or provided answer
|
||||
9. Re-run validation after all clarifications are resolved
|
||||
|
||||
d. **Update Checklist**: After each validation iteration, update the checklist file with current pass/fail status
|
||||
|
||||
7. Report completion with branch name, spec file path, checklist results, and readiness for the next phase (`/speckit.clarify` or `/speckit.plan`).
|
||||
|
||||
8. **Check for extension hooks**: After reporting completion, check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.after_specify` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
```
|
||||
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
**NOTE:** The script creates and checks out the new branch and initializes the spec file before writing.
|
||||
|
||||
## Quick Guidelines
|
||||
|
||||
- Focus on **WHAT** users need and **WHY**.
|
||||
- Avoid HOW to implement (no tech stack, APIs, code structure).
|
||||
- Written for business stakeholders, not developers.
|
||||
- DO NOT create any checklists that are embedded in the spec. That will be a separate command.
|
||||
|
||||
### Section Requirements
|
||||
|
||||
- **Mandatory sections**: Must be completed for every feature
|
||||
- **Optional sections**: Include only when relevant to the feature
|
||||
- When a section doesn't apply, remove it entirely (don't leave as "N/A")
|
||||
|
||||
### For AI Generation
|
||||
|
||||
When creating this spec from a user prompt:
|
||||
|
||||
1. **Make informed guesses**: Use context, industry standards, and common patterns to fill gaps
|
||||
2. **Document assumptions**: Record reasonable defaults in the Assumptions section
|
||||
3. **Limit clarifications**: Maximum 3 [NEEDS CLARIFICATION] markers - use only for critical decisions that:
|
||||
- Significantly impact feature scope or user experience
|
||||
- Have multiple reasonable interpretations with different implications
|
||||
- Lack any reasonable default
|
||||
4. **Prioritize clarifications**: scope > security/privacy > user experience > technical details
|
||||
5. **Think like a tester**: Every vague requirement should fail the "testable and unambiguous" checklist item
|
||||
6. **Common areas needing clarification** (only if no reasonable default exists):
|
||||
- Feature scope and boundaries (include/exclude specific use cases)
|
||||
- User types and permissions (if multiple conflicting interpretations possible)
|
||||
- Security/compliance requirements (when legally/financially significant)
|
||||
|
||||
**Examples of reasonable defaults** (don't ask about these):
|
||||
|
||||
- Data retention: Industry-standard practices for the domain
|
||||
- Performance targets: Standard web/mobile app expectations unless specified
|
||||
- Error handling: User-friendly messages with appropriate fallbacks
|
||||
- Authentication method: Standard session-based or OAuth2 for web apps
|
||||
- Integration patterns: Use project-appropriate patterns (REST/GraphQL for web services, function calls for libraries, CLI args for tools, etc.)
|
||||
|
||||
### Success Criteria Guidelines
|
||||
|
||||
Success criteria must be:
|
||||
|
||||
1. **Measurable**: Include specific metrics (time, percentage, count, rate)
|
||||
2. **Technology-agnostic**: No mention of frameworks, languages, databases, or tools
|
||||
3. **User-focused**: Describe outcomes from user/business perspective, not system internals
|
||||
4. **Verifiable**: Can be tested/validated without knowing implementation details
|
||||
|
||||
**Good examples**:
|
||||
|
||||
- "Users can complete checkout in under 3 minutes"
|
||||
- "System supports 10,000 concurrent users"
|
||||
- "95% of searches return results in under 1 second"
|
||||
- "Task completion rate improves by 40%"
|
||||
|
||||
**Bad examples** (implementation-focused):
|
||||
|
||||
- "API response time is under 200ms" (too technical, use "Users see results instantly")
|
||||
- "Database can handle 1000 TPS" (implementation detail, use user-facing metric)
|
||||
- "React components render efficiently" (framework-specific)
|
||||
- "Redis cache hit rate above 80%" (technology-specific)
|
||||
@@ -1,209 +0,0 @@
|
||||
---
|
||||
description: Generate an actionable, dependency-ordered tasks.md for the feature based on available design artifacts.
|
||||
handoffs:
|
||||
- label: Analyze For Consistency
|
||||
agent: speckit.analyze
|
||||
prompt: Run a project analysis for consistency
|
||||
send: true
|
||||
- label: Implement Project
|
||||
agent: speckit.implement
|
||||
prompt: Start the implementation in phases
|
||||
send: true
|
||||
---
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Pre-Execution Checks
|
||||
|
||||
**Check for extension hooks (before tasks generation)**:
|
||||
|
||||
- Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.before_tasks` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Pre-Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Pre-Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
|
||||
Wait for the result of the hook command before proceeding to the Outline.
|
||||
```
|
||||
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
## Outline
|
||||
|
||||
1. **Setup**: Run `.specify/scripts/bash/check-prerequisites.sh --json` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
|
||||
2. **Load design documents**: Read from FEATURE_DIR:
|
||||
- **Required**: plan.md (tech stack, libraries, structure), spec.md (user stories with priorities)
|
||||
- **Optional**: data-model.md (entities), contracts/ (interface contracts), research.md (decisions), quickstart.md (test scenarios)
|
||||
- Note: Not all projects have all documents. Generate tasks based on what's available.
|
||||
|
||||
3. **Execute task generation workflow**:
|
||||
- Load plan.md and extract tech stack, libraries, project structure
|
||||
- Load spec.md and extract user stories with their priorities (P1, P2, P3, etc.)
|
||||
- If data-model.md exists: Extract entities and map to user stories
|
||||
- If contracts/ exists: Map interface contracts to user stories
|
||||
- If research.md exists: Extract decisions for setup tasks
|
||||
- Generate tasks organized by user story (see Task Generation Rules below)
|
||||
- Generate dependency graph showing user story completion order
|
||||
- Create parallel execution examples per user story
|
||||
- Validate task completeness (each user story has all needed tasks, independently testable)
|
||||
|
||||
4. **Generate tasks.md**: Use `.specify/templates/tasks-template.md` as structure, fill with:
|
||||
- Correct feature name from plan.md
|
||||
- Phase 1: Setup tasks (project initialization)
|
||||
- Phase 2: Foundational tasks (blocking prerequisites for all user stories)
|
||||
- Phase 3+: One phase per user story (in priority order from spec.md)
|
||||
- Each phase includes: story goal, independent test criteria, tests (if requested), implementation tasks
|
||||
- Final Phase: Polish & cross-cutting concerns
|
||||
- All tasks must follow the strict checklist format (see Task Generation Rules below)
|
||||
- Clear file paths for each task
|
||||
- Dependencies section showing story completion order
|
||||
- Parallel execution examples per story
|
||||
- Implementation strategy section (MVP first, incremental delivery)
|
||||
|
||||
5. **Report**: Output path to generated tasks.md and summary:
|
||||
- Total task count
|
||||
- Task count per user story
|
||||
- Parallel opportunities identified
|
||||
- Independent test criteria for each story
|
||||
- Suggested MVP scope (typically just User Story 1)
|
||||
- Format validation: Confirm ALL tasks follow the checklist format (checkbox, ID, labels, file paths)
|
||||
|
||||
6. **Check for extension hooks**: After tasks.md is generated, check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.after_tasks` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
```
|
||||
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
Context for task generation: $ARGUMENTS
|
||||
|
||||
The tasks.md should be immediately executable - each task must be specific enough that an LLM can complete it without additional context.
|
||||
|
||||
## Task Generation Rules
|
||||
|
||||
**CRITICAL**: Tasks MUST be organized by user story to enable independent implementation and testing.
|
||||
|
||||
**Tests are OPTIONAL**: Only generate test tasks if explicitly requested in the feature specification or if user requests TDD approach.
|
||||
|
||||
### Checklist Format (REQUIRED)
|
||||
|
||||
Every task MUST strictly follow this format:
|
||||
|
||||
```text
|
||||
- [ ] [TaskID] [P?] [Story?] Description with file path
|
||||
```
|
||||
|
||||
**Format Components**:
|
||||
|
||||
1. **Checkbox**: ALWAYS start with `- [ ]` (markdown checkbox)
|
||||
2. **Task ID**: Sequential number (T001, T002, T003...) in execution order
|
||||
3. **[P] marker**: Include ONLY if task is parallelizable (different files, no dependencies on incomplete tasks)
|
||||
4. **[Story] label**: REQUIRED for user story phase tasks only
|
||||
- Format: [US1], [US2], [US3], etc. (maps to user stories from spec.md)
|
||||
- Setup phase: NO story label
|
||||
- Foundational phase: NO story label
|
||||
- User Story phases: MUST have story label
|
||||
- Polish phase: NO story label
|
||||
5. **Description**: Clear action with exact file path
|
||||
|
||||
**Examples**:
|
||||
|
||||
- ✅ CORRECT: `- [ ] T001 Create project structure per implementation plan`
|
||||
- ✅ CORRECT: `- [ ] T005 [P] Implement authentication middleware in src/middleware/auth.py`
|
||||
- ✅ CORRECT: `- [ ] T012 [P] [US1] Create User model in src/models/user.py`
|
||||
- ✅ CORRECT: `- [ ] T014 [US1] Implement UserService in src/services/user_service.py`
|
||||
- ❌ WRONG: `- [ ] Create User model` (missing ID and Story label)
|
||||
- ❌ WRONG: `T001 [US1] Create model` (missing checkbox)
|
||||
- ❌ WRONG: `- [ ] [US1] Create User model` (missing Task ID)
|
||||
- ❌ WRONG: `- [ ] T001 [US1] Create model` (missing file path)
|
||||
|
||||
### Task Organization
|
||||
|
||||
1. **From User Stories (spec.md)** - PRIMARY ORGANIZATION:
|
||||
- Each user story (P1, P2, P3...) gets its own phase
|
||||
- Map all related components to their story:
|
||||
- Models needed for that story
|
||||
- Services needed for that story
|
||||
- Interfaces/UI needed for that story
|
||||
- If tests requested: Tests specific to that story
|
||||
- Mark story dependencies (most stories should be independent)
|
||||
|
||||
2. **From Contracts**:
|
||||
- Map each interface contract → to the user story it serves
|
||||
- If tests requested: Each interface contract → contract test task [P] before implementation in that story's phase
|
||||
|
||||
3. **From Data Model**:
|
||||
- Map each entity to the user story(ies) that need it
|
||||
- If entity serves multiple stories: Put in earliest story or Setup phase
|
||||
- Relationships → service layer tasks in appropriate story phase
|
||||
|
||||
4. **From Setup/Infrastructure**:
|
||||
- Shared infrastructure → Setup phase (Phase 1)
|
||||
- Foundational/blocking tasks → Foundational phase (Phase 2)
|
||||
- Story-specific setup → within that story's phase
|
||||
|
||||
### Phase Structure
|
||||
|
||||
- **Phase 1**: Setup (project initialization)
|
||||
- **Phase 2**: Foundational (blocking prerequisites - MUST complete before user stories)
|
||||
- **Phase 3+**: User Stories in priority order (P1, P2, P3...)
|
||||
- Within each story: Tests (if requested) → Models → Services → Endpoints → Integration
|
||||
- Each phase should be a complete, independently testable increment
|
||||
- **Final Phase**: Polish & Cross-Cutting Concerns
|
||||
@@ -1,30 +0,0 @@
|
||||
---
|
||||
description: Convert existing tasks into actionable, dependency-ordered GitHub issues for the feature based on available design artifacts.
|
||||
tools: ["github/github-mcp-server/issue_write"]
|
||||
---
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Outline
|
||||
|
||||
1. Run `.specify/scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
1. From the executed script, extract the path to **tasks**.
|
||||
1. Get the Git remote by running:
|
||||
|
||||
```bash
|
||||
git config --get remote.origin.url
|
||||
```
|
||||
|
||||
> [!CAUTION]
|
||||
> ONLY PROCEED TO NEXT STEPS IF THE REMOTE IS A GITHUB URL
|
||||
|
||||
1. For each task in the list, use the GitHub MCP server to create a new issue in the repository that is representative of the Git remote.
|
||||
|
||||
> [!CAUTION]
|
||||
> UNDER NO CIRCUMSTANCES EVER CREATE ISSUES IN REPOSITORIES THAT DO NOT MATCH THE REMOTE URL
|
||||
@@ -196,6 +196,8 @@ firmware/
|
||||
- Prefer `LOG_DEBUG`, `LOG_INFO`, `LOG_WARN`, `LOG_ERROR` for logging
|
||||
- Use `assert()` for invariants that should never fail
|
||||
- C++17 features are available (`std::optional`, structured bindings, `if constexpr`, etc.)
|
||||
- **Keep code comments minimal — one or two lines, max.** Comment only when the _why_ isn't obvious from the code; never restate what the next line does. No multi-paragraph block comments explaining straightforward changes. The diff and commit message carry the rationale; the code carries the behavior.
|
||||
- **Use `Throttle` for time-based rate limiting, not raw `millis()` math.** `src/mesh/Throttle.h` provides `Throttle::isWithinTimespanMs(lastMs, intervalMs)` (returns true while inside the cooldown) and `Throttle::execute(&lastMs, intervalMs, func)` (function-pointer form that updates the timestamp on fire). Use these for any "did N ms pass since X" check — raw `millis() > lastMs + N` is rollover-unsafe (breaks after ~49.7 days) and inconsistent with the rest of the codebase. The helpers compute `now - lastMs` with unsigned subtraction, which wraps correctly.
|
||||
|
||||
### Naming Conventions
|
||||
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
---
|
||||
agent: hardware-support
|
||||
---
|
||||
@@ -1,3 +0,0 @@
|
||||
---
|
||||
agent: speckit.analyze
|
||||
---
|
||||
@@ -1,3 +0,0 @@
|
||||
---
|
||||
agent: speckit.checklist
|
||||
---
|
||||
@@ -1,3 +0,0 @@
|
||||
---
|
||||
agent: speckit.clarify
|
||||
---
|
||||
@@ -1,3 +0,0 @@
|
||||
---
|
||||
agent: speckit.constitution
|
||||
---
|
||||
@@ -1,3 +0,0 @@
|
||||
---
|
||||
agent: speckit.implement
|
||||
---
|
||||
@@ -1,3 +0,0 @@
|
||||
---
|
||||
agent: speckit.plan
|
||||
---
|
||||
@@ -1,3 +0,0 @@
|
||||
---
|
||||
agent: speckit.specify
|
||||
---
|
||||
@@ -1,3 +0,0 @@
|
||||
---
|
||||
agent: speckit.tasks
|
||||
---
|
||||
@@ -1,3 +0,0 @@
|
||||
---
|
||||
agent: speckit.taskstoissues
|
||||
---
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"ai": "copilot",
|
||||
"ai_commands_dir": null,
|
||||
"ai_skills": false,
|
||||
"branch_numbering": "sequential",
|
||||
"here": true,
|
||||
"offline": false,
|
||||
"preset": null,
|
||||
"script": "sh",
|
||||
"speckit_version": "0.4.2"
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
<!--
|
||||
Sync Impact Report
|
||||
Version change: template -> 1.0.0
|
||||
Modified principles:
|
||||
- [PRINCIPLE_1_NAME] -> I. Safety-Critical Mesh Behavior
|
||||
- [PRINCIPLE_2_NAME] -> II. Variant-Scoped Hardware Truth
|
||||
- [PRINCIPLE_3_NAME] -> III. Verification by Targeted Evidence
|
||||
- [PRINCIPLE_4_NAME] -> IV. Resource and Power Discipline
|
||||
- [PRINCIPLE_5_NAME] -> V. Minimal, Reviewable Change Sets
|
||||
Added sections:
|
||||
- Engineering Constraints
|
||||
- Delivery Workflow
|
||||
Removed sections:
|
||||
- None
|
||||
Templates requiring updates:
|
||||
- ✅ .specify/templates/plan-template.md
|
||||
- ✅ .specify/templates/spec-template.md
|
||||
- ✅ .specify/templates/tasks-template.md
|
||||
- ⚠ pending .specify/templates/checklist-template.md (not required for current constitution alignment)
|
||||
- ⚠ pending .specify/templates/agent-file-template.md (generic scaffold, no constitution-specific drift found)
|
||||
Follow-up TODOs:
|
||||
- None
|
||||
-->
|
||||
|
||||
# Meshtastic Firmware Constitution
|
||||
|
||||
## Core Principles
|
||||
|
||||
### I. Safety-Critical Mesh Behavior
|
||||
|
||||
All feature and bug-fix work MUST preserve safe, predictable behavior on live mesh networks.
|
||||
Changes that affect routing, airtime, broadcast intervals, MQTT bridging, channel handling,
|
||||
or packet processing MUST document the operational impact on shared bandwidth, public-channel
|
||||
abuse protections, and interoperability. Any relaxation of rate limits, encryption behavior,
|
||||
or default-channel safeguards MUST be treated as a breaking governance change unless explicitly
|
||||
approved and justified.
|
||||
|
||||
Rationale: Meshtastic devices operate in constrained radio environments where seemingly small
|
||||
behavior changes can degrade network reliability, privacy, and fairness for other nodes.
|
||||
|
||||
### II. Variant-Scoped Hardware Truth
|
||||
|
||||
Board definitions, platform conditionals, and peripheral flags MUST reflect verified hardware
|
||||
truth and MUST remain scoped to the exact supported target. New capabilities in `variant.h`,
|
||||
`platformio.ini`, `pins_arduino.h`, or related board files MUST be backed by pin mappings,
|
||||
chip selection, and power assumptions that are consistent with the board design. Cross-board
|
||||
copying is prohibited unless every reused define is revalidated for the destination variant.
|
||||
|
||||
Rationale: This firmware spans many architectures and board revisions; incorrect hardware
|
||||
declarations create silent regressions that are hard to detect until devices are flashed.
|
||||
|
||||
### III. Verification by Targeted Evidence
|
||||
|
||||
Every change MUST be validated by the smallest credible evidence that matches its risk.
|
||||
At minimum, contributors MUST run formatting or static validation for touched files and MUST
|
||||
run a targeted build, test, or simulation path for the affected platform when feasible.
|
||||
Changes to shared core logic, protobufs, routing, or configuration defaults SHOULD include a
|
||||
native test, simulator run, or equivalent cross-target evidence. If validation cannot be run,
|
||||
the gap MUST be stated explicitly in the plan, tasks, and final review.
|
||||
|
||||
Rationale: The repository supports many targets, so quality depends on explicit validation
|
||||
rather than assumptions that one successful build implies system-wide safety.
|
||||
|
||||
### IV. Resource and Power Discipline
|
||||
|
||||
Implementations MUST respect embedded constraints for memory, flash, CPU, battery, and radio
|
||||
duty cycle. New dependencies, background tasks, logging, polling, display work, and peripheral
|
||||
power use MUST be justified against the target hardware footprint. Defaults MUST prefer safe
|
||||
operation on constrained devices, and network-facing behavior MUST account for scaling with
|
||||
node count where existing project patterns provide that mechanism.
|
||||
|
||||
Rationale: Meshtastic firmware runs on low-power devices where unnecessary work directly harms
|
||||
battery life, responsiveness, thermal behavior, and mesh capacity.
|
||||
|
||||
### V. Minimal, Reviewable Change Sets
|
||||
|
||||
Changes MUST solve the root problem with the smallest coherent diff that fits the existing
|
||||
architecture and coding patterns. Unrelated refactors, opportunistic renames, and speculative
|
||||
abstractions are prohibited in the same change unless they are required to make the fix safe.
|
||||
Public behavior, configuration semantics, and generated artifacts MUST remain stable unless the
|
||||
specification and plan explicitly call out the intended change.
|
||||
|
||||
Rationale: Small, scoped changes are easier to review across board variants and reduce the
|
||||
risk of hidden regressions in a large multi-platform firmware repository.
|
||||
|
||||
## Engineering Constraints
|
||||
|
||||
The authoritative implementation context for this repository is `.github/copilot-instructions.md`.
|
||||
Plans and tasks MUST align with the existing PlatformIO-based build system, generated protobuf
|
||||
workflow, architecture-specific source layout, and hardware-variant structure already used in
|
||||
the repository.
|
||||
|
||||
Feature work MUST honor these constraints:
|
||||
|
||||
- Code MUST follow existing logging, naming, threading, and configuration patterns.
|
||||
- Default values and user-facing configuration changes MUST use existing `Default` helpers and
|
||||
public-channel safeguards where applicable.
|
||||
- Protobuf changes MUST include regeneration steps and identify downstream effects in generated
|
||||
code and dependent modules.
|
||||
- Build and validation steps MUST prefer repository-standard commands such as targeted `pio run`,
|
||||
`pio test -e native`, simulator tooling, and `trunk fmt` where applicable.
|
||||
- Platform-specific logic MUST be isolated to the narrowest valid architecture or variant scope.
|
||||
|
||||
## Delivery Workflow
|
||||
|
||||
Spec-driven work in this repository MUST produce artifacts that make operational risk visible
|
||||
before code is written.
|
||||
|
||||
- Specifications MUST describe affected user or device behavior, impacted platforms, and edge
|
||||
cases for unavailable peripherals, misconfigured variants, and constrained-network scenarios.
|
||||
- Plans MUST include a Constitution Check that names the exact validation evidence, target
|
||||
environments, and any justified deviations from the constitution.
|
||||
- Tasks MUST be organized so that foundational hardware, protocol, or configuration work is
|
||||
completed before feature-specific behavior that depends on it.
|
||||
- Review and implementation notes MUST call out any skipped validation, generated-file updates,
|
||||
migration considerations, or behavior changes requiring maintainer scrutiny.
|
||||
|
||||
## Governance
|
||||
|
||||
This constitution supersedes ad hoc workflow preferences for spec-driven work in this repository.
|
||||
All plans, tasks, reviews, and implementation summaries MUST verify compliance with these
|
||||
principles.
|
||||
|
||||
- Amendments MUST be documented in this file and reflected in dependent templates before new
|
||||
work proceeds under the changed rule set.
|
||||
- Versioning policy for this constitution follows semantic versioning.
|
||||
MAJOR versions indicate removed or materially redefined principles.
|
||||
MINOR versions indicate new principles, sections, or materially expanded guidance.
|
||||
PATCH versions indicate clarifications, wording improvements, or non-semantic refinements.
|
||||
- Compliance review is mandatory for every feature plan and code review. Any constitutional
|
||||
violation MUST be listed in the plan's Complexity Tracking section or equivalent justification.
|
||||
- Repository guidance files remain authoritative for implementation detail. Where conflict is
|
||||
discovered, this constitution governs process and quality gates, while `.github/copilot-instructions.md`
|
||||
governs repository-specific coding practice until the conflict is resolved by amendment.
|
||||
|
||||
**Version**: 1.0.0 | **Ratified**: 2026-03-25 | **Last Amended**: 2026-03-25
|
||||
@@ -1,193 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Consolidated prerequisite checking script
|
||||
#
|
||||
# This script provides unified prerequisite checking for Spec-Driven Development workflow.
|
||||
# It replaces the functionality previously spread across multiple scripts.
|
||||
#
|
||||
# Usage: ./check-prerequisites.sh [OPTIONS]
|
||||
#
|
||||
# OPTIONS:
|
||||
# --json Output in JSON format
|
||||
# --require-tasks Require tasks.md to exist (for implementation phase)
|
||||
# --include-tasks Include tasks.md in AVAILABLE_DOCS list
|
||||
# --paths-only Only output path variables (no validation)
|
||||
# --help, -h Show help message
|
||||
#
|
||||
# OUTPUTS:
|
||||
# JSON mode: {"FEATURE_DIR":"...", "AVAILABLE_DOCS":["..."]}
|
||||
# Text mode: FEATURE_DIR:... \n AVAILABLE_DOCS: \n ✓/✗ file.md
|
||||
# Paths only: REPO_ROOT: ... \n BRANCH: ... \n FEATURE_DIR: ... etc.
|
||||
|
||||
set -e
|
||||
|
||||
# Parse command line arguments
|
||||
JSON_MODE=false
|
||||
REQUIRE_TASKS=false
|
||||
INCLUDE_TASKS=false
|
||||
PATHS_ONLY=false
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--json)
|
||||
JSON_MODE=true
|
||||
;;
|
||||
--require-tasks)
|
||||
REQUIRE_TASKS=true
|
||||
;;
|
||||
--include-tasks)
|
||||
INCLUDE_TASKS=true
|
||||
;;
|
||||
--paths-only)
|
||||
PATHS_ONLY=true
|
||||
;;
|
||||
--help | -h)
|
||||
cat <<'EOF'
|
||||
Usage: check-prerequisites.sh [OPTIONS]
|
||||
|
||||
Consolidated prerequisite checking for Spec-Driven Development workflow.
|
||||
|
||||
OPTIONS:
|
||||
--json Output in JSON format
|
||||
--require-tasks Require tasks.md to exist (for implementation phase)
|
||||
--include-tasks Include tasks.md in AVAILABLE_DOCS list
|
||||
--paths-only Only output path variables (no prerequisite validation)
|
||||
--help, -h Show this help message
|
||||
|
||||
EXAMPLES:
|
||||
# Check task prerequisites (plan.md required)
|
||||
./check-prerequisites.sh --json
|
||||
|
||||
# Check implementation prerequisites (plan.md + tasks.md required)
|
||||
./check-prerequisites.sh --json --require-tasks --include-tasks
|
||||
|
||||
# Get feature paths only (no validation)
|
||||
./check-prerequisites.sh --paths-only
|
||||
|
||||
EOF
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "ERROR: Unknown option '$arg'. Use --help for usage information." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Source common functions
|
||||
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/common.sh"
|
||||
|
||||
# Get feature paths and validate branch
|
||||
_paths_output=$(get_feature_paths) || {
|
||||
echo "ERROR: Failed to resolve feature paths" >&2
|
||||
exit 1
|
||||
}
|
||||
eval "$_paths_output"
|
||||
unset _paths_output
|
||||
check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1
|
||||
|
||||
# If paths-only mode, output paths and exit (support JSON + paths-only combined)
|
||||
if $PATHS_ONLY; then
|
||||
if $JSON_MODE; then
|
||||
# Minimal JSON paths payload (no validation performed)
|
||||
if has_jq; then
|
||||
jq -cn \
|
||||
--arg repo_root "$REPO_ROOT" \
|
||||
--arg branch "$CURRENT_BRANCH" \
|
||||
--arg feature_dir "$FEATURE_DIR" \
|
||||
--arg feature_spec "$FEATURE_SPEC" \
|
||||
--arg impl_plan "$IMPL_PLAN" \
|
||||
--arg tasks "$TASKS" \
|
||||
'{REPO_ROOT:$repo_root,BRANCH:$branch,FEATURE_DIR:$feature_dir,FEATURE_SPEC:$feature_spec,IMPL_PLAN:$impl_plan,TASKS:$tasks}'
|
||||
else
|
||||
printf '{"REPO_ROOT":"%s","BRANCH":"%s","FEATURE_DIR":"%s","FEATURE_SPEC":"%s","IMPL_PLAN":"%s","TASKS":"%s"}\n' \
|
||||
"$(json_escape "$REPO_ROOT")" "$(json_escape "$CURRENT_BRANCH")" "$(json_escape "$FEATURE_DIR")" "$(json_escape "$FEATURE_SPEC")" "$(json_escape "$IMPL_PLAN")" "$(json_escape "$TASKS")"
|
||||
fi
|
||||
else
|
||||
echo "REPO_ROOT: $REPO_ROOT"
|
||||
echo "BRANCH: $CURRENT_BRANCH"
|
||||
echo "FEATURE_DIR: $FEATURE_DIR"
|
||||
echo "FEATURE_SPEC: $FEATURE_SPEC"
|
||||
echo "IMPL_PLAN: $IMPL_PLAN"
|
||||
echo "TASKS: $TASKS"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Validate required directories and files
|
||||
if [[ ! -d $FEATURE_DIR ]]; then
|
||||
echo "ERROR: Feature directory not found: $FEATURE_DIR" >&2
|
||||
echo "Run /speckit.specify first to create the feature structure." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f $IMPL_PLAN ]]; then
|
||||
echo "ERROR: plan.md not found in $FEATURE_DIR" >&2
|
||||
echo "Run /speckit.plan first to create the implementation plan." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check for tasks.md if required
|
||||
if $REQUIRE_TASKS && [[ ! -f $TASKS ]]; then
|
||||
echo "ERROR: tasks.md not found in $FEATURE_DIR" >&2
|
||||
echo "Run /speckit.tasks first to create the task list." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Build list of available documents
|
||||
docs=()
|
||||
|
||||
# Always check these optional docs
|
||||
[[ -f $RESEARCH ]] && docs+=("research.md")
|
||||
[[ -f $DATA_MODEL ]] && docs+=("data-model.md")
|
||||
|
||||
# Check contracts directory (only if it exists and has files)
|
||||
if [[ -d $CONTRACTS_DIR ]] && [[ -n "$(ls -A "$CONTRACTS_DIR" 2>/dev/null)" ]]; then
|
||||
docs+=("contracts/")
|
||||
fi
|
||||
|
||||
[[ -f $QUICKSTART ]] && docs+=("quickstart.md")
|
||||
|
||||
# Include tasks.md if requested and it exists
|
||||
if $INCLUDE_TASKS && [[ -f $TASKS ]]; then
|
||||
docs+=("tasks.md")
|
||||
fi
|
||||
|
||||
# Output results
|
||||
if $JSON_MODE; then
|
||||
# Build JSON array of documents
|
||||
if has_jq; then
|
||||
if [[ ${#docs[@]} -eq 0 ]]; then
|
||||
json_docs="[]"
|
||||
else
|
||||
json_docs=$(printf '%s\n' "${docs[@]}" | jq -R . | jq -s .)
|
||||
fi
|
||||
jq -cn \
|
||||
--arg feature_dir "$FEATURE_DIR" \
|
||||
--argjson docs "$json_docs" \
|
||||
'{FEATURE_DIR:$feature_dir,AVAILABLE_DOCS:$docs}'
|
||||
else
|
||||
if [[ ${#docs[@]} -eq 0 ]]; then
|
||||
json_docs="[]"
|
||||
else
|
||||
json_docs=$(for d in "${docs[@]}"; do printf '"%s",' "$(json_escape "$d")"; done)
|
||||
json_docs="[${json_docs%,}]"
|
||||
fi
|
||||
printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s}\n' "$(json_escape "$FEATURE_DIR")" "$json_docs"
|
||||
fi
|
||||
else
|
||||
# Text output
|
||||
echo "FEATURE_DIR:$FEATURE_DIR"
|
||||
echo "AVAILABLE_DOCS:"
|
||||
|
||||
# Show status of each potential document
|
||||
check_file "$RESEARCH" "research.md"
|
||||
check_file "$DATA_MODEL" "data-model.md"
|
||||
check_dir "$CONTRACTS_DIR" "contracts/"
|
||||
check_file "$QUICKSTART" "quickstart.md"
|
||||
|
||||
if $INCLUDE_TASKS; then
|
||||
check_file "$TASKS" "tasks.md"
|
||||
fi
|
||||
fi
|
||||
@@ -1,329 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Common functions and variables for all scripts
|
||||
|
||||
# Find repository root by searching upward for .specify directory
|
||||
# This is the primary marker for spec-kit projects
|
||||
find_specify_root() {
|
||||
local dir="${1:-$(pwd)}"
|
||||
# Normalize to absolute path to prevent infinite loop with relative paths
|
||||
# Use -- to handle paths starting with - (e.g., -P, -L)
|
||||
dir="$(cd -- "$dir" 2>/dev/null && pwd)" || return 1
|
||||
local prev_dir=""
|
||||
while true; do
|
||||
if [ -d "$dir/.specify" ]; then
|
||||
echo "$dir"
|
||||
return 0
|
||||
fi
|
||||
# Stop if we've reached filesystem root or dirname stops changing
|
||||
if [ "$dir" = "/" ] || [ "$dir" = "$prev_dir" ]; then
|
||||
break
|
||||
fi
|
||||
prev_dir="$dir"
|
||||
dir="$(dirname "$dir")"
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Get repository root, prioritizing .specify directory over git
|
||||
# This prevents using a parent git repo when spec-kit is initialized in a subdirectory
|
||||
get_repo_root() {
|
||||
# First, look for .specify directory (spec-kit's own marker)
|
||||
local specify_root
|
||||
if specify_root=$(find_specify_root); then
|
||||
echo "$specify_root"
|
||||
return
|
||||
fi
|
||||
|
||||
# Fallback to git if no .specify found
|
||||
if git rev-parse --show-toplevel >/dev/null 2>&1; then
|
||||
git rev-parse --show-toplevel
|
||||
return
|
||||
fi
|
||||
|
||||
# Final fallback to script location for non-git repos
|
||||
local script_dir="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
(cd "$script_dir/../../.." && pwd)
|
||||
}
|
||||
|
||||
# Get current branch, with fallback for non-git repositories
|
||||
get_current_branch() {
|
||||
# First check if SPECIFY_FEATURE environment variable is set
|
||||
if [[ -n ${SPECIFY_FEATURE-} ]]; then
|
||||
echo "$SPECIFY_FEATURE"
|
||||
return
|
||||
fi
|
||||
|
||||
# Then check git if available at the spec-kit root (not parent)
|
||||
local repo_root=$(get_repo_root)
|
||||
if has_git; then
|
||||
git -C "$repo_root" rev-parse --abbrev-ref HEAD
|
||||
return
|
||||
fi
|
||||
|
||||
# For non-git repos, try to find the latest feature directory
|
||||
local specs_dir="$repo_root/specs"
|
||||
|
||||
if [[ -d $specs_dir ]]; then
|
||||
local latest_feature=""
|
||||
local highest=0
|
||||
local latest_timestamp=""
|
||||
|
||||
for dir in "$specs_dir"/*; do
|
||||
if [[ -d $dir ]]; then
|
||||
local dirname=$(basename "$dir")
|
||||
if [[ $dirname =~ ^([0-9]{8}-[0-9]{6})- ]]; then
|
||||
# Timestamp-based branch: compare lexicographically
|
||||
local ts="${BASH_REMATCH[1]}"
|
||||
if [[ $ts > $latest_timestamp ]]; then
|
||||
latest_timestamp="$ts"
|
||||
latest_feature=$dirname
|
||||
fi
|
||||
elif [[ $dirname =~ ^([0-9]{3})- ]]; then
|
||||
local number=${BASH_REMATCH[1]}
|
||||
number=$((10#$number))
|
||||
if [[ $number -gt $highest ]]; then
|
||||
highest=$number
|
||||
# Only update if no timestamp branch found yet
|
||||
if [[ -z $latest_timestamp ]]; then
|
||||
latest_feature=$dirname
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ -n $latest_feature ]]; then
|
||||
echo "$latest_feature"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "main" # Final fallback
|
||||
}
|
||||
|
||||
# Check if we have git available at the spec-kit root level
|
||||
# Returns true only if git is installed and the repo root is inside a git work tree
|
||||
# Handles both regular repos (.git directory) and worktrees/submodules (.git file)
|
||||
has_git() {
|
||||
# First check if git command is available (before calling get_repo_root which may use git)
|
||||
command -v git >/dev/null 2>&1 || return 1
|
||||
local repo_root=$(get_repo_root)
|
||||
# Check if .git exists (directory or file for worktrees/submodules)
|
||||
[ -e "$repo_root/.git" ] || return 1
|
||||
# Verify it's actually a valid git work tree
|
||||
git -C "$repo_root" rev-parse --is-inside-work-tree >/dev/null 2>&1
|
||||
}
|
||||
|
||||
check_feature_branch() {
|
||||
local branch="$1"
|
||||
local has_git_repo="$2"
|
||||
|
||||
# For non-git repos, we can't enforce branch naming but still provide output
|
||||
if [[ $has_git_repo != "true" ]]; then
|
||||
echo "[specify] Warning: Git repository not detected; skipped branch validation" >&2
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ ! $branch =~ ^[0-9]{3}- ]] && [[ ! $branch =~ ^[0-9]{8}-[0-9]{6}- ]]; then
|
||||
echo "ERROR: Not on a feature branch. Current branch: $branch" >&2
|
||||
echo "Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
get_feature_dir() { echo "$1/specs/$2"; }
|
||||
|
||||
# Find feature directory by numeric prefix instead of exact branch match
|
||||
# This allows multiple branches to work on the same spec (e.g., 004-fix-bug, 004-add-feature)
|
||||
find_feature_dir_by_prefix() {
|
||||
local repo_root="$1"
|
||||
local branch_name="$2"
|
||||
local specs_dir="$repo_root/specs"
|
||||
|
||||
# Extract prefix from branch (e.g., "004" from "004-whatever" or "20260319-143022" from timestamp branches)
|
||||
local prefix=""
|
||||
if [[ $branch_name =~ ^([0-9]{8}-[0-9]{6})- ]]; then
|
||||
prefix="${BASH_REMATCH[1]}"
|
||||
elif [[ $branch_name =~ ^([0-9]{3})- ]]; then
|
||||
prefix="${BASH_REMATCH[1]}"
|
||||
else
|
||||
# If branch doesn't have a recognized prefix, fall back to exact match
|
||||
echo "$specs_dir/$branch_name"
|
||||
return
|
||||
fi
|
||||
|
||||
# Search for directories in specs/ that start with this prefix
|
||||
local matches=()
|
||||
if [[ -d $specs_dir ]]; then
|
||||
for dir in "$specs_dir"/"$prefix"-*; do
|
||||
if [[ -d $dir ]]; then
|
||||
matches+=("$(basename "$dir")")
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# Handle results
|
||||
if [[ ${#matches[@]} -eq 0 ]]; then
|
||||
# No match found - return the branch name path (will fail later with clear error)
|
||||
echo "$specs_dir/$branch_name"
|
||||
elif [[ ${#matches[@]} -eq 1 ]]; then
|
||||
# Exactly one match - perfect!
|
||||
echo "$specs_dir/${matches[0]}"
|
||||
else
|
||||
# Multiple matches - this shouldn't happen with proper naming convention
|
||||
echo "ERROR: Multiple spec directories found with prefix '$prefix': ${matches[*]}" >&2
|
||||
echo "Please ensure only one spec directory exists per prefix." >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
get_feature_paths() {
|
||||
local repo_root=$(get_repo_root)
|
||||
local current_branch=$(get_current_branch)
|
||||
local has_git_repo="false"
|
||||
|
||||
if has_git; then
|
||||
has_git_repo="true"
|
||||
fi
|
||||
|
||||
# Use prefix-based lookup to support multiple branches per spec
|
||||
local feature_dir
|
||||
if ! feature_dir=$(find_feature_dir_by_prefix "$repo_root" "$current_branch"); then
|
||||
echo "ERROR: Failed to resolve feature directory" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Use printf '%q' to safely quote values, preventing shell injection
|
||||
# via crafted branch names or paths containing special characters
|
||||
printf 'REPO_ROOT=%q\n' "$repo_root"
|
||||
printf 'CURRENT_BRANCH=%q\n' "$current_branch"
|
||||
printf 'HAS_GIT=%q\n' "$has_git_repo"
|
||||
printf 'FEATURE_DIR=%q\n' "$feature_dir"
|
||||
printf 'FEATURE_SPEC=%q\n' "$feature_dir/spec.md"
|
||||
printf 'IMPL_PLAN=%q\n' "$feature_dir/plan.md"
|
||||
printf 'TASKS=%q\n' "$feature_dir/tasks.md"
|
||||
printf 'RESEARCH=%q\n' "$feature_dir/research.md"
|
||||
printf 'DATA_MODEL=%q\n' "$feature_dir/data-model.md"
|
||||
printf 'QUICKSTART=%q\n' "$feature_dir/quickstart.md"
|
||||
printf 'CONTRACTS_DIR=%q\n' "$feature_dir/contracts"
|
||||
}
|
||||
|
||||
# Check if jq is available for safe JSON construction
|
||||
has_jq() {
|
||||
command -v jq >/dev/null 2>&1
|
||||
}
|
||||
|
||||
# Escape a string for safe embedding in a JSON value (fallback when jq is unavailable).
|
||||
# Handles backslash, double-quote, and JSON-required control character escapes (RFC 8259).
|
||||
json_escape() {
|
||||
local s="$1"
|
||||
s="${s//\\/\\\\}"
|
||||
s="${s//\"/\\\"}"
|
||||
s="${s//$'\n'/\\n}"
|
||||
s="${s//$'\t'/\\t}"
|
||||
s="${s//$'\r'/\\r}"
|
||||
s="${s//$'\b'/\\b}"
|
||||
s="${s//$'\f'/\\f}"
|
||||
# Escape any remaining U+0001-U+001F control characters as \uXXXX.
|
||||
# (U+0000/NUL cannot appear in bash strings and is excluded.)
|
||||
# LC_ALL=C ensures ${#s} counts bytes and ${s:$i:1} yields single bytes,
|
||||
# so multi-byte UTF-8 sequences (first byte >= 0xC0) pass through intact.
|
||||
local LC_ALL=C
|
||||
local i char code
|
||||
for ((i = 0; i < ${#s}; i++)); do
|
||||
char="${s:i:1}"
|
||||
printf -v code '%d' "'$char" 2>/dev/null || code=256
|
||||
if ((code >= 1 && code <= 31)); then
|
||||
printf '\\u%04x' "$code"
|
||||
else
|
||||
printf '%s' "$char"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
check_file() { [[ -f $1 ]] && echo " ✓ $2" || echo " ✗ $2"; }
|
||||
check_dir() { [[ -d $1 && -n $(ls -A "$1" 2>/dev/null) ]] && echo " ✓ $2" || echo " ✗ $2"; }
|
||||
|
||||
# Resolve a template name to a file path using the priority stack:
|
||||
# 1. .specify/templates/overrides/
|
||||
# 2. .specify/presets/<preset-id>/templates/ (sorted by priority from .registry)
|
||||
# 3. .specify/extensions/<ext-id>/templates/
|
||||
# 4. .specify/templates/ (core)
|
||||
resolve_template() {
|
||||
local template_name="$1"
|
||||
local repo_root="$2"
|
||||
local base="$repo_root/.specify/templates"
|
||||
|
||||
# Priority 1: Project overrides
|
||||
local override="$base/overrides/${template_name}.md"
|
||||
[ -f "$override" ] && echo "$override" && return 0
|
||||
|
||||
# Priority 2: Installed presets (sorted by priority from .registry)
|
||||
local presets_dir="$repo_root/.specify/presets"
|
||||
if [ -d "$presets_dir" ]; then
|
||||
local registry_file="$presets_dir/.registry"
|
||||
if [ -f "$registry_file" ] && command -v python3 >/dev/null 2>&1; then
|
||||
# Read preset IDs sorted by priority (lower number = higher precedence).
|
||||
# The python3 call is wrapped in an if-condition so that set -e does not
|
||||
# abort the function when python3 exits non-zero (e.g. invalid JSON).
|
||||
local sorted_presets=""
|
||||
if sorted_presets=$(SPECKIT_REGISTRY="$registry_file" python3 -c "
|
||||
import json, sys, os
|
||||
try:
|
||||
with open(os.environ['SPECKIT_REGISTRY']) as f:
|
||||
data = json.load(f)
|
||||
presets = data.get('presets', {})
|
||||
for pid, meta in sorted(presets.items(), key=lambda x: x[1].get('priority', 10)):
|
||||
print(pid)
|
||||
except Exception:
|
||||
sys.exit(1)
|
||||
" 2>/dev/null); then
|
||||
if [ -n "$sorted_presets" ]; then
|
||||
# python3 succeeded and returned preset IDs — search in priority order
|
||||
while IFS= read -r preset_id; do
|
||||
local candidate="$presets_dir/$preset_id/templates/${template_name}.md"
|
||||
[ -f "$candidate" ] && echo "$candidate" && return 0
|
||||
done <<<"$sorted_presets"
|
||||
fi
|
||||
# python3 succeeded but registry has no presets — nothing to search
|
||||
else
|
||||
# python3 failed (missing, or registry parse error) — fall back to unordered directory scan
|
||||
for preset in "$presets_dir"/*/; do
|
||||
[ -d "$preset" ] || continue
|
||||
local candidate="$preset/templates/${template_name}.md"
|
||||
[ -f "$candidate" ] && echo "$candidate" && return 0
|
||||
done
|
||||
fi
|
||||
else
|
||||
# Fallback: alphabetical directory order (no python3 available)
|
||||
for preset in "$presets_dir"/*/; do
|
||||
[ -d "$preset" ] || continue
|
||||
local candidate="$preset/templates/${template_name}.md"
|
||||
[ -f "$candidate" ] && echo "$candidate" && return 0
|
||||
done
|
||||
fi
|
||||
fi
|
||||
|
||||
# Priority 3: Extension-provided templates
|
||||
local ext_dir="$repo_root/.specify/extensions"
|
||||
if [ -d "$ext_dir" ]; then
|
||||
for ext in "$ext_dir"/*/; do
|
||||
[ -d "$ext" ] || continue
|
||||
# Skip hidden directories (e.g. .backup, .cache)
|
||||
case "$(basename "$ext")" in .*) continue ;; esac
|
||||
local candidate="$ext/templates/${template_name}.md"
|
||||
[ -f "$candidate" ] && echo "$candidate" && return 0
|
||||
done
|
||||
fi
|
||||
|
||||
# Priority 4: Core templates
|
||||
local core="$base/${template_name}.md"
|
||||
[ -f "$core" ] && echo "$core" && return 0
|
||||
|
||||
# Template not found in any location.
|
||||
# Return 1 so callers can distinguish "not found" from "found".
|
||||
# Callers running under set -e should use: TEMPLATE=$(resolve_template ...) || true
|
||||
return 1
|
||||
}
|
||||
@@ -1,335 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
|
||||
JSON_MODE=false
|
||||
SHORT_NAME=""
|
||||
BRANCH_NUMBER=""
|
||||
USE_TIMESTAMP=false
|
||||
ARGS=()
|
||||
i=1
|
||||
while [ $i -le $# ]; do
|
||||
arg="${!i}"
|
||||
case "$arg" in
|
||||
--json)
|
||||
JSON_MODE=true
|
||||
;;
|
||||
--short-name)
|
||||
if [ $((i + 1)) -gt $# ]; then
|
||||
echo 'Error: --short-name requires a value' >&2
|
||||
exit 1
|
||||
fi
|
||||
i=$((i + 1))
|
||||
next_arg="${!i}"
|
||||
# Check if the next argument is another option (starts with --)
|
||||
if [[ $next_arg == --* ]]; then
|
||||
echo 'Error: --short-name requires a value' >&2
|
||||
exit 1
|
||||
fi
|
||||
SHORT_NAME="$next_arg"
|
||||
;;
|
||||
--number)
|
||||
if [ $((i + 1)) -gt $# ]; then
|
||||
echo 'Error: --number requires a value' >&2
|
||||
exit 1
|
||||
fi
|
||||
i=$((i + 1))
|
||||
next_arg="${!i}"
|
||||
if [[ $next_arg == --* ]]; then
|
||||
echo 'Error: --number requires a value' >&2
|
||||
exit 1
|
||||
fi
|
||||
BRANCH_NUMBER="$next_arg"
|
||||
;;
|
||||
--timestamp)
|
||||
USE_TIMESTAMP=true
|
||||
;;
|
||||
--help | -h)
|
||||
echo "Usage: $0 [--json] [--short-name <name>] [--number N] [--timestamp] <feature_description>"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " --json Output in JSON format"
|
||||
echo " --short-name <name> Provide a custom short name (2-4 words) for the branch"
|
||||
echo " --number N Specify branch number manually (overrides auto-detection)"
|
||||
echo " --timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
|
||||
echo " --help, -h Show this help message"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " $0 'Add user authentication system' --short-name 'user-auth'"
|
||||
echo " $0 'Implement OAuth2 integration for API' --number 5"
|
||||
echo " $0 --timestamp --short-name 'user-auth' 'Add user authentication'"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
ARGS+=("$arg")
|
||||
;;
|
||||
esac
|
||||
i=$((i + 1))
|
||||
done
|
||||
|
||||
FEATURE_DESCRIPTION="${ARGS[*]}"
|
||||
if [ -z "$FEATURE_DESCRIPTION" ]; then
|
||||
echo "Usage: $0 [--json] [--short-name <name>] [--number N] [--timestamp] <feature_description>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Trim whitespace and validate description is not empty (e.g., user passed only whitespace)
|
||||
FEATURE_DESCRIPTION=$(echo "$FEATURE_DESCRIPTION" | xargs)
|
||||
if [ -z "$FEATURE_DESCRIPTION" ]; then
|
||||
echo "Error: Feature description cannot be empty or contain only whitespace" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Function to get highest number from specs directory
|
||||
get_highest_from_specs() {
|
||||
local specs_dir="$1"
|
||||
local highest=0
|
||||
|
||||
if [ -d "$specs_dir" ]; then
|
||||
for dir in "$specs_dir"/*; do
|
||||
[ -d "$dir" ] || continue
|
||||
dirname=$(basename "$dir")
|
||||
# Only match sequential prefixes (###-*), skip timestamp dirs
|
||||
if echo "$dirname" | grep -q '^[0-9]\{3\}-'; then
|
||||
number=$(echo "$dirname" | grep -o '^[0-9]\{3\}')
|
||||
number=$((10#$number))
|
||||
if [ "$number" -gt "$highest" ]; then
|
||||
highest=$number
|
||||
fi
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
echo "$highest"
|
||||
}
|
||||
|
||||
# Function to get highest number from git branches
|
||||
get_highest_from_branches() {
|
||||
local highest=0
|
||||
|
||||
# Get all branches (local and remote)
|
||||
branches=$(git branch -a 2>/dev/null || echo "")
|
||||
|
||||
if [ -n "$branches" ]; then
|
||||
while IFS= read -r branch; do
|
||||
# Clean branch name: remove leading markers and remote prefixes
|
||||
clean_branch=$(echo "$branch" | sed 's/^[* ]*//; s|^remotes/[^/]*/||')
|
||||
|
||||
# Extract feature number if branch matches pattern ###-*
|
||||
if echo "$clean_branch" | grep -q '^[0-9]\{3\}-'; then
|
||||
number=$(echo "$clean_branch" | grep -o '^[0-9]\{3\}' || echo "0")
|
||||
number=$((10#$number))
|
||||
if [ "$number" -gt "$highest" ]; then
|
||||
highest=$number
|
||||
fi
|
||||
fi
|
||||
done <<<"$branches"
|
||||
fi
|
||||
|
||||
echo "$highest"
|
||||
}
|
||||
|
||||
# Function to check existing branches (local and remote) and return next available number
|
||||
check_existing_branches() {
|
||||
local specs_dir="$1"
|
||||
|
||||
# Fetch all remotes to get latest branch info (suppress errors if no remotes)
|
||||
git fetch --all --prune >/dev/null 2>&1 || true
|
||||
|
||||
# Get highest number from ALL branches (not just matching short name)
|
||||
local highest_branch=$(get_highest_from_branches)
|
||||
|
||||
# Get highest number from ALL specs (not just matching short name)
|
||||
local highest_spec=$(get_highest_from_specs "$specs_dir")
|
||||
|
||||
# Take the maximum of both
|
||||
local max_num=$highest_branch
|
||||
if [ "$highest_spec" -gt "$max_num" ]; then
|
||||
max_num=$highest_spec
|
||||
fi
|
||||
|
||||
# Return next number
|
||||
echo $((max_num + 1))
|
||||
}
|
||||
|
||||
# Function to clean and format a branch name
|
||||
clean_branch_name() {
|
||||
local name="$1"
|
||||
echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//'
|
||||
}
|
||||
|
||||
# Resolve repository root using common.sh functions which prioritize .specify over git
|
||||
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/common.sh"
|
||||
|
||||
REPO_ROOT=$(get_repo_root)
|
||||
|
||||
# Check if git is available at this repo root (not a parent)
|
||||
if has_git; then
|
||||
HAS_GIT=true
|
||||
else
|
||||
HAS_GIT=false
|
||||
fi
|
||||
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
SPECS_DIR="$REPO_ROOT/specs"
|
||||
mkdir -p "$SPECS_DIR"
|
||||
|
||||
# Function to generate branch name with stop word filtering and length filtering
|
||||
generate_branch_name() {
|
||||
local description="$1"
|
||||
|
||||
# Common stop words to filter out
|
||||
local stop_words="^(i|a|an|the|to|for|of|in|on|at|by|with|from|is|are|was|were|be|been|being|have|has|had|do|does|did|will|would|should|could|can|may|might|must|shall|this|that|these|those|my|your|our|their|want|need|add|get|set)$"
|
||||
|
||||
# Convert to lowercase and split into words
|
||||
local clean_name=$(echo "$description" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/ /g')
|
||||
|
||||
# Filter words: remove stop words and words shorter than 3 chars (unless they're uppercase acronyms in original)
|
||||
local meaningful_words=()
|
||||
for word in $clean_name; do
|
||||
# Skip empty words
|
||||
[ -z "$word" ] && continue
|
||||
|
||||
# Keep words that are NOT stop words AND (length >= 3 OR are potential acronyms)
|
||||
if ! echo "$word" | grep -qiE "$stop_words"; then
|
||||
if [ ${#word} -ge 3 ]; then
|
||||
meaningful_words+=("$word")
|
||||
elif echo "$description" | grep -q "\b${word^^}\b"; then
|
||||
# Keep short words if they appear as uppercase in original (likely acronyms)
|
||||
meaningful_words+=("$word")
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
# If we have meaningful words, use first 3-4 of them
|
||||
if [ ${#meaningful_words[@]} -gt 0 ]; then
|
||||
local max_words=3
|
||||
if [ ${#meaningful_words[@]} -eq 4 ]; then max_words=4; fi
|
||||
|
||||
local result=""
|
||||
local count=0
|
||||
for word in "${meaningful_words[@]}"; do
|
||||
if [ $count -ge $max_words ]; then break; fi
|
||||
if [ -n "$result" ]; then result="$result-"; fi
|
||||
result="$result$word"
|
||||
count=$((count + 1))
|
||||
done
|
||||
echo "$result"
|
||||
else
|
||||
# Fallback to original logic if no meaningful words found
|
||||
local cleaned=$(clean_branch_name "$description")
|
||||
echo "$cleaned" | tr '-' '\n' | grep -v '^$' | head -3 | tr '\n' '-' | sed 's/-$//'
|
||||
fi
|
||||
}
|
||||
|
||||
# Generate branch name
|
||||
if [ -n "$SHORT_NAME" ]; then
|
||||
# Use provided short name, just clean it up
|
||||
BRANCH_SUFFIX=$(clean_branch_name "$SHORT_NAME")
|
||||
else
|
||||
# Generate from description with smart filtering
|
||||
BRANCH_SUFFIX=$(generate_branch_name "$FEATURE_DESCRIPTION")
|
||||
fi
|
||||
|
||||
# Warn if --number and --timestamp are both specified
|
||||
if [ "$USE_TIMESTAMP" = true ] && [ -n "$BRANCH_NUMBER" ]; then
|
||||
echo >&2 "[specify] Warning: --number is ignored when --timestamp is used"
|
||||
BRANCH_NUMBER=""
|
||||
fi
|
||||
|
||||
# Determine branch prefix
|
||||
if [ "$USE_TIMESTAMP" = true ]; then
|
||||
FEATURE_NUM=$(date +%Y%m%d-%H%M%S)
|
||||
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
|
||||
else
|
||||
# Determine branch number
|
||||
if [ -z "$BRANCH_NUMBER" ]; then
|
||||
if [ "$HAS_GIT" = true ]; then
|
||||
# Check existing branches on remotes
|
||||
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR")
|
||||
else
|
||||
# Fall back to local directory check
|
||||
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
|
||||
BRANCH_NUMBER=$((HIGHEST + 1))
|
||||
fi
|
||||
fi
|
||||
|
||||
# Force base-10 interpretation to prevent octal conversion (e.g., 010 → 8 in octal, but should be 10 in decimal)
|
||||
FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))")
|
||||
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
|
||||
fi
|
||||
|
||||
# GitHub enforces a 244-byte limit on branch names
|
||||
# Validate and truncate if necessary
|
||||
MAX_BRANCH_LENGTH=244
|
||||
if [ ${#BRANCH_NAME} -gt $MAX_BRANCH_LENGTH ]; then
|
||||
# Calculate how much we need to trim from suffix
|
||||
# Account for prefix length: timestamp (15) + hyphen (1) = 16, or sequential (3) + hyphen (1) = 4
|
||||
PREFIX_LENGTH=$((${#FEATURE_NUM} + 1))
|
||||
MAX_SUFFIX_LENGTH=$((MAX_BRANCH_LENGTH - PREFIX_LENGTH))
|
||||
|
||||
# Truncate suffix at word boundary if possible
|
||||
TRUNCATED_SUFFIX=$(echo "$BRANCH_SUFFIX" | cut -c1-$MAX_SUFFIX_LENGTH)
|
||||
# Remove trailing hyphen if truncation created one
|
||||
TRUNCATED_SUFFIX=$(echo "$TRUNCATED_SUFFIX" | sed 's/-$//')
|
||||
|
||||
ORIGINAL_BRANCH_NAME="$BRANCH_NAME"
|
||||
BRANCH_NAME="${FEATURE_NUM}-${TRUNCATED_SUFFIX}"
|
||||
|
||||
echo >&2 "[specify] Warning: Branch name exceeded GitHub's 244-byte limit"
|
||||
echo >&2 "[specify] Original: $ORIGINAL_BRANCH_NAME (${#ORIGINAL_BRANCH_NAME} bytes)"
|
||||
echo >&2 "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)"
|
||||
fi
|
||||
|
||||
if [ "$HAS_GIT" = true ]; then
|
||||
if ! git checkout -b "$BRANCH_NAME" 2>/dev/null; then
|
||||
# Check if branch already exists
|
||||
if git branch --list "$BRANCH_NAME" | grep -q .; then
|
||||
if [ "$USE_TIMESTAMP" = true ]; then
|
||||
echo >&2 "Error: Branch '$BRANCH_NAME' already exists. Rerun to get a new timestamp or use a different --short-name."
|
||||
else
|
||||
echo >&2 "Error: Branch '$BRANCH_NAME' already exists. Please use a different feature name or specify a different number with --number."
|
||||
fi
|
||||
exit 1
|
||||
else
|
||||
echo >&2 "Error: Failed to create git branch '$BRANCH_NAME'. Please check your git configuration and try again."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo >&2 "[specify] Warning: Git repository not detected; skipped branch creation for $BRANCH_NAME"
|
||||
fi
|
||||
|
||||
FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME"
|
||||
mkdir -p "$FEATURE_DIR"
|
||||
|
||||
TEMPLATE=$(resolve_template "spec-template" "$REPO_ROOT") || true
|
||||
SPEC_FILE="$FEATURE_DIR/spec.md"
|
||||
if [ -n "$TEMPLATE" ] && [ -f "$TEMPLATE" ]; then
|
||||
cp "$TEMPLATE" "$SPEC_FILE"
|
||||
else
|
||||
echo "Warning: Spec template not found; created empty spec file" >&2
|
||||
touch "$SPEC_FILE"
|
||||
fi
|
||||
|
||||
# Inform the user how to persist the feature variable in their own shell
|
||||
printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2
|
||||
|
||||
if $JSON_MODE; then
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
jq -cn \
|
||||
--arg branch_name "$BRANCH_NAME" \
|
||||
--arg spec_file "$SPEC_FILE" \
|
||||
--arg feature_num "$FEATURE_NUM" \
|
||||
'{BRANCH_NAME:$branch_name,SPEC_FILE:$spec_file,FEATURE_NUM:$feature_num}'
|
||||
else
|
||||
printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s"}\n' "$(json_escape "$BRANCH_NAME")" "$(json_escape "$SPEC_FILE")" "$(json_escape "$FEATURE_NUM")"
|
||||
fi
|
||||
else
|
||||
echo "BRANCH_NAME: $BRANCH_NAME"
|
||||
echo "SPEC_FILE: $SPEC_FILE"
|
||||
echo "FEATURE_NUM: $FEATURE_NUM"
|
||||
printf '# To persist in your shell: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME"
|
||||
fi
|
||||
@@ -1,75 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
|
||||
# Parse command line arguments
|
||||
JSON_MODE=false
|
||||
ARGS=()
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--json)
|
||||
JSON_MODE=true
|
||||
;;
|
||||
--help | -h)
|
||||
echo "Usage: $0 [--json]"
|
||||
echo " --json Output results in JSON format"
|
||||
echo " --help Show this help message"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
ARGS+=("$arg")
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Get script directory and load common functions
|
||||
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/common.sh"
|
||||
|
||||
# Get all paths and variables from common functions
|
||||
_paths_output=$(get_feature_paths) || {
|
||||
echo "ERROR: Failed to resolve feature paths" >&2
|
||||
exit 1
|
||||
}
|
||||
eval "$_paths_output"
|
||||
unset _paths_output
|
||||
|
||||
# Check if we're on a proper feature branch (only for git repos)
|
||||
check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1
|
||||
|
||||
# Ensure the feature directory exists
|
||||
mkdir -p "$FEATURE_DIR"
|
||||
|
||||
# Copy plan template if it exists
|
||||
TEMPLATE=$(resolve_template "plan-template" "$REPO_ROOT") || true
|
||||
if [[ -n $TEMPLATE ]] && [[ -f $TEMPLATE ]]; then
|
||||
cp "$TEMPLATE" "$IMPL_PLAN"
|
||||
echo "Copied plan template to $IMPL_PLAN"
|
||||
else
|
||||
echo "Warning: Plan template not found"
|
||||
# Create a basic plan file if template doesn't exist
|
||||
touch "$IMPL_PLAN"
|
||||
fi
|
||||
|
||||
# Output results
|
||||
if $JSON_MODE; then
|
||||
if has_jq; then
|
||||
jq -cn \
|
||||
--arg feature_spec "$FEATURE_SPEC" \
|
||||
--arg impl_plan "$IMPL_PLAN" \
|
||||
--arg specs_dir "$FEATURE_DIR" \
|
||||
--arg branch "$CURRENT_BRANCH" \
|
||||
--arg has_git "$HAS_GIT" \
|
||||
'{FEATURE_SPEC:$feature_spec,IMPL_PLAN:$impl_plan,SPECS_DIR:$specs_dir,BRANCH:$branch,HAS_GIT:$has_git}'
|
||||
else
|
||||
printf '{"FEATURE_SPEC":"%s","IMPL_PLAN":"%s","SPECS_DIR":"%s","BRANCH":"%s","HAS_GIT":"%s"}\n' \
|
||||
"$(json_escape "$FEATURE_SPEC")" "$(json_escape "$IMPL_PLAN")" "$(json_escape "$FEATURE_DIR")" "$(json_escape "$CURRENT_BRANCH")" "$(json_escape "$HAS_GIT")"
|
||||
fi
|
||||
else
|
||||
echo "FEATURE_SPEC: $FEATURE_SPEC"
|
||||
echo "IMPL_PLAN: $IMPL_PLAN"
|
||||
echo "SPECS_DIR: $FEATURE_DIR"
|
||||
echo "BRANCH: $CURRENT_BRANCH"
|
||||
echo "HAS_GIT: $HAS_GIT"
|
||||
fi
|
||||
@@ -1,840 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Update agent context files with information from plan.md
|
||||
#
|
||||
# This script maintains AI agent context files by parsing feature specifications
|
||||
# and updating agent-specific configuration files with project information.
|
||||
#
|
||||
# MAIN FUNCTIONS:
|
||||
# 1. Environment Validation
|
||||
# - Verifies git repository structure and branch information
|
||||
# - Checks for required plan.md files and templates
|
||||
# - Validates file permissions and accessibility
|
||||
#
|
||||
# 2. Plan Data Extraction
|
||||
# - Parses plan.md files to extract project metadata
|
||||
# - Identifies language/version, frameworks, databases, and project types
|
||||
# - Handles missing or incomplete specification data gracefully
|
||||
#
|
||||
# 3. Agent File Management
|
||||
# - Creates new agent context files from templates when needed
|
||||
# - Updates existing agent files with new project information
|
||||
# - Preserves manual additions and custom configurations
|
||||
# - Supports multiple AI agent formats and directory structures
|
||||
#
|
||||
# 4. Content Generation
|
||||
# - Generates language-specific build/test commands
|
||||
# - Creates appropriate project directory structures
|
||||
# - Updates technology stacks and recent changes sections
|
||||
# - Maintains consistent formatting and timestamps
|
||||
#
|
||||
# 5. Multi-Agent Support
|
||||
# - Handles agent-specific file paths and naming conventions
|
||||
# - Supports: Claude, Gemini, Copilot, Cursor, Qwen, opencode, Codex, Windsurf, Junie, Kilo Code, Auggie CLI, Roo Code, CodeBuddy CLI, Qoder CLI, Amp, SHAI, Tabnine CLI, Kiro CLI, Mistral Vibe, Kimi Code, Pi Coding Agent, iFlow CLI, Antigravity or Generic
|
||||
# - Can update single agents or all existing agent files
|
||||
# - Creates default Claude file if no agent files exist
|
||||
#
|
||||
# Usage: ./update-agent-context.sh [agent_type]
|
||||
# Agent types: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|generic
|
||||
# Leave empty to update all existing agent files
|
||||
|
||||
set -e
|
||||
|
||||
# Enable strict error handling
|
||||
set -u
|
||||
set -o pipefail
|
||||
|
||||
#==============================================================================
|
||||
# Configuration and Global Variables
|
||||
#==============================================================================
|
||||
|
||||
# Get script directory and load common functions
|
||||
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/common.sh"
|
||||
|
||||
# Get all paths and variables from common functions
|
||||
_paths_output=$(get_feature_paths) || {
|
||||
echo "ERROR: Failed to resolve feature paths" >&2
|
||||
exit 1
|
||||
}
|
||||
eval "$_paths_output"
|
||||
unset _paths_output
|
||||
|
||||
NEW_PLAN="$IMPL_PLAN" # Alias for compatibility with existing code
|
||||
AGENT_TYPE="${1-}"
|
||||
|
||||
# Agent-specific file paths
|
||||
CLAUDE_FILE="$REPO_ROOT/CLAUDE.md"
|
||||
GEMINI_FILE="$REPO_ROOT/GEMINI.md"
|
||||
COPILOT_FILE="$REPO_ROOT/.github/agents/copilot-instructions.md"
|
||||
CURSOR_FILE="$REPO_ROOT/.cursor/rules/specify-rules.mdc"
|
||||
QWEN_FILE="$REPO_ROOT/QWEN.md"
|
||||
AGENTS_FILE="$REPO_ROOT/AGENTS.md"
|
||||
WINDSURF_FILE="$REPO_ROOT/.windsurf/rules/specify-rules.md"
|
||||
JUNIE_FILE="$REPO_ROOT/.junie/AGENTS.md"
|
||||
KILOCODE_FILE="$REPO_ROOT/.kilocode/rules/specify-rules.md"
|
||||
AUGGIE_FILE="$REPO_ROOT/.augment/rules/specify-rules.md"
|
||||
ROO_FILE="$REPO_ROOT/.roo/rules/specify-rules.md"
|
||||
CODEBUDDY_FILE="$REPO_ROOT/CODEBUDDY.md"
|
||||
QODER_FILE="$REPO_ROOT/QODER.md"
|
||||
# Amp, Kiro CLI, IBM Bob, and Pi all share AGENTS.md — use AGENTS_FILE to avoid
|
||||
# updating the same file multiple times.
|
||||
AMP_FILE="$AGENTS_FILE"
|
||||
SHAI_FILE="$REPO_ROOT/SHAI.md"
|
||||
TABNINE_FILE="$REPO_ROOT/TABNINE.md"
|
||||
KIRO_FILE="$AGENTS_FILE"
|
||||
AGY_FILE="$REPO_ROOT/.agent/rules/specify-rules.md"
|
||||
BOB_FILE="$AGENTS_FILE"
|
||||
VIBE_FILE="$REPO_ROOT/.vibe/agents/specify-agents.md"
|
||||
KIMI_FILE="$REPO_ROOT/KIMI.md"
|
||||
TRAE_FILE="$REPO_ROOT/.trae/rules/AGENTS.md"
|
||||
IFLOW_FILE="$REPO_ROOT/IFLOW.md"
|
||||
|
||||
# Template file
|
||||
TEMPLATE_FILE="$REPO_ROOT/.specify/templates/agent-file-template.md"
|
||||
|
||||
# Global variables for parsed plan data
|
||||
NEW_LANG=""
|
||||
NEW_FRAMEWORK=""
|
||||
NEW_DB=""
|
||||
NEW_PROJECT_TYPE=""
|
||||
|
||||
#==============================================================================
|
||||
# Utility Functions
|
||||
#==============================================================================
|
||||
|
||||
log_info() {
|
||||
echo "INFO: $1"
|
||||
}
|
||||
|
||||
log_success() {
|
||||
echo "✓ $1"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
echo "ERROR: $1" >&2
|
||||
}
|
||||
|
||||
log_warning() {
|
||||
echo "WARNING: $1" >&2
|
||||
}
|
||||
|
||||
# Cleanup function for temporary files
|
||||
cleanup() {
|
||||
local exit_code=$?
|
||||
# Disarm traps to prevent re-entrant loop
|
||||
trap - EXIT INT TERM
|
||||
rm -f /tmp/agent_update_*_$$
|
||||
rm -f /tmp/manual_additions_$$
|
||||
exit $exit_code
|
||||
}
|
||||
|
||||
# Set up cleanup trap
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
#==============================================================================
|
||||
# Validation Functions
|
||||
#==============================================================================
|
||||
|
||||
validate_environment() {
|
||||
# Check if we have a current branch/feature (git or non-git)
|
||||
if [[ -z $CURRENT_BRANCH ]]; then
|
||||
log_error "Unable to determine current feature"
|
||||
if [[ $HAS_GIT == "true" ]]; then
|
||||
log_info "Make sure you're on a feature branch"
|
||||
else
|
||||
log_info "Set SPECIFY_FEATURE environment variable or create a feature first"
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if plan.md exists
|
||||
if [[ ! -f $NEW_PLAN ]]; then
|
||||
log_error "No plan.md found at $NEW_PLAN"
|
||||
log_info "Make sure you're working on a feature with a corresponding spec directory"
|
||||
if [[ $HAS_GIT != "true" ]]; then
|
||||
log_info "Use: export SPECIFY_FEATURE=your-feature-name or create a new feature first"
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if template exists (needed for new files)
|
||||
if [[ ! -f $TEMPLATE_FILE ]]; then
|
||||
log_warning "Template file not found at $TEMPLATE_FILE"
|
||||
log_warning "Creating new agent files will fail"
|
||||
fi
|
||||
}
|
||||
|
||||
#==============================================================================
|
||||
# Plan Parsing Functions
|
||||
#==============================================================================
|
||||
|
||||
extract_plan_field() {
|
||||
local field_pattern="$1"
|
||||
local plan_file="$2"
|
||||
|
||||
grep "^\*\*${field_pattern}\*\*: " "$plan_file" 2>/dev/null |
|
||||
head -1 |
|
||||
sed "s|^\*\*${field_pattern}\*\*: ||" |
|
||||
sed 's/^[ \t]*//;s/[ \t]*$//' |
|
||||
grep -v "NEEDS CLARIFICATION" |
|
||||
grep -v "^N/A$" || echo ""
|
||||
}
|
||||
|
||||
parse_plan_data() {
|
||||
local plan_file="$1"
|
||||
|
||||
if [[ ! -f $plan_file ]]; then
|
||||
log_error "Plan file not found: $plan_file"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ ! -r $plan_file ]]; then
|
||||
log_error "Plan file is not readable: $plan_file"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log_info "Parsing plan data from $plan_file"
|
||||
|
||||
NEW_LANG=$(extract_plan_field "Language/Version" "$plan_file")
|
||||
NEW_FRAMEWORK=$(extract_plan_field "Primary Dependencies" "$plan_file")
|
||||
NEW_DB=$(extract_plan_field "Storage" "$plan_file")
|
||||
NEW_PROJECT_TYPE=$(extract_plan_field "Project Type" "$plan_file")
|
||||
|
||||
# Log what we found
|
||||
if [[ -n $NEW_LANG ]]; then
|
||||
log_info "Found language: $NEW_LANG"
|
||||
else
|
||||
log_warning "No language information found in plan"
|
||||
fi
|
||||
|
||||
if [[ -n $NEW_FRAMEWORK ]]; then
|
||||
log_info "Found framework: $NEW_FRAMEWORK"
|
||||
fi
|
||||
|
||||
if [[ -n $NEW_DB ]] && [[ $NEW_DB != "N/A" ]]; then
|
||||
log_info "Found database: $NEW_DB"
|
||||
fi
|
||||
|
||||
if [[ -n $NEW_PROJECT_TYPE ]]; then
|
||||
log_info "Found project type: $NEW_PROJECT_TYPE"
|
||||
fi
|
||||
}
|
||||
|
||||
format_technology_stack() {
|
||||
local lang="$1"
|
||||
local framework="$2"
|
||||
local parts=()
|
||||
|
||||
# Add non-empty parts
|
||||
[[ -n $lang && $lang != "NEEDS CLARIFICATION" ]] && parts+=("$lang")
|
||||
[[ -n $framework && $framework != "NEEDS CLARIFICATION" && $framework != "N/A" ]] && parts+=("$framework")
|
||||
|
||||
# Join with proper formatting
|
||||
if [[ ${#parts[@]} -eq 0 ]]; then
|
||||
echo ""
|
||||
elif [[ ${#parts[@]} -eq 1 ]]; then
|
||||
echo "${parts[0]}"
|
||||
else
|
||||
# Join multiple parts with " + "
|
||||
local result="${parts[0]}"
|
||||
for ((i = 1; i < ${#parts[@]}; i++)); do
|
||||
result="$result + ${parts[i]}"
|
||||
done
|
||||
echo "$result"
|
||||
fi
|
||||
}
|
||||
|
||||
#==============================================================================
|
||||
# Template and Content Generation Functions
|
||||
#==============================================================================
|
||||
|
||||
get_project_structure() {
|
||||
local project_type="$1"
|
||||
|
||||
if [[ $project_type == *"web"* ]]; then
|
||||
echo 'backend/\nfrontend/\ntests/'
|
||||
else
|
||||
echo 'src/\ntests/'
|
||||
fi
|
||||
}
|
||||
|
||||
get_commands_for_language() {
|
||||
local lang="$1"
|
||||
|
||||
case "$lang" in
|
||||
*"Python"*)
|
||||
echo "cd src && pytest && ruff check ."
|
||||
;;
|
||||
*"Rust"*)
|
||||
echo "cargo test && cargo clippy"
|
||||
;;
|
||||
*"JavaScript"* | *"TypeScript"*)
|
||||
echo 'npm test \&\& npm run lint'
|
||||
;;
|
||||
*)
|
||||
echo "# Add commands for $lang"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
get_language_conventions() {
|
||||
local lang="$1"
|
||||
echo "$lang: Follow standard conventions"
|
||||
}
|
||||
|
||||
create_new_agent_file() {
|
||||
local target_file="$1"
|
||||
local temp_file="$2"
|
||||
local project_name="$3"
|
||||
local current_date="$4"
|
||||
|
||||
if [[ ! -f $TEMPLATE_FILE ]]; then
|
||||
log_error "Template not found at $TEMPLATE_FILE"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ ! -r $TEMPLATE_FILE ]]; then
|
||||
log_error "Template file is not readable: $TEMPLATE_FILE"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log_info "Creating new agent context file from template..."
|
||||
|
||||
if ! cp "$TEMPLATE_FILE" "$temp_file"; then
|
||||
log_error "Failed to copy template file"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Replace template placeholders
|
||||
local project_structure
|
||||
project_structure=$(get_project_structure "$NEW_PROJECT_TYPE")
|
||||
|
||||
local commands
|
||||
commands=$(get_commands_for_language "$NEW_LANG")
|
||||
|
||||
local language_conventions
|
||||
language_conventions=$(get_language_conventions "$NEW_LANG")
|
||||
|
||||
# Perform substitutions with error checking using safer approach
|
||||
# Escape special characters for sed by using a different delimiter or escaping
|
||||
local escaped_lang=$(printf '%s\n' "$NEW_LANG" | sed 's/[\[\.*^$()+{}|]/\\&/g')
|
||||
local escaped_framework=$(printf '%s\n' "$NEW_FRAMEWORK" | sed 's/[\[\.*^$()+{}|]/\\&/g')
|
||||
local escaped_branch=$(printf '%s\n' "$CURRENT_BRANCH" | sed 's/[\[\.*^$()+{}|]/\\&/g')
|
||||
|
||||
# Build technology stack and recent change strings conditionally
|
||||
local tech_stack
|
||||
if [[ -n $escaped_lang && -n $escaped_framework ]]; then
|
||||
tech_stack="- $escaped_lang + $escaped_framework ($escaped_branch)"
|
||||
elif [[ -n $escaped_lang ]]; then
|
||||
tech_stack="- $escaped_lang ($escaped_branch)"
|
||||
elif [[ -n $escaped_framework ]]; then
|
||||
tech_stack="- $escaped_framework ($escaped_branch)"
|
||||
else
|
||||
tech_stack="- ($escaped_branch)"
|
||||
fi
|
||||
|
||||
local recent_change
|
||||
if [[ -n $escaped_lang && -n $escaped_framework ]]; then
|
||||
recent_change="- $escaped_branch: Added $escaped_lang + $escaped_framework"
|
||||
elif [[ -n $escaped_lang ]]; then
|
||||
recent_change="- $escaped_branch: Added $escaped_lang"
|
||||
elif [[ -n $escaped_framework ]]; then
|
||||
recent_change="- $escaped_branch: Added $escaped_framework"
|
||||
else
|
||||
recent_change="- $escaped_branch: Added"
|
||||
fi
|
||||
|
||||
local substitutions=(
|
||||
"s|\[PROJECT NAME\]|$project_name|"
|
||||
"s|\[DATE\]|$current_date|"
|
||||
"s|\[EXTRACTED FROM ALL PLAN.MD FILES\]|$tech_stack|"
|
||||
"s|\[ACTUAL STRUCTURE FROM PLANS\]|$project_structure|g"
|
||||
"s|\[ONLY COMMANDS FOR ACTIVE TECHNOLOGIES\]|$commands|"
|
||||
"s|\[LANGUAGE-SPECIFIC, ONLY FOR LANGUAGES IN USE\]|$language_conventions|"
|
||||
"s|\[LAST 3 FEATURES AND WHAT THEY ADDED\]|$recent_change|"
|
||||
)
|
||||
|
||||
for substitution in "${substitutions[@]}"; do
|
||||
if ! sed -i.bak -e "$substitution" "$temp_file"; then
|
||||
log_error "Failed to perform substitution: $substitution"
|
||||
rm -f "$temp_file" "$temp_file.bak"
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
|
||||
# Convert \n sequences to actual newlines
|
||||
newline=$(printf '\n')
|
||||
sed -i.bak2 "s/\\\\n/${newline}/g" "$temp_file"
|
||||
|
||||
# Clean up backup files
|
||||
rm -f "$temp_file.bak" "$temp_file.bak2"
|
||||
|
||||
# Prepend Cursor frontmatter for .mdc files so rules are auto-included
|
||||
if [[ $target_file == *.mdc ]]; then
|
||||
local frontmatter_file
|
||||
frontmatter_file=$(mktemp) || return 1
|
||||
printf '%s\n' "---" "description: Project Development Guidelines" 'globs: ["**/*"]' "alwaysApply: true" "---" "" >"$frontmatter_file"
|
||||
cat "$temp_file" >>"$frontmatter_file"
|
||||
mv "$frontmatter_file" "$temp_file"
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
update_existing_agent_file() {
|
||||
local target_file="$1"
|
||||
local current_date="$2"
|
||||
|
||||
log_info "Updating existing agent context file..."
|
||||
|
||||
# Use a single temporary file for atomic update
|
||||
local temp_file
|
||||
temp_file=$(mktemp) || {
|
||||
log_error "Failed to create temporary file"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Process the file in one pass
|
||||
local tech_stack=$(format_technology_stack "$NEW_LANG" "$NEW_FRAMEWORK")
|
||||
local new_tech_entries=()
|
||||
local new_change_entry=""
|
||||
|
||||
# Prepare new technology entries
|
||||
if [[ -n $tech_stack ]] && ! grep -q "$tech_stack" "$target_file"; then
|
||||
new_tech_entries+=("- $tech_stack ($CURRENT_BRANCH)")
|
||||
fi
|
||||
|
||||
if [[ -n $NEW_DB ]] && [[ $NEW_DB != "N/A" ]] && [[ $NEW_DB != "NEEDS CLARIFICATION" ]] && ! grep -q "$NEW_DB" "$target_file"; then
|
||||
new_tech_entries+=("- $NEW_DB ($CURRENT_BRANCH)")
|
||||
fi
|
||||
|
||||
# Prepare new change entry
|
||||
if [[ -n $tech_stack ]]; then
|
||||
new_change_entry="- $CURRENT_BRANCH: Added $tech_stack"
|
||||
elif [[ -n $NEW_DB ]] && [[ $NEW_DB != "N/A" ]] && [[ $NEW_DB != "NEEDS CLARIFICATION" ]]; then
|
||||
new_change_entry="- $CURRENT_BRANCH: Added $NEW_DB"
|
||||
fi
|
||||
|
||||
# Check if sections exist in the file
|
||||
local has_active_technologies=0
|
||||
local has_recent_changes=0
|
||||
|
||||
if grep -q "^## Active Technologies" "$target_file" 2>/dev/null; then
|
||||
has_active_technologies=1
|
||||
fi
|
||||
|
||||
if grep -q "^## Recent Changes" "$target_file" 2>/dev/null; then
|
||||
has_recent_changes=1
|
||||
fi
|
||||
|
||||
# Process file line by line
|
||||
local in_tech_section=false
|
||||
local in_changes_section=false
|
||||
local tech_entries_added=false
|
||||
local changes_entries_added=false
|
||||
local existing_changes_count=0
|
||||
local file_ended=false
|
||||
|
||||
while IFS= read -r line || [[ -n $line ]]; do
|
||||
# Handle Active Technologies section
|
||||
if [[ $line == "## Active Technologies" ]]; then
|
||||
echo "$line" >>"$temp_file"
|
||||
in_tech_section=true
|
||||
continue
|
||||
elif [[ $in_tech_section == true ]] && [[ $line =~ ^##[[:space:]] ]]; then
|
||||
# Add new tech entries before closing the section
|
||||
if [[ $tech_entries_added == false ]] && [[ ${#new_tech_entries[@]} -gt 0 ]]; then
|
||||
printf '%s\n' "${new_tech_entries[@]}" >>"$temp_file"
|
||||
tech_entries_added=true
|
||||
fi
|
||||
echo "$line" >>"$temp_file"
|
||||
in_tech_section=false
|
||||
continue
|
||||
elif [[ $in_tech_section == true ]] && [[ -z $line ]]; then
|
||||
# Add new tech entries before empty line in tech section
|
||||
if [[ $tech_entries_added == false ]] && [[ ${#new_tech_entries[@]} -gt 0 ]]; then
|
||||
printf '%s\n' "${new_tech_entries[@]}" >>"$temp_file"
|
||||
tech_entries_added=true
|
||||
fi
|
||||
echo "$line" >>"$temp_file"
|
||||
continue
|
||||
fi
|
||||
|
||||
# Handle Recent Changes section
|
||||
if [[ $line == "## Recent Changes" ]]; then
|
||||
echo "$line" >>"$temp_file"
|
||||
# Add new change entry right after the heading
|
||||
if [[ -n $new_change_entry ]]; then
|
||||
echo "$new_change_entry" >>"$temp_file"
|
||||
fi
|
||||
in_changes_section=true
|
||||
changes_entries_added=true
|
||||
continue
|
||||
elif [[ $in_changes_section == true ]] && [[ $line =~ ^##[[:space:]] ]]; then
|
||||
echo "$line" >>"$temp_file"
|
||||
in_changes_section=false
|
||||
continue
|
||||
elif [[ $in_changes_section == true ]] && [[ $line == "- "* ]]; then
|
||||
# Keep only first 2 existing changes
|
||||
if [[ $existing_changes_count -lt 2 ]]; then
|
||||
echo "$line" >>"$temp_file"
|
||||
((existing_changes_count++))
|
||||
fi
|
||||
continue
|
||||
fi
|
||||
|
||||
# Update timestamp
|
||||
if [[ $line =~ (\*\*)?Last\ updated(\*\*)?:.*[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9] ]]; then
|
||||
echo "$line" | sed "s/[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]/$current_date/" >>"$temp_file"
|
||||
else
|
||||
echo "$line" >>"$temp_file"
|
||||
fi
|
||||
done <"$target_file"
|
||||
|
||||
# Post-loop check: if we're still in the Active Technologies section and haven't added new entries
|
||||
if [[ $in_tech_section == true ]] && [[ $tech_entries_added == false ]] && [[ ${#new_tech_entries[@]} -gt 0 ]]; then
|
||||
printf '%s\n' "${new_tech_entries[@]}" >>"$temp_file"
|
||||
tech_entries_added=true
|
||||
fi
|
||||
|
||||
# If sections don't exist, add them at the end of the file
|
||||
if [[ $has_active_technologies -eq 0 ]] && [[ ${#new_tech_entries[@]} -gt 0 ]]; then
|
||||
echo "" >>"$temp_file"
|
||||
echo "## Active Technologies" >>"$temp_file"
|
||||
printf '%s\n' "${new_tech_entries[@]}" >>"$temp_file"
|
||||
tech_entries_added=true
|
||||
fi
|
||||
|
||||
if [[ $has_recent_changes -eq 0 ]] && [[ -n $new_change_entry ]]; then
|
||||
echo "" >>"$temp_file"
|
||||
echo "## Recent Changes" >>"$temp_file"
|
||||
echo "$new_change_entry" >>"$temp_file"
|
||||
changes_entries_added=true
|
||||
fi
|
||||
|
||||
# Ensure Cursor .mdc files have YAML frontmatter for auto-inclusion
|
||||
if [[ $target_file == *.mdc ]]; then
|
||||
if ! head -1 "$temp_file" | grep -q '^---'; then
|
||||
local frontmatter_file
|
||||
frontmatter_file=$(mktemp) || {
|
||||
rm -f "$temp_file"
|
||||
return 1
|
||||
}
|
||||
printf '%s\n' "---" "description: Project Development Guidelines" 'globs: ["**/*"]' "alwaysApply: true" "---" "" >"$frontmatter_file"
|
||||
cat "$temp_file" >>"$frontmatter_file"
|
||||
mv "$frontmatter_file" "$temp_file"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Move temp file to target atomically
|
||||
if ! mv "$temp_file" "$target_file"; then
|
||||
log_error "Failed to update target file"
|
||||
rm -f "$temp_file"
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
#==============================================================================
|
||||
# Main Agent File Update Function
|
||||
#==============================================================================
|
||||
|
||||
update_agent_file() {
|
||||
local target_file="$1"
|
||||
local agent_name="$2"
|
||||
|
||||
if [[ -z $target_file ]] || [[ -z $agent_name ]]; then
|
||||
log_error "update_agent_file requires target_file and agent_name parameters"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log_info "Updating $agent_name context file: $target_file"
|
||||
|
||||
local project_name
|
||||
project_name=$(basename "$REPO_ROOT")
|
||||
local current_date
|
||||
current_date=$(date +%Y-%m-%d)
|
||||
|
||||
# Create directory if it doesn't exist
|
||||
local target_dir
|
||||
target_dir=$(dirname "$target_file")
|
||||
if [[ ! -d $target_dir ]]; then
|
||||
if ! mkdir -p "$target_dir"; then
|
||||
log_error "Failed to create directory: $target_dir"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ ! -f $target_file ]]; then
|
||||
# Create new file from template
|
||||
local temp_file
|
||||
temp_file=$(mktemp) || {
|
||||
log_error "Failed to create temporary file"
|
||||
return 1
|
||||
}
|
||||
|
||||
if create_new_agent_file "$target_file" "$temp_file" "$project_name" "$current_date"; then
|
||||
if mv "$temp_file" "$target_file"; then
|
||||
log_success "Created new $agent_name context file"
|
||||
else
|
||||
log_error "Failed to move temporary file to $target_file"
|
||||
rm -f "$temp_file"
|
||||
return 1
|
||||
fi
|
||||
else
|
||||
log_error "Failed to create new agent file"
|
||||
rm -f "$temp_file"
|
||||
return 1
|
||||
fi
|
||||
else
|
||||
# Update existing file
|
||||
if [[ ! -r $target_file ]]; then
|
||||
log_error "Cannot read existing file: $target_file"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ ! -w $target_file ]]; then
|
||||
log_error "Cannot write to existing file: $target_file"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if update_existing_agent_file "$target_file" "$current_date"; then
|
||||
log_success "Updated existing $agent_name context file"
|
||||
else
|
||||
log_error "Failed to update existing agent file"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
#==============================================================================
|
||||
# Agent Selection and Processing
|
||||
#==============================================================================
|
||||
|
||||
update_specific_agent() {
|
||||
local agent_type="$1"
|
||||
|
||||
case "$agent_type" in
|
||||
claude)
|
||||
update_agent_file "$CLAUDE_FILE" "Claude Code" || return 1
|
||||
;;
|
||||
gemini)
|
||||
update_agent_file "$GEMINI_FILE" "Gemini CLI" || return 1
|
||||
;;
|
||||
copilot)
|
||||
update_agent_file "$COPILOT_FILE" "GitHub Copilot" || return 1
|
||||
;;
|
||||
cursor-agent)
|
||||
update_agent_file "$CURSOR_FILE" "Cursor IDE" || return 1
|
||||
;;
|
||||
qwen)
|
||||
update_agent_file "$QWEN_FILE" "Qwen Code" || return 1
|
||||
;;
|
||||
opencode)
|
||||
update_agent_file "$AGENTS_FILE" "opencode" || return 1
|
||||
;;
|
||||
codex)
|
||||
update_agent_file "$AGENTS_FILE" "Codex CLI" || return 1
|
||||
;;
|
||||
windsurf)
|
||||
update_agent_file "$WINDSURF_FILE" "Windsurf" || return 1
|
||||
;;
|
||||
junie)
|
||||
update_agent_file "$JUNIE_FILE" "Junie" || return 1
|
||||
;;
|
||||
kilocode)
|
||||
update_agent_file "$KILOCODE_FILE" "Kilo Code" || return 1
|
||||
;;
|
||||
auggie)
|
||||
update_agent_file "$AUGGIE_FILE" "Auggie CLI" || return 1
|
||||
;;
|
||||
roo)
|
||||
update_agent_file "$ROO_FILE" "Roo Code" || return 1
|
||||
;;
|
||||
codebuddy)
|
||||
update_agent_file "$CODEBUDDY_FILE" "CodeBuddy CLI" || return 1
|
||||
;;
|
||||
qodercli)
|
||||
update_agent_file "$QODER_FILE" "Qoder CLI" || return 1
|
||||
;;
|
||||
amp)
|
||||
update_agent_file "$AMP_FILE" "Amp" || return 1
|
||||
;;
|
||||
shai)
|
||||
update_agent_file "$SHAI_FILE" "SHAI" || return 1
|
||||
;;
|
||||
tabnine)
|
||||
update_agent_file "$TABNINE_FILE" "Tabnine CLI" || return 1
|
||||
;;
|
||||
kiro-cli)
|
||||
update_agent_file "$KIRO_FILE" "Kiro CLI" || return 1
|
||||
;;
|
||||
agy)
|
||||
update_agent_file "$AGY_FILE" "Antigravity" || return 1
|
||||
;;
|
||||
bob)
|
||||
update_agent_file "$BOB_FILE" "IBM Bob" || return 1
|
||||
;;
|
||||
vibe)
|
||||
update_agent_file "$VIBE_FILE" "Mistral Vibe" || return 1
|
||||
;;
|
||||
kimi)
|
||||
update_agent_file "$KIMI_FILE" "Kimi Code" || return 1
|
||||
;;
|
||||
trae)
|
||||
update_agent_file "$TRAE_FILE" "Trae" || return 1
|
||||
;;
|
||||
pi)
|
||||
update_agent_file "$AGENTS_FILE" "Pi Coding Agent" || return 1
|
||||
;;
|
||||
iflow)
|
||||
update_agent_file "$IFLOW_FILE" "iFlow CLI" || return 1
|
||||
;;
|
||||
generic)
|
||||
log_info "Generic agent: no predefined context file. Use the agent-specific update script for your agent."
|
||||
;;
|
||||
*)
|
||||
log_error "Unknown agent type '$agent_type'"
|
||||
log_error "Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|generic"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Helper: skip non-existent files and files already updated (dedup by
|
||||
# realpath so that variables pointing to the same file — e.g. AMP_FILE,
|
||||
# KIRO_FILE, BOB_FILE all resolving to AGENTS_FILE — are only written once).
|
||||
# Uses a linear array instead of associative array for bash 3.2 compatibility.
|
||||
# Note: defined at top level because bash 3.2 does not support true
|
||||
# nested/local functions. _updated_paths, _found_agent, and _all_ok are
|
||||
# initialised exclusively inside update_all_existing_agents so that
|
||||
# sourcing this script has no side effects on the caller's environment.
|
||||
|
||||
_update_if_new() {
|
||||
local file="$1" name="$2"
|
||||
[[ -f $file ]] || return 0
|
||||
local real_path
|
||||
real_path=$(realpath "$file" 2>/dev/null || echo "$file")
|
||||
local p
|
||||
if [[ ${#_updated_paths[@]} -gt 0 ]]; then
|
||||
for p in "${_updated_paths[@]}"; do
|
||||
[[ $p == "$real_path" ]] && return 0
|
||||
done
|
||||
fi
|
||||
# Record the file as seen before attempting the update so that:
|
||||
# (a) aliases pointing to the same path are not retried on failure
|
||||
# (b) _found_agent reflects file existence, not update success
|
||||
_updated_paths+=("$real_path")
|
||||
_found_agent=true
|
||||
update_agent_file "$file" "$name"
|
||||
}
|
||||
|
||||
update_all_existing_agents() {
|
||||
_found_agent=false
|
||||
_updated_paths=()
|
||||
local _all_ok=true
|
||||
|
||||
_update_if_new "$CLAUDE_FILE" "Claude Code" || _all_ok=false
|
||||
_update_if_new "$GEMINI_FILE" "Gemini CLI" || _all_ok=false
|
||||
_update_if_new "$COPILOT_FILE" "GitHub Copilot" || _all_ok=false
|
||||
_update_if_new "$CURSOR_FILE" "Cursor IDE" || _all_ok=false
|
||||
_update_if_new "$QWEN_FILE" "Qwen Code" || _all_ok=false
|
||||
_update_if_new "$AGENTS_FILE" "Codex/opencode" || _all_ok=false
|
||||
_update_if_new "$AMP_FILE" "Amp" || _all_ok=false
|
||||
_update_if_new "$KIRO_FILE" "Kiro CLI" || _all_ok=false
|
||||
_update_if_new "$BOB_FILE" "IBM Bob" || _all_ok=false
|
||||
_update_if_new "$WINDSURF_FILE" "Windsurf" || _all_ok=false
|
||||
_update_if_new "$JUNIE_FILE" "Junie" || _all_ok=false
|
||||
_update_if_new "$KILOCODE_FILE" "Kilo Code" || _all_ok=false
|
||||
_update_if_new "$AUGGIE_FILE" "Auggie CLI" || _all_ok=false
|
||||
_update_if_new "$ROO_FILE" "Roo Code" || _all_ok=false
|
||||
_update_if_new "$CODEBUDDY_FILE" "CodeBuddy CLI" || _all_ok=false
|
||||
_update_if_new "$SHAI_FILE" "SHAI" || _all_ok=false
|
||||
_update_if_new "$TABNINE_FILE" "Tabnine CLI" || _all_ok=false
|
||||
_update_if_new "$QODER_FILE" "Qoder CLI" || _all_ok=false
|
||||
_update_if_new "$AGY_FILE" "Antigravity" || _all_ok=false
|
||||
_update_if_new "$VIBE_FILE" "Mistral Vibe" || _all_ok=false
|
||||
_update_if_new "$KIMI_FILE" "Kimi Code" || _all_ok=false
|
||||
_update_if_new "$TRAE_FILE" "Trae" || _all_ok=false
|
||||
_update_if_new "$IFLOW_FILE" "iFlow CLI" || _all_ok=false
|
||||
|
||||
# If no agent files exist, create a default Claude file
|
||||
if [[ $_found_agent == false ]]; then
|
||||
log_info "No existing agent files found, creating default Claude file..."
|
||||
update_agent_file "$CLAUDE_FILE" "Claude Code" || return 1
|
||||
fi
|
||||
|
||||
[[ $_all_ok == true ]]
|
||||
}
|
||||
print_summary() {
|
||||
echo
|
||||
log_info "Summary of changes:"
|
||||
|
||||
if [[ -n $NEW_LANG ]]; then
|
||||
echo " - Added language: $NEW_LANG"
|
||||
fi
|
||||
|
||||
if [[ -n $NEW_FRAMEWORK ]]; then
|
||||
echo " - Added framework: $NEW_FRAMEWORK"
|
||||
fi
|
||||
|
||||
if [[ -n $NEW_DB ]] && [[ $NEW_DB != "N/A" ]]; then
|
||||
echo " - Added database: $NEW_DB"
|
||||
fi
|
||||
|
||||
echo
|
||||
log_info "Usage: $0 [claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|generic]"
|
||||
}
|
||||
|
||||
#==============================================================================
|
||||
# Main Execution
|
||||
#==============================================================================
|
||||
|
||||
main() {
|
||||
# Validate environment before proceeding
|
||||
validate_environment
|
||||
|
||||
log_info "=== Updating agent context files for feature $CURRENT_BRANCH ==="
|
||||
|
||||
# Parse the plan file to extract project information
|
||||
if ! parse_plan_data "$NEW_PLAN"; then
|
||||
log_error "Failed to parse plan data"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Process based on agent type argument
|
||||
local success=true
|
||||
|
||||
if [[ -z $AGENT_TYPE ]]; then
|
||||
# No specific agent provided - update all existing agent files
|
||||
log_info "No agent specified, updating all existing agent files..."
|
||||
if ! update_all_existing_agents; then
|
||||
success=false
|
||||
fi
|
||||
else
|
||||
# Specific agent provided - update only that agent
|
||||
log_info "Updating specific agent: $AGENT_TYPE"
|
||||
if ! update_specific_agent "$AGENT_TYPE"; then
|
||||
success=false
|
||||
fi
|
||||
fi
|
||||
|
||||
# Print summary
|
||||
print_summary
|
||||
|
||||
if [[ $success == true ]]; then
|
||||
log_success "Agent context update completed successfully"
|
||||
exit 0
|
||||
else
|
||||
log_error "Agent context update completed with errors"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Execute main function if script is run directly
|
||||
if [[ ${BASH_SOURCE[0]} == "${0}" ]]; then
|
||||
main "$@"
|
||||
fi
|
||||
@@ -1,28 +0,0 @@
|
||||
# [PROJECT NAME] Development Guidelines
|
||||
|
||||
Auto-generated from all feature plans. Last updated: [DATE]
|
||||
|
||||
## Active Technologies
|
||||
|
||||
[EXTRACTED FROM ALL PLAN.MD FILES]
|
||||
|
||||
## Project Structure
|
||||
|
||||
```text
|
||||
[ACTUAL STRUCTURE FROM PLANS]
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
[ONLY COMMANDS FOR ACTIVE TECHNOLOGIES]
|
||||
|
||||
## Code Style
|
||||
|
||||
[LANGUAGE-SPECIFIC, ONLY FOR LANGUAGES IN USE]
|
||||
|
||||
## Recent Changes
|
||||
|
||||
[LAST 3 FEATURES AND WHAT THEY ADDED]
|
||||
|
||||
<!-- MANUAL ADDITIONS START -->
|
||||
<!-- MANUAL ADDITIONS END -->
|
||||
@@ -1,40 +0,0 @@
|
||||
# [CHECKLIST TYPE] Checklist: [FEATURE NAME]
|
||||
|
||||
**Purpose**: [Brief description of what this checklist covers]
|
||||
**Created**: [DATE]
|
||||
**Feature**: [Link to spec.md or relevant documentation]
|
||||
|
||||
**Note**: This checklist is generated by the `/speckit.checklist` command based on feature context and requirements.
|
||||
|
||||
<!--
|
||||
============================================================================
|
||||
IMPORTANT: The checklist items below are SAMPLE ITEMS for illustration only.
|
||||
|
||||
The /speckit.checklist command MUST replace these with actual items based on:
|
||||
- User's specific checklist request
|
||||
- Feature requirements from spec.md
|
||||
- Technical context from plan.md
|
||||
- Implementation details from tasks.md
|
||||
|
||||
DO NOT keep these sample items in the generated checklist file.
|
||||
============================================================================
|
||||
-->
|
||||
|
||||
## [Category 1]
|
||||
|
||||
- [ ] CHK001 First checklist item with clear action
|
||||
- [ ] CHK002 Second checklist item
|
||||
- [ ] CHK003 Third checklist item
|
||||
|
||||
## [Category 2]
|
||||
|
||||
- [ ] CHK004 Another category item
|
||||
- [ ] CHK005 Item with specific criteria
|
||||
- [ ] CHK006 Final item in this category
|
||||
|
||||
## Notes
|
||||
|
||||
- Check items off as completed: `[x]`
|
||||
- Add comments or findings inline
|
||||
- Link to relevant resources or documentation
|
||||
- Items are numbered sequentially for easy reference
|
||||
@@ -1,73 +0,0 @@
|
||||
# [PROJECT_NAME] Constitution
|
||||
|
||||
<!-- Example: Spec Constitution, TaskFlow Constitution, etc. -->
|
||||
|
||||
## Core Principles
|
||||
|
||||
### [PRINCIPLE_1_NAME]
|
||||
|
||||
<!-- Example: I. Library-First -->
|
||||
|
||||
[PRINCIPLE_1_DESCRIPTION]
|
||||
|
||||
<!-- Example: Every feature starts as a standalone library; Libraries must be self-contained, independently testable, documented; Clear purpose required - no organizational-only libraries -->
|
||||
|
||||
### [PRINCIPLE_2_NAME]
|
||||
|
||||
<!-- Example: II. CLI Interface -->
|
||||
|
||||
[PRINCIPLE_2_DESCRIPTION]
|
||||
|
||||
<!-- Example: Every library exposes functionality via CLI; Text in/out protocol: stdin/args → stdout, errors → stderr; Support JSON + human-readable formats -->
|
||||
|
||||
### [PRINCIPLE_3_NAME]
|
||||
|
||||
<!-- Example: III. Test-First (NON-NEGOTIABLE) -->
|
||||
|
||||
[PRINCIPLE_3_DESCRIPTION]
|
||||
|
||||
<!-- Example: TDD mandatory: Tests written → User approved → Tests fail → Then implement; Red-Green-Refactor cycle strictly enforced -->
|
||||
|
||||
### [PRINCIPLE_4_NAME]
|
||||
|
||||
<!-- Example: IV. Integration Testing -->
|
||||
|
||||
[PRINCIPLE_4_DESCRIPTION]
|
||||
|
||||
<!-- Example: Focus areas requiring integration tests: New library contract tests, Contract changes, Inter-service communication, Shared schemas -->
|
||||
|
||||
### [PRINCIPLE_5_NAME]
|
||||
|
||||
<!-- Example: V. Observability, VI. Versioning & Breaking Changes, VII. Simplicity -->
|
||||
|
||||
[PRINCIPLE_5_DESCRIPTION]
|
||||
|
||||
<!-- Example: Text I/O ensures debuggability; Structured logging required; Or: MAJOR.MINOR.BUILD format; Or: Start simple, YAGNI principles -->
|
||||
|
||||
## [SECTION_2_NAME]
|
||||
|
||||
<!-- Example: Additional Constraints, Security Requirements, Performance Standards, etc. -->
|
||||
|
||||
[SECTION_2_CONTENT]
|
||||
|
||||
<!-- Example: Technology stack requirements, compliance standards, deployment policies, etc. -->
|
||||
|
||||
## [SECTION_3_NAME]
|
||||
|
||||
<!-- Example: Development Workflow, Review Process, Quality Gates, etc. -->
|
||||
|
||||
[SECTION_3_CONTENT]
|
||||
|
||||
<!-- Example: Code review requirements, testing gates, deployment approval process, etc. -->
|
||||
|
||||
## Governance
|
||||
|
||||
<!-- Example: Constitution supersedes all other practices; Amendments require documentation, approval, migration plan -->
|
||||
|
||||
[GOVERNANCE_RULES]
|
||||
|
||||
<!-- Example: All PRs/reviews must verify compliance; Complexity must be justified; Use [GUIDANCE_FILE] for runtime development guidance -->
|
||||
|
||||
**Version**: [CONSTITUTION_VERSION] | **Ratified**: [RATIFICATION_DATE] | **Last Amended**: [LAST_AMENDED_DATE]
|
||||
|
||||
<!-- Example: Version: 2.1.1 | Ratified: 2025-06-13 | Last Amended: 2025-07-16 -->
|
||||
@@ -1,109 +0,0 @@
|
||||
# Implementation Plan: [FEATURE]
|
||||
|
||||
**Branch**: `[###-feature-name]` | **Date**: [DATE] | **Spec**: [link]
|
||||
**Input**: Feature specification from `/specs/[###-feature-name]/spec.md`
|
||||
|
||||
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/templates/plan-template.md` for the execution workflow.
|
||||
|
||||
## Summary
|
||||
|
||||
[Extract from feature spec: primary requirement + technical approach from research]
|
||||
|
||||
## Technical Context
|
||||
|
||||
<!--
|
||||
ACTION REQUIRED: Replace the content in this section with the technical details
|
||||
for the project. The structure here is presented in advisory capacity to guide
|
||||
the iteration process.
|
||||
-->
|
||||
|
||||
**Language/Version**: [e.g., Python 3.11, Swift 5.9, Rust 1.75 or NEEDS CLARIFICATION]
|
||||
**Primary Dependencies**: [e.g., FastAPI, UIKit, LLVM or NEEDS CLARIFICATION]
|
||||
**Storage**: [if applicable, e.g., PostgreSQL, CoreData, files or N/A]
|
||||
**Testing**: [e.g., pytest, XCTest, cargo test or NEEDS CLARIFICATION]
|
||||
**Target Platform**: [e.g., Linux server, iOS 15+, WASM or NEEDS CLARIFICATION]
|
||||
**Project Type**: [e.g., library/cli/web-service/mobile-app/compiler/desktop-app or NEEDS CLARIFICATION]
|
||||
**Performance Goals**: [domain-specific, e.g., 1000 req/s, 10k lines/sec, 60 fps or NEEDS CLARIFICATION]
|
||||
**Constraints**: [domain-specific, e.g., <200ms p95, <100MB memory, offline-capable or NEEDS CLARIFICATION]
|
||||
**Scale/Scope**: [domain-specific, e.g., 10k users, 1M LOC, 50 screens or NEEDS CLARIFICATION]
|
||||
|
||||
## Constitution Check
|
||||
|
||||
_GATE: Must pass before Phase 0 research. Re-check after Phase 1 design._
|
||||
|
||||
- Safety-critical mesh impact is identified for any routing, airtime, MQTT, channel, or packet-path change.
|
||||
- Variant and platform scope are explicit, and all hardware flags or pin mappings are verified against the target board.
|
||||
- Validation evidence is defined, including the exact `pio`, native test, simulator, formatting, or static checks to run.
|
||||
- Resource, power, memory, and dependency impact are assessed for the affected targets.
|
||||
- Any constitutional violation or validation gap is documented with justification in Complexity Tracking.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/[###-feature]/
|
||||
├── plan.md # This file (/speckit.plan command output)
|
||||
├── research.md # Phase 0 output (/speckit.plan command)
|
||||
├── data-model.md # Phase 1 output (/speckit.plan command)
|
||||
├── quickstart.md # Phase 1 output (/speckit.plan command)
|
||||
├── contracts/ # Phase 1 output (/speckit.plan command)
|
||||
└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan)
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
<!--
|
||||
ACTION REQUIRED: Replace the placeholder tree below with the concrete layout
|
||||
for this feature. Delete unused options and expand the chosen structure with
|
||||
real paths (e.g., apps/admin, packages/something). The delivered plan must
|
||||
not include Option labels.
|
||||
-->
|
||||
|
||||
```text
|
||||
# [REMOVE IF UNUSED] Option 1: Single project (DEFAULT)
|
||||
src/
|
||||
├── models/
|
||||
├── services/
|
||||
├── cli/
|
||||
└── lib/
|
||||
|
||||
tests/
|
||||
├── contract/
|
||||
├── integration/
|
||||
└── unit/
|
||||
|
||||
# [REMOVE IF UNUSED] Option 2: Web application (when "frontend" + "backend" detected)
|
||||
backend/
|
||||
├── src/
|
||||
│ ├── models/
|
||||
│ ├── services/
|
||||
│ └── api/
|
||||
└── tests/
|
||||
|
||||
frontend/
|
||||
├── src/
|
||||
│ ├── components/
|
||||
│ ├── pages/
|
||||
│ └── services/
|
||||
└── tests/
|
||||
|
||||
# [REMOVE IF UNUSED] Option 3: Mobile + API (when "iOS/Android" detected)
|
||||
api/
|
||||
└── [same as backend above]
|
||||
|
||||
ios/ or android/
|
||||
└── [platform-specific structure: feature modules, UI flows, platform tests]
|
||||
```
|
||||
|
||||
**Structure Decision**: [Document the selected structure and reference the real
|
||||
directories captured above]
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
> **Fill ONLY if Constitution Check has violations that must be justified**
|
||||
|
||||
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||
| -------------------------- | ------------------ | ------------------------------------ |
|
||||
| [e.g., 4th project] | [current need] | [why 3 projects insufficient] |
|
||||
| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] |
|
||||
@@ -1,140 +0,0 @@
|
||||
# Feature Specification: [FEATURE NAME]
|
||||
|
||||
**Feature Branch**: `[###-feature-name]`
|
||||
**Created**: [DATE]
|
||||
**Status**: Draft
|
||||
**Input**: User description: "$ARGUMENTS"
|
||||
|
||||
## User Scenarios & Testing _(mandatory)_
|
||||
|
||||
<!--
|
||||
IMPORTANT: User stories should be PRIORITIZED as user journeys ordered by importance.
|
||||
Each user story/journey must be INDEPENDENTLY TESTABLE - meaning if you implement just ONE of them,
|
||||
you should still have a viable MVP (Minimum Viable Product) that delivers value.
|
||||
|
||||
Assign priorities (P1, P2, P3, etc.) to each story, where P1 is the most critical.
|
||||
Think of each story as a standalone slice of functionality that can be:
|
||||
- Developed independently
|
||||
- Tested independently
|
||||
- Deployed independently
|
||||
- Demonstrated to users independently
|
||||
-->
|
||||
|
||||
### User Story 1 - [Brief Title] (Priority: P1)
|
||||
|
||||
[Describe this user journey in plain language]
|
||||
|
||||
**Why this priority**: [Explain the value and why it has this priority level]
|
||||
|
||||
**Independent Test**: [Describe how this can be tested independently - e.g., "Can be fully tested by [specific action] and delivers [specific value]"]
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** [initial state], **When** [action], **Then** [expected outcome]
|
||||
2. **Given** [initial state], **When** [action], **Then** [expected outcome]
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - [Brief Title] (Priority: P2)
|
||||
|
||||
[Describe this user journey in plain language]
|
||||
|
||||
**Why this priority**: [Explain the value and why it has this priority level]
|
||||
|
||||
**Independent Test**: [Describe how this can be tested independently]
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** [initial state], **When** [action], **Then** [expected outcome]
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - [Brief Title] (Priority: P3)
|
||||
|
||||
[Describe this user journey in plain language]
|
||||
|
||||
**Why this priority**: [Explain the value and why it has this priority level]
|
||||
|
||||
**Independent Test**: [Describe how this can be tested independently]
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** [initial state], **When** [action], **Then** [expected outcome]
|
||||
|
||||
---
|
||||
|
||||
[Add more user stories as needed, each with an assigned priority]
|
||||
|
||||
For firmware and hardware-facing work, each story MUST identify affected platforms or variants
|
||||
and describe how the behavior is validated on its own.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
<!--
|
||||
ACTION REQUIRED: The content in this section represents placeholders.
|
||||
Fill them out with the right edge cases.
|
||||
-->
|
||||
|
||||
- What happens when [boundary condition]?
|
||||
- How does system handle [error scenario]?
|
||||
- What happens when the target hardware capability is absent, misdeclared, or only present on some variants?
|
||||
- How does the system behave when radio, power, timing, or memory constraints are tighter than expected?
|
||||
|
||||
## Requirements _(mandatory)_
|
||||
|
||||
<!--
|
||||
ACTION REQUIRED: The content in this section represents placeholders.
|
||||
Fill them out with the right functional requirements.
|
||||
-->
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: System MUST [specific capability, e.g., "allow users to create accounts"]
|
||||
- **FR-002**: System MUST [specific capability, e.g., "validate email addresses"]
|
||||
- **FR-003**: Users MUST be able to [key interaction, e.g., "reset their password"]
|
||||
- **FR-004**: System MUST [data requirement, e.g., "persist user preferences"]
|
||||
- **FR-005**: System MUST [behavior, e.g., "log all security events"]
|
||||
|
||||
_Example of marking unclear requirements:_
|
||||
|
||||
- **FR-006**: System MUST authenticate users via [NEEDS CLARIFICATION: auth method not specified - email/password, SSO, OAuth?]
|
||||
- **FR-007**: System MUST retain user data for [NEEDS CLARIFICATION: retention period not specified]
|
||||
|
||||
Where relevant, requirements MUST also state:
|
||||
|
||||
- affected architectures, boards, or modules
|
||||
- whether behavior changes public defaults, protocol compatibility, or generated artifacts
|
||||
- any required validation evidence for high-risk mesh, hardware, or power behavior
|
||||
|
||||
### Key Entities _(include if feature involves data)_
|
||||
|
||||
- **[Entity 1]**: [What it represents, key attributes without implementation]
|
||||
- **[Entity 2]**: [What it represents, relationships to other entities]
|
||||
|
||||
## Success Criteria _(mandatory)_
|
||||
|
||||
<!--
|
||||
ACTION REQUIRED: Define measurable success criteria.
|
||||
These must be technology-agnostic and measurable.
|
||||
-->
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: [Measurable metric, e.g., "Users can complete account creation in under 2 minutes"]
|
||||
- **SC-002**: [Measurable metric, e.g., "System handles 1000 concurrent users without degradation"]
|
||||
- **SC-003**: [User satisfaction metric, e.g., "90% of users successfully complete primary task on first attempt"]
|
||||
- **SC-004**: [Business metric, e.g., "Reduce support tickets related to [X] by 50%"]
|
||||
|
||||
## Assumptions
|
||||
|
||||
<!--
|
||||
ACTION REQUIRED: The content in this section represents placeholders.
|
||||
Fill them out with the right assumptions based on reasonable defaults
|
||||
chosen when the feature description did not specify certain details.
|
||||
-->
|
||||
|
||||
- [Assumption about target users, e.g., "Users have stable internet connectivity"]
|
||||
- [Assumption about scope boundaries, e.g., "Mobile support is out of scope for v1"]
|
||||
- [Assumption about data/environment, e.g., "Existing authentication system will be reused"]
|
||||
- [Dependency on existing system/service, e.g., "Requires access to the existing user profile API"]
|
||||
- [Assumption about available hardware capabilities, board revisions, or build targets]
|
||||
@@ -1,259 +0,0 @@
|
||||
---
|
||||
description: "Task list template for feature implementation"
|
||||
---
|
||||
|
||||
# Tasks: [FEATURE NAME]
|
||||
|
||||
**Input**: Design documents from `/specs/[###-feature-name]/`
|
||||
**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/
|
||||
|
||||
**Tests**: Validation tasks are REQUIRED whenever the constitution or feature risk profile demands evidence. Include targeted builds, native tests, simulator runs, protobuf regeneration checks, formatting, or other repository-standard validation appropriate to the change.
|
||||
|
||||
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
|
||||
|
||||
## Format: `[ID] [P?] [Story] Description`
|
||||
|
||||
- **[P]**: Can run in parallel (different files, no dependencies)
|
||||
- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3)
|
||||
- Include exact file paths in descriptions
|
||||
|
||||
## Path Conventions
|
||||
|
||||
- **Single project**: `src/`, `tests/` at repository root
|
||||
- **Web app**: `backend/src/`, `frontend/src/`
|
||||
- **Mobile**: `api/src/`, `ios/src/` or `android/src/`
|
||||
- Paths shown below assume single project - adjust based on plan.md structure
|
||||
|
||||
<!--
|
||||
============================================================================
|
||||
IMPORTANT: The tasks below are SAMPLE TASKS for illustration purposes only.
|
||||
|
||||
The /speckit.tasks command MUST replace these with actual tasks based on:
|
||||
- User stories from spec.md (with their priorities P1, P2, P3...)
|
||||
- Feature requirements from plan.md
|
||||
- Entities from data-model.md
|
||||
- Endpoints from contracts/
|
||||
|
||||
Tasks MUST be organized by user story so each story can be:
|
||||
- Implemented independently
|
||||
- Tested independently
|
||||
- Delivered as an MVP increment
|
||||
|
||||
DO NOT keep these sample tasks in the generated tasks.md file.
|
||||
============================================================================
|
||||
-->
|
||||
|
||||
## Phase 1: Setup (Shared Infrastructure)
|
||||
|
||||
**Purpose**: Project initialization and basic structure
|
||||
|
||||
- [ ] T001 Create project structure per implementation plan
|
||||
- [ ] T002 Initialize [language] project with [framework] dependencies
|
||||
- [ ] T003 [P] Configure linting and formatting tools
|
||||
- [ ] T00X Identify affected targets, variants, and validation commands
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Core infrastructure that MUST be complete before ANY user story can be implemented
|
||||
|
||||
**⚠️ CRITICAL**: No user story work can begin until this phase is complete
|
||||
|
||||
Examples of foundational tasks (adjust based on your project):
|
||||
|
||||
- [ ] T004 Setup database schema and migrations framework
|
||||
- [ ] T005 [P] Implement authentication/authorization framework
|
||||
- [ ] T006 [P] Setup API routing and middleware structure
|
||||
- [ ] T007 Create base models/entities that all stories depend on
|
||||
- [ ] T008 Configure error handling and logging infrastructure
|
||||
- [ ] T009 Setup environment configuration management
|
||||
- [ ] T00X Verify board-specific flags, pins, generated assets, or protocol prerequisites needed by all stories
|
||||
|
||||
**Checkpoint**: Foundation ready - user story implementation can now begin in parallel
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 - [Title] (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: [Brief description of what this story delivers]
|
||||
|
||||
**Independent Test**: [How to verify this story works on its own]
|
||||
|
||||
### Validation for User Story 1 ⚠️
|
||||
|
||||
> **NOTE: Add the smallest credible validation for the story's risk level before declaring it complete**
|
||||
|
||||
- [ ] T010 [P] [US1] Contract test for [endpoint] in tests/contract/test\_[name].py
|
||||
- [ ] T011 [P] [US1] Integration test for [user journey] in tests/integration/test\_[name].py
|
||||
- [ ] T01X [P] [US1] Run targeted build or simulation for affected platform(s)
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [ ] T012 [P] [US1] Create [Entity1] model in src/models/[entity1].py
|
||||
- [ ] T013 [P] [US1] Create [Entity2] model in src/models/[entity2].py
|
||||
- [ ] T014 [US1] Implement [Service] in src/services/[service].py (depends on T012, T013)
|
||||
- [ ] T015 [US1] Implement [endpoint/feature] in src/[location]/[file].py
|
||||
- [ ] T016 [US1] Add validation and error handling
|
||||
- [ ] T017 [US1] Add logging for user story 1 operations
|
||||
- [ ] T01Y [US1] Confirm variant-scoped behavior and configuration defaults remain correct
|
||||
|
||||
**Checkpoint**: At this point, User Story 1 should be fully functional and testable independently
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 - [Title] (Priority: P2)
|
||||
|
||||
**Goal**: [Brief description of what this story delivers]
|
||||
|
||||
**Independent Test**: [How to verify this story works on its own]
|
||||
|
||||
### Validation for User Story 2 ⚠️
|
||||
|
||||
- [ ] T018 [P] [US2] Contract test for [endpoint] in tests/contract/test\_[name].py
|
||||
- [ ] T019 [P] [US2] Integration test for [user journey] in tests/integration/test\_[name].py
|
||||
- [ ] T02X [P] [US2] Run targeted build or simulation for affected platform(s)
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [ ] T020 [P] [US2] Create [Entity] model in src/models/[entity].py
|
||||
- [ ] T021 [US2] Implement [Service] in src/services/[service].py
|
||||
- [ ] T022 [US2] Implement [endpoint/feature] in src/[location]/[file].py
|
||||
- [ ] T023 [US2] Integrate with User Story 1 components (if needed)
|
||||
- [ ] T02Y [US2] Confirm resource, power, and compatibility impacts stay within plan constraints
|
||||
|
||||
**Checkpoint**: At this point, User Stories 1 AND 2 should both work independently
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 - [Title] (Priority: P3)
|
||||
|
||||
**Goal**: [Brief description of what this story delivers]
|
||||
|
||||
**Independent Test**: [How to verify this story works on its own]
|
||||
|
||||
### Validation for User Story 3 ⚠️
|
||||
|
||||
- [ ] T024 [P] [US3] Contract test for [endpoint] in tests/contract/test\_[name].py
|
||||
- [ ] T025 [P] [US3] Integration test for [user journey] in tests/integration/test\_[name].py
|
||||
- [ ] T03X [P] [US3] Run targeted build or simulation for affected platform(s)
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [ ] T026 [P] [US3] Create [Entity] model in src/models/[entity].py
|
||||
- [ ] T027 [US3] Implement [Service] in src/services/[service].py
|
||||
- [ ] T028 [US3] Implement [endpoint/feature] in src/[location]/[file].py
|
||||
- [ ] T03Y [US3] Confirm backward compatibility or explicitly document intentional behavior changes
|
||||
|
||||
**Checkpoint**: All user stories should now be independently functional
|
||||
|
||||
---
|
||||
|
||||
[Add more user story phases as needed, following the same pattern]
|
||||
|
||||
---
|
||||
|
||||
## Phase N: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Improvements that affect multiple user stories
|
||||
|
||||
- [ ] TXXX [P] Documentation updates in docs/
|
||||
- [ ] TXXX Code cleanup and refactoring
|
||||
- [ ] TXXX Performance optimization across all stories
|
||||
- [ ] TXXX [P] Additional unit tests (if requested) in tests/unit/
|
||||
- [ ] TXXX Security hardening
|
||||
- [ ] TXXX Run quickstart.md validation
|
||||
- [ ] TXXX Summarize skipped validations, migration notes, and generated-file updates for review
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Setup (Phase 1)**: No dependencies - can start immediately
|
||||
- **Foundational (Phase 2)**: Depends on Setup completion - BLOCKS all user stories
|
||||
- **User Stories (Phase 3+)**: All depend on Foundational phase completion
|
||||
- User stories can then proceed in parallel (if staffed)
|
||||
- Or sequentially in priority order (P1 → P2 → P3)
|
||||
- **Polish (Final Phase)**: Depends on all desired user stories being complete
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **User Story 1 (P1)**: Can start after Foundational (Phase 2) - No dependencies on other stories
|
||||
- **User Story 2 (P2)**: Can start after Foundational (Phase 2) - May integrate with US1 but should be independently testable
|
||||
- **User Story 3 (P3)**: Can start after Foundational (Phase 2) - May integrate with US1/US2 but should be independently testable
|
||||
|
||||
### Within Each User Story
|
||||
|
||||
- Tests (if included) MUST be written and FAIL before implementation
|
||||
- Models before services
|
||||
- Services before endpoints
|
||||
- Core implementation before integration
|
||||
- Story complete before moving to next priority
|
||||
|
||||
### Parallel Opportunities
|
||||
|
||||
- All Setup tasks marked [P] can run in parallel
|
||||
- All Foundational tasks marked [P] can run in parallel (within Phase 2)
|
||||
- Once Foundational phase completes, all user stories can start in parallel (if team capacity allows)
|
||||
- All tests for a user story marked [P] can run in parallel
|
||||
- Models within a story marked [P] can run in parallel
|
||||
- Different user stories can be worked on in parallel by different team members
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: User Story 1
|
||||
|
||||
```bash
|
||||
# Launch all tests for User Story 1 together (if tests requested):
|
||||
Task: "Contract test for [endpoint] in tests/contract/test_[name].py"
|
||||
Task: "Integration test for [user journey] in tests/integration/test_[name].py"
|
||||
|
||||
# Launch all models for User Story 1 together:
|
||||
Task: "Create [Entity1] model in src/models/[entity1].py"
|
||||
Task: "Create [Entity2] model in src/models/[entity2].py"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First (User Story 1 Only)
|
||||
|
||||
1. Complete Phase 1: Setup
|
||||
2. Complete Phase 2: Foundational (CRITICAL - blocks all stories)
|
||||
3. Complete Phase 3: User Story 1
|
||||
4. **STOP and VALIDATE**: Test User Story 1 independently
|
||||
5. Deploy/demo if ready
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Complete Setup + Foundational → Foundation ready
|
||||
2. Add User Story 1 → Test independently → Deploy/Demo (MVP!)
|
||||
3. Add User Story 2 → Test independently → Deploy/Demo
|
||||
4. Add User Story 3 → Test independently → Deploy/Demo
|
||||
5. Each story adds value without breaking previous stories
|
||||
|
||||
### Parallel Team Strategy
|
||||
|
||||
With multiple developers:
|
||||
|
||||
1. Team completes Setup + Foundational together
|
||||
2. Once Foundational is done:
|
||||
- Developer A: User Story 1
|
||||
- Developer B: User Story 2
|
||||
- Developer C: User Story 3
|
||||
3. Stories complete and integrate independently
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- [P] tasks = different files, no dependencies
|
||||
- [Story] label maps task to specific user story for traceability
|
||||
- Each user story should be independently completable and testable
|
||||
- Verify tests fail before implementing
|
||||
- Commit after each task or logical group
|
||||
- Stop at any checkpoint to validate story independently
|
||||
- Avoid: vague tasks, same file conflicts, cross-story dependencies that break independence
|
||||
+2
-2
@@ -4,11 +4,11 @@ cli:
|
||||
plugins:
|
||||
sources:
|
||||
- id: trunk
|
||||
ref: v1.8.0
|
||||
ref: v1.9.0
|
||||
uri: https://github.com/trunk-io/plugins
|
||||
lint:
|
||||
enabled:
|
||||
- checkov@3.2.526
|
||||
- checkov@3.2.528
|
||||
- renovate@43.150.0
|
||||
- prettier@3.8.3
|
||||
- trufflehog@3.95.2
|
||||
|
||||
Vendored
-11
@@ -10,16 +10,5 @@
|
||||
},
|
||||
"[powershell]": {
|
||||
"editor.defaultFormatter": "ms-vscode.powershell"
|
||||
},
|
||||
"chat.promptFilesRecommendations": {
|
||||
"speckit.constitution": true,
|
||||
"speckit.specify": true,
|
||||
"speckit.plan": true,
|
||||
"speckit.tasks": true,
|
||||
"speckit.implement": true
|
||||
},
|
||||
"chat.tools.terminal.autoApprove": {
|
||||
".specify/scripts/bash/": true,
|
||||
".specify/scripts/powershell/": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +66,8 @@ Key rotation to never trigger casually: only the **full** factory reset (`factor
|
||||
- **Don't speculate about firmware root causes.** When evidence doesn't support a classification, say "unknown" and list what would disambiguate.
|
||||
- **Run `trunk fmt` before proposing a commit.** The `trunk_check` CI gate will reject unformatted code.
|
||||
- **`confirm=True` on destructive MCP tools is a real gate, not a formality.** Don't bypass it via auto-approve settings.
|
||||
- **Keep code comments minimal — one or two lines, max.** Comment only when the _why_ isn't obvious from the code; never restate what the next line does. No multi-paragraph block comments explaining straightforward changes. The diff and commit message carry the rationale; the code carries the behavior.
|
||||
- **Use `Throttle` for time-based rate limiting, not raw `millis()` math.** `src/mesh/Throttle.h` provides `Throttle::isWithinTimespanMs(lastMs, intervalMs)` (returns true while inside the cooldown) and `Throttle::execute(&lastMs, intervalMs, func)` (function-pointer form that updates the timestamp on fire). Use these for any "did N ms pass since X" check — raw `millis() > lastMs + N` is rollover-unsafe (breaks after ~49.7 days) and inconsistent with the rest of the codebase. The helpers compute `now - lastMs` with unsigned subtraction, which wraps correctly.
|
||||
|
||||
## Typical agent workflows
|
||||
|
||||
|
||||
@@ -1,628 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Board intake assessment for new Meshtastic hardware support.
|
||||
|
||||
Validates a board intake request against the repository hardware context,
|
||||
identifies evidence gaps, and produces a structured readiness report.
|
||||
|
||||
Usage:
|
||||
python3 bin/board_intake.py <intake.json>
|
||||
python3 bin/board_intake.py <intake.json> --output report.md
|
||||
python3 bin/board_intake.py <intake.json> --validate # gaps check only, exit 1 if not scaffold_ready
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DEFAULT_CONTEXT_PATH = ROOT / "docs" / "hardware-support-context.md"
|
||||
|
||||
# Metadata keys every new PlatformIO environment should declare.
|
||||
REQUIRED_METADATA_KEYS = [
|
||||
"custom_meshtastic_hw_model",
|
||||
"custom_meshtastic_hw_model_slug",
|
||||
"custom_meshtastic_architecture",
|
||||
"custom_meshtastic_actively_supported",
|
||||
"custom_meshtastic_support_level",
|
||||
"custom_meshtastic_display_name",
|
||||
]
|
||||
|
||||
RECOMMENDED_METADATA_KEYS = [
|
||||
"custom_meshtastic_images",
|
||||
"custom_meshtastic_tags",
|
||||
"custom_meshtastic_requires_dfu",
|
||||
"custom_meshtastic_partition_scheme",
|
||||
]
|
||||
|
||||
# Pin groups that should be backed by evidence before scaffolding.
|
||||
EVIDENCE_CATEGORIES = ["radio", "display", "input", "GPS", "power"]
|
||||
|
||||
# Architecture families that rely on BSP defaults for many pin defines.
|
||||
BSP_DEFAULT_FAMILIES = {"nrf52840", "rp2040", "stm32", "native"}
|
||||
|
||||
# Known valid architectures from the repository.
|
||||
KNOWN_ARCHITECTURES = {
|
||||
"esp32",
|
||||
"esp32-s3",
|
||||
"esp32-c3",
|
||||
"esp32-c6",
|
||||
"esp32s2",
|
||||
"nrf52840",
|
||||
"rp2040",
|
||||
"rp2350",
|
||||
"stm32",
|
||||
"native",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T004 – BoardIntakeRequest dataclass
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class BoardIntakeRequest:
|
||||
"""Maintainer-supplied description of a proposed new board."""
|
||||
|
||||
# Required
|
||||
environment_name: str
|
||||
hardware_model: str
|
||||
display_name: str
|
||||
architecture: str
|
||||
|
||||
# Recommended
|
||||
hardware_model_slug: str = ""
|
||||
actively_supported: bool | None = None
|
||||
support_level: str = ""
|
||||
source_materials: list[str] = field(default_factory=list)
|
||||
board_notes: str = ""
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "BoardIntakeRequest":
|
||||
return cls(
|
||||
environment_name=data.get("environment_name", ""),
|
||||
hardware_model=str(data.get("hardware_model", "")),
|
||||
display_name=data.get("display_name", ""),
|
||||
architecture=data.get("architecture", ""),
|
||||
hardware_model_slug=data.get("hardware_model_slug", ""),
|
||||
actively_supported=data.get("actively_supported"),
|
||||
support_level=str(data.get("support_level", "")),
|
||||
source_materials=list(data.get("source_materials", [])),
|
||||
board_notes=data.get("board_notes", ""),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, path: Path) -> "BoardIntakeRequest":
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
return cls.from_dict(data)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T005 – EvidenceGap dataclass
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class EvidenceGap:
|
||||
"""A specific missing, conflicting, or ambiguous hardware fact."""
|
||||
|
||||
category: str # metadata | radio | display | input | GPS | power | storage | connectivity | revision-scope
|
||||
description: str
|
||||
affected_artifact: str
|
||||
required_evidence: str
|
||||
blocking: bool
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T006 – IntakeAssessment dataclass
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class IntakeAssessment:
|
||||
"""Structured result of evaluating a BoardIntakeRequest."""
|
||||
|
||||
request: BoardIntakeRequest
|
||||
expected_artifacts: list[str]
|
||||
required_metadata: list[str]
|
||||
matched_patterns: list[dict]
|
||||
evidence_gaps: list[EvidenceGap]
|
||||
risk_flags: list[str]
|
||||
next_actions: list[str]
|
||||
scaffold_ready: bool
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T007 – load_hardware_context
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def load_hardware_context(path: Path = DEFAULT_CONTEXT_PATH) -> dict:
|
||||
"""Parse the generated hardware-support-context.md into a usable dict.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"architecture_names": list[str], # families present in the inventory
|
||||
"metadata_keys": list[str], # custom_meshtastic_* keys observed
|
||||
"environments": dict[str, dict], # env_name -> {display, hw_model, hw_slug, variant_dir, arch}
|
||||
}
|
||||
"""
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(
|
||||
f"Hardware context not found at {path}. "
|
||||
"Run: python3 bin/generate_hardware_support_context.py"
|
||||
)
|
||||
|
||||
text = path.read_text(encoding="utf-8")
|
||||
|
||||
# Extract metadata keys from the "## Repository Metadata Inputs" section.
|
||||
metadata_keys: list[str] = re.findall(r"`(custom_meshtastic_[^`]+)`", text)
|
||||
metadata_keys = list(dict.fromkeys(metadata_keys)) # deduplicate, preserve order
|
||||
|
||||
# Extract architecture families from the "## Architecture and Environment Inventory" section.
|
||||
arch_names: list[str] = re.findall(r"^### ([a-z0-9\-]+)\s*$", text, re.MULTILINE)
|
||||
# Filter out sub-headings that are architecture names (exclude e.g. "nrf52840" inside examples)
|
||||
# The inventory section has short arch names; filter to known set plus any that look like archs.
|
||||
arch_names = [a for a in dict.fromkeys(arch_names) if not a[0].isupper()]
|
||||
|
||||
# Extract environments from inventory table rows.
|
||||
environments: dict[str, dict] = {}
|
||||
current_arch = ""
|
||||
for line in text.splitlines():
|
||||
arch_match = re.match(r"^### ([a-z0-9\-]+)\s*$", line)
|
||||
if arch_match:
|
||||
current_arch = arch_match.group(1)
|
||||
continue
|
||||
# Table row: | env | display | hw_model | hw_slug | variant_dir | categories |
|
||||
row = re.match(
|
||||
r"^\|\s*([^|]+?)\s*\|\s*([^|]*?)\s*\|\s*([^|]*?)\s*\|\s*([^|]*?)\s*\|\s*([^|]*?)\s*\|",
|
||||
line,
|
||||
)
|
||||
if (
|
||||
row
|
||||
and not row.group(1).startswith("Environment")
|
||||
and not row.group(1).startswith("---")
|
||||
):
|
||||
env_name = row.group(1).strip()
|
||||
if env_name:
|
||||
environments[env_name] = {
|
||||
"display_name": row.group(2).strip(),
|
||||
"hw_model": row.group(3).strip(),
|
||||
"hw_slug": row.group(4).strip(),
|
||||
"variant_dir": row.group(5).strip(),
|
||||
"architecture": current_arch,
|
||||
}
|
||||
|
||||
return {
|
||||
"architecture_names": arch_names,
|
||||
"metadata_keys": metadata_keys,
|
||||
"environments": environments,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T018 – validate_intake
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def validate_intake(request: BoardIntakeRequest, context: dict) -> list[str]:
|
||||
"""Check required fields and detect conflicts with existing environments/models.
|
||||
|
||||
Returns a list of validation error strings (empty = valid).
|
||||
"""
|
||||
errors: list[str] = []
|
||||
|
||||
if not request.environment_name:
|
||||
errors.append("environment_name is required.")
|
||||
if not request.hardware_model:
|
||||
errors.append("hardware_model is required.")
|
||||
if not request.display_name:
|
||||
errors.append("display_name is required.")
|
||||
if not request.architecture:
|
||||
errors.append("architecture is required.")
|
||||
|
||||
if request.architecture and request.architecture not in KNOWN_ARCHITECTURES:
|
||||
errors.append(
|
||||
f"architecture '{request.architecture}' is not a known repository architecture. "
|
||||
f"Known: {', '.join(sorted(KNOWN_ARCHITECTURES))}"
|
||||
)
|
||||
|
||||
# Conflict: environment name already exists
|
||||
if request.environment_name and request.environment_name in context.get(
|
||||
"environments", {}
|
||||
):
|
||||
errors.append(
|
||||
f"environment_name '{request.environment_name}' already exists in the repository. "
|
||||
"Choose a unique name or confirm this is an intentional update."
|
||||
)
|
||||
|
||||
# Conflict: hardware model already assigned to a different environment
|
||||
if request.hardware_model:
|
||||
for env_name, env_data in context.get("environments", {}).items():
|
||||
if (
|
||||
env_data.get("hw_model") == request.hardware_model
|
||||
and env_name != request.environment_name
|
||||
):
|
||||
errors.append(
|
||||
f"hardware_model '{request.hardware_model}' is already assigned to "
|
||||
f"environment '{env_name}' ({env_data.get('display_name', '')!r}). "
|
||||
"Verify this is a new model number or confirm the shared-model intent."
|
||||
)
|
||||
break # report once
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T019 – find_matched_patterns
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def find_matched_patterns(request: BoardIntakeRequest, context: dict) -> list[dict]:
|
||||
"""Return up to 3 existing environments closest to the request by architecture."""
|
||||
envs = context.get("environments", {})
|
||||
arch = request.architecture
|
||||
|
||||
# Prefer exact architecture match, then partial (e.g., "esp32" matches "esp32-s3").
|
||||
exact: list[dict] = []
|
||||
partial: list[dict] = []
|
||||
for env_name, env_data in envs.items():
|
||||
entry = {**env_data, "environment": env_name}
|
||||
env_arch = env_data.get("architecture", "")
|
||||
if env_arch == arch:
|
||||
exact.append(entry)
|
||||
elif arch and (env_arch.startswith(arch) or arch.startswith(env_arch)):
|
||||
partial.append(entry)
|
||||
|
||||
candidates = exact + partial
|
||||
# Prefer boards that have a display_name and hw_model (more complete entries).
|
||||
candidates.sort(key=lambda e: (not e.get("display_name"), not e.get("hw_model")))
|
||||
return candidates[:3]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T020 – build_evidence_gaps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def build_evidence_gaps(request: BoardIntakeRequest) -> list[EvidenceGap]:
|
||||
"""Identify missing pin-group evidence and metadata gaps."""
|
||||
gaps: list[EvidenceGap] = []
|
||||
has_sources = bool(request.source_materials)
|
||||
|
||||
if not has_sources:
|
||||
# Every pin category is unresolvable without sources.
|
||||
for cat in EVIDENCE_CATEGORIES:
|
||||
gaps.append(
|
||||
EvidenceGap(
|
||||
category=cat,
|
||||
description=(
|
||||
f"No source materials supplied. {cat.capitalize()} pin mappings cannot be "
|
||||
"verified without a schematic, pinout diagram, or vendor datasheet."
|
||||
),
|
||||
affected_artifact="variant.h",
|
||||
required_evidence="Schematic, pinout image, or vendor board page",
|
||||
blocking=True,
|
||||
)
|
||||
)
|
||||
else:
|
||||
# Sources exist but may still be incomplete; flag as non-blocking advisory.
|
||||
for cat in EVIDENCE_CATEGORIES:
|
||||
gaps.append(
|
||||
EvidenceGap(
|
||||
category=cat,
|
||||
description=(
|
||||
f"Source materials are present but {cat} pin assignments have not been "
|
||||
"extracted and cross-checked against repository macro conventions."
|
||||
),
|
||||
affected_artifact="variant.h",
|
||||
required_evidence=f"Explicit {cat} pin listing matched to repository #define names",
|
||||
blocking=False,
|
||||
)
|
||||
)
|
||||
|
||||
# Metadata gaps
|
||||
if not request.hardware_model_slug:
|
||||
gaps.append(
|
||||
EvidenceGap(
|
||||
category="metadata",
|
||||
description="hardware_model_slug is not set. The repository requires an UPPER_SNAKE_CASE slug for PlatformIO metadata.",
|
||||
affected_artifact="platformio.ini (custom_meshtastic_hw_model_slug)",
|
||||
required_evidence="Agreed slug from the project maintainers",
|
||||
blocking=True,
|
||||
)
|
||||
)
|
||||
|
||||
if request.actively_supported is None:
|
||||
gaps.append(
|
||||
EvidenceGap(
|
||||
category="metadata",
|
||||
description="actively_supported is not specified. This controls CI matrix inclusion.",
|
||||
affected_artifact="platformio.ini (custom_meshtastic_actively_supported)",
|
||||
required_evidence="Maintainer decision on support status",
|
||||
blocking=False,
|
||||
)
|
||||
)
|
||||
|
||||
if not request.support_level:
|
||||
gaps.append(
|
||||
EvidenceGap(
|
||||
category="metadata",
|
||||
description="support_level is not specified (expected: 1 = active, 2 = supported, 3 = extra).",
|
||||
affected_artifact="platformio.ini (custom_meshtastic_support_level)",
|
||||
required_evidence="Maintainer decision on support tier",
|
||||
blocking=False,
|
||||
)
|
||||
)
|
||||
|
||||
# Revision-scope: multiple display/radio options without disambiguation.
|
||||
# Match whole-word choice language rather than raw substrings so normal text
|
||||
# like "radio" does not trigger the ambiguity gate.
|
||||
note_text = request.board_notes.lower()
|
||||
revision_scope_patterns = (
|
||||
r"\brevision\b",
|
||||
r"\bvariant\b",
|
||||
r"\bvariants\b",
|
||||
r"\boption\b",
|
||||
r"\boptions\b",
|
||||
r"\balternative\b",
|
||||
r"\balternatives\b",
|
||||
r"\bmulti\b",
|
||||
r"\btwo\b",
|
||||
r"\beither\b",
|
||||
r"\bor\b",
|
||||
)
|
||||
if note_text and any(re.search(pattern, note_text) for pattern in revision_scope_patterns):
|
||||
gaps.append(
|
||||
EvidenceGap(
|
||||
category="revision-scope",
|
||||
description=(
|
||||
"Board notes mention multiple variants, revisions, or options. "
|
||||
"The intake must be scoped to a single hardware revision before scaffolding can proceed."
|
||||
),
|
||||
affected_artifact="variant.h, platformio.ini",
|
||||
required_evidence="Explicit decision on which revision this intake covers",
|
||||
blocking=True,
|
||||
)
|
||||
)
|
||||
|
||||
return gaps
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T021 – assess_intake
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def assess_intake(request: BoardIntakeRequest, context: dict) -> IntakeAssessment:
|
||||
"""Produce a full structured assessment from intake request and context."""
|
||||
validation_errors = validate_intake(request, context)
|
||||
matched = find_matched_patterns(request, context)
|
||||
gaps = build_evidence_gaps(request)
|
||||
|
||||
expected_artifacts = [
|
||||
f"variants/{request.architecture or '<architecture>'}/<variant-dir>/variant.h",
|
||||
f"variants/{request.architecture or '<architecture>'}/<variant-dir>/platformio.ini (env:{request.environment_name or '<env>'})",
|
||||
]
|
||||
if request.architecture in {"esp32", "esp32-s3", "esp32-c3", "esp32-c6"}:
|
||||
expected_artifacts.append(
|
||||
"(optional) variants/.../variant.cpp — only if board requires custom init hooks"
|
||||
)
|
||||
expected_artifacts += [
|
||||
"PlatformIO metadata: all required custom_meshtastic_* keys (see required_metadata below)",
|
||||
"(optional) board image under branding/ or images/ if custom_meshtastic_images is set",
|
||||
]
|
||||
|
||||
required_metadata = list(REQUIRED_METADATA_KEYS)
|
||||
if request.architecture in BSP_DEFAULT_FAMILIES:
|
||||
required_metadata.append(
|
||||
f"(BSP note) {request.architecture} boards may inherit some pin defines from BSP headers — "
|
||||
"check the Inherited Defaults section of docs/hardware-support-context.md before assuming a missing define is an error."
|
||||
)
|
||||
|
||||
risk_flags: list[str] = []
|
||||
for err in validation_errors:
|
||||
risk_flags.append(f"Validation error: {err}")
|
||||
if request.architecture in BSP_DEFAULT_FAMILIES:
|
||||
risk_flags.append(
|
||||
f"Architecture '{request.architecture}' uses BSP defaults for some pin defines. "
|
||||
"Verify which macros are inherited before declaring them explicitly in variant.h."
|
||||
)
|
||||
if not matched:
|
||||
risk_flags.append(
|
||||
"No closely matched existing board found for this architecture. "
|
||||
"Manual review of variant structure is required."
|
||||
)
|
||||
|
||||
next_actions: list[str] = []
|
||||
if validation_errors:
|
||||
next_actions.append("Resolve validation errors before proceeding.")
|
||||
blocking_gaps = [g for g in gaps if g.blocking]
|
||||
non_blocking_gaps = [g for g in gaps if not g.blocking]
|
||||
for gap in blocking_gaps:
|
||||
next_actions.append(
|
||||
f"Provide {gap.required_evidence} for {gap.category} ({gap.affected_artifact})."
|
||||
)
|
||||
if non_blocking_gaps:
|
||||
next_actions.append(
|
||||
f"Review {len(non_blocking_gaps)} non-blocking gap(s) before merging scaffold output."
|
||||
)
|
||||
if not next_actions:
|
||||
next_actions.append(
|
||||
"All required evidence is present. Proceed to scaffold generation."
|
||||
)
|
||||
|
||||
scaffold_ready = len(validation_errors) == 0 and all(not g.blocking for g in gaps)
|
||||
|
||||
return IntakeAssessment(
|
||||
request=request,
|
||||
expected_artifacts=expected_artifacts,
|
||||
required_metadata=required_metadata,
|
||||
matched_patterns=matched,
|
||||
evidence_gaps=gaps,
|
||||
risk_flags=risk_flags,
|
||||
next_actions=next_actions,
|
||||
scaffold_ready=scaffold_ready,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T022 – render_assessment_markdown
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def render_assessment_markdown(assessment: IntakeAssessment) -> str:
|
||||
req = assessment.request
|
||||
lines: list[str] = []
|
||||
|
||||
lines.append(f"# Board Intake Assessment: `{req.environment_name or '(unnamed)'}`")
|
||||
lines.append("")
|
||||
lines.append(
|
||||
f"**Scaffold ready**: {'✅ Yes' if assessment.scaffold_ready else '❌ No — see blocking gaps below'}"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
lines.append("## Request Summary")
|
||||
lines.append("")
|
||||
lines.append(f"- **Environment name**: `{req.environment_name}`")
|
||||
lines.append(f"- **Hardware model**: `{req.hardware_model}`")
|
||||
lines.append(
|
||||
f"- **Hardware model slug**: `{req.hardware_model_slug or '(not set)'}`"
|
||||
)
|
||||
lines.append(f"- **Display name**: {req.display_name}")
|
||||
lines.append(f"- **Architecture**: `{req.architecture}`")
|
||||
if req.actively_supported is not None:
|
||||
lines.append(f"- **Actively supported**: {req.actively_supported}")
|
||||
if req.support_level:
|
||||
lines.append(f"- **Support level**: {req.support_level}")
|
||||
if req.source_materials:
|
||||
lines.append("- **Source materials**:")
|
||||
for src in req.source_materials:
|
||||
lines.append(f" - {src}")
|
||||
if req.board_notes:
|
||||
lines.append(f"- **Board notes**: {req.board_notes}")
|
||||
lines.append("")
|
||||
|
||||
lines.append("## Expected Artifacts")
|
||||
lines.append("")
|
||||
for artifact in assessment.expected_artifacts:
|
||||
lines.append(f"- {artifact}")
|
||||
lines.append("")
|
||||
|
||||
lines.append("## Required Metadata")
|
||||
lines.append("")
|
||||
for key in assessment.required_metadata:
|
||||
lines.append(
|
||||
f"- `{key}`" if key.startswith("custom_meshtastic") else f"- {key}"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
lines.append("## Matched Repository Patterns")
|
||||
lines.append("")
|
||||
if assessment.matched_patterns:
|
||||
for pattern in assessment.matched_patterns:
|
||||
name = pattern.get("display_name") or pattern.get("environment", "")
|
||||
env = pattern.get("environment", "")
|
||||
arch = pattern.get("architecture", "")
|
||||
vdir = pattern.get("variant_dir", "")
|
||||
lines.append(f"- **{name}** (`{env}`, {arch}) — `{vdir}`")
|
||||
else:
|
||||
lines.append("- No closely matched patterns found for this architecture.")
|
||||
lines.append("")
|
||||
|
||||
blocking = [g for g in assessment.evidence_gaps if g.blocking]
|
||||
non_blocking = [g for g in assessment.evidence_gaps if not g.blocking]
|
||||
|
||||
lines.append("## Evidence Gaps")
|
||||
lines.append("")
|
||||
if blocking:
|
||||
lines.append("### Blocking")
|
||||
lines.append("")
|
||||
for gap in blocking:
|
||||
lines.append(f"- **[{gap.category}]** {gap.description}")
|
||||
lines.append(f" - Affected: `{gap.affected_artifact}`")
|
||||
lines.append(f" - Required evidence: {gap.required_evidence}")
|
||||
else:
|
||||
lines.append("*No blocking evidence gaps.*")
|
||||
lines.append("")
|
||||
if non_blocking:
|
||||
lines.append("### Non-blocking (review before merge)")
|
||||
lines.append("")
|
||||
for gap in non_blocking:
|
||||
lines.append(f"- **[{gap.category}]** {gap.description}")
|
||||
lines.append(f" - Affected: `{gap.affected_artifact}`")
|
||||
lines.append(f" - Required evidence: {gap.required_evidence}")
|
||||
lines.append("")
|
||||
|
||||
if assessment.risk_flags:
|
||||
lines.append("## Risk Flags")
|
||||
lines.append("")
|
||||
for flag in assessment.risk_flags:
|
||||
lines.append(f"- {flag}")
|
||||
lines.append("")
|
||||
|
||||
lines.append("## Next Actions")
|
||||
lines.append("")
|
||||
for i, action in enumerate(assessment.next_actions, 1):
|
||||
lines.append(f"{i}. {action}")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T023 – CLI entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Evaluate a board intake request against the repository hardware context."
|
||||
)
|
||||
parser.add_argument("intake", help="Path to the intake JSON file")
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
default="-",
|
||||
help="Output path for the assessment markdown (default: stdout)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--validate",
|
||||
action="store_true",
|
||||
help="Exit with code 1 if scaffold_ready is false (useful for CI gate checks)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--context",
|
||||
default=str(DEFAULT_CONTEXT_PATH),
|
||||
help="Path to docs/hardware-support-context.md (default: auto-detected)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
intake_path = Path(args.intake)
|
||||
if not intake_path.exists():
|
||||
print(f"Error: intake file not found: {intake_path}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
request = BoardIntakeRequest.from_json(intake_path)
|
||||
context = load_hardware_context(Path(args.context))
|
||||
assessment = assess_intake(request, context)
|
||||
report = render_assessment_markdown(assessment)
|
||||
|
||||
if args.output == "-":
|
||||
print(report)
|
||||
else:
|
||||
output_path = Path(args.output)
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_text(report, encoding="utf-8")
|
||||
print(f"Assessment written to {output_path}")
|
||||
|
||||
if args.validate and not assessment.scaffold_ready:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,392 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Scaffold board-support files from a validated intake assessment."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from board_intake import (
|
||||
BoardIntakeRequest,
|
||||
EvidenceGap,
|
||||
IntakeAssessment,
|
||||
assess_intake,
|
||||
load_hardware_context,
|
||||
render_assessment_markdown,
|
||||
)
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DEFAULT_OUTPUT_ROOT = ROOT / "generated" / "hardware-support"
|
||||
|
||||
ARCH_BASE_ENV = {
|
||||
"esp32": "esp32_base",
|
||||
"esp32-s3": "esp32s3_base",
|
||||
"esp32-c3": "esp32c3_base",
|
||||
"esp32-c6": "esp32c6_base",
|
||||
"esp32s2": "esp32s2_base",
|
||||
"nrf52840": "nrf52_base",
|
||||
"rp2040": "rp2040_base",
|
||||
"rp2350": "rp2350_base",
|
||||
"stm32": "stm32_base",
|
||||
"native": "native_base",
|
||||
}
|
||||
|
||||
ARCH_VARIANT_ROOT = {
|
||||
"esp32": "esp32",
|
||||
"esp32-s3": "esp32s3",
|
||||
"esp32-c3": "esp32c3",
|
||||
"esp32-c6": "esp32c6",
|
||||
"esp32s2": "esp32s2",
|
||||
"nrf52840": "nrf52840",
|
||||
"rp2040": "rp2040",
|
||||
"rp2350": "rp2350",
|
||||
"stm32": "stm32",
|
||||
"native": "native",
|
||||
}
|
||||
|
||||
PLACEHOLDER_DEFINES = {
|
||||
"status": [
|
||||
"#define LED_PIN // TODO: verify status LED pin if present",
|
||||
],
|
||||
"input": [
|
||||
"#define BUTTON_PIN // TODO: verify user button pin",
|
||||
],
|
||||
"display": [
|
||||
"#define HAS_SCREEN 1",
|
||||
"#define USE_SSD1306 // TODO: verify display controller",
|
||||
"#define I2C_SCL // TODO: verify display I2C clock pin",
|
||||
"#define I2C_SDA // TODO: verify display I2C data pin",
|
||||
],
|
||||
"GPS": [
|
||||
"#define GPS_RX_PIN // TODO: verify GPS RX pin or remove if GPS absent",
|
||||
"#define GPS_TX_PIN // TODO: verify GPS TX pin or remove if GPS absent",
|
||||
],
|
||||
"power": [
|
||||
"#define BATTERY_PIN // TODO: verify battery sense pin",
|
||||
"#define ADC_MULTIPLIER // TODO: verify voltage divider ratio",
|
||||
],
|
||||
"radio": [
|
||||
"#define USE_SX1262 // TODO: verify radio chip selection from schematic",
|
||||
"#define SX126X_CS // TODO: verify radio chip-select pin",
|
||||
"#define SX126X_BUSY // TODO: verify radio busy pin",
|
||||
"#define SX126X_DIO1 // TODO: verify radio IRQ pin",
|
||||
"#define SX126X_RESET // TODO: verify radio reset pin",
|
||||
"#define LORA_SCK // TODO: verify radio SPI clock pin",
|
||||
"#define LORA_MISO // TODO: verify radio SPI MISO pin",
|
||||
"#define LORA_MOSI // TODO: verify radio SPI MOSI pin",
|
||||
],
|
||||
}
|
||||
|
||||
DEFINE_PATTERN = re.compile(r"^#define\s+([A-Za-z0-9_]+)(?:\s+(.*?))?\s*(?://.*)?$")
|
||||
|
||||
CATEGORY_MACROS = {
|
||||
"status": ["LED_POWER", "LED_PIN", "LED_STATE_ON", "VEXT_ENABLE"],
|
||||
"input": ["BUTTON_PIN", "BUTTON_NEED_PULLUP", "ROTARY_A", "ROTARY_B"],
|
||||
"display": [
|
||||
"HAS_SCREEN",
|
||||
"USE_SSD1306",
|
||||
"USE_SH1106",
|
||||
"USE_TFTDISPLAY",
|
||||
"USE_ST7789",
|
||||
"I2C_SCL",
|
||||
"I2C_SDA",
|
||||
"I2C_SCL1",
|
||||
"I2C_SDA1",
|
||||
"TFT_HEIGHT",
|
||||
"TFT_WIDTH",
|
||||
"TFT_OFFSET_X",
|
||||
"TFT_OFFSET_Y",
|
||||
"SCREEN_ROTATE",
|
||||
"SCREEN_TRANSITION_FRAMERATE",
|
||||
],
|
||||
"GPS": [
|
||||
"HAS_GPS",
|
||||
"GPS_RX_PIN",
|
||||
"GPS_TX_PIN",
|
||||
"GPS_BAUDRATE",
|
||||
"PIN_GPS_PPS",
|
||||
"GPS_THREAD_INTERVAL",
|
||||
],
|
||||
"power": [
|
||||
"ADC_CTRL",
|
||||
"ADC_CTRL_ENABLED",
|
||||
"BATTERY_PIN",
|
||||
"ADC_CHANNEL",
|
||||
"ADC_ATTENUATION",
|
||||
"ADC_MULTIPLIER",
|
||||
"USE_POWERSAVE",
|
||||
"SLEEP_TIME",
|
||||
],
|
||||
"radio": [
|
||||
"USE_SX1262",
|
||||
"USE_SX1268",
|
||||
"USE_LR1121",
|
||||
"USE_SX1280",
|
||||
"LORA_DIO0",
|
||||
"LORA_RESET",
|
||||
"LORA_DIO1",
|
||||
"LORA_DIO2",
|
||||
"LORA_SCK",
|
||||
"LORA_MISO",
|
||||
"LORA_MOSI",
|
||||
"LORA_CS",
|
||||
"SX126X_CS",
|
||||
"SX126X_DIO1",
|
||||
"SX126X_BUSY",
|
||||
"SX126X_RESET",
|
||||
"SX126X_DIO2_AS_RF_SWITCH",
|
||||
"SX126X_DIO3_TCXO_VOLTAGE",
|
||||
"LR1121_IRQ_PIN",
|
||||
"LR1121_NRESET_PIN",
|
||||
"LR1121_BUSY_PIN",
|
||||
"LR1121_SPI_NSS_PIN",
|
||||
"LR1121_SPI_SCK_PIN",
|
||||
"LR1121_SPI_MOSI_PIN",
|
||||
"LR1121_SPI_MISO_PIN",
|
||||
"LR11X0_DIO3_TCXO_VOLTAGE",
|
||||
"LR11X0_DIO_AS_RF_SWITCH",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def slugify_variant_dir(req: BoardIntakeRequest) -> str:
|
||||
return req.environment_name.replace("_", "-").lower()
|
||||
|
||||
|
||||
def target_variant_dir(req: BoardIntakeRequest) -> Path:
|
||||
arch_root = ARCH_VARIANT_ROOT.get(req.architecture, req.architecture)
|
||||
return Path("variants") / arch_root / slugify_variant_dir(req)
|
||||
|
||||
|
||||
def pick_pattern(assessment: IntakeAssessment) -> dict | None:
|
||||
return assessment.matched_patterns[0] if assessment.matched_patterns else None
|
||||
|
||||
|
||||
def source_basis_lines(assessment: IntakeAssessment, context: dict) -> list[str]:
|
||||
lines = [
|
||||
f"// Intake environment: {assessment.request.environment_name}",
|
||||
f"// Intake architecture: {assessment.request.architecture}",
|
||||
]
|
||||
pattern = pick_pattern(assessment)
|
||||
if pattern:
|
||||
lines.append(
|
||||
"// Repository pattern basis: "
|
||||
f"{pattern.get('environment', '')} -> {pattern.get('variant_dir', '')}"
|
||||
)
|
||||
lines.append(
|
||||
f"// Context source: {context.get('context_path', 'docs/hardware-support-context.md')}"
|
||||
)
|
||||
return lines
|
||||
|
||||
|
||||
def parse_variant_defines(variant_path: Path) -> dict[str, str]:
|
||||
defines: dict[str, str] = {}
|
||||
if not variant_path.exists():
|
||||
return defines
|
||||
|
||||
for raw_line in variant_path.read_text(encoding="utf-8").splitlines():
|
||||
match = DEFINE_PATTERN.match(raw_line.strip())
|
||||
if not match:
|
||||
continue
|
||||
name = match.group(1)
|
||||
value = (match.group(2) or "1").strip()
|
||||
defines[name] = value
|
||||
return defines
|
||||
|
||||
|
||||
def infer_category_lines(pattern_variant_path: Path, category: str) -> list[str]:
|
||||
defines = parse_variant_defines(pattern_variant_path)
|
||||
lines: list[str] = []
|
||||
for macro_name in CATEGORY_MACROS[category]:
|
||||
value = defines.get(macro_name)
|
||||
if value is None:
|
||||
continue
|
||||
if value == "1":
|
||||
lines.append(f"#define {macro_name}")
|
||||
else:
|
||||
lines.append(f"#define {macro_name} {value}")
|
||||
|
||||
if lines:
|
||||
return lines
|
||||
return PLACEHOLDER_DEFINES[category]
|
||||
|
||||
|
||||
# T028
|
||||
|
||||
|
||||
def generate_variant_h(assessment: IntakeAssessment, context: dict) -> str:
|
||||
req = assessment.request
|
||||
lines: list[str] = []
|
||||
lines.extend(source_basis_lines(assessment, context))
|
||||
lines.append("")
|
||||
lines.append("#pragma once")
|
||||
lines.append("")
|
||||
|
||||
pattern = pick_pattern(assessment)
|
||||
pattern_variant_path = None
|
||||
if pattern and pattern.get("variant_dir"):
|
||||
pattern_variant_path = (ROOT / str(pattern["variant_dir"]) / "variant.h").resolve()
|
||||
|
||||
for category in ("status", "input", "display", "GPS", "power", "radio"):
|
||||
lines.append(f"// {category}")
|
||||
if pattern_variant_path is not None:
|
||||
lines.extend(infer_category_lines(pattern_variant_path, category))
|
||||
else:
|
||||
lines.extend(PLACEHOLDER_DEFINES[category])
|
||||
lines.append("")
|
||||
|
||||
lines.append("// Board identity")
|
||||
macro_name = req.hardware_model_slug or req.environment_name.upper().replace(
|
||||
"-", "_"
|
||||
)
|
||||
lines.append(f"#define {macro_name} 1")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines).rstrip() + "\n"
|
||||
|
||||
|
||||
# T029
|
||||
|
||||
|
||||
def generate_platformio_env(assessment: IntakeAssessment) -> str:
|
||||
req = assessment.request
|
||||
extends = ARCH_BASE_ENV.get(req.architecture, f"{req.architecture}_base")
|
||||
variant_dir = target_variant_dir(req)
|
||||
build_define = (
|
||||
req.hardware_model_slug or req.environment_name.upper().replace("-", "_")
|
||||
).replace(" ", "_")
|
||||
|
||||
lines = [
|
||||
f"[env:{req.environment_name}]",
|
||||
f"custom_meshtastic_hw_model = {req.hardware_model}",
|
||||
f"custom_meshtastic_hw_model_slug = {req.hardware_model_slug or 'TODO_SET_HW_MODEL_SLUG'}",
|
||||
f"custom_meshtastic_architecture = {req.architecture}",
|
||||
f"custom_meshtastic_actively_supported = {str(req.actively_supported).lower() if req.actively_supported is not None else 'TODO_SET_ACTIVELY_SUPPORTED'}",
|
||||
f"custom_meshtastic_support_level = {req.support_level or 'TODO_SET_SUPPORT_LEVEL'}",
|
||||
f"custom_meshtastic_display_name = {req.display_name}",
|
||||
"custom_meshtastic_images = TODO_SET_IMAGES",
|
||||
"custom_meshtastic_tags = TODO_SET_TAGS",
|
||||
"custom_meshtastic_requires_dfu = TODO_SET_REQUIRES_DFU",
|
||||
"custom_meshtastic_partition_scheme = TODO_SET_PARTITION_SCHEME",
|
||||
"",
|
||||
"board = TODO_SET_PLATFORMIO_BOARD",
|
||||
f"extends = {extends}",
|
||||
"build_flags =",
|
||||
f" ${{{extends}.build_flags}}",
|
||||
f" -D {build_define}",
|
||||
f" -I {variant_dir.as_posix()}",
|
||||
]
|
||||
return "\n".join(lines).rstrip() + "\n"
|
||||
|
||||
|
||||
# T030
|
||||
|
||||
|
||||
def annotate_unresolved(content: str, gaps: list[EvidenceGap], is_ini: bool = False) -> str:
|
||||
annotations = [
|
||||
gap
|
||||
for gap in gaps
|
||||
if not gap.blocking
|
||||
and gap.category in {"radio", "display", "input", "GPS", "power", "metadata"}
|
||||
]
|
||||
if not annotations:
|
||||
return content
|
||||
|
||||
lines = content.splitlines()
|
||||
todo_lines = [f"; TODO: verify — {gap.description}" for gap in annotations]
|
||||
|
||||
if is_ini:
|
||||
# For INI files, insert TODOs after the first section header
|
||||
if lines and lines[0].startswith("["):
|
||||
return "\n".join(lines[:1] + todo_lines + [""] + lines[1:]) + "\n"
|
||||
else:
|
||||
# For .h files, prepend TODOs with C++ comment syntax
|
||||
todo_lines = [f"// TODO: verify — {gap.description}" for gap in annotations]
|
||||
return "\n".join(todo_lines + [""] + lines) + "\n"
|
||||
|
||||
return content
|
||||
|
||||
|
||||
# T031
|
||||
|
||||
|
||||
def scaffold_board(
|
||||
assessment: IntakeAssessment, context: dict, output_dir: Path
|
||||
) -> dict[str, Path]:
|
||||
req = assessment.request
|
||||
variant_dir = output_dir / target_variant_dir(req)
|
||||
variant_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
variant_h_path = variant_dir / "variant.h"
|
||||
platformio_path = variant_dir / "platformio.ini"
|
||||
|
||||
variant_h_content = annotate_unresolved(
|
||||
generate_variant_h(assessment, context), assessment.evidence_gaps, is_ini=False
|
||||
)
|
||||
platformio_content = annotate_unresolved(
|
||||
generate_platformio_env(assessment), assessment.evidence_gaps, is_ini=True
|
||||
)
|
||||
|
||||
variant_h_path.write_text(variant_h_content, encoding="utf-8")
|
||||
platformio_path.write_text(platformio_content, encoding="utf-8")
|
||||
|
||||
generated = {
|
||||
"variant_h": variant_h_path,
|
||||
"platformio": platformio_path,
|
||||
}
|
||||
|
||||
if req.architecture in {"esp32", "esp32-s3", "esp32-c3", "esp32-c6"}:
|
||||
variant_cpp_path = variant_dir / "variant.cpp"
|
||||
variant_cpp_content = annotate_unresolved(
|
||||
"\n".join(
|
||||
[
|
||||
'#include "variant.h"',
|
||||
"",
|
||||
"// Optional board-specific initialization hooks go here.",
|
||||
"// Leave this file out if the board does not need custom startup behavior.",
|
||||
]
|
||||
)
|
||||
+ "\n",
|
||||
assessment.evidence_gaps,
|
||||
)
|
||||
variant_cpp_path.write_text(variant_cpp_content, encoding="utf-8")
|
||||
generated["variant_cpp"] = variant_cpp_path
|
||||
|
||||
return generated
|
||||
|
||||
|
||||
# T032 + T033
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Generate board-support scaffold files from an intake JSON file."
|
||||
)
|
||||
parser.add_argument("intake", help="Path to intake JSON")
|
||||
parser.add_argument(
|
||||
"--output-dir",
|
||||
default=str(DEFAULT_OUTPUT_ROOT),
|
||||
help="Directory where scaffold files will be written",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
intake_path = Path(args.intake)
|
||||
request = BoardIntakeRequest.from_json(intake_path)
|
||||
context = load_hardware_context()
|
||||
context["context_path"] = "docs/hardware-support-context.md"
|
||||
assessment = assess_intake(request, context)
|
||||
|
||||
if not assessment.scaffold_ready:
|
||||
print(render_assessment_markdown(assessment))
|
||||
sys.exit(1)
|
||||
|
||||
generated = scaffold_board(assessment, context, Path(args.output_dir))
|
||||
print("Generated scaffold files:")
|
||||
for name, path in generated.items():
|
||||
print(f"- {name}: {path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"environment_name": "new_esp32s3_board",
|
||||
"hardware_model": "999",
|
||||
"hardware_model_slug": "NEW_ESP32S3_BOARD",
|
||||
"display_name": "Acme ESP32-S3 Dev Board v1",
|
||||
"architecture": "esp32-s3",
|
||||
"actively_supported": true,
|
||||
"support_level": "1",
|
||||
"source_materials": [
|
||||
"https://example.com/acme-esp32s3-schematic.pdf",
|
||||
"https://example.com/acme-esp32s3-pinout.png"
|
||||
],
|
||||
"board_notes": "Initial bring-up for v1 PCB only. SX1262 radio on SPI2, OLED on I2C bus."
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"environment_name": "hypothetical_esp32s3_board",
|
||||
"hardware_model": "9981",
|
||||
"display_name": "Hypothetical ESP32-S3 Board",
|
||||
"architecture": "esp32-s3"
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"environment_name": "new_nrf52_tracker",
|
||||
"hardware_model": "998",
|
||||
"hardware_model_slug": "NEW_NRF52_TRACKER",
|
||||
"display_name": "Acme nRF52840 Tracker v2",
|
||||
"architecture": "nrf52840",
|
||||
"actively_supported": null,
|
||||
"support_level": "2",
|
||||
"source_materials": [
|
||||
"https://example.com/acme-tracker-pinout.pdf"
|
||||
],
|
||||
"board_notes": "Two revisions exist: v2 (SSD1306 OLED) and v2a (no display). This intake covers v2 only."
|
||||
}
|
||||
@@ -1,592 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""Generate a markdown inventory of board-support metadata and common target-definition macros."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
from collections import Counter, defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
VARIANTS_DIR = ROOT / "variants"
|
||||
OUTPUT_PATH = ROOT / "docs" / "hardware-support-context.md"
|
||||
|
||||
PLATFORMIO_METADATA_KEYS = [
|
||||
"custom_meshtastic_hw_model",
|
||||
"custom_meshtastic_hw_model_slug",
|
||||
"custom_meshtastic_architecture",
|
||||
"custom_meshtastic_actively_supported",
|
||||
"custom_meshtastic_support_level",
|
||||
"custom_meshtastic_display_name",
|
||||
"custom_meshtastic_images",
|
||||
"custom_meshtastic_tags",
|
||||
"custom_meshtastic_requires_dfu",
|
||||
"custom_meshtastic_partition_scheme",
|
||||
]
|
||||
|
||||
MACRO_PATTERNS = {
|
||||
"Input": [
|
||||
"BUTTON_PIN",
|
||||
"BUTTON_PIN_TOUCH",
|
||||
"ALT_BUTTON_PIN",
|
||||
"CANCEL_BUTTON_PIN",
|
||||
"ROTARY_",
|
||||
"KB_",
|
||||
"INPUTDRIVER_",
|
||||
],
|
||||
"Radio": [
|
||||
"USE_RF95",
|
||||
"USE_SX126",
|
||||
"USE_SX128",
|
||||
"USE_LLCC68",
|
||||
"USE_LR11",
|
||||
"LORA_",
|
||||
"SX126X_",
|
||||
"SX128X_",
|
||||
"LR1121_",
|
||||
"RF95_",
|
||||
],
|
||||
"GPS": [
|
||||
"HAS_GPS",
|
||||
"GPS_",
|
||||
"PIN_GPS_",
|
||||
"GPS_DEFAULT_NOT_PRESENT",
|
||||
],
|
||||
"Display": [
|
||||
"HAS_SCREEN",
|
||||
"USE_TFTDISPLAY",
|
||||
"USE_TFT",
|
||||
"USE_SSD",
|
||||
"USE_SH",
|
||||
"USE_ST",
|
||||
"TFT_",
|
||||
"OLED_",
|
||||
"SCREEN_",
|
||||
"PIN_EINK_",
|
||||
"EINK_",
|
||||
"DISPLAY_",
|
||||
"LGFX_",
|
||||
"HAS_TFT",
|
||||
],
|
||||
"I2C/SPI": [
|
||||
"I2C_",
|
||||
"SPI_",
|
||||
"PIN_SPI",
|
||||
"WIRE_",
|
||||
],
|
||||
"Power": [
|
||||
"BATTERY_",
|
||||
"ADC_",
|
||||
"PIN_POWER",
|
||||
"USE_POWERSAVE",
|
||||
"SLEEP_TIME",
|
||||
"XPOWERS_",
|
||||
"HAS_AXP",
|
||||
"HAS_PPM",
|
||||
"HAS_BQ",
|
||||
"EXT_NOTIFY_OUT",
|
||||
"FAN_CTRL_PIN",
|
||||
"RF95_FAN_EN",
|
||||
],
|
||||
"Connectivity/Other": [
|
||||
"HAS_WIFI",
|
||||
"HAS_BLUETOOTH",
|
||||
"HAS_ETHERNET",
|
||||
"HAS_NEOPIXEL",
|
||||
"HAS_I2S",
|
||||
"HAS_TOUCH",
|
||||
"HAS_TOUCHSCREEN",
|
||||
"HAS_NFC",
|
||||
"NFC_",
|
||||
"USE_XL9555",
|
||||
"EXPANDS_",
|
||||
"PCF",
|
||||
"RTC",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def classify_macro(name: str) -> str:
|
||||
for category, prefixes in MACRO_PATTERNS.items():
|
||||
for prefix in prefixes:
|
||||
if name.startswith(prefix):
|
||||
return category
|
||||
return "Other"
|
||||
|
||||
|
||||
def parse_platformio_envs(path: Path) -> list[dict[str, object]]:
|
||||
envs: list[dict[str, object]] = []
|
||||
current: dict[str, object] | None = None
|
||||
for raw_line in path.read_text(encoding="utf-8").splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith(";"):
|
||||
continue
|
||||
if line.startswith("[") and line.endswith("]"):
|
||||
if current:
|
||||
envs.append(current)
|
||||
current = {"section": line[1:-1], "metadata": {}}
|
||||
continue
|
||||
if current is None or "=" not in line:
|
||||
continue
|
||||
key, value = [part.strip() for part in line.split("=", 1)]
|
||||
if key in PLATFORMIO_METADATA_KEYS:
|
||||
current["metadata"][key] = value
|
||||
if current:
|
||||
envs.append(current)
|
||||
return envs
|
||||
|
||||
|
||||
def parse_variant_macros(path: Path) -> tuple[dict[str, str], Counter[str]]:
|
||||
defines: dict[str, str] = {}
|
||||
category_counts: Counter[str] = Counter()
|
||||
pattern = re.compile(r"^#define\s+([A-Za-z0-9_]+)(?:\s+(.*?))?\s*(?://.*)?$")
|
||||
for raw_line in path.read_text(encoding="utf-8").splitlines():
|
||||
match = pattern.match(raw_line.strip())
|
||||
if not match:
|
||||
continue
|
||||
name = match.group(1)
|
||||
value = (match.group(2) or "1").strip()
|
||||
defines[name] = value
|
||||
category_counts[classify_macro(name)] += 1
|
||||
return defines, category_counts
|
||||
|
||||
|
||||
MACRO_DESCRIPTIONS: dict[str, str] = {
|
||||
# ---------- Input ----------
|
||||
"BUTTON_PIN": "Primary user button GPIO pin number.",
|
||||
"ALT_BUTTON_PIN": "Secondary / alternate button pin, used on boards with two buttons.",
|
||||
"CANCEL_BUTTON_PIN": "Button wired to cancel/back actions (e.g., T-Deck cancel key).",
|
||||
"BUTTON_NEED_PULLUP": "Set to 1 when the button GPIO requires the internal pull-up to be enabled.",
|
||||
"BUTTON_PIN_ALT": "Alternative spelling used by some older board families for a second button.",
|
||||
"KB_BL_PIN": "Keyboard backlight control pin (e.g., T-LoRa Pager keyboard).",
|
||||
"KB_INT": "Keyboard interrupt input pin; signals a keypress to the MCU.",
|
||||
"KB_POWERON": "Pin used to power-on or enable the keyboard peripheral.",
|
||||
"KB_SLAVE_ADDRESS": "I2C address of the keyboard controller IC.",
|
||||
"INPUTDRIVER_ENCODER_TYPE": "Selects the rotary encoder driver variant (0 = none, 1+ = specific type).",
|
||||
"INPUTDRIVER_TWO_WAY_ROCKER": "Enables the two-way rocker input driver.",
|
||||
"INPUTDRIVER_TWO_WAY_ROCKER_RIGHT": "GPIO pin for the rightward rocker direction.",
|
||||
"INPUTDRIVER_TWO_WAY_ROCKER_LEFT": "GPIO pin for the leftward rocker direction.",
|
||||
"INPUTDRIVER_TWO_WAY_ROCKER_BTN": "GPIO pin for the rocker click/press direction.",
|
||||
"ROTARY_A": "First encoder channel pin (clock / A signal).",
|
||||
"ROTARY_B": "Second encoder channel pin (data / B signal).",
|
||||
# ---------- Radio ----------
|
||||
"USE_SX1262": "Select the SX1262 sub-GHz LoRa chip driver.",
|
||||
"USE_SX1268": "Select the SX1268 sub-GHz LoRa chip driver (higher power variant of SX1262).",
|
||||
"USE_SX1280": "Select the SX1280 2.4 GHz LoRa chip driver.",
|
||||
"USE_RF95": "Select the RFM95/SX1276 legacy LoRa chip driver.",
|
||||
"USE_LLCC68": "Select the LLCC68 low-cost LoRa chip driver.",
|
||||
"USE_LR1110": "Select the LR1110 wideband radio driver.",
|
||||
"USE_LR1120": "Select the LR1120 wideband radio driver.",
|
||||
"USE_LR1121": "Select the LR1121 wideband radio driver.",
|
||||
"LORA_CS": "LoRa radio SPI chip-select GPIO pin.",
|
||||
"LORA_RESET": "LoRa radio hardware-reset GPIO pin (active low).",
|
||||
"LORA_DIO0": "LoRa radio DIO0 interrupt pin (RF95/SX1276 done/RxDone).",
|
||||
"LORA_DIO1": "LoRa radio DIO1 interrupt pin; on SX126x this is the primary IRQ line.",
|
||||
"LORA_DIO2": "LoRa radio DIO2 pin; on RF95 used for FSK interrupt; on SX126x tx/rx control.",
|
||||
"LORA_SCK": "LoRa radio SPI clock pin.",
|
||||
"LORA_MISO": "LoRa radio SPI MISO pin.",
|
||||
"LORA_MOSI": "LoRa radio SPI MOSI pin.",
|
||||
"SX126X_CS": "SX126x chip-select pin (often same as LORA_CS).",
|
||||
"SX126X_RESET": "SX126x reset pin (often same as LORA_RESET).",
|
||||
"SX126X_BUSY": "SX126x BUSY pin; must be polled low before issuing SPI commands.",
|
||||
"SX126X_DIO1": "SX126x DIO1 interrupt output used for all IRQ events.",
|
||||
"SX126X_DIO3_TCXO_VOLTAGE": "Drives the TCXO regulator via DIO3; set to the supply voltage (e.g., 1.8).",
|
||||
"SX126X_DIO2_AS_RF_SWITCH": "Set to 1 so the driver controls the TX/RX RF switch via DIO2.",
|
||||
"LR1121_IRQ_PIN": "LR1121 interrupt request pin.",
|
||||
"RF95_FAN_EN": "Enables a cooling fan for high-power RF95 installations.",
|
||||
# ---------- GPS ----------
|
||||
"HAS_GPS": "Set to 1 if the board has an on-board GPS receiver; 0 to disable GPS entirely.",
|
||||
"GPS_RX_PIN": "UART RX pin connected to the GPS module TX output.",
|
||||
"GPS_TX_PIN": "UART TX pin connected to the GPS module RX input.",
|
||||
"PIN_GPS_EN": "GPIO to power-enable or power-gate the GPS module.",
|
||||
"PIN_GPS_PPS": "GPS pulse-per-second input pin for timing synchronisation.",
|
||||
"PIN_GPS_STANDBY": "Places the GPS into standby/low-power mode when driven.",
|
||||
"PIN_GPS_RESET": "Hardware-reset line to the GPS module.",
|
||||
"PIN_GPS_REINIT": "Pin used to trigger GPS re-initialisation sequences.",
|
||||
"GPS_THREAD_INTERVAL": "Millisecond poll interval for the GPS background thread.",
|
||||
"GPS_BAUDRATE": "UART baud rate for GPS serial communication.",
|
||||
"GPS_L76K": "Selects the Quectel L76K GPS driver and protocol.",
|
||||
"GPS_UBLOX": "Selects the u-blox GPS driver and UBX protocol.",
|
||||
"GPS_EN_ACTIVE": "Logic level (HIGH or LOW) that enables the GPS power pin.",
|
||||
"GPS_RESET_MODE": "Defines the reset signal polarity or protocol for the GPS chip.",
|
||||
"GPS_DEFAULT_NOT_PRESENT": "Compiled-in default assuming no GPS; overridden at runtime if detected.",
|
||||
# ---------- Display ----------
|
||||
"HAS_SCREEN": "Set to 1 if the board has any display hardware.",
|
||||
"USE_TFTDISPLAY": "Selects the TFT LCD driver path.",
|
||||
"USE_SSD1306": "Selects the SSD1306 128×64 OLED driver over I2C.",
|
||||
"USE_SH1106": "Selects the SH1106 128×64 OLED driver.",
|
||||
"USE_ST7735": "Selects the ST7735 TFT SPI driver.",
|
||||
"USE_ST7789": "Selects the ST7789 TFT SPI driver.",
|
||||
"TFT_WIDTH": "Horizontal pixel resolution of the TFT display.",
|
||||
"TFT_HEIGHT": "Vertical pixel resolution of the TFT display.",
|
||||
"TFT_OFFSET_X": "Horizontal pixel offset for display alignment correction.",
|
||||
"TFT_OFFSET_Y": "Vertical pixel offset for display alignment correction.",
|
||||
"TFT_BL": "Backlight control PWM or GPIO pin for the TFT panel.",
|
||||
"SCREEN_ROTATE": "Non-zero value rotates the display 180° for mounted-upside-down screens.",
|
||||
"SCREEN_TRANSITION_FRAMERATE": "Target framerate for UI animations and transitions.",
|
||||
"PIN_EINK_CS": "E-ink display SPI chip-select pin.",
|
||||
"PIN_EINK_BUSY": "E-ink display BUSY output; high when a page update is in progress.",
|
||||
"PIN_EINK_DC": "E-ink display data/command select pin.",
|
||||
"PIN_EINK_RES": "E-ink display hardware-reset pin.",
|
||||
"PIN_EINK_SCLK": "E-ink display SPI clock pin.",
|
||||
"PIN_EINK_MOSI": "E-ink display SPI MOSI pin.",
|
||||
# ---------- I2C/SPI ----------
|
||||
"I2C_SDA": "Primary I2C data line GPIO pin.",
|
||||
"I2C_SCL": "Primary I2C clock line GPIO pin.",
|
||||
"PIN_SPI_MISO": "Primary SPI MISO (data from peripheral) GPIO pin.",
|
||||
"PIN_SPI_MOSI": "Primary SPI MOSI (data to peripheral) GPIO pin.",
|
||||
"PIN_SPI_SCK": "Primary SPI clock GPIO pin.",
|
||||
"SPI_INTERFACES_COUNT": "Number of hardware SPI buses available on this board.",
|
||||
"WIRE_INTERFACES_COUNT": "Number of hardware I2C buses available on this board.",
|
||||
"PIN_SPI1_MISO": "Secondary SPI bus MISO pin.",
|
||||
"PIN_SPI1_MOSI": "Secondary SPI bus MOSI pin.",
|
||||
"PIN_SPI1_SCK": "Secondary SPI bus clock pin.",
|
||||
"SPI_FREQUENCY": "Default SPI clock frequency in Hz for this board.",
|
||||
"SPI_READ_FREQUENCY": "Reduced SPI clock rate used for read transactions.",
|
||||
"SPI_SCK": "SPI clock pin alias used in older board files.",
|
||||
"SPI_MOSI": "SPI MOSI pin alias used in older board files.",
|
||||
"SPI_MISO": "SPI MISO pin alias used in older board files.",
|
||||
# ---------- Power ----------
|
||||
"BATTERY_PIN": "ADC input GPIO connected to the battery voltage divider.",
|
||||
"ADC_MULTIPLIER": "Floating-point scale factor to convert raw ADC reading to battery voltage.",
|
||||
"ADC_CHANNEL": "ADC channel enum or number for the battery sense input.",
|
||||
"BATTERY_SENSE_RESOLUTION_BITS": "ADC resolution in bits used for battery voltage sampling.",
|
||||
"ADC_RESOLUTION": "Board-level ADC resolution definition, referenced by other power macros.",
|
||||
"BATTERY_SENSE_RESOLUTION": "Alias for the effective ADC resolution for battery sense.",
|
||||
"ADC_CTRL": "GPIO that enables or gates the ADC voltage-divider circuit.",
|
||||
"ADC_CTRL_ENABLED": "Logic level (HIGH or LOW) that turns on the ADC control switch.",
|
||||
"ADC_ATTENUATION": "ESP32 ADC input attenuation setting; controls measurable voltage range.",
|
||||
"BATTERY_SENSE_SAMPLES": "Number of ADC samples to average for a stable battery reading.",
|
||||
"EXT_NOTIFY_OUT": "GPIO output used to signal an external LED or buzzer for notifications.",
|
||||
"USE_POWERSAVE": "Enables aggressive power-save mode (deep sleep, reduced poll intervals).",
|
||||
"SLEEP_TIME": "Default light-sleep duration in milliseconds between wakeups.",
|
||||
"PIN_POWER_EN": "GPIO to assert to enable a board power rail or load switch.",
|
||||
"HAS_PPM": "Set to 1 if the board has an IP5306 or similar PPM power path IC.",
|
||||
# ---------- Connectivity/Other ----------
|
||||
"HAS_TOUCHSCREEN": "Set to 1 for boards with a capacitive or resistive touch panel.",
|
||||
"HAS_NEOPIXEL": "Set to 1 if the board has addressable RGB LEDs (WS2812 / NeoPixel).",
|
||||
"PCF8563_RTC": "I2C address of the PCF8563 real-time clock IC.",
|
||||
"PCF85063_RTC": "I2C address of the PCF85063 real-time clock IC.",
|
||||
"HAS_ETHERNET": "Set to 1 for boards with a wired Ethernet interface.",
|
||||
"HAS_I2S": "Set to 1 if I2S audio output is present.",
|
||||
"NFC_INT": "Interrupt pin from the NFC controller IC.",
|
||||
"NFC_CS": "SPI chip-select for the NFC controller.",
|
||||
"USE_XL9555": "Enables the XL9555 16-bit I2C GPIO expander driver.",
|
||||
"EXPANDS_DRV_EN": "GPIO expander pin used to enable the haptic driver.",
|
||||
"EXPANDS_AMP_EN": "GPIO expander pin used to power-on the audio amplifier.",
|
||||
"EXPANDS_KB_RST": "GPIO expander pin used to reset the keyboard controller.",
|
||||
"EXPANDS_LORA_EN": "GPIO expander pin used to power-gate the LoRa radio.",
|
||||
"EXPANDS_GPS_EN": "GPIO expander pin used to power-gate the GPS module.",
|
||||
"EXPANDS_NFC_EN": "GPIO expander pin used to power-gate the NFC controller.",
|
||||
# ---------- Other ----------
|
||||
"LED_POWER": "GPIO for the status LED, defines the LED pin number.",
|
||||
"LED_STATE_ON": "Logic level (HIGH or LOW) that turns the status LED on.",
|
||||
"PIN_SERIAL1_RX": "Secondary UART RX pin (used for accessories, GPS on some boards).",
|
||||
"PIN_SERIAL1_TX": "Secondary UART TX pin.",
|
||||
"PIN_WIRE_SDA": "Arduino-framework I2C SDA pin alias (nRF52 / RP2040 style).",
|
||||
"PIN_WIRE_SCL": "Arduino-framework I2C SCL pin alias.",
|
||||
"PIN_LED1": "First LED GPIO pin in the nRF52 Arduino BSP pin table.",
|
||||
"VARIANT_MCK": "Crystal oscillator frequency in Hz for nRF52 variant clock configuration.",
|
||||
"PINS_COUNT": "Total number of GPIO pins defined in the Arduino BSP variant table.",
|
||||
"NUM_DIGITAL_PINS": "Count of digital-capable pins in the BSP variant table.",
|
||||
"NUM_ANALOG_INPUTS": "Count of analog-input pins in the BSP variant table.",
|
||||
"NUM_ANALOG_OUTPUTS": "Count of analog-output (DAC) pins in the BSP variant table.",
|
||||
"LED_BLUE": "GPIO number of the blue status LED (typical on nRF52 and RP2040 boards).",
|
||||
"USE_LFXO": "Instructs the nRF52 BSP to use the low-frequency crystal oscillator.",
|
||||
"BUTTON_NEED_PULLUP": "Enables internal pull-up on the button GPIO (duplicate entry for clarity).",
|
||||
}
|
||||
|
||||
|
||||
def shorten(value: str, limit: int = 60) -> str:
|
||||
compact = " ".join(value.split())
|
||||
return compact if len(compact) <= limit else compact[: limit - 3] + "..."
|
||||
|
||||
|
||||
# Architecture families known to rely heavily on BSP or base-environment defaults rather
|
||||
# than declaring every field locally. Used in the Inherited Defaults Note section.
|
||||
_BSP_DEFAULT_FAMILIES: dict[str, list[str]] = {
|
||||
"nrf52840": [
|
||||
"VARIANT_MCK — nRF52 BSP clock constant (e.g., 64000000ul); always inherited from BSP unless overridden.",
|
||||
"USE_LFXO — low-frequency crystal oscillator selection; declared locally only when the board uses LFXO rather than the RC oscillator.",
|
||||
"PIN_SPI_* / PIN_SPI1_* — SPI bus pin numbers come from the BSP variant table; boards override only when the LoRa radio or display uses non-default SPI routing.",
|
||||
"WIRE_INTERFACES_COUNT / SPI_INTERFACES_COUNT — bus count comes from BSP; explicitly set only when the board deviates.",
|
||||
"LED_BLUE / PIN_LED1 / PINS_COUNT / NUM_DIGITAL_PINS — standard BSP pin-table entries inherited from the nRF52 Arduino core.",
|
||||
],
|
||||
"rp2040": [
|
||||
"PIN_SPI_* — primary SPI pins come from the RP2040 Arduino BSP; most boards declare them explicitly, but the defaults align with the Pico pin assignments.",
|
||||
"NUM_DIGITAL_PINS / NUM_ANALOG_INPUTS — Arduino BSP counts; rarely overridden locally.",
|
||||
],
|
||||
"stm32": [
|
||||
"Radio and pin assignments for STM32WL targets are largely internal to the WL SoC and declared via STM32 HAL/BSP headers; variant.h files are minimal.",
|
||||
"USE_STM32WLx is typically the only explicit define; all other radio config comes from the BSP.",
|
||||
],
|
||||
"native": [
|
||||
"The native/Portduino target uses runtime configuration rather than compile-time pin defines; variant.h only sets display and GPS stubs.",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def collect_inventory() -> dict[str, object]:
|
||||
architectures: defaultdict[str, list[dict[str, object]]] = defaultdict(list)
|
||||
category_frequency: defaultdict[str, Counter[str]] = defaultdict(Counter)
|
||||
total_variant_dirs = 0
|
||||
total_envs = 0
|
||||
no_variant_h: defaultdict[str, int] = defaultdict(int) # arch -> count of dirs with no variant.h
|
||||
has_metadata: int = 0 # env count that has at least one custom_meshtastic_* key
|
||||
|
||||
for platformio_path in sorted(VARIANTS_DIR.glob("**/platformio.ini")):
|
||||
variant_dir = platformio_path.parent
|
||||
total_variant_dirs += 1
|
||||
variant_path = variant_dir / "variant.h"
|
||||
board_level_arch_raw = variant_dir.parts[-2] if len(variant_dir.parts) >= 2 else "unknown"
|
||||
if not variant_path.exists():
|
||||
no_variant_h[board_level_arch_raw] += 1
|
||||
continue
|
||||
|
||||
envs = parse_platformio_envs(platformio_path)
|
||||
defines, category_counts = parse_variant_macros(variant_path)
|
||||
total_envs += len(envs)
|
||||
|
||||
board_level_arch = board_level_arch_raw
|
||||
for env in envs:
|
||||
section = str(env["section"])
|
||||
if not section.startswith("env:"):
|
||||
continue
|
||||
metadata = dict(env["metadata"])
|
||||
if metadata:
|
||||
has_metadata += 1
|
||||
architecture = str(metadata.get("custom_meshtastic_architecture") or board_level_arch)
|
||||
if architecture == "esp32s3":
|
||||
architecture = "esp32-s3"
|
||||
if architecture == "esp32c3":
|
||||
architecture = "esp32-c3"
|
||||
if architecture == "esp32c6":
|
||||
architecture = "esp32-c6"
|
||||
|
||||
entry = {
|
||||
"environment": section.split(":", 1)[1],
|
||||
"variant_dir": str(variant_dir.relative_to(ROOT)),
|
||||
"metadata": metadata,
|
||||
"defines": defines,
|
||||
"category_counts": category_counts,
|
||||
}
|
||||
architectures[architecture].append(entry)
|
||||
|
||||
for name in defines:
|
||||
category = classify_macro(name)
|
||||
category_frequency[category][name] += 1
|
||||
|
||||
return {
|
||||
"architectures": architectures,
|
||||
"category_frequency": category_frequency,
|
||||
"total_variant_dirs": total_variant_dirs,
|
||||
"total_envs": total_envs,
|
||||
"no_variant_h": dict(no_variant_h),
|
||||
"has_metadata": has_metadata,
|
||||
}
|
||||
|
||||
|
||||
def render_markdown(inventory: dict[str, object]) -> str:
|
||||
architectures = inventory["architectures"]
|
||||
category_frequency = inventory["category_frequency"]
|
||||
lines: list[str] = []
|
||||
|
||||
lines.append("# Hardware Support Context")
|
||||
lines.append("")
|
||||
lines.append("This document inventories the board-support inputs and common target-definition fields")
|
||||
lines.append("currently used in the Meshtastic firmware repository. It is intended as reusable context")
|
||||
lines.append("for adding new hardware support without re-discovering naming patterns, metadata keys, and")
|
||||
lines.append("frequently used pin or capability macros from scratch.")
|
||||
lines.append("")
|
||||
lines.append("## Scope")
|
||||
lines.append("")
|
||||
lines.append(f"- Variant directories scanned: {inventory['total_variant_dirs']}")
|
||||
lines.append(f"- PlatformIO environments summarized: {inventory['total_envs']}")
|
||||
lines.append("- Sources: `variants/**/platformio.ini` and `variants/**/variant.h`")
|
||||
lines.append("- Notes: This inventory reflects explicit per-variant declarations. Some boards also inherit")
|
||||
lines.append(" defaults from architecture headers or shared base environments, which must still be checked")
|
||||
lines.append(" before creating new hardware support.")
|
||||
lines.append("")
|
||||
lines.append("## Repository Metadata Inputs")
|
||||
lines.append("")
|
||||
lines.append("The following `custom_meshtastic_*` metadata keys are already used across board environments:")
|
||||
lines.append("")
|
||||
for key in PLATFORMIO_METADATA_KEYS:
|
||||
lines.append(f"- `{key}`")
|
||||
lines.append("")
|
||||
lines.append("## Common Target-Definition Categories")
|
||||
lines.append("")
|
||||
for category in ["Input", "Radio", "GPS", "Display", "I2C/SPI", "Power", "Connectivity/Other", "Other"]:
|
||||
counter = category_frequency.get(category, Counter())
|
||||
if not counter:
|
||||
continue
|
||||
lines.append(f"### {category}")
|
||||
lines.append("")
|
||||
lines.append("| Macro | Used in | Description |")
|
||||
lines.append("| --- | --- | --- |")
|
||||
for name, count in counter.most_common(15):
|
||||
description = MACRO_DESCRIPTIONS.get(name, "")
|
||||
lines.append(f"| `{name}` | {count} variants | {description} |")
|
||||
lines.append("")
|
||||
|
||||
lines.append("## Architecture and Environment Inventory")
|
||||
lines.append("")
|
||||
for architecture in sorted(architectures):
|
||||
entries = sorted(architectures[architecture], key=lambda item: item["environment"])
|
||||
lines.append(f"### {architecture}")
|
||||
lines.append("")
|
||||
lines.append("| Environment | Display Name | HW Model | HW Slug | Variant Dir | Common Categories |")
|
||||
lines.append("| --- | --- | --- | --- | --- | --- |")
|
||||
for entry in entries:
|
||||
metadata = entry["metadata"]
|
||||
category_counts = entry["category_counts"]
|
||||
categories = []
|
||||
for category in ["Display", "Radio", "Input", "GPS", "Power", "Connectivity/Other"]:
|
||||
count = category_counts.get(category, 0)
|
||||
if count:
|
||||
categories.append(f"{category}:{count}")
|
||||
category_summary = ", ".join(categories) if categories else "none"
|
||||
lines.append(
|
||||
"| {environment} | {display_name} | {hw_model} | {hw_slug} | {variant_dir} | {categories} |".format(
|
||||
environment=entry["environment"],
|
||||
display_name=metadata.get("custom_meshtastic_display_name", ""),
|
||||
hw_model=metadata.get("custom_meshtastic_hw_model", ""),
|
||||
hw_slug=metadata.get("custom_meshtastic_hw_model_slug", ""),
|
||||
variant_dir=entry["variant_dir"],
|
||||
categories=category_summary,
|
||||
)
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
lines.append("## Representative Board Examples")
|
||||
lines.append("")
|
||||
for architecture in sorted(architectures):
|
||||
entries = sorted(architectures[architecture], key=lambda item: item["environment"])
|
||||
if not entries:
|
||||
continue
|
||||
sample = entries[0]
|
||||
lines.append(f"### {architecture}: `{sample['environment']}`")
|
||||
lines.append("")
|
||||
lines.append(f"- Variant directory: `{sample['variant_dir']}`")
|
||||
metadata = sample["metadata"]
|
||||
for key in PLATFORMIO_METADATA_KEYS:
|
||||
value = metadata.get(key)
|
||||
if value:
|
||||
lines.append(f"- `{key}`: `{value}`")
|
||||
defines = sample["defines"]
|
||||
for category in ["Input", "Radio", "GPS", "Display", "I2C/SPI", "Power", "Connectivity/Other"]:
|
||||
selected = [name for name in defines if classify_macro(name) == category][:8]
|
||||
if not selected:
|
||||
continue
|
||||
lines.append(f"- {category} examples:")
|
||||
for name in selected:
|
||||
lines.append(f" - `{name}` = `{shorten(defines[name])}`")
|
||||
lines.append("")
|
||||
|
||||
lines.append("## Intake Guidance For New Hardware")
|
||||
lines.append("")
|
||||
lines.append("When using this context to add a new board, collect these inputs before generating files:")
|
||||
lines.append("")
|
||||
lines.append("- PlatformIO environment name")
|
||||
lines.append("- `custom_meshtastic_hw_model` and `custom_meshtastic_hw_model_slug`")
|
||||
lines.append("- Display name and architecture")
|
||||
lines.append("- Whether the board is actively supported and its support level")
|
||||
lines.append("- Partition scheme, DFU requirement, and image/tag metadata if applicable")
|
||||
lines.append("- Radio chip family and complete radio pin group")
|
||||
lines.append("- Input/button/rotary/keyboard pins")
|
||||
lines.append("- Display interface pins and driver-related macros")
|
||||
lines.append("- GPS, power-management, I2C, SPI, storage, and auxiliary peripheral definitions")
|
||||
lines.append("- Any board-specific initialization that requires `variant.cpp` or extra variant hooks")
|
||||
lines.append("")
|
||||
lines.append("## Inherited Defaults Note")
|
||||
lines.append("")
|
||||
lines.append(
|
||||
"Some architecture families rely on BSP (Board Support Package) or base-environment defaults "
|
||||
"rather than declaring every pin or capability macro explicitly in `variant.h`. "
|
||||
"When adding a new board for one of these families, check the relevant BSP headers before "
|
||||
"assuming a missing define means a feature is absent."
|
||||
)
|
||||
lines.append("")
|
||||
no_variant_h = inventory.get("no_variant_h", {})
|
||||
if no_variant_h:
|
||||
lines.append(
|
||||
f"Directories scanned that had no `variant.h` "
|
||||
f"(relying entirely on BSP/base-environment): "
|
||||
+ ", ".join(f"{arch}: {count}" for arch, count in sorted(no_variant_h.items()))
|
||||
)
|
||||
lines.append("")
|
||||
for family, notes in _BSP_DEFAULT_FAMILIES.items():
|
||||
lines.append(f"### {family}")
|
||||
lines.append("")
|
||||
for note in notes:
|
||||
lines.append(f"- {note}")
|
||||
lines.append("")
|
||||
lines.append("## Cautions")
|
||||
lines.append("")
|
||||
lines.append("- Some boards rely on architecture defaults rather than declaring every field locally.")
|
||||
lines.append("- Some board families expose multiple environments or display variants that share one hardware model.")
|
||||
lines.append("- Source materials such as schematics still need human verification before new pin mappings are trusted.")
|
||||
lines.append("- This document is a starting context artifact, not proof that a new board definition is safe to merge.")
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def print_validate_summary(inventory: dict[str, object]) -> None:
|
||||
"""Print a validation summary to stdout without writing the output file."""
|
||||
total_dirs = inventory["total_variant_dirs"]
|
||||
total_envs = inventory["total_envs"]
|
||||
has_metadata = inventory["has_metadata"]
|
||||
no_variant_h = inventory.get("no_variant_h", {})
|
||||
no_variant_h_total = sum(no_variant_h.values())
|
||||
architectures = inventory["architectures"]
|
||||
arch_count = len(architectures)
|
||||
|
||||
print("Hardware Support Context — Validation Summary")
|
||||
print("=" * 48)
|
||||
print(f"Variant directories scanned : {total_dirs}")
|
||||
print(f"Directories with no variant.h (BSP-only) : {no_variant_h_total}")
|
||||
if no_variant_h:
|
||||
for arch, count in sorted(no_variant_h.items()):
|
||||
print(f" {arch}: {count}")
|
||||
print(f"PlatformIO environments found : {total_envs}")
|
||||
print(f"Environments with custom_meshtastic_* metadata : {has_metadata}")
|
||||
print(f"Architecture families represented : {arch_count}")
|
||||
print("")
|
||||
print("Architecture families:")
|
||||
for arch in sorted(architectures):
|
||||
entries = architectures[arch]
|
||||
print(f" {arch}: {len(entries)} environment(s)")
|
||||
print("")
|
||||
print("BSP-default families with special notes:")
|
||||
for family in _BSP_DEFAULT_FAMILIES:
|
||||
marker = "YES" if family in {a.split("-")[0] for a in architectures} else "not in scan"
|
||||
print(f" {family}: {marker}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Generate hardware support context markdown")
|
||||
parser.add_argument("--output", default=str(OUTPUT_PATH), help="Output markdown path")
|
||||
parser.add_argument(
|
||||
"--validate",
|
||||
action="store_true",
|
||||
help="Print a validation summary (variant counts, BSP-only dirs, metadata coverage) without writing output",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
inventory = collect_inventory()
|
||||
|
||||
if args.validate:
|
||||
print_validate_summary(inventory)
|
||||
return
|
||||
|
||||
markdown = render_markdown(inventory)
|
||||
output_path = Path(args.output)
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_text(markdown + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -87,6 +87,9 @@
|
||||
</screenshots>
|
||||
|
||||
<releases>
|
||||
<release version="2.7.24" date="2026-05-08">
|
||||
<url type="details">https://github.com/meshtastic/firmware/releases?q=tag%3Av2.7.24</url>
|
||||
</release>
|
||||
<release version="2.7.23" date="2026-04-14">
|
||||
<url type="details">https://github.com/meshtastic/firmware/releases?q=tag%3Av2.7.23</url>
|
||||
</release>
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"build": {
|
||||
"arduino": {
|
||||
"ldscript": "esp32s3_out.ld",
|
||||
"memory_type": "qio_opi"
|
||||
},
|
||||
"core": "esp32",
|
||||
"extra_flags": [
|
||||
"-D BOARD_HAS_PSRAM",
|
||||
"-D ARDUINO_USB_CDC_ON_BOOT=0",
|
||||
"-D ARDUINO_USB_MODE=0",
|
||||
"-D ARDUINO_RUNNING_CORE=1",
|
||||
"-D ARDUINO_EVENT_RUNNING_CORE=0"
|
||||
],
|
||||
"f_cpu": "240000000L",
|
||||
"f_flash": "80000000L",
|
||||
"flash_mode": "qio",
|
||||
"psram_type": "qio_opi",
|
||||
"hwids": [["0x303A", "0x1001"]],
|
||||
"mcu": "esp32s3",
|
||||
"variant": "ELECROW-ThinkNode-M7"
|
||||
},
|
||||
"connectivity": ["wifi", "bluetooth", "lora"],
|
||||
"debug": {
|
||||
"default_tool": "esp-builtin",
|
||||
"onboard_tools": ["esp-builtin"],
|
||||
"openocd_target": "esp32s3.cfg"
|
||||
},
|
||||
"frameworks": ["arduino", "espidf"],
|
||||
"name": "ELECROW ThinkNode M7",
|
||||
"upload": {
|
||||
"flash_size": "8MB",
|
||||
"maximum_ram_size": 524288,
|
||||
"maximum_size": 8388608,
|
||||
"use_1200bps_touch": true,
|
||||
"wait_for_upload_port": true,
|
||||
"require_upload_port": true,
|
||||
"speed": 921600
|
||||
},
|
||||
"url": "https://www.elecrow.com",
|
||||
"vendor": "ELECROW"
|
||||
}
|
||||
Vendored
+6
@@ -1,3 +1,9 @@
|
||||
meshtasticd (2.7.24.0) unstable; urgency=medium
|
||||
|
||||
* Version 2.7.24
|
||||
|
||||
-- GitHub Actions <github-actions[bot]@users.noreply.github.com> Fri, 08 May 2026 10:44:12 +0000
|
||||
|
||||
meshtasticd (2.7.23.0) unstable; urgency=medium
|
||||
|
||||
* Version 2.7.23
|
||||
|
||||
@@ -1,742 +0,0 @@
|
||||
# Hardware Support Context
|
||||
|
||||
This document inventories the board-support inputs and common target-definition fields
|
||||
currently used in the Meshtastic firmware repository. It is intended as reusable context
|
||||
for adding new hardware support without re-discovering naming patterns, metadata keys, and
|
||||
frequently used pin or capability macros from scratch.
|
||||
|
||||
## Scope
|
||||
|
||||
- Variant directories scanned: 166
|
||||
- PlatformIO environments summarized: 212
|
||||
- Sources: `variants/**/platformio.ini` and `variants/**/variant.h`
|
||||
- Notes: This inventory reflects explicit per-variant declarations. Some boards also inherit
|
||||
defaults from architecture headers or shared base environments, which must still be checked
|
||||
before creating new hardware support.
|
||||
|
||||
## Repository Metadata Inputs
|
||||
|
||||
The following `custom_meshtastic_*` metadata keys are already used across board environments:
|
||||
|
||||
- `custom_meshtastic_hw_model`
|
||||
- `custom_meshtastic_hw_model_slug`
|
||||
- `custom_meshtastic_architecture`
|
||||
- `custom_meshtastic_actively_supported`
|
||||
- `custom_meshtastic_support_level`
|
||||
- `custom_meshtastic_display_name`
|
||||
- `custom_meshtastic_images`
|
||||
- `custom_meshtastic_tags`
|
||||
- `custom_meshtastic_requires_dfu`
|
||||
- `custom_meshtastic_partition_scheme`
|
||||
|
||||
## Common Target-Definition Categories
|
||||
|
||||
### Input
|
||||
|
||||
| Macro | Used in | Description |
|
||||
| ---------------------------------- | ------------ | --------------------------------------------------------------------------- |
|
||||
| `BUTTON_PIN` | 127 variants | Primary user button GPIO pin number. |
|
||||
| `ALT_BUTTON_PIN` | 12 variants | Secondary / alternate button pin, used on boards with two buttons. |
|
||||
| `CANCEL_BUTTON_PIN` | 8 variants | Button wired to cancel/back actions (e.g., T-Deck cancel key). |
|
||||
| `KB_BL_PIN` | 5 variants | Keyboard backlight control pin (e.g., T-LoRa Pager keyboard). |
|
||||
| `KB_INT` | 4 variants | Keyboard interrupt input pin; signals a keypress to the MCU. |
|
||||
| `INPUTDRIVER_ENCODER_TYPE` | 3 variants | Selects the rotary encoder driver variant (0 = none, 1+ = specific type). |
|
||||
| `BUTTON_PIN_ALT` | 2 variants | Alternative spelling used by some older board families for a second button. |
|
||||
| `INPUTDRIVER_TWO_WAY_ROCKER` | 2 variants | Enables the two-way rocker input driver. |
|
||||
| `INPUTDRIVER_TWO_WAY_ROCKER_RIGHT` | 2 variants | GPIO pin for the rightward rocker direction. |
|
||||
| `INPUTDRIVER_TWO_WAY_ROCKER_LEFT` | 2 variants | GPIO pin for the leftward rocker direction. |
|
||||
| `INPUTDRIVER_TWO_WAY_ROCKER_BTN` | 2 variants | GPIO pin for the rocker click/press direction. |
|
||||
| `KB_POWERON` | 2 variants | Pin used to power-on or enable the keyboard peripheral. |
|
||||
| `KB_SLAVE_ADDRESS` | 2 variants | I2C address of the keyboard controller IC. |
|
||||
| `ROTARY_A` | 2 variants | First encoder channel pin (clock / A signal). |
|
||||
| `ROTARY_B` | 2 variants | Second encoder channel pin (data / B signal). |
|
||||
|
||||
### Radio
|
||||
|
||||
| Macro | Used in | Description |
|
||||
| -------------------------- | ------------ | ----------------------------------------------------------------------------- |
|
||||
| `SX126X_CS` | 150 variants | SX126x chip-select pin (often same as LORA_CS). |
|
||||
| `SX126X_RESET` | 150 variants | SX126x reset pin (often same as LORA_RESET). |
|
||||
| `SX126X_BUSY` | 150 variants | SX126x BUSY pin; must be polled low before issuing SPI commands. |
|
||||
| `SX126X_DIO1` | 150 variants | SX126x DIO1 interrupt output used for all IRQ events. |
|
||||
| `USE_SX1262` | 145 variants | Select the SX1262 sub-GHz LoRa chip driver. |
|
||||
| `SX126X_DIO3_TCXO_VOLTAGE` | 140 variants | Drives the TCXO regulator via DIO3; set to the supply voltage (e.g., 1.8). |
|
||||
| `LORA_CS` | 137 variants | LoRa radio SPI chip-select GPIO pin. |
|
||||
| `LORA_RESET` | 135 variants | LoRa radio hardware-reset GPIO pin (active low). |
|
||||
| `SX126X_DIO2_AS_RF_SWITCH` | 135 variants | Set to 1 so the driver controls the TX/RX RF switch via DIO2. |
|
||||
| `LORA_DIO1` | 130 variants | LoRa radio DIO1 interrupt pin; on SX126x this is the primary IRQ line. |
|
||||
| `LORA_SCK` | 125 variants | LoRa radio SPI clock pin. |
|
||||
| `LORA_MISO` | 125 variants | LoRa radio SPI MISO pin. |
|
||||
| `LORA_MOSI` | 125 variants | LoRa radio SPI MOSI pin. |
|
||||
| `LORA_DIO2` | 110 variants | LoRa radio DIO2 pin; on RF95 used for FSK interrupt; on SX126x tx/rx control. |
|
||||
| `LORA_DIO0` | 98 variants | LoRa radio DIO0 interrupt pin (RF95/SX1276 done/RxDone). |
|
||||
|
||||
### GPS
|
||||
|
||||
| Macro | Used in | Description |
|
||||
| ------------------------- | ------------ | ------------------------------------------------------------------------------ |
|
||||
| `GPS_RX_PIN` | 114 variants | UART RX pin connected to the GPS module TX output. |
|
||||
| `GPS_TX_PIN` | 111 variants | UART TX pin connected to the GPS module RX input. |
|
||||
| `HAS_GPS` | 69 variants | Set to 1 if the board has an on-board GPS receiver; 0 to disable GPS entirely. |
|
||||
| `PIN_GPS_PPS` | 43 variants | GPS pulse-per-second input pin for timing synchronisation. |
|
||||
| `PIN_GPS_EN` | 30 variants | GPIO to power-enable or power-gate the GPS module. |
|
||||
| `PIN_GPS_STANDBY` | 29 variants | Places the GPS into standby/low-power mode when driven. |
|
||||
| `GPS_THREAD_INTERVAL` | 28 variants | Millisecond poll interval for the GPS background thread. |
|
||||
| `GPS_L76K` | 27 variants | Selects the Quectel L76K GPS driver and protocol. |
|
||||
| `GPS_BAUDRATE` | 27 variants | UART baud rate for GPS serial communication. |
|
||||
| `GPS_EN_ACTIVE` | 17 variants | Logic level (HIGH or LOW) that enables the GPS power pin. |
|
||||
| `PIN_GPS_RESET` | 16 variants | Hardware-reset line to the GPS module. |
|
||||
| `GPS_DEFAULT_NOT_PRESENT` | 16 variants | Compiled-in default assuming no GPS; overridden at runtime if detected. |
|
||||
| `GPS_RESET_MODE` | 13 variants | Defines the reset signal polarity or protocol for the GPS chip. |
|
||||
| `GPS_UBLOX` | 8 variants | Selects the u-blox GPS driver and UBX protocol. |
|
||||
| `PIN_GPS_REINIT` | 7 variants | Pin used to trigger GPS re-initialisation sequences. |
|
||||
|
||||
### Display
|
||||
|
||||
| Macro | Used in | Description |
|
||||
| ----------------------------- | ----------- | ------------------------------------------------------------------------ |
|
||||
| `PIN_EINK_CS` | 51 variants | E-ink display SPI chip-select pin. |
|
||||
| `PIN_EINK_BUSY` | 51 variants | E-ink display BUSY output; high when a page update is in progress. |
|
||||
| `PIN_EINK_DC` | 51 variants | E-ink display data/command select pin. |
|
||||
| `PIN_EINK_RES` | 51 variants | E-ink display hardware-reset pin. |
|
||||
| `PIN_EINK_SCLK` | 49 variants | E-ink display SPI clock pin. |
|
||||
| `PIN_EINK_MOSI` | 49 variants | E-ink display SPI MOSI pin. |
|
||||
| `HAS_SCREEN` | 34 variants | Set to 1 if the board has any display hardware. |
|
||||
| `USE_TFTDISPLAY` | 27 variants | Selects the TFT LCD driver path. |
|
||||
| `TFT_HEIGHT` | 25 variants | Vertical pixel resolution of the TFT display. |
|
||||
| `TFT_WIDTH` | 25 variants | Horizontal pixel resolution of the TFT display. |
|
||||
| `SCREEN_TRANSITION_FRAMERATE` | 23 variants | Target framerate for UI animations and transitions. |
|
||||
| `TFT_OFFSET_X` | 22 variants | Horizontal pixel offset for display alignment correction. |
|
||||
| `TFT_OFFSET_Y` | 22 variants | Vertical pixel offset for display alignment correction. |
|
||||
| `SCREEN_ROTATE` | 18 variants | Non-zero value rotates the display 180° for mounted-upside-down screens. |
|
||||
| `TFT_BL` | 15 variants | Backlight control PWM or GPIO pin for the TFT panel. |
|
||||
|
||||
### I2C/SPI
|
||||
|
||||
| Macro | Used in | Description |
|
||||
| ----------------------- | ------------ | ----------------------------------------------------- |
|
||||
| `I2C_SDA` | 103 variants | Primary I2C data line GPIO pin. |
|
||||
| `I2C_SCL` | 103 variants | Primary I2C clock line GPIO pin. |
|
||||
| `PIN_SPI_MISO` | 72 variants | Primary SPI MISO (data from peripheral) GPIO pin. |
|
||||
| `PIN_SPI_MOSI` | 72 variants | Primary SPI MOSI (data to peripheral) GPIO pin. |
|
||||
| `PIN_SPI_SCK` | 72 variants | Primary SPI clock GPIO pin. |
|
||||
| `SPI_INTERFACES_COUNT` | 66 variants | Number of hardware SPI buses available on this board. |
|
||||
| `WIRE_INTERFACES_COUNT` | 64 variants | Number of hardware I2C buses available on this board. |
|
||||
| `PIN_SPI1_MISO` | 35 variants | Secondary SPI bus MISO pin. |
|
||||
| `PIN_SPI1_MOSI` | 35 variants | Secondary SPI bus MOSI pin. |
|
||||
| `PIN_SPI1_SCK` | 35 variants | Secondary SPI bus clock pin. |
|
||||
| `SPI_FREQUENCY` | 24 variants | Default SPI clock frequency in Hz for this board. |
|
||||
| `SPI_READ_FREQUENCY` | 21 variants | Reduced SPI clock rate used for read transactions. |
|
||||
| `SPI_SCK` | 19 variants | SPI clock pin alias used in older board files. |
|
||||
| `SPI_MOSI` | 19 variants | SPI MOSI pin alias used in older board files. |
|
||||
| `SPI_MISO` | 19 variants | SPI MISO pin alias used in older board files. |
|
||||
|
||||
### Power
|
||||
|
||||
| Macro | Used in | Description |
|
||||
| ------------------------------- | ------------ | -------------------------------------------------------------------------- |
|
||||
| `BATTERY_PIN` | 128 variants | ADC input GPIO connected to the battery voltage divider. |
|
||||
| `ADC_MULTIPLIER` | 122 variants | Floating-point scale factor to convert raw ADC reading to battery voltage. |
|
||||
| `ADC_CHANNEL` | 71 variants | ADC channel enum or number for the battery sense input. |
|
||||
| `BATTERY_SENSE_RESOLUTION_BITS` | 64 variants | ADC resolution in bits used for battery voltage sampling. |
|
||||
| `ADC_RESOLUTION` | 49 variants | Board-level ADC resolution definition, referenced by other power macros. |
|
||||
| `BATTERY_SENSE_RESOLUTION` | 44 variants | Alias for the effective ADC resolution for battery sense. |
|
||||
| `ADC_CTRL` | 29 variants | GPIO that enables or gates the ADC voltage-divider circuit. |
|
||||
| `ADC_CTRL_ENABLED` | 27 variants | Logic level (HIGH or LOW) that turns on the ADC control switch. |
|
||||
| `EXT_NOTIFY_OUT` | 23 variants | GPIO output used to signal an external LED or buzzer for notifications. |
|
||||
| `USE_POWERSAVE` | 21 variants | Enables aggressive power-save mode (deep sleep, reduced poll intervals). |
|
||||
| `SLEEP_TIME` | 21 variants | Default light-sleep duration in milliseconds between wakeups. |
|
||||
| `ADC_ATTENUATION` | 20 variants | ESP32 ADC input attenuation setting; controls measurable voltage range. |
|
||||
| `PIN_POWER_EN` | 18 variants | GPIO to assert to enable a board power rail or load switch. |
|
||||
| `BATTERY_SENSE_SAMPLES` | 11 variants | Number of ADC samples to average for a stable battery reading. |
|
||||
| `HAS_PPM` | 4 variants | Set to 1 if the board has an IP5306 or similar PPM power path IC. |
|
||||
|
||||
### Connectivity/Other
|
||||
|
||||
| Macro | Used in | Description |
|
||||
| ----------------- | ----------- | ------------------------------------------------------------------- |
|
||||
| `HAS_TOUCHSCREEN` | 19 variants | Set to 1 for boards with a capacitive or resistive touch panel. |
|
||||
| `HAS_NEOPIXEL` | 14 variants | Set to 1 if the board has addressable RGB LEDs (WS2812 / NeoPixel). |
|
||||
| `PCF8563_RTC` | 11 variants | I2C address of the PCF8563 real-time clock IC. |
|
||||
| `HAS_ETHERNET` | 9 variants | Set to 1 for boards with a wired Ethernet interface. |
|
||||
| `HAS_I2S` | 7 variants | Set to 1 if I2S audio output is present. |
|
||||
| `PCF85063_RTC` | 3 variants | I2C address of the PCF85063 real-time clock IC. |
|
||||
| `NFC_INT` | 2 variants | Interrupt pin from the NFC controller IC. |
|
||||
| `NFC_CS` | 2 variants | SPI chip-select for the NFC controller. |
|
||||
| `USE_XL9555` | 2 variants | Enables the XL9555 16-bit I2C GPIO expander driver. |
|
||||
| `EXPANDS_DRV_EN` | 2 variants | GPIO expander pin used to enable the haptic driver. |
|
||||
| `EXPANDS_AMP_EN` | 2 variants | GPIO expander pin used to power-on the audio amplifier. |
|
||||
| `EXPANDS_KB_RST` | 2 variants | GPIO expander pin used to reset the keyboard controller. |
|
||||
| `EXPANDS_LORA_EN` | 2 variants | GPIO expander pin used to power-gate the LoRa radio. |
|
||||
| `EXPANDS_GPS_EN` | 2 variants | GPIO expander pin used to power-gate the GPS module. |
|
||||
| `EXPANDS_NFC_EN` | 2 variants | GPIO expander pin used to power-gate the NFC controller. |
|
||||
|
||||
### Other
|
||||
|
||||
| Macro | Used in | Description |
|
||||
| -------------------- | ----------- | -------------------------------------------------------------------------- |
|
||||
| `LED_POWER` | 94 variants | GPIO for the status LED, defines the LED pin number. |
|
||||
| `LED_STATE_ON` | 88 variants | Logic level (HIGH or LOW) that turns the status LED on. |
|
||||
| `PIN_SERIAL1_RX` | 67 variants | Secondary UART RX pin (used for accessories, GPS on some boards). |
|
||||
| `PIN_SERIAL1_TX` | 67 variants | Secondary UART TX pin. |
|
||||
| `PIN_WIRE_SDA` | 64 variants | Arduino-framework I2C SDA pin alias (nRF52 / RP2040 style). |
|
||||
| `PIN_WIRE_SCL` | 64 variants | Arduino-framework I2C SCL pin alias. |
|
||||
| `PIN_LED1` | 63 variants | First LED GPIO pin in the nRF52 Arduino BSP pin table. |
|
||||
| `VARIANT_MCK` | 63 variants | Crystal oscillator frequency in Hz for nRF52 variant clock configuration. |
|
||||
| `PINS_COUNT` | 63 variants | Total number of GPIO pins defined in the Arduino BSP variant table. |
|
||||
| `NUM_DIGITAL_PINS` | 63 variants | Count of digital-capable pins in the BSP variant table. |
|
||||
| `NUM_ANALOG_INPUTS` | 63 variants | Count of analog-input pins in the BSP variant table. |
|
||||
| `NUM_ANALOG_OUTPUTS` | 63 variants | Count of analog-output (DAC) pins in the BSP variant table. |
|
||||
| `LED_BLUE` | 62 variants | GPIO number of the blue status LED (typical on nRF52 and RP2040 boards). |
|
||||
| `USE_LFXO` | 58 variants | Instructs the nRF52 BSP to use the low-frequency crystal oscillator. |
|
||||
| `BUTTON_NEED_PULLUP` | 54 variants | Enables internal pull-up on the button GPIO (duplicate entry for clarity). |
|
||||
|
||||
## Architecture and Environment Inventory
|
||||
|
||||
### diy
|
||||
|
||||
| Environment | Display Name | HW Model | HW Slug | Variant Dir | Common Categories |
|
||||
| ------------------------- | ------------ | -------- | ------- | --------------------------------------------- | --------------------------------------------------------- |
|
||||
| 9m2ibr_aprs_lora_tracker | | | | variants/esp32/diy/9m2ibr_aprs_lora_tracker | Display:1, Radio:24, Input:1, GPS:2, Power:4 |
|
||||
| esp32c3_super_mini | | | | variants/esp32c3/diy/esp32c3_super_mini | Display:1, Radio:18, Input:1, GPS:2 |
|
||||
| meshtastic-diy-v1_1 | | | | variants/esp32/diy/v1_1 | Radio:24, Input:1, GPS:1, Power:1 |
|
||||
| my-esp32s3-diy-eink | | | | variants/esp32s3/diy/my_esp32s3_diy_eink | Display:6, Radio:17, Input:1, GPS:1, Connectivity/Other:1 |
|
||||
| my-esp32s3-diy-oled | | | | variants/esp32s3/diy/my_esp32s3_diy_oled | Display:1, Radio:17, Input:1, GPS:1, Connectivity/Other:1 |
|
||||
| nrf52_promicro_diy-inkhud | | | | variants/nrf52840/diy/nrf52_promicro_diy_tcxo | Display:4, Radio:29, Input:1, GPS:4, Power:6 |
|
||||
| t-energy-s3_e22 | | | | variants/esp32s3/diy/t-energy-s3_e22 | Display:1, Radio:18, Input:1, GPS:3, Power:3 |
|
||||
|
||||
### esp32
|
||||
|
||||
| Environment | Display Name | HW Model | HW Slug | Variant Dir | Common Categories |
|
||||
| --------------------------- | --------------------------- | -------- | --------------------------- | ------------------------------------------ | ------------------------------------------------------------------ |
|
||||
| betafpv_2400_tx_micro | | | | variants/esp32/betafpv_2400_tx_micro | Radio:14, Input:1, Connectivity/Other:1 |
|
||||
| betafpv_900_tx_nano | | | | variants/esp32/betafpv_900_tx_nano | Display:1, Radio:10, Input:1 |
|
||||
| chatter2 | | | | variants/esp32/chatter2 | Display:12, Radio:16, Input:3, GPS:3, Power:4 |
|
||||
| hackerboxes-esp32-io | | | | variants/esp32/hackerboxes_esp32_io | Display:1, Radio:16, Input:1, GPS:1 |
|
||||
| heltec-v1 | Heltec V1 | 11 | HELTEC_V1 | variants/esp32/heltec_v1 | Radio:5, Input:1, GPS:2, Power:3 |
|
||||
| heltec-v2_0 | Heltec V2.0 | 5 | HELTEC_V2_0 | variants/esp32/heltec_v2 | Radio:5, Input:1, GPS:2, Power:3 |
|
||||
| heltec-v2_1 | Heltec V2.1 | 10 | HELTEC_V2_1 | variants/esp32/heltec_v2.1 | Radio:5, Input:1, GPS:3, Power:4 |
|
||||
| heltec-wireless-bridge | | | | variants/esp32/heltec_wireless_bridge | Radio:9, GPS:1 |
|
||||
| heltec-wsl-v2_1 | | | | variants/esp32/heltec_wsl_v2.1 | Radio:9, Input:1, Power:5 |
|
||||
| hydra | Hydra | 39 | HYDRA | variants/esp32/diy/hydra | Radio:22, Input:1, GPS:3, Power:4 |
|
||||
| m5stack-core | M5 Stack | 42 | M5STACK | variants/esp32/m5stack_core | Display:7, Radio:9, Input:1, GPS:2 |
|
||||
| m5stack-coreink | | | | variants/esp32/m5stack_coreink | Display:7, Radio:34, Input:1, GPS:2, Power:3, Connectivity/Other:1 |
|
||||
| meshtastic-diy-v1 | DIY V1 | 39 | DIY_V1 | variants/esp32/diy/v1 | Radio:23, Input:1, GPS:3, Power:4 |
|
||||
| meshtastic-dr-dev | DR-DEV | 41 | DR_DEV | variants/esp32/diy/dr-dev | Display:1, Radio:26, Input:1, GPS:2, Power:3 |
|
||||
| nano-g1 | Nano G1 | 14 | NANO_G1 | variants/esp32/nano-g1 | Display:1, Radio:13, Input:1, GPS:2, Power:1 |
|
||||
| nano-g1-explorer | Nano G1 Explorer | 17 | NANO_G1_EXPLORER | variants/esp32/nano-g1-explorer | Display:1, Radio:13, Input:1, GPS:2, Power:5 |
|
||||
| radiomaster_900_bandit | | | | variants/esp32/radiomaster_900_bandit | Radio:19, Input:1, GPS:1, Connectivity/Other:1 |
|
||||
| radiomaster_900_bandit_nano | RadioMaster 900 Bandit Nano | 64 | RADIOMASTER_900_BANDIT_NANO | variants/esp32/radiomaster_900_bandit_nano | Display:1, Radio:19 |
|
||||
| rak11200 | RAK WisBlock 11200 | 13 | RAK11200 | variants/esp32/rak11200 | Radio:18, GPS:2, Power:2 |
|
||||
| station-g1 | Station G1 | 25 | STATION_G1 | variants/esp32/station-g1 | Display:1, Radio:13, Input:1, GPS:2, Power:5 |
|
||||
| sugarcube | | | | variants/esp32/tlora_v2_1_16 | Radio:6, Power:4 |
|
||||
| tbeam | LILYGO T-Beam | 4 | TBEAM | variants/esp32/tbeam | Display:4, Radio:14, Input:1, GPS:3, Power:2, Connectivity/Other:1 |
|
||||
| tbeam-displayshield | | | | variants/esp32/tbeam | Display:4, Radio:14, Input:1, GPS:3, Power:2, Connectivity/Other:1 |
|
||||
| tbeam0_7 | LILYGO T-Beam V0.7 | 6 | TBEAM_V0P7 | variants/esp32/tbeam_v07 | Radio:5, Input:1, GPS:3, Power:3 |
|
||||
| tlora-v1 | LILYGO T-LoRa V1 | 2 | TLORA_V1 | variants/esp32/tlora_v1 | Radio:5, Input:1, Power:1 |
|
||||
| tlora-v2 | LILYGO T-LoRa V2 | 1 | TLORA_V2 | variants/esp32/tlora_v2 | Radio:5, Input:1, Power:2 |
|
||||
| tlora-v2-1-1_6 | LILYGO T-LoRa V2.1-1.6 | 3 | TLORA_V2_1_1P6 | variants/esp32/tlora_v2_1_16 | Radio:6, Power:4 |
|
||||
| tlora-v2-1-1_8 | LILYGO T-LoRa V2.1-1.8 | 15 | TLORA_V2_1_1P8 | variants/esp32/tlora_v2_1_18 | Radio:6, Input:1, Power:3 |
|
||||
| tlora_v1_3 | | | | variants/esp32/tlora_v1_3 | Radio:5, Input:1, Power:2 |
|
||||
| trackerd | | | | variants/esp32/trackerd | Display:1, Radio:5, Input:1, GPS:9, Power:4 |
|
||||
| wiphone | | | | variants/esp32/wiphone | Display:9, Radio:9, GPS:1 |
|
||||
|
||||
### esp32-c3
|
||||
|
||||
| Environment | Display Name | HW Model | HW Slug | Variant Dir | Common Categories |
|
||||
| -------------------------- | ------------ | -------- | ----------- | ----------------------------------------- | ------------------------------------------------------------------ |
|
||||
| ai-c3 | | | | variants/esp32c3/ai-c3 | Radio:15, Input:1, GPS:1 |
|
||||
| hackerboxes-esp32c3-oled | | | | variants/esp32c3/hackerboxes_esp32c3_oled | Display:1, Radio:16, Input:1, GPS:1 |
|
||||
| heltec-hru-3601 | | | | variants/esp32c3/heltec_hru_3601 | Display:1, Radio:16, Input:1, GPS:1, Power:1, Connectivity/Other:1 |
|
||||
| heltec-ht62-esp32c3-sx1262 | Heltec HT62 | 53 | HELTEC_HT62 | variants/esp32c3/heltec_esp32c3 | Display:1, Radio:16, Input:1, GPS:1 |
|
||||
| m5stack-stamp-c3 | | | | variants/esp32c3/m5stack-stamp-c3 | Radio:9, Input:1, GPS:1 |
|
||||
|
||||
### esp32-c6
|
||||
|
||||
| Environment | Display Name | HW Model | HW Slug | Variant Dir | Common Categories |
|
||||
| --------------- | ---------------- | -------- | ----------- | -------------------------------- | ------------------------------------------------ |
|
||||
| m5stack-unitc6l | M5Stack Unit C6L | 111 | M5STACK_C6L | variants/esp32c6/m5stack_unitc6l | Display:1, Radio:14, GPS:3, Connectivity/Other:1 |
|
||||
| tlora-c6 | | | | variants/esp32c6/tlora_c6 | Radio:15 |
|
||||
|
||||
### esp32-s3
|
||||
|
||||
| Environment | Display Name | HW Model | HW Slug | Variant Dir | Common Categories |
|
||||
| -------------------------------- | ----------------------------- | -------- | ---------------------------- | --------------------------------------------- | -------------------------------------------------------------------- |
|
||||
| CDEBYTE_EoRa-Hub | | | | variants/esp32s3/CDEBYTE_EoRa-Hub | Display:2, Radio:14, Input:1, Power:6 |
|
||||
| CDEBYTE_EoRa-S3 | EBYTE EoRa-S3 | 61 | CDEBYTE_EORA_S3 | variants/esp32s3/CDEBYTE_EoRa-S3 | Display:2, Radio:13, Input:1, Power:3 |
|
||||
| EBYTE_ESP32-S3 | | | | variants/esp32s3/EBYTE_ESP32-S3 | Display:1, Radio:26, Input:1, GPS:5, Power:1 |
|
||||
| ESP32-S3-Pico | | | | variants/esp32s3/esp32-s3-pico | Display:6, Radio:18, Input:2, GPS:1, Power:4, Connectivity/Other:1 |
|
||||
| bpi_picow_esp32_s3 | | | | variants/esp32s3/bpi_picow_esp32_s3 | Display:1, Radio:25, Input:1, GPS:1 |
|
||||
| crowpanel-esp32s3-2-epaper | | | | variants/esp32s3/crowpanel-esp32s3-5-epaper | Display:6, Radio:21, Input:1, GPS:1, Power:1 |
|
||||
| crowpanel-esp32s3-4-epaper | | | | variants/esp32s3/crowpanel-esp32s3-5-epaper | Display:6, Radio:21, Input:1, GPS:1, Power:1 |
|
||||
| crowpanel-esp32s3-5-epaper | | | | variants/esp32s3/crowpanel-esp32s3-5-epaper | Display:6, Radio:21, Input:1, GPS:1, Power:1 |
|
||||
| dreamcatcher-2206 | | | | variants/esp32s3/dreamcatcher | Radio:16, Input:1, Power:1, Connectivity/Other:1 |
|
||||
| elecrow-adv-24-28-tft | Crowpanel Adv 2.4/2.8 TFT | 97 | CROWPANEL | variants/esp32s3/elecrow_panel | Display:1, Radio:21, GPS:6, Power:2 |
|
||||
| elecrow-adv-35-tft | Crowpanel Adv 3.5 TFT | 97 | CROWPANEL | variants/esp32s3/elecrow_panel | Display:1, Radio:21, GPS:6, Power:2 |
|
||||
| elecrow-adv1-43-50-70-tft | Crowpanel Adv 4.3/5.0/7.0 TFT | 97 | CROWPANEL | variants/esp32s3/elecrow_panel | Display:1, Radio:21, GPS:6, Power:2 |
|
||||
| hackaday-communicator | | | | variants/esp32s3/hackaday-communicator | Display:12, Radio:14, Input:1, GPS:1, Power:2 |
|
||||
| heltec-v3 | Heltec V3 | 43 | HELTEC_V3 | variants/esp32s3/heltec_v3 | Display:1, Radio:16, Input:1, Power:6 |
|
||||
| heltec-v4 | Heltec V4 | 110 | HELTEC_V4 | variants/esp32s3/heltec_v4 | Display:1, Radio:21, Input:1, GPS:10, Power:6 |
|
||||
| heltec-v4-tft | Heltec V4 TFT | 110 | HELTEC_V4 | variants/esp32s3/heltec_v4 | Display:1, Radio:21, Input:1, GPS:10, Power:6 |
|
||||
| heltec-vision-master-e213 | Heltec Vision Master E213 | 67 | HELTEC_VISION_MASTER_E213 | variants/esp32s3/heltec_vision_master_e213 | Display:6, Radio:15, Input:2, Power:6 |
|
||||
| heltec-vision-master-e213-inkhud | | | | variants/esp32s3/heltec_vision_master_e213 | Display:6, Radio:15, Input:2, Power:6 |
|
||||
| heltec-vision-master-e290 | Heltec Vision Master E290 | 68 | HELTEC_VISION_MASTER_E290 | variants/esp32s3/heltec_vision_master_e290 | Display:6, Radio:15, Input:2, Power:6 |
|
||||
| heltec-vision-master-e290-inkhud | | | | variants/esp32s3/heltec_vision_master_e290 | Display:6, Radio:15, Input:2, Power:6 |
|
||||
| heltec-vision-master-t190 | Heltec Vision Master T190 | 66 | HELTEC_VISION_MASTER_T190 | variants/esp32s3/heltec_vision_master_t190 | Display:6, Radio:16, Input:2, Power:6 |
|
||||
| heltec-wireless-paper | Heltec Wireless Paper | 49 | HELTEC_WIRELESS_PAPER | variants/esp32s3/heltec_wireless_paper | Display:6, Radio:15, Input:1, Power:6 |
|
||||
| heltec-wireless-paper-inkhud | | | | variants/esp32s3/heltec_wireless_paper | Display:6, Radio:15, Input:1, Power:6 |
|
||||
| heltec-wireless-paper-v1_0 | Heltec Wireless Paper V1.0 | 57 | HELTEC_WIRELESS_PAPER_V1_0 | variants/esp32s3/heltec_wireless_paper_v1 | Display:6, Radio:15, Input:1, Power:6 |
|
||||
| heltec-wireless-tracker | Heltec Wireless Tracker V1.1 | 48 | HELTEC_WIRELESS_TRACKER | variants/esp32s3/heltec_wireless_tracker | Display:9, Radio:16, Input:1, GPS:7, Power:6 |
|
||||
| heltec-wireless-tracker-V1-0 | Heltec Wireless Tracker V1.0 | 58 | HELTEC_WIRELESS_TRACKER_V1_0 | variants/esp32s3/heltec_wireless_tracker_V1_0 | Display:9, Radio:16, Input:1, GPS:9, Power:4 |
|
||||
| heltec-wireless-tracker-v2 | Heltec Wireless Tracker V2 | 113 | HELTEC_WIRELESS_TRACKER_V2 | variants/esp32s3/heltec_wireless_tracker_v2 | Display:10, Radio:19, Input:1, GPS:7, Power:6 |
|
||||
| heltec-wsl-v3 | Heltec Wireless Stick Lite V3 | 44 | HELTEC_WSL_V3 | variants/esp32s3/heltec_wsl_v3 | Radio:16, Input:1, Power:6 |
|
||||
| heltec_capsule_sensor_v3 | | | | variants/esp32s3/heltec_capsule_sensor_v3 | Display:1, Radio:16, Input:1, GPS:7, Power:6 |
|
||||
| heltec_sensor_hub | | | | variants/esp32s3/heltec_sensor_hub | Display:1, Radio:15, Input:1, Power:6, Connectivity/Other:1 |
|
||||
| icarus | | | | variants/esp32s3/icarus | Display:1, Radio:12, Input:1 |
|
||||
| link32-s3-v1 | | | | variants/esp32s3/link32_s3_v1 | Display:1, Radio:16, Input:2, Power:4, Connectivity/Other:1 |
|
||||
| m5stack-cardputer-adv | | | | variants/esp32s3/m5stack_cardputer_adv | Display:5, Radio:17, Input:2, GPS:4, Power:3, Connectivity/Other:2 |
|
||||
| m5stack-cores3 | | | | variants/esp32s3/m5stack_cores3 | Radio:11, Power:1 |
|
||||
| mesh-tab-3-2-IPS-capacitive | | | | variants/esp32s3/mesh-tab | Radio:18, Input:1, GPS:2, Power:5, Connectivity/Other:1 |
|
||||
| mesh-tab-3-2-IPS-resistive | | | | variants/esp32s3/mesh-tab | Radio:18, Input:1, GPS:2, Power:5, Connectivity/Other:1 |
|
||||
| mesh-tab-3-2-TN-resistive | | | | variants/esp32s3/mesh-tab | Radio:18, Input:1, GPS:2, Power:5, Connectivity/Other:1 |
|
||||
| mesh-tab-3-5-IPS-capacitive | | | | variants/esp32s3/mesh-tab | Radio:18, Input:1, GPS:2, Power:5, Connectivity/Other:1 |
|
||||
| mesh-tab-3-5-IPS-resistive | | | | variants/esp32s3/mesh-tab | Radio:18, Input:1, GPS:2, Power:5, Connectivity/Other:1 |
|
||||
| mesh-tab-3-5-TN-resistive | | | | variants/esp32s3/mesh-tab | Radio:18, Input:1, GPS:2, Power:5, Connectivity/Other:1 |
|
||||
| mesh-tab-4-0-IPS-capacitive | | | | variants/esp32s3/mesh-tab | Radio:18, Input:1, GPS:2, Power:5, Connectivity/Other:1 |
|
||||
| mini-epaper-s3 | LILYGO Mini ePaper S3 E-Ink | | MINI_EPAPER_S3 | variants/esp32s3/mini-epaper-s3 | Display:8, Radio:12, Input:5, GPS:1, Power:3, Connectivity/Other:1 |
|
||||
| mini-epaper-s3-inkhud | | | | variants/esp32s3/mini-epaper-s3 | Display:8, Radio:12, Input:5, GPS:1, Power:3, Connectivity/Other:1 |
|
||||
| nibble-esp32 | | | | variants/esp32s3/nibble_esp32 | Radio:9, Input:1 |
|
||||
| nugget-s3-lora | | | | variants/esp32s3/nugget_s3_lora | Display:2, Radio:9, Input:1, Connectivity/Other:1 |
|
||||
| picomputer-s3 | Pi Computer S3 | 52 | PICOMPUTER_S3 | variants/esp32s3/picomputer-s3 | Display:10, Radio:9, Input:1, Power:3 |
|
||||
| picomputer-s3-tft | | | | variants/esp32s3/picomputer-s3 | Display:10, Radio:9, Input:1, Power:3 |
|
||||
| rak3112 | | | | variants/esp32s3/rak3312 | Radio:13, GPS:3, Power:5 |
|
||||
| rak3312 | RAK3312 | 106 | RAK3312 | variants/esp32s3/rak3312 | Radio:13, GPS:3, Power:5 |
|
||||
| rak_wismesh_tap_v2 | RAK WisMesh Tap V2 | 116 | WISMESH_TAP_V2 | variants/esp32s3/rak_wismesh_tap_v2 | Radio:13, Input:1, GPS:3, Power:4 |
|
||||
| rak_wismesh_tap_v2-tft | | | | variants/esp32s3/rak_wismesh_tap_v2 | Radio:13, Input:1, GPS:3, Power:4 |
|
||||
| seeed-sensecap-indicator | Seeed SenseCAP Indicator | 70 | SENSECAP_INDICATOR | variants/esp32s3/seeed-sensecap-indicator | Display:12, Radio:17, Input:1, GPS:4, Connectivity/Other:1 |
|
||||
| seeed-sensecap-indicator-tft | | | | variants/esp32s3/seeed-sensecap-indicator | Display:12, Radio:17, Input:1, GPS:4, Connectivity/Other:1 |
|
||||
| seeed-xiao-s3 | Seeed Xiao ESP32-S3 | 81 | SEEED_XIAO_S3 | variants/esp32s3/seeed_xiao_s3 | Radio:16, Input:1, GPS:6, Power:3 |
|
||||
| station-g2 | Station G2 | 31 | STATION_G2 | variants/esp32s3/station-g2 | Display:1, Radio:14, Input:1, GPS:2, Power:4 |
|
||||
| t-beam-1w | LILYGO T-Beam 1W | 122 | TBEAM_1_WATT | variants/esp32s3/t-beam-1w | Display:3, Radio:19, Input:2, GPS:6, Power:5 |
|
||||
| t-deck | LILYGO T-Deck | 50 | T_DECK | variants/esp32s3/t-deck | Display:11, Radio:17, Input:3, GPS:3, Power:5, Connectivity/Other:2 |
|
||||
| t-deck-pro | LILYGO T-Deck Pro | 102 | T_DECK_PRO | variants/esp32s3/t-deck-pro | Display:6, Radio:18, Input:2, GPS:7, Power:5, Connectivity/Other:1 |
|
||||
| t-deck-tft | | | | variants/esp32s3/t-deck | Display:11, Radio:17, Input:3, GPS:3, Power:5, Connectivity/Other:2 |
|
||||
| t-eth-elite | | | | variants/esp32s3/t-eth-elite | Display:1, Radio:34, Input:1, GPS:5, Connectivity/Other:1 |
|
||||
| t-watch-s3 | LILYGO T-Watch S3 | 51 | T_WATCH_S3 | variants/esp32s3/t-watch-s3 | Display:12, Radio:17, Input:1, GPS:5, Power:3, Connectivity/Other:3 |
|
||||
| t5s3_epaper_inkhud | | | | variants/esp32s3/t5s3_epaper | Radio:21, Input:3, GPS:2, Power:5, Connectivity/Other:3 |
|
||||
| tbeam-s3-core | LILYGO T-Beam Supreme | 12 | LILYGO_TBEAM_S3_CORE | variants/esp32s3/tbeam-s3-core | Display:1, Radio:25, Input:1, GPS:4, Power:1, Connectivity/Other:2 |
|
||||
| thinknode_m2 | ThinkNode M2 | 90 | THINKNODE_M2 | variants/esp32s3/ELECROW-ThinkNode-M2 | Display:2, Radio:14, Input:2, GPS:1, Power:6 |
|
||||
| thinknode_m5 | ThinkNode M5 | 107 | THINKNODE_M5 | variants/esp32s3/ELECROW-ThinkNode-M5 | Display:6, Radio:14, Input:2, GPS:8, Power:4, Connectivity/Other:1 |
|
||||
| tlora-pager | LILYGO T-LoRa Pager | 103 | T_LORA_PAGER | variants/esp32s3/tlora-pager | Display:10, Radio:30, Input:6, GPS:5, Power:5, Connectivity/Other:17 |
|
||||
| tlora-pager-tft | | | | variants/esp32s3/tlora-pager | Display:10, Radio:30, Input:6, GPS:5, Power:5, Connectivity/Other:17 |
|
||||
| tlora-t3s3-epaper | LILYGO T-LoRa T3-S3 E-Ink | 16 | TLORA_T3_S3 | variants/esp32s3/tlora_t3s3_epaper | Display:6, Radio:28, Input:1, GPS:3, Power:3 |
|
||||
| tlora-t3s3-epaper-inkhud | | | | variants/esp32s3/tlora_t3s3_epaper | Display:6, Radio:28, Input:1, GPS:3, Power:3 |
|
||||
| tlora-t3s3-v1 | LILYGO T-LoRa T3-S3 | 16 | TLORA_T3_S3 | variants/esp32s3/tlora_t3s3_v1 | Display:1, Radio:36, Input:1, Power:3 |
|
||||
| unphone | unPhone | 59 | UNPHONE | variants/esp32s3/unphone | Display:9, Radio:9, Input:3, GPS:1, Power:2, Connectivity/Other:1 |
|
||||
| unphone-tft | | | | variants/esp32s3/unphone | Display:9, Radio:9, Input:3, GPS:1, Power:2, Connectivity/Other:1 |
|
||||
|
||||
### esp32s2
|
||||
|
||||
| Environment | Display Name | HW Model | HW Slug | Variant Dir | Common Categories |
|
||||
| -------------- | ------------ | -------- | ------- | ------------------------------- | -------------------------------------- |
|
||||
| nugget-s2-lora | | | | variants/esp32s2/nugget_s2_lora | Radio:9, Input:1, Connectivity/Other:1 |
|
||||
|
||||
### native
|
||||
|
||||
| Environment | Display Name | HW Model | HW Slug | Variant Dir | Common Categories |
|
||||
| ---------------- | ------------ | -------- | ------- | ----------------------------------- | ----------------- |
|
||||
| buildroot | | | | variants/native/portduino-buildroot | Display:2, GPS:1 |
|
||||
| coverage | | | | variants/native/portduino | Display:2, GPS:1 |
|
||||
| native | | | | variants/native/portduino | Display:2, GPS:1 |
|
||||
| native-fb | | | | variants/native/portduino | Display:2, GPS:1 |
|
||||
| native-tft | | | | variants/native/portduino | Display:2, GPS:1 |
|
||||
| native-tft-debug | | | | variants/native/portduino | Display:2, GPS:1 |
|
||||
|
||||
### nrf52840
|
||||
|
||||
| Environment | Display Name | HW Model | HW Slug | Variant Dir | Common Categories |
|
||||
| -------------------------------- | -------------------------- | -------- | ------------------------- | ---------------------------------------------- | --------------------------------------------------------- |
|
||||
| ME25LS01-4Y10TD | | | | variants/nrf52840/ME25LS01-4Y10TD | Radio:8, Input:1, GPS:8, Power:4 |
|
||||
| ME25LS01-4Y10TD_e-ink | | | | variants/nrf52840/ME25LS01-4Y10TD_e-ink | Display:6, Radio:8, Input:1, GPS:8, Power:4 |
|
||||
| TWC_mesh_v4 | | | | variants/nrf52840/TWC_mesh_v4 | Display:1, Radio:7, GPS:4, Power:5 |
|
||||
| canaryone | Canary One | 29 | CANARYONE | variants/nrf52840/canaryone | Radio:7, GPS:8, Power:5 |
|
||||
| feather_diy | | | | variants/nrf52840/feather_diy | Radio:17, Input:1 |
|
||||
| gat562_mesh_trial_tracker | | | | variants/nrf52840/gat562_mesh_trial_tracker | Display:2, Radio:8, GPS:4, Power:5 |
|
||||
| heltec-mesh-node-t096 | Heltec Mesh Node 096 | 127 | HELTEC_MESH_NODE_T096 | variants/nrf52840/heltec_mesh_node_t096 | Display:8, Radio:11, GPS:10, Power:9 |
|
||||
| heltec-mesh-node-t114 | Heltec Mesh Node T114 | 69 | HELTEC_MESH_NODE_T114 | variants/nrf52840/heltec_mesh_node_t114 | Display:7, Radio:8, GPS:7, Power:8, Connectivity/Other:1 |
|
||||
| heltec-mesh-node-t114-inkhud | | | | variants/nrf52840/heltec_mesh_node_t114-inkhud | Display:6, Radio:8, GPS:7, Power:7, Connectivity/Other:1 |
|
||||
| heltec-mesh-pocket-10000 | Heltec Mesh Pocket | 94 | HELTEC_MESH_POCKET | variants/nrf52840/heltec_mesh_pocket | Display:6, Radio:8, GPS:1, Power:7 |
|
||||
| heltec-mesh-pocket-10000-inkhud | Heltec Mesh Pocket | 94 | HELTEC_MESH_POCKET | variants/nrf52840/heltec_mesh_pocket | Display:6, Radio:8, GPS:1, Power:7 |
|
||||
| heltec-mesh-pocket-5000 | Heltec Mesh Pocket | 94 | HELTEC_MESH_POCKET | variants/nrf52840/heltec_mesh_pocket | Display:6, Radio:8, GPS:1, Power:7 |
|
||||
| heltec-mesh-pocket-5000-inkhud | Heltec Mesh Pocket | 94 | HELTEC_MESH_POCKET | variants/nrf52840/heltec_mesh_pocket | Display:6, Radio:8, GPS:1, Power:7 |
|
||||
| heltec-mesh-solar | Heltec MeshSolar | 108 | HELTEC_MESH_SOLAR | variants/nrf52840/heltec_mesh_solar | Radio:8, GPS:6 |
|
||||
| heltec-mesh-solar-eink | | | | variants/nrf52840/heltec_mesh_solar | Radio:8, GPS:6 |
|
||||
| heltec-mesh-solar-inkhud | | | | variants/nrf52840/heltec_mesh_solar | Radio:8, GPS:6 |
|
||||
| heltec-mesh-solar-oled | | | | variants/nrf52840/heltec_mesh_solar | Radio:8, GPS:6 |
|
||||
| heltec-mesh-solar-tft | | | | variants/nrf52840/heltec_mesh_solar | Radio:8, GPS:6 |
|
||||
| makerpython_nrf52840_sx1280_eink | | | | variants/nrf52840/MakePython_nRF52840_eink | Display:6, Radio:6, GPS:4, Power:5 |
|
||||
| makerpython_nrf52840_sx1280_oled | | | | variants/nrf52840/MakePython_nRF52840_oled | Radio:6, GPS:4, Power:5 |
|
||||
| meshlink | | | | variants/nrf52840/meshlink | Display:6, Radio:7, Input:1, GPS:5, Power:5 |
|
||||
| meshlink_eink | | | | variants/nrf52840/meshlink | Display:6, Radio:7, Input:1, GPS:5, Power:5 |
|
||||
| meshtiny | | | | variants/nrf52840/meshtiny | Display:2, Radio:8, Input:5, Power:5 |
|
||||
| minimesh_lite | | | | variants/nrf52840/dls_Minimesh_Lite | Radio:15, Input:1, GPS:4, Power:6 |
|
||||
| monteops_hw1 | | | | variants/nrf52840/monteops_hw1 | Radio:8, GPS:3, Power:5, Connectivity/Other:1 |
|
||||
| ms24sf1 | | | | variants/nrf52840/MS24SF1 | Radio:8, Input:1, GPS:8, Power:4 |
|
||||
| muzi-base | muzi BASE | 93 | MUZI_BASE | variants/nrf52840/muzi_base | Display:3, Radio:19, Input:1, GPS:4, Power:5 |
|
||||
| nano-g2-ultra | Nano G2 Ultra | 18 | NANO_G2_ULTRA | variants/nrf52840/nano-g2-ultra | Display:1, Radio:7, GPS:4, Power:6, Connectivity/Other:1 |
|
||||
| nrf52_promicro_diy_tcxo | NRF52 Pro-micro DIY | 63 | NRF52_PROMICRO_DIY | variants/nrf52840/diy/nrf52_promicro_diy_tcxo | Display:4, Radio:29, Input:1, GPS:4, Power:6 |
|
||||
| pca10059_diy_eink | | | | variants/nrf52840/Dongle_nRF52840-pca10059-v1 | Display:7, Radio:8, GPS:4, Power:5 |
|
||||
| r1-neo | muzi R1 Neo | 101 | MUZI_R1_NEO | variants/nrf52840/r1-neo | Display:1, Radio:8, GPS:4, Power:5 |
|
||||
| rak2560 | RAK WisMesh Repeater | 22 | WISMESH_HUB | variants/nrf52840/rak2560 | Display:6, Radio:8, GPS:2, Power:5 |
|
||||
| rak3401-1watt | RAK3401 1W | 117 | RAK3401 | variants/nrf52840/rak3401_1watt | Display:6, Radio:12, GPS:3, Power:5 |
|
||||
| rak4631 | RAK WisBlock 4631 | 9 | RAK4631 | variants/nrf52840/rak4631 | Display:6, Radio:8, GPS:3, Power:7, Connectivity/Other:1 |
|
||||
| rak4631_dbg | | | | variants/nrf52840/rak4631 | Display:6, Radio:8, GPS:3, Power:7, Connectivity/Other:1 |
|
||||
| rak4631_eink | | | | variants/nrf52840/rak4631_epaper | Display:6, Radio:8, GPS:4, Power:5 |
|
||||
| rak4631_eink_onrxtx | | | | variants/nrf52840/rak4631_epaper_onrxtx | Display:6, Radio:8, Power:1 |
|
||||
| rak4631_eth_gw | | | | variants/nrf52840/rak4631_eth_gw | Display:6, Radio:8, GPS:3, Power:5, Connectivity/Other:1 |
|
||||
| rak4631_eth_gw_dbg | | | | variants/nrf52840/rak4631_eth_gw | Display:6, Radio:8, GPS:3, Power:5, Connectivity/Other:1 |
|
||||
| rak4631_nomadstar_meteor_pro | NomadStar Meteor Pro | 96 | NOMADSTAR_METEOR_PRO | variants/nrf52840/rak4631_nomadstar_meteor_pro | Display:6, Radio:8, GPS:3, Power:5, Connectivity/Other:1 |
|
||||
| rak4631_nomadstar_meteor_pro_dbg | | | | variants/nrf52840/rak4631_nomadstar_meteor_pro | Display:6, Radio:8, GPS:3, Power:5, Connectivity/Other:1 |
|
||||
| rak_wismeshtag | RAK WisMesh Tag | 105 | WISMESH_TAG | variants/nrf52840/rak_wismeshtag | Display:7, Radio:8, GPS:4, Power:5 |
|
||||
| rak_wismeshtap | RAK WisMesh Tap | 84 | WISMESH_TAP | variants/nrf52840/rak_wismeshtap | Display:21, Radio:8, GPS:3, Power:7, Connectivity/Other:1 |
|
||||
| seeed_solar_node | Seeed SenseCAP Solar Node | 95 | SEEED_SOLAR_NODE | variants/nrf52840/seeed_solar_node | Radio:9, Input:2, GPS:8, Power:3 |
|
||||
| seeed_wio_tracker_L1 | Seeed Wio Tracker L1 | 99 | SEEED_WIO_TRACKER_L1 | variants/nrf52840/seeed_wio_tracker_L1 | Display:2, Radio:9, Input:1, GPS:7, Power:3 |
|
||||
| seeed_wio_tracker_L1_eink | Seeed Wio Tracker L1 E-Ink | 100 | SEEED_WIO_TRACKER_L1_EINK | variants/nrf52840/seeed_wio_tracker_L1_eink | Display:7, Radio:9, Input:1, GPS:7, Power:3 |
|
||||
| seeed_wio_tracker_L1_eink-inkhud | | | | variants/nrf52840/seeed_wio_tracker_L1_eink | Display:7, Radio:9, Input:1, GPS:7, Power:3 |
|
||||
| seeed_xiao_nrf52840_kit | Seeed Xiao NRF52840 Kit | 88 | XIAO_NRF52_KIT | variants/nrf52840/seeed_xiao_nrf52840_kit | Radio:19, Input:2, GPS:9, Power:6 |
|
||||
| seeed_xiao_nrf52840_kit_i2c | | | | variants/nrf52840/seeed_xiao_nrf52840_kit | Radio:19, Input:2, GPS:9, Power:6 |
|
||||
| t-echo | LILYGO T-Echo | 7 | T_ECHO | variants/nrf52840/t-echo | Display:7, Radio:8, GPS:7, Power:6, Connectivity/Other:1 |
|
||||
| t-echo-inkhud | | | | variants/nrf52840/t-echo | Display:7, Radio:8, GPS:7, Power:6, Connectivity/Other:1 |
|
||||
| t-echo-lite | LILYGO T-Echo Lite | 109 | T_ECHO_LITE | variants/nrf52840/t-echo-lite | Display:6, Radio:9, GPS:10, Power:8 |
|
||||
| t-echo-plus | | | | variants/nrf52840/t-echo-plus | Display:8, Radio:8, GPS:7, Power:6 |
|
||||
| thinknode_m1 | ThinkNode M1 | 89 | THINKNODE_M1 | variants/nrf52840/ELECROW-ThinkNode-M1 | Display:7, Radio:8, Input:1, GPS:8, Power:8 |
|
||||
| thinknode_m1-inkhud | | | | variants/nrf52840/ELECROW-ThinkNode-M1 | Display:7, Radio:8, Input:1, GPS:8, Power:8 |
|
||||
| thinknode_m3 | Elecrow ThinkNode M3 | 115 | THINKNODE_M3 | variants/nrf52840/ELECROW-ThinkNode-M3 | Radio:1, Input:2, GPS:8, Power:7, Connectivity/Other:1 |
|
||||
| thinknode_m4 | | | | variants/nrf52840/ELECROW-ThinkNode-M4 | Radio:8, GPS:12, Power:7 |
|
||||
| thinknode_m6 | ThinkNode M6 | 120 | THINKNODE_M6 | variants/nrf52840/ELECROW-ThinkNode-M6 | Radio:7, GPS:9, Power:9, Connectivity/Other:1 |
|
||||
| tracker-t1000-e | Seeed SenseCAP T1000-E | 71 | TRACKER_T1000_E | variants/nrf52840/tracker-t1000-e | Display:1, Radio:8, Input:1, GPS:13, Power:5 |
|
||||
| wio-sdk-wm1110 | | | | variants/nrf52840/wio-sdk-wm1110 | Radio:8, Input:1 |
|
||||
| wio-t1000-s | | | | variants/nrf52840/wio-t1000-s | Radio:8, Input:1, GPS:12, Power:4 |
|
||||
| wio-tracker-wm1110 | Seeed Wio WM1110 Tracker | 21 | WIO_WM1110 | variants/nrf52840/wio-tracker-wm1110 | Radio:8, Input:1, GPS:3 |
|
||||
|
||||
### rp2040
|
||||
|
||||
| Environment | Display Name | HW Model | HW Slug | Variant Dir | Common Categories |
|
||||
| -------------------- | ------------------- | -------- | ----------- | ------------------------------------ | ------------------------------------------------ |
|
||||
| catsniffer | | | | variants/rp2040/ec_catsniffer | Display:1, Radio:17, GPS:1 |
|
||||
| challenger_2040_lora | | | | variants/rp2040/challenger_2040_lora | Radio:17, Input:1, Power:1 |
|
||||
| feather_rp2040_rfm95 | | | | variants/rp2040/feather_rp2040_rfm95 | Radio:17, Input:1, Power:1 |
|
||||
| nibble-rp2040 | | | | variants/rp2040/nibble_rp2040 | Radio:9, Input:1 |
|
||||
| pico | Raspberry Pi Pico | 47 | RPI_PICO | variants/rp2040/rpipico | Radio:16, Input:1, Power:4 |
|
||||
| pico_slowclock | | | | variants/rp2040/rpipico-slowclock | Display:2, Radio:16, Input:1, GPS:5, Power:4 |
|
||||
| picow | Raspberry Pi Pico W | 47 | RPI_PICO | variants/rp2040/rpipicow | Radio:16, Input:1, Power:4, Connectivity/Other:1 |
|
||||
| rak11310 | RAK WisBlock 11310 | 26 | RAK11310 | variants/rp2040/rak11310 | Radio:17, Input:1, Power:3, Connectivity/Other:1 |
|
||||
| rp2040-lora | RP2040 LoRa | 30 | RP2040_LORA | variants/rp2040/rp2040-lora | Radio:18, Input:1, Power:1 |
|
||||
| senselora_rp2040 | | | | variants/rp2040/senselora_rp2040 | Display:1, Radio:9, Input:1, Power:1 |
|
||||
|
||||
### rp2350
|
||||
|
||||
| Environment | Display Name | HW Model | HW Slug | Variant Dir | Common Categories |
|
||||
| ----------- | ------------ | -------- | ------- | ------------------------- | ------------------------------------------------ |
|
||||
| pico2 | | | | variants/rp2350/rpipico2 | Radio:16, Input:1, Power:4 |
|
||||
| pico2w | | | | variants/rp2350/rpipico2w | Radio:16, Input:1, Power:4, Connectivity/Other:1 |
|
||||
|
||||
### stm32
|
||||
|
||||
| Environment | Display Name | HW Model | HW Slug | Variant Dir | Common Categories |
|
||||
| --------------- | ------------ | -------- | ------- | ------------------------------ | ---------------------------------- |
|
||||
| CDEBYTE_E77-MBL | | | | variants/stm32/CDEBYTE_E77-MBL | Display:1 |
|
||||
| milesight_gs301 | | | | variants/stm32/milesight_gs301 | Display:1, Radio:1, Input:1 |
|
||||
| rak3172 | | | | variants/stm32/rak3172 | Display:1 |
|
||||
| russell | | | | variants/stm32/russell | Display:1, Radio:1, Input:1, GPS:4 |
|
||||
| wio-e5 | | | | variants/stm32/wio-e5 | Display:1 |
|
||||
|
||||
## Representative Board Examples
|
||||
|
||||
### diy: `9m2ibr_aprs_lora_tracker`
|
||||
|
||||
- Variant directory: `variants/esp32/diy/9m2ibr_aprs_lora_tracker`
|
||||
- Input examples:
|
||||
- `BUTTON_PIN` = `15`
|
||||
- Radio examples:
|
||||
- `LORA_SCK` = `18`
|
||||
- `LORA_MISO` = `19`
|
||||
- `LORA_MOSI` = `23`
|
||||
- `LORA_CS` = `5`
|
||||
- `LORA_DIO0` = `26`
|
||||
- `LORA_RESET` = `27`
|
||||
- `LORA_DIO1` = `12`
|
||||
- `LORA_DIO2` = `RADIOLIB_NC`
|
||||
- GPS examples:
|
||||
- `GPS_RX_PIN` = `16`
|
||||
- `GPS_TX_PIN` = `17`
|
||||
- Display examples:
|
||||
- `HAS_SCREEN` = `1`
|
||||
- I2C/SPI examples:
|
||||
- `I2C_SDA` = `21`
|
||||
- `I2C_SCL` = `22`
|
||||
- Power examples:
|
||||
- `BATTERY_PIN` = `35`
|
||||
- `ADC_MULTIPLIER` = `2.01`
|
||||
- `ADC_CHANNEL` = `ADC1_GPIO35_CHANNEL`
|
||||
- `BATTERY_SENSE_RESOLUTION_BITS` = `ADC_RESOLUTION`
|
||||
|
||||
### esp32: `betafpv_2400_tx_micro`
|
||||
|
||||
- Variant directory: `variants/esp32/betafpv_2400_tx_micro`
|
||||
- Input examples:
|
||||
- `BUTTON_PIN` = `25`
|
||||
- Radio examples:
|
||||
- `LORA_SCK` = `18`
|
||||
- `LORA_MISO` = `19`
|
||||
- `LORA_MOSI` = `23`
|
||||
- `LORA_CS` = `5`
|
||||
- `RF95_FAN_EN` = `17`
|
||||
- `USE_SX1280` = `1`
|
||||
- `LORA_RESET` = `14`
|
||||
- `SX128X_CS` = `5`
|
||||
- I2C/SPI examples:
|
||||
- `I2C_SDA` = `22`
|
||||
- `I2C_SCL` = `32`
|
||||
- Connectivity/Other examples:
|
||||
- `HAS_NEOPIXEL` = `1`
|
||||
|
||||
### esp32-c3: `ai-c3`
|
||||
|
||||
- Variant directory: `variants/esp32c3/ai-c3`
|
||||
- Input examples:
|
||||
- `BUTTON_PIN` = `9`
|
||||
- Radio examples:
|
||||
- `USE_RF95` = `1`
|
||||
- `LORA_SCK` = `4`
|
||||
- `LORA_MISO` = `5`
|
||||
- `LORA_MOSI` = `6`
|
||||
- `LORA_CS` = `7`
|
||||
- `LORA_DIO0` = `10`
|
||||
- `LORA_DIO1` = `3`
|
||||
- `LORA_RESET` = `2`
|
||||
- GPS examples:
|
||||
- `HAS_GPS` = `0`
|
||||
- I2C/SPI examples:
|
||||
- `I2C_SDA` = `SDA`
|
||||
- `I2C_SCL` = `SCL`
|
||||
|
||||
### esp32-c6: `m5stack-unitc6l`
|
||||
|
||||
- Variant directory: `variants/esp32c6/m5stack_unitc6l`
|
||||
- `custom_meshtastic_hw_model`: `111`
|
||||
- `custom_meshtastic_hw_model_slug`: `M5STACK_C6L`
|
||||
- `custom_meshtastic_architecture`: `esp32-c6`
|
||||
- `custom_meshtastic_actively_supported`: `true`
|
||||
- `custom_meshtastic_support_level`: `1`
|
||||
- `custom_meshtastic_display_name`: `M5Stack Unit C6L`
|
||||
- `custom_meshtastic_images`: `m5_c6l.svg`
|
||||
- `custom_meshtastic_tags`: `M5Stack`
|
||||
- Radio examples:
|
||||
- `USE_SX1262` = `1`
|
||||
- `LORA_MISO` = `22`
|
||||
- `LORA_SCK` = `20`
|
||||
- `LORA_MOSI` = `21`
|
||||
- `LORA_CS` = `23`
|
||||
- `LORA_RESET` = `RADIOLIB_NC`
|
||||
- `LORA_DIO1` = `7`
|
||||
- `LORA_BUSY` = `19`
|
||||
- GPS examples:
|
||||
- `HAS_GPS` = `1`
|
||||
- `GPS_RX_PIN` = `4`
|
||||
- `GPS_TX_PIN` = `5`
|
||||
- Display examples:
|
||||
- `SCREEN_TRANSITION_FRAMERATE` = `10`
|
||||
- I2C/SPI examples:
|
||||
- `I2C_SDA` = `10`
|
||||
- `I2C_SCL` = `8`
|
||||
- Connectivity/Other examples:
|
||||
- `HAS_NEOPIXEL` = `1`
|
||||
|
||||
### esp32-s3: `CDEBYTE_EoRa-Hub`
|
||||
|
||||
- Variant directory: `variants/esp32s3/CDEBYTE_EoRa-Hub`
|
||||
- Input examples:
|
||||
- `BUTTON_PIN` = `0`
|
||||
- Radio examples:
|
||||
- `USE_LR1121` = `1`
|
||||
- `LORA_SCK` = `9`
|
||||
- `LORA_MOSI` = `10`
|
||||
- `LORA_MISO` = `11`
|
||||
- `LORA_RESET` = `12`
|
||||
- `LORA_CS` = `8`
|
||||
- `LORA_DIO9` = `13`
|
||||
- `LR1121_IRQ_PIN` = `14`
|
||||
- Display examples:
|
||||
- `HAS_SCREEN` = `1`
|
||||
- `USE_SSD1306` = `1`
|
||||
- I2C/SPI examples:
|
||||
- `I2C_SCL` = `17`
|
||||
- `I2C_SDA` = `18`
|
||||
- `I2C_SCL1` = `21`
|
||||
- `I2C_SDA1` = `10`
|
||||
- Power examples:
|
||||
- `BATTERY_PIN` = `1`
|
||||
- `ADC_CHANNEL` = `ADC1_GPIO1_CHANNEL`
|
||||
- `ADC_MULTIPLIER` = `103.0`
|
||||
- `ADC_ATTENUATION` = `ADC_ATTEN_DB_0`
|
||||
- `ADC_CTRL` = `37`
|
||||
- `ADC_CTRL_ENABLED` = `LOW`
|
||||
|
||||
### esp32s2: `nugget-s2-lora`
|
||||
|
||||
- Variant directory: `variants/esp32s2/nugget_s2_lora`
|
||||
- Input examples:
|
||||
- `BUTTON_PIN` = `0`
|
||||
- Radio examples:
|
||||
- `USE_RF95` = `1`
|
||||
- `LORA_SCK` = `6`
|
||||
- `LORA_MISO` = `8`
|
||||
- `LORA_MOSI` = `10`
|
||||
- `LORA_CS` = `13`
|
||||
- `LORA_DIO0` = `16`
|
||||
- `LORA_RESET` = `5`
|
||||
- `LORA_DIO1` = `RADIOLIB_NC`
|
||||
- I2C/SPI examples:
|
||||
- `I2C_SDA` = `34`
|
||||
- `I2C_SCL` = `36`
|
||||
- Connectivity/Other examples:
|
||||
- `HAS_NEOPIXEL` = `1`
|
||||
|
||||
### native: `buildroot`
|
||||
|
||||
- Variant directory: `variants/native/portduino-buildroot`
|
||||
- GPS examples:
|
||||
- `HAS_GPS` = `1`
|
||||
- Display examples:
|
||||
- `HAS_SCREEN` = `1`
|
||||
- `USE_TFTDISPLAY` = `1`
|
||||
|
||||
### nrf52840: `ME25LS01-4Y10TD`
|
||||
|
||||
- Variant directory: `variants/nrf52840/ME25LS01-4Y10TD`
|
||||
- Input examples:
|
||||
- `BUTTON_PIN` = `(0 + 27)`
|
||||
- Radio examples:
|
||||
- `LORA_RESET` = `(32 + 11)`
|
||||
- `LORA_DIO1` = `(32 + 12)`
|
||||
- `LORA_DIO2` = `(32 + 10)`
|
||||
- `LORA_SCK` = `PIN_SPI_SCK`
|
||||
- `LORA_MISO` = `PIN_SPI_MISO`
|
||||
- `LORA_MOSI` = `PIN_SPI_MOSI`
|
||||
- `LORA_CS` = `PIN_SPI_NSS`
|
||||
- `USE_LR1110` = `1`
|
||||
- GPS examples:
|
||||
- `HAS_GPS` = `0`
|
||||
- `PIN_GPS_EN` = `-1`
|
||||
- `GPS_EN_ACTIVE` = `HIGH`
|
||||
- `PIN_GPS_RESET` = `-1`
|
||||
- `GPS_VRTC_EN` = `-1`
|
||||
- `GPS_SLEEP_INT` = `-1`
|
||||
- `GPS_RTC_INT` = `-1`
|
||||
- `GPS_RESETB_OUT` = `-1`
|
||||
- I2C/SPI examples:
|
||||
- `WIRE_INTERFACES_COUNT` = `1`
|
||||
- `SPI_INTERFACES_COUNT` = `1`
|
||||
- `PIN_SPI_MISO` = `(0 + 29)`
|
||||
- `PIN_SPI_MOSI` = `(0 + 2)`
|
||||
- `PIN_SPI_SCK` = `(32 + 15)`
|
||||
- `PIN_SPI_NSS` = `(32 + 13)`
|
||||
- Power examples:
|
||||
- `BATTERY_PIN` = `-1`
|
||||
- `ADC_MULTIPLIER` = `(2.0F)`
|
||||
- `ADC_RESOLUTION` = `14`
|
||||
- `BATTERY_SENSE_RESOLUTION_BITS` = `12`
|
||||
|
||||
### rp2040: `catsniffer`
|
||||
|
||||
- Variant directory: `variants/rp2040/ec_catsniffer`
|
||||
- Radio examples:
|
||||
- `USE_SX1262` = `1`
|
||||
- `LORA_SCK` = `18`
|
||||
- `LORA_MISO` = `16`
|
||||
- `LORA_MOSI` = `19`
|
||||
- `LORA_CS` = `17`
|
||||
- `LORA_DIO0` = `5`
|
||||
- `LORA_RESET` = `24`
|
||||
- `LORA_DIO1` = `4`
|
||||
- GPS examples:
|
||||
- `HAS_GPS` = `0`
|
||||
- Display examples:
|
||||
- `HAS_SCREEN` = `0`
|
||||
|
||||
### rp2350: `pico2`
|
||||
|
||||
- Variant directory: `variants/rp2350/rpipico2`
|
||||
- Input examples:
|
||||
- `BUTTON_PIN` = `17`
|
||||
- Radio examples:
|
||||
- `USE_SX1262` = `1`
|
||||
- `LORA_SCK` = `10`
|
||||
- `LORA_MISO` = `12`
|
||||
- `LORA_MOSI` = `11`
|
||||
- `LORA_CS` = `3`
|
||||
- `LORA_DIO0` = `RADIOLIB_NC`
|
||||
- `LORA_RESET` = `15`
|
||||
- `LORA_DIO1` = `20`
|
||||
- Power examples:
|
||||
- `EXT_NOTIFY_OUT` = `22`
|
||||
- `BATTERY_PIN` = `26`
|
||||
- `ADC_MULTIPLIER` = `3.1`
|
||||
- `BATTERY_SENSE_RESOLUTION_BITS` = `ADC_RESOLUTION`
|
||||
|
||||
### stm32: `CDEBYTE_E77-MBL`
|
||||
|
||||
- Variant directory: `variants/stm32/CDEBYTE_E77-MBL`
|
||||
- Display examples:
|
||||
- `USE_STM32WLx` = `1`
|
||||
|
||||
## Intake Guidance For New Hardware
|
||||
|
||||
When using this context to add a new board, collect these inputs before generating files:
|
||||
|
||||
- PlatformIO environment name
|
||||
- `custom_meshtastic_hw_model` and `custom_meshtastic_hw_model_slug`
|
||||
- Display name and architecture
|
||||
- Whether the board is actively supported and its support level
|
||||
- Partition scheme, DFU requirement, and image/tag metadata if applicable
|
||||
- Radio chip family and complete radio pin group
|
||||
- Input/button/rotary/keyboard pins
|
||||
- Display interface pins and driver-related macros
|
||||
- GPS, power-management, I2C, SPI, storage, and auxiliary peripheral definitions
|
||||
- Any board-specific initialization that requires `variant.cpp` or extra variant hooks
|
||||
|
||||
## Inherited Defaults Note
|
||||
|
||||
Some architecture families rely on BSP (Board Support Package) or base-environment defaults rather than declaring every pin or capability macro explicitly in `variant.h`. When adding a new board for one of these families, check the relevant BSP headers before assuming a missing define means a feature is absent.
|
||||
|
||||
Directories scanned that had no `variant.h` (relying entirely on BSP/base-environment): diy: 4, esp32: 3, esp32s3: 1
|
||||
|
||||
### nrf52840
|
||||
|
||||
- VARIANT_MCK — nRF52 BSP clock constant (e.g., 64000000ul); always inherited from BSP unless overridden.
|
||||
- USE_LFXO — low-frequency crystal oscillator selection; declared locally only when the board uses LFXO rather than the RC oscillator.
|
||||
- PIN*SPI*_ / PIN*SPI1*_ — SPI bus pin numbers come from the BSP variant table; boards override only when the LoRa radio or display uses non-default SPI routing.
|
||||
- WIRE_INTERFACES_COUNT / SPI_INTERFACES_COUNT — bus count comes from BSP; explicitly set only when the board deviates.
|
||||
- LED_BLUE / PIN_LED1 / PINS_COUNT / NUM_DIGITAL_PINS — standard BSP pin-table entries inherited from the nRF52 Arduino core.
|
||||
|
||||
### rp2040
|
||||
|
||||
- PIN*SPI*\* — primary SPI pins come from the RP2040 Arduino BSP; most boards declare them explicitly, but the defaults align with the Pico pin assignments.
|
||||
- NUM_DIGITAL_PINS / NUM_ANALOG_INPUTS — Arduino BSP counts; rarely overridden locally.
|
||||
|
||||
### stm32
|
||||
|
||||
- Radio and pin assignments for STM32WL targets are largely internal to the WL SoC and declared via STM32 HAL/BSP headers; variant.h files are minimal.
|
||||
- USE_STM32WLx is typically the only explicit define; all other radio config comes from the BSP.
|
||||
|
||||
### native
|
||||
|
||||
- The native/Portduino target uses runtime configuration rather than compile-time pin defines; variant.h only sets display and GPS stubs.
|
||||
|
||||
## Cautions
|
||||
|
||||
- Some boards rely on architecture defaults rather than declaring every field locally.
|
||||
- Some board families expose multiple environments or display variants that share one hardware model.
|
||||
- Source materials such as schematics still need human verification before new pin mappings are trusted.
|
||||
- This document is a starting context artifact, not proof that a new board definition is safe to merge.
|
||||
@@ -7,6 +7,12 @@ __pycache__/
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Persistent device-log capture (recorder + Datadog cursor).
|
||||
# Cross-session JSONL streams written by the autouse Recorder singleton
|
||||
# (see src/meshtastic_mcp/recorder/). Lives outside tests/ so the pytest
|
||||
# fixture truncate doesn't touch it.
|
||||
.mtlog/
|
||||
|
||||
# Test harness artifacts
|
||||
tests/report.html
|
||||
tests/junit.xml
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
{
|
||||
"title": "Meshtastic Firmware — Recorder Stream",
|
||||
"description": "Live view of `.mtlog/` streams shipped by `mtlog_to_datadog.py`. Heap, packet volume, log levels, errors. One row per port.",
|
||||
"widgets": [
|
||||
{
|
||||
"definition": {
|
||||
"title": "Free heap (bytes)",
|
||||
"type": "timeseries",
|
||||
"show_legend": true,
|
||||
"requests": [
|
||||
{
|
||||
"queries": [
|
||||
{
|
||||
"name": "free_heap",
|
||||
"data_source": "metrics",
|
||||
"query": "avg:mesh.local.heap_free_bytes{service:meshtastic-firmware} by {port}"
|
||||
}
|
||||
],
|
||||
"response_format": "timeseries",
|
||||
"display_type": "line"
|
||||
}
|
||||
],
|
||||
"yaxis": { "label": "bytes" }
|
||||
}
|
||||
},
|
||||
{
|
||||
"definition": {
|
||||
"title": "Heap slope (bytes/min) — last 1h",
|
||||
"type": "query_value",
|
||||
"precision": 0,
|
||||
"requests": [
|
||||
{
|
||||
"queries": [
|
||||
{
|
||||
"name": "slope",
|
||||
"data_source": "metrics",
|
||||
"query": "derivative(avg:mesh.local.heap_free_bytes{service:meshtastic-firmware})",
|
||||
"aggregator": "avg"
|
||||
}
|
||||
],
|
||||
"response_format": "scalar"
|
||||
}
|
||||
],
|
||||
"conditional_formats": [
|
||||
{ "comparator": "<", "value": -100, "palette": "white_on_red" },
|
||||
{ "comparator": "<", "value": 0, "palette": "white_on_yellow" },
|
||||
{ "comparator": ">=", "value": 0, "palette": "white_on_green" }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"definition": {
|
||||
"title": "Total heap (bytes)",
|
||||
"type": "timeseries",
|
||||
"requests": [
|
||||
{
|
||||
"queries": [
|
||||
{
|
||||
"name": "total_heap",
|
||||
"data_source": "metrics",
|
||||
"query": "avg:mesh.local.heap_total_bytes{service:meshtastic-firmware} by {port}"
|
||||
}
|
||||
],
|
||||
"response_format": "timeseries",
|
||||
"display_type": "line"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"definition": {
|
||||
"title": "Battery level (%)",
|
||||
"type": "timeseries",
|
||||
"requests": [
|
||||
{
|
||||
"queries": [
|
||||
{
|
||||
"name": "battery",
|
||||
"data_source": "metrics",
|
||||
"query": "avg:mesh.device.battery_level{service:meshtastic-firmware} by {port}"
|
||||
}
|
||||
],
|
||||
"response_format": "timeseries",
|
||||
"display_type": "line"
|
||||
}
|
||||
],
|
||||
"yaxis": { "min": "0", "max": "105" }
|
||||
}
|
||||
},
|
||||
{
|
||||
"definition": {
|
||||
"title": "Air utilization (TX %)",
|
||||
"type": "timeseries",
|
||||
"requests": [
|
||||
{
|
||||
"queries": [
|
||||
{
|
||||
"name": "airutil",
|
||||
"data_source": "metrics",
|
||||
"query": "avg:mesh.device.air_util_tx{service:meshtastic-firmware} by {port}"
|
||||
}
|
||||
],
|
||||
"response_format": "timeseries",
|
||||
"display_type": "line"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"definition": {
|
||||
"title": "Channel utilization (%)",
|
||||
"type": "timeseries",
|
||||
"requests": [
|
||||
{
|
||||
"queries": [
|
||||
{
|
||||
"name": "chutil",
|
||||
"data_source": "metrics",
|
||||
"query": "avg:mesh.device.channel_utilization{service:meshtastic-firmware} by {port}"
|
||||
}
|
||||
],
|
||||
"response_format": "timeseries",
|
||||
"display_type": "line"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"definition": {
|
||||
"title": "Log volume by level",
|
||||
"type": "timeseries",
|
||||
"show_legend": true,
|
||||
"requests": [
|
||||
{
|
||||
"response_format": "timeseries",
|
||||
"display_type": "bars",
|
||||
"queries": [
|
||||
{
|
||||
"name": "log_count",
|
||||
"data_source": "logs",
|
||||
"indexes": ["*"],
|
||||
"compute": { "aggregation": "count" },
|
||||
"search": { "query": "service:meshtastic-firmware" },
|
||||
"group_by": [
|
||||
{
|
||||
"facet": "@level",
|
||||
"limit": 10,
|
||||
"sort": { "order": "desc", "aggregation": "count" }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"definition": {
|
||||
"title": "Recent ERROR / CRIT firmware logs",
|
||||
"type": "list_stream",
|
||||
"requests": [
|
||||
{
|
||||
"response_format": "event_list",
|
||||
"query": {
|
||||
"data_source": "logs_stream",
|
||||
"query_string": "service:meshtastic-firmware (status:error OR @level:ERROR OR @level:CRIT)",
|
||||
"indexes": [],
|
||||
"sort": { "column": "timestamp", "order": "desc" }
|
||||
},
|
||||
"columns": [
|
||||
{ "field": "timestamp", "width": "auto" },
|
||||
{ "field": "host", "width": "auto" },
|
||||
{ "field": "@port", "width": "auto" },
|
||||
{ "field": "@level", "width": "auto" },
|
||||
{ "field": "@thread", "width": "auto" },
|
||||
{ "field": "message", "width": "stretch" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"definition": {
|
||||
"title": "Recorder marker events",
|
||||
"type": "list_stream",
|
||||
"requests": [
|
||||
{
|
||||
"response_format": "event_list",
|
||||
"query": {
|
||||
"data_source": "logs_stream",
|
||||
"query_string": "service:meshtastic-firmware @level:MARK",
|
||||
"indexes": [],
|
||||
"sort": { "column": "timestamp", "order": "desc" }
|
||||
},
|
||||
"columns": [
|
||||
{ "field": "timestamp", "width": "auto" },
|
||||
{ "field": "host", "width": "auto" },
|
||||
{ "field": "message", "width": "stretch" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"template_variables": [
|
||||
{
|
||||
"name": "port",
|
||||
"prefix": "port",
|
||||
"available_values": [],
|
||||
"default": "*"
|
||||
},
|
||||
{ "name": "host", "prefix": "host", "available_values": [], "default": "*" }
|
||||
],
|
||||
"layout_type": "ordered",
|
||||
"notify_list": [],
|
||||
"reflow_type": "auto"
|
||||
}
|
||||
Executable
+389
@@ -0,0 +1,389 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Forward selected recorder JSONL streams to Datadog.
|
||||
|
||||
Reads `.mtlog/logs.jsonl` and `.mtlog/telemetry.jsonl`, ships logs to the
|
||||
Logs Intake API and telemetry numerics to the Metrics v2 series API.
|
||||
Resumes from `.mtlog/.dd-cursor.json` so a daemon restart doesn't
|
||||
duplicate rows already shipped from the current live files.
|
||||
|
||||
This forwarder does not currently backfill rotated `.jsonl.gz` archives.
|
||||
If the recorder rotates before this process drains the live file, or the
|
||||
forwarder is down across a rotation, those older rows are skipped.
|
||||
|
||||
Usage:
|
||||
DD_API_KEY=... ./scripts/mtlog_to_datadog.py --tail
|
||||
./scripts/mtlog_to_datadog.py --once # catch up + exit
|
||||
./scripts/mtlog_to_datadog.py --since 3600 # backfill last hour from start
|
||||
|
||||
Default `DD_SITE` is `us5.datadoghq.com` — the team's Datadog instance.
|
||||
Override via `DD_SITE=...` env var or `--site` flag for one-offs.
|
||||
|
||||
The forwarder is a separate process by design — a Datadog outage or
|
||||
auth failure must not backpressure the recorder. We exit non-zero on
|
||||
fatal config errors (missing API key) and keep retrying on transient
|
||||
network/HTTP errors.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterator
|
||||
|
||||
try:
|
||||
import requests
|
||||
except ImportError:
|
||||
print(
|
||||
"requests is required. Install it in the mcp-server venv: "
|
||||
"uv pip install requests",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
_DEFAULT_LOG_DIR = Path(__file__).resolve().parents[1] / ".mtlog"
|
||||
_LOG_INTAKE_TPL = "https://http-intake.logs.{site}/api/v2/logs"
|
||||
_METRICS_TPL = "https://api.{site}/api/v2/series"
|
||||
_LOG_BATCH = 50
|
||||
_METRICS_BATCH = 100
|
||||
_MAX_RETRIES = 5
|
||||
_RETRY_BASE_S = 1.5
|
||||
|
||||
|
||||
# --- streaming JSONL with byte-position cursor -------------------------
|
||||
|
||||
|
||||
class _StreamReader:
|
||||
"""Reads a single rotating JSONL with cursor-based resume.
|
||||
|
||||
This tails only the live `.jsonl` file. The recorder rotates files
|
||||
(live `.jsonl` → `.YYYYMMDD-HHMMSS-uuuuuu-NNNNN.jsonl.gz`), which means
|
||||
the live file shrinks abruptly. We detect that via inode change OR live
|
||||
size < cursor position, and reset the live-file cursor to 0.
|
||||
"""
|
||||
|
||||
def __init__(self, path: Path, cursor: dict[str, Any]):
|
||||
self.path = path
|
||||
self.cursor = cursor
|
||||
|
||||
def _state(self) -> tuple[int, int]:
|
||||
"""Return (inode, size) for the live file. (0, 0) if missing."""
|
||||
try:
|
||||
st = self.path.stat()
|
||||
return (st.st_ino, st.st_size)
|
||||
except FileNotFoundError:
|
||||
return (0, 0)
|
||||
|
||||
def iter_new_records(self) -> Iterator[dict[str, Any]]:
|
||||
ino, size = self._state()
|
||||
last_ino = self.cursor.get("ino")
|
||||
last_pos = int(self.cursor.get("pos") or 0)
|
||||
if ino == 0:
|
||||
return
|
||||
if last_ino is not None and last_ino != ino:
|
||||
# Rotation happened. Start over.
|
||||
last_pos = 0
|
||||
if last_pos > size:
|
||||
# Live file truncated/shrunk under us — recorder rotated.
|
||||
last_pos = 0
|
||||
try:
|
||||
with self.path.open("r", encoding="utf-8") as fh:
|
||||
fh.seek(last_pos)
|
||||
for line in fh:
|
||||
line = line.rstrip("\n")
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
yield json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
last_pos = fh.tell()
|
||||
except FileNotFoundError:
|
||||
return
|
||||
self.cursor["ino"] = ino
|
||||
self.cursor["pos"] = last_pos
|
||||
|
||||
|
||||
def _load_cursor(path: Path) -> dict[str, Any]:
|
||||
if not path.exists():
|
||||
return {}
|
||||
try:
|
||||
return json.loads(path.read_text())
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {}
|
||||
|
||||
|
||||
def _save_cursor(path: Path, data: dict[str, Any]) -> None:
|
||||
tmp = path.with_suffix(".json.tmp")
|
||||
tmp.write_text(json.dumps(data, separators=(",", ":")))
|
||||
tmp.replace(path)
|
||||
|
||||
|
||||
# --- Datadog clients ---------------------------------------------------
|
||||
|
||||
|
||||
class _DDSession:
|
||||
"""Pool one HTTPS session, share retry logic."""
|
||||
|
||||
def __init__(self, api_key: str, site: str, hostname: str) -> None:
|
||||
self.api_key = api_key
|
||||
self.site = site
|
||||
self.hostname = hostname
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update(
|
||||
{
|
||||
"DD-API-KEY": api_key,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
)
|
||||
|
||||
def _post(self, url: str, payload: Any) -> bool:
|
||||
for attempt in range(_MAX_RETRIES):
|
||||
try:
|
||||
resp = self.session.post(url, json=payload, timeout=30)
|
||||
except requests.RequestException as e:
|
||||
_wait_retry(attempt, f"network error: {e}")
|
||||
continue
|
||||
if 200 <= resp.status_code < 300:
|
||||
return True
|
||||
if resp.status_code in (408, 429, 500, 502, 503, 504):
|
||||
_wait_retry(
|
||||
attempt,
|
||||
f"HTTP {resp.status_code} retrying",
|
||||
)
|
||||
continue
|
||||
print(
|
||||
f"datadog refused: {resp.status_code} {resp.text[:200]}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return False
|
||||
return False
|
||||
|
||||
def send_logs(self, records: list[dict[str, Any]]) -> int:
|
||||
if not records:
|
||||
return 0
|
||||
url = _LOG_INTAKE_TPL.format(site=self.site)
|
||||
sent = 0
|
||||
for i in range(0, len(records), _LOG_BATCH):
|
||||
batch = records[i : i + _LOG_BATCH]
|
||||
if self._post(url, batch):
|
||||
sent += len(batch)
|
||||
return sent
|
||||
|
||||
def send_metrics(self, series: list[dict[str, Any]]) -> int:
|
||||
if not series:
|
||||
return 0
|
||||
url = _METRICS_TPL.format(site=self.site)
|
||||
sent = 0
|
||||
for i in range(0, len(series), _METRICS_BATCH):
|
||||
batch = series[i : i + _METRICS_BATCH]
|
||||
if self._post(url, {"series": batch}):
|
||||
sent += len(batch)
|
||||
return sent
|
||||
|
||||
|
||||
def _wait_retry(attempt: int, reason: str) -> None:
|
||||
wait = _RETRY_BASE_S * (2**attempt)
|
||||
print(
|
||||
f" retry {attempt + 1}/{_MAX_RETRIES} in {wait:.1f}s ({reason})",
|
||||
file=sys.stderr,
|
||||
)
|
||||
time.sleep(wait)
|
||||
|
||||
|
||||
# --- record → datadog payload ------------------------------------------
|
||||
|
||||
|
||||
def _log_record_to_dd(rec: dict[str, Any], host: str) -> dict[str, Any]:
|
||||
line = rec.get("line") or ""
|
||||
tags = [
|
||||
f"role:{rec.get('role')}",
|
||||
f"port:{rec.get('port')}",
|
||||
]
|
||||
level = rec.get("level")
|
||||
if level:
|
||||
tags.append(f"level:{level}")
|
||||
tag = rec.get("tag")
|
||||
if tag:
|
||||
tags.append(f"thread:{tag}")
|
||||
return {
|
||||
"ddsource": "meshtastic-firmware",
|
||||
"service": "meshtastic-firmware",
|
||||
"hostname": host,
|
||||
"message": line,
|
||||
"ddtags": ",".join(t for t in tags if t and "None" not in t),
|
||||
"timestamp": int((rec.get("ts") or time.time()) * 1000),
|
||||
"level": level,
|
||||
}
|
||||
|
||||
|
||||
def _telemetry_record_to_metrics(
|
||||
rec: dict[str, Any], host: str
|
||||
) -> list[dict[str, Any]]:
|
||||
fields = rec.get("fields") or {}
|
||||
if not isinstance(fields, dict):
|
||||
return []
|
||||
variant = rec.get("variant") or "unknown"
|
||||
ts = int(rec.get("ts") or time.time())
|
||||
out: list[dict[str, Any]] = []
|
||||
tags = []
|
||||
if rec.get("port"):
|
||||
tags.append(f"port:{rec['port']}")
|
||||
if rec.get("role"):
|
||||
tags.append(f"role:{rec['role']}")
|
||||
if rec.get("from_node"):
|
||||
tags.append(f"from_node:{rec['from_node']}")
|
||||
tags.append(f"variant:{variant}")
|
||||
for field, value in fields.items():
|
||||
if not isinstance(value, (int, float)) or isinstance(value, bool):
|
||||
continue
|
||||
metric = f"mesh.{variant}.{_metric_safe(field)}"
|
||||
out.append(
|
||||
{
|
||||
"metric": metric,
|
||||
"type": 3, # GAUGE
|
||||
"points": [{"timestamp": ts, "value": float(value)}],
|
||||
"tags": tags,
|
||||
"resources": [{"type": "host", "name": host}],
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _metric_safe(name: str) -> str:
|
||||
# Lowercase, replace non-alnum with underscore for safe metric names.
|
||||
return "".join(c.lower() if c.isalnum() else "_" for c in name)
|
||||
|
||||
|
||||
# --- main loop ---------------------------------------------------------
|
||||
|
||||
|
||||
def run(
|
||||
log_dir: Path,
|
||||
*,
|
||||
once: bool,
|
||||
since_seconds: float | None,
|
||||
poll_interval: float,
|
||||
dd: _DDSession,
|
||||
) -> int:
|
||||
cursor_path = log_dir / ".dd-cursor.json"
|
||||
cursors = _load_cursor(cursor_path)
|
||||
|
||||
# `--since` overrides cursor: rewind to (now-since) timestamp.
|
||||
# We can't seek by timestamp directly (cursor is byte position), so
|
||||
# we just reset cursors to 0 and let the time filter in iter_new
|
||||
# drop older records.
|
||||
cutoff_ts: float | None = None
|
||||
if since_seconds is not None:
|
||||
cursors = {}
|
||||
cutoff_ts = time.time() - since_seconds
|
||||
|
||||
sent_total = {"logs": 0, "telemetry": 0}
|
||||
|
||||
while True:
|
||||
# logs.jsonl → DD logs
|
||||
log_cursor = cursors.setdefault("logs", {})
|
||||
log_batch: list[dict[str, Any]] = []
|
||||
for rec in _StreamReader(log_dir / "logs.jsonl", log_cursor).iter_new_records():
|
||||
if cutoff_ts and (rec.get("ts") or 0) < cutoff_ts:
|
||||
continue
|
||||
log_batch.append(_log_record_to_dd(rec, dd.hostname))
|
||||
if log_batch:
|
||||
n = dd.send_logs(log_batch)
|
||||
sent_total["logs"] += n
|
||||
print(f"logs: sent {n}/{len(log_batch)}")
|
||||
|
||||
# telemetry.jsonl → DD metrics
|
||||
telem_cursor = cursors.setdefault("telemetry", {})
|
||||
metric_series: list[dict[str, Any]] = []
|
||||
for rec in _StreamReader(
|
||||
log_dir / "telemetry.jsonl", telem_cursor
|
||||
).iter_new_records():
|
||||
if cutoff_ts and (rec.get("ts") or 0) < cutoff_ts:
|
||||
continue
|
||||
metric_series.extend(_telemetry_record_to_metrics(rec, dd.hostname))
|
||||
if metric_series:
|
||||
n = dd.send_metrics(metric_series)
|
||||
sent_total["telemetry"] += n
|
||||
print(f"telemetry: sent {n}/{len(metric_series)} metric points")
|
||||
|
||||
_save_cursor(cursor_path, cursors)
|
||||
|
||||
if once:
|
||||
print(f"done. logs={sent_total['logs']} metrics={sent_total['telemetry']}")
|
||||
return 0
|
||||
time.sleep(poll_interval)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--log-dir",
|
||||
default=str(_DEFAULT_LOG_DIR),
|
||||
help="Path to .mtlog/ (default: mcp-server/.mtlog)",
|
||||
)
|
||||
mode = parser.add_mutually_exclusive_group()
|
||||
mode.add_argument("--once", action="store_true", help="Catch up then exit")
|
||||
mode.add_argument(
|
||||
"--tail",
|
||||
action="store_true",
|
||||
help="Daemon: poll forever (default)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--since",
|
||||
type=float,
|
||||
default=None,
|
||||
help="Backfill last N seconds. Resets cursor.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--poll-interval",
|
||||
type=float,
|
||||
default=5.0,
|
||||
help="Seconds between tail polls (default 5)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--site",
|
||||
default=os.environ.get("DD_SITE", "us5.datadoghq.com"),
|
||||
help=(
|
||||
"Datadog site. Default is the team's instance (us5.datadoghq.com). "
|
||||
"Override via DD_SITE env var or this flag."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--host",
|
||||
default=socket.gethostname(),
|
||||
help="Hostname tag (default: socket.gethostname())",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
api_key = os.environ.get("DD_API_KEY")
|
||||
if not api_key:
|
||||
print("DD_API_KEY env var required.", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
log_dir = Path(args.log_dir)
|
||||
if not log_dir.exists():
|
||||
print(
|
||||
f"log dir {log_dir} does not exist — start the mcp-server first.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
dd = _DDSession(api_key=api_key, site=args.site, hostname=args.host)
|
||||
once = args.once and not args.tail
|
||||
return run(
|
||||
log_dir,
|
||||
once=once,
|
||||
since_seconds=args.since,
|
||||
poll_interval=args.poll_interval,
|
||||
dd=dd,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -108,18 +108,33 @@ def build(
|
||||
env: str,
|
||||
with_manifest: bool = True,
|
||||
userprefs_overrides: dict[str, Any] | None = None,
|
||||
build_flags: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Run `pio run -e <env>` and return artifact paths.
|
||||
|
||||
`userprefs_overrides` (optional): dict of `USERPREFS_<KEY>: value` to inject
|
||||
into userPrefs.jsonc for this build only. File is restored byte-for-byte
|
||||
on exit. Use `userprefs_set()` for persistent changes.
|
||||
|
||||
`build_flags` (optional): dict of `-D<NAME>=<VALUE>` macros to set for
|
||||
this build only via `PLATFORMIO_BUILD_FLAGS`. Common useful flag:
|
||||
`{"DEBUG_HEAP": 1}` enables per-thread leak detection + `[heap N]`
|
||||
prefix on every log line. Combines with the recorder so heap shows
|
||||
up at log cadence (much higher resolution than the ~60 s LocalStats
|
||||
packet) — see `recorder/parsers.py:_HEAP_PREFIX_RE`. Bool values
|
||||
expand to bare `-D<NAME>` (presence-only flags).
|
||||
"""
|
||||
args = ["run", "-e", env]
|
||||
if with_manifest:
|
||||
args.extend(["-t", "mtjson"])
|
||||
extra_env = _build_flags_env(build_flags) if build_flags else None
|
||||
with userprefs.temporary_overrides(userprefs_overrides) as effective:
|
||||
result = pio.run(args, timeout=pio.TIMEOUT_BUILD, check=False)
|
||||
result = pio.run(
|
||||
args,
|
||||
timeout=pio.TIMEOUT_BUILD,
|
||||
check=False,
|
||||
extra_env=extra_env,
|
||||
)
|
||||
return {
|
||||
"exit_code": result.returncode,
|
||||
"artifacts": [str(p) for p in _artifacts_for(env)],
|
||||
@@ -127,9 +142,27 @@ def build(
|
||||
"stderr_tail": pio.tail_lines(result.stderr, 200),
|
||||
"duration_s": round(result.duration_s, 2),
|
||||
"userprefs": _userprefs_summary(effective),
|
||||
"build_flags": dict(build_flags) if build_flags else None,
|
||||
}
|
||||
|
||||
|
||||
def _build_flags_env(build_flags: dict[str, Any]) -> dict[str, str]:
|
||||
"""Translate `{"DEBUG_HEAP": 1, "FOO": "bar"}` → `{"PLATFORMIO_BUILD_FLAGS":
|
||||
"-DDEBUG_HEAP=1 -DFOO=bar"}`. Bool True → bare `-D<NAME>`; False/None drop
|
||||
the flag entirely. Other types stringify."""
|
||||
parts: list[str] = []
|
||||
for key, value in build_flags.items():
|
||||
if value is False or value is None:
|
||||
continue
|
||||
if value is True:
|
||||
parts.append(f"-D{key}")
|
||||
else:
|
||||
parts.append(f"-D{key}={value}")
|
||||
if not parts:
|
||||
return {}
|
||||
return {"PLATFORMIO_BUILD_FLAGS": " ".join(parts)}
|
||||
|
||||
|
||||
def clean(env: str) -> dict[str, Any]:
|
||||
"""Run `pio run -e <env> -t clean`."""
|
||||
result = pio.run(["run", "-e", env, "-t", "clean"], timeout=120, check=False)
|
||||
@@ -146,20 +179,29 @@ def flash(
|
||||
port: str,
|
||||
confirm: bool = False,
|
||||
userprefs_overrides: dict[str, Any] | None = None,
|
||||
build_flags: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""`pio run -e <env> -t upload --upload-port <port>`. All architectures.
|
||||
|
||||
`userprefs_overrides` (optional): see `build()` — the rebuild-before-upload
|
||||
that pio performs will pick up the injected values.
|
||||
|
||||
`build_flags` (optional): same shape as `build()` — `PLATFORMIO_BUILD_FLAGS`
|
||||
is exported for the rebuild-before-upload, so the uploaded firmware
|
||||
actually carries the flags. Without this propagation, `pio run -t upload`
|
||||
would relink without the env var and silently drop them. Common use:
|
||||
`build_flags={"DEBUG_HEAP": 1}` for the leak-hunt path.
|
||||
"""
|
||||
_require_confirm(confirm, "flash")
|
||||
_reject_native_env(env, "flash")
|
||||
connection.reject_if_tcp(port, "flash")
|
||||
extra_env = _build_flags_env(build_flags) if build_flags else None
|
||||
with userprefs.temporary_overrides(userprefs_overrides) as effective:
|
||||
result = pio.run(
|
||||
["run", "-e", env, "-t", "upload", "--upload-port", port],
|
||||
timeout=pio.TIMEOUT_UPLOAD,
|
||||
check=False,
|
||||
extra_env=extra_env,
|
||||
)
|
||||
return {
|
||||
"exit_code": result.returncode,
|
||||
@@ -167,6 +209,7 @@ def flash(
|
||||
"stderr_tail": pio.tail_lines(result.stderr, 200),
|
||||
"duration_s": round(result.duration_s, 2),
|
||||
"userprefs": _userprefs_summary(effective),
|
||||
"build_flags": dict(build_flags) if build_flags else None,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
"""Read-side queries over the recorder's JSONL streams.
|
||||
|
||||
Pure functions over `mcp-server/.mtlog/`. Streaming JSONL reader: never
|
||||
loads a whole file. Time-bound queries short-circuit as soon as `ts`
|
||||
exceeds the requested end. The recorder writes monotonically, so a
|
||||
forward scan is cheap; we don't need an index.
|
||||
|
||||
All time arguments accept:
|
||||
- epoch seconds (int/float)
|
||||
- relative strings: "-15m", "-2h", "-3d", "now"
|
||||
- ISO-ish absolute strings: "2026-05-07T14:30:00" (naive timestamps are
|
||||
treated as UTC)
|
||||
|
||||
Tools that return data ALWAYS cap their output (max_lines / max_points
|
||||
/ max), and report whether more matched than was returned.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import gzip
|
||||
import json
|
||||
import re
|
||||
import statistics
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterator
|
||||
|
||||
from .recorder.recorder import get_recorder
|
||||
|
||||
_REL_RE = re.compile(r"^\s*-\s*(\d+(?:\.\d+)?)\s*([smhd])\s*$")
|
||||
_REGEX_PREVIEW_MAX = 100
|
||||
_REGEX_PREVIEW_TRUNCATE = 97
|
||||
|
||||
|
||||
def _parse_time(value: Any, *, now: float | None = None) -> float:
|
||||
"""Coerce to epoch seconds. Defaults `now` to `time.time()`."""
|
||||
if value is None:
|
||||
return time.time()
|
||||
if isinstance(value, (int, float)):
|
||||
return float(value)
|
||||
if not isinstance(value, str):
|
||||
raise ValueError(f"invalid time: {value!r}")
|
||||
s = value.strip().lower()
|
||||
if s in ("", "now"):
|
||||
return time.time() if now is None else now
|
||||
m = _REL_RE.match(s)
|
||||
if m:
|
||||
n = float(m.group(1))
|
||||
unit = m.group(2)
|
||||
secs = n * {"s": 1, "m": 60, "h": 3600, "d": 86400}[unit]
|
||||
base = time.time() if now is None else now
|
||||
return base - secs
|
||||
# Try ISO 8601. Accept naive (assume UTC) and Z-suffixed.
|
||||
try:
|
||||
if s.endswith("z"):
|
||||
s = s[:-1] + "+00:00"
|
||||
dt = datetime.fromisoformat(s)
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt.timestamp()
|
||||
except ValueError as e:
|
||||
raise ValueError(f"unparseable time: {value!r}") from e
|
||||
|
||||
|
||||
def _iter_jsonl(path: Path, *, since: float, until: float) -> Iterator[dict[str, Any]]:
|
||||
"""Stream records in chronological order: rotated archives first
|
||||
(oldest → newest by lex sort, which is chronological for our
|
||||
`YYYYMMDD-HHMMSS-uuuuuu-NNNNN` archive naming), then the live file
|
||||
last. The "keep last N" pop-front logic in the window queries
|
||||
relies on records arriving in time order across files.
|
||||
"""
|
||||
files: list[Path] = []
|
||||
# Gzipped archives are named "<stem>.YYYYMMDD-HHMMSS-uuuuuu-NNNNN.jsonl.gz".
|
||||
for archive in sorted(path.parent.glob(f"{path.stem}.*.jsonl.gz")):
|
||||
files.append(archive)
|
||||
if path.exists():
|
||||
files.append(path)
|
||||
for f in files:
|
||||
opener = gzip.open if f.suffix == ".gz" else open
|
||||
try:
|
||||
with opener(f, "rt", encoding="utf-8") as fh: # type: ignore[arg-type]
|
||||
for line in fh:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
rec = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
ts = rec.get("ts")
|
||||
if not isinstance(ts, (int, float)):
|
||||
continue
|
||||
if ts < since:
|
||||
continue
|
||||
if ts > until:
|
||||
# Records are append-monotonic within a file, so
|
||||
# the rest of this file is also past `until`.
|
||||
# Archives can still overlap each other, so only
|
||||
# short-circuit this file, not the whole scan.
|
||||
break
|
||||
yield rec
|
||||
except (FileNotFoundError, OSError):
|
||||
continue
|
||||
|
||||
|
||||
# -- queries ------------------------------------------------------------
|
||||
|
||||
|
||||
def logs_window(
|
||||
start: Any = "-15m",
|
||||
end: Any = "now",
|
||||
*,
|
||||
grep: str | None = None,
|
||||
level: str | None = None,
|
||||
tag: str | None = None,
|
||||
port: str | None = None,
|
||||
max_lines: int = 200,
|
||||
) -> dict[str, Any]:
|
||||
"""Recent firmware log lines, filtered.
|
||||
|
||||
`level` accepts a single level name or pipe-separated set
|
||||
("WARN|ERROR|CRIT"). `grep` is a regex (Python re) over the raw
|
||||
`line` field. Returns the last `max_lines` matches.
|
||||
"""
|
||||
s = _parse_time(start)
|
||||
e = _parse_time(end)
|
||||
levels = _split_set(level)
|
||||
if grep:
|
||||
try:
|
||||
grep_re = re.compile(grep)
|
||||
except re.error as exc:
|
||||
preview = (
|
||||
grep
|
||||
if len(grep) <= _REGEX_PREVIEW_MAX
|
||||
else f"{grep[:_REGEX_PREVIEW_TRUNCATE]}..."
|
||||
)
|
||||
raise ValueError(f"invalid grep regex {preview!r}: {exc}") from exc
|
||||
else:
|
||||
grep_re = None
|
||||
|
||||
base = get_recorder().base_dir
|
||||
matched = 0
|
||||
out: list[dict[str, Any]] = []
|
||||
for rec in _iter_jsonl(base / "logs.jsonl", since=s, until=e):
|
||||
if levels and rec.get("level") not in levels:
|
||||
continue
|
||||
if tag and rec.get("tag") != tag:
|
||||
continue
|
||||
if port and rec.get("port") != port:
|
||||
continue
|
||||
if grep_re and not grep_re.search(rec.get("line") or ""):
|
||||
continue
|
||||
matched += 1
|
||||
out.append(rec)
|
||||
if len(out) > max_lines:
|
||||
out.pop(0) # keep the most recent N
|
||||
return {
|
||||
"lines": out,
|
||||
"total_matched": matched,
|
||||
"dropped": max(0, matched - max_lines),
|
||||
"window": {"start": s, "end": e},
|
||||
}
|
||||
|
||||
|
||||
def telemetry_timeline(
|
||||
window: Any = "1h",
|
||||
*,
|
||||
variant: str = "local",
|
||||
field: str = "free_heap",
|
||||
port: str | None = None,
|
||||
max_points: int = 200,
|
||||
) -> dict[str, Any]:
|
||||
"""Timeseries of one telemetry field, downsampled.
|
||||
|
||||
`field` matches both the protobuf snake_case name (`free_heap`,
|
||||
`heap_free_bytes`, `battery_level`) and camelCase (`freeHeap`).
|
||||
Server-side bucket-mean downsamples to ≤ `max_points`. Returns
|
||||
`slope_per_min` (linear regression slope, units/min) so a leak
|
||||
detector can read one number.
|
||||
"""
|
||||
end = time.time()
|
||||
if isinstance(window, (int, float)):
|
||||
# Numeric `window` is a duration in seconds — "last N seconds".
|
||||
# Without this branch, `_parse_time(-N)` would treat -N as an
|
||||
# absolute epoch timestamp (i.e., Jan 1 1970 minus N seconds),
|
||||
# producing a wildly negative `start` and matching nothing.
|
||||
start = end - float(window)
|
||||
elif isinstance(window, str) and not window.startswith("-"):
|
||||
# Bare string like "1h" is sugar for "-1h".
|
||||
start = _parse_time(f"-{window}", now=end)
|
||||
else:
|
||||
start = _parse_time(window, now=end)
|
||||
|
||||
base = get_recorder().base_dir
|
||||
raw: list[tuple[float, float]] = []
|
||||
field_aliases = _field_aliases(field)
|
||||
for rec in _iter_jsonl(base / "telemetry.jsonl", since=start, until=end):
|
||||
if rec.get("variant") != variant:
|
||||
continue
|
||||
if port and rec.get("port") != port:
|
||||
continue
|
||||
fields = rec.get("fields") or {}
|
||||
value: Any = None
|
||||
for alias in field_aliases:
|
||||
if alias in fields:
|
||||
value = fields[alias]
|
||||
break
|
||||
if not isinstance(value, (int, float)):
|
||||
continue
|
||||
raw.append((float(rec["ts"]), float(value)))
|
||||
|
||||
if not raw:
|
||||
return {
|
||||
"points": [],
|
||||
"samples": 0,
|
||||
"min": None,
|
||||
"max": None,
|
||||
"slope_per_min": None,
|
||||
"window": {"start": start, "end": end, "variant": variant, "field": field},
|
||||
}
|
||||
|
||||
points = _downsample(raw, max_points=max_points)
|
||||
values = [v for _, v in raw]
|
||||
return {
|
||||
"points": [{"ts": ts, "value": v} for ts, v in points],
|
||||
"samples": len(raw),
|
||||
"min": min(values),
|
||||
"max": max(values),
|
||||
"slope_per_min": _slope_per_min(raw),
|
||||
"window": {"start": start, "end": end, "variant": variant, "field": field},
|
||||
}
|
||||
|
||||
|
||||
def packets_window(
|
||||
start: Any = "-5m",
|
||||
end: Any = "now",
|
||||
*,
|
||||
portnum: str | None = None,
|
||||
from_node: str | None = None,
|
||||
to_node: str | None = None,
|
||||
max: int = 200,
|
||||
) -> dict[str, Any]:
|
||||
s = _parse_time(start)
|
||||
e = _parse_time(end)
|
||||
portnums = _split_set(portnum)
|
||||
base = get_recorder().base_dir
|
||||
matched = 0
|
||||
out: list[dict[str, Any]] = []
|
||||
for rec in _iter_jsonl(base / "packets.jsonl", since=s, until=e):
|
||||
if portnums and rec.get("portnum") not in portnums:
|
||||
continue
|
||||
if from_node and str(rec.get("from_node")) != str(from_node):
|
||||
continue
|
||||
if to_node and str(rec.get("to_node")) != str(to_node):
|
||||
continue
|
||||
matched += 1
|
||||
out.append(rec)
|
||||
if len(out) > max:
|
||||
out.pop(0)
|
||||
return {
|
||||
"packets": out,
|
||||
"total_matched": matched,
|
||||
"dropped": matched - max if matched > max else 0,
|
||||
"window": {"start": s, "end": e},
|
||||
}
|
||||
|
||||
|
||||
def events_window(
|
||||
start: Any = "-1h",
|
||||
end: Any = "now",
|
||||
*,
|
||||
kind: str | None = None,
|
||||
max: int = 200,
|
||||
) -> dict[str, Any]:
|
||||
s = _parse_time(start)
|
||||
e = _parse_time(end)
|
||||
kinds = _split_set(kind)
|
||||
base = get_recorder().base_dir
|
||||
matched = 0
|
||||
out: list[dict[str, Any]] = []
|
||||
for rec in _iter_jsonl(base / "events.jsonl", since=s, until=e):
|
||||
if kinds and rec.get("kind") not in kinds:
|
||||
continue
|
||||
matched += 1
|
||||
out.append(rec)
|
||||
if len(out) > max:
|
||||
out.pop(0)
|
||||
return {
|
||||
"events": out,
|
||||
"total_matched": matched,
|
||||
"dropped": matched - max if matched > max else 0,
|
||||
"window": {"start": s, "end": e},
|
||||
}
|
||||
|
||||
|
||||
def export(
|
||||
start: Any,
|
||||
end: Any,
|
||||
dest_dir: str,
|
||||
*,
|
||||
streams: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Bundle a slice of each requested stream into `dest_dir`.
|
||||
|
||||
For a notebook, a bug report, or a Datadog backfill. Output files
|
||||
are uncompressed JSONL (callers gzip themselves if they want to).
|
||||
"""
|
||||
s = _parse_time(start)
|
||||
e = _parse_time(end)
|
||||
selected = streams or ["logs", "telemetry", "packets", "events"]
|
||||
dest = Path(dest_dir)
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
base = get_recorder().base_dir
|
||||
paths: dict[str, str] = {}
|
||||
for stream in selected:
|
||||
src = base / f"{stream}.jsonl"
|
||||
if not src.exists() and not list(base.glob(f"{stream}.*.jsonl.gz")):
|
||||
continue
|
||||
out_path = dest / f"{stream}.jsonl"
|
||||
n = 0
|
||||
with out_path.open("w", encoding="utf-8") as fh:
|
||||
for rec in _iter_jsonl(src, since=s, until=e):
|
||||
fh.write(json.dumps(rec, separators=(",", ":")) + "\n")
|
||||
n += 1
|
||||
paths[stream] = str(out_path)
|
||||
paths[f"{stream}_count"] = str(n)
|
||||
return {"dest_dir": str(dest), "paths": paths, "window": {"start": s, "end": e}}
|
||||
|
||||
|
||||
# -- helpers ------------------------------------------------------------
|
||||
|
||||
|
||||
def _split_set(value: str | None) -> set[str] | None:
|
||||
if not value:
|
||||
return None
|
||||
return {v.strip() for v in value.split("|") if v.strip()}
|
||||
|
||||
|
||||
def _field_aliases(field: str) -> list[str]:
|
||||
"""Accept snake_case OR camelCase, plus a few legacy aliases."""
|
||||
snake = field
|
||||
camel = _snake_to_camel(field)
|
||||
aliases = {snake, camel}
|
||||
# Old protobuf fields (pre-LocalStats) used different names
|
||||
legacy = {
|
||||
"free_heap": ["free_heap", "freeHeap", "heap_free_bytes", "heapFreeBytes"],
|
||||
"heap_free_bytes": [
|
||||
"heap_free_bytes",
|
||||
"heapFreeBytes",
|
||||
"free_heap",
|
||||
"freeHeap",
|
||||
],
|
||||
"total_heap": ["total_heap", "totalHeap", "heap_total_bytes", "heapTotalBytes"],
|
||||
"heap_total_bytes": [
|
||||
"heap_total_bytes",
|
||||
"heapTotalBytes",
|
||||
"total_heap",
|
||||
"totalHeap",
|
||||
],
|
||||
}
|
||||
if field in legacy:
|
||||
aliases.update(legacy[field])
|
||||
return list(aliases)
|
||||
|
||||
|
||||
def _snake_to_camel(name: str) -> str:
|
||||
parts = name.split("_")
|
||||
return parts[0] + "".join(p.title() for p in parts[1:])
|
||||
|
||||
|
||||
def _downsample(
|
||||
points: list[tuple[float, float]], *, max_points: int
|
||||
) -> list[tuple[float, float]]:
|
||||
if len(points) <= max_points:
|
||||
return points
|
||||
# Even-bucket mean. Preserves shape better than nth-sample picking.
|
||||
n = len(points)
|
||||
bucket = n / max_points
|
||||
out: list[tuple[float, float]] = []
|
||||
i = 0
|
||||
for k in range(max_points):
|
||||
end = int((k + 1) * bucket)
|
||||
end = min(end, n)
|
||||
if end <= i:
|
||||
continue
|
||||
chunk = points[i:end]
|
||||
ts = chunk[len(chunk) // 2][0]
|
||||
val = statistics.fmean(v for _, v in chunk)
|
||||
out.append((ts, val))
|
||||
i = end
|
||||
return out
|
||||
|
||||
|
||||
def _slope_per_min(points: list[tuple[float, float]]) -> float | None:
|
||||
"""Least-squares slope (units per minute). None if too few points."""
|
||||
if len(points) < 2:
|
||||
return None
|
||||
xs = [t for t, _ in points]
|
||||
ys = [v for _, v in points]
|
||||
n = len(xs)
|
||||
mean_x = sum(xs) / n
|
||||
mean_y = sum(ys) / n
|
||||
num = sum((xs[i] - mean_x) * (ys[i] - mean_y) for i in range(n))
|
||||
den = sum((x - mean_x) ** 2 for x in xs)
|
||||
if den == 0:
|
||||
return None
|
||||
slope_per_sec = num / den
|
||||
return slope_per_sec * 60.0
|
||||
@@ -92,6 +92,7 @@ def _run_capturing(
|
||||
cwd: Path | None = None,
|
||||
timeout: float | None = None,
|
||||
tee_header: str | None = None,
|
||||
extra_env: dict[str, str] | None = None,
|
||||
) -> tuple[int, str, str, float]:
|
||||
"""Run a subprocess, capture stdout+stderr, optionally tee to the flash log.
|
||||
|
||||
@@ -99,6 +100,9 @@ def _run_capturing(
|
||||
`subprocess.TimeoutExpired` on timeout (callers map this to their own
|
||||
domain-specific error).
|
||||
|
||||
`extra_env` merges into the subprocess environment (parent env stays
|
||||
intact). Used for `PLATFORMIO_BUILD_FLAGS=-DDEBUG_HEAP=1` and similar.
|
||||
|
||||
Fast path: `subprocess.run(capture_output=True)` when no flash log is
|
||||
configured (unchanged behavior).
|
||||
|
||||
@@ -110,6 +114,9 @@ def _run_capturing(
|
||||
"""
|
||||
log_path = _flash_log_path()
|
||||
t0 = time.monotonic()
|
||||
env = None
|
||||
if extra_env:
|
||||
env = {**os.environ, **extra_env}
|
||||
|
||||
if log_path is None:
|
||||
# Fast path — unchanged.
|
||||
@@ -119,6 +126,7 @@ def _run_capturing(
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
env=env,
|
||||
)
|
||||
return (
|
||||
proc.returncode,
|
||||
@@ -145,6 +153,7 @@ def _run_capturing(
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
bufsize=1, # line-buffered
|
||||
env=env,
|
||||
)
|
||||
stdout_chunks: list[str] = []
|
||||
stderr_chunks: list[str] = []
|
||||
@@ -232,12 +241,17 @@ def run(
|
||||
cwd: Path | None = None,
|
||||
timeout: float | None = TIMEOUT_DEFAULT,
|
||||
check: bool = True,
|
||||
extra_env: dict[str, str] | None = None,
|
||||
) -> PioResult:
|
||||
"""Invoke `pio <args>` and return captured output.
|
||||
|
||||
`cwd` defaults to the firmware root. `check=True` raises `PioError` on
|
||||
non-zero exit; set `check=False` to inspect `returncode` manually.
|
||||
|
||||
`extra_env` merges into the subprocess environment — used for
|
||||
`PLATFORMIO_BUILD_FLAGS=-DDEBUG_HEAP=1` and similar build-time
|
||||
toggles that can't be expressed as command-line args.
|
||||
|
||||
If `MESHTASTIC_MCP_FLASH_LOG` is set, output is also tee'd to that file
|
||||
line-by-line as it arrives (for live flash progress in the TUI).
|
||||
"""
|
||||
@@ -250,6 +264,7 @@ def run(
|
||||
cwd=work_dir,
|
||||
timeout=timeout,
|
||||
tee_header=f"pio {' '.join(args)}",
|
||||
extra_env=extra_env,
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise PioTimeout(f"pio {' '.join(args)} timed out after {timeout}s") from exc
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Persistent device-log capture.
|
||||
|
||||
Singleton `Recorder` subscribes once to the meshtastic pubsub fan-out
|
||||
(`meshtastic.log.line`, `meshtastic.receive.*`, `meshtastic.connection.*`)
|
||||
and appends to four JSONL files under `mcp-server/.mtlog/`. Pubsub is
|
||||
process-global so a single subscription captures every active interface
|
||||
(serial / TCP / BLE) without any per-connection bookkeeping.
|
||||
|
||||
The recorder is opt-in-by-import: importing this package is a no-op; call
|
||||
`get_recorder().start()` (which `server.py` does at FastMCP app init) to
|
||||
begin writing. `pause()` / `resume()` exist for the rare case the user
|
||||
wants a clean stretch of file (e.g. capturing a known-good baseline).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .recorder import Recorder, get_recorder
|
||||
|
||||
__all__ = ["Recorder", "get_recorder"]
|
||||
@@ -0,0 +1,309 @@
|
||||
"""Best-effort parsers for log lines and telemetry packets.
|
||||
|
||||
Two flavors of log line cross our pubsub subscription:
|
||||
1. Text-mode path (debug_log_api disabled): the meshtastic Python lib
|
||||
accumulates bytes between protobuf frames and emits the full
|
||||
firmware-formatted line, e.g.
|
||||
"INFO | 12:34:56 12345 [Main] Booting"
|
||||
— level, HH:MM:SS, uptime seconds, thread bracket, then message.
|
||||
2. LogRecord protobuf path (debug_log_api enabled): the lib calls
|
||||
`_handleLogLine(record.message)` with ONLY the message body. The
|
||||
level/source/time fields on the LogRecord are dropped before
|
||||
pubsub fan-out. We get e.g. just "Booting".
|
||||
|
||||
Both arrive on `meshtastic.log.line`. The parser tries to recover a
|
||||
level + thread when the prefix is present and falls back to level=None
|
||||
otherwise. Consumers who want level filtering on protobuf-mode hosts
|
||||
should grep the raw `line` field instead.
|
||||
|
||||
Telemetry: `meshtastic.receive.telemetry` packets carry one of several
|
||||
metric variants in `packet["decoded"]["telemetry"]`. We flatten the
|
||||
chosen variant into a {field: value} dict so callers don't have to
|
||||
know the protobuf shape.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
# Match: LEVEL | HH:MM:SS UPTIME [Thread] message
|
||||
# HH:MM:SS may be ??:??:?? when RTC isn't valid. The level alternation
|
||||
# below is the canonical list — DebugConfiguration.h's MESHTASTIC_LOG_LEVEL_*
|
||||
# macros must stay in sync with these strings.
|
||||
_LINE_RE = re.compile(
|
||||
r"""
|
||||
^
|
||||
(?P<level>DEBUG|INFO\ |WARN\ |ERROR|CRIT\ |TRACE|HEAP\ )
|
||||
\s*\|\s*
|
||||
(?P<clock>(?:\d{2}:\d{2}:\d{2})|(?:\?{2}:\?{2}:\?{2}))
|
||||
\s+
|
||||
(?P<uptime>\d+)
|
||||
\s+
|
||||
(?:\[(?P<thread>[^\]]+)\]\s+)?
|
||||
(?P<msg>.*)
|
||||
$
|
||||
""",
|
||||
re.VERBOSE,
|
||||
)
|
||||
|
||||
# DEBUG_HEAP build prepends `[heap N] ` to every message body, AFTER the
|
||||
# thread bracket. See src/RedirectablePrint.cpp:175.
|
||||
_HEAP_PREFIX_RE = re.compile(r"^\[heap\s+(?P<heap>\d+)\]\s+(?P<rest>.*)$")
|
||||
|
||||
# OSThread leak/free detection. See src/concurrency/OSThread.cpp:89-91.
|
||||
# Format: "------ Thread NAME leaked heap A -> B (delta) ------"
|
||||
# "++++++ Thread NAME freed heap A -> B (delta) ++++++"
|
||||
_THREAD_HEAP_RE = re.compile(
|
||||
r"""
|
||||
^[\-+]+\s*
|
||||
Thread\s+(?P<thread>\S+)\s+
|
||||
(?P<kind>leaked|freed)\s+heap\s+
|
||||
(?P<before>-?\d+)\s*->\s*(?P<after>-?\d+)\s+
|
||||
\((?P<delta>-?\d+)\)
|
||||
""",
|
||||
re.VERBOSE,
|
||||
)
|
||||
|
||||
# Power.cpp:908 periodic heap status (DEBUG_HEAP only).
|
||||
# Format: "Heap status: FREE/TOTAL bytes free (DELTA), running R/N threads"
|
||||
_HEAP_STATUS_RE = re.compile(
|
||||
r"""
|
||||
Heap\s+status:\s+
|
||||
(?P<free>\d+)\s*/\s*(?P<total>\d+)\s+bytes\s+free
|
||||
(?:\s+\((?P<delta>-?\d+)\))?
|
||||
""",
|
||||
re.VERBOSE,
|
||||
)
|
||||
|
||||
|
||||
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
|
||||
_HEAP_BRACKET_RE = re.compile(r"^heap\s+(?P<heap>\d+)$")
|
||||
|
||||
|
||||
def parse_log_line(line: str) -> dict[str, Any]:
|
||||
"""Best-effort decompose a raw firmware log line.
|
||||
|
||||
Returns a dict with at least `line` (the original, unmodified — ANSI
|
||||
codes preserved for fidelity). Adds `level`, `tag`, `clock`,
|
||||
`uptime_s`, and `msg` when the full prefix is present.
|
||||
|
||||
Handles two firmware quirks:
|
||||
- LogRecord.message can carry ANSI color escapes from RedirectablePrint
|
||||
(the BLE/StreamAPI path inherited the colored body in some builds).
|
||||
We strip ANSI before regex matching so the prefix survives.
|
||||
- DEBUG_HEAP injects `[heap N]` after the thread bracket. When NO
|
||||
thread name is set, the heap takes the thread bracket position —
|
||||
looks like `[heap 12345] msg`. We detect that shape and move it
|
||||
out of `tag` and into `heap_free`.
|
||||
|
||||
DEBUG_HEAP-build extras (when `[heap N]` is injected): `heap_free`
|
||||
(bytes), and when a `Thread X leaked|freed heap` line is recognized,
|
||||
`heap_event` = {kind, thread, before, after, delta}.
|
||||
|
||||
Never raises.
|
||||
"""
|
||||
out: dict[str, Any] = {"line": line}
|
||||
if not line:
|
||||
return out
|
||||
|
||||
# Strip ANSI escapes BEFORE any regex matching. The original `line`
|
||||
# stays in `out["line"]` for fidelity / future grep.
|
||||
clean = _ANSI_RE.sub("", line)
|
||||
|
||||
m = _LINE_RE.match(clean)
|
||||
msg: str | None = None
|
||||
if m:
|
||||
level = m.group("level").rstrip()
|
||||
out["level"] = level
|
||||
out["clock"] = m.group("clock")
|
||||
try:
|
||||
out["uptime_s"] = int(m.group("uptime"))
|
||||
except (TypeError, ValueError):
|
||||
out["uptime_s"] = None
|
||||
thread = m.group("thread")
|
||||
if thread:
|
||||
# If "thread" is actually the heap prefix taking the bracket
|
||||
# position (DEBUG_HEAP build, no thread set), capture heap
|
||||
# and leave tag unset.
|
||||
hb = _HEAP_BRACKET_RE.match(thread.strip())
|
||||
if hb:
|
||||
try:
|
||||
out["heap_free"] = int(hb.group("heap"))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
else:
|
||||
out["tag"] = thread
|
||||
msg = m.group("msg")
|
||||
out["msg"] = msg
|
||||
else:
|
||||
# No prefix — bare LogRecord.message body. Inspect the whole
|
||||
# line for DEBUG_HEAP-style content; the heap-prefix and
|
||||
# thread-leak patterns can survive on either path.
|
||||
msg = clean
|
||||
|
||||
# DEBUG_HEAP per-line heap prefix: `[heap 92344] message`.
|
||||
# Sits AFTER the thread bracket and BEFORE the message body, but
|
||||
# for bare LogRecord lines it's at the start. Match it at the
|
||||
# head of `msg`.
|
||||
if msg:
|
||||
hp = _HEAP_PREFIX_RE.match(msg)
|
||||
if hp:
|
||||
try:
|
||||
out["heap_free"] = int(hp.group("heap"))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
else:
|
||||
# Strip the prefix from `msg` so a grep on the message
|
||||
# body doesn't have to know about it.
|
||||
out["msg"] = hp.group("rest")
|
||||
msg = hp.group("rest")
|
||||
|
||||
# Thread-level leak/free detection.
|
||||
thr = _THREAD_HEAP_RE.search(msg)
|
||||
if thr:
|
||||
try:
|
||||
out["heap_event"] = {
|
||||
"kind": thr.group("kind"),
|
||||
"thread": thr.group("thread"),
|
||||
"before": int(thr.group("before")),
|
||||
"after": int(thr.group("after")),
|
||||
"delta": int(thr.group("delta")),
|
||||
}
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
# Power.cpp periodic "Heap status: F/T bytes free (D), running ..."
|
||||
hs = _HEAP_STATUS_RE.search(msg)
|
||||
if hs:
|
||||
try:
|
||||
out["heap_free"] = int(hs.group("free"))
|
||||
out["heap_total"] = int(hs.group("total"))
|
||||
if hs.group("delta") is not None:
|
||||
out["heap_delta"] = int(hs.group("delta"))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
return out
|
||||
|
||||
|
||||
# -- Telemetry ----------------------------------------------------------
|
||||
|
||||
# Order matters: meshtastic-python decoded packets use the protobuf
|
||||
# `oneof variant` field name (snake_case) as the dict key.
|
||||
_TELEMETRY_VARIANTS = (
|
||||
("device_metrics", "device"),
|
||||
("local_stats", "local"),
|
||||
("environment_metrics", "environment"),
|
||||
("power_metrics", "power"),
|
||||
("air_quality_metrics", "airQuality"),
|
||||
("health_metrics", "health"),
|
||||
("host_metrics", "host"),
|
||||
)
|
||||
|
||||
|
||||
def extract_telemetry(packet: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""Pull the telemetry variant + flat fields out of a `meshtastic.receive.telemetry`
|
||||
packet. Returns None when the shape isn't what we expect — so the
|
||||
caller can fall back to a generic packets.jsonl row.
|
||||
"""
|
||||
if not isinstance(packet, dict):
|
||||
return None
|
||||
decoded = packet.get("decoded")
|
||||
if not isinstance(decoded, dict):
|
||||
return None
|
||||
telem = decoded.get("telemetry")
|
||||
if not isinstance(telem, dict):
|
||||
return None
|
||||
# The Python lib produces dict-of-camelCase keys via MessageToDict.
|
||||
# Try both camelCase and snake_case to be robust to lib version drift.
|
||||
for snake, label in _TELEMETRY_VARIANTS:
|
||||
camel = _snake_to_camel(snake)
|
||||
for key in (snake, camel):
|
||||
value = telem.get(key)
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
"variant": label,
|
||||
"fields": {k: _scalarize(v) for k, v in value.items()},
|
||||
"time": telem.get("time"),
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def _snake_to_camel(name: str) -> str:
|
||||
parts = name.split("_")
|
||||
return parts[0] + "".join(p.title() for p in parts[1:])
|
||||
|
||||
|
||||
def _scalarize(value: Any) -> Any:
|
||||
"""Keep telemetry fields JSON-friendly. Lists/dicts pass through
|
||||
untouched; bytes -> hex string; protobuf enums occasionally arrive
|
||||
as ints (fine) or strings (also fine)."""
|
||||
if isinstance(value, (bytes, bytearray, memoryview)):
|
||||
return bytes(value).hex()
|
||||
return value
|
||||
|
||||
|
||||
# -- Generic packet summary ---------------------------------------------
|
||||
|
||||
|
||||
def summarize_packet(
|
||||
packet: dict[str, Any], *, payload_hex_len: int = 64
|
||||
) -> dict[str, Any]:
|
||||
"""Reduce a packet dict to a stable, queryable summary. Drops the
|
||||
full payload bytes — the recorder records summaries, not pcaps.
|
||||
"""
|
||||
if not isinstance(packet, dict):
|
||||
return {"raw_type": type(packet).__name__}
|
||||
decoded = packet.get("decoded") if isinstance(packet.get("decoded"), dict) else {}
|
||||
portnum = decoded.get("portnum") if isinstance(decoded, dict) else None
|
||||
payload = decoded.get("payload") if isinstance(decoded, dict) else None
|
||||
payload_hex = None
|
||||
payload_size = None
|
||||
if isinstance(payload, (bytes, bytearray, memoryview)):
|
||||
b = bytes(payload)
|
||||
payload_size = len(b)
|
||||
payload_hex = b[:payload_hex_len].hex() if b else ""
|
||||
elif isinstance(payload, str):
|
||||
# Some decoded payloads (text messages) come as decoded strings.
|
||||
payload_size = len(payload)
|
||||
payload_hex = None # not bytes
|
||||
return {
|
||||
"from_node": packet.get("fromId") or packet.get("from"),
|
||||
"to_node": packet.get("toId") or packet.get("to"),
|
||||
"portnum": portnum,
|
||||
"hop_limit": packet.get("hopLimit"),
|
||||
"want_ack": packet.get("wantAck"),
|
||||
"rx_rssi": packet.get("rxRssi"),
|
||||
"rx_snr": packet.get("rxSnr"),
|
||||
"channel": packet.get("channel"),
|
||||
"id": packet.get("id"),
|
||||
"payload_size": payload_size,
|
||||
"payload_hex_prefix": payload_hex,
|
||||
}
|
||||
|
||||
|
||||
# -- Interface identification ------------------------------------------
|
||||
|
||||
|
||||
def interface_label(interface: Any) -> dict[str, Any]:
|
||||
"""Stable identifier for the meshtastic interface that emitted an event.
|
||||
|
||||
Used as the `port`/`role` tag on every recorded row. SerialInterface
|
||||
has `devPath`; TCPInterface has `hostname`+`portNumber`; BLEInterface
|
||||
has `address`. Falls back to the class name when none of those exist.
|
||||
"""
|
||||
if interface is None:
|
||||
return {"port": None, "role": None}
|
||||
dev_path = getattr(interface, "devPath", None)
|
||||
if dev_path:
|
||||
return {"port": str(dev_path), "role": "serial"}
|
||||
hostname = getattr(interface, "hostname", None)
|
||||
if hostname:
|
||||
port_num = getattr(interface, "portNumber", None)
|
||||
endpoint = f"tcp://{hostname}:{port_num}" if port_num else f"tcp://{hostname}"
|
||||
return {"port": endpoint, "role": "tcp"}
|
||||
address = getattr(interface, "address", None)
|
||||
if address:
|
||||
return {"port": str(address), "role": "ble"}
|
||||
return {"port": type(interface).__name__, "role": None}
|
||||
@@ -0,0 +1,467 @@
|
||||
"""Process-global recorder singleton.
|
||||
|
||||
Subscribes once to the meshtastic pubsub fan-out and writes four append-only
|
||||
JSONL streams under `mcp-server/.mtlog/`. The pubsub fan-out is
|
||||
process-global — a single subscription captures every active interface
|
||||
without per-connection bookkeeping.
|
||||
|
||||
Files:
|
||||
logs.jsonl — every `meshtastic.log.line` event (best-effort prefix
|
||||
parsed for level/tag/uptime; raw `line` always preserved)
|
||||
telemetry.jsonl — `meshtastic.receive.telemetry` packets, flattened by
|
||||
variant (device / local / environment / power / etc.)
|
||||
packets.jsonl — every other `meshtastic.receive.*` packet, summarized
|
||||
(portnum, hops, RSSI/SNR, payload size + 64-byte hex)
|
||||
events.jsonl — connection lifecycle, node-DB updates, and manual
|
||||
`mark_event` rows. Lower volume; useful for aligning
|
||||
timelines.
|
||||
|
||||
Pause/resume: `pause()` flips a flag; subscriptions stay registered. The
|
||||
write methods short-circuit when paused, so we don't lose ordering when
|
||||
resumed (we just have a gap). No queueing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from . import parsers
|
||||
from .rotating import _RotatingJsonl
|
||||
|
||||
_DEFAULT_DIR = Path(__file__).resolve().parents[3] / ".mtlog"
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Recorder:
|
||||
"""Singleton write-side of the persistent log capture system."""
|
||||
|
||||
def __init__(self, base_dir: Path | None = None) -> None:
|
||||
self.base_dir = Path(base_dir) if base_dir else _DEFAULT_DIR
|
||||
self._lock = threading.RLock()
|
||||
self._started = False
|
||||
self._paused = False
|
||||
self._pause_reason: str | None = None
|
||||
self._started_at: float | None = None
|
||||
self._handlers: list[tuple[str, Any]] = []
|
||||
self._files: dict[str, _RotatingJsonl] = {}
|
||||
|
||||
# -- lifecycle ----------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
"""Idempotent. Safe to call from FastMCP app startup."""
|
||||
with self._lock:
|
||||
if self._started:
|
||||
return
|
||||
self.base_dir.mkdir(parents=True, exist_ok=True)
|
||||
self._files = {
|
||||
"logs": _RotatingJsonl(self.base_dir / "logs.jsonl"),
|
||||
"telemetry": _RotatingJsonl(self.base_dir / "telemetry.jsonl"),
|
||||
"packets": _RotatingJsonl(self.base_dir / "packets.jsonl"),
|
||||
"events": _RotatingJsonl(self.base_dir / "events.jsonl"),
|
||||
}
|
||||
self._wire_pubsub()
|
||||
self._started = True
|
||||
self._started_at = time.time()
|
||||
# Write the recorder_start marker after the initialization block.
|
||||
# `_write_event()` re-checks recorder state via `_files_snapshot()`,
|
||||
# so keeping this out of the setup block avoids nested lifecycle work.
|
||||
self._write_event(kind="recorder_start", label="recorder_started")
|
||||
|
||||
def stop(self) -> None:
|
||||
with self._lock:
|
||||
if not self._started:
|
||||
return
|
||||
self._unwire_pubsub()
|
||||
for f in self._files.values():
|
||||
f.close()
|
||||
self._files = {}
|
||||
self._started = False
|
||||
|
||||
def pause(self, reason: str | None = None) -> None:
|
||||
# Write the pause marker BEFORE flipping the flag — `_write_event`
|
||||
# short-circuits when paused, so the order matters for this event
|
||||
# to actually land in events.jsonl.
|
||||
self._write_event(
|
||||
kind="recorder_pause",
|
||||
label="paused",
|
||||
note=reason,
|
||||
)
|
||||
with self._lock:
|
||||
self._paused = True
|
||||
self._pause_reason = reason
|
||||
|
||||
def resume(self) -> None:
|
||||
# Mirror of `pause()`: clear the flag first, then write the marker
|
||||
# so it isn't suppressed by the still-paused short-circuit.
|
||||
with self._lock:
|
||||
self._paused = False
|
||||
self._pause_reason = None
|
||||
self._write_event(kind="recorder_resume", label="resumed")
|
||||
|
||||
# -- pubsub wiring ------------------------------------------------
|
||||
|
||||
def _wire_pubsub(self) -> None:
|
||||
from pubsub import pub # type: ignore[import-untyped]
|
||||
|
||||
# Subscribers — one per topic. Each pubsub publisher sends
|
||||
# keyword args matching its handler's signature; pubsub
|
||||
# introspects the function signature to route args.
|
||||
bindings = [
|
||||
("meshtastic.log.line", self._on_log_line),
|
||||
("meshtastic.serial.line", self._on_serial_line),
|
||||
("meshtastic.receive", self._on_receive),
|
||||
("meshtastic.receive.telemetry", self._on_telemetry),
|
||||
("meshtastic.connection.established", self._on_connection_established),
|
||||
("meshtastic.connection.lost", self._on_connection_lost),
|
||||
("meshtastic.node.updated", self._on_node_updated),
|
||||
]
|
||||
for topic, handler in bindings:
|
||||
try:
|
||||
pub.subscribe(handler, topic)
|
||||
self._handlers.append((topic, handler))
|
||||
except Exception as exc:
|
||||
# If pubsub refuses one binding (signature mismatch on
|
||||
# an old lib version), log it and keep the rest.
|
||||
log.warning("Recorder failed to subscribe to %s: %s", topic, exc)
|
||||
|
||||
def _unwire_pubsub(self) -> None:
|
||||
from pubsub import pub # type: ignore[import-untyped]
|
||||
|
||||
for topic, handler in self._handlers:
|
||||
try:
|
||||
pub.unsubscribe(handler, topic)
|
||||
except Exception:
|
||||
pass
|
||||
self._handlers.clear()
|
||||
|
||||
# -- handlers -----------------------------------------------------
|
||||
#
|
||||
# Pubsub callbacks must never raise. Every handler is wrapped in a
|
||||
# try/except that swallows so a bug here can't take down the
|
||||
# SerialInterface receive thread.
|
||||
#
|
||||
# Threading: handlers fire on whatever thread the meshtastic library
|
||||
# dispatches from (varies by interface), while `stop()` clears
|
||||
# `self._files` under `self._lock`. We snapshot `_files` under the
|
||||
# lock at the top of each handler so a concurrent stop can't
|
||||
# KeyError us mid-write. The actual file write goes through
|
||||
# `_RotatingJsonl` which has its own lock.
|
||||
|
||||
def _files_snapshot(self) -> dict[str, _RotatingJsonl] | None:
|
||||
"""Atomic-ish view of `self._files`. Returns None when the recorder
|
||||
is paused or stopped, so handlers can early-exit cleanly without
|
||||
racing `stop()`'s clear."""
|
||||
with self._lock:
|
||||
if not self._started or self._paused:
|
||||
return None
|
||||
return dict(self._files)
|
||||
|
||||
def _on_log_line(self, line: str, interface: Any = None) -> None:
|
||||
files = self._files_snapshot()
|
||||
if files is None:
|
||||
return
|
||||
try:
|
||||
tags = parsers.interface_label(interface)
|
||||
parsed = parsers.parse_log_line(str(line))
|
||||
ts = time.time()
|
||||
record: dict[str, Any] = {
|
||||
"ts": ts,
|
||||
"port": tags["port"],
|
||||
"role": tags["role"],
|
||||
"level": parsed.get("level"),
|
||||
"tag": parsed.get("tag"),
|
||||
"uptime_s": parsed.get("uptime_s"),
|
||||
"line": parsed["line"],
|
||||
}
|
||||
# DEBUG_HEAP enrichments (only present when the firmware
|
||||
# was built with -DDEBUG_HEAP=1). Surface as first-class
|
||||
# fields so logs_window can grep/filter on them and so
|
||||
# heap_free synthesizes a telemetry point below.
|
||||
if "heap_free" in parsed:
|
||||
record["heap_free"] = parsed["heap_free"]
|
||||
if "heap_total" in parsed:
|
||||
record["heap_total"] = parsed["heap_total"]
|
||||
if "heap_delta" in parsed:
|
||||
record["heap_delta"] = parsed["heap_delta"]
|
||||
heap_event = parsed.get("heap_event")
|
||||
if heap_event:
|
||||
record["heap_event"] = heap_event
|
||||
files["logs"].write(record)
|
||||
|
||||
# If the line carried a heap snapshot, also write it as a
|
||||
# synthesized LocalStats-shaped row so telemetry_timeline
|
||||
# picks it up at log cadence (much higher resolution than
|
||||
# the ~60 s LocalStats packet). Tagged source=debug_heap so
|
||||
# consumers can filter if mixing scales is unwanted.
|
||||
heap_free = parsed.get("heap_free")
|
||||
if isinstance(heap_free, int):
|
||||
fields: dict[str, Any] = {"heap_free_bytes": heap_free}
|
||||
heap_total = parsed.get("heap_total")
|
||||
if isinstance(heap_total, int):
|
||||
fields["heap_total_bytes"] = heap_total
|
||||
files["telemetry"].write(
|
||||
{
|
||||
"ts": ts,
|
||||
"port": tags["port"],
|
||||
"role": tags["role"],
|
||||
"from_node": None,
|
||||
"variant": "local",
|
||||
"fields": fields,
|
||||
"source": "debug_heap",
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _on_serial_line(self, line: str, port: str | None = None) -> None:
|
||||
"""Text-mode passive tap. Fired from `serial_session._drain` when a
|
||||
`pio device monitor` subprocess is running.
|
||||
|
||||
Same parse + heap-synthesis path as `_on_log_line`, but receives
|
||||
the raw text-formatted line (full level/clock/uptime/thread/`[heap N]`/
|
||||
body). On DEBUG_HEAP builds in text mode this gives us per-log-line
|
||||
heap data — far higher cadence than LocalStats, and works without
|
||||
protobuf API mode (no SerialInterface required).
|
||||
"""
|
||||
files = self._files_snapshot()
|
||||
if files is None:
|
||||
return
|
||||
try:
|
||||
parsed = parsers.parse_log_line(str(line))
|
||||
ts = time.time()
|
||||
record: dict[str, Any] = {
|
||||
"ts": ts,
|
||||
"port": port,
|
||||
"role": "serial_session",
|
||||
"level": parsed.get("level"),
|
||||
"tag": parsed.get("tag"),
|
||||
"uptime_s": parsed.get("uptime_s"),
|
||||
"line": parsed["line"],
|
||||
}
|
||||
if "heap_free" in parsed:
|
||||
record["heap_free"] = parsed["heap_free"]
|
||||
if "heap_total" in parsed:
|
||||
record["heap_total"] = parsed["heap_total"]
|
||||
if "heap_delta" in parsed:
|
||||
record["heap_delta"] = parsed["heap_delta"]
|
||||
heap_event = parsed.get("heap_event")
|
||||
if heap_event:
|
||||
record["heap_event"] = heap_event
|
||||
files["logs"].write(record)
|
||||
|
||||
# Synthesize a heap_free telemetry sample whenever the line
|
||||
# carries one — same logic as _on_log_line, tagged source so
|
||||
# consumers can distinguish text-mode tap from protobuf path.
|
||||
heap_free = parsed.get("heap_free")
|
||||
if isinstance(heap_free, int):
|
||||
fields: dict[str, Any] = {"heap_free_bytes": heap_free}
|
||||
heap_total = parsed.get("heap_total")
|
||||
if isinstance(heap_total, int):
|
||||
fields["heap_total_bytes"] = heap_total
|
||||
files["telemetry"].write(
|
||||
{
|
||||
"ts": ts,
|
||||
"port": port,
|
||||
"role": "serial_session",
|
||||
"from_node": None,
|
||||
"variant": "local",
|
||||
"fields": fields,
|
||||
"source": "debug_heap_serial",
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _on_telemetry(self, packet: dict[str, Any], interface: Any = None) -> None:
|
||||
files = self._files_snapshot()
|
||||
if files is None:
|
||||
return
|
||||
try:
|
||||
tags = parsers.interface_label(interface)
|
||||
extracted = parsers.extract_telemetry(packet)
|
||||
if extracted is None:
|
||||
# Couldn't extract a known variant — fall through to the
|
||||
# generic `_on_receive` path, which will still fire for
|
||||
# this packet via the parent topic.
|
||||
return
|
||||
record = {
|
||||
"ts": time.time(),
|
||||
"port": tags["port"],
|
||||
"role": tags["role"],
|
||||
"from_node": packet.get("fromId") or packet.get("from"),
|
||||
"variant": extracted["variant"],
|
||||
"fields": extracted["fields"],
|
||||
"device_time": extracted.get("time"),
|
||||
}
|
||||
files["telemetry"].write(record)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _on_receive(self, packet: dict[str, Any], interface: Any = None) -> None:
|
||||
# Generic-receive fires for EVERY packet. Telemetry packets get
|
||||
# recorded twice (here and in _on_telemetry) — that's intentional:
|
||||
# packets.jsonl is the universal record, telemetry.jsonl is the
|
||||
# structured timeseries view.
|
||||
files = self._files_snapshot()
|
||||
if files is None:
|
||||
return
|
||||
try:
|
||||
tags = parsers.interface_label(interface)
|
||||
summary = parsers.summarize_packet(packet)
|
||||
record = {
|
||||
"ts": time.time(),
|
||||
"port": tags["port"],
|
||||
"role": tags["role"],
|
||||
**summary,
|
||||
}
|
||||
files["packets"].write(record)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _on_connection_established(self, interface: Any = None) -> None:
|
||||
self._write_event(
|
||||
kind="connection_established",
|
||||
interface=interface,
|
||||
)
|
||||
|
||||
def _on_connection_lost(self, interface: Any = None) -> None:
|
||||
self._write_event(
|
||||
kind="connection_lost",
|
||||
interface=interface,
|
||||
)
|
||||
|
||||
def _on_node_updated(
|
||||
self, node: dict[str, Any] | None = None, interface: Any = None
|
||||
) -> None:
|
||||
# Lower-volume than packets but informative — node ID, hops away,
|
||||
# last heard. Skip the user dict if absent.
|
||||
try:
|
||||
user = (node or {}).get("user") if isinstance(node, dict) else None
|
||||
self._write_event(
|
||||
kind="node_updated",
|
||||
interface=interface,
|
||||
data={
|
||||
"num": (node or {}).get("num"),
|
||||
"id": (user or {}).get("id"),
|
||||
"short": (user or {}).get("shortName"),
|
||||
"long": (user or {}).get("longName"),
|
||||
"hops_away": (node or {}).get("hopsAway"),
|
||||
"snr": (node or {}).get("snr"),
|
||||
"last_heard": (node or {}).get("lastHeard"),
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# -- public write helpers -----------------------------------------
|
||||
|
||||
def mark_event(
|
||||
self,
|
||||
label: str,
|
||||
note: str | None = None,
|
||||
data: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""User-facing marker. Writes to events.jsonl AND emits a
|
||||
synthetic logs.jsonl row tagged level=MARK so timelines align.
|
||||
"""
|
||||
ts = self._write_event(kind="mark", label=label, note=note, data=data)
|
||||
# Mirror into logs so a single logs_window grep finds it.
|
||||
files = self._files_snapshot()
|
||||
if files is not None:
|
||||
try:
|
||||
files["logs"].write(
|
||||
{
|
||||
"ts": ts,
|
||||
"port": None,
|
||||
"role": "marker",
|
||||
"level": "MARK",
|
||||
"tag": "mark_event",
|
||||
"line": f"[mark] {label}" + (f" — {note}" if note else ""),
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return {"ts": ts, "label": label}
|
||||
|
||||
def _write_event(
|
||||
self,
|
||||
*,
|
||||
kind: str,
|
||||
label: str | None = None,
|
||||
note: str | None = None,
|
||||
interface: Any = None,
|
||||
data: dict[str, Any] | None = None,
|
||||
) -> float:
|
||||
ts = time.time()
|
||||
# Lifecycle markers (recorder_start, recorder_pause, recorder_resume)
|
||||
# arrive at choreographed moments — `pause()` writes BEFORE flipping
|
||||
# the flag and `resume()` writes AFTER clearing it, so those calls
|
||||
# see _paused=False here. Other event kinds short-circuit when
|
||||
# paused via the snapshot guard below.
|
||||
files = self._files_snapshot()
|
||||
if files is None:
|
||||
return ts
|
||||
try:
|
||||
tags = parsers.interface_label(interface)
|
||||
files["events"].write(
|
||||
{
|
||||
"ts": ts,
|
||||
"kind": kind,
|
||||
"label": label,
|
||||
"note": note,
|
||||
"port": tags["port"],
|
||||
"role": tags["role"],
|
||||
"data": data,
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return ts
|
||||
|
||||
# -- introspection ------------------------------------------------
|
||||
|
||||
def status(self) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
return {
|
||||
"running": self._started,
|
||||
"paused": self._paused,
|
||||
"pause_reason": self._pause_reason,
|
||||
"started_at": self._started_at,
|
||||
"base_dir": str(self.base_dir),
|
||||
"files": {name: f.status() for name, f in self._files.items()},
|
||||
}
|
||||
|
||||
def force_rotate_all(self) -> dict[str, Any]:
|
||||
"""Test/admin hook: rotate every stream right now."""
|
||||
with self._lock:
|
||||
files = list(self._files.values())
|
||||
for f in files:
|
||||
f.force_rotate()
|
||||
# `status()` re-acquires `self._lock`; release before calling it.
|
||||
return self.status()
|
||||
|
||||
|
||||
# -- module-level singleton accessor ------------------------------------
|
||||
|
||||
_INSTANCE_LOCK = threading.Lock()
|
||||
_INSTANCE: Recorder | None = None
|
||||
|
||||
|
||||
def get_recorder() -> Recorder:
|
||||
"""Return the process-global Recorder. Created on first call.
|
||||
|
||||
Honors `MESHTASTIC_MCP_LOG_DIR` env var for the base directory
|
||||
(used by tests to redirect to a tmpdir).
|
||||
"""
|
||||
global _INSTANCE
|
||||
with _INSTANCE_LOCK:
|
||||
if _INSTANCE is None:
|
||||
override = os.environ.get("MESHTASTIC_MCP_LOG_DIR")
|
||||
base = Path(override) if override else None
|
||||
_INSTANCE = Recorder(base_dir=base)
|
||||
return _INSTANCE
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Append-only JSONL writer with size-capped rotation.
|
||||
|
||||
A `_RotatingJsonl` owns one live `.jsonl` file. Writes are line-delimited
|
||||
JSON objects (one row per call). When the live file exceeds `max_bytes`,
|
||||
it is closed, gzipped to `<name>.YYYYMMDD-HHMMSS-uuuuuu-NNNNN.jsonl.gz`,
|
||||
and the live file resets to empty. Old archives past `keep_archives` are
|
||||
unlinked oldest-first.
|
||||
|
||||
Size check is amortized — `os.fstat` runs every `check_every` writes,
|
||||
not per-write, so the hot path stays at one `fh.write` + one `fh.flush`.
|
||||
|
||||
Threading: every public method acquires `self._lock`. The recorder runs
|
||||
several pubsub handlers on whatever thread the meshtastic library
|
||||
dispatches from (varies by interface), and queries from MCP tool calls
|
||||
arrive on the FastMCP request thread, so this lock is not optional.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import gzip
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
class _RotatingJsonl:
|
||||
"""Append-only JSONL with size rotation. Thread-safe."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
path: Path,
|
||||
*,
|
||||
max_bytes: int = 100 * 1024 * 1024,
|
||||
keep_archives: int = 5,
|
||||
check_every: int = 1000,
|
||||
) -> None:
|
||||
self.path = path
|
||||
self.max_bytes = max_bytes
|
||||
self.keep_archives = keep_archives
|
||||
self.check_every = check_every
|
||||
self._lock = threading.Lock()
|
||||
self._fh: Any = None
|
||||
self._writes_since_check = 0
|
||||
self._rotations = 0
|
||||
self._lines_written = 0
|
||||
self._last_ts: float | None = None
|
||||
self._open()
|
||||
|
||||
# -- lifecycle ----------------------------------------------------
|
||||
|
||||
def _open(self) -> None:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._fh = self.path.open("a", encoding="utf-8")
|
||||
|
||||
def close(self) -> None:
|
||||
with self._lock:
|
||||
if self._fh is not None:
|
||||
try:
|
||||
self._fh.close()
|
||||
finally:
|
||||
self._fh = None
|
||||
|
||||
# -- write --------------------------------------------------------
|
||||
|
||||
def write(self, record: dict[str, Any]) -> None:
|
||||
"""Append one JSON object as a line. Triggers rotation if oversized."""
|
||||
line = json.dumps(record, separators=(",", ":"), default=str) + "\n"
|
||||
with self._lock:
|
||||
if self._fh is None:
|
||||
return
|
||||
try:
|
||||
self._fh.write(line)
|
||||
self._fh.flush()
|
||||
except Exception:
|
||||
# Best-effort: a failed write must not crash the pubsub
|
||||
# handler. Caller has no way to react anyway.
|
||||
return
|
||||
self._lines_written += 1
|
||||
ts = record.get("ts")
|
||||
if isinstance(ts, (int, float)):
|
||||
self._last_ts = float(ts)
|
||||
self._writes_since_check += 1
|
||||
if self._writes_since_check >= self.check_every:
|
||||
self._writes_since_check = 0
|
||||
self._maybe_rotate()
|
||||
|
||||
# -- rotation -----------------------------------------------------
|
||||
|
||||
def _maybe_rotate(self) -> None:
|
||||
# Caller holds self._lock.
|
||||
try:
|
||||
size = os.fstat(self._fh.fileno()).st_size
|
||||
except OSError:
|
||||
return
|
||||
if size < self.max_bytes:
|
||||
return
|
||||
self._rotate_locked()
|
||||
|
||||
def _rotate_locked(self) -> None:
|
||||
# Close, gzip-rename, reopen empty, prune oldest archives.
|
||||
try:
|
||||
self._fh.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._fh = None
|
||||
# Microsecond-resolution timestamp + per-instance counter so back-
|
||||
# to-back rotations (small max_bytes, repeated `force_rotate()`,
|
||||
# or chatty test loops) get unique archive filenames. The lex
|
||||
# sort order of `YYYYMMDD-HHMMSS-uuuuuu-NNNNN` is chronological,
|
||||
# which `_prune_archives()` and `log_query._iter_jsonl()` both
|
||||
# rely on.
|
||||
stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S-%f")
|
||||
archive = self.path.with_suffix(f".{stamp}-{self._rotations:05d}.jsonl.gz")
|
||||
try:
|
||||
with self.path.open("rb") as src, gzip.open(archive, "wb") as dst:
|
||||
shutil.copyfileobj(src, dst, length=1024 * 1024)
|
||||
self.path.unlink()
|
||||
except Exception:
|
||||
# Rotation is best-effort. If gzip fails, leave the file
|
||||
# in place and re-open it; we'll try again next check.
|
||||
pass
|
||||
self._open()
|
||||
self._rotations += 1
|
||||
self._prune_archives()
|
||||
|
||||
def _prune_archives(self) -> None:
|
||||
# Match siblings of self.path.name with `.jsonl.gz` suffix.
|
||||
prefix = self.path.stem # "logs" for "logs.jsonl"
|
||||
# Archive filenames are already lexicographically chronological.
|
||||
# Prune by name, not mtime, so copied/restored files don't reorder.
|
||||
archives = sorted(self.path.parent.glob(f"{prefix}.*.jsonl.gz"))
|
||||
excess = len(archives) - self.keep_archives
|
||||
for old in archives[: max(0, excess)]:
|
||||
try:
|
||||
old.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def force_rotate(self) -> None:
|
||||
"""Test/admin hook: rotate immediately regardless of size."""
|
||||
with self._lock:
|
||||
if self._fh is not None:
|
||||
self._rotate_locked()
|
||||
|
||||
# -- introspection ------------------------------------------------
|
||||
|
||||
def status(self) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
try:
|
||||
size = os.fstat(self._fh.fileno()).st_size if self._fh else 0
|
||||
except OSError:
|
||||
size = 0
|
||||
return {
|
||||
"path": str(self.path),
|
||||
"size": size,
|
||||
"lines": self._lines_written,
|
||||
"last_ts": self._last_ts,
|
||||
"rotations": self._rotations,
|
||||
}
|
||||
@@ -46,7 +46,23 @@ class SerialSession:
|
||||
|
||||
|
||||
def _drain(session: SerialSession) -> None:
|
||||
"""Reader thread: line-by-line pull stdout into buffer."""
|
||||
"""Reader thread: line-by-line pull stdout into buffer.
|
||||
|
||||
Each line is also published to the `meshtastic.serial.line` pubsub
|
||||
topic so the persistent recorder can capture it without holding its
|
||||
own port. This is the text-mode tap path: when no SerialInterface is
|
||||
open, the firmware emits full formatted lines (level + clock + uptime
|
||||
+ thread + `[heap N]` prefix on DEBUG_HEAP builds + body), and we
|
||||
fan them out to whoever is listening. Pubsub is best-effort —
|
||||
publish failures must never block the reader.
|
||||
"""
|
||||
# Lazy import: pubsub isn't required just to import this module
|
||||
# (e.g., during static analysis), and we want a clean test surface.
|
||||
try:
|
||||
from pubsub import pub # type: ignore[import-untyped]
|
||||
except Exception: # pragma: no cover - defensive
|
||||
pub = None
|
||||
|
||||
assert session.proc.stdout is not None
|
||||
try:
|
||||
for line in session.proc.stdout:
|
||||
@@ -54,6 +70,16 @@ def _drain(session: SerialSession) -> None:
|
||||
with session.lock:
|
||||
session.buffer.append(line_stripped)
|
||||
session.total_lines += 1
|
||||
if pub is not None:
|
||||
try:
|
||||
pub.sendMessage(
|
||||
"meshtastic.serial.line",
|
||||
line=line_stripped,
|
||||
port=session.port,
|
||||
)
|
||||
except Exception:
|
||||
# A subscriber raising must not break the reader.
|
||||
pass
|
||||
except Exception: # pragma: no cover - defensive
|
||||
pass
|
||||
finally:
|
||||
|
||||
@@ -6,6 +6,7 @@ etc.). Business logic does not live here.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
@@ -17,14 +18,34 @@ from . import (
|
||||
flash,
|
||||
hw_tools,
|
||||
info,
|
||||
log_query,
|
||||
registry,
|
||||
serial_session,
|
||||
)
|
||||
from . import userprefs as userprefs_mod
|
||||
from .recorder import get_recorder
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
app = FastMCP("meshtastic-mcp")
|
||||
|
||||
|
||||
def _start_recorder() -> None:
|
||||
# Persistent device-log capture. Starts on first import — pubsub fan-out
|
||||
# is process-global, so subscribing here captures every active interface
|
||||
# (whether opened by an MCP tool, a pytest fixture, or a serial_session).
|
||||
# Files land in mcp-server/.mtlog/ (gitignored). See recorder/recorder.py
|
||||
# for the full design. Recorder startup is best-effort: an unwritable
|
||||
# log dir or pubsub mismatch should not take the MCP server down.
|
||||
try:
|
||||
get_recorder().start()
|
||||
except Exception as exc:
|
||||
log.warning("Failed to start persistent recorder: %s", exc)
|
||||
|
||||
|
||||
_start_recorder()
|
||||
|
||||
|
||||
# ---------- Discovery & metadata ------------------------------------------
|
||||
|
||||
|
||||
@@ -75,6 +96,7 @@ def build(
|
||||
env: str,
|
||||
with_manifest: bool = True,
|
||||
userprefs: dict[str, Any] | None = None,
|
||||
build_flags: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build firmware for one env via `pio run -e <env>`.
|
||||
|
||||
@@ -86,8 +108,21 @@ def build(
|
||||
build via userPrefs.jsonc injection. The file is restored after the build
|
||||
completes. Use `userprefs_manifest` to discover available keys. Use
|
||||
`userprefs_set` for persistent changes.
|
||||
|
||||
`build_flags` (optional): dict of `-D<NAME>=<VALUE>` macros for this build
|
||||
only, injected via `PLATFORMIO_BUILD_FLAGS`. Common pattern:
|
||||
`build_flags={"DEBUG_HEAP": 1}` enables per-thread leak detection + a
|
||||
`[heap N]` prefix on every log line. The recorder picks the prefix up
|
||||
automatically and synthesizes a high-resolution heap timeline that
|
||||
`telemetry_timeline(field="free_heap")` can read alongside the normal
|
||||
~60 s LocalStats packets. Pair with `/leakhunt` for classification.
|
||||
"""
|
||||
return flash.build(env, with_manifest=with_manifest, userprefs_overrides=userprefs)
|
||||
return flash.build(
|
||||
env,
|
||||
with_manifest=with_manifest,
|
||||
userprefs_overrides=userprefs,
|
||||
build_flags=build_flags,
|
||||
)
|
||||
|
||||
|
||||
@app.tool()
|
||||
@@ -105,6 +140,7 @@ def pio_flash(
|
||||
port: str,
|
||||
confirm: bool = False,
|
||||
userprefs: dict[str, Any] | None = None,
|
||||
build_flags: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Flash firmware via `pio run -e <env> -t upload --upload-port <port>`.
|
||||
|
||||
@@ -114,8 +150,19 @@ def pio_flash(
|
||||
|
||||
`userprefs` (optional): dict of `USERPREFS_<KEY>: value` baked into this
|
||||
build via userPrefs.jsonc injection; restored after upload.
|
||||
|
||||
`build_flags` (optional): dict of `-D<NAME>=<VALUE>` macros for the
|
||||
rebuild-before-upload, e.g. `{"DEBUG_HEAP": 1}`. Required for the flags
|
||||
to actually land in the uploaded firmware — without it, the implicit
|
||||
rebuild relinks without the env var and silently drops them.
|
||||
"""
|
||||
return flash.flash(env, port, confirm=confirm, userprefs_overrides=userprefs)
|
||||
return flash.flash(
|
||||
env,
|
||||
port,
|
||||
confirm=confirm,
|
||||
userprefs_overrides=userprefs,
|
||||
build_flags=build_flags,
|
||||
)
|
||||
|
||||
|
||||
@app.tool()
|
||||
@@ -734,3 +781,185 @@ def picotool_load(uf2_path: str, confirm: bool = False) -> dict[str, Any]:
|
||||
def picotool_raw(args: list[str], confirm: bool = False) -> dict[str, Any]:
|
||||
"""Pass-through to `picotool`. load/reboot/save/erase require confirm=True."""
|
||||
return hw_tools.picotool_raw(args, confirm=confirm)
|
||||
|
||||
|
||||
# ---------- Persistent device-log capture (recorder) ----------------------
|
||||
#
|
||||
# The recorder is autouse — it starts at server import and continuously
|
||||
# writes every meshtastic pubsub event to JSONL files under .mtlog/. These
|
||||
# tools are query-only over those files, plus a few lifecycle controls.
|
||||
|
||||
|
||||
@app.tool()
|
||||
def logs_window(
|
||||
start: str = "-15m",
|
||||
end: str = "now",
|
||||
grep: str | None = None,
|
||||
level: str | None = None,
|
||||
tag: str | None = None,
|
||||
port: str | None = None,
|
||||
max_lines: int = 200,
|
||||
) -> dict[str, Any]:
|
||||
"""Recent firmware log lines from the persistent recorder.
|
||||
|
||||
Filters by time window, regex over the line, level (single or
|
||||
pipe-separated set like "WARN|ERROR|CRIT"), thread-name tag, and
|
||||
interface port. Returns up to max_lines most-recent matches.
|
||||
|
||||
Time strings: "-15m", "-2h", "-3d", "now", or ISO 8601.
|
||||
|
||||
Note: lines arriving via the LogRecord protobuf path (when
|
||||
set_debug_log_api(True) is on) come without level prefix — the
|
||||
meshtastic Python lib drops record.level before fan-out. For those,
|
||||
`level` filter won't match; use `grep` instead.
|
||||
"""
|
||||
return log_query.logs_window(
|
||||
start=start,
|
||||
end=end,
|
||||
grep=grep,
|
||||
level=level,
|
||||
tag=tag,
|
||||
port=port,
|
||||
max_lines=max_lines,
|
||||
)
|
||||
|
||||
|
||||
@app.tool()
|
||||
def telemetry_timeline(
|
||||
window: str = "1h",
|
||||
variant: str = "local",
|
||||
field: str = "free_heap",
|
||||
port: str | None = None,
|
||||
max_points: int = 200,
|
||||
) -> dict[str, Any]:
|
||||
"""Time series of one telemetry field, downsampled to <= max_points.
|
||||
|
||||
`variant` ∈ device, local, environment, power, airQuality, health, host.
|
||||
`field` accepts snake_case or camelCase; common aliases (free_heap ↔
|
||||
heap_free_bytes) are normalized.
|
||||
|
||||
Returns slope_per_min (linear-regression slope, units/minute) so a
|
||||
leak detector can read one number — negative slope on free_heap over
|
||||
a long window indicates a real leak.
|
||||
|
||||
LocalStats variant ("local") cadence is ~60 s (whatever the device's
|
||||
`device_update_interval` is set to), so a 1 h window gives ~60 raw
|
||||
points. Bucket-mean downsampling preserves shape.
|
||||
"""
|
||||
return log_query.telemetry_timeline(
|
||||
window=window,
|
||||
variant=variant,
|
||||
field=field,
|
||||
port=port,
|
||||
max_points=max_points,
|
||||
)
|
||||
|
||||
|
||||
@app.tool()
|
||||
def packets_window(
|
||||
start: str = "-5m",
|
||||
end: str = "now",
|
||||
portnum: str | None = None,
|
||||
from_node: str | None = None,
|
||||
to_node: str | None = None,
|
||||
max: int = 200,
|
||||
) -> dict[str, Any]:
|
||||
"""Recent mesh packets recorded by the recorder.
|
||||
|
||||
Each row is a summary (portnum, from/to, hop_limit, RSSI/SNR, payload
|
||||
size + first 64 bytes hex) — full payload bytes are not stored.
|
||||
`portnum` accepts a pipe-separated set like "TEXT_MESSAGE_APP|POSITION_APP".
|
||||
"""
|
||||
return log_query.packets_window(
|
||||
start=start,
|
||||
end=end,
|
||||
portnum=portnum,
|
||||
from_node=from_node,
|
||||
to_node=to_node,
|
||||
max=max,
|
||||
)
|
||||
|
||||
|
||||
@app.tool()
|
||||
def events_window(
|
||||
start: str = "-1h",
|
||||
end: str = "now",
|
||||
kind: str | None = None,
|
||||
max: int = 200,
|
||||
) -> dict[str, Any]:
|
||||
"""Return recorder events: connection lifecycle, node updates, and `mark_event` markers.
|
||||
|
||||
`kind` ∈ recorder_start, recorder_pause, recorder_resume,
|
||||
connection_established, connection_lost, node_updated, mark.
|
||||
Pipe-separated sets ("connection_lost|connection_established") work.
|
||||
"""
|
||||
return log_query.events_window(start=start, end=end, kind=kind, max=max)
|
||||
|
||||
|
||||
@app.tool()
|
||||
def mark_event(
|
||||
label: str,
|
||||
note: str | None = None,
|
||||
data: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Drop a named marker into events.jsonl AND logs.jsonl.
|
||||
|
||||
Useful for aligning a timeline around a known stimulus: call before
|
||||
and after a stress workload, then query telemetry_timeline /
|
||||
logs_window with the markers' timestamps as bounds.
|
||||
|
||||
The marker also lands in logs.jsonl with level=MARK so a single
|
||||
grep over logs picks it up.
|
||||
"""
|
||||
return get_recorder().mark_event(label=label, note=note, data=data)
|
||||
|
||||
|
||||
@app.tool()
|
||||
def recorder_status() -> dict[str, Any]:
|
||||
"""Return recorder runtime info: running, paused, file sizes, last_ts per stream.
|
||||
|
||||
Use this to sanity-check that capture is working before you trust a
|
||||
`logs_window` / `telemetry_timeline` result.
|
||||
"""
|
||||
return get_recorder().status()
|
||||
|
||||
|
||||
@app.tool()
|
||||
def recorder_pause(reason: str | None = None) -> dict[str, Any]:
|
||||
"""Pause writes to all four streams. Pubsub subscriptions stay active —
|
||||
we just drop events on the floor while paused. Resume with `recorder_resume`.
|
||||
|
||||
Use when capturing a known-good baseline that you don't want to
|
||||
pollute with pre-test noise. Default state is recording; this is
|
||||
rarely needed.
|
||||
"""
|
||||
get_recorder().pause(reason=reason)
|
||||
return {"ok": True, "paused": True, "reason": reason}
|
||||
|
||||
|
||||
@app.tool()
|
||||
def recorder_resume() -> dict[str, Any]:
|
||||
"""Resume writes after `recorder_pause`. No-op if already running."""
|
||||
get_recorder().resume()
|
||||
return {"ok": True, "paused": False}
|
||||
|
||||
|
||||
@app.tool()
|
||||
def recorder_export(
|
||||
start: str,
|
||||
end: str,
|
||||
dest_dir: str,
|
||||
streams: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Bundle a slice of the recorder's streams into `dest_dir`.
|
||||
|
||||
Writes one uncompressed JSONL per requested stream (logs / telemetry /
|
||||
packets / events). Useful for: attaching to a bug report, feeding a
|
||||
notebook, or backfilling Datadog after the fact.
|
||||
"""
|
||||
return log_query.export(
|
||||
start=start,
|
||||
end=end,
|
||||
dest_dir=dest_dir,
|
||||
streams=streams,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Unit tests for the `build_flags` injection on `flash.build()`.
|
||||
|
||||
We don't actually run pio here — too slow, requires hardware-aware envs.
|
||||
We test the translation layer (`_build_flags_env`) and that the env vars
|
||||
are threaded through pio.run correctly via mock.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from meshtastic_mcp import flash, pio
|
||||
|
||||
|
||||
class TestBuildFlagsEnv:
|
||||
def test_simple_value(self) -> None:
|
||||
out = flash._build_flags_env({"DEBUG_HEAP": 1})
|
||||
assert out == {"PLATFORMIO_BUILD_FLAGS": "-DDEBUG_HEAP=1"}
|
||||
|
||||
def test_string_value(self) -> None:
|
||||
out = flash._build_flags_env({"FOO": "bar"})
|
||||
assert out == {"PLATFORMIO_BUILD_FLAGS": "-DFOO=bar"}
|
||||
|
||||
def test_bool_true_is_bare_flag(self) -> None:
|
||||
out = flash._build_flags_env({"DEBUG_HEAP": True})
|
||||
assert out == {"PLATFORMIO_BUILD_FLAGS": "-DDEBUG_HEAP"}
|
||||
|
||||
def test_bool_false_dropped(self) -> None:
|
||||
out = flash._build_flags_env({"DEBUG_HEAP": False, "OTHER": 1})
|
||||
assert out == {"PLATFORMIO_BUILD_FLAGS": "-DOTHER=1"}
|
||||
|
||||
def test_none_dropped(self) -> None:
|
||||
out = flash._build_flags_env({"DEBUG_HEAP": None})
|
||||
assert out == {}
|
||||
|
||||
def test_multiple_combined(self) -> None:
|
||||
out = flash._build_flags_env({"DEBUG_HEAP": 1, "FOO": "x", "BAR": True})
|
||||
# Order isn't guaranteed in dict iteration, so check membership.
|
||||
flags = out["PLATFORMIO_BUILD_FLAGS"].split()
|
||||
assert set(flags) == {"-DDEBUG_HEAP=1", "-DFOO=x", "-DBAR"}
|
||||
|
||||
|
||||
class TestBuildPropagatesFlags:
|
||||
def test_extra_env_passed_to_pio_run(self) -> None:
|
||||
# Mock pio.run so we don't actually invoke pio. Capture extra_env.
|
||||
captured = {}
|
||||
|
||||
class _StubResult:
|
||||
returncode = 0
|
||||
stdout = ""
|
||||
stderr = ""
|
||||
duration_s = 0.1
|
||||
|
||||
def _stub(args, **kwargs):
|
||||
captured["args"] = args
|
||||
captured["kwargs"] = kwargs
|
||||
return _StubResult()
|
||||
|
||||
with patch.object(pio, "run", side_effect=_stub):
|
||||
with patch.object(flash, "_artifacts_for", return_value=[]):
|
||||
out = flash.build(
|
||||
"fake-env",
|
||||
with_manifest=False,
|
||||
build_flags={"DEBUG_HEAP": 1},
|
||||
)
|
||||
assert captured["args"] == ["run", "-e", "fake-env"]
|
||||
assert captured["kwargs"]["extra_env"] == {
|
||||
"PLATFORMIO_BUILD_FLAGS": "-DDEBUG_HEAP=1"
|
||||
}
|
||||
assert out["build_flags"] == {"DEBUG_HEAP": 1}
|
||||
|
||||
def test_no_flags_means_no_extra_env(self) -> None:
|
||||
captured = {}
|
||||
|
||||
class _StubResult:
|
||||
returncode = 0
|
||||
stdout = ""
|
||||
stderr = ""
|
||||
duration_s = 0.1
|
||||
|
||||
def _stub(args, **kwargs):
|
||||
captured["kwargs"] = kwargs
|
||||
return _StubResult()
|
||||
|
||||
with patch.object(pio, "run", side_effect=_stub):
|
||||
with patch.object(flash, "_artifacts_for", return_value=[]):
|
||||
flash.build("fake-env", with_manifest=False)
|
||||
assert captured["kwargs"]["extra_env"] is None
|
||||
@@ -0,0 +1,548 @@
|
||||
"""Unit tests for the persistent device-log recorder.
|
||||
|
||||
Hardware-free: drives the Recorder through its `_on_*` handlers with
|
||||
synthetic packet/line dicts, then queries via log_query. Validates
|
||||
prefix parsing, telemetry variant dispatch, marker round-trip, time
|
||||
window filtering, downsampling, slope estimation, and gzip rotation
|
||||
+ archive pruning.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import gzip
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pubsub
|
||||
import pytest
|
||||
from meshtastic_mcp import log_query
|
||||
from meshtastic_mcp.recorder.parsers import (
|
||||
extract_telemetry,
|
||||
interface_label,
|
||||
parse_log_line,
|
||||
summarize_packet,
|
||||
)
|
||||
from meshtastic_mcp.recorder.recorder import Recorder
|
||||
from meshtastic_mcp.recorder.rotating import _RotatingJsonl
|
||||
|
||||
# -- isolation: every test gets a fresh Recorder + tmp dir -----------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def recorder(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Recorder:
|
||||
# Redirect both the Recorder and the module-level singleton lookup
|
||||
# to the same tmp dir so log_query queries the same files we write.
|
||||
monkeypatch.setenv("MESHTASTIC_MCP_LOG_DIR", str(tmp_path))
|
||||
monkeypatch.setattr(
|
||||
"meshtastic_mcp.recorder.recorder._INSTANCE", None, raising=False
|
||||
)
|
||||
r = Recorder(base_dir=tmp_path)
|
||||
r.start()
|
||||
monkeypatch.setattr("meshtastic_mcp.recorder.recorder._INSTANCE", r, raising=False)
|
||||
yield r
|
||||
r.stop()
|
||||
|
||||
|
||||
class _FakeIface:
|
||||
devPath = "/dev/cu.fake"
|
||||
|
||||
|
||||
# -- parsers ---------------------------------------------------------
|
||||
|
||||
|
||||
class TestParseLogLine:
|
||||
def test_full_prefix(self) -> None:
|
||||
out = parse_log_line("INFO | 12:34:56 12345 [Main] Booting")
|
||||
assert out["level"] == "INFO"
|
||||
assert out["tag"] == "Main"
|
||||
assert out["uptime_s"] == 12345
|
||||
assert out["msg"] == "Booting"
|
||||
assert out["clock"] == "12:34:56"
|
||||
|
||||
def test_invalid_clock(self) -> None:
|
||||
out = parse_log_line("WARN | ??:??:?? 7 [SerialConsole] Boot")
|
||||
assert out["level"] == "WARN"
|
||||
assert out["clock"] == "??:??:??"
|
||||
assert out["uptime_s"] == 7
|
||||
|
||||
def test_no_thread_bracket(self) -> None:
|
||||
out = parse_log_line("DEBUG | 00:00:00 0 raw message body")
|
||||
assert out["level"] == "DEBUG"
|
||||
assert out.get("tag") is None
|
||||
assert out["msg"] == "raw message body"
|
||||
|
||||
def test_bare_message(self) -> None:
|
||||
# LogRecord.message path — no level prefix at all.
|
||||
out = parse_log_line("just a bare message")
|
||||
assert "level" not in out or out.get("level") is None
|
||||
assert out["line"] == "just a bare message"
|
||||
|
||||
def test_empty(self) -> None:
|
||||
assert parse_log_line("") == {"line": ""}
|
||||
|
||||
def test_debug_heap_prefix_extracted(self) -> None:
|
||||
out = parse_log_line("INFO | 12:34:56 12345 [Main] [heap 92344] Booting")
|
||||
assert out["level"] == "INFO"
|
||||
assert out["tag"] == "Main"
|
||||
assert out["heap_free"] == 92344
|
||||
assert out["msg"] == "Booting"
|
||||
|
||||
def test_debug_heap_prefix_on_bare_line(self) -> None:
|
||||
# LogRecord.message path: no level prefix but still has [heap N].
|
||||
out = parse_log_line("[heap 12345] some message")
|
||||
assert out["heap_free"] == 12345
|
||||
assert out["msg"] == "some message"
|
||||
|
||||
def test_thread_leak_event(self) -> None:
|
||||
out = parse_log_line(
|
||||
"HEAP | 00:00:01 100 [Power] [heap 90000] "
|
||||
"------ Thread MeshPacket leaked heap 92344 -> 90000 (-2344) ------"
|
||||
)
|
||||
assert out["level"] == "HEAP"
|
||||
assert out["heap_free"] == 90000
|
||||
ev = out["heap_event"]
|
||||
assert ev["kind"] == "leaked"
|
||||
assert ev["thread"] == "MeshPacket"
|
||||
assert ev["before"] == 92344
|
||||
assert ev["after"] == 90000
|
||||
assert ev["delta"] == -2344
|
||||
|
||||
def test_thread_freed_event(self) -> None:
|
||||
out = parse_log_line(
|
||||
"++++++ Thread Router freed heap 1000 -> 1500 (500) ++++++"
|
||||
)
|
||||
ev = out["heap_event"]
|
||||
assert ev["kind"] == "freed"
|
||||
assert ev["thread"] == "Router"
|
||||
assert ev["delta"] == 500
|
||||
|
||||
def test_heap_status_periodic(self) -> None:
|
||||
out = parse_log_line(
|
||||
"HEAP | 00:00:30 30 [Power] "
|
||||
"Heap status: 92344/200000 bytes free (-128), running 8/12 threads"
|
||||
)
|
||||
assert out["heap_free"] == 92344
|
||||
assert out["heap_total"] == 200000
|
||||
assert out["heap_delta"] == -128
|
||||
|
||||
|
||||
class TestRecorderDebugHeapSynthesis:
|
||||
def test_log_with_heap_writes_telemetry(self, recorder: "Recorder") -> None:
|
||||
# When a log line carries [heap N], the recorder should also
|
||||
# emit a synthesized telemetry row tagged source=debug_heap.
|
||||
recorder._on_log_line(
|
||||
"INFO | 00:00:00 1 [Main] [heap 88888] hello",
|
||||
_FakeIface(),
|
||||
)
|
||||
telem = (recorder.base_dir / "telemetry.jsonl").read_text().splitlines()
|
||||
synth = [json.loads(r) for r in telem if '"source":"debug_heap"' in r]
|
||||
assert len(synth) == 1
|
||||
assert synth[0]["fields"]["heap_free_bytes"] == 88888
|
||||
assert synth[0]["variant"] == "local"
|
||||
|
||||
def test_heap_status_writes_total_too(self, recorder: "Recorder") -> None:
|
||||
recorder._on_log_line(
|
||||
"HEAP | 00:00:30 30 [Power] "
|
||||
"Heap status: 50000/200000 bytes free (-100), running 8/12 threads",
|
||||
_FakeIface(),
|
||||
)
|
||||
telem = (recorder.base_dir / "telemetry.jsonl").read_text().splitlines()
|
||||
synth = [json.loads(r) for r in telem if '"source":"debug_heap"' in r]
|
||||
assert synth[-1]["fields"]["heap_free_bytes"] == 50000
|
||||
assert synth[-1]["fields"]["heap_total_bytes"] == 200000
|
||||
|
||||
def test_no_heap_no_synthesis(self, recorder: "Recorder") -> None:
|
||||
# Plain log line (no [heap N], no Heap status) — telemetry.jsonl
|
||||
# should NOT gain a synth row.
|
||||
before = (recorder.base_dir / "telemetry.jsonl").read_text().count("\n")
|
||||
recorder._on_log_line("INFO | 00:00:00 1 [Main] just a message", _FakeIface())
|
||||
after = (recorder.base_dir / "telemetry.jsonl").read_text().count("\n")
|
||||
assert after == before
|
||||
|
||||
def test_thread_leak_event_persists_on_log_row(self, recorder: "Recorder") -> None:
|
||||
recorder._on_log_line(
|
||||
"HEAP | 00:00:01 100 [Power] [heap 90000] "
|
||||
"------ Thread MeshPacket leaked heap 92344 -> 90000 (-2344) ------",
|
||||
_FakeIface(),
|
||||
)
|
||||
rows = [
|
||||
json.loads(r)
|
||||
for r in (recorder.base_dir / "logs.jsonl").read_text().splitlines()
|
||||
if r
|
||||
]
|
||||
evt_rows = [r for r in rows if r.get("heap_event")]
|
||||
assert len(evt_rows) == 1
|
||||
assert evt_rows[0]["heap_event"]["thread"] == "MeshPacket"
|
||||
assert evt_rows[0]["heap_event"]["delta"] == -2344
|
||||
|
||||
|
||||
class TestSerialTap:
|
||||
def test_serial_line_records_log_and_synthesizes_heap(
|
||||
self, recorder: "Recorder"
|
||||
) -> None:
|
||||
recorder._on_serial_line(
|
||||
"INFO | 00:00:00 5 [Main] [heap 88888] tap-line",
|
||||
port="/dev/cu.tap",
|
||||
)
|
||||
logs = (recorder.base_dir / "logs.jsonl").read_text().splitlines()
|
||||
telem = (recorder.base_dir / "telemetry.jsonl").read_text().splitlines()
|
||||
log_rows = [json.loads(r) for r in logs if r]
|
||||
# Find the row from this call (port=/dev/cu.tap, role=serial_session)
|
||||
tap_rows = [r for r in log_rows if r.get("port") == "/dev/cu.tap"]
|
||||
assert len(tap_rows) == 1
|
||||
assert tap_rows[0]["role"] == "serial_session"
|
||||
assert tap_rows[0]["level"] == "INFO"
|
||||
assert tap_rows[0]["tag"] == "Main"
|
||||
assert tap_rows[0]["heap_free"] == 88888
|
||||
synth = [json.loads(r) for r in telem if '"source":"debug_heap_serial"' in r]
|
||||
assert len(synth) == 1
|
||||
assert synth[0]["fields"]["heap_free_bytes"] == 88888
|
||||
assert synth[0]["role"] == "serial_session"
|
||||
|
||||
def test_serial_line_thread_leak_event(self, recorder: "Recorder") -> None:
|
||||
recorder._on_serial_line(
|
||||
"HEAP | 00:00:30 30 [Power] [heap 53484] "
|
||||
"------ Thread Router leaked heap 53612 -> 53484 (-128) ------",
|
||||
port="/dev/cu.tap",
|
||||
)
|
||||
rows = [
|
||||
json.loads(r)
|
||||
for r in (recorder.base_dir / "logs.jsonl").read_text().splitlines()
|
||||
if r
|
||||
]
|
||||
evt = [r for r in rows if r.get("heap_event")]
|
||||
assert len(evt) == 1
|
||||
assert evt[0]["heap_event"]["thread"] == "Router"
|
||||
assert evt[0]["heap_event"]["delta"] == -128
|
||||
# Heap also synthesized.
|
||||
telem = (recorder.base_dir / "telemetry.jsonl").read_text()
|
||||
assert '"source":"debug_heap_serial"' in telem
|
||||
|
||||
def test_serial_line_pause(self, recorder: "Recorder") -> None:
|
||||
recorder.pause("baseline")
|
||||
recorder._on_serial_line(
|
||||
"INFO | 00:00:00 1 [t] [heap 1000] dropped",
|
||||
port="/dev/cu.tap",
|
||||
)
|
||||
# Only the pause event row should exist; no tap row.
|
||||
logs = (recorder.base_dir / "logs.jsonl").read_text()
|
||||
assert "dropped" not in logs
|
||||
|
||||
def test_serial_line_handler_swallows_exceptions(
|
||||
self, recorder: "Recorder"
|
||||
) -> None:
|
||||
# Hostile input — should not raise.
|
||||
recorder._on_serial_line(None, port="/dev/cu.tap") # type: ignore[arg-type]
|
||||
recorder._on_serial_line(b"\x00\x01\x02\x03", port="/dev/cu.tap") # type: ignore[arg-type]
|
||||
# Survived.
|
||||
|
||||
|
||||
class TestExtractTelemetry:
|
||||
def test_local_stats_camel(self) -> None:
|
||||
pkt = {
|
||||
"decoded": {
|
||||
"telemetry": {
|
||||
"localStats": {"heap_total_bytes": 1000, "heap_free_bytes": 600}
|
||||
}
|
||||
}
|
||||
}
|
||||
out = extract_telemetry(pkt)
|
||||
assert out is not None
|
||||
assert out["variant"] == "local"
|
||||
assert out["fields"]["heap_free_bytes"] == 600
|
||||
|
||||
def test_device_metrics_snake(self) -> None:
|
||||
pkt = {
|
||||
"decoded": {
|
||||
"telemetry": {"device_metrics": {"battery_level": 88, "voltage": 4.1}}
|
||||
}
|
||||
}
|
||||
out = extract_telemetry(pkt)
|
||||
assert out is not None
|
||||
assert out["variant"] == "device"
|
||||
assert out["fields"]["battery_level"] == 88
|
||||
|
||||
def test_unknown_variant_returns_none(self) -> None:
|
||||
assert extract_telemetry({"decoded": {"telemetry": {"weird": {}}}}) is None
|
||||
assert extract_telemetry({}) is None
|
||||
assert extract_telemetry({"decoded": "not-a-dict"}) is None
|
||||
|
||||
|
||||
class TestSummarizePacket:
|
||||
def test_text_with_payload(self) -> None:
|
||||
pkt = {
|
||||
"fromId": "!abc",
|
||||
"toId": "!def",
|
||||
"decoded": {"portnum": "TEXT_MESSAGE_APP", "payload": b"hello"},
|
||||
"hopLimit": 3,
|
||||
}
|
||||
out = summarize_packet(pkt)
|
||||
assert out["from_node"] == "!abc"
|
||||
assert out["portnum"] == "TEXT_MESSAGE_APP"
|
||||
assert out["payload_size"] == 5
|
||||
assert out["payload_hex_prefix"] == "68656c6c6f"
|
||||
|
||||
def test_no_decoded(self) -> None:
|
||||
out = summarize_packet({"fromId": "!abc"})
|
||||
assert out["from_node"] == "!abc"
|
||||
assert out["portnum"] is None
|
||||
|
||||
|
||||
class TestInterfaceLabel:
|
||||
def test_serial(self) -> None:
|
||||
assert interface_label(_FakeIface()) == {
|
||||
"port": "/dev/cu.fake",
|
||||
"role": "serial",
|
||||
}
|
||||
|
||||
def test_tcp(self) -> None:
|
||||
class T:
|
||||
hostname = "node.lan"
|
||||
portNumber = 4403
|
||||
|
||||
assert interface_label(T()) == {"port": "tcp://node.lan:4403", "role": "tcp"}
|
||||
|
||||
def test_unknown(self) -> None:
|
||||
assert interface_label(object()) == {"port": "object", "role": None}
|
||||
|
||||
def test_none(self) -> None:
|
||||
assert interface_label(None) == {"port": None, "role": None}
|
||||
|
||||
|
||||
# -- recorder write side ---------------------------------------------
|
||||
|
||||
|
||||
class TestRecorderWrites:
|
||||
def test_log_line_is_recorded(self, recorder: Recorder) -> None:
|
||||
recorder._on_log_line("INFO | 12:34:56 99 [T] hi", _FakeIface())
|
||||
path = recorder.base_dir / "logs.jsonl"
|
||||
rows = [json.loads(line) for line in path.read_text().splitlines() if line]
|
||||
# First row is recorder_start_event mirror? No — that's events.jsonl only.
|
||||
assert any(r.get("level") == "INFO" and r.get("tag") == "T" for r in rows)
|
||||
|
||||
def test_telemetry_recorded_and_packet_double(self, recorder: Recorder) -> None:
|
||||
# _on_telemetry alone — only telemetry.jsonl
|
||||
recorder._on_telemetry(
|
||||
{
|
||||
"fromId": "!abc",
|
||||
"decoded": {"telemetry": {"localStats": {"heap_free_bytes": 600}}},
|
||||
},
|
||||
_FakeIface(),
|
||||
)
|
||||
telem_rows = (recorder.base_dir / "telemetry.jsonl").read_text().splitlines()
|
||||
assert any('"variant":"local"' in r for r in telem_rows)
|
||||
|
||||
def test_packets_summary(self, recorder: Recorder) -> None:
|
||||
recorder._on_receive(
|
||||
{
|
||||
"fromId": "!abc",
|
||||
"toId": "!def",
|
||||
"decoded": {"portnum": "TEXT_MESSAGE_APP", "payload": b"hi"},
|
||||
},
|
||||
_FakeIface(),
|
||||
)
|
||||
rows = (recorder.base_dir / "packets.jsonl").read_text().splitlines()
|
||||
assert any('"portnum":"TEXT_MESSAGE_APP"' in r for r in rows)
|
||||
|
||||
def test_mark_event_round_trip(self, recorder: Recorder) -> None:
|
||||
out = recorder.mark_event("checkpoint", note="midpoint")
|
||||
assert "ts" in out
|
||||
events = (recorder.base_dir / "events.jsonl").read_text().splitlines()
|
||||
logs = (recorder.base_dir / "logs.jsonl").read_text().splitlines()
|
||||
assert any('"label":"checkpoint"' in r and '"kind":"mark"' in r for r in events)
|
||||
assert any('"level":"MARK"' in r and "checkpoint" in r for r in logs)
|
||||
|
||||
def test_pause_drops_writes(self, recorder: Recorder) -> None:
|
||||
before = len((recorder.base_dir / "logs.jsonl").read_text().splitlines())
|
||||
recorder.pause(reason="baseline")
|
||||
recorder._on_log_line("INFO | 00:00:00 1 [t] swallowed", _FakeIface())
|
||||
after = len((recorder.base_dir / "logs.jsonl").read_text().splitlines())
|
||||
assert after == before
|
||||
recorder.resume()
|
||||
recorder._on_log_line("INFO | 00:00:00 2 [t] kept", _FakeIface())
|
||||
post_resume = (recorder.base_dir / "logs.jsonl").read_text()
|
||||
assert "kept" in post_resume
|
||||
|
||||
def test_pubsub_handler_swallows_exceptions(self, recorder: Recorder) -> None:
|
||||
# If the writer dies, the pubsub callback must NOT raise — that
|
||||
# would crash the meshtastic receive thread.
|
||||
bad_packet = object() # not a dict
|
||||
recorder._on_receive(bad_packet, _FakeIface()) # type: ignore[arg-type]
|
||||
recorder._on_telemetry(bad_packet, _FakeIface()) # type: ignore[arg-type]
|
||||
recorder._on_log_line(None, _FakeIface()) # type: ignore[arg-type]
|
||||
# No assertion needed — survival is the test.
|
||||
|
||||
|
||||
# -- log_query read side ---------------------------------------------
|
||||
|
||||
|
||||
class TestLogQuery:
|
||||
def test_logs_window_grep_and_level(self, recorder: Recorder) -> None:
|
||||
recorder._on_log_line("INFO | 12:00:00 1 [A] alpha", _FakeIface())
|
||||
recorder._on_log_line("WARN | 12:00:01 2 [B] bravo failed", _FakeIface())
|
||||
recorder._on_log_line("ERROR | 12:00:02 3 [C] charlie failed", _FakeIface())
|
||||
|
||||
out = log_query.logs_window(start="-1m", level="WARN|ERROR", max_lines=10)
|
||||
assert out["total_matched"] == 2
|
||||
levels = {r["level"] for r in out["lines"]}
|
||||
assert levels == {"WARN", "ERROR"}
|
||||
|
||||
out2 = log_query.logs_window(start="-1m", grep=r"failed$", max_lines=10)
|
||||
assert out2["total_matched"] == 2
|
||||
|
||||
def test_logs_window_invalid_regex(self, recorder: Recorder) -> None:
|
||||
recorder._on_log_line("INFO | 12:00:00 1 [A] alpha", _FakeIface())
|
||||
with pytest.raises(ValueError, match="invalid grep regex"):
|
||||
log_query.logs_window(start="-1m", grep="(")
|
||||
|
||||
def test_telemetry_timeline_slope_and_downsample(self, recorder: Recorder) -> None:
|
||||
# Synthesize a downward leak: 100 points, free_heap drops 1 byte/sample.
|
||||
base_ts = time.time() - 60
|
||||
for i in range(100):
|
||||
recorder._files["telemetry"].write(
|
||||
{
|
||||
"ts": base_ts + i * 0.5,
|
||||
"port": "/dev/cu.fake",
|
||||
"role": "serial",
|
||||
"from_node": "!abc",
|
||||
"variant": "local",
|
||||
"fields": {"heap_free_bytes": 10000 - i},
|
||||
}
|
||||
)
|
||||
|
||||
out = log_query.telemetry_timeline(
|
||||
window="2m", variant="local", field="free_heap", max_points=10
|
||||
)
|
||||
assert out["samples"] == 100
|
||||
assert len(out["points"]) <= 10
|
||||
# Negative slope (heap dropping). Magnitude: 1 byte every 0.5s = 120/min.
|
||||
assert out["slope_per_min"] is not None
|
||||
assert out["slope_per_min"] < -100
|
||||
|
||||
def test_export_bundles_slice(self, recorder: Recorder, tmp_path: Path) -> None:
|
||||
recorder._on_log_line("INFO | 00:00:00 1 [t] one", _FakeIface())
|
||||
recorder._on_log_line("INFO | 00:00:00 2 [t] two", _FakeIface())
|
||||
dest = tmp_path / "bundle"
|
||||
out = log_query.export(start="-1m", end="now", dest_dir=str(dest))
|
||||
assert (dest / "logs.jsonl").exists()
|
||||
assert "logs" in out["paths"]
|
||||
|
||||
|
||||
# -- time parser -----------------------------------------------------
|
||||
|
||||
|
||||
class TestParseTime:
|
||||
def test_relative(self) -> None:
|
||||
now = 1_000_000.0
|
||||
assert log_query._parse_time("-15m", now=now) == now - 900
|
||||
assert log_query._parse_time("-2h", now=now) == now - 7200
|
||||
assert log_query._parse_time("-1d", now=now) == now - 86400
|
||||
|
||||
def test_now_and_epoch(self) -> None:
|
||||
now = 1_000_000.0
|
||||
assert log_query._parse_time("now", now=now) == now
|
||||
assert log_query._parse_time(now) == now
|
||||
|
||||
def test_iso(self) -> None:
|
||||
ts = log_query._parse_time("2026-01-01T00:00:00Z")
|
||||
assert isinstance(ts, float) and ts > 1_700_000_000
|
||||
|
||||
def test_naive_iso_assumes_utc(self) -> None:
|
||||
assert log_query._parse_time("2026-01-01T00:00:00") == log_query._parse_time(
|
||||
"2026-01-01T00:00:00Z"
|
||||
)
|
||||
|
||||
def test_invalid(self) -> None:
|
||||
with pytest.raises(ValueError):
|
||||
log_query._parse_time("not a time")
|
||||
|
||||
|
||||
# -- rotation --------------------------------------------------------
|
||||
|
||||
|
||||
class TestRotation:
|
||||
def test_size_cap_rotates_and_gzips(self, tmp_path: Path) -> None:
|
||||
path = tmp_path / "rot.jsonl"
|
||||
r = _RotatingJsonl(path, max_bytes=512, keep_archives=5, check_every=1)
|
||||
for i in range(100):
|
||||
r.write({"ts": float(i), "i": i, "pad": "x" * 40})
|
||||
r.close()
|
||||
archives = sorted(tmp_path.glob("rot.*.jsonl.gz"))
|
||||
assert archives, "expected at least one rotation"
|
||||
# Archive content is valid gzip + valid JSONL
|
||||
with gzip.open(archives[0], "rt") as fh:
|
||||
first = json.loads(fh.readline())
|
||||
assert "ts" in first
|
||||
|
||||
def test_archive_pruning(self, tmp_path: Path) -> None:
|
||||
path = tmp_path / "rot.jsonl"
|
||||
r = _RotatingJsonl(path, max_bytes=200, keep_archives=2, check_every=1)
|
||||
# Force several rotations.
|
||||
for _ in range(8):
|
||||
for i in range(20):
|
||||
r.write({"ts": float(i), "pad": "x" * 30})
|
||||
r.force_rotate()
|
||||
r.close()
|
||||
archives = sorted(tmp_path.glob("rot.*.jsonl.gz"))
|
||||
assert len(archives) <= 2, f"expected ≤2 kept archives, got {len(archives)}"
|
||||
|
||||
def test_archive_pruning_uses_filename_order(self, tmp_path: Path) -> None:
|
||||
path = tmp_path / "rot.jsonl"
|
||||
r = _RotatingJsonl(path, keep_archives=2)
|
||||
old = tmp_path / "rot.20260101-000000-000000-00000.jsonl.gz"
|
||||
mid = tmp_path / "rot.20260101-000001-000000-00000.jsonl.gz"
|
||||
new = tmp_path / "rot.20260101-000002-000000-00000.jsonl.gz"
|
||||
for archive in (old, mid, new):
|
||||
with gzip.open(archive, "wt", encoding="utf-8") as fh:
|
||||
fh.write('{"ts":1}\n')
|
||||
# Deliberately scramble mtimes so lexicographic filename order is
|
||||
# the only stable chronological signal.
|
||||
os.utime(old, (300, 300))
|
||||
os.utime(mid, (100, 100))
|
||||
os.utime(new, (200, 200))
|
||||
|
||||
r._prune_archives()
|
||||
r.close()
|
||||
|
||||
archives = sorted(p.name for p in tmp_path.glob("rot.*.jsonl.gz"))
|
||||
assert archives == [mid.name, new.name]
|
||||
|
||||
def test_force_rotate_when_below_threshold(self, tmp_path: Path) -> None:
|
||||
path = tmp_path / "rot.jsonl"
|
||||
r = _RotatingJsonl(path, max_bytes=10_000_000, check_every=999_999)
|
||||
r.write({"ts": 1.0, "msg": "tiny"})
|
||||
r.force_rotate()
|
||||
r.write({"ts": 2.0, "msg": "after-rotate"})
|
||||
r.close()
|
||||
archives = sorted(tmp_path.glob("rot.*.jsonl.gz"))
|
||||
assert len(archives) == 1
|
||||
assert path.exists()
|
||||
assert "after-rotate" in path.read_text()
|
||||
|
||||
|
||||
class TestRecorderLocks:
|
||||
def test_force_rotate_all_returns_status(self, recorder: Recorder) -> None:
|
||||
out = recorder.force_rotate_all()
|
||||
assert out["running"] is True
|
||||
assert out["files"]
|
||||
|
||||
def test_wire_pubsub_logs_subscription_failure(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
class FailingPubSubMock:
|
||||
def subscribe(self, callback: object, topic: str) -> None:
|
||||
raise RuntimeError("boom")
|
||||
|
||||
monkeypatch.setattr(pubsub, "pub", FailingPubSubMock())
|
||||
recorder = Recorder(base_dir=tmp_path)
|
||||
with caplog.at_level(logging.WARNING):
|
||||
recorder._wire_pubsub()
|
||||
assert (
|
||||
"Recorder failed to subscribe to meshtastic.log.line: boom" in caplog.text
|
||||
)
|
||||
+1
-1
Submodule protobufs updated: 1d6f1a71ff...b302d92332
@@ -1,35 +0,0 @@
|
||||
# Specification Quality Checklist: Hardware Support Agent
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-03-25
|
||||
**Feature**: [spec.md](/Users/benmeadors/Documents/GitHub/firmware/specs/129-hardware-support-agent/spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- Specification validated against the repository constitution on 2026-03-25.
|
||||
- Initial scope is intentionally bounded to reusable hardware context plus safe board-intake and scaffolding workflow preparation.
|
||||
@@ -1,58 +0,0 @@
|
||||
# Contract: Board Intake Request And Assessment
|
||||
|
||||
## Purpose
|
||||
|
||||
This contract defines the minimum maintainer input and the expected workflow output for the hardware-support-agent feature.
|
||||
|
||||
## Input Contract
|
||||
|
||||
### Required Fields
|
||||
|
||||
- `environment_name`: Proposed PlatformIO environment name for the new board.
|
||||
- `hardware_model`: Meshtastic hardware model identifier to assign.
|
||||
- `display_name`: Human-readable board name.
|
||||
- `architecture`: Target architecture family such as `esp32`, `esp32-s3`, `nrf52840`, `rp2040`, or `stm32`.
|
||||
|
||||
### Recommended Fields
|
||||
|
||||
- `hardware_model_slug`: Repository-style uppercase slug if already known.
|
||||
- `actively_supported`: Whether the board is intended to be actively supported.
|
||||
- `support_level`: Intended `custom_meshtastic_support_level` value.
|
||||
- `source_materials`: Links, file paths, or notes for schematics, pinouts, datasheets, or vendor pages.
|
||||
- `board_notes`: Freeform notes about revisions, peripherals, or known uncertainty.
|
||||
|
||||
## Validation Rules
|
||||
|
||||
- The workflow must reject or pause on missing required fields.
|
||||
- The workflow must detect conflicts with existing environment names or existing hardware identifiers where discoverable.
|
||||
- The workflow must not invent radio, power, display, GPS, input, or auxiliary pin mappings.
|
||||
- The workflow must identify when architecture defaults may be relevant and flag them for human review.
|
||||
|
||||
## Output Contract
|
||||
|
||||
### Required Assessment Sections
|
||||
|
||||
- `expected_artifacts`: What board-support artifacts are likely required.
|
||||
- `required_metadata`: What `custom_meshtastic_*` metadata and related fields must be decided.
|
||||
- `matched_patterns`: Existing repository examples that are closest to the request.
|
||||
- `evidence_gaps`: Missing or conflicting facts that block safe scaffolding.
|
||||
- `risk_flags`: Conditions that require special maintainer attention.
|
||||
- `next_actions`: Concrete steps to move the request toward scaffold readiness.
|
||||
- `scaffold_ready`: Boolean decision indicating whether draft file generation is safe.
|
||||
|
||||
## Artifact Expectations
|
||||
|
||||
Depending on the board and architecture, the assessment should consider whether the request will need:
|
||||
|
||||
- a new or reused variant directory under `variants/<architecture>/...`
|
||||
- `variant.h`
|
||||
- optional `variant.cpp`
|
||||
- one or more PlatformIO environments with `custom_meshtastic_*` metadata
|
||||
- board images, tags, partition-scheme metadata, or DFU metadata
|
||||
- any architecture-specific board files or notes for inherited defaults
|
||||
|
||||
## Non-Goals For Phase 1
|
||||
|
||||
- Direct parsing of PDF schematics.
|
||||
- Automatic merging of new board files into the repository.
|
||||
- Any change to firmware runtime behavior, protocol behavior, or shared board defaults.
|
||||
@@ -1,124 +0,0 @@
|
||||
# Data Model: Hardware Support Agent
|
||||
|
||||
## Hardware Support Context
|
||||
|
||||
Purpose: Repository-backed summary of current target-definition patterns across supported board variants.
|
||||
|
||||
Fields:
|
||||
|
||||
- source_paths: list of repository glob roots used to build the context
|
||||
- generated_at: timestamp or generation marker
|
||||
- variant_count: integer count of scanned variant directories
|
||||
- environment_count: integer count of summarized PlatformIO environments
|
||||
- metadata*keys: list of observed `custom_meshtastic*\*` keys
|
||||
- category_counts: mapping of capability category to common macros and frequencies
|
||||
- architecture_inventory: list of architecture groups and their environments
|
||||
- representative_examples: list of example boards with extracted metadata and macro samples
|
||||
- cautions: list of caveats about inherited defaults, multi-environment boards, and verification limits
|
||||
|
||||
Validation rules:
|
||||
|
||||
- Must be derived from repository state rather than hand-maintained guesses.
|
||||
- Must clearly separate explicit declarations from inherited/default behavior where known.
|
||||
- Must remain read-only context and not imply that any new board is validated for merge.
|
||||
|
||||
Relationships:
|
||||
|
||||
- Used by Board Intake Request as the canonical repository pattern source.
|
||||
|
||||
## Board Intake Request
|
||||
|
||||
Purpose: Maintainer-supplied description of a proposed new board or board revision.
|
||||
|
||||
Fields:
|
||||
|
||||
- environment_name: proposed PlatformIO environment name
|
||||
- hardware_model: numeric or repository-convention hardware model identifier
|
||||
- hardware_model_slug: uppercase slug when known
|
||||
- display_name: human-readable board name
|
||||
- architecture: target family such as `esp32-s3` or `nrf52840`
|
||||
- source_materials: optional list of schematic, pinout, datasheet, or board-page references
|
||||
- support_level: optional intended support metadata
|
||||
- actively_supported: optional boolean intent
|
||||
- board_notes: optional maintainer notes about revisions, optional peripherals, or known gaps
|
||||
|
||||
Validation rules:
|
||||
|
||||
- `environment_name`, `hardware_model`, and `display_name` are required for phase 1 intake.
|
||||
- Architecture is required before any artifact expectation can be considered complete.
|
||||
- Source materials are optional for submission but required for moving unresolved hardware fields toward scaffold generation.
|
||||
- Conflicts with existing environment names or hardware identifiers must be surfaced.
|
||||
|
||||
Relationships:
|
||||
|
||||
- Produces one Intake Assessment.
|
||||
- May eventually lead to one or more Board Support Scaffolds.
|
||||
|
||||
## Intake Assessment
|
||||
|
||||
Purpose: Structured result of evaluating a Board Intake Request against repository patterns and evidence sufficiency.
|
||||
|
||||
Fields:
|
||||
|
||||
- expected_artifacts: list of files or sections likely needed, such as `variant.h`, optional `variant.cpp`, PlatformIO environment entries, board metadata, images, or tags
|
||||
- required*metadata: list of mandatory `custom_meshtastic*\*` values and board-definition fields
|
||||
- matched_patterns: list of related repository examples by architecture or board family
|
||||
- evidence_gaps: list of unresolved or conflicting hardware facts
|
||||
- risk_flags: list of issues such as ambiguous board revisions, unsupported peripherals, or inherited-default uncertainty
|
||||
- next_actions: ordered maintainer actions needed before safe scaffold generation
|
||||
- scaffold_ready: boolean indicating whether evidence is sufficient for a later scaffold phase
|
||||
|
||||
Validation rules:
|
||||
|
||||
- Must never infer unsupported pin mappings silently.
|
||||
- Must describe missing information in maintainer-actionable language.
|
||||
- Must remain architecture- and variant-scoped.
|
||||
|
||||
Relationships:
|
||||
|
||||
- Derived from Board Intake Request and Hardware Support Context.
|
||||
- Blocks or permits creation of Board Support Scaffold.
|
||||
|
||||
## Evidence Gap
|
||||
|
||||
Purpose: Specific missing, conflicting, or ambiguous fact that prevents safe draft generation.
|
||||
|
||||
Fields:
|
||||
|
||||
- category: metadata, radio, display, input, GPS, power, storage, connectivity, or revision-scope
|
||||
- description: maintainer-readable explanation of what is missing or conflicting
|
||||
- affected_artifact: target file or configuration area impacted
|
||||
- required_evidence: type of source needed to resolve the gap
|
||||
- blocking: boolean indicating whether the gap prevents scaffold generation
|
||||
|
||||
Validation rules:
|
||||
|
||||
- Must be traceable to a missing repository pattern or missing board truth.
|
||||
- Must not be collapsed into generic “needs more info” language when the specific blocker is knowable.
|
||||
|
||||
Relationships:
|
||||
|
||||
- Belongs to an Intake Assessment.
|
||||
|
||||
## Board Support Scaffold
|
||||
|
||||
Purpose: Draft board-support content for a new target once evidence is sufficient.
|
||||
|
||||
Fields:
|
||||
|
||||
- target_variant_dir: proposed variant directory path
|
||||
- variant_h_content: draft content or structured sections for `variant.h`
|
||||
- variant_cpp_content: optional draft for `variant.cpp`
|
||||
- platformio_env_content: draft PlatformIO environment metadata and extends chain
|
||||
- unresolved_annotations: inline markers for any remaining non-blocking maintainer review items
|
||||
- source_basis: references to intake data and repository patterns used to draft content
|
||||
|
||||
Validation rules:
|
||||
|
||||
- Must only include fields backed by evidence and repository conventions.
|
||||
- Must preserve variant-scoped truth and never modify unrelated board definitions.
|
||||
- May only be produced when Intake Assessment marks `scaffold_ready` true.
|
||||
|
||||
Relationships:
|
||||
|
||||
- Produced from Intake Assessment after gaps are resolved.
|
||||
@@ -1,97 +0,0 @@
|
||||
# Implementation Plan: Hardware Support Agent
|
||||
|
||||
**Branch**: `[129-hardware-support-agent]` | **Date**: 2026-03-25 | **Spec**: /Users/benmeadors/Documents/GitHub/firmware/specs/129-hardware-support-agent/spec.md
|
||||
**Input**: Feature specification from `/Users/benmeadors/Documents/GitHub/firmware/specs/129-hardware-support-agent/spec.md`
|
||||
|
||||
**Note**: This plan covers Phase 0 and Phase 1 outputs for a constitution-safe first increment. The first delivery scope centers on repository-backed hardware context plus intake validation and report generation; scaffold generation remains designed but gated behind sufficient evidence.
|
||||
|
||||
## Summary
|
||||
|
||||
Build a Copilot-oriented hardware support workflow for Meshtastic that starts from repository-derived board-definition context, accepts a constrained board intake request, and produces a structured readiness report before any new board files are drafted. The technical approach uses lightweight Python tooling and repository-local markdown/JSON contract artifacts to inventory existing `variant.h` and `platformio.ini` patterns, normalize maintainer inputs, identify evidence gaps, and prepare a later scaffolding phase without changing live firmware behavior.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: Python 3.x for workflow tooling, Markdown for generated artifacts, existing C/C++/PlatformIO repository conventions for downstream scaffold targets
|
||||
**Primary Dependencies**: Python standard library for inventory/intake tooling, existing Spec Kit artifacts, repository-local Copilot prompt/agent files, PlatformIO environment metadata conventions already in `variants/**/platformio.ini`
|
||||
**Storage**: Repository-local files under `docs/`, `specs/129-hardware-support-agent/`, optional future intake examples under repo docs or fixtures
|
||||
**Testing**: Targeted script execution, generated artifact review, `python3 bin/generate_hardware_support_context.py`, `trunk fmt` where applicable for markdown/templates, and targeted follow-up validation against representative variant files
|
||||
**Target Platform**: Maintainer workflow inside the Meshtastic firmware repository on macOS/Linux development environments; outputs describe supported firmware architectures including ESP32, ESP32-S3, ESP32-C3, ESP32-C6, nRF52, RP2040/RP2350, STM32, and native patterns
|
||||
**Project Type**: Repository tooling and Copilot workflow support
|
||||
**Performance Goals**: Generate context and intake reports quickly enough for interactive maintainer use on a local checkout; avoid repository-wide processing that would materially slow a normal Copilot session
|
||||
**Constraints**: No changes to mesh protocol behavior or shared device defaults; no guessing of pin mappings; architecture-specific truth must stay variant-scoped; keep implementation dependency-light and reviewable
|
||||
**Scale/Scope**: Inventory currently spans 166 variant directories and 212 PlatformIO environments; phase 1 scope covers context generation, intake contract, evidence-gap reporting, and custom-agent planning, not autonomous end-to-end board enablement
|
||||
|
||||
## Constitution Check
|
||||
|
||||
_GATE: Must pass before Phase 0 research. Re-check after Phase 1 design._
|
||||
|
||||
- Safety-critical mesh impact: Pass. This feature is workflow/documentation/tooling only and does not modify routing, airtime, MQTT, channel, packet-path, or public default behavior.
|
||||
- Variant and platform scope: Pass. The workflow is explicitly scoped to board-definition artifacts and requires variant-specific evidence before any scaffold output is permitted.
|
||||
- Validation evidence: Pass with explicit plan. Validation for this phase is `python3 bin/generate_hardware_support_context.py`, manual spot-check against representative variants, and review of generated documentation/contracts. Later implementation phases should add targeted intake fixture checks.
|
||||
- Resource, power, memory, dependency impact: Pass. The feature adds lightweight local tooling and markdown contracts only, with no runtime firmware impact and no new external runtime dependency requirement.
|
||||
- Constitutional violations or gaps: No active violations for Phase 0/1 planning. The only open product choice is whether future phase 2 includes scaffold generation immediately or remains report-only until more intake validation exists.
|
||||
|
||||
### Post-Design Re-Check
|
||||
|
||||
- Safety-critical mesh impact remains unchanged after design: no firmware runtime path is altered.
|
||||
- Variant-scoped hardware truth is reinforced by the intake contract and evidence-gap model.
|
||||
- Validation evidence remains sufficient for design artifacts, with implementation tasks needing targeted script-level checks.
|
||||
- Resource and dependency impact remains minimal and repository-local.
|
||||
- No justification entries are required in Complexity Tracking for this plan.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/129-hardware-support-agent/
|
||||
├── plan.md
|
||||
├── research.md
|
||||
├── data-model.md
|
||||
├── quickstart.md
|
||||
├── contracts/
|
||||
│ └── board-intake-contract.md
|
||||
└── tasks.md
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
.github/
|
||||
├── agents/
|
||||
└── prompts/
|
||||
|
||||
bin/
|
||||
├── generate_hardware_support_context.py
|
||||
├── board_intake.py
|
||||
└── board_scaffold.py
|
||||
|
||||
docs/
|
||||
└── hardware-support-context.md
|
||||
|
||||
variants/
|
||||
├── esp32/
|
||||
├── esp32c3/
|
||||
├── esp32c6/
|
||||
├── esp32s2/
|
||||
├── esp32s3/
|
||||
├── native/
|
||||
├── nrf52840/
|
||||
├── rp2040/
|
||||
├── rp2350/
|
||||
└── stm32/
|
||||
```
|
||||
|
||||
**Structure Decision**: Use the existing single-repository tooling structure. Planning artifacts live under `specs/129-hardware-support-agent/`, reusable generated context stays in `docs/`, implementation scripts live in `bin/`, and any future custom Copilot workflow files belong in `.github/prompts/` and `.github/agents/`. No new top-level application structure is needed.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
No constitutional violations or complexity exceptions are currently required.
|
||||
|
||||
## Review Notes
|
||||
|
||||
- **SC-004** is a post-launch business metric and is not a blocking acceptance gate before merge. A timed baseline comparison should be collected after the first real board intake using the completed workflow.
|
||||
- Validation completed so far: `python3 bin/generate_hardware_support_context.py --validate`, `python3 bin/board_intake.py bin/fixtures/intake_minimal.json --validate`, `python3 bin/board_intake.py bin/fixtures/intake_full.json`, `python3 bin/board_intake.py bin/fixtures/intake_multi_env.json`, `python3 bin/board_scaffold.py bin/fixtures/intake_full.json`, and `python3 bin/board_scaffold.py bin/fixtures/intake_multi_env.json`.
|
||||
- `bin/board_scaffold.py` intentionally emits placeholder pin and metadata values plus `// TODO: verify — ...` annotations. Generated scaffold output is draft-only and not suitable for direct merge without maintainer review against schematics and existing board patterns.
|
||||
- Scaffold generation currently writes to `generated/hardware-support/` rather than directly into `variants/` to keep the workflow reviewable and constitution-safe.
|
||||
- Skipped validations: no targeted `pio run`, native test, or simulator run was executed because this feature adds repository tooling and generated draft artifacts only; no firmware runtime files under `src/` were changed.
|
||||
@@ -1,111 +0,0 @@
|
||||
# Quickstart: Hardware Support Agent
|
||||
|
||||
## Goal
|
||||
|
||||
Use the repository-backed hardware support workflow to understand current board-definition patterns and evaluate whether a new board request is ready for safe scaffolding.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Work from the repository root.
|
||||
- Ensure Python 3 is available.
|
||||
- Have the new board’s minimum intake information ready:
|
||||
- proposed PlatformIO environment name
|
||||
- hardware model identifier
|
||||
- display name
|
||||
- target architecture
|
||||
- any available schematic, pinout, or datasheet references
|
||||
|
||||
## Step 1: Regenerate the repository context artifact
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
python3 bin/generate_hardware_support_context.py
|
||||
```
|
||||
|
||||
Review:
|
||||
|
||||
- `docs/hardware-support-context.md`
|
||||
|
||||
Confirm that the architectures, representative examples, and metadata keys still reflect the current repository state.
|
||||
|
||||
## Step 2: Prepare the board intake request
|
||||
|
||||
Capture the request using the contract in:
|
||||
|
||||
- `specs/129-hardware-support-agent/contracts/board-intake-contract.md`
|
||||
|
||||
At minimum, fill:
|
||||
|
||||
- environment name
|
||||
- hardware model
|
||||
- display name
|
||||
- architecture
|
||||
|
||||
Add source materials for radio, display, power, GPS, input, and auxiliary peripherals when available.
|
||||
|
||||
## Step 3: Evaluate readiness
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
python3 bin/board_intake.py path/to/intake.json
|
||||
```
|
||||
|
||||
The intake report now includes:
|
||||
|
||||
- request summary
|
||||
- expected artifacts
|
||||
- required metadata
|
||||
- matched repository patterns
|
||||
- blocking and non-blocking evidence gaps
|
||||
- risk flags
|
||||
- next actions
|
||||
- a `scaffold_ready` decision
|
||||
|
||||
For gate-style validation, run:
|
||||
|
||||
```bash
|
||||
python3 bin/board_intake.py path/to/intake.json --validate
|
||||
```
|
||||
|
||||
This exits non-zero when blocking gaps remain.
|
||||
|
||||
## Step 4: Generate scaffold output when ready
|
||||
|
||||
If Step 3 reports `scaffold_ready: true`, run:
|
||||
|
||||
```bash
|
||||
python3 bin/board_scaffold.py path/to/intake.json --output-dir generated/hardware-support
|
||||
```
|
||||
|
||||
Expected outputs:
|
||||
|
||||
- `generated/hardware-support/variants/<arch>/<variant-dir>/variant.h`
|
||||
- `generated/hardware-support/variants/<arch>/<variant-dir>/platformio.ini`
|
||||
- optional `variant.cpp` for ESP32-family targets
|
||||
|
||||
If the intake is not scaffold-ready, the scaffold command prints the assessment and exits non-zero instead of generating files.
|
||||
|
||||
## Step 5: Validate the workflow artifacts
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
python3 bin/generate_hardware_support_context.py
|
||||
python3 bin/board_intake.py bin/fixtures/intake_full.json
|
||||
python3 bin/board_scaffold.py bin/fixtures/intake_full.json --output-dir generated/hardware-support
|
||||
```
|
||||
|
||||
Then manually spot-check representative targets such as:
|
||||
|
||||
- `variants/esp32/tbeam`
|
||||
- `variants/esp32s3/tlora-pager`
|
||||
- `variants/nrf52840/t-echo`
|
||||
- `variants/rp2040/rak11310`
|
||||
|
||||
Ensure the generated context, intake assessment, and scaffold output remain consistent with repository truth.
|
||||
|
||||
## Next Step
|
||||
|
||||
Use [hardware-support.prompt.md](.github/prompts/hardware-support.prompt.md) and [hardware-support.agent.md](.github/agents/hardware-support.agent.md) to invoke the workflow directly from Copilot.
|
||||
@@ -1,46 +0,0 @@
|
||||
# Research: Hardware Support Agent
|
||||
|
||||
## Decision: Use repository-local Python tooling and markdown artifacts as the first implementation slice
|
||||
|
||||
Rationale: The repository already uses lightweight scripts under `bin/` and maintains board truth primarily in `variants/**/platformio.ini` and `variants/**/variant.h`. A Python script plus markdown outputs fits existing repo patterns, keeps dependencies minimal, and provides immediate value without touching firmware runtime code.
|
||||
|
||||
Alternatives considered:
|
||||
|
||||
- Implement the first increment directly as a full custom Copilot agent that generates new board files. Rejected because the workflow still needs a safer evidence-validation layer before scaffolding hardware definitions.
|
||||
- Build a standalone service or extension-backed parser. Rejected because it would add unnecessary complexity, operational overhead, and dependencies for a repo-local maintainer workflow.
|
||||
|
||||
## Decision: Treat intake validation and readiness reporting as the first generation boundary
|
||||
|
||||
Rationale: The constitution requires verified, variant-scoped hardware truth and forbids guessing pins or capabilities. A report-first boundary lets maintainers capture missing evidence, expected artifacts, and metadata requirements before draft files are produced.
|
||||
|
||||
Alternatives considered:
|
||||
|
||||
- Generate `variant.h` and `platformio.ini` scaffolding immediately from minimum inputs. Rejected because environment name, `hw_model`, and display name are not enough to guarantee correct radio, power, display, and peripheral mappings.
|
||||
- Block all workflow progress until every future scaffold field is known. Rejected because maintainers still need a useful way to understand what is missing and what repository patterns apply.
|
||||
|
||||
## Decision: Reuse the existing hardware inventory document as the canonical context input for planning
|
||||
|
||||
Rationale: `docs/hardware-support-context.md` already inventories metadata keys, common macro categories, and representative board examples across the repository. That artifact can serve as the phase 1 foundation for both maintainers and future agent prompts.
|
||||
|
||||
Alternatives considered:
|
||||
|
||||
- Generate a new per-architecture context file for each family. Rejected for now because a single canonical context artifact is easier to review and sufficient for the first intake/report workflow.
|
||||
- Depend on maintainers manually browsing variant files during intake. Rejected because it defeats the feature goal of reducing rediscovery work.
|
||||
|
||||
## Decision: Represent the maintainer workflow as a small set of explicit entities and a human-readable contract
|
||||
|
||||
Rationale: The feature is primarily a repository workflow, not a networked API. A markdown contract describing required fields, validations, and outputs is sufficient for Spec Kit design and future agent/prompt implementation.
|
||||
|
||||
Alternatives considered:
|
||||
|
||||
- Define a JSON Schema or OpenAPI contract immediately. Rejected for phase 1 because no external service boundary exists yet and the workflow is still evolving.
|
||||
- Keep the contract implicit in prompt text only. Rejected because it would be harder to review, test, and keep aligned with the constitution.
|
||||
|
||||
## Decision: Keep scaffold generation as a designed later phase gated by evidence sufficiency
|
||||
|
||||
Rationale: The user wants the end state to include new board-support scaffolding, but constitutional safety requires a stronger intake and validation model first. Planning the later phase now preserves momentum without collapsing safe and unsafe scopes together.
|
||||
|
||||
Alternatives considered:
|
||||
|
||||
- Remove scaffolding from the feature entirely. Rejected because it is core to the requested long-term outcome.
|
||||
- Merge report generation and scaffolding into a single undifferentiated phase. Rejected because it weakens reviewability and blurs the safety boundary.
|
||||
@@ -1,170 +0,0 @@
|
||||
# Feature Specification: Hardware Support Agent
|
||||
|
||||
**Feature Branch**: `[129-hardware-support-agent]`
|
||||
**Created**: 2026-03-25
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Implement the feature specification based on the updated constitution. I want to build an agent inside copilot to add new hardware support, including variant.h/cpp, any platformio ini environments will all of our custom metadata, and all of the pinmappings. I would start this process by just giving the board environment name, hw_model, display name, and perhaps some source materials illustrating the pin mappings in a PDF schematic for instance. I think we should start by creating a context for you in the form of a markdown file documenting all of the current input, button, radio, gpio, and other common pins we use in the meshtastic firmware at a device target definition level, so that we reuse instead of re-invent."
|
||||
|
||||
## User Scenarios & Testing _(mandatory)_
|
||||
|
||||
<!--
|
||||
IMPORTANT: User stories should be PRIORITIZED as user journeys ordered by importance.
|
||||
Each user story/journey must be INDEPENDENTLY TESTABLE - meaning if you implement just ONE of them,
|
||||
you should still have a viable MVP (Minimum Viable Product) that delivers value.
|
||||
|
||||
Assign priorities (P1, P2, P3, etc.) to each story, where P1 is the most critical.
|
||||
Think of each story as a standalone slice of functionality that can be:
|
||||
- Developed independently
|
||||
- Tested independently
|
||||
- Deployed independently
|
||||
- Demonstrated to users independently
|
||||
-->
|
||||
|
||||
### User Story 1 - Build Reusable Hardware Context (Priority: P1)
|
||||
|
||||
As a firmware maintainer adding support for a new device, I want a single repository-backed reference
|
||||
that summarizes the common board-definition inputs already used across Meshtastic targets so I can
|
||||
start from verified patterns instead of re-deriving pins, feature flags, and board metadata from scratch.
|
||||
|
||||
**Why this priority**: Without an authoritative context source, any later agent workflow will repeat the
|
||||
same manual discovery work and risks copying incorrect or incomplete target definitions.
|
||||
|
||||
**Independent Test**: Can be fully tested by generating the hardware context artifact from the current
|
||||
repository and confirming it captures existing target-definition fields, common pins, and board-scoped
|
||||
capability patterns for a representative set of boards.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an existing firmware checkout with multiple board variants, **When** a maintainer requests
|
||||
the hardware support context, **Then** the system produces a markdown artifact that documents current
|
||||
board-definition inputs, common pin categories, and recurring variant-level capabilities from the repo.
|
||||
2. **Given** a maintainer reviewing an existing board, **When** they inspect the context artifact,
|
||||
**Then** they can identify the board environment, hardware model identifiers, display-related fields,
|
||||
radio pin definitions, input pins, and other commonly reused target-definition elements without
|
||||
manually scanning many variant files.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Define New Board Intake (Priority: P2)
|
||||
|
||||
As a firmware maintainer, I want to provide a small set of board inputs such as environment name,
|
||||
hardware model, display name, and source materials so the Copilot workflow can determine what new
|
||||
hardware support artifacts need to be created or filled in.
|
||||
|
||||
**Why this priority**: A constrained intake contract is required before the workflow can safely generate
|
||||
variant files and PlatformIO environments for new hardware support.
|
||||
|
||||
**Independent Test**: Can be tested independently by supplying the declared board inputs for a hypothetical
|
||||
new target and confirming the workflow identifies required artifacts, missing evidence, and board-definition
|
||||
fields that must be resolved before code generation proceeds.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a maintainer provides an environment name, hardware model, display name, and source references,
|
||||
**When** the intake workflow runs, **Then** it identifies the expected target-definition artifacts,
|
||||
required metadata fields, and unresolved board details that still need confirmation.
|
||||
2. **Given** the supplied materials do not establish enough hardware truth for safe generation,
|
||||
**When** the workflow evaluates the request, **Then** it explicitly flags the missing pin mappings,
|
||||
peripheral capabilities, or board metadata instead of guessing silently.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Generate Board Support Scaffolding (Priority: P3)
|
||||
|
||||
As a firmware maintainer, I want the Copilot workflow to use the validated intake and reusable context to
|
||||
draft board-support artifacts such as `variant.h`, optional `variant.cpp`, and PlatformIO environment content
|
||||
with repository-specific metadata so I can add new hardware support consistently and with less manual setup.
|
||||
|
||||
**Why this priority**: This delivers the actual acceleration benefit, but it depends on verified context and
|
||||
safe intake rules to avoid generating incorrect hardware support.
|
||||
|
||||
**Independent Test**: Can be tested independently by running the workflow for a new board request and
|
||||
confirming it produces scaffold content or structured instructions that align with repository patterns and
|
||||
does not invent unsupported hardware details.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** validated board inputs and sufficient source evidence, **When** the maintainer requests new
|
||||
hardware support scaffolding, **Then** the workflow drafts the required board-support files and metadata
|
||||
using existing repository conventions.
|
||||
2. **Given** the request would affect hardware flags, pin mappings, or build metadata beyond the available
|
||||
evidence, **When** scaffolding is attempted, **Then** the workflow limits output to supported fields and
|
||||
clearly marks unresolved items for human confirmation.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- The requested board environment name conflicts with an existing PlatformIO environment or target directory.
|
||||
- The same hardware model appears under multiple existing naming conventions and the correct repository form is ambiguous.
|
||||
- A schematic or PDF source omits some pins or names them differently than the repository’s existing macros.
|
||||
- A target uses architecture defaults today, so the context artifact must distinguish explicitly declared pins from inherited defaults.
|
||||
- A board has multiple display or radio options, optional peripherals, or revision-specific pinouts that cannot be collapsed into one truth.
|
||||
- The request includes peripherals that exist in source material but are not currently supported by repository patterns.
|
||||
- A generated board definition would require changing shared defaults or introducing unverified power, timing, or RF assumptions.
|
||||
|
||||
## Requirements _(mandatory)_
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: The system MUST produce a repository-local hardware context artifact that documents the current
|
||||
target-definition patterns used for board support in this firmware repository.
|
||||
- **FR-002**: The hardware context artifact MUST describe, at minimum, the board environment name,
|
||||
hardware model identifier, display-related identifiers, radio pin group, input/button-related pins,
|
||||
and other commonly reused pin or capability categories present at the device-target-definition level.
|
||||
- **FR-003**: The hardware context artifact MUST distinguish board-specific declarations from architecture-level
|
||||
defaults or inherited behavior when that distinction affects new board support work.
|
||||
- **FR-004**: Users MUST be able to initiate the workflow by providing a minimal set of board inputs that includes
|
||||
board environment name, hardware model, display name, and target architecture, with optional supporting source materials.
|
||||
Architecture is required because expected artifacts and matched patterns cannot be determined without it.
|
||||
- **FR-005**: The intake workflow MUST identify which board-support artifacts are expected for the request,
|
||||
including variant files and PlatformIO environment content where applicable.
|
||||
- **FR-006**: The intake workflow MUST surface missing or conflicting hardware evidence instead of inventing
|
||||
unresolved pins, capabilities, metadata, or power assumptions.
|
||||
- **FR-007**: The workflow MUST preserve variant-scoped hardware truth by keeping generated or suggested values
|
||||
scoped to the intended target architecture, board, and board revision when known.
|
||||
- **FR-008**: The workflow MUST support repository-specific metadata required for new PlatformIO environments,
|
||||
including custom support metadata already used in this codebase.
|
||||
- **FR-009**: The workflow MUST be able to draft scaffold content for `variant.h`, optional `variant.cpp`, and
|
||||
related target files only when the provided evidence is sufficient to do so safely.
|
||||
- **FR-010**: The workflow MUST record unresolved questions in a form the maintainer can act on before using any
|
||||
scaffolded board support in the repository.
|
||||
- **FR-011**: The workflow MUST be applicable to current Meshtastic hardware target definitions across supported
|
||||
architectures, while allowing architecture-specific details to remain architecture-scoped.
|
||||
- **FR-012**: The workflow MUST not change public protocol behavior, shared radio safety defaults, or unrelated
|
||||
board definitions as part of preparing new hardware support context.
|
||||
|
||||
Where relevant, requirements MUST also state:
|
||||
|
||||
- affected architectures, boards, or modules: ESP32, ESP32-S3, ESP32-C3, nRF52, RP2040/RP2350, STM32WL, and Portduino-style target-definition patterns where present in repo conventions
|
||||
- whether behavior changes public defaults, protocol compatibility, or generated artifacts: this feature must not alter public mesh defaults or protocol compatibility; it may create or update documentation artifacts for board-support workflow context
|
||||
- any required validation evidence for high-risk mesh, hardware, or power behavior: targeted verification of generated context against representative variant files and naming patterns is required before relying on it for scaffolding
|
||||
|
||||
### Key Entities _(include if feature involves data)_
|
||||
|
||||
- **Hardware Support Context**: A repository-backed reference artifact that summarizes the current board-support
|
||||
fields, common pin categories, variant capability macros, and target-definition conventions used across the firmware.
|
||||
- **Board Intake Request**: The maintainer-provided input set for a proposed new board, including environment name,
|
||||
hardware model, display name, target architecture, and optional source materials such as schematics.
|
||||
- **Target Definition Pattern**: A reusable repository convention describing how a board is represented through
|
||||
`variant.h`, optional companion files, PlatformIO environments, and board-specific metadata.
|
||||
- **Evidence Gap**: A missing, ambiguous, or conflicting hardware fact that blocks safe generation of new board support.
|
||||
- **Board Support Scaffold**: The draft output for new hardware support artifacts, limited to fields supported by
|
||||
verified evidence and current repository conventions.
|
||||
|
||||
## Success Criteria _(mandatory)_
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: Maintainers can locate the common target-definition inputs and pin categories for an existing board in one artifact within 5 minutes, without manually reading multiple variant directories.
|
||||
- **SC-002**: For a representative set of existing boards, the context artifact correctly captures the board environment name, key capability categories, and primary pin groups with no unresolved mismatches after maintainer review.
|
||||
- **SC-003**: A maintainer can submit a new board intake request using the declared minimum inputs and receive a complete list of required board-support artifacts and unresolved evidence gaps in a single workflow pass.
|
||||
- **SC-004**: _(Post-launch metric — not a pre-merge acceptance gate)_ For new board requests with sufficient source evidence, the workflow reduces manual setup time for initial board-support scaffolding by at least 50% compared with manually assembling variant and PlatformIO definitions from scratch. A baseline timed comparison should be conducted after the first production intake.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- The initial increment focuses on repository context and intake/scaffolding workflow support, not full autonomous end-to-end board enablement.
|
||||
- Maintainers using this workflow already have access to the repository, Copilot, and any board source materials they want to reference.
|
||||
- The first implementation may rely on repository-readable sources and manually supplied board details rather than automated PDF parsing.
|
||||
- Existing Meshtastic variant files and PlatformIO environments provide enough representative patterns to build a useful reusable context artifact.
|
||||
- The workflow will be allowed to stop and request clarification when hardware truth is incomplete instead of forcing a guessed output.
|
||||
@@ -1,170 +0,0 @@
|
||||
# Tasks: Hardware Support Agent
|
||||
|
||||
**Branch**: `129-hardware-support-agent`
|
||||
**Input**: Design documents from `specs/129-hardware-support-agent/`
|
||||
**Prerequisites**: plan.md ✅, spec.md ✅, research.md ✅, data-model.md ✅, contracts/board-intake-contract.md ✅
|
||||
|
||||
**Note on existing work**: The Phase 3 (US1) inventory script and generated context artifact already exist.
|
||||
Tasks T001–T004 treat them as in-scope for validation and hardening rather than creation from scratch.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Setup
|
||||
|
||||
**Purpose**: Confirm repository structure and tooling baseline for this feature.
|
||||
|
||||
- [x] T001 Confirm `bin/generate_hardware_support_context.py` executes without errors from the repo root via `python3 bin/generate_hardware_support_context.py`
|
||||
- [x] T002 [P] Confirm `docs/hardware-support-context.md` exists and is committed or tracked in the working tree
|
||||
- [x] T003 [P] Clean up the duplicated `"Where relevant, requirements MUST also state:"` block in `specs/129-hardware-support-agent/spec.md`
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Shared utilities and data structures that US2 and US3 both depend on.
|
||||
|
||||
**⚠️ CRITICAL**: US2 and US3 cannot begin until this phase is complete.
|
||||
|
||||
- [x] T004 Add a `BoardIntakeRequest` dataclass (or typed dict) capturing the fields from `specs/129-hardware-support-agent/data-model.md` in `bin/board_intake.py`
|
||||
- [x] T005 Add a `EvidenceGap` dataclass in `bin/board_intake.py` matching the data model
|
||||
- [x] T006 Add an `IntakeAssessment` dataclass in `bin/board_intake.py` matching the data model
|
||||
- [x] T007 Implement a `load_hardware_context(path)` helper in `bin/board_intake.py` that reads `docs/hardware-support-context.md` and returns architecture family names and metadata key list for use by the assessment logic
|
||||
|
||||
**Checkpoint**: Shared data structures and context loader in place — US2 and US3 implementation can begin.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 — Build Reusable Hardware Context (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: A single repository-backed markdown document accurately inventories current board-support patterns across all supported architectures so maintainers can start a new board definition from verified patterns.
|
||||
|
||||
**Independent Test**: Run `python3 bin/generate_hardware_support_context.py` and manually spot-check output against four representative variants from different architectures.
|
||||
|
||||
### Validation for User Story 1
|
||||
|
||||
- [x] T008 [P] [US1] Spot-check generated `docs/hardware-support-context.md` against `variants/esp32/tbeam` — confirm radio pins, metadata keys, and category counts match the variant.h and platformio.ini declarations
|
||||
- [x] T009 [P] [US1] Spot-check against `variants/esp32s3/tlora-pager` — confirm all 17 `custom_meshtastic_*` metadata keys and high Connectivity/Other category macro count are reflected
|
||||
- [x] T010 [P] [US1] Spot-check against `variants/nrf52840/t-echo` — confirm nRF52-specific macros (`USE_LFXO`, `VARIANT_MCK`, nRF52-style SPI pins) appear correctly
|
||||
- [x] T011 [P] [US1] Spot-check against `variants/rp2040/rak11310` — confirm RP2040 architecture entry is present and radio pins match
|
||||
- [x] T012 [US1] Run `trunk fmt bin/generate_hardware_support_context.py` and fix any formatting issues
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [x] T013 [P] [US1] Add `--validate` CLI flag to `bin/generate_hardware_support_context.py` that prints a summary of how many variants were scanned, how many had metadata keys, and how many had no `variant.h` (for inherited-defaults audit)
|
||||
- [x] T014 [US1] Add a `## Inherited Defaults Note` section to the generated `docs/hardware-support-context.md` that lists architecture families known to rely on BSP/base-environment defaults rather than locally declared macros (informed by validate output)
|
||||
|
||||
**Checkpoint**: `docs/hardware-support-context.md` is validated, formatted, and includes inherited-defaults guidance. US1 fully testable and deliverable independently.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 — Define New Board Intake (Priority: P2)
|
||||
|
||||
**Goal**: A maintainer can provide an environment name, hardware model, display name, and architecture and receive back a structured assessment listing expected artifacts, required metadata, closest matching patterns, and any evidence gaps.
|
||||
|
||||
**Independent Test**: Run the intake workflow against a sample hypothetical board (e.g., a new ESP32-S3 board with only minimum inputs) and confirm it returns a complete assessment with at least one evidence gap identified.
|
||||
|
||||
### Validation for User Story 2
|
||||
|
||||
- [x] T015 [P] [US2] Create `bin/fixtures/intake_minimal.json` with just the required fields for a hypothetical new ESP32-S3 board and confirm `bin/board_intake.py` parses it without error
|
||||
- [x] T016 [P] [US2] Create `bin/fixtures/intake_full.json` with all recommended fields and source materials and confirm the workflow marks `scaffold_ready: true`
|
||||
- [x] T016b [P] [US2] Create `bin/fixtures/intake_multi_env.json` representing a board with two display variants sharing one hardware model (e.g., a TFT and an e-ink variant) and confirm `assess_intake` flags `revision-scope` ambiguity as a blocking Evidence Gap rather than collapsing the options silently
|
||||
- [x] T017 [US2] Confirm `bin/board_intake.py --validate bin/fixtures/intake_minimal.json` correctly flags missing radio, display, and power evidence as blocking gaps
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [x] T018 [P] [US2] Implement `validate_intake(request: BoardIntakeRequest) -> list[str]` in `bin/board_intake.py` that checks required fields and detects environment-name conflicts against the existing architecture inventory in `docs/hardware-support-context.md`
|
||||
- [x] T019 [P] [US2] Implement `find_matched_patterns(request: BoardIntakeRequest, context) -> list[dict]` that returns the three closest existing board examples by architecture from the context document
|
||||
- [x] T020 [US2] Implement `build_evidence_gaps(request: BoardIntakeRequest) -> list[EvidenceGap]` that identifies missing pin group evidence (radio, display, GPS, power, input) based on declared source materials
|
||||
- [x] T021 [US2] Implement `assess_intake(request: BoardIntakeRequest, context) -> IntakeAssessment` combining T018–T020 to produce a full structured assessment
|
||||
- [x] T022 [US2] Implement `render_assessment_markdown(assessment: IntakeAssessment) -> str` that formats the assessment as a maintainer-readable markdown report
|
||||
- [x] T023 [US2] Add a CLI entry point `bin/board_intake.py <intake.json>` that prints the assessment markdown to stdout or an output file
|
||||
- [x] T024 [US2] Run `trunk fmt bin/board_intake.py` and fix any formatting issues
|
||||
|
||||
**Checkpoint**: `bin/board_intake.py` fully processes a new board request and prints an assessment. US2 independently testable.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 — Generate Board Support Scaffolding (Priority: P3)
|
||||
|
||||
**Goal**: When intake assessment marks `scaffold_ready: true`, the workflow drafts `variant.h`, optional `variant.cpp`, and PlatformIO environment content using repository conventions, with inline annotations for unresolved items.
|
||||
|
||||
**Independent Test**: Run the scaffold generator for a fully-specified test case and confirm the output files follow existing repository patterns and include `// TODO:` markers for any fields not backed by supplied evidence.
|
||||
|
||||
### Validation for User Story 3
|
||||
|
||||
- [x] T025 [P] [US3] Run scaffold generator for `bin/fixtures/intake_full.json` and confirm `variant.h` output contains required radio pin group, capability macros, and metadata section
|
||||
- [x] T026 [P] [US3] Confirm scaffold generator emits `// TODO: verify —` annotations for any recommended fields absent from the intake fixture
|
||||
- [x] T027 [US3] Confirm generated PlatformIO env block includes all `custom_meshtastic_*` metadata keys from the contract and uses `extends` to reference the correct base environment for the declared architecture
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [x] T028 [P] [US3] Create `bin/board_scaffold.py` with a `generate_variant_h(assessment: IntakeAssessment, context) -> str` function that drafts a `variant.h` file using architecture-appropriate macro order from the context document
|
||||
- [x] T029 [P] [US3] Implement `generate_platformio_env(assessment: IntakeAssessment) -> str` in `bin/board_scaffold.py` that emits the PlatformIO environment block with `custom_meshtastic_*` fields
|
||||
- [x] T030 [US3] Implement `annotate_unresolved(content: str, gaps: list[EvidenceGap]) -> str` that inserts `// TODO: verify — {gap.description}` comments next to lines that correspond to unresolved evidence gaps
|
||||
- [x] T031 [US3] Implement `scaffold_board(assessment: IntakeAssessment, context, output_dir: Path)` which orchestrates T028–T030 and writes files to the proposed variant directory path
|
||||
- [x] T032 [US3] Add CLI entry point `bin/board_scaffold.py <intake.json> [--output-dir <path>]` that reads an intake file, runs the full intake + scaffold pipeline, and writes output
|
||||
- [x] T033 [US3] Guard scaffold entry: if `assess_intake` returns `scaffold_ready: false`, print the assessment report and exit with a non-zero code rather than generating files
|
||||
- [x] T034 [US3] Run `trunk fmt bin/board_scaffold.py` and fix any formatting issues
|
||||
|
||||
**Checkpoint**: All three user stories independently functional. Hardware context, intake assessment, and scaffold generation each work as standalone deliverables.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Custom Copilot agent integration, documentation, and final review.
|
||||
|
||||
- [x] T035 [P] Create `.github/prompts/hardware-support.prompt.md` that routes the hardware support workflow to the custom agent
|
||||
- [x] T036 [P] Create `.github/agents/hardware-support.agent.md` with step-by-step instructions for the Copilot hardware-support workflow: context regeneration → intake → assessment → optional scaffold
|
||||
- [x] T037 [US1] Update `specs/129-hardware-support-agent/quickstart.md` Step 3 and Step 4 to reference the completed `bin/board_intake.py` CLI and describe the expected output shape
|
||||
- [x] T038 [P] Confirm that no firmware runtime files under `src/`, `variants/`, or `protobufs/` were modified as part of this feature (constitution compliance check)
|
||||
- [x] T039 Run `python3 bin/generate_hardware_support_context.py` one final time to confirm the regenerated artifact is up to date and passes spot-checks from T008–T011
|
||||
- [x] T040 Summarize any skipped validations, open `// TODO:` items in scaffold output, and any known limitations in `specs/129-hardware-support-agent/plan.md` under a `## Review Notes` section
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Phase 1 (Setup)**: No dependencies — start immediately
|
||||
- **Phase 2 (Foundational)**: Depends on Phase 1 — blocks US2 and US3
|
||||
- **Phase 3 (US1)**: Can start after Phase 1; does not depend on Phase 2
|
||||
- **Phase 4 (US2)**: Depends on Phase 2 (shared data structures)
|
||||
- **Phase 5 (US3)**: Depends on Phase 2 and Phase 4 (uses IntakeAssessment output)
|
||||
- **Phase 6 (Polish)**: Depends on all story phases
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **US1 (P1)**: Depends on Phase 1 only — fully independent MVP
|
||||
- **US2 (P2)**: Depends on Phase 2; integrates with US1 context document but not the script directly
|
||||
- **US3 (P3)**: Depends on Phase 2 and US2 `assess_intake` result
|
||||
|
||||
### Parallel Opportunities
|
||||
|
||||
**Within Phase 2**:
|
||||
|
||||
- T004, T005, T006 can be written in parallel (same file, separate dataclasses — serialize to avoid conflicts)
|
||||
|
||||
**Within Phase 3 (US1)**:
|
||||
|
||||
- T008, T009, T010, T011 (spot-checks) are fully independent and can run in parallel
|
||||
- T013 [P] is independent of validation tasks
|
||||
|
||||
**Within Phase 4 (US2)**:
|
||||
|
||||
- T015, T016 fixture creation can run in parallel
|
||||
- T018 and T019 are independent of each other and can be implemented in parallel
|
||||
|
||||
**Within Phase 5 (US3)**:
|
||||
|
||||
- T028 and T029 are independent scaffold generators — can be written in parallel
|
||||
- T025 and T026 validation tasks are independent
|
||||
|
||||
**Within Phase 6**:
|
||||
|
||||
- T035, T036, T038 are independent files — can all be done in parallel
|
||||
|
||||
### MVP Scope
|
||||
|
||||
To deliver US1 as a standalone MVP: complete Phases 1 and 3 (T001–T003, T008–T014). This verifies the hardware context artifact is accurate and useful without requiring any intake or scaffold tooling.
|
||||
+16
-19
@@ -26,6 +26,8 @@ SOFTWARE.*/
|
||||
|
||||
#include "DebugConfiguration.h"
|
||||
|
||||
#include <memory>
|
||||
|
||||
#ifdef ARCH_PORTDUINO
|
||||
#include "platform/portduino/PortduinoGlue.h"
|
||||
#endif
|
||||
@@ -119,27 +121,22 @@ bool Syslog::vlogf(uint16_t pri, const char *fmt, va_list args)
|
||||
|
||||
bool Syslog::vlogf(uint16_t pri, const char *appName, const char *fmt, va_list args)
|
||||
{
|
||||
char *message;
|
||||
size_t initialLen;
|
||||
size_t len;
|
||||
bool result;
|
||||
// First measure the formatted length using a copy of args; passing args directly
|
||||
// to vsnprintf consumes it, and reusing a consumed va_list is undefined behavior.
|
||||
va_list args_measure;
|
||||
va_copy(args_measure, args);
|
||||
int needed = vsnprintf(nullptr, 0, fmt, args_measure);
|
||||
va_end(args_measure);
|
||||
|
||||
initialLen = strlen(fmt);
|
||||
if (needed < 0)
|
||||
return false; // encoding error
|
||||
|
||||
message = new char[initialLen + 1];
|
||||
auto message = std::unique_ptr<char[]>(new char[static_cast<size_t>(needed) + 1]);
|
||||
int written = vsnprintf(message.get(), static_cast<size_t>(needed) + 1, fmt, args);
|
||||
if (written < 0)
|
||||
return false;
|
||||
|
||||
len = vsnprintf(message, initialLen + 1, fmt, args);
|
||||
if (len > initialLen) {
|
||||
delete[] message;
|
||||
message = new char[len + 1];
|
||||
|
||||
vsnprintf(message, len + 1, fmt, args);
|
||||
}
|
||||
|
||||
result = this->_sendLog(pri, appName, message);
|
||||
|
||||
delete[] message;
|
||||
return result;
|
||||
return this->_sendLog(pri, appName, message.get());
|
||||
}
|
||||
|
||||
inline bool Syslog::_sendLog(uint16_t pri, const char *appName, const char *message)
|
||||
@@ -154,7 +151,7 @@ inline bool Syslog::_sendLog(uint16_t pri, const char *appName, const char *mess
|
||||
if (!this->_enabled)
|
||||
return false;
|
||||
|
||||
if ((this->_server == NULL && this->_ip == INADDR_NONE) || this->_port == 0)
|
||||
if ((this->_server == NULL && this->_ip == IPAddress(0, 0, 0, 0)) || this->_port == 0)
|
||||
return false;
|
||||
|
||||
// Check priority against priMask values.
|
||||
|
||||
@@ -13,6 +13,11 @@ extern MemGet memGet;
|
||||
#define LED_STATE_ON 1
|
||||
#endif
|
||||
|
||||
// WIFI LED
|
||||
#ifndef WIFI_STATE_ON
|
||||
#define WIFI_STATE_ON 1
|
||||
#endif
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// DEBUG
|
||||
// -----------------------------------------------------------------------------
|
||||
@@ -147,7 +152,9 @@ extern "C" void logLegacy(const char *level, const char *fmt, ...);
|
||||
// Default Bluetooth PIN
|
||||
#define defaultBLEPin 123456
|
||||
|
||||
#if HAS_ETHERNET && !defined(USE_WS5500)
|
||||
#if HAS_ETHERNET && defined(USE_CH390D)
|
||||
#include <ESP32_CH390.h>
|
||||
#elif HAS_ETHERNET && !defined(USE_WS5500)
|
||||
#include <RAK13800_W5100S.h>
|
||||
#endif // HAS_ETHERNET
|
||||
|
||||
|
||||
@@ -14,6 +14,11 @@ Lock::Lock() : handle(xSemaphoreCreateBinary())
|
||||
}
|
||||
}
|
||||
|
||||
Lock::~Lock()
|
||||
{
|
||||
vSemaphoreDelete(handle);
|
||||
}
|
||||
|
||||
void Lock::lock()
|
||||
{
|
||||
if (xSemaphoreTake(handle, portMAX_DELAY) == false) {
|
||||
@@ -30,6 +35,8 @@ void Lock::unlock()
|
||||
#else
|
||||
Lock::Lock() {}
|
||||
|
||||
Lock::~Lock() {}
|
||||
|
||||
void Lock::lock() {}
|
||||
|
||||
void Lock::unlock() {}
|
||||
|
||||
@@ -12,6 +12,7 @@ class Lock
|
||||
{
|
||||
public:
|
||||
Lock();
|
||||
~Lock();
|
||||
|
||||
Lock(const Lock &) = delete;
|
||||
Lock &operator=(const Lock &) = delete;
|
||||
|
||||
+11
-3
@@ -1017,10 +1017,13 @@ void GPS::up()
|
||||
setPowerState(GPS_ACTIVE);
|
||||
}
|
||||
|
||||
// We've got a GPS lock. Enter a low power state, potentially.
|
||||
// We've finished a GPS search cycle (lock or timeout). Enter a low power state, potentially.
|
||||
void GPS::down()
|
||||
{
|
||||
scheduling.informGotLock();
|
||||
if (hasValidLocation)
|
||||
scheduling.informGotLock();
|
||||
else
|
||||
scheduling.informSearchFailed();
|
||||
uint32_t predictedSearchDuration = scheduling.predictedSearchDurationMs();
|
||||
uint32_t sleepTime = scheduling.msUntilNextSearch();
|
||||
uint32_t updateInterval = Default::getConfiguredOrDefaultMs(config.position.gps_update_interval);
|
||||
@@ -1545,7 +1548,12 @@ std::unique_ptr<GPS> GPS::createGps()
|
||||
_en_gpio = PIN_GPS_EN;
|
||||
#endif
|
||||
#ifdef ARCH_PORTDUINO
|
||||
if (!portduino_config.has_gps)
|
||||
if (portduino_config.has_gps) {
|
||||
// These need to set as flags so later checks will pass on native and GPS will work.
|
||||
// They are not used for any hardware access.
|
||||
_rx_gpio = 1;
|
||||
_tx_gpio = 1;
|
||||
} else
|
||||
return nullptr;
|
||||
#endif
|
||||
if (!_rx_gpio || !_serial_gps) // Configured to have no GPS at all
|
||||
|
||||
@@ -15,6 +15,19 @@ void GPSUpdateScheduling::informGotLock()
|
||||
searchEndedMs = millis();
|
||||
LOG_DEBUG("Took %us to get lock", (searchEndedMs - searchStartedMs) / 1000);
|
||||
updateLockTimePrediction();
|
||||
consecutiveFailures = 0; // Drop back to fast cadence as soon as we acquire any fix
|
||||
}
|
||||
|
||||
// Search finished without obtaining a fix. We still need to mark the end time so
|
||||
// the next sleep is timed correctly, but we must not feed the timeout duration
|
||||
// into predictedMsToGetLock — doing so poisons msUntilNextSearch() and causes
|
||||
// down() to fall into GPS_IDLE, leaving the chip awake on subsequent indoor cycles.
|
||||
void GPSUpdateScheduling::informSearchFailed()
|
||||
{
|
||||
searchEndedMs = millis();
|
||||
consecutiveFailures++;
|
||||
LOG_DEBUG("GPS search ended without fix after %us (consecutive failures: %u)", (searchEndedMs - searchStartedMs) / 1000,
|
||||
consecutiveFailures);
|
||||
}
|
||||
|
||||
// Clear old lock-time prediction data.
|
||||
@@ -25,6 +38,7 @@ void GPSUpdateScheduling::reset()
|
||||
searchEndedMs = 0;
|
||||
searchCount = 0;
|
||||
predictedMsToGetLock = 0;
|
||||
consecutiveFailures = 0;
|
||||
}
|
||||
|
||||
// How many milliseconds before we should next search for GPS position
|
||||
@@ -36,6 +50,20 @@ uint32_t GPSUpdateScheduling::msUntilNextSearch()
|
||||
// Target interval (seconds), between GPS updates
|
||||
uint32_t updateInterval = Default::getConfiguredOrDefaultMs(config.position.gps_update_interval, default_gps_update_interval);
|
||||
|
||||
// After a failed search, back off: indoors / no-sky environments will keep failing,
|
||||
// so wake at most once per broadcast interval rather than once per gps_update_interval.
|
||||
// Capped at 1 hour so a user-configured very-long broadcast interval still retries
|
||||
// periodically (in case conditions change). Reset on any successful lock.
|
||||
if (consecutiveFailures > 0) {
|
||||
constexpr uint32_t failureRetryCapMs = 60UL * 60UL * 1000UL; // 1 hour cap
|
||||
uint32_t failureSleepMs =
|
||||
Default::getConfiguredOrDefaultMs(config.position.position_broadcast_secs, default_broadcast_interval_secs);
|
||||
if (failureSleepMs > failureRetryCapMs)
|
||||
failureSleepMs = failureRetryCapMs;
|
||||
if (updateInterval < failureSleepMs)
|
||||
updateInterval = failureSleepMs;
|
||||
}
|
||||
|
||||
// Check how long until we should start searching, to hopefully hit our target interval
|
||||
uint32_t dueAtMs = searchEndedMs + updateInterval;
|
||||
uint32_t compensatedStart = dueAtMs - predictedMsToGetLock;
|
||||
@@ -71,14 +99,18 @@ bool GPSUpdateScheduling::isUpdateDue()
|
||||
bool GPSUpdateScheduling::searchedTooLong()
|
||||
{
|
||||
constexpr uint32_t oneMinuteMs = 60UL * 1000UL;
|
||||
constexpr uint32_t maxSearchClampMs = 15UL * oneMinuteMs; // Hard cap: 15 minutes is always too long
|
||||
constexpr uint32_t maxSearchClampMs = 15UL * oneMinuteMs; // Hard cap: 15 minutes is always too long
|
||||
constexpr uint32_t postFailureSearchMs = 5UL * oneMinuteMs; // Tighter dwell once we know the environment is hostile
|
||||
uint32_t elapsed = elapsedSearchMs();
|
||||
|
||||
// Anything over 15 minutes is too long, regardless of the broadcast interval.
|
||||
// TODO: Make a smarter algorithm that backs off the search dwell time when not getting a lock.
|
||||
if (elapsed > maxSearchClampMs)
|
||||
return true;
|
||||
|
||||
// After a prior failed search, shorten the dwell
|
||||
if (consecutiveFailures > 0 && elapsed > postFailureSearchMs)
|
||||
return true;
|
||||
|
||||
uint32_t minimumOrConfiguredSecs =
|
||||
Default::getConfiguredOrMinimumValue(config.position.position_broadcast_secs, default_broadcast_interval_secs);
|
||||
uint32_t maxSearchMs = Default::getConfiguredOrDefaultMs(minimumOrConfiguredSecs, default_broadcast_interval_secs);
|
||||
|
||||
@@ -8,7 +8,8 @@ class GPSUpdateScheduling
|
||||
public:
|
||||
// Marks the time of these events, for calculation use
|
||||
void informSearching();
|
||||
void informGotLock(); // Predicted lock-time is recalculated here
|
||||
void informGotLock(); // Predicted lock-time is recalculated here
|
||||
void informSearchFailed(); // Search ended without a fix; prediction is left untouched
|
||||
|
||||
void reset(); // Reset the prediction - after GPS::disable() / GPS::enable()
|
||||
bool isUpdateDue(); // Is it time to begin searching for a GPS position?
|
||||
@@ -24,6 +25,7 @@ class GPSUpdateScheduling
|
||||
uint32_t searchEndedMs = 0;
|
||||
uint32_t searchCount = 0;
|
||||
uint32_t predictedMsToGetLock = 0;
|
||||
uint32_t consecutiveFailures = 0; // Count of search cycles that ended without a fix; reset on lock
|
||||
|
||||
const float weighting = 0.2; // Controls exponential smoothing of lock-times prediction. 20% weighting of "latest lock-time".
|
||||
};
|
||||
@@ -333,6 +333,12 @@ void InputBroker::Init()
|
||||
BaseType_t higherWake = 0;
|
||||
concurrency::mainDelay.interruptFromISR(&higherWake);
|
||||
};
|
||||
#if defined(ELECROW_ThinkNode_M7)
|
||||
userConfigNoScreen.longLongPressTime = 15 * 1000;
|
||||
userConfigNoScreen.longLongPress = INPUT_BROKER_FACTORY_RST;
|
||||
#else
|
||||
userConfigNoScreen.longLongPress = INPUT_BROKER_SHUTDOWN;
|
||||
#endif
|
||||
userConfigNoScreen.singlePress = INPUT_BROKER_USER_PRESS;
|
||||
userConfigNoScreen.longPress = INPUT_BROKER_NONE;
|
||||
userConfigNoScreen.longPressTime = 500;
|
||||
|
||||
@@ -25,6 +25,7 @@ enum input_broker_event {
|
||||
INPUT_BROKER_USER_PRESS,
|
||||
INPUT_BROKER_ALT_PRESS,
|
||||
INPUT_BROKER_ALT_LONG,
|
||||
INPUT_BROKER_FACTORY_RST = 0x9a,
|
||||
INPUT_BROKER_SHUTDOWN = 0x9b,
|
||||
INPUT_BROKER_GPS_TOGGLE = 0x9e,
|
||||
INPUT_BROKER_SEND_PING = 0xaf,
|
||||
|
||||
@@ -13,7 +13,6 @@ RotaryEncoderImpl *rotaryEncoderImpl;
|
||||
|
||||
RotaryEncoderImpl::RotaryEncoderImpl()
|
||||
{
|
||||
rotary = nullptr;
|
||||
#ifdef ARCH_ESP32
|
||||
isFirstInit = true;
|
||||
#endif
|
||||
@@ -23,11 +22,6 @@ RotaryEncoderImpl::~RotaryEncoderImpl()
|
||||
{
|
||||
LOG_DEBUG("RotaryEncoderImpl destructor");
|
||||
detachRotaryEncoderInterrupts();
|
||||
|
||||
if (rotary != nullptr) {
|
||||
delete rotary;
|
||||
rotary = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
bool RotaryEncoderImpl::init()
|
||||
@@ -43,8 +37,9 @@ bool RotaryEncoderImpl::init()
|
||||
eventPressed = static_cast<input_broker_event>(moduleConfig.canned_message.inputbroker_event_press);
|
||||
|
||||
if (rotary == nullptr) {
|
||||
rotary = new RotaryEncoder(moduleConfig.canned_message.inputbroker_pin_a, moduleConfig.canned_message.inputbroker_pin_b,
|
||||
moduleConfig.canned_message.inputbroker_pin_press);
|
||||
rotary.reset(new RotaryEncoder(moduleConfig.canned_message.inputbroker_pin_a,
|
||||
moduleConfig.canned_message.inputbroker_pin_b,
|
||||
moduleConfig.canned_message.inputbroker_pin_press));
|
||||
}
|
||||
|
||||
attachRotaryEncoderInterrupts();
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#include "InputBroker.h"
|
||||
#include "concurrency/OSThread.h"
|
||||
#include "mesh/NodeDB.h"
|
||||
#include <memory>
|
||||
|
||||
class RotaryEncoder;
|
||||
|
||||
@@ -28,7 +29,7 @@ class RotaryEncoderImpl final : public InputPollable
|
||||
input_broker_event eventCcw = INPUT_BROKER_NONE;
|
||||
input_broker_event eventPressed = INPUT_BROKER_NONE;
|
||||
|
||||
RotaryEncoder *rotary;
|
||||
std::unique_ptr<RotaryEncoder> rotary;
|
||||
|
||||
private:
|
||||
#ifdef ARCH_ESP32
|
||||
|
||||
+11
-4
@@ -59,12 +59,12 @@ NimbleBluetooth *nimbleBluetooth = nullptr;
|
||||
NRF52Bluetooth *nrf52Bluetooth = nullptr;
|
||||
#endif
|
||||
|
||||
#if HAS_WIFI || defined(USE_WS5500)
|
||||
#if HAS_WIFI || defined(USE_WS5500) || defined(USE_CH390D)
|
||||
#include "mesh/api/WiFiServerAPI.h"
|
||||
#include "mesh/wifi/WiFiAPClient.h"
|
||||
#endif
|
||||
|
||||
#if HAS_ETHERNET && !defined(USE_WS5500)
|
||||
#if HAS_ETHERNET && !defined(USE_WS5500) && !defined(USE_CH390D)
|
||||
#include "mesh/api/ethServerAPI.h"
|
||||
#include "mesh/eth/ethClient.h"
|
||||
#endif
|
||||
@@ -335,7 +335,7 @@ void setup()
|
||||
|
||||
#ifdef WIFI_LED
|
||||
pinMode(WIFI_LED, OUTPUT);
|
||||
digitalWrite(WIFI_LED, LOW);
|
||||
digitalWrite(WIFI_LED, HIGH ^ WIFI_STATE_ON);
|
||||
#endif
|
||||
|
||||
#ifdef BLE_LED
|
||||
@@ -731,8 +731,15 @@ void setup()
|
||||
#elif defined(USE_SH1107_128_64)
|
||||
screen_model = meshtastic_Config_DisplayConfig_OledType_OLED_SH1107; // keep dimension of 128x64
|
||||
#else
|
||||
if (config.display.oled != meshtastic_Config_DisplayConfig_OledType_OLED_AUTO)
|
||||
if (config.display.oled != meshtastic_Config_DisplayConfig_OledType_OLED_AUTO) {
|
||||
screen_model = config.display.oled;
|
||||
|
||||
// Fix: update geometry for SH1107 128x128 selected via menu
|
||||
if (screen_model == meshtastic_Config_DisplayConfig_OledType_OLED_SH1107_128_128) {
|
||||
screen_geometry = GEOMETRY_128_128;
|
||||
screen_model = meshtastic_Config_DisplayConfig_OledType_OLED_SH1107; // normalize
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ template class LR11x0Interface<LR1121>;
|
||||
template class SX126xInterface<STM32WLx>;
|
||||
#endif
|
||||
|
||||
#if HAS_ETHERNET && !defined(USE_WS5500)
|
||||
#if HAS_ETHERNET && !defined(USE_WS5500) && !defined(USE_CH390D)
|
||||
#include "api/ethServerAPI.h"
|
||||
template class ServerAPI<EthernetClient>;
|
||||
template class APIServerPort<ethServerAPI, EthernetServer>;
|
||||
|
||||
@@ -80,6 +80,14 @@ static unsigned char userprefs_admin_key_1[] = USERPREFS_USE_ADMIN_KEY_1;
|
||||
static unsigned char userprefs_admin_key_2[] = USERPREFS_USE_ADMIN_KEY_2;
|
||||
#endif
|
||||
|
||||
// Weak empty variant initialization function.
|
||||
// May be redefined by variant files.
|
||||
void variantDefaultConfig() __attribute__((weak));
|
||||
void variantDefaultConfig() {}
|
||||
|
||||
void variantDefaultModuleConfig() __attribute__((weak));
|
||||
void variantDefaultModuleConfig() {}
|
||||
|
||||
#ifdef HELTEC_MESH_NODE_T114
|
||||
|
||||
uint32_t read8(uint8_t bits, uint8_t dummy, uint8_t cs, uint8_t sck, uint8_t mosi, uint8_t dc, uint8_t rst)
|
||||
@@ -785,6 +793,8 @@ void NodeDB::installDefaultConfig(bool preserveKey = false)
|
||||
#endif
|
||||
|
||||
initConfigIntervals();
|
||||
variantDefaultConfig();
|
||||
variantDefaultModuleConfig();
|
||||
}
|
||||
|
||||
void NodeDB::initConfigIntervals()
|
||||
|
||||
+23
-32
@@ -16,7 +16,7 @@
|
||||
#define VERBOSE_PACKET_HISTORY 0 // Set to 1 for verbose logging, 2 for heavy debugging
|
||||
#define PACKET_HISTORY_TRACE_AGING 1 // Set to 1 to enable logging of the age of re/used history slots
|
||||
|
||||
PacketHistory::PacketHistory(uint32_t size) : recentPacketsCapacity(0), recentPackets(NULL) // Initialize members
|
||||
PacketHistory::PacketHistory(uint32_t size) : recentPacketsCapacity(0) // Initialize members
|
||||
{
|
||||
if (size < 4 || size > PACKETHISTORY_MAX) { // Copilot suggested - makes sense
|
||||
LOG_WARN("Packet History - Invalid size %d, using default %d", size, PACKETHISTORY_MAX);
|
||||
@@ -25,7 +25,7 @@ PacketHistory::PacketHistory(uint32_t size) : recentPacketsCapacity(0), recentPa
|
||||
|
||||
// Allocate memory for the recent packets array
|
||||
recentPacketsCapacity = size;
|
||||
recentPackets = new PacketRecord[recentPacketsCapacity];
|
||||
recentPackets.reset(new PacketRecord[recentPacketsCapacity]);
|
||||
if (!recentPackets) { // No logging here, console/log probably uninitialized yet.
|
||||
LOG_ERROR("Packet History - Memory allocation failed for size=%d entries / %d Bytes", size,
|
||||
sizeof(PacketRecord) * recentPacketsCapacity);
|
||||
@@ -34,14 +34,7 @@ PacketHistory::PacketHistory(uint32_t size) : recentPacketsCapacity(0), recentPa
|
||||
}
|
||||
|
||||
// Initialize the recent packets array to zero
|
||||
memset(recentPackets, 0, sizeof(PacketRecord) * recentPacketsCapacity);
|
||||
}
|
||||
|
||||
PacketHistory::~PacketHistory()
|
||||
{
|
||||
recentPacketsCapacity = 0;
|
||||
delete[] recentPackets;
|
||||
recentPackets = NULL;
|
||||
memset(recentPackets.get(), 0, sizeof(PacketRecord) * recentPacketsCapacity);
|
||||
}
|
||||
|
||||
/** Update recentPackets and return true if we have already seen this packet */
|
||||
@@ -205,13 +198,14 @@ PacketHistory::PacketRecord *PacketHistory::find(NodeNum sender, PacketId id)
|
||||
return NULL;
|
||||
}
|
||||
|
||||
PacketRecord *base = recentPackets.get();
|
||||
PacketRecord *it = NULL;
|
||||
for (it = recentPackets; it < (recentPackets + recentPacketsCapacity); ++it) {
|
||||
for (it = base; it < (base + recentPacketsCapacity); ++it) {
|
||||
if (it->id == id && it->sender == sender) {
|
||||
#if VERBOSE_PACKET_HISTORY
|
||||
LOG_DEBUG("Packet History - find: s=%08x id=%08x FOUND nh=%02x rby=%02x %02x %02x age=%d slot=%d/%d", it->sender,
|
||||
it->id, it->next_hop, it->relayed_by[0], it->relayed_by[1], it->relayed_by[2], millis() - (it->rxTimeMsec),
|
||||
it - recentPackets, recentPacketsCapacity);
|
||||
it - base, recentPacketsCapacity);
|
||||
#endif
|
||||
// only the first match is returned, so be careful not to create duplicate entries
|
||||
return it; // Return pointer to the found record
|
||||
@@ -229,39 +223,38 @@ void PacketHistory::insert(const PacketRecord &r)
|
||||
{
|
||||
uint32_t now_millis = millis(); // Should not jump with time changes
|
||||
uint32_t OldtrxTimeMsec = 0;
|
||||
PacketRecord *base = recentPackets.get();
|
||||
PacketRecord *tu = NULL; // Will insert here.
|
||||
PacketRecord *it = NULL;
|
||||
|
||||
// Find a free, matching or oldest used slot in the recentPackets array
|
||||
for (it = recentPackets; it < (recentPackets + recentPacketsCapacity); ++it) {
|
||||
for (it = base; it < (base + recentPacketsCapacity); ++it) {
|
||||
if (it->id == 0 && it->sender == 0 /*&& rxTimeMsec == 0*/) { // Record is empty
|
||||
tu = it; // Remember the free slot
|
||||
#if VERBOSE_PACKET_HISTORY >= 2
|
||||
LOG_DEBUG("Packet History - insert: Free slot@ %d/%d", tu - recentPackets, recentPacketsCapacity);
|
||||
LOG_DEBUG("Packet History - insert: Free slot@ %d/%d", tu - base, recentPacketsCapacity);
|
||||
#endif
|
||||
// We have that, Exit the loop
|
||||
it = (recentPackets + recentPacketsCapacity);
|
||||
it = (base + recentPacketsCapacity);
|
||||
} else if (it->id == r.id && it->sender == r.sender) { // Record matches the packet we want to insert
|
||||
tu = it; // Remember the matching slot
|
||||
OldtrxTimeMsec = now_millis - it->rxTimeMsec; // ..and save current entry's age
|
||||
#if VERBOSE_PACKET_HISTORY >= 2
|
||||
LOG_DEBUG("Packet History - insert: Matched slot@ %d/%d age=%d", tu - recentPackets, recentPacketsCapacity,
|
||||
OldtrxTimeMsec);
|
||||
LOG_DEBUG("Packet History - insert: Matched slot@ %d/%d age=%d", tu - base, recentPacketsCapacity, OldtrxTimeMsec);
|
||||
#endif
|
||||
// We have that, Exit the loop
|
||||
it = (recentPackets + recentPacketsCapacity);
|
||||
it = (base + recentPacketsCapacity);
|
||||
} else {
|
||||
if (it->rxTimeMsec == 0) {
|
||||
LOG_WARN(
|
||||
"Packet History - insert: Found packet s=%08x id=%08x with rxTimeMsec = 0, slot %d/%d. Should never happen!",
|
||||
it->sender, it->id, it - recentPackets, recentPacketsCapacity);
|
||||
it->sender, it->id, it - base, recentPacketsCapacity);
|
||||
}
|
||||
if ((now_millis - it->rxTimeMsec) > OldtrxTimeMsec) { // 49.7 days rollover friendly
|
||||
OldtrxTimeMsec = now_millis - it->rxTimeMsec;
|
||||
tu = it; // remember the oldest packet
|
||||
#if VERBOSE_PACKET_HISTORY >= 2
|
||||
LOG_DEBUG("Packet History - insert: Older slot@ %d/%d age=%d", tu - recentPackets, recentPacketsCapacity,
|
||||
OldtrxTimeMsec);
|
||||
LOG_DEBUG("Packet History - insert: Older slot@ %d/%d age=%d", tu - base, recentPacketsCapacity, OldtrxTimeMsec);
|
||||
#endif
|
||||
}
|
||||
// keep looking for oldest till entire array is checked
|
||||
@@ -276,13 +269,11 @@ void PacketHistory::insert(const PacketRecord &r)
|
||||
|
||||
#if VERBOSE_PACKET_HISTORY
|
||||
if (tu->id == 0 && tu->sender == 0) {
|
||||
LOG_DEBUG("Packet History - insert: slot@ %d/%d is NEW", tu - recentPackets, recentPacketsCapacity);
|
||||
LOG_DEBUG("Packet History - insert: slot@ %d/%d is NEW", tu - base, recentPacketsCapacity);
|
||||
} else if (tu->id == r.id && tu->sender == r.sender) {
|
||||
LOG_DEBUG("Packet History - insert: slot@ %d/%d MATCHED, age=%d", tu - recentPackets, recentPacketsCapacity,
|
||||
OldtrxTimeMsec);
|
||||
LOG_DEBUG("Packet History - insert: slot@ %d/%d MATCHED, age=%d", tu - base, recentPacketsCapacity, OldtrxTimeMsec);
|
||||
} else {
|
||||
LOG_DEBUG("Packet History - insert: slot@ %d/%d REUSE OLDEST, age=%d", tu - recentPackets, recentPacketsCapacity,
|
||||
OldtrxTimeMsec);
|
||||
LOG_DEBUG("Packet History - insert: slot@ %d/%d REUSE OLDEST, age=%d", tu - base, recentPacketsCapacity, OldtrxTimeMsec);
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -315,9 +306,9 @@ void PacketHistory::insert(const PacketRecord &r)
|
||||
#endif
|
||||
|
||||
#if VERBOSE_PACKET_HISTORY
|
||||
LOG_DEBUG("Packet History - insert: Store slot@ %d/%d s=%08x id=%08x nh=%02x rby=%02x %02x %02x rxT=%d BEFORE",
|
||||
tu - recentPackets, recentPacketsCapacity, tu->sender, tu->id, tu->next_hop, tu->relayed_by[0], tu->relayed_by[1],
|
||||
tu->relayed_by[2], tu->rxTimeMsec);
|
||||
LOG_DEBUG("Packet History - insert: Store slot@ %d/%d s=%08x id=%08x nh=%02x rby=%02x %02x %02x rxT=%d BEFORE", tu - base,
|
||||
recentPacketsCapacity, tu->sender, tu->id, tu->next_hop, tu->relayed_by[0], tu->relayed_by[1], tu->relayed_by[2],
|
||||
tu->rxTimeMsec);
|
||||
#endif
|
||||
|
||||
if (r.rxTimeMsec == 0) {
|
||||
@@ -330,9 +321,9 @@ void PacketHistory::insert(const PacketRecord &r)
|
||||
*tu = r; // store the packet
|
||||
|
||||
#if VERBOSE_PACKET_HISTORY
|
||||
LOG_DEBUG("Packet History - insert: Store slot@ %d/%d s=%08x id=%08x nh=%02x rby=%02x %02x %02x rxT=%d AFTER",
|
||||
tu - recentPackets, recentPacketsCapacity, tu->sender, tu->id, tu->next_hop, tu->relayed_by[0], tu->relayed_by[1],
|
||||
tu->relayed_by[2], tu->rxTimeMsec);
|
||||
LOG_DEBUG("Packet History - insert: Store slot@ %d/%d s=%08x id=%08x nh=%02x rby=%02x %02x %02x rxT=%d AFTER", tu - base,
|
||||
recentPacketsCapacity, tu->sender, tu->id, tu->next_hop, tu->relayed_by[0], tu->relayed_by[1], tu->relayed_by[2],
|
||||
tu->rxTimeMsec);
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include "NodeDB.h"
|
||||
#include <memory>
|
||||
|
||||
// Number of relayers we keep track of. Use 6 to be efficient with memory alignment of PacketRecord to 20 bytes
|
||||
#define NUM_RELAYERS 6
|
||||
@@ -26,7 +27,7 @@ class PacketHistory
|
||||
|
||||
uint32_t recentPacketsCapacity =
|
||||
0; // Can be set in constructor, no need to recompile. Used to allocate memory for mx_recentPackets.
|
||||
PacketRecord *recentPackets = NULL; // Simple and fixed in size. Debloat.
|
||||
std::unique_ptr<PacketRecord[]> recentPackets; // Simple and fixed in size. Debloat.
|
||||
|
||||
/** Find a packet record in history.
|
||||
* @param sender NodeNum
|
||||
@@ -48,11 +49,8 @@ class PacketHistory
|
||||
uint8_t getOurTxHopLimit(const PacketRecord &r);
|
||||
void setOurTxHopLimit(PacketRecord &r, uint8_t hopLimit);
|
||||
|
||||
PacketHistory(const PacketHistory &); // non construction-copyable
|
||||
PacketHistory &operator=(const PacketHistory &); // non copyable
|
||||
public:
|
||||
explicit PacketHistory(uint32_t size = -1); // Constructor with size parameter, default is PACKETHISTORY_MAX
|
||||
~PacketHistory();
|
||||
|
||||
/**
|
||||
* Update recentBroadcasts and return true if we have already seen this packet
|
||||
@@ -74,5 +72,5 @@ class PacketHistory
|
||||
void removeRelayer(const uint8_t relayer, const uint32_t id, const NodeNum sender);
|
||||
|
||||
// To check if the PacketHistory was initialized correctly by constructor
|
||||
bool initOk(void) { return recentPackets != NULL && recentPacketsCapacity != 0; }
|
||||
bool initOk(void) { return recentPackets != nullptr && recentPacketsCapacity != 0; }
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#include "configuration.h"
|
||||
#include <Arduino.h>
|
||||
|
||||
#if HAS_ETHERNET && !defined(USE_WS5500)
|
||||
#if HAS_ETHERNET && !defined(USE_WS5500) && !defined(USE_CH390D)
|
||||
|
||||
#include "ethServerAPI.h"
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include "ServerAPI.h"
|
||||
#ifndef USE_WS5500
|
||||
#if !defined(USE_WS5500) && !defined(USE_CH390D)
|
||||
#include <RAK13800_W5100S.h>
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
#include <RAK13800_W5100S.h>
|
||||
#include <SPI.h>
|
||||
|
||||
#if HAS_NETWORKING
|
||||
#if HAS_NETWORKING && !defined(USE_WS5500) && !defined(USE_CH390D)
|
||||
|
||||
#ifndef DISABLE_NTP
|
||||
#include <NTPClient.h>
|
||||
|
||||
@@ -317,6 +317,10 @@ typedef enum _meshtastic_HardwareModel {
|
||||
meshtastic_HardwareModel_THINKNODE_M9 = 131,
|
||||
/* The Heltec-V4-R8 uses an ESP32S3R8 chip, plus an SX1262. */
|
||||
meshtastic_HardwareModel_HELTEC_V4_R8 = 132,
|
||||
/* The HELTEC_MESH_NODE_T1 uses an NRF52840 chip, plus an SX1262. */
|
||||
meshtastic_HardwareModel_HELTEC_MESH_NODE_T1 = 133,
|
||||
/* B&Q Consulting Station G3: TBD */
|
||||
meshtastic_HardwareModel_STATION_G3 = 134,
|
||||
/* ------------------------------------------------------------------------------------------------------------------------------------------
|
||||
Reserved ID For developing private Ports. These will show up in live traffic sparsely, so we can use a high number. Keep it within 8 bits.
|
||||
------------------------------------------------------------------------------------------------------------------------------------------ */
|
||||
|
||||
@@ -115,7 +115,11 @@ typedef enum _meshtastic_TelemetrySensorType {
|
||||
/* SHT family of sensors for temperature and humidity */
|
||||
meshtastic_TelemetrySensorType_SHTXX = 50,
|
||||
/* DS248X Bridge for one-wire temperature sensors */
|
||||
meshtastic_TelemetrySensorType_DS248X = 51
|
||||
meshtastic_TelemetrySensorType_DS248X = 51,
|
||||
/* MMC5983MA 3-Axis Digital Magnetic Sensor */
|
||||
meshtastic_TelemetrySensorType_MMC5983MA = 52,
|
||||
/* ICM-42607-P 6‑Axis IMU */
|
||||
meshtastic_TelemetrySensorType_ICM42607P = 53
|
||||
} meshtastic_TelemetrySensorType;
|
||||
|
||||
/* Struct definitions */
|
||||
@@ -496,8 +500,8 @@ extern "C" {
|
||||
|
||||
/* Helper constants for enums */
|
||||
#define _meshtastic_TelemetrySensorType_MIN meshtastic_TelemetrySensorType_SENSOR_UNSET
|
||||
#define _meshtastic_TelemetrySensorType_MAX meshtastic_TelemetrySensorType_DS248X
|
||||
#define _meshtastic_TelemetrySensorType_ARRAYSIZE ((meshtastic_TelemetrySensorType)(meshtastic_TelemetrySensorType_DS248X+1))
|
||||
#define _meshtastic_TelemetrySensorType_MAX meshtastic_TelemetrySensorType_ICM42607P
|
||||
#define _meshtastic_TelemetrySensorType_ARRAYSIZE ((meshtastic_TelemetrySensorType)(meshtastic_TelemetrySensorType_ICM42607P+1))
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -15,6 +15,12 @@
|
||||
#define ETH ETH2
|
||||
#endif // HAS_ETHERNET
|
||||
|
||||
#if HAS_ETHERNET && defined(USE_CH390D)
|
||||
#include "ESP32_CH390.h"
|
||||
#include "hal/spi_types.h"
|
||||
#define ETH CH390
|
||||
#endif // HAS_ETHERNET
|
||||
|
||||
#include <WiFiUdp.h>
|
||||
#ifdef ARCH_ESP32
|
||||
#if !MESHTASTIC_EXCLUDE_WEBSERVER
|
||||
@@ -56,12 +62,43 @@ unsigned long lastrun_ntp = 0;
|
||||
|
||||
bool needReconnect = true; // If we create our reconnector, run it once at the beginning
|
||||
bool isReconnecting = false; // If we are currently reconnecting
|
||||
#if defined(USE_WS5500) || defined(USE_CH390D)
|
||||
static volatile bool ethNetworkConnectedPending = false;
|
||||
#endif
|
||||
|
||||
WiFiUDP syslogClient;
|
||||
meshtastic::Syslog syslog(syslogClient);
|
||||
|
||||
Periodic *wifiReconnect;
|
||||
|
||||
#if defined(USE_WS5500) || defined(USE_CH390D)
|
||||
static void onNetworkConnected();
|
||||
static uint32_t lastEthIP = 0;
|
||||
static int32_t ethNetworkConnectedPoll()
|
||||
{
|
||||
if (ethNetworkConnectedPending) {
|
||||
ethNetworkConnectedPending = false;
|
||||
uint32_t ip = (uint32_t)ETH.localIP();
|
||||
bool ipChanged = APStartupComplete && ip != 0 && ip != lastEthIP;
|
||||
onNetworkConnected();
|
||||
if (ipChanged) {
|
||||
LOG_INFO("Ethernet IP changed (%u.%u.%u.%u), restarting mDNS", ip & 0xff, (ip >> 8) & 0xff, (ip >> 16) & 0xff,
|
||||
(ip >> 24) & 0xff);
|
||||
MDNS.end();
|
||||
if (MDNS.begin("Meshtastic")) {
|
||||
MDNS.addService("meshtastic", "tcp", SERVER_API_DEFAULT_PORT);
|
||||
MDNS.addServiceTxt("meshtastic", "tcp", "shortname", String(owner.short_name));
|
||||
MDNS.addServiceTxt("meshtastic", "tcp", "id", String(nodeDB->getNodeId().c_str()));
|
||||
MDNS.addServiceTxt("meshtastic", "tcp", "pio_env", optstr(APP_ENV));
|
||||
}
|
||||
}
|
||||
if (ip != 0)
|
||||
lastEthIP = ip;
|
||||
}
|
||||
return 500;
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef USE_WS5500
|
||||
// Startup Ethernet
|
||||
bool initEthernet()
|
||||
@@ -72,6 +109,38 @@ bool initEthernet()
|
||||
#if !MESHTASTIC_EXCLUDE_WEBSERVER
|
||||
createSSLCert(); // For WebServer
|
||||
#endif
|
||||
new concurrency::Periodic("EthConnect", ethNetworkConnectedPoll);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef USE_CH390D
|
||||
// Startup Ethernet
|
||||
bool initEthernet()
|
||||
{
|
||||
// Configure CH390
|
||||
ch390_config_t ch390_conf = CH390_DEFAULT_CONFIG();
|
||||
ch390_conf.spi_host = SPI3_HOST;
|
||||
ch390_conf.spi_cs_gpio = ETH_CS_PIN;
|
||||
ch390_conf.spi_sck_gpio = ETH_SCLK_PIN;
|
||||
ch390_conf.spi_mosi_gpio = ETH_MOSI_PIN;
|
||||
ch390_conf.spi_miso_gpio = ETH_MISO_PIN;
|
||||
ch390_conf.int_gpio = ETH_INT_PIN;
|
||||
#ifdef ETH_RST_PIN
|
||||
ch390_conf.reset_gpio = ETH_RST_PIN;
|
||||
#else
|
||||
ch390_conf.reset_gpio = -1;
|
||||
#endif
|
||||
ch390_conf.spi_clock_mhz = 20;
|
||||
if ((config.network.eth_enabled) && (ETH.begin(ch390_conf))) {
|
||||
WiFi.onEvent(WiFiEvent);
|
||||
#if !MESHTASTIC_EXCLUDE_WEBSERVER
|
||||
createSSLCert(); // For WebServer
|
||||
#endif
|
||||
new concurrency::Periodic("EthConnect", ethNetworkConnectedPoll);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -234,7 +303,7 @@ bool isWifiAvailable()
|
||||
|
||||
if (config.network.wifi_enabled && (config.network.wifi_ssid[0])) {
|
||||
return true;
|
||||
#ifdef USE_WS5500
|
||||
#if defined(USE_WS5500) || defined(USE_CH390D)
|
||||
} else if (config.network.eth_enabled) {
|
||||
return true;
|
||||
#endif
|
||||
@@ -384,13 +453,13 @@ static void WiFiEvent(WiFiEvent_t event)
|
||||
#endif
|
||||
}
|
||||
#ifdef WIFI_LED
|
||||
digitalWrite(WIFI_LED, HIGH);
|
||||
digitalWrite(WIFI_LED, LOW ^ WIFI_STATE_ON);
|
||||
#endif
|
||||
break;
|
||||
case ARDUINO_EVENT_WIFI_STA_DISCONNECTED:
|
||||
LOG_INFO("Disconnected from WiFi access point");
|
||||
#ifdef WIFI_LED
|
||||
digitalWrite(WIFI_LED, LOW);
|
||||
digitalWrite(WIFI_LED, HIGH ^ WIFI_STATE_ON);
|
||||
#endif
|
||||
#if HAS_UDP_MULTICAST
|
||||
if (udpHandler) {
|
||||
@@ -452,13 +521,13 @@ static void WiFiEvent(WiFiEvent_t event)
|
||||
case ARDUINO_EVENT_WIFI_AP_START:
|
||||
LOG_INFO("WiFi access point started");
|
||||
#ifdef WIFI_LED
|
||||
digitalWrite(WIFI_LED, HIGH);
|
||||
digitalWrite(WIFI_LED, LOW ^ WIFI_STATE_ON);
|
||||
#endif
|
||||
break;
|
||||
case ARDUINO_EVENT_WIFI_AP_STOP:
|
||||
LOG_INFO("WiFi access point stopped");
|
||||
#ifdef WIFI_LED
|
||||
digitalWrite(WIFI_LED, LOW);
|
||||
digitalWrite(WIFI_LED, HIGH ^ WIFI_STATE_ON);
|
||||
#endif
|
||||
break;
|
||||
case ARDUINO_EVENT_WIFI_AP_STACONNECTED:
|
||||
@@ -494,18 +563,18 @@ static void WiFiEvent(WiFiEvent_t event)
|
||||
LOG_INFO("Ethernet disconnected");
|
||||
break;
|
||||
case ARDUINO_EVENT_ETH_GOT_IP:
|
||||
#ifdef USE_WS5500
|
||||
#if defined(USE_WS5500) || defined(USE_CH390D)
|
||||
LOG_INFO("Obtained IP address: %s, %u Mbps, %s", ETH.localIP().toString().c_str(), ETH.linkSpeed(),
|
||||
ETH.fullDuplex() ? "FULL_DUPLEX" : "HALF_DUPLEX");
|
||||
onNetworkConnected();
|
||||
ethNetworkConnectedPending = true;
|
||||
#endif
|
||||
break;
|
||||
case ARDUINO_EVENT_ETH_GOT_IP6:
|
||||
#ifdef USE_WS5500
|
||||
#if defined(USE_WS5500) || defined(USE_CH390D)
|
||||
#if ESP_ARDUINO_VERSION >= ESP_ARDUINO_VERSION_VAL(3, 0, 0)
|
||||
LOG_INFO("Obtained Local IP6 address: %s", ETH.linkLocalIPv6().toString().c_str());
|
||||
LOG_INFO("Obtained GlobalIP6 address: %s", ETH.globalIPv6().toString().c_str());
|
||||
#else
|
||||
#elif defined(USE_WS5500)
|
||||
LOG_INFO("Obtained IP6 address: %s", ETH.localIPv6().toString().c_str());
|
||||
#endif
|
||||
#endif
|
||||
|
||||
@@ -26,7 +26,7 @@ bool isWifiAvailable();
|
||||
|
||||
uint8_t getWifiDisconnectReason();
|
||||
|
||||
#ifdef USE_WS5500
|
||||
#if defined(USE_WS5500) || defined(USE_CH390D)
|
||||
// Startup Ethernet
|
||||
bool initEthernet();
|
||||
#endif
|
||||
@@ -1281,7 +1281,7 @@ void AdminModule::handleGetDeviceConnectionStatus(const meshtastic_MeshPacket &r
|
||||
}
|
||||
#endif
|
||||
|
||||
#if HAS_ETHERNET && !defined(USE_WS5500)
|
||||
#if HAS_ETHERNET && !defined(USE_WS5500) && !defined(USE_CH390D)
|
||||
conn.has_ethernet = true;
|
||||
conn.ethernet.has_status = true;
|
||||
if (Ethernet.linkStatus() == LinkON) {
|
||||
|
||||
@@ -95,8 +95,23 @@ int32_t StatusLEDModule::runOnce()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (power_state != charging && power_state != charged && !doing_fast_blink) {
|
||||
// If we want a LED to be dedicated to the simple hearbeat, we can use that instead of the charge LED
|
||||
#if defined(LED_HEARTBEAT)
|
||||
if (power_state != charging && power_state != charged && !doing_fast_blink && !config.device.led_heartbeat_disabled) {
|
||||
if (HEARTBEAT_LED_state == LED_STATE_ON) {
|
||||
HEARTBEAT_LED_state = LED_STATE_OFF;
|
||||
my_interval = 999;
|
||||
} else {
|
||||
HEARTBEAT_LED_state = LED_STATE_ON;
|
||||
my_interval = 1;
|
||||
}
|
||||
digitalWrite(LED_HEARTBEAT, HEARTBEAT_LED_state);
|
||||
} else {
|
||||
HEARTBEAT_LED_state = LED_STATE_OFF;
|
||||
digitalWrite(LED_HEARTBEAT, HEARTBEAT_LED_state);
|
||||
}
|
||||
#else
|
||||
if (power_state != charging && power_state != charged && !doing_fast_blink && !config.device.led_heartbeat_disabled) {
|
||||
if (CHARGE_LED_state == LED_STATE_ON) {
|
||||
CHARGE_LED_state = LED_STATE_OFF;
|
||||
my_interval = 999;
|
||||
@@ -105,7 +120,7 @@ int32_t StatusLEDModule::runOnce()
|
||||
my_interval = 1;
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
if (!config.bluetooth.enabled || PAIRING_LED_starttime + 30 * 1000 < millis() || doing_fast_blink) {
|
||||
PAIRING_LED_state = LED_STATE_OFF;
|
||||
} else if (ble_state == unpaired) {
|
||||
|
||||
@@ -43,6 +43,9 @@ class StatusLEDModule : private concurrency::OSThread
|
||||
private:
|
||||
bool CHARGE_LED_state = LED_STATE_OFF;
|
||||
bool PAIRING_LED_state = LED_STATE_OFF;
|
||||
#if defined(LED_HEARTBEAT)
|
||||
bool HEARTBEAT_LED_state = LED_STATE_OFF;
|
||||
#endif
|
||||
|
||||
uint32_t PAIRING_LED_starttime = 0;
|
||||
uint32_t lastUserbuttonTime = 0;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user