From 43fda71c1bc82506e454fe8f92ca495bd661a8ca Mon Sep 17 00:00:00 2001 From: Willem Serruys Date: Fri, 21 Aug 2026 11:49:49 +0200 Subject: [PATCH] fix: various fixes --- energy-management/.env.example | 12 + energy-management/backend/src/control/loop.ts | 2 +- .../backend/src/control/runner.ts | 50 ++- energy-management/backend/src/env.ts | 9 + energy-management/backend/src/influx/power.ts | 81 +++- energy-management/backend/src/types.ts | 7 +- energy-management/frontend/src/Dashboard.tsx | 30 +- energy-management/frontend/src/Devices.tsx | 395 +++++++++++------- energy-management/frontend/src/api.ts | 10 +- energy-management/frontend/src/theme.css | 30 ++ 10 files changed, 441 insertions(+), 185 deletions(-) diff --git a/energy-management/.env.example b/energy-management/.env.example index 0d8d6b6..b0ea88e 100644 --- a/energy-management/.env.example +++ b/energy-management/.env.example @@ -13,6 +13,18 @@ INFLUX_FIELD_UNIT=W HA_URL=http://homeassistant.local:8123 HA_TOKEN=replace-with-a-long-lived-access-token +# Solar inverter production, read from InfluxDB (same bucket as the house +# power series above). Its latest sample is compared against +# SOLAR_PRODUCTION_THRESHOLD_W to gate "only when sun" devices. Leave +# SOLAR_INFLUX_ENTITY_ID unset to fall back to HA's sun.sun (astronomical +# above/below horizon). +SOLAR_INFLUX_MEASUREMENT=power +SOLAR_INFLUX_DOMAIN=sensor +SOLAR_INFLUX_ENTITY_ID=inverter_pv_power +SOLAR_INFLUX_FIELD=value +SOLAR_INFLUX_FIELD_UNIT=W +SOLAR_PRODUCTION_THRESHOLD_W=50 + # Capacity tariff target: keep every 15-minute block's average under this many kW TARGET_KW=5 diff --git a/energy-management/backend/src/control/loop.ts b/energy-management/backend/src/control/loop.ts index b9b8505..c4527f4 100644 --- a/energy-management/backend/src/control/loop.ts +++ b/energy-management/backend/src/control/loop.ts @@ -6,7 +6,7 @@ export interface LoopDecisionInput { nowMs: number; /** Only restore once projected power is at least this far below target, to avoid flapping. */ restoreMarginW: number; - /** Whether HA's sun.sun entity currently reports above_horizon. */ + /** Whether the solar inverter is currently producing power (see HomeAssistantClient.isSolarProducing). */ sunUp: boolean; } diff --git a/energy-management/backend/src/control/runner.ts b/energy-management/backend/src/control/runner.ts index eae058e..3451ed9 100644 --- a/energy-management/backend/src/control/runner.ts +++ b/energy-management/backend/src/control/runner.ts @@ -1,10 +1,14 @@ import type { QueryApi } from "@influxdata/influxdb-client"; import type { Env } from "../env.js"; -import { fetchPowerSamplesSince } from "../influx/power.js"; +import { + fetchLatestPowerSample, + fetchLatestSolarProductionSample, + fetchPowerSamplesSince, +} from "../influx/power.js"; import { HomeAssistantClient } from "../homeassistant/client.js"; import { getDeviceStatus, restoreDevice, shedDevice } from "../homeassistant/devices.js"; import type { Store } from "../db/store.js"; -import type { DeviceStatus, QuarterHourStatus } from "../types.js"; +import type { CurrentReadings, DeviceStatus, QuarterHourStatus } from "../types.js"; import { blockStartFor, computeQuarterHourStatus } from "./quarterHour.js"; import { decideControlAction } from "./loop.js"; @@ -19,17 +23,24 @@ export class ControlLoopRunner { private readonly log: { info: (o: unknown, msg?: string) => void; error: (o: unknown, msg?: string) => void }, ) {} - async getStatus(): Promise<{ quarterHour: QuarterHourStatus; devices: DeviceStatus[] }> { + async getStatus(): Promise<{ quarterHour: QuarterHourStatus; devices: DeviceStatus[]; current: CurrentReadings }> { const nowMs = Date.now(); const blockStart = blockStartFor(nowMs); const targetW = this.store.getTargetW(); - const samples = await fetchPowerSamplesSince(this.queryApi, this.env, blockStart); + const [samples, latestPower, latestSolar, devices] = await Promise.all([ + fetchPowerSamplesSince(this.queryApi, this.env, blockStart), + fetchLatestPowerSample(this.queryApi, this.env), + fetchLatestSolarProductionSample(this.queryApi, this.env), + this.loadDeviceStatuses(), + ]); const quarterHour = computeQuarterHourStatus(samples, nowMs, targetW); + const current: CurrentReadings = { + powerW: latestPower?.watts ?? null, + solarW: latestSolar?.watts ?? null, + }; - const devices = await this.loadDeviceStatuses(); - - return { quarterHour, devices }; + return { quarterHour, devices, current }; } private async loadDeviceStatuses(): Promise { @@ -42,6 +53,26 @@ export class ControlLoopRunner { ); } + /** + * Whether the solar inverter is currently producing power, used to gate + * `onlyWhenSun` devices. Reads the configured InfluxDB solar production + * series and compares its latest sample against SOLAR_PRODUCTION_THRESHOLD_W. + * Falls back to HA's sun.sun (astronomical above/below horizon) when no + * solar production series is configured, or if either read fails. + */ + private async isSolarProducing(): Promise { + try { + const sample = await fetchLatestSolarProductionSample(this.queryApi, this.env); + if (sample) return sample.watts >= this.env.SOLAR_PRODUCTION_THRESHOLD_W; + } catch (err) { + this.log.error({ err }, "failed to read solar production series; falling back to sun.sun"); + } + return this.ha.isSunUp().catch((err) => { + this.log.error({ err }, "failed to read sun.sun state; treating as unrestricted"); + return true; + }); + } + async tick(): Promise { const nowMs = Date.now(); const { quarterHour, devices } = await this.getStatus(); @@ -50,10 +81,7 @@ export class ControlLoopRunner { // as that block's final average, with no separate "finalize" step needed. this.store.upsertBlockHistory(quarterHour.blockStartMs, quarterHour.runningAverageW); - const sunUp = await this.ha.isSunUp().catch((err) => { - this.log.error({ err }, "failed to read sun.sun state; treating as unrestricted"); - return true; - }); + const sunUp = await this.isSolarProducing(); const decision = decideControlAction({ status: quarterHour, devices, nowMs, restoreMarginW: RESTORE_MARGIN_W, sunUp }); if (!decision) return; diff --git a/energy-management/backend/src/env.ts b/energy-management/backend/src/env.ts index 46fcb83..d4ec07c 100644 --- a/energy-management/backend/src/env.ts +++ b/energy-management/backend/src/env.ts @@ -20,6 +20,15 @@ const envSchema = z.object({ INFLUX_FIELD_UNIT: z.enum(["W", "kW"]).default("W"), HA_URL: z.string().url(), HA_TOKEN: z.string().min(1), + // Solar inverter production, read from InfluxDB (same shape as the house + // power series above). Leave SOLAR_INFLUX_ENTITY_ID unset to fall back to + // sun.sun (astronomical day/night). + SOLAR_INFLUX_MEASUREMENT: z.string().default("power"), + SOLAR_INFLUX_DOMAIN: z.string().optional(), + SOLAR_INFLUX_ENTITY_ID: z.string().optional(), + SOLAR_INFLUX_FIELD: z.string().default("value"), + SOLAR_INFLUX_FIELD_UNIT: z.enum(["W", "kW"]).default("W"), + SOLAR_PRODUCTION_THRESHOLD_W: z.coerce.number().default(50), TARGET_KW: z.coerce.number().positive(), DB_PATH: z.string().default("./data/energy.db"), CONTROL_LOOP_INTERVAL_MS: z.coerce.number().default(10_000), diff --git a/energy-management/backend/src/influx/power.ts b/energy-management/backend/src/influx/power.ts index 9fe6726..6b3a2d8 100644 --- a/energy-management/backend/src/influx/power.ts +++ b/energy-management/backend/src/influx/power.ts @@ -2,27 +2,40 @@ import type { QueryApi } from "@influxdata/influxdb-client"; import type { Env } from "../env.js"; import type { PowerSample } from "../types.js"; +interface SeriesConfig { + bucket: string; + measurement: string; + field: string; + fieldUnit: "W" | "kW"; + domain?: string; + entityId?: string; +} + /** - * Fetches raw power samples between `sinceMs` and now. - * Converts the configured field to watts based on INFLUX_FIELD_UNIT. + * Fetches raw samples for one series between `sinceMs` and now, converting + * the configured field to watts based on `fieldUnit`. */ -export async function fetchPowerSamplesSince( +async function fetchSeriesSince( queryApi: QueryApi, - env: Env, + config: SeriesConfig, sinceMs: number, ): Promise { const sinceIso = new Date(sinceMs).toISOString(); + const domainFilter = config.domain + ? `|> filter(fn: (r) => r["domain"] == "${config.domain}")\n ` + : ""; + const entityFilter = config.entityId + ? `|> filter(fn: (r) => r["entity_id"] == "${config.entityId}")\n ` + : ""; const flux = ` - from(bucket: "${env.INFLUX_BUCKET}") + from(bucket: "${config.bucket}") |> range(start: ${sinceIso}) - |> filter(fn: (r) => r["_measurement"] == "${env.INFLUX_FIELD_UNIT}") - |> filter(fn: (r) => r["_field"] == "${env.INFLUX_FIELD}") - |> filter(fn: (r) => r["domain"] == "${env.INFLUX_DOMAIN}") - |> filter(fn: (r) => r["entity_id"] == "${env.INFLUX_ENTITY_ID}") - |> sort(columns: ["_time"]) + |> filter(fn: (r) => r["_measurement"] == "${config.fieldUnit}") + |> filter(fn: (r) => r["_field"] == "${config.field}") + ${domainFilter}${entityFilter}|> sort(columns: ["_time"]) `; - const unitMultiplier = env.INFLUX_FIELD_UNIT === "kW" ? 1000 : 1; + const unitMultiplier = config.fieldUnit === "kW" ? 1000 : 1; const samples: PowerSample[] = []; for await (const { values, tableMeta } of queryApi.iterateRows(flux)) { @@ -36,6 +49,29 @@ export async function fetchPowerSamplesSince( return samples; } +function housePowerConfig(env: Env): SeriesConfig { + return { + bucket: env.INFLUX_BUCKET, + measurement: env.INFLUX_MEASUREMENT, + field: env.INFLUX_FIELD, + fieldUnit: env.INFLUX_FIELD_UNIT, + domain: env.INFLUX_DOMAIN, + entityId: env.INFLUX_ENTITY_ID, + }; +} + +/** + * Fetches raw power samples between `sinceMs` and now. + * Converts the configured field to watts based on INFLUX_FIELD_UNIT. + */ +export async function fetchPowerSamplesSince( + queryApi: QueryApi, + env: Env, + sinceMs: number, +): Promise { + return fetchSeriesSince(queryApi, housePowerConfig(env), sinceMs); +} + /** Fetches the single most recent power sample, if any exists within the lookback window. */ export async function fetchLatestPowerSample( queryApi: QueryApi, @@ -49,3 +85,26 @@ export async function fetchLatestPowerSample( ); return samples.length > 0 ? samples[samples.length - 1] : null; } + +/** + * Fetches the most recent solar production sample from InfluxDB, if any + * exists within the lookback window. Returns null when no solar production + * series is configured (SOLAR_INFLUX_ENTITY_ID unset). + */ +export async function fetchLatestSolarProductionSample( + queryApi: QueryApi, + env: Env, + lookbackMs = 60_000, +): Promise { + if (!env.SOLAR_INFLUX_ENTITY_ID) return null; + const config: SeriesConfig = { + bucket: env.INFLUX_BUCKET, + measurement: env.SOLAR_INFLUX_MEASUREMENT, + field: env.SOLAR_INFLUX_FIELD, + fieldUnit: env.SOLAR_INFLUX_FIELD_UNIT, + domain: env.SOLAR_INFLUX_DOMAIN, + entityId: env.SOLAR_INFLUX_ENTITY_ID, + }; + const samples = await fetchSeriesSince(queryApi, config, Date.now() - lookbackMs); + return samples.length > 0 ? samples[samples.length - 1] : null; +} diff --git a/energy-management/backend/src/types.ts b/energy-management/backend/src/types.ts index ce19e34..ea8cdd8 100644 --- a/energy-management/backend/src/types.ts +++ b/energy-management/backend/src/types.ts @@ -10,7 +10,7 @@ export interface DeviceConfig { maxValue: number; // for "number" kind: normal operating value; for "switch": unused (1) dwellSeconds: number; // minimum time between state changes for this device manualOverride: boolean; // if true, control loop leaves this device alone - onlyWhenSun: boolean; // if true, device may only be enabled while sun.sun reports above_horizon + onlyWhenSun: boolean; // if true, device may only be enabled while the solar inverter is producing power fromTime: string | null; // "HH:MM", start of allowed enable window (local time), null = no restriction toTime: string | null; // "HH:MM", end of allowed enable window (local time), null = no restriction enableUrl: string | null; // if set, called (HTTP POST) to enable/restore the device instead of Home Assistant @@ -39,6 +39,11 @@ export interface QuarterHourStatus { overTarget: boolean; } +export interface CurrentReadings { + powerW: number | null; // most recent house power sample, null if none within lookback + solarW: number | null; // most recent solar production sample, null if unconfigured or none within lookback +} + export interface ControlAction { timestampMs: number; entityId: string; diff --git a/energy-management/frontend/src/Dashboard.tsx b/energy-management/frontend/src/Dashboard.tsx index 3dfbebb..62989a7 100644 --- a/energy-management/frontend/src/Dashboard.tsx +++ b/energy-management/frontend/src/Dashboard.tsx @@ -1,11 +1,24 @@ import { useEffect, useState } from "react"; -import { api, type BlockHistoryPoint, type ControlAction, type DeviceStatus, type QuarterHourStatus } from "./api"; +import { + api, + type BlockHistoryPoint, + type ControlAction, + type CurrentReadings, + type DeviceStatus, + type QuarterHourStatus, +} from "./api"; import { PeakHistoryChart } from "./PeakHistoryChart"; const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000; +function formatW(watts: number | null): string { + if (watts == null) return "—"; + return Math.abs(watts) >= 1000 ? `${(watts / 1000).toFixed(2)} kW` : `${Math.round(watts)} W`; +} + export function Dashboard() { const [quarterHour, setQuarterHour] = useState(null); + const [current, setCurrent] = useState(null); const [devices, setDevices] = useState([]); const [history, setHistory] = useState([]); const [actions, setActions] = useState([]); @@ -23,6 +36,7 @@ export function Dashboard() { ]); if (cancelled) return; setQuarterHour(status.quarterHour); + setCurrent(status.current); setDevices(status.devices); setHistory(hist); setActions(acts); @@ -58,6 +72,20 @@ export function Dashboard() { return ( <> +
+

