fix: added energy-management

This commit is contained in:
Willem Serruys
2026-08-19 18:35:47 +02:00
parent fece1603e7
commit 53fa112aa8
38 changed files with 6060 additions and 0 deletions

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,29 @@
{
"name": "backend",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/index.js",
"test": "vitest run"
},
"dependencies": {
"@fastify/cors": "^10.0.1",
"@fastify/static": "^10.1.3",
"@influxdata/influxdb-client": "^1.35.0",
"better-sqlite3": "^11.8.1",
"dotenv": "^17.4.2",
"fastify": "^5.2.1",
"pino": "^9.6.0",
"zod": "^3.24.1"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.12",
"@types/node": "^22.10.7",
"pino-pretty": "^13.0.0",
"tsx": "^4.19.2",
"typescript": "^5.7.3",
"vitest": "^3.0.4"
}
}

View File

@@ -0,0 +1,133 @@
import { describe, expect, it } from "vitest";
import { decideControlAction } from "../control/loop.js";
import type { DeviceStatus, QuarterHourStatus } from "../types.js";
function makeStatus(overrides: Partial<QuarterHourStatus> = {}): QuarterHourStatus {
return {
blockStartMs: 0,
blockEndMs: 900_000,
elapsedFractionOfBlock: 0.5,
runningAverageW: 4000,
projectedAverageW: 4000,
targetW: 5000,
overTarget: false,
...overrides,
};
}
function makeDevice(overrides: Partial<DeviceStatus> = {}): DeviceStatus {
return {
id: 1,
entityId: "switch.test",
name: "Test Device",
kind: "switch",
priority: 1,
minValue: 0,
maxValue: 1,
dwellSeconds: 300,
manualOverride: false,
onlyWhenSun: false,
fromTime: null,
toTime: null,
enableUrl: null,
disableUrl: null,
currentValue: 1,
shed: false,
lastChangedAt: null,
available: true,
...overrides,
};
}
describe("decideControlAction", () => {
it("sheds the lowest-priority unshed device when over target", () => {
const status = makeStatus({ overTarget: true, projectedAverageW: 6000 });
const devices = [
makeDevice({ entityId: "switch.a", priority: 3 }),
makeDevice({ entityId: "switch.b", priority: 1 }),
makeDevice({ entityId: "switch.c", priority: 2 }),
];
const action = decideControlAction({ status, devices, nowMs: 1_000_000, restoreMarginW: 500, sunUp: true });
expect(action).toMatchObject({ entityId: "switch.b", action: "shed" });
});
it("does not shed devices under manual override or dwell lockout", () => {
const status = makeStatus({ overTarget: true, projectedAverageW: 6000 });
const devices = [
makeDevice({ entityId: "switch.a", priority: 1, manualOverride: true }),
makeDevice({
entityId: "switch.b",
priority: 2,
lastChangedAt: new Date(999_800).toISOString(),
dwellSeconds: 300,
}),
makeDevice({ entityId: "switch.c", priority: 3 }),
];
const action = decideControlAction({ status, devices, nowMs: 1_000_000, restoreMarginW: 500, sunUp: true });
expect(action).toMatchObject({ entityId: "switch.c", action: "shed" });
});
it("returns null when over target but no controllable device is available", () => {
const status = makeStatus({ overTarget: true, projectedAverageW: 6000 });
const devices = [makeDevice({ manualOverride: true }), makeDevice({ shed: true })];
const action = decideControlAction({ status, devices, nowMs: 1_000_000, restoreMarginW: 500, sunUp: true });
expect(action).toBeNull();
});
it("restores the highest-priority shed device once margin is comfortable", () => {
const status = makeStatus({ overTarget: false, projectedAverageW: 3000, targetW: 5000 });
const devices = [
makeDevice({ entityId: "switch.a", priority: 3, shed: true }),
makeDevice({ entityId: "switch.b", priority: 1, shed: true }),
];
const action = decideControlAction({ status, devices, nowMs: 1_000_000, restoreMarginW: 500, sunUp: true });
expect(action).toMatchObject({ entityId: "switch.a", action: "restore" });
});
it("does not restore when margin is below the hysteresis threshold", () => {
const status = makeStatus({ overTarget: false, projectedAverageW: 4800, targetW: 5000 });
const devices = [makeDevice({ shed: true })];
const action = decideControlAction({ status, devices, nowMs: 1_000_000, restoreMarginW: 500, sunUp: true });
expect(action).toBeNull();
});
it("returns null when nothing is shed and under target", () => {
const status = makeStatus({ overTarget: false, projectedAverageW: 1000, targetW: 5000 });
const devices = [makeDevice({ shed: false })];
const action = decideControlAction({ status, devices, nowMs: 1_000_000, restoreMarginW: 500, sunUp: true });
expect(action).toBeNull();
});
it("sheds a sun-only device when the sun is down, even under target", () => {
const status = makeStatus({ overTarget: false, projectedAverageW: 1000, targetW: 5000 });
const devices = [makeDevice({ entityId: "switch.solar", onlyWhenSun: true, shed: false })];
const action = decideControlAction({ status, devices, nowMs: 1_000_000, restoreMarginW: 500, sunUp: false });
expect(action).toMatchObject({ entityId: "switch.solar", action: "shed", reason: "outside allowed sun hours" });
});
it("does not restore a sun-only device while the sun is down", () => {
const status = makeStatus({ overTarget: false, projectedAverageW: 3000, targetW: 5000 });
const devices = [makeDevice({ entityId: "switch.solar", onlyWhenSun: true, shed: true })];
const action = decideControlAction({ status, devices, nowMs: 1_000_000, restoreMarginW: 500, sunUp: false });
expect(action).toBeNull();
});
it("sheds a device once it falls outside its allowed time window", () => {
const status = makeStatus({ overTarget: false, projectedAverageW: 1000, targetW: 5000 });
// nowMs = 23:00 local time-of-day in a day starting at epoch-aligned midnight is awkward to
// construct portably, so drive minutesSinceMidnight indirectly via a known Date.
const outsideWindow = new Date();
outsideWindow.setHours(23, 0, 0, 0);
const devices = [
makeDevice({ entityId: "switch.scheduled", fromTime: "08:00", toTime: "20:00", shed: false }),
];
const action = decideControlAction({
status,
devices,
nowMs: outsideWindow.getTime(),
restoreMarginW: 500,
sunUp: true,
});
expect(action).toMatchObject({ entityId: "switch.scheduled", action: "shed", reason: "outside allowed time window" });
});
});

