IoT Implementation Records

Implementation judgement recorded for Vanling's own ESP32 firmware (apps/iot). AI-produced, not human-reviewed — see the parent note.

The system description lives in development docs / IoT firmware.

Findings, quick

This table is where "what is the conclusion, and on how many numbers" is answered. Most questions stop here; the full text is for derivation, rejected alternatives and reproduction.

Camera (GC2145 + ST7789)

FindingConclusionNumbers
How the field of view is computedoutput width ÷ array width, independent of any register value. ratio 5 divides evenly, so 320×240 is the full view320/1600 = 1/5
bins and frame rateTwo faces of one switch. This repository writes 0x000x00 4.5 fps / 691 KB/s; 0x01 10 fps / 1558 KB/s
ratio stepsratio 5 is the only step that reads the whole array; 1 yields no frame, 3/4 are undersampledratio 3 = 3444 KB/s; ratio 1 = 0 frames
Scalar mode 0xfdDoubles frame rate but gives a narrower view; this driver does not write it (leaves the power-on 0x00) but reads it back5 → 10 fps
Ring descriptor chainWithdrawn; the linear chain stays. The peripheral stops before completing one lapring 28 KB/s vs linear 645 KB/s
The linear chain drops bytesThe ring must be a whole frame, at the cost of rebuilds falling to 4 per secondone dropped byte shifts every later line (moving banding)
PSRAM ringAlways writes garbage. esp-hal cannot express internal SRAM only, so it stays in the configuration the vendor explicitly refuses150 KB ring
Frame-rate ceilingComes from data volume, not AEC exposure. Faster needs a smaller window or a different part5.2 fps; exposure 486 lines vs ~1200 read out
Pixel clockFixed at 24 MHz. At 20 MHz the PLL will not lock and the output is structurally correct, entirely noise, with every register reading back normally—
Entering/leaving the Camera pageThe chain is rebuilt each time and blocks once built. Render side 8% → 29–36%11 repaints/window at boot; 25–29 when cycling
The blank window on entry~192 ms, not shortenable; a CAMERA WAIT transition frame mitigates it5.2 fps ⇒ ~192 ms
Panel read-backThe panel has no MISO, so every register reads 0x00 and verification is visual only—
Frame-buffer ownerFrameOwner with three variants makes the fourth combination unrepresentable (two flags once deadlocked permanently)153,600 B

Audio (ES7210 + ZTS6216)

FindingConclusionNumbers
May the capture plane be throttledNo. Only sample() drains the ring, so batching to 30 s fills it and halts I2Sring full at 60 ms
How Off is implementedThe chain must be parked from the consumer side, not merely left unpoll'edchain stops 1 ms after the demand lands
Silence detectionMust read as "no change", or the whole diff chain spins—
A plane's marginal costRises with what else is awake; it is not a constant12.52% (alone) → 15.73% (something else awake)
What throttling achievesDoes not save the device total; it moves executor from one place to another94.8% → 94.0%
Scaling the poll sideScaled by the cadence in force, matching measurement at both ends+0.6% / −4.1% (40% high before scaling)
Quantisation noiseFeeds a 65 dBA phantom floor; the dBA table needs converting against the raw table65 dBA
SPL calibration0 dBFS = 102 dB SPL is this microphone's figure (397.8 mV/Pa ⇒ full scale 2.51 Pa)101.98 dB

Motion (QMI8658A)

FindingConclusionNumbers
Tap engineUnusable (reported, not reproduced); tap detection moved core-side and QMI8658_CAPABILITIES does not claim TAP—
Tap thresholdUses the raw magnitude, not a de-gravitated estimate8–22 mG at rest; 150 mG quiet / 250 mG peak
The aLPF filterMeasured irrelevant, switched off. A tap is a broadband transient and the filter only clips its peakstatic noise and tap readings identical either way
No-MotionA level, not an event; only the 0→1 edge is published, or a still device reports Still every frame—
Shake criterionCompares vector residual and counts zero crossings, not time above a threshold2.0 g; W3C's 2.5 g / 60 Hz single-axis amplitude does not apply

Input and touch

