@@ -9,7 +9,15 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
ENERGY_PORT: 8080
|
ENERGY_PORT: 8080
|
||||||
ENERGY_DB_DSN: postgres://energy:changeme@timescaledb:5432/energy
|
ENERGY_DB_DSN: postgres://energy:changeme@timescaledb:5432/energy
|
||||||
|
ENERGY_CHARGER_HOST: go-echarger-239054
|
||||||
networks:
|
networks:
|
||||||
- proxy
|
- proxy
|
||||||
- fitmonitor-network
|
- fitmonitor-network
|
||||||
|
|
||||||
|
networks:
|
||||||
|
proxy:
|
||||||
|
name: proxy
|
||||||
|
fitmonitor-network:
|
||||||
|
name: fitmonitor-network
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,31 @@ export interface BatteryPoint {
|
|||||||
level: number
|
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 = {
|
export const api = {
|
||||||
login(username: string, password: string) {
|
login(username: string, password: string) {
|
||||||
return request<{ status: string }>('/login', {
|
return request<{ status: string }>('/login', {
|
||||||
@@ -71,4 +96,23 @@ export const api = {
|
|||||||
getBattery(range: string) {
|
getBattery(range: string) {
|
||||||
return request<{ points: BatteryPoint[] }>(`/battery?range=${range}`)
|
return request<{ points: BatteryPoint[] }>(`/battery?range=${range}`)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getCharger() {
|
||||||
|
return request<ChargerStatus>('/charger')
|
||||||
|
},
|
||||||
|
|
||||||
|
resetCharger() {
|
||||||
|
return request<ChargerStatus>('/charger/reset', { method: 'POST' })
|
||||||
|
},
|
||||||
|
|
||||||
|
getChargerMode() {
|
||||||
|
return request<ControllerState>('/charger/mode')
|
||||||
|
},
|
||||||
|
|
||||||
|
setChargerMode(params: ChargingParams) {
|
||||||
|
return request<ControllerState>('/charger/mode', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(params),
|
||||||
|
})
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
CategoryScale, LinearScale,
|
CategoryScale, LinearScale,
|
||||||
Tooltip, Legend, Filler,
|
Tooltip, Legend, Filler,
|
||||||
} from 'chart.js'
|
} 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(
|
Chart.register(
|
||||||
LineController, LineElement, PointElement,
|
LineController, LineElement, PointElement,
|
||||||
@@ -16,14 +16,32 @@ Chart.register(
|
|||||||
|
|
||||||
// ── Data ─────────────────────────────────────────────────────────────────────
|
// ── 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 ranges = ['1h', '6h', '24h', '7d'] as const
|
||||||
|
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
|
|
||||||
const powerPts = ref<PowerPoint[]>([])
|
const powerPts = ref<PowerPoint[]>([])
|
||||||
const battPts = ref<BatteryPoint[]>([])
|
const battPts = ref<BatteryPoint[]>([])
|
||||||
|
const chargerSt = ref<ChargerStatus | null>(null)
|
||||||
|
const chargerCtrl = ref<ControllerState | null>(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<ChargingParams>({
|
||||||
|
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 ───────────────────────────────────────────────────────────
|
// ── Chart instances ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -129,6 +147,79 @@ function buildBattChart(canvas: HTMLCanvasElement, pts: BatteryPoint[], range: s
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Charger helpers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const CAR_LABELS: Record<number, string> = {
|
||||||
|
1: 'No car',
|
||||||
|
2: 'Charging',
|
||||||
|
3: 'Waiting',
|
||||||
|
4: 'Complete',
|
||||||
|
5: 'Error',
|
||||||
|
}
|
||||||
|
|
||||||
|
const CAR_COLORS: Record<number, string> = {
|
||||||
|
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 ──────────────────────────────────────────────────────────────
|
// ── Data loading ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function loadTimeSeries() {
|
async function loadTimeSeries() {
|
||||||
@@ -150,7 +241,7 @@ function latestBattery(): number | null {
|
|||||||
// ── Refresh ───────────────────────────────────────────────────────────────────
|
// ── Refresh ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function refreshLive() {
|
async function refreshLive() {
|
||||||
await loadTimeSeries()
|
await Promise.all([loadTimeSeries(), refreshCharger()])
|
||||||
await nextTick()
|
await nextTick()
|
||||||
powerChart?.destroy(); powerChart = null
|
powerChart?.destroy(); powerChart = null
|
||||||
battChart?.destroy(); battChart = null
|
battChart?.destroy(); battChart = null
|
||||||
@@ -166,7 +257,7 @@ onMounted(async () => {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
try {
|
try {
|
||||||
await loadTimeSeries()
|
await Promise.all([loadTimeSeries(), refreshCharger()])
|
||||||
loading.value = false
|
loading.value = false
|
||||||
await nextTick()
|
await nextTick()
|
||||||
if (powerCanvas.value && powerPts.value.length) powerChart = buildPowerChart(powerCanvas.value, powerPts.value, selectedRange.value)
|
if (powerCanvas.value && powerPts.value.length) powerChart = buildPowerChart(powerCanvas.value, powerPts.value, selectedRange.value)
|
||||||
@@ -217,6 +308,73 @@ watch(selectedRange, refreshLive)
|
|||||||
</div>
|
</div>
|
||||||
<div class="chart-wrap chart-wrap-sm"><canvas ref="battCanvas" /></div>
|
<div class="chart-wrap chart-wrap-sm"><canvas ref="battCanvas" /></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="chargerSt" class="card charger-card">
|
||||||
|
|
||||||
|
<!-- Status row -->
|
||||||
|
<div class="ch-status-row">
|
||||||
|
<span class="ch-car" :style="{ color: carColor(chargerSt.car) }">{{ carLabel(chargerSt.car) }}</span>
|
||||||
|
<span class="ch-info">{{ (chargerSt.session_wh / 1000).toFixed(2) }} kWh</span>
|
||||||
|
<span v-if="chargerSt.amp" class="ch-info">{{ chargerSt.amp }}A</span>
|
||||||
|
<span v-if="chargerSt.soc" class="ch-info">car {{ chargerSt.soc }}%</span>
|
||||||
|
<span v-if="chargerCtrl" class="ch-ctrl-status">{{ chargerCtrl.status }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mode tabs -->
|
||||||
|
<div class="ch-modes">
|
||||||
|
<button
|
||||||
|
v-for="m in CHARGING_MODES" :key="m.value"
|
||||||
|
:class="['ch-mode-btn', { active: modeForm.mode === m.value }]"
|
||||||
|
@click="modeForm.mode = m.value"
|
||||||
|
>{{ m.label }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Parameters (hidden when off) -->
|
||||||
|
<template v-if="modeForm.mode !== 'off'">
|
||||||
|
<div class="ch-param-row">
|
||||||
|
<span class="ch-param-label">Max current</span>
|
||||||
|
<input type="range" min="6" max="32" step="1" v-model.number="modeForm.max_amp" class="ch-range" />
|
||||||
|
<span class="ch-param-val">{{ modeForm.max_amp }}A</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="modeForm.mode === 'solar_battery'" class="ch-param-row">
|
||||||
|
<span class="ch-param-label">Min battery</span>
|
||||||
|
<input type="range" min="10" max="90" step="5" v-model.number="modeForm.min_battery_soc" class="ch-range" />
|
||||||
|
<span class="ch-param-val">{{ modeForm.min_battery_soc }}%</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="modeForm.mode === 'solar_battery'" class="ch-param-row">
|
||||||
|
<span class="ch-param-label">Resume offset</span>
|
||||||
|
<input type="range" min="1" max="20" step="1" v-model.number="modeForm.hysteresis" class="ch-range" />
|
||||||
|
<span class="ch-param-val">+{{ modeForm.hysteresis }}% → {{ modeForm.min_battery_soc + modeForm.hysteresis }}%</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="modeForm.mode !== 'grid'" class="ch-param-row">
|
||||||
|
<span class="ch-param-label">Max phases</span>
|
||||||
|
<select v-model.number="modeForm.phases" class="ch-select">
|
||||||
|
<option :value="3">3-phase</option>
|
||||||
|
<option :value="1">1-phase</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="ch-param-row">
|
||||||
|
<span class="ch-param-label">Stop at</span>
|
||||||
|
<select v-model="stopMode" class="ch-select">
|
||||||
|
<option value="none">No limit</option>
|
||||||
|
<option value="kwh">kWh session</option>
|
||||||
|
<option value="soc">% car SOC</option>
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
v-if="stopMode !== 'none'"
|
||||||
|
type="number" min="1" step="1"
|
||||||
|
v-model.number="stopValue"
|
||||||
|
class="ch-num"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="ch-footer">
|
||||||
|
<button class="ch-apply-btn" :disabled="chargerBusy" @click="applyMode">Apply</button>
|
||||||
|
<button class="ch-reset-btn" :disabled="chargerBusy" @click="softReset">Reset charger</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="chargerErr" class="ch-err">{{ chargerErr }}</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -317,4 +475,135 @@ watch(selectedRange, refreshLive)
|
|||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
color: #fff;
|
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;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -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"}
|
{"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"}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
+41
-16
@@ -38,10 +38,11 @@ var periodDefaults = map[string]int{
|
|||||||
}
|
}
|
||||||
|
|
||||||
// viewForRange picks the right continuous aggregate views for the given time range.
|
// 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) {
|
func viewForRange(timeRange string) (invView, meterView, chargerView, interval string) {
|
||||||
switch timeRange {
|
switch timeRange {
|
||||||
case "1h":
|
case "1h":
|
||||||
return "inverter_10m", "power_meter_10m", "charger_10m", "1 hour"
|
return "", "", "", "1 hour" // raw tables, 30s buckets
|
||||||
case "6h":
|
case "6h":
|
||||||
return "inverter_10m", "power_meter_10m", "charger_10m", "6 hours"
|
return "inverter_10m", "power_meter_10m", "charger_10m", "6 hours"
|
||||||
case "7d":
|
case "7d":
|
||||||
@@ -90,18 +91,35 @@ func getPower(ctx context.Context, pool *pgxpool.Pool, timeRange string) ([]Powe
|
|||||||
return m, rows.Err()
|
return m, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
pvSQL := fmt.Sprintf(
|
var pvSQL, houseSQL, barnSQL, chargerSQL string
|
||||||
`SELECT bucket, COALESCE(pv_power, 0) FROM %s WHERE bucket >= NOW() - '%s'::interval ORDER BY bucket`,
|
if invView == "" {
|
||||||
invView, interval)
|
// Raw tables with 30-second buckets for short live range
|
||||||
houseSQL := fmt.Sprintf(
|
pvSQL = fmt.Sprintf(
|
||||||
`SELECT bucket, COALESCE(total_power, 0) FROM %s WHERE device = 'house' AND bucket >= NOW() - '%s'::interval ORDER BY bucket`,
|
`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`,
|
||||||
meterView, interval)
|
interval)
|
||||||
barnSQL := fmt.Sprintf(
|
houseSQL = fmt.Sprintf(
|
||||||
`SELECT bucket, COALESCE(total_power, 0) FROM %s WHERE device = 'barn' AND bucket >= NOW() - '%s'::interval ORDER BY bucket`,
|
`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`,
|
||||||
meterView, interval)
|
interval)
|
||||||
chargerSQL := fmt.Sprintf(
|
barnSQL = fmt.Sprintf(
|
||||||
`SELECT bucket, COALESCE(power, 0) FROM %s WHERE bucket >= NOW() - '%s'::interval ORDER BY bucket`,
|
`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`,
|
||||||
chargerView, interval)
|
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 {
|
type result struct {
|
||||||
m map[time.Time]float64
|
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) {
|
func getBattery(ctx context.Context, pool *pgxpool.Pool, timeRange string) ([]BatteryPoint, error) {
|
||||||
invView, _, _, interval := viewForRange(timeRange)
|
invView, _, _, interval := viewForRange(timeRange)
|
||||||
|
|
||||||
sql := fmt.Sprintf(
|
var sql string
|
||||||
`SELECT bucket, COALESCE(battery_soc, 0) FROM %s WHERE bucket >= NOW() - '%s'::interval ORDER BY bucket`,
|
if invView == "" {
|
||||||
invView, interval)
|
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)
|
rows, err := pool.Query(ctx, sql)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -69,6 +69,65 @@ func NewRouter(cfg config.Config, frontendFS fs.FS) http.Handler {
|
|||||||
writeJSON(w, http.StatusOK, map[string]interface{}{"points": pts})
|
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))
|
mux.Handle("/api/", auth.middleware(protected))
|
||||||
|
|
||||||
if frontendFS != nil {
|
if frontendFS != nil {
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Server ServerConfig
|
Server ServerConfig
|
||||||
Auth AuthConfig
|
Auth AuthConfig
|
||||||
DB DBConfig
|
DB DBConfig
|
||||||
|
Charger ChargerConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
type ServerConfig struct {
|
type ServerConfig struct {
|
||||||
@@ -25,6 +26,10 @@ type DBConfig struct {
|
|||||||
DSN string
|
DSN string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ChargerConfig struct {
|
||||||
|
Host string
|
||||||
|
}
|
||||||
|
|
||||||
func Load() *Config {
|
func Load() *Config {
|
||||||
return &Config{
|
return &Config{
|
||||||
Server: ServerConfig{
|
Server: ServerConfig{
|
||||||
@@ -38,6 +43,9 @@ func Load() *Config {
|
|||||||
DB: DBConfig{
|
DB: DBConfig{
|
||||||
DSN: getEnv("ENERGY_DB_DSN", "postgres://energy:changeme@localhost:5433/energy"),
|
DSN: getEnv("ENERGY_DB_DSN", "postgres://energy:changeme@localhost:5433/energy"),
|
||||||
},
|
},
|
||||||
|
Charger: ChargerConfig{
|
||||||
|
Host: getEnv("ENERGY_CHARGER_HOST", ""),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user