Louisianair · Lesson 02 · firmware bring-up

TinyGo ADS1220 + MQTT: first H2S reading

Make the Pico W read the ADS1220 over SPI, then shape that reading for the existing Louisianair MQTT path on Fly.io.

🎯 Mission: get one H2S electrode pair from Lesson 01 to become a signed 24-bit count, then understand how that count will ride the deployed MQTT route: os-mqtt-gateway.fly.dev:1883.

1. The one real path

H2SAlphasense cell
AFEWE/AE voltages
ADS122024-bit count
Pico WTinyGo + WiFi
MQTTFly.io broker

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.

Do not publish calibrated ppb yet. First lesson goal is transport: raw count / volts / temporary H2S index. Real h2s_ppb waits for AFE calibration sheet zero + gain.

2. Wiring contract from Lesson 01

SignalADS1220 pinPico W / role
H2S WE outputAIN0positive analog input
H2S AE outputAIN1negative analog input
Pt1000+AIN2next lesson: temperature differential
Pt1000−AIN3next lesson: temperature differential
SCLK / DIN / DOUT / CSSPI pinsGP18, GP19, GP16, GP17
DRDYDRDYGP20; goes low when data ready

ADS1220 logic stays 3.3V. AFE can stay on 5V. Grounds must be shared.

3. ADS1220 setup bytes

We configure one differential pair: AIN0 - AIN1. PGA bypass stays on because bench signals may sit near ground and gain is 1.

CommandWREG 0..3 = 0x43

Write four config registers.

Reg 0 = 0x01

MUX AIN0/AIN1, gain 1, PGA bypass.

Reg 1 = 0x04

20 SPS, normal mode, continuous conversion.

Reg 2 = 0x00

Internal 2.048V reference, no IDAC.

Reg 3 = 0x00

Dedicated 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

4. TinyGo driver skeleton

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)
}
Lazy check: first prove ReadRaw() changes when WE/AE wiring changes. Then clean the SPI helper. Do not build a full driver library before one number prints.

5. Bench main.go

func 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.

6. Send it through existing MQTT path

The firmware repo already has the deployed route:

Env varUseCurrent pattern
MQTT_ADDRbrokeros-mqtt-gateway.fly.dev:1883
MQTT_USERtopic account + auth usernamepicopuff or assigned user
MQTT_PASSauth passwordsecret, do not commit
MQTT_DEVICEbackend device sluglowercased Pico MAC / registered source key
MQTT_TOPICoverrideusually 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
  }
}
Firmware note: current mqttsensor/mqtt.SensorReading knows Ratioh2s_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.

7. Debug map

SymptomLikely causeFirst fix
DRDY never goes lowWrong DRDY pin, no START, ADC not poweredCheck GP20 wire, 3.3V, GND, START byte
All reads 0x7FFFFF or 0x800000Input over range or floatingMeter AIN0/AIN1 vs GND; verify AFE outputs
MQTT DNS failsWiFi/DHCP not readyReuse existing cyw43439 setup + NTP flow
Backend ignores messageBad topic/device slug/schemaUse lowercased MAC as MQTT_DEVICE; keep default topic

Check yourself

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.