initial commit: geekworm x1208 ups prometheus exporter
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
/build/
|
||||||
|
/monitoring-overlay/*.original
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -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'
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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:
|
||||||
@@ -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'
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user