Compare commits
2 Commits
77e42910c5
...
53fa112aa8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53fa112aa8 | ||
|
|
fece1603e7 |
10
.gitignore
vendored
10
.gitignore
vendored
@@ -17,3 +17,13 @@ config/
|
||||
[Oo]bj/
|
||||
.aider*
|
||||
.env
|
||||
|
||||
node_modules/
|
||||
dist/
|
||||
build/
|
||||
*.db
|
||||
*.db-journal
|
||||
.env
|
||||
.env.local
|
||||
*.log
|
||||
.DS_Store
|
||||
|
||||
@@ -131,6 +131,46 @@ services:
|
||||
# Only enable if external services present
|
||||
# ports:
|
||||
# - 4317:4317 # OTLP gRPC receiver
|
||||
energy-management:
|
||||
build:
|
||||
context: ./energy-management
|
||||
labels:
|
||||
- traefik.http.routers.prometheus.rule=Host(`energy-management.pladijs`)
|
||||
- traefik.http.services.prometheus.loadbalancer.server.port=3000
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
DB_PATH: /app/data/energy.db
|
||||
# InfluxDB 2.x
|
||||
INFLUX_URL: http://influxdb.pladijs
|
||||
INFLUX_TOKEN: f9YTt1XmjUwtYuM1JXKyHaXU3D1NH3aRxf3jcP2cEyD0IfPmM-_Im35VpIAPct7oWKjk261rX5uzrQ4ue4tJFw==
|
||||
INFLUX_ORG: homeassistant
|
||||
INFLUX_BUCKET: homeassistant
|
||||
# Measurement/field that holds live power draw for the whole house
|
||||
INFLUX_FIELD: value
|
||||
# Unit of INFLUX_FIELD as stored in Influx: W or kW
|
||||
INFLUX_FIELD_UNIT: W
|
||||
INFLUX_DOMAIN: sensor
|
||||
INFLUX_ENTITY_ID: p1_meter_power
|
||||
|
||||
# Home Assistant
|
||||
HA_URL: http://homeassistant.pladijs
|
||||
HA_TOKEN: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiIxZjlhYzBjYjlkZDE0YWEzYWFhMzVjMzU2Y2VkM2U5MCIsImlhdCI6MTc4NzA4MjEyNywiZXhwIjoyMTAyNDQyMTI3fQ.W6MF78ZPiXF-__GmlFK2D-3vwYGEI3eEgouxw007D64
|
||||
|
||||
# Capacity tariff target: keep every 15-minute block's average under this many kW
|
||||
TARGET_KW: 4
|
||||
|
||||
# Optional
|
||||
PORT: 3000
|
||||
DB_PATH: ./data/energy.db
|
||||
CONTROL_LOOP_INTERVAL_MS: 10000
|
||||
volumes:
|
||||
- energy-management-data:/app/data
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
energy-management-data:
|
||||
|
||||
volumes:
|
||||
prometheus-data:
|
||||
influxdb-data:
|
||||
@@ -139,3 +179,4 @@ volumes:
|
||||
mosquitto-data:
|
||||
traefik-data:
|
||||
syncthing-data:
|
||||
energy-management-data:
|
||||
|
||||
22
energy-management/.env.example
Normal file
22
energy-management/.env.example
Normal file
@@ -0,0 +1,22 @@
|
||||
# InfluxDB 2.x
|
||||
INFLUX_URL=http://influxdb.local:8086
|
||||
INFLUX_TOKEN=replace-with-a-read-scoped-token
|
||||
INFLUX_ORG=your-org
|
||||
INFLUX_BUCKET=your-bucket
|
||||
# Measurement/field that holds live power draw for the whole house
|
||||
INFLUX_MEASUREMENT=power
|
||||
INFLUX_FIELD=value
|
||||
# Unit of INFLUX_FIELD as stored in Influx: W or kW
|
||||
INFLUX_FIELD_UNIT=W
|
||||
|
||||
# Home Assistant
|
||||
HA_URL=http://homeassistant.local:8123
|
||||
HA_TOKEN=replace-with-a-long-lived-access-token
|
||||
|
||||
# Capacity tariff target: keep every 15-minute block's average under this many kW
|
||||
TARGET_KW=5
|
||||
|
||||
# Optional
|
||||
PORT=3000
|
||||
DB_PATH=./data/energy.db
|
||||
CONTROL_LOOP_INTERVAL_MS=10000
|
||||
9
energy-management/.gitignore
vendored
Normal file
9
energy-management/.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
node_modules/
|
||||
dist/
|
||||
build/
|
||||
*.db
|
||||
*.db-journal
|
||||
.env
|
||||
.env.local
|
||||
*.log
|
||||
.DS_Store
|
||||
27
energy-management/Dockerfile
Normal file
27
energy-management/Dockerfile
Normal file
@@ -0,0 +1,27 @@
|
||||
FROM node:22-slim AS build
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /repo
|
||||
COPY package.json ./
|
||||
COPY backend/package.json backend/package.json
|
||||
COPY frontend/package.json frontend/package.json
|
||||
RUN npm install
|
||||
|
||||
COPY backend backend
|
||||
COPY frontend frontend
|
||||
RUN npm run build
|
||||
|
||||
FROM node:22-slim AS runtime
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
|
||||
COPY --from=build /repo/package.json ./package.json
|
||||
COPY --from=build /repo/backend/package.json ./backend/package.json
|
||||
COPY --from=build /repo/node_modules ./node_modules
|
||||
COPY --from=build /repo/backend/node_modules ./backend/node_modules
|
||||
COPY --from=build /repo/backend/dist ./backend/dist
|
||||
COPY --from=build /repo/frontend/dist ./backend/dist/public
|
||||
|
||||
VOLUME ["/app/data"]
|
||||
EXPOSE 3000
|
||||
CMD ["node", "backend/dist/index.js"]
|
||||
57
energy-management/README.md
Normal file
57
energy-management/README.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# Peak Power Manager
|
||||
|
||||
Keeps your electricity capacity tariff in check by watching live power draw
|
||||
(from InfluxDB) and shedding/restoring large consumers (via Home Assistant)
|
||||
whenever the current 15-minute billing block is projected to exceed a target.
|
||||
|
||||
## How it works
|
||||
|
||||
Belgian-style capacity tariffs bill on the highest 15-minute average power of
|
||||
the month. Every `CONTROL_LOOP_INTERVAL_MS` (default 10s), the app:
|
||||
|
||||
1. Reads recent power samples for the current quarter-hour block from InfluxDB.
|
||||
2. Projects what that block's final average will be if current draw continues.
|
||||
3. If the projection exceeds `TARGET_KW`, it sheds the lowest-priority
|
||||
configured device (turns a switch off, or lowers a number set-point to its
|
||||
floor) via Home Assistant.
|
||||
4. Once the projection is comfortably back under target, it restores the
|
||||
highest-priority shed device.
|
||||
|
||||
A minimum dwell time per device (configurable) prevents rapid on/off cycling.
|
||||
Devices with "manual override" enabled are left alone by the control loop.
|
||||
|
||||
## Setup
|
||||
|
||||
1. Copy `.env.example` to `.env` and fill in your InfluxDB and Home Assistant
|
||||
connection details, the measurement/field that holds live power, and your
|
||||
target kW.
|
||||
2. `docker compose up --build`
|
||||
3. Open `http://localhost:3000`, go to the **Devices** tab, and add your EV
|
||||
charger, heat pump, and any other large consumers with their Home
|
||||
Assistant entity IDs, control type (switch or number), and priority
|
||||
(lower priority number = shed first).
|
||||
|
||||
## Local development
|
||||
|
||||
```
|
||||
npm install
|
||||
npm run dev:backend # Fastify API on :3000
|
||||
npm run dev:frontend # Vite dev server on :5173, proxies /api to :3000
|
||||
npm test # backend unit tests (quarter-hour projection, control decisions)
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
- `backend/src/influx` — queries live power samples from InfluxDB (Flux).
|
||||
- `backend/src/control/quarterHour.ts` — pure logic computing the running and
|
||||
projected average power for the current 15-minute block.
|
||||
- `backend/src/control/loop.ts` — pure decision logic: which device to shed
|
||||
or restore given the projection and device states.
|
||||
- `backend/src/control/runner.ts` — orchestrates the above against real
|
||||
InfluxDB/Home Assistant/SQLite on each tick.
|
||||
- `backend/src/homeassistant` — Home Assistant REST client and device
|
||||
shed/restore abstraction.
|
||||
- `backend/src/db` — SQLite config store (devices, target, action log, block
|
||||
history).
|
||||
- `frontend/` — React dashboard: live status, device list, 30-day peak
|
||||
history chart, recent actions log, and a Devices config page.
|
||||
BIN
energy-management/backend/data/energy.db-shm
Normal file
BIN
energy-management/backend/data/energy.db-shm
Normal file
Binary file not shown.
BIN
energy-management/backend/data/energy.db-wal
Normal file
BIN
energy-management/backend/data/energy.db-wal
Normal file
Binary file not shown.
29
energy-management/backend/package.json
Normal file
29
energy-management/backend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
133
energy-management/backend/src/__tests__/loop.test.ts
Normal file
133
energy-management/backend/src/__tests__/loop.test.ts
Normal 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" });
|
||||
});
|
||||
});
|
||||
72
energy-management/backend/src/__tests__/quarterHour.test.ts
Normal file
72
energy-management/backend/src/__tests__/quarterHour.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
80
energy-management/backend/src/api/routes.ts
Normal file
80
energy-management/backend/src/api/routes.ts
Normal 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 };
|
||||
});
|
||||
}
|
||||
100
energy-management/backend/src/control/loop.ts
Normal file
100
energy-management/backend/src/control/loop.ts
Normal 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;
|
||||
}
|
||||
54
energy-management/backend/src/control/quarterHour.ts
Normal file
54
energy-management/backend/src/control/quarterHour.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
88
energy-management/backend/src/control/runner.ts
Normal file
88
energy-management/backend/src/control/runner.ts
Normal 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);
|
||||
}
|
||||
72
energy-management/backend/src/db/index.ts
Normal file
72
energy-management/backend/src/db/index.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
140
energy-management/backend/src/db/store.ts
Normal file
140
energy-management/backend/src/db/store.ts
Normal 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 }));
|
||||
}
|
||||
}
|
||||
37
energy-management/backend/src/env.ts
Normal file
37
energy-management/backend/src/env.ts
Normal 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;
|
||||
}
|
||||
56
energy-management/backend/src/homeassistant/client.ts
Normal file
56
energy-management/backend/src/homeassistant/client.ts
Normal 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";
|
||||
}
|
||||
}
|
||||
87
energy-management/backend/src/homeassistant/devices.ts
Normal file
87
energy-management/backend/src/homeassistant/devices.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
53
energy-management/backend/src/index.ts
Normal file
53
energy-management/backend/src/index.ts
Normal 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);
|
||||
});
|
||||
7
energy-management/backend/src/influx/client.ts
Normal file
7
energy-management/backend/src/influx/client.ts
Normal 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);
|
||||
}
|
||||
51
energy-management/backend/src/influx/power.ts
Normal file
51
energy-management/backend/src/influx/power.ts
Normal 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;
|
||||
}
|
||||
47
energy-management/backend/src/types.ts
Normal file
47
energy-management/backend/src/types.ts
Normal 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;
|
||||
}
|
||||
16
energy-management/backend/tsconfig.json
Normal file
16
energy-management/backend/tsconfig.json
Normal 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"]
|
||||
}
|
||||
15
energy-management/docker-compose.yml
Normal file
15
energy-management/docker-compose.yml
Normal file
@@ -0,0 +1,15 @@
|
||||
services:
|
||||
energy-management:
|
||||
build: .
|
||||
ports:
|
||||
- "3000:3000"
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
DB_PATH: /app/data/energy.db
|
||||
volumes:
|
||||
- energy-management-data:/app/data
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
energy-management-data:
|
||||
12
energy-management/frontend/index.html
Normal file
12
energy-management/frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Peak Power Manager</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
21
energy-management/frontend/package.json
Normal file
21
energy-management/frontend/package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.7",
|
||||
"@types/react-dom": "^19.0.3",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^7.0.0"
|
||||
}
|
||||
}
|
||||
23
energy-management/frontend/src/App.tsx
Normal file
23
energy-management/frontend/src/App.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { useState } from "react";
|
||||
import { Dashboard } from "./Dashboard";
|
||||
import { Devices } from "./Devices";
|
||||
|
||||
type Tab = "dashboard" | "devices";
|
||||
|
||||
export function App() {
|
||||
const [tab, setTab] = useState<Tab>("dashboard");
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<nav className="tabs">
|
||||
<button className={tab === "dashboard" ? "active" : ""} onClick={() => setTab("dashboard")}>
|
||||
Dashboard
|
||||
</button>
|
||||
<button className={tab === "devices" ? "active" : ""} onClick={() => setTab("devices")}>
|
||||
Devices
|
||||
</button>
|
||||
</nav>
|
||||
{tab === "dashboard" ? <Dashboard /> : <Devices />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
136
energy-management/frontend/src/Dashboard.tsx
Normal file
136
energy-management/frontend/src/Dashboard.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, type BlockHistoryPoint, type ControlAction, type DeviceStatus, type QuarterHourStatus } from "./api";
|
||||
import { PeakHistoryChart } from "./PeakHistoryChart";
|
||||
|
||||
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export function Dashboard() {
|
||||
const [quarterHour, setQuarterHour] = useState<QuarterHourStatus | null>(null);
|
||||
const [devices, setDevices] = useState<DeviceStatus[]>([]);
|
||||
const [history, setHistory] = useState<BlockHistoryPoint[]>([]);
|
||||
const [actions, setActions] = useState<ControlAction[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const [status, hist, acts] = await Promise.all([
|
||||
api.getStatus(),
|
||||
api.getHistory(Date.now() - THIRTY_DAYS_MS),
|
||||
api.getActions(10),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
setQuarterHour(status.quarterHour);
|
||||
setDevices(status.devices);
|
||||
setHistory(hist);
|
||||
setActions(acts);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
if (!cancelled) setError(err instanceof Error ? err.message : "Failed to load status");
|
||||
}
|
||||
}
|
||||
|
||||
refresh();
|
||||
const interval = setInterval(refresh, 10_000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="card">
|
||||
<h2>Error</h2>
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!quarterHour) {
|
||||
return <div className="card">Loading…</div>;
|
||||
}
|
||||
|
||||
const overTarget = quarterHour.overTarget;
|
||||
const shedCount = devices.filter((d) => d.shed).length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="card">
|
||||
<h2>Current quarter-hour block</h2>
|
||||
<div className="hero-figure">{(quarterHour.projectedAverageW / 1000).toFixed(2)} kW</div>
|
||||
<div className="hero-sub">
|
||||
projected average · target {(quarterHour.targetW / 1000).toFixed(2)} kW · running average{" "}
|
||||
{(quarterHour.runningAverageW / 1000).toFixed(2)} kW ·{" "}
|
||||
{Math.round(quarterHour.elapsedFractionOfBlock * 100)}% through block
|
||||
</div>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<span className={`status-pill ${overTarget ? "critical" : "good"}`}>
|
||||
{overTarget ? `Over target · ${shedCount} device(s) shed` : "Under target"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h2>Devices</h2>
|
||||
{devices.length === 0 ? (
|
||||
<p style={{ color: "var(--text-muted)", fontSize: 13 }}>No devices configured yet — add them in the Devices tab.</p>
|
||||
) : (
|
||||
devices
|
||||
.slice()
|
||||
.sort((a, b) => a.priority - b.priority)
|
||||
.map((d) => (
|
||||
<div className="device-row" key={d.id}>
|
||||
<div>
|
||||
<div className="device-name">{d.name}</div>
|
||||
<div className="device-meta">
|
||||
{d.entityId} · priority {d.priority}
|
||||
{!d.available ? " · unavailable" : ""}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 6 }}>
|
||||
{d.manualOverride && <span className="badge override">Manual</span>}
|
||||
<span className={`badge ${d.shed ? "shed" : "active"}`}>{d.shed ? "Shed" : "Active"}</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h2>Daily peak (last 30 days)</h2>
|
||||
<PeakHistoryChart points={history} targetW={quarterHour.targetW} />
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h2>Recent control actions</h2>
|
||||
{actions.length === 0 ? (
|
||||
<p style={{ color: "var(--text-muted)", fontSize: 13 }}>No actions yet.</p>
|
||||
) : (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Device</th>
|
||||
<th>Action</th>
|
||||
<th>Reason</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{actions.map((a, i) => (
|
||||
<tr key={i}>
|
||||
<td>{new Date(a.timestampMs).toLocaleTimeString()}</td>
|
||||
<td>{a.entityId}</td>
|
||||
<td>{a.action}</td>
|
||||
<td style={{ color: "var(--text-secondary)" }}>{a.reason}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
247
energy-management/frontend/src/Devices.tsx
Normal file
247
energy-management/frontend/src/Devices.tsx
Normal file
@@ -0,0 +1,247 @@
|
||||
import { useEffect, useState, type FormEvent } from "react";
|
||||
import { api, type ControlKind, type DeviceConfig } from "./api";
|
||||
|
||||
const emptyForm = {
|
||||
entityId: "",
|
||||
name: "",
|
||||
kind: "switch" as ControlKind,
|
||||
priority: 1,
|
||||
minValue: 0,
|
||||
maxValue: 1,
|
||||
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,
|
||||
};
|
||||
|
||||
export function Devices() {
|
||||
const [devices, setDevices] = useState<DeviceConfig[]>([]);
|
||||
const [form, setForm] = useState(emptyForm);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function refresh() {
|
||||
setDevices(await api.getDevices());
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, []);
|
||||
|
||||
async function handleCreate(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await api.createDevice(form);
|
||||
setForm(emptyForm);
|
||||
await refresh();
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to create device");
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleOverride(d: DeviceConfig) {
|
||||
await api.updateDevice(d.id, { manualOverride: !d.manualOverride });
|
||||
await refresh();
|
||||
}
|
||||
|
||||
async function remove(d: DeviceConfig) {
|
||||
if (!confirm(`Remove ${d.name}?`)) return;
|
||||
await api.deleteDevice(d.id);
|
||||
await refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="card">
|
||||
<h2>Add device</h2>
|
||||
<form onSubmit={handleCreate}>
|
||||
<div className="form-grid">
|
||||
<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 }}>
|
||||
Set both custom endpoints to control this device over plain HTTP
|
||||
(POST) instead of Home Assistant. The entity ID above is still used
|
||||
as the device's unique key.
|
||||
</p>
|
||||
{error && (
|
||||
<p style={{ color: "var(--status-critical)", fontSize: 13 }}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<button className="primary" type="submit">
|
||||
Add device
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h2>Configured devices</h2>
|
||||
{devices.length === 0 ? (
|
||||
<p style={{ color: "var(--text-muted)", fontSize: 13 }}>
|
||||
No devices configured yet.
|
||||
</p>
|
||||
) : (
|
||||
devices
|
||||
.slice()
|
||||
.sort((a, b) => a.priority - b.priority)
|
||||
.map((d) => (
|
||||
<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={() => remove(d)}>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
114
energy-management/frontend/src/PeakHistoryChart.tsx
Normal file
114
energy-management/frontend/src/PeakHistoryChart.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import type { BlockHistoryPoint } from "./api";
|
||||
|
||||
interface DailyPeak {
|
||||
dayStartMs: number;
|
||||
peakW: number;
|
||||
}
|
||||
|
||||
function toDailyPeaks(points: BlockHistoryPoint[]): DailyPeak[] {
|
||||
const byDay = new Map<number, number>();
|
||||
for (const p of points) {
|
||||
const dayStart = new Date(p.blockStartMs);
|
||||
dayStart.setHours(0, 0, 0, 0);
|
||||
const key = dayStart.getTime();
|
||||
byDay.set(key, Math.max(byDay.get(key) ?? 0, p.averageW));
|
||||
}
|
||||
return [...byDay.entries()]
|
||||
.map(([dayStartMs, peakW]) => ({ dayStartMs, peakW }))
|
||||
.sort((a, b) => a.dayStartMs - b.dayStartMs);
|
||||
}
|
||||
|
||||
const WIDTH = 880;
|
||||
const HEIGHT = 220;
|
||||
const PAD = { top: 16, right: 16, bottom: 28, left: 48 };
|
||||
|
||||
export function PeakHistoryChart({ points, targetW }: { points: BlockHistoryPoint[]; targetW: number }) {
|
||||
const daily = useMemo(() => toDailyPeaks(points), [points]);
|
||||
const [hoverIdx, setHoverIdx] = useState<number | null>(null);
|
||||
|
||||
if (daily.length === 0) {
|
||||
return <p style={{ color: "var(--text-muted)", fontSize: 13 }}>No history yet — check back after the first day of data.</p>;
|
||||
}
|
||||
|
||||
const maxW = Math.max(targetW, ...daily.map((d) => d.peakW)) * 1.1;
|
||||
const innerW = WIDTH - PAD.left - PAD.right;
|
||||
const innerH = HEIGHT - PAD.top - PAD.bottom;
|
||||
|
||||
const x = (i: number) => PAD.left + (daily.length === 1 ? innerW / 2 : (i / (daily.length - 1)) * innerW);
|
||||
const y = (w: number) => PAD.top + innerH - (w / maxW) * innerH;
|
||||
|
||||
const linePath = daily.map((d, i) => `${i === 0 ? "M" : "L"}${x(i)},${y(d.peakW)}`).join(" ");
|
||||
const targetY = y(targetW);
|
||||
const hovered = hoverIdx !== null ? daily[hoverIdx] : null;
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative" }}>
|
||||
<svg viewBox={`0 0 ${WIDTH} ${HEIGHT}`} style={{ width: "100%", height: "auto", display: "block" }}>
|
||||
{[0, 0.25, 0.5, 0.75, 1].map((f) => (
|
||||
<line
|
||||
key={f}
|
||||
x1={PAD.left}
|
||||
x2={WIDTH - PAD.right}
|
||||
y1={PAD.top + innerH * (1 - f)}
|
||||
y2={PAD.top + innerH * (1 - f)}
|
||||
stroke="var(--gridline)"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
))}
|
||||
|
||||
<line x1={PAD.left} x2={WIDTH - PAD.right} y1={targetY} y2={targetY} stroke="var(--status-critical)" strokeWidth={1.5} strokeDasharray="4 4" />
|
||||
<text x={WIDTH - PAD.right} y={targetY - 6} textAnchor="end" fontSize={11} fill="var(--status-critical)">
|
||||
target {Math.round(targetW / 1000)} kW
|
||||
</text>
|
||||
|
||||
<path d={linePath} fill="none" stroke="var(--series-1)" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
|
||||
|
||||
{daily.map((d, i) => (
|
||||
<circle
|
||||
key={d.dayStartMs}
|
||||
cx={x(i)}
|
||||
cy={y(d.peakW)}
|
||||
r={hoverIdx === i ? 6 : 4}
|
||||
fill={d.peakW > targetW ? "var(--status-critical)" : "var(--series-1)"}
|
||||
stroke="var(--surface-1)"
|
||||
strokeWidth={2}
|
||||
onMouseEnter={() => setHoverIdx(i)}
|
||||
onMouseLeave={() => setHoverIdx(null)}
|
||||
style={{ cursor: "pointer" }}
|
||||
/>
|
||||
))}
|
||||
|
||||
{daily.map((d, i) =>
|
||||
i % Math.ceil(daily.length / 8 || 1) === 0 ? (
|
||||
<text key={d.dayStartMs} x={x(i)} y={HEIGHT - 8} textAnchor="middle" fontSize={11} fill="var(--text-muted)">
|
||||
{new Date(d.dayStartMs).toLocaleDateString(undefined, { month: "short", day: "numeric" })}
|
||||
</text>
|
||||
) : null,
|
||||
)}
|
||||
</svg>
|
||||
|
||||
{hovered && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: `${(x(hoverIdx!) / WIDTH) * 100}%`,
|
||||
top: 0,
|
||||
transform: "translate(-50%, -100%)",
|
||||
background: "var(--surface-1)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 8,
|
||||
padding: "6px 10px",
|
||||
fontSize: 12,
|
||||
whiteSpace: "nowrap",
|
||||
pointerEvents: "none",
|
||||
boxShadow: "0 2px 8px rgba(0,0,0,0.15)",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600 }}>{new Date(hovered.dayStartMs).toLocaleDateString()}</div>
|
||||
<div style={{ color: "var(--text-secondary)" }}>peak {(hovered.peakW / 1000).toFixed(2)} kW</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
71
energy-management/frontend/src/api.ts
Normal file
71
energy-management/frontend/src/api.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
export type ControlKind = "switch" | "number";
|
||||
|
||||
export interface DeviceConfig {
|
||||
id: number;
|
||||
entityId: string;
|
||||
name: string;
|
||||
kind: ControlKind;
|
||||
priority: number;
|
||||
minValue: number;
|
||||
maxValue: number;
|
||||
dwellSeconds: number;
|
||||
manualOverride: boolean;
|
||||
onlyWhenSun: boolean;
|
||||
fromTime: string | null;
|
||||
toTime: string | null;
|
||||
enableUrl: string | null;
|
||||
disableUrl: string | null;
|
||||
}
|
||||
|
||||
export interface DeviceStatus extends DeviceConfig {
|
||||
currentValue: number | null;
|
||||
shed: boolean;
|
||||
lastChangedAt: string | null;
|
||||
available: boolean;
|
||||
}
|
||||
|
||||
export interface QuarterHourStatus {
|
||||
blockStartMs: number;
|
||||
blockEndMs: number;
|
||||
elapsedFractionOfBlock: number;
|
||||
runningAverageW: number;
|
||||
projectedAverageW: number;
|
||||
targetW: number;
|
||||
overTarget: boolean;
|
||||
}
|
||||
|
||||
export interface ControlAction {
|
||||
timestampMs: number;
|
||||
entityId: string;
|
||||
action: "shed" | "restore";
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface BlockHistoryPoint {
|
||||
blockStartMs: number;
|
||||
averageW: number;
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
...init,
|
||||
headers: { ...(init?.body ? { "Content-Type": "application/json" } : {}), ...init?.headers },
|
||||
});
|
||||
if (!res.ok) throw new Error(`${init?.method ?? "GET"} ${path} failed: ${res.status}`);
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
getStatus: () => request<{ quarterHour: QuarterHourStatus; devices: DeviceStatus[] }>("/api/status"),
|
||||
getHistory: (sinceMs: number) => request<BlockHistoryPoint[]>(`/api/history?sinceMs=${sinceMs}`),
|
||||
getActions: (limit = 50) => request<ControlAction[]>(`/api/actions?limit=${limit}`),
|
||||
getDevices: () => request<DeviceConfig[]>("/api/devices"),
|
||||
createDevice: (device: Omit<DeviceConfig, "id">) =>
|
||||
request<DeviceConfig>("/api/devices", { method: "POST", body: JSON.stringify(device) }),
|
||||
updateDevice: (id: number, patch: Partial<Omit<DeviceConfig, "id">>) =>
|
||||
request<{ ok: true }>(`/api/devices/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
|
||||
deleteDevice: (id: number) => request<{ ok: true }>(`/api/devices/${id}`, { method: "DELETE" }),
|
||||
getTarget: () => request<{ targetW: number }>("/api/settings/target"),
|
||||
setTargetKw: (targetKw: number) =>
|
||||
request<{ ok: true }>("/api/settings/target", { method: "PUT", body: JSON.stringify({ targetKw }) }),
|
||||
};
|
||||
10
energy-management/frontend/src/main.tsx
Normal file
10
energy-management/frontend/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
import "./theme.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
260
energy-management/frontend/src/theme.css
Normal file
260
energy-management/frontend/src/theme.css
Normal file
@@ -0,0 +1,260 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--surface-1: #fcfcfb;
|
||||
--page-plane: #f9f9f7;
|
||||
--text-primary: #0b0b0b;
|
||||
--text-secondary: #52514e;
|
||||
--text-muted: #898781;
|
||||
--gridline: #e1e0d9;
|
||||
--baseline: #c3c2b7;
|
||||
--border: rgba(11, 11, 11, 0.1);
|
||||
--series-1: #2a78d6;
|
||||
--seq-300: #6da7ec;
|
||||
--seq-500: #256abf;
|
||||
--status-good: #0ca30c;
|
||||
--status-warning: #fab219;
|
||||
--status-critical: #d03b3b;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:where(:not([data-theme="light"])) {
|
||||
color-scheme: dark;
|
||||
--surface-1: #1a1a19;
|
||||
--page-plane: #0d0d0d;
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #c3c2b7;
|
||||
--text-muted: #898781;
|
||||
--gridline: #2c2c2a;
|
||||
--baseline: #383835;
|
||||
--border: rgba(255, 255, 255, 0.1);
|
||||
--series-1: #3987e5;
|
||||
--seq-300: #5598e7;
|
||||
--seq-500: #1c5cab;
|
||||
--status-good: #0ca30c;
|
||||
--status-warning: #fab219;
|
||||
--status-critical: #d03b3b;
|
||||
}
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--surface-1: #1a1a19;
|
||||
--page-plane: #0d0d0d;
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #c3c2b7;
|
||||
--text-muted: #898781;
|
||||
--gridline: #2c2c2a;
|
||||
--baseline: #383835;
|
||||
--border: rgba(255, 255, 255, 0.1);
|
||||
--series-1: #3987e5;
|
||||
--seq-300: #5598e7;
|
||||
--seq-500: #1c5cab;
|
||||
--status-good: #0ca30c;
|
||||
--status-warning: #fab219;
|
||||
--status-critical: #d03b3b;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--page-plane);
|
||||
color: var(--text-primary);
|
||||
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
.app {
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 20px 64px;
|
||||
}
|
||||
|
||||
nav.tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
nav.tabs button {
|
||||
background: none;
|
||||
border: none;
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
padding: 10px 14px;
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
|
||||
nav.tabs button.active {
|
||||
color: var(--text-primary);
|
||||
border-bottom-color: var(--series-1);
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--surface-1);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.hero-figure {
|
||||
font-size: 40px;
|
||||
font-weight: 600;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.hero-sub {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.status-pill.good {
|
||||
color: var(--status-good);
|
||||
background: color-mix(in srgb, var(--status-good) 14%, transparent);
|
||||
}
|
||||
|
||||
.status-pill.critical {
|
||||
color: var(--status-critical);
|
||||
background: color-mix(in srgb, var(--status-critical) 14%, transparent);
|
||||
}
|
||||
|
||||
.device-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid var(--gridline);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.device-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.device-name {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.device-meta {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
padding: 3px 8px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.badge.shed {
|
||||
color: var(--status-critical);
|
||||
background: color-mix(in srgb, var(--status-critical) 14%, transparent);
|
||||
}
|
||||
|
||||
.badge.active {
|
||||
color: var(--status-good);
|
||||
background: color-mix(in srgb, var(--status-good) 14%, transparent);
|
||||
}
|
||||
|
||||
.badge.override {
|
||||
color: var(--status-warning);
|
||||
background: color-mix(in srgb, var(--status-warning) 20%, transparent);
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
font: inherit;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--surface-1);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
button.primary {
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
padding: 8px 14px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: var(--series-1);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button.secondary {
|
||||
font: inherit;
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid var(--gridline);
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid var(--gridline);
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.form-grid label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
16
energy-management/frontend/tsconfig.json
Normal file
16
energy-management/frontend/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
1
energy-management/frontend/tsconfig.tsbuildinfo
Normal file
1
energy-management/frontend/tsconfig.tsbuildinfo
Normal file
@@ -0,0 +1 @@
|
||||
{"root":["./src/App.tsx","./src/Dashboard.tsx","./src/Devices.tsx","./src/PeakHistoryChart.tsx","./src/api.ts","./src/main.tsx"],"version":"5.9.3"}
|
||||
11
energy-management/frontend/vite.config.ts
Normal file
11
energy-management/frontend/vite.config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": "http://localhost:3000",
|
||||
},
|
||||
},
|
||||
});
|
||||
3872
energy-management/package-lock.json
generated
Normal file
3872
energy-management/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
14
energy-management/package.json
Normal file
14
energy-management/package.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "energy-management",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"backend",
|
||||
"frontend"
|
||||
],
|
||||
"scripts": {
|
||||
"dev:backend": "npm run dev -w backend",
|
||||
"dev:frontend": "npm run dev -w frontend",
|
||||
"build": "npm run build -w backend && npm run build -w frontend",
|
||||
"test": "npm run test -w backend"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user