commit aae6cee886427fef8be9587100a0259bc7564c20 Author: m5r Date: Fri Apr 17 20:30:34 2026 +0200 initial commit: geekworm x1208 ups prometheus exporter diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f83240a --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/build/ +/monitoring-overlay/*.original diff --git a/README.md b/README.md new file mode 100644 index 0000000..62b3c65 --- /dev/null +++ b/README.md @@ -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 (0–100) | +| `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 +``` diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..3d08c17 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..431bb30 --- /dev/null +++ b/go.sum @@ -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= diff --git a/justfile b/justfile new file mode 100644 index 0000000..edb700b --- /dev/null +++ b/justfile @@ -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' diff --git a/main.go b/main.go new file mode 100644 index 0000000..93693a8 --- /dev/null +++ b/main.go @@ -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) + } +} diff --git a/monitoring-overlay/README.md b/monitoring-overlay/README.md new file mode 100644 index 0000000..91aff57 --- /dev/null +++ b/monitoring-overlay/README.md @@ -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. diff --git a/monitoring-overlay/compose.yaml b/monitoring-overlay/compose.yaml new file mode 100644 index 0000000..656393d --- /dev/null +++ b/monitoring-overlay/compose.yaml @@ -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: diff --git a/monitoring-overlay/prometheus.yaml b/monitoring-overlay/prometheus.yaml new file mode 100644 index 0000000..3d16e78 --- /dev/null +++ b/monitoring-overlay/prometheus.yaml @@ -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' diff --git a/systemd/x1208-exporter.service b/systemd/x1208-exporter.service new file mode 100644 index 0000000..45b0b6b --- /dev/null +++ b/systemd/x1208-exporter.service @@ -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 diff --git a/systemd/x1208-exporter.timer b/systemd/x1208-exporter.timer new file mode 100644 index 0000000..d40a168 --- /dev/null +++ b/systemd/x1208-exporter.timer @@ -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