View File

@@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import { blockStartFor, computeQuarterHourStatus } from "../control/quarterHour.js";
const BLOCK_MS = 15 * 60 * 1000;
describe("blockStartFor", () => {
it("aligns to the current 15-minute boundary", () => {
const t = new Date("2026-08-18T10:07:30Z").getTime();
expect(blockStartFor(t)).toBe(new Date("2026-08-18T10:00:00Z").getTime());
});
it("is idempotent on an exact boundary", () => {
const t = new Date("2026-08-18T10:15:00Z").getTime();
expect(blockStartFor(t)).toBe(t);
});
});
describe("computeQuarterHourStatus", () => {
it("returns zero averages when no samples exist yet", () => {
const blockStart = new Date("2026-08-18T10:00:00Z").getTime();
const now = blockStart + 60_000;
const status = computeQuarterHourStatus([], now, 5000);
expect(status.runningAverageW).toBe(0);
expect(status.projectedAverageW).toBe(0);
expect(status.overTarget).toBe(false);
});
it("computes a flat running average for a constant power draw", () => {
const blockStart = new Date("2026-08-18T10:00:00Z").getTime();
const now = blockStart + 5 * 60_000;
const samples = [{ timestampMs: blockStart, watts: 3000 }];
const status = computeQuarterHourStatus(samples, now, 5000);
expect(status.runningAverageW).toBeCloseTo(3000);
// Projection assumes the last known power (3000W) holds for the rest of the block.
expect(status.projectedAverageW).toBeCloseTo(3000);
expect(status.overTarget).toBe(false);
});
it("projects over target when a late spike would push the block average up", () => {
const blockStart = new Date("2026-08-18T10:00:00Z").getTime();
const tenMinIn = blockStart + 10 * 60_000;
const now = blockStart + 12 * 60_000;
const samples = [
{ timestampMs: blockStart, watts: 1000 },
{ timestampMs: tenMinIn, watts: 9000 },
];
const status = computeQuarterHourStatus(samples, now, 3000);
// energy so far: 1000W*10min + 9000W*2min; remaining 3min projected at 9000W
const expectedEnergy = 1000 * 10 * 60_000 + 9000 * 2 * 60_000;
const expectedRunningAvg = expectedEnergy / (12 * 60_000);
expect(status.runningAverageW).toBeCloseTo(expectedRunningAvg);
const expectedProjectedEnergy = expectedEnergy + 9000 * 3 * 60_000;
const expectedProjectedAvg = expectedProjectedEnergy / BLOCK_MS;
expect(status.projectedAverageW).toBeCloseTo(expectedProjectedAvg);
expect(status.overTarget).toBe(true);
});
it("ignores samples from before the current block", () => {
const prevBlockSample = { timestampMs: new Date("2026-08-18T09:59:00Z").getTime(), watts: 20000 };
const blockStart = new Date("2026-08-18T10:00:00Z").getTime();
const now = blockStart + 60_000;
const status = computeQuarterHourStatus([prevBlockSample], now, 5000);
expect(status.runningAverageW).toBe(0);
});
it("elapsedFractionOfBlock reflects how far into the block now is", () => {
const blockStart = new Date("2026-08-18T10:00:00Z").getTime();
const now = blockStart + 3 * 60_000;
const status = computeQuarterHourStatus([], now, 5000);
expect(status.elapsedFractionOfBlock).toBeCloseTo(3 / 15);
});
});

