Compare commits

...

35 Commits

Author SHA1 Message Date
Willem Serruys
7afd3b878d chore: rename 2026-08-29 09:05:50 +02:00
Willem Serruys
02f1b2549c fix: env file 2026-08-29 08:55:49 +02:00
Willem Serruys
0cfd13807c feat: added immich 2026-08-29 08:54:37 +02:00
willem.serruys
ff749940e6 fix: when no solar energy, do not enable consumers 2026-08-22 19:56:31 +02:00
willem.serruys
7bf479f90a fix: height 2026-08-22 19:46:16 +02:00
willem.serruys
a6d8b66b8a fix: sensor 2026-08-22 19:44:07 +02:00
willem.serruys
d2ea0f16aa fix: sensor 2026-08-22 19:41:03 +02:00
willem.serruys
279a095b05 fix citern 2026-08-22 19:28:13 +02:00
willem.serruys
b56b4912a2 fix: fill percentage 2026-08-22 19:21:50 +02:00
willem.serruys
40cad2ec70 fix: sensor_offset 2026-08-22 19:20:07 +02:00
willem.serruys
6b0d12d6d2 fix: sensor 2026-08-22 19:16:58 +02:00
willem.serruys
2961b477b4 formatting 2026-08-22 19:11:20 +02:00
willem.serruys
e630c92b60 updated config 2026-08-22 19:11:09 +02:00
Willem Serruys
46e10fc00b fix: target KW 2026-08-21 12:02:25 +02:00
Willem Serruys
9791ea22ab fix: production threshold 2026-08-21 11:54:43 +02:00
Willem Serruys
8480d7f71b fix: solar settings 2026-08-21 11:52:04 +02:00
Willem Serruys
43fda71c1b fix: various fixes 2026-08-21 11:49:49 +02:00
Willem Serruys
161b226db9 fix: docker-compose 2026-08-21 08:05:19 +02:00
Willem Serruys
2cb6ca0588 fix docker compose 2026-08-21 08:04:53 +02:00
Willem Serruys
c87c09dde3 chore: added smappee api 2026-08-21 08:02:09 +02:00
Willem Serruys
1d0a5beb8c chore: added smappee api 2026-08-21 07:38:21 +02:00
Willem Serruys
df21ce581b fix: url 2026-08-19 19:40:18 +02:00
Willem Serruys
0ca32f4bae fix DNS 2026-08-19 19:36:53 +02:00
Willem Serruys
ab0e2a3bdf fix docker compose 2026-08-19 19:33:18 +02:00
Willem Serruys
40495eab61 fix: docker-compose 2026-08-19 19:28:56 +02:00
Willem Serruys
d577530deb fix: energy management 2026-08-19 19:26:05 +02:00
Willem Serruys
240af40c39 fix: docker compose 2026-08-19 19:24:19 +02:00
Willem Serruys
bd0d6da7f4 fix: docker compose 2026-08-19 18:57:08 +02:00
Willem Serruys
c1f8e3bf4f fix: docker compose 2026-08-19 18:53:16 +02:00
Willem Serruys
22af3312ba fix dockerfile 2026-08-19 18:51:54 +02:00
Willem Serruys
4fda42504f fix dockerfile 2026-08-19 18:48:26 +02:00
Willem Serruys
04064364e2 fix docker compose 2026-08-19 18:38:05 +02:00
Willem Serruys
95c872ddc8 fix: docker-compose 2026-08-19 18:37:27 +02:00
Willem Serruys
53fa112aa8 fix: added energy-management 2026-08-19 18:35:47 +02:00
Willem Serruys
fece1603e7 chore: added gitignore and docker-compose 2026-08-19 18:35:20 +02:00
57 changed files with 6897 additions and 1 deletions

10
.gitignore vendored
View File

@@ -17,3 +17,13 @@ config/
[Oo]bj/
.aider*
.env
node_modules/
dist/
build/
*.db
*.db-journal
.env
.env.local
*.log
.DS_Store

View File

