Make the Pico W read the ADS1220 over SPI, then shape that reading for the existing Louisianair MQTT path on Fly.io.
os-mqtt-gateway.fly.dev:1883.Old firmware prototypes read SGP30, MQ-style, and SEN0568 sensors. Those sensors differ. Reuse the boring parts: WiFi, NTP, MQTT client, Makefile env vars. Replace only the sensor-reading middle with ADS1220 + Alphasense H2S.
h2s_ppb waits for AFE calibration sheet zero + gain.| Signal | ADS1220 pin | Pico W / role |
|---|---|---|
| H2S WE output | AIN0 | positive analog input |
| H2S AE output | AIN1 | negative analog input |
| Pt1000+ | AIN2 | next lesson: temperature differential |
| Pt1000− | AIN3 | next lesson: temperature differential |
| SCLK / DIN / DOUT / CS | SPI pins | GP18, GP19, GP16, GP17 |
| DRDY | DRDY | GP20; goes low when data ready |
ADS1220 logic stays 3.3V. AFE can stay on 5V. Grounds must be shared.
We configure one differential pair: AIN0 - AIN1. PGA bypass stays on because bench signals may sit near ground and gain is 1.
WREG 0..3 = 0x43Write four config registers.
0x01MUX AIN0/AIN1, gain 1, PGA bypass.
0x0420 SPS, normal mode, continuous conversion.
0x00Internal 2.048V reference, no IDAC.
0x00Dedicated DRDY pin, no IDAC routing.
// Bytes sent after RESET:
0x43, 0x01, 0x04, 0x00, 0x00
// Then START/SYNC once:
0x08
// Each ready sample:
0x10, read 3 bytes
This is intentionally tiny. No package gymnastics yet. Drop it into a future sulfurmqtt app or into a quick bench folder in ../aethernet-firmware.
package main
import (
"machine"
"time"
)
const (
adsReset = 0x06
adsStart = 0x08
adsRData = 0x10
adsWReg0 = 0x43 // write registers 0..3
)
type ADS1220 struct {
spi *machine.SPI
cs machine.Pin
drdy machine.Pin
}
func (d *ADS1220) Configure() error {
d.cs.Configure(machine.PinConfig{Mode: machine.PinOutput})
d.drdy.Configure(machine.PinConfig{Mode: machine.PinInputPullup})
d.cs.High()
if err := d.cmd(adsReset); err != nil { return err }
time.Sleep(1 * time.Millisecond)
// AIN0-AIN1, gain 1, PGA bypass; 20 SPS continuous; internal 2.048V ref.
if err := d.tx([]byte{adsWReg0, 0x01, 0x04, 0x00, 0x00}, nil); err != nil { return err }
return d.cmd(adsStart)
}
func (d *ADS1220) ReadRaw() (int32, error) {
for d.drdy.Get() { time.Sleep(time.Millisecond) }
rx := make([]byte, 4)
if err := d.tx([]byte{adsRData, 0, 0, 0}, rx); err != nil {
return 0, err
}
// rx[0] is the command-time byte. Data arrives in rx[1:4].
b0, b1, b2 := rx[1], rx[2], rx[3]
raw := int32(b0)<<16 | int32(b1)<<8 | int32(b2)
if raw&0x800000 != 0 { raw |= ^0xFFFFFF } // sign-extend 24-bit two's complement
return raw, nil
}
func (d *ADS1220) Volts(raw int32) float32 {
return float32(raw) * 2.048 / 8388608.0 // gain 1, internal ref
}
func (d *ADS1220) cmd(c byte) error { return d.tx([]byte{c}, nil) }
func (d *ADS1220) tx(w, r []byte) error {
d.cs.Low(); defer d.cs.High()
return d.spi.Tx(w, r)
}
ReadRaw() changes when WE/AE wiring changes. Then clean the SPI helper. Do not build a full driver library before one number prints.main.gofunc main() {
machine.SPI0.Configure(machine.SPIConfig{
Frequency: 1_000_000,
SCK: machine.GPIO18, SDO: machine.GPIO19, SDI: machine.GPIO16,
})
adc := ADS1220{spi: machine.SPI0, cs: machine.GPIO17, drdy: machine.GPIO20}
if err := adc.Configure(); err != nil { println("ads config:", err.Error()); return }
for {
raw, err := adc.ReadRaw()
if err != nil { println("ads read:", err.Error()); continue }
volts := adc.Volts(raw)
println("h2s_raw=", raw, "h2s_volts=", volts)
time.Sleep(5 * time.Second)
}
}
If serial shows numbers, Lesson 02 hardware+SPI is alive. If it sticks at max/min, suspect wiring, shared ground, or AFE output outside ADC range.
The firmware repo already has the deployed route:
| Env var | Use | Current pattern |
|---|---|---|
MQTT_ADDR | broker | os-mqtt-gateway.fly.dev:1883 |
MQTT_USER | topic account + auth username | picopuff or assigned user |
MQTT_PASS | auth password | secret, do not commit |
MQTT_DEVICE | backend device slug | lowercased Pico MAC / registered source key |
MQTT_TOPIC | override | usually blank |
Default topic resolves to:
louisianair/dev/<MQTT_USER>/<MQTT_DEVICE>/telemetry
Existing canonical payload shape is:
{
"source_reading_key": "<device>:<unix-ns>:<since-boot-ns>",
"recorded_at": "2026-07-02T14:30:00Z",
"measurements": {
"temperature": 26.1,
"humidity": 62.0,
"h2s_index": 1.04
}
}
mqttsensor/mqtt.SensorReading knows Ratio → h2s_index. It does not yet know h2s_raw, h2s_volts, or calibrated h2s_ppb. For the first MQTT smoke test, publish Ratio or extend that struct later when backend accepts sulfur fields.// Conceptual bridge inside sulfurmqtt main loop:
raw, _ := adc.ReadRaw()
volts := adc.Volts(raw)
// Temporary transport smoke value. Real ppb comes after calibration sheet math.
h2sIndex := volts // or normalized volts/baseline once baseline exists
readings <- mqtt.SensorReading{
Ratio: h2sIndex,
Timestamp: ntpTime,
SinceBootNS: time.Duration(time.Now().UnixNano()),
}
Build/flash still follows the existing Makefile pattern:
MQTT_ADDR=os-mqtt-gateway.fly.dev:1883 \
MQTT_USER=picopuff \
MQTT_PASS='ask-your-instructor' \
MQTT_DEVICE='aa:bb:cc:dd:ee:ff' \
make flash/sulfurmqtt
flash/sulfurmqtt does not exist yet. Next firmware task: copy the existing sgp30mqtt app shape, replace sensor read code with ADS1220, keep WiFi/NTP/MQTT plumbing.
| Symptom | Likely cause | First fix |
|---|---|---|
| DRDY never goes low | Wrong DRDY pin, no START, ADC not powered | Check GP20 wire, 3.3V, GND, START byte |
All reads 0x7FFFFF or 0x800000 | Input over range or floating | Meter AIN0/AIN1 vs GND; verify AFE outputs |
| MQTT DNS fails | WiFi/DHCP not ready | Reuse existing cyw43439 setup + NTP flow |
| Backend ignores message | Bad topic/device slug/schema | Use lowercased MAC as MQTT_DEVICE; keep default topic |
Q1. Why read WE-AE as AIN0-AIN1 instead of one Pico ADC pin?
Q2. What does 0x43 0x01 0x04 0x00 0x00 configure?
Q3. Which firmware pieces survive from SGP30/MQ2 prototypes?
Answers: Q1: ADS1220 gives low-noise differential 24-bit data; Pico ADC is not enough. Q2: write regs 0..3: H2S WE/AE pair, gain 1, PGA bypass, 20 SPS continuous, internal ref. Q3: WiFi, NTP, MQTT, env vars, topic routing; sensor read + measurement schema change.