View File

@@ -0,0 +1,80 @@
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import type { Store } from "../db/store.js";
import type { ControlLoopRunner } from "../control/runner.js";
const timeOfDaySchema = z
.string()
.regex(/^([01]\d|2[0-3]):[0-5]\d$/, "expected HH:MM")
.nullable();
const endpointUrlSchema = z.string().url().nullable();
const deviceInputSchema = z.object({
entityId: z.string().min(1),
name: z.string().min(1),
kind: z.enum(["switch", "number"]),
priority: z.number().int(),
minValue: z.number().default(0),
maxValue: z.number().default(1),
dwellSeconds: z.number().int().positive().default(300),
manualOverride: z.boolean().default(false),
onlyWhenSun: z.boolean().default(false),
fromTime: timeOfDaySchema.default(null),
toTime: timeOfDaySchema.default(null),
enableUrl: endpointUrlSchema.default(null),
disableUrl: endpointUrlSchema.default(null),
});
const deviceUpdateSchema = deviceInputSchema.partial();
export function registerRoutes(app: FastifyInstance, store: Store, runner: ControlLoopRunner): void {
app.get("/api/status", async () => {
return runner.getStatus();
});
app.get("/api/history", async (req) => {
const query = z.object({ sinceMs: z.coerce.number().optional() }).parse(req.query);
const sinceMs = query.sinceMs ?? Date.now() - 30 * 24 * 60 * 60 * 1000;
return store.listBlockHistory(sinceMs);
});
app.get("/api/actions", async (req) => {
const query = z.object({ limit: z.coerce.number().optional() }).parse(req.query);
return store.listRecentActions(query.limit ?? 50);
});
app.get("/api/devices", async () => {
return store.listDevices();
});
app.post("/api/devices", async (req, reply) => {
const body = deviceInputSchema.parse(req.body);
const device = store.createDevice(body);
reply.code(201);
return device;
});
app.patch("/api/devices/:id", async (req) => {
const params = z.object({ id: z.coerce.number() }).parse(req.params);
const body = deviceUpdateSchema.parse(req.body);
store.updateDevice(params.id, body);
return { ok: true };
});
app.delete("/api/devices/:id", async (req) => {
const params = z.object({ id: z.coerce.number() }).parse(req.params);
store.deleteDevice(params.id);
return { ok: true };
});
app.get("/api/settings/target", async () => {
return { targetW: store.getTargetW() };
});
app.put("/api/settings/target", async (req) => {
const body = z.object({ targetKw: z.number().positive() }).parse(req.body);
store.setTargetKw(body.targetKw);
return { ok: true };
});
}

View File

@@ -0,0 +1,100 @@
import type { ControlAction, DeviceStatus, QuarterHourStatus } from "../types.js";
export interface LoopDecisionInput {
status: QuarterHourStatus;
devices: DeviceStatus[];
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. */
sunUp: boolean;
}
function isDwellElapsed(device: DeviceStatus, nowMs: number): boolean {
if (device.lastChangedAt === null) return true;
const lastChangeMs = new Date(device.lastChangedAt).getTime();
return nowMs - lastChangeMs >= device.dwellSeconds * 1000;
}
function isControllable(device: DeviceStatus, nowMs: number): boolean {
return !device.manualOverride && device.available && isDwellElapsed(device, nowMs);
}
function minutesSinceMidnight(nowMs: number): number {
const d = new Date(nowMs);
return d.getHours() * 60 + d.getMinutes();
}
function isWithinTimeWindow(fromTime: string | null, toTime: string | null, nowMs: number): boolean {
if (!fromTime || !toTime) return true;
const [fromH, fromM] = fromTime.split(":").map(Number);
const [toH, toM] = toTime.split(":").map(Number);
const fromMinutes = fromH * 60 + fromM;
const toMinutes = toH * 60 + toM;
const nowMinutes = minutesSinceMidnight(nowMs);
if (fromMinutes === toMinutes) return true;
if (fromMinutes < toMinutes) return nowMinutes >= fromMinutes && nowMinutes < toMinutes;
return nowMinutes >= fromMinutes || nowMinutes < toMinutes; // window wraps past midnight
}
/** Whether device is currently allowed to be enabled, per its sun/time-window constraints. */
function isWithinSchedule(device: DeviceStatus, nowMs: number, sunUp: boolean): boolean {
if (device.onlyWhenSun && !sunUp) return false;
return isWithinTimeWindow(device.fromTime, device.toTime, nowMs);
}
/**
* Pure decision function: given the current quarter-hour projection and device
* states, decides at most one shed or restore action to take this tick.
* Acting on a single device per tick (rather than all eligible at once) avoids
* overshooting the target and keeps the effect of each action observable.
*/
export function decideControlAction(input: LoopDecisionInput): ControlAction | null {
const { status, devices, nowMs, restoreMarginW, sunUp } = input;
// Devices currently enabled outside their allowed sun/time window get shed
// first, independent of the power budget.
const scheduleViolators = devices
.filter((d) => !d.shed && isControllable(d, nowMs) && !isWithinSchedule(d, nowMs, sunUp))
.sort((a, b) => a.priority - b.priority);
if (scheduleViolators[0]) {
const target = scheduleViolators[0];
return {
timestampMs: nowMs,
entityId: target.entityId,
action: "shed",
reason: target.onlyWhenSun && !sunUp ? "outside allowed sun hours" : "outside allowed time window",
};
}
if (status.overTarget) {
const candidates = devices
.filter((d) => !d.shed && isControllable(d, nowMs))
.sort((a, b) => a.priority - b.priority); // least important (lowest priority) first
const target = candidates[0];
if (!target) return null;
return {
timestampMs: nowMs,
entityId: target.entityId,
action: "shed",
reason: `projected block average ${Math.round(status.projectedAverageW)}W exceeds target ${Math.round(status.targetW)}W`,
};
}
const marginW = status.targetW - status.projectedAverageW;
if (marginW >= restoreMarginW) {
const candidates = devices
.filter((d) => d.shed && isControllable(d, nowMs) && isWithinSchedule(d, nowMs, sunUp))
.sort((a, b) => b.priority - a.priority); // most important (highest priority) first
const target = candidates[0];
if (!target) return null;
return {
timestampMs: nowMs,
entityId: target.entityId,
action: "restore",
reason: `projected block average ${Math.round(status.projectedAverageW)}W is ${Math.round(marginW)}W under target ${Math.round(status.targetW)}W`,
};
}
return null;
}