@@ -11,3 +11,78 @@ http:
use_x_forwarded_for: true
trusted_proxies:
- 172.16.0.0/12
# ---- 1. Track a running monthly average of the EPEX day-ahead price ----
# (Replace sensor.nordpool_kwh_be_eur_3_10_0 with your actual Nord Pool entity ID)
sensor:
- platform: statistics
name: "Epex Monthly Average"
entity_id: sensor.nordpool_kwh_be_eur_3_10_0
state_characteristic: mean
max_age:
days: 31
sampling_size: 9999
# ---- 2. Apply Mega's formula and add the fixed cost components ----
template:
- sensor:
- name: "Mega Energy Price"
unique_id: mega_energy_price
unit_of_measurement: "€/kWh"
state: >
{% set epex = states('sensor.epex_monthly_average') | float(0) %}
{% set excl_vat = epex * 1.085 %}
{% set incl_vat = excl_vat * 1.06 %}
{{ incl_vat | round(5) }}
- name: "Mega All-in Price"
unique_id: mega_allin_price
unit_of_measurement: "€/kWh"
state: >
{% set energy = states('sensor.mega_energy_price') | float(0) %}
{% set green_power = 0.01554 %}
{% set distribution = 0.066985 %}
{% set excise = 0.0503288 %}
{% set energy_contribution = 0.0020417 %}
{{ (energy + green_power + distribution + excise + energy_contribution) | round(5) }}
- sensor:
- name: "Mega Injection Price"
unique_id: mega_injection_price
unit_of_measurement: "€/kWh"
state: >
{% set epex_spp = states('sensor.epex_spp_monthly_average') | float(0) %}
{% set price = (epex_spp * 0.96) - 1 %}
{{ [price, 0] | max | round(5) }}
- sensor:
- name: "Cistern Water Height"
unique_id: cistern_water_height
unit_of_measurement: "mm"
state_class: measurement
state: >
{% set mount_gap = 290 %} {# mm from sensor down to the "full" water line #}
{% set max_water_height = 1800 %} {# mm, tank depth from full line to bottom #}
{% set distance = states('sensor.regenput_sensor_distance') | float(0) %}
{% set height = max_water_height - (distance - mount_gap) %}
{{ [ [height, 0] | max, max_water_height] | min | round(1) }}
- name: "Cistern Volume"
unique_id: cistern_volume
unit_of_measurement: "L"
device_class: volume
state_class: measurement
state: >
{% set diameter = 1450 %} {# mm #}
{% set radius = diameter / 2 %}
{% set height = states('sensor.cistern_water_height') | float(0) %}
{% set volume_mm3 = 3.14159265 * (radius ** 2) * height %}
{{ (volume_mm3 / 1000000) | round(1) }} {# convert mm³ to liters #}
- name: "Cistern Fill Percentage"
unique_id: cistern_fill_percentage
unit_of_measurement: "%"
state_class: measurement
state: >
{% set sensor_offset = 2090 - 290 %} {# full tank height - sensor_mount #}
{% set height = states('sensor.cistern_water_height') | float(0) %}
{{ (height / sensor_offset * 100) | round(1) }}

76
docker-compose-immich.yml Normal file
View File

@@ -0,0 +1,76 @@
#
# WARNING: To install Immich, follow our guide: https://docs.immich.app/install/docker-compose
#
# Make sure to use the docker-compose.yml of the current release:
#
# https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml
#
# The compose file on main may not be compatible with the latest release.
name: homeAutomation
services:
immich-server:
container_name: immich_server
image: ghcr.io/immich-app/immich-server:${IMMICH_VERSION:-release}
# extends:
# file: hwaccel.transcoding.yml
# service: cpu # set to one of [nvenc, quicksync, rkmpp, vaapi, vaapi-wsl] for accelerated transcoding
volumes:
# Do not edit the next line. If you want to change the media storage location on your system, edit the value of UPLOAD_LOCATION in the .env file
- ${UPLOAD_LOCATION}:/data
- /etc/localtime:/etc/localtime:ro
env_file:
- immich.env
labels:
- traefik.http.routers.immich.rule=Host(`immich.pladijs`)
- traefik.http.services.immich.loadbalancer.server.port=2283
depends_on:
- redis
- database
restart: always
healthcheck:
disable: false
immich-machine-learning:
container_name: immich_machine_learning
# For hardware acceleration, add one of -[armnn, cuda, rocm, openvino, rknn] to the image tag.
# Example tag: ${IMMICH_VERSION:-release}-cuda
image: ghcr.io/immich-app/immich-machine-learning:${IMMICH_VERSION:-release}
# extends: # uncomment this section for hardware acceleration - see https://docs.immich.app/features/ml-hardware-acceleration
# file: hwaccel.ml.yml
# service: cpu # set to one of [armnn, cuda, rocm, openvino, openvino-wsl, rknn] for accelerated inference - use the `-wsl` version for WSL2 where applicable
volumes:
- model-cache:/cache
env_file:
- immich.env
restart: always
healthcheck:
disable: false
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:8e8d64b405ce18f41b8e5ee20aa4687a8ed0022d1298f2ce31cdcf3a76e09411
healthcheck:
test: redis-cli ping || exit 1
restart: always
database:
container_name: immich_postgres
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:bcf63357191b76a916ae5eb93464d65c07511da41e3bf7a8416db519b40b1c23
environment:
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_USER: ${DB_USERNAME}
POSTGRES_DB: ${DB_DATABASE_NAME}
POSTGRES_INITDB_ARGS: '--data-checksums'
# Uncomment the DB_STORAGE_TYPE: 'HDD' var if your database isn't stored on SSDs
# DB_STORAGE_TYPE: 'HDD'
volumes:
# Do not edit the next line. If you want to change the database storage location on your system, edit the value of DB_DATA_LOCATION in the .env file
- ${DB_DATA_LOCATION}:/var/lib/postgresql/data
shm_size: 128mb
restart: always
healthcheck:
disable: false
volumes:
model-cache:

View File

@@ -1,3 +1,4 @@
name: homeAutomation
services:
mosquitto:
restart: unless-stopped
@@ -131,6 +132,59 @@ services:
# Only enable if external services present
# ports:
# - 4317:4317 # OTLP gRPC receiver
smappee-api:
build: ./smappee-api
env_file:
- .env.smappee
labels:
- traefik.http.routers.smappee.rule=Host(`smappee.pladijs`)
- traefik.http.services.smappee.loadbalancer.server.port=8080
restart: unless-stopped
energy-management:
build:
context: ./energy-management
labels:
- traefik.http.routers.energymanagement.rule=Host(`energymanagement.pladijs`)
- traefik.http.services.energymanagement.loadbalancer.server.port=3000
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: 3.5
# Optional
PORT: 3000
CONTROL_LOOP_INTERVAL_MS: 10000
SOLAR_INFLUX_MEASUREMENT: power
SOLAR_INFLUX_DOMAIN: sensor
SOLAR_INFLUX_ENTITY_ID: sb4_0_1av_41_812_pv_power
SOLAR_INFLUX_FIELD: value
SOLAR_INFLUX_FIELD_UNIT: W
SOLAR_PRODUCTION_THRESHOLD_W: 300
volumes:
- energy-management-data:/app/data
- /run/dbus:/run/dbus:ro
cap_add:
- NET_ADMIN
- NET_RAW
restart: unless-stopped
volumes:
prometheus-data:
influxdb-data:
@@ -139,3 +193,4 @@ volumes:
mosquitto-data:
traefik-data:
syncthing-data:
energy-management-data:

View File

@@ -0,0 +1,34 @@
# 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
# Solar inverter production, read from InfluxDB (same bucket as the house
# power series above). Its latest sample is compared against
# SOLAR_PRODUCTION_THRESHOLD_W to gate "only when sun" devices. Leave
# SOLAR_INFLUX_ENTITY_ID unset to fall back to HA's sun.sun (astronomical
# above/below horizon).
SOLAR_INFLUX_MEASUREMENT=power
SOLAR_INFLUX_DOMAIN=sensor
SOLAR_INFLUX_ENTITY_ID=inverter_pv_power
SOLAR_INFLUX_FIELD=value
SOLAR_INFLUX_FIELD_UNIT=W
SOLAR_PRODUCTION_THRESHOLD_W=50
# Capacity tariff target: keep every 15-minute block's average under this many kW
TARGET_KW=5
# Optional
PORT=3000
DB_PATH=./data/energy.db
CONTROL_LOOP_INTERVAL_MS=10000

9
energy-management/.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
node_modules/
dist/
build/
*.db
*.db-journal
.env
.env.local
*.log
.DS_Store

View 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/dist ./backend/dist
COPY --from=build /repo/frontend/dist ./backend/dist/public
VOLUME ["/app/data"]
EXPOSE 3000
CMD ["node", "backend/dist/index.js"]

View 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.

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 the solar inverter is currently producing power (see HomeAssistantClient.isSolarProducing). */
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,126 @@
import type { QueryApi } from "@influxdata/influxdb-client";
import type { Env } from "../env.js";
import {
fetchLatestPowerSample,
fetchLatestSolarProductionSample,
fetchPowerSamplesSince,
} from "../influx/power.js";
import { HomeAssistantClient } from "../homeassistant/client.js";
import { getDeviceStatus, restoreDevice, shedDevice } from "../homeassistant/devices.js";
import type { Store } from "../db/store.js";
import type { CurrentReadings, 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[]; current: CurrentReadings }> {
const nowMs = Date.now();
const blockStart = blockStartFor(nowMs);
const targetW = this.store.getTargetW();
const [samples, latestPower, latestSolar, devices] = await Promise.all([
fetchPowerSamplesSince(this.queryApi, this.env, blockStart),
fetchLatestPowerSample(this.queryApi, this.env),
fetchLatestSolarProductionSample(this.queryApi, this.env),
this.loadDeviceStatuses(),
]);
const quarterHour = computeQuarterHourStatus(samples, nowMs, targetW);
const current: CurrentReadings = {
powerW: latestPower?.watts ?? null,
solarW: this.env.SOLAR_INFLUX_ENTITY_ID ? (latestSolar?.watts ?? 0) : null,
};
return { quarterHour, devices, current };
}
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);
}),
);
}
/**
* Whether the solar inverter is currently producing power, used to gate
* `onlyWhenSun` devices. Reads the configured InfluxDB solar production
* series and compares its latest sample (treating no reading within the
* lookback window as 0W) against SOLAR_PRODUCTION_THRESHOLD_W.
* Falls back to HA's sun.sun (astronomical above/below horizon) when no
* solar production series is configured at all, or if the Influx read
* itself fails.
*/
private async isSolarProducing(): Promise<boolean> {
if (!this.env.SOLAR_INFLUX_ENTITY_ID) {
return this.fallbackToSunUp();
}
try {
const sample = await fetchLatestSolarProductionSample(this.queryApi, this.env);
const watts = sample?.watts ?? 0;
return watts >= this.env.SOLAR_PRODUCTION_THRESHOLD_W;
} catch (err) {
this.log.error({ err }, "failed to read solar production series; falling back to sun.sun");
return this.fallbackToSunUp();
}
}
private fallbackToSunUp(): Promise<boolean> {
return this.ha.isSunUp().catch((err) => {
this.log.error({ err }, "failed to read sun.sun state; treating as unrestricted");
return true;
});
}
async tick(): Promise<void> {
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.isSolarProducing();
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,46 @@
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),
// Solar inverter production, read from InfluxDB (same shape as the house
// power series above). Leave SOLAR_INFLUX_ENTITY_ID unset to fall back to
// sun.sun (astronomical day/night).
SOLAR_INFLUX_MEASUREMENT: z.string().default("power"),
SOLAR_INFLUX_DOMAIN: z.string().optional(),
SOLAR_INFLUX_ENTITY_ID: z.string().optional(),
SOLAR_INFLUX_FIELD: z.string().default("value"),
SOLAR_INFLUX_FIELD_UNIT: z.enum(["W", "kW"]).default("W"),
SOLAR_PRODUCTION_THRESHOLD_W: z.coerce.number().default(50),
TARGET_KW: z.coerce.number().positive(),
DB_PATH: z.string().default("./data/energy.db"),
CONTROL_LOOP_INTERVAL_MS: z.coerce.number().default(10_000),
});
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,110 @@
import type { QueryApi } from "@influxdata/influxdb-client";
import type { Env } from "../env.js";
import type { PowerSample } from "../types.js";
interface SeriesConfig {
bucket: string;
measurement: string;
field: string;
fieldUnit: "W" | "kW";
domain?: string;
entityId?: string;
}
/**
* Fetches raw samples for one series between `sinceMs` and now, converting
* the configured field to watts based on `fieldUnit`.
*/
async function fetchSeriesSince(
queryApi: QueryApi,
config: SeriesConfig,
sinceMs: number,
): Promise<PowerSample[]> {
const sinceIso = new Date(sinceMs).toISOString();
const domainFilter = config.domain
? `|> filter(fn: (r) => r["domain"] == "${config.domain}")\n `
: "";
const entityFilter = config.entityId
? `|> filter(fn: (r) => r["entity_id"] == "${config.entityId}")\n `
: "";
const flux = `
from(bucket: "${config.bucket}")
|> range(start: ${sinceIso})
|> filter(fn: (r) => r["_measurement"] == "${config.fieldUnit}")
|> filter(fn: (r) => r["_field"] == "${config.field}")
${domainFilter}${entityFilter}|> sort(columns: ["_time"])
`;
const unitMultiplier = config.fieldUnit === "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;
}
function housePowerConfig(env: Env): SeriesConfig {
return {
bucket: env.INFLUX_BUCKET,
measurement: env.INFLUX_MEASUREMENT,
field: env.INFLUX_FIELD,
fieldUnit: env.INFLUX_FIELD_UNIT,
domain: env.INFLUX_DOMAIN,
entityId: env.INFLUX_ENTITY_ID,
};
}
/**
* Fetches raw power samples between `sinceMs` and now.
* Converts the configured field to watts based on INFLUX_FIELD_UNIT.
*/
export async function fetchPowerSamplesSince(
queryApi: QueryApi,
env: Env,
sinceMs: number,
): Promise<PowerSample[]> {
return fetchSeriesSince(queryApi, housePowerConfig(env), sinceMs);
}
/** Fetches the single most recent power sample, if any exists within the lookback window. */
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;
}
/**
* Fetches the most recent solar production sample from InfluxDB, if any
* exists within the lookback window. Returns null when no solar production
* series is configured (SOLAR_INFLUX_ENTITY_ID unset).
*/
export async function fetchLatestSolarProductionSample(
queryApi: QueryApi,
env: Env,
lookbackMs = 60_000,
): Promise<PowerSample | null> {
if (!env.SOLAR_INFLUX_ENTITY_ID) return null;
const config: SeriesConfig = {
bucket: env.INFLUX_BUCKET,
measurement: env.SOLAR_INFLUX_MEASUREMENT,
field: env.SOLAR_INFLUX_FIELD,
fieldUnit: env.SOLAR_INFLUX_FIELD_UNIT,
domain: env.SOLAR_INFLUX_DOMAIN,
entityId: env.SOLAR_INFLUX_ENTITY_ID,
};
const samples = await fetchSeriesSince(queryApi, config, Date.now() - lookbackMs);
return samples.length > 0 ? samples[samples.length - 1] : null;
}

