From 4e81a3637108e348b05f99116168a0c86c03eee6 Mon Sep 17 00:00:00 2001 From: Dusan Vojacek Date: Sun, 12 Apr 2026 16:56:44 +0200 Subject: [PATCH] stranka configuration --- backend/app/main.py | 2 + backend/app/routers/site_configuration.py | 248 +++++++++++ frontend/src/App.tsx | 5 + frontend/src/api/backend.ts | 6 + frontend/src/pages/SiteConfiguration.tsx | 485 ++++++++++++++++++++++ frontend/src/types/siteConfiguration.ts | 50 +++ 6 files changed, 796 insertions(+) create mode 100644 backend/app/routers/site_configuration.py create mode 100644 frontend/src/pages/SiteConfiguration.tsx create mode 100644 frontend/src/types/siteConfiguration.ts diff --git a/backend/app/main.py b/backend/app/main.py index 90c656f..bac3569 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -20,6 +20,7 @@ from app.routers.energy_flows import router as energy_flows_router from app.routers.ev import router as ev_router from app.routers.full_status import router as full_status_router from app.routers.plan import router as plan_router +from app.routers.site_configuration import router as site_configuration_router from app.ws_log_handler import WSLogHandler from app.ws_manager import manager from fastapi import ( @@ -526,6 +527,7 @@ app = FastAPI(title="EMS Platform", lifespan=lifespan) app.include_router(plan_router, prefix="/api/v1") app.include_router(ev_router, prefix="/api/v1") app.include_router(full_status_router, prefix="/api/v1") +app.include_router(site_configuration_router, prefix="/api/v1") app.include_router(economics_router, prefix="/api/v1") app.include_router(energy_flows_router, prefix="/api/v1") diff --git a/backend/app/routers/site_configuration.py b/backend/app/routers/site_configuration.py new file mode 100644 index 0000000..1550755 --- /dev/null +++ b/backend/app/routers/site_configuration.py @@ -0,0 +1,248 @@ +"""GET /sites/{site_id}/configuration – read-only souhrn konfigurace lokality.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Annotated, Any + +import asyncpg +from fastapi import APIRouter, Depends, HTTPException + +from app.db_json import record_to_dict +from app.deps import get_pg_pool + +router = APIRouter(prefix="/sites/{site_id}", tags=["sites"]) + +_DEYE_KEYS = frozenset( + { + "deye_last_system_time_sync_at", + "deye_last_system_time_sync_minute", + "deye_last_tou_inactive_write_prague_date", + "deye_tou_inactive_signature", + } +) + + +def _mask_secret_reference(raw: str | None) -> str | None: + if raw is None: + return None + s = str(raw).strip() + if not s: + return None + if len(s) <= 4: + return "nastaveno" + return f"…{s[-2:]}" + + +def _iso_utc(dt: datetime | None) -> str | None: + if dt is None: + return None + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc).isoformat() + + +@router.get("/configuration") +async def get_site_configuration( + site_id: int, + pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)], +) -> dict[str, Any]: + async with pool.acquire() as conn: + site_row = await conn.fetchrow( + """ + SELECT id, code, name, timezone, latitude, longitude, active, notes, created_at + FROM ems.site + WHERE id = $1 + """, + site_id, + ) + if site_row is None: + raise HTTPException(status_code=404, detail="Site not found") + + grid_row = await conn.fetchrow( + "SELECT * FROM ems.site_grid_connection WHERE site_id = $1", + site_id, + ) + market_row = await conn.fetchrow( + """ + SELECT * + FROM ems.site_market_config + WHERE site_id = $1 + AND valid_from <= now() + AND (valid_to IS NULL OR valid_to > now()) + ORDER BY valid_from DESC + LIMIT 1 + """, + site_id, + ) + endpoint_rows = await conn.fetch( + """ + SELECT id, site_id, endpoint_type, host, port, protocol, unit_id, + auth_reference, enabled, notes + FROM ems.site_endpoint + WHERE site_id = $1 + ORDER BY id + """, + site_id, + ) + endpoints: list[dict[str, Any]] = [] + for er in endpoint_rows: + d = record_to_dict(er) + d["auth_reference"] = _mask_secret_reference(er["auth_reference"]) + endpoints.append(d) + + inv_rows = await conn.fetch( + """ + SELECT ai.*, + (SELECT ep.host || CASE + WHEN ep.port IS NOT NULL THEN ':' || ep.port::text + ELSE '' + END + FROM ems.site_endpoint ep + WHERE ep.id = ai.endpoint_id) AS endpoint_connection + FROM ems.asset_inverter ai + WHERE ai.site_id = $1 + ORDER BY ai.id + """, + site_id, + ) + inverters: list[dict[str, Any]] = [] + for ir in inv_rows: + full = record_to_dict(ir) + ep_label = full.pop("endpoint_connection", None) + core = {k: v for k, v in full.items() if k not in _DEYE_KEYS} + deye_meta = {k: full[k] for k in _DEYE_KEYS if full.get(k) is not None} + core["endpoint_connection"] = ep_label + core["deye_meta"] = deye_meta if deye_meta else None + inverters.append(core) + + bat_rows = await conn.fetch( + "SELECT * FROM ems.asset_battery WHERE site_id = $1 ORDER BY id", + site_id, + ) + pv_rows = await conn.fetch( + "SELECT * FROM ems.asset_pv_array WHERE site_id = $1 ORDER BY id", + site_id, + ) + ev_rows = await conn.fetch( + """ + SELECT ec.*, + se.host || CASE + WHEN se.port IS NOT NULL THEN ':' || se.port::text + ELSE '' + END AS endpoint_connection + FROM ems.asset_ev_charger ec + LEFT JOIN ems.site_endpoint se ON se.id = ec.endpoint_id + WHERE ec.site_id = $1 + ORDER BY ec.id + """, + site_id, + ) + ev_chargers = [record_to_dict(r) for r in ev_rows] + + veh_rows = await conn.fetch( + """ + SELECT id, site_id, code, name, make, model, battery_capacity_kwh, + max_charge_power_w, default_charger_id, api_type, api_reference, + default_target_soc_pct, default_deadline_hour, active + FROM ems.asset_vehicle + WHERE site_id = $1 + ORDER BY code + """, + site_id, + ) + vehicles: list[dict[str, Any]] = [] + for vr in veh_rows: + d = record_to_dict(vr) + d["api_reference"] = _mask_secret_reference(vr["api_reference"]) + vehicles.append(d) + + hp_rows = await conn.fetch( + """ + SELECT hp.*, + se.host || CASE + WHEN se.port IS NOT NULL THEN ':' || se.port::text + ELSE '' + END AS endpoint_connection + FROM ems.asset_heat_pump hp + LEFT JOIN ems.site_endpoint se ON se.id = hp.endpoint_id + WHERE hp.site_id = $1 + ORDER BY hp.id + """, + site_id, + ) + heat_pumps = [record_to_dict(r) for r in hp_rows] + + mode_row = await conn.fetchrow( + """ + SELECT m.mode_code, m.activated_at, m.activated_by, m.valid_until, + m.previous_mode, m.notes, + d.name AS mode_name, d.description AS mode_description, + d.loxone_mode_value, d.ev_enabled, d.heat_pump_enabled, + d.battery_mode, d.grid_mode, d.is_autonomous + FROM ems.site_operating_mode m + JOIN ems.operating_mode_def d ON d.code = m.mode_code + WHERE m.site_id = $1 + """, + site_id, + ) + + override_rows = await conn.fetch( + """ + SELECT id, override_type, value_json, valid_from, valid_to, reason, created_by, created_at + FROM ems.site_override + WHERE site_id = $1 + AND valid_from <= now() + AND (valid_to IS NULL OR valid_to > now()) + ORDER BY valid_from DESC + LIMIT 50 + """, + site_id, + ) + + hb_row = await conn.fetchrow( + "SELECT last_seen, status FROM ems.site_heartbeat WHERE site_id = $1", + site_id, + ) + run_row = await conn.fetchrow( + """ + SELECT id, created_at + FROM ems.planning_run + WHERE site_id = $1 AND status = 'active' + ORDER BY created_at DESC + LIMIT 1 + """, + site_id, + ) + + site = record_to_dict(site_row) + lat = site_row["latitude"] + lon = site_row["longitude"] + site["latitude"] = float(lat) if lat is not None else None + site["longitude"] = float(lon) if lon is not None else None + + operating_mode = record_to_dict(mode_row) if mode_row else None + + return { + "site": site, + "grid_connection": record_to_dict(grid_row) if grid_row else None, + "market_config": record_to_dict(market_row) if market_row else None, + "market_config_note": ( + "Zelený bonus za výrobu je u FVE polí (asset_pv_array), ne v obchodní konfiguraci." + ), + "endpoints": endpoints, + "inverters": inverters, + "batteries": [record_to_dict(r) for r in bat_rows], + "pv_arrays": [record_to_dict(r) for r in pv_rows], + "ev_chargers": ev_chargers, + "vehicles": vehicles, + "heat_pumps": heat_pumps, + "operating_mode": operating_mode, + "active_overrides": [record_to_dict(r) for r in override_rows], + "operational": { + "heartbeat_last_seen": _iso_utc(hb_row["last_seen"]) if hb_row else None, + "heartbeat_status": hb_row["status"] if hb_row else None, + "has_active_plan": run_row is not None, + "active_plan_created_at": _iso_utc(run_row["created_at"]) if run_row else None, + }, + } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4fd154e..4d3f044 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -8,6 +8,7 @@ import Economics from './pages/Economics' import EnergyFlows from './pages/EnergyFlows' import { Logs } from './pages/Logs' import Planning from './pages/Planning' +import SiteConfiguration from './pages/SiteConfiguration' import { Settings } from './pages/Settings' function SiteCombo() { @@ -75,6 +76,9 @@ function AppLayout() { Toky + + Konfigurace + Nastavení @@ -109,6 +113,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> diff --git a/frontend/src/api/backend.ts b/frontend/src/api/backend.ts index 8e0a3f1..be89dca 100644 --- a/frontend/src/api/backend.ts +++ b/frontend/src/api/backend.ts @@ -1,6 +1,7 @@ import axios, { type AxiosInstance } from 'axios' import type { FullStatusResponse } from '../types/fullStatus' +import type { SiteConfigurationResponse } from '../types/siteConfiguration' import type { Notification } from '../types/dashboard' import type { CurrentPlanResponse, RunPlanResponse } from '../types/plan' @@ -53,6 +54,11 @@ export async function getSiteStatusFull(siteId: number): Promise { + const { data } = await client.get(`/sites/${siteId}/configuration`) + return data +} + export type SiteNotificationsResponse = { notifications: Notification[] } diff --git a/frontend/src/pages/SiteConfiguration.tsx b/frontend/src/pages/SiteConfiguration.tsx new file mode 100644 index 0000000..7eecafe --- /dev/null +++ b/frontend/src/pages/SiteConfiguration.tsx @@ -0,0 +1,485 @@ +import { ChevronDown, ChevronRight, ExternalLink } from 'lucide-react' +import { useCallback, useEffect, useState } from 'react' +import { NavLink } from 'react-router-dom' + +import { getSiteConfiguration } from '../api/backend' +import { useSiteSelection } from '../context/SiteSelectionContext' +import type { SiteConfigurationResponse } from '../types/siteConfiguration' + +export function osmMapUrl(lat: number, lon: number): string { + return `https://www.openstreetmap.org/?mlat=${lat}&mlon=${lon}#map=16/${lat}/${lon}` +} + +export function googleMapUrl(lat: number, lon: number): string { + return `https://www.google.com/maps?q=${lat},${lon}` +} + +/** OSM embed: bbox = minLon, minLat, maxLon, maxLat */ +export function osmEmbedUrl(lat: number, lon: number, delta = 0.012): string { + const left = lon - delta + const right = lon + delta + const bottom = lat - delta + const top = lat + delta + const bbox = `${left},${bottom},${right},${top}` + return `https://www.openstreetmap.org/export/embed.html?bbox=${encodeURIComponent(bbox)}&layer=mapnik&marker=${encodeURIComponent(`${lat},${lon}`)}` +} + +function fmtVal(v: unknown): string { + if (v == null) return '—' + if (typeof v === 'boolean') return v ? 'ano' : 'ne' + if (typeof v === 'number') return Number.isFinite(v) ? String(v) : '—' + if (typeof v === 'object') return JSON.stringify(v) + return String(v) +} + +function DefRow({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+
{label}
+
{children}
+
+ ) +} + +function Section({ + kicker, + title, + children, +}: { + kicker: string + title: string + children: React.ReactNode +}) { + return ( +
+

{kicker}

+

{title}

+ {children} +
+ ) +} + +function ObjectRows({ + obj, + labels, + skip = [], +}: { + obj: Record + labels?: Record + skip?: string[] +}) { + const keys = Object.keys(obj) + .filter((k) => !skip.includes(k)) + .filter((k) => obj[k] !== undefined) + .sort() + if (keys.length === 0) { + return

Žádná data

+ } + return ( +
+ {keys.map((k) => ( + + {fmtVal(obj[k])} + + ))} +
+ ) +} + +const GRID_LABELS: Record = { + max_import_power_w: 'Max. import (W)', + max_export_power_w: 'Max. export (W)', + no_export: 'Zákaz exportu', + reserved_capacity_w: 'Rezervovaný výkon (W)', + notes: 'Poznámky', +} + +const MARKET_LABELS: Record = { + purchase_pricing_mode: 'Režim nákupu', + sale_pricing_mode: 'Režim prodeje', + buy_margin_fixed_czk: 'Marže nákup fix (Kč/kWh)', + buy_margin_percent: 'Marže nákup %', + sell_margin_fixed_czk: 'Marže prodej fix (Kč/kWh)', + sell_margin_percent: 'Marže prodej %', + currency: 'Měna', + valid_from: 'Platnost od', + valid_to: 'Platnost do', + notes: 'Poznámky', +} + +export default function SiteConfiguration() { + const { selectedSiteId, ready: selectionReady, error: selectionError } = useSiteSelection() + const [data, setData] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [deyeOpen, setDeyeOpen] = useState>({}) + + const load = useCallback(async () => { + if (selectedSiteId == null) { + setData(null) + setError(null) + return + } + setLoading(true) + setError(null) + try { + const res = await getSiteConfiguration(selectedSiteId) + setData(res) + } catch { + setData(null) + setError('Konfiguraci se nepodařilo načíst') + } finally { + setLoading(false) + } + }, [selectedSiteId]) + + useEffect(() => { + void load() + }, [load]) + + const toggleDeye = (id: number) => { + setDeyeOpen((prev) => ({ ...prev, [id]: !prev[id] })) + } + + if (!selectionReady) { + return ( +
+

Načítám lokality…

+
+ ) + } + + if (selectionError != null || selectedSiteId == null) { + return ( +
+

{selectionError ?? 'Vyberte lokalitu v horní liště.'}

+
+ ) + } + + const site = data?.site + const lat = site?.latitude + const lon = site?.longitude + const hasCoords = lat != null && lon != null && Number.isFinite(lat) && Number.isFinite(lon) + + return ( +
+
+

Konfigurace lokality

+

+ Souhrn statického nastavení z databáze pro vybranou lokalitu (pouze čtení). +

+ {site ? ( +

+ {site.name} ({site.code}) · ID {site.id} +

+ ) : null} + +
+ + {loading ?

Načítám konfiguraci…

: null} + {error ?

{error}

: null} + + {data ? ( +
+
+
+ {site?.timezone} + {fmtVal(site?.active)} + {site?.notes?.trim() ? site.notes : '—'} + {site?.created_at ?? '—'} + {lat != null ? String(lat) : '—'} + {lon != null ? String(lon) : '—'} +
+ {hasCoords ? ( +
+ +
+