FindingConclusionNumbers
Page cycleCorrect on hardware. [DISPLAY]'s 2 s window cannot show page order — reading it that way once suggested "pages are jumping around"46 transitions, 0 off-cycle, 0 repeated
The four polled sourcesOnly capture may be switched off; the interaction path is off-limits by designsteady button 0 / touch 1 / motion 3 / capture 16–20 ms
The interaction floorA theoretical lower bound unrelated to demand, not tuning headroom10.87%

Echo cancellation

FindingConclusionNumbers
Is there a public recording to validate againstNo. All four tried: two Espressif recordings, Microsoft AEC-Challenge (unusable by design — consumer stacks may already run AEC/NS on the send path), and ITU-T G.168 (Annex D does carry explicit impulse responses, but a transcription of it cannot be verified)correlation 0.137 / 0.743 / ≤0.137; Wiener bound −4.97…−0.42 dB (best of 40 GUIDs still negative)
Is ERLE this dataset's metricNo. The paper says "we can not use ERLE and PESQ"ERLE vs subjective quality PCC 0.31
ITU-T G.168's standard pathsRight idea, unverifiable transcription. Eight m_i(k) impulse-response tables, needing no audio files at allextraction loses the first column; reading order ambiguous; both readings violate the standard's own "|G| ≤ 0 dB" invariant by 1–11 dB
What the standard itself saysG.168 Test 12 note: real acoustic test vectors are "for further study"the standards body confirms they do not exist
The reference becameThe offline optimum on the same path, solved there and then. Computed, not transcribedgap to optimum 0.1–1.9 dB across four paths
The 15 dB barIt is about the path, not the algorithm. With the tail longer than the filter the offline optimum itself is 14.4 dBno 128-tap linear filter can reach 15
A generated path needs a direct reflectionOtherwise it has no onset, the delay search has nothing to lock onto, and it reports a noise peakwithout one: a 12-tap path reported 15, a 4-tap one 22, ERLE 0.4 dB; with one every delay hits exactly, 61 dB
The freeze stops continued damage, not the first half secondThe coherence meter has a ~512-sample time constant and the filter has already run through themfreeze covers 98%; energy past the path grows 3.7e6×; no growth after the freeze. Unfixed
CPU: measure the clock firstAt the time the whole board ran at 80 MHz, not 240. CpuClock's default arm is _80MHz; Config::default() never sets the maximumesp_hal::clock::cpu_clock() = 80,000,000 Hz
The measurement used the wrong clockThe first version's "219 cycles/MAC" was converted at 240 MHz and is wrong. A reference loop caught it — a trivial integer loop should be faster, yet ran 193× slowerreference FIR 12 cycles/MAC @80 MHz
Block adaptation: it passesCoefficients updated once every four samples. Steady-state ERLE bit-identical; convergence slowerL=16 9,680 µs ≤ 10 ms; L=128 70,945 → 34,911 µs
Frequency-domain MDF (best practice)Wrote the 512-point fixed-point FFT, measured it before discussing the architecture, then deleted it (no caller on the product path). The transform alone is 1.8× over budget; partitioning and filtering not yet counted3,046 µs each = 182% of budget; RAM 3,064 B → 6 KB (no RAM saved)
The second core: identical digitsFROM_CPU_INTR1 is a second interrupt source on the same core. The product runs on core 0; core 1 is idletwenty-odd measurements all identical to the digit → the bottleneck is shared
IRAM: not that eitherThe butterfly moved into .rwtext, confirmed at 0x4037a4c4 and not flash3,046 → 3,235 µs, 6% slower. Fetch is not the bottleneck
Four loops, IPC 0.1–0.33Fetch ruled out, second core ruled out, so it is the memory system itself. The cleanest code (FFT butterfly) has the lowest IPCbutterfly 0.09; reference FIR 0.33
The delay search can break the 30 ms bound aloneOne search is about 32 ms, once every 256 ms, 3.0 ms per tick amortised (31% of L=16). Thinning the grid is ruled outAgainst a broadband reference the correlation peak is one sample wide — at a one-sample delay even adjacent lags 0 and 4 score nothing (146 against 1000)
240 MHz, and the console survivedesp-hal's "changing the PLL breaks USB Serial/JTAG" was [reported]. Measured: 240 MHz runs and the console is fineL=16 3,248 µs; L=128 11,639 µs; worst tick 21.6 ms, inside the 30 ms bound
Not one cycle count movedThe clock is only a divisor: cycles/butterfly 112 → 112, reference FIR 12 → 12every time figure ÷3 at 240 MHz
The data cache is out of reachESPHome/ESP-SR's AEC wants DATA_CACHE_64KB + LINE_64B and refuses to start without it. esp-hal has no API, esp-metadata no eFuse field[reported] whether this board has one, unread
The IPC puzzle is not the cacheOrdered by writes, not working set: read-only 12 / 31.6, read+write 105.8 / 119. The smallest working set is not the fastestwrite-back cache, rewriting the same location every tap
But it exposed a much larger leverThe board has been at a third of its rated clock, and the existing 17–25 ms worst tick is paid at that same 80 MHz@240 MHz: AEC L=64 → 13.4 ms (fits); worst tick → 5.7–8.3 ms. esp-hal notes that changing PLL affects USB Serial/JTAG, which is this repo's console
The coefficient update dominatesgain is 64-bit, so gain · x costs muluh+mull+srai+add. The clamp branches are almost never taken and are not the costL=128 update 48.9 ms vs convolution 16.2 ms
A 7.6 ms fixed floorDelay search 3.0 + meters 1.8 + unattributed 2.7, independent of tap countPayable at zero taps; even a free update lands L=16 at ~8 ms
Does the conclusion changeNo. What binds is wall clock, independent of the CPU clock (systimer runs off the crystal). All three changes together bought 9.6%L=128 71.0 ms = 355% of a tick; L=16 15.5 ms against a ≤10 ms bar
The FPU is off on this targetA same-shaped f32 loop ran over 120 s against 140 ms for its i32 twin, with libgcc's whole soft-float complement linkedFloat runs in software emulation; "switch to float" is not a shortcut
Cost3,040 B RAM (on the board)state_bytes::<128>() = size_of::<Canceller<128>>(); the old function counted only the filter and under-reported by 3.4×