View File

@@ -0,0 +1,52 @@
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 the solar inverter is producing power
fromTime: string | null; // "HH:MM", start of allowed enable window (local time), null = no restriction
toTime: string | null; // "HH:MM", end of allowed enable window (local time), null = no restriction
enableUrl: string | null; // if set, called (HTTP POST) to enable/restore the device instead of Home Assistant
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 CurrentReadings {
powerW: number | null; // most recent house power sample, null if none within lookback
solarW: number | null; // most recent solar production sample; 0 if configured but no reading within lookback, null if unconfigured
}
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"]
}

View 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:

View 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>

View 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"
}
}

View 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>
);
}

View File

@@ -0,0 +1,164 @@
import { useEffect, useState } from "react";
import {
api,
type BlockHistoryPoint,
type ControlAction,
type CurrentReadings,
type DeviceStatus,
type QuarterHourStatus,
} from "./api";
import { PeakHistoryChart } from "./PeakHistoryChart";
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
function formatW(watts: number | null): string {
if (watts == null) return "—";
return Math.abs(watts) >= 1000 ? `${(watts / 1000).toFixed(2)} kW` : `${Math.round(watts)} W`;
}
export function Dashboard() {
const [quarterHour, setQuarterHour] = useState<QuarterHourStatus | null>(null);
const [current, setCurrent] = useState<CurrentReadings | 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);
setCurrent(status.current);
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>Live readings</h2>
<div className="stat-grid">
<div className="stat-tile">
<div className="stat-label">Power usage</div>
<div className="stat-value">{formatW(current?.powerW ?? null)}</div>
</div>
<div className="stat-tile">
<div className="stat-label">Solar generation</div>
<div className="stat-value">{formatW(current?.solarW ?? null)}</div>
</div>
</div>
</div>
<div className="card">
<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>
</>
);
}