Live readings

+
+
+
Power usage
+
{formatW(current?.powerW ?? null)}
+
+
+
Solar generation
+
{formatW(current?.solarW ?? null)}
+
+
+
+

Current quarter-hour block

{(quarterHour.projectedAverageW / 1000).toFixed(2)} kW
diff --git a/energy-management/frontend/src/Devices.tsx b/energy-management/frontend/src/Devices.tsx index c55385f..5f54418 100644 --- a/energy-management/frontend/src/Devices.tsx +++ b/energy-management/frontend/src/Devices.tsx @@ -1,7 +1,9 @@ import { useEffect, useState, type FormEvent } from "react"; import { api, type ControlKind, type DeviceConfig } from "./api"; -const emptyForm = { +type DeviceFormValues = Omit; + +const emptyForm: DeviceFormValues = { entityId: "", name: "", kind: "switch" as ControlKind, @@ -11,16 +13,158 @@ const emptyForm = { dwellSeconds: 300, manualOverride: false, onlyWhenSun: false, - fromTime: null as string | null, - toTime: null as string | null, - enableUrl: null as string | null, - disableUrl: null as string | null, + fromTime: null, + toTime: null, + enableUrl: null, + disableUrl: null, }; +function DeviceFields({ + values, + onChange, +}: { + values: DeviceFormValues; + onChange: (next: DeviceFormValues) => void; +}) { + return ( +
+ + + + + {values.kind === "number" && ( + <> + + + + )} + + + + + + +
+ ); +} + export function Devices() { const [devices, setDevices] = useState([]); const [form, setForm] = useState(emptyForm); const [error, setError] = useState(null); + const [editingId, setEditingId] = useState(null); + const [editForm, setEditForm] = useState(emptyForm); + const [editError, setEditError] = useState(null); + const [saving, setSaving] = useState(false); async function refresh() { setDevices(await api.getDevices()); @@ -53,138 +197,40 @@ export function Devices() { await refresh(); } + function startEdit(d: DeviceConfig) { + const { id: _id, ...rest } = d; + setEditForm(rest); + setEditingId(d.id); + setEditError(null); + } + + function cancelEdit() { + setEditingId(null); + setEditError(null); + } + + async function saveEdit(e: FormEvent) { + e.preventDefault(); + if (editingId == null) return; + setSaving(true); + try { + await api.updateDevice(editingId, editForm); + await refresh(); + setEditingId(null); + setEditError(null); + } catch (err) { + setEditError(err instanceof Error ? err.message : "Failed to save device"); + } finally { + setSaving(false); + } + } + return ( <>

Add device

-
- - - - - {form.kind === "number" && ( - <> - - - - )} - - - - - - -
+

Set both custom endpoints to control this device over plain HTTP (POST) instead of Home Assistant. The entity ID above is still used @@ -211,35 +257,66 @@ export function Devices() { devices .slice() .sort((a, b) => a.priority - b.priority) - .map((d) => ( -

-
-
{d.name}
-
- {d.entityId} · {d.kind} · priority {d.priority} · dwell{" "} - {d.dwellSeconds}s{d.onlyWhenSun && " · sun only"} - {d.fromTime && d.toTime && ` · ${d.fromTime}–${d.toTime}`} - {d.enableUrl && d.disableUrl && " · custom endpoints"} -
{d.enableUrl}
-
{d.disableUrl}
+ .map((d) => + editingId === d.id ? ( + + + {editError && ( +

+ {editError} +

+ )} +
+ + +
+ + ) : ( +
+
+
{d.name}
+
+ {d.entityId} · {d.kind} · priority {d.priority} · dwell{" "} + {d.dwellSeconds}s{d.onlyWhenSun && " · sun only"} + {d.fromTime && d.toTime && ` · ${d.fromTime}–${d.toTime}`} + {d.enableUrl && d.disableUrl && " · custom endpoints"} +
{d.enableUrl}
+
{d.disableUrl}
+
+
+
+ {d.manualOverride && ( + Manual + )} + + +
-
- {d.manualOverride && ( - Manual - )} - - -
-
- )) + ), + ) )}
diff --git a/energy-management/frontend/src/api.ts b/energy-management/frontend/src/api.ts index a7132b8..1b2c12b 100644 --- a/energy-management/frontend/src/api.ts +++ b/energy-management/frontend/src/api.ts @@ -34,6 +34,11 @@ export interface QuarterHourStatus { overTarget: boolean; } +export interface CurrentReadings { + powerW: number | null; + solarW: number | null; +} + export interface ControlAction { timestampMs: number; entityId: string; @@ -56,7 +61,10 @@ async function request(path: string, init?: RequestInit): Promise { } export const api = { - getStatus: () => request<{ quarterHour: QuarterHourStatus; devices: DeviceStatus[] }>("/api/status"), + getStatus: () => + request<{ quarterHour: QuarterHourStatus; devices: DeviceStatus[]; current: CurrentReadings }>( + "/api/status", + ), getHistory: (sinceMs: number) => request(`/api/history?sinceMs=${sinceMs}`), getActions: (limit = 50) => request(`/api/actions?limit=${limit}`), getDevices: () => request("/api/devices"), diff --git a/energy-management/frontend/src/theme.css b/energy-management/frontend/src/theme.css index fb835e4..969d725 100644 --- a/energy-management/frontend/src/theme.css +++ b/energy-management/frontend/src/theme.css @@ -124,6 +124,31 @@ nav.tabs button.active { margin-top: 4px; } +.stat-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 16px; +} + +.stat-tile { + background: var(--surface-2, transparent); + border: 1px solid var(--border); + border-radius: 10px; + padding: 14px 16px; +} + +.stat-label { + font-size: 12px; + color: var(--text-muted); + margin-bottom: 4px; +} + +.stat-value { + font-size: 28px; + font-weight: 600; + line-height: 1.1; +} + .status-pill { display: inline-flex; align-items: center; @@ -162,6 +187,11 @@ nav.tabs button.active { font-size: 14px; } +.device-row-editing { + display: block; + padding: 16px 0; +} + .device-meta { font-size: 12px; color: var(--text-muted);