Signed-off-by: Thomas Klaehn <tkl@blackfinn.de>
This commit is contained in:
2026-04-30 09:13:47 +02:00
parent 74f7266632
commit d4c08467cb
9 changed files with 857 additions and 26 deletions
+302
View File
@@ -0,0 +1,302 @@
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"` // 632, 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()
}