Files
tkl d4c08467cb wip
Signed-off-by: Thomas Klaehn <tkl@blackfinn.de>
2026-04-30 09:13:47 +02:00

303 lines
8.3 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package api
import (
"context"
"fmt"
"log"
"sync"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
type ChargingMode string
const (
ModeOff ChargingMode = "off"
ModeGrid ChargingMode = "grid"
ModeSolar ChargingMode = "solar"
ModeSolarBattery ChargingMode = "solar_battery"
)
type ChargingParams struct {
Mode ChargingMode `json:"mode"`
MaxAmp int `json:"max_amp"` // 632, 0 → 16
MinBatterySoc int `json:"min_battery_soc"` // solar_battery: stop below this %
Hysteresis int `json:"hysteresis"` // solar_battery: resume only above min+hysteresis %, 0 → 5
TargetKwh float64 `json:"target_kwh"` // stop after X kWh this session, 0 = no limit
TargetSoc int `json:"target_soc"` // stop when car SOC reaches X%, 0 = no limit
Phases int `json:"phases"` // 1 or 3, 0 → 3
}
type ControllerState struct {
Params ChargingParams `json:"params"`
Status string `json:"status"`
}
type ChargerController struct {
mu sync.RWMutex
params ChargingParams
status string
host string
pool *pgxpool.Pool
cancel context.CancelFunc
currentPhases int // active phase count set on charger; 0 = unknown
battPaused bool // true while waiting for battery to recover above min+hysteresis
}
func NewChargerController(host string, pool *pgxpool.Pool) *ChargerController {
return &ChargerController{
host: host,
pool: pool,
params: ChargingParams{Mode: ModeOff},
status: "idle",
}
}
func (c *ChargerController) State() ControllerState {
c.mu.RLock()
defer c.mu.RUnlock()
return ControllerState{Params: c.params, Status: c.status}
}
func (c *ChargerController) SetParams(p ChargingParams) error {
if p.MaxAmp == 0 {
p.MaxAmp = 16
}
if p.Phases == 0 {
p.Phases = 3
}
c.mu.Lock()
if c.cancel != nil {
c.cancel()
c.cancel = nil
}
c.params = p
c.currentPhases = 0
c.battPaused = false
c.mu.Unlock()
switch p.Mode {
case ModeOff:
c.setStatus("off")
return setChargerFrc(c.host, 1)
case ModeGrid:
if err := setChargerAmp(c.host, p.MaxAmp); err != nil {
return err
}
c.setStatus(fmt.Sprintf("grid %dA", p.MaxAmp))
return setChargerFrc(c.host, 2)
case ModeSolar, ModeSolarBattery:
ctx, cancel := context.WithCancel(context.Background())
c.mu.Lock()
c.cancel = cancel
c.mu.Unlock()
go c.run(ctx)
}
return nil
}
func (c *ChargerController) run(ctx context.Context) {
c.adjust(ctx)
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
c.adjust(ctx)
}
}
}
func (c *ChargerController) adjust(ctx context.Context) {
c.mu.RLock()
params := c.params
c.mu.RUnlock()
// ── Check stop targets ────────────────────────────────────────────────────
if params.TargetKwh > 0 || params.TargetSoc > 0 {
st, err := fetchChargerStatus(c.host)
if err != nil {
c.setStatus("charger unreachable")
return
}
if params.TargetSoc > 0 && st.Soc > 0 && st.Soc >= params.TargetSoc {
c.stopForTarget(fmt.Sprintf("car SOC %d%% reached", params.TargetSoc))
return
}
if params.TargetKwh > 0 && st.SessionWh/1000.0 >= params.TargetKwh {
c.stopForTarget(fmt.Sprintf("%.1f kWh target reached", params.TargetKwh))
return
}
}
// ── Query real-time power ─────────────────────────────────────────────────
var pvPower, batterySoc float64
err := c.pool.QueryRow(ctx,
`SELECT COALESCE(AVG(pv1_power + pv2_power), 0), COALESCE(AVG(battery_soc), 0)
FROM inverter WHERE time > NOW() - '30 seconds'::interval`,
).Scan(&pvPower, &batterySoc)
if err != nil {
log.Printf("charger ctrl: inverter: %v", err)
c.setStatus("db error")
return
}
housePower, barnPower := 0.0, 0.0
rows, err := c.pool.Query(ctx,
`SELECT device, COALESCE(AVG(l1_power + l2_power + l3_power), 0)
FROM power_meter WHERE time > NOW() - '30 seconds'::interval
GROUP BY device`)
if err != nil {
log.Printf("charger ctrl: meter: %v", err)
c.setStatus("db error")
return
}
defer rows.Close()
for rows.Next() {
var dev string
var pwr float64
if err := rows.Scan(&dev, &pwr); err != nil {
continue
}
switch dev {
case "house":
housePower = pwr
case "barn":
barnPower = pwr
}
}
// ── Compute desired charging watts ────────────────────────────────────────
//
// solar: track PV surplus (PV minus all home loads); auto-switch
// phases to stay above the 6 A minimum where possible.
//
// solar_battery: charge at full configured current regardless of surplus;
// the battery absorbs the delta. Stop when battery SOC hits
// MinBatterySoc; resume only after it recovers to
// MinBatterySoc+Hysteresis (prevents rapid cycling).
var desiredW float64
switch params.Mode {
case ModeSolar:
desiredW = pvPower - housePower - barnPower
case ModeSolarBattery:
hysteresis := params.Hysteresis
if hysteresis == 0 {
hysteresis = 5
}
resumeAt := float64(params.MinBatterySoc + hysteresis)
c.mu.Lock()
if !c.battPaused && batterySoc <= float64(params.MinBatterySoc) {
c.battPaused = true
} else if c.battPaused && batterySoc >= resumeAt {
c.battPaused = false
}
paused := c.battPaused
c.mu.Unlock()
if paused {
if err := setChargerFrc(c.host, 1); err != nil {
log.Printf("charger ctrl: batt pause: %v", err)
}
c.setStatus(fmt.Sprintf("paused — bat %.0f%% ≤ %d%% (resume at %.0f%%)", batterySoc, params.MinBatterySoc, resumeAt))
return
}
// Battery above threshold — charge at max
desiredW = float64(params.MaxAmp*params.Phases) * 230.0
}
// ── Auto phase selection ──────────────────────────────────────────────────
//
// Prefer max phases when surplus is sufficient; drop to 1-phase to keep
// charging on partly cloudy days rather than stopping entirely.
// Minimum current per phase is 6 A (IEC 61851 floor).
const minAmps = 6
const voltageV = 230.0
maxPh := params.Phases // user-configured ceiling (1 or 3)
var targetPhases int
if desiredW >= float64(minAmps*maxPh)*voltageV {
targetPhases = maxPh
} else if desiredW >= float64(minAmps)*voltageV {
targetPhases = 1
} else {
// Not enough even for 1-phase — pause
if err := setChargerFrc(c.host, 1); err != nil {
log.Printf("charger ctrl: pause: %v", err)
}
c.setStatus(fmt.Sprintf("waiting — %.0fW surplus (need ≥%.0fW for 1-phase)", desiredW, float64(minAmps)*voltageV))
return
}
// Switch phases only when necessary to avoid interrupting the session
c.mu.Lock()
needSwitch := c.currentPhases != targetPhases
if needSwitch {
c.currentPhases = targetPhases
}
c.mu.Unlock()
if needSwitch {
if err := setChargerPhases(c.host, targetPhases); err != nil {
log.Printf("charger ctrl: phase switch: %v", err)
c.setStatus("phase switch failed")
return
}
}
// ── Apply current ─────────────────────────────────────────────────────────
amps := int(desiredW / float64(targetPhases) / voltageV)
if amps > params.MaxAmp {
amps = params.MaxAmp
}
if amps < minAmps {
amps = minAmps
}
if err := setChargerAmp(c.host, amps); err != nil {
log.Printf("charger ctrl: amp: %v", err)
c.setStatus("amp set failed")
return
}
if err := setChargerFrc(c.host, 2); err != nil {
log.Printf("charger ctrl: enable: %v", err)
c.setStatus("enable failed")
return
}
c.setStatus(fmt.Sprintf("%dA/%dph · %.0fW surplus · bat %.0f%%", amps, targetPhases, desiredW, batterySoc))
}
func (c *ChargerController) stopForTarget(reason string) {
if err := setChargerFrc(c.host, 1); err != nil {
log.Printf("charger ctrl: stop: %v", err)
}
c.mu.Lock()
c.params.Mode = ModeOff
if c.cancel != nil {
c.cancel()
c.cancel = nil
}
c.mu.Unlock()
c.setStatus(reason)
}
func (c *ChargerController) setStatus(s string) {
c.mu.Lock()
c.status = s
c.mu.Unlock()
}