Skip to content

Export the C6/C61 continuous-capture engine (RINGINFO?/RINGCAP) - #14

Open
juicebox6030 wants to merge 1 commit into
ESPARGOS:mainfrom
juicebox6030:c6-c61-continuous-capture-export
Open

juicebox6030 wants to merge 1 commit into
ESPARGOS:mainfrom
juicebox6030:c6-c61-continuous-capture-export

Conversation

@juicebox6030

@juicebox6030 juicebox6030 commented Oct 18, 2026 •

Copy link
Copy Markdown

This work is agent-built. A coding agent (Claude Code) authored the patch and this
description under its owner's direction. The patch text is derived mechanically from the pinned
upstream sources by a tracked script rather than hand-typed, and every claim below names the file
or the measurement that supports it. Treat the maintainer's own review as the authority.

What this changes

main/common/ring_capture.c is compiled for ESP32-C61 and ESP32-C6 (main/CMakeLists.txt), and
main/families/c5_c6_c61/receiver.c already drives that engine from its ring_test() handler. But
the raw-IQ export of the engine — RINGINFO? and RINGCAP — exists only in
main/targets/esp32s3/receiver.c, so it is unreachable on the chips whose ring is already
running. That looks like a gap in the tree rather than a deliberate choice, so this PR wires the
export into the C5/C6/C61 family.

One file, one thing, 117 diff lines:

File Change
main/families/c5_c6_c61/receiver.c esp_heap_caps.h include; the RINGINFO?/RINGCAP export; CAPS advertises RINGINFO RINGCAP

No new files, no main/targets/ edits, no build-system change, no sdkconfig change, and no test
changes
— your own tests/ suite passes unmodified against this patch (see How to verify).

The shape, and why it is this small

Your ESP32-S3 target keeps one ring dispatcher: helpers above ring_command(), a single dispatch
in handle_command, and the export commands answered by the dispatcher itself. The family receiver
already has exactly one ring entry point, ring_test(), which owns every other continuous-capture
command on this target. So the export is answered from there:

