diff --git a/internal/api/charger_controller.go b/internal/api/charger_controller.go index 57bcc0c..f33e7f3 100644 --- a/internal/api/charger_controller.go +++ b/internal/api/charger_controller.go @@ -52,12 +52,82 @@ func NewChargerController(host string, pool *pgxpool.Pool) *ChargerController { 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() + c.initParamsSchema(context.Background()) + go c.startup() return c } +// startup runs once after construction. If an active charging mode was +// persisted (e.g. solar_battery at 90% SOC), it restores the goroutine +// without touching the charger hardware — charging simply continues. +// Only when mode is off do we reset the charger to RFID-gated safe state, +// since that hardware state (acs=0, nmo=true, frc=2) persists across reboots. +func (c *ChargerController) startup() { + ctx := context.Background() + p, err := c.loadParams(ctx) + if err != nil { + log.Printf("charger ctrl: load params on startup: %v", err) + p = ChargingParams{Mode: ModeOff} + } + if p.Mode == ModeOff { + c.initSafeState() + return + } + log.Printf("charger ctrl: restoring mode=%s (max=%dA, minSOC=%d%%) after restart", p.Mode, p.MaxAmp, p.MinBatterySoc) + if err := c.SetParams(p); err != nil { + log.Printf("charger ctrl: restore params: %v", err) + c.initSafeState() + } +} + +func (c *ChargerController) initParamsSchema(ctx context.Context) { + _, err := c.pool.Exec(ctx, ` + CREATE TABLE IF NOT EXISTS charger_params ( + id INT PRIMARY KEY DEFAULT 1, + mode TEXT NOT NULL DEFAULT 'off', + max_amp INT NOT NULL DEFAULT 16, + min_battery_soc INT NOT NULL DEFAULT 0, + hysteresis INT NOT NULL DEFAULT 0, + target_kwh DOUBLE PRECISION NOT NULL DEFAULT 0, + target_soc INT NOT NULL DEFAULT 0, + phases INT NOT NULL DEFAULT 0 + ); + INSERT INTO charger_params (id, mode) VALUES (1, 'off') ON CONFLICT DO NOTHING; + `) + if err != nil { + log.Printf("charger ctrl: schema init: %v", err) + } +} + +func (c *ChargerController) saveParams(ctx context.Context, p ChargingParams) error { + _, err := c.pool.Exec(ctx, ` + INSERT INTO charger_params + (id, mode, max_amp, min_battery_soc, hysteresis, target_kwh, target_soc, phases) + VALUES (1, $1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (id) DO UPDATE SET + mode = EXCLUDED.mode, + max_amp = EXCLUDED.max_amp, + min_battery_soc = EXCLUDED.min_battery_soc, + hysteresis = EXCLUDED.hysteresis, + target_kwh = EXCLUDED.target_kwh, + target_soc = EXCLUDED.target_soc, + phases = EXCLUDED.phases + `, string(p.Mode), p.MaxAmp, p.MinBatterySoc, p.Hysteresis, p.TargetKwh, p.TargetSoc, p.Phases) + return err +} + +func (c *ChargerController) loadParams(ctx context.Context) (ChargingParams, error) { + var p ChargingParams + var mode string + err := c.pool.QueryRow(ctx, ` + SELECT mode, max_amp, min_battery_soc, hysteresis, target_kwh, target_soc, phases + FROM charger_params WHERE id = 1 + `).Scan(&mode, &p.MaxAmp, &p.MinBatterySoc, &p.Hysteresis, &p.TargetKwh, &p.TargetSoc, &p.Phases) + p.Mode = ChargingMode(mode) + return p, err +} + + func (c *ChargerController) initSafeState() { if err := setChargerFrc(c.host, 1); err != nil { log.Printf("charger ctrl: init: %v", err) @@ -85,6 +155,10 @@ func (c *ChargerController) SetParams(p ChargingParams) error { p.Phases = 3 } + if err := c.saveParams(context.Background(), p); err != nil { + log.Printf("charger ctrl: save params: %v", err) + } + c.mu.Lock() if c.cancel != nil { c.cancel() @@ -286,6 +360,24 @@ func (c *ChargerController) adjust(ctx context.Context) { return } + // car=3 with alw=true: the CP line is stuck in IEC state B even though + // authorization was already granted (alw=true, frc=2). Simply re-setting + // frc=2 every tick has no effect — a full charger reset is required to + // force a fresh IEC 61851 handshake. + if st.Car == 3 && st.Alw { + c.mu.RLock() + paused := c.battPaused + c.mu.RUnlock() + if !paused { + c.setStatus("resetting — car stuck in IEC state B") + if err := c.enableCharging(params.MaxAmp); err != nil { + log.Printf("charger ctrl: car=3 stuck: %v", err) + c.setStatus("reset failed: " + err.Error()) + } + } + 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)) @@ -445,6 +537,9 @@ func (c *ChargerController) stopOnDisconnect() { c.cancel = nil } c.mu.Unlock() + if err := c.saveParams(context.Background(), ChargingParams{Mode: ModeOff}); err != nil { + log.Printf("charger ctrl: save disconnect params: %v", err) + } c.setStatus("off — car disconnected") } @@ -468,6 +563,9 @@ func (c *ChargerController) stopForTarget(reason string) { c.cancel = nil } c.mu.Unlock() + if err := c.saveParams(context.Background(), ChargingParams{Mode: ModeOff}); err != nil { + log.Printf("charger ctrl: save target-stop params: %v", err) + } c.setStatus(reason) }