Add go-e wallbox charger integration
- charger.go: polls go-e /api/status?filter=nrg,eto every 10 s - db.go: WriteCharger() inserts into charger hypertable - config.go: ChargerConf with host field - main.go: polls charger in parallel with inverter and meters - schema.sql: charger table + charger_10m/1h/daily aggregates + policies Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
43
charger.go
Normal file
43
charger.go
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ChargerReading struct {
|
||||||
|
Time time.Time
|
||||||
|
Power float32 // W total
|
||||||
|
EtoWh int64 // Wh cumulative
|
||||||
|
}
|
||||||
|
|
||||||
|
type chargerStatus struct {
|
||||||
|
Nrg []float64 `json:"nrg"` // [0..15] voltages/currents/powers; [11] = total W
|
||||||
|
Eto int64 `json:"eto"` // cumulative Wh
|
||||||
|
}
|
||||||
|
|
||||||
|
var chargerClient = &http.Client{Timeout: 5 * time.Second}
|
||||||
|
|
||||||
|
func PollCharger(host string) (ChargerReading, error) {
|
||||||
|
url := "http://" + host + "/api/status?filter=nrg,eto"
|
||||||
|
resp, err := chargerClient.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return ChargerReading{}, fmt.Errorf("get: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var s chargerStatus
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&s); err != nil {
|
||||||
|
return ChargerReading{}, fmt.Errorf("decode: %w", err)
|
||||||
|
}
|
||||||
|
if len(s.Nrg) < 12 {
|
||||||
|
return ChargerReading{}, fmt.Errorf("nrg array too short (%d)", len(s.Nrg))
|
||||||
|
}
|
||||||
|
return ChargerReading{
|
||||||
|
Time: time.Now(),
|
||||||
|
Power: float32(s.Nrg[11]),
|
||||||
|
EtoWh: s.Eto,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
13
config.go
13
config.go
@@ -6,10 +6,15 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
SampleRate int `json:"sample_rate"` // seconds
|
SampleRate int `json:"sample_rate"` // seconds
|
||||||
AlphaEss AlphaConf `json:"alphaess"`
|
AlphaEss AlphaConf `json:"alphaess"`
|
||||||
MQTT MQTTConf `json:"mqtt"`
|
MQTT MQTTConf `json:"mqtt"`
|
||||||
DB DBConf `json:"db"`
|
Charger ChargerConf `json:"charger"`
|
||||||
|
DB DBConf `json:"db"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChargerConf struct {
|
||||||
|
Host string `json:"host"` // hostname or IP of go-e charger
|
||||||
}
|
}
|
||||||
|
|
||||||
type AlphaConf struct {
|
type AlphaConf struct {
|
||||||
|
|||||||
8
db.go
8
db.go
@@ -50,6 +50,14 @@ func (db *DB) WriteMeter(ctx context.Context, t time.Time, r MeterReading) error
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (db *DB) WriteCharger(ctx context.Context, r ChargerReading) error {
|
||||||
|
_, err := db.pool.Exec(ctx,
|
||||||
|
`INSERT INTO charger (time, power, eto_wh) VALUES ($1, $2, $3)`,
|
||||||
|
r.Time, r.Power, r.EtoWh,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func (db *DB) Close() {
|
func (db *DB) Close() {
|
||||||
db.pool.Close()
|
db.pool.Close()
|
||||||
}
|
}
|
||||||
|
|||||||
37
main.go
37
main.go
@@ -44,7 +44,7 @@ func main() {
|
|||||||
defer meters.Close()
|
defer meters.Close()
|
||||||
|
|
||||||
interval := time.Duration(cfg.SampleRate) * time.Second
|
interval := time.Duration(cfg.SampleRate) * time.Second
|
||||||
go runPollLoop(ctx, alpha, meters, db, interval)
|
go runPollLoop(ctx, alpha, meters, cfg.Charger.Host, db, interval)
|
||||||
|
|
||||||
sig := make(chan os.Signal, 1)
|
sig := make(chan os.Signal, 1)
|
||||||
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
|
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
|
||||||
@@ -52,7 +52,7 @@ func main() {
|
|||||||
logger.Println("shutting down")
|
logger.Println("shutting down")
|
||||||
}
|
}
|
||||||
|
|
||||||
func runPollLoop(ctx context.Context, alpha *AlphaEss, meters *MeterPoller, db *DB, interval time.Duration) {
|
func runPollLoop(ctx context.Context, alpha *AlphaEss, meters *MeterPoller, chargerHost string, db *DB, interval time.Duration) {
|
||||||
ticker := time.NewTicker(interval)
|
ticker := time.NewTicker(interval)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
for {
|
for {
|
||||||
@@ -60,22 +60,22 @@ func runPollLoop(ctx context.Context, alpha *AlphaEss, meters *MeterPoller, db *
|
|||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
poll(ctx, alpha, meters, db)
|
poll(ctx, alpha, meters, chargerHost, db)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func poll(ctx context.Context, alpha *AlphaEss, meters *MeterPoller, db *DB) {
|
func poll(ctx context.Context, alpha *AlphaEss, meters *MeterPoller, chargerHost string, db *DB) {
|
||||||
t := time.Now()
|
t := time.Now()
|
||||||
|
|
||||||
// Poll AlphaEss and all SDM630 meters in parallel.
|
|
||||||
var (
|
var (
|
||||||
inverterData *InverterData
|
inverterData *InverterData
|
||||||
meterData []MeterReading
|
meterData []MeterReading
|
||||||
wg sync.WaitGroup
|
chargerData *ChargerReading
|
||||||
|
wg sync.WaitGroup
|
||||||
)
|
)
|
||||||
|
|
||||||
wg.Add(2)
|
wg.Add(3)
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
d, err := alpha.Poll()
|
d, err := alpha.Poll()
|
||||||
@@ -89,9 +89,21 @@ func poll(ctx context.Context, alpha *AlphaEss, meters *MeterPoller, db *DB) {
|
|||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
meterData = meters.PollAll()
|
meterData = meters.PollAll()
|
||||||
}()
|
}()
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
if chargerHost == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r, err := PollCharger(chargerHost)
|
||||||
|
if err != nil {
|
||||||
|
logger.Printf("charger: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.Time = t
|
||||||
|
chargerData = &r
|
||||||
|
}()
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
|
|
||||||
// Write everything with the same timestamp t.
|
|
||||||
if inverterData != nil {
|
if inverterData != nil {
|
||||||
if err := db.WriteInverter(ctx, t, inverterData); err != nil {
|
if err := db.WriteInverter(ctx, t, inverterData); err != nil {
|
||||||
logger.Printf("write inverter: %v", err)
|
logger.Printf("write inverter: %v", err)
|
||||||
@@ -102,4 +114,9 @@ func poll(ctx context.Context, alpha *AlphaEss, meters *MeterPoller, db *DB) {
|
|||||||
logger.Printf("write meter %s: %v", r.Device, err)
|
logger.Printf("write meter %s: %v", r.Device, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if chargerData != nil {
|
||||||
|
if err := db.WriteCharger(ctx, *chargerData); err != nil {
|
||||||
|
logger.Printf("write charger: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
50
schema.sql
50
schema.sql
@@ -98,10 +98,40 @@ SELECT time_bucket('1 day', time) AS bucket,
|
|||||||
FROM power_meter
|
FROM power_meter
|
||||||
GROUP BY bucket, device;
|
GROUP BY bucket, device;
|
||||||
|
|
||||||
|
-- Written by energy-collector every 10 s
|
||||||
|
CREATE TABLE charger (
|
||||||
|
time TIMESTAMPTZ NOT NULL,
|
||||||
|
power REAL, -- W total charging power
|
||||||
|
eto_wh BIGINT -- Wh cumulative total energy (from go-e eto field)
|
||||||
|
);
|
||||||
|
SELECT create_hypertable('charger', 'time', chunk_time_interval => INTERVAL '7 days');
|
||||||
|
|
||||||
|
CREATE MATERIALIZED VIEW charger_10m
|
||||||
|
WITH (timescaledb.continuous, timescaledb.materialized_only = false) AS
|
||||||
|
SELECT time_bucket('10 minutes', time) AS bucket,
|
||||||
|
AVG(power) AS power
|
||||||
|
FROM charger
|
||||||
|
GROUP BY bucket;
|
||||||
|
|
||||||
|
CREATE MATERIALIZED VIEW charger_1h
|
||||||
|
WITH (timescaledb.continuous, timescaledb.materialized_only = false) AS
|
||||||
|
SELECT time_bucket('1 hour', bucket) AS bucket,
|
||||||
|
AVG(power) AS power
|
||||||
|
FROM charger_10m
|
||||||
|
GROUP BY 1;
|
||||||
|
|
||||||
|
CREATE MATERIALIZED VIEW charger_daily
|
||||||
|
WITH (timescaledb.continuous, timescaledb.materialized_only = false) AS
|
||||||
|
SELECT time_bucket('1 day', time) AS bucket,
|
||||||
|
last(eto_wh, time) AS eto_wh
|
||||||
|
FROM charger
|
||||||
|
GROUP BY bucket;
|
||||||
|
|
||||||
-- ── Retention — keep 30 days of raw data; aggregates stay forever ─────────────
|
-- ── Retention — keep 30 days of raw data; aggregates stay forever ─────────────
|
||||||
|
|
||||||
SELECT add_retention_policy('inverter', INTERVAL '30 days');
|
SELECT add_retention_policy('inverter', INTERVAL '30 days');
|
||||||
SELECT add_retention_policy('power_meter', INTERVAL '30 days');
|
SELECT add_retention_policy('power_meter', INTERVAL '30 days');
|
||||||
|
SELECT add_retention_policy('charger', INTERVAL '30 days');
|
||||||
|
|
||||||
-- ── Refresh policies ──────────────────────────────────────────────────────────
|
-- ── Refresh policies ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -135,6 +165,22 @@ SELECT add_continuous_aggregate_policy('power_meter_daily',
|
|||||||
end_offset => INTERVAL '1 day',
|
end_offset => INTERVAL '1 day',
|
||||||
schedule_interval => INTERVAL '1 day');
|
schedule_interval => INTERVAL '1 day');
|
||||||
|
|
||||||
-- Grant SELECT on all tables and views (including continuous aggregates) to energy.
|
SELECT add_continuous_aggregate_policy('charger_10m',
|
||||||
-- Run after all views are created so the grant covers them.
|
start_offset => INTERVAL '1 hour',
|
||||||
|
end_offset => INTERVAL '10 minutes',
|
||||||
|
schedule_interval => INTERVAL '10 minutes');
|
||||||
|
|
||||||
|
SELECT add_continuous_aggregate_policy('charger_1h',
|
||||||
|
start_offset => INTERVAL '3 hours',
|
||||||
|
end_offset => INTERVAL '1 hour',
|
||||||
|
schedule_interval => INTERVAL '1 hour');
|
||||||
|
|
||||||
|
SELECT add_continuous_aggregate_policy('charger_daily',
|
||||||
|
start_offset => INTERVAL '3 days',
|
||||||
|
end_offset => INTERVAL '1 day',
|
||||||
|
schedule_interval => INTERVAL '1 day');
|
||||||
|
|
||||||
|
-- Grant privileges after all objects are created.
|
||||||
|
-- INSERT on raw hypertables (collector writes), SELECT on everything else (frontend reads).
|
||||||
|
GRANT INSERT ON inverter, power_meter, charger TO energy;
|
||||||
GRANT SELECT ON ALL TABLES IN SCHEMA public TO energy;
|
GRANT SELECT ON ALL TABLES IN SCHEMA public TO energy;
|
||||||
|
|||||||
Reference in New Issue
Block a user