Compare commits

...

33 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
29 changed files with 981 additions and 196 deletions

View File

@@ -11,3 +11,78 @@ http:
use_x_forwarded_for: true use_x_forwarded_for: true
trusted_proxies: trusted_proxies:
- 172.16.0.0/12 - 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: services:
mosquitto: mosquitto:
restart: unless-stopped restart: unless-stopped
@@ -131,14 +132,20 @@ services:
# Only enable if external services present # Only enable if external services present
# ports: # ports:
# - 4317:4317 # OTLP gRPC receiver # - 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: energy-management:
build: build:
context: ./energy-management context: ./energy-management
labels: labels:
- traefik.http.routers.prometheus.rule=Host(`energy-management.pladijs`) - traefik.http.routers.energymanagement.rule=Host(`energymanagement.pladijs`)
- traefik.http.services.prometheus.loadbalancer.server.port=3000 - traefik.http.services.energymanagement.loadbalancer.server.port=3000
env_file:
- .env
environment: environment:
DB_PATH: /app/data/energy.db DB_PATH: /app/data/energy.db
# InfluxDB 2.x # InfluxDB 2.x
@@ -158,18 +165,25 @@ services:
HA_TOKEN: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiIxZjlhYzBjYjlkZDE0YWEzYWFhMzVjMzU2Y2VkM2U5MCIsImlhdCI6MTc4NzA4MjEyNywiZXhwIjoyMTAyNDQyMTI3fQ.W6MF78ZPiXF-__GmlFK2D-3vwYGEI3eEgouxw007D64 HA_TOKEN: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiIxZjlhYzBjYjlkZDE0YWEzYWFhMzVjMzU2Y2VkM2U5MCIsImlhdCI6MTc4NzA4MjEyNywiZXhwIjoyMTAyNDQyMTI3fQ.W6MF78ZPiXF-__GmlFK2D-3vwYGEI3eEgouxw007D64
# Capacity tariff target: keep every 15-minute block's average under this many kW # Capacity tariff target: keep every 15-minute block's average under this many kW
TARGET_KW: 4 TARGET_KW: 3.5
# Optional # Optional
PORT: 3000 PORT: 3000
DB_PATH: ./data/energy.db
CONTROL_LOOP_INTERVAL_MS: 10000 CONTROL_LOOP_INTERVAL_MS: 10000
volumes: SOLAR_INFLUX_MEASUREMENT: power
- energy-management-data:/app/data SOLAR_INFLUX_DOMAIN: sensor
restart: unless-stopped 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: volumes:
energy-management-data: - energy-management-data:/app/data
- /run/dbus:/run/dbus:ro
cap_add:
- NET_ADMIN
- NET_RAW
restart: unless-stopped
volumes: volumes:
prometheus-data: prometheus-data:

View File

@@ -13,6 +13,18 @@ INFLUX_FIELD_UNIT=W
HA_URL=http://homeassistant.local:8123 HA_URL=http://homeassistant.local:8123
HA_TOKEN=replace-with-a-long-lived-access-token HA_TOKEN=replace-with-a-long-lived-access-token
# Solar inverter production, read from InfluxDB (same bucket as the house
# power series above). Its latest sample is compared against
# SOLAR_PRODUCTION_THRESHOLD_W to gate "only when sun" devices. Leave
# SOLAR_INFLUX_ENTITY_ID unset to fall back to HA's sun.sun (astronomical
# above/below horizon).
SOLAR_INFLUX_MEASUREMENT=power
SOLAR_INFLUX_DOMAIN=sensor
SOLAR_INFLUX_ENTITY_ID=inverter_pv_power
SOLAR_INFLUX_FIELD=value
SOLAR_INFLUX_FIELD_UNIT=W
SOLAR_PRODUCTION_THRESHOLD_W=50
# Capacity tariff target: keep every 15-minute block's average under this many kW # Capacity tariff target: keep every 15-minute block's average under this many kW
TARGET_KW=5 TARGET_KW=5

View File

@@ -5,6 +5,7 @@ WORKDIR /repo
COPY package.json ./ COPY package.json ./
COPY backend/package.json backend/package.json COPY backend/package.json backend/package.json
COPY frontend/package.json frontend/package.json COPY frontend/package.json frontend/package.json
RUN npm install RUN npm install
COPY backend backend COPY backend backend
@@ -18,7 +19,6 @@ ENV NODE_ENV=production
COPY --from=build /repo/package.json ./package.json COPY --from=build /repo/package.json ./package.json
COPY --from=build /repo/backend/package.json ./backend/package.json COPY --from=build /repo/backend/package.json ./backend/package.json
COPY --from=build /repo/node_modules ./node_modules 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/backend/dist ./backend/dist
COPY --from=build /repo/frontend/dist ./backend/dist/public COPY --from=build /repo/frontend/dist ./backend/dist/public