Hardware constraints

FindingConclusionNumbers
Power managementNo PM yet, so no clock-source lock is the right call (two locks with no holders is dead code). The order still matters—
Light-sleepPeripherals are clock-gated and raise no interrupt, so drivers relying on peripheral interrupts must hold NO_LIGHT_SLEEPDVP and I2S chains both fall here
Shared MCLKThe two units run in their own clock domains; the transmit unit drives the pin and capture is forced subordinate, and the direction is not interchangeablewith_signal_loopback is the only misleading name here
Main task stackChanged when the camera entered the product; read the linked ELF symbols_stack_start_cpu0 = 83,060 B
esp-hal stack-overflow watchpointFires spuriously and cannot be used as an overflow criterion—
A 64 KB data cache leaves the reclaimed region emptyesp-hal calls the space above 0x3FCF0000 "leaving to the heap", but takes the data cache off its top. At the default, #[ram(reclaimed)] has nowhere to goThe existing ELF in this tree has zero symbols above 0x3FCF0000; halving the cache buys 32 KiB back

Network (Wi-Fi)

FindingConclusionNumbers
Is there a TCP/IP stack in this generationNo lwIP, no esp-netif (404 on crates.io, no directory in the repo, zero hits in the changelog). What replaces it is esp-radio (L2) plus embassy-net (smoltcp)esp-radio ships an embassy_net_driver::Driver implementation, so getting an address is wiring, not writing
What "connected" meansAn address, not an association. The reference firmware's STA_CONNECTED handler is empty; the bit is set from GOT_IPAssociated ≠ connected
The three stages have three budgetsThe scan is the radio's, association is the AP's reply and retries, DHCP is the network's lease — one "connecting" label hides which is slowThe probe times them apart
The trap in the driver's connect defaultsA fast scan takes the first match, so a strong same-name AP that fails authentication once is silently replaced by a weak oneMeasured on the reference firmware: −73 dBm in range, connected to −79 dBm
The DRAM budget is the hardest constraint on this boarddram_seg can give 12,344 B more (all that is left under the 65,536 B stack assertion) — not enough for the driver's buffersMain stack 77,880 B; the Wi-Fi heap is 8 KiB + 32 KiB reclaimed
PHY calibration restore is todo!() on the S3PROVIDE(misc_nvs_restore = __esp_radio_misc_nvs_restore) binds the blob's undefined reference to a #[cfg(xtensa)] todo!(), and this is an Xtensa targetWorkaround: no phy_init partition (at the cost of calibrating at every boot)
Partition tableespflash defaults to a 1 MB factory partition and the firmware was already 630 KB before the Wi-Fi blobsfactory 6 MB; a 16 MB part