View File

@@ -0,0 +1,324 @@
import { useEffect, useState, type FormEvent } from "react";
import { api, type ControlKind, type DeviceConfig } from "./api";
type DeviceFormValues = Omit<DeviceConfig, "id">;
const emptyForm: DeviceFormValues = {
entityId: "",
name: "",
kind: "switch" as ControlKind,
priority: 1,
minValue: 0,
maxValue: 1,
dwellSeconds: 300,
manualOverride: false,
onlyWhenSun: false,
fromTime: null,
toTime: null,
enableUrl: null,
disableUrl: null,
};
function DeviceFields({
values,
onChange,
}: {
values: DeviceFormValues;
onChange: (next: DeviceFormValues) => void;
}) {
return (
<div className="form-grid">
<label>
Name
<input
value={values.name}
onChange={(e) => onChange({ ...values, name: e.target.value })}
required
/>
</label>
<label>
Home Assistant entity ID
<input
value={values.entityId}
onChange={(e) => onChange({ ...values, entityId: e.target.value })}
placeholder="switch.ev_charger"
required
/>
</label>
<label>
Control type
<select
value={values.kind}
onChange={(e) =>
onChange({ ...values, kind: e.target.value as ControlKind })
}
>
<option value="switch">Switch (on/off)</option>
<option value="number">Number (set-point)</option>
</select>
</label>
<label>
Priority (lower = shed first)
<input
type="number"
value={values.priority}
onChange={(e) =>
onChange({ ...values, priority: Number(e.target.value) })
}
/>
</label>
{values.kind === "number" && (
<>
<label>
Min value (shed target)
<input
type="number"
value={values.minValue}
onChange={(e) =>
onChange({ ...values, minValue: Number(e.target.value) })
}
/>
</label>
<label>
Max value (normal)
<input
type="number"
value={values.maxValue}
onChange={(e) =>
onChange({ ...values, maxValue: Number(e.target.value) })
}
/>
</label>
</>
)}
<label>
Min dwell time (seconds)
<input
type="number"
value={values.dwellSeconds}
onChange={(e) =>
onChange({ ...values, dwellSeconds: Number(e.target.value) })
}
/>
</label>
<label>
Only enable if sun
<input
type="checkbox"
checked={values.onlyWhenSun}
onChange={(e) =>
onChange({ ...values, onlyWhenSun: e.target.checked })
}
/>
</label>
<label>
From (allowed window start)
<input
type="time"
value={values.fromTime ?? ""}
onChange={(e) =>
onChange({ ...values, fromTime: e.target.value || null })
}
/>
</label>
<label>
To (allowed window end)
<input
type="time"
value={values.toTime ?? ""}
onChange={(e) =>
onChange({ ...values, toTime: e.target.value || null })
}
/>
</label>
<label>
Custom enable endpoint (optional)
<input
type="url"
value={values.enableUrl ?? ""}
onChange={(e) =>
onChange({ ...values, enableUrl: e.target.value || null })
}
placeholder="https://example.local/device/on"
/>
</label>
<label>
Custom disable endpoint (optional)
<input
type="url"
value={values.disableUrl ?? ""}
onChange={(e) =>
onChange({ ...values, disableUrl: e.target.value || null })
}
placeholder="https://example.local/device/off"
/>
</label>
</div>
);
}
export function Devices() {
const [devices, setDevices] = useState<DeviceConfig[]>([]);
const [form, setForm] = useState(emptyForm);
const [error, setError] = useState<string | null>(null);
const [editingId, setEditingId] = useState<number | null>(null);
const [editForm, setEditForm] = useState<DeviceFormValues>(emptyForm);
const [editError, setEditError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
async function refresh() {
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();
}
function startEdit(d: DeviceConfig) {
const { id: _id, ...rest } = d;
setEditForm(rest);
setEditingId(d.id);
setEditError(null);
}
function cancelEdit() {
setEditingId(null);
setEditError(null);
}
async function saveEdit(e: FormEvent) {
e.preventDefault();
if (editingId == null) return;
setSaving(true);
try {
await api.updateDevice(editingId, editForm);
await refresh();
setEditingId(null);
setEditError(null);
} catch (err) {
setEditError(err instanceof Error ? err.message : "Failed to save device");
} finally {
setSaving(false);
}
}
return (
<>
<div className="card">
<h2>Add device</h2>
<form onSubmit={handleCreate}>
<DeviceFields values={form} onChange={setForm} />
<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) =>
editingId === d.id ? (
<form
className="device-row device-row-editing"
key={d.id}
onSubmit={saveEdit}
>
<DeviceFields values={editForm} onChange={setEditForm} />
{editError && (
<p style={{ color: "var(--status-critical)", fontSize: 13 }}>
{editError}
</p>
)}
<div style={{ display: "flex", gap: 8 }}>
<button className="primary" type="submit" disabled={saving}>
{saving ? "Saving…" : "Save"}
</button>
<button
className="secondary"
type="button"
onClick={cancelEdit}
disabled={saving}
>
Cancel
</button>
</div>
</form>
) : (
<div className="device-row" key={d.id}>
<div>
<div className="device-name">{d.name}</div>
<div className="device-meta">
{d.entityId} · {d.kind} · priority {d.priority} · dwell{" "}
{d.dwellSeconds}s{d.onlyWhenSun && " · sun only"}
{d.fromTime && d.toTime && ` · ${d.fromTime}${d.toTime}`}
{d.enableUrl && d.disableUrl && " · custom endpoints"}
<div>{d.enableUrl}</div>
<div>{d.disableUrl}</div>
</div>
</div>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
{d.manualOverride && (
<span className="badge override">Manual</span>
)}
<button
className="secondary"
onClick={() => toggleOverride(d)}
>
{d.manualOverride ? "Resume auto" : "Manual override"}
</button>
<button className="secondary" onClick={() => startEdit(d)}>
Edit
</button>
<button className="secondary" onClick={() => remove(d)}>
Remove
</button>
</div>
</div>
),
)
)}
</div>
</>
);
}

