Files
energy-frontend/internal/api/charger_controller.go
T
2026-06-11 14:09:57 +02:00

479 lines
14 KiB
Go
Raw 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 {
c := &ChargerController{
host: host,
pool: pool,
params: ChargingParams{Mode: ModeOff},
status: "initializing",
}
// Always restore RFID-gated idle state on startup. nmo/acs settings persist
// on the charger hardware across app restarts, so we must reset them explicitly.
go c.initSafeState()
return c
}
func (c *ChargerController) initSafeState() {
if err := setChargerFrc(c.host, 1); err != nil {
log.Printf("charger ctrl: init: %v", err)
c.setStatus("charger unreachable")
return
}
setChargerNmo(c.host, false)
setChargerAcs(c.host, 1)
setChargerFrc(c.host, 0)
c.setStatus("idle")
log.Printf("charger ctrl: initialized — RFID-gated (nmo=false, acs=1)")
}
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:
// Stop charging immediately, then restore RFID-gated access so the
// car does not start charging automatically on next plug-in.
// frc=0 (neutral after stopping) lets the RFID card still work at
// the wallbox; acs=1 + nmo=false ensures RFID is mandatory.
if err := setChargerFrc(c.host, 1); err != nil {
log.Printf("charger ctrl: off frc: %v", err)
}
if err := setChargerNmo(c.host, false); err != nil {
log.Printf("charger ctrl: off nmo: %v", err)
}
if err := setChargerAcs(c.host, 1); err != nil {
log.Printf("charger ctrl: off acs: %v", err)
}
if err := setChargerFrc(c.host, 0); err != nil {
log.Printf("charger ctrl: off frc0: %v", err)
}
c.setStatus("off")
return nil
case ModeGrid:
if err := c.enableCharging(p.MaxAmp); err != nil {
return err
}
c.setStatus(fmt.Sprintf("grid %dA", p.MaxAmp))
ctx, cancel := context.WithCancel(context.Background())
c.mu.Lock()
c.cancel = cancel
c.mu.Unlock()
go c.run(ctx)
return nil
case ModeSolar, ModeSolarBattery:
// enableCharging is called by the first adjust() tick so the goroutine
// can restart the session asynchronously if car=4.
ctx, cancel := context.WithCancel(context.Background())
c.mu.Lock()
c.cancel = cancel
c.mu.Unlock()
go c.run(ctx)
}
return nil
}
// enableCharging prepares the charger for a new session and starts it.
// If the charger is in car=4 (complete) state the old session cannot be
// re-used; a soft reset is required to clear it before starting fresh.
func (c *ChargerController) enableCharging(amps int) error {
st, err := fetchChargerStatus(c.host)
if err != nil {
return fmt.Errorf("charger unreachable: %w", err)
}
// car=4: previous session must be cleared before a new one can start.
// car=3: car entered IEC state B while acs=1 was active; the CP-line
// "wait for auth" signal means the car won't accept frc=2 alone —
// a full reset forces a fresh IEC 61851 handshake.
if st.Car == 4 || st.Car == 3 {
c.setStatus("resetting — fresh IEC negotiation needed")
if err := resetCharger(c.host); err != nil {
return fmt.Errorf("reset: %w", err)
}
c.mu.Lock()
c.currentPhases = 0
c.mu.Unlock()
}
if err := setChargerNmo(c.host, true); err != nil {
log.Printf("charger ctrl: nmo: %v", err)
}
if err := setChargerAcs(c.host, 0); err != nil {
log.Printf("charger ctrl: acs: %v", err)
}
if amps > 0 {
if err := setChargerAmp(c.host, amps); err != nil {
return err
}
}
if err := setChargerTrx(c.host); err != nil {
log.Printf("charger ctrl: trx: %v", err)
}
return setChargerFrc(c.host, 2)
}
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()
// ── Query real-time power ─────────────────────────────────────────────────
// Runs unconditionally before charger-status checks so that battPaused is
// always current. Without this ordering, a car=4 early-return would prevent
// the SOC recovery logic from ever running, leaving battPaused=true
// permanently even after the battery fully recovers.
var pvPower, batterySoc float64
if 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); 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
}
}
// Update battPaused before checking car state so that battery recovery
// while the charger reports car=4 is detected and charging can resume.
if params.Mode == 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
}
c.mu.Unlock()
}
// ── Fetch charger status ──────────────────────────────────────────────────
st, chargerErr := fetchChargerStatus(c.host)
if chargerErr != nil {
c.setStatus("charger unreachable — " + chargerErr.Error())
// fall through: mode logic still runs; commands will be retried next tick
} else {
// car=1: no car connected — restore RFID-gated state and stop the loop.
if st.Car == 1 {
c.stopOnDisconnect()
return
}
// car=4: previous session ended; a soft reset is the only way to clear
// the transaction before a new session can start.
// Exception: if a battery-SOC pause is still active (battery hasn't
// recovered yet), do NOT restart — that would cause charge/stop toggling.
if st.Car == 4 {
c.mu.RLock()
paused := c.battPaused
c.mu.RUnlock()
if !paused {
c.setStatus("restarting — session complete")
if err := c.enableCharging(0); err != nil {
log.Printf("charger ctrl: session restart: %v", err)
c.setStatus("session restart failed")
}
}
return
}
if params.TargetKwh > 0 || params.TargetSoc > 0 {
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
}
}
// frc=2 alone does not override acs=1 on this firmware: if access control
// is still blocking (alw=false) and we haven't intentionally force-paused
// (frc=1), the session hasn't been authorised yet — call enableCharging to
// set acs=0 and start the session properly.
// Guard on battPaused: if the battery SOC is already below the cutoff
// (set at the top of this function), do not enable charging even if the
// charger looks idle — the pause takes priority.
c.mu.RLock()
paused := c.battPaused
c.mu.RUnlock()
if !st.Alw && st.Frc != 1 && !paused {
if err := c.enableCharging(params.MaxAmp); err != nil {
log.Printf("charger ctrl: session start: %v", err)
c.setStatus("session start failed: " + err.Error())
}
return
}
}
// Grid mode: current is fixed and already applied by enableCharging(); the
// only job of the monitoring goroutine is car=1/4 detection above.
if params.Mode == ModeGrid {
return
}
// ── 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:
c.mu.RLock()
paused := c.battPaused
c.mu.RUnlock()
if paused {
if err := setChargerFrc(c.host, 1); err != nil {
log.Printf("charger ctrl: batt pause: %v", err)
}
hysteresis := params.Hysteresis
if hysteresis == 0 {
hysteresis = 5
}
resumeAt := float64(params.MinBatterySoc + hysteresis)
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))
}
// stopOnDisconnect restores RFID-gated idle state and cancels the goroutine.
// Called when car=1 (no car connected) is detected during a monitoring tick.
func (c *ChargerController) stopOnDisconnect() {
if err := setChargerFrc(c.host, 1); err != nil {
log.Printf("charger ctrl: disconnect frc1: %v", err)
}
if err := setChargerNmo(c.host, false); err != nil {
log.Printf("charger ctrl: disconnect nmo: %v", err)
}
if err := setChargerAcs(c.host, 1); err != nil {
log.Printf("charger ctrl: disconnect acs: %v", err)
}
if err := setChargerFrc(c.host, 0); err != nil {
log.Printf("charger ctrl: disconnect frc0: %v", err)
}
c.mu.Lock()
c.params.Mode = ModeOff
c.battPaused = false
if c.cancel != nil {
c.cancel()
c.cancel = nil
}
c.mu.Unlock()
c.setStatus("off — car disconnected")
}
func (c *ChargerController) stopForTarget(reason string) {
if err := setChargerFrc(c.host, 1); err != nil {
log.Printf("charger ctrl: stop frc1: %v", err)
}
if err := setChargerNmo(c.host, false); err != nil {
log.Printf("charger ctrl: stop nmo: %v", err)
}
if err := setChargerAcs(c.host, 1); err != nil {
log.Printf("charger ctrl: stop acs: %v", err)
}
if err := setChargerFrc(c.host, 0); err != nil {
log.Printf("charger ctrl: stop frc0: %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()
}