View File

@@ -6,7 +6,7 @@ export interface LoopDecisionInput {
nowMs: number; nowMs: number;
/** Only restore once projected power is at least this far below target, to avoid flapping. */ /** Only restore once projected power is at least this far below target, to avoid flapping. */
restoreMarginW: number; restoreMarginW: number;
/** Whether HA's sun.sun entity currently reports above_horizon. */ /** Whether the solar inverter is currently producing power (see HomeAssistantClient.isSolarProducing). */
sunUp: boolean; sunUp: boolean;
} }

View File

@@ -1,10 +1,14 @@
import type { QueryApi } from "@influxdata/influxdb-client"; import type { QueryApi } from "@influxdata/influxdb-client";
import type { Env } from "../env.js"; import type { Env } from "../env.js";
import { fetchPowerSamplesSince } from "../influx/power.js"; import {
fetchLatestPowerSample,
fetchLatestSolarProductionSample,
fetchPowerSamplesSince,
} from "../influx/power.js";
import { HomeAssistantClient } from "../homeassistant/client.js"; import { HomeAssistantClient } from "../homeassistant/client.js";
import { getDeviceStatus, restoreDevice, shedDevice } from "../homeassistant/devices.js"; import { getDeviceStatus, restoreDevice, shedDevice } from "../homeassistant/devices.js";
import type { Store } from "../db/store.js"; import type { Store } from "../db/store.js";
import type { DeviceStatus, QuarterHourStatus } from "../types.js"; import type { CurrentReadings, DeviceStatus, QuarterHourStatus } from "../types.js";
import { blockStartFor, computeQuarterHourStatus } from "./quarterHour.js"; import { blockStartFor, computeQuarterHourStatus } from "./quarterHour.js";
import { decideControlAction } from "./loop.js"; import { decideControlAction } from "./loop.js";
@@ -19,17 +23,24 @@ export class ControlLoopRunner {
private readonly log: { info: (o: unknown, msg?: string) => void; error: (o: unknown, msg?: string) => void }, private readonly log: { info: (o: unknown, msg?: string) => void; error: (o: unknown, msg?: string) => void },
) {} ) {}
async getStatus(): Promise<{ quarterHour: QuarterHourStatus; devices: DeviceStatus[] }> { async getStatus(): Promise<{ quarterHour: QuarterHourStatus; devices: DeviceStatus[]; current: CurrentReadings }> {
const nowMs = Date.now(); const nowMs = Date.now();
const blockStart = blockStartFor(nowMs); const blockStart = blockStartFor(nowMs);
const targetW = this.store.getTargetW(); const targetW = this.store.getTargetW();
const samples = await fetchPowerSamplesSince(this.queryApi, this.env, blockStart); const [samples, latestPower, latestSolar, devices] = await Promise.all([
fetchPowerSamplesSince(this.queryApi, this.env, blockStart),
fetchLatestPowerSample(this.queryApi, this.env),
fetchLatestSolarProductionSample(this.queryApi, this.env),
this.loadDeviceStatuses(),
]);
const quarterHour = computeQuarterHourStatus(samples, nowMs, targetW); const quarterHour = computeQuarterHourStatus(samples, nowMs, targetW);
const current: CurrentReadings = {
powerW: latestPower?.watts ?? null,
solarW: this.env.SOLAR_INFLUX_ENTITY_ID ? (latestSolar?.watts ?? 0) : null,
};
const devices = await this.loadDeviceStatuses(); return { quarterHour, devices, current };
return { quarterHour, devices };
} }
private async loadDeviceStatuses(): Promise<DeviceStatus[]> { private async loadDeviceStatuses(): Promise<DeviceStatus[]> {
@@ -42,6 +53,36 @@ export class ControlLoopRunner {
); );
} }
/**
* Whether the solar inverter is currently producing power, used to gate
* `onlyWhenSun` devices. Reads the configured InfluxDB solar production
* series and compares its latest sample (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> { async tick(): Promise<void> {
const nowMs = Date.now(); const nowMs = Date.now();
const { quarterHour, devices } = await this.getStatus(); const { quarterHour, devices } = await this.getStatus();
@@ -50,10 +91,7 @@ export class ControlLoopRunner {
// as that block's final average, with no separate "finalize" step needed. // as that block's final average, with no separate "finalize" step needed.
this.store.upsertBlockHistory(quarterHour.blockStartMs, quarterHour.runningAverageW); this.store.upsertBlockHistory(quarterHour.blockStartMs, quarterHour.runningAverageW);
const sunUp = await this.ha.isSunUp().catch((err) => { const sunUp = await this.isSolarProducing();
this.log.error({ err }, "failed to read sun.sun state; treating as unrestricted");
return true;
});
const decision = decideControlAction({ status: quarterHour, devices, nowMs, restoreMarginW: RESTORE_MARGIN_W, sunUp }); const decision = decideControlAction({ status: quarterHour, devices, nowMs, restoreMarginW: RESTORE_MARGIN_W, sunUp });
if (!decision) return; if (!decision) return;

View File

@@ -20,6 +20,15 @@ const envSchema = z.object({
INFLUX_FIELD_UNIT: z.enum(["W", "kW"]).default("W"), INFLUX_FIELD_UNIT: z.enum(["W", "kW"]).default("W"),
HA_URL: z.string().url(), HA_URL: z.string().url(),
HA_TOKEN: z.string().min(1), HA_TOKEN: z.string().min(1),
// Solar inverter production, read from InfluxDB (same shape as the house
// power series above). Leave SOLAR_INFLUX_ENTITY_ID unset to fall back to
// sun.sun (astronomical day/night).
SOLAR_INFLUX_MEASUREMENT: z.string().default("power"),
SOLAR_INFLUX_DOMAIN: z.string().optional(),
SOLAR_INFLUX_ENTITY_ID: z.string().optional(),
SOLAR_INFLUX_FIELD: z.string().default("value"),
SOLAR_INFLUX_FIELD_UNIT: z.enum(["W", "kW"]).default("W"),
SOLAR_PRODUCTION_THRESHOLD_W: z.coerce.number().default(50),
TARGET_KW: z.coerce.number().positive(), TARGET_KW: z.coerce.number().positive(),
DB_PATH: z.string().default("./data/energy.db"), DB_PATH: z.string().default("./data/energy.db"),
CONTROL_LOOP_INTERVAL_MS: z.coerce.number().default(10_000), CONTROL_LOOP_INTERVAL_MS: z.coerce.number().default(10_000),

View File

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

View File

@@ -10,7 +10,7 @@ export interface DeviceConfig {
maxValue: number; // for "number" kind: normal operating value; for "switch": unused (1) maxValue: number; // for "number" kind: normal operating value; for "switch": unused (1)
dwellSeconds: number; // minimum time between state changes for this device dwellSeconds: number; // minimum time between state changes for this device
manualOverride: boolean; // if true, control loop leaves this device alone manualOverride: boolean; // if true, control loop leaves this device alone
onlyWhenSun: boolean; // if true, device may only be enabled while sun.sun reports above_horizon onlyWhenSun: boolean; // if true, device may only be enabled while the solar inverter is producing power
fromTime: string | null; // "HH:MM", start of allowed enable window (local time), null = no restriction fromTime: string | null; // "HH:MM", start of allowed enable window (local time), null = no restriction
toTime: string | null; // "HH:MM", end of allowed enable window (local time), null = no restriction toTime: string | null; // "HH:MM", end of allowed enable window (local time), null = no restriction
enableUrl: string | null; // if set, called (HTTP POST) to enable/restore the device instead of Home Assistant enableUrl: string | null; // if set, called (HTTP POST) to enable/restore the device instead of Home Assistant
@@ -39,6 +39,11 @@ export interface QuarterHourStatus {
overTarget: boolean; overTarget: boolean;
} }
export interface CurrentReadings {
powerW: number | null; // most recent house power sample, null if none within lookback
solarW: number | null; // most recent solar production sample; 0 if configured but no reading within lookback, null if unconfigured
}
export interface ControlAction { export interface ControlAction {
timestampMs: number; timestampMs: number;
entityId: string; entityId: string;

View File

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

View File

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

View File

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

View File

@@ -124,6 +124,31 @@ nav.tabs button.active {
margin-top: 4px; margin-top: 4px;
} }
.stat-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 16px;
}
.stat-tile {
background: var(--surface-2, transparent);
border: 1px solid var(--border);
border-radius: 10px;
padding: 14px 16px;
}
.stat-label {
font-size: 12px;
color: var(--text-muted);
margin-bottom: 4px;
}
.stat-value {
font-size: 28px;
font-weight: 600;
line-height: 1.1;
}
.status-pill { .status-pill {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -162,6 +187,11 @@ nav.tabs button.active {
font-size: 14px; font-size: 14px;
} }
.device-row-editing {
display: block;
padding: 16px 0;
}
.device-meta { .device-meta {
font-size: 12px; font-size: 12px;
color: var(--text-muted); color: var(--text-muted);

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