View 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>
);
}

View File

@@ -0,0 +1,79 @@
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 CurrentReadings {
powerW: number | null;
solarW: number | null;
}
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[]; current: CurrentReadings }>(
"/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 }) }),
};

View 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>,
);

View File

@@ -0,0 +1,290 @@
: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;
}
.stat-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 16px;
}
.stat-tile {
background: var(--surface-2, transparent);
border: 1px solid var(--border);
border-radius: 10px;
padding: 14px 16px;
}
.stat-label {
font-size: 12px;
color: var(--text-muted);
margin-bottom: 4px;
}
.stat-value {
font-size: 28px;
font-weight: 600;
line-height: 1.1;
}
.status-pill {
display: inline-flex;
align-items: center;
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-row-editing {
display: block;
padding: 16px 0;
}
.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);
}

View 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"]
}

View 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"}

View 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

File diff suppressed because it is too large Load Diff

View 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"
}
}

22
immich.env Normal file
View File

@@ -0,0 +1,22 @@
# You can find documentation for all the supported env variables at https://docs.immich.app/install/environment-variables
# The location where your uploaded files are stored
UPLOAD_LOCATION=/srv/appdata/immich/upload
# The location where your database files are stored. Network shares are not supported for the database
DB_DATA_LOCATION=/srv/appdata/immich/db
# To set a timezone, uncomment the next line and change Etc/UTC to a TZ identifier from this list: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List
# TZ=Etc/UTC
# The Immich version to use. You can pin this to a specific version like "v2.1.0"
IMMICH_VERSION=v3
# Connection secret for postgres. You should change it to a random password
# Please use only the characters `A-Za-z0-9`, without special characters or spaces
DB_PASSWORD=postgres
# The values below this line do not need to be changed
###################################################################################
DB_USERNAME=postgres
DB_DATABASE_NAME=immich

