mirror of
https://github.com/RWejlgaard/octopus_exporter.git
synced 2026-07-04 22:06:17 +00:00
Compare commits
6 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 85c5a96d1e | |||
| dbcf50eb13 | |||
|
|
00f1b29ab0 | ||
|
|
50a7bce8e3 | ||
|
|
f4557c7a2f | ||
| 5f26d818b7 |
14 changed files with 377 additions and 81 deletions
6
.github/workflows/pr-check.yml
vendored
6
.github/workflows/pr-check.yml
vendored
|
|
@ -8,9 +8,9 @@ jobs:
|
|||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
|
|
@ -18,6 +18,6 @@ jobs:
|
|||
run: go test -race ./...
|
||||
|
||||
- name: Lint
|
||||
uses: golangci/golangci-lint-action@v6
|
||||
uses: golangci/golangci-lint-action@v9
|
||||
with:
|
||||
version: latest
|
||||
|
|
|
|||
10
.github/workflows/release.yml
vendored
10
.github/workflows/release.yml
vendored
|
|
@ -12,7 +12,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
if: "!startsWith(github.event.head_commit.message, 'chore:')"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
|
@ -49,19 +49,19 @@ jobs:
|
|||
echo "version=$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Log in to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PAT }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
FROM golang:1.24-alpine AS builder
|
||||
FROM golang:1.26-alpine AS builder
|
||||
ARG VERSION=dev
|
||||
ARG COMMIT=none
|
||||
WORKDIR /src
|
||||
|
|
|
|||
16
README.md
16
README.md
|
|
@ -28,6 +28,18 @@ Gas metrics are only exposed if a smart gas meter is found on the account.
|
|||
| `octopus_gas_unit_rate_pence` | GraphQL | Current unit rate in pence per kWh |
|
||||
| `octopus_gas_standing_charge_pence` | GraphQL | Current standing charge in pence per day |
|
||||
|
||||
### Solar / export
|
||||
|
||||
Solar metrics are only exposed if an electricity export meter point (direction `EXPORT`) is found on the account. Solar export usually shares the physical meter and smart device with the import meter, so live export watts come from the same telemetry call as electricity demand (no extra API request).
|
||||
|
||||
| Metric | Source | Description |
|
||||
|---|---|---|
|
||||
| `octopus_solar_export_watts` | GraphQL | Live electricity export to the grid in watts |
|
||||
| `octopus_solar_last_read_timestamp` | GraphQL | Unix timestamp of last solar export reading |
|
||||
| `octopus_solar_export_kwh` | REST | Latest half-hourly energy exported to the grid in kWh |
|
||||
| `octopus_solar_export_interval_timestamp` | REST | Unix timestamp of the start of the latest export interval |
|
||||
| `octopus_solar_export_rate_pence` | GraphQL | Current export (outgoing) unit rate in pence per kWh |
|
||||
|
||||
### Account
|
||||
|
||||
| Metric | Source | Description |
|
||||
|
|
@ -58,8 +70,12 @@ Metrics are refreshed every `POLL_INTERVAL` (default 60 seconds).
|
|||
| `OCTOPUS_GAS_MPRN` | No | Filter gas meter by MPRN |
|
||||
| `OCTOPUS_GAS_SERIAL` | No | Filter gas meter by serial number |
|
||||
| `OCTOPUS_GAS_DEVICE_ID` | No | Use a specific gas smart device ID directly |
|
||||
| `OCTOPUS_SOLAR_MPAN` | No | Filter solar export meter by MPAN |
|
||||
| `OCTOPUS_SOLAR_SERIAL` | No | Filter solar export meter by serial number |
|
||||
| `OCTOPUS_SOLAR_DEVICE_ID` | No | Use a specific solar export smart device ID directly |
|
||||
| `PORT` | No | Port to expose metrics on (default: `9359`) |
|
||||
| `POLL_INTERVAL` | No | How often to poll Octopus APIs (Go duration, default: `60s`) |
|
||||
| `LOG_LEVEL` | No | Minimum log level: `debug`, `info`, `warn`, `error` (default: `info`). Logs are emitted as JSON to stderr |
|
||||
|
||||
If no filter variables are set, the exporter auto-discovers the first smart meter of each type found on the account. Use `OCTOPUS_MPAN` / `OCTOPUS_MPRN` to pin to a specific meter on accounts with multiple meters.
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import (
|
|||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
|
@ -76,7 +76,7 @@ func executeWithRetry(makeReq func() (*http.Request, error)) ([]byte, error) {
|
|||
wait = time.Duration(secs) * time.Second
|
||||
}
|
||||
}
|
||||
log.Printf("rate limited; retrying in %v (attempt %d/%d)", wait, attempt+1, maxRetries)
|
||||
slog.Warn("rate limited; retrying", "wait", wait, "attempt", attempt+1, "max_retries", maxRetries)
|
||||
time.Sleep(wait)
|
||||
backoff *= 2
|
||||
continue
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import (
|
|||
"time"
|
||||
)
|
||||
|
||||
var errNoConsumptionData = errors.New("no consumption data in last 7 days")
|
||||
|
||||
type consumptionReading struct {
|
||||
KWh float64
|
||||
IntervalStart time.Time
|
||||
|
|
@ -21,9 +23,11 @@ func getLatestConsumption(kind meterKind, id, serial, key string) (*consumptionR
|
|||
path = fmt.Sprintf("/v1/gas-meter-points/%s/meters/%s/consumption/", id, serial)
|
||||
}
|
||||
|
||||
// Consumption data can lag several hours, so use a 24h window and take the latest entry.
|
||||
// Consumption data can lag significantly — meters that report once a day can be
|
||||
// 24–48h behind — so use a wide window and take the latest entry. The interval
|
||||
// timestamp metric tells consumers how stale the latest reading actually is.
|
||||
result, err := doREST(path, url.Values{
|
||||
"period_from": {time.Now().UTC().Add(-24 * time.Hour).Format(time.RFC3339)},
|
||||
"period_from": {time.Now().UTC().Add(-7 * 24 * time.Hour).Format(time.RFC3339)},
|
||||
"order_by": {"period"},
|
||||
}, key)
|
||||
if err != nil {
|
||||
|
|
@ -32,7 +36,7 @@ func getLatestConsumption(kind meterKind, id, serial, key string) (*consumptionR
|
|||
|
||||
results := toSlice(result["results"])
|
||||
if len(results) == 0 {
|
||||
return nil, errors.New("no consumption data in last 24h")
|
||||
return nil, errNoConsumptionData
|
||||
}
|
||||
|
||||
latest, ok := results[len(results)-1].(map[string]any)
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ package main
|
|||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
|
|
@ -23,10 +23,15 @@ var (
|
|||
commit = "none"
|
||||
)
|
||||
|
||||
func fatal(msg string, args ...any) {
|
||||
slog.Error(msg, args...)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
func mustEnv(key string) string {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
log.Fatalf("required environment variable %s is not set", key)
|
||||
fatal("required environment variable not set", "var", key)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
|
@ -53,43 +58,63 @@ func parseInterval(key string, def time.Duration) time.Duration {
|
|||
}
|
||||
d, err := time.ParseDuration(v)
|
||||
if err != nil {
|
||||
log.Printf("invalid %s=%q, falling back to %s: %v", key, v, def, err)
|
||||
slog.Warn("invalid duration, falling back to default", "key", key, "value", v, "default", def, "err", err)
|
||||
return def
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func main() {
|
||||
logLevel := slog.LevelInfo
|
||||
badLevel := os.Getenv("LOG_LEVEL")
|
||||
if badLevel != "" {
|
||||
if err := logLevel.UnmarshalText([]byte(badLevel)); err == nil {
|
||||
badLevel = ""
|
||||
}
|
||||
}
|
||||
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: logLevel})))
|
||||
if badLevel != "" {
|
||||
slog.Warn("invalid LOG_LEVEL, falling back to info", "value", badLevel)
|
||||
}
|
||||
|
||||
apiKey = mustEnv("OCTOPUS_API_KEY")
|
||||
port = envOrDefault("PORT", "9359")
|
||||
pollInterval := parseInterval("POLL_INTERVAL", 60*time.Second)
|
||||
log.Printf("octopus_exporter %s (%s), poll interval %s", version, commit, pollInterval)
|
||||
slog.Info("starting octopus_exporter", "version", version, "commit", commit, "poll_interval", pollInterval)
|
||||
|
||||
token, err := getKrakenToken(apiKey)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to get initial token: %v", err)
|
||||
fatal("failed to get initial token", "err", err)
|
||||
}
|
||||
|
||||
log.Println("discovering meters from account...")
|
||||
slog.Info("discovering meters from account")
|
||||
candidates, err := getMeters(token)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to discover meters: %v", err)
|
||||
fatal("failed to discover meters", "err", err)
|
||||
}
|
||||
|
||||
elecMeter, err := resolveMeter(candidates, electricity)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to resolve electricity meter: %v", err)
|
||||
fatal("failed to resolve electricity meter", "err", err)
|
||||
}
|
||||
if elecMeter == nil {
|
||||
log.Fatal("no electricity smart meter found on account")
|
||||
fatal("no electricity smart meter found on account")
|
||||
}
|
||||
|
||||
gasMeter, err := resolveMeter(candidates, gas)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to resolve gas meter: %v", err)
|
||||
fatal("failed to resolve gas meter", "err", err)
|
||||
}
|
||||
if gasMeter == nil {
|
||||
log.Println("no gas smart meter found — gas metrics disabled")
|
||||
slog.Info("no gas smart meter found — gas metrics disabled")
|
||||
}
|
||||
|
||||
solarMeter, err := resolveMeter(candidates, solar)
|
||||
if err != nil {
|
||||
fatal("failed to resolve solar export meter", "err", err)
|
||||
}
|
||||
if solarMeter == nil {
|
||||
slog.Info("no solar export meter found — solar metrics disabled")
|
||||
}
|
||||
|
||||
// --- Metrics ---
|
||||
|
|
@ -149,6 +174,22 @@ func main() {
|
|||
toRegister = append(toRegister, gasDemand, gasLastRead, gasConsumption, gasConsumptionInterval, gasUnitRate, gasStandCharge)
|
||||
}
|
||||
|
||||
var (
|
||||
solarExportWatts prometheus.Gauge
|
||||
solarLastRead prometheus.Gauge
|
||||
solarExport prometheus.Gauge
|
||||
solarExportInterval prometheus.Gauge
|
||||
solarExportRate prometheus.Gauge
|
||||
)
|
||||
if solarMeter != nil {
|
||||
solarExportWatts = gauge("octopus_solar_export_watts", "Live electricity export (solar) to the grid in watts")
|
||||
solarLastRead = gauge("octopus_solar_last_read_timestamp", "Unix timestamp of last solar export reading")
|
||||
solarExport = gauge("octopus_solar_export_kwh", "Half-hourly electricity exported to the grid in kWh")
|
||||
solarExportInterval = gauge("octopus_solar_export_interval_timestamp", "Unix timestamp of the start of the latest solar export interval")
|
||||
solarExportRate = gauge("octopus_solar_export_rate_pence", "Current export (outgoing) unit rate in pence per kWh")
|
||||
toRegister = append(toRegister, solarExportWatts, solarLastRead, solarExport, solarExportInterval, solarExportRate)
|
||||
}
|
||||
|
||||
prometheus.MustRegister(toRegister...)
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
|
|
@ -166,9 +207,9 @@ func main() {
|
|||
ReadHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
go func() {
|
||||
log.Printf("serving metrics on :%s/metrics", port)
|
||||
slog.Info("serving metrics", "addr", ":"+port+"/metrics")
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Fatal(err)
|
||||
fatal("HTTP server failed", "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
|
|
@ -192,7 +233,7 @@ func main() {
|
|||
newT, e := getKrakenToken(apiKey)
|
||||
if e != nil {
|
||||
tokenMu.Unlock()
|
||||
log.Printf("token refresh failed: %v", e)
|
||||
slog.Error("token refresh failed", "err", e)
|
||||
return err
|
||||
}
|
||||
token = newT
|
||||
|
|
@ -213,8 +254,8 @@ func main() {
|
|||
failedAny atomic.Bool
|
||||
)
|
||||
|
||||
fail := func(format string, args ...any) {
|
||||
log.Printf(format, args...)
|
||||
fail := func(msg string, args ...any) {
|
||||
slog.Error(msg, args...)
|
||||
pollErrors.Inc()
|
||||
failedAny.Store(true)
|
||||
}
|
||||
|
|
@ -223,12 +264,24 @@ func main() {
|
|||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if err := fn(); err != nil {
|
||||
fail("%s error: %v", name, err)
|
||||
err := fn()
|
||||
switch {
|
||||
case err == nil:
|
||||
case errors.Is(err, errNoTelemetryData):
|
||||
slog.Warn("no telemetry data", "collector", name)
|
||||
case errors.Is(err, errNoConsumptionData):
|
||||
slog.Info("no consumption data", "collector", name)
|
||||
default:
|
||||
fail(name+" error", "err", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Solar export shares the physical meter/device with the import meter on
|
||||
// most installs, so we can read live export watts from the same telemetry
|
||||
// call rather than spending a second request.
|
||||
solarSharesDevice := solarMeter != nil && solarMeter.deviceID != "" && solarMeter.deviceID == elecMeter.deviceID
|
||||
|
||||
// Electricity telemetry (live demand)
|
||||
if elecMeter.deviceID != "" {
|
||||
collect("electricity telemetry", func() error {
|
||||
|
|
@ -241,6 +294,29 @@ func main() {
|
|||
if ts, err := time.Parse(time.RFC3339, reading.ReadAt); err == nil {
|
||||
elecLastRead.Set(float64(ts.Unix()))
|
||||
}
|
||||
if solarSharesDevice {
|
||||
solarExportWatts.Set(float64(reading.Export))
|
||||
if ts, err := time.Parse(time.RFC3339, reading.ReadAt); err == nil {
|
||||
solarLastRead.Set(float64(ts.Unix()))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Solar telemetry on a distinct export device (rare).
|
||||
if solarMeter != nil && solarMeter.deviceID != "" && !solarSharesDevice {
|
||||
collect("solar telemetry", func() error {
|
||||
return withToken(func(t string) error {
|
||||
reading, err := getLiveConsumption(t, solarMeter.deviceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
solarExportWatts.Set(float64(reading.Export))
|
||||
if ts, err := time.Parse(time.RFC3339, reading.ReadAt); err == nil {
|
||||
solarLastRead.Set(float64(ts.Unix()))
|
||||
}
|
||||
return nil
|
||||
})
|
||||
})
|
||||
|
|
@ -289,6 +365,19 @@ func main() {
|
|||
})
|
||||
}
|
||||
|
||||
// Solar half-hourly export (REST; uses the electricity meter-point path with the export MPAN)
|
||||
if solarMeter != nil && solarMeter.mpan != "" && solarMeter.serial != "" {
|
||||
collect("solar export", func() error {
|
||||
c, err := getLatestConsumption(electricity, solarMeter.mpan, solarMeter.serial, apiKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
solarExport.Set(c.KWh)
|
||||
solarExportInterval.Set(float64(c.IntervalStart.Unix()))
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// Tariff rates (result needed for optional agile lookup after wg.Wait)
|
||||
var collectedRates *tariffRates
|
||||
collect("rates", func() error {
|
||||
|
|
@ -322,7 +411,7 @@ func main() {
|
|||
if collectedRates.ElectricityIsAgile && collectedRates.ElectricityProductCode != "" && collectedRates.ElectricityTariffCode != "" {
|
||||
agileRate, err := getCurrentAgileRate(collectedRates.ElectricityProductCode, collectedRates.ElectricityTariffCode, apiKey)
|
||||
if err != nil {
|
||||
fail("agile rate error: %v", err)
|
||||
fail("agile rate error", "err", err)
|
||||
} else {
|
||||
unitRate = agileRate
|
||||
}
|
||||
|
|
@ -333,6 +422,9 @@ func main() {
|
|||
gasUnitRate.Set(collectedRates.GasUnitRate)
|
||||
gasStandCharge.Set(collectedRates.GasStandingCharge)
|
||||
}
|
||||
if solarMeter != nil && collectedRates.SolarHasExport {
|
||||
solarExportRate.Set(collectedRates.SolarExportRate)
|
||||
}
|
||||
}
|
||||
|
||||
if failedAny.Load() {
|
||||
|
|
@ -347,11 +439,11 @@ func main() {
|
|||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.Println("shutdown signal received, draining HTTP server")
|
||||
slog.Info("shutdown signal received, draining HTTP server")
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||
log.Printf("HTTP shutdown error: %v", err)
|
||||
slog.Error("HTTP shutdown error", "err", err)
|
||||
}
|
||||
return
|
||||
case <-ticker.C:
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ package main
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"os"
|
||||
)
|
||||
|
||||
|
|
@ -11,6 +11,9 @@ type meterKind string
|
|||
const (
|
||||
electricity meterKind = "electricity"
|
||||
gas meterKind = "gas"
|
||||
// solar is an electricity export meter point (direction EXPORT) — a separate
|
||||
// MPAN that usually shares the physical meter/device with the import meter.
|
||||
solar meterKind = "solar"
|
||||
)
|
||||
|
||||
type meterCandidate struct {
|
||||
|
|
@ -33,6 +36,7 @@ func getMeters(token string) ([]meterCandidate, error) {
|
|||
Query: `{ viewer { accounts { ... on AccountType { properties {
|
||||
electricityMeterPoints {
|
||||
mpan
|
||||
direction
|
||||
meters {
|
||||
serialNumber
|
||||
smartDevices { deviceId }
|
||||
|
|
@ -70,6 +74,12 @@ func getMeters(token string) ([]meterCandidate, error) {
|
|||
continue
|
||||
}
|
||||
mpan, _ := mpPoint["mpan"].(string)
|
||||
// EXPORT meter points are solar generation export; everything else
|
||||
// (IMPORT or unknown/legacy with no direction) is grid import.
|
||||
kind := electricity
|
||||
if dir, _ := mpPoint["direction"].(string); dir == "EXPORT" {
|
||||
kind = solar
|
||||
}
|
||||
for _, m := range toSlice(mpPoint["meters"]) {
|
||||
meterMap, ok := m.(map[string]any)
|
||||
if !ok {
|
||||
|
|
@ -84,12 +94,12 @@ func getMeters(token string) ([]meterCandidate, error) {
|
|||
}
|
||||
deviceID, _ := dMap["deviceId"].(string)
|
||||
if deviceID != "" {
|
||||
candidates = append(candidates, meterCandidate{kind: electricity, mpan: mpan, serial: serial, deviceID: deviceID})
|
||||
candidates = append(candidates, meterCandidate{kind: kind, mpan: mpan, serial: serial, deviceID: deviceID})
|
||||
}
|
||||
}
|
||||
// Include meters without smart devices so we can still use the REST consumption endpoint.
|
||||
if len(devices) == 0 && serial != "" {
|
||||
candidates = append(candidates, meterCandidate{kind: electricity, mpan: mpan, serial: serial})
|
||||
candidates = append(candidates, meterCandidate{kind: kind, mpan: mpan, serial: serial})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -141,6 +151,10 @@ func resolveMeter(candidates []meterCandidate, kind meterKind) (*resolvedMeter,
|
|||
wantDeviceID = os.Getenv("OCTOPUS_GAS_DEVICE_ID")
|
||||
wantID = os.Getenv("OCTOPUS_GAS_MPRN")
|
||||
wantSerial = os.Getenv("OCTOPUS_GAS_SERIAL")
|
||||
case solar:
|
||||
wantDeviceID = os.Getenv("OCTOPUS_SOLAR_DEVICE_ID")
|
||||
wantID = os.Getenv("OCTOPUS_SOLAR_MPAN")
|
||||
wantSerial = os.Getenv("OCTOPUS_SOLAR_SERIAL")
|
||||
}
|
||||
|
||||
for _, c := range candidates {
|
||||
|
|
@ -151,7 +165,7 @@ func resolveMeter(candidates []meterCandidate, kind meterKind) (*resolvedMeter,
|
|||
continue
|
||||
}
|
||||
if wantID != "" {
|
||||
if kind == electricity && c.mpan != wantID {
|
||||
if (kind == electricity || kind == solar) && c.mpan != wantID {
|
||||
continue
|
||||
}
|
||||
if kind == gas && c.mprn != wantID {
|
||||
|
|
@ -165,9 +179,11 @@ func resolveMeter(candidates []meterCandidate, kind meterKind) (*resolvedMeter,
|
|||
m := &resolvedMeter{deviceID: c.deviceID, mpan: c.mpan, mprn: c.mprn, serial: c.serial}
|
||||
switch kind {
|
||||
case electricity:
|
||||
log.Printf("using electricity meter: MPAN=%s serial=%s deviceID=%s", m.mpan, m.serial, m.deviceID)
|
||||
slog.Info("using electricity meter", "mpan", m.mpan, "serial", m.serial, "device_id", m.deviceID)
|
||||
case gas:
|
||||
log.Printf("using gas meter: MPRN=%s serial=%s deviceID=%s", m.mprn, m.serial, m.deviceID)
|
||||
slog.Info("using gas meter", "mprn", m.mprn, "serial", m.serial, "device_id", m.deviceID)
|
||||
case solar:
|
||||
slog.Info("using solar export meter", "mpan", m.mpan, "serial", m.serial, "device_id", m.deviceID)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -173,6 +173,56 @@ func TestGetMeters_MeterWithoutSmartDevice(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestGetMeters_ExportClassifiedAsSolar(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprint(w, `{"data":{"viewer":{"accounts":[{"properties":[{
|
||||
"electricityMeterPoints":[
|
||||
{"mpan":"1900000000001","direction":"EXPORT","meters":[{"serialNumber":"A001","smartDevices":[{"deviceId":"dev1"}]}]},
|
||||
{"mpan":"1900000000002","direction":"IMPORT","meters":[{"serialNumber":"A001","smartDevices":[{"deviceId":"dev1"}]}]}
|
||||
],
|
||||
"gasMeterPoints":[]
|
||||
}]}]}}}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
octopusGraphQL = srv.URL + "/"
|
||||
|
||||
candidates, err := getMeters("token")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
byKind := map[meterKind]meterCandidate{}
|
||||
for _, c := range candidates {
|
||||
byKind[c.kind] = c
|
||||
}
|
||||
if byKind[electricity].mpan != "1900000000002" {
|
||||
t.Errorf("electricity mpan: got %q, want 1900000000002", byKind[electricity].mpan)
|
||||
}
|
||||
if byKind[solar].mpan != "1900000000001" {
|
||||
t.Errorf("solar mpan: got %q, want 1900000000001", byKind[solar].mpan)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMeter_Solar(t *testing.T) {
|
||||
t.Setenv("OCTOPUS_SOLAR_DEVICE_ID", "")
|
||||
t.Setenv("OCTOPUS_SOLAR_MPAN", "")
|
||||
t.Setenv("OCTOPUS_SOLAR_SERIAL", "")
|
||||
|
||||
candidates := []meterCandidate{
|
||||
{kind: electricity, mpan: "1900000000002", serial: "A001", deviceID: "dev1"},
|
||||
{kind: solar, mpan: "1900000000001", serial: "A001", deviceID: "dev1"},
|
||||
}
|
||||
m, err := resolveMeter(candidates, solar)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if m == nil {
|
||||
t.Fatal("expected solar meter, got nil")
|
||||
}
|
||||
if m.mpan != "1900000000001" {
|
||||
t.Errorf("got mpan %q, want 1900000000001", m.mpan)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMeters_BothElectricityAndGas(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprint(w, `{"data":{"viewer":{"accounts":[{"properties":[{
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
package main
|
||||
|
||||
import "time"
|
||||
|
||||
type tariffRates struct {
|
||||
ElectricityUnitRate float64
|
||||
ElectricityStandingCharge float64
|
||||
|
|
@ -8,20 +10,23 @@ type tariffRates struct {
|
|||
ElectricityIsAgile bool
|
||||
GasUnitRate float64
|
||||
GasStandingCharge float64
|
||||
SolarExportRate float64
|
||||
SolarHasExport bool
|
||||
}
|
||||
|
||||
func getRates(token string) (*tariffRates, error) {
|
||||
result, err := doGraphQL(gqlRequest{
|
||||
Query: `{ viewer { accounts { ... on AccountType { properties {
|
||||
electricityMeterPoints {
|
||||
agreements { validTo tariff {
|
||||
direction
|
||||
agreements { validFrom validTo tariff {
|
||||
... on StandardTariff { unitRate standingCharge productCode tariffCode }
|
||||
... on HalfHourlyTariff { standingCharge productCode tariffCode }
|
||||
... on PrepayTariff { unitRate standingCharge productCode tariffCode }
|
||||
} }
|
||||
}
|
||||
gasMeterPoints {
|
||||
agreements { validTo tariff { unitRate standingCharge } }
|
||||
agreements { validFrom validTo tariff { unitRate standingCharge } }
|
||||
}
|
||||
} } } } }`,
|
||||
}, token)
|
||||
|
|
@ -43,15 +48,23 @@ func getRates(token string) (*tariffRates, error) {
|
|||
}
|
||||
|
||||
for _, mp := range toSlice(pm["electricityMeterPoints"]) {
|
||||
if tariff := activeAgreementTariff(mp); tariff != nil {
|
||||
rates.ElectricityUnitRate, _ = tariff["unitRate"].(float64)
|
||||
rates.ElectricityStandingCharge, _ = tariff["standingCharge"].(float64)
|
||||
rates.ElectricityProductCode, _ = tariff["productCode"].(string)
|
||||
rates.ElectricityTariffCode, _ = tariff["tariffCode"].(string)
|
||||
// HalfHourlyTariff has no unitRate field — detect Agile by absence.
|
||||
if _, hasUnit := tariff["unitRate"]; !hasUnit {
|
||||
rates.ElectricityIsAgile = true
|
||||
}
|
||||
tariff := activeAgreementTariff(mp)
|
||||
if tariff == nil {
|
||||
continue
|
||||
}
|
||||
mpm, _ := mp.(map[string]any)
|
||||
if dir, _ := mpm["direction"].(string); dir == "EXPORT" {
|
||||
rates.SolarExportRate, _ = tariff["unitRate"].(float64)
|
||||
rates.SolarHasExport = true
|
||||
continue
|
||||
}
|
||||
rates.ElectricityUnitRate, _ = tariff["unitRate"].(float64)
|
||||
rates.ElectricityStandingCharge, _ = tariff["standingCharge"].(float64)
|
||||
rates.ElectricityProductCode, _ = tariff["productCode"].(string)
|
||||
rates.ElectricityTariffCode, _ = tariff["tariffCode"].(string)
|
||||
// HalfHourlyTariff has no unitRate field — detect Agile by absence.
|
||||
if _, hasUnit := tariff["unitRate"]; !hasUnit {
|
||||
rates.ElectricityIsAgile = true
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -67,21 +80,33 @@ func getRates(token string) (*tariffRates, error) {
|
|||
return rates, nil
|
||||
}
|
||||
|
||||
// activeAgreementTariff returns the tariff map for the agreement with validTo == null.
|
||||
// activeAgreementTariff returns the tariff for the agreement in effect now:
|
||||
// validFrom in the past (or absent) and validTo null or in the future. Fixed-term
|
||||
// tariffs carry a future validTo (their contract end), so matching only validTo==null
|
||||
// would miss them.
|
||||
func activeAgreementTariff(meterPoint any) map[string]any {
|
||||
mp, ok := meterPoint.(map[string]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
now := time.Now()
|
||||
for _, ag := range toSlice(mp["agreements"]) {
|
||||
agm, ok := ag.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if agm["validTo"] == nil {
|
||||
tariff, _ := agm["tariff"].(map[string]any)
|
||||
return tariff
|
||||
if from, ok := agm["validFrom"].(string); ok {
|
||||
if t, err := time.Parse(time.RFC3339, from); err == nil && t.After(now) {
|
||||
continue // not started yet
|
||||
}
|
||||
}
|
||||
if to, ok := agm["validTo"].(string); ok {
|
||||
if t, err := time.Parse(time.RFC3339, to); err == nil && !t.After(now) {
|
||||
continue // already ended
|
||||
}
|
||||
}
|
||||
tariff, _ := agm["tariff"].(map[string]any)
|
||||
return tariff
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,33 @@ func TestActiveAgreementTariff_ActiveFound(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestActiveAgreementTariff_FixedTermFutureValidTo(t *testing.T) {
|
||||
// Fixed-term tariffs carry a future validTo (contract end), not null.
|
||||
mp := map[string]any{
|
||||
"agreements": []any{
|
||||
map[string]any{"validFrom": "2020-01-01T00:00:00Z", "validTo": "2099-01-01T00:00:00Z", "tariff": map[string]any{"unitRate": 23.37}},
|
||||
},
|
||||
}
|
||||
tariff := activeAgreementTariff(mp)
|
||||
if tariff == nil {
|
||||
t.Fatal("expected tariff for in-effect fixed-term agreement, got nil")
|
||||
}
|
||||
if tariff["unitRate"].(float64) != 23.37 {
|
||||
t.Errorf("got unitRate %v, want 23.37", tariff["unitRate"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestActiveAgreementTariff_NotYetStarted(t *testing.T) {
|
||||
mp := map[string]any{
|
||||
"agreements": []any{
|
||||
map[string]any{"validFrom": "2099-01-01T00:00:00Z", "validTo": nil, "tariff": map[string]any{"unitRate": 99.0}},
|
||||
},
|
||||
}
|
||||
if tariff := activeAgreementTariff(mp); tariff != nil {
|
||||
t.Errorf("expected nil for not-yet-started agreement, got %v", tariff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActiveAgreementTariff_NoneActive(t *testing.T) {
|
||||
mp := map[string]any{
|
||||
"agreements": []any{
|
||||
|
|
@ -95,6 +122,40 @@ func TestGetRates_AgileTariff(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestGetRates_SplitsImportAndExport(t *testing.T) {
|
||||
// Export meter point listed first (as the live API returns it) must not
|
||||
// clobber the import unit rate.
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprint(w, `{"data":{"viewer":{"accounts":[{"properties":[{
|
||||
"electricityMeterPoints":[
|
||||
{"direction":"EXPORT","agreements":[{"validTo":null,"tariff":{
|
||||
"unitRate":12.0,"standingCharge":0.0,"productCode":"OUTGOING-VAR-24-10-26","tariffCode":"E-1R-OUTGOING-VAR-24-10-26-J"
|
||||
}}]},
|
||||
{"direction":"IMPORT","agreements":[{"validFrom":"2020-01-01T00:00:00Z","validTo":"2099-01-01T00:00:00Z","tariff":{
|
||||
"unitRate":23.37,"standingCharge":46.63,"productCode":"OE-FIX-12M","tariffCode":"E-1R-OE-FIX-12M-J"
|
||||
}}]}
|
||||
],
|
||||
"gasMeterPoints":[]
|
||||
}]}]}}}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
octopusGraphQL = srv.URL + "/"
|
||||
|
||||
rates, err := getRates("token")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if rates.ElectricityUnitRate != 23.37 {
|
||||
t.Errorf("import unit rate: got %v, want 23.37", rates.ElectricityUnitRate)
|
||||
}
|
||||
if !rates.SolarHasExport {
|
||||
t.Error("expected SolarHasExport=true")
|
||||
}
|
||||
if rates.SolarExportRate != 12.0 {
|
||||
t.Errorf("export rate: got %v, want 12.0", rates.SolarExportRate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRates_WithGas(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprint(w, `{"data":{"viewer":{"accounts":[{"properties":[{
|
||||
|
|
|
|||
|
|
@ -6,17 +6,20 @@ import (
|
|||
"fmt"
|
||||
)
|
||||
|
||||
var errNoTelemetryData = errors.New("no telemetry data returned")
|
||||
|
||||
type telemetryReading struct {
|
||||
ReadAt string `json:"readAt"`
|
||||
Consumption jsonFloat `json:"consumption"`
|
||||
Demand jsonFloat `json:"demand"`
|
||||
Export jsonFloat `json:"export"`
|
||||
}
|
||||
|
||||
func getLiveConsumption(token, deviceID string) (*telemetryReading, error) {
|
||||
result, err := doGraphQL(gqlRequest{
|
||||
OperationName: "getSmartMeterTelemetry",
|
||||
Variables: map[string]any{"meterDeviceId": deviceID},
|
||||
Query: "query getSmartMeterTelemetry($meterDeviceId: String!, $start: DateTime, $end: DateTime, $grouping: TelemetryGrouping) {\n smartMeterTelemetry(deviceId: $meterDeviceId, start: $start, end: $end, grouping: $grouping) {\n readAt\n consumption\n demand\n __typename\n }\n}\n",
|
||||
Query: "query getSmartMeterTelemetry($meterDeviceId: String!, $start: DateTime, $end: DateTime, $grouping: TelemetryGrouping) {\n smartMeterTelemetry(deviceId: $meterDeviceId, start: $start, end: $end, grouping: $grouping) {\n readAt\n consumption\n demand\n export\n __typename\n }\n}\n",
|
||||
}, token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -28,7 +31,7 @@ func getLiveConsumption(token, deviceID string) (*telemetryReading, error) {
|
|||
}
|
||||
telemetry := toSlice(data["smartMeterTelemetry"])
|
||||
if len(telemetry) == 0 {
|
||||
return nil, errors.New("no telemetry data returned")
|
||||
return nil, errNoTelemetryData
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(telemetry[0])
|
||||
|
|
|
|||
17
go.mod
17
go.mod
|
|
@ -2,14 +2,17 @@ module github.com/pez/octopus_exporter
|
|||
|
||||
go 1.24
|
||||
|
||||
require github.com/prometheus/client_golang v1.19.1
|
||||
require github.com/prometheus/client_golang v1.23.2
|
||||
|
||||
require (
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||
github.com/prometheus/client_model v0.5.0 // indirect
|
||||
github.com/prometheus/common v0.48.0 // indirect
|
||||
github.com/prometheus/procfs v0.12.0 // indirect
|
||||
golang.org/x/sys v0.17.0 // indirect
|
||||
google.golang.org/protobuf v1.33.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.66.1 // indirect
|
||||
github.com/prometheus/procfs v0.16.1 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||
golang.org/x/sys v0.35.0 // indirect
|
||||
google.golang.org/protobuf v1.36.8 // indirect
|
||||
)
|
||||
|
|
|
|||
58
go.sum
58
go.sum
|
|
@ -1,20 +1,46 @@
|
|||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
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/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE=
|
||||
github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho=
|
||||
github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw=
|
||||
github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI=
|
||||
github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSzKKE=
|
||||
github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc=
|
||||
github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
|
||||
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
|
||||
golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
|
||||
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
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/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
|
||||
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
|
||||
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
|
||||
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
|
||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
|
||||
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
|
||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
|
||||
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue