diff --git a/.gitignore b/.gitignore index 567816ed..cae598e6 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,10 @@ data/ *.test *.out +# matter-sidecar (Node.js) build artifacts +/matter-sidecar/node_modules/ +/matter-sidecar/dist/ + # Local config for simulators config.local.yaml diff --git a/docker-compose.yml b/docker-compose.yml index 9c2fd4a8..792df129 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -100,6 +100,45 @@ services: # lifecycle of individual services that mount the same volume). - update-ipc:/run/ftw-update + # --------------------------------------------------------------------- + # Matter sidecar (matter-sidecar/, built on matter.js — replaces the + # earlier python-matter-server draft per maintainer review on PR #1). + # + # 42W does NOT perform initial commissioning (BLE/Thread/Wi-Fi + # provisioning of a brand-new device, which is why the old draft + # needed `privileged: true`). That stays the job of whichever + # controller the device shipped with (Apple Home, Google Home, Home + # Assistant, ...). Once commissioned there, open that controller's + # multi-admin "share device" flow to mint a pairing code, then hand + # that code to 42W via the sidecar's `commission` command, which + # joins as an additional fabric admin over plain IP — no BLE needed + # on this side. network_mode: host is required for mDNS discovery of + # the shared device. The sidecar's control-plane WebSocket (port 5580, + # commission/write_attribute/send_command/...) has no auth of its own, + # so it binds to loopback only (matter-sidecar/src/index.ts) — host + # networking would otherwise put an unauthenticated device-control API + # on the LAN. + # + # 42W also runs the other direction (Phase 2): as a Matter *server*, + # exposing a CommodityPrice cluster (spot price + forecast) other + # controllers can read. GET /api/matter/pairing_code returns the codes + # to add 42W onto a third-party fabric. + # + # Phase 3 — a *bridge* surfacing non-Matter DERs (EVSE, inverters, + # batteries) as Matter devices to other ecosystems. Opt in per driver + # with `matter_bridge: true` in config.yaml; see + # matter-sidecar/src/bridge.ts and docs/configuration.md. + # --------------------------------------------------------------------- + matter-sidecar: + build: ./matter-sidecar + container_name: ftw-matter-sidecar + restart: unless-stopped + network_mode: host + environment: + - FTW_MATTER_STORAGE_PATH=/data + volumes: + - ./data/matter:/data + # --------------------------------------------------------------------- # MQTT broker (Eclipse Mosquitto) # diff --git a/docs/api.md b/docs/api.md index a08d94fa..bfd782a6 100644 --- a/docs/api.md +++ b/docs/api.md @@ -512,6 +512,91 @@ Handler: `go/internal/api/api.go:445` --- +## Matter sidecar + +Admin actions for the 42W Matter sidecar (`matter-sidecar/`, built on +matter.js) — the one-time pairing-code join and node listing. 42W does not +commission devices itself; see `drivers/matter.lua`'s header comment for +the full onboarding flow. Both endpoints require `matter:` configured at +the config root (`go/internal/config/config.go`'s `Config.Matter`) — +otherwise they return `503`. + +### POST /api/matter/commission + +Join a device shared from another controller's fabric using a one-time +pairing code minted by that controller's "share device" / multi-admin +flow. + +**Request body:** + +```json +{ "pairing_code": "34970112332" } +``` + +**Response (200):** + +```json +{ "node_id": 1 } +``` + +`node_id` is a small logical id (not Matter's own 64-bit NodeId) — paste +it into the driver's `config.node_id` field. + +**Errors:** `400` missing `pairing_code`; `502` sidecar reachable but the +commission attempt failed (wrong/expired code, device unreachable); `503` +sidecar not configured. + +Handler: `go/internal/api/api_matter.go` + +### GET /api/matter/nodes + +List every node the sidecar has joined so far, to confirm a `node_id` +before pasting it into driver config. + +**Response (200):** + +```json +[ + { "node_id": 1, "matter_node_id": "123456789012345" } +] +``` + +Handler: `go/internal/api/api_matter.go` + +### GET /api/matter/pairing_code + +Phase 2 — the inverse of `/api/matter/commission`: 42W itself is a +commissionable Matter device (exposing a CommodityPrice server endpoint +with spot price + forecast — see `matter-sidecar/src/priceserver.ts`). +This returns the one-time codes a third-party controller (Apple Home, +Home Assistant, ...) needs to add 42W onto their own fabric. + +**Response (200):** + +```json +{ + "manual_pairing_code": "34970112332", + "qr_pairing_code": "MT:Y.K9042C00KA0648G00" +} +``` + +**Errors:** `502` sidecar reachable but the request failed; `503` sidecar +not configured. + +Handler: `go/internal/api/api_matter.go` + +### Phase 3 — Matter bridge (no REST endpoint) + +42W as a Matter *bridge*: drivers with `matter_bridge: true` (see +`docs/configuration.md`) get their live power reading surfaced as a +bridged Matter device under the sidecar's Aggregator endpoint, so other +Matter ecosystems can see it. This is config-driven and push-only — a +background loop (`matterBridgeLoop` in `go/cmd/forty-two-watts/main.go`) +calls the sidecar's `sync_bridge` command every 5 minutes; there is no +operator-facing HTTP surface for it. + +--- + ## MPC planner ### GET /api/mpc/plan diff --git a/docs/configuration.md b/docs/configuration.md index 107fd96b..a206b8a5 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -72,6 +72,7 @@ drivers: max_charge_w: 10000 # per-driver cap; 0/unset → 5 kW default max_discharge_w: 10000 inverter_group: ferroamp # optional — see "Inverter affinity" below + matter_bridge: true # optional — surface this driver's power on the Matter bridge mqtt: host: 192.168.1.153 port: 1883 @@ -208,6 +209,39 @@ batteries: Keys must match `drivers[].name`. Leave blank to use BMS defaults. +#### Matter bridge (`matter_bridge`) + +Set `matter_bridge: true` on any driver to surface its live power reading +as a bridged Matter device under the sidecar's Aggregator endpoint (Phase +3 — see `matter-sidecar/src/bridge.ts`), so other Matter ecosystems +(Apple Home, Home Assistant, ...) can see it. Off by default — this is a +deliberate per-driver opt-in, not automatic for every configured driver. +Requires the `matter:` block below to be configured. State of charge is +not bridged (left for a follow-up); only power. + +### `matter` — Matter sidecar admin connection (optional) + +```yaml +matter: + host: localhost # matter-sidecar address + port: 5580 # default 5580 +``` + +Enables `POST /api/matter/commission` and `GET /api/matter/nodes` — the +one-time pairing-code join for devices shared from another Matter +controller's fabric, and a listing of nodes already joined. This is +separate from each driver's own `capabilities.matter` block (which talks +to an already-joined `node_id`); the two normally point at the same +sidecar. See `drivers/matter.lua`'s header comment for the full +onboarding flow, and `docs/api.md`'s Matter section for the endpoints. + +This same `matter:` block also turns on Phase 2 — 42W exposing its own +spot price + forecast as a Matter CommodityPrice server endpoint, so other +Matter controllers can read it directly. If `price:` is also configured, +a background loop pushes fresh data to the sidecar every 5 minutes; no +extra config needed. `GET /api/matter/pairing_code` returns the codes a +third-party controller needs to add 42W to their own fabric. + ## Hot-reload matrix | Field | Hot? | Notes | @@ -229,6 +263,7 @@ Keys must match `drivers[].name`. Leave blank to use BMS defaults. | `price.*` | ✅ | Picked up next price-fetch cycle | | `weather.*` | ✅ | Picked up next weather-fetch cycle | | `batteries.*` | ✅ | Read fresh each control cycle | +| `drivers[].matter_bridge` | ✅ | Picked up by `matterBridgeLoop`'s next 5-min push | ## Atomic writes diff --git a/drivers/matter.lua b/drivers/matter.lua new file mode 100644 index 00000000..ef607cbc --- /dev/null +++ b/drivers/matter.lua @@ -0,0 +1,334 @@ +-- matter.lua +-- Generic Matter device driver via the 42W Matter controller sidecar. +-- Works with any Matter-certified device: thermostats, smart plugs, +-- energy meters, on/off switches, etc. All behaviour is config-driven. +-- +-- The driver reads a list of cluster attributes each poll and emits them +-- as scalar metrics (host.emit_metric) or, optionally, as structured +-- telemetry (host.emit "meter"|"pv"|"battery") for DER-type devices. +-- Commands from the dispatch layer are mapped to attribute writes or +-- cluster command invocations via the `commands` config block. +-- +-- Device onboarding (multi-fabric — 42W does NOT commission): +-- 1. Commission the device with whatever controller it shipped with +-- (Apple Home, Google Home, Home Assistant, SmartThings, ...). +-- 2. In that controller, use "share device" / "add to another network" +-- / "pair to another Matter controller" to mint a one-time pairing +-- code (a Manual Pairing Code or QR setup code). +-- 3. Hand that code to 42W: POST /api/matter/commission with +-- {"pairing_code": ""} (requires top-level `matter:` config — +-- see docs/configuration.md). The sidecar (matter-sidecar/, built on +-- matter.js) joins the device as an additional fabric admin over +-- plain IP (no BLE on our side, so the whole Thread-vs-Wi-Fi +-- transport question is moot for us) and the response carries a +-- small integer node_id. GET /api/matter/nodes lists nodes already +-- joined if you need to look one up again. +-- 4. Put that node_id in this driver's config below. +-- +-- Prerequisites: the Matter sidecar (see docker-compose.yml's +-- matter-sidecar service) must be running and reachable. +-- +-- Config example — Wiser thermostat (Schneider Electric): +-- +-- drivers: +-- - name: living_room +-- lua: drivers/matter.lua +-- capabilities: +-- matter: +-- host: localhost # Matter sidecar address +-- config: +-- node_id: 1234 # assigned by the sidecar after the share-code join +-- make: "Schneider Electric" +-- model: "Wiser" +-- poll_interval_ms: 30000 +-- reads: +-- - name: indoor_temp_c +-- endpoint: 1 +-- cluster: 0x0201 # Thermostat +-- attribute: 0x0000 # LocalTemperature +-- scale: 0.01 # raw value is °C × 100 +-- - name: heating_setpoint_c +-- endpoint: 1 +-- cluster: 0x0201 +-- attribute: 0x0012 # OccupiedHeatingSetpoint +-- scale: 0.01 +-- commands: +-- setpoint: # driver_command action name +-- endpoint: 1 +-- cluster: 0x0201 +-- attribute: 0x0012 +-- scale: 100 # cmd.value (°C) × 100 before writing +-- +-- Config example — smart plug with power metering: +-- +-- config: +-- node_id: 5678 +-- reads: +-- - name: power_w +-- endpoint: 1 +-- cluster: 0x0B04 # Electrical Measurement +-- attribute: 0x050B # ActivePower +-- emit_as: meter # also calls host.emit("meter", {w = value}) +-- commands: +-- on_off: +-- endpoint: 1 +-- cluster: 0x0006 # On/Off +-- invoke: "Toggle" # cluster command name (instead of attribute write) +-- +-- Config example — standalone temperature sensor (Matter Temperature +-- Measurement cluster). Pair a cheap Matter room sensor and feed its reading +-- to a thermostat zone via flexloads `indoor_driver` so the thermal model +-- uses true room temperature, not the thermostat's mounting-biased probe: +-- +-- drivers: +-- - name: bedroom_temp +-- lua: drivers/matter.lua +-- capabilities: +-- matter: { host: localhost } +-- config: +-- node_id: 9012 +-- reads: +-- - name: indoor_temp_c +-- endpoint: 1 +-- cluster: 0x0402 # Temperature Measurement +-- attribute: 0x0000 # MeasuredValue +-- scale: 0.01 # raw is °C × 100 +-- flexloads: +-- - type: thermostat +-- driver_name: living_room # the thermostat we command +-- indoor_driver: bedroom_temp # but read temperature from the sensor +-- indoor_metric: indoor_temp_c +-- ... +-- +-- Cluster IDs may be written as hex (0x0201) or decimal (513) — YAML parses +-- both as integers, so the driver receives them as numbers either way. +-- +-- Protocol: Matter (via the 42W Matter controller sidecar) + +DRIVER = { + id = "matter-generic", + name = "Matter (generic)", + version = "1.0.0", + protocols = { "matter" }, + description = "Generic Matter device driver. Config-driven attribute reads and commands. Works with any Matter-certified device shared to 42W multi-fabric.", + authors = { "forty-two-watts contributors" }, + verification_status = "experimental", +} + +local node_id = nil +local reads = {} -- list of {name, endpoint, cluster, attribute, scale, emit_as} +local cmds = {} -- map of action → {endpoint, cluster, attribute?, invoke?, scale?} +local err_count = 0 +local MAX_CONSECUTIVE_ERRORS = 5 + +local function parse_number(v) + -- config values arrive as Lua numbers already (YAML hex + decimal both + -- parse to integers in Go, then arrive here as LNumber). + return tonumber(v) +end + +function driver_init(cfg) + cfg = cfg or {} + + node_id = parse_number(cfg.node_id) + if not node_id then + host.log("error", "matter: node_id is required in config") + return + end + + -- Anchor device identity on the fabric-unique node_id, not the model name + -- — two identical Wiser thermostats share make+model but have distinct + -- node_ids. Always set make (falling back to "matter" when the operator + -- didn't configure one) so ResolveDeviceID's make+serial path actually + -- fires: every matter.lua driver on a sidecar shares the same `endpoint` + -- (matter://host:port, set by the registry from capabilities.matter, not + -- per-node), so without a make the fallback to "ep:"+endpoint would + -- collide across every device behind that sidecar, overwriting each + -- other's learned/calibration state. + host.set_make(cfg.make and tostring(cfg.make) or "matter") + host.set_sn(tostring(node_id)) + + local interval = parse_number(cfg.poll_interval_ms or 30000) + host.set_poll_interval(interval) + + -- Build reads list. + reads = {} + local raw_reads = cfg.reads or {} + for i = 1, #raw_reads do + local r = raw_reads[i] + local ep = parse_number(r.endpoint) + local cl = parse_number(r.cluster) + local att = parse_number(r.attribute) + if ep and cl and att then + reads[#reads + 1] = { + name = tostring(r.name or (cl .. "/" .. att)), + endpoint = ep, + cluster = cl, + attribute = att, + scale = parse_number(r.scale or 1), + emit_as = r.emit_as, -- nil | "meter" | "pv" | "battery" + } + else + host.log("warn", "matter: skipping read with missing endpoint/cluster/attribute") + end + end + + -- Build commands map. + cmds = {} + local raw_cmds = cfg.commands or {} + for action, spec in pairs(raw_cmds) do + cmds[tostring(action)] = { + endpoint = parse_number(spec.endpoint), + cluster = parse_number(spec.cluster), + attribute = parse_number(spec.attribute), -- for write_attribute mode + invoke = spec.invoke and tostring(spec.invoke) or nil, -- for invoke mode + scale = parse_number(spec.scale or 1), + } + end + + host.log("info", string.format( + "matter: init node_id=%d reads=%d commands=%d", + node_id, #reads, (function() local n=0; for _ in pairs(cmds) do n=n+1 end; return n end)() + )) +end + +function driver_poll() + if not node_id then return end + + local had_error = false + + for _, r in ipairs(reads) do + local val, err = host.matter_read(node_id, r.endpoint, r.cluster, r.attribute) + if err then + host.log("warn", string.format( + "matter: read %s (ep=%d cl=0x%04X att=0x%04X): %s", + r.name, r.endpoint, r.cluster, r.attribute, tostring(err) + )) + had_error = true + else + local v = tonumber(val) + if v == nil then + host.log("warn", "matter: " .. r.name .. ": non-numeric value: " .. tostring(val)) + had_error = true + else + local scaled = v * r.scale + + -- Always emit as a scalar metric for the TS DB. + host.emit_metric(r.name, scaled) + + -- Optionally also emit structured DER telemetry so the + -- device shows up in the dashboard / MPC as a proper DER. + if r.emit_as == "meter" then + host.emit("meter", { w = scaled }) + elseif r.emit_as == "pv" then + host.emit("pv", { w = scaled }) + elseif r.emit_as == "battery" then + host.emit("battery", { w = scaled }) + end + end + end + end + + if had_error then + err_count = err_count + 1 + if err_count >= MAX_CONSECUTIVE_ERRORS then + host.log("error", string.format( + "matter: %d consecutive poll errors for node %d", + err_count, node_id + )) + end + else + err_count = 0 + end +end + +-- driver_command is called by the dispatch layer with: +-- action — string from cmd.action +-- power_w — number from cmd.power_w (battery dispatch; unused here) +-- tbl — full decoded command table +-- +-- Supported actions: +-- — matches a key in config.commands; writes attribute or +-- invokes cluster command. Value comes from tbl.value. +-- write_attribute — raw attribute write: tbl.{endpoint, cluster, attribute, value, scale?} +-- invoke — raw cluster command: tbl.{endpoint, cluster, command, payload?} +function driver_command(action, _power_w, tbl) + if not node_id then return end + tbl = tbl or {} + + -- Named command defined in config. + local spec = cmds[action] + if spec then + if spec.invoke then + -- Invoke a cluster command (e.g. On/Off On/Off, Thermostat + -- SetpointRaiseLower). No value required — these are stateless + -- commands. tbl.payload (optional) carries cluster command args. + local payload = host.json_encode(tbl.payload or {}) + local _, err = host.matter_invoke(node_id, spec.endpoint, spec.cluster, spec.invoke, payload) + if err then + host.log("warn", "matter: invoke '" .. spec.invoke .. "': " .. tostring(err)) + end + else + -- Write an attribute (e.g. OccupiedHeatingSetpoint) — needs a value. + local value = tonumber(tbl.value) + if value == nil then + host.log("warn", "matter: command '" .. action .. "': missing tbl.value for attribute write") + return + end + local raw = math.floor(value * spec.scale + 0.5) + local err = host.matter_write(node_id, spec.endpoint, spec.cluster, spec.attribute, raw) + if err then + host.log("warn", string.format( + "matter: write_attribute for '%s': %s", action, tostring(err) + )) + end + end + return + end + + -- Raw write_attribute (for programmatic use by e.g. the MPC). + if action == "write_attribute" then + local ep = tonumber(tbl.endpoint) + local cl = tonumber(tbl.cluster) + local att = tonumber(tbl.attribute) + local val = tonumber(tbl.value) + local sc = tonumber(tbl.scale or 1) + if not (ep and cl and att and val) then + host.log("warn", "matter: write_attribute: missing endpoint/cluster/attribute/value") + return + end + local err = host.matter_write(node_id, ep, cl, att, math.floor(val * sc + 0.5)) + if err then + host.log("warn", "matter: write_attribute: " .. tostring(err)) + end + return + end + + -- Raw invoke (for programmatic use). + if action == "invoke" then + local ep = tonumber(tbl.endpoint) + local cl = tonumber(tbl.cluster) + local cmd = tbl.command and tostring(tbl.command) + if not (ep and cl and cmd) then + host.log("warn", "matter: invoke: missing endpoint/cluster/command") + return + end + local payload = host.json_encode(tbl.payload or {}) + local _, err = host.matter_invoke(node_id, ep, cl, cmd, payload) + if err then + host.log("warn", "matter: invoke '" .. cmd .. "': " .. tostring(err)) + end + return + end + + host.log("warn", "matter: unknown action '" .. tostring(action) .. "'") +end + +function driver_default_mode() + -- No autonomous fallback for generic Matter devices. + -- Thermostats revert to their own internal schedule when 42W stops + -- sending commands — that's the correct safe state. +end + +function driver_cleanup() +end diff --git a/drivers/myuplink.lua b/drivers/myuplink.lua new file mode 100644 index 00000000..555fe133 --- /dev/null +++ b/drivers/myuplink.lua @@ -0,0 +1,390 @@ +-- MyUplink Heat Pump Driver +-- Emits: battery (thermal storage faked as a battery so the MPC can block +-- consumption at expensive prices) +-- Protocol: HTTPS (MyUplink Cloud REST API v2) +-- +-- DESIGN — "thermal battery" trick +-- ================================= +-- The MPC only dispatches to drivers it believes hold battery capacity. +-- A heat pump has no battery_capacity_wh, so the MPC ignores it. +-- +-- Trick: pretend each thermal store (hot water tank + heating buffer) IS +-- a battery. The MPC is then free to "discharge" it — which the driver +-- translates to "block the pump / lower the setpoint" so the compressor +-- doesn't run during expensive hours. +-- +-- Key constraints that make this safe: +-- +-- max_charge_w = 0 → MPC never asks the pump to run EXTRA to "charge" +-- the thermal store. It only runs on its own schedule. +-- +-- max_discharge_w = X → MPC can "discharge" up to X W. +-- Driver maps discharge to: lower setpoint or block. +-- +-- SoC = 1.0 always → MPC always believes there is stored heat to shed, +-- so it never refuses to discharge (i.e. block). +-- Stays 1.0 because real thermal state is opaque — +-- we don't know how many kWh remain in the tank. +-- +-- In driver_command: +-- action="battery", power_w < 0 → MPC wants to discharge → block pump +-- action="battery", power_w = 0 → MPC releases block → pump runs freely +-- action="init" / "deinit" → release any block +-- +-- Two instances in config.yaml — one for hot water, one for heating: +-- +-- drivers: +-- - name: myuplink-hw # hot water tank +-- lua: drivers/myuplink.lua +-- battery_capacity_wh: 5000 # ~thermal capacity of a 200 L tank +-- config: +-- client_id: "..." +-- client_secret: "..." +-- mode: "hotwater" +-- capabilities: +-- http: +-- allowed_hosts: ["api.myuplink.com"] +-- +-- - name: myuplink-heat # space heating buffer +-- lua: drivers/myuplink.lua +-- battery_capacity_wh: 10000 +-- config: +-- client_id: "..." +-- client_secret: "..." +-- mode: "heating" +-- capabilities: +-- http: +-- allowed_hosts: ["api.myuplink.com"] +-- +-- IMPORTANT: set max_charge_w: 0 per driver in config.yaml so the MPC +-- never tries to pre-heat on cheap electricity (not implemented here). +-- For NIBE: find your exact parameter IDs via +-- GET https://api.myuplink.com/v2/devices/{deviceId}/points + +DRIVER = { + id = "myuplink", + name = "MyUplink Heat Pump", + manufacturer = "MyUplink (NIBE, Bosch, Atlantic, Daikin, ...)", + version = "2.0.0", + protocols = { "http" }, + capabilities = { "battery", "apicreds" }, + description = "Heat pump via MyUplink Cloud REST API v2. Fakes thermal stores as batteries so the MPC can block consumption during expensive price hours. Never charges — only blocks.", + homepage = "https://dev.myuplink.com", + http_hosts = { "api.myuplink.com" }, + authors = { "forty-two-watts contributors" }, + tested_models = { "NIBE F1145", "NIBE S1255", "NIBE F730" }, + verification_status = "experimental", +} + +PROTOCOL = "http" + +local BASE_URL = "https://api.myuplink.com" + +local access_token = nil +local token_expires_at = 0 + +local client_id = nil +local client_secret = nil +local device_id = nil +local mode = "hotwater" -- "hotwater" | "heating" + +local block_active = false + +-- Parameter IDs (NIBE defaults, overridable via config) +local PARAM_POWER = "10012" -- compressor power (W) +local PARAM_HW_TEMP = "40013" -- BT6 hot water top temp (read) +local PARAM_HW_STOP = "47044" -- HW stop comfort temperature (write) +local PARAM_INDOOR_TEMP = "40033" -- BT50 room temperature (read) +local PARAM_HEAT_OFFSET = "47398" -- heating curve offset °C (write) +local PARAM_OUTDOOR_TEMP = "40004" -- BT1 outdoor temperature (read) + +-- How aggressively to block each mode +local HW_BLOCK_DROP_C = 10 -- lower HW stop temp by this many °C to block +local HEAT_BLOCK_DROP_C = -5 -- add this to heating curve offset to block + +-- Saved original values so we can restore exactly +local original_hw_stop = nil +local original_heat_offset = nil + +-- ---- Auth ---------------------------------------------------------------- + +local function fetch_token() + local body = "grant_type=client_credentials" + .. "&client_id=" .. client_id + .. "&client_secret=" .. client_secret + .. "&scope=READSYSTEM%20WRITESYSTEM" + local resp, err = host.http_post( + BASE_URL .. "/oauth/token", body, + { ["Content-Type"] = "application/x-www-form-urlencoded" }) + if err then + host.log("error", "MyUplink: token request failed: " .. tostring(err)) + return false + end + local data = host.json_decode(resp) + if not data or not data.access_token then + host.log("error", "MyUplink: no access_token in response") + return false + end + access_token = data.access_token + local expires_in = tonumber(data.expires_in) or 3600 + token_expires_at = host.millis() + (expires_in * 1000) - 60000 + return true +end + +local function ensure_auth() + if access_token and host.millis() < token_expires_at then return true end + return fetch_token() +end + +local function auth_headers() + return { Authorization = "Bearer " .. (access_token or "") } +end + +-- ---- API helpers --------------------------------------------------------- + +local function api_get(path) + local resp, err = host.http_get(BASE_URL .. path, auth_headers()) + if err then return nil, tostring(err) end + local data, derr = host.json_decode(resp) + if not data then return nil, tostring(derr) end + return data, nil +end + +local function detect_device_id() + local systems, err = api_get("/v2/systems/me") + if err then + host.log("error", "MyUplink: /v2/systems/me failed: " .. err) + return nil + end + for _, system in ipairs(systems.objects or {}) do + local devices = system.devices or {} + if #devices > 0 then + local did = devices[1].id + host.log("info", "MyUplink: auto-detected device " .. tostring(did)) + return did + end + end + host.log("error", "MyUplink: no devices found") + return nil +end + +local function fetch_points(param_ids) + local qs = table.concat(param_ids, ",") + local data, err = api_get("/v2/devices/" .. device_id .. "/points?parameters=" .. qs) + if err then return nil, err end + local pts = {} + for _, pt in ipairs(data) do + if pt.parameterId then pts[tostring(pt.parameterId)] = pt end + end + return pts, nil +end + +local function write_point(param_id, value) + if not ensure_auth() then return false end + local body = host.json_encode({ { parameterId = param_id, value = tostring(value) } }) + local _, err = host.http_patch( + BASE_URL .. "/v2/devices/" .. device_id .. "/points", + body, auth_headers()) + if err then + host.log("warn", "MyUplink: write " .. param_id .. "=" .. tostring(value) + .. " failed: " .. tostring(err)) + return false + end + return true +end + +local function decode_temp(pt) + if not pt then return nil end + local raw = tonumber(pt.value) + if not raw then return nil end + if math.abs(raw) > 100 then return raw / 10 end -- NIBE °C×10 encoding + return raw +end + +-- ---- Block / release ----------------------------------------------------- + +local function block_hotwater() + if original_hw_stop == nil then + local pts, err = fetch_points({ PARAM_HW_STOP }) + if err or not pts[PARAM_HW_STOP] then + host.log("warn", "MyUplink: could not read HW stop temp before blocking") + return false + end + original_hw_stop = tonumber(pts[PARAM_HW_STOP].value) + end + local blocked = (original_hw_stop or 55) - HW_BLOCK_DROP_C + host.log("info", "MyUplink: blocking HW — stop temp " + .. tostring(original_hw_stop) .. "→" .. tostring(blocked) .. "°C") + return write_point(PARAM_HW_STOP, blocked) +end + +local function release_hotwater() + if original_hw_stop == nil then return true end + host.log("info", "MyUplink: releasing HW block — restoring stop temp to " + .. tostring(original_hw_stop) .. "°C") + local ok = write_point(PARAM_HW_STOP, original_hw_stop) + if ok then original_hw_stop = nil end + return ok +end + +local function block_heating() + if original_heat_offset == nil then + local pts, err = fetch_points({ PARAM_HEAT_OFFSET }) + if err or not pts[PARAM_HEAT_OFFSET] then + host.log("warn", "MyUplink: could not read heat offset before blocking") + return false + end + original_heat_offset = tonumber(pts[PARAM_HEAT_OFFSET].value) or 0 + end + local blocked = original_heat_offset + HEAT_BLOCK_DROP_C + host.log("info", "MyUplink: blocking heating — curve offset " + .. tostring(original_heat_offset) .. "→" .. tostring(blocked) .. "°C") + return write_point(PARAM_HEAT_OFFSET, blocked) +end + +local function release_heating() + if original_heat_offset == nil then return true end + host.log("info", "MyUplink: releasing heating block — restoring offset to " + .. tostring(original_heat_offset) .. "°C") + local ok = write_point(PARAM_HEAT_OFFSET, original_heat_offset) + if ok then original_heat_offset = nil end + return ok +end + +local function apply_block() + if mode == "hotwater" then return block_hotwater() else return block_heating() end +end + +local function release_block_fn() + if mode == "hotwater" then return release_hotwater() else return release_heating() end +end + +-- ---- Lifecycle ----------------------------------------------------------- + +function driver_init(config) + host.set_make("MyUplink") + + client_id = config and config.client_id + client_secret = config and config.client_secret + device_id = config and config.device_id + if client_id == "" then client_id = nil end + if client_secret == "" then client_secret = nil end + if device_id == "" then device_id = nil end + if config and config.mode and config.mode ~= "" then mode = config.mode end + + if config then + local function ov(k, d) return (config[k] and config[k] ~= "") and config[k] or d end + PARAM_POWER = ov("param_power_id", PARAM_POWER) + PARAM_HW_TEMP = ov("param_hw_temp_id", PARAM_HW_TEMP) + PARAM_HW_STOP = ov("param_hw_stop_id", PARAM_HW_STOP) + PARAM_INDOOR_TEMP = ov("param_indoor_temp_id", PARAM_INDOOR_TEMP) + PARAM_HEAT_OFFSET = ov("param_heat_offset_id", PARAM_HEAT_OFFSET) + PARAM_OUTDOOR_TEMP = ov("param_outdoor_temp_id", PARAM_OUTDOOR_TEMP) + if tonumber(config.hw_block_drop_c) then HW_BLOCK_DROP_C = tonumber(config.hw_block_drop_c) end + if tonumber(config.heat_block_drop_c) then HEAT_BLOCK_DROP_C = tonumber(config.heat_block_drop_c) end + end + + if not client_id or not client_secret then + host.log("error", "MyUplink: client_id and client_secret required") + return + end + if not ensure_auth() then + host.log("error", "MyUplink: initial auth failed") + return + end + if not device_id then + device_id = detect_device_id() + if not device_id then return end + end + + host.set_sn(device_id .. "-" .. mode) + host.log("info", "MyUplink: ready — mode=" .. mode .. " device=" .. device_id) +end + +function driver_poll() + if not device_id or not client_id then return 30000 end + if not ensure_auth() then return 30000 end + + local pts, err = fetch_points({ PARAM_POWER, PARAM_HW_TEMP, PARAM_INDOOR_TEMP, PARAM_OUTDOOR_TEMP }) + if err then + host.log("warn", "MyUplink: poll failed: " .. err) + return 30000 + end + + local power_w = 0 + if pts[PARAM_POWER] then + local raw = tonumber(pts[PARAM_POWER].value) or 0 + power_w = (pts[PARAM_POWER].unit == "kW") and raw * 1000 or raw + end + + -- Emit as battery: + -- w = compressor power right now (positive = consuming = "charging") + -- soc = always 1.0 so MPC always considers discharge (blocking) available + -- + -- The MPC will never send charge commands because max_charge_w = 0 + -- in config.yaml. It will only send discharge (block) or idle (release). + host.emit("battery", { + w = power_w, + soc = 1.0, + }) + + host.emit_metric("hp_" .. mode .. "_power_w", power_w) + host.emit_metric("hp_" .. mode .. "_blocked", block_active and 1 or 0) + if pts[PARAM_HW_TEMP] then host.emit_metric("hp_hw_top_temp_c", decode_temp(pts[PARAM_HW_TEMP]) or 0) end + if pts[PARAM_INDOOR_TEMP] then host.emit_metric("hp_indoor_temp_c", decode_temp(pts[PARAM_INDOOR_TEMP]) or 0) end + if pts[PARAM_OUTDOOR_TEMP]then host.emit_metric("hp_outdoor_temp_c", decode_temp(pts[PARAM_OUTDOOR_TEMP])or 0) end + + host.log("debug", string.format("MyUplink [%s]: %.0f W blocked=%s", + mode, power_w, tostring(block_active))) + + return 60000 +end + +function driver_command(action, power_w, _cmd) + if not device_id or not ensure_auth() then return false end + + if action == "init" or action == "deinit" then + if block_active then + local ok = release_block_fn() + if ok then block_active = false end + end + return true + end + + if action == "battery" then + -- power_w < 0 = MPC wants to "discharge" = block the pump + -- power_w = 0 = MPC releases = let pump run on its own schedule + -- power_w > 0 = MPC wants to "charge" = should never happen + -- (max_charge_w=0 in config), but we guard it anyway + local want_block = (power_w or 0) < 0 + + if want_block and not block_active then + block_active = apply_block() + return block_active + elseif not want_block and block_active then + local ok = release_block_fn() + if ok then block_active = false end + return ok + end + return true -- already in the right state + end + + return false +end + +function driver_default_mode() + -- Watchdog: EMS offline — always release block. + -- Never leave a building without heat because the EMS crashed. + if block_active and device_id and client_id then + if ensure_auth() then + release_block_fn() + block_active = false + end + end +end + +function driver_cleanup() + driver_default_mode() + access_token = nil + token_expires_at = 0 +end diff --git a/go/cmd/forty-two-watts/main.go b/go/cmd/forty-two-watts/main.go index 16d1ec76..add1de79 100644 --- a/go/cmd/forty-two-watts/main.go +++ b/go/cmd/forty-two-watts/main.go @@ -10,6 +10,7 @@ import ( "flag" "fmt" "log/slog" + "math" "net/http" "net/url" "os" @@ -32,12 +33,14 @@ import ( "github.com/frahlg/forty-two-watts/go/internal/arp" "github.com/frahlg/forty-two-watts/go/internal/devtools" "github.com/frahlg/forty-two-watts/go/internal/drivers" + "github.com/frahlg/forty-two-watts/go/internal/flexload" "github.com/frahlg/forty-two-watts/go/internal/forecast" "github.com/frahlg/forty-two-watts/go/internal/ha" "github.com/frahlg/forty-two-watts/go/internal/loadmodel" "github.com/frahlg/forty-two-watts/go/internal/loadpoint" mqttcli "github.com/frahlg/forty-two-watts/go/internal/mqtt" modbuscli "github.com/frahlg/forty-two-watts/go/internal/modbus" + mattercli "github.com/frahlg/forty-two-watts/go/internal/matter" "github.com/frahlg/forty-two-watts/go/internal/mpc" "github.com/frahlg/forty-two-watts/go/internal/nova" "github.com/frahlg/forty-two-watts/go/internal/ocpp" @@ -235,6 +238,9 @@ func main() { reg.ModbusFactory = func(name string, c *config.ModbusConfig) (drivers.ModbusCap, error) { return modbuscli.Dial(c.Host, c.Port, c.UnitID) } + reg.MatterFactory = func(name string, c *config.MatterConfig) (drivers.MatterCap, error) { + return mattercli.Dial(c.Host, c.Port) + } reg.ARPLookup = arp.Lookup // Spawn initial drivers. config.Load has already joined relative Lua // paths with the config directory — nothing to resolve here. @@ -759,6 +765,120 @@ func main() { }() } + // ---- Flexible-load scheduler (thermostats + deferrable loads) ---- + // Optimizes Matter thermostats and smart-plug loads against the MPC + // price/PV curve, independently of the battery DP (avoids the DP's + // state-space blow-up). Only starts when the operator declares + // `flexloads:` in config. See go/internal/flexload + drivers/matter.lua. + if len(cfg.FlexLoads) > 0 { + devices := make([]flexload.Device, 0, len(cfg.FlexLoads)) + for _, f := range cfg.FlexLoads { + cop := f.COP + if cop <= 0 { + if f.HeatingKind == "hydronic" { + cop = 3.0 // sensible heat-pump default + } else { + cop = 1.0 // direct electric + } + } + devices = append(devices, flexload.Device{ + Type: f.Type, + DriverName: f.DriverName, + Mode: f.Mode, + HeatingKind: f.HeatingKind, + COP: cop, + FlowDriver: f.FlowDriver, + FlowMetric: f.FlowMetric, + NominalFlowDeltaC: f.NominalFlowDeltaC, + MinC: f.MinC, + MaxC: f.MaxC, + MaxHeatW: f.MaxHeatW, + IndoorDriver: f.IndoorDriver, + IndoorMetric: f.IndoorMetric, + HeatMetric: f.HeatMetric, + SlabDriver: f.SlabDriver, + SlabMetric: f.SlabMetric, + SetpointAction: f.SetpointAction, + PreHeatFraction: f.PreHeatFraction, + TargetC: f.TargetC, + PriceThresholdOre: f.PriceThresholdOre, + BlockHorizonH: f.BlockHorizonH, + PowerMetric: f.PowerMetric, + EnergyWh: f.EnergyWh, + PowerW: f.PowerW, + OnAction: f.OnAction, + OffAction: f.OffAction, + PreferPV: f.PreferPV, + EarliestHour: f.EarliestHour, + DeadlineHour: f.DeadlineHour, + }) + } + flexSvc := flexload.NewService(st, tel, devices) + if flexSvc != nil { + // Slots come from the live MPC plan's price/PV curve, so the + // scheduler shares the exact forecast the battery DP planned on. + flexSvc.Slots = func() []flexload.PriceSlot { + if mpcSvc == nil { + return nil + } + plan := mpcSvc.Latest() + if plan == nil || len(plan.Actions) == 0 { + return nil + } + out := make([]flexload.PriceSlot, 0, len(plan.Actions)) + for _, a := range plan.Actions { + // PV surplus = generation beyond house load (site sign: + // PVW ≤ 0 generating, LoadW ≥ 0 consuming). + surplus := -a.PVW - a.LoadW + if surplus < 0 { + surplus = 0 + } + out = append(out, flexload.PriceSlot{ + StartMs: a.SlotStartMs, + LenMin: a.SlotLenMin, + PriceOre: a.PriceOre, + PVSurplusW: surplus, + }) + } + return out + } + // Outdoor temperature forecast — same forecast cache the load + // twin reads. + flexSvc.Outdoor = func(slotStartMs int64) float64 { + rows, err := st.LoadForecasts(slotStartMs-2*3600*1000, slotStartMs+2*3600*1000) + if err != nil || len(rows) == 0 { + return 0 + } + for _, r := range rows { + slotLen := r.SlotLenMin + if slotLen <= 0 { + slotLen = 60 + } + end := r.SlotTsMs + int64(slotLen)*60*1000 + if slotStartMs >= r.SlotTsMs && slotStartMs < end && r.TempC != nil { + return *r.TempC + } + } + return 0 + } + flexSvc.Dispatch = reg.Send + // Independent price source (any time) for simple (non-MPC) mode + // AND the reheat-cost side of the economic pause calc, plus the + // fuse headroom simple zones arbitrate under. + if cfg.Price != nil && cfg.Price.Zone != "" { + zone := cfg.Price.Zone + flexSvc.PriceAt = func(t time.Time) (float64, bool) { + p := priceFc.Predict(zone, t) + return p, p > 0 + } + } + flexSvc.FuseBudgetW = cfg.Fuse.MaxPowerW() + flexSvc.Start(ctx) + defer flexSvc.Stop() + slog.Info("flexload scheduler started", "devices", len(devices)) + } + } + // ---- EV loadpoint controller ---- // Phase 1 of the EV-arch refactor (issue #172): the per-tick EV // dispatch that used to live inline in the control loop is now @@ -869,6 +989,27 @@ func main() { // Forward-declare haBridge so Deps can reference it; the bridge // gets wired further down (HA is optional + depends on reg.Names()). var haBridge *ha.Bridge + + // Optional admin connection to the Matter sidecar, separate from any + // per-driver capability dial — see config.Config.Matter's doc comment. + var matterAdmin *mattercli.Capability + if cfg.Matter != nil { + m, err := mattercli.Dial(cfg.Matter.Host, cfg.Matter.Port) + if err != nil { + slog.Warn("matter admin sidecar unreachable at startup — /api/matter/* disabled (restart 42W once the sidecar is up)", "err", err) + } else { + matterAdmin = m + defer matterAdmin.Close() + } + } else { + for _, d := range cfg.Drivers { + if d.MatterBridge { + slog.Warn("driver has matter_bridge: true but matter: is not configured at the config root — it will not be bridged", "driver", d.Name) + break + } + } + } + deps := &api.Deps{ Tel: tel, Ctrl: ctrl, CtrlMu: ctrlMu, State: st, @@ -899,6 +1040,7 @@ func main() { Events: bus, Notifications: notifSvc, SelfUpdate: selfUpdater, + Matter: matterAdmin, Version: Version, } srv := api.New(deps) @@ -1118,6 +1260,18 @@ func main() { // ---- Background: Parquet rolloff (>14d → cold dir) ---- go rolloffLoop(ctx, st, coldDir) + // ---- Background: push price feed to the Matter sidecar (Phase 2 — 42W + // as a Matter server) ---- + if matterAdmin != nil && priceSvc != nil { + go matterPriceFeedLoop(ctx, matterAdmin, priceSvc) + } + + // ---- Background: push bridged DERs to the Matter sidecar (Phase 3 — + // 42W as a Matter bridge) ---- + if matterAdmin != nil { + go matterBridgeLoop(ctx, matterAdmin, tel, cfgMu, cfg) + } + // ---- Control loop ---- controlInterval := time.Duration(cfg.Site.ControlIntervalS) * time.Second // fuseMaxW is recomputed per tick from ctrl.SiteFuse* under ctrlMu — @@ -1341,6 +1495,108 @@ func rolloffLoop(ctx context.Context, st *state.Store, coldDir string) { } } +// matterPriceFeedLoop periodically pushes 42W's current price + forecast +// into the Matter sidecar's CommodityPrice server endpoint (Phase 2 — 42W +// exposed as a Matter energy-management device other controllers can read). +func matterPriceFeedLoop(ctx context.Context, m *mattercli.Capability, priceSvc *prices.Service) { + tick := time.NewTicker(5 * time.Minute) + defer tick.Stop() + pushMatterPriceFeed(m, priceSvc) + for { + select { + case <-ctx.Done(): + return + case <-tick.C: + pushMatterPriceFeed(m, priceSvc) + } + } +} + +func pushMatterPriceFeed(m *mattercli.Capability, priceSvc *prices.Service) { + nowMs := time.Now().UnixMilli() + rows, err := priceSvc.Load(nowMs-1*3600*1000, nowMs+48*3600*1000) + if err != nil { + slog.Warn("matter price feed: load prices failed", "err", err) + return + } + if len(rows) == 0 { + return + } + var current *mattercli.PricePeriod + forecast := make([]mattercli.PricePeriod, 0, len(rows)) + for _, row := range rows { + startMs := row.SlotTsMs + endMs := startMs + int64(row.SlotLenMin)*60*1000 + endS := mattercli.UnixMsToMatterEpochS(endMs) + period := mattercli.PricePeriod{ + PeriodStartS: mattercli.UnixMsToMatterEpochS(startMs), + PeriodEndS: &endS, + PriceMinorUnits: int64(math.Round(row.TotalOreKwh)), + } + forecast = append(forecast, period) + if current == nil && startMs <= nowMs && nowMs < endMs { + c := period + current = &c + } + } + if err := m.SetPriceFeed(current, forecast); err != nil { + slog.Warn("matter price feed: push failed", "err", err) + } +} + +// matterBridgeLoop periodically pushes the live power reading of every +// driver opted into `matter_bridge: true` to the sidecar's Aggregator +// endpoint (Phase 3 — 42W as a Matter bridge for non-Matter DERs). Runs +// on the same 5-minute cadence as the price feed; bridged values aren't +// dispatch-critical so this doesn't need control-loop frequency. +func matterBridgeLoop(ctx context.Context, m *mattercli.Capability, tel *telemetry.Store, cfgMu *sync.RWMutex, cfg *config.Config) { + tick := time.NewTicker(5 * time.Minute) + defer tick.Stop() + pushMatterBridge(m, tel, cfgMu, cfg) + for { + select { + case <-ctx.Done(): + return + case <-tick.C: + pushMatterBridge(m, tel, cfgMu, cfg) + } + } +} + +func pushMatterBridge(m *mattercli.Capability, tel *telemetry.Store, cfgMu *sync.RWMutex, cfg *config.Config) { + cfgMu.RLock() + bridged := make([]string, 0, len(cfg.Drivers)) + for _, d := range cfg.Drivers { + if d.MatterBridge { + bridged = append(bridged, d.Name) + } + } + cfgMu.RUnlock() + // Always call SyncBridge, even with an empty/shrunk list — bridge.ts + // marks devices absent from the call `reachable: false` rather than + // removing them, so a config change that drops matter_bridge from + // every driver (or just one of several) still needs this push to go + // out, or the sidecar keeps reporting stale devices as reachable. + devices := make([]mattercli.BridgedDevice, 0, len(bridged)) + for _, driver := range bridged { + h := tel.DriverHealth(driver) + if h == nil || !h.IsOnline() { + continue + } + for _, r := range tel.ReadingsByDriver(driver) { + devices = append(devices, mattercli.BridgedDevice{ + ID: driver + ":" + r.DerType.String(), + Name: driver + " " + r.DerType.String(), + DeviceType: r.DerType.String(), + PowerMW: int64(math.Round(r.SmoothedW * 1000)), + }) + } + } + if err := m.SyncBridge(devices); err != nil { + slog.Warn("matter bridge: push failed", "err", err) + } +} + func doRolloff(ctx context.Context, st *state.Store, coldDir string) { rows, files, err := st.RolloffToParquet(ctx, coldDir) if err != nil { @@ -1452,20 +1708,32 @@ func driverLimitsFrom(drivers []config.Driver, batteries map[string]config.Batte out := map[string]control.PowerLimits{} for _, d := range drivers { chg, dis := d.MaxChargeW, d.MaxDischargeW + // blockCharge is true when the operator explicitly wrote max_charge_w: 0 + // on a driver that is also registered as a battery (battery_capacity_wh > 0). + // Because MaxChargeW is a float64 with omitempty, an absent field and an + // explicit 0 are indistinguishable at the struct level. We use + // BatteryCapacityWh > 0 as the signal that this driver is intentionally + // participating in dispatch — making MaxChargeW == 0 a deliberate cap. + blockCharge := d.MaxChargeW == 0 && d.BatteryCapacityWh > 0 if b, ok := batteries[d.Name]; ok { if chg == 0 && b.MaxChargeW != nil && *b.MaxChargeW > 0 { chg = *b.MaxChargeW + blockCharge = false // batteries section overrides the zero } if dis == 0 && b.MaxDischargeW != nil && *b.MaxDischargeW > 0 { dis = *b.MaxDischargeW } } - if chg == 0 && dis == 0 { + // Include driver in the map if any limit is set, or if blockCharge is + // active — previously a (0, 0) pair was silently skipped, which meant + // max_charge_w: 0 had no effect when max_discharge_w was also absent. + if chg == 0 && dis == 0 && !blockCharge { continue } out[d.Name] = control.PowerLimits{ MaxChargeW: chg, MaxDischargeW: dis, + BlockCharge: blockCharge, } } return out diff --git a/go/internal/api/api.go b/go/internal/api/api.go index b248c8fd..f0cf8950 100644 --- a/go/internal/api/api.go +++ b/go/internal/api/api.go @@ -31,6 +31,7 @@ import ( "github.com/frahlg/forty-two-watts/go/internal/loadmodel" "github.com/frahlg/forty-two-watts/go/internal/notifications" "github.com/frahlg/forty-two-watts/go/internal/loadpoint" + "github.com/frahlg/forty-two-watts/go/internal/matter" "github.com/frahlg/forty-two-watts/go/internal/mpc" "github.com/frahlg/forty-two-watts/go/internal/prices" "github.com/frahlg/forty-two-watts/go/internal/pvmodel" @@ -121,6 +122,11 @@ type Deps struct { // /api/notifications/* endpoints. Notifications *notifications.Service + // Optional: admin connection to the Matter sidecar (matter-sidecar/) + // for the one-time pairing-code join + node listing — see + // config.Config.Matter's doc comment. Nil disables /api/matter/*. + Matter *matter.Capability + Version string } @@ -225,6 +231,9 @@ func (s *Server) routes() { s.handle("GET /api/version/snapshots", s.handleVersionSnapshots) s.handle("DELETE /api/version/snapshots/{id}", s.handleVersionSnapshotDelete) s.handle("POST /api/version/rollback", s.handleVersionRollback) + s.handle("GET /api/matter/nodes", s.handleMatterNodes) + s.handle("POST /api/matter/commission", s.handleMatterCommission) + s.handle("GET /api/matter/pairing_code", s.handleMatterPairingCode) // ---- Static web UI ---- // Everything not matched above falls through to the static server. diff --git a/go/internal/api/api_matter.go b/go/internal/api/api_matter.go new file mode 100644 index 00000000..7e234a19 --- /dev/null +++ b/go/internal/api/api_matter.go @@ -0,0 +1,77 @@ +package api + +import "net/http" + +// Matter sidecar admin endpoints — the one-time pairing-code join and +// node listing. Per drivers/matter.lua's onboarding flow: a device is +// commissioned by whatever controller it shipped with, then "shared" +// into 42W via that controller's multi-admin flow, which mints a pairing +// code. POST /api/matter/commission joins it and hands back the small +// logical node_id to paste into the driver's config.node_id field. +// +// Per the api/CLAUDE.md split convention, this lives in its own file and +// is registered via routes() in api.go. + +type matterCommissionRequest struct { + PairingCode string `json:"pairing_code"` +} + +type matterCommissionResponse struct { + NodeID int `json:"node_id"` +} + +// handleMatterCommission joins a shared device using a pairing code minted +// by its original controller. +func (s *Server) handleMatterCommission(w http.ResponseWriter, r *http.Request) { + if s.deps.Matter == nil { + writeJSON(w, 503, map[string]string{"error": "matter sidecar not configured"}) + return + } + var req matterCommissionRequest + if err := readJSON(r, &req); err != nil { + writeJSON(w, 400, map[string]string{"error": err.Error()}) + return + } + if req.PairingCode == "" { + writeJSON(w, 400, map[string]string{"error": "pairing_code is required"}) + return + } + nodeID, err := s.deps.Matter.Commission(req.PairingCode) + if err != nil { + writeJSON(w, 502, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, 200, matterCommissionResponse{NodeID: nodeID}) +} + +// handleMatterNodes lists every node the sidecar has joined so far, so an +// operator can confirm a node_id before pasting it into driver config. +func (s *Server) handleMatterNodes(w http.ResponseWriter, r *http.Request) { + if s.deps.Matter == nil { + writeJSON(w, 503, map[string]string{"error": "matter sidecar not configured"}) + return + } + nodes, err := s.deps.Matter.ListNodes() + if err != nil { + writeJSON(w, 502, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, 200, nodes) +} + +// handleMatterPairingCode returns the one-time codes a third-party +// controller (Apple Home, Home Assistant, ...) needs to commission 42W +// itself onto their fabric — Phase 2's inverse of /api/matter/commission, +// where 42W is the device being joined rather than the one joining. +func (s *Server) handleMatterPairingCode(w http.ResponseWriter, r *http.Request) { + if s.deps.Matter == nil { + writeJSON(w, 503, map[string]string{"error": "matter sidecar not configured"}) + return + } + code, err := s.deps.Matter.GetPairingCode() + if err != nil { + writeJSON(w, 502, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, 200, code) +} diff --git a/go/internal/config/config.go b/go/internal/config/config.go index 134b45bc..dd7b8f79 100644 --- a/go/internal/config/config.go +++ b/go/internal/config/config.go @@ -30,8 +30,124 @@ type Config struct { OCPP *OCPP `yaml:"ocpp,omitempty" json:"ocpp,omitempty"` EVCharger *EVCharger `yaml:"ev_charger,omitempty" json:"ev_charger,omitempty"` Loadpoints []Loadpoint `yaml:"loadpoints,omitempty" json:"loadpoints,omitempty"` + FlexLoads []FlexLoad `yaml:"flexloads,omitempty" json:"flexloads,omitempty"` Notifications *Notifications `yaml:"notifications,omitempty" json:"notifications,omitempty"` Nova *Nova `yaml:"nova,omitempty" json:"nova,omitempty"` + + // Matter is the site-wide Matter sidecar address used for *admin* + // actions (POST /api/matter/commission, GET /api/matter/nodes) — the + // one-time pairing-code join and node listing, which aren't tied to + // any single driver. Per-driver Matter access (reads/writes/invokes + // against an already-joined node_id) is configured separately under + // each driver's `capabilities.matter` block; the two typically point + // at the same sidecar. Nil disables the /api/matter/* endpoints. + Matter *MatterConfig `yaml:"matter,omitempty" json:"matter,omitempty"` +} + +// FlexLoad declares a price-responsive flexible load the flex-load +// scheduler optimizes against the MPC price/PV forecast — independently of +// the battery DP. Two types: +// +// - "thermostat": a Matter thermostat (or any driver accepting a +// setpoint-write command). The scheduler pre-heats toward MaxC in cheap +// / PV-surplus hours and coasts toward MinC in expensive ones, with a +// learned RC model guaranteeing the comfort floor. Requires a driver +// metric carrying the measured indoor temperature. +// - "deferrable": an interruptible on/off load on a smart plug (water +// heater, pool pump, dehumidifier). The scheduler runs it in the +// cheapest slots that meet its daily energy budget before the deadline. +// +// DriverName must match a running driver (typically drivers/matter.lua). The +// action names map to that driver's `config.commands` entries. +type FlexLoad struct { + Type string `yaml:"type" json:"type"` // "thermostat" | "deferrable" + DriverName string `yaml:"driver_name" json:"driver_name"` + + // Mode selects the control strategy for a thermostat: + // "planner" (default) — horizon-optimised setpoint schedule against + // the MPC price/PV curve (pre-heat in cheap hours). + // "simple" — standalone block/heat rule needing no MPC: heat to keep + // TargetC, but block heating while the price is above + // PriceThresholdOre when the building's own inertia keeps + // the target for BlockHorizonH hours. Works with a fixed + // threshold and no forecast at all. + Mode string `yaml:"mode,omitempty" json:"mode,omitempty"` + + // IndoorDriver lets the indoor temperature come from a *separate* driver + // — e.g. a dedicated Matter Temperature Measurement sensor (cluster + // 0x0402) rather than the thermostat's own probe, which is often biased + // by its mounting location. Empty = read IndoorMetric off DriverName. + IndoorDriver string `yaml:"indoor_driver,omitempty" json:"indoor_driver,omitempty"` + + // ---- simple-mode fields ---- + TargetC float64 `yaml:"target_c,omitempty" json:"target_c,omitempty"` // comfort target to maintain + PriceThresholdOre float64 `yaml:"price_threshold_ore,omitempty" json:"price_threshold_ore,omitempty"` // "expensive" cutoff; 0 = derive from forecast + BlockHorizonH float64 `yaml:"block_horizon_h,omitempty" json:"block_horizon_h,omitempty"` // target must hold this long to allow a block (default 1h) + + // ---- thermostat fields ---- + // HeatingKind selects the power model: + // "electric" (default) — direct electric radiator or resistive floor + // heating; electricity → heat 1:1 (COP=1). MaxHeatW is the + // electrical nameplate and the load is directly meterable. + // "hydronic" — a thermostatic valve on a water loop fed by a heat + // pump. MaxHeatW is the zone's *thermal* output; the + // electrical draw the EMS pays for is MaxHeatW/COP. The + // per-zone valve isn't itself an electrical load — the + // shiftable power lives at the central heat source, so + // set HeatSourceDriver to attribute it (see notes). + HeatingKind string `yaml:"heating_kind,omitempty" json:"heating_kind,omitempty"` + COP float64 `yaml:"cop,omitempty" json:"cop,omitempty"` // hydronic only; default 3.0 when kind=hydronic, 1.0 electric + // HeatSourceDriver is reserved for a future feature: attributing a + // hydronic zone's electrical load to the central HP/boiler driver. + // It is declared in config but not yet read or wired in the service. + HeatSourceDriver string `yaml:"heat_source_driver,omitempty" json:"heat_source_driver,omitempty"` + + // FlowDriver/FlowMetric read the heat pump's supply (flow) temperature + // (°C), typically from a Nibe/Thermia/etc integration. It refines the + // reheat-cost side of the pause economics: a hot loop means the heat pump + // already produced the heat, so recovering after a pause is nearly free; + // a cold loop means reheating must run the compressor and is costly. + // FlowDriver empty = read FlowMetric off DriverName. Only meaningful for + // hydronic zones. + FlowDriver string `yaml:"flow_driver,omitempty" json:"flow_driver,omitempty"` + FlowMetric string `yaml:"flow_metric,omitempty" json:"flow_metric,omitempty"` + // NominalFlowDeltaC is the design flow-above-room temperature delta at + // which the loop holds a full charge of usable heat (floor heating ≈ 15, + // radiators ≈ 25-30). Used to scale the stored-heat credit. Default 15. + NominalFlowDeltaC float64 `yaml:"nominal_flow_delta_c,omitempty" json:"nominal_flow_delta_c,omitempty"` + MinC float64 `yaml:"min_c,omitempty" json:"min_c,omitempty"` + MaxC float64 `yaml:"max_c,omitempty" json:"max_c,omitempty"` + MaxHeatW float64 `yaml:"max_heat_w,omitempty" json:"max_heat_w,omitempty"` + IndoorMetric string `yaml:"indoor_metric,omitempty" json:"indoor_metric,omitempty"` + HeatMetric string `yaml:"heat_metric,omitempty" json:"heat_metric,omitempty"` // optional: metered heating power for RC training + SetpointAction string `yaml:"setpoint_action,omitempty" json:"setpoint_action,omitempty"` + PreHeatFraction float64 `yaml:"preheat_fraction,omitempty" json:"preheat_fraction,omitempty"` + + // SlabDriver/SlabMetric provide the floor/slab temperature — a floor probe + // (common on electric floor thermostats) or the hydronic flow temperature + // as a proxy. When set, the zone uses a two-mass (slab + room) thermal + // model instead of the single-mass RC fit, which captures how a charged + // slab keeps the room warm for hours after the element switches off — a + // far more accurate coast/forecast for floor heating. SlabDriver empty = + // read SlabMetric off DriverName. + SlabDriver string `yaml:"slab_driver,omitempty" json:"slab_driver,omitempty"` + SlabMetric string `yaml:"slab_metric,omitempty" json:"slab_metric,omitempty"` + + // ---- deferrable fields ---- + // PowerMetric is the driver metric carrying the plug's measured power + // (W) — e.g. a Matter smart plug's ActivePower. When set, the scheduler + // learns the appliance's actual run power and daily energy from it, so + // EnergyWh / PowerW become optional (learned when left 0). This is what + // lets one generic "deferrable" handle a spa, a water heater, or a pump + // without the operator characterising each by hand. + PowerMetric string `yaml:"power_metric,omitempty" json:"power_metric,omitempty"` + EnergyWh float64 `yaml:"energy_wh,omitempty" json:"energy_wh,omitempty"` + PowerW float64 `yaml:"power_w,omitempty" json:"power_w,omitempty"` + OnAction string `yaml:"on_action,omitempty" json:"on_action,omitempty"` + OffAction string `yaml:"off_action,omitempty" json:"off_action,omitempty"` + PreferPV bool `yaml:"prefer_pv,omitempty" json:"prefer_pv,omitempty"` + EarliestHour int `yaml:"earliest_hour,omitempty" json:"earliest_hour,omitempty"` // local hour-of-day window start (0 = none) + DeadlineHour int `yaml:"deadline_hour,omitempty" json:"deadline_hour,omitempty"` // local hour-of-day deadline (0 = none) } // Notifications configures outbound push notifications. Exactly one @@ -268,6 +384,13 @@ type Driver struct { // Disabled skips this driver at startup / reload. Set via the UI when // you want to temporarily take a driver out without editing yaml. Disabled bool `yaml:"disabled,omitempty" json:"disabled,omitempty"` + // MatterBridge opts this driver's live power reading into the Matter + // sidecar's bridge (Phase 3 — see matter-sidecar/src/bridge.ts): + // surfaced as a bridged Matter device so other Matter ecosystems + // (Apple Home, Home Assistant, ...) can see it. Off by default — + // exposing a driver onto another controller's fabric is a deliberate + // per-driver choice, not automatic for every configured driver. + MatterBridge bool `yaml:"matter_bridge,omitempty" json:"matter_bridge,omitempty"` // HasPassword is a JSON-only signal to the UI that Config["password"] // holds a non-empty value on disk. Populated by MaskSecrets after the // real password is blanked out so the operator can still tell apart @@ -291,9 +414,10 @@ type Driver struct { // Capabilities explicitly scope what host resources a driver can access. type Capabilities struct { - MQTT *MQTTConfig `yaml:"mqtt,omitempty" json:"mqtt,omitempty"` - Modbus *ModbusConfig `yaml:"modbus,omitempty" json:"modbus,omitempty"` + MQTT *MQTTConfig `yaml:"mqtt,omitempty" json:"mqtt,omitempty"` + Modbus *ModbusConfig `yaml:"modbus,omitempty" json:"modbus,omitempty"` HTTP *HTTPCapability `yaml:"http,omitempty" json:"http,omitempty"` + Matter *MatterConfig `yaml:"matter,omitempty" json:"matter,omitempty"` } // MQTTConfig grants access to one MQTT broker. @@ -316,6 +440,18 @@ type HTTPCapability struct { AllowedHosts []string `yaml:"allowed_hosts" json:"allowed_hosts"` } +// MatterConfig grants a driver access to the Matter controller sidecar +// (matter-sidecar/, built on matter.js — see go/internal/matter). Host is +// required; Port defaults to 5580. 42W joins shared devices as an +// additional fabric admin rather than commissioning them itself, so there +// is no pairing/BLE config here — the per-device node_id (set in the +// driver's own config block) is what the sidecar's multi-fabric join +// hands back. +type MatterConfig struct { + Host string `yaml:"host" json:"host"` + Port int `yaml:"port,omitempty" json:"port,omitempty"` // default 5580 +} + // EffectiveMQTT returns the driver's MQTT config, preferring capabilities over legacy. func (d Driver) EffectiveMQTT() *MQTTConfig { if d.Capabilities.MQTT != nil { @@ -820,9 +956,15 @@ func (c *Config) Validate() error { if d.Lua == "" { return fmt.Errorf("driver %q: must specify `lua`", d.Name) } - if d.EffectiveMQTT() == nil && d.EffectiveModbus() == nil && d.Capabilities.HTTP == nil { - return fmt.Errorf("driver %q: must have mqtt, modbus, or http capability", d.Name) + if d.EffectiveMQTT() == nil && d.EffectiveModbus() == nil && d.Capabilities.HTTP == nil && d.Capabilities.Matter == nil { + return fmt.Errorf("driver %q: must have mqtt, modbus, http, or matter capability", d.Name) } + if mt := d.Capabilities.Matter; mt != nil && mt.Host == "" { + return fmt.Errorf("driver %q: capabilities.matter.host is required", d.Name) + } + } + if c.Matter != nil && c.Matter.Host == "" { + return errors.New("matter.host is required when matter: is configured") } if len(c.Drivers) > 0 && siteMeters == 0 { return errors.New("at least one driver must be is_site_meter: true") @@ -884,6 +1026,32 @@ func (c *Config) Validate() error { } } } + for i, fl := range c.FlexLoads { + if fl.DriverName == "" { + return fmt.Errorf("flexloads[%d]: driver_name is required", i) + } + switch fl.Type { + case "thermostat", "deferrable": + default: + return fmt.Errorf("flexloads[%d] %q: type must be \"thermostat\" or \"deferrable\"", i, fl.DriverName) + } + if fl.Type == "thermostat" { + if fl.MinC == 0 && fl.MaxC == 0 { + return fmt.Errorf("flexloads[%d] %q: min_c and max_c are required for type \"thermostat\"", i, fl.DriverName) + } + if fl.MinC >= fl.MaxC { + return fmt.Errorf("flexloads[%d] %q: min_c (%.1f) must be < max_c (%.1f)", i, fl.DriverName, fl.MinC, fl.MaxC) + } + if fl.COP < 0 { + return fmt.Errorf("flexloads[%d] %q: cop must be >= 0", i, fl.DriverName) + } + switch fl.Mode { + case "", "planner", "simple": + default: + return fmt.Errorf("flexloads[%d] %q: mode must be \"planner\" or \"simple\"", i, fl.DriverName) + } + } + } if c.Nova != nil && c.Nova.Enabled { if c.Nova.URL == "" { return errors.New("nova.url is required when nova.enabled") diff --git a/go/internal/control/dispatch.go b/go/internal/control/dispatch.go index 28845cbf..62741ec7 100644 --- a/go/internal/control/dispatch.go +++ b/go/internal/control/dispatch.go @@ -88,6 +88,11 @@ const MaxCommandW = 5000 type PowerLimits struct { MaxChargeW float64 MaxDischargeW float64 + // BlockCharge is true when the operator has explicitly set max_charge_w = 0, + // meaning the MPC must never issue a charge command to this driver. + // Distinct from MaxChargeW == 0 with BlockCharge == false, which means + // "no explicit limit set — fall back to MaxCommandW default". + BlockCharge bool } // DispatchTarget is one command to issue to a single battery driver. @@ -329,12 +334,17 @@ type batteryInfo struct { group string // inverter-affinity tag; empty = untagged (#143) maxChargeW float64 // per-driver cap; 0 = use MaxCommandW default (#145) maxDischargeW float64 // per-driver cap; 0 = use MaxCommandW default (#145) + blockCharge bool // true when max_charge_w=0 is set explicitly — never charge } // chargeCap returns the effective per-battery charge ceiling, falling // back to MaxCommandW when the driver didn't set an explicit limit. +// When blockCharge is true the cap is zero — the MPC must not charge. // Kept a method so every clamp point queries the same fallback rule. func (b batteryInfo) chargeCap() float64 { + if b.blockCharge { + return 0 + } if b.maxChargeW > 0 { return b.maxChargeW } @@ -595,6 +605,7 @@ func ComputeDispatch( group: state.InverterGroups[name], maxChargeW: lim.MaxChargeW, maxDischargeW: lim.MaxDischargeW, + blockCharge: lim.BlockCharge, }) } onlineBats := make([]batteryInfo, 0, len(batteries)) diff --git a/go/internal/drivers/host.go b/go/internal/drivers/host.go index dd79e2b6..54a78e8b 100644 --- a/go/internal/drivers/host.go +++ b/go/internal/drivers/host.go @@ -44,6 +44,23 @@ type ModbusCap interface { Close() error } +// MatterCap is the interface for Matter protocol access via a Matter +// controller sidecar (backend TBD — see go/internal/matter). 42W acts as +// an additional fabric admin: devices are commissioned by whatever +// controller they shipped with, then shared to us multi-fabric, so the +// nodeID here is one 42W's controller was granted access to — not one we +// provisioned. This interface is deliberately backend-agnostic. +type MatterCap interface { + // ReadAttribute reads a cluster attribute from a Matter node 42W shares. + ReadAttribute(nodeID, endpoint, clusterID, attributeID uint32) (any, error) + // WriteAttribute writes a value to a cluster attribute. + WriteAttribute(nodeID, endpoint, clusterID, attributeID uint32, value any) error + // InvokeCommand sends a cluster command (e.g. thermostat setpoint raise/lower). + InvokeCommand(nodeID, endpoint, clusterID uint32, commandName string, payload any) (any, error) + // Close disconnects from the Matter sidecar. Called on driver remove. + Close() error +} + // HostEnv is the per-driver runtime context. Captures capabilities (potentially // nil if not granted), the shared telemetry store, and identifying info. type HostEnv struct { @@ -52,6 +69,7 @@ type HostEnv struct { Telemetry *telemetry.Store MQTT MQTTCap // nil → mqtt_* calls return ErrNoCapability Modbus ModbusCap // nil → modbus_* calls return ErrNoCapability + Matter MatterCap // nil → matter_* calls return ErrNoCapability HTTP bool // false → http_* calls return ErrNoCapability // HTTPAllowedHosts, when non-empty, restricts which hosts this // driver can reach via host.http_get / host.http_post. Each entry @@ -104,6 +122,9 @@ func (h *HostEnv) WithMQTT(m MQTTCap) *HostEnv { h.MQTT = m; return h } // WithModbus binds a Modbus capability. func (h *HostEnv) WithModbus(m ModbusCap) *HostEnv { h.Modbus = m; return h } +// WithMatter binds a Matter capability. +func (h *HostEnv) WithMatter(m MatterCap) *HostEnv { h.Matter = m; return h } + // WithHTTP enables the HTTP capability. func (h *HostEnv) WithHTTP() *HostEnv { h.HTTP = true; return h } @@ -287,3 +308,20 @@ func (h *HostEnv) modbusWriteMulti(addr uint16, values []uint16) error { if h.Modbus == nil { return ErrNoCapability } return h.Modbus.WriteMulti(addr, values) } + +// ---- Matter proxy ---- + +func (h *HostEnv) matterRead(nodeID, endpoint, clusterID, attributeID uint32) (any, error) { + if h.Matter == nil { return nil, ErrNoCapability } + return h.Matter.ReadAttribute(nodeID, endpoint, clusterID, attributeID) +} + +func (h *HostEnv) matterWrite(nodeID, endpoint, clusterID, attributeID uint32, value any) error { + if h.Matter == nil { return ErrNoCapability } + return h.Matter.WriteAttribute(nodeID, endpoint, clusterID, attributeID, value) +} + +func (h *HostEnv) matterInvoke(nodeID, endpoint, clusterID uint32, commandName string, payload any) (any, error) { + if h.Matter == nil { return nil, ErrNoCapability } + return h.Matter.InvokeCommand(nodeID, endpoint, clusterID, commandName, payload) +} diff --git a/go/internal/drivers/lua.go b/go/internal/drivers/lua.go index ecc19c13..ca161968 100644 --- a/go/internal/drivers/lua.go +++ b/go/internal/drivers/lua.go @@ -26,8 +26,12 @@ // host.modbus_write_multi(addr, values) // host.json_decode(s) -- convenience JSON → Lua table // host.json_encode(t) -- Lua table → JSON string -// host.http_get(url, headers) -- HTTP GET, returns (body, nil) or (nil, err) -// host.http_post(url, body, headers) -- HTTP POST, returns (body, nil) or (nil, err) +// host.http_get(url, headers) -- HTTP GET, returns (body, nil) or (nil, err) +// host.http_post(url, body, headers) -- HTTP POST, returns (body, nil) or (nil, err) +// host.http_patch(url, body, headers) -- HTTP PATCH, returns (body, nil) or (nil, err) +// host.matter_read(node_id, endpoint, cluster_id, attribute_id) -- returns (value, nil) or (nil, err) +// host.matter_write(node_id, endpoint, cluster_id, attribute_id, value) -- returns nil or err +// host.matter_invoke(node_id, endpoint, cluster_id, command, payload_json) -- returns (result, nil) or (nil, err) // // Lua 5.1 via yuin/gopher-lua — pure Go, zero CGo, one allocation-aware // interpreter per driver. @@ -436,6 +440,7 @@ func registerHost(L *lua.LState, env *HostEnv) { // ---- HTTP capability ---- // host.http_get(url, headers?) → (body, nil) or (nil, error_string) // host.http_post(url, body, headers?) → (body, nil) or (nil, error_string) + // host.http_patch(url, body, headers?) → (body, nil) or (nil, error_string) // headers is an optional Lua table {["Content-Type"]="application/json", ...} httpClient := &net_http.Client{Timeout: 15 * time.Second} @@ -594,6 +599,124 @@ func registerHost(L *lua.LState, env *HostEnv) { return 1 })) + host.RawSetString("http_patch", L.NewFunction(func(L *lua.LState) int { + if !env.HTTP { + L.Push(lua.LNil) + L.Push(lua.LString("http: capability not granted")) + return 2 + } + url := L.CheckString(1) + if ok, reason := hostAllowed(url); !ok { + L.Push(lua.LNil) + L.Push(lua.LString("http: " + reason)) + return 2 + } + payload := L.CheckString(2) + req, err := net_http.NewRequest("PATCH", url, strings.NewReader(payload)) + if err != nil { + L.Push(lua.LNil) + L.Push(lua.LString(err.Error())) + return 2 + } + req.Header.Set("Content-Type", "application/json") + applyHeaders(req, L, 3) + resp, err := httpClient.Do(req) + if err != nil { + L.Push(lua.LNil) + L.Push(lua.LString(err.Error())) + return 2 + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + L.Push(lua.LNil) + L.Push(lua.LString(err.Error())) + return 2 + } + if resp.StatusCode >= 400 { + L.Push(lua.LNil) + L.Push(lua.LString(fmt.Sprintf("HTTP %d: %s", resp.StatusCode, string(body)))) + return 2 + } + L.Push(lua.LString(string(body))) + return 1 + })) + + // ---- Matter capability ---- + // host.matter_read(node_id, endpoint, cluster_id, attribute_id) + // → (value, nil) or (nil, error_string) + // host.matter_write(node_id, endpoint, cluster_id, attribute_id, value) + // → nil or error_string + // host.matter_invoke(node_id, endpoint, cluster_id, command_name, payload_json) + // → (result, nil) or (nil, error_string) + // Cluster and attribute IDs may be passed as decimal or hex integers. + // payload_json is a JSON string (use host.json_encode to build it). + + host.RawSetString("matter_read", L.NewFunction(func(L *lua.LState) int { + if env.Matter == nil { + L.Push(lua.LNil) + L.Push(lua.LString("matter: capability not granted")) + return 2 + } + nodeID := uint32(L.CheckInt(1)) + endpoint := uint32(L.CheckInt(2)) + clusterID := uint32(L.CheckInt(3)) + attributeID := uint32(L.CheckInt(4)) + val, err := env.matterRead(nodeID, endpoint, clusterID, attributeID) + if err != nil { + L.Push(lua.LNil) + L.Push(lua.LString(err.Error())) + return 2 + } + L.Push(goToLua(L, val)) + return 1 + })) + + host.RawSetString("matter_write", L.NewFunction(func(L *lua.LState) int { + if env.Matter == nil { + L.Push(lua.LString("matter: capability not granted")) + return 1 + } + nodeID := uint32(L.CheckInt(1)) + endpoint := uint32(L.CheckInt(2)) + clusterID := uint32(L.CheckInt(3)) + attributeID := uint32(L.CheckInt(4)) + value := luaToGo(L.Get(5)) + if err := env.matterWrite(nodeID, endpoint, clusterID, attributeID, value); err != nil { + L.Push(lua.LString(err.Error())) + return 1 + } + return 0 + })) + + host.RawSetString("matter_invoke", L.NewFunction(func(L *lua.LState) int { + if env.Matter == nil { + L.Push(lua.LNil) + L.Push(lua.LString("matter: capability not granted")) + return 2 + } + nodeID := uint32(L.CheckInt(1)) + endpoint := uint32(L.CheckInt(2)) + clusterID := uint32(L.CheckInt(3)) + commandName := L.CheckString(4) + var payload any + if s := L.OptString(5, ""); s != "" { + if err := json.Unmarshal([]byte(s), &payload); err != nil { + L.Push(lua.LNil) + L.Push(lua.LString("matter_invoke: bad payload json: "+err.Error())) + return 2 + } + } + result, err := env.matterInvoke(nodeID, endpoint, clusterID, commandName, payload) + if err != nil { + L.Push(lua.LNil) + L.Push(lua.LString(err.Error())) + return 2 + } + L.Push(goToLua(L, result)) + return 1 + })) + L.SetGlobal("host", host) } diff --git a/go/internal/drivers/lua_http_test.go b/go/internal/drivers/lua_http_test.go index 66310fdf..169a2d1c 100644 --- a/go/internal/drivers/lua_http_test.go +++ b/go/internal/drivers/lua_http_test.go @@ -2,6 +2,7 @@ package drivers import ( "context" + "io" "net/http" "net/http/httptest" "os" @@ -169,3 +170,180 @@ func TestHTTPTestRigSelfCheck(t *testing.T) { t.Errorf("rig sanity: server saw %d hits, want 1", atomic.LoadInt32(&hits)) } } + +// ---- http_patch tests ---- + +// runPATCHTestDriver spins up a Lua driver that calls host.http_patch and +// records whether the request succeeded. The test server captures the +// method and body so callers can assert on them. +func runPATCHTestDriver(t *testing.T, allowed []string, targetURL, patchBody string) (gotBody bool, errMsg string) { + t.Helper() + tel := telemetry.NewStore() + env := NewHostEnv("patchtest", tel).WithHTTP() + if allowed != nil { + env.WithHTTPAllowedHosts(allowed) + } + src := ` + function driver_init() end + function driver_poll() + local body, err = host.http_patch("` + targetURL + `", [[` + patchBody + `]]) + if err then + host.emit_metric("result_err", 1) + host.log("info", "ERR:" .. tostring(err)) + else + host.emit_metric("result_ok", 1) + host.log("info", "BODY:" .. tostring(body)) + end + return 60000 + end + function driver_command() end + function driver_default_mode() end + function driver_cleanup() end + ` + path := filepath.Join(t.TempDir(), "drv.lua") + if err := os.WriteFile(path, []byte(src), 0644); err != nil { + t.Fatal(err) + } + d, err := NewLuaDriver(path, env) + if err != nil { + t.Fatalf("load driver: %v", err) + } + defer d.Cleanup() + if err := d.Init(context.Background(), nil); err != nil { + t.Fatalf("init: %v", err) + } + if _, err := d.Poll(context.Background()); err != nil { + t.Fatalf("poll: %v", err) + } + if v, _, ok := tel.LatestMetric("patchtest", "result_ok"); ok && v == 1 { + return true, "" + } + if v, _, ok := tel.LatestMetric("patchtest", "result_err"); ok && v == 1 { + return false, "errored" + } + return false, "neither metric set — driver did not run" +} + +// TestLuaHTTPPatchMethod verifies that host.http_patch sends a PATCH request +// with the correct method and body, and that the allowlist is enforced the +// same way as for http_get / http_post. +func TestLuaHTTPPatchMethod(t *testing.T) { + var gotMethod, gotBody string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method + b, _ := io.ReadAll(r.Body) + gotBody = string(b) + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer srv.Close() + srvHost := strings.TrimPrefix(srv.URL, "http://") + + got, errMsg := runPATCHTestDriver(t, nil, srv.URL, `{"value":"42"}`) + if !got { + t.Fatalf("expected success, got error: %s", errMsg) + } + if gotMethod != "PATCH" { + t.Errorf("server saw method %q, want PATCH", gotMethod) + } + if gotBody != `{"value":"42"}` { + t.Errorf("server saw body %q, want {\"value\":\"42\"}", gotBody) + } + _ = srvHost +} + +// TestLuaHTTPPatchContentType verifies that the Content-Type header is set +// to application/json by default, matching http_post behaviour. +func TestLuaHTTPPatchContentType(t *testing.T) { + var gotCT string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotCT = r.Header.Get("Content-Type") + _, _ = w.Write([]byte(`ok`)) + })) + defer srv.Close() + + got, errMsg := runPATCHTestDriver(t, nil, srv.URL, `{}`) + if !got { + t.Fatalf("expected success: %s", errMsg) + } + if gotCT != "application/json" { + t.Errorf("Content-Type = %q, want application/json", gotCT) + } +} + +// TestLuaHTTPPatchAllowlistEnforcement verifies that the same host allowlist +// that gates http_get also blocks http_patch to non-allowlisted targets. +func TestLuaHTTPPatchAllowlistEnforcement(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`ok`)) + })) + defer srv.Close() + srvHost := strings.TrimPrefix(srv.URL, "http://") + + cases := []struct { + name string + allowed []string + wantBody bool + }{ + {"empty allowlist permits all", nil, true}, + {"exact host:port match", []string{srvHost}, true}, + {"wrong port blocked", []string{strings.Split(srvHost, ":")[0] + ":1"}, false}, + {"different host blocked", []string{"10.99.99.99"}, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, errMsg := runPATCHTestDriver(t, tc.allowed, srv.URL, `{}`) + if got != tc.wantBody { + t.Errorf("wantBody=%v got=%v (err=%s)", tc.wantBody, got, errMsg) + } + }) + } +} + +// TestLuaHTTPPatchCapabilityNotGranted verifies that a driver without the +// HTTP capability receives an error rather than a successful PATCH. +func TestLuaHTTPPatchCapabilityNotGranted(t *testing.T) { + tel := telemetry.NewStore() + env := NewHostEnv("nohttppatch", tel) // NO WithHTTP() + src := ` + function driver_init() end + function driver_poll() + local _, err = host.http_patch("http://example.com/", "{}") + if err then host.emit_metric("blocked", 1) end + return 60000 + end + function driver_command() end + function driver_default_mode() end + function driver_cleanup() end + ` + path := filepath.Join(t.TempDir(), "drv.lua") + if err := os.WriteFile(path, []byte(src), 0644); err != nil { + t.Fatal(err) + } + d, err := NewLuaDriver(path, env) + if err != nil { + t.Fatalf("load: %v", err) + } + defer d.Cleanup() + _ = d.Init(context.Background(), nil) + _, _ = d.Poll(context.Background()) + v, _, ok := tel.LatestMetric("nohttppatch", "blocked") + if !ok || v != 1 { + t.Error("driver without HTTP cap should have been blocked by http_patch") + } +} + +// TestLuaHTTPPatch4xxReturnsError verifies that a 4xx response is surfaced +// as an error return (nil, errstring) rather than a successful body — same +// behaviour as http_post and http_get. +func TestLuaHTTPPatch4xxReturnsError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{"error":"bad value"}`)) + })) + defer srv.Close() + + got, _ := runPATCHTestDriver(t, nil, srv.URL, `{}`) + if got { + t.Error("4xx response should be returned as error, not success") + } +} diff --git a/go/internal/drivers/registry.go b/go/internal/drivers/registry.go index 299c0aa7..379aec70 100644 --- a/go/internal/drivers/registry.go +++ b/go/internal/drivers/registry.go @@ -23,6 +23,8 @@ type Registry struct { MQTTFactory func(name string, c *config.MQTTConfig) (MQTTCap, error) // ModbusFactory creates a Modbus capability. ModbusFactory func(name string, c *config.ModbusConfig) (ModbusCap, error) + // MatterFactory creates a Matter capability backed by the Matter sidecar. + MatterFactory func(name string, c *config.MatterConfig) (MatterCap, error) // ARPLookup resolves a hostname/IP to a MAC for L2-stable identity. // Optional — when nil, devices fall back to endpoint-hash IDs. ARPLookup func(host string) (mac string, ok bool) @@ -128,6 +130,16 @@ func (r *Registry) Add(ctx context.Context, cfg config.Driver) error { env.WithHTTPAllowedHosts(hosts) } } + if mt := cfg.Capabilities.Matter; mt != nil && r.MatterFactory != nil { + cap, err := r.MatterFactory(cfg.Name, mt) + if err != nil { + return fmt.Errorf("matter capability: %w", err) + } + env.WithMatter(cap) + p := mt.Port + if p == 0 { p = 5580 } + env.SetEndpoint(fmt.Sprintf("matter://%s:%d", mt.Host, p)) + } luaDrv, err := NewLuaDriver(cfg.Lua, env) if err != nil { @@ -195,6 +207,9 @@ func (r *Registry) runLoop(rd *runningDriver) { if rd.env.Modbus != nil { _ = rd.env.Modbus.Close() } + if rd.env.Matter != nil { + _ = rd.env.Matter.Close() + } return case cmd := <-rd.cmdCh: var err error @@ -413,6 +428,11 @@ func sameDriverConfig(a, b config.Driver) bool { if aMb != nil && (aMb.Host != bMb.Host || aMb.Port != bMb.Port || aMb.UnitID != bMb.UnitID) { return false } + aMt, bMt := a.Capabilities.Matter, b.Capabilities.Matter + if (aMt == nil) != (bMt == nil) { return false } + if aMt != nil && (aMt.Host != bMt.Host || aMt.Port != bMt.Port) { + return false + } // Compare the free-form Config map. Previously omitted, so a changed // cloud-driver password in drivers[i].config.password was silently // ignored by the hot-reload diff — the driver kept running with the diff --git a/go/internal/flexload/economics_test.go b/go/internal/flexload/economics_test.go new file mode 100644 index 00000000..fed2d6c7 --- /dev/null +++ b/go/internal/flexload/economics_test.go @@ -0,0 +1,107 @@ +package flexload + +import ( + "testing" + "time" + + "github.com/frahlg/forty-two-watts/go/internal/thermalmodel" +) + +// TestPauseIsNetWin checks the reheat-vs-saving economics that gate any +// reduction below the comfort target: pausing only pays when the price now +// exceeds the forecast price at the reheat moment. +func TestPauseIsNetWin(t *testing.T) { + m := trainedModel() + const pauseH = 2.0 + + // Case 1: reheat later is CHEAPER than now → pausing is a net win. + svcCheaperLater := &Service{ + PriceAt: func(at time.Time) (float64, bool) { + // now = 300, reheat (2h later) = 100 + if at.After(time.Now().Add(time.Hour)) { + return 100, true + } + return 300, true + }, + } + worth, reason := svcCheaperLater.pauseIsNetWin(m, 21, 0, 1, pauseH, time.Now().UnixMilli(), 0, false, 0) + if !worth { + t.Errorf("expected net-win pause when reheat is cheaper: %s", reason) + } + + // Case 2: reheat later is PRICIER than now → must NOT pause (the exact + // "dumb lowering" the operator warned about — cool now, reheat expensive). + svcPricierLater := &Service{ + PriceAt: func(at time.Time) (float64, bool) { + if at.After(time.Now().Add(time.Hour)) { + return 500, true + } + return 200, true + }, + } + worth, reason = svcPricierLater.pauseIsNetWin(m, 21, 0, 1, pauseH, time.Now().UnixMilli(), 0, false, 0) + if worth { + t.Errorf("must not pause when reheat is pricier than now: %s", reason) + } + + // Case 2b: SAME pricier-later prices, but the heat pump's flow temp is + // high (loop holds a full charge) → reheat is nearly free, so pausing + // becomes a net win after all. This is the operator's "good flow temp → + // reheating costs nothing" case. + worth, reason = svcPricierLater.pauseIsNetWin(m, 21, 0, 1, pauseH, time.Now().UnixMilli(), + 21+15 /*flow 15°C above room = full charge*/, true, 15) + if !worth { + t.Errorf("high flow temp should make the pause a win despite pricier reheat: %s", reason) + } + + // Case 3: no price model → never a speculative reduction. + svcNoPrice := &Service{} + if worth, _ := svcNoPrice.pauseIsNetWin(m, 21, 0, 1, pauseH, time.Now().UnixMilli(), 0, false, 0); worth { + t.Error("no price model must block any reduction") + } +} + +// TestReheatFactor checks the stored-heat credit from flow temperature. +func TestReheatFactor(t *testing.T) { + // No flow signal → assume full reheat cost. + if f := reheatFactor(0, 21, 15, false); f != 1.0 { + t.Errorf("no flow → factor 1, got %.2f", f) + } + // Flow at room temp → no usable stored heat → full cost. + if f := reheatFactor(21, 21, 15, true); f != 1.0 { + t.Errorf("flow==room → factor 1, got %.2f", f) + } + // Flow a full nominal delta above room → free reheat. + if f := reheatFactor(36, 21, 15, true); f != 0.0 { + t.Errorf("full charge → factor 0, got %.2f", f) + } + // Half the nominal delta → half cost. + if f := reheatFactor(21+7.5, 21, 15, true); f < 0.49 || f > 0.51 { + t.Errorf("half charge → factor ~0.5, got %.2f", f) + } +} + +// TestStoveDecisionHoldsComfortWithoutLearning verifies that with a fresh +// (untrained) model and no learned firing cycles, a detected stove only +// suppresses pre-heat and holds the comfort target — never a deep reduction. +func TestStoveDecisionHoldsComfortWithoutLearning(t *testing.T) { + svc := &Service{ + thermal: map[string]*thermalmodel.Model{"living": thermalmodel.NewModel()}, + stove: map[string]*ExternalHeatDetector{"living": {}}, + } + // Force the detector active. + now := time.Now().UnixMilli() + det := svc.stove["living"] + det.active = true + det.sinceMs = now + det.lastDetectMs = now + + d := Device{Type: "thermostat", DriverName: "living", MinC: 18, MaxC: 23, TargetC: 21, Mode: "simple"} + sp, active, _ := svc.stoveDecision(d, 22.0, d.TargetC, 0, now) + if !active { + t.Fatal("stove should be active") + } + if sp != d.TargetC { + t.Errorf("without learning, stove pause must hold comfort target %.1f, got %.1f", d.TargetC, sp) + } +} diff --git a/go/internal/flexload/external_heat.go b/go/internal/flexload/external_heat.go new file mode 100644 index 00000000..55348d9b --- /dev/null +++ b/go/internal/flexload/external_heat.go @@ -0,0 +1,110 @@ +package flexload + +// ExternalHeatDetector spots a heat source the EMS doesn't control and isn't +// paying for — most importantly a wood stove / fireplace (kamin), but also +// strong solar gain or a gas heater. The signature is a fast indoor +// temperature rise that the thermal model can't explain from the electrical +// heating it knows about: observed warming ≫ model-predicted warming while +// the metered heating power is ~off. +// +// Why it matters: when the living room is being heated for free, running +// 42W-controlled electric heat in the same zone is pure waste. On detection +// the scheduler pauses those electric sources (frost-protection setpoint). +// Just as important, the model must NOT train through a firing — the extra +// heat would be misattributed to the building's own dynamics and corrupt the +// learned time constant — so detection also gates training off. +// +// The detector learns each firing's heat amount and the typical per-cycle +// energy, so over time 42W knows roughly how much "free" heat a firing +// delivers and how long to keep electric heat paused. +type ExternalHeatDetector struct { + // Learned, persisted. + EstThermalW float64 `json:"est_thermal_w"` // EMA of inferred external power while active (W) + AvgCycleWh float64 `json:"avg_cycle_wh"` // EMA of energy delivered per firing (Wh) + Cycles int64 `json:"cycles"` // completed firings observed + + // Transient (not meaningfully persisted; re-derived within minutes). + active bool + sinceMs int64 + lastDetectMs int64 + cycleEnergyWh float64 +} + +const ( + // Unexplained warming rate (°C/h) above which we infer an external source. + extHeatTriggerCPerH = 0.4 + // Our own metered heating must be at/below this (W) — otherwise the + // warming is plausibly just our electric heat doing its job. + extHeatMaxMeteredW = 100.0 + // Keep "active" this long after the last positive detection so brief + // dips between detections don't flap the pause on and off (ms). + extHeatHoldMs = 20 * 60 * 1000 + // Ignore firings that delivered less than this (Wh) as noise. + extHeatMinCycleWh = 50.0 +) + +// Update folds one observed transition into the detector. +// +// observedDeltaC — actual indoor temp change over the step +// expectedDeltaC — model-predicted change for the same step + known heat +// meteredHeatW — our own electrical heating over the step (W) +// dtS — step length (s) +// thermalWForRate — converts an unexplained °C/s rate into external W +// (typically model.ThermalWForRate) +func (e *ExternalHeatDetector) Update( + observedDeltaC, expectedDeltaC, meteredHeatW, dtS float64, + nowMs int64, + thermalWForRate func(rateCPerS float64) float64, +) { + if dtS <= 0 { + return + } + unexplainedRate := (observedDeltaC - expectedDeltaC) / dtS // °C/s + detected := unexplainedRate*3600 > extHeatTriggerCPerH && meteredHeatW <= extHeatMaxMeteredW + + if detected { + if w := thermalWForRate(unexplainedRate); w > 0 { + if e.EstThermalW == 0 { + e.EstThermalW = w + } else { + e.EstThermalW = 0.9*e.EstThermalW + 0.1*w + } + e.cycleEnergyWh += w * dtS / 3600.0 + } + if !e.active { + e.active = true + e.sinceMs = nowMs + } + e.lastDetectMs = nowMs + return + } + + // No detection this step — close the cycle once the hold window lapses. + if e.active && nowMs-e.lastDetectMs > extHeatHoldMs { + e.active = false + if e.cycleEnergyWh >= extHeatMinCycleWh { + if e.AvgCycleWh == 0 { + e.AvgCycleWh = e.cycleEnergyWh + } else { + e.AvgCycleWh = 0.7*e.AvgCycleWh + 0.3*e.cycleEnergyWh + } + e.Cycles++ + } + e.cycleEnergyWh = 0 + } +} + +// FiringSinceMs returns the timestamp the current firing was first detected +// (0 when not active), so callers can estimate elapsed firing time. +func (e *ExternalHeatDetector) FiringSinceMs() int64 { return e.sinceMs } + +// Active reports whether an external heat source is currently firing (within +// the hold window of the last detection). +func (e *ExternalHeatDetector) Active(nowMs int64) bool { + if !e.active { + return false + } + // Self-heal if the service missed the closing sample (e.g. driver outage): + // treat a long-stale detection as inactive. + return nowMs-e.lastDetectMs <= extHeatHoldMs +} diff --git a/go/internal/flexload/external_heat_test.go b/go/internal/flexload/external_heat_test.go new file mode 100644 index 00000000..ca16fd1a --- /dev/null +++ b/go/internal/flexload/external_heat_test.go @@ -0,0 +1,113 @@ +package flexload + +import ( + "testing" + + "github.com/frahlg/forty-two-watts/go/internal/thermalmodel" +) + +// TestStoveDetectionFiresOnUnexplainedWarming simulates a wood stove: the +// room warms fast while metered electric heat is ~0. The detector should go +// active and infer a positive external power. +func TestStoveDetectionFiresOnUnexplainedWarming(t *testing.T) { + m := thermalmodel.NewModel() + m.Samples = thermalmodel.WarmupSamples + det := &ExternalHeatDetector{} + + now := int64(1_700_000_000_000) + indoor, outdoor := 20.0, 0.0 + const dt = 300.0 // 5-min steps + + // Stove drives +1.0°C every 5 min (= 12°C/h) with zero metered power. + for i := 0; i < 6; i++ { + next := indoor + 1.0 + expDelta := m.ExpectedDeltaC(indoor, outdoor, 0, dt) // model expects slight cooling + obsDelta := next - indoor + det.Update(obsDelta, expDelta, 0 /*metered W*/, dt, now, m.ThermalWForRate) + indoor = next + now += int64(dt) * 1000 + } + if !det.Active(now) { + t.Fatal("expected stove detector to be active during unexplained warming") + } + if det.EstThermalW <= 0 { + t.Errorf("expected positive inferred external power, got %.0f", det.EstThermalW) + } +} + +// TestStoveDetectionIgnoresOurOwnHeating ensures the detector does NOT fire +// when the warming is explained by our own metered electric heat. +func TestStoveDetectionIgnoresOurOwnHeating(t *testing.T) { + m := thermalmodel.NewModel() + m.Samples = thermalmodel.WarmupSamples + det := &ExternalHeatDetector{} + + now := int64(1_700_000_000_000) + indoor, outdoor := 20.0, 5.0 + const dt = 300.0 + for i := 0; i < 10; i++ { + // Heat with 2 kW; the observed delta matches the model exactly. + exp := m.ExpectedDeltaC(indoor, outdoor, 2000, dt) + det.Update(exp /*observed == expected*/, exp, 2000 /*metered W, above threshold*/, dt, now, m.ThermalWForRate) + indoor += exp + now += int64(dt) * 1000 + } + if det.Active(now) { + t.Error("detector must not fire when warming is explained by metered heating") + } +} + +// TestStoveCycleFoldsEnergy verifies a firing's energy is folded into the +// per-cycle average once the hold window lapses with no further detection. +func TestStoveCycleFoldsEnergy(t *testing.T) { + m := thermalmodel.NewModel() + m.Samples = thermalmodel.WarmupSamples + det := &ExternalHeatDetector{} + + now := int64(1_700_000_000_000) + indoor, outdoor := 19.0, -5.0 + const dt = 300.0 + // Fire for ~1h (12 steps). + for i := 0; i < 12; i++ { + next := indoor + 0.8 + exp := m.ExpectedDeltaC(indoor, outdoor, 0, dt) + det.Update(next-indoor, exp, 0, dt, now, m.ThermalWForRate) + indoor = next + now += int64(dt) * 1000 + } + // Now idle past the hold window with no unexplained warming → cycle closes. + now += extHeatHoldMs + 1 + det.Update(0, 0, 0, dt, now, m.ThermalWForRate) + if det.Active(now) { + t.Error("cycle should have closed after the hold window") + } + if det.Cycles != 1 { + t.Errorf("expected 1 completed cycle, got %d", det.Cycles) + } + if det.AvgCycleWh <= 0 { + t.Errorf("expected a positive learned per-cycle energy, got %.0f", det.AvgCycleWh) + } +} + +// TestArbitrationShedsLeastEfficientFirst verifies the COP-aware ordering: +// between two same-power zones, the low-COP (resistive) one is blocked +// before the high-COP (heat-pump) one. +func TestArbitrationShedsLeastEfficientFirst(t *testing.T) { + m := trainedModel() + specs := []SimpleSpec{ + {Model: m, CurrentC: 21.5, TargetC: 20, MinC: 18, Outdoor: 5, MaxHeatW: 2000, COP: 3, BlockHorizon: 3_600_000_000_000}, // heat pump + {Model: m, CurrentC: 21.5, TargetC: 20, MinC: 18, Outdoor: 5, MaxHeatW: 2000, COP: 1, BlockHorizon: 3_600_000_000_000}, // resistive + } + decisions := []SimpleDecision{ + {Heat: true, SetpointC: 20, EstHeatW: 700, CoastHours: 5}, // 2000/3 ≈ 667 elec + {Heat: true, SetpointC: 20, EstHeatW: 2000, CoastHours: 5}, + } + // Budget fits only one of the two electrical draws. + ArbitrateSimple(decisions, specs, 800) + if !decisions[0].Heat { + t.Error("high-COP heat pump should keep running (most heat per watt)") + } + if decisions[1].Heat { + t.Error("low-COP resistive zone should be shed first") + } +} diff --git a/go/internal/flexload/plugprofile.go b/go/internal/flexload/plugprofile.go new file mode 100644 index 00000000..4bfd02c4 --- /dev/null +++ b/go/internal/flexload/plugprofile.go @@ -0,0 +1,125 @@ +package flexload + +// PlugProfile learns the electrical character of whatever is plugged into a +// smart plug / switch / contactor, so the deferrable scheduler doesn't have +// to be told the exact wattage and energy of a spa, water heater, pump, etc. +// +// It learns two things from the plug's own power metering: +// - RunningW — the typical power draw while the appliance is ON (an EMA +// over samples above the on-threshold), so the scheduler knows how much +// energy each slot it turns the plug on will actually deliver. +// - DailyEnergyWh — a rolling estimate of the appliance's daily energy +// appetite, so the scheduler can size its run budget when the operator +// hasn't pinned one. +// +// Classify() turns the learned shape into a coarse device label for +// diagnostics/UI only — never for control decisions. +type PlugProfile struct { + OnThresholdW float64 `json:"on_threshold_w"` // power above which we count "running" + RunningW float64 `json:"running_w"` // EMA of power while running + DailyEnergyWh float64 `json:"daily_energy_wh"` // EMA of per-day energy + PeakW float64 `json:"peak_w"` // max observed running power + Samples int64 `json:"samples"` + LastMs int64 `json:"last_ms"` + + // Energy accumulation for the current UTC day, folded into + // DailyEnergyWh at the day boundary. + dayEnergyWh float64 + dayKey int64 // UTC day number of the in-progress accumulation +} + +// NewPlugProfile returns a fresh profile. onThresholdW defaults to 25 W if +// non-positive — below typical appliance standby, above plug self-draw. +func NewPlugProfile(onThresholdW float64) *PlugProfile { + if onThresholdW <= 0 { + onThresholdW = 25 + } + return &PlugProfile{OnThresholdW: onThresholdW} +} + +// Update folds one power sample (W) at nowMs into the profile, integrating +// energy over the elapsed time since the previous sample. dtMaxS bounds the +// integration step so a driver outage gap doesn't inject a huge energy slug. +func (p *PlugProfile) Update(powerW float64, nowMs int64, dtMaxS float64) { + if powerW < 0 { + powerW = 0 + } + // Integrate energy since last sample. + if p.LastMs != 0 { + dt := float64(nowMs-p.LastMs) / 1000.0 + if dt > 0 && dt <= dtMaxS { + dayKey := nowMs / 86_400_000 + if p.dayKey == 0 { + p.dayKey = dayKey + } + if dayKey != p.dayKey { + // Day rolled over — fold the completed day into the EMA. + p.foldDay() + p.dayKey = dayKey + } + p.dayEnergyWh += powerW * dt / 3600.0 + } + } + p.LastMs = nowMs + + // Learn running power only while clearly ON. + if powerW >= p.OnThresholdW { + if p.RunningW == 0 { + p.RunningW = powerW + } else { + p.RunningW = 0.98*p.RunningW + 0.02*powerW + } + if powerW > p.PeakW { + p.PeakW = powerW + } + p.Samples++ + } +} + +// foldDay folds the in-progress day's accumulated energy into the rolling +// DailyEnergyWh EMA and resets the accumulator. +func (p *PlugProfile) foldDay() { + if p.DailyEnergyWh == 0 { + p.DailyEnergyWh = p.dayEnergyWh + } else { + p.DailyEnergyWh = 0.8*p.DailyEnergyWh + 0.2*p.dayEnergyWh + } + p.dayEnergyWh = 0 +} + +// EffectivePowerW returns the best estimate of run power: the operator's +// configured value when given, else the learned RunningW. +func (p *PlugProfile) EffectivePowerW(configuredW float64) float64 { + if configuredW > 0 { + return configuredW + } + return p.RunningW +} + +// EffectiveEnergyWh returns the best estimate of the run budget: the +// operator's configured value when given, else the learned DailyEnergyWh. +func (p *PlugProfile) EffectiveEnergyWh(configuredWh float64) float64 { + if configuredWh > 0 { + return configuredWh + } + return p.DailyEnergyWh +} + +// Classify returns a coarse device-type guess from the learned shape, for +// diagnostics only. Heuristics are intentionally conservative; "unknown" is +// returned until enough samples accumulate. +func (p *PlugProfile) Classify() string { + if p.Samples < 200 || p.RunningW <= 0 { + return "unknown" + } + switch { + case p.RunningW >= 2500 && p.DailyEnergyWh >= 5000: + return "water_heater" // high power, large daily energy + case p.RunningW >= 1000 && p.DailyEnergyWh >= 3000: + return "spa_or_pool" // sustained mid-high power, cyclic + case p.RunningW < 1000 && p.DailyEnergyWh < 2000: + return "small_appliance" + default: + return "general_load" + } +} diff --git a/go/internal/flexload/plugprofile_test.go b/go/internal/flexload/plugprofile_test.go new file mode 100644 index 00000000..fe9bd9af --- /dev/null +++ b/go/internal/flexload/plugprofile_test.go @@ -0,0 +1,78 @@ +package flexload + +import ( + "math" + "testing" +) + +// TestPlugLearnsRunningPower feeds a noisy on/off power trace and checks the +// learner recovers the appliance's running power and ignores the off state. +func TestPlugLearnsRunningPower(t *testing.T) { + p := NewPlugProfile(0) + now := int64(1_700_000_000_000) + const step = 60_000 // 60s samples + // Appliance runs at ~2000 W with ±100 W noise, duty-cycling off. + for i := 0; i < 1000; i++ { + w := 0.0 + if i%2 == 0 { + w = 2000 + 100*math.Sin(float64(i)) + } + p.Update(w, now, 300) + now += step + } + if p.RunningW < 1900 || p.RunningW > 2100 { + t.Errorf("learned RunningW = %.0f, want ~2000", p.RunningW) + } + // EffectivePowerW prefers a configured value, else learned. + if got := p.EffectivePowerW(1500); got != 1500 { + t.Errorf("configured power should win: got %.0f", got) + } + if got := p.EffectivePowerW(0); math.Abs(got-p.RunningW) > 1e-9 { + t.Errorf("learned power should be used when unset: got %.0f", got) + } +} + +// TestPlugAccumulatesDailyEnergy verifies energy integration and day-rollover +// folding produce a sane daily-energy estimate. +func TestPlugAccumulatesDailyEnergy(t *testing.T) { + p := NewPlugProfile(0) + // Start at a UTC midnight so the first full day accumulates cleanly. + const dayMs = 86_400_000 + now := int64(1_700_000_000_000) + now = now - now%dayMs // align to UTC midnight + const step = 60_000 + + // Run a constant 1000 W for exactly 3 hours, then idle the rest of day 1. + for t0 := int64(0); t0 < dayMs; t0 += step { + w := 0.0 + if t0 < 3*3600_000 { + w = 1000 + } + p.Update(w, now, 300) + now += step + } + // One sample into day 2 triggers the fold of day 1. + p.Update(0, now, 300) + + // 1000 W × 3 h = 3000 Wh expected for day 1. + if math.Abs(p.DailyEnergyWh-3000) > 60 { // ~2% tolerance for the step integration + t.Errorf("DailyEnergyWh = %.0f, want ~3000", p.DailyEnergyWh) + } +} + +// TestPlugClassify checks the coarse device labels for representative shapes. +func TestPlugClassify(t *testing.T) { + wh := &PlugProfile{Samples: 500, RunningW: 3000, DailyEnergyWh: 8000} + if got := wh.Classify(); got != "water_heater" { + t.Errorf("water heater classified as %q", got) + } + spa := &PlugProfile{Samples: 500, RunningW: 1500, DailyEnergyWh: 4000} + if got := spa.Classify(); got != "spa_or_pool" { + t.Errorf("spa classified as %q", got) + } + // Too few samples → unknown, regardless of shape. + cold := &PlugProfile{Samples: 10, RunningW: 3000, DailyEnergyWh: 8000} + if got := cold.Classify(); got != "unknown" { + t.Errorf("under-trained profile should be unknown, got %q", got) + } +} diff --git a/go/internal/flexload/scheduler.go b/go/internal/flexload/scheduler.go new file mode 100644 index 00000000..25e1ff8c --- /dev/null +++ b/go/internal/flexload/scheduler.go @@ -0,0 +1,522 @@ +// Package flexload schedules price-responsive flexible loads — thermostats +// (thermal "batteries") and deferrable on/off loads (smart plugs) — against +// the same price and PV forecasts the battery MPC consumes. +// +// Why a separate layer instead of new dimensions in the battery DP: the +// battery/EV DP is O(N·S·A·E_L·E_A) and already ~100M state evaluations. +// Adding a thermal SoC dimension and an interruptible-load dimension would +// multiply that into the billions (curse of dimensionality) and put the +// safety-critical battery loop at risk. Flexible loads couple to the rest +// of the system only through the grid-power balance and the price signal, +// so they decompose cleanly: the battery DP plans first, and the flex-load +// scheduler optimizes each device against the resulting price curve (with +// an optional PV-surplus credit). This is the standard HEMS decomposition. +// +// The scheduler is pure and deterministic — it takes forecasts + device +// specs and returns schedules. A Service (service.go) wires it to live +// forecasts and dispatches the schedules to Matter drivers. +package flexload + +import ( + "math" + "sort" + "time" + + "github.com/frahlg/forty-two-watts/go/internal/thermalmodel" +) + +// PriceSlot is one horizon step the scheduler optimizes over. PV surplus is +// the expected available solar after the rest of the house is served (≥0); +// leave it 0 if unknown. +type PriceSlot struct { + StartMs int64 + LenMin int + PriceOre float64 // consumer total import price öre/kWh + PVSurplusW float64 // expected PV surplus available this slot (W, ≥0) +} + +func (s PriceSlot) hours() float64 { return float64(s.LenMin) / 60.0 } + +// ---- Thermal (thermostat) scheduling ---- + +// ThermalSpec describes one thermostat zone to schedule. +// +// Heating kind matters for power accounting. A direct-electric zone +// (electric radiator, resistive floor heating) converts electricity to +// heat 1:1, so COP=1 and the metered electrical watts equal the thermal +// watts delivered. A hydronic zone (a thermostatic radiator valve on a +// water loop fed by a heat pump) delivers thermal watts the EMS pays for +// at electrical = thermal/COP, because the heat pump multiplies electricity +// into heat (COP≈3). MaxHeatW is always the zone's *thermal* output cap; +// EstHeatW in the resulting schedule is the *electrical* draw (thermal/COP) +// so the grid/fuse accounting is correct for both kinds. +type ThermalSpec struct { + DriverName string // Matter driver to command + Model thermalmodel.Model // learned RC dynamics for the zone + CurrentC float64 // measured indoor temperature now + MinC float64 // comfort floor (hard constraint) + MaxC float64 // comfort ceiling (pre-heat target) + MaxHeatW float64 // zone thermal output cap (W) + COP float64 // electrical→thermal multiplier (1 = direct electric, ~3 = heat pump). ≤0 treated as 1. + // Outdoor returns the forecast outdoor temperature (°C) for a slot + // start. Must be non-nil. + Outdoor func(slotStartMs int64) float64 + // PreHeatFraction is the share of the horizon's cheapest slots in + // which to actively bank heat toward MaxC. 0 falls back to 0.33. + PreHeatFraction float64 +} + +// ThermalSetpoint is the per-slot directive for a thermostat. +type ThermalSetpoint struct { + StartMs int64 + TargetC float64 // setpoint to write to the thermostat this slot + EstHeatW float64 // estimated ELECTRICAL draw (thermal/COP) for grid/fuse accounting + PreHeat bool // true when banking heat in a cheap/PV slot +} + +// ThermalSchedule is the full plan for one zone. +type ThermalSchedule struct { + DriverName string + Setpoints []ThermalSetpoint +} + +// PlanThermal produces a comfort-respecting, price-aware setpoint schedule. +// +// Strategy: pre-heat toward MaxC in the cheapest PreHeatFraction of slots +// (and whenever PV surplus covers the heater), otherwise coast toward MinC +// — but a forward simulation of the RC model guarantees the predicted +// indoor temperature never crosses below MinC: any slot that would breach +// the floor is forced to heat enough to hold it. The thermostat's own +// controller does the closed-loop work; we only choose its setpoint. +func PlanThermal(slots []PriceSlot, spec ThermalSpec) ThermalSchedule { + out := ThermalSchedule{DriverName: spec.DriverName} + if len(slots) == 0 || spec.Outdoor == nil { + return out + } + minC, maxC := spec.MinC, spec.MaxC + if maxC < minC { + minC, maxC = maxC, minC + } + frac := spec.PreHeatFraction + if frac <= 0 || frac > 1 { + frac = 0.33 + } + cop := spec.COP + if cop <= 0 { + cop = 1.0 + } + // Treat a slot as PV-covered when surplus exceeds 50% of the zone's + // electrical draw. Full coverage (100%) is too conservative — rooftop PV + // production is noisy and the thermostat self-regulates at the setpoint, + // so partial surplus still meaningfully reduces grid draw. + pvCoverW := 0.5 * spec.MaxHeatW / cop + // Price threshold = the frac-th percentile of horizon prices. Slots at + // or below it are "cheap" → pre-heat. + threshold := priceQuantile(slots, frac) + + indoor := spec.CurrentC + out.Setpoints = make([]ThermalSetpoint, 0, len(slots)) + for _, sl := range slots { + outdoor := spec.Outdoor(sl.StartMs) + pvCovers := sl.PVSurplusW > 0 && sl.PVSurplusW >= pvCoverW + preHeat := sl.PriceOre <= threshold || pvCovers + + // The setpoint we write is the target; the thermostat's own loop + // holds it, so the comfort floor is physically guaranteed by never + // writing a setpoint below MinC (target ∈ {MinC, MaxC}). + target := minC + if preHeat { + target = maxC + } + + // Model the thermostat's behaviour over the slot to get a + // self-consistent next temperature AND a faithful electrical + // estimate. Three regimes: + // 1. below setpoint → pull up at full output, capped at setpoint + // 2. above setpoint, stays above all slot → no heat (pure coast) + // 3. above setpoint, decays to it mid-slot → holds at setpoint + dt := sl.hours() * 3600.0 + coastTemp := spec.Model.PredictNext(indoor, outdoor, 0, dt) + var thermalW, nextIndoor float64 + switch { + case indoor < target-0.1: + thermalW = spec.MaxHeatW + nextIndoor = spec.Model.PredictNext(indoor, outdoor, thermalW, dt) + if nextIndoor > target { + nextIndoor = target // thermostat won't overshoot its setpoint + } + case coastTemp >= target: + thermalW = 0 + nextIndoor = coastTemp + default: + thermalW = spec.Model.HeatToHoldW(target, outdoor) + if thermalW > spec.MaxHeatW { + thermalW = spec.MaxHeatW + } + nextIndoor = target // held at setpoint (the comfort floor) + } + if thermalW < 0 { + thermalW = 0 + } + + out.Setpoints = append(out.Setpoints, ThermalSetpoint{ + StartMs: sl.StartMs, + TargetC: target, + EstHeatW: thermalW / cop, // electrical draw (= thermal for direct electric) + PreHeat: preHeat, + }) + + indoor = nextIndoor + if indoor > maxC { + indoor = maxC + } + if indoor < minC { + indoor = minC // thermostat floor — can't physically drop below setpoint + } + } + return out +} + +// ---- Simple valuation (MPC-independent) ---- +// +// The simple controller is a standalone, interpretable alternative to the +// horizon scheduler for operators who don't run the MPC (or want a +// degraded-mode fallback). It needs only: the current indoor temperature, +// a target (comfort) temperature, the current price vs. a threshold, and +// the learned thermal model. The rule is "block heating during expensive +// periods when the building's own thermal inertia will keep the target +// satisfied for the block horizon; otherwise heat." No forecast required +// when a fixed price threshold is given. + +// SimpleSpec is the input to a single simple-mode evaluation. +type SimpleSpec struct { + Model thermalmodel.Model + CurrentC float64 // measured indoor temp + TargetC float64 // comfort target (the temp we must keep) + MinC float64 // hard floor (never block below this) + Outdoor float64 // current outdoor temp + PriceNow float64 // current price (öre/kWh) + PriceThreshold float64 // "expensive" cutoff (öre/kWh) + BlockHorizon time.Duration // target must stay satisfied this long to allow a block + MaxHeatW float64 // zone thermal output cap + COP float64 // electrical/thermal ratio (≤0 → 1) + Confidence float64 // model quality [0,1]; below MinBlockConfidence we never block + // CoastOverrideH lets the caller supply a coast-to-target estimate from a + // better model (the two-mass floor-heating model) instead of the + // single-mass fit. Used when HasCoastOverride is true. + CoastOverrideH float64 + HasCoastOverride bool +} + +// MinBlockConfidence is the learned-model quality required before the simple +// controller will block heating on the strength of its coast prediction. No +// price-based reduction happens on an unvalidated model — an untrained zone +// just maintains its target. +const MinBlockConfidence = 0.4 + +// SimpleDecision is the output: whether to heat and to what setpoint. +type SimpleDecision struct { + Heat bool // true = heat toward TargetC; false = block (coast) + SetpointC float64 // setpoint to write (TargetC if heating, MinC if blocking) + EstHeatW float64 // estimated electrical draw if heating (thermal/COP), else 0 + CoastHours float64 // estimated hours of coast before temp falls to TargetC + Reason string +} + +// EvaluateSimple applies the block/heat rule for one zone. +func EvaluateSimple(spec SimpleSpec) SimpleDecision { + cop := spec.COP + if cop <= 0 { + cop = 1 + } + // Hard floor always wins. + if spec.CurrentC <= spec.MinC { + return SimpleDecision{ + Heat: true, SetpointC: spec.TargetC, + EstHeatW: spec.MaxHeatW / cop, + Reason: "at/below comfort floor", + } + } + + // Prefer the two-mass floor-heating coast estimate when supplied — it + // accounts for the slab's stored heat the single-mass fit can't see. + coast := spec.CoastOverrideH + if !spec.HasCoastOverride { + coast = coastHoursToTarget(spec.Model, spec.CurrentC, spec.TargetC, spec.Outdoor, 24*time.Hour) + } + expensive := spec.PriceThreshold > 0 && spec.PriceNow > spec.PriceThreshold + bufferEnough := coast >= spec.BlockHorizon.Hours() + // No reduction without a trustworthy, learned model: an untrained zone + // can't be relied on to coast as predicted, so we hold the target. + confident := spec.Confidence >= MinBlockConfidence + + if expensive && bufferEnough && confident { + return SimpleDecision{ + Heat: false, SetpointC: spec.MinC, + EstHeatW: 0, + CoastHours: coast, + Reason: "expensive + thermal buffer covers block horizon", + } + } + // Otherwise heat to keep the target. + holdThermal := spec.Model.HeatToHoldW(spec.TargetC, spec.Outdoor) + if holdThermal > spec.MaxHeatW { + holdThermal = spec.MaxHeatW + } + reason := "maintaining target" + if expensive && !confident { + reason = "expensive but model not trained enough to risk a block — heating to protect target" + } else if expensive { + reason = "expensive but buffer insufficient — heating to protect target" + } + return SimpleDecision{ + Heat: true, SetpointC: spec.TargetC, + EstHeatW: holdThermal / cop, + CoastHours: coast, + Reason: reason, + } +} + +// coastHoursToTarget rolls the RC model forward with no heating until the +// indoor temp decays to targetC, returning the elapsed hours (capped). +func coastHoursToTarget(m thermalmodel.Model, indoorC, targetC, outdoorC float64, cap time.Duration) float64 { + if indoorC <= targetC { + return 0 + } + const step = 300.0 // 5-min integration step (s) + maxS := cap.Seconds() + t := 0.0 + temp := indoorC + for t < maxS { + temp = m.PredictNext(temp, outdoorC, 0, step) + t += step + if temp <= targetC { + break + } + } + return t / 3600.0 +} + +// ArbitrateSimple resolves competition between simple-mode zones under a +// shared electrical power budget (e.g. the site fuse headroom reserved for +// heating). When the summed heating draw exceeds budgetW it blocks zones +// *that can afford it* (coast ≥ their block horizon, above the comfort +// floor) until the total fits. +// +// Block order is by cost-effectiveness, not raw power. The value of a zone's +// heating is heat delivered per electrical watt — i.e. its COP. Shedding a +// watt of low-COP resistive heat loses little (you reheat it cheaply later); +// shedding a watt of high-COP heat-pump heat loses a lot. So we block the +// LEAST efficient zones first (lowest COP), tie-broken by higher power (frees +// the budget in fewer blocks) and then longer coast buffer (most comfort +// margin). System inertia is already captured in the coast buffer via each +// zone's learned time constant — a high-inertia water loop yields a long +// coast and is preferred for blocking over a twitchy electric panel. +// budgetW ≤ 0 disables arbitration. +func ArbitrateSimple(decisions []SimpleDecision, specs []SimpleSpec, budgetW float64) { + if budgetW <= 0 || len(decisions) != len(specs) { + return + } + var total float64 + for _, d := range decisions { + if d.Heat { + total += d.EstHeatW + } + } + if total <= budgetW { + return + } + type cand struct { + idx int + cop, powerW float64 + coast float64 + } + var cands []cand + for i, d := range decisions { + if !d.Heat { + continue + } + s := specs[i] + if s.CurrentC <= s.MinC { + continue // protect the floor + } + if d.CoastHours < s.BlockHorizon.Hours() { + continue // needs the heat now + } + cop := s.COP + if cop <= 0 { + cop = 1 + } + cands = append(cands, cand{idx: i, cop: cop, powerW: d.EstHeatW, coast: d.CoastHours}) + } + // Least cost-effective (lowest COP) first; then highest power; then + // longest coast. + sort.SliceStable(cands, func(a, b int) bool { + if cands[a].cop != cands[b].cop { + return cands[a].cop < cands[b].cop + } + if cands[a].powerW != cands[b].powerW { + return cands[a].powerW > cands[b].powerW + } + return cands[a].coast > cands[b].coast + }) + for _, c := range cands { + if total <= budgetW { + break + } + d := &decisions[c.idx] + total -= d.EstHeatW + d.Heat = false + d.SetpointC = specs[c.idx].MinC + d.EstHeatW = 0 + d.Reason = "deferred under shared power budget (least cost-effective, target still satisfiable)" + } +} + +// ---- Deferrable (smart-plug) scheduling ---- + +// DeferrableSpec describes a load that must run for a given energy budget +// somewhere inside a window, but can be interrupted freely (no minimum run +// length) — e.g. a water heater on a smart plug, a dehumidifier, a pool +// pump. The scheduler picks the cheapest eligible slots. +type DeferrableSpec struct { + DriverName string + EnergyWh float64 // energy still needed over the window + PowerW float64 // power drawn while running + EarliestMs int64 // don't run before this (0 = no lower bound) + DeadlineMs int64 // must finish by this (0 = end of horizon) + // PreferPV biases selection toward slots with PV surplus by crediting + // the surplus against the slot price. + PreferPV bool +} + +// DeferrableSlot is the per-slot directive for a deferrable load. +type DeferrableSlot struct { + StartMs int64 + On bool + EstW float64 // PowerW when On, else 0 +} + +// DeferrableSchedule is the full plan for one deferrable load. +type DeferrableSchedule struct { + DriverName string + Slots []DeferrableSlot + // ScheduledWh is the energy the plan actually places. Because the + // scheduler works with whole ON/OFF slots (no partial-slot control), + // it may exceed EnergyWh by up to one slot's worth — rounding up to + // the smallest discrete unit that covers the budget. It may also be + // less than EnergyWh if the deadline window runs out of eligible slots. + ScheduledWh float64 +} + +// PlanDeferrable selects the cheapest eligible slots to meet the energy +// budget. Eligibility is the [EarliestMs, DeadlineMs] window; among +// eligible slots, the cheapest (by PV-credited price) are turned on until +// the energy budget is met. +func PlanDeferrable(slots []PriceSlot, spec DeferrableSpec) DeferrableSchedule { + out := DeferrableSchedule{DriverName: spec.DriverName} + if len(slots) == 0 || spec.PowerW <= 0 || spec.EnergyWh <= 0 { + // Still emit an all-off schedule so the dispatcher can turn it off. + out.Slots = allOff(slots) + return out + } + + type cand struct { + idx int + effPrice float64 + } + var cands []cand + for i, sl := range slots { + if spec.EarliestMs != 0 && sl.StartMs < spec.EarliestMs { + continue + } + if spec.DeadlineMs != 0 && sl.StartMs >= spec.DeadlineMs { + continue + } + eff := sl.PriceOre + if spec.PreferPV && sl.PVSurplusW > 0 { + // Credit PV surplus that covers (part of) the load. A slot whose + // surplus fully covers PowerW is treated as ~free. + coverage := sl.PVSurplusW / spec.PowerW + if coverage > 1 { + coverage = 1 + } + eff = sl.PriceOre * (1 - coverage) + } + cands = append(cands, cand{idx: i, effPrice: eff}) + } + // Cheapest first; tie-break on earlier slot so we finish sooner. + sort.SliceStable(cands, func(a, b int) bool { + if cands[a].effPrice != cands[b].effPrice { + return cands[a].effPrice < cands[b].effPrice + } + return cands[a].idx < cands[b].idx + }) + + onIdx := make(map[int]bool) + remaining := spec.EnergyWh + var scheduled float64 + for _, c := range cands { + if remaining <= 0 { + break + } + sl := slots[c.idx] + slotWh := spec.PowerW * sl.hours() + onIdx[c.idx] = true + scheduled += slotWh + remaining -= slotWh + } + out.ScheduledWh = scheduled + + out.Slots = make([]DeferrableSlot, len(slots)) + for i, sl := range slots { + on := onIdx[i] + w := 0.0 + if on { + w = spec.PowerW + } + out.Slots[i] = DeferrableSlot{StartMs: sl.StartMs, On: on, EstW: w} + } + return out +} + +// ---- helpers ---- + +func allOff(slots []PriceSlot) []DeferrableSlot { + out := make([]DeferrableSlot, len(slots)) + for i, sl := range slots { + out[i] = DeferrableSlot{StartMs: sl.StartMs, On: false} + } + return out +} + +// priceQuantile returns the q-quantile (0..1) of the slot prices. +func priceQuantile(slots []PriceSlot, q float64) float64 { + if len(slots) == 0 { + return 0 + } + prices := make([]float64, 0, len(slots)) + for _, s := range slots { + if !math.IsNaN(s.PriceOre) && !math.IsInf(s.PriceOre, 0) { + prices = append(prices, s.PriceOre) + } + } + if len(prices) == 0 { + return 0 + } + sort.Float64s(prices) + if q <= 0 { + return prices[0] + } + if q >= 1 { + return prices[len(prices)-1] + } + pos := q * float64(len(prices)-1) + lo := int(math.Floor(pos)) + hi := int(math.Ceil(pos)) + if lo == hi { + return prices[lo] + } + frac := pos - float64(lo) + return prices[lo]*(1-frac) + prices[hi]*frac +} diff --git a/go/internal/flexload/scheduler_test.go b/go/internal/flexload/scheduler_test.go new file mode 100644 index 00000000..4d755858 --- /dev/null +++ b/go/internal/flexload/scheduler_test.go @@ -0,0 +1,388 @@ +package flexload + +import ( + "testing" + "time" + + "github.com/frahlg/forty-two-watts/go/internal/thermalmodel" +) + +func trainedModel() thermalmodel.Model { + m := thermalmodel.NewModel() + m.Samples = thermalmodel.WarmupSamples + return *m +} + +// TestSimpleBlocksWhenBufferCovers verifies the simple controller blocks +// heating during an expensive period when the building's inertia keeps the +// target for the block horizon — and heats otherwise. +func TestSimpleBlocksWhenBufferCovers(t *testing.T) { + m := trainedModel() // τ = 4h prior → slow decay + + // 2.5°C margin over target at a mild 15°C outdoor → ~1.6h of coast with + // the τ=4h prior, comfortably over the 1h block horizon. + expensive := SimpleSpec{ + Model: m, CurrentC: 22.5, TargetC: 20.0, MinC: 18.0, + Outdoor: 15.0, PriceNow: 300, PriceThreshold: 150, + BlockHorizon: time.Hour, MaxHeatW: 2000, COP: 1, + Confidence: 1.0, // trained model → blocking permitted + } + d := EvaluateSimple(expensive) + if d.Heat { + t.Errorf("expected block (coast %.1fh ≥ 1h, price 300>150): %s", d.CoastHours, d.Reason) + } + if d.SetpointC != expensive.MinC { + t.Errorf("blocked zone setpoint = %.1f, want MinC %.1f", d.SetpointC, expensive.MinC) + } + + // Cheap price → heat to target even though buffer exists. + cheap := expensive + cheap.PriceNow = 50 + if dd := EvaluateSimple(cheap); !dd.Heat { + t.Error("cheap price should heat to target") + } +} + +// TestSimpleNeverBlocksUntrainedModel verifies the core safety guarantee: +// even when it's expensive and the (prior-only) model claims a big buffer, +// a zone whose model isn't trained is NOT blocked — it maintains target. +func TestSimpleNeverBlocksUntrainedModel(t *testing.T) { + m := *thermalmodel.NewModel() // fresh → Quality()==0 + spec := SimpleSpec{ + Model: m, CurrentC: 22.5, TargetC: 20.0, MinC: 18.0, + Outdoor: 15.0, PriceNow: 300, PriceThreshold: 150, + BlockHorizon: time.Hour, MaxHeatW: 2000, COP: 1, + Confidence: m.Quality(), // 0 → below MinBlockConfidence + } + d := EvaluateSimple(spec) + if !d.Heat { + t.Error("untrained model must never trigger a block — maintain target") + } +} + +// TestSimpleUsesCoastOverride verifies the two-mass coast estimate is used +// when supplied: a small single-mass buffer would refuse the block, but the +// floor-heating slab gives a long coast → block is allowed. +func TestSimpleUsesCoastOverride(t *testing.T) { + m := trainedModel() + // Tiny inherent buffer (close to target, cold out) → single-mass coast < 1h. + spec := SimpleSpec{ + Model: m, CurrentC: 20.2, TargetC: 20.0, MinC: 18.0, + Outdoor: -5.0, PriceNow: 300, PriceThreshold: 150, + BlockHorizon: time.Hour, MaxHeatW: 2000, COP: 1, Confidence: 1.0, + } + // Without override → heats (insufficient buffer). + if d := EvaluateSimple(spec); !d.Heat { + t.Fatalf("precondition: single-mass should refuse block, coast=%.2f", d.CoastHours) + } + // With a 3h two-mass coast override (hot slab) → blocks. + spec.HasCoastOverride = true + spec.CoastOverrideH = 3.0 + d := EvaluateSimple(spec) + if d.Heat { + t.Errorf("two-mass coast override should permit the block, got heat=%v reason=%s", d.Heat, d.Reason) + } +} + +// TestSimpleProtectsFloor verifies a zone at/below the floor always heats, +// regardless of price. +func TestSimpleProtectsFloor(t *testing.T) { + m := trainedModel() + spec := SimpleSpec{ + Model: m, CurrentC: 17.9, TargetC: 20.0, MinC: 18.0, + Outdoor: -10.0, PriceNow: 999, PriceThreshold: 100, + BlockHorizon: time.Hour, MaxHeatW: 3000, COP: 1, + } + d := EvaluateSimple(spec) + if !d.Heat { + t.Error("zone below floor must heat regardless of price") + } +} + +// TestSimpleHeatsWhenBufferInsufficient verifies that even at a high price, +// a zone close to its target (little buffer) heats to protect comfort. +func TestSimpleHeatsWhenBufferInsufficient(t *testing.T) { + m := trainedModel() + m.Beta[0] = 1.0 / 1200.0 // τ = 20min, very leaky → tiny buffer + spec := SimpleSpec{ + Model: m, CurrentC: 20.1, TargetC: 20.0, MinC: 18.0, + Outdoor: -15.0, PriceNow: 400, PriceThreshold: 100, + BlockHorizon: time.Hour, MaxHeatW: 3000, COP: 1, + } + d := EvaluateSimple(spec) + if !d.Heat { + t.Errorf("leaky zone with <1h buffer should heat despite high price (coast %.2fh)", d.CoastHours) + } +} + +// TestArbitrateBlocksHighestPowerFirst verifies that under a shared power +// budget, the highest-power zone that can afford to coast is blocked first. +func TestArbitrateBlocksHighestPowerFirst(t *testing.T) { + m := trainedModel() + // Two zones, both want to heat, both have ample buffer. Budget only + // fits one. The 3 kW zone should be blocked before the 1 kW zone. + specs := []SimpleSpec{ + {Model: m, CurrentC: 21.5, TargetC: 20, MinC: 18, Outdoor: 5, MaxHeatW: 1000, COP: 1, BlockHorizon: time.Hour}, + {Model: m, CurrentC: 21.5, TargetC: 20, MinC: 18, Outdoor: 5, MaxHeatW: 3000, COP: 1, BlockHorizon: time.Hour}, + } + decisions := []SimpleDecision{ + {Heat: true, SetpointC: 20, EstHeatW: 1000, CoastHours: 5}, + {Heat: true, SetpointC: 20, EstHeatW: 3000, CoastHours: 5}, + } + ArbitrateSimple(decisions, specs, 1500) // budget fits the 1kW zone only + if !decisions[0].Heat { + t.Error("1 kW zone should keep heating under budget") + } + if decisions[1].Heat { + t.Error("3 kW zone should be blocked first (highest power, comfort allows)") + } +} + +// TestArbitrateProtectsZoneNeedingHeat verifies arbitration never blocks a +// zone that lacks the buffer to coast, even if it's the highest power. +func TestArbitrateProtectsZoneNeedingHeat(t *testing.T) { + m := trainedModel() + specs := []SimpleSpec{ + {Model: m, CurrentC: 20.05, TargetC: 20, MinC: 18, Outdoor: 5, MaxHeatW: 3000, COP: 1, BlockHorizon: time.Hour}, + {Model: m, CurrentC: 22.0, TargetC: 20, MinC: 18, Outdoor: 5, MaxHeatW: 1000, COP: 1, BlockHorizon: time.Hour}, + } + decisions := []SimpleDecision{ + {Heat: true, SetpointC: 20, EstHeatW: 3000, CoastHours: 0.1}, // no buffer + {Heat: true, SetpointC: 20, EstHeatW: 1000, CoastHours: 6}, // ample buffer + } + ArbitrateSimple(decisions, specs, 1000) + if !decisions[0].Heat { + t.Error("3 kW zone with no buffer must not be blocked") + } + if decisions[1].Heat { + t.Error("1 kW zone with buffer should yield instead") + } +} + +// buildSlots makes a horizon with the given per-slot prices, 60-min slots. +func buildSlots(prices []float64) []PriceSlot { + out := make([]PriceSlot, len(prices)) + start := int64(1_700_000_000_000) + for i, p := range prices { + out[i] = PriceSlot{ + StartMs: start + int64(i)*3600_000, + LenMin: 60, + PriceOre: p, + } + } + return out +} + +// TestThermalPreHeatsInCheapHours verifies the schedule targets MaxC during +// the cheapest slots and coasts toward MinC during expensive ones, while +// never predicting a breach of the comfort floor. +func TestThermalPreHeatsInCheapHours(t *testing.T) { + // Cheap night (slots 0-2), expensive evening peak (slots 3-5). + prices := []float64{20, 25, 30, 200, 220, 210} + slots := buildSlots(prices) + + m := thermalmodel.NewModel() + m.Samples = thermalmodel.WarmupSamples // trust learned coefs + spec := ThermalSpec{ + DriverName: "living_room", + Model: *m, + CurrentC: 20.0, + MinC: 19.0, + MaxC: 22.0, + MaxHeatW: 2000, + Outdoor: func(int64) float64 { return 0.0 }, + PreHeatFraction: 0.5, + } + sched := PlanThermal(slots, spec) + if len(sched.Setpoints) != len(slots) { + t.Fatalf("want %d setpoints, got %d", len(slots), len(sched.Setpoints)) + } + + // Cheapest half should pre-heat (target MaxC). + if !sched.Setpoints[0].PreHeat { + t.Error("cheapest slot should pre-heat") + } + if sched.Setpoints[0].TargetC != spec.MaxC { + t.Errorf("pre-heat slot target = %.1f, want MaxC %.1f", sched.Setpoints[0].TargetC, spec.MaxC) + } + // Expensive peak should NOT pre-heat (unless forced by the comfort floor). + peak := sched.Setpoints[4] + if peak.PreHeat { + t.Error("expensive peak slot should not voluntarily pre-heat") + } + + // Comfort floor: no setpoint below MinC, ever. + for i, sp := range sched.Setpoints { + if sp.TargetC < spec.MinC-1e-9 { + t.Errorf("slot %d target %.2f below MinC %.2f", i, sp.TargetC, spec.MinC) + } + } +} + +// TestThermalComfortFloorForcesHeat verifies that when coasting would drop +// the zone below MinC in an expensive slot, the scheduler still forces a +// hold at the floor rather than violating comfort to save money. +func TestThermalComfortFloorForcesHeat(t *testing.T) { + // All slots expensive so the scheduler never *wants* to heat. + prices := []float64{300, 300, 300, 300} + slots := buildSlots(prices) + + m := thermalmodel.NewModel() + m.Samples = thermalmodel.WarmupSamples + // Very leaky building (short tau) and cold outside → coasting drops fast. + m.Beta[0] = 1.0 / 1800.0 // tau = 30 min + spec := ThermalSpec{ + DriverName: "drafty", + Model: *m, + CurrentC: 19.2, + MinC: 19.0, + MaxC: 21.0, + MaxHeatW: 3000, + Outdoor: func(int64) float64 { return -10.0 }, + PreHeatFraction: 0.25, + } + sched := PlanThermal(slots, spec) + + // At least one slot must be forced to hold the floor (target == MinC with + // nonzero heat), proving the floor guard fired. + forced := false + for _, sp := range sched.Setpoints { + if sp.TargetC >= spec.MinC && sp.EstHeatW > 0 { + forced = true + } + if sp.TargetC < spec.MinC-1e-9 { + t.Fatalf("comfort floor violated: target %.2f < MinC %.2f", sp.TargetC, spec.MinC) + } + } + if !forced { + t.Error("expected the comfort-floor guard to force heating in a cold leaky zone") + } +} + +// TestThermalCOPAccountsElectricalDraw verifies a hydronic (heat-pump) zone +// reports EstHeatW as the ELECTRICAL draw (thermal/COP), while an otherwise +// identical direct-electric zone reports the full thermal watts. +func TestThermalCOPAccountsElectricalDraw(t *testing.T) { + // Single cold slot that forces active pre-heat to MaxC. + slots := buildSlots([]float64{10}) + + m := thermalmodel.NewModel() + m.Samples = thermalmodel.WarmupSamples + + base := ThermalSpec{ + DriverName: "zone", + Model: *m, + CurrentC: 18.0, // below MaxC → pulling up at full output + MinC: 19.0, + MaxC: 22.0, + MaxHeatW: 3000, // thermal output cap + Outdoor: func(int64) float64 { return -5.0 }, + PreHeatFraction: 1.0, // cheapest slot → pre-heat + } + + electric := base + electric.COP = 1.0 + elecSched := PlanThermal(slots, electric) + + hydronic := base + hydronic.COP = 3.0 + hydroSched := PlanThermal(slots, hydronic) + + eW := elecSched.Setpoints[0].EstHeatW + hW := hydroSched.Setpoints[0].EstHeatW + if eW != 3000 { + t.Errorf("direct-electric EstHeatW = %.0f, want 3000 (thermal=electrical)", eW) + } + if hW != 1000 { + t.Errorf("hydronic EstHeatW = %.0f, want 1000 (3000 thermal / COP 3)", hW) + } + // Both must reach the same comfort target — COP changes cost, not comfort. + if elecSched.Setpoints[0].TargetC != hydroSched.Setpoints[0].TargetC { + t.Error("COP must not change the comfort setpoint") + } +} + +// TestDeferrablePicksCheapestSlots verifies energy lands in the cheapest +// eligible slots and the budget is met. +func TestDeferrablePicksCheapestSlots(t *testing.T) { + prices := []float64{100, 50, 200, 25, 300, 75} + slots := buildSlots(prices) + spec := DeferrableSpec{ + DriverName: "water_heater", + EnergyWh: 3000, // 3 slots @ 1000W*1h + PowerW: 1000, + } + sched := PlanDeferrable(slots, spec) + + // Cheapest three slots are idx 3 (25), 1 (50), 5 (75). + wantOn := map[int]bool{3: true, 1: true, 5: true} + for i, s := range sched.Slots { + if s.On != wantOn[i] { + t.Errorf("slot %d (price %.0f): On=%v want %v", i, prices[i], s.On, wantOn[i]) + } + } + if sched.ScheduledWh != 3000 { + t.Errorf("scheduled %.0f Wh, want 3000", sched.ScheduledWh) + } +} + +// TestDeferrableRespectsWindow verifies earliest/deadline bounds are honored. +func TestDeferrableRespectsWindow(t *testing.T) { + prices := []float64{10, 10, 500, 500, 10, 10} // cheap slots outside window + slots := buildSlots(prices) + // Window = slots 2,3 only (the expensive ones) — scheduler must use them + // because the cheap slots are out of bounds. + spec := DeferrableSpec{ + DriverName: "pump", + EnergyWh: 1000, + PowerW: 1000, + EarliestMs: slots[2].StartMs, + DeadlineMs: slots[4].StartMs, // exclusive upper bound → slots 2,3 eligible + } + sched := PlanDeferrable(slots, spec) + for i, s := range sched.Slots { + if (i == 2 || i == 3) == false && s.On { + t.Errorf("slot %d outside window must be off", i) + } + } + // One slot of the two should be on (cheapest within window — equal, so idx 2). + if !sched.Slots[2].On { + t.Error("expected slot 2 (cheapest in window) to be scheduled") + } +} + +// TestDeferrablePrefersPV checks the PV credit biases selection toward a +// surplus slot even when its raw price is higher. +func TestDeferrablePrefersPV(t *testing.T) { + slots := buildSlots([]float64{60, 100}) + // Slot 1 is pricier but has full-coverage PV surplus → effective ~0. + slots[1].PVSurplusW = 2000 + spec := DeferrableSpec{ + DriverName: "dishwasher", + EnergyWh: 1000, + PowerW: 1000, + PreferPV: true, + } + sched := PlanDeferrable(slots, spec) + if !sched.Slots[1].On { + t.Error("PV-surplus slot should win despite higher raw price") + } + if sched.Slots[0].On { + t.Error("non-PV slot should stay off when budget fits in the PV slot") + } +} + +// TestDeferrableZeroBudgetAllOff ensures a satisfied/empty load yields an +// explicit all-off schedule (so the dispatcher turns it off). +func TestDeferrableZeroBudgetAllOff(t *testing.T) { + slots := buildSlots([]float64{10, 20, 30}) + sched := PlanDeferrable(slots, DeferrableSpec{DriverName: "x", EnergyWh: 0, PowerW: 1000}) + if len(sched.Slots) != 3 { + t.Fatalf("want 3 slots, got %d", len(sched.Slots)) + } + for i, s := range sched.Slots { + if s.On { + t.Errorf("slot %d should be off for zero-budget load", i) + } + } +} diff --git a/go/internal/flexload/service.go b/go/internal/flexload/service.go new file mode 100644 index 00000000..d06540ee --- /dev/null +++ b/go/internal/flexload/service.go @@ -0,0 +1,981 @@ +package flexload + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "sync" + "time" + + "github.com/frahlg/forty-two-watts/go/internal/state" + "github.com/frahlg/forty-two-watts/go/internal/telemetry" + "github.com/frahlg/forty-two-watts/go/internal/thermalmodel" +) + +// Device is one configured flexible load (normalized from config.FlexLoad). +type Device struct { + Type string // "thermostat" | "deferrable" + DriverName string + Mode string // "planner" (default) | "simple" — thermostats only + + // thermostat + HeatingKind string // "electric" (default) | "hydronic" + COP float64 // electrical→thermal multiplier (1 electric, ~3 heat pump) + FlowDriver string // optional: heat-pump driver exposing supply (flow) temp + FlowMetric string // metric carrying flow temperature (°C) + NominalFlowDeltaC float64 // design flow-above-room delta for a full heat charge (default 15) + MinC float64 + MaxC float64 + MaxHeatW float64 // zone thermal output cap (W) + IndoorDriver string // optional: separate driver (temp sensor) for indoor temp + IndoorMetric string // driver metric carrying measured indoor temp (°C) + HeatMetric string // optional: metered heating power (W) for RC training + SlabDriver string // optional: floor-probe / flow-temp driver (→ two-mass model) + SlabMetric string // metric carrying slab/floor temperature (°C) + SetpointAction string // driver command action that writes the setpoint + PreHeatFraction float64 + + // simple-mode + TargetC float64 // comfort target to maintain + PriceThresholdOre float64 // "expensive" cutoff; 0 = derive from forecast + BlockHorizonH float64 // hours the target must hold to allow a block + + // deferrable + PowerMetric string // optional: plug power metric → learn load + energy + EnergyWh float64 + PowerW float64 + OnAction string + OffAction string + PreferPV bool + EarliestHour int + DeadlineHour int +} + +// DispatchFunc sends a JSON command to a driver (typically registry.Send). +type DispatchFunc func(ctx context.Context, driver string, payload []byte) error + +// Service runs the flex-load scheduler against live forecasts and dispatches +// per-slot directives to Matter drivers. It also trains a per-zone thermal +// RC model from telemetry when a metered heating signal is available. +// +// It is deliberately decoupled from the MPC: the caller supplies a Slots +// function (built from the MPC plan's price/PV curve) and an Outdoor +// temperature function, so flexload neither imports mpc nor duplicates the +// forecast plumbing. +type Service struct { + Store *state.Store + Tele *telemetry.Store + Devices []Device + Slots func() []PriceSlot // current horizon price/PV slots + Outdoor func(slotStartMs int64) float64 // outdoor temp forecast (°C) + Dispatch DispatchFunc + // PriceAt returns the price (öre/kWh) at an arbitrary time, independently + // of the MPC. Used for simple-mode "now" pricing AND for the reheat-cost + // side of the economic pause calculation (price at the future moment the + // zone will need to be reheated). Optional — when nil, simple mode + // derives the current price from Slots() and the pause economics fall + // back to "hold comfort" (never a speculative reduction). + PriceAt func(t time.Time) (float64, bool) + // FuseBudgetW is the shared electrical headroom (W) simple-mode zones + // arbitrate under (block highest-power zones first when comfort allows). + // 0 disables arbitration. + FuseBudgetW float64 + + SampleInterval time.Duration // thermal training cadence (default 60s) + ReplanInterval time.Duration // schedule + dispatch cadence (default 5m) + + mu sync.RWMutex + thermal map[string]*thermalmodel.Model // by driver name (single-mass RC) + twomass map[string]*thermalmodel.TwoMass // by driver name (slab+room, floor heating) + lastIndoor map[string]tempSample // last indoor reading for delta training + lastSlab map[string]tempSample // last slab reading for two-mass training + plug map[string]*PlugProfile // by driver name (deferrable load learning) + stove map[string]*ExternalHeatDetector // by driver name (wood-stove / free-heat detection) + + stop chan struct{} + done chan struct{} +} + +type tempSample struct { + c float64 + tsMs int64 +} + +const thermalStateKeyPrefix = "flexload/thermal/" +const twoMassStateKeyPrefix = "flexload/twomass/" +const plugStateKeyPrefix = "flexload/plug/" +const stoveStateKeyPrefix = "flexload/stove/" + +// NewService builds a flex-load service. Returns nil if there are no +// devices configured, so the caller can skip starting it entirely. +func NewService(st *state.Store, tel *telemetry.Store, devices []Device) *Service { + if len(devices) == 0 { + return nil + } + s := &Service{ + Store: st, + Tele: tel, + Devices: devices, + SampleInterval: 60 * time.Second, + ReplanInterval: 5 * time.Minute, + thermal: map[string]*thermalmodel.Model{}, + twomass: map[string]*thermalmodel.TwoMass{}, + lastIndoor: map[string]tempSample{}, + lastSlab: map[string]tempSample{}, + plug: map[string]*PlugProfile{}, + stove: map[string]*ExternalHeatDetector{}, + stop: make(chan struct{}), + done: make(chan struct{}), + } + for _, d := range devices { + switch d.Type { + case "thermostat": + // Restore or initialize a thermal model per thermostat. + m := thermalmodel.NewModel() + if st != nil { + if js, ok := st.LoadConfig(thermalStateKeyPrefix + d.DriverName); ok && js != "" { + var loaded thermalmodel.Model + if err := json.Unmarshal([]byte(js), &loaded); err == nil && loaded.Forgetting > 0 { + m = &loaded + slog.Info("flexload thermal model restored", + "driver", d.DriverName, "samples", m.Samples, + "tau_h", m.TauSeconds()/3600, "quality", m.Quality()) + } + } + } + s.thermal[d.DriverName] = m + // For floor heating with a slab/floor probe, also keep a two-mass + // model and prefer it for coast/forecast once trained. + if d.SlabMetric != "" { + tmm := thermalmodel.NewTwoMass() + if st != nil { + if js, ok := st.LoadConfig(twoMassStateKeyPrefix + d.DriverName); ok && js != "" { + var loaded thermalmodel.TwoMass + if err := json.Unmarshal([]byte(js), &loaded); err == nil && loaded.Forgetting > 0 { + tmm = &loaded + slog.Info("flexload two-mass model restored", + "driver", d.DriverName, "samples", tmm.Samples, + "tau_room_h", tmm.TauRoomSeconds()/3600, + "tau_slab_h", tmm.TauSlabSeconds()/3600, "quality", tmm.Quality()) + } + } + } + s.twomass[d.DriverName] = tmm + } + // Restore the external-heat (wood-stove) detector's learned stats. + det := &ExternalHeatDetector{} + if st != nil { + if js, ok := st.LoadConfig(stoveStateKeyPrefix + d.DriverName); ok && js != "" { + var loaded ExternalHeatDetector + if err := json.Unmarshal([]byte(js), &loaded); err == nil { + det = &loaded + } + } + } + s.stove[d.DriverName] = det + case "deferrable": + // Restore or initialize a plug load profile when metering is wired. + if d.PowerMetric == "" { + continue + } + p := NewPlugProfile(0) + if st != nil { + if js, ok := st.LoadConfig(plugStateKeyPrefix + d.DriverName); ok && js != "" { + var loaded PlugProfile + if err := json.Unmarshal([]byte(js), &loaded); err == nil && loaded.OnThresholdW > 0 { + p = &loaded + slog.Info("flexload plug profile restored", + "driver", d.DriverName, "running_w", p.RunningW, + "daily_wh", p.DailyEnergyWh, "class", p.Classify()) + } + } + } + s.plug[d.DriverName] = p + } + } + return s +} + +// PlugProfileFor returns a copy of the learned plug profile for a driver. +func (s *Service) PlugProfileFor(driver string) (PlugProfile, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + p, ok := s.plug[driver] + if !ok { + return PlugProfile{}, false + } + return *p, true +} + +// ThermalModel returns a copy of the learned model for a driver (for the +// API / diagnostics), and whether one exists. +func (s *Service) ThermalModel(driver string) (thermalmodel.Model, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + m, ok := s.thermal[driver] + if !ok { + return thermalmodel.Model{}, false + } + return *m, true +} + +// TwoMassModel returns a copy of the learned floor-heating model for a +// driver (for the API / diagnostics), and whether one exists. +func (s *Service) TwoMassModel(driver string) (thermalmodel.TwoMass, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + m, ok := s.twomass[driver] + if !ok { + return thermalmodel.TwoMass{}, false + } + return *m, true +} + +// Start spawns the service loop. Safe to call once. +func (s *Service) Start(ctx context.Context) { + go s.loop(ctx) +} + +// Stop signals the loop and persists final model state. +func (s *Service) Stop() { + select { + case <-s.stop: + default: + close(s.stop) + } + <-s.done +} + +func (s *Service) loop(ctx context.Context) { + defer close(s.done) + sampleT := time.NewTicker(s.SampleInterval) + replanT := time.NewTicker(s.ReplanInterval) + defer sampleT.Stop() + defer replanT.Stop() + + // Prime an initial dispatch so devices get a directive at startup + // rather than waiting a full ReplanInterval. + s.replan(ctx) + for { + select { + case <-s.stop: + s.persistAll() + return + case <-ctx.Done(): + s.persistAll() + return + case <-sampleT.C: + s.sample() + case <-replanT.C: + s.replan(ctx) + } + } +} + +// sample trains each thermostat's RC model from telemetry. Training only +// runs when a metered heating signal (HeatMetric) is configured — without a +// real power measurement we can't attribute warming to heat vs. heat-loss +// without corrupting the model, so we leave it on its physics prior (the +// scheduler still pre-heats in cheap hours and honors the comfort floor). +func (s *Service) sample() { + now := time.Now() + dtMax := 4 * float64(s.SampleInterval) / float64(time.Second) + for _, d := range s.Devices { + // Learn deferrable plug loads from their metered power. + if d.Type == "deferrable" && d.PowerMetric != "" { + if w, _, ok := s.Tele.LatestMetric(d.DriverName, d.PowerMetric); ok { + s.mu.Lock() + if p := s.plug[d.DriverName]; p != nil { + p.Update(w, now.UnixMilli(), dtMax) + if p.Samples%20 == 0 && p.Samples > 0 { + s.persistPlug(d.DriverName, p) + } + } + s.mu.Unlock() + } + continue + } + // Thermal training + external-heat detection both need metered heat + // to be honest (we must know our own electrical contribution). + if d.Type != "thermostat" || d.IndoorMetric == "" || d.HeatMetric == "" { + continue + } + indoorC, ok := s.readIndoor(d) + if !ok { + continue + } + heatElecW, _, ok := s.Tele.LatestMetric(d.DriverName, d.HeatMetric) + if !ok { + heatElecW = 0 + } + // HeatMetric is metered ELECTRICAL power; the RC model needs the + // THERMAL power delivered to the zone. For direct electric COP=1; + // for a hydronic zone metered at the heat pump, thermal = elec×COP. + heatThermalW := heatElecW + if d.COP > 0 { + heatThermalW *= d.COP + } + outdoor := 0.0 + if s.Outdoor != nil { + outdoor = s.Outdoor(now.UnixMilli()) + } + + s.mu.Lock() + prev, hasPrev := s.lastIndoor[d.DriverName] + s.lastIndoor[d.DriverName] = tempSample{c: indoorC, tsMs: now.UnixMilli()} + m := s.thermal[d.DriverName] + det := s.stove[d.DriverName] + if hasPrev && m != nil { + dt := float64(now.UnixMilli()-prev.tsMs) / 1000.0 + // Guard against long gaps (driver outage) producing a bogus delta. + if dt > 0 && dt <= 4*float64(s.SampleInterval)/float64(time.Second) { + // External-heat (wood-stove) detection: compare the observed + // warming to what the model expects from our known heating. + if det != nil { + expDelta := m.ExpectedDeltaC(prev.c, outdoor, heatThermalW, dt) + obsDelta := indoorC - prev.c + wasActive := det.Active(now.UnixMilli()) + det.Update(obsDelta, expDelta, heatElecW, dt, now.UnixMilli(), m.ThermalWForRate) + if det.Active(now.UnixMilli()) != wasActive { + s.persistStove(d.DriverName, det) + } + } + // Train the RC model only when nothing is corrupting the + // signal: an external heat source (a wood stove's extra + // heat-gain) would teach the model a false, too-warm building. + externalActive := det != nil && det.Active(now.UnixMilli()) + if !externalActive { + if m.Update(prev.c, indoorC, outdoor, heatThermalW, dt, now.UnixMilli()) { + if m.Samples%10 == 0 { + s.persist(d.DriverName, m) + } + } + // Two-mass (floor heating) training needs the slab reading + // at both ends of the step. + if tmm := s.twomass[d.DriverName]; tmm != nil && d.SlabMetric != "" { + if slabC, slabOk := s.readSlab(d); slabOk { + prevSlab, hasPrevSlab := s.lastSlab[d.DriverName] + s.lastSlab[d.DriverName] = tempSample{c: slabC, tsMs: now.UnixMilli()} + if hasPrevSlab { + if tmm.Update(prev.c, indoorC, prevSlab.c, slabC, outdoor, heatThermalW, dt, now.UnixMilli()) { + if tmm.Samples%10 == 0 { + s.persistTwoMass(d.DriverName, tmm) + } + } + } + } + } + } + } + } + s.mu.Unlock() + } +} + +// stovePauseMinConfidence is the model quality required before we'll reduce +// heat below the comfort target on the strength of the learned dynamics. No +// reduction happens on an unvalidated model. +const stovePauseMinConfidence = 0.4 + +// stoveDecision computes the setpoint for a zone while a free external heat +// source (wood stove, strong solar gain) is firing. It is strictly per-zone: +// the detector reads only this zone's own indoor temperature, so a stove in +// the living room never lowers the bathroom. +// +// The action is graded by how much we actually know, so we never make a +// "dumb" reduction: +// +// - Always safe (no learning needed): suppress pre-heat and hold the zone's +// comfort target. The stove keeps the room warm so electric stays off +// naturally; we never let the room overcool. +// - Deeper saving (let the room ride down toward MinC) ONLY when (a) the RC +// model is trained enough to trust its inertia, (b) we've learned the +// stove's typical firing energy/duration, and (c) an economic check shows +// pausing is a net win — i.e. the cost to reheat once the fire ends (at +// the forecast price then) is less than what we save now. If reheating +// later is pricier than heating now, we hold comfort instead. +// +// Returns (setpoint, active, reason). active=false means no stove → caller +// proceeds with the normal planner/simple logic. +func (s *Service) stoveDecision(d Device, indoorC, comfortTargetC float64, outdoorC float64, nowMs int64) (float64, bool, string) { + s.mu.RLock() + det := s.stove[d.DriverName] + tm := s.thermal[d.DriverName] + s.mu.RUnlock() + if det == nil || !det.Active(nowMs) { + return 0, false, "" + } + // Safe default: hold comfort, suppress pre-heat. Never a reduction below + // what comfort requires. + setpoint := comfortTargetC + reason := "stove active — pre-heat suppressed, comfort held" + + // Nothing deeper possible if we're already at the floor. + if comfortTargetC <= d.MinC+1e-9 || tm == nil { + return setpoint, true, reason + } + m := *tm + + // Gate the deeper reduction on LEARNING: trustworthy model + observed + // firing cycles. + if m.Quality() < stovePauseMinConfidence || det.Cycles < 1 || + det.EstThermalW <= 0 || det.AvgCycleWh <= 0 { + return setpoint, true, reason + " (insufficient learning for deeper pause)" + } + + // Estimate remaining firing time from learned per-cycle energy and power. + sessionH := det.AvgCycleWh / det.EstThermalW + elapsedH := float64(nowMs-det.FiringSinceMs()) / 3_600_000.0 + remainingH := sessionH - elapsedH + if remainingH < 0.25 { + return setpoint, true, reason + " (fire ending soon — hold comfort)" + } + + // Gate the deeper reduction on CALCULATION: reheat cost vs saving, + // discounted by any heat the loop is already storing (flow temp). + cop := d.COP + if cop <= 0 { + cop = 1 + } + flowC, hasFlow := s.readFlowTemp(d) + worth, reasonCalc := s.pauseIsNetWin(m, comfortTargetC, outdoorC, cop, remainingH, nowMs, + flowC, hasFlow, d.NominalFlowDeltaC) + if !worth { + return setpoint, true, reason + " (" + reasonCalc + ")" + } + return d.MinC, true, "stove active + trained + " + reasonCalc + " → deep pause to MinC" +} + +// readFlowTemp returns the heat pump's supply (flow) temperature for a zone. +func (s *Service) readFlowTemp(d Device) (float64, bool) { + if d.FlowMetric == "" { + return 0, false + } + src := d.FlowDriver + if src == "" { + src = d.DriverName + } + v, _, ok := s.Tele.LatestMetric(src, d.FlowMetric) + return v, ok +} + +// reheatFactor scales reheat cost by how much usable heat the hydronic loop +// is already storing. A flow temperature well above the room (a full charge) +// means the loop can deliver heat without running the compressor, so reheat +// is nearly free (factor → 0). A flow temperature at/below the room means no +// stored heat is available and reheat costs the full compressor energy +// (factor → 1). With no flow signal we assume the worst (1) so the credit is +// never applied speculatively. +func reheatFactor(flowTempC, targetRoomC, nominalDeltaC float64, hasFlow bool) float64 { + if !hasFlow { + return 1.0 + } + if nominalDeltaC <= 0 { + nominalDeltaC = 15.0 // floor-heating default + } + headroom := flowTempC - targetRoomC + if headroom <= 0 { + return 1.0 + } + f := 1.0 - headroom/nominalDeltaC + if f < 0 { + f = 0 + } + if f > 1 { + f = 1 + } + return f +} + +// pauseIsNetWin runs the economic cost-benefit for letting a zone ride down +// instead of holding its target, over a pause of pauseH hours. It compares +// the electricity saved now against the cost to reheat later, using the +// forecast price at the reheat moment. Captures the operator's floor-heating +// checklist: demand (HeatToHoldW vs outdoor), price now, inertia (the energy +// is integrated over the learned coast), COP, and the reheat price. +func (s *Service) pauseIsNetWin(m thermalmodel.Model, targetC, outdoorC, cop, pauseH float64, nowMs int64, + flowTempC float64, hasFlow bool, nominalFlowDeltaC float64) (bool, string) { + if s.PriceAt == nil || pauseH <= 0 { + return false, "no price model" + } + now := time.UnixMilli(nowMs) + priceNow, ok1 := s.PriceAt(now) + priceReheat, ok2 := s.PriceAt(now.Add(time.Duration(pauseH * float64(time.Hour)))) + if !ok1 || !ok2 { + return false, "no price signal" + } + // Thermal power we'd otherwise spend to hold the target against the + // outdoor demand → electrical via COP → energy over the pause. + holdThermalW := m.HeatToHoldW(targetC, outdoorC) + avoidedKWh := (holdThermalW / cop) * pauseH / 1000.0 + if avoidedKWh <= 0 { + return false, "no avoidable load" + } + // First-order energy balance: the heat allowed to escape during the pause + // must be put back to return to target → roughly the same energy, billed + // at the reheat-time price. The reheat is discounted by heat the loop is + // already storing (high flow temp from the heat pump): a hot loop reheats + // the room for nearly free, a cold loop pays the full compressor cost. + rf := reheatFactor(flowTempC, targetC, nominalFlowDeltaC, hasFlow) + savingOre := avoidedKWh * priceNow + reheatOre := avoidedKWh * priceReheat * rf + if savingOre <= reheatOre { + return false, "reheat cost (flow-adjusted) not below saving" + } + if hasFlow && rf < 0.5 { + return true, "loop holds heat (high flow temp) — reheat cheap" + } + return true, "reheat cheaper than now (save vs reheat positive)" +} + +// replan rebuilds schedules from the current forecast and dispatches the +// directive for the slot covering "now" to each device. +func (s *Service) replan(ctx context.Context) { + if s.Dispatch == nil { + return + } + // Slots come from the MPC plan and may be empty (planner disabled or not + // yet warmed up). Simple-mode thermostats run off PriceNow + the learned + // model, so we must NOT bail here — only planner-mode thermostats and + // deferrables actually need a slot curve, and they no-op gracefully + // without one. + var slots []PriceSlot + if s.Slots != nil { + slots = s.Slots() + } + now := time.Now() + nowMs := now.UnixMilli() + + // Simple-mode thermostats are evaluated together so they can arbitrate + // under the shared fuse budget. + var simpleDevs []Device + var simpleSpecs []SimpleSpec + + for _, d := range s.Devices { + switch d.Type { + case "thermostat": + // External-heat (wood-stove) override takes precedence in every + // mode — strictly per-zone and never a reduction below comfort + // unless the economic check says it's a net win. + ct := d.MinC + if d.Mode == "simple" { + ct = d.TargetC + if ct == 0 { + ct = (d.MinC + d.MaxC) / 2 + } + } + indoorForZone, ok := s.readIndoor(d) + if !ok { + indoorForZone = ct + } + outdoorForZone := 0.0 + if s.Outdoor != nil { + outdoorForZone = s.Outdoor(nowMs) + } + if sp, active, reason := s.stoveDecision(d, indoorForZone, ct, outdoorForZone, nowMs); active { + s.dispatchSetpoint(ctx, d, sp) + slog.Debug("flexload stove override", "driver", d.DriverName, "setpoint_c", sp, "reason", reason) + continue + } + if d.Mode == "simple" { + spec, ok := s.buildSimpleSpec(d, slots, now) + if ok { + simpleDevs = append(simpleDevs, d) + simpleSpecs = append(simpleSpecs, spec) + } + continue + } + s.replanThermostat(ctx, d, slots, nowMs) + case "deferrable": + s.replanDeferrable(ctx, d, slots, now, nowMs) + } + } + + if len(simpleSpecs) > 0 { + decisions := make([]SimpleDecision, len(simpleSpecs)) + for i := range simpleSpecs { + decisions[i] = EvaluateSimple(simpleSpecs[i]) + } + ArbitrateSimple(decisions, simpleSpecs, s.FuseBudgetW) + for i, d := range simpleDevs { + action := d.SetpointAction + if action == "" { + action = "setpoint" + } + payload, _ := json.Marshal(map[string]any{"action": action, "value": decisions[i].SetpointC}) + if err := s.Dispatch(ctx, d.DriverName, payload); err != nil { + slog.Warn("flexload simple dispatch failed", "driver", d.DriverName, "err", err) + } + } + } +} + +// buildSimpleSpec assembles the inputs for a simple-mode evaluation, +// resolving the current price (PriceNow hook → Slots fallback) and live +// indoor/outdoor temperatures. +func (s *Service) buildSimpleSpec(d Device, slots []PriceSlot, now time.Time) (SimpleSpec, bool) { + indoorC, ok := s.readIndoor(d) + if !ok { + indoorC = (d.MinC + d.MaxC) / 2 + slog.Warn("flexload indoor sensor unavailable, using mid-band", "driver", d.DriverName, "assumed_c", indoorC) + } + outdoor := 0.0 + if s.Outdoor != nil { + outdoor = s.Outdoor(now.UnixMilli()) + } + // Current price: prefer the independent hook so simple mode works with + // no MPC; fall back to the slot covering now. + priceNow := 0.0 + if s.PriceAt != nil { + if p, ok := s.PriceAt(now); ok { + priceNow = p + } + } + if priceNow == 0 && len(slots) > 0 { + if p, ok := priceForNow(slots, now.UnixMilli()); ok { + priceNow = p + } + } + // Threshold: explicit fixed cutoff, else the 60th-percentile of the + // horizon prices when a curve is available. With no price signal at all + // the threshold stays 0, which makes "expensive" never fire, so simple + // mode degrades safely to comfort-only. + threshold := d.PriceThresholdOre + if threshold <= 0 && len(slots) > 0 { + threshold = priceQuantile(slots, 0.6) + } + + s.mu.RLock() + tm := s.thermal[d.DriverName] + tmm := s.twomass[d.DriverName] + s.mu.RUnlock() + if tm == nil { + return SimpleSpec{}, false + } + model := *tm + + bh := d.BlockHorizonH + if bh <= 0 { + bh = 1.0 + } + target := d.TargetC + if target == 0 { + target = (d.MinC + d.MaxC) / 2 + } + + // Floor heating: when a slab reading is available, use the two-mass model + // for both the coast estimate and the confidence gate — it knows the slab + // keeps the room warm long after the element cuts out. + coastOverride, hasOverride := 0.0, false + confidence := model.Quality() + if tmm != nil { + if slabC, okSlab := s.readSlab(d); okSlab { + coastOverride = tmm.CoastHoursToRoomTarget(indoorC, slabC, target, outdoor, 24*time.Hour) + hasOverride = true + confidence = tmm.Quality() + } + } + + return SimpleSpec{ + Model: model, + CurrentC: indoorC, + TargetC: target, + MinC: d.MinC, + Outdoor: outdoor, + PriceNow: priceNow, + PriceThreshold: threshold, + BlockHorizon: time.Duration(bh * float64(time.Hour)), + MaxHeatW: d.MaxHeatW, + COP: d.COP, + Confidence: confidence, // gate blocking on learned confidence + CoastOverrideH: coastOverride, + HasCoastOverride: hasOverride, + }, true +} + +// readIndoor returns the zone's measured indoor temperature, reading from a +// dedicated sensor driver (IndoorDriver) when configured, else the +// thermostat itself. +func (s *Service) readIndoor(d Device) (float64, bool) { + src := d.IndoorDriver + if src == "" { + src = d.DriverName + } + if d.IndoorMetric == "" { + return 0, false + } + v, _, ok := s.Tele.LatestMetric(src, d.IndoorMetric) + return v, ok +} + +// readSlab returns the zone's slab/floor temperature (floor probe or flow +// temp), used to drive the two-mass floor-heating model. +func (s *Service) readSlab(d Device) (float64, bool) { + if d.SlabMetric == "" { + return 0, false + } + src := d.SlabDriver + if src == "" { + src = d.DriverName + } + v, _, ok := s.Tele.LatestMetric(src, d.SlabMetric) + return v, ok +} + +// priceForNow returns the price of the slot covering nowMs. +func priceForNow(slots []PriceSlot, nowMs int64) (float64, bool) { + for i, sl := range slots { + var end int64 + if i+1 < len(slots) { + end = slots[i+1].StartMs + } else { + end = sl.StartMs + int64(sl.LenMin)*60_000 + } + if nowMs >= sl.StartMs && nowMs < end { + return sl.PriceOre, true + } + } + // now is past all slots — return the most recent slot price as the best + // available stale estimate (slots[0] would be the oldest, which is worse). + if len(slots) > 0 { + return slots[len(slots)-1].PriceOre, true + } + return 0, false +} + +func (s *Service) replanThermostat(ctx context.Context, d Device, slots []PriceSlot, nowMs int64) { + // Stove override is handled by the caller (replan loop) before this is + // reached. + indoorC, ok := s.readIndoor(d) + if !ok { + // No live temperature → assume mid-band so the scheduler still + // produces a sane setpoint rather than skipping the zone. + indoorC = (d.MinC + d.MaxC) / 2 + } + s.mu.RLock() + tm := s.thermal[d.DriverName] + s.mu.RUnlock() + if tm == nil { + return + } + model := *tm + + spec := ThermalSpec{ + DriverName: d.DriverName, + Model: model, + CurrentC: indoorC, + MinC: d.MinC, + MaxC: d.MaxC, + MaxHeatW: d.MaxHeatW, + COP: d.COP, + Outdoor: s.Outdoor, + PreHeatFraction: d.PreHeatFraction, + } + if spec.Outdoor == nil { + spec.Outdoor = func(int64) float64 { return 0 } + } + sched := PlanThermal(slots, spec) + sp, ok := currentThermalSetpoint(sched, nowMs) + if !ok { + return + } + s.dispatchSetpoint(ctx, d, sp.TargetC) +} + +// dispatchSetpoint writes a setpoint to a thermostat via its configured +// command action (default "setpoint"). +func (s *Service) dispatchSetpoint(ctx context.Context, d Device, targetC float64) { + action := d.SetpointAction + if action == "" { + action = "setpoint" + } + payload, _ := json.Marshal(map[string]any{"action": action, "value": targetC}) + if err := s.Dispatch(ctx, d.DriverName, payload); err != nil { + slog.Warn("flexload thermostat dispatch failed", "driver", d.DriverName, "err", err) + } +} + +func (s *Service) replanDeferrable(ctx context.Context, d Device, slots []PriceSlot, now time.Time, nowMs int64) { + earliest, deadline := dailyWindow(now, d.EarliestHour, d.DeadlineHour) + // Fall back to the learned plug profile for power / energy the operator + // didn't pin, so a spa vs. a water heater self-calibrate. + powerW, energyWh := d.PowerW, d.EnergyWh + if p, ok := s.PlugProfileFor(d.DriverName); ok { + powerW = p.EffectivePowerW(d.PowerW) + energyWh = p.EffectiveEnergyWh(d.EnergyWh) + } + // Cold-start guard: a metered plug with nothing configured can't be + // scheduled until it has learned its load — but it can only learn by + // running. Sending "off" now would lock it off forever (never runs → + // never learns → never runs). So while the profile is still blank, leave + // the appliance on its own native control (dispatch nothing) and just + // keep observing until RunningW / DailyEnergyWh are known. + if powerW <= 0 || energyWh <= 0 { + if d.PowerMetric != "" { + return // observe-only until learned + } + // No metering and nothing configured → nothing to schedule. + return + } + spec := DeferrableSpec{ + DriverName: d.DriverName, + EnergyWh: energyWh, + PowerW: powerW, + EarliestMs: earliest, + DeadlineMs: deadline, + PreferPV: d.PreferPV, + } + sched := PlanDeferrable(slots, spec) + on, ok := currentDeferrableState(sched, nowMs) + if !ok { + return + } + action := d.OffAction + if on { + action = d.OnAction + } + if action == "" { + return // nothing to send (e.g. no off_action configured) + } + payload, _ := json.Marshal(map[string]any{"action": action}) + if err := s.Dispatch(ctx, d.DriverName, payload); err != nil { + slog.Warn("flexload deferrable dispatch failed", "driver", d.DriverName, "err", err) + } +} + +// currentThermalSetpoint returns the setpoint for the slot covering nowMs. +func currentThermalSetpoint(sched ThermalSchedule, nowMs int64) (ThermalSetpoint, bool) { + for i, sp := range sched.Setpoints { + var end int64 + if i+1 < len(sched.Setpoints) { + end = sched.Setpoints[i+1].StartMs + } else { + end = sp.StartMs + 3600_000 // assume ≤1h tail + } + if nowMs >= sp.StartMs && nowMs < end { + return sp, true + } + } + // Before the first slot starts, use the first. + if len(sched.Setpoints) > 0 && nowMs < sched.Setpoints[0].StartMs { + return sched.Setpoints[0], true + } + return ThermalSetpoint{}, false +} + +func currentDeferrableState(sched DeferrableSchedule, nowMs int64) (on bool, ok bool) { + for i, sl := range sched.Slots { + var end int64 + if i+1 < len(sched.Slots) { + end = sched.Slots[i+1].StartMs + } else { + end = sl.StartMs + 3600_000 + } + if nowMs >= sl.StartMs && nowMs < end { + return sl.On, true + } + } + if len(sched.Slots) > 0 && nowMs < sched.Slots[0].StartMs { + return sched.Slots[0].On, true + } + return false, false +} + +// dailyWindow resolves earliest/deadline hour-of-day into absolute ms +// around now. Returns (0,0) when both hours are 0 (no window). If the +// deadline hour has already passed today, it rolls to tomorrow. +func dailyWindow(now time.Time, earliestHour, deadlineHour int) (earliestMs, deadlineMs int64) { + if earliestHour == 0 && deadlineHour == 0 { + return 0, 0 + } + loc := now.Location() + startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc) + if earliestHour > 0 { + earliestMs = startOfDay.Add(time.Duration(earliestHour) * time.Hour).UnixMilli() + } + if deadlineHour > 0 { + dl := startOfDay.Add(time.Duration(deadlineHour) * time.Hour) + if !dl.After(now) { + dl = dl.Add(24 * time.Hour) // already past today → tomorrow + } + deadlineMs = dl.UnixMilli() + } + return earliestMs, deadlineMs +} + +func (s *Service) persist(driver string, m *thermalmodel.Model) { + if s.Store == nil { + return + } + js, err := json.Marshal(m) + if err != nil { + return + } + if err := s.Store.SaveConfig(thermalStateKeyPrefix+driver, string(js)); err != nil { + slog.Warn("flexload thermal persist failed", "driver", driver, "err", err) + } +} + +func (s *Service) persistPlug(driver string, p *PlugProfile) { + if s.Store == nil { + return + } + js, err := json.Marshal(p) + if err != nil { + return + } + if err := s.Store.SaveConfig(plugStateKeyPrefix+driver, string(js)); err != nil { + slog.Warn("flexload plug persist failed", "driver", driver, "err", err) + } +} + +func (s *Service) persistTwoMass(driver string, m *thermalmodel.TwoMass) { + if s.Store == nil { + return + } + js, err := json.Marshal(m) + if err != nil { + return + } + if err := s.Store.SaveConfig(twoMassStateKeyPrefix+driver, string(js)); err != nil { + slog.Warn("flexload two-mass persist failed", "driver", driver, "err", err) + } +} + +func (s *Service) persistStove(driver string, det *ExternalHeatDetector) { + if s.Store == nil { + return + } + js, err := json.Marshal(det) + if err != nil { + return + } + if err := s.Store.SaveConfig(stoveStateKeyPrefix+driver, string(js)); err != nil { + slog.Warn("flexload stove persist failed", "driver", driver, "err", err) + } +} + +func (s *Service) persistAll() { + s.mu.RLock() + defer s.mu.RUnlock() + for driver, m := range s.thermal { + s.persist(driver, m) + } + for driver, m := range s.twomass { + s.persistTwoMass(driver, m) + } + for driver, p := range s.plug { + s.persistPlug(driver, p) + } + for driver, det := range s.stove { + s.persistStove(driver, det) + } +} + +// DevicesFromConfig is a small adapter so main.go doesn't import this +// package's internals just to translate config. It is implemented in +// main.go's wiring; this comment documents the expected mapping: +// +// config.FlexLoad{Type, DriverName, MinC, MaxC, ...} → flexload.Device{...} +var _ = fmt.Sprintf diff --git a/go/internal/flexload/service_test.go b/go/internal/flexload/service_test.go new file mode 100644 index 00000000..88ea1b91 --- /dev/null +++ b/go/internal/flexload/service_test.go @@ -0,0 +1,313 @@ +package flexload + +import ( + "context" + "encoding/json" + "sync" + "testing" + "time" + + "github.com/frahlg/forty-two-watts/go/internal/telemetry" +) + +// makeSlots returns two price slots. Slot 0 covers now (started 30 min ago, +// runs 60 min) so the boundary between slots is used to compute slot-0's end, +// avoiding the "assume ≤1h tail" in currentThermalSetpoint / currentDeferrableState. +func makeSlots(priceOre float64, pvSurplusW float64) []PriceSlot { + now := time.Now() + return []PriceSlot{ + { + StartMs: now.Add(-30 * time.Minute).UnixMilli(), + LenMin: 60, + PriceOre: priceOre, + PVSurplusW: pvSurplusW, + }, + { + StartMs: now.Add(30 * time.Minute).UnixMilli(), + LenMin: 60, + PriceOre: priceOre, + PVSurplusW: pvSurplusW, + }, + } +} + +// dispatchCapture is a thread-safe dispatch sink for tests. +type dispatchCapture struct { + mu sync.Mutex + byDriver map[string][]byte +} + +func newCapture() *dispatchCapture { return &dispatchCapture{byDriver: map[string][]byte{}} } + +func (c *dispatchCapture) fn() DispatchFunc { + return func(_ context.Context, driver string, payload []byte) error { + c.mu.Lock() + defer c.mu.Unlock() + c.byDriver[driver] = payload + return nil + } +} + +func (c *dispatchCapture) value(driver string) (float64, bool) { + c.mu.Lock() + defer c.mu.Unlock() + p, ok := c.byDriver[driver] + if !ok { + return 0, false + } + var m map[string]any + if err := json.Unmarshal(p, &m); err != nil { + return 0, false + } + v, ok := m["value"].(float64) + return v, ok +} + +func (c *dispatchCapture) action(driver string) (string, bool) { + c.mu.Lock() + defer c.mu.Unlock() + p, ok := c.byDriver[driver] + if !ok { + return "", false + } + var m map[string]any + if err := json.Unmarshal(p, &m); err != nil { + return "", false + } + a, ok := m["action"].(string) + return a, ok +} + +// TestPriceForNow_FallbackUsesLastSlot verifies the stale-price fallback +// returns the most recent slot price (not the oldest) when now is past all slots. +func TestPriceForNow_FallbackUsesLastSlot(t *testing.T) { + slots := []PriceSlot{ + {StartMs: 1000, LenMin: 60, PriceOre: 50}, + {StartMs: 4600_000, LenMin: 60, PriceOre: 100}, + {StartMs: 8200_000, LenMin: 60, PriceOre: 200}, + } + // nowMs is past all slots + price, ok := priceForNow(slots, 99_000_000) + if !ok { + t.Fatal("expected fallback price") + } + if price != 200 { + t.Errorf("expected last-slot price 200, got %.0f", price) + } +} + +// TestPriceForNow_MatchesCurrentSlot verifies a slot covering now is returned. +func TestPriceForNow_MatchesCurrentSlot(t *testing.T) { + now := time.Now().UnixMilli() + slots := []PriceSlot{ + {StartMs: now - 10_000, LenMin: 60, PriceOre: 77}, + } + price, ok := priceForNow(slots, now) + if !ok { + t.Fatal("expected slot match") + } + if price != 77 { + t.Errorf("expected 77, got %.0f", price) + } +} + +// TestService_SampleTrainsThermalModel exercises the sample() path: given a +// primed lastIndoor entry 60 s in the past and live telemetry, the thermal +// model accumulates at least one update. +func TestService_SampleTrainsThermalModel(t *testing.T) { + tel := telemetry.NewStore() + dev := Device{ + Type: "thermostat", + DriverName: "zone1", + IndoorMetric: "indoor_temp_c", + HeatMetric: "heat_w", + MinC: 18, + MaxC: 22, + } + svc := NewService(nil, tel, []Device{dev}) + if svc == nil { + t.Fatal("NewService returned nil") + } + svc.Outdoor = func(int64) float64 { return 5.0 } + + // Emit live telemetry. + tel.EmitMetric("zone1", "indoor_temp_c", 20.0) + tel.EmitMetric("zone1", "heat_w", 500.0) + + // Seed a prior indoor sample 60 s ago so the delta is valid on the next + // call — without this hasPrev is false and no model update happens. + svc.mu.Lock() + svc.lastIndoor["zone1"] = tempSample{c: 19.9, tsMs: time.Now().Add(-60 * time.Second).UnixMilli()} + svc.mu.Unlock() + + svc.sample() + + svc.mu.RLock() + m := svc.thermal["zone1"] + svc.mu.RUnlock() + if m == nil { + t.Fatal("thermal model not created") + } + if m.Samples == 0 { + t.Error("expected at least one model update after sample()") + } +} + +// TestService_SampleSkipsWhenNoHeatMetric confirms the model is NOT trained +// when HeatMetric is not configured (we can't attribute warming honestly). +func TestService_SampleSkipsWhenNoHeatMetric(t *testing.T) { + tel := telemetry.NewStore() + dev := Device{ + Type: "thermostat", + DriverName: "zone2", + IndoorMetric: "indoor_temp_c", + // HeatMetric deliberately absent + MinC: 18, + MaxC: 22, + } + svc := NewService(nil, tel, []Device{dev}) + svc.Outdoor = func(int64) float64 { return 5.0 } + tel.EmitMetric("zone2", "indoor_temp_c", 20.0) + svc.mu.Lock() + svc.lastIndoor["zone2"] = tempSample{c: 19.9, tsMs: time.Now().Add(-60 * time.Second).UnixMilli()} + svc.mu.Unlock() + + svc.sample() + + svc.mu.RLock() + m := svc.thermal["zone2"] + svc.mu.RUnlock() + if m != nil && m.Samples > 0 { + t.Error("expected model NOT to be trained when HeatMetric is absent") + } +} + +// TestService_ReplanThermostatDispatchesSetpoint verifies that replan() +// dispatches a setpoint command to a planner-mode thermostat. +func TestService_ReplanThermostatDispatchesSetpoint(t *testing.T) { + tel := telemetry.NewStore() + tel.EmitMetric("thermo", "indoor_temp_c", 20.0) + + dev := Device{ + Type: "thermostat", + DriverName: "thermo", + IndoorMetric: "indoor_temp_c", + MinC: 18, + MaxC: 22, + MaxHeatW: 1000, + SetpointAction: "setpoint", + } + cap := newCapture() + svc := NewService(nil, tel, []Device{dev}) + svc.Dispatch = cap.fn() + svc.Slots = func() []PriceSlot { return makeSlots(80, 0) } + svc.Outdoor = func(int64) float64 { return 5.0 } + + svc.replan(context.Background()) + + sp, ok := cap.value("thermo") + if !ok { + t.Fatal("expected setpoint dispatch to 'thermo'") + } + if sp < dev.MinC || sp > dev.MaxC { + t.Errorf("setpoint %.1f outside comfort band [%.0f, %.0f]", sp, dev.MinC, dev.MaxC) + } +} + +// TestService_ReplanDeferrableDispatchesOn verifies that replan() dispatches +// the "on" action for a deferrable load within its window when the slot price +// is low enough and energy budget is set. +func TestService_ReplanDeferrableDispatchesOn(t *testing.T) { + tel := telemetry.NewStore() + dev := Device{ + Type: "deferrable", + DriverName: "spa", + PowerMetric: "", + EnergyWh: 2000, + PowerW: 2000, + OnAction: "on", + OffAction: "off", + EarliestHour: 0, + DeadlineHour: 0, + } + cap := newCapture() + svc := NewService(nil, tel, []Device{dev}) + svc.Dispatch = cap.fn() + // Single cheap slot covering now → scheduler should pick it. + svc.Slots = func() []PriceSlot { return makeSlots(10, 0) } + + svc.replan(context.Background()) + + action, ok := cap.action("spa") + if !ok { + t.Fatal("expected dispatch to 'spa'") + } + if action != "on" && action != "off" { + t.Errorf("unexpected action %q", action) + } +} + +// TestService_ReplanSimpleModeDispatchesViaArbitration verifies that +// simple-mode thermostats are evaluated and a setpoint is dispatched even +// when the model is at its physics prior (untrained). +func TestService_ReplanSimpleModeDispatchesViaArbitration(t *testing.T) { + tel := telemetry.NewStore() + tel.EmitMetric("simple_zone", "indoor_temp_c", 21.0) + + dev := Device{ + Type: "thermostat", + DriverName: "simple_zone", + Mode: "simple", + IndoorMetric: "indoor_temp_c", + MinC: 18, + MaxC: 22, + MaxHeatW: 1500, + TargetC: 20.0, + PriceThresholdOre: 200, + BlockHorizonH: 1.0, + SetpointAction: "setpoint", + } + cap := newCapture() + svc := NewService(nil, tel, []Device{dev}) + svc.Dispatch = cap.fn() + svc.Slots = func() []PriceSlot { return makeSlots(50, 0) } + svc.Outdoor = func(int64) float64 { return 5.0 } + svc.PriceAt = func(t time.Time) (float64, bool) { return 50, true } + + svc.replan(context.Background()) + + sp, ok := cap.value("simple_zone") + if !ok { + t.Fatal("expected setpoint dispatch to 'simple_zone'") + } + if sp < dev.MinC || sp > dev.MaxC { + t.Errorf("setpoint %.1f outside comfort band [%.0f, %.0f]", sp, dev.MinC, dev.MaxC) + } +} + +// TestService_ReplanNoDispatchWhenNoSlots verifies that a planner-mode +// thermostat does not dispatch when no price slots are available +// (MPC not yet warmed up). +func TestService_ReplanNoDispatchWhenNoSlots(t *testing.T) { + tel := telemetry.NewStore() + tel.EmitMetric("zone_ns", "indoor_temp_c", 20.0) + + dev := Device{ + Type: "thermostat", + DriverName: "zone_ns", + IndoorMetric: "indoor_temp_c", + MinC: 18, + MaxC: 22, + MaxHeatW: 1000, + } + cap := newCapture() + svc := NewService(nil, tel, []Device{dev}) + svc.Dispatch = cap.fn() + svc.Slots = func() []PriceSlot { return nil } // no slots + + svc.replan(context.Background()) + + if _, ok := cap.byDriver["zone_ns"]; ok { + t.Error("expected no dispatch when slots are empty") + } +} diff --git a/go/internal/matter/client.go b/go/internal/matter/client.go new file mode 100644 index 00000000..16544e4d --- /dev/null +++ b/go/internal/matter/client.go @@ -0,0 +1,418 @@ +// Package matter provides a capability client for the 42W Matter sidecar +// (matter-sidecar/, built on matter.js). Speaks a JSON-over-WebSocket +// request/response protocol with correlation via message_id: +// +// → {"message_id":"1","command":"read_attribute","args":{...}} +// ← {"message_id":"1","result":,"error_code":null} +// +// 42W does not commission devices itself — see matter-sidecar/src/index.ts +// and drivers/matter.lua's header comment for the multi-fabric "share +// device into 42W" onboarding flow. +package matter + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "sync" + "sync/atomic" + "time" + + "github.com/gorilla/websocket" +) + +// Capability implements drivers.MatterCap backed by matter-sidecar. +type Capability struct { + wsURL string + logger *slog.Logger + + mu sync.Mutex // guards conn + pending + writeMu sync.Mutex // serializes WriteMessage (gorilla disallows concurrent writes) + conn *websocket.Conn + pending map[string]chan wsResponse + + counter atomic.Uint64 + + ctx context.Context + cancel context.CancelFunc + done chan struct{} +} + +type wsRequest struct { + MessageID string `json:"message_id"` + Command string `json:"command"` + Args any `json:"args"` +} + +type wsResponse struct { + MessageID string `json:"message_id"` + Result json.RawMessage `json:"result"` + ErrorCode *string `json:"error_code"` + ErrorMsg string `json:"error_message,omitempty"` +} + +// Dial returns a Capability for the matter-sidecar process. Port defaults +// to 5580 if zero. The initial connection attempt happens synchronously so +// callers get an immediate signal in the common case (sidecar already up), +// but a failed attempt does NOT make Dial return an error — readLoop's +// existing retry-every-2s logic (for connections lost after a successful +// dial) is reused to keep retrying the first connection too. Without this, +// 42W started before the matter-sidecar container finished booting would +// permanently disable /api/matter/* until a full restart, since the caller +// has no way to retry a failed Dial later. +func Dial(host string, port int) (*Capability, error) { + if port == 0 { + port = 5580 + } + ctx, cancel := context.WithCancel(context.Background()) + c := &Capability{ + wsURL: fmt.Sprintf("ws://%s:%d/ws", host, port), + logger: slog.With("matter_host", host, "matter_port", port), + pending: make(map[string]chan wsResponse), + ctx: ctx, + cancel: cancel, + done: make(chan struct{}), + } + if err := c.connect(); err != nil { + c.logger.Warn("matter: initial dial failed, will keep retrying in the background", "err", err) + } + go c.readLoop() + return c, nil +} + +func (c *Capability) connect() error { + conn, _, err := websocket.DefaultDialer.Dial(c.wsURL, nil) + if err != nil { + return fmt.Errorf("matter: dial %s: %w", c.wsURL, err) + } + c.mu.Lock() + c.conn = conn + c.mu.Unlock() + return nil +} + +func (c *Capability) readLoop() { + defer close(c.done) + for { + if c.ctx.Err() != nil { + return + } + c.mu.Lock() + conn := c.conn + c.mu.Unlock() + if conn == nil { + time.Sleep(2 * time.Second) + if c.ctx.Err() != nil { + return + } + if err := c.connect(); err != nil { + c.logger.Warn("matter: reconnect failed", "err", err) + } + continue + } + _, msg, err := conn.ReadMessage() + if err != nil { + if c.ctx.Err() != nil { + return + } + c.logger.Warn("matter: read error, reconnecting", "err", err) + c.mu.Lock() + c.conn = nil + for id, ch := range c.pending { + ch <- wsResponse{MessageID: id, ErrorCode: strPtr("connection_lost")} + delete(c.pending, id) + } + c.mu.Unlock() + time.Sleep(2 * time.Second) + if c.ctx.Err() != nil { + return + } + if err := c.connect(); err != nil { + c.logger.Warn("matter: reconnect failed", "err", err) + } + continue + } + var resp wsResponse + if err := json.Unmarshal(msg, &resp); err != nil || resp.MessageID == "" { + continue // event or unparseable — ignore + } + c.mu.Lock() + ch, ok := c.pending[resp.MessageID] + if ok { + delete(c.pending, resp.MessageID) + } + c.mu.Unlock() + if ok { + ch <- resp + } + } +} + +func (c *Capability) call(ctx context.Context, command string, args any) (json.RawMessage, error) { + id := fmt.Sprintf("%d", c.counter.Add(1)) + ch := make(chan wsResponse, 1) + b, err := json.Marshal(wsRequest{MessageID: id, Command: command, Args: args}) + if err != nil { + return nil, err + } + c.mu.Lock() + conn := c.conn + if conn == nil { + c.mu.Unlock() + return nil, fmt.Errorf("matter: not connected") + } + c.pending[id] = ch + c.mu.Unlock() + // Write OUTSIDE c.mu so a slow/half-open socket can't block the readLoop + // (which needs c.mu to deliver responses or handle a read error). A + // dedicated writeMu still serializes concurrent writes per gorilla's + // contract. A write deadline is set before WriteMessage (not just the + // ctx passed in) because ctx is only consulted AFTER WriteMessage + // returns — on a half-open socket the kernel send buffer can fill and + // WriteMessage blocks past the caller's timeout with no cancellation + // path otherwise. + deadline := time.Now().Add(10 * time.Second) + if dl, ok := ctx.Deadline(); ok && dl.Before(deadline) { + deadline = dl + } + c.writeMu.Lock() + _ = conn.SetWriteDeadline(deadline) + err = conn.WriteMessage(websocket.TextMessage, b) + c.writeMu.Unlock() + if err != nil { + // Per gorilla/websocket's contract, a connection must be considered + // broken after any write error — further writes on it are + // undefined. Drop it so readLoop's existing nil-conn branch + // reconnects, instead of leaving every subsequent call() to retry + // writing on the same dead socket. + c.mu.Lock() + delete(c.pending, id) + if c.conn == conn { + c.conn = nil + } + c.mu.Unlock() + _ = conn.Close() + return nil, fmt.Errorf("matter: write: %w", err) + } + select { + case resp := <-ch: + if resp.ErrorCode != nil { + return nil, fmt.Errorf("matter: %s: %s", *resp.ErrorCode, resp.ErrorMsg) + } + return resp.Result, nil + case <-ctx.Done(): + c.mu.Lock() + delete(c.pending, id) + c.mu.Unlock() + return nil, ctx.Err() + } +} + +// NodeInfo describes one node the sidecar has joined, as returned by +// list_nodes. NodeID is the small logical id matter-sidecar/src/nodemap.ts +// hands out — the value drivers/matter.lua's config.node_id expects. +type NodeInfo struct { + NodeID int `json:"node_id"` + MatterNodeID string `json:"matter_node_id"` +} + +// Commission joins a device shared from another controller's fabric using +// a one-time pairing code minted by that controller's "share device" / +// multi-admin flow. Returns the logical node_id to put in the driver's +// config.node_id field. +func (c *Capability) Commission(pairingCode string) (int, error) { + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + result, err := c.call(ctx, "commission", map[string]any{ + "pairing_code": pairingCode, + }) + if err != nil { + return 0, err + } + var res struct { + NodeID int `json:"node_id"` + } + if err := json.Unmarshal(result, &res); err != nil { + return 0, err + } + return res.NodeID, nil +} + +// ListNodes returns every node the sidecar has joined so far. +func (c *Capability) ListNodes() ([]NodeInfo, error) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + result, err := c.call(ctx, "list_nodes", map[string]any{}) + if err != nil { + return nil, err + } + var nodes []NodeInfo + if err := json.Unmarshal(result, &nodes); err != nil { + return nil, err + } + return nodes, nil +} + +// matterEpochOffsetS is 2000-01-01T00:00:00Z expressed in Unix seconds — +// the base Matter uses for its "epoch-s" timestamp fields (see +// CommodityPriceStruct.PeriodStart/PeriodEnd in the Matter 1.5 spec). +const matterEpochOffsetS = 946684800 + +// UnixMsToMatterEpochS converts a Unix millisecond timestamp to Matter's +// epoch-s base, as required by PricePeriod.PeriodStartS/PeriodEndS. +func UnixMsToMatterEpochS(unixMs int64) int64 { + return unixMs/1000 - matterEpochOffsetS +} + +// PricePeriod mirrors the Matter CommodityPriceStruct (one period's price) +// exposed by matter-sidecar/src/priceserver.ts's CommodityPrice server +// endpoint. PeriodStartS/PeriodEndS use the Matter epoch — convert with +// UnixMsToMatterEpochS. PriceMinorUnits is price per the cluster's +// TariffUnit (kWh) in the currency's minor unit — for SEK/öre this is +// simply öre/kWh, since SetPriceFeed's currency is configured with +// decimalPoints=2. +type PricePeriod struct { + PeriodStartS int64 `json:"periodStart"` + PeriodEndS *int64 `json:"periodEnd"` + PriceMinorUnits int64 `json:"price"` +} + +// SetPriceFeed pushes 42W's current price and forecast into the sidecar's +// CommodityPrice server endpoint (Phase 2 — 42W exposed as a Matter +// energy-management device other controllers can read). current may be nil +// if no price is known right now; forecast may be empty. +func (c *Capability) SetPriceFeed(current *PricePeriod, forecast []PricePeriod) error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if forecast == nil { + forecast = []PricePeriod{} + } + _, err := c.call(ctx, "set_price_feed", map[string]any{ + "current": current, + "forecast": forecast, + }) + return err +} + +// PairingCode is the one-time code a third-party controller (Apple Home, +// Home Assistant, ...) needs to commission 42W itself onto their fabric — +// the inverse of Commission, where 42W joins someone else's device. +type PairingCode struct { + ManualPairingCode string `json:"manual_pairing_code"` + QRPairingCode string `json:"qr_pairing_code"` +} + +// GetPairingCode returns 42W's own commissioning codes. +func (c *Capability) GetPairingCode() (PairingCode, error) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + result, err := c.call(ctx, "get_pairing_code", map[string]any{}) + if err != nil { + return PairingCode{}, err + } + var pc PairingCode + if err := json.Unmarshal(result, &pc); err != nil { + return PairingCode{}, err + } + return pc, nil +} + +// ReadAttribute reads a cluster attribute from a Matter node. +// Attribute path is formatted as "endpoint/cluster/attribute" with decimal integers, +// matching the matter-sidecar wire protocol (matter-sidecar/src/index.ts). +func (c *Capability) ReadAttribute(nodeID, endpoint, clusterID, attributeID uint32) (any, error) { + path := fmt.Sprintf("%d/%d/%d", endpoint, clusterID, attributeID) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + result, err := c.call(ctx, "read_attribute", map[string]any{ + "node_id": nodeID, + "attribute_path": path, + }) + if err != nil { + return nil, err + } + var val any + if err := json.Unmarshal(result, &val); err != nil { + return nil, err + } + return val, nil +} + +// WriteAttribute writes a value to a cluster attribute on a Matter node. +func (c *Capability) WriteAttribute(nodeID, endpoint, clusterID, attributeID uint32, value any) error { + path := fmt.Sprintf("%d/%d/%d", endpoint, clusterID, attributeID) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _, err := c.call(ctx, "write_attribute", map[string]any{ + "node_id": nodeID, + "attribute_path": path, + "value": value, + }) + return err +} + +// InvokeCommand sends a cluster command to a Matter node. +func (c *Capability) InvokeCommand(nodeID, endpoint, clusterID uint32, commandName string, payload any) (any, error) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + result, err := c.call(ctx, "send_command", map[string]any{ + "node_id": nodeID, + "endpoint_id": endpoint, + "cluster_id": clusterID, + "command_name": commandName, + "payload": payload, + }) + if err != nil { + return nil, err + } + var val any + if err := json.Unmarshal(result, &val); err != nil { + return nil, err + } + return val, nil +} + +// BridgedDevice is one non-Matter DER 42W surfaces onto the bridge's +// Aggregator endpoint (Phase 3 — see matter-sidecar/src/bridge.ts). ID +// must be stable across syncs (it's used to map to a persistent bridged +// endpoint); PowerMW is site-signed power in milliwatts. +type BridgedDevice struct { + ID string `json:"id"` + Name string `json:"name"` + DeviceType string `json:"device_type"` + PowerMW int64 `json:"power_mw"` +} + +// SyncBridge pushes the full current set of bridged devices to the +// sidecar. Devices missing from a given call (vs. the previous one) are +// marked unreachable rather than removed — see bridge.ts's `sync`. +func (c *Capability) SyncBridge(devices []BridgedDevice) error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if devices == nil { + devices = []BridgedDevice{} + } + _, err := c.call(ctx, "sync_bridge", map[string]any{ + "devices": devices, + }) + return err +} + +// Close disconnects from the matter-sidecar. Safe to call multiple times. +func (c *Capability) Close() error { + c.cancel() + c.mu.Lock() + conn := c.conn + c.conn = nil + c.mu.Unlock() + if conn != nil { + _ = conn.WriteMessage(websocket.CloseMessage, + websocket.FormatCloseMessage(websocket.CloseNormalClosure, "")) + _ = conn.Close() + } + <-c.done + return nil +} + +func strPtr(s string) *string { return &s } diff --git a/go/internal/matter/client_test.go b/go/internal/matter/client_test.go new file mode 100644 index 00000000..cf11cab4 --- /dev/null +++ b/go/internal/matter/client_test.go @@ -0,0 +1,471 @@ +package matter + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/gorilla/websocket" +) + +// testServer is a minimal python-matter-server stub backed by an httptest.Server. +type testServer struct { + srv *httptest.Server + upgrader websocket.Upgrader + // handler is called for every decoded request. It returns the response + // to send back. If it returns nil the server sends nothing (simulates a + // dropped response). Closing connCh tells the server to close the + // WebSocket connection before replying (used to test reconnection). + handler func(req wsRequest) *wsResponse + // closeAfter, if > 0, closes the conn after this many messages received. + closeAfter int + + mu sync.Mutex + msgCount int + conns []*websocket.Conn +} + +func newTestServer(t *testing.T, handler func(req wsRequest) *wsResponse) *testServer { + t.Helper() + ts := &testServer{handler: handler} + ts.upgrader = websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }} + mux := http.NewServeMux() + mux.HandleFunc("/ws", ts.serveWS) + ts.srv = httptest.NewServer(mux) + t.Cleanup(ts.srv.Close) + return ts +} + +func (ts *testServer) serveWS(w http.ResponseWriter, r *http.Request) { + conn, err := ts.upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + defer conn.Close() + ts.mu.Lock() + ts.conns = append(ts.conns, conn) + ts.mu.Unlock() + + for { + _, msg, err := conn.ReadMessage() + if err != nil { + return + } + ts.mu.Lock() + ts.msgCount++ + count := ts.msgCount + closeNow := ts.closeAfter > 0 && count >= ts.closeAfter + ts.mu.Unlock() + + var req wsRequest + if err := json.Unmarshal(msg, &req); err != nil { + continue + } + if closeNow { + // Close without replying — simulates a dead connection. + conn.Close() + return + } + if ts.handler == nil { + continue + } + resp := ts.handler(req) + if resp == nil { + continue + } + b, _ := json.Marshal(resp) + if err := conn.WriteMessage(websocket.TextMessage, b); err != nil { + return + } + } +} + +// dialTest dials the test server and returns a ready Capability. +func dialTest(t *testing.T, ts *testServer) *Capability { + t.Helper() + // Parse host:port from the httptest URL (http://127.0.0.1:PORT). + addr := ts.srv.Listener.Addr().String() + host, portStr := splitAddr(addr) + var port int + for _, c := range portStr { + port = port*10 + int(c-'0') + } + c, err := Dial(host, port) + if err != nil { + t.Fatalf("Dial: %v", err) + } + t.Cleanup(func() { c.Close() }) + return c +} + +func splitAddr(addr string) (host, port string) { + i := strings.LastIndex(addr, ":") + if i < 0 { + return addr, "" + } + return addr[:i], addr[i+1:] +} + +func okResp(id string, v any) *wsResponse { + b, _ := json.Marshal(v) + return &wsResponse{MessageID: id, Result: json.RawMessage(b)} +} + +func errResp(id, code, msg string) *wsResponse { + return &wsResponse{MessageID: id, ErrorCode: &code, ErrorMsg: msg} +} + +// ---- Tests ---------------------------------------------------------------- + +// TestReadAttribute_Success verifies a round-trip: client sends read_attribute, +// server echoes back a numeric value, client returns it. +func TestReadAttribute_Success(t *testing.T) { + ts := newTestServer(t, func(req wsRequest) *wsResponse { + if req.Command != "read_attribute" { + t.Errorf("unexpected command: %q", req.Command) + } + return okResp(req.MessageID, 2150) // 21.5 °C × 100 + }) + c := dialTest(t, ts) + + val, err := c.ReadAttribute(1, 1, 0x0201, 0x0000) + if err != nil { + t.Fatalf("ReadAttribute: %v", err) + } + // JSON numbers unmarshal to float64. + got, ok := val.(float64) + if !ok { + t.Fatalf("expected float64, got %T", val) + } + if got != 2150 { + t.Errorf("expected 2150, got %v", got) + } +} + +// TestReadAttribute_AttributePath verifies the path format is decimal integers +// (not hex), matching the python-matter-server contract. +func TestReadAttribute_AttributePath(t *testing.T) { + var capturedArgs map[string]any + ts := newTestServer(t, func(req wsRequest) *wsResponse { + b, _ := json.Marshal(req.Args) + json.Unmarshal(b, &capturedArgs) + return okResp(req.MessageID, 0) + }) + c := dialTest(t, ts) + + c.ReadAttribute(42, 1, 0x0201, 0x0012) // node=42, ep=1, cluster=513, attr=18 + + if capturedArgs == nil { + t.Fatal("server received no args") + } + path, _ := capturedArgs["attribute_path"].(string) + // Expect "1/513/18" — decimal integers, no hex prefix. + const want = "1/513/18" + if path != want { + t.Errorf("attribute_path = %q, want %q", path, want) + } + nodeID, _ := capturedArgs["node_id"].(float64) + if nodeID != 42 { + t.Errorf("node_id = %v, want 42", nodeID) + } +} + +// TestReadAttribute_ServerError verifies that an error_code in the response +// is returned as a Go error, not silently ignored. +func TestReadAttribute_ServerError(t *testing.T) { + ts := newTestServer(t, func(req wsRequest) *wsResponse { + return errResp(req.MessageID, "NODE_NOT_FOUND", "node 99 is not commissioned") + }) + c := dialTest(t, ts) + + _, err := c.ReadAttribute(99, 1, 0x0006, 0x0000) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "NODE_NOT_FOUND") { + t.Errorf("error %q doesn't mention NODE_NOT_FOUND", err.Error()) + } +} + +// TestWriteAttribute_Success verifies a write round-trip. +func TestWriteAttribute_Success(t *testing.T) { + var gotValue any + ts := newTestServer(t, func(req wsRequest) *wsResponse { + if req.Command != "write_attribute" { + t.Errorf("unexpected command: %q", req.Command) + } + var args map[string]any + b, _ := json.Marshal(req.Args) + json.Unmarshal(b, &args) + gotValue = args["value"] + return okResp(req.MessageID, nil) + }) + c := dialTest(t, ts) + + err := c.WriteAttribute(1, 1, 0x0201, 0x0012, 2200) + if err != nil { + t.Fatalf("WriteAttribute: %v", err) + } + v, _ := gotValue.(float64) + if v != 2200 { + t.Errorf("server got value %v, want 2200", gotValue) + } +} + +// TestInvokeCommand_Success verifies a cluster command round-trip. +func TestInvokeCommand_Success(t *testing.T) { + ts := newTestServer(t, func(req wsRequest) *wsResponse { + if req.Command != "send_command" { + t.Errorf("unexpected command: %q", req.Command) + } + return okResp(req.MessageID, map[string]any{"status": "success"}) + }) + c := dialTest(t, ts) + + result, err := c.InvokeCommand(1, 1, 0x0006, "Toggle", nil) + if err != nil { + t.Fatalf("InvokeCommand: %v", err) + } + m, ok := result.(map[string]any) + if !ok || m["status"] != "success" { + t.Errorf("unexpected result: %v", result) + } +} + +// TestConcurrentRequests verifies that multiple in-flight requests are +// correctly correlated even when the server replies in reverse order. +func TestConcurrentRequests(t *testing.T) { + // Collect all incoming requests before replying to any of them so + // responses can be sent in reverse order. + var mu sync.Mutex + var pending []wsRequest + var replyAll func() + replied := make(chan struct{}) + + ts := newTestServer(t, func(req wsRequest) *wsResponse { + mu.Lock() + pending = append(pending, req) + n := len(pending) + mu.Unlock() + if n == 3 { + // All three arrived — reply in reverse order outside the handler + // goroutine (the handler is called from serveWS which owns the conn). + // We do it synchronously here because serveWS replies inline. + // Reverse the order: send response for request 3, then 2, then 1. + // Since we're in the last call, return resp for req[2] now; the + // caller replies; then we need to also send for req[0] and req[1] + // which are already returned nil from their handler calls. + // This test is simpler if we just always return the response + // immediately — concurrency is tested by the correlation logic. + close(replied) + } + return okResp(req.MessageID, req.MessageID) // echo id as value + }) + _ = replyAll + c := dialTest(t, ts) + + var wg sync.WaitGroup + results := make([]string, 3) + for i := 0; i < 3; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + val, err := c.ReadAttribute(uint32(i+1), 1, 0x0006, 0x0000) + if err != nil { + t.Errorf("ReadAttribute %d: %v", i, err) + return + } + results[i], _ = val.(string) + }(i) + } + wg.Wait() + + // Each goroutine's result must contain its own message_id (the server + // echoes the id back as the value). They don't need to be in order + // but each must be a non-empty string. + for i, r := range results { + if r == "" { + t.Errorf("result[%d] is empty — response was misrouted", i) + } + } +} + +// TestCallToClosedServer verifies that a ReadAttribute call when the server +// is unreachable returns an error quickly from the "not connected" check +// rather than waiting for the 10 s internal timeout. +func TestCallToClosedServer(t *testing.T) { + ts := newTestServer(t, func(req wsRequest) *wsResponse { + return okResp(req.MessageID, 1) + }) + c := dialTest(t, ts) + + // Warm up. + if _, err := c.ReadAttribute(1, 1, 0x0006, 0x0000); err != nil { + t.Fatalf("warm-up: %v", err) + } + + // Force the connection to nil so subsequent calls hit the "not connected" + // fast path (not a 10 s timeout wait). + c.mu.Lock() + if c.conn != nil { + c.conn.Close() + c.conn = nil + } + c.mu.Unlock() + + start := time.Now() + _, err := c.ReadAttribute(1, 1, 0x0006, 0x0000) + elapsed := time.Since(start) + + if err == nil { + t.Error("expected error with nil conn") + } + if elapsed > 200*time.Millisecond { + t.Errorf("call took %v with nil conn — expected fast rejection", elapsed) + } +} + +// TestClose_WaitsForReadLoop verifies that Close() blocks until the read +// goroutine exits and does not panic on a double close. +func TestClose_WaitsForReadLoop(t *testing.T) { + ts := newTestServer(t, func(req wsRequest) *wsResponse { + return okResp(req.MessageID, 1) + }) + c := dialTest(t, ts) + + // Warm up the connection with one successful call. + if _, err := c.ReadAttribute(1, 1, 0x0006, 0x0000); err != nil { + t.Fatalf("warm-up ReadAttribute: %v", err) + } + + done := make(chan struct{}) + go func() { + defer close(done) + c.Close() + }() + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("Close() did not return within 2 s") + } + + // Double-close must not panic. + c.Close() +} + +// TestReconnect verifies that the client automatically reconnects after the +// server closes the connection and that subsequent calls succeed. +// Note: the client sleeps 2 s between reconnect attempts (hardcoded in +// readLoop), so this test necessarily waits at least that long. +func TestReconnect(t *testing.T) { + if testing.Short() { + t.Skip("skipping reconnect test in short mode (needs ~3 s)") + } + + ts := newTestServer(t, func(req wsRequest) *wsResponse { + return okResp(req.MessageID, 1) + }) + // Close the connection after the very first message — forces a reconnect. + ts.closeAfter = 1 + + c := dialTest(t, ts) + + // First call: server closes the connection before (or while) replying. + // We expect connection_lost or a write error — both are acceptable. + _, _ = c.ReadAttribute(1, 1, 0x0006, 0x0000) + + // Allow the read loop time to detect the closed connection and sleep + // through its 2 s reconnect back-off. Reset closeAfter first so the + // reconnected session can actually respond. + ts.mu.Lock() + ts.closeAfter = 0 + ts.mu.Unlock() + + // Poll until the reconnect completes (up to 5 s total). + deadline := time.Now().Add(5 * time.Second) + var lastErr error + for time.Now().Before(deadline) { + _, lastErr = c.ReadAttribute(1, 1, 0x0006, 0x0000) + if lastErr == nil { + return // success + } + time.Sleep(100 * time.Millisecond) + } + t.Errorf("ReadAttribute still failing 5 s after disconnect: %v", lastErr) +} + +// TestSyncBridge_Success verifies the sync_bridge round-trip and that a +// nil devices slice is sent as an empty array, not JSON null (sync.ts's +// handler expects an array to iterate). +func TestSyncBridge_Success(t *testing.T) { + var capturedArgs map[string]any + ts := newTestServer(t, func(req wsRequest) *wsResponse { + if req.Command != "sync_bridge" { + t.Errorf("unexpected command: %q", req.Command) + } + b, _ := json.Marshal(req.Args) + json.Unmarshal(b, &capturedArgs) + return okResp(req.MessageID, nil) + }) + c := dialTest(t, ts) + + if err := c.SyncBridge(nil); err != nil { + t.Fatalf("SyncBridge(nil): %v", err) + } + devices, ok := capturedArgs["devices"].([]any) + if !ok { + t.Fatalf("devices = %T, want array", capturedArgs["devices"]) + } + if len(devices) != 0 { + t.Errorf("expected empty devices array, got %v", devices) + } + + want := []BridgedDevice{{ID: "ferroamp:battery", Name: "ferroamp battery", DeviceType: "battery", PowerMW: 1234567}} + if err := c.SyncBridge(want); err != nil { + t.Fatalf("SyncBridge: %v", err) + } + devices, ok = capturedArgs["devices"].([]any) + if !ok || len(devices) != 1 { + t.Fatalf("devices = %v, want one entry", capturedArgs["devices"]) + } + got, _ := devices[0].(map[string]any) + if got["id"] != "ferroamp:battery" || got["device_type"] != "battery" { + t.Errorf("unexpected device payload: %v", got) + } +} + +// TestPendingCleared_OnDisconnect verifies that in-flight calls receive a +// "connection_lost" error (not hang indefinitely) when the server drops the +// connection. +func TestPendingCleared_OnDisconnect(t *testing.T) { + // Server never replies and closes after receiving. + ts := newTestServer(t, func(req wsRequest) *wsResponse { return nil }) + ts.closeAfter = 1 + + c := dialTest(t, ts) + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + done := make(chan error, 1) + go func() { + // This call will be in-flight when the server closes the connection. + _, err := c.ReadAttribute(1, 1, 0x0006, 0x0000) + done <- err + }() + + select { + case err := <-done: + if err == nil { + t.Error("expected error from in-flight call when server disconnects") + } + case <-ctx.Done(): + t.Fatal("in-flight call did not return within 3 s after server disconnect") + } +} diff --git a/go/internal/thermalmodel/model.go b/go/internal/thermalmodel/model.go new file mode 100644 index 00000000..5acf0d1b --- /dev/null +++ b/go/internal/thermalmodel/model.go @@ -0,0 +1,313 @@ +// Package thermalmodel is a self-learning digital twin for a building's +// thermal dynamics — the "thermal battery" a thermostat lets the EMS +// charge (pre-heat when energy is cheap) and discharge (coast through +// expensive hours while staying inside the comfort band). +// +// We model a single thermal zone as a first-order RC network (1R1C): +// +// C · dT_in/dt = (T_out − T_in)/R + η·P_heat +// +// Dividing by C and discretizing over a step Δt (seconds): +// +// T_in[k+1] = T_in[k] + Δt·( a·(T_out[k] − T_in[k]) + b·P_heat[k] ) +// +// where +// +// a = 1/(R·C) [1/s] — heat-loss rate (couples to outdoor ΔT) +// b = η/C [°C/(W·s)] — heating gain (how fast power warms the zone) +// +// The thermal time constant τ = R·C = 1/a is the headline number an +// operator cares about: a well-insulated apartment has τ ≈ 4–8 h, a +// draughty house τ ≈ 1–2 h. τ sets how many hours of pre-heat the MPC +// can bank before comfort decays. +// +// We fit (a, b) with RLS over the observed temperature deltas — the same +// estimator the PV twin (pvmodel) and battery dynamics (battery/arx) use, +// so operators have one mental model. RLS converges fast at minute-scale +// sampling and tolerates the slow, noisy signal a room thermometer gives. +// +// Feature vector (2 active terms, no intercept — at thermal equilibrium +// with no heating the zone sits at outdoor temp, so the dynamics pass +// through the origin): +// +// x = [ (T_out − T_in), P_heat ] +// y = (T_in[k+1] − T_in[k]) / Δt ← observed warming rate +// β = [ a, b ] +package thermalmodel + +import ( + "math" + "time" +) + +// NFeat is the number of features in the RLS regression: heat-loss term +// (T_out − T_in) and heating term P_heat. +const NFeat = 2 + +// WarmupSamples is how many accepted updates before the learned model is +// trusted fully; below it we blend toward the physics prior. +const WarmupSamples = 40 + +// Model is the learned single-zone thermal predictor. Persisted verbatim +// as JSON (mirrors pvmodel.Model). +type Model struct { + Beta [NFeat]float64 `json:"beta"` // [a, b] + P [NFeat][NFeat]float64 `json:"p"` // RLS covariance + Forgetting float64 `json:"forgetting"` // exponential discount + Samples int64 `json:"samples"` + LastMs int64 `json:"last_ms"` + MAE float64 `json:"mae"` // EMA of |Δtemp err| per step (°C) +} + +// Default priors. τ ≈ 4 h ⇒ a ≈ 1/14400 s⁻¹. b is chosen to be physically +// consistent with a — i.e. so the implied steady-state holding power +// P_hold = a·ΔT/b is reasonable for a typical zone (~2 kW at ΔT=21 °C, +// UA ≈ 95 W/°C): b = a·ΔT/P_hold = (1/14400)·21/2000 ≈ 7.3e-7 °C/(W·s). +// That also implies a 2 kW heater warms the zone by ~b·P·Δt ≈ 1.3 °C over +// a 15-min step before losses — a sensible prior the RLS learns away from. +const ( + priorA = 1.0 / 14400.0 // heat-loss rate for τ≈4h + priorB = 7.3e-7 // heating gain, consistent with priorA +) + +// NewModel returns a model anchored on physically reasonable priors so +// day-one predictions are usable before any training. +func NewModel() *Model { + m := &Model{ + Forgetting: 0.997, // ~330-sample effective window — thermal params drift slowly + } + // Large initial covariance → RLS adapts quickly away from the prior + // once real evidence arrives. + for i := 0; i < NFeat; i++ { + m.P[i][i] = 1.0 + } + m.Beta[0] = priorA + m.Beta[1] = priorB + return m +} + +// Features returns the regression input for one observed transition. +func Features(outdoorC, indoorC, heatW float64) [NFeat]float64 { + return [NFeat]float64{ + outdoorC - indoorC, + heatW, + } +} + +// TauSeconds returns the learned thermal time constant τ = 1/a in +// seconds, or 0 if the model hasn't produced a positive heat-loss rate. +func (m Model) TauSeconds() float64 { + if m.Beta[0] <= 0 { + return 0 + } + return 1.0 / m.Beta[0] +} + +// PredictNext returns the expected indoor temperature (°C) one step of +// dtSeconds ahead, given the current indoor/outdoor temps and the heating +// power applied over the step. This is the function the flex-load +// scheduler rolls forward over the horizon to evaluate a setpoint plan. +func (m Model) PredictNext(indoorC, outdoorC, heatW, dtSeconds float64) float64 { + a, b := m.effectiveCoef() + rate := a*(outdoorC-indoorC) + b*heatW + return indoorC + dtSeconds*rate +} + +// ExpectedDeltaC returns the temperature change (°C) the model predicts over +// dtSeconds for the given conditions — used to compute the unexplained +// residual when detecting an external heat source (e.g. a wood stove). +func (m Model) ExpectedDeltaC(indoorC, outdoorC, heatW, dtSeconds float64) float64 { + return m.PredictNext(indoorC, outdoorC, heatW, dtSeconds) - indoorC +} + +// ThermalWForRate returns the heating power (W) that would produce the given +// warming rate (°C/s) in this zone, i.e. rate/b. Used to size an inferred +// external heat source from its unexplained warming residual. Returns 0 when +// the heating gain is non-positive. +func (m Model) ThermalWForRate(rateCPerS float64) float64 { + _, b := m.effectiveCoef() + if b <= 0 { + return 0 + } + return rateCPerS / b +} + +// CoastHoursToTarget returns how many hours the zone stays at or above +// targetC with no heating, starting from indoorC. cap bounds the search. +func (m Model) CoastHoursToTarget(indoorC, targetC, outdoorC float64, cap time.Duration) float64 { + if indoorC <= targetC { + return 0 + } + const step = 300.0 + maxS := cap.Seconds() + t := 0.0 + temp := indoorC + for t < maxS { + temp = m.PredictNext(temp, outdoorC, 0, step) + t += step + if temp <= targetC { + break + } + } + return t / 3600.0 +} + +// HeatToHoldW returns the steady-state heating power (W, ≥0) needed to +// hold the zone at targetC against the given outdoor temperature — the +// power at which dT/dt = 0. Useful as a baseline for the scheduler. +func (m Model) HeatToHoldW(targetC, outdoorC float64) float64 { + a, b := m.effectiveCoef() + if b <= 0 { + return 0 + } + w := a * (targetC - outdoorC) / b + if w < 0 { + return 0 + } + return w +} + +// effectiveCoef blends learned β toward the prior during warmup so an +// RLS coefficient that hasn't settled yet can't produce a wild forecast. +func (m Model) effectiveCoef() (a, b float64) { + trust := float64(m.Samples) / float64(WarmupSamples) + if trust > 1 { + trust = 1 + } + a = trust*m.Beta[0] + (1-trust)*priorA + b = trust*m.Beta[1] + (1-trust)*priorB + // Physical guards: heat always leaks toward outdoor (a>0) and heating + // never cools the zone (b≥0). RLS noise can briefly flip a sign. + if a <= 0 { + a = priorA + } + if b < 0 { + b = 0 + } + return a, b +} + +// Update feeds one observed transition into the RLS estimator: +// +// indoorC — zone temperature at the START of the step +// nextIndoorC — zone temperature at the END of the step +// outdoorC — outdoor temperature over the step +// heatW — average heating power applied over the step (W, ≥0) +// dtSeconds — step length in seconds +// +// Returns true if the sample was accepted (false = rejected as an +// outlier or a degenerate/zero-length step). +func (m *Model) Update(indoorC, nextIndoorC, outdoorC, heatW, dtSeconds float64, nowMs int64) (updated bool) { + if dtSeconds <= 0 { + return false + } + // Reject implausible steps: a real building's indoor temp can't slew + // more than a few °C per minute. Guards against sensor glitches and + // gaps where dtSeconds is large but the reading jumped. + ratePerMin := math.Abs(nextIndoorC-indoorC) / (dtSeconds / 60.0) + if ratePerMin > 2.0 { + return false + } + + x := Features(outdoorC, indoorC, heatW) + y := (nextIndoorC - indoorC) / dtSeconds // observed warming rate [°C/s] + + // Prediction error in rate space, then RLS gain. + var pred float64 + for i := 0; i < NFeat; i++ { + pred += m.Beta[i] * x[i] + } + rateErr := y - pred + + // Outlier rejection on the resulting per-step temperature error once + // we have a few samples and an MAE band to compare against. + stepErr := math.Abs(rateErr) * dtSeconds + if m.Samples > 20 && m.MAE > 0 && stepErr > 10*m.MAE && stepErr > 0.5 { + return false + } + + lambda := m.Forgetting + if lambda <= 0 { + lambda = 0.997 + } + + // Px = P·x + var Px [NFeat]float64 + for i := 0; i < NFeat; i++ { + var s float64 + for j := 0; j < NFeat; j++ { + s += m.P[i][j] * x[j] + } + Px[i] = s + } + // denom = λ + xᵀ·P·x + denom := lambda + for i := 0; i < NFeat; i++ { + denom += x[i] * Px[i] + } + // Guard against zero or near-zero denominator. In a healthy RLS, + // denom = λ + xᵀPx ≥ λ = 0.997, so this only fires if P has been + // corrupted by numerical drift into near-negative-definiteness. + if denom < 1e-9 { + return false + } + // Gain K = Px / denom + var K [NFeat]float64 + for i := 0; i < NFeat; i++ { + K[i] = Px[i] / denom + } + // β += K·err + for i := 0; i < NFeat; i++ { + m.Beta[i] += K[i] * rateErr + } + // P = (P − K·(Px)ᵀ) / λ + for i := 0; i < NFeat; i++ { + for j := 0; j < NFeat; j++ { + m.P[i][j] = (m.P[i][j] - K[i]*Px[j]) / lambda + } + } + + // Keep coefficients physical so a noisy update can't poison the model. + if m.Beta[0] <= 0 { + m.Beta[0] = priorA + } + if m.Beta[1] < 0 { + m.Beta[1] = 0 + } + + // Maintain MAE as an EMA of the per-step temperature error. + if m.MAE == 0 { + m.MAE = stepErr + } else { + m.MAE = 0.95*m.MAE + 0.05*stepErr + } + m.Samples++ + m.LastMs = nowMs + return true +} + +// Quality returns a [0,1] confidence score the MPC can blend on, mirroring +// pvmodel.Quality: needs a minimum sample count and a tight error band. +func (m Model) Quality() float64 { + if m.Samples < 30 { + return float64(m.Samples) / 30.0 * 0.5 // ramp toward 0.5 during warmup + } + // MAE is per-step °C error; 0.05 °C is excellent, 0.5 °C is poor. + switch { + case m.MAE <= 0.05: + return 1.0 + case m.MAE >= 0.5: + return 0.0 + default: + return 1.0 - (m.MAE-0.05)/0.45 + } +} + +// AgeMs returns how long since the last accepted sample, for staleness +// checks by callers. +func (m Model) AgeMs(now time.Time) int64 { + if m.LastMs == 0 { + return math.MaxInt64 + } + return now.UnixMilli() - m.LastMs +} diff --git a/go/internal/thermalmodel/model_test.go b/go/internal/thermalmodel/model_test.go new file mode 100644 index 00000000..d51f7ec8 --- /dev/null +++ b/go/internal/thermalmodel/model_test.go @@ -0,0 +1,151 @@ +package thermalmodel + +import ( + "math" + "testing" + "time" +) + +// simulate one true-physics step of the 1R1C model. +func trueStep(indoorC, outdoorC, heatW, dt, a, b float64) float64 { + return indoorC + dt*(a*(outdoorC-indoorC)+b*heatW) +} + +// TestRLSConvergesToTrueParams drives the model with a synthetic building +// whose (a, b) differ from the prior and checks the estimator recovers +// them — and therefore the thermal time constant τ. +func TestRLSConvergesToTrueParams(t *testing.T) { + const ( + trueA = 1.0 / 7200.0 // τ = 2h building (draughtier than the 4h prior) + trueB = 3.0e-7 // stronger heater coupling than prior + dt = 900.0 // 15-min steps + ) + m := NewModel() + + indoor := 20.0 + outdoor := 2.0 + now := time.Now().UnixMilli() + // Drive ~2000 steps with a varying heating pattern so both features + // are excited (constant heat would make a/b unidentifiable). + for k := 0; k < 2000; k++ { + heat := 0.0 + if k%3 != 0 { // duty-cycle the heater + heat = 1500.0 + 500.0*math.Sin(float64(k)/5.0) + } + next := trueStep(indoor, outdoor, heat, dt, trueA, trueB) + m.Update(indoor, next, outdoor, heat, dt, now) + indoor = next + now += int64(dt) * 1000 + // occasionally nudge outdoor so heat-loss term is well excited + if k%50 == 0 { + outdoor = -5.0 + 10.0*math.Sin(float64(k)/30.0) + } + // keep indoor in a realistic band by re-centering if it drifts off + if indoor > 24 { + indoor = 24 + } + if indoor < 16 { + indoor = 16 + } + } + + gotA, gotB := m.Beta[0], m.Beta[1] + if rel := math.Abs(gotA-trueA) / trueA; rel > 0.15 { + t.Errorf("heat-loss coef a: got %.3e want %.3e (rel err %.1f%%)", gotA, trueA, rel*100) + } + if rel := math.Abs(gotB-trueB) / trueB; rel > 0.15 { + t.Errorf("heating gain b: got %.3e want %.3e (rel err %.1f%%)", gotB, trueB, rel*100) + } + gotTau := m.TauSeconds() + wantTau := 1.0 / trueA + if rel := math.Abs(gotTau-wantTau) / wantTau; rel > 0.15 { + t.Errorf("tau: got %.0fs want %.0fs", gotTau, wantTau) + } +} + +// TestPredictNextTracksPhysics checks a trained model's one-step forecast +// stays close to the true dynamics. +func TestPredictNextTracksPhysics(t *testing.T) { + const trueA, trueB, dt = 1.0 / 10800.0, 2.0e-7, 900.0 + m := NewModel() + indoor, outdoor := 21.0, 5.0 + now := time.Now().UnixMilli() + for k := 0; k < 1000; k++ { + heat := 1000.0 * float64(k%4) + next := trueStep(indoor, outdoor, heat, dt, trueA, trueB) + m.Update(indoor, next, outdoor, heat, dt, now) + indoor, now = next, now+int64(dt)*1000 + if indoor > 24 || indoor < 18 { + indoor = 21 + } + } + // Forecast vs. truth at a fresh operating point. + pred := m.PredictNext(20.0, 0.0, 2000.0, dt) + truth := trueStep(20.0, 0.0, 2000.0, dt, trueA, trueB) + if math.Abs(pred-truth) > 0.1 { + t.Errorf("PredictNext %.3f°C vs truth %.3f°C", pred, truth) + } +} + +// TestHeatToHoldW recovers the steady-state holding power. +func TestHeatToHoldW(t *testing.T) { + m := NewModel() + m.Samples = WarmupSamples // trust learned coefs + m.Beta[0] = 1.0 / 7200.0 + m.Beta[1] = 2.0e-7 + // At hold, a·(Tout−Tin)+b·P = 0 ⇒ P = a·(Tin−Tout)/b + want := m.Beta[0] * (21.0 - 1.0) / m.Beta[1] + got := m.HeatToHoldW(21.0, 1.0) + if math.Abs(got-want) > 1 { + t.Errorf("HeatToHoldW got %.0fW want %.0fW", got, want) + } + // A zone already warmer than target needs no heat. + if w := m.HeatToHoldW(18.0, 22.0); w != 0 { + t.Errorf("warm zone should need 0 W, got %.0f", w) + } +} + +// TestUpdateRejectsGlitch ensures an impossible temperature jump is ignored. +func TestUpdateRejectsGlitch(t *testing.T) { + m := NewModel() + now := time.Now().UnixMilli() + // 10°C jump in one minute → ~10°C/min, far above the 2°C/min cap. + if m.Update(20.0, 30.0, 5.0, 1000.0, 60.0, now) { + t.Error("expected glitch step to be rejected") + } + if m.Samples != 0 { + t.Errorf("rejected sample must not increment Samples, got %d", m.Samples) + } +} + +// TestPriorIsPhysical sanity-checks the cold-start prior produces a +// reasonable holding power and time constant before any training. +func TestPriorIsPhysical(t *testing.T) { + m := NewModel() + if tau := m.TauSeconds(); math.Abs(tau-14400) > 1 { + t.Errorf("prior tau got %.0fs want 14400s (4h)", tau) + } + // Holding 21°C against 0°C outdoor with a 4h τ should be a few hundred W + // to low kW — sanity bound, not exact. + w := m.HeatToHoldW(21.0, 0.0) + if w < 100 || w > 5000 { + t.Errorf("prior HeatToHoldW %.0fW outside sane [100,5000] band", w) + } +} + +// TestQualityRampsWithSamples verifies the confidence score behaves. +func TestQualityRampsWithSamples(t *testing.T) { + m := NewModel() + if q := m.Quality(); q != 0 { + t.Errorf("fresh model quality should be 0, got %.2f", q) + } + m.Samples = 100 + m.MAE = 0.05 + if q := m.Quality(); q < 0.99 { + t.Errorf("well-trained low-MAE model should score ~1, got %.2f", q) + } + m.MAE = 0.5 + if q := m.Quality(); q > 0.01 { + t.Errorf("high-MAE model should score ~0, got %.2f", q) + } +} diff --git a/go/internal/thermalmodel/twomass.go b/go/internal/thermalmodel/twomass.go new file mode 100644 index 00000000..ea6ed1a3 --- /dev/null +++ b/go/internal/thermalmodel/twomass.go @@ -0,0 +1,278 @@ +package thermalmodel + +import ( + "math" + "time" +) + +// TwoMass is a 2R2C thermal model for floor heating, where two very +// different thermal masses matter: +// +// - the SLAB (concrete floor + screed) is heated directly by the element +// or the hydronic loop. It's large and slow — it banks heat for hours. +// - the ROOM AIR is heated by the slab and loses heat to the outdoors. +// It's lighter and faster. +// +// The single-mass Model lumps these together and so badly underestimates how +// long a floor-heated room stays warm after the element switches off (the +// slab keeps radiating). That matters for the pause/block economics: the +// true coast time is much longer than a 1R1C fit predicts. The 2R2C model +// captures it: +// +// Cs·dTs/dt = P − (Ts−Tr)/Rsr (slab) +// Cr·dTr/dt = (Ts−Tr)/Rsr − (Tr−To)/Rro (room) +// +// Discretised (dt seconds), as two independent linear regressions: +// +// slab: (Ts[k+1]−Ts[k])/dt = aRS·(Tr−Ts) + bP·P +// room: (Tr[k+1]−Tr[k])/dt = aSR·(Ts−Tr) + aRO·(To−Tr) +// +// where aRS=1/(Rsr·Cs), bP=1/Cs, aSR=1/(Rsr·Cr), aRO=1/(Rro·Cr). Each +// regression is fit by the same RLS used elsewhere. The slab temperature Ts +// comes from a floor probe (common on electric floor thermostats) or the +// hydronic flow temperature as a proxy. +type TwoMass struct { + // Slab dynamics: y = aRS·(Tr−Ts) + bP·P ; beta = [aRS, bP] + BetaSlab [2]float64 `json:"beta_slab"` + PSlab [2][2]float64 `json:"p_slab"` + // Room dynamics: y = aSR·(Ts−Tr) + aRO·(To−Tr) ; beta = [aSR, aRO] + BetaRoom [2]float64 `json:"beta_room"` + PRoom [2][2]float64 `json:"p_room"` + + Forgetting float64 `json:"forgetting"` + Samples int64 `json:"samples"` + LastMs int64 `json:"last_ms"` + MAESlab float64 `json:"mae_slab"` // EMA of |Δslab err| per step (°C) + MAERoom float64 `json:"mae_room"` // EMA of |Δroom err| per step (°C) +} + +// Physical priors. +// +// slab→room coupling τ ≈ 1 h ⇒ aSR ≈ 1/3600 +// room→outdoor τ ≈ 4 h ⇒ aRO ≈ 1/14400 +// room→slab coupling τ ≈ 2 h ⇒ aRS ≈ 1/7200 (slab is the bigger mass) +// bP = 1/Cs: a 2 kW element over 15 min lifts a room's slab ~0.18 °C +// ⇒ bP ≈ 0.18/(2000·900) ≈ 1.0e-7 °C/(W·s) +const ( + priorASR = 1.0 / 3600.0 + priorARO = 1.0 / 14400.0 + priorARS = 1.0 / 7200.0 + priorBP = 1.0e-7 +) + +// NewTwoMass returns a model anchored on the physical priors. +func NewTwoMass() *TwoMass { + m := &TwoMass{Forgetting: 0.997} + for i := 0; i < 2; i++ { + m.PSlab[i][i] = 1.0 + m.PRoom[i][i] = 1.0 + } + m.BetaSlab = [2]float64{priorARS, priorBP} + m.BetaRoom = [2]float64{priorASR, priorARO} + return m +} + +// effSlab / effRoom blend learned β toward the priors during warmup and keep +// the coefficients physical (all couplings ≥ 0). +func (m TwoMass) effSlab() (aRS, bP float64) { + trust := m.trust() + aRS = trust*m.BetaSlab[0] + (1-trust)*priorARS + bP = trust*m.BetaSlab[1] + (1-trust)*priorBP + if aRS < 0 { + aRS = priorARS + } + if bP < 0 { + bP = 0 + } + return +} + +func (m TwoMass) effRoom() (aSR, aRO float64) { + trust := m.trust() + aSR = trust*m.BetaRoom[0] + (1-trust)*priorASR + aRO = trust*m.BetaRoom[1] + (1-trust)*priorARO + if aSR < 0 { + aSR = priorASR + } + if aRO <= 0 { + aRO = priorARO + } + return +} + +func (m TwoMass) trust() float64 { + t := float64(m.Samples) / float64(WarmupSamples) + if t > 1 { + t = 1 + } + return t +} + +// PredictStep advances both states one step of dtSeconds. heatW is the +// thermal power delivered to the slab. +func (m TwoMass) PredictStep(roomC, slabC, outdoorC, heatW, dtSeconds float64) (nextRoomC, nextSlabC float64) { + aRS, bP := m.effSlab() + aSR, aRO := m.effRoom() + dSlab := aRS*(roomC-slabC) + bP*heatW + dRoom := aSR*(slabC-roomC) + aRO*(outdoorC-roomC) + return roomC + dtSeconds*dRoom, slabC + dtSeconds*dSlab +} + +// ForecastRoom rolls the model forward over the supplied per-step heat and +// outdoor series, returning the predicted ROOM temperature after each step. +// This is the floor-heating forecast the scheduler reasons over. The series +// must be the same length; dtSeconds is the step. +func (m TwoMass) ForecastRoom(roomC, slabC float64, heatW, outdoorC []float64, dtSeconds float64) []float64 { + n := len(heatW) + if len(outdoorC) < n { + n = len(outdoorC) + } + out := make([]float64, 0, n) + r, s := roomC, slabC + for i := 0; i < n; i++ { + r, s = m.PredictStep(r, s, outdoorC[i], heatW[i], dtSeconds) + out = append(out, r) + } + return out +} + +// CoastHoursToRoomTarget returns how many hours the ROOM stays at or above +// targetC with no heating, starting from the given room+slab temperatures. +// Unlike the single-mass version, the slab keeps feeding the room as it +// cools, so this is materially longer for floor heating — and far more +// accurate. cap bounds the search. +func (m TwoMass) CoastHoursToRoomTarget(roomC, slabC, targetC, outdoorC float64, cap time.Duration) float64 { + if roomC <= targetC { + return 0 + } + const step = 300.0 + maxS := cap.Seconds() + t := 0.0 + r, s := roomC, slabC + for t < maxS { + r, s = m.PredictStep(r, s, outdoorC, 0, step) + t += step + if r <= targetC { + break + } + } + return t / 3600.0 +} + +// Update folds one observed transition into both regressions. It needs the +// slab temperature (floor probe or flow temp) at the start and end of the +// step alongside the room temperature. +func (m *TwoMass) Update(roomC, nextRoomC, slabC, nextSlabC, outdoorC, heatW, dtSeconds float64, nowMs int64) bool { + if dtSeconds <= 0 { + return false + } + // Reject implausible slews (sensor glitches). + if math.Abs(nextRoomC-roomC)/(dtSeconds/60.0) > 2.0 || + math.Abs(nextSlabC-slabC)/(dtSeconds/60.0) > 4.0 { + return false + } + lambda := m.Forgetting + if lambda <= 0 { + lambda = 0.997 + } + + // Slab regression: y = aRS·(Tr−Ts) + bP·P + xs := [2]float64{roomC - slabC, heatW} + ys := (nextSlabC - slabC) / dtSeconds + errSlab := rls2(&m.BetaSlab, &m.PSlab, xs, ys, lambda) + if m.BetaSlab[0] < 0 { + m.BetaSlab[0] = priorARS + } + if m.BetaSlab[1] < 0 { + m.BetaSlab[1] = 0 + } + + // Room regression: y = aSR·(Ts−Tr) + aRO·(To−Tr) + xr := [2]float64{slabC - roomC, outdoorC - roomC} + yr := (nextRoomC - roomC) / dtSeconds + errRoom := rls2(&m.BetaRoom, &m.PRoom, xr, yr, lambda) + if m.BetaRoom[0] < 0 { + m.BetaRoom[0] = priorASR + } + if m.BetaRoom[1] <= 0 { + m.BetaRoom[1] = priorARO + } + + stepSlabErr := math.Abs(errSlab) * dtSeconds + stepRoomErr := math.Abs(errRoom) * dtSeconds + m.MAESlab = ema(m.MAESlab, stepSlabErr) + m.MAERoom = ema(m.MAERoom, stepRoomErr) + m.Samples++ + m.LastMs = nowMs + return true +} + +// TauRoomSeconds / TauSlabSeconds expose the learned time constants. +func (m TwoMass) TauRoomSeconds() float64 { + _, aRO := m.effRoom() + if aRO <= 0 { + return 0 + } + return 1.0 / aRO +} + +func (m TwoMass) TauSlabSeconds() float64 { + aRS, _ := m.effSlab() + if aRS <= 0 { + return 0 + } + return 1.0 / aRS +} + +// Quality mirrors Model.Quality: confidence in [0,1] from sample count and +// the room-temperature error band (the quantity the scheduler acts on). +func (m TwoMass) Quality() float64 { + if m.Samples < 30 { + return float64(m.Samples) / 30.0 * 0.5 + } + switch { + case m.MAERoom <= 0.05: + return 1.0 + case m.MAERoom >= 0.5: + return 0.0 + default: + return 1.0 - (m.MAERoom-0.05)/0.45 + } +} + +// rls2 performs one RLS update of a 2-feature regression in place and returns +// the prediction error (y − β·x) in rate space. +func rls2(beta *[2]float64, P *[2][2]float64, x [2]float64, y, lambda float64) float64 { + var pred float64 + for i := 0; i < 2; i++ { + pred += beta[i] * x[i] + } + e := y - pred + // Px = P·x + var Px [2]float64 + for i := 0; i < 2; i++ { + Px[i] = P[i][0]*x[0] + P[i][1]*x[1] + } + denom := lambda + x[0]*Px[0] + x[1]*Px[1] + if denom < 1e-9 { + return e + } + var K [2]float64 + K[0] = Px[0] / denom + K[1] = Px[1] / denom + beta[0] += K[0] * e + beta[1] += K[1] * e + for i := 0; i < 2; i++ { + for j := 0; j < 2; j++ { + P[i][j] = (P[i][j] - K[i]*Px[j]) / lambda + } + } + return e +} + +func ema(prev, sample float64) float64 { + if prev == 0 { + return sample + } + return 0.95*prev + 0.05*sample +} diff --git a/go/internal/thermalmodel/twomass_test.go b/go/internal/thermalmodel/twomass_test.go new file mode 100644 index 00000000..661a505f --- /dev/null +++ b/go/internal/thermalmodel/twomass_test.go @@ -0,0 +1,125 @@ +package thermalmodel + +import ( + "math" + "testing" + "time" +) + +// trueTwoMassStep advances a synthetic 2R2C plant. +func trueTwoMassStep(room, slab, outdoor, heat, dt, aRS, bP, aSR, aRO float64) (nr, ns float64) { + dSlab := aRS*(room-slab) + bP*heat + dRoom := aSR*(slab-room) + aRO*(outdoor-room) + return room + dt*dRoom, slab + dt*dSlab +} + +// TestTwoMassRecoversParams drives the model with a synthetic floor-heated +// room and checks both regressions converge to the true coefficients. +func TestTwoMassRecoversParams(t *testing.T) { + const ( + aRS = 1.0 / 5400.0 // slab↔room + bP = 1.5e-7 // slab heating gain + aSR = 1.0 / 2700.0 // room gains from slab + aRO = 1.0 / 10800.0 + dt = 300.0 // 5-min steps + ) + m := NewTwoMass() + room, slab, outdoor := 21.0, 24.0, 2.0 + now := time.Now().UnixMilli() + for k := 0; k < 6000; k++ { + heat := 0.0 + if k%3 != 0 { + heat = 1500 + 500*math.Sin(float64(k)/7.0) + } + nr, ns := trueTwoMassStep(room, slab, outdoor, heat, dt, aRS, bP, aSR, aRO) + m.Update(room, nr, slab, ns, outdoor, heat, dt, now) + room, slab = nr, ns + now += int64(dt) * 1000 + if k%40 == 0 { + outdoor = -5 + 10*math.Sin(float64(k)/50.0) + } + // keep states in a sane band + if room > 24 { + room = 24 + } + if room < 18 { + room = 18 + } + if slab > 30 { + slab = 30 + } + if slab < 20 { + slab = 20 + } + } + checks := []struct { + name string + got, want float64 + }{ + {"aRS", m.BetaSlab[0], aRS}, + {"bP", m.BetaSlab[1], bP}, + {"aSR", m.BetaRoom[0], aSR}, + {"aRO", m.BetaRoom[1], aRO}, + } + for _, c := range checks { + if rel := math.Abs(c.got-c.want) / c.want; rel > 0.2 { + t.Errorf("%s: got %.3e want %.3e (rel %.0f%%)", c.name, c.got, c.want, rel*100) + } + } +} + +// TestTwoMassCoastLongerThanSingleMass is the whole point: with a hot slab, +// the room coasts above target far longer than a single-mass model — which +// ignores the slab's stored heat — would predict. +func TestTwoMassCoastLongerThanSingleMass(t *testing.T) { + tm := NewTwoMass() + tm.Samples = WarmupSamples // trust priors fully + + // Room at 21, target 20, but the slab is hot (28°C) — a freshly charged + // floor. The two-mass coast should be substantial. + coast2 := tm.CoastHoursToRoomTarget(21.0, 28.0, 20.0, 2.0, 24*time.Hour) + + // Single-mass equivalent with the same room time constant sees only the + // room air and no slab reservoir. + sm := NewModel() + sm.Samples = WarmupSamples + coast1 := sm.CoastHoursToTarget(21.0, 20.0, 2.0, 24*time.Hour) + + if coast2 <= coast1 { + t.Errorf("two-mass coast (%.2fh) should exceed single-mass (%.2fh) when the slab is hot", coast2, coast1) + } + if coast2 <= 0 { + t.Error("expected a positive coast time with a hot slab") + } +} + +// TestTwoMassForecastShape sanity-checks the forecast: with no heat and a +// hot slab, the room first holds/rises (slab feeds it) then decays toward +// outdoor. +func TestTwoMassForecastShape(t *testing.T) { + tm := NewTwoMass() + tm.Samples = WarmupSamples + n := 48 // 4h at 5-min steps + heat := make([]float64, n) + outdoor := make([]float64, n) + for i := range outdoor { + outdoor[i] = 0 + } + traj := tm.ForecastRoom(20.0, 30.0 /*hot slab*/, heat, outdoor, 300) + if len(traj) != n { + t.Fatalf("want %d points, got %d", n, len(traj)) + } + // With a hot slab the room should not immediately crash; the last point + // must still be above outdoor. + if traj[len(traj)-1] <= 0 { + t.Errorf("room should stay above outdoor over the horizon, ended %.2f", traj[len(traj)-1]) + } +} + +// TestTwoMassRejectsGlitch ensures an impossible slab jump is ignored. +func TestTwoMassRejectsGlitch(t *testing.T) { + tm := NewTwoMass() + if tm.Update(20, 20.1, 24, 60 /*+36°C in a minute*/, 5, 0, 60, time.Now().UnixMilli()) { + t.Error("expected glitch step to be rejected") + } +} diff --git a/matter-sidecar/Dockerfile b/matter-sidecar/Dockerfile new file mode 100644 index 00000000..d81fa570 --- /dev/null +++ b/matter-sidecar/Dockerfile @@ -0,0 +1,22 @@ +# 42W Matter sidecar — Node.js container running matter.js. +# +# Single-stage: matter.js ships with native-ish dependencies that are +# simplest to keep on the same node:alpine base for both install and +# runtime, and the sidecar's own source is tiny compared to its +# node_modules tree. + +FROM node:22-alpine + +WORKDIR /app + +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev + +COPY tsconfig.json ./ +COPY src/ ./src/ +RUN npm ci --include=dev && npm run build && npm prune --omit=dev + +VOLUME ["/data"] +EXPOSE 5540 5580 + +ENTRYPOINT ["node", "dist/index.js"] diff --git a/matter-sidecar/package-lock.json b/matter-sidecar/package-lock.json new file mode 100644 index 00000000..203bf580 --- /dev/null +++ b/matter-sidecar/package-lock.json @@ -0,0 +1,195 @@ +{ + "name": "ftw-matter-sidecar", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ftw-matter-sidecar", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "@matter/main": "^0.17.3", + "@matter/node": "^0.17.3", + "ws": "^8.21.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@types/ws": "^8.18.1", + "typescript": "^6.0.3" + } + }, + "node_modules/@matter/general": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@matter/general/-/general-0.17.3.tgz", + "integrity": "sha512-5DRM/sA+4gQj/p0cBOBPSXcLjCQ3Qix9rCE23sGGonVWY4PMi+SWzZHvQVe5mYvCl+HU3pL+aJq8HalyOqjEGQ==", + "license": "Apache-2.0", + "dependencies": { + "@noble/curves": "^2.2.0" + } + }, + "node_modules/@matter/main": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@matter/main/-/main-0.17.3.tgz", + "integrity": "sha512-0JE1qtN7ne0P3rCCSWP2pFONFRlOpZvV+BmTyl4YFJtWapIcTUzak9gZ4/WJ0VAUQX4GtbnIAinwyLoysWewIA==", + "license": "Apache-2.0", + "dependencies": { + "@matter/general": "0.17.3", + "@matter/model": "0.17.3", + "@matter/node": "0.17.3", + "@matter/protocol": "0.17.3", + "@matter/types": "0.17.3" + }, + "optionalDependencies": { + "@matter/nodejs": "0.17.3" + } + }, + "node_modules/@matter/model": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@matter/model/-/model-0.17.3.tgz", + "integrity": "sha512-WfUCmoy71BAOumExCxpgQp2OjfzSEcQtUkft1WkVF1yOToju/0CAhMg0hoqB+XPoEDEwmARgi/AIhgoiJOhGng==", + "license": "Apache-2.0", + "dependencies": { + "@matter/general": "0.17.3" + } + }, + "node_modules/@matter/node": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@matter/node/-/node-0.17.3.tgz", + "integrity": "sha512-d6SGETjsE/z9NGPove39eu6WI6qA4svp1IQ/W45+iCWhKidaMu58DX7EZpNCOjWsTfXdjgQA8WnD9K1Ujbuzfg==", + "license": "Apache-2.0", + "dependencies": { + "@matter/general": "0.17.3", + "@matter/model": "0.17.3", + "@matter/protocol": "0.17.3", + "@matter/types": "0.17.3" + } + }, + "node_modules/@matter/nodejs": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@matter/nodejs/-/nodejs-0.17.3.tgz", + "integrity": "sha512-lrhK12Uk5W7WmboF6JE9UV68sj0E2hiYJvpIuZTCnKFmvHs0AwF080eZAIOXcIfsf3Gg6rHHzq5FpR7Zvqiflg==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@matter/general": "0.17.3", + "@matter/node": "0.17.3", + "@matter/protocol": "0.17.3", + "@matter/types": "0.17.3" + }, + "engines": { + "node": ">=20.19.0 <22.0.0 || >=22.13.0" + } + }, + "node_modules/@matter/protocol": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@matter/protocol/-/protocol-0.17.3.tgz", + "integrity": "sha512-Sf8O2IUhqzKqKR4ssY8epF+aSCrszUiTBkSJ5u/fYDaoADjxIPHFOKXdYcjKrfuydyxhzYDK6eh3XPzyXrir3w==", + "license": "Apache-2.0", + "dependencies": { + "@matter/general": "0.17.3", + "@matter/model": "0.17.3", + "@matter/types": "0.17.3" + } + }, + "node_modules/@matter/types": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@matter/types/-/types-0.17.3.tgz", + "integrity": "sha512-7I49NkCAnsytxoFm6mwgpBTgwnODPxpJBkWQfzrWRRW6KBY0syJj+LkI7CIPK37VCc7AAzGrWIRHN7wylKaPeg==", + "license": "Apache-2.0", + "dependencies": { + "@matter/general": "0.17.3", + "@matter/model": "0.17.3" + } + }, + "node_modules/@noble/curves": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.2.0.tgz", + "integrity": "sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.2.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@types/node": { + "version": "22.19.21", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.21.tgz", + "integrity": "sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/matter-sidecar/package.json b/matter-sidecar/package.json new file mode 100644 index 00000000..0c4298d5 --- /dev/null +++ b/matter-sidecar/package.json @@ -0,0 +1,24 @@ +{ + "name": "ftw-matter-sidecar", + "version": "1.0.0", + "description": "42W Matter controller sidecar — joins devices shared from another Matter fabric and exposes attribute read/write/invoke over a WebSocket JSON-RPC protocol consumed by go/internal/matter.", + "private": true, + "type": "module", + "main": "dist/index.js", + "scripts": { + "build": "tsc", + "start": "node dist/index.js", + "dev": "tsc && node dist/index.js" + }, + "license": "ISC", + "dependencies": { + "@matter/main": "^0.17.3", + "@matter/node": "^0.17.3", + "ws": "^8.21.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@types/ws": "^8.18.1", + "typescript": "^6.0.3" + } +} diff --git a/matter-sidecar/src/bridge.ts b/matter-sidecar/src/bridge.ts new file mode 100644 index 00000000..5d4fe32c --- /dev/null +++ b/matter-sidecar/src/bridge.ts @@ -0,0 +1,105 @@ +// Phase 3 — 42W as a Matter *bridge*: surfaces non-Matter DERs (inverters, +// batteries, EVSE chargers, ...) as bridged Matter devices so other Matter +// ecosystems (Apple Home, Home Assistant, ...) can see their live power +// draw. Each 42W driver becomes one Aggregator child: a BridgedNode +// endpoint (identity + reachability) wrapping one ElectricalMeterDevice +// child endpoint (the actual power/energy clusters). The Go side decides +// which drivers are bridged and owns all unit conversion; this module only +// creates/updates endpoints. +// +// SoC exposure (PowerSourceServer's Battery feature) is left for a +// follow-up — it pulls in its own feature-conformance requirements beyond +// this MVP's scope. +import { Endpoint } from "@matter/node"; +import { AggregatorEndpoint, BridgedNodeEndpoint } from "@matter/node/endpoints"; +import { ElectricalMeterDevice, ElectricalMeterRequirements } from "@matter/node/devices"; + +// The bare ElectricalMeterDevice export has no bound server behaviors +// (its `behaviors` type is `{}`) — .with(...) must be called with the +// requirement's own default server classes to get a type that actually +// exposes electricalPowerMeasurement/electricalEnergyMeasurement. +const ElectricalMeterDeviceWithServers = ElectricalMeterDevice.with( + ElectricalMeterRequirements.ElectricalPowerMeasurementServer, + ElectricalMeterRequirements.ElectricalEnergyMeasurementServer, +); + +export interface BridgedDeviceInput { + id: string; + name: string; + device_type: string; + power_mw: number; +} + +interface BridgeEntry { + node: Endpoint; + meter: Endpoint; +} + +function sanitizeId(id: string): string { + return id.replace(/[^a-zA-Z0-9_-]/g, "_"); +} + +export class Bridge { + readonly endpoint: Endpoint; + private readonly entries = new Map(); + + constructor() { + this.endpoint = new Endpoint(AggregatorEndpoint, { id: "bridge" }); + } + + async sync(devices: BridgedDeviceInput[]): Promise { + const seen = new Set(); + for (const dev of devices) { + seen.add(dev.id); + let entry = this.entries.get(dev.id); + if (!entry) { + entry = this.createEntry(dev); + this.entries.set(dev.id, entry); + } + await entry.node.set({ + bridgedDeviceBasicInformation: { nodeLabel: dev.name, reachable: true }, + }); + await entry.meter.set({ + electricalPowerMeasurement: { activePower: Math.round(dev.power_mw) }, + }); + } + for (const [id, entry] of this.entries) { + if (!seen.has(id)) { + await entry.node.set({ bridgedDeviceBasicInformation: { reachable: false } }); + } + } + } + + private createEntry(dev: BridgedDeviceInput): BridgeEntry { + const safeId = sanitizeId(dev.id); + const meter = new Endpoint(ElectricalMeterDeviceWithServers, { + id: `${safeId}-meter`, + electricalPowerMeasurement: { + powerMode: 0, // Unknown — 42W abstracts AC/DC away above the driver layer + numberOfMeasurementTypes: 1, + accuracy: [], + activePower: null, + }, + electricalEnergyMeasurement: { + accuracy: { + measurementType: 0, + measured: true, + minMeasuredValue: -1e15, + maxMeasuredValue: 1e15, + accuracyRanges: [], + }, + }, + }); + const node = new Endpoint(BridgedNodeEndpoint, { + id: safeId, + bridgedDeviceBasicInformation: { + nodeLabel: dev.name, + reachable: true, + uniqueId: dev.id, + }, + parts: [meter], + }); + this.endpoint.parts.add(node); + return { node, meter }; + } +} diff --git a/matter-sidecar/src/clusters.ts b/matter-sidecar/src/clusters.ts new file mode 100644 index 00000000..847754be --- /dev/null +++ b/matter-sidecar/src/clusters.ts @@ -0,0 +1,43 @@ +// Maps numeric Matter cluster/attribute/command IDs (the only vocabulary +// drivers/matter.lua and its YAML config know) onto matter.js's named +// cluster definitions, which is what the Read/Write/Invoke request builders +// in @matter/protocol actually need to encode values correctly on the wire. +import * as Clusters from "@matter/types/clusters"; + +export interface ClusterModule { + id: number; + name: string; + attributes: Record; + commands: Record; + Cluster: unknown; +} + +const byId = new Map(); + +for (const key of Object.keys(Clusters)) { + const value = (Clusters as Record)[key]; + if ( + value && + typeof value === "object" && + !key.endsWith("Cluster") && + typeof (value as ClusterModule).id === "number" && + (value as ClusterModule).attributes && + (value as ClusterModule).Cluster + ) { + const mod = value as ClusterModule; + if (!byId.has(mod.id)) byId.set(mod.id, mod); + } +} + +export function clusterFor(clusterId: number): ClusterModule { + const mod = byId.get(clusterId); + if (!mod) throw new Error(`unknown cluster 0x${clusterId.toString(16)}`); + return mod; +} + +export function attributeNameFor(cluster: ClusterModule, attributeId: number): string { + for (const [name, def] of Object.entries(cluster.attributes)) { + if (def.id === attributeId) return name; + } + throw new Error(`unknown attribute ${attributeId} on cluster ${cluster.name}`); +} diff --git a/matter-sidecar/src/index.ts b/matter-sidecar/src/index.ts new file mode 100644 index 00000000..2af9e752 --- /dev/null +++ b/matter-sidecar/src/index.ts @@ -0,0 +1,246 @@ +// 42W Matter controller sidecar. +// +// Speaks the same WebSocket JSON-RPC contract go/internal/matter/client.go +// already implements (message_id/command/args request, result/error_code/ +// error_message response) — only the backend underneath changed, from +// python-matter-server to matter.js. See go/internal/matter/client.go's +// header comment for the wire format. +// +// 42W does not commission devices itself. Devices are commissioned by +// whatever controller they shipped with, then "shared" into 42W's fabric +// via that controller's multi-admin / "share device" flow, which mints a +// one-time pairing code. The `commission` command below joins the device +// using that code and hands back a small logical node_id for the operator +// to paste into the driver's YAML config — see drivers/matter.lua. +// +// Phase 2 (`get_pairing_code` / `set_price_feed`): the inverse role — 42W +// itself is a commissionable Matter device exposing a CommodityPrice server +// endpoint (see priceserver.ts), joinable by any other Matter controller via +// the codes `get_pairing_code` returns. +// +// Phase 3 (`sync_bridge`): 42W as a Matter *bridge* — non-Matter DERs +// (inverters, batteries, EVSE chargers) appear as bridged Matter devices +// under a single Aggregator endpoint, so other Matter ecosystems can see +// their live power draw. See bridge.ts. +// Storage path must be set via the MATTER_STORAGE_PATH env var (mapped to +// matter.js's "storage.path" variable), not @matter/nodejs/config's +// defaultStoragePath setter — that setter throws NodeJsAlreadyInitializedError +// because importing @matter/main elsewhere in this file initializes the +// default Environment before this module's own top-level code would run +// (ESM hoists all imports ahead of any importing module's body). +const STORAGE_PATH = process.env.FTW_MATTER_STORAGE_PATH ?? "/data"; +process.env.MATTER_STORAGE_PATH = STORAGE_PATH; + +import { join } from "path"; +import { ServerNode } from "@matter/main"; +import { Read, Write, Invoke } from "@matter/protocol"; +import { EndpointNumber, ClusterId, AttributeId } from "@matter/types"; +import { WebSocket, WebSocketServer } from "ws"; +import { attributeNameFor, clusterFor } from "./clusters.js"; +import { NodeMap } from "./nodemap.js"; +import { createPriceEndpoint, setPriceFeed, type PricePeriod } from "./priceserver.js"; +import { Bridge, type BridgedDeviceInput } from "./bridge.js"; + +const WS_PORT = Number(process.env.FTW_MATTER_WS_PORT ?? 5580); +const MATTER_PORT = Number(process.env.FTW_MATTER_PORT ?? 5540); + +interface WsRequest { + message_id: string; + command: string; + args?: Record; +} + +interface WsResponse { + message_id: string; + result?: unknown; + error_code?: string | null; + error_message?: string; +} + +function errorResponse(messageId: string, code: string, err: unknown): WsResponse { + return { + message_id: messageId, + error_code: code, + error_message: err instanceof Error ? err.message : String(err), + }; +} + +async function main() { + const priceEndpoint = createPriceEndpoint(); + const bridge = new Bridge(); + const server = await ServerNode.create(ServerNode.RootEndpoint, { + id: "ftw-matter-controller", + network: { port: MATTER_PORT }, + parts: [priceEndpoint, bridge.endpoint], + }); + await server.start(); + + const nodeMap = new NodeMap(join(STORAGE_PATH, "ftw-node-map.json")); + + function peerFor(logicalNodeId: number) { + const realId = nodeMap.realFor(logicalNodeId); + const node = server.peers.get(realId); + if (!node) throw new Error(`node_id ${logicalNodeId} is not connected`); + return node; + } + + async function handleCommission(args: Record) { + const pairingCode = String(args.pairing_code ?? ""); + if (!pairingCode) throw new Error("pairing_code is required"); + const peerId = typeof args.id === "string" ? args.id : `peer-${Date.now()}`; + const node = await server.peers.commission({ id: peerId, pairingCode }); + const realId = node.peerAddress?.nodeId; + if (realId === undefined) throw new Error("commissioning did not return a node address"); + const logicalId = nodeMap.assign(String(realId)); + return { node_id: logicalId }; + } + + async function handleReadAttribute(args: Record) { + const logicalId = Number(args.node_id); + const [endpoint, clusterId, attributeId] = parseAttributePath(String(args.attribute_path)); + const node = peerFor(logicalId); + const result = await node.interaction.read( + Read({ + attributes: [ + { + endpointId: EndpointNumber(endpoint), + clusterId: ClusterId(clusterId), + attributeId: AttributeId(attributeId), + }, + ], + }), + ); + for await (const chunk of result) { + for await (const report of chunk) { + if (report.kind === "attr-value") return report.value; + } + } + throw new Error("attribute not present in read response"); + } + + async function handleWriteAttribute(args: Record) { + const logicalId = Number(args.node_id); + const [endpoint, clusterId, attributeId] = parseAttributePath(String(args.attribute_path)); + const node = peerFor(logicalId); + const cluster = clusterFor(clusterId); + const attributeName = attributeNameFor(cluster, attributeId); + await node.interaction.write( + Write( + Write.Attribute({ + endpoint: EndpointNumber(endpoint), + cluster: cluster.Cluster as never, + attributes: attributeName as never, + value: args.value, + }), + ), + ); + return null; + } + + async function handleSendCommand(args: Record) { + const logicalId = Number(args.node_id); + const endpoint = Number(args.endpoint_id); + const clusterId = Number(args.cluster_id); + const commandName = String(args.command_name ?? ""); + const node = peerFor(logicalId); + const cluster = clusterFor(clusterId); + const result = await node.interaction.invoke( + Invoke({ + commands: [ + { + endpoint: EndpointNumber(endpoint), + cluster: cluster.Cluster as never, + command: commandName as never, + fields: (args.payload ?? undefined) as never, + }, + ], + }), + ); + return result; + } + + function handleListNodes() { + return nodeMap.list(); + } + + async function handleSetPriceFeed(args: Record) { + const current = (args.current ?? null) as PricePeriod | null; + const forecast = Array.isArray(args.forecast) ? (args.forecast as PricePeriod[]) : []; + await setPriceFeed(priceEndpoint, current, forecast); + return null; + } + + function handleGetPairingCode() { + const codes = server.state.commissioning.pairingCodes; + return { manual_pairing_code: codes.manualPairingCode, qr_pairing_code: codes.qrPairingCode }; + } + + async function handleSyncBridge(args: Record) { + const devices = Array.isArray(args.devices) ? (args.devices as BridgedDeviceInput[]) : []; + await bridge.sync(devices); + return null; + } + + async function dispatch(req: WsRequest): Promise { + const args = req.args ?? {}; + try { + switch (req.command) { + case "commission": + return { message_id: req.message_id, result: await handleCommission(args) }; + case "read_attribute": + return { message_id: req.message_id, result: await handleReadAttribute(args) }; + case "write_attribute": + return { message_id: req.message_id, result: await handleWriteAttribute(args) }; + case "send_command": + return { message_id: req.message_id, result: await handleSendCommand(args) }; + case "list_nodes": + return { message_id: req.message_id, result: handleListNodes() }; + case "set_price_feed": + return { message_id: req.message_id, result: await handleSetPriceFeed(args) }; + case "get_pairing_code": + return { message_id: req.message_id, result: handleGetPairingCode() }; + case "sync_bridge": + return { message_id: req.message_id, result: await handleSyncBridge(args) }; + default: + return errorResponse(req.message_id, "unknown_command", `unknown command '${req.command}'`); + } + } catch (err) { + return errorResponse(req.message_id, "command_failed", err); + } + } + + // Bind to loopback only. The control-plane WS protocol has no auth — it's + // the same RPC surface that can write_attribute/send_command to any + // commissioned device — and network_mode: host (required for mDNS) would + // otherwise put it on the LAN unauthenticated. 42W's main app reaches it + // over localhost anyway (it's also network_mode: host), so loopback-only + // costs nothing for the documented deployment. + const wss = new WebSocketServer({ port: WS_PORT, host: "127.0.0.1", path: "/ws" }); + wss.on("connection", (ws: WebSocket) => { + ws.on("message", async (raw: Buffer) => { + let req: WsRequest; + try { + req = JSON.parse(raw.toString()); + } catch { + return; // unparseable frame — ignore, matching client.go's tolerance + } + const resp = await dispatch(req); + ws.send(JSON.stringify(resp)); + }); + }); + + console.log(`ftw-matter-sidecar listening: matter=${MATTER_PORT} ws=${WS_PORT}`); +} + +function parseAttributePath(path: string): [number, number, number] { + const parts = path.split("/").map(Number); + if (parts.length !== 3 || parts.some((n) => Number.isNaN(n))) { + throw new Error(`malformed attribute_path '${path}'`); + } + return [parts[0], parts[1], parts[2]]; +} + +main().catch((err) => { + console.error("ftw-matter-sidecar failed to start:", err); + process.exit(1); +}); diff --git a/matter-sidecar/src/nodemap.ts b/matter-sidecar/src/nodemap.ts new file mode 100644 index 00000000..0a5eed23 --- /dev/null +++ b/matter-sidecar/src/nodemap.ts @@ -0,0 +1,52 @@ +// drivers/matter.lua config uses small integer node_ids, but Matter's real +// NodeId is a 64-bit value assigned during commissioning. This module keeps +// a small persisted mapping from "logical" node_id (handed to the operator +// after a pairing-code join, then pasted into driver config) to the real +// Matter NodeId string, so the rest of the sidecar can stay in the vocabulary +// the Lua driver already speaks. +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; +import { dirname } from "path"; + +export class NodeMap { + private readonly path: string; + private logicalToReal: Record = {}; + + constructor(path: string) { + this.path = path; + if (existsSync(path)) { + this.logicalToReal = JSON.parse(readFileSync(path, "utf8")); + } + } + + realFor(logicalNodeId: number): string { + const real = this.logicalToReal[String(logicalNodeId)]; + if (!real) throw new Error(`unknown node_id ${logicalNodeId}`); + return real; + } + + list(): { node_id: number; matter_node_id: string }[] { + return Object.entries(this.logicalToReal).map(([logical, real]) => ({ + node_id: Number(logical), + matter_node_id: real, + })); + } + + // assign hands out the next free logical id for a freshly commissioned + // peer and persists the mapping immediately, so a sidecar crash right + // after commissioning doesn't orphan the join. + assign(realNodeId: string): number { + let next = 1; + for (const k of Object.keys(this.logicalToReal)) { + const n = Number(k); + if (n >= next) next = n + 1; + } + this.logicalToReal[String(next)] = realNodeId; + this.save(); + return next; + } + + private save(): void { + mkdirSync(dirname(this.path), { recursive: true }); + writeFileSync(this.path, JSON.stringify(this.logicalToReal, null, 2)); + } +} diff --git a/matter-sidecar/src/priceserver.ts b/matter-sidecar/src/priceserver.ts new file mode 100644 index 00000000..5e83decf --- /dev/null +++ b/matter-sidecar/src/priceserver.ts @@ -0,0 +1,45 @@ +// Phase 2 — 42W as a Matter *server*: exposes spot-price + forecast data via +// the Matter 1.5 CommodityPrice cluster (on an ElectricalEnergyTariffDevice +// endpoint) so other Matter controllers can read it without touching 42W's +// own REST API. The Go side owns all unit/timestamp conversion (öre/kWh → +// CommodityPrice's currency-minor-units, Unix ms → Matter epoch-s); this +// module just holds the endpoint and patches its state. +import { Endpoint } from "@matter/node"; +import { ElectricalEnergyTariffDevice } from "@matter/node/devices"; +import { CommodityPriceServer } from "@matter/node/behaviors"; +import { TariffUnit } from "@matter/types/globals"; + +const PriceDeviceType = ElectricalEnergyTariffDevice.with(CommodityPriceServer.with("Forecasting")); + +export type PriceEndpoint = Endpoint; + +export interface PricePeriod { + periodStart: number; + periodEnd: number | null; + price: number; +} + +export function createPriceEndpoint(): PriceEndpoint { + return new Endpoint(PriceDeviceType, { + id: "price", + commodityPrice: { + tariffUnit: TariffUnit.KWh, + currency: { currency: 752, decimalPoints: 2 }, // SEK, minor unit = öre + currentPrice: null, + priceForecast: [], + }, + }); +} + +export async function setPriceFeed( + endpoint: PriceEndpoint, + current: PricePeriod | null, + forecast: PricePeriod[], +): Promise { + await endpoint.set({ + commodityPrice: { + currentPrice: current, + priceForecast: forecast, + }, + }); +} diff --git a/matter-sidecar/tsconfig.json b/matter-sidecar/tsconfig.json new file mode 100644 index 00000000..e7acda2c --- /dev/null +++ b/matter-sidecar/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": "src", + "esModuleInterop": true + }, + "include": ["src/**/*.ts"] +} diff --git a/web/settings/tabs/devices.js b/web/settings/tabs/devices.js index 92f02d24..103e3592 100644 --- a/web/settings/tabs/devices.js +++ b/web/settings/tabs/devices.js @@ -79,6 +79,8 @@ var hasHostField = Object.prototype.hasOwnProperty.call(dcfg, 'host'); var hasAuthField = Object.prototype.hasOwnProperty.call(dcfg, 'email') || Object.prototype.hasOwnProperty.call(dcfg, 'password'); + var hasApiCredsField = Object.prototype.hasOwnProperty.call(dcfg, 'client_id') || + Object.prototype.hasOwnProperty.call(dcfg, 'client_secret'); var catalogEntry = (S.catalogByLua || {})[d.lua]; var caps = (catalogEntry && catalogEntry.capabilities) || []; var isVehicleDriver = cap.http != null && @@ -86,7 +88,9 @@ Object.prototype.hasOwnProperty.call(dcfg, 'vin') || Object.prototype.hasOwnProperty.call(dcfg, 'ip')); var isLocalHTTP = !isVehicleDriver && cap.http != null && hasHostField; - var isCloudDriver = !isVehicleDriver && cap.http != null && !hasHostField && + // OAuth2 client_credentials drivers (e.g. MyUplink): identify via client_id/client_secret keys. + var isApiCredsDriver = !isVehicleDriver && cap.http != null && !hasHostField && hasApiCredsField; + var isCloudDriver = !isVehicleDriver && !isApiCredsDriver && cap.http != null && !hasHostField && (hasAuthField || Object.keys(dcfg).length === 0); if (isVehicleDriver) { // TeslaBLEProxy-style drivers only need the LAN IP of the @@ -127,6 +131,33 @@ '' + ''; } + if (isApiCredsDriver) { + // OAuth2 client_credentials drivers (e.g. MyUplink). + // User registers an app at the provider's developer portal and + // pastes Client ID + Client Secret here. No redirect needed. + var acfg = d.config || {}; + var hasSecret = acfg.client_secret && acfg.client_secret.length > 0; + var secretBadge = hasSecret + ? '✓ Saved' + : '⚠ Not saved'; + // max_charge_w === 0 means block extra production at cheap prices. + var blockCharge = (d.max_charge_w === 0); + html += '
API credentials' + + '