View File

@@ -0,0 +1,36 @@
using System.ComponentModel.DataAnnotations;
namespace options;
internal class ChargerOptions
{
public const string Name = "CHARGER";
[Required]
[ConfigurationKeyName("URL")]
public required Uri Url { get; init; }
[Required]
[ConfigurationKeyName("CLIENT_ID")]
public required string ClientId { get; init; }
[Required]
[ConfigurationKeyName("CLIENT_SECRET")]
public required string ClientSecret { get; init; }
[Required]
[ConfigurationKeyName("USERNAME")]
public required string UserName { get; init; }
[Required]
[ConfigurationKeyName("PASSWORD")]
public required string Password { get; init; }
[Required]
[ConfigurationKeyName("STATION")]
public required string ChargingStation { get; init; }
[Required]
[ConfigurationKeyName("CONNECTOR")]
public required int Connector { get; init; }
}

17
smappee-api/Dockerfile Normal file
View File

@@ -0,0 +1,17 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY smappee-api.csproj ./
RUN dotnet restore
COPY . .
RUN dotnet publish -c Release -o /app
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
WORKDIR /app
COPY --from=build /app .
ENV ASPNETCORE_URLS=http://+:8080
EXPOSE 8080
ENTRYPOINT ["dotnet", "smappee-api.dll"]

