diff --git a/docker-compose.yml b/docker-compose.yml index 77e0e4e..0cc0536 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,7 +9,15 @@ services: environment: ENERGY_PORT: 8080 ENERGY_DB_DSN: postgres://energy:changeme@timescaledb:5432/energy + ENERGY_CHARGER_HOST: go-echarger-239054 networks: - proxy - fitmonitor-network +networks: + proxy: + name: proxy + fitmonitor-network: + name: fitmonitor-network + + diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 3491763..e5558b5 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -44,6 +44,31 @@ export interface BatteryPoint { level: number } +export interface ChargerStatus { + car: number // 1=idle 2=charging 3=waiting 4=complete 5=error + frc: number // 0=auto 1=force-off 2=force-on + session_wh: number + amp: number // current charging current setting + soc: number // car battery %, 0 = not reported +} + +export type ChargingMode = 'off' | 'grid' | 'solar' | 'solar_battery' + +export interface ChargingParams { + mode: ChargingMode + max_amp: number + min_battery_soc: number + hysteresis: number + target_kwh: number + target_soc: number + phases: number +} + +export interface ControllerState { + params: ChargingParams + status: string +} + export const api = { login(username: string, password: string) { return request<{ status: string }>('/login', { @@ -71,4 +96,23 @@ export const api = { getBattery(range: string) { return request<{ points: BatteryPoint[] }>(`/battery?range=${range}`) }, + + getCharger() { + return request('/charger') + }, + + resetCharger() { + return request('/charger/reset', { method: 'POST' }) + }, + + getChargerMode() { + return request('/charger/mode') + }, + + setChargerMode(params: ChargingParams) { + return request('/charger/mode', { + method: 'POST', + body: JSON.stringify(params), + }) + }, } diff --git a/frontend/src/views/EnergyView.vue b/frontend/src/views/EnergyView.vue index c35f010..5851d24 100644 --- a/frontend/src/views/EnergyView.vue +++ b/frontend/src/views/EnergyView.vue @@ -6,7 +6,7 @@ import { CategoryScale, LinearScale, Tooltip, Legend, Filler, } from 'chart.js' -import { api, type PowerPoint, type BatteryPoint } from '@/api/client' +import { api, type PowerPoint, type BatteryPoint, type ChargerStatus, type ChargingParams, type ControllerState } from '@/api/client' Chart.register( LineController, LineElement, PointElement, @@ -16,14 +16,32 @@ Chart.register( // ── Data ───────────────────────────────────────────────────────────────────── -const selectedRange = ref<'1h' | '6h' | '24h' | '7d'>('24h') +const selectedRange = ref<'1h' | '6h' | '24h' | '7d'>('1h') const ranges = ['1h', '6h', '24h', '7d'] as const const loading = ref(true) const error = ref('') -const powerPts = ref([]) -const battPts = ref([]) +const powerPts = ref([]) +const battPts = ref([]) +const chargerSt = ref(null) +const chargerCtrl = ref(null) +const chargerErr = ref('') +const chargerBusy = ref(false) + +const CHARGING_MODES = [ + { value: 'off' as const, label: 'Off' }, + { value: 'grid' as const, label: 'Grid' }, + { value: 'solar' as const, label: 'Solar' }, + { value: 'solar_battery' as const, label: 'Solar+Batt' }, +] +const modeForm = ref({ + mode: 'off', max_amp: 16, min_battery_soc: 20, hysteresis: 5, + target_kwh: 0, target_soc: 0, phases: 3, +}) +const stopMode = ref<'none' | 'kwh' | 'soc'>('none') +const stopValue = ref(20) +let formInitialized = false // ── Chart instances ─────────────────────────────────────────────────────────── @@ -129,6 +147,79 @@ function buildBattChart(canvas: HTMLCanvasElement, pts: BatteryPoint[], range: s }) } +// ── Charger helpers ─────────────────────────────────────────────────────────── + +const CAR_LABELS: Record = { + 1: 'No car', + 2: 'Charging', + 3: 'Waiting', + 4: 'Complete', + 5: 'Error', +} + +const CAR_COLORS: Record = { + 1: '#8b8fa3', + 2: '#66bb6a', + 3: '#ffa726', + 4: '#42a5f5', + 5: '#ef5350', +} + +function carLabel(car: number) { return CAR_LABELS[car] ?? 'Unknown' } +function carColor(car: number) { return CAR_COLORS[car] ?? '#8b8fa3' } + +async function refreshCharger() { + try { + const [st, ctrl] = await Promise.all([api.getCharger(), api.getChargerMode()]) + chargerSt.value = st + chargerCtrl.value = ctrl + if (!formInitialized) { + modeForm.value = { ...ctrl.params, max_amp: ctrl.params.max_amp || 16, phases: ctrl.params.phases || 3 } + if (ctrl.params.target_soc > 0) { stopMode.value = 'soc'; stopValue.value = ctrl.params.target_soc } + else if (ctrl.params.target_kwh > 0) { stopMode.value = 'kwh'; stopValue.value = ctrl.params.target_kwh } + formInitialized = true + } + chargerErr.value = '' + } catch (e: any) { + chargerErr.value = e.message + } +} + +async function softReset() { + if (chargerBusy.value) return + chargerBusy.value = true + chargerErr.value = '' + try { + chargerSt.value = await api.resetCharger() + await refreshCharger() + } catch (e: any) { + chargerErr.value = e.message + } finally { + chargerBusy.value = false + } +} + +async function applyMode() { + if (chargerBusy.value) return + chargerBusy.value = true + try { + const params: ChargingParams = { + ...modeForm.value, + target_kwh: stopMode.value === 'kwh' ? stopValue.value : 0, + target_soc: stopMode.value === 'soc' ? stopValue.value : 0, + } + await api.setChargerMode(params) + chargerErr.value = '' + // Wait for the controller's first adjust() tick to hit the charger, then refresh + await new Promise(r => setTimeout(r, 1500)) + await refreshCharger() + } catch (e: any) { + chargerErr.value = e.message + } finally { + chargerBusy.value = false + } +} + // ── Data loading ────────────────────────────────────────────────────────────── async function loadTimeSeries() { @@ -150,7 +241,7 @@ function latestBattery(): number | null { // ── Refresh ─────────────────────────────────────────────────────────────────── async function refreshLive() { - await loadTimeSeries() + await Promise.all([loadTimeSeries(), refreshCharger()]) await nextTick() powerChart?.destroy(); powerChart = null battChart?.destroy(); battChart = null @@ -166,7 +257,7 @@ onMounted(async () => { loading.value = true error.value = '' try { - await loadTimeSeries() + await Promise.all([loadTimeSeries(), refreshCharger()]) loading.value = false await nextTick() if (powerCanvas.value && powerPts.value.length) powerChart = buildPowerChart(powerCanvas.value, powerPts.value, selectedRange.value) @@ -217,6 +308,73 @@ watch(selectedRange, refreshLive)
+
+ + +
+ {{ carLabel(chargerSt.car) }} + {{ (chargerSt.session_wh / 1000).toFixed(2) }} kWh + {{ chargerSt.amp }}A + car {{ chargerSt.soc }}% + {{ chargerCtrl.status }} +
+ + +
+ +
+ + + + + +
{{ chargerErr }}
+ +
@@ -317,4 +475,135 @@ watch(selectedRange, refreshLive) border-color: var(--accent); color: #fff; } + +.charger-card { + display: flex; + flex-direction: column; + gap: 0.6rem; + padding: 0.75rem 1rem; +} + +.ch-status-row { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; +} + +.ch-car { + font-size: 0.875rem; + font-weight: 700; + min-width: 5rem; +} + +.ch-info { + font-size: 0.8rem; + color: var(--text-muted); +} + +.ch-ctrl-status { + font-size: 0.75rem; + color: var(--text-muted); + font-style: italic; + margin-left: auto; +} + +.ch-modes { + display: flex; + gap: 0.25rem; + flex-wrap: wrap; +} + +.ch-mode-btn { + background: var(--bg-input); + border: 1px solid var(--border); + color: var(--text-muted); + padding: 0.25rem 0.625rem; + border-radius: 4px; + font-size: 0.8rem; + cursor: pointer; + transition: all 0.15s; +} + +.ch-mode-btn:hover { color: var(--text); border-color: var(--text-muted); } +.ch-mode-btn.active { background: var(--accent); border-color: var(--accent); color: #fff; } + +.ch-param-row { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.8rem; +} + +.ch-param-label { + color: var(--text-muted); + min-width: 6rem; +} + +.ch-param-val { + color: var(--text); + min-width: 2.5rem; + text-align: right; +} + +.ch-range { + flex: 1; + accent-color: var(--accent); +} + +.ch-select { + background: var(--bg-input); + border: 1px solid var(--border); + color: var(--text); + border-radius: 4px; + padding: 0.15rem 0.4rem; + font-size: 0.8rem; +} + +.ch-num { + width: 4rem; + background: var(--bg-input); + border: 1px solid var(--border); + color: var(--text); + border-radius: 4px; + padding: 0.15rem 0.4rem; + font-size: 0.8rem; + text-align: right; +} + +.ch-footer { + display: flex; + gap: 0.5rem; + align-items: center; +} + +.ch-apply-btn { + background: var(--accent); + border: none; + color: #fff; + padding: 0.3rem 1rem; + border-radius: 4px; + font-size: 0.8rem; + cursor: pointer; + transition: opacity 0.15s; +} + +.ch-reset-btn { + background: transparent; + border: 1px solid var(--border); + color: var(--text-muted); + padding: 0.3rem 0.75rem; + border-radius: 4px; + font-size: 0.8rem; + cursor: pointer; + transition: all 0.15s; +} + +.ch-reset-btn:hover { border-color: #ffa726; color: #ffa726; } +.ch-apply-btn:disabled, .ch-reset-btn:disabled { opacity: 0.5; cursor: not-allowed; } + +.ch-err { + font-size: 0.8rem; + color: #ef5350; +} diff --git a/frontend/tsconfig.tsbuildinfo b/frontend/tsconfig.tsbuildinfo index e1375e3..10abc3b 100644 --- a/frontend/tsconfig.tsbuildinfo +++ b/frontend/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/main.ts","./src/api/client.ts","./src/composables/useAuth.ts","./src/router/index.ts","./src/App.vue","./src/views/DashboardView.vue","./src/views/LoginView.vue"],"version":"5.7.3"} \ No newline at end of file +{"root":["./src/main.ts","./src/api/client.ts","./src/composables/useAuth.ts","./src/router/index.ts","./src/App.vue","./src/views/EnergyLayout.vue","./src/views/EnergyView.vue","./src/views/LoginView.vue","./src/views/StatisticsView.vue"],"version":"5.7.3"} \ No newline at end of file diff --git a/internal/api/charger_controller.go b/internal/api/charger_controller.go new file mode 100644 index 0000000..854ef87 --- /dev/null +++ b/internal/api/charger_controller.go @@ -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"` // 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() +} diff --git a/internal/api/charger_proxy.go b/internal/api/charger_proxy.go new file mode 100644 index 0000000..19189ad --- /dev/null +++ b/internal/api/charger_proxy.go @@ -0,0 +1,96 @@ +package api + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +type ChargerStatus struct { + Car int `json:"car"` + Frc int `json:"frc"` + SessionWh float64 `json:"session_wh"` + Amp int `json:"amp"` + Soc int `json:"soc"` // car battery %, 0 = not reported by car +} + +type goeStatus struct { + Car int `json:"car"` + Frc int `json:"frc"` + Wh float64 `json:"wh"` + Amp int `json:"amp"` + Soc int `json:"soc"` +} + +var chargerHTTP = &http.Client{Timeout: 5 * time.Second} + +func fetchChargerStatus(host string) (ChargerStatus, error) { + url := "http://" + host + "/api/status?filter=car,frc,wh,amp,soc" + resp, err := chargerHTTP.Get(url) + if err != nil { + return ChargerStatus{}, fmt.Errorf("charger unreachable: %w", err) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return ChargerStatus{}, err + } + var s goeStatus + 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 +} + +func setChargerFrc(host string, frc int) error { + url := fmt.Sprintf("http://%s/api/set?frc=%d", host, frc) + resp, err := chargerHTTP.Get(url) + if err != nil { + return fmt.Errorf("charger unreachable: %w", err) + } + defer resp.Body.Close() + return nil +} + +func setChargerAmp(host string, amps int) error { + url := fmt.Sprintf("http://%s/api/set?amp=%d", host, amps) + resp, err := chargerHTTP.Get(url) + if err != nil { + return fmt.Errorf("charger unreachable: %w", err) + } + defer resp.Body.Close() + return nil +} + +// 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. +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) + if _, err := fetchChargerStatus(host); err != nil { + return fmt.Errorf("charger did not recover from reset: %w", err) + } + return nil +} + +// setChargerPhases switches between 1-phase (psm=1) and 3-phase (psm=2). +// Requires phase-switching hardware in the charger. +func setChargerPhases(host string, phases int) error { + psm := 2 + if phases == 1 { + psm = 1 + } + url := fmt.Sprintf("http://%s/api/set?psm=%d", host, psm) + resp, err := chargerHTTP.Get(url) + if err != nil { + return fmt.Errorf("charger unreachable: %w", err) + } + defer resp.Body.Close() + return nil +} diff --git a/internal/api/energy.go b/internal/api/energy.go index 9f1ce6e..05e7327 100644 --- a/internal/api/energy.go +++ b/internal/api/energy.go @@ -38,10 +38,11 @@ var periodDefaults = map[string]int{ } // viewForRange picks the right continuous aggregate views for the given time range. +// Returns empty strings for invView when the range should use raw tables (see getPower). func viewForRange(timeRange string) (invView, meterView, chargerView, interval string) { switch timeRange { case "1h": - return "inverter_10m", "power_meter_10m", "charger_10m", "1 hour" + return "", "", "", "1 hour" // raw tables, 30s buckets case "6h": return "inverter_10m", "power_meter_10m", "charger_10m", "6 hours" case "7d": @@ -90,18 +91,35 @@ func getPower(ctx context.Context, pool *pgxpool.Pool, timeRange string) ([]Powe return m, rows.Err() } - pvSQL := fmt.Sprintf( - `SELECT bucket, COALESCE(pv_power, 0) FROM %s WHERE bucket >= NOW() - '%s'::interval ORDER BY bucket`, - invView, interval) - houseSQL := fmt.Sprintf( - `SELECT bucket, COALESCE(total_power, 0) FROM %s WHERE device = 'house' AND bucket >= NOW() - '%s'::interval ORDER BY bucket`, - meterView, interval) - barnSQL := fmt.Sprintf( - `SELECT bucket, COALESCE(total_power, 0) FROM %s WHERE device = 'barn' AND bucket >= NOW() - '%s'::interval ORDER BY bucket`, - meterView, interval) - chargerSQL := fmt.Sprintf( - `SELECT bucket, COALESCE(power, 0) FROM %s WHERE bucket >= NOW() - '%s'::interval ORDER BY bucket`, - chargerView, interval) + var pvSQL, houseSQL, barnSQL, chargerSQL string + if invView == "" { + // Raw tables with 30-second buckets for short live range + pvSQL = fmt.Sprintf( + `SELECT time_bucket('30 seconds', time) AS bucket, COALESCE(AVG(pv1_power + pv2_power), 0) FROM inverter WHERE time >= NOW() - '%s'::interval GROUP BY bucket ORDER BY bucket`, + interval) + houseSQL = fmt.Sprintf( + `SELECT time_bucket('30 seconds', time) AS bucket, COALESCE(AVG(l1_power + l2_power + l3_power), 0) FROM power_meter WHERE device = 'house' AND time >= NOW() - '%s'::interval GROUP BY bucket ORDER BY bucket`, + interval) + barnSQL = fmt.Sprintf( + `SELECT time_bucket('30 seconds', time) AS bucket, COALESCE(AVG(l1_power + l2_power + l3_power), 0) FROM power_meter WHERE device = 'barn' AND time >= NOW() - '%s'::interval GROUP BY bucket ORDER BY bucket`, + interval) + chargerSQL = fmt.Sprintf( + `SELECT time_bucket('30 seconds', time) AS bucket, COALESCE(AVG(power), 0) FROM charger WHERE time >= NOW() - '%s'::interval GROUP BY bucket ORDER BY bucket`, + interval) + } else { + pvSQL = fmt.Sprintf( + `SELECT bucket, COALESCE(pv_power, 0) FROM %s WHERE bucket >= NOW() - '%s'::interval ORDER BY bucket`, + invView, interval) + houseSQL = fmt.Sprintf( + `SELECT bucket, COALESCE(total_power, 0) FROM %s WHERE device = 'house' AND bucket >= NOW() - '%s'::interval ORDER BY bucket`, + meterView, interval) + barnSQL = fmt.Sprintf( + `SELECT bucket, COALESCE(total_power, 0) FROM %s WHERE device = 'barn' AND bucket >= NOW() - '%s'::interval ORDER BY bucket`, + meterView, interval) + chargerSQL = fmt.Sprintf( + `SELECT bucket, COALESCE(power, 0) FROM %s WHERE bucket >= NOW() - '%s'::interval ORDER BY bucket`, + chargerView, interval) + } type result struct { m map[time.Time]float64 @@ -152,9 +170,16 @@ func getPower(ctx context.Context, pool *pgxpool.Pool, timeRange string) ([]Powe func getBattery(ctx context.Context, pool *pgxpool.Pool, timeRange string) ([]BatteryPoint, error) { invView, _, _, interval := viewForRange(timeRange) - sql := fmt.Sprintf( - `SELECT bucket, COALESCE(battery_soc, 0) FROM %s WHERE bucket >= NOW() - '%s'::interval ORDER BY bucket`, - invView, interval) + var sql string + if invView == "" { + sql = fmt.Sprintf( + `SELECT time_bucket('30 seconds', time) AS bucket, COALESCE(AVG(battery_soc), 0) FROM inverter WHERE time >= NOW() - '%s'::interval GROUP BY bucket ORDER BY bucket`, + interval) + } else { + sql = fmt.Sprintf( + `SELECT bucket, COALESCE(battery_soc, 0) FROM %s WHERE bucket >= NOW() - '%s'::interval ORDER BY bucket`, + invView, interval) + } rows, err := pool.Query(ctx, sql) if err != nil { diff --git a/internal/api/router.go b/internal/api/router.go index 7a316d8..b443b06 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -69,6 +69,65 @@ func NewRouter(cfg config.Config, frontendFS fs.FS) http.Handler { writeJSON(w, http.StatusOK, map[string]interface{}{"points": pts}) }) + var controller *ChargerController + if cfg.Charger.Host != "" { + controller = NewChargerController(cfg.Charger.Host, pool) + } + + protected.HandleFunc("GET /api/charger", func(w http.ResponseWriter, r *http.Request) { + if controller == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "charger not configured"}) + return + } + status, err := fetchChargerStatus(cfg.Charger.Host) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, status) + }) + protected.HandleFunc("GET /api/charger/mode", func(w http.ResponseWriter, r *http.Request) { + if controller == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "charger not configured"}) + return + } + writeJSON(w, http.StatusOK, controller.State()) + }) + protected.HandleFunc("POST /api/charger/reset", func(w http.ResponseWriter, r *http.Request) { + if controller == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "charger not configured"}) + return + } + if err := resetCharger(cfg.Charger.Host); err != nil { + writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) + return + } + // Re-apply current mode since reset clears frc to 0 + _ = controller.SetParams(controller.State().Params) + status, err := fetchChargerStatus(cfg.Charger.Host) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, status) + }) + protected.HandleFunc("POST /api/charger/mode", func(w http.ResponseWriter, r *http.Request) { + if controller == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "charger not configured"}) + return + } + var p ChargingParams + if err := json.NewDecoder(r.Body).Decode(&p); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid body"}) + return + } + if err := controller.SetParams(p); err != nil { + writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, controller.State()) + }) + mux.Handle("/api/", auth.middleware(protected)) if frontendFS != nil { diff --git a/internal/config/config.go b/internal/config/config.go index 19e3f01..9257cf2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -6,9 +6,10 @@ import ( ) type Config struct { - Server ServerConfig - Auth AuthConfig - DB DBConfig + Server ServerConfig + Auth AuthConfig + DB DBConfig + Charger ChargerConfig } type ServerConfig struct { @@ -25,6 +26,10 @@ type DBConfig struct { DSN string } +type ChargerConfig struct { + Host string +} + func Load() *Config { return &Config{ Server: ServerConfig{ @@ -38,6 +43,9 @@ func Load() *Config { DB: DBConfig{ DSN: getEnv("ENERGY_DB_DSN", "postgres://energy:changeme@localhost:5433/energy"), }, + Charger: ChargerConfig{ + Host: getEnv("ENERGY_CHARGER_HOST", ""), + }, } }