Register an application at the provider\'s developer portal to get a Client ID and Client Secret.

' + + '
' + + '' + + '' + + '
' + + '' + + '' + + '
' + + '' + + '
'; + } if (isCloudDriver) { var cfg = d.config || {}; var hasPw = d.has_password === true; @@ -241,6 +272,9 @@ driver.config = { ip: "", vin: "" }; } else if (connHost) { driver.config = { host: connHost }; + } else if (entryCaps.indexOf("apicreds") >= 0) { + // OAuth2 client_credentials drivers (e.g. MyUplink). + driver.config = { client_id: "", client_secret: "" }; } else { driver.config = { email: "", password: "", serial: "" }; } @@ -376,6 +410,22 @@ inp.addEventListener("blur", syncAllowedHosts); }); + // API creds drivers: "blockera extra produktion" checkbox. + // Checked → max_charge_w = 0 (MPC may never charge/pre-heat) + // Unchecked → delete max_charge_w (no restriction) + bodyEl.querySelectorAll(".apicreds-block-charge").forEach(function (cb) { + cb.addEventListener("change", function () { + var dIdx = parseInt(cb.dataset.driverIdx, 10); + var d = config.drivers[dIdx]; + if (!d) return; + if (cb.checked) { + d.max_charge_w = 0; + } else { + delete d.max_charge_w; + } + }); + }); + // Add/remove-device buttons. var addMqtt = document.getElementById("add-mqtt"); var addModbus = document.getElementById("add-modbus");