42
smappee-api/Program.cs Normal file
View File

@@ -0,0 +1,42 @@
using enums;
using interfaces;
using options;
using services;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient();
builder.Services.AddRedaction();
builder.Services.AddExtendedHttpClientLogging(
builder.Configuration.GetSection("HttpClientLogging"));
builder
.Services.AddOptions<ChargerOptions>()
.Bind(builder.Configuration.GetSection(ChargerOptions.Name))
.ValidateDataAnnotations();
builder.Services.AddSingleton<IBearerTokenService, SmappeeTokenService>();
builder.Services.AddScoped<IChargerService, SmappeeChargerService>();
var app = builder.Build();
app.MapPost(
"/enable",
async (IChargerService chargerService) =>
{
await chargerService.ExecuteRequest(ChargerRequests.Enable);
return;
}
)
.WithName("Enable");
app.MapPost(
"/disable",
async (IChargerService chargerService) =>
{
await chargerService.ExecuteRequest(ChargerRequests.Disable);
return;
}
)
.WithName("Disable");
app.Run();

View File

@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5057",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:7028;http://localhost:5057",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,37 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Information"
},
"Console": {
"FormatterName": "json"
}
},
"CHARGER": {
"URL": "https://app1pub.smappee.net",
"CLIENT_ID": "5700002178",
"CLIENT_SECRET": "cPcRKc26ul",
"USERNAME": "serruysw@gmail.com",
"PASSWORD": "uAfUhnBXkZeqs$@7ca7P3M9QE6z0Dg1N0cbz&",
"STATION": "6230003897",
"CONNECTOR": 1
},
"HttpClientLogging": {
"LogRequestStart": false,
"LogBody": true,
"LogContentHeaders": true,
"BodySizeLimit": 32768,
"BodyReadTimeout": "00:00:01",
"RequestBodyContentTypes": [ "application/json", "application/x-www-form-urlencoded" ],
"ResponseBodyContentTypes": [ "application/json" ],
"RequestHeadersDataClasses": {
"User-Agent": "None",
"Content-Type": "None"
},
"ResponseHeadersDataClasses": {
"Content-Type": "None"
},
"RequestPathParameterRedactionMode": "None"
}
}

