second version

This commit is contained in:
Dusan Vojacek
2026-04-03 14:23:16 +02:00
parent 897b95f728
commit 9f4126946d
105 changed files with 9738 additions and 1470 deletions

View File

@@ -0,0 +1,307 @@
import axios from 'axios'
import { RefreshCw } from 'lucide-react'
import { memo, useCallback, useEffect, useState } from 'react'
import { getCommandJournal, getDeyeRegisters, type DeyeRegistersLive, type ModbusJournalCommandDto } from '../api/backend'
const BATT_VOLTAGE_V = 51.2
const POLL_REGISTERS_MS = 30_000
const POLL_JOURNAL_MS = 60_000
const TZ = 'Europe/Prague'
function fmtTime(iso: string): string {
return new Date(iso).toLocaleString('cs-CZ', {
timeZone: TZ,
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
}
function ampsToKw(a: number | null | undefined): string {
if (a == null || Number.isNaN(a)) return '—'
return `${((a * BATT_VOLTAGE_V) / 1000).toFixed(2)} kW`
}
function fmtW(w: number | null | undefined): string {
if (w == null || Number.isNaN(w)) return '—'
return `${w} W`
}
function journalSignature(cmds: ModbusJournalCommandDto[]): string {
return cmds
.map(
(c) =>
`${c.id}:${c.status}:${c.attempt_count}:${c.value_written ?? ''}:${c.value_verified ?? ''}`,
)
.join('|')
}
function statusBadgeClass(status: string): string {
const u = status.toLowerCase()
if (u === 'verified') return 'bg-emerald-600/25 text-emerald-200 ring-1 ring-emerald-500/40'
if (u === 'written') return 'bg-sky-600/25 text-sky-200 ring-1 ring-sky-500/40'
if (u === 'pending' || u === 'retrying') return 'bg-slate-600/30 text-slate-300 ring-1 ring-slate-500/35'
if (u === 'failed' || u === 'mismatch')
return u === 'mismatch'
? 'bg-red-600/30 text-red-100 font-bold ring-1 ring-red-500/50'
: 'bg-red-600/25 text-red-200 ring-1 ring-red-500/40'
return 'bg-slate-600/30 text-slate-300 ring-1 ring-slate-500/35'
}
type LiveSectionProps = {
live: DeyeRegistersLive | null
liveLoading: boolean
onRefresh: () => void
}
const LiveRegistersSection = memo(
function LiveRegistersSection({ live, liveLoading, onRefresh }: LiveSectionProps) {
return (
<div className="rounded-xl border border-slate-800 bg-slate-900/50 p-4">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-slate-200">Živé registry</h3>
<button
type="button"
onClick={() => onRefresh()}
disabled={liveLoading}
className="inline-flex items-center gap-1.5 rounded-lg border border-slate-600 bg-slate-800/80 px-3 py-1.5 text-xs font-medium text-slate-100 hover:bg-slate-700 disabled:opacity-50"
>
<RefreshCw className={`h-3.5 w-3.5 ${liveLoading ? 'animate-spin' : ''}`} aria-hidden />
Refresh
</button>
</div>
<div className="mt-2 grid grid-cols-1 gap-3 sm:grid-cols-2">
<Metric label="Max nabíjení" reg={108} unitA={live?.reg108_charge_a} kwHint />
<Metric label="Max vybíjení" reg={109} unitA={live?.reg109_discharge_a} kwHint />
<Metric
label="Limit control"
reg={142}
sub="0 = selling first, 1 = zero export"
valueText={live?.reg142_limit_control != null ? String(live.reg142_limit_control) : undefined}
/>
<Metric label="Energy mode" reg={141} valueText={live?.reg141_energy_mode != null ? String(live.reg141_energy_mode) : undefined} />
<Metric
label="Peak shaving switch"
reg={178}
sub="Bit45: 10 = disable při exportu, 11 = enable při IDLE/CHARGE"
valueText={live?.reg178_peak_shaving_switch != null ? String(live.reg178_peak_shaving_switch) : undefined}
/>
<Metric
label="Grid peak shaving W"
reg={191}
sub="EMS nezapisuje nastavit v SolarmanApp (výkon peak shavingu v W)"
valueText={fmtW(live?.reg191_peak_shaving_w)}
/>
</div>
{live?.read_at ? (
<p className="mt-3 text-[10px] text-slate-500">Načteno: {fmtTime(live.read_at)}</p>
) : null}
</div>
)
},
(a, b) =>
a.liveLoading === b.liveLoading &&
a.live?.read_at === b.live?.read_at &&
a.live?.reg108_charge_a === b.live?.reg108_charge_a &&
a.live?.reg109_discharge_a === b.live?.reg109_discharge_a &&
a.live?.reg141_energy_mode === b.live?.reg141_energy_mode &&
a.live?.reg142_limit_control === b.live?.reg142_limit_control &&
a.live?.reg143_export_limit_w === b.live?.reg143_export_limit_w &&
a.live?.reg178_peak_shaving_switch === b.live?.reg178_peak_shaving_switch &&
a.live?.reg191_peak_shaving_w === b.live?.reg191_peak_shaving_w,
)
type MetricProps = {
label: string
reg: number
unitA?: number | null
kwHint?: boolean
valueText?: string
sub?: string
}
function Metric({ label, reg, unitA, kwHint, valueText, sub }: MetricProps) {
const main =
valueText ??
(unitA != null && !Number.isNaN(unitA) ? `${unitA} A` : '—')
const extra = kwHint ? ampsToKw(unitA ?? null) : null
return (
<div className="rounded-lg border border-slate-800/80 bg-slate-950/40 px-3 py-2">
<p className="text-[10px] font-semibold uppercase tracking-wide text-slate-500">{label}</p>
<p className="mt-0.5 font-mono text-sm text-slate-100">
reg {reg}: {main}
{extra && extra !== '—' ? <span className="text-slate-400"> · {extra}</span> : null}
</p>
{sub ? <p className="mt-0.5 text-[10px] text-slate-500">{sub}</p> : null}
</div>
)
}
type JournalSectionProps = {
commands: ModbusJournalCommandDto[]
}
const JournalSection = memo(
function JournalSection({ commands }: JournalSectionProps) {
return (
<div className="rounded-xl border border-slate-800 bg-slate-900/50 p-4">
<h3 className="mb-3 text-sm font-semibold text-slate-200">Posledních 50 zápisů</h3>
<div
className="overflow-x-auto"
style={{
maxHeight: '400px',
overflowY: 'auto',
borderRadius: 'var(--border-radius-md)',
border: '0.5px solid var(--color-border-tertiary)',
}}
>
<table className="w-full border-collapse text-left text-xs">
<thead>
<tr className="text-slate-500">
<th className="py-2 pr-2 font-medium">Čas</th>
<th className="py-2 pr-2 font-medium">Reg</th>
<th className="py-2 pr-2 font-medium">Popis</th>
<th className="py-2 pr-2 font-medium">Hodnota</th>
<th className="py-2 pr-2 font-medium">Pokus</th>
<th className="py-2 font-medium">Status</th>
</tr>
</thead>
<tbody>
{commands.length === 0 ? (
<tr>
<td colSpan={6} className="py-4 text-slate-500">
Žádné záznamy v journalu.
</td>
</tr>
) : (
commands.map((c) => (
<tr key={c.id} className="border-t border-slate-800/80">
<td className="whitespace-nowrap py-1.5 pr-2 font-mono text-slate-400">
{fmtTime(c.created_at)}
</td>
<td className="pr-2 font-mono text-slate-300">{c.register}</td>
<td className="max-w-[140px] truncate pr-2 text-slate-400" title={c.register_name ?? ''}>
{c.register_name ?? '—'}
</td>
<td className="pr-2 font-mono tabular-nums text-slate-200">
{c.value_to_write}
{c.value_verified != null ? (
<span className="text-slate-500"> {c.value_verified}</span>
) : null}
</td>
<td className="pr-2 font-mono tabular-nums text-slate-300">{c.attempt_count}</td>
<td className="py-1.5">
<span
className={`inline-block rounded-md px-2 py-0.5 text-[10px] font-semibold uppercase ${statusBadgeClass(c.status)}`}
>
{c.status}
</span>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
)
},
(a, b) => journalSignature(a.commands) === journalSignature(b.commands),
)
function ControlPanelImpl({ siteId }: { siteId: number }) {
const [live, setLive] = useState<DeyeRegistersLive | null>(null)
const [liveError, setLiveError] = useState<string | null>(null)
const [liveLoading, setLiveLoading] = useState(false)
const [commands, setCommands] = useState<ModbusJournalCommandDto[]>([])
const [journalError, setJournalError] = useState<string | null>(null)
const fetchRegisters = useCallback(async () => {
setLiveLoading(true)
setLiveError(null)
try {
const data = await getDeyeRegisters(siteId)
setLive(data)
} catch (e: unknown) {
let msg = 'Chyba čtení registrů'
if (axios.isAxiosError(e)) {
const d = e.response?.data as { detail?: string } | undefined
if (typeof d?.detail === 'string') msg = d.detail
} else if (e instanceof Error) {
msg = e.message
}
setLiveError(msg)
setLive(null)
} finally {
setLiveLoading(false)
}
}, [siteId])
const fetchJournal = useCallback(async () => {
setJournalError(null)
try {
const res = await getCommandJournal(siteId, 50)
setCommands(res.commands)
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : 'Chyba načtení journalu'
setJournalError(msg)
setCommands([])
}
}, [siteId])
useEffect(() => {
void fetchRegisters()
}, [fetchRegisters])
useEffect(() => {
void fetchJournal()
}, [fetchJournal])
useEffect(() => {
const t = window.setInterval(() => void fetchRegisters(), POLL_REGISTERS_MS)
return () => window.clearInterval(t)
}, [fetchRegisters])
useEffect(() => {
const t = window.setInterval(() => void fetchJournal(), POLL_JOURNAL_MS)
return () => window.clearInterval(t)
}, [fetchJournal])
const apiError = liveError ?? journalError
return (
<div className="space-y-4">
{apiError ? (
<div
role="alert"
className="rounded-lg border border-red-500/45 bg-red-950/50 px-4 py-3 text-sm text-red-100"
>
<p className="font-semibold text-red-50">Chyba API řízení / Modbus</p>
{liveError ? (
<p className="mt-1.5">
<span className="text-red-200/90">GET /control/registers: </span>
{liveError}
</p>
) : null}
{journalError ? (
<p className={liveError ? 'mt-2' : 'mt-1.5'}>
<span className="text-red-200/90">Journal: </span>
{journalError}
</p>
) : null}
</div>
) : null}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 lg:gap-6">
<LiveRegistersSection live={live} liveLoading={liveLoading} onRefresh={fetchRegisters} />
<JournalSection commands={commands} />
</div>
</div>
)
}
export const ControlPanel = memo(ControlPanelImpl)