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() }