View File

@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

View File

@@ -0,0 +1,7 @@
services:
smappee-api:
build: .
ports:
- "5057:8080"
env_file:
- .env

View File

@@ -0,0 +1,7 @@
namespace enums;
internal enum ChargerRequests
{
Enable,
Disable
}

View File

@@ -0,0 +1,5 @@
namespace interfaces;
internal interface IBearerTokenService {
ValueTask<string> GetToken(bool forceNewToken);
}

View File

@@ -0,0 +1,8 @@
using enums;
namespace interfaces;
internal interface IChargerService
{
Task ExecuteRequest(ChargerRequests request);
}

View File

@@ -0,0 +1,51 @@
using enums;
using interfaces;
using Microsoft.Extensions.Options;
using options;
namespace services;
internal class SmappeeChargerService : IChargerService
{
private readonly ChargerOptions options;
private readonly IBearerTokenService tokenService;
private readonly IHttpClientFactory httpClientFactory;
public SmappeeChargerService(
IOptions<ChargerOptions> options,
IBearerTokenService tokenService,
IHttpClientFactory httpClientFactory
)
{
ArgumentNullException.ThrowIfNull(tokenService);
this.options = options?.Value ?? throw new ArgumentNullException(nameof(options));
this.tokenService = tokenService;
this.httpClientFactory =
httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
}
public async Task ExecuteRequest(ChargerRequests request)
{
var token = await tokenService.GetToken(false);
var httpClient = httpClientFactory.CreateClient("smappee");
httpClient.BaseAddress = options.Url;
httpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {token}");
var response = request switch
{
ChargerRequests.Enable => await httpClient.PutAsJsonAsync<SmappeeRequestBody>(
$"/dev/v3/chargingstations/{options.ChargingStation}/connectors/{options.Connector}/mode",
new SmappeeRequestBody("NORMAL", new Limit("AMPERE", 6))
),
ChargerRequests.Disable => await httpClient.PutAsJsonAsync(
$"/dev/v3/chargingstations/{options.ChargingStation}/connectors/{options.Connector}/mode",
new SmappeeRequestBody("PAUSED", null)
),
_ => throw new NotImplementedException(),
};
response.EnsureSuccessStatusCode();
}
record SmappeeRequestBody(string mode, Limit? limit);
record Limit(string unit, int value);
}

View File

@@ -0,0 +1,68 @@
using interfaces;
using Microsoft.Extensions.Options;
using options;
namespace services;
internal class SmappeeTokenService : IBearerTokenService
{
private readonly ChargerOptions options;
private readonly IHttpClientFactory httpClientFactory;
private TokenData? tokenData = null;
public SmappeeTokenService(
IOptions<ChargerOptions> options,
IHttpClientFactory httpClientFactory
)
{
this.options = options?.Value ?? throw new ArgumentNullException(nameof(options));
this.httpClientFactory =
httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
}
public async ValueTask<string> GetToken(bool forceNewToken)
{
if (
tokenData == null
|| tokenData.Expiration < DateTime.UtcNow.AddSeconds(-10)
|| forceNewToken
)
{
tokenData = await RequestNewToken();
}
return tokenData.Token;
}
private async Task<TokenData> RequestNewToken()
{
var httpClient = httpClientFactory.CreateClient("smappeeTokenService");
httpClient.BaseAddress = options.Url;
var result = await httpClient.PostAsync(
"/dev/v3/oauth2/token",
new FormUrlEncodedContent(
new Dictionary<string, string>
{
["grant_type"] = "password",
["client_id"] = options.ClientId,
["client_secret"] = options.ClientSecret,
["username"] = options.UserName,
["password"] = options.Password,
}
)
);
result.EnsureSuccessStatusCode();
var tokenResponse =
await result.Content.ReadFromJsonAsync<TokenResponse>()
?? throw new InvalidOperationException("Token could not be fetched");
return new TokenData(
tokenResponse.access_token,
DateTime.UtcNow.AddSeconds(tokenResponse.expires_in)
);
}
record TokenResponse(string access_token, int expires_in);
record TokenData(string Token, DateTime Expiration);
}

View File

@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>smappee_api</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.10" />
<PackageReference Include="Microsoft.Extensions.Compliance.Redaction" Version="10.9.0" />
<PackageReference Include="Microsoft.Extensions.Http.Diagnostics" Version="10.9.0" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,6 @@
@smappee_api_HostAddress = http://localhost:5057
GET {{smappee_api_HostAddress}}/weatherforecast/
Accept: application/json
###