View File

@@ -0,0 +1,54 @@
import type { PowerSample, QuarterHourStatus } from "../types.js";
export const BLOCK_DURATION_MS = 15 * 60 * 1000;
export function blockStartFor(timestampMs: number): number {
return Math.floor(timestampMs / BLOCK_DURATION_MS) * BLOCK_DURATION_MS;
}
/**
* Computes the running and projected average power for the current 15-minute
* billing block, given a step-function reading of power samples (each sample
* holds until the next one arrives). Samples outside the current block are
* ignored; if the block has no samples yet, power is assumed to be 0 so far.
*/
export function computeQuarterHourStatus(
samples: PowerSample[],
nowMs: number,
targetW: number,
): QuarterHourStatus {
const blockStartMs = blockStartFor(nowMs);
const blockEndMs = blockStartMs + BLOCK_DURATION_MS;
const relevant = samples
.filter((s) => s.timestampMs >= blockStartMs && s.timestampMs <= nowMs)
.sort((a, b) => a.timestampMs - b.timestampMs);
let energyWms = 0;
let cursorMs = blockStartMs;
let cursorWatts = relevant.length > 0 ? relevant[0].watts : 0;
for (const sample of relevant) {
energyWms += cursorWatts * (sample.timestampMs - cursorMs);
cursorMs = sample.timestampMs;
cursorWatts = sample.watts;
}
energyWms += cursorWatts * (nowMs - cursorMs);
const elapsedMs = nowMs - blockStartMs;
const runningAverageW = elapsedMs > 0 ? energyWms / elapsedMs : 0;
const remainingMs = blockEndMs - nowMs;
const projectedEnergyWms = energyWms + cursorWatts * remainingMs;
const projectedAverageW = projectedEnergyWms / BLOCK_DURATION_MS;
return {
blockStartMs,
blockEndMs,
elapsedFractionOfBlock: elapsedMs / BLOCK_DURATION_MS,
runningAverageW,
projectedAverageW,
targetW,
overTarget: projectedAverageW > targetW,
};
}

View File