#if CONFIG_IDF_TARGET_ESP32C61 || CONFIG_IDF_TARGET_ESP32C6
static bool ring_test(const char *line) {
    /* RINGINFO?/RINGCAP: raw-IQ export of the engine above. */
    if (ring_export_command(line)) return true;
    ...

That choice is not cosmetic. handle_command() is shared by C5 as well as C6/C61, while
ring_capture.c is compiled only for C6/C61, so a second call site there would need its own target
guard and a forward declaration to stay linkable on C5. Reaching the export through ring_test()
keeps the guard in the one place that already has it, leaves handle_command() byte-identical, and
means a call through ring_test() — which your tests already stub — needs no new stub.

The export is declared with a const char *line parameter here, matching your own
ring_command(const char *line). The copy this was composed from declares it char * because the
other profile in this project calls it from a char *line handler; the family calls it from
ring_test(const char *line), so leaving the parameter non-const would make the caller discard
the qualifier and add a -Wdiscarded-qualifiers warning to every maintainer build.

The C61 rate check, and why it is not a copied literal

The copy's entry point hard-coded rate != 0 && rate != 1 && rate != 6. That literal is your
ESP32-S3 validation, verbatim, and it is correct there and on the C6 — both have exactly those three
rates. It is wrong for the C61, which this same family receiver compiles and whose table in
main/common/ring_capture.c is six rates (0..5, 80 down to 4 Msps). A copied literal would reject
every C61 rate except 0/1/6 and wave 6 through to the engine's own check.

The family already owns the target-aware predicate a few lines below this one —
ring_test() validates with exactly !ring_capture_rate_hz(rate), and ring_capture.h declares it.
The contributed copy uses the same call, so the accepted set is correct on both targets by
construction:

if (!ring_capture_rate_hz(rate)) {
    reply("ERR rate\n");
    return true;
}

The RINGINFO? comment likewise said "Two 128 KiB banks". That is the C6 (RING_BANK_STRIDE
0x20000); the C61 falls back to the 0x10000 default stride. The comment now states the
reservation without a size that only one of the two targets has. The RINGINFO? output is
unchanged in both cases — it reports RING_BANKS and RING_THRESHOLD, which are target-correct
already.

The one adaptation worth a reviewer's eye

The contributed code reads

r->cap[0].count, r->units > 1 ? r->cap[1].count : (uint32_t)0, (uint32_t)0

This is a target adaptation, not a repair. The expression it is adapted from reads
r->units > 2 ? r->cap[2].count : 0; C's conditional operator does not evaluate that arm unless
units > 2, and both the command parser and the engine already reject capture_units > RING_BANKS.
On a two-bank part the third slot is never read. The literal zero makes the two-bank assumption
explicit instead of relying on a guard elsewhere, and the entry check refuses units > RING_BANKS
at the command boundary as well. Nothing here is memory-unsafe today.

How to verify

The generator is deterministic and refuses to run on anything but a verified copy of the pinned
tree. It rewrites the copy it is given, so pass a throwaway copy:

cp -a <tree at f86da818> build-topic-r1/copy
python3 tools/patch_esp_sdr_c6_upstream.py build-topic-r1/copy --diff /tmp/pr.patch
sha256sum /tmp/pr.patch   # 2e3b4b7e3f14e7ddec784afaaacaed1406eb3e15b69b1c4842793eeb1c26e99f

The patched source is what reproduces (diff_lines: 117, and the file applies cleanly to
f86da818):

sha256sum build-topic-r1/copy/main/families/c5_c6_c61/receiver.c
# a571e22d3aa4220996172ddb53f7f3359dc0e6ec3d5e30498f119d173cb86f87

The patch was built against your own untouched sdkconfig.defaults + sdkconfig.defaults.esp32c6 +
partitions.csv, with tools/generate_version.py left in place:

idf.py -D IDF_TARGET=esp32c6  build   ->  exit code 0
idf.py -D IDF_TARGET=esp32c61 build   ->  exit code 0
idf.py -D IDF_TARGET=esp32c5  build   ->  exit code 0

That is the compiler's own : warning: count over the whole build, which is 0 on all three.
The five CMake Warning lines the build does emit are IDF's own
components/esp_wifi/wpa_supplicant private-include validation, unchanged by this patch and
present without it. There are no compiler warnings at all, on any of the three, including
-Wdiscarded-qualifiers.

esp32c5 is the target worth calling out, because main/CMakeLists.txt routes it to this same
families/c5_c6_c61/receiver.c while compiling main/common/ring_capture.c only for the C6 and C61.
C5 is therefore the one target where the added file-level esp_heap_caps.h include and the
target-guarded export must coexist with the engine absent — the case that would break if the
guard or the include were placed wrongly. It builds clean, and esp32c5 is in your own CI matrix
(python3 tools/build_firmware.py --matrix).

receiver.c compiles to an object carrying the RINGINFO RINGCAP token, and the link produces
esp_sdr.bin — 798,496 bytes for esp32c6, 757,584 for esp32c61, 803,536 for esp32c5. The
image hash is not a reproducibility claim
: generate_version.py stamps a UTC timestamp, so
esp_sdr.bin differs on every build. Your own host suite passes on the patched tree with no test
edits:

python3 -m unittest discover -s tests   ->  Ran 59 tests ... OK

Upstream's C6 defaults already carry what this feature needs, and say why:

# Continuous ring acquisition masks interrupts; host-stall timeout bounds abandoned runs.
# CONFIG_ESP_INT_WDT is not set

So no sdkconfig change belongs in this PR. Do not enable the interrupt watchdog and then run
SPEC
— the C6 does its FFT work inside ring_capture.c's interrupt-masked span and the device
crashes (observed as a Guru Meditation mid-transaction).

On hardware, your own client is the test:
python3 tools/check_spectrum.py --port /dev/ttyACM0 --milliseconds 3000 --stats — it reads
SPECINFO? and exercises every advertised profile. On the equivalent image it exits 0 across all
12 C6 profiles. For the export itself, RINGINFO? reports the heap and bank geometry, and
RINGCAP <units> <rate> streams the raw IQ with a RINGDATA header, the samples, and a RINGCAP
report line.

What is proven, and what is not

Proven, with the receipt named. The patched source reproduces from the tracked generator (stable
hash across runs), the diff touches exactly one file and adds none, and applies cleanly to
f86da818 (git apply --check --stat → 1 file changed, 89 insertions(+)). It compiles for
esp32c6, esp32c61 and esp32c5 with your own defaults, exit code 0 on all three, zero
compiler warnings on all three. Your tests/ suite passes unmodified on the patched tree — 59
tests, no edits, run against a copy carrying the regenerated patch. The RINGCAP hardware figures below were measured on the
author's own build, not this trial; see item 1. RINGCAP's device-emitted CRC32 matched the host's zlib.crc32 exactly
(2813587756), so the checksum convention is device-confirmed. SPEC at n = 256 ran for 60 s at
16 MS/s with 960,012,795 pairs over 60,000,810 µs × 16 MS/s = 960,012,960 pair-times
(0.99999983), 39,039 CRC-valid frames of the expected FFT shape, a monotonic sample index, zero
drops and status 0. State the second number too: 39,039 FFT windows analysed of 78,077 window
events, i.e. 39,038 abandoned — so 9,993,984 of 960,012,795 pairs (1.04 %) are transformed. The
acquisition is continuous; the spectral output is sampled windows over it, and the end line carries
both counts.

Not proven — stated here rather than left for a reviewer to discover.

  1. The hardware numbers were taken on the author's own profile, not on this trial build. The
    trial keeps upstream's UART transport and console enabled; the measured image used a different
    profile. The trial therefore proves composition and buildability, not that this exact image was
    measured.
  2. An upstream inconsistency this PR does not touch: upstream sets
    CONFIG_ESPTOOLPY_FLASHSIZE_2MB=y while its own partitions.csv declares a 4 MB factory
    partition. Pre-existing, unaffected by this change, noted only so it is not attributed here.
  3. No RF calibration of any kind. A declared sample rate is not a measured one. Sample clock
    accuracy, LO placement, passband and trigger alignment are unestablished, and no RF source was
    characterised.
  4. n ≥ 512 is not a continuum on this chip. SPEC … 512 returns successfully having
    consumed 2.4 % of the samples. A status 0 and a plausible frame count do not prove continuous
    acquisition — only the pair ratio does. SPECINFO?'s sixth profile field is the
    machine-checkable discriminator (1 for n == 256 over USB, 0 otherwise).
  5. Continuous acquisition is not complete spectral coverage, and not a gapless IQ recording. At
    n = 256 the 60 s run analysed 39,039 of 78,077 windows — 1.04 % of the acquired pairs. Your own
    docs/spectrum.md says it: continuous capture "keeps the RF writer running, but the CPU analyzes
    only selected FFT windows: it does not deliver every sample."
  6. No hardware run of RINGINFO?/RINGCAP on the C6 in this exact form. The code path is the
    one already exercised on the S3 for the same engine, adapted only for the two-bank geometry, and
    it compiles and links for both esp32c6 and esp32c61; it has not been exercised on a C6 device
    by this author.
  7. No host-parser coverage of the new commands. The export is answered from ring_test(), which
    sits above the line your host tests slice the file at (#define BURST_MAX_RATE), so
    test_c6_commands.py / test_c5_c61_commands.py compile the shared handler and stub ring_test()
    rather than exercising it. That is deliberate — it is also why this patch needs no test change —
    but it does mean RINGINFO?/RINGCAP parsing is covered by review and by the S3 precedent, not
    by a host test.
  8. Only the targets that compile this file were built locally. Your CI matrix has 11 targets; I
    built the three that reach families/c5_c6_c61/receiver.c (esp32c6, esp32c61, esp32c5). The
    other eight never compile this file, so the patch is inert on them — but that is reasoning from
    main/CMakeLists.txt, not a build, and your CI is the authority. This is exactly what the PR's
    own check run is for. (At the time of writing the workflow on this PR reads action_required with
    0 jobs executing — a fork first-contribution approval gate, not a build result. Nothing has
    been compiled on the runner, so treat these local builds as the only build evidence so far.)

Two judgement calls a maintainer may want to overrule

CAPS advertises RINGINFO, while the parser answers RINGINFO?. Your ESP32-S3 ladder does the
same thing — it advertises the bare RING token, not RINGINFO? — so this follows your own
convention: the ladder names the capability family, not the literal query string. The differentiated
ladder is SPEC/SPECN/SPECCAPS/SPECSTAT, which this patch does not touch. If you would rather
it read RINGINFO?, that is a one-token change.

The dated /* Modified 2026-10-18: … */ notice above your header. It records what changed and
that upstream attribution stands, which the contributing project's process requires. Your sources
carry no SPDX-License-Identifier and this patch adds none. If you would prefer the contribution be
plain upstream style, delete the notice — nothing depends on it.

License and provenance

  • Upstream is GPL-3.0; the contributed changes are a derived work of it. Upstream copyright and
    attribution are preserved untouched in the file.
  • The patch does not add an SPDX-License-Identifier line. The upstream source carries none (it
    uses a plain /* … */ header) and the generator does not insert one — grep -c SPDX on the patch
    returns 0.
  • The generator adds a dated /* Modified 2026-10-18: … */ notice above the existing upstream
    header, naming what changed and stating that original upstream source and license attribution
    remain.
  • No license file is added or altered, no SDK or third-party implementation source is included, and
    no generated or compiled artifacts are added. tools/generate_version.py is untouched.

🤖 Generated with Claude Code

main/common/ring_capture.c is compiled for ESP32-C61 and ESP32-C6, and
main/families/c5_c6_c61/receiver.c already drives it from ring_test(), but the
raw-IQ export of that engine exists only in main/targets/esp32s3/receiver.c. It
is unreachable on the chips whose ring is already running.

This wires the export to the family's existing ring entry point, ring_test(),
rather than adding a second call site in the shared handle_command() -- which C5
also compiles, without the engine, and which would then need its own target
guard and forward declaration. handle_command() is left byte-identical.

One file, 89 insertions. No new files, no main/targets/ edits, no build-system
or sdkconfig change, and no test changes: tests/ passes unmodified.

The export code is the same definition already exercised on the S3 for this
engine, adapted for the family's two-bank geometry and made const-correct
(ring_test takes const char *), and its rate validation uses the family's own
ring_capture_rate_hz() rather than a copied literal -- the S3's 0/1/6 set is
wrong on the C61, whose table is six rates.

Replaces the earlier version of this branch, which additionally carried
defensive edits to main/common/burst_serial.c. Those are dropped: neither was a
memory-safety defect, and they are a separate change with a separate argument.

Body carries the caveats stated in the pull request description, including the$
explicit non-claims.
@juicebox6030
juicebox6030 force-pushed the c6-c61-continuous-capture-export branch from 6f9ad52 to c955f7b Compare October 18, 2026 22:39
@juicebox6030 juicebox6030 changed the title Export the C6/C61 continuous-capture engine, and harden the USB Serial/JTAG write path Export the C6/C61 continuous-capture engine (RINGINFO?/RINGCAP) Oct 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant