Compare commits
33 Commits
53fa112aa8
...
feat/home-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7afd3b878d | ||
|
|
02f1b2549c | ||
|
|
0cfd13807c | ||
|
|
ff749940e6 | ||
|
|
7bf479f90a | ||
|
|
a6d8b66b8a | ||
|
|
d2ea0f16aa | ||
|
|
279a095b05 | ||
|
|
b56b4912a2 | ||
|
|
40cad2ec70 | ||
|
|
6b0d12d6d2 | ||
|
|
2961b477b4 | ||
|
|
e630c92b60 | ||
|
|
46e10fc00b | ||
|
|
9791ea22ab | ||
|
|
8480d7f71b | ||
|
|
43fda71c1b | ||
|
|
161b226db9 | ||
|
|
2cb6ca0588 | ||
|
|
c87c09dde3 | ||
|
|
1d0a5beb8c | ||
|
|
df21ce581b | ||
|
|
0ca32f4bae | ||
|
|
ab0e2a3bdf | ||
|
|
40495eab61 | ||
|
|
d577530deb | ||
|
|
240af40c39 | ||
|
|
bd0d6da7f4 | ||
|
|
c1f8e3bf4f | ||
|
|
22af3312ba | ||
|
|
4fda42504f | ||
|
|
04064364e2 | ||
|
|
95c872ddc8 |
@@ -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
76
docker-compose-immich.yml
Normal 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:
|
||||||
@@ -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,19 +165,26 @@ 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
|
||||||
|
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:
|
volumes:
|
||||||
- energy-management-data:/app/data
|
- energy-management-data:/app/data
|
||||||
|
- /run/dbus:/run/dbus:ro
|
||||||
|
cap_add:
|
||||||
|
- NET_ADMIN
|
||||||
|
- NET_RAW
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
|
||||||
energy-management-data:
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
prometheus-data:
|
prometheus-data:
|
||||||
influxdb-data:
|
influxdb-data:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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
22
immich.env
Normal 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
|
||||||
36
smappee-api/ChargerOptions.cs
Normal file
36
smappee-api/ChargerOptions.cs
Normal 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
17
smappee-api/Dockerfile
Normal 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
42
smappee-api/Program.cs
Normal 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();
|
||||||
23
smappee-api/Properties/launchSettings.json
Normal file
23
smappee-api/Properties/launchSettings.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
37
smappee-api/appsettings.Development.json
Normal file
37
smappee-api/appsettings.Development.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
9
smappee-api/appsettings.json
Normal file
9
smappee-api/appsettings.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*"
|
||||||
|
}
|
||||||
7
smappee-api/docker-compose.yml
Normal file
7
smappee-api/docker-compose.yml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
services:
|
||||||
|
smappee-api:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "5057:8080"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
7
smappee-api/enums/ChargerRequests.cs
Normal file
7
smappee-api/enums/ChargerRequests.cs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
namespace enums;
|
||||||
|
|
||||||
|
internal enum ChargerRequests
|
||||||
|
{
|
||||||
|
Enable,
|
||||||
|
Disable
|
||||||
|
}
|
||||||
5
smappee-api/interfaces/IBearerTokenService.cs
Normal file
5
smappee-api/interfaces/IBearerTokenService.cs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
namespace interfaces;
|
||||||
|
|
||||||
|
internal interface IBearerTokenService {
|
||||||
|
ValueTask<string> GetToken(bool forceNewToken);
|
||||||
|
}
|
||||||
8
smappee-api/interfaces/IChargerService.cs
Normal file
8
smappee-api/interfaces/IChargerService.cs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
using enums;
|
||||||
|
|
||||||
|
namespace interfaces;
|
||||||
|
|
||||||
|
internal interface IChargerService
|
||||||
|
{
|
||||||
|
Task ExecuteRequest(ChargerRequests request);
|
||||||
|
}
|
||||||
51
smappee-api/services/SmappeeChargerService.cs
Normal file
51
smappee-api/services/SmappeeChargerService.cs
Normal 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);
|
||||||
|
}
|
||||||
68
smappee-api/services/SmappeeTokenService.cs
Normal file
68
smappee-api/services/SmappeeTokenService.cs
Normal 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);
|
||||||
|
}
|
||||||
16
smappee-api/smappee-api.csproj
Normal file
16
smappee-api/smappee-api.csproj
Normal 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>
|
||||||
6
smappee-api/smappee-api.http
Normal file
6
smappee-api/smappee-api.http
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
@smappee_api_HostAddress = http://localhost:5057
|
||||||
|
|
||||||
|
GET {{smappee_api_HostAddress}}/weatherforecast/
|
||||||
|
Accept: application/json
|
||||||
|
|
||||||
|
###
|
||||||
Reference in New Issue
Block a user