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