
Bsl drv v1
jackw01/scanlight
A better light source for scanning color negative film

CVBS2HDMI: composite NTSC to HDMI on a Tang Nano 20K, decoded in the FPGA (AD9280 ADC, open-source toolchain)
TangADC is an open-source printed circuit board by otanl, from the otanl/CVBS2HDMI repository on GitHub, published under the MIT licence. It is a 2-layer board of 65.1 × 36.2 mm with 53 parts on 59 nets, routed with 478 tracks and 44 vias.
It is built around the AD9280ARS and H5VL10B. Its parts include 19 resistors, 13 capacitors, 5 connectors, 2 ICs and 1 diode. It belongs with the fPGA and programmable logic and interfaces and adapters boards of this library.
English | 日本語
v0.1
Status. What has been tested is the firmware together with the board as built, v0.1 (tag
v0.1). The board design now inhardware/is v0.2, which has not been manufactured or tested. If you make a board, either accept that, or use the v0.1 design from the tag.
CVBS2HDMI digitises composite NTSC video (CVBS) with an 8-bit ADC (AD9280), decodes it in colour on the Tang Nano 20K's FPGA, and sends it to the on-board HDMI connector as 640×480p. There is no frame buffer. Everything is built with an open-source toolchain (Yosys / nextpnr-himbaechel / Apicula / openFPGALoader / Icarus Verilog); development was done on macOS.
make test)Not supported: PAL, audio, black-level correction for 7.5 IRE setup.
tangADC): an AD9280ARS and its input circuit, taking the Tang Nano 20K in
a DIP-40 socket
The carrier board's KiCad 10 project is in hardware/: schematic, PCB, and the JLCPCB
production files (BOM, placement, gerbers). They are at v0.2, the next revision, which has not
been manufactured; the board as built and measured is tag v0.1. docs/pcb-respin.md §4.6 and
§4.7 list the changes. Every part JLCPCB places is from its Basic or Preferred library.
Pin assignment (the single source is constraints/tangnano20k_adc_probe.cst):
| Signal | FPGA pin | Signal | FPGA pin | |
|---|---|---|---|---|
adc_clk | 73 | adc_d[4] | 29 | |
adc_clamp | 74 | adc_d[5] | 30 | |
adc_d[0] | 27 | adc_d[6] | 75 | |
adc_d[1] | 28 | adc_d[7] | 77 | |
adc_d[2] | 72 | adc_otr | 71 | |
adc_d[3] | 76 | I²C SCL / SDA | 48 / 49 |
The data bus is not in physical pin order. The board's revision history, the pins that must not
be used, and the fixes planned for the next revision are in docs/pcb-respin.md (Japanese).
Caution: pin 3 of the Grove connectors is 5 V. A device that pulls I²C up to 5 V puts 5 V on the FPGA's pins. The 8Angle pulls up to its own 3.3 V and is safe.
Caution: ADC bits 0, 1, 4 and 5 (pins 27..30) share an I/O bank with HDMI. Stock Apicula
packs those inputs as LVDS receivers, so every build must go through scripts/gowin_pack_io.py
(the Makefile does).
./scripts/setup-macos.sh
make check-toolsThis installs the OSS CAD Suite into .tools/oss-cad-suite; your shell's PATH and macOS
settings are left alone.
make ntsc # build
make ntsc-program # load into SRAM (lost at power-off)
make ntsc-flash # write to flash (kept across power cycles)make program loads not the HDMI decoder but the ADC probe used to bring up a board (see below).
An input that cannot be synced (a broken signal, a wrong standard, an unplugged cable) does not black out the screen: the signal is shown as it arrives, as noise, and a good signal is locked again as soon as it returns. 240p (262 lines every field, 60.05 Hz), as most game consoles send it, is shown by measuring the field length and switching the output frame between 525 and 524 lines.
Connect an M5Stack Unit 8Angle to Grove J4 or J5 and its eight knobs and switch are read
continuously over I²C (address 0x43, 100 kHz). Near the bottom of the S1 diagnostic view, each
knob is drawn as a cyan bar, the switch as a green block, and a red block shows while the unit
is not answering. make sim-angle8 checks the reader against a model of the unit.
The eight knobs control glitch effects. They do not post-process the image; each one breaks a stage of the decoder itself. A knob turned fully left is off, and with all knobs left the picture is the clean one. The effects are enabled with the switch in the green position (as the diagnostic view shows it).
Knobs 1..4 break where lines go and their shape, 5..8 break colour; each group is in the order the signal passes through the decoder.
| Knob | What breaks |
|---|---|
| 1 | Sync slicing: dark parts of the picture read as sync, so the picture decides where lines start |
| 2 | Horizontal hold: real syncs are ignored and the line timing free-runs off frequency (sideways drift, diagonal tearing) |
| 3 | Line stretch: lines at random are stretched to twice the width or squeezed to half, leaving an older line behind |
| 4 | Line buffer: lines stop being updated, and old lines smear downwards |
| 5 | ADC bus faults: the faults this board really had, in order (pair misreads → stuck bits → crossed wiring) |
| 6 | Colour reference collapse: the burst is measured inside the picture and the oscillator drifts, so the hue jumps and rainbow stripes roll |
| 7 | Colour matrix overflow: the YUV→RGB gain is raised and the clipping removed, so bright and saturated parts fold into their complements (neon, solarised) |
| 8 | HDMI link failure: first the lanes skew, splitting red, green and blue sideways with a per-line shiver; past half way, blocks of a few lines lose a colour lane or misread it (inverted and so on). Worked out in the FPGA as the monitor would show it, so the link never drops |
make sim-video-glitch checks that each effect breaks the picture and that the picture is clean
again once the effect is removed. make sim-link checks, line by line, that the HDMI link
failure breaks the picture only in the ways it is allowed to.
A signal whose sync tip sits at blanking level (an early M5 setup, for example) needs a build with different window positions. It is not switched automatically.
make ntsc-legacy
make ntsc-legacy-programOn a source with a sync step, the default (LEGACY_TIMING=0) measured 0.43% dropped rows and
colour on 120/120 frames, against 47.8% and 0/120 for LEGACY_TIMING=1 (2026-09-24, same seed).
The version verified on 2026-09-26 (tag good-2026-09-26) is kept as a bitstream,
bitstreams/ntsc_good_2026-09-26.fs.gz, so it can be written back whatever happens to the
sources or the toolchain.
make restore-flash # write it to flash (kept across power cycles)
make restore-program # load it into SRAM only (to try it)
git checkout good-2026-09-26 # the sources as they weremake test # everything (a few minutes)
make sim-video # NTSC-J 75% bars, three fields, vertical interval included
make sim-zoom # the 1.5× zoom (240p and interlaced, with a negative control)
make sim-video-glitch # each glitch effect breaks the picture, and removing it restores it
make sim-link # the HDMI link failure effect
make sim-hdmi # TMDS encoding, line buffer races, start-up and video timingmake test includes negative controls: each bench also has to recognise a deliberately broken
setup as broken.
The decisions and measurements made during development are recorded in CLAUDE.md. When the
hardware behaves unexpectedly, search there first.
make sim-reference # every phase, ±100 ppm, half-wave burst, signal loss
make sim-tracking # colour phase from the first line after 24 lines without burst
make sim-video-weak # small sync amplitude
make sim-video-mono # monochrome input with no burst
make sim-video-late # recovery from a horizontal sync that arrives latePNG captures of the HDMI output can be scored with the Python standard library and FFmpeg:
python3 scripts/video_quality.py 'build/cap*.png' --rows 20:350 --start 24 --bar-width 80It reports, separately, rows in luma order, rows with a dark first bar, rows with colour, RGB saturation, and row-to-row colour variation. The grey staircase in the bottom quarter is left out; compare only captures of the same region and test pattern. The first capture after programming is affected by the link and the vertical position re-acquiring, so always capture twice and score the second, the same way for both sides of a comparison. Frames with no signal are counted too. Bars in luma order do not prove that hue or amplitude is right.
With no UART, ADC values can be measured from the waveform view on HDMI:
make ntsc-scope-program # starts in the full-range waveform and diagnostic view
# compare a phase: make ntsc-scope-program NTSC_SCOPE_PHASE=4
./scripts/live_capture.sh build/scope_new 120 8 --scope-mode 0 --phase 2
python3 scripts/scope_trace.py build/scope_new_012.png --trace-only --require-mode 0
# check the display path with a known ramp: make ntsc-scope-program NTSC_SCOPE_RAMP=2
make ntsc-program # back to the normal pictureTo look at a signal that has no usable sync, capture raw ADC data without waiting for sync:
make ntsc-scope-program NTSC_SCOPE_RAMP=3 NTSC_SCOPE_FREERUN=1
./scripts/live_capture.sh build/raw_free_new 12 3 --scope-mode 3 --phase 2
python3 scripts/scope_trace.py build/raw_free_new_012.png --trace-only --require-mode 3The normal capture waits for sync, and when none is found its buffer is never written, so zeros
that were never captured can be mistaken for measurements. NTSC_SCOPE_FREERUN=1 captures every
66 ms or so regardless of sync, and the build gets its own name. make sim-scope-freerun checks
that all 2048 points are captured and re-captured with no sync, and that the sync-waiting mode
does not capture.
The waveform is drawn at 640×480 with three samples a pixel, and values are recovered from the
height of the red and white trace. The diagnostic build's vertical axis shows all 256 codes; to
read the S1 view of the normal build, pass --legacy-scale (there, codes above about 150 are
hidden behind the bars at the top). Ambiguous columns are dropped. This is an approximate
measurement from the screen, not a dump of every sample. Rows 200..207 of the diagnostic view
carry the mode, the ADC phase and a frame counter. live_capture.sh always captures at least
twice and checks the mode, the phase and that the counter advances; each time it sets aside the
first 120 frames as warm-up and measures the requested number after them. It never filters out
bad frames from the measured run. Each attempt is kept in its own folder and existing images are
never overwritten (use a new prefix). On the normal picture only pixel changes are checked:
without the diagnostic counter, a still picture cannot be told from a frozen capture, nor the
loaded bitstream identified. Amplitudes of oscillating parts are not validated; use
--trace-only, and do not use these readings to set colour gain.
The waveform view shows one sample in three; this records 32768 consecutive raw ADC samples (about 20 lines) exactly, without the UART.
make ntsc-tape-program # record once, freeze, show as grey cells
python3 scripts/live_capture.py build/tape 12 8
python3 scripts/tape_decode.py build/tape.hex build/tape_0*.png # vote across 12 frames
python3 scripts/tape_trim.py build/tape.hex sim/my_tape.hex 18 # cut to an even number of lines
./scripts/tool iverilog -g2012 -s replay_tb -o build/replay_tb \
src/ntsc_capture.v src/adc_front.v src/sync_lpf.v src/burst_nco.v src/cordic_atan.v \
src/chroma_sincos.v sim/gowin_prim_sim.v sim/replay_tb.v
./scripts/tool vvp build/replay_tb +stim=sim/my_tape.hex +nsamp=<samples> +lines=400 +out=build/replay.txt
python3 scripts/replay_quality.py build/replay.txt --png build/replay.png # same scale as the board
python3 scripts/tape_reference.py sim/my_tape.hex # floating-point reference decodereplay_quality.py --frames 'build/cap_*.png' scores captures from the board on the same scale.
make sim-tape checks the recording display and its decoder end to end against a known memory
image.
With the M5 as the source, correctly decoded bars are not in luma order (the M5 itself sends
yellow < cyan, green < magenta, red < blue). To evaluate with the M5, compare replay_quality.py's
hue error and line-to-line variation with tape_reference.py's values instead of
video_quality.py's "correct rows".
A separate design for the first check on a new board: that the ADC's data is captured correctly.
Measured in step 1:
ADC ph=2 tog=FF min=96 max=255 thr=115 ln=1716 lmin=1715 lmax=1717 ok=15734 lns=15734 vs=60 otr=0 lk=1A raw waveform (taken with make autodump-program, saved as sim/ntsc_line_capture.hex):
| Quantity | Measured | NTSC | Error |
|---|---|---|---|
| Sync tip | code 98 (±1) | — | — |
| Sync width | 4.59 us | 4.70 us | -2.3% |
| Blanking | code 133.9 | — | — |
| Sync to blanking | 282 mV | 286 mV | -1.3% |
| Burst start | 5.19 us after the sync edge | 5.3 us | — |
| Burst length | 2.52 us / 9.02 cycles | 2.51 us / 9 cycles | +0.2% |
| Line period | 1716 samples | 1716.05 | — |
Counting exactly nine burst cycles shows that the sample clock and the subcarrier are in the right time relation, and an amplitude error of -1.3% shows that the gain is right.
A synthetic NTSC signal goes through a behavioural model of the AD9280 and the line period is measured.
make sim # positive: must measure ln=1716
make sim-badphase # negative: a wrong sampling phase must be reported as brokenThe negative case exists to show that ln=1716 is not a value that comes out whatever goes in.
make program # load into SRAM (lost at power-off)
make monitor # read the reportOne line a second at 115200 8N1:
ADC ph=2 tog=FF min=79 max=205 thr=94 ln=1716 lmin=1716 lmax=1717 ok=15720 lns=15734 vs=60 otr=0| Field | Meaning | Healthy value |
|---|---|---|
ph | sampling phase; 0..3 = 0 / 9.3 / 18.5 / 27.8 ns after the adc_clk rising edge | 2 |
tog | OR of the bits that changed during the window | FF |
min | lowest code → the sync tip level | about 70..90 |
max | highest code → peak white | about 190..215 |
thr | the sync slicing threshold actually used | min + a few tens |
ln | the latest line period (samples) | 1716 |
lmin / lmax | range of accepted line periods | 1716..1717 |
ok | lines whose period is within 1700..1732 | about lns |
lns | accepted lines | about 15734 |
vs | vertical syncs detected | about 60 |
otr | samples the AD9280 flagged as over range | 0 |
ln=1716 is the decisive one. One NTSC line (15734.264 Hz) sampled at 27 MHz is
27e6 / 15734.264 = 1716.05 samplesso a steady 1716 proves at once that the ADC clock, the data bus's bit order and the sampling phase are all right. No other single figure says that much.
ln does not appear, try all four.| LED | Meaning |
|---|---|
| 0 | heartbeat (the design is running) |
| 1 | PLL locked |
| 2 | line period in range |
| 3 | vertical sync detected |
| 4 | over range detected (input too large) |
| 5 | a data bit never changes (bad wiring and so on) |
The AD9280's clamp (CLAMP/CLAMPIN) is not used: on this board it does more harm than good. C2 at 1 µF is larger than the AD9280's clamp amplifier is designed for, so each pulse leaves a transient longer than a line, which spoils the next sync edge. Measured:
| Clamp | Result |
|---|---|
| off (default) | ok = lns = 15734, vs=60, ln=1716, perfect |
| gated on sync lock | about 72% good lines, lock oscillating at 1 Hz, vs erratic |
Without the clamp the input still biases itself inside the conversion range (sync tip at 98, white at about 225, 30 codes below 255). The decoder measures the porch and restores DC digitally. The sync tip is not the black level, so do not subtract it from the picture.
make clampgated-program brings the clamp back for experiments.
For suspecting the analog side. The RTL is the same; only a constraint or a parameter differs.
make pullup-program # weak pull-ups on the ADC inputs; min=max=255 means the ADC is not driving the bus
make clampon-program # CLAMP held on: AIN pinned to CLAMPIN, measurable with no video
make clampoff-program # CLAMP held off (for comparison)
make clkhigh-program # adc_clk held at 3.3 V → TP4 should read about 3.3 V
make clklow-program # adc_clk held at 0 V → TP4 should read about 0 V
make clkslow-program # adc_clk at about 1 Hz → TP4 swings 0 ↔ 3.3 V on a meter
make program # back to normalclk= at the end of the report shows which clock mode the loaded bitstream has.
The most effective technique of the bring-up. Build the same design three ways, with no pull, pull-up and pull-down, and compare the reported codes in binary. A ~50 kΩ internal pull cannot move a pin the ADC is really driving. A bit that follows the pull is an open joint; a bit that resists it is connected, one pin at a time.
make program && make monitor # no pull
make pullup-program && make monitor # pull-up
make pulldown-program && make monitor # pull-downThe trap: a test point measures the net, not the pin. TP4 shows a healthy clock even if the AD9280's pin 15 is lifted, and FB2 shows 3.3 V even if pin 2 is. The pull test gets round this electrically for every pin the FPGA can read.
If min/max stay at 0 with clampon, the cause is not the video signal but the ADC's
conversion, its reference or the clamp circuit. If the bus does not move with clkslow either,
the ADC is not responding to its clock.
Easy to miss: the AD9280's analog and digital supplies come from different pins of the Tang Nano.
Tang Nano DIP pad 19 → +3V3 → FB1 → +3V3A → U1 pin 28 AVDD
Tang Nano DIP pad 25 → FB2 → +3V3D → U1 pin 2 DRVDDSo a healthy TP2 (VREF) does not prove that DRVDD is there: VREF depends on AVDD alone. Without DRVDD the digital outputs are never driven and the bus is dead. On the v0.1 board FB1 and FB2 are leaded parts, easy to probe at both ends. (The next revision uses 0805 chip beads; probe TP5 and TP6 on the ADC side and the module's 3V3 pins on the other.)
Never put DRIVE= or BANK_VCCIO= on an LVDS25 constraint. Sipeed's .cst for Gowin EDA
carries DRIVE=3.5 BANK_VCCIO=3.3; brought into the open-source flow, it builds, meets timing,
and produces a link no sink locks to, with no warning. Apicula's own
examples/tangnano20k.cst uses only IO_TYPE=LVDS25 PULL_MODE=NONE.
The 20K's HDMI pins are true LVDS. The 9K's are emulated, so ELVDS_OBUF/LVDS25E ported
as-is is rejected outright by Apicula. Use TLVDS_OBUF/LVDS25, with P on the pair's IOBA pin
and N on its IOBB pin (33/34, 35/36, 37/38, 39/40).
Pass the pixel clock to --freq. nextpnr applies --freq to every clock it does not
otherwise know, so passing the serial clock (135) fails the TMDS encoder's combinational path on
a timing violation that does not exist.
Faster than reasoning. Load Sipeed's prebuilt .fs (fetched with gh) and you know at once
whether the board, cable and sink are fine. Build Apicula's examples/DVI with the same flow and
you also know that the open-source toolchain can drive this board's TLVDS; what is left is the
difference from your own sources.
/dev/cu.* port to 9600 baud when its last
descriptor closes, so stty -f PORT 115200 followed by cat PORT loses the setting.
make monitor (scripts/monitor.py) sets the speed on the descriptor it reads from.unable to open ftdi device: close anything using the board's USB device (a serial
monitor, for instance) and reconnect.make clean, then make build.MIT License. See LICENSE.
| Qty | Value | Footprint | References |
|---|---|---|---|
| 10 | 20 | Resistor_SMD:R_0603_1608Metric | R3, R5, R6, R7, R8, R9, R10, R11, R12, R18 |
| 7 | 0.1u | Capacitor_SMD:C_0603_1608Metric_Pad1.08x0.95mm_HandSolder | C1, C4, C6, C8, C10, C11, C12 |
| 7 | TestPoint | TestPoint:TestPoint_THTPad_D1.0mm_Drill0.5mm | TP1, TP2, TP3, TP4, TP5, TP6, TP7 |
| 4 | 10k | Resistor_SMD:R_0603_1608Metric | R2, R4, R14, R17 |
| 4 | MountingHole | MountingHole:MountingHole_2.7mm_M2.5 | H1, H2, H3, H4 |
| 3 | 10u | Capacitor_SMD:C_0805_2012Metric_Pad1.18x1.45mm_HandSolder | C3, C5, C9 |
| 2 | 4.7k | Resistor_SMD:R_0603_1608Metric | R15, R16 |
| 2 | 600R@100MHz | Inductor_SMD:L_0805_2012Metric | FB1, FB2 |
| 2 | Conn_01x04_Pin | Connector:NS-Tech_Grove_1x04_P2mm_Vertical | J4, J5 |
| 1 | 100p | Capacitor_SMD:C_0603_1608Metric_Pad1.08x0.95mm_HandSolder | C13 |
| 1 | 1u | Capacitor_Tantalum_SMD:CP_EIA-3216-10_Kemet-I_HandSolder | C7 |
| 1 | 1u | Capacitor_SMD:C_0805_2012Metric_Pad1.18x1.45mm_HandSolder | C2 |
| 1 | 33 | Resistor_SMD:R_0603_1608Metric | R13 |
| 1 | 47k | Resistor_SMD:R_0603_1608Metric | R19 |
| 1 | 75 | Resistor_SMD:R_0603_1608Metric | R1 |
| 1 | AD9280ARS | Package_SO:SSOP-28_5.3x10.2mm_P0.65mm | U1 |
| 1 | Conn_01x12_Pin | Connector_PinHeader_2.54mm:PinHeader_2x06_P2.54mm_Vertical | J2 |
| 1 | H5VL10B | Diode_SMD:D_SOD-882 | D1 |
| 1 | RJ-2410N | RJ-2410N:RJ2410N | J3 |
| 1 | Tang_Nano_20K | 0my_project:DIP-40_TangNano20K | U2 |
| 1 | screw | TerminalBlock_Altech:Altech_AK100_1x02_P5.00mm | J1 |
It measures 65.1 × 36.2 mm and has 2 copper layers, 1.6 mm thick.
53 parts, built around the AD9280ARS and H5VL10B. The full list of its parts is on this page, with links to each part's symbol and footprint where PCBKit's library has them.
From the otanl/CVBS2HDMI repository on GitHub, at the commit this page was made from: the board is hardware/tangADC.kicad_pcb and its schematic hardware/tangADC.kicad_sch. PCBKit opens the board in the browser, with nothing to install.
Its repository publishes it under the MIT licence: you may reuse it on that licence's terms (keep the author's notice, and for a copyleft licence share your changes alike).

jackw01/scanlight
A better light source for scanning color negative film

alextrical/ESP32-Sentinel-Kinetic-Wireless-Dongle
ESP8266 based Re-implementation of the Vent-Axia Sentinel Kinetic Wired Remote Controller without a Display

openUC2/TechnicalDocs-openUC2-HoloBox
This is the holographic box for openUC2


jakepoz/jpbms
JPBms is a simple BMS for solar charging and balancing a 3-cell lithium ion battery.

Brendanofawesome/SimpleFOC-DualESC
High-performance dual BLDC motor driver with FOC (Field-Oriented Control) algorithm support, designed for applications requiring precise motor control with simultaneous dual-motor operation.

Cadburies/SisuAssistant
Sailing yacht Sisu: Home Assistant + ESPHome Marine Board (alternator regulation, tanks) + MQTT kernel, Signal K/Grafana/InfluxDB on a NAS, and Sisu Nav chart & weather-routing cockpit.

houseofbigseals/grip_strength_meter_pcb
schematic and pcb for custom esp32 grip strength meter project