A Go service for the Orange Pi Zero LTS that reads 20 digital inputs over the on-board I²C and exposes them three ways:
- a live terminal display
- MQTT publish-on-change (with a 30 s heartbeat)
- an embedded web UI for live pin status and editing
config.yaml
- Board: Orange Pi Zero LTS
- Expanders: 3 × MCP23008 (8-bit I/O expander, I²C) on I²C bus 0
- Default addresses:
0x20,0x21,0x22 - Inputs: 20 active-low digital lines
| Pins | Expander | Bits |
|---|---|---|
| 1–8 | i1 @ 0x20 |
0–7 |
| 9–16 | i2 @ 0x21 |
0–7 |
| 17–20 | i3 @ 0x22 |
0–3 |
Bits 4–7 on i3 are unused. The IODIR register isn't written — MCP23008
power-on default is all GPIO as inputs, which matches the wiring. The code
inverts the bit when returning pin state, so a line pulled to GND reads as
1.
The Go code never opens /dev/i2c-* directly. It shells out to an
i2cget-compatible binary, configured via app.i2c_bin:
<i2c_bin> -y <bus> <chip-addr> <register>
That indirection exists so the same binary runs on a Mac dev box even though
macOS has no I²C. i2cget.sh in this repo is a dummy stub for Mac dev
that just echoes 0xAA; with it pointed at by i2c_bin, the display,
MQTT publish, and web UI can be exercised end-to-end without hardware.
On the Orange Pi, install i2c-tools and point i2c_bin at the real
binary:
app:
i2c_bin: "/usr/sbin/i2cget"config.yaml (resolution order):
--configPathflagENV_CONFIG_PATHenvironment variable/root/app/config.yamlif the binary lives in/root/app./config.yaml
version: "1"
app:
box_id: "R0001"
i2c_bin: "/usr/sbin/i2cget"
devices:
- { name: i1, address: 0x20, hardware: 0 }
- { name: i2, address: 0x21, hardware: 0 }
- { name: i3, address: 0x22, hardware: 0 }
mqtt:
host: "tcp://broker.example"
port: 1883
username: "user"
password: "password"
publish_topic: "v24reader"
subscribe_topic: "v24reader"
web:
enabled: true
bind: "127.0.0.1:8080"hardware is the I²C bus number passed to i2cget -y.
compile.sh cross-compiles for ARMv7 Linux and optionally scps the binary
to the Pi:
./compile.sh # build only
./compile.sh put # build + scp to root@orangepi:/root/app/
DEPLOY_TARGET=user@host:/path ./compile.sh put # override deploy target
Local Mac build for development:
go build -o /tmp/v20bit .
/tmp/v20bit
Ctrl-C unwinds cleanly: terminal cursor is restored, MQTT disconnects, web server shuts down.
Default bind is loopback only (127.0.0.1:8080). Open
http://127.0.0.1:8080 and the page polls
/api/pins every 500 ms.
| Endpoint | Method | Notes |
|---|---|---|
/ |
GET | dashboard HTML |
/api/pins |
GET | {"box_id","ts","pins":[20]} |
/api/config |
GET | raw config.yaml bytes (comments preserved) |
/api/config |
POST | replace config.yaml; rejects YAML missing app.box_id or with empty app.devices. Restart required to apply. |
To expose on the LAN, change web.bind to 0.0.0.0:8080. There's no auth —
keep the box on a trusted network.
Publish-on-change with a 30 s heartbeat. QoS 0, no retain. Payload:
{
"box_id": "R0001",
"ts": "2026-05-18T10:00:00Z",
"pins": [1,0,1,1,0,0,0,1,1,1,0,0,0,1,0,0,1,0,1,0]
}The MQTT client connects in the background and auto-reconnects forever, so the service starts and stays responsive even when the broker is unreachable. Messages published while disconnected are dropped (telemetry semantics — the next sample supersedes the last).
.
├── main.go # signal-driven run loop (poll → render → publish)
├── config/ # yaml load + atomic save
├── controller/ # board init, i2cget shell-out, snapshot cache
├── mqttx/ # paho wrapper, non-blocking connect
├── web/ # http server + embedded index.html
├── config.yaml
├── i2cget.sh # Mac dev stub (echoes 0xAA)
└── compile.sh # GOOS=linux GOARCH=arm GOARM=7 build