fix: various fixes

This commit is contained in:
Willem Serruys
2026-08-21 11:49:49 +02:00
parent 161b226db9
commit 43fda71c1b
10 changed files with 441 additions and 185 deletions

View File

@@ -13,6 +13,18 @@ INFLUX_FIELD_UNIT=W
HA_URL=http://homeassistant.local:8123 HA_URL=http://homeassistant.local:8123
HA_TOKEN=replace-with-a-long-lived-access-token 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 # Capacity tariff target: keep every 15-minute block's average under this many kW
TARGET_KW=5 TARGET_KW=5

View File

@@ -6,7 +6,7 @@ export interface LoopDecisionInput {
nowMs: number; nowMs: number;
/** Only restore once projected power is at least this far below target, to avoid flapping. */ /** Only restore once projected power is at least this far below target, to avoid flapping. */
restoreMarginW: number; 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; sunUp: boolean;
} }

View File

@@ -1,10 +1,14 @@
import type { QueryApi } from "@influxdata/influxdb-client"; import type { QueryApi } from "@influxdata/influxdb-client";
import type { Env } from "../env.js"; 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 { HomeAssistantClient } from "../homeassistant/client.js";
import { getDeviceStatus, restoreDevice, shedDevice } from "../homeassistant/devices.js"; import { getDeviceStatus, restoreDevice, shedDevice } from "../homeassistant/devices.js";
import type { Store } from "../db/store.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 { blockStartFor, computeQuarterHourStatus } from "./quarterHour.js";
import { decideControlAction } from "./loop.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 }, 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 nowMs = Date.now();
const blockStart = blockStartFor(nowMs); const blockStart = blockStartFor(nowMs);
const targetW = this.store.getTargetW(); 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 quarterHour = computeQuarterHourStatus(samples, nowMs, targetW);
const current: CurrentReadings = {
powerW: latestPower?.watts ?? null,
solarW: latestSolar?.watts ?? null,
};
const devices = await this.loadDeviceStatuses(); return { quarterHour, devices, current };
return { quarterHour, devices };
} }
private async loadDeviceStatuses(): Promise<DeviceStatus[]> { private async loadDeviceStatuses(): Promise<DeviceStatus[]> {
@@ -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<boolean> {
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<void> { async tick(): Promise<void> {
const nowMs = Date.now(); const nowMs = Date.now();
const { quarterHour, devices } = await this.getStatus(); 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. // as that block's final average, with no separate "finalize" step needed.
this.store.upsertBlockHistory(quarterHour.blockStartMs, quarterHour.runningAverageW); this.store.upsertBlockHistory(quarterHour.blockStartMs, quarterHour.runningAverageW);
const sunUp = await this.ha.isSunUp().catch((err) => { const sunUp = await this.isSolarProducing();
this.log.error({ err }, "failed to read sun.sun state; treating as unrestricted");
return true;
});
const decision = decideControlAction({ status: quarterHour, devices, nowMs, restoreMarginW: RESTORE_MARGIN_W, sunUp }); const decision = decideControlAction({ status: quarterHour, devices, nowMs, restoreMarginW: RESTORE_MARGIN_W, sunUp });
if (!decision) return; if (!decision) return;

View File

@@ -20,6 +20,15 @@ const envSchema = z.object({
INFLUX_FIELD_UNIT: z.enum(["W", "kW"]).default("W"), INFLUX_FIELD_UNIT: z.enum(["W", "kW"]).default("W"),
HA_URL: z.string().url(), HA_URL: z.string().url(),
HA_TOKEN: z.string().min(1), 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(), TARGET_KW: z.coerce.number().positive(),
DB_PATH: z.string().default("./data/energy.db"), DB_PATH: z.string().default("./data/energy.db"),
CONTROL_LOOP_INTERVAL_MS: z.coerce.number().default(10_000), CONTROL_LOOP_INTERVAL_MS: z.coerce.number().default(10_000),

View File

@@ -2,27 +2,40 @@ import type { QueryApi } from "@influxdata/influxdb-client";
import type { Env } from "../env.js"; import type { Env } from "../env.js";
import type { PowerSample } from "../types.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. * Fetches raw samples for one series between `sinceMs` and now, converting
* Converts the configured field to watts based on INFLUX_FIELD_UNIT. * the configured field to watts based on `fieldUnit`.
*/ */
export async function fetchPowerSamplesSince( async function fetchSeriesSince(
queryApi: QueryApi, queryApi: QueryApi,
env: Env, config: SeriesConfig,
sinceMs: number, sinceMs: number,
): Promise<PowerSample[]> { ): Promise<PowerSample[]> {
const sinceIso = new Date(sinceMs).toISOString(); 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 = ` const flux = `
from(bucket: "${env.INFLUX_BUCKET}") from(bucket: "${config.bucket}")
|> range(start: ${sinceIso}) |> range(start: ${sinceIso})
|> filter(fn: (r) => r["_measurement"] == "${env.INFLUX_FIELD_UNIT}") |> filter(fn: (r) => r["_measurement"] == "${config.fieldUnit}")
|> filter(fn: (r) => r["_field"] == "${env.INFLUX_FIELD}") |> filter(fn: (r) => r["_field"] == "${config.field}")
|> filter(fn: (r) => r["domain"] == "${env.INFLUX_DOMAIN}") ${domainFilter}${entityFilter}|> sort(columns: ["_time"])
|> filter(fn: (r) => r["entity_id"] == "${env.INFLUX_ENTITY_ID}")
|> sort(columns: ["_time"])
`; `;
const unitMultiplier = env.INFLUX_FIELD_UNIT === "kW" ? 1000 : 1; const unitMultiplier = config.fieldUnit === "kW" ? 1000 : 1;
const samples: PowerSample[] = []; const samples: PowerSample[] = [];
for await (const { values, tableMeta } of queryApi.iterateRows(flux)) { for await (const { values, tableMeta } of queryApi.iterateRows(flux)) {
@@ -36,6 +49,29 @@ export async function fetchPowerSamplesSince(
return samples; 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<PowerSample[]> {
return fetchSeriesSince(queryApi, housePowerConfig(env), sinceMs);
}
/** Fetches the single most recent power sample, if any exists within the lookback window. */ /** Fetches the single most recent power sample, if any exists within the lookback window. */
export async function fetchLatestPowerSample( export async function fetchLatestPowerSample(
queryApi: QueryApi, queryApi: QueryApi,
@@ -49,3 +85,26 @@ export async function fetchLatestPowerSample(
); );
return samples.length > 0 ? samples[samples.length - 1] : null; 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<PowerSample | null> {
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;
}

View File

@@ -10,7 +10,7 @@ export interface DeviceConfig {
maxValue: number; // for "number" kind: normal operating value; for "switch": unused (1) maxValue: number; // for "number" kind: normal operating value; for "switch": unused (1)
dwellSeconds: number; // minimum time between state changes for this device dwellSeconds: number; // minimum time between state changes for this device
manualOverride: boolean; // if true, control loop leaves this device alone 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 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 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 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; 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 { export interface ControlAction {
timestampMs: number; timestampMs: number;
entityId: string; entityId: string;

View File

@@ -1,11 +1,24 @@
import { useEffect, useState } from "react"; 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"; import { PeakHistoryChart } from "./PeakHistoryChart";
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000; 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() { export function Dashboard() {
const [quarterHour, setQuarterHour] = useState<QuarterHourStatus | null>(null); const [quarterHour, setQuarterHour] = useState<QuarterHourStatus | null>(null);
const [current, setCurrent] = useState<CurrentReadings | null>(null);
const [devices, setDevices] = useState<DeviceStatus[]>([]); const [devices, setDevices] = useState<DeviceStatus[]>([]);
const [history, setHistory] = useState<BlockHistoryPoint[]>([]); const [history, setHistory] = useState<BlockHistoryPoint[]>([]);
const [actions, setActions] = useState<ControlAction[]>([]); const [actions, setActions] = useState<ControlAction[]>([]);
@@ -23,6 +36,7 @@ export function Dashboard() {
]); ]);
if (cancelled) return; if (cancelled) return;
setQuarterHour(status.quarterHour); setQuarterHour(status.quarterHour);
setCurrent(status.current);
setDevices(status.devices); setDevices(status.devices);
setHistory(hist); setHistory(hist);
setActions(acts); setActions(acts);
@@ -58,6 +72,20 @@ export function Dashboard() {
return ( return (
<> <>
<div className="card">
<h2>Live readings</h2>
<div className="stat-grid">
<div className="stat-tile">
<div className="stat-label">Power usage</div>
<div className="stat-value">{formatW(current?.powerW ?? null)}</div>
</div>
<div className="stat-tile">
<div className="stat-label">Solar generation</div>
<div className="stat-value">{formatW(current?.solarW ?? null)}</div>
</div>
</div>
</div>
<div className="card"> <div className="card">
<h2>Current quarter-hour block</h2> <h2>Current quarter-hour block</h2>
<div className="hero-figure">{(quarterHour.projectedAverageW / 1000).toFixed(2)} kW</div> <div className="hero-figure">{(quarterHour.projectedAverageW / 1000).toFixed(2)} kW</div>

View File

@@ -1,7 +1,9 @@
import { useEffect, useState, type FormEvent } from "react"; import { useEffect, useState, type FormEvent } from "react";
import { api, type ControlKind, type DeviceConfig } from "./api"; import { api, type ControlKind, type DeviceConfig } from "./api";
const emptyForm = { type DeviceFormValues = Omit<DeviceConfig, "id">;
const emptyForm: DeviceFormValues = {
entityId: "", entityId: "",
name: "", name: "",
kind: "switch" as ControlKind, kind: "switch" as ControlKind,
@@ -11,16 +13,158 @@ const emptyForm = {
dwellSeconds: 300, dwellSeconds: 300,
manualOverride: false, manualOverride: false,
onlyWhenSun: false, onlyWhenSun: false,
fromTime: null as string | null, fromTime: null,
toTime: null as string | null, toTime: null,
enableUrl: null as string | null, enableUrl: null,
disableUrl: null as string | null, disableUrl: null,
}; };
function DeviceFields({
values,
onChange,
}: {
values: DeviceFormValues;
onChange: (next: DeviceFormValues) => void;
}) {
return (
<div className="form-grid">
<label>
Name
<input
value={values.name}
onChange={(e) => onChange({ ...values, name: e.target.value })}
required
/>
</label>
<label>
Home Assistant entity ID
<input
value={values.entityId}
onChange={(e) => onChange({ ...values, entityId: e.target.value })}
placeholder="switch.ev_charger"
required
/>
</label>
<label>
Control type
<select
value={values.kind}
onChange={(e) =>
onChange({ ...values, kind: e.target.value as ControlKind })
}
>
<option value="switch">Switch (on/off)</option>
<option value="number">Number (set-point)</option>
</select>
</label>
<label>
Priority (lower = shed first)
<input
type="number"
value={values.priority}
onChange={(e) =>
onChange({ ...values, priority: Number(e.target.value) })
}
/>
</label>
{values.kind === "number" && (
<>
<label>
Min value (shed target)
<input
type="number"
value={values.minValue}
onChange={(e) =>
onChange({ ...values, minValue: Number(e.target.value) })
}
/>
</label>
<label>
Max value (normal)
<input
type="number"
value={values.maxValue}
onChange={(e) =>
onChange({ ...values, maxValue: Number(e.target.value) })
}
/>
</label>
</>
)}
<label>
Min dwell time (seconds)
<input
type="number"
value={values.dwellSeconds}
onChange={(e) =>
onChange({ ...values, dwellSeconds: Number(e.target.value) })
}
/>
</label>
<label>
Only enable if sun
<input
type="checkbox"
checked={values.onlyWhenSun}
onChange={(e) =>
onChange({ ...values, onlyWhenSun: e.target.checked })
}
/>
</label>
<label>
From (allowed window start)
<input
type="time"
value={values.fromTime ?? ""}
onChange={(e) =>
onChange({ ...values, fromTime: e.target.value || null })
}
/>
</label>
<label>
To (allowed window end)
<input
type="time"
value={values.toTime ?? ""}
onChange={(e) =>
onChange({ ...values, toTime: e.target.value || null })
}
/>
</label>
<label>
Custom enable endpoint (optional)
<input
type="url"
value={values.enableUrl ?? ""}
onChange={(e) =>
onChange({ ...values, enableUrl: e.target.value || null })
}
placeholder="https://example.local/device/on"
/>
</label>
<label>
Custom disable endpoint (optional)
<input
type="url"
value={values.disableUrl ?? ""}
onChange={(e) =>
onChange({ ...values, disableUrl: e.target.value || null })
}
placeholder="https://example.local/device/off"
/>
</label>
</div>
);
}
export function Devices() { export function Devices() {
const [devices, setDevices] = useState<DeviceConfig[]>([]); const [devices, setDevices] = useState<DeviceConfig[]>([]);
const [form, setForm] = useState(emptyForm); const [form, setForm] = useState(emptyForm);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [editingId, setEditingId] = useState<number | null>(null);
const [editForm, setEditForm] = useState<DeviceFormValues>(emptyForm);
const [editError, setEditError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
async function refresh() { async function refresh() {
setDevices(await api.getDevices()); setDevices(await api.getDevices());
@@ -53,138 +197,40 @@ export function Devices() {
await refresh(); 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 ( return (
<> <>
<div className="card"> <div className="card">
<h2>Add device</h2> <h2>Add device</h2>
<form onSubmit={handleCreate}> <form onSubmit={handleCreate}>
<div className="form-grid"> <DeviceFields values={form} onChange={setForm} />
<label>
Name
<input
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
required
/>
</label>
<label>
Home Assistant entity ID
<input
value={form.entityId}
onChange={(e) => setForm({ ...form, entityId: e.target.value })}
placeholder="switch.ev_charger"
required
/>
</label>
<label>
Control type
<select
value={form.kind}
onChange={(e) =>
setForm({ ...form, kind: e.target.value as ControlKind })
}
>
<option value="switch">Switch (on/off)</option>
<option value="number">Number (set-point)</option>
</select>
</label>
<label>
Priority (lower = shed first)
<input
type="number"
value={form.priority}
onChange={(e) =>
setForm({ ...form, priority: Number(e.target.value) })
}
/>
</label>
{form.kind === "number" && (
<>
<label>
Min value (shed target)
<input
type="number"
value={form.minValue}
onChange={(e) =>
setForm({ ...form, minValue: Number(e.target.value) })
}
/>
</label>
<label>
Max value (normal)
<input
type="number"
value={form.maxValue}
onChange={(e) =>
setForm({ ...form, maxValue: Number(e.target.value) })
}
/>
</label>
</>
)}
<label>
Min dwell time (seconds)
<input
type="number"
value={form.dwellSeconds}
onChange={(e) =>
setForm({ ...form, dwellSeconds: Number(e.target.value) })
}
/>
</label>
<label>
Only enable if sun
<input
type="checkbox"
checked={form.onlyWhenSun}
onChange={(e) =>
setForm({ ...form, onlyWhenSun: e.target.checked })
}
/>
</label>
<label>
From (allowed window start)
<input
type="time"
value={form.fromTime ?? ""}
onChange={(e) =>
setForm({ ...form, fromTime: e.target.value || null })
}
/>
</label>
<label>
To (allowed window end)
<input
type="time"
value={form.toTime ?? ""}
onChange={(e) =>
setForm({ ...form, toTime: e.target.value || null })
}
/>
</label>
<label>
Custom enable endpoint (optional)
<input
type="url"
value={form.enableUrl ?? ""}
onChange={(e) =>
setForm({ ...form, enableUrl: e.target.value || null })
}
placeholder="https://example.local/device/on"
/>
</label>
<label>
Custom disable endpoint (optional)
<input
type="url"
value={form.disableUrl ?? ""}
onChange={(e) =>
setForm({ ...form, disableUrl: e.target.value || null })
}
placeholder="https://example.local/device/off"
/>
</label>
</div>
<p style={{ color: "var(--text-muted)", fontSize: 13 }}> <p style={{ color: "var(--text-muted)", fontSize: 13 }}>
Set both custom endpoints to control this device over plain HTTP Set both custom endpoints to control this device over plain HTTP
(POST) instead of Home Assistant. The entity ID above is still used (POST) instead of Home Assistant. The entity ID above is still used
@@ -211,35 +257,66 @@ export function Devices() {
devices devices
.slice() .slice()
.sort((a, b) => a.priority - b.priority) .sort((a, b) => a.priority - b.priority)
.map((d) => ( .map((d) =>
<div className="device-row" key={d.id}> editingId === d.id ? (
<div> <form
<div className="device-name">{d.name}</div> className="device-row device-row-editing"
<div className="device-meta"> key={d.id}
{d.entityId} · {d.kind} · priority {d.priority} · dwell{" "} onSubmit={saveEdit}
{d.dwellSeconds}s{d.onlyWhenSun && " · sun only"} >
{d.fromTime && d.toTime && ` · ${d.fromTime}${d.toTime}`} <DeviceFields values={editForm} onChange={setEditForm} />
{d.enableUrl && d.disableUrl && " · custom endpoints"} {editError && (
<div>{d.enableUrl}</div> <p style={{ color: "var(--status-critical)", fontSize: 13 }}>
<div>{d.disableUrl}</div> {editError}
</p>
)}
<div style={{ display: "flex", gap: 8 }}>
<button className="primary" type="submit" disabled={saving}>
{saving ? "Saving…" : "Save"}
</button>
<button
className="secondary"
type="button"
onClick={cancelEdit}
disabled={saving}
>
Cancel
</button>
</div>
</form>
) : (
<div className="device-row" key={d.id}>
<div>
<div className="device-name">{d.name}</div>
<div className="device-meta">
{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"}
<div>{d.enableUrl}</div>
<div>{d.disableUrl}</div>
</div>
</div>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
{d.manualOverride && (
<span className="badge override">Manual</span>
)}
<button
className="secondary"
onClick={() => toggleOverride(d)}
>
{d.manualOverride ? "Resume auto" : "Manual override"}
</button>
<button className="secondary" onClick={() => startEdit(d)}>
Edit
</button>
<button className="secondary" onClick={() => remove(d)}>
Remove
</button>
</div> </div>
</div> </div>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}> ),
{d.manualOverride && ( )
<span className="badge override">Manual</span>
)}
<button
className="secondary"
onClick={() => toggleOverride(d)}
>
{d.manualOverride ? "Resume auto" : "Manual override"}
</button>
<button className="secondary" onClick={() => remove(d)}>
Remove
</button>
</div>
</div>
))
)} )}
</div> </div>
</> </>

View File

@@ -34,6 +34,11 @@ export interface QuarterHourStatus {
overTarget: boolean; overTarget: boolean;
} }
export interface CurrentReadings {
powerW: number | null;
solarW: number | null;
}
export interface ControlAction { export interface ControlAction {
timestampMs: number; timestampMs: number;
entityId: string; entityId: string;
@@ -56,7 +61,10 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
} }
export const api = { 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<BlockHistoryPoint[]>(`/api/history?sinceMs=${sinceMs}`), getHistory: (sinceMs: number) => request<BlockHistoryPoint[]>(`/api/history?sinceMs=${sinceMs}`),
getActions: (limit = 50) => request<ControlAction[]>(`/api/actions?limit=${limit}`), getActions: (limit = 50) => request<ControlAction[]>(`/api/actions?limit=${limit}`),
getDevices: () => request<DeviceConfig[]>("/api/devices"), getDevices: () => request<DeviceConfig[]>("/api/devices"),

View File

@@ -124,6 +124,31 @@ nav.tabs button.active {
margin-top: 4px; 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 { .status-pill {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -162,6 +187,11 @@ nav.tabs button.active {
font-size: 14px; font-size: 14px;
} }
.device-row-editing {
display: block;
padding: 16px 0;
}
.device-meta { .device-meta {
font-size: 12px; font-size: 12px;
color: var(--text-muted); color: var(--text-muted);