diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index e5558b5..8b66f91 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -50,6 +50,7 @@ export interface ChargerStatus { session_wh: number amp: number // current charging current setting soc: number // car battery %, 0 = not reported + alw: boolean // charging allowed; false = RFID/access control blocking } export type ChargingMode = 'off' | 'grid' | 'solar' | 'solar_battery' diff --git a/frontend/src/views/EnergyView.vue b/frontend/src/views/EnergyView.vue index 5851d24..9fa1d46 100644 --- a/frontend/src/views/EnergyView.vue +++ b/frontend/src/views/EnergyView.vue @@ -316,6 +316,7 @@ watch(selectedRange, refreshLive) {{ (chargerSt.session_wh / 1000).toFixed(2) }} kWh {{ chargerSt.amp }}A car {{ chargerSt.soc }}% + access control {{ chargerCtrl.status }} @@ -501,6 +502,12 @@ watch(selectedRange, refreshLive) color: var(--text-muted); } +.ch-blocked { + font-size: 0.75rem; + color: #ef5350; + font-weight: 600; +} + .ch-ctrl-status { font-size: 0.75rem; color: var(--text-muted); diff --git a/internal/api/charger_controller.go b/internal/api/charger_controller.go index 854ef87..52b383a 100644 --- a/internal/api/charger_controller.go +++ b/internal/api/charger_controller.go @@ -80,17 +80,40 @@ func (c *ChargerController) SetParams(p ChargingParams) error { 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 setChargerFrc(c.host, 1) + return nil case ModeGrid: - if err := setChargerAmp(c.host, p.MaxAmp); err != nil { + if err := c.enableCharging(p.MaxAmp); err != nil { return err } c.setStatus(fmt.Sprintf("grid %dA", p.MaxAmp)) - return setChargerFrc(c.host, 2) + 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 @@ -100,6 +123,41 @@ func (c *ChargerController) SetParams(p ChargingParams) error { 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) @@ -119,32 +177,17 @@ func (c *ChargerController) adjust(ctx context.Context) { 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 ───────────────────────────────────────────────── + // 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 - err := c.pool.QueryRow(ctx, + 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) - if err != nil { + ).Scan(&pvPower, &batterySoc); err != nil { log.Printf("charger ctrl: inverter: %v", err) c.setStatus("db error") return @@ -175,6 +218,72 @@ func (c *ChargerController) adjust(ctx context.Context) { } } + // 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 @@ -191,25 +300,19 @@ func (c *ChargerController) adjust(ctx context.Context) { 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 - } + c.mu.RLock() paused := c.battPaused - c.mu.Unlock() + 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 } @@ -281,6 +384,32 @@ func (c *ChargerController) adjust(ctx context.Context) { 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) diff --git a/internal/api/charger_proxy.go b/internal/api/charger_proxy.go index 19189ad..a057901 100644 --- a/internal/api/charger_proxy.go +++ b/internal/api/charger_proxy.go @@ -14,6 +14,7 @@ type ChargerStatus struct { SessionWh float64 `json:"session_wh"` Amp int `json:"amp"` Soc int `json:"soc"` // car battery %, 0 = not reported by car + Alw bool `json:"alw"` // charging allowed (false = access control blocking) } type goeStatus struct { @@ -22,12 +23,13 @@ type goeStatus struct { Wh float64 `json:"wh"` Amp int `json:"amp"` Soc int `json:"soc"` + Alw bool `json:"alw"` } -var chargerHTTP = &http.Client{Timeout: 5 * time.Second} +var chargerHTTP = &http.Client{Timeout: 15 * time.Second} func fetchChargerStatus(host string) (ChargerStatus, error) { - url := "http://" + host + "/api/status?filter=car,frc,wh,amp,soc" + url := "http://" + host + "/api/status?filter=car,frc,wh,amp,soc,alw" resp, err := chargerHTTP.Get(url) if err != nil { return ChargerStatus{}, fmt.Errorf("charger unreachable: %w", err) @@ -41,7 +43,46 @@ func fetchChargerStatus(host string) (ChargerStatus, error) { if err := json.Unmarshal(body, &s); err != nil { return ChargerStatus{}, err } - return ChargerStatus{Car: s.Car, Frc: s.Frc, SessionWh: s.Wh, Amp: s.Amp, Soc: s.Soc}, nil + return ChargerStatus{Car: s.Car, Frc: s.Frc, SessionWh: s.Wh, Amp: s.Amp, Soc: s.Soc, Alw: s.Alw}, nil +} + +// setChargerAcs sets access control: 0 = open (app controls), 1 = RFID required. +func setChargerAcs(host string, acs int) error { + url := fmt.Sprintf("http://%s/api/set?acs=%d", host, acs) + resp, err := chargerHTTP.Get(url) + if err != nil { + return fmt.Errorf("charger unreachable: %w", err) + } + defer resp.Body.Close() + return nil +} + +// setChargerNmo sets Norwegian mode: true = charge without RFID, false = normal. +func setChargerNmo(host string, enabled bool) error { + val := "false" + if enabled { + val = "true" + } + url := "http://" + host + "/api/set?nmo=" + val + resp, err := chargerHTTP.Get(url) + if err != nil { + return fmt.Errorf("charger unreachable: %w", err) + } + defer resp.Body.Close() + return nil +} + +// setChargerTrx starts a charging session (trx=0). With acs=0 this bypasses +// RFID authentication but still initiates the IEC 61851 handshake — without +// it the charger stays in modelStatus "paused" and never closes its relay. +func setChargerTrx(host string) error { + url := "http://" + host + "/api/set?trx=0" + resp, err := chargerHTTP.Get(url) + if err != nil { + return fmt.Errorf("charger unreachable: %w", err) + } + defer resp.Body.Close() + return nil } func setChargerFrc(host string, frc int) error { @@ -65,14 +106,14 @@ func setChargerAmp(host string, amps int) error { } // resetCharger sends rst=1 and waits for the charger to reboot. -// frc is reset to 0 by the charger firmware, so callers must re-apply mode after this. +// frc and trx are cleared by the firmware on reset; callers must re-apply mode after this. func resetCharger(host string) error { url := "http://" + host + "/api/set?rst=1" resp, _ := chargerHTTP.Get(url) // charger may close connection before responding if resp != nil { resp.Body.Close() } - time.Sleep(8 * time.Second) + time.Sleep(12 * time.Second) // v59.x firmware takes longer to reboot if _, err := fetchChargerStatus(host); err != nil { return fmt.Errorf("charger did not recover from reset: %w", err) }