Logging

FindingConclusionNumbers
Default levelinfo carries events only; a steady state may print nothing for a long time, and silence is not a hang169 lines/43.8 s → 36 lines/70 s; zero lines in the 62 s after the 8 s mark
One knobIOT_LOG_LEVEL; a name it does not recognise falls back to info rather than going silent, and log::max_level() is the only holder of the levelThe boot line [IOT] log level … is the only evidence
Periodic telemetryFrame cost, stack peak, feed cadence, link heartbeat and scan lists are all debug; the window that crosses a bound is raised to warn102 DEBUG lines in 40 s from the same source at debug
What removing lines boughtThe reader's time, not CPU: a UART write over USB CDC does not enter the executor-hold account1426 → 1417 ms per 5 s window
TagsOne tag per module, not one per peripheral[MIC]→[AUDIO], [SPEAKER]→[PLAY], [TOUCH]→[INPUT], [RADIO]→[WIFI], [PSRAM]→[HEAP], [BACKLIGHT]→[DISPLAY]

Probes

Each probe is a standalone bin that wires one layer of the framework straight to real hardware. Consult this before touching a driver — the question "which probe answers this" should end here.

A probe is not another form of the product: the product firmware cannot produce the inputs a probe can. All five pages ask all four planes for Continuous, so on a device you can never reach any cell of the (configuration × cadence) matrix but the default one.

ProbeWhat it answersWhat it reports
demand-probeWhat each cell of the motion plane's configuration × cadence costsEach cell settled before measuring: bus transactions/second, executor occupancy as a wall-clock share, and the number of polls measured so the first two can check each other
camera-probeWhether the camera's frame reaches the panel unmodified, or something substituted for itFrame bytes and checksum; "the picture looks wrong" is not a measurement
audio-probeThe transmit path's steady state with the runtime to itselfStalls and gaps across the two alternated sounds
multicore-s3Whether starting the second core survives the main stack sitting beneath itWhether the stack top — where the thread-mode executor's ThreadFlag lives — is overwritten by the startup prologue
aec-probeWhat one audio tick of the linear canceller costs, at L = 16/32/64/128First/mean/worst tick, share of 20 ms, and the delay search, filter and meters timed separately
clock-probeWhat a runtime CPU clock switch costs, what stays true afterwards, and what core 1 makes of itRatio of one work unit across the three presets (not cpu_clock() — it reads configuration, not hardware), switch wall clock, Instant across a switch, both cores measuring the same work
wifi-probeWhether the radio and the IP stack come up at all, and whether this board's DRAM budget holds themScan / association / DHCP timed apart, how many access points the scan found, the driver's heap every 2 s, what calibration costs at boot
moon run iot:probe-demand-s3 && moon run iot:flash-probe-demand-s3

probe-*-s3 only builds; flash-probe-*-s3 is what flashes. The port is in the command in moon.yml. wifi-probe exists for both chips (-s3 / -c6): one source, and the C6 runs on a plain reset rather than -a watchdog-reset (why: @/development/iot/flashing.md).

wifi-probe has one extra step: its credentials are a build-time input, so the build command carries two variables.

IOT_WIFI_SSID=home IOT_WIFI_PSK=secret moon run iot:probe-wifi-s3
IOT_PORT=/dev/cu.usbmodem1422301 moon run iot:flash-probe-wifi-s3
# the C6 probe: same source, plain DTR reset instead of `-a watchdog-reset`
IOT_WIFI_SSID=home IOT_WIFI_PSK=secret moon run iot:probe-wifi-c6
IOT_PORT=/dev/cu.usbmodem14401 moon run iot:flash-probe-wifi-c6

