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
+44
View File
@@ -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<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),
})
},
}
+295 -6
View File
@@ -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<PowerPoint[]>([])
const battPts = ref<BatteryPoint[]>([])
const powerPts = ref<PowerPoint[]>([])
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 ───────────────────────────────────────────────────────────
@@ -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 ──────────────────────────────────────────────────────────────
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)
</div>
<div class="chart-wrap chart-wrap-sm"><canvas ref="battCanvas" /></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>
@@ -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;
}
</style>
+1 -1
View File
@@ -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"}