@@ -0,0 +1,88 @@
import type { QueryApi } from "@influxdata/influxdb-client";
import type { Env } from "../env.js";
import { 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 { blockStartFor, computeQuarterHourStatus } from "./quarterHour.js";
import { decideControlAction } from "./loop.js";
const RESTORE_MARGIN_W = 300;
export class ControlLoopRunner {
constructor(
private readonly env: Env,
private readonly store: Store,
private readonly queryApi: QueryApi,
private readonly ha: HomeAssistantClient,
private readonly log: { info: (o: unknown, msg?: string) => void; error: (o: unknown, msg?: string) => void },
) {}
async getStatus(): Promise<{ quarterHour: QuarterHourStatus; devices: DeviceStatus[] }> {
const nowMs = Date.now();
const blockStart = blockStartFor(nowMs);
const targetW = this.store.getTargetW();
const samples = await fetchPowerSamplesSince(this.queryApi, this.env, blockStart);
const quarterHour = computeQuarterHourStatus(samples, nowMs, targetW);
const devices = await this.loadDeviceStatuses();
return { quarterHour, devices };
}
private async loadDeviceStatuses(): Promise<DeviceStatus[]> {
const configs = this.store.listDevices();
return Promise.all(
configs.map(async (config) => {
const { shed, lastChangedAt } = this.store.getDeviceShedState(config.id);
return getDeviceStatus(this.ha, config, shed, lastChangedAt);
}),
);
}
async tick(): Promise<void> {
const nowMs = Date.now();
const { quarterHour, devices } = await this.getStatus();
// Upserting every tick means the last write before a block rolls over holds
// 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 decision = decideControlAction({ status: quarterHour, devices, nowMs, restoreMarginW: RESTORE_MARGIN_W, sunUp });
if (!decision) return;
const device = devices.find((d) => d.entityId === decision.entityId);
if (!device) return;
try {
if (decision.action === "shed") {
await shedDevice(this.ha, device);
} else {
await restoreDevice(this.ha, device);
}
const changedAtIso = new Date(decision.timestampMs).toISOString();
this.store.setDeviceShed(device.id, decision.action === "shed", changedAtIso);
this.store.logAction(decision);
this.log.info({ decision }, "control action applied");
} catch (err) {
this.log.error({ err, decision }, "failed to apply control action");
}
}
}
export function createControlLoopRunner(
env: Env,
store: Store,
queryApi: QueryApi,
log: { info: (o: unknown, msg?: string) => void; error: (o: unknown, msg?: string) => void },
): ControlLoopRunner {
const ha = new HomeAssistantClient(env);
return new ControlLoopRunner(env, store, queryApi, ha, log);
}

View File

@@ -0,0 +1,72 @@
import Database from "better-sqlite3";
import { mkdirSync } from "node:fs";
import { dirname } from "node:path";
import type { Env } from "../env.js";
export function openDb(env: Env): Database.Database {
mkdirSync(dirname(env.DB_PATH), { recursive: true });
const db = new Database(env.DB_PATH);
db.pragma("journal_mode = WAL");
migrate(db, env);
return db;
}
function migrate(db: Database.Database, env: Env): void {
db.exec(`
CREATE TABLE IF NOT EXISTS devices (
id INTEGER PRIMARY KEY AUTOINCREMENT,
entity_id TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('switch', 'number')),
priority INTEGER NOT NULL,
min_value REAL NOT NULL DEFAULT 0,
max_value REAL NOT NULL DEFAULT 1,
dwell_seconds INTEGER NOT NULL DEFAULT 300,
manual_override INTEGER NOT NULL DEFAULT 0,
shed INTEGER NOT NULL DEFAULT 0,
last_changed_at TEXT
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS control_actions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp_ms INTEGER NOT NULL,
entity_id TEXT NOT NULL,
action TEXT NOT NULL CHECK (action IN ('shed', 'restore')),
reason TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS block_history (
block_start_ms INTEGER PRIMARY KEY,
average_w REAL NOT NULL
);
`);
const existingTarget = db.prepare("SELECT value FROM settings WHERE key = 'target_kw'").get();
if (!existingTarget) {
db.prepare("INSERT INTO settings (key, value) VALUES ('target_kw', ?)").run(String(env.TARGET_KW));
}
const deviceColumns = new Set(
(db.prepare("PRAGMA table_info(devices)").all() as { name: string }[]).map((c) => c.name),
);
if (!deviceColumns.has("only_when_sun")) {
db.exec("ALTER TABLE devices ADD COLUMN only_when_sun INTEGER NOT NULL DEFAULT 0");
}
if (!deviceColumns.has("from_time")) {
db.exec("ALTER TABLE devices ADD COLUMN from_time TEXT");
}
if (!deviceColumns.has("to_time")) {
db.exec("ALTER TABLE devices ADD COLUMN to_time TEXT");
}
if (!deviceColumns.has("enable_url")) {
db.exec("ALTER TABLE devices ADD COLUMN enable_url TEXT");
}
if (!deviceColumns.has("disable_url")) {
db.exec("ALTER TABLE devices ADD COLUMN disable_url TEXT");
}
}

View File

@@ -0,0 +1,140 @@
import type Database from "better-sqlite3";
import type { ControlAction, DeviceConfig } from "../types.js";
interface DeviceRow {
id: number;
entity_id: string;
name: string;
kind: "switch" | "number";
priority: number;
min_value: number;
max_value: number;
dwell_seconds: number;
manual_override: number;
only_when_sun: number;
from_time: string | null;
to_time: string | null;
enable_url: string | null;
disable_url: string | null;
shed: number;
last_changed_at: string | null;
}
function rowToDeviceConfig(row: DeviceRow): DeviceConfig {
return {
id: row.id,
entityId: row.entity_id,
name: row.name,
kind: row.kind,
priority: row.priority,
minValue: row.min_value,
maxValue: row.max_value,
dwellSeconds: row.dwell_seconds,
manualOverride: row.manual_override === 1,
onlyWhenSun: row.only_when_sun === 1,
fromTime: row.from_time,
toTime: row.to_time,
enableUrl: row.enable_url,
disableUrl: row.disable_url,
};
}
export class Store {
constructor(private readonly db: Database.Database) {}
listDevices(): DeviceConfig[] {
const rows = this.db.prepare("SELECT * FROM devices ORDER BY priority ASC").all() as DeviceRow[];
return rows.map(rowToDeviceConfig);
}
getDeviceShedState(id: number): { shed: boolean; lastChangedAt: string | null } {
const row = this.db.prepare("SELECT shed, last_changed_at FROM devices WHERE id = ?").get(id) as
| { shed: number; last_changed_at: string | null }
| undefined;
return { shed: row?.shed === 1, lastChangedAt: row?.last_changed_at ?? null };
}
createDevice(device: Omit<DeviceConfig, "id">): DeviceConfig {
const result = this.db
.prepare(
`INSERT INTO devices (entity_id, name, kind, priority, min_value, max_value, dwell_seconds, manual_override, only_when_sun, from_time, to_time, enable_url, disable_url)
VALUES (@entityId, @name, @kind, @priority, @minValue, @maxValue, @dwellSeconds, @manualOverride, @onlyWhenSun, @fromTime, @toTime, @enableUrl, @disableUrl)`,
)
.run({ ...device, manualOverride: device.manualOverride ? 1 : 0, onlyWhenSun: device.onlyWhenSun ? 1 : 0 });
return { ...device, id: Number(result.lastInsertRowid) };
}
updateDevice(id: number, patch: Partial<Omit<DeviceConfig, "id">>): void {
const fields = Object.entries(patch);
if (fields.length === 0) return;
const columnMap: Record<string, string> = {
entityId: "entity_id",
name: "name",
kind: "kind",
priority: "priority",
minValue: "min_value",
maxValue: "max_value",
dwellSeconds: "dwell_seconds",
manualOverride: "manual_override",
onlyWhenSun: "only_when_sun",
fromTime: "from_time",
toTime: "to_time",
enableUrl: "enable_url",
disableUrl: "disable_url",
};
const setClause = fields.map(([key]) => `${columnMap[key]} = @${key}`).join(", ");
const values: Record<string, unknown> = { id };
for (const [key, value] of fields) {
values[key] = key === "manualOverride" || key === "onlyWhenSun" ? (value ? 1 : 0) : value;
}
this.db.prepare(`UPDATE devices SET ${setClause} WHERE id = @id`).run(values);
}
deleteDevice(id: number): void {
this.db.prepare("DELETE FROM devices WHERE id = ?").run(id);
}
setDeviceShed(id: number, shed: boolean, changedAtIso: string): void {
this.db
.prepare("UPDATE devices SET shed = ?, last_changed_at = ? WHERE id = ?")
.run(shed ? 1 : 0, changedAtIso, id);
}
getTargetW(): number {
const row = this.db.prepare("SELECT value FROM settings WHERE key = 'target_kw'").get() as { value: string };
return Number(row.value) * 1000;
}
setTargetKw(targetKw: number): void {
this.db.prepare("UPDATE settings SET value = ? WHERE key = 'target_kw'").run(String(targetKw));
}
logAction(action: ControlAction): void {
this.db
.prepare("INSERT INTO control_actions (timestamp_ms, entity_id, action, reason) VALUES (?, ?, ?, ?)")
.run(action.timestampMs, action.entityId, action.action, action.reason);
}
listRecentActions(limit = 50): ControlAction[] {
const rows = this.db
.prepare("SELECT timestamp_ms, entity_id, action, reason FROM control_actions ORDER BY timestamp_ms DESC LIMIT ?")
.all(limit) as { timestamp_ms: number; entity_id: string; action: "shed" | "restore"; reason: string }[];
return rows.map((r) => ({ timestampMs: r.timestamp_ms, entityId: r.entity_id, action: r.action, reason: r.reason }));
}
upsertBlockHistory(blockStartMs: number, averageW: number): void {
this.db
.prepare(
`INSERT INTO block_history (block_start_ms, average_w) VALUES (?, ?)
ON CONFLICT(block_start_ms) DO UPDATE SET average_w = excluded.average_w`,
)
.run(blockStartMs, averageW);
}
listBlockHistory(sinceMs: number): { blockStartMs: number; averageW: number }[] {
const rows = this.db
.prepare("SELECT block_start_ms, average_w FROM block_history WHERE block_start_ms >= ? ORDER BY block_start_ms ASC")
.all(sinceMs) as { block_start_ms: number; average_w: number }[];
return rows.map((r) => ({ blockStartMs: r.block_start_ms, averageW: r.average_w }));
}
}

View File

@@ -0,0 +1,37 @@
import { config as loadDotenv } from "dotenv";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { z } from "zod";
// In production (docker-compose) env vars are already injected via env_file;
// this only fills gaps for local `npm run dev`, and is a no-op if the file is absent.
loadDotenv({ path: join(dirname(fileURLToPath(import.meta.url)), "../../.env") });
const envSchema = z.object({
PORT: z.coerce.number().default(3000),
INFLUX_URL: z.string().url(),
INFLUX_TOKEN: z.string().min(1),
INFLUX_ORG: z.string().min(1),
INFLUX_BUCKET: z.string().min(1),
INFLUX_MEASUREMENT: z.string().default("power"),
INFLUX_DOMAIN: z.string(),
INFLUX_ENTITY_ID: z.string(),
INFLUX_FIELD: z.string().default("value"),
INFLUX_FIELD_UNIT: z.enum(["W", "kW"]).default("W"),
HA_URL: z.string().url(),
HA_TOKEN: z.string().min(1),
TARGET_KW: z.coerce.number().positive(),
DB_PATH: z.string().default("./data/energy.db"),
CONTROL_LOOP_INTERVAL_MS: z.coerce.number().default(10_000),
});
export type Env = z.infer<typeof envSchema>;
export function loadEnv(): Env {
const parsed = envSchema.safeParse(process.env);
if (!parsed.success) {
console.error("Invalid environment configuration:", parsed.error.flatten().fieldErrors);
process.exit(1);
}
return parsed.data;
}

View File

@@ -0,0 +1,56 @@
import type { Env } from "../env.js";
export interface HaState {
entity_id: string;
state: string;
attributes: Record<string, unknown>;
}
export class HomeAssistantClient {
constructor(private readonly env: Env) {}
private async request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${this.env.HA_URL}${path}`, {
...init,
headers: {
Authorization: `Bearer ${this.env.HA_TOKEN}`,
"Content-Type": "application/json",
...init?.headers,
},
});
if (!res.ok) {
throw new Error(`Home Assistant request failed: ${init?.method ?? "GET"} ${path} -> ${res.status} ${await res.text()}`);
}
return (await res.json()) as T;
}
async getState(entityId: string): Promise<HaState> {
return this.request<HaState>(`/api/states/${entityId}`);
}
async getStates(): Promise<HaState[]> {
return this.request<HaState[]>(`/api/states`);
}
async callService(domain: string, service: string, data: Record<string, unknown>): Promise<void> {
await this.request(`/api/services/${domain}/${service}`, {
method: "POST",
body: JSON.stringify(data),
});
}
async setSwitch(entityId: string, on: boolean): Promise<void> {
const domain = entityId.split(".")[0];
await this.callService(domain, on ? "turn_on" : "turn_off", { entity_id: entityId });
}
async setNumber(entityId: string, value: number): Promise<void> {
await this.callService("number", "set_value", { entity_id: entityId, value });
}
/** Whether HA's sun.sun entity currently reports the sun above the horizon. */
async isSunUp(): Promise<boolean> {
const state = await this.getState("sun.sun");
return state.state === "above_horizon";
}
}

View File

@@ -0,0 +1,87 @@
import type { DeviceConfig, DeviceStatus } from "../types.js";
import type { HomeAssistantClient } from "./client.js";
function normalizeHaValue(kind: DeviceConfig["kind"], state: string): number | null {
if (kind === "switch") {
if (state === "on") return 1;
if (state === "off") return 0;
return null;
}
const n = Number(state);
return Number.isFinite(n) ? n : null;
}
/** True when both directions are driven by custom endpoints rather than Home Assistant. */
function isExternallyControlled(device: DeviceConfig): boolean {
return device.enableUrl !== null && device.disableUrl !== null;
}
async function callEndpoint(url: string): Promise<void> {
const res = await fetch(url, { method: "POST" });
if (!res.ok) {
throw new Error(`custom endpoint request failed: POST ${url} -> ${res.status} ${await res.text()}`);
}
}
export async function getDeviceStatus(
ha: HomeAssistantClient,
device: DeviceConfig,
shed: boolean,
lastChangedAt: string | null,
): Promise<DeviceStatus> {
// Externally controlled devices have no Home Assistant entity to poll; state is
// whatever we last commanded via the custom endpoints.
if (isExternallyControlled(device)) {
return {
...device,
currentValue: shed ? (device.kind === "switch" ? 0 : device.minValue) : device.kind === "switch" ? 1 : device.maxValue,
shed,
lastChangedAt,
available: true,
};
}
try {
const haState = await ha.getState(device.entityId);
return {
...device,
currentValue: normalizeHaValue(device.kind, haState.state),
shed,
lastChangedAt,
available: haState.state !== "unavailable" && haState.state !== "unknown",
};
} catch {
return {
...device,
currentValue: null,
shed,
lastChangedAt,
available: false,
};
}
}
/** Sheds (reduces load of) a device: turns a switch off, or lowers a number to its floor. */
export async function shedDevice(ha: HomeAssistantClient, device: DeviceConfig): Promise<void> {
if (device.disableUrl) {
await callEndpoint(device.disableUrl);
return;
}
if (device.kind === "switch") {
await ha.setSwitch(device.entityId, false);
} else {
await ha.setNumber(device.entityId, device.minValue);
}
}
/** Restores a device to its normal operating value. */
export async function restoreDevice(ha: HomeAssistantClient, device: DeviceConfig): Promise<void> {
if (device.enableUrl) {
await callEndpoint(device.enableUrl);
return;
}
if (device.kind === "switch") {
await ha.setSwitch(device.entityId, true);
} else {
await ha.setNumber(device.entityId, device.maxValue);
}
}

View File

@@ -0,0 +1,53 @@
import Fastify from "fastify";
import cors from "@fastify/cors";
import staticPlugin from "@fastify/static";
import { existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { loadEnv } from "./env.js";
import { openDb } from "./db/index.js";
import { Store } from "./db/store.js";
import { createInfluxQueryApi } from "./influx/client.js";
import { createControlLoopRunner } from "./control/runner.js";
import { registerRoutes } from "./api/routes.js";
async function main() {
const env = loadEnv();
const app = Fastify({
logger: {
transport: process.env.NODE_ENV === "production" ? undefined : { target: "pino-pretty" },
},
});
await app.register(cors, { origin: true });
const db = openDb(env);
const store = new Store(db);
const queryApi = createInfluxQueryApi(env);
const runner = createControlLoopRunner(env, store, queryApi, app.log);
registerRoutes(app, store, runner);
const publicDir = join(dirname(fileURLToPath(import.meta.url)), "public");
if (existsSync(publicDir)) {
await app.register(staticPlugin, { root: publicDir });
app.setNotFoundHandler((req, reply) => {
if (req.raw.url?.startsWith("/api")) {
reply.code(404).send({ error: "not found" });
return;
}
reply.sendFile("index.html");
});
}
setInterval(() => {
runner.tick().catch((err) => app.log.error({ err }, "control loop tick failed"));
}, env.CONTROL_LOOP_INTERVAL_MS);
await app.listen({ port: env.PORT, host: "0.0.0.0" });
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -0,0 +1,7 @@
import { InfluxDB, type QueryApi } from "@influxdata/influxdb-client";
import type { Env } from "../env.js";
export function createInfluxQueryApi(env: Env): QueryApi {
const influx = new InfluxDB({ url: env.INFLUX_URL, token: env.INFLUX_TOKEN });
return influx.getQueryApi(env.INFLUX_ORG);
}

View File

@@ -0,0 +1,51 @@
import type { QueryApi } from "@influxdata/influxdb-client";
import type { Env } from "../env.js";
import type { PowerSample } from "../types.js";
/**
* 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[]> {
const sinceIso = new Date(sinceMs).toISOString();
const flux = `
from(bucket: "${env.INFLUX_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"])
`;
const unitMultiplier = env.INFLUX_FIELD_UNIT === "kW" ? 1000 : 1;
const samples: PowerSample[] = [];
for await (const { values, tableMeta } of queryApi.iterateRows(flux)) {
const row = tableMeta.toObject(values) as { _time: string; _value: number };
samples.push({
timestampMs: new Date(row._time).getTime(),
watts: row._value * unitMultiplier,
});
}
return samples;
}
/** Fetches the single most recent power sample, if any exists within the lookback window. */
export async function fetchLatestPowerSample(
queryApi: QueryApi,
env: Env,
lookbackMs = 60_000,
): Promise<PowerSample | null> {
const samples = await fetchPowerSamplesSince(
queryApi,
env,
Date.now() - lookbackMs,
);
return samples.length > 0 ? samples[samples.length - 1] : null;
}

View File

@@ -0,0 +1,47 @@
export type ControlKind = "switch" | "number";
export interface DeviceConfig {
id: number;
entityId: string;
name: string;
kind: ControlKind;
priority: number; // lower = shed first
minValue: number; // for "number" kind: floor to shed to; for "switch": unused (0)
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
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
disableUrl: string | null; // if set, called (HTTP POST) to disable/shed the device instead of Home Assistant
}
export interface DeviceStatus extends DeviceConfig {
currentValue: number | null; // current HA state, normalized (0/1 for switch, number value)
shed: boolean;
lastChangedAt: string | null; // ISO timestamp of last control-loop action
available: boolean; // whether HA reports the entity as reachable
}
export interface PowerSample {
timestampMs: number;
watts: number;
}
export interface QuarterHourStatus {
blockStartMs: number;
blockEndMs: number;
elapsedFractionOfBlock: number; // 0..1
runningAverageW: number;
projectedAverageW: number;
targetW: number;
overTarget: boolean;
}
export interface ControlAction {
timestampMs: number;
entityId: string;
action: "shed" | "restore";
reason: string;
}

View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"declaration": false,
"sourceMap": true
},
"include": ["src"]
}