initial commit: geekworm x1208 ups prometheus exporter

This commit is contained in:
2026-04-17 20:30:34 +02:00
commit aae6cee886
11 changed files with 389 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
/build/
/monitoring-overlay/*.original
+49
View File
@@ -0,0 +1,49 @@
# x1208-exporter
One-shot Prometheus textfile exporter for the Geekworm X1208 UPS HAT on Raspberry Pi 5.
Reads:
- GPIO 6 (PLD) — `1` = AC plugged in, `0` = on battery.
- I2C `0x36` (MAX17040 fuel gauge) — battery voltage and state of charge.
Writes `/var/lib/node-exporter/textfiles/x1208.prom` atomically. node-exporter's
textfile collector serves it at scrape time.
## Metrics
| Name | Type | Description |
| ------------------------------- | ----- | -------------------------------------------- |
| `rpi_ups_ac_power` | gauge | `1` = AC present, `0` = on battery |
| `rpi_ups_voltage_volts` | gauge | Battery cell voltage |
| `rpi_ups_battery_percent` | gauge | State of charge (0100) |
| `rpi_ups_last_update_seconds` | gauge | Unix timestamp of last exporter run |
## Deploy
```bash
just deploy
```
Builds the arm64 binary, scps to `rpi`, installs systemd units, starts the timer.
## Verify
```bash
just run-once # forces one read, prints the prom file
just logs # journalctl tail
```
## How node-exporter picks it up
The `monitoring` compose stack on the Pi must mount the textfile dir and pass
the flag to node-exporter:
```yaml
node-exporter:
volumes:
- /var/lib/node-exporter/textfiles:/textfiles:ro
command:
- '--collector.textfile.directory=/textfiles'
# ...existing flags
```
+8
View File
@@ -0,0 +1,8 @@
module github.com/mokhtar/x1208-exporter
go 1.23
require (
github.com/warthog618/go-gpiocdev v0.9.1
golang.org/x/sys v0.27.0
)
+16
View File
@@ -0,0 +1,16 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/warthog618/go-gpiocdev v0.9.1 h1:pwHPaqjJfhCipIQl78V+O3l9OKHivdRDdmgXYbmhuCI=
github.com/warthog618/go-gpiocdev v0.9.1/go.mod h1:dN3e3t/S2aSNC+hgigGE/dBW8jE1ONk9bDSEYfoPyl8=
github.com/warthog618/go-gpiosim v0.1.1 h1:MRAEv+T+itmw+3GeIGpQJBfanUVyg0l3JCTwHtwdre4=
github.com/warthog618/go-gpiosim v0.1.1/go.mod h1:YXsnB+I9jdCMY4YAlMSRrlts25ltjmuIsrnoUrBLdqU=
golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s=
golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+23
View File
@@ -0,0 +1,23 @@
default:
@just --list
build:
GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o build/x1208-exporter ./...
deploy: build
scp build/x1208-exporter rpi:/tmp/x1208-exporter
scp systemd/x1208-exporter.service systemd/x1208-exporter.timer rpi:/tmp/
ssh rpi 'sudo install -m 0755 /tmp/x1208-exporter /usr/local/bin/x1208-exporter && \
sudo install -m 0644 /tmp/x1208-exporter.service /etc/systemd/system/ && \
sudo install -m 0644 /tmp/x1208-exporter.timer /etc/systemd/system/ && \
sudo install -d -m 0755 -o root /var/lib/node-exporter/textfiles && \
sudo systemctl daemon-reload && \
sudo systemctl enable --now x1208-exporter.timer && \
sudo systemctl start x1208-exporter.service && \
cat /var/lib/node-exporter/textfiles/x1208.prom'
logs:
ssh rpi 'journalctl -u x1208-exporter.service -n 30 --no-pager'
run-once:
ssh rpi 'sudo systemctl start x1208-exporter.service && cat /var/lib/node-exporter/textfiles/x1208.prom'
+181
View File
@@ -0,0 +1,181 @@
package main
import (
"encoding/binary"
"flag"
"fmt"
"os"
"path/filepath"
"time"
"github.com/warthog618/go-gpiocdev"
"golang.org/x/sys/unix"
)
const (
i2cDev = "/dev/i2c-1"
gpioChip = "gpiochip0"
gpioPLDPin = 6
maxAddr = 0x36
regVoltage = 0x02
regCapacity = 0x04
i2cSlave = 0x0703 // I2C_SLAVE
maxRetries = 3
)
func readI2CWord(fd int, reg byte) (uint16, error) {
if _, err := unix.Write(fd, []byte{reg}); err != nil {
return 0, fmt.Errorf("write reg: %w", err)
}
buf := make([]byte, 2)
n, err := unix.Read(fd, buf)
if err != nil {
return 0, fmt.Errorf("read: %w", err)
}
if n != 2 {
return 0, fmt.Errorf("short read: %d bytes", n)
}
return binary.BigEndian.Uint16(buf), nil
}
func readI2CWordRetry(fd int, reg byte, validate func(uint16) bool) (uint16, error) {
var lastErr error
for i := 0; i < maxRetries; i++ {
v, err := readI2CWord(fd, reg)
if err == nil && validate(v) {
return v, nil
}
if err != nil {
lastErr = err
} else {
lastErr = fmt.Errorf("read out of range: 0x%04x", v)
}
time.Sleep(20 * time.Millisecond)
}
return 0, lastErr
}
func readBattery() (voltage float64, percent float64, err error) {
fd, err := unix.Open(i2cDev, unix.O_RDWR, 0)
if err != nil {
return 0, 0, fmt.Errorf("open %s: %w", i2cDev, err)
}
defer unix.Close(fd)
if err := unix.IoctlSetInt(fd, i2cSlave, maxAddr); err != nil {
return 0, 0, fmt.Errorf("ioctl I2C_SLAVE: %w", err)
}
rawV, err := readI2CWordRetry(fd, regVoltage, func(v uint16) bool {
f := float64(v) * 1.25 / 1000 / 16
return f > 2.0 && f < 5.0
})
if err != nil {
return 0, 0, fmt.Errorf("voltage: %w", err)
}
voltage = float64(rawV) * 1.25 / 1000 / 16
rawC, err := readI2CWordRetry(fd, regCapacity, func(v uint16) bool {
return float64(v)/256 <= 110
})
if err != nil {
return 0, 0, fmt.Errorf("capacity: %w", err)
}
percent = float64(rawC) / 256
return voltage, percent, nil
}
func readACPower() (int, error) {
line, err := gpiocdev.RequestLine(gpioChip, gpioPLDPin,
gpiocdev.AsInput,
gpiocdev.WithConsumer("x1208-exporter"))
if err != nil {
return 0, fmt.Errorf("request gpio: %w", err)
}
defer line.Close()
v, err := line.Value()
if err != nil {
return 0, fmt.Errorf("read gpio: %w", err)
}
return v, nil
}
func writeAtomic(path string, content string) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
tmp, err := os.CreateTemp(dir, ".x1208.*.tmp")
if err != nil {
return err
}
tmpPath := tmp.Name()
defer os.Remove(tmpPath)
if _, err := tmp.WriteString(content); err != nil {
tmp.Close()
return err
}
if err := tmp.Chmod(0o644); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmpPath, path)
}
func main() {
out := flag.String("out", "/var/lib/node-exporter/textfiles/x1208.prom", "output prom file")
flag.Parse()
var (
ac int
voltage float64
percent float64
acErr, batErr error
)
ac, acErr = readACPower()
voltage, percent, batErr = readBattery()
if acErr != nil && batErr != nil {
fmt.Fprintf(os.Stderr, "all reads failed: ac=%v bat=%v\n", acErr, batErr)
os.Exit(1)
}
now := time.Now().Unix()
var content string
content += "# HELP rpi_ups_last_update_seconds Unix timestamp of last successful exporter run.\n"
content += "# TYPE rpi_ups_last_update_seconds gauge\n"
content += fmt.Sprintf("rpi_ups_last_update_seconds %d\n", now)
if acErr == nil {
content += "# HELP rpi_ups_ac_power AC adapter present (1=plugged in, 0=on battery).\n"
content += "# TYPE rpi_ups_ac_power gauge\n"
content += fmt.Sprintf("rpi_ups_ac_power %d\n", ac)
} else {
fmt.Fprintf(os.Stderr, "ac read failed: %v\n", acErr)
}
if batErr == nil {
content += "# HELP rpi_ups_voltage_volts Battery cell voltage from MAX17040 fuel gauge.\n"
content += "# TYPE rpi_ups_voltage_volts gauge\n"
content += fmt.Sprintf("rpi_ups_voltage_volts %.3f\n", voltage)
content += "# HELP rpi_ups_battery_percent Battery state of charge from MAX17040 fuel gauge.\n"
content += "# TYPE rpi_ups_battery_percent gauge\n"
content += fmt.Sprintf("rpi_ups_battery_percent %.2f\n", percent)
} else {
fmt.Fprintf(os.Stderr, "battery read failed: %v\n", batErr)
}
if err := writeAtomic(*out, content); err != nil {
fmt.Fprintf(os.Stderr, "write %s: %v\n", *out, err)
os.Exit(1)
}
if acErr != nil || batErr != nil {
os.Exit(1)
}
}
+26
View File
@@ -0,0 +1,26 @@
# Monitoring overlay for the Pi
These two files are the desired state of `/home/mokhtar/app/monitoring/` on the Pi
after the X1208 exporter is in place. The `.original` siblings are snapshots of
what's there right now (pulled with `ssh rpi cat ...`).
## Diff summary
**`compose.yaml`** — node-exporter service:
- Add volume `/var/lib/node-exporter/textfiles:/textfiles:ro`
- Add command flag `--collector.textfile.directory=/textfiles`
**`prometheus.yaml`** — global block:
- Add `external_labels: { host: rpi }` so metrics are tagged in Grafana Cloud
(instead of relying on the Docker service name as `instance`).
## Apply
```bash
scp compose.yaml prometheus.yaml rpi:/home/mokhtar/app/monitoring/
ssh rpi 'cd /home/mokhtar/app/monitoring && docker compose up -d'
```
This recreates the node-exporter container (~2s blip) and reloads Prometheus.
+42
View File
@@ -0,0 +1,42 @@
services:
node-exporter:
image: prom/node-exporter:latest
hostname: node-exporter
restart: unless-stopped
volumes:
- /proc:/host/proc:ro
- /sys:/host/sys:ro
- /:/rootfs:ro
- /var/lib/node-exporter/textfiles:/textfiles:ro
command:
- '--path.procfs=/host/proc'
- '--path.rootfs=/rootfs'
- '--path.sysfs=/host/sys'
- '--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)'
- '--collector.textfile.directory=/textfiles'
- '--web.telemetry-path=/_prom/metrics'
expose:
- 9100
networks:
- monitoring-network
logging:
driver: journald
options:
tag: "container-{{.Name}}"
prometheus:
image: prom/prometheus:latest
restart: unless-stopped
depends_on:
- node-exporter
volumes:
- "./prometheus.yaml:/etc/prometheus/prometheus.yml"
networks:
- monitoring-network
logging:
driver: journald
options:
tag: "container-{{.Name}}"
networks:
monitoring-network:
+16
View File
@@ -0,0 +1,16 @@
global:
scrape_interval: 60s
external_labels:
host: rpi
scrape_configs:
- job_name: scrape_mokhtar-rpi
metrics_path: /_prom/metrics
static_configs:
- targets: ['node-exporter:9100']
remote_write:
- url: 'https://prometheus-prod-39-prod-eu-north-0.grafana.net/api/prom/push'
basic_auth:
username: '1830601'
password: 'glc_eyJvIjoiNTM3NDYzIiwibiI6InN0YWNrLTEwNTY4MDYtaG0td3JpdGUtcHJvbWV0aGV1cy1ycGkiLCJrIjoidDlXblM2ME1EMTNNMEpKM2IycDBTMWt1IiwibSI6eyJyIjoicHJvZC1ldS1ub3J0aC0wIn19'
+15
View File
@@ -0,0 +1,15 @@
[Unit]
Description=Geekworm X1208 UPS metrics exporter (one-shot)
After=local-fs.target
[Service]
Type=oneshot
ExecStart=/usr/local/bin/x1208-exporter
Nice=10
ProtectSystem=strict
ReadWritePaths=/var/lib/node-exporter/textfiles
ProtectHome=true
PrivateTmp=true
NoNewPrivileges=true
DeviceAllow=/dev/i2c-1 rw
DeviceAllow=/dev/gpiochip0 rw
+11
View File
@@ -0,0 +1,11 @@
[Unit]
Description=Run X1208 UPS metrics exporter every 30s
[Timer]
OnBootSec=30s
OnUnitActiveSec=30s
AccuracySec=1s
Unit=x1208-exporter.service
[Install]
WantedBy=timers.target