Design record¶
This is spec.md from the repository root, included verbatim — there is one copy, so the
site and the file cannot drift apart. It stays the living design record: when bring-up
teaches us something, it gets updated there and appears here.
The rest of this site is operational: how to wire it, run it, extend it and debug it.
NanEyeC → Teensy 4.1 → Windows USB Camera — Specification¶
Status: draft for approval Date: 2026-09-17 Scope owner: developed autonomously by Claude; hardware actions and approvals by P. Baetens
1. Purpose¶
Stream live 320×320 images from a NanEyeC miniature image sensor (mounted on a NanoBerry board) to a Windows PC, using a Teensy 4.1 as the camera interface. The sensor's single-ended interface (SEIM) is half-duplex on two wires; the Teensy must clock it, configure it, capture pixel data, and forward frames over USB.
The primary application is measurement and image capture: recording frames for later analysis. This drives requirements for 10-bit fidelity, frame timestamps, explicit dropped-frame accounting, deterministic exposure/gain control, and lossless framing (§7, §9).
2. Agreed decisions¶
| # | Decision | Value |
|---|---|---|
| D1 | PC interface | USB CDC serial + Python viewer/recorder (no UVC) |
| D2 | Sensor variant | Mono / B&W (confirmed by measurement, §3.4) |
| D3 | Physical connection | Jumper wires, Teensy ↔ NanoBerry 40-pin RPi header (J2) |
| D4 | Autonomy | Claude installs the toolchain, builds/flashes from CLI, and drives the Saleae via the Logic 2 automation API |
| D5 | Bit depth | 10-bit. 8-bit was selectable and nothing ever asked for it; removed 2026-09-25 |
| D6 | Clock rate | Start low: 12.375 MHz, then 24.75 MHz. Higher rates are a stretch goal |
| D7 | Illumination | On/off plus DAC current control (bit-banged, §4.4). Kept minimal: set current in mA, clamped |
| D8 | Board population | Checked part by part on 2026-09-18. Fitted: R19 (EN pull-down), R20 (SCLK 24R), R23 (SDAT 24R). Not fitted: R13 and R33, the 10k header pull-downs on SDAT and SCLK, as the schematic's NoBom marking says; firmware substitutes the pads' internal pull-downs (§4.3). The first answer here was "everything is mounted" |
| D9 | 5 V rail | Teensy VUSB |
| D10 | Teensy pins | Claude's choice; nothing reserved |
3. Reference capture — verified ground truth¶
doc/digital.csv is a 433 MB Saleae export (2 channels, 500 MS/s, 0.383 s) of a working
NanoBerry ↔ Raspberry Pi link. It has been fully decoded (§9, M0 is therefore already
partly satisfied). Everything in this section is measured, not assumed, and takes
precedence over datasheet nominal values where they differ.
3.1 Link parameters¶
| Property | Measured value |
|---|---|
| SCLK frequency | 31.25 MHz (32.0 ns period, exact) |
| Sensor data launch | ~8 ns after each SCLK rising edge |
| Setup margin at the next rising edge | 24 ns (median), 20 ns (min) |
| Correct sample edge | Rising (SPI mode 0, CPOL=0 CPHA=0) |
| Frame readout duration | 40.30 ms (320 × 328 PP × 12 bits @ 31.25 MHz) |
| Frame-to-frame period | 43.1 ms → 23.2 fps (nominal 24 fps; the deficit is host clock gaps) |
| Row pitch | 3936 bits = 328 PP × 12 bits, exact, no exceptions |
| Start/stop bit validity | 100 % over 7 frames (2,240 rows, 716,800 pixels) |
The ~8 ns launch delay is a round trip: Teensy clock edge out → sensor → data back. It is the hard limit on SCLK. At 49.5 MHz (20.2 ns period) the setup window is ~12 ns; at 62.6 MHz (16.0 ns) only ~8 ns. This independently justifies D6.
On this bench the round trip is longer. Teensy pad delays and jumper wires add to it,
and at 49.5 MHz rising-edge sampling landed on the transition (79 % of words corrupt), while
falling-edge sampling was clean (2026-09-18, tools/link_quality.py). The firmware
therefore measures the sampling point at every start (§6.4) rather than fixing the edge.
3.2 Register sequence used by the working host¶
All writes are 24 bits: 1001 + 3-bit address + 16-bit data (MSB first) + 0, sent at full
SCLK speed with the MCU driving SDAT. Decoded by tools/decode_golden.py, which scans each
INTERFACE MODE window (the 648 PP before every SYNC run):
t=0 1 activation clock (SDAT low)
t=0.001037 CONFIG_0 = 0x009F CONFIG_1 = 0x009F (SEIM selected, idle still ON)
t=0.001..0.006 ~22 slow clocks (~217 us apart) — bit-banged by the Pi, SDAT low
t=0.0484 CONFIG_0 = 0x009F CONFIG_1 = 0x0065 (idle OFF → streaming starts)
then continuous 31.25 MHz clocking, and BOTH registers rewritten
in every frame's INTERFACE MODE window
Decoded field values:
| Register | Value | Fields |
|---|---|---|
| CONFIG_0 | 0x009F |
rows_in_reset=0 (→2 rows in reset, max exposure), vrst_pix=2.6 V (recommended), ramp_gain=0.99× (unity), offset_ramp=2.2 V (recommended), output_curr=9.6 mA (max drive) |
| CONFIG_0 | 0x7F9F |
as above but rows_in_reset=127 (→256 rows in reset) — the host's auto-exposure, written for the last two frames |
| CONFIG_1 (power-up) | 0x009F |
output_mode=SEIM, mclk_mode=2 (/2), vref=2.0 V, cvc_curr=3, idle_mode=1, high_speed=1 |
| CONFIG_1 (streaming) | 0x0065 |
output_mode=SEIM, mclk_mode=1 (default), vref=2.1 V (recommended), cvc_curr=1 (recommended), idle_mode=0, high_speed=1 → 31.1 MHz |
Notable points:
- The data line stays low for the first 1.28 M clocks (idle_mode=1), then streams as soon as the second write clears idle.
- Final settings
mclk_mode=1, high_speed=1→ nominal 31.1 MHz internal MCLK against a 31.25 MHz external SCLK (+0.5 %), confirming the required external/internal clock match. - Apart from
rows_in_reset, the host uses the datasheet's recommended values throughout, at unity analog gain and maximum output drive strength. - The host rewrites both registers every frame. This is how it does auto-exposure, and it means a steady-state implementation should keep driving the interface window rather than configuring once.
rows_in_reset0→127 (t_exp 105,616 → 22,304 PP, a 4.74× reduction) was written in the window before frame 6 but only takes effect on frame 7 — one frame of latency, as expected from rolling-shutter integration overlapping readout. Measured signal above black scales consistently with the §5.4 formula (black level ≈ 200 DN; to be measured properly in M6).
We will replicate this exact sequence for bring-up, since it is proven on this board, then re-tune (§6.7).
3.3 Defect observed in the reference host — an explicit non-goal¶
The Pi restarts its DMA every 65,532 bytes, producing 50–200 µs clock gaps mid-row. Each gap corrupts exactly three consecutive pixels:
row 129: 266 274 270 278 271 275 | 1020 84 0 | 272 274 277
row 128: 275 271 276 271 274 274 | 272 273 272 | 275 277 272 (same columns, clean)
This is the effect AN000611 warns about. It costs the reference ≥2 pixel triplets per frame. Our design must not do this (§6.5), and this is a measurable acceptance criterion (§9, M4).
3.4 Sensor variant confirmation¶
Bayer sub-lattice means on a smooth image region: 279.98 / 280.72 / 280.64 / 281.18 DN — spread < 0.5 %. No colour filter array. Mono confirmed (D2); no debayering anywhere in the pipeline.
3.5 First-frame behaviour¶
Frame 0 after idle-off is fully saturated (mean 1020 DN of 1023). Frames 1–6 are normal (mean ≈ 274, temporal σ = 2.74 DN). The datasheet's warning to discard the first frame after power-on / idle-deactivation is confirmed and must be implemented.
4. Hardware¶
4.1 Signal path¶
Onboard sensor S1 (NanEyeC 2×2 SGA) pads: A1 VDDA, A2 VSS, B1 SCLK/DATA-,
B2 SDAT/DATA+. From the schematic:
Teensy pin 27 ──────────────── J2.23 ──[R20 24R]── S1.B1 (SCLK, host→sensor)
Teensy pin 26 ┐ C11 15pF
Teensy pin 1 ┴─── tie at J2 ── J2.19 ──[R23 24R]── S1.B2 (SDAT, bidirectional)
C13 15pF
Teensy pin 2 ──────────────── J2.33 ── R19 10k pd ── TPS71701 EN → VCC_SENSOR 3.3 V
Teensy VUSB ──────────────── J2.2 / J2.4 (5vs)
Teensy GND ──────────────── J2.6, 14, 20, 25 (GND)
Optional illumination (§4.4):
Teensy pin 3 ──── J2.31 LED_VCC_ON_1 (enables LT3473 boost)
Teensy pin 4 ──── J2.36 LED_DAC_CS_N ┐ LTC2630 12-bit DAC, bit-banged
Teensy pin 5 ──── J2.38 LED_DAC_SDI │ (sets LT3092 LED current)
Teensy pin 6 ──── J2.40 LED_DAC_SCK ┘
Teensy GND ──── J2.9 GNDL (separate LED ground — only if LEDs used)
Wiring rules: - SCLK and SDAT leads as short as possible, each with its own adjacent ground return. - Do not connect anything to J1 (FPC) or P1 (6-pin): they sit on the same D+/D- nets as the onboard sensor. A second camera there would contend on the bus. - Teensy 4.1 I/O is 3.3 V and not 5 V tolerant; the sensor is 3.3 V. Direct connection is correct. Sensor V_IH,min = VDDA − 0.3 V = 3.0 V, satisfied by Teensy's 3.3 V push-pull.
4.2 Power¶
5vs from Teensy VUSB → TPS71701 LDO → VCC_SENSOR 3.3 V, gated by Naneye_EN
(10 k pulldown, so the sensor is off until the Teensy drives pin 2 high — this is the
designed power-on reset control and the recovery mechanism of last resort).
Sensor power is negligible (9.7 mW in SEIM). LED draw is bounded by the DAC ceiling (§4.4).
The rail discharges slowly. Measured after EN goes low: still 0.5 V after 143 ms,
below 0.1 V only after ~630 ms — nothing on the NanoBerry actively discharges it. A power
cycle shorter than that may not give the sensor a clean power-on reset, and the start-up
sequence (§6.4) depends on one: starts after a 0.4 s off-time failed. seim::power(true)
therefore enforces at least 1 s off since the last power-down, however soon it is
called, and every START power-cycles the sensor first.
4.3 Half-duplex direction control¶
SDAT is driven by the Teensy during INTERFACE MODE and by the sensor at all other times. The datasheet is explicit that the host must tristate before the sensor starts transmitting, and recommends driving the bus through the interface window to avoid EMI pickup on a floating line.
Two refinements, both from reading the datasheet against the board rather than from hardware:
- The host drives 647 of the 648 PP, not all of them. DS000503 §6.4.3 says the sensor itself transmits an end-of-interface word (0x015 in SEIM) in the last PP, which is also why register writes are forbidden there. AN000611's recipe drives all 648. The reference capture cannot settle it, because the host's GPIO would out-drive the sensor's current-limited output either way. Releasing costs nothing if the sensor is silent and avoids a fight every frame if not; the firmware receives that PP and reports it, so M2 settles the question.
- R13 and R33 are not fitted (D8), and the SPI pads are configured with no pull, so both nets would float whenever undriven. The firmware enables the pads' internal 100k pull-downs on SCLK and both SDAT pins. SCLK matters most: a floating clock while the sensor is powered could inject a spurious edge and slip word alignment.
Primary scheme (deterministic): two Teensy pins tied together at the header —
pin 26 (LPSPI3_SDO) and pin 1 (LPSPI3_SDI). Direction is one IOMUXC write:
| Phase | Pin 26 mux | Pin 1 |
|---|---|---|
| INTERFACE MODE | LPSPI3_SDO (drives) |
reads (harmless) |
| SYNC / DELAY / READOUT | GPIO input, hi-Z | reads sensor data |
Alternative to evaluate later: true 3-wire half-duplex on pin 26 alone via
LPSPIx_CFGR1[PINCFG]=10b (SOUT used for both input and output), saving a wire and ~5 pF
of pad capacitance. Deferred because the two-pin scheme has no peripheral-behaviour
unknowns. Note the Raspberry Pi reference does exactly this — it drives SDAT from
SPI0 MOSI (header pin 19) with MISO (pin 21) unconnected — so it is known to work
electrically.
4.4 Illumination¶
The NanoBerry has VIS (D3/D4, DURIS S2) and NIR (D1/D2, SFH 4053) LED strings in
parallel between +VCC_LED and LED_CATHODE, fed by an LT3473 boost and sunk by an
LT3092 programmable current source. The set point comes from an LTC2630 12-bit DAC:
LTC2630 VOUT ──[R9 1k]──┬── LT3092 SET LED_CATHODE ──[R7 12R]── LT3092 IN
└── C10 1nF LT3092 OUT
│
[R11 56R]
│
GNDL
The LT3092 servos its OUT pin to the SET voltage, and R9 only carries the internal 10 µA
bias, so V_SET ≈ V_DAC and:
With the -LZ12 part (2.5 V full scale, 12-bit): 0 → 44.6 mA, 10.9 µA per LSB.
The Z suffix means power-on reset to zero scale, which resolves R11 — LED_VCC_ON
alone yields ≈ 0.18 mA (the 10 µA bias term), i.e. no useful light. The DAC must be
programmed, which is why D7 now includes it.
Implementation (deliberately minimal): the LTC2630 is a write-only 24-bit SPI device with no readback and no timing constraints that matter at these speeds, so it is bit-banged on 3 GPIOs — no second SPI peripheral, no contention with the camera bus.
CS low; shift 24 bits MSB-first (SDI set, then SCK high, then SCK low); CS high to load
byte 0 : 0x30 command "write and update, power up"
byte 1 : code >> 4 12-bit code left-justified in 16 bits
byte 2 : (code & 0xF) << 4
Firmware exposes this as a current in mA (converted via the formula above) and clamps to a
configurable ceiling, defaulting to 20 mA so a mistyped command cannot dump 45 mA into the
LEDs or the USB rail. Power-down (command 0x40) is used for LED 0, alongside dropping
LED_VCC_ON.
To verify on first contact with hardware: R16 and R17 are 0 Ω jumpers selecting the NIR and VIS strings respectively. If both are fitted (likely, per D8), the two strings sit in parallel across one current sink and the split between them is set by their forward voltages, not by us — so "45 mA" is a total, unevenly shared. Normally only one would be fitted. Confirm visually and note which; it changes nothing in the firmware but matters for interpreting radiometry.
4.5 Colour filter array, and where colour is processed¶
A colour NanEyeC is indistinguishable from a mono one on the link: same framing, same word
size, no identifier. So which sensor is fitted is configuration, not measurement. CFA sets
it, EEPROM remembers it, and bits 4-6 of every frame header's flags carry it, which makes
each frame and each recording self-describing.
Pattern, from the datasheet (6.3.1): the first pixel read out, (1,1), is the bottom left one and has a blue filter, so the array as received starts BGGR. Confirmed on the bench as far as a frame can confirm it — the two green sites are the (0,1)/(1,0) diagonal, by sub-lattice correlation and by their response to the board's white LED. Red against blue is the datasheet's word alone: no single frame distinguishes them, and the white LED lifts both equally.
The ISP (host/naneye/isp.py) is black level, white balance, bilinear demosaic, colour
matrix and gamma, in that order, and it is built for speed rather than fidelity: 2.3 ms
per 320x320 frame against the 28 ms a 35 fps frame allows. White balance is applied on the
mosaic, where there is a quarter of the data; the demosaic kernels are separable; gamma is a
lookup table. The colour matrix is uncalibrated and defaults to identity.
5. Sensor interface — protocol summary¶
5.1 SEIM basics¶
- Host drives SCLK. Sensor shifts out one bit per rising edge on SDAT.
- Everything is in 12-bit pixel periods (PP):
start(1) + 10-bit data + stop(0). - Pixel word: start = 1, stop = 0, payload = 10-bit pixel, MSB first.
- Training word
0x555=010101010101(SYNC, DELAY, start-of-row). - Training word
0xAAA=101010101010(INITIAL PRE-SYNC, and the first row of the first frame after power-on only). - End of frame
0x000, sent 8×. - No chip select. The sensor cannot share the bus.
5.2 Frame structure¶
| Phase | Duration (PP) | Direction |
|---|---|---|
| INTERFACE MODE | 648 | host drives SDAT |
| SYNC MODE | 2 × 328 = 656 | sensor sends 0x555 |
| DELAY MODE | (16·rows_delay + 2) × 328, default 656 | sensor sends 0x555 |
| READOUT | 320 × (8 × 0x555 + 320 pixels) = 104,960 |
sensor |
| EOF | 8 × 0x000 |
sensor |
| Total (default delay) | 106,928 PP |
Row boundaries are found by the ...0101 → 11 transition (last training bit 1 followed
by the pixel start bit 1), which is how the reference capture was decoded: it yielded a row
pitch of exactly 3936 bits with zero exceptions.
5.3 Clock plan¶
The external SCLK must closely match the sensor's internal MCLK or dynamic range is lost (SCLK too fast → ADC clips low, reducing full-scale; too slow → raised black level).
LPSPI root clock: CCM_CBCMR[LPSPI_CLK_SEL]=11b (PLL2_PFD2, 396 MHz), LPSPI_PODF=3
(÷4) → 99 MHz. Then SCK = 99 / (SCKDIV + 2):
| SCKDIV | SCLK | Sensor mode (high_speed=0) |
Nominal MCLK | Error | PP rate | Frame time | fps | 8-bit MB/s |
|---|---|---|---|---|---|---|---|---|
| 6 | 12.375 MHz | mclk_mode=2 (÷2) | 12.3 MHz | +0.6 % | 1.031 MHz | 103.7 ms | 9.6 | 0.99 |
| 2 | 24.75 MHz | mclk_mode=1 (default) | 24.7 MHz | +0.2 % | 2.063 MHz | 51.8 ms | 19.3 | 1.98 |
| 0 | 49.5 MHz | mclk_mode=0 (2×) | 49.1 MHz | +0.8 % | 4.125 MHz | 25.9 ms | 38.6 | 3.95 |
All three land within 1 % of nominal — better than the reference host's +0.5 %, and better than the 6–7 % error a 132 MHz root clock would give. The resulting 9.6 / 19.3 / 38.6 fps match the datasheet's 9 / 19 / 38 fps exactly.
high_speed=1 modes (15.7 / 31.1 / 62.6 MHz) have no clean divisor from available PLLs
(best: 480/4 → 60.0 MHz, −4 % against 62.6 MHz) and exceed the round-trip timing budget of
§3.1. They are out of scope, with one exception: 31.25 MHz is reachable if we ever want to
reproduce the reference capture bit-for-bit (root 528/4 = 132 MHz, SCKDIV=2 → 33.0 MHz is
+6 %, so an exact match would need a dedicated PLL setup — deferred).
5.4 Exposure and gain¶
t_exp = t_rows_btw_frame + t_rows_matrix − t_rows_in_reset − t_rows_in_readout [PP]
t_rows_btw_frame = 648 + 656 + (16·rows_delay[4:0] + 2)·328
t_rows_matrix = 320·328 + 8 = 104,968
t_rows_in_reset = (2·rows_in_reset[7:0] + 2)·328
t_rows_in_readout= 656
rows_in_reset— fine exposure control, no frame-rate cost. Range ≈ 0.5 ms (max reset) to ~full frame (reference used 1 → near-max exposure).rows_delay— coarse exposure extension, reduces frame rate; up to 261 ms at 12.3 MHz.- Gain:
ramp_gain(0.79 / 0.99 / 1.32 / 1.97) ×cds_gain(1.3 or 2.0).
Firmware computes t_exp from the formula and reports it in every frame header so
recordings are self-describing. A register write takes effect one frame later (§3.2), so
the header must report the settings that were actually in force for the frame it describes,
not the most recently written ones.
5.5 Register access¶
24 bits: 1001 + 00a + 16 data bits (MSB first) + 0. Address 0 = CONFIG_0,
1 = CONFIG_1. Captured on SCLK rising edges; SDAT should change on falling edges. Rules:
never send config in the first clock pulse after power-up (send ≥1 activation clock first),
and never in the last PP of INTERFACE MODE.
6. Firmware architecture¶
Target: Teensy 4.1 (i.MX RT1062, 600 MHz). Framework: Arduino/Teensyduino via PlatformIO
(teensy41), with direct register access for LPSPI/DMA/IOMUXC.
6.1 Peripheral choice¶
LPSPI3 (Teensy "SPI1": SCK 27, SDO 26, SDI 1), master, mode 0, with eDMA.
Rationale: AN000611 explicitly recommends SPI+DMA, and LPSPI on the i.MX RT1062 supports
arbitrary frame sizes (TCR[FRAMESZ], 8–4096 bits) — including 12, which is exactly one
pixel period. LPSPI3 is chosen over the default LPSPI4 to keep the 60 MHz-class clock off
pin 13 (onboard LED capacitance). Pin 1 and 26 are adjacent-ish on the header edge and
pin 27 is next to 26, keeping the wire bundle short.
6.2 Frame size options¶
| Option | FRAMESZ |
RX word → memory | Unpack | Risk |
|---|---|---|---|---|
| A (primary) | 12 bits | 1 PP → one uint16 |
(w>>1) & 0x3FF, plus start/stop validation |
LPSPI may insert idle SCK cycles between frames |
| B (fallback) | 3936 bits = 328 PP = one row | 123 × uint32 |
8 pixels per 3 words (96 bits = 8 PP exactly, fixed repeating pattern) | Gaps possible only at row boundaries, where they are harmless |
Option A makes unpacking trivial and costs 2 bytes per PP (214 KB/frame of DMA writes,
8.3 MB/s at 38.6 fps — comfortable). Decision criterion: a Saleae capture must show
no SCK gap > 1 PP within a row; if option A cannot meet that with TCR[CONT]
continuation, switch to option B. This is settled in M4.
6.3 Memory plan¶
Row-chunked ring buffer, not whole-frame buffering:
- RX ring: 16 rows × 328 PP × 2 B = 10.5 KB in
DMAMEM(OCRAM). DMAMEMis cached. OCRAM sits behind the Cortex-M7's write-back D-cache, and the eDMA writes RAM behind the cache's back. Every row buffer is invalidated (arm_dcache_delete) before its transfer is armed and again when it completes, and each buffer is padded to whole 32-byte cache lines. Without this the sensor streamed perfect frames on the wire — seen on the logic analyser — while the firmware read zeros or stale words: the bug that hid first light for most of a day (2026-09-18).- Decoded frame buffer: 320 × 320 × 1 B (8-bit) or packed 10-bit (128 KB), double-buffered.
- Total well under the 512 KB OCRAM + 512 KB RAM1 budget. PSRAM is not required.
Per-row time budget at 24.75 MHz is 159 µs; unpacking 320 pixels is a few hundred cycles — roughly two orders of magnitude of headroom.
6.4 Capture state machine¶
POWER_CYCLE → EN low (if on), ≥ 1 s off (§4.2), EN high, 5 ms
INIT_CONFIG → 1 activation clock; CONFIG_0; CONFIG_1 (idle=1, SEIM), bit-banged at ~1 MHz
(replicates §3.2 step 1)
RUN_IN → one frame's worth of clocks (1,279,366) with SDAT driven low — as the
reference host does
START → CONFIG_0; CONFIG_1 with idle=0 and rows_delay=0 at SCLK rate, zeros to the
end of a 648 PP window, release SDAT (§3.2 step 2)
CALIBRATE → 1024 bits of the alternating training at each of 4 sampling points (rising or
falling edge, with or without CFGR1[SAMPLE]); keep the one with fewest breaks
PRESYNC → first row received: must be mostly training ("is there a sensor?")
ROW_LOCK → scan the bits for row 1's 8×0x555 → "11" break; clock the exact number of
bits to the next row boundary; that row must show 8 training words, and
the next 8 rows of real pixels are checked and their bad words reported
FRAME_0 → rest of the first frame + EOF clocked and DISCARDED (saturated — §3.5)
STREAM → per frame: 648 PP interface window (drive SDAT: 2 register writes, then
zeros, sensor owns the last PP), tristate, sync + delay, 320 rows, 8 PP EOF
FAULT → capture failure: power-cycle and START again
Why this and not the datasheet's sequence. AN000611's single write (activation clock,
CONFIG_1 idle-off, 10 alignment clocks) started only sometimes on this board, and when it
did, its fixed phase count put every row transfer 2 clocks late, so every row failed. The
reference host's sequence starts every time. And the phase after it is found, not
counted: how many SCLK clocks the first frame's training lasts varies from start to start
(12,074–12,084 bits measured, not whole pixel periods), because parts of the sensor run on
its own oscillator. From the first locked row on, everything is SCLK-counted and
deterministic — every row of every later frame lands exactly (tools/check_alignment.py).
Row 0 of the first frame is trained with 0xAAA, which runs straight on into its first
start bit, so the lock is taken on row 1.
6.5 Continuous clocking requirement¶
The sensor's ADC counter runs off its own oscillator, so any host clock gap desynchronises readout from conversion and corrupts pixels (§3.3, measured: 3 pixels per gap). Therefore:
- The TX DMA must keep the LPSPI TX FIFO fed so SCK never stalls mid-row.
- DMA chunk boundaries must align to row ends (AN000611's recommendation).
- Firmware counts and reports any detected discontinuity rather than hiding it.
6.6 Sync loss detection¶
Every row is validated: 8 training words must read 0x555, all 320 pixel words must have
start = 1 and stop = 0. Rows failing validation increment a counter in the frame header; a
threshold triggers re-sync. Full validation is cheap and, per §3.1, is expected to pass
100 % of the time — so any failure is real information.
Concealment. SEIM carries no redundancy beyond each word's start and stop bits, so
errors in the ten data bits cannot be corrected or even detected; errors that break the
framing can be detected, and then the value is known to be wrong. Such a pixel is replaced
by the mean of its nearest intact neighbours on the row and counted in the header
(pixels_concealed, flag CONCEALED). Rows
with more than 32 broken words are not concealed: that is a lost row, not a damaged one.
SYNC_LOST now means a row's training words failed (the phase is in doubt), not that a
pixel was damaged. A fault-injection hook (since removed) corrupted n words per frame to test all of this on a clean
link: with 500 per frame, the concealed image differs from a clean one by 2.1 DN on
average against 1.8 DN of frame-to-frame noise, and 3 pixels stay more than 100 DN off,
against 416 without concealment.
6.6b Watchdog¶
RTWDOG (WDOG3), 2 s timeout, clocked from the 32 kHz LPO so it survives any PLL mistake.
Fed from loop(), per row in LISTEN, and while waiting out a power cycle; the longest
legitimate blocking operation is ~0.7 s. The reset cause is latched at boot and reported by
ID (last reset: WATCHDOG). Measured with a command that hung on purpose (since
removed): the port drops after 1.97 s and is back at 2.25 s. A flash also usually reads as a watchdog reset, because the
old image parks in the bootloader hand-off with the watchdog still running — harmless.
6.7 Tuning after bring-up¶
The reference's static settings (§3.2) are a sound starting point: recommended vrst_pix,
offset_ramp and vref, unity ramp_gain, max output drive. Two changes to consider:
rows_in_reset=0is maximum exposure and will clip in normal room light. The host worked around this with per-frame auto-exposure; we exposerows_in_resetas a command (§7) and leave any AE loop to the PC side, where the measurement application can control it.output_curr=9.6 mA(max drive) is the right default for fast edges on jumper wires, but it is also the noisiest choice for the analog front end. Once the link is reliable, sweep it down and check whether temporal noise improves without hurting setup margin.
Defaults will be chosen from measured black level and saturation (M6).
7. USB protocol¶
Single USB CDC serial port (one COM port, no driver questions on Windows 11).
Device to host is always framed, images and text alike, so log output can never be
mistaken for image data. Host to device is plain ASCII lines, which keeps the port
usable from a plain terminal. The asymmetry is deliberate: framing matters for the
high-rate direction that has to be parsed by machine, and costs nothing in the direction a
human types into. The command packet type is reserved in case that changes.
offset size field
0 4 magic "NANE"
4 1 version = 1
5 1 type 0=image, 1=command, 2=response, 3=log
6 2 header_len
8 4 payload_len
12 4 frame_counter (monotonic, gaps = drops)
16 4 timestamp_us (ARM cycle counter based)
20 2 width = 320
22 2 height = 320
24 1 format 1=10-bit packed (5 B per 4 px), 2=raw 12-bit PP
(0 was 8-bit, retired; the host still decodes it)
25 1 flags bit0 sync_lost, bit1 clock_gap, bit2 first_frame_discarded,
bit3 concealed, bits4-6 colour filter array (0 mono, 1 BGGR,
2 GBRG, 3 GRBG, 4 RGGB; §4.5)
26 2 rows_failed_validation
28 4 sclk_hz
32 4 exposure_pp
36 2 cfg0
38 2 cfg1
40 4 frames_dropped_total
44 4 reserved
48 4 crc32 (header + payload)
52 payload
ASCII commands (type 1), one per line, for terminal and script use alike:
ID → firmware version, chip UID
POWER 0|1 → Naneye_EN
CLK 12375000|24750000 → SCLK (also sets matching mclk_mode)
START / STOP
DEPTH 10|12 → 10-bit pixels, or raw pixel periods (diagnostic)
EXP <rows_in_reset> [rows_delay]
REG <0|1> <0xHHHH> → raw register write
CFA MONO|BGGR|... → which sensor is fitted; kept in EEPROM, sent in every header
LED 0|1 → LED_VCC_ON + DAC power-up/down
LEDI <mA> → LED current, 0..20 mA (ceiling raised with LEDMAX)
STATS → counters
SELFTEST → run the unpack over an embedded golden buffer (§8.3)
Throughput: worst case in scope is 4.94 MB/s (10-bit at 49.5 MHz). Teensy 4.1 USB HS CDC sustains well above that. Back-pressure is handled by dropping whole frames and counting them, never by emitting a partial frame.
8. Host software (Windows)¶
host/naneye/
transport.py— framing, CRC, reconnect; yields(header, payload).decode.py— 12-bit word → pixel unpack, row/frame framing, validation. Also decodes Saleae exports, so the same code validates both the live link and logic captures.viewer.py— live view (OpenCV), histogram, saturation/black-level readout, FPS and drop counters.record.py— lossless capture to disk with sidecar metadata (exposure, gain, SCLK, timestamps), 10-bit preserved.analyze.py— dark frame, temporal noise, FPN, black level and full-scale vs SCLK/MCLK mismatch.saleae.py— Logic 2 automation: capture N ms on 2 channels at 500 MS/s, export, decode, compare against what the Teensy reported for the same frames.
Dependencies: numpy, pillow, opencv-python, pyserial, logic2-automation.
Python 3.13 and Saleae Logic 2 are already installed; numpy and pillow are in place and
were used to produce the §3 results.
9. Milestones and acceptance criteria¶
| M | Goal | Acceptance criterion |
|---|---|---|
| M0 | Tooling + golden decode | PlatformIO installed; blink builds and flashes from CLI; Saleae automation API reachable and scripted capture works. Golden decode of digital.csv reproduces §3 numbers. (decode half already done) |
| M1 | Clock and configure | Saleae capture of the Teensy's own output contains the activation clock plus CONFIG_0=0x009F / CONFIG_1=0x009F then 0x0065, bit-identical to §3.2, at 12.375 MHz, and decodes cleanly with tools/decode_golden.py |
| M2 | First light | Teensy reports ≥ 8 consecutive 0x555 words and achieves word alignment; SYNC_LOCK reached |
| M3 | First frame | One frame over USB → PNG on the PC. All 102,400 pixels have start=1/stop=0. A simultaneous Saleae capture, decoded independently by decode.py, is pixel-identical to what the Teensy sent |
| M4 | Continuous streaming | 60 s at 12.375 MHz then 24.75 MHz: zero dropped frames, zero failed rows, and a Saleae capture showing no SCK gap > 1 PP within a row (i.e. none of the §3.3 pixel corruption) |
| M5 | Control | Exposure, gain, bit depth and SCLK settable at runtime. Measured t_exp matches the §5.4 formula within 1 % (verified by a light-level sweep); black level responds as predicted |
| M5b | Illumination | LED/LEDI work; measured LED current matches V_DAC / 56 Ω; image brightness scales with commanded current. Done 2026-09-23: the DAC writes were read off the wire (0x30 + code = I / 44.6 mA × 4095, rail raised before the first code and dropped after the power-down) and image brightness is linear at 1.98 DN/mA from 0 to 20 mA. The current itself is still inferred from the code, not metered |
| M6 | Measurement readiness | Lossless recording with metadata; dark-frame/temporal-noise/FPN report; black level and full-scale characterised vs SCLK-to-MCLK mismatch. Temporal noise should land near the reference's 2.74 DN |
| M7 | Stretch | 49.5 MHz / 38.6 fps sustained; single-pin half-duplex (§4.3). 49.5 MHz reached 2026-09-18: 35.3 fps sustained for 60 s, 0 failed rows (the 38.6 fps ceiling assumes no gaps between row transfers). Single-pin not attempted |
10. Repository layout¶
spec.md
doc/ datasheets, schematic, digital.csv (golden capture)
firmware/ platformio.ini, src/ (main, seim driver, usb transport)
host/naneye/ transport, decode, viewer, record, analyze, saleae
tools/ golden-capture decode, bring-up scripts
tests/ golden decode regression, protocol round-trip
11. Risks and open items¶
| # | Risk | Mitigation |
|---|---|---|
| R1 | Jumper-wire signal integrity ≥ 25 MHz (24 R series + ~30 pF of pads, caps and connector stubs) | Resolved 2026-09-18: all three rates run clean on jumper wires. 49.5 MHz first looked like a slew-rate limit; it was the sampling point (§3.1), now calibrated at every start. 60 s at 49.5 MHz: 0 failed rows, 0 concealed pixels |
| R2 | ~8 ns measured round-trip delay caps SCLK (longer on this bench) | Stay ≤ 49.5 MHz; treat 62.6 MHz as out of scope. The sampling-point calibration absorbs the delay up to 49.5 MHz |
| R3 | LPSPI may insert idle SCK cycles between 12-bit frames → §3.3 pixel corruption | TCR[CONT]; fallback to one-row FRAMESZ (option B, §6.2); verified by Saleae in M4 |
| R4 | Sensor has no chip select and cannot share the bus | LPSPI3 dedicated to the camera; LED DAC bit-banged on GPIO |
| R5 | Changing the LPSPI root clock affects all LPSPI instances | Only LPSPI3 is used; no other SPI peripheral in the design |
| R6 | Bus contention if the Teensy fails to tristate SDAT in time | Datasheet says this degrades integrity but is not destructive; direction switch is a single register write; the 24 R series resistors limit current |
| R7 | USB back-pressure causing partial frames | Drop whole frames only, and count them in the header |
| R8 | First frame after idle-off is saturated | Confirmed in §3.5; always discarded, flagged in header bit 2 |
| R9 | Teensy VUSB current budget with LEDs on | Bounded by design: LED current is DAC-limited to 44.6 mA absolute and 20 mA by default (§4.4), so the USB budget is safe; sensor draw is negligible |
| R10 | 433 MB golden CSV is slow to parse (~47 s) | Cache the extracted bitstream as .npy; use Saleae binary export for new captures |
| R11 | Resolved: the LTC2630 powers up at zero scale, so LED_VCC_ON alone gives ≈ 0.18 mA — no useful light |
DAC control brought into scope (D7, §4.4), bit-banged on 3 GPIOs |
| R14 | If R16 and R17 are both fitted, the VIS and NIR strings share one current sink and the split between them is set by their forward voltages | Firmware unaffected; confirm the jumpers on first hardware contact and record which strings are active (§4.4) |
| R12 | Open: exact high_speed clock matching is unavailable from the PLL dividers |
Out of scope (§5.3); non-HS modes match within 1 % |
| R13 | Teensy pins 26/1 tied together adds pad capacitance to SDAT | Acceptable at ≤ 24.75 MHz; single-pin half-duplex (§4.3) is the fix if it bites at 49.5 MHz |
| R15 | Resolved: row DMA into cached OCRAM read stale data | D-cache invalidation around every row transfer (§6.3) |
| R16 | Resolved: first-frame phase not deterministic in SCLK clocks; datasheet start unreliable | Reference start sequence plus a bit-level row lock (§6.4); ≥ 1 s power-off (§4.2) |
12. Out of scope¶
- UVC / standard webcam enumeration (D1; may be revisited after M6).
- LVDS mode — needs a comparator front-end and > 750 MHz sampling; impossible on a Teensy.
- On-device colour processing. D2 was reversed on 2026-09-24: a colour NanEyeC was
fitted, so the mosaic is now real. The firmware still only records which mosaic
(
CFA, §4.5) and passes pixels through untouched; demosaicing and the rest of the ISP live on the PC, where changing them costs a restart of a Python process rather than a reflash, and where the raw mosaic stays available for measurement. - Multi-camera synchronisation.
- On-Teensy image processing beyond unpacking.
13. References¶
doc/NanEyeC_DS000503_3-00.pdf— datasheet v3-00. §3 pins, §5 electrical, §6.3.2.2 SEIM sequence, §6.4.2 SEIM encoding, §6.4.3 register access, §6.5 exposure, §7 registers.doc/NanEyeC_AN000611_2-00.pdf— AN000611 "NanEyeC MCU Interface", SPI+DMA reference implementation, DMA-gap warning, sampling-edge guidance.doc/nanoberry_board.pdf— NanoBerry schematic rev 1.0. Sheet 2 sensor + RPi header, sheet 3 LED current source, sheet 4 sensor power.doc/digital.csv— golden Saleae capture of a working link; decoded in §3.