Add go-e wallbox charger to live and statistics views

- PowerPoint/EnergyBar: add charger/charge fields
- getPower: query charger_10m/charger_1h alongside PV and meters
- getBars: compute daily charge kWh via LAG on charger_daily.eto_wh
- EnergyView: green Charger series in live power chart
- StatisticsView: purple Charge bars in statistics chart

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-18 11:31:10 +02:00
parent 9fa7d36610
commit 74f7266632
4 changed files with 60 additions and 30 deletions

View File

@@ -15,13 +15,15 @@ type EnergyBar struct {
Sell float64 `json:"sell"`
Produce float64 `json:"produce"`
Consume float64 `json:"consume"`
Charge float64 `json:"charge"`
}
type PowerPoint struct {
Time time.Time `json:"time"`
PV float64 `json:"pv"`
Barn float64 `json:"barn"`
House float64 `json:"house"`
Time time.Time `json:"time"`
PV float64 `json:"pv"`
Barn float64 `json:"barn"`
House float64 `json:"house"`
Charger float64 `json:"charger"`
}
type BatteryPoint struct {
@@ -36,16 +38,16 @@ var periodDefaults = map[string]int{
}
// viewForRange picks the right continuous aggregate views for the given time range.
func viewForRange(timeRange string) (invView, meterView, interval string) {
func viewForRange(timeRange string) (invView, meterView, chargerView, interval string) {
switch timeRange {
case "1h":
return "inverter_10m", "power_meter_10m", "1 hour"
return "inverter_10m", "power_meter_10m", "charger_10m", "1 hour"
case "6h":
return "inverter_10m", "power_meter_10m", "6 hours"
return "inverter_10m", "power_meter_10m", "charger_10m", "6 hours"
case "7d":
return "inverter_1h", "power_meter_1h", "7 days"
return "inverter_1h", "power_meter_1h", "charger_1h", "7 days"
default: // "24h" and anything else
return "inverter_10m", "power_meter_10m", "24 hours"
return "inverter_10m", "power_meter_10m", "charger_10m", "24 hours"
}
}
@@ -65,7 +67,7 @@ func sortedUnion(maps ...map[time.Time]float64) []time.Time {
}
func getPower(ctx context.Context, pool *pgxpool.Pool, timeRange string) ([]PowerPoint, error) {
invView, meterView, interval := viewForRange(timeRange)
invView, meterView, chargerView, interval := viewForRange(timeRange)
type colRow struct {
bucket time.Time
@@ -97,22 +99,28 @@ func getPower(ctx context.Context, pool *pgxpool.Pool, timeRange string) ([]Powe
barnSQL := fmt.Sprintf(
`SELECT bucket, COALESCE(total_power, 0) FROM %s WHERE device = 'barn' AND bucket >= NOW() - '%s'::interval ORDER BY bucket`,
meterView, interval)
chargerSQL := fmt.Sprintf(
`SELECT bucket, COALESCE(power, 0) FROM %s WHERE bucket >= NOW() - '%s'::interval ORDER BY bucket`,
chargerView, interval)
type result struct {
m map[time.Time]float64
err error
}
pvCh := make(chan result, 1)
houseCh := make(chan result, 1)
barnCh := make(chan result, 1)
pvCh := make(chan result, 1)
houseCh := make(chan result, 1)
barnCh := make(chan result, 1)
chargerCh := make(chan result, 1)
go func() { m, e := queryCol(pvSQL); pvCh <- result{m, e} }()
go func() { m, e := queryCol(houseSQL); houseCh <- result{m, e} }()
go func() { m, e := queryCol(barnSQL); barnCh <- result{m, e} }()
go func() { m, e := queryCol(chargerSQL); chargerCh <- result{m, e} }()
pvRes := <-pvCh
houseRes := <-houseCh
barnRes := <-barnCh
pvRes := <-pvCh
houseRes := <-houseCh
barnRes := <-barnCh
chargerRes := <-chargerCh
if pvRes.err != nil {
return nil, pvRes.err
@@ -123,22 +131,26 @@ func getPower(ctx context.Context, pool *pgxpool.Pool, timeRange string) ([]Powe
if barnRes.err != nil {
return nil, barnRes.err
}
if chargerRes.err != nil {
return nil, chargerRes.err
}
times := sortedUnion(pvRes.m, houseRes.m, barnRes.m)
times := sortedUnion(pvRes.m, houseRes.m, barnRes.m, chargerRes.m)
pts := make([]PowerPoint, 0, len(times))
for _, t := range times {
pts = append(pts, PowerPoint{
Time: t,
PV: pvRes.m[t],
House: houseRes.m[t],
Barn: barnRes.m[t],
Time: t,
PV: pvRes.m[t],
House: houseRes.m[t],
Barn: barnRes.m[t],
Charger: chargerRes.m[t],
})
}
return pts, nil
}
func getBattery(ctx context.Context, pool *pgxpool.Pool, timeRange string) ([]BatteryPoint, error) {
invView, _, interval := viewForRange(timeRange)
invView, _, _, interval := viewForRange(timeRange)
sql := fmt.Sprintf(
`SELECT bucket, COALESCE(battery_soc, 0) FROM %s WHERE bucket >= NOW() - '%s'::interval ORDER BY bucket`,
@@ -206,6 +218,14 @@ barn_last AS (
WHERE device = 'barn' AND bucket >= NOW() - '%[2]s'::interval
ORDER BY date_trunc('%[1]s', bucket), bucket DESC
),
charger_last AS (
SELECT DISTINCT ON (date_trunc('%[1]s', bucket))
date_trunc('%[1]s', bucket) AS period,
eto_wh
FROM charger_daily
WHERE bucket >= NOW() - '%[2]s'::interval
ORDER BY date_trunc('%[1]s', bucket), bucket DESC
),
joined AS (
SELECT
i.period,
@@ -218,17 +238,21 @@ joined AS (
h.import_kwh AS house_kwh,
LAG(h.import_kwh) OVER (ORDER BY i.period) AS prev_house,
b.import_kwh AS barn_kwh,
LAG(b.import_kwh) OVER (ORDER BY i.period) AS prev_barn
LAG(b.import_kwh) OVER (ORDER BY i.period) AS prev_barn,
c.eto_wh AS charger_wh,
LAG(c.eto_wh) OVER (ORDER BY i.period) AS prev_charger_wh
FROM inv_last i
LEFT JOIN house_last h ON h.period = i.period
LEFT JOIN barn_last b ON b.period = i.period
LEFT JOIN house_last h ON h.period = i.period
LEFT JOIN barn_last b ON b.period = i.period
LEFT JOIN charger_last c ON c.period = i.period
)
SELECT
period,
GREATEST(0, grid_import_kwh - prev_import) AS buy,
GREATEST(0, grid_export_kwh - prev_export) AS sell,
GREATEST(0, pv_energy_kwh - prev_pv) AS produce,
GREATEST(0, COALESCE(house_kwh - prev_house, 0) + COALESCE(barn_kwh - prev_barn, 0)) AS consume
GREATEST(0, COALESCE(house_kwh - prev_house, 0) + COALESCE(barn_kwh - prev_barn, 0)) AS consume,
GREATEST(0, COALESCE((charger_wh - prev_charger_wh) / 1000.0, 0)) AS charge
FROM joined
WHERE prev_import IS NOT NULL
ORDER BY period
@@ -243,7 +267,7 @@ ORDER BY period
var bars []EnergyBar
for rows.Next() {
var b EnergyBar
if err := rows.Scan(&b.Time, &b.Buy, &b.Sell, &b.Produce, &b.Consume); err != nil {
if err := rows.Scan(&b.Time, &b.Buy, &b.Sell, &b.Produce, &b.Consume, &b.Charge); err != nil {
return nil, err
}
bars = append(bars, b)