fix: various fixes

This commit is contained in:
Willem Serruys
2026-08-21 11:49:49 +02:00
parent 161b226db9
commit 43fda71c1b
10 changed files with 441 additions and 185 deletions

View File

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

View File

@@ -1,10 +1,14 @@
import type { QueryApi } from "@influxdata/influxdb-client";
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 { getDeviceStatus, restoreDevice, shedDevice } from "../homeassistant/devices.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 { 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 },
) {}
async getStatus(): Promise<{ quarterHour: QuarterHourStatus; devices: DeviceStatus[] }> {
async getStatus(): Promise<{ quarterHour: QuarterHourStatus; devices: DeviceStatus[]; current: CurrentReadings }> {
const nowMs = Date.now();
const blockStart = blockStartFor(nowMs);
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 current: CurrentReadings = {
powerW: latestPower?.watts ?? null,
solarW: latestSolar?.watts ?? null,
};
const devices = await this.loadDeviceStatuses();
return { quarterHour, devices };
return { quarterHour, devices, current };
}
private async loadDeviceStatuses(): Promise<DeviceStatus[]> {
@@ -42,6 +53,26 @@ 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 against SOLAR_PRODUCTION_THRESHOLD_W.
* Falls back to HA's sun.sun (astronomical above/below horizon) when no
* solar production series is configured, or if either read fails.
*/
private async isSolarProducing(): Promise<boolean> {
try {
const sample = await fetchLatestSolarProductionSample(this.queryApi, this.env);
if (sample) return sample.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.ha.isSunUp().catch((err) => {
this.log.error({ err }, "failed to read sun.sun state; treating as unrestricted");
return true;
});
}
async tick(): Promise<void> {
const nowMs = Date.now();
const { quarterHour, devices } = await this.getStatus();
@@ -50,10 +81,7 @@ export class ControlLoopRunner {
// as that block's final average, with no separate "finalize" step needed.
this.store.upsertBlockHistory(quarterHour.blockStartMs, quarterHour.runningAverageW);
const sunUp = await this.ha.isSunUp().catch((err) => {
this.log.error({ err }, "failed to read sun.sun state; treating as unrestricted");
return true;
});
const sunUp = await this.isSolarProducing();
const decision = decideControlAction({ status: quarterHour, devices, nowMs, restoreMarginW: RESTORE_MARGIN_W, sunUp });
if (!decision) return;

View File

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

View File

@@ -2,27 +2,40 @@ import type { QueryApi } from "@influxdata/influxdb-client";
import type { Env } from "../env.js";
import type { PowerSample } from "../types.js";
interface SeriesConfig {
bucket: string;
measurement: string;
field: string;
fieldUnit: "W" | "kW";
domain?: string;
entityId?: string;
}
/**
* Fetches raw power samples between `sinceMs` and now.
* Converts the configured field to watts based on INFLUX_FIELD_UNIT.
* Fetches raw samples for one series between `sinceMs` and now, converting
* the configured field to watts based on `fieldUnit`.
*/
export async function fetchPowerSamplesSince(
async function fetchSeriesSince(
queryApi: QueryApi,
env: Env,
config: SeriesConfig,
sinceMs: number,
): Promise<PowerSample[]> {
const sinceIso = new Date(sinceMs).toISOString();
const domainFilter = config.domain
? `|> filter(fn: (r) => r["domain"] == "${config.domain}")\n `
: "";
const entityFilter = config.entityId
? `|> filter(fn: (r) => r["entity_id"] == "${config.entityId}")\n `
: "";
const flux = `
from(bucket: "${env.INFLUX_BUCKET}")
from(bucket: "${config.bucket}")
|> range(start: ${sinceIso})
|> filter(fn: (r) => r["_measurement"] == "${env.INFLUX_FIELD_UNIT}")
|> filter(fn: (r) => r["_field"] == "${env.INFLUX_FIELD}")
|> filter(fn: (r) => r["domain"] == "${env.INFLUX_DOMAIN}")
|> filter(fn: (r) => r["entity_id"] == "${env.INFLUX_ENTITY_ID}")
|> sort(columns: ["_time"])
|> filter(fn: (r) => r["_measurement"] == "${config.fieldUnit}")
|> filter(fn: (r) => r["_field"] == "${config.field}")
${domainFilter}${entityFilter}|> sort(columns: ["_time"])
`;
const unitMultiplier = env.INFLUX_FIELD_UNIT === "kW" ? 1000 : 1;
const unitMultiplier = config.fieldUnit === "kW" ? 1000 : 1;
const samples: PowerSample[] = [];
for await (const { values, tableMeta } of queryApi.iterateRows(flux)) {
@@ -36,6 +49,29 @@ export async function fetchPowerSamplesSince(
return samples;
}
function housePowerConfig(env: Env): SeriesConfig {
return {
bucket: env.INFLUX_BUCKET,
measurement: env.INFLUX_MEASUREMENT,
field: env.INFLUX_FIELD,
fieldUnit: env.INFLUX_FIELD_UNIT,
domain: env.INFLUX_DOMAIN,
entityId: env.INFLUX_ENTITY_ID,
};
}
/**
* Fetches raw power samples between `sinceMs` and now.
* Converts the configured field to watts based on INFLUX_FIELD_UNIT.
*/
export async function fetchPowerSamplesSince(
queryApi: QueryApi,
env: Env,
sinceMs: number,
): Promise<PowerSample[]> {
return fetchSeriesSince(queryApi, housePowerConfig(env), sinceMs);
}
/** Fetches the single most recent power sample, if any exists within the lookback window. */
export async function fetchLatestPowerSample(
queryApi: QueryApi,
@@ -49,3 +85,26 @@ export async function fetchLatestPowerSample(
);
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)
dwellSeconds: number; // minimum time between state changes for this device
manualOverride: boolean; // if true, control loop leaves this device alone
onlyWhenSun: boolean; // if true, device may only be enabled while sun.sun reports above_horizon
onlyWhenSun: boolean; // if true, device may only be enabled while the solar inverter is producing power
fromTime: string | null; // "HH:MM", start of allowed enable window (local time), null = no restriction
toTime: string | null; // "HH:MM", end of allowed enable window (local time), null = no restriction
enableUrl: string | null; // if set, called (HTTP POST) to enable/restore the device instead of Home Assistant
@@ -39,6 +39,11 @@ export interface QuarterHourStatus {
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, null if unconfigured or none within lookback
}
export interface ControlAction {
timestampMs: number;
entityId: string;