|
|
|
@@ -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>
|
|
|
|
|