ca51807d92
Signed-off-by: Thomas Klaehn <tkl@blackfinn.de>
432 lines
13 KiB
Go
432 lines
13 KiB
Go
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"` // 6–32, 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()
|
||
}
|