Files
energy-frontend/internal/api/charger_controller.go
T
tkl ca51807d92 improve wall box integration
Signed-off-by: Thomas Klaehn <tkl@blackfinn.de>
2026-05-21 11:41:58 +02:00

432 lines
13 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 {
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:
// 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)
}
if st.Car == 4 {
c.setStatus("resetting — previous session complete")
if err := resetCharger(c.host); err != nil {
return fmt.Errorf("reset: %w", err)
}
// Reset clears currentPhases so phase-switch logic starts fresh
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
}
}
}
// 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: %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()
}