Files
DigiRadio/Software/AGENTS.md
T
micheleandCursor 0a1188ad66 Release fw 0.8.5: BT1035 I2S boot init and hardware doc alignment.
Switch BT1035 bring-up from Line-In to I2S slave (AT+AUXCFG=3, AT+I2SCFG=67) to match the ADAU1701 PCM routing, confirm 2 kΩ I2C pull-ups on R1/R16, and sync firmware docs, AGENTS rules, and the DATASHEET bundle.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 14:41:31 +02:00

570 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# AGENTS.md — DigiRadio Firmware
Rules for any coding agent (Claude Code, etc.) working on the DigiRadio
firmware. This file is authoritative. If a request conflicts with these
rules, stop and surface the conflict before writing code.
Target: ESP32-S3-WROOM-1. Framework: **ESP-IDF v5.5.x** (stable).
Language: **C++23** (`-std=gnu++23`), strongly typed, class-based.
C++ exceptions: **disabled** (ESP-IDF default; keep off).
Companion chips: Si4684 (DAB+/FM tuner), ADAU1701 (SigmaDSP audio),
FSC-BT1035 / QCC3056 (Bluetooth audio, AT-controlled over UART).
---
## 0. How this agent must behave
- **Blockers first.** Open every response with what will stop the build
or the hardware from working. State the risk before the solution.
- **Zero tolerance for guessing at the hardware.** Never invent a
register address, an opcode, a bit field, or a boot sequence. If it is
not in the datasheet / programming guide, say so and stop. Cite the
document and section for every register-level decision.
- **No silent failure.** Every fallible operation returns a typed error
(see §6). Nothing is swallowed, nothing is logged-and-ignored.
- **Small steps.** One vertical slice at a time, compiling and testable
at every commit. No big-bang subsystems.
- **Ask when the invariant is unclear.** A wrong assumption baked into a
driver costs a re-flash and a debugging session. Confirm, don't assume.
---
## 1. Prime directive — Code That Fits in Your Head
Human working memory holds about seven things. Every unit of code must
fit in that budget at its own zoom level (methods, classes, modules —
fractal). Concretely:
- **Cyclomatic complexity <= 7 per method.** At 8, decompose. No
exceptions for "it's just a switch over registers" — extract a table.
- **The 80x24 box.** A method fits in an old terminal screen: <= 80
columns wide, <= 24 lines tall. If it doesn't fit, it's doing too much.
- **A method does one thing** at one level of abstraction. Mixing I2C
byte-twiddling and business logic in the same method is a smell.
- **Name for intent, not mechanism.** `tuneTo(Frequency)` not
`writeReg0x30()`. The datasheet detail lives *inside* the method.
- **Delete before you add.** The cheapest code to maintain is the code
that isn't there. Prefer removing a branch to adding one.
- **Chunk.** A reader should grasp a class from its public interface
without reading the bodies. If they can't, the interface leaks.
These are hard limits, enforced in CI where possible (clang-tidy
`readability-function-cognitive-complexity`, line-length lint).
---
## 2. Language and typing rules
C++ standard: **C++23**, pinned as `-std=gnu++23` in CMake (do not rely
on the toolchain default, which differs between ESP-IDF 5.x and 6.x).
This makes `std::expected` available natively (see §6).
### 2.1 Make illegal states unrepresentable
- **No primitive obsession.** Domain quantities get their own types.
A frequency is not an `int`; a gain is not a `float`; a station id is
not a `uint8_t`. Use a small strong-typedef template (a `NamedType`)
or dedicated value classes:
```cpp
class FrequencyKHz { // 80x24, one invariant, immutable
public:
explicit constexpr FrequencyKHz(std::uint32_t khz); // validates
constexpr std::uint32_t value() const noexcept;
private:
std::uint32_t khz_; // invariant: within band limits
};
```
- **Parse at the boundary, then trust.** Validate untrusted input
(network, UART, flash) once, at the edge, into a domain type. After
that, the type *is* the guarantee — no re-checking downstream.
- **`enum class` always.** Never a bare `enum`. Opcodes, states, bands,
and modes are enums, not magic numbers.
- **No booleans in public APIs for mode selection.** `setBand(Band::Dab)`
not `setBand(true)`.
### 2.2 Const-correctness, ownership, RAII
- **`const` by default.** Mutable is the exception you justify.
- **`[[nodiscard]]`** on every function returning a status or a value
that must not be dropped.
- **Rule of zero.** Wrap every OS/hardware handle (I2C bus, SPI device,
NVS handle, task, mutex) in a RAII type. No raw `new`/`delete`, no
manual `*_delete()` calls scattered in code — the destructor owns it.
- **Own with values and smart pointers**, borrow with references or
`std::span`. Never pass `pointer + length`; pass `std::span<std::byte>`.
- **`noexcept`** on anything that genuinely cannot throw (hot paths,
destructors, move ops).
### 2.3 Functions and purity
- **Command Query Separation.** A method either changes state (returns
void / status) or answers a question (returns a value, no side
effects). Never both.
- **Functional core, imperative shell.** Pure logic — station-list
operations, EQ coefficient math, config parsing/serialisation, boot
blob framing — lives in a hardware-free core that compiles and tests
on the host. All I2C/SPI/UART/flash lives in a thin shell that calls
the core. The core has zero `#include` of ESP-IDF headers.
---
## 3. File headers and code documentation
These are mandatory and checked in the Definition of Done. A file
without its licence header, or a class/method without its documentation
block, is not done.
### 3.1 File header (every source and header file)
Every `.hpp` / `.cpp` starts with this block, filled in for the file.
Use the SPDX identifier plus the short Apache notice — firmware is
Apache-2.0.
```cpp
/**
* @file Si4684Driver.hpp
* @brief Si4684 DAB+/FM tuner driver (intent-level interface).
*
* DigiRadio firmware — https://github.com/manvalan/DigiRadio
*
* Copyright 2026 Michele Bigi
* SPDX-License-Identifier: Apache-2.0
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
*
* @author Michele Bigi
* @date <YYYY-MM-DD of creation>
*/
```
Rules:
- The `@date` is the file's creation date and is not rewritten on later
edits (history lives in version control).
- The copyright year matches the creation year.
- Never place a secret, token, or path to a private resource in a header.
### 3.2 Documentation block — every class and every method
Every class and every method carries a Doxygen block with the fields
below, in this order. Native Doxygen tags are used for the standard
fields; two project aliases (`@dname`, `@pubstate`, defined in the
Doxyfile, §3.3) render the non-standard fields as titled sections in the
generated documentation. This means the required format *is* the tool's
format — one source of truth, no drift.
**Method block:**
```cpp
/**
* @brief tuneTo — set the tuner to a validated frequency.
*
* @dname tuneTo
* @param freq Target frequency, already validated to the active band.
* @return Ok on success, or Error::TunerTimeout / Error::NotBooted.
* @pubstate reads band_ (range-check); writes lastRsq_ (refreshed after
* a successful tune); uses spi_ (injected SPI dependency).
*
* States intent and the contract upheld — why this method exists and
* what it guarantees. Do NOT restate the code line by line.
*
* @author Michele Bigi
* @date <YYYY-MM-DD>
*/
```
**Class block:**
```cpp
/**
* @brief Si4684Driver — owns one Si4684, exposes intent-level tuning.
*
* @dname Si4684Driver
* @param spi Injected SPI device, borrowed for the driver's life.
* @param fw Injected firmware source for boot images.
* @return n/a (type)
* @pubstate Public interface: powerUp(), loadImage(Band),
* tuneTo(FrequencyKHz), readRsq(). Owns one SPI handle (RAII).
* No public data members.
*
* Single responsibility of the class in one or two sentences, plus its
* key invariants (e.g. tuneTo is valid only after a successful
* loadImage).
*
* @author Michele Bigi
* @date <YYYY-MM-DD>
*/
```
The mapping: **Name** → `@dname`, **Parameters** → `@param` (per param;
constructor/template params for a class), **Return** → `@return`,
**Public variables used** → `@pubstate`, **Description** → the free
text, **Author/Date** → `@author` / `@date`.
Field rules:
- **Name** — the class or method name, verbatim.
- **Parameters** — one `@param` per parameter; for a class, the
constructor / template parameters. Write "none" if there are none.
- **Return** — `@return` with success value and each error cause it can
return; "void" or "n/a" where applicable.
- **Public state used** — member state read/written and injected
dependencies touched. With proper encapsulation there are normally no
public data members, so this documents the shared/member state and
collaborators the method relies on. Write "none" for a pure function.
- **Description** — intent, contract, and invariants. Explains *why*,
never a restatement of the implementation.
- **Author / Date** — `@author Michele Bigi` and the `@date`.
Keep the block honest: if a method's "Public state used" list grows
long, that is a design signal to split the method (§1), not to write a
longer comment.
### 3.3 Documentation tooling — Doxygen
The documentation format above is backed by **Doxygen** (the standard
C++ documentation generator), configured by the `Doxyfile` at the repo
root. The tool does two jobs:
1. **Renders** the doc blocks into browsable HTML under `docs/api/`. The
`@dname` and `@pubstate` aliases turn the project-specific fields into
proper titled sections, so the generated docs match this spec exactly.
2. **Enforces** the rule. The Doxyfile sets `EXTRACT_ALL = NO`,
`WARN_IF_UNDOCUMENTED = YES`, `WARN_NO_PARAMDOC = YES`, and
`WARN_AS_ERROR = FAIL_ON_WARNINGS`. Any class, method, or parameter
without its documentation block makes `doxygen` exit non-zero.
Rules for the agent:
- **The docs build is part of Done.** Run `doxygen Doxyfile` and it must
exit 0 with an empty `docs/api/doxygen-warnings.log`. A non-zero exit
means something is undocumented or malformed — fix it, don't suppress
the warning.
- **Wire it into CI** as a required job, so an undocumented symbol blocks
the merge exactly like a failing test does.
- **Do not use `EXTRACT_ALL = YES` to silence warnings.** That flag hides
missing documentation instead of reporting it, defeating the purpose.
- Generated output (`docs/api/`) is a build artifact — git-ignore it,
don't commit it.
- Graphviz (`dot`) is optional but enabled: it produces class and
collaboration diagrams, which help keep the structure "in your head".
If `dot` is unavailable in an environment, set `HAVE_DOT = NO` there.
The `WARN_AS_ERROR = FAIL_ON_WARNINGS` value requires a recent Doxygen
(1.9.x+); on an older version use `WARN_AS_ERROR = YES`. Verify the
version rather than assuming.
---
### 3.4 Manual synchronisation
The LaTeX manual documents the firmware at the **design level** and must
never fall behind the code. The split of duties is strict:
- **Doxygen** documents the API — exact signatures, parameters, returns,
per §3.2. Generated from the code.
- **The manual** documents design — what a component is for, its
responsibility, its collaborators and invariants, and how it fits the
system. Written prose plus diagrams. It does **not** repeat the
per-method API.
Rule: **every public, architecturally-significant class has a matching
manual section.** These are the classes with a public interface —
drivers, application services, and public domain-core types (the ones
declared under an `include/` directory). Each gets a `\subsection` (or
`\subsubsection`) in the manual, tagged with a stable label
`\label{cls:ClassName}`, describing its responsibility, collaborators,
key invariants, and how it is used.
- When a public class is **added**, its manual section is written in the
same change.
- When its **public interface changes**, the manual section is updated in
the same change.
- When it is **removed**, its manual section is removed.
- Internal / private helper classes (declared only in `src/`, not
exposed through `include/`) do **not** require a manual section.
This is enforceable, not aspirational: `tools/check-manual-sync.py`
lists the public classes and fails if any lacks a `\label{cls:...}` in
the manual sources. Wire it into CI alongside the Doxygen check.
---
## 4. Architecture
Layered, dependencies point inward only:
```
Shell (imperative): drivers, web server, NVS, tasks, ISRs
— thin, no business logic [ESP-IDF, HW]
Application services: TunerService, AudioService, StationService,
BluetoothService, IntegrationService [orchestration]
Domain core (pure, host-testable): Station, Frequency,
EqProfile, MixerState, Credential, boot-blob
framing, validation [no HW headers]
```
- **Depend on abstractions.** Services take driver *interfaces*
(e.g. `ITuner`, `IDsp`, `IBtModule`, `ISecureStore`), injected via the
constructor. This is what makes the core testable without hardware.
- **No god object.** No single `DigiRadio` class that knows everything.
Compose small services.
- **One class = one responsibility.** If a class name needs "and", split
it.
---
## 5. Embedded constraints (ESP32-S3)
- **Heap discipline.** Prefer static / stack / pool allocation. No
dynamic allocation in audio or ISR paths, ever. Allocate at init,
reuse buffers. Watch fragmentation — long-running device.
- **ISR rules.** ISRs do the minimum: read/clear flag, signal a task.
No logging, no allocation, no blocking, no C++ exceptions in an ISR.
No virtual function calls in IRAM-safe ISRs — vtables live in flash and
are inaccessible when the flash cache is disabled.
- **Tasks and concurrency.** Each subsystem that needs its own timeline
gets a FreeRTOS task with an explicit stack size and priority,
documented. Shared state crosses task boundaries only through queues
or mutex-guarded types — never raw shared globals.
- **Exception policy.** C++ exceptions are disabled by default in
ESP-IDF and stay disabled here. All recoverable errors use the typed
result (§6). Destructors and hot paths are `noexcept`.
- **Blocking.** No busy-wait spin loops. Use event groups / notifications
with timeouts. Every wait has a timeout and a defined failure path.
---
## 6. Error handling
- **Typed results, not error codes floating in `int`.** Use
`std::expected<T, Error>` — available natively under C++23, so no
vendored library is needed. `Error` is an `enum class` with a stable
set of causes plus optional context.
- **Errors propagate to a place that can act.** A driver reports; a
service decides (retry, degrade, surface to UI); the top level logs.
Do not decide policy deep in a driver.
- **Every timeout is an error value**, handled explicitly — never a
silent return.
- **No `assert` for runtime-reachable conditions.** `assert` is only for
programmer-invariant violations that are bugs by definition. Hardware
can fail; that's a result, not an assertion.
---
## 7. Subsystem rules
### 7.1 Si4684 tuner driver
- The boot flow (POWER_UP → load patch/bootloader → load firmware image
→ BOOT) must follow the AN649 / programming-guide sequence exactly.
**Cite the section** for each step in a comment.
- Firmware images (FM, DAB) are large blobs. The driver **streams** them
in bounded chunks from flash — never loads a whole image into a heap
buffer. Blob source is an injected interface (`IFirmwareSource`) so it
can be faked in host tests.
- Command opcodes and property IDs are `enum class`. A `Command` builder
frames bytes; a `Response` parser validates the CTS/STATUS byte before
any payload is trusted.
- The public interface is intent-level: `powerUp()`, `loadImage(Band)`,
`tuneTo(FrequencyKHz)`, `readRsq()`. Register access is private.
- One driver instance owns one SPI (or I2C) device handle via RAII.
### 7.2 ADAU1701 DSP driver (RAM boot, no EEPROM)
- The ESP32 writes the SigmaStudio-exported program **to DSP RAM at every
boot** (self-boot EEPROM removed by design). Model the export as a
domain type — an ordered list of `RegisterWrite{ address, bytes }` —
parsed in the pure core, replayed by the shell over I2C.
- **Safeload for live updates.** EQ and mixer parameter changes at
runtime use the ADAU1701 safeload mechanism (write to the safeload
registers + IST) so audio updates are click-free. A raw param write
while audio runs is a bug.
- Provide typed control surfaces, not raw cell addresses to callers:
- `setEqBand(EqBandIndex, GainDb, FrequencyHz, Q)` → computes
biquad coefficients in the pure core, then safeloads them.
- `setInputMix(MixSource, GainDb)` where
`enum class MixSource { Si4684, Esp32 }` — this is the input mixer
between the tuner and the ESP32 audio path.
- Coefficient math (biquad design, gain-to-linear) lives in the pure
core with host unit tests against known reference values. No DSP math
hidden inside an I2C method.
### 7.3 FSC-BT1035 (QCC3056) driver
- Controlled by AT commands over UART. Build commands with a typed
builder; parse responses with explicit `OK`/`ERROR`/timeout handling.
- **I\textsuperscript{2}S slave mode is mandatory:** `AT+AUXCFG=3` and
`AT+I2SCFG=67` (Feasycom programming guide §5.1.25 / §5.1.4) must be part
of the documented init sequence and covered by host tests. The PCB routes
ADAU I\textsuperscript{2}S to the module; `AT+AUXCFG=1` (Line-In) does not
match the schematic.
- The AT subset in use is enumerated and documented; unknown responses
are an error value, not ignored.
### 7.4 Network configuration + Web UI
- **Provisioning:** captive portal / SoftAP for first setup, then STA.
State machine is explicit (`enum class NetState`), no ad-hoc flags.
- **UI: elegant and essential.** A minimal single-page app served
gzipped from flash. No heavy frameworks; small, fast, legible. Design
tokens (spacing, type scale, one accent colour) defined once and
reused — consistency over decoration. The UI is a thin client over a
typed JSON API; it holds no business logic.
- **API:** REST/JSON with typed DTOs on the firmware side. Every request
body is parsed into a domain type at the boundary (§2.1) before use.
Reject malformed input with a clear status; never partially apply.
- Serve UI assets read-only; never expose a raw filesystem or debug
endpoint in a shipping build (guard behind a build flag).
### 7.5 Secure storage
- Stores: Wi-Fi SSID + password, station/frequency list, audio profiles,
last-preset index. **Encrypted at rest** — NVS encryption with flash
encryption enabled in `sdkconfig.defaults` (development mode); keys in
`nvs_keys` partition; init via `secure_store::initEncryptedStorage()`.
Production release mode: `sdkconfig.defaults.production`. See
`docs/security-flash-nvs.md`.
- **Secrets never leave their type.** A `Secret` wrapper: no `operator<<`,
no implicit conversion to a loggable string, buffer zeroised on
destruction. Secrets are never logged, never placed in URLs, never
serialised to plaintext.
- Access is behind `ISecureStore` so the core and tests never touch real
flash or real keys.
### 7.6 Station / frequency list
- A `Station` is a value type: name, band, frequency (or DAB service id),
optional preset slot. The list is a domain collection with CRUD in the
pure core; persistence goes through `ISecureStore`.
- All list operations (add, remove, reorder, find, validate duplicates)
are host-tested with zero hardware.
---
## 8. Testing
- **TDD where it pays:** the pure core is developed test-first
(red → green → refactor). Coefficient math, blob framing, config
parsing, station-list logic — all covered on the host.
- **ArrangeActAssert**, one behaviour per test, names that state the
behaviour: `tuneTo_rejectsFrequencyOutsideFmBand`.
- **Fakes over mocks** for the driver interfaces; assert on observable
behaviour, not on internal call sequences.
- **Hardware-in-the-loop** tests are separate, explicitly marked, and
never block the host test suite.
- A change without a test for its logic is not done (hardware-only glue
excepted, and that glue must be trivially thin).
---
## 9. Version control and workflow
- **Small, frequent commits.** Each commit compiles and keeps tests
green. One logical change per commit.
- **Commit messages: the 50/72 rule.** Summary line <= 50 chars,
imperative mood; blank line; body wrapped at 72 explaining *why*.
- **Feature flags / branches by abstraction** for anything half-built —
`main` always builds and runs.
- No commented-out code committed. Version control is the history.
---
## 10. Definition of Done (checklist)
Before a slice is considered complete:
- [ ] Compiles with warnings-as-errors; clang-tidy clean.
- [ ] Every file has the Apache-2.0 header block (§3.1).
- [ ] Every class and method has its documentation block (§3.2).
- [ ] `doxygen Doxyfile` exits 0 with an empty warnings log (§3.3).
- [ ] Manual section exists/updated for any added or changed public
class; `tools/check-manual-sync.py` passes (§3.4).
- [ ] Every method <= 80x24, complexity <= 7.
- [ ] No primitive obsession in public interfaces.
- [ ] Fallible paths return typed results; no silent failure.
- [ ] Pure-core logic has host unit tests, all green.
- [ ] No secret is loggable or stored in plaintext.
- [ ] Register-level decisions cite datasheet section in comments.
- [ ] No dynamic allocation in audio/ISR paths.
- [ ] Public interface is understandable without reading bodies.
---
## 11. The agent must NOT
- Ship a file without the Apache-2.0 licence header.
- Ship a class or method without its documentation block.
- Add or change a public class without updating its manual section.
- Invent register addresses, opcodes, bit fields, or boot sequences.
- Put business logic in a driver or in an ISR.
- Return a bare error code or swallow a failure.
- Introduce a class whose name needs "and".
- Exceed the complexity / size limits "just this once".
- Store or log a credential in plaintext.
- Ship a slice that doesn't compile or breaks the host tests.
- Proceed past an unclear hardware invariant without asking.
---
## 12. Repo layout, build and test
### Layout (ESP-IDF project; core is host-testable)
```
Software/
├── AGENTS.md Doxyfile instructions.md
├── CMakeLists.txt top-level ESP-IDF project
├── sdkconfig.defaults C++23, exceptions off, flash/NVS encryption
├── partitions.csv nvs, otadata, ota_0/ota_1, dsp blob, nvs_keys
├── .cursor/rules/*.mdc
├── main/ imperative shell entry (app_main)
├── components/
│ ├── core/ PURE domain core — no ESP-IDF headers
│ │ ├── include/core/ public headers
│ │ ├── src/
│ │ └── test/ host unit tests (plain CMake + ctest)
│ ├── drivers/{si4684,adau1701,bt1035}/
│ ├── net/ provisioning + web server
│ ├── secure_store/
│ └── services/ TunerService, AudioService, ...
└── docs/api/ Doxygen output (git-ignored)
```
Rule: `components/core` compiles two ways — as an ESP-IDF component AND
standalone on the host for unit tests. It must never `#include` an
ESP-IDF header, so the host build stays hardware-free.
### Commands
Device build / flash / monitor (first encrypted flash: erase once):
```
idf.py set-target esp32s3
idf.py build
idf.py erase-flash flash monitor
```
Host unit tests (pure core; needs a C++23 stdlib compiler):
```
cmake -S components/core/test -B build-host \
-DCMAKE_CXX_COMPILER="$(brew --prefix llvm)/bin/clang++"
cmake --build build-host
ctest --test-dir build-host --output-on-failure
```
Docs and policy (must exit 0, from `Software/`):
```
doxygen Doxyfile
python3 tools/check-manual-sync.py
python3 tools/check_si4684_blobs.py
```
### Host toolchain note (macOS)
On the M4 Mac, `std::expected` needs a recent C++23 stdlib. Use Homebrew
`llvm` (>= 18) or `gcc-14` for the host test build — the system Apple
Clang may be too old. This affects only host tests, not the firmware.