Without them it still runs and says that this build was not told to join anything — which answers "does the scan work on its own" without pretending to be a connection test. Both names are in FORWARDED_ENV in scripts/iot-xtensa.sh, and the host test build_env.rs fails the build if one is read but not forwarded.

All five bins are compiled on every check-s3, and never linked. A probe nothing compiles rots in place: each one calls a bring-up or a chip API of its own, so a change to Board, Speaker or the chip layer breaks its build and nobody notices until the day it is needed on a bench.

The boundary between probe and product can move silently: both sides still compile, so drift is not a compile error. When a change touches a framework path a probe calls (poll_plan_of, MotionConfig::for_mode, a driver entry point), flash the probe and treat its output as evidence rather than waiting for the build to go green.

Check tasks

moon caches on a content hash. With no inputs its default takes in every file under the project; declaring inputs replaces that default rather than adding to it. So a task that compiles through a workspace-root script (check-s3 / check-features / the probe-*-s3 ones) has to name that script, or editing the dispatcher leaves the cache valid. See the Check tasks and caching record.

FindingConclusionNumbers
What is in the cache keyTask text + project env: + one sha1 per input file; the default input is the whole projectcheck-host's default key holds 121 entries
The script is outside itA task compiling through scripts/iot-xtensa.sh must name it, or editing the script does not invalidateAppend one comment: key 0973f854 → 577401ca; restore → cache hit
check-features used to run every timeIt was cache: false. Now an unchanged tree replays in 0.09 s, a source change still pays full price2m20s570ms (eight S3 subsets at ~15 s each)
The C6 product checkMoved to the host's stable toolchain: the target has a prebuilt core/alloc, and that is the toolchain producing its firmware1m12s → 1.65 s
Merging 8 containers into 1Rejected: starting a container measures 0.45 s, so the ceiling is 3.5 s — not worth a batch protocol3 × 0.45 s (5.4 s was a cold start)
The guard that stays on the dispatchercheck-features' C6 subset is kept deliberately: it is CI's only witness of the S3-only ESP_HAL_CONFIG_DATA_CACHE_SIZE gating—

Full records

Read on for derivations, the full list of rejected alternatives, and reproduction steps.

ChapterContents
Playback recordCp0Disabled investigation, rejected approaches, feed-tick ownership, ISR split, sound recipes
Hardware constraints recordShared MCLK, clock-source lock vs PM ordering, microphone front-end write order and calibration, where the stack size comes from
Camera recordGC2145 field-of-view arithmetic, bins and scalar measurements, why PSRAM is unusable, panel wiring, repeated page cycling rebuilding and stalling the chain
Audio recordSilence detection, measured marginal executor cost, only one place the capture chain can stop, calibration walk gated by demand, backlog depth, dBA against the raw table, quantisation noise floor
Motion recordRejected tap approaches, where the thresholds come from, No-Motion is a level not an event
Input and touch recordPage cycle measured on hardware, 2 s windows cannot show page order, only one of four polled sources may be switched off
Echo cancellation recordWhy Espressif's vectors cannot measure AEC, why the step must not be scaled by the residual, coherence as the freeze criterion, the delay search's confidence and rate limit, steady-state against whole-file ERLE
Runtime CPU clock recordesp-hal's frozen clock frequencies, the off-path register route measured at 1 : 2.00 : 2.95, a switch costing 1–50 us, no difference on the second core or outside the CPU, the table following the chip, one existing defect in the way
Wi-Fi recordNo lwIP in this generation, only esp-radio plus embassy-net; "connected" means an address; three connection defaults copied from the reference firmware; how the DRAM budget was made to fit; the PHY calibration todo!() and the partition table's workaround
Check tasks and caching recordWhat moon's cache key is made of and the script blind spot, check-features gaining a cache, the C6 check moving to the host, the rejected container merge
Logging recordThe measurement behind the default info, IOT_LOG_LEVEL held in one place, the event/telemetry/crossed-bound split, unified tags, the post-mortem gap

The full derivation of throttling, with measurements and method, is in the architecture doc.