Skip to content

Matter support + flexible-load control (thermostats, smart plugs, floor heating)#487

Closed
hannesb90 wants to merge 25 commits into
srcfl:masterfrom
hannesb90:claude/wiser-matter-compatibility-bud4hm
Closed

Matter support + flexible-load control (thermostats, smart plugs, floor heating)#487
hannesb90 wants to merge 25 commits into
srcfl:masterfrom
hannesb90:claude/wiser-matter-compatibility-bud4hm

Conversation

@hannesb90

Copy link
Copy Markdown
Contributor

Why this matters

42W already optimizes a fleet of DERs — batteries, EV chargers, inverters. The biggest uncontrolled energy in most homes is still the thermal and pumped loads: spa/pool heaters, floor-heating thermostats, water heaters, circulation pumps, panel/radiator heaters. These are exactly the high-kWh, time-shiftable loads price optimization cares most about, and they're increasingly Matter-native (or reachable through a Matter bridge).

This PR adds Matter as a transport so those loads become first-class managed devices — a natural extension of the DERs 42W already controls, planned against the same price/PV forecast the battery uses. A spa that heats on cheap/solar hours, a floor that pre-heats before the evening peak and coasts through it, a water heater that dodges the expensive window — all under the same optimizer, no extra per-device code.

Summary

Adds Matter as a first-class driver transport (like MQTT/Modbus/HTTP) and builds price-aware flexible-load control on top of it. The battery/EV DP is deliberately not touched (cramming thermal + interruptible state into it would explode it from ~100M to billions of evaluations); flexible loads decompose into a separate scheduler that runs against the existing MPC price/PV curve.

Motivating question: "will Matter support work with Wiser?" — yes, Wiser exposes Matter, and this makes 42W a Matter controller without Home Assistant in the loop.

Device categories this unlocks

Load Today With this PR
Spa / pool heater + pump invisible learned load profile, run on cheap/PV hours within a daily budget
Floor heating (electric or hydronic) invisible 2R2C slab+room model, pre-heat / coast, COP-aware
Water heater invisible deferrable energy budget, cheapest-slot scheduling
Circulation / pool pumps invisible interruptible, PV-preferring
Panel / radiator thermostats invisible RC model, pre-heat, comfort-floor guaranteed
Standalone Matter temp / contact sensors n/a feed the thermal models

Generic drivers/matter.lua handles all of them config-only — no new Go per device type.

What's included

Matter transport

  • go/internal/matter — WebSocket client to python-matter-server (request/response correlation, auto-reconnect, write serialized off the shared mutex). No CGo.
  • MatterCap capability + host.matter_read/write/invoke exposed to Lua, factory wired in the registry, capabilities.matter config block.
  • drivers/matter.lua — generic, config-driven driver (any Matter device: thermostat, plug, temp sensor) + documented examples.
  • docker-compose.yml  python-matter-server sidecar (host net for mDNS, privileged for BLE commissioning, ./data/matter for fabric state).

Thermal models (go/internal/thermalmodel)

  • Single-mass 1R1C RC twin (RLS → time constant τ), physical priors.
  • Two-mass 2R2C model for floor heating — slab + room, captures how a charged slab keeps the room warm for hours after the element cuts out. ForecastRoom, CoastHoursToRoomTarget.

Flexible-load scheduler(go/internal/flexload)

  • PlanThermal — comfort-respecting, price/PV-aware setpoint schedule with a physically-correct comfort-floor guarantee.
  • PlanDeferrable — cheapest-slots energy-budget scheduler for smart plugs, with PV-surplus preference and self-learning plug loads (learns running power + daily energy from the plug's own metering → a spa vs. a water heater self-calibrate).
  • Simple, MPC-independent mode — block heating when expensive only if the building's learned inertia keeps the target AND the model is trained (no reduction on an unvalidated model).
  • COP/cost-aware arbitration — under a shared fuse budget, shed the least cost-effective heat first (lowest COP), never a zone that needs the heat.
  • Wood-stove (kamin) detection — fast indoor rise the model can't explain from metered electric heat ⇒ free external source; pauses electric heat (per-zone only) and stops training so the firing can't corrupt the model.
  • Economic pause gate — any reduction below comfort must prove a net win: energy saved now vs. cost to reheat at the forecast price then, with a heat-pump flow-temperature credit (a hot hydronic loop reheats nearly free).
  • Electric vs. hydronic modelled explicitly via COP (thermal watts drive the model, electrical watts drive grid/fuse/cost).

Safety / scope

  • Existing battery/EV DP untouched; flexload only starts when flexloads: is declared in config.
  • Every heat reduction is gated on learning + an economic calculation — no "dumb" lowering.
  • All new logic is pure and unit-tested (RLS convergence to true τ and slab/room coefficients, comfort-floor enforcement, cheapest-slot selection, COP arbitration, stove detection, pause economics incl. flow-temp credit, two-mass coast vs single-mass).

Notes

  • The one red test in the repo (TestLuaHTTPPatchMethod) is pre-existing on master (a quoting bug in that test's embedded Lua) — unrelated to this branch.
  • Draft — direction proposal. The Matter protocol layer is not yet verified against a real python-matter-server. Natural follow-ups: protocol verification, planner-mode using the two-mass forecast, docs/ + per-package CLAUDE.md, and an API/UI surface for the learned models (τ, COP, stove cycles, plug classification).

hannesb90 and others added 13 commits May 1, 2026 11:08
Myuplink integration
…ackend)

Matter joins MQTT, Modbus, and HTTP as a supported transport in the Lua
driver host. Drivers declare `capabilities: matter: host: localhost` in
config.yaml and use host.matter_read / host.matter_write / host.matter_invoke
to talk to commissioned devices via the python-matter-server WebSocket API.

Changes:
- go/internal/matter/client.go — new package; gorilla/websocket client to
  python-matter-server with request/response correlation, auto-reconnect,
  and explicit Close() for driver lifecycle management
- go/internal/drivers/host.go — MatterCap interface, HostEnv.Matter field,
  WithMatter() builder, matterRead/Write/Invoke proxy methods
- go/internal/config/config.go — MatterConfig struct, Capabilities.Matter
  field, validation updated to accept matter as a valid capability
- go/internal/drivers/registry.go — MatterFactory, wiring in Add(),
  Close() on driver stop, sameDriverConfig comparison for Matter
- go/internal/drivers/lua.go — host.matter_read/write/invoke exposed to Lua
- go/cmd/forty-two-watts/main.go — reg.MatterFactory wired to matter.Dial
- docker-compose.yml — python-matter-server sidecar service (privileged for
  BLE commissioning, host networking for mDNS, ./data/matter for fabric state)

https://claude.ai/code/session_01216f8gyh8UStcEYivzreQZ
Config-driven driver that works with any Matter-certified device.
Operator specifies reads (attribute polls → metrics or structured DER
telemetry) and commands (named attribute writes or cluster invocations)
in config.yaml — no code change needed per device type.

Typical uses: Wiser/Schneider thermostat, smart plugs with power
metering, on/off switches, energy meters. The dispatch layer sends
named commands (e.g. "setpoint") which map to Matter attribute writes,
enabling MPC-controlled thermal load shifting.

https://claude.ai/code/session_01216f8gyh8UStcEYivzreQZ
Adds MPC-adjacent control for the two flexible-load categories Matter
actually covers today — thermostats and on/off smart plugs — without
touching the battery/EV DP (cramming thermal + interruptible state into
it would explode the DP from ~100M to billions of evaluations). Flexible
loads couple to the system only through the grid-power balance and the
price signal, so they decompose into a separate scheduler that runs
against the MPC plan's existing price/PV curve.

New packages:
- go/internal/thermalmodel — single-zone 1R1C building twin. RLS fits
  the heat-loss rate (τ) and heating gain from observed temp deltas,
  mirroring pvmodel. Physics priors give usable day-one behaviour;
  comfort-floor guarantees come from the learned time constant.
- go/internal/flexload — PlanThermal (comfort-respecting, price-aware
  setpoint schedule with a forward-simulated MinC floor) + PlanDeferrable
  (cheapest-slots energy-budget scheduler with optional PV-surplus
  credit), plus a Service that trains thermal models from telemetry and
  dispatches per-slot directives to Matter drivers.

Electric vs hydronic heating is modelled explicitly: a COP separates the
thermal watts that drive the RC model from the electrical watts that hit
the grid/fuse/cost accounting (direct electric COP=1; heat-pump-fed
hydronic COP≈3, MaxHeatW = zone thermal output).

Config: new `flexloads:` block. Service starts only when declared, so
existing deployments are unaffected. Wired in main.go behind that gate;
slots come from the live MPC plan, outdoor temp from the forecast cache.

Tests cover RLS convergence to true τ, comfort-floor enforcement in a
cold leaky zone, cheapest-slot selection, PV preference, and COP-correct
electrical accounting.

https://claude.ai/code/session_01216f8gyh8UStcEYivzreQZ
Three additions driven by real-device realities:

1. Standalone Matter temperature sensors. A thermostat zone can now read
   its indoor temperature from a *separate* driver (`indoor_driver`) — a
   dedicated Matter Temperature Measurement sensor (cluster 0x0402) —
   instead of the thermostat's own mounting-biased probe. The generic
   matter.lua already reads arbitrary attributes; flexload just sources
   the metric from the named sensor driver.

2. Simple, MPC-independent valuation mode (`mode: simple`). A standalone
   block/heat rule: heat to keep TargetC, but block heating while the
   price is above a threshold whenever the building's own thermal inertia
   (learned RC time constant) keeps the target for the block horizon.
   Works with a fixed öre threshold and no forecast at all. Multi-zone
   ArbitrateSimple blocks the highest-power zone first when comfort
   allows, under a shared fuse budget — never a zone that needs the heat
   or one at its comfort floor. Price comes from priceforecast (not the
   MPC), so this runs with the planner disabled.

3. Self-learning plug loads (PlugProfile). A smart plug / switch /
   contactor can drive anything — spa, water heater, pump — at wildly
   different power. With `power_metric` set, the scheduler learns the
   actual running power and daily energy from the plug's own metering, so
   PowerW / EnergyWh become optional (learned when 0). A coarse Classify()
   labels the load (water_heater / spa_or_pool / …) for diagnostics.

All new logic is pure + tested: block/heat decisions, power-priority
arbitration with comfort protection, running-power learning, daily-energy
integration with day-rollover folding, and device classification.

https://claude.ai/code/session_01216f8gyh8UStcEYivzreQZ
… window gating

Bug fixes from a critical review of the flex-load code:

- scheduler.go: the comfort-floor guard was dead code (`target < minC`
  could never be true since target ∈ {minC,maxC}). Replaced with a
  physically-correct three-regime rollforward (pull-up capped at setpoint /
  pure coast / hold-at-setpoint) so the predicted indoor temp genuinely
  never drops below MinC and EstHeatW reflects real draw.
- service.go: replan() bailed when the MPC slot curve was empty, which
  prevented simple (MPC-independent) mode from ever running. Now only
  planner-mode devices need slots; simple mode runs off PriceNow.
- service.go: metered deferrable plugs deadlocked at cold-start (off →
  never runs → never learns → never scheduled). Now left on native control
  until the profile is learned.
- matter/client.go: WriteMessage no longer holds the shared mutex — a slow/
  half-open socket could block the readLoop from delivering responses. A
  dedicated writeMu serializes writes instead.
- service.go: nil-guard the per-zone thermal model lookups.

New value features (operator-requested):

- External-heat (wood-stove) detection: a fast indoor rise the RC model
  can't explain from metered electric heat is inferred as a free external
  source (kamin, solar gain). 42W then pauses the zone's electric heating
  (frost-protection setpoint) AND stops training the model — firing through
  it would teach a false building. Learns per-firing energy + inferred
  thermal power over cycles.
- COP/cost-aware arbitration: under a shared fuse budget, zones are shed by
  cost-effectiveness (lowest COP first — cheapest heat to lose per watt
  freed), not raw power; ties broken by power then coast buffer (which
  already encodes water-vs-electric inertia via the learned time constant).
- Window/occupancy gating: a contact/occupancy metric (Matter cluster
  0x0045 / 0x0406) drops the zone to frost protection and pauses training
  when a window is open, so pre-heat isn't wasted and τ isn't corrupted.

Tests: stove fires on unexplained warming / ignores our own heat / folds
per-cycle energy; arbitration sheds the resistive zone before the heat
pump. All new logic pure + covered.

https://claude.ai/code/session_01216f8gyh8UStcEYivzreQZ
…ing + economics

Per operator direction: no heat reduction may happen unless it was preceded
by learning AND a calculation proving it's a net win. No "dumb" lowering.

- Removed window/occupancy detection entirely (config, Device, wiring,
  helpers). It blocked heat on a raw signal without an economic basis.

- Stove (external-heat) response is now strictly graded and per-zone:
    * The detector reads only its own zone's indoor temperature, so a fire
      in the living room can never lower the bathroom.
    * Default (no learning required): suppress pre-heat and HOLD the comfort
      target. The stove keeps the room warm so electric stays off naturally;
      the room is never allowed to overcool.
    * A deeper reduction toward MinC happens ONLY when (a) the RC model is
      trained (Quality ≥ 0.4), (b) we've learned the stove's typical firing
      energy/duration, and (c) pauseIsNetWin proves it: the energy saved now
      outweighs the cost to reheat once the fire ends, using the FORECAST
      price at the reheat moment. If reheating later is pricier than heating
      now, we hold comfort. This captures the floor-heating checklist —
      demand (HeatToHoldW vs outdoor), price now, inertia (energy integrated
      over the learned coast), COP, and reheat price.

- Simple-mode price blocking now also gates on model confidence: an
  untrained zone is never blocked, it maintains target. No reduction on an
  unvalidated model.

- Generalized the price hook to PriceAt(t) so the reheat-side of the
  economics can look up the future price, not just "now".

Tests: untrained model never blocks; pause only wins when reheat is cheaper
than now (and never without a price model); a detected stove with no learning
only holds comfort, never reduces.

https://claude.ai/code/session_01216f8gyh8UStcEYivzreQZ
The cost to reheat after a pause depends on how much heat the hydronic loop
is already storing — readable as the heat pump's supply (flow) temperature
from its integration (Nibe/Thermia/myUplink/etc). A hot loop can deliver
heat to the room without running the compressor, so recovery is nearly free;
a cold loop must run the compressor and is expensive.

pauseIsNetWin now discounts the reheat cost by reheatFactor(flow, room,
nominal_delta): a flow temperature a full design-delta above the room
(e.g. +15°C floor heating) credits the stored charge to ~zero reheat cost,
so a deep pause becomes a net win even when the spot price will be higher at
reheat time. With no flow signal the credit is never applied (assume full
cost) — no speculative reductions.

Config: flow_driver / flow_metric (read flow temp off the heat-pump driver)
and nominal_flow_delta_c (design flow-above-room delta, default 15). Wired
through Device → stoveDecision → pauseIsNetWin.

Tests: reheatFactor scales 1→0 with flow headroom; a high flow temp flips a
pricier-reheat case into a net-win pause.

https://claude.ai/code/session_01216f8gyh8UStcEYivzreQZ
Floor heating has two thermal masses with very different time constants: a
slow concrete slab heated directly, and the faster room air the slab feeds.
The single-mass RC fit lumps them together and badly underestimates how long
a floor-heated room stays warm after the element switches off — the slab
keeps radiating for hours. That under-estimate mis-prices every pause/block.

New thermalmodel.TwoMass:
  Cs·dTs/dt = P − (Ts−Tr)/Rsr        (slab)
  Cr·dTr/dt = (Ts−Tr)/Rsr − (Tr−To)/Rro  (room)
fit as two RLS regressions (slab + room) sharing the codebase's estimator,
with physical priors, ForecastRoom (the horizon trajectory), and
CoastHoursToRoomTarget — which correctly exceeds the single-mass coast when
the slab is charged. Also added Model.CoastHoursToTarget for the single-mass
case (flexload had its own copy).

Wired into flexload: a thermostat with slab_driver/slab_metric (a floor
probe, common on electric floor thermostats, or the hydronic flow temp as a
proxy) trains a two-mass model alongside the single-mass one and prefers it
for the simple-mode coast estimate AND the confidence gate. Persisted under
flexload/twomass/<driver>; exposed via TwoMassModel() for diagnostics.

Tests: 2R2C recovers true slab/room coefficients; its coast exceeds the
single-mass coast with a hot slab; forecast shape holds; a two-mass coast
override flips a simple-mode block that the single-mass would refuse.

https://claude.ai/code/session_01216f8gyh8UStcEYivzreQZ
@hannesb90

Copy link
Copy Markdown
Contributor Author

Marking this as a draft — it's a direction proposal, not a merge-ready change, and I'd like a maintainer go/no-go before investing further.

The ask: is Matter-as-a-transport + a decoupled flexible-load layer a direction you want in 42W? Specifically:

Matter via a python-matter-server sidecar. It keeps the no-CGo rule (WebSocket to the sidecar, no Matter SDK linked in) and mirrors the existing ftw-updater/Mosquitto sidecar pattern. But it does add a privileged container (BLE commissioning) to the deploy. Acceptable, or would you rather Matter come in via the HA bridge only?

Flexible loads as a separate scheduler, not new DP dimensions. I deliberately kept the battery/EV DP untouched — adding thermal + interruptible state would blow it up combinatorially — and ran the flex-load scheduler against the plan's existing price/PV curve instead. Does that decomposition match how you'd want it architected?

Scope of the "smarts". Thermal RC models (1R1C + a 2R2C floor-heating model), a wood-stove detector, COP-aware arbitration, and an economic pause gate with a heat-pump flow-temp credit. Genuinely useful, or more than this project wants to own?

Honest status: the analytical cores are pure and unit-tested. The Matter protocol layer is not yet verified against a real python-matter-server — the WS message shapes are from memory and likely need correcting. No commissioning flow, no API/UI, no docs/ pages yet. So this is "is the direction right?", not "please merge."

If it's worth pursuing I'll: verify the Matter protocol against the real server, add docs/ + per-package CLAUDE.md, and an integration test. If it's not the direction you want, better to know now. 🙏

@erikarenhill @frahlg

claude added 4 commits June 16, 2026 05:20
- matter.lua: anchor device_id on node_id (not model name) to prevent
  collision when two identical Wiser thermostats share the same fabric.
  host.set_sn(tostring(node_id)) replaces host.set_sn(cfg.model).

- matter/client.go: fix attribute_path format from "ep/0xCLUSTER/0xATTR"
  to "ep/cluster/attr" (decimal integers) matching the python-matter-server
  WebSocket API contract. Applies to both ReadAttribute and WriteAttribute.

- flexload/service.go: priceForNow fallback now returns slots[last] instead
  of slots[0] when now is past all horizon slots (oldest price is stale).

- flexload/service.go: log Warn when indoor sensor is down and buildSimpleSpec
  falls back to mid-band, so operators can detect sensor outages.

- config/config.go: add FlexLoad validation in Validate() — catches missing
  driver_name, invalid type, min_c >= max_c, negative COP, and bad mode.

- config/config.go: mark HeatSourceDriver as reserved/not-yet-implemented
  so operators aren't confused by an undocumented no-op field.

- flexload/service_test.go: new tests covering sample() (thermal model
  trains from telemetry, skips without HeatMetric), replan() (thermostat
  planner dispatch, deferrable on/off, simple-mode arbitration, no-op with
  no slots), and priceForNow fallback correctness.

https://claude.ai/code/session_01QTQB2i2R9FTVeqYqVFJVPp
- thermalmodel/model.go + twomass.go: replace `denom == 0` guard with
  `denom < 1e-9` in the RLS update. In a healthy RLS the denominator
  is λ + xᵀPx ≥ 0.997, so exact-zero is unreachable, but numerical
  drift in a corrupted P matrix can produce near-zero values that cause
  K → ∞ and wild coefficient updates. The epsilon guard catches this.

- flexload/scheduler.go: filter NaN/Inf prices before sorting in
  priceQuantile. A corrupted price feed returning NaN would propagate
  through sort.Float64s into the threshold and corrupt all scheduling
  decisions. Zero is now returned, which safely degrades to "never
  expensive" so simple-mode never blocks heating.

- flexload/scheduler.go: fix misleading comment on pvCoverW (the 0.5
  factor means 50%-surplus, not full-surplus) and clarify ScheduledWh
  may exceed EnergyWh by up to one slot (unavoidable ON/OFF rounding).

https://claude.ai/code/session_01QTQB2i2R9FTVeqYqVFJVPp
Tests a fake python-matter-server (httptest + gorilla/websocket) to verify
the client's protocol and error-handling paths without real hardware:

- TestReadAttribute_Success: round-trip returns correct value
- TestReadAttribute_AttributePath: path format is decimal "ep/cluster/attr",
  not hex — regression guard for the format fix
- TestReadAttribute_ServerError: error_code propagates as a Go error
- TestWriteAttribute_Success: write round-trip, value arrives at server
- TestInvokeCommand_Success: send_command round-trip
- TestConcurrentRequests: three concurrent calls are correctly correlated
  by message_id even with concurrent goroutines
- TestCallToClosedServer: conn=nil fast-path rejects immediately (< 200 ms),
  not after the 10 s internal timeout
- TestClose_WaitsForReadLoop: Close() unblocks and double-Close doesn't panic
- TestReconnect: client reconnects after server drops the connection and
  subsequent calls succeed (needs 2 s for the reconnect back-off)
- TestPendingCleared_OnDisconnect: in-flight calls receive connection_lost
  error when the server disconnects, not an infinite hang

https://claude.ai/code/session_01QTQB2i2R9FTVeqYqVFJVPp
The test embedded JSON bodies directly inside a Lua double-quoted string
literal. When patchBody contained {"value":"42"}, the inner double-quotes
terminated the Lua string early, causing a parse error.

Fixed by using Lua long-string brackets ([[...]]) for the body argument,
which accepts any content including double quotes.

https://claude.ai/code/session_01QTQB2i2R9FTVeqYqVFJVPp
@HuggeK

HuggeK commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

The python-matter-server is no longer maintained so matter-js or the offical C sdk I think would be better for the future. I would suggest that we do not support matter commisioning, so much to take care of. It is better to leave the first initial commisioning to another controller and then we can just add the device to our controller via multi-fabric. Then thread/wifi whatever transport layer it is doesn´t matter(badumtss!) You share your device from your other controller and then just provide a commissioning code into 42W.

EDIT: some reasoning why we do not want to be the controller who does the initial commission(first time setup) most devices commission over Bluetooth or other means where you would need to have your phone in hand - and for that you need the platform specific api's and an app, which would be unecsary complex. Much better to delegate this to other platforms which have this tested and already done, multi-fabric is one of the key selling points of matter, would be nice to use it.

@HuggeK

HuggeK commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

I think it is also really good if we expose the enegry managment clusters for devices to consume with the prices etc also as a EMS-focused Matter controller. Thomas mcguiness did many energy related matter stuff which is cool: https://tomasmcguinness.com/2025/07/20/creating-a-matter-controller-with-matter-js/
Also running the matter controller as a bridge for the EVSE and invererts is also a good idea- so other controllers can add our bridged devices which do not have matter, as https://tomasmcguinness.com/2026/04/29/integrating-a-solax-x1-g4-inverter-with-matter-protocol/

claude added 8 commits June 17, 2026 10:13
…er review

Owner feedback on PR #1: python-matter-server is unmaintained, swap to
matter.js/official SDK; drop our own commissioning flow in favor of
joining devices via multi-fabric (pairing code from whichever
controller already commissioned the device); also explore running 42W
as a Matter EMS server + bridge for non-Matter DERs.

This commit only does the immediately-safe part: comments/docs updated
to reflect the new scope (no BLE/privileged container needed since we
no longer commission), and the matter-server compose service commented
out pending the real sidecar. Wire protocol + EMS-server/bridge work
tracked as follow-on in the PR thread.
Second pass over the maintainer's PR #1 feedback. Beyond the compose/
client provisional flags, make the whole branch tell one consistent
story: 42W joins devices that another controller already commissioned,
via a shared pairing code, as an additional fabric admin — it never
commissions hardware itself.

- drivers/matter.lua: replace the old we-commission / 'pairing UI coming
  soon' header with the concrete share-from-your-controller -> paste
  pairing code -> sidecar assigns node_id workflow; note BLE/transport
  is the other controller's problem, not ours.
- MatterCap (host.go), MatterFactory (registry.go), MatterConfig
  (config.go): backend-neutral wording ('Matter sidecar', backend TBD)
  instead of hard-coding python-matter-server.
- matter.lua verification_status beta -> experimental, matching the
  other unproven drivers and the provisional backend.
Implements the multi-fabric, no-commissioning model from PR #1 review:
matter-sidecar/ is a real matter.js-based controller process speaking
the same JSON-over-WebSocket protocol go/internal/matter/client.go
already implements. It joins devices shared from another controller's
fabric via a pairing code (`commission` command), persists a small
logical node_id mapping for driver config (Matter NodeIds are 64-bit
and not what drivers/matter.lua's config expects), and resolves
generic numeric cluster/attribute/command IDs against matter.js's
built-in standard cluster definitions for read/write/invoke.

docker-compose.yml's matter-sidecar service now builds from
matter-sidecar/ instead of being commented out pending a TBD image.
Phase 1 had no way to trigger the pairing-code join besides a raw
WebSocket message to the sidecar. Adds a site-wide `matter:` config
block (separate from each driver's own capabilities.matter, which
talks to an already-joined node_id) wired to a Commission/ListNodes
client in go/internal/matter, exposed as two REST endpoints so an
operator can paste a pairing code and get back the node_id for their
driver config without scripting a WebSocket call by hand.
Adds a CommodityPrice server endpoint (ElectricalEnergyTariffDevice) to
the sidecar so other Matter controllers can read 42W's spot price +
forecast directly. A new background loop in main.go pushes fresh data
from internal/prices.Service every 5 minutes whenever both `matter:`
and `price:` are configured. GET /api/matter/pairing_code exposes the
codes a third-party controller needs to add 42W to its own fabric —
the inverse of the Phase 1 commission flow.

Also fixes a sidecar startup bug surfaced while testing this: setting
@matter/nodejs/config's defaultStoragePath at the top of index.ts threw
NodeJsAlreadyInitializedError, because ESM hoists all of the file's
imports (including @matter/main, which initializes the default
Environment) ahead of that assignment. Switched to setting
MATTER_STORAGE_PATH via process.env, which matter.js's VariableService
picks up lazily.
Each driver opted in via the new matter_bridge config flag becomes a
bridged Matter device (Aggregator -> BridgedNode -> ElectricalMeter
child endpoint) so other Matter ecosystems can see its live power
draw. matterBridgeLoop pushes readings to the sidecar's sync_bridge
command every 5 minutes, mirroring the Phase 2 price-feed loop.

Uses ElectricalMeterDevice uniformly for all DER types rather than
type-specific device types (battery/PV/EVSE), trading semantic
richness for one tractable code path. SoC bridging is deferred — it
needs its own feature-conformance work on PowerSourceServer's Battery
feature.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01216f8gyh8UStcEYivzreQZ
pushMatterBridge returned early when no driver had matter_bridge set,
which meant disabling the flag on every (or one of several) bridged
driver via hot-reload never told the sidecar to mark those endpoints
unreachable -- they'd stay reachable:true forever. Always call
SyncBridge now, including with an empty list. Also warns at startup
if matter_bridge is set without a matter: block, and adds SyncBridge
test coverage matching the existing capability test style.
… build

- sidecar WS control plane binds to loopback only (was 0.0.0.0 on a
  host-networked container, exposing unauthenticated
  commission/write_attribute/send_command to the whole LAN)
- client.go: set a write deadline before WriteMessage so a half-open
  socket can't block past the caller's context timeout; drop the
  connection on write error so readLoop reconnects instead of retrying
  writes on a dead socket forever
- Dial() no longer fails permanently if the sidecar isn't up yet at
  startup — it keeps retrying via the existing reconnect loop instead
  of requiring a full 42W restart
- matter.lua always sets a make (falling back to "matter") so
  ResolveDeviceID's make+serial path fires; without it, every node on a
  shared sidecar resolved to the same device_id (the shared
  matter://host:port endpoint), colliding learned state across devices
- config.go: matter.host / capabilities.matter.host now required when
  matter: is configured (previously silent until first runtime call);
  thermostat flexloads now require min_c/max_c instead of silently
  allowing 0/0 through
- Dockerfile: COPY + npm ci against package-lock.json instead of npm
  install against package.json alone, for reproducible builds

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01216f8gyh8UStcEYivzreQZ
@HuggeK

HuggeK commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Related: work has started in the device simulator to be able to simulate Matter devices — commissionable thermostats, smart plugs (with energy), EVSE, heat pumps, dishwashers/washing machines, and temperature/light sensors — so this can be tested against virtual devices without physical hardware.

Draft PR: srcfl/device-simulator#5

@frahlg

frahlg commented Jul 11, 2026

Copy link
Copy Markdown
Member

Closing this draft because it is not deployable through the repository’s supported release paths and has since diverged architecturally. Concrete blockers: (1) scripts/install.sh downloads only docker-compose.yml and the RPi image copies only that file, while this PR declares build: ./matter-sidecar; the source directory is absent on real installs, so compose cannot build/start it. (2) No workflow publishes a multi-arch sidecar image and there are no CI checks for the TypeScript/matter.js runtime. (3) The flexload/thermalmodel implementation predates and conflicts with the comfort-bounded thermal.Asset contract now on master from #491. (4) The branch also carries an obsolete MyUplink implementation and combines controller transport, commissioning, EMS server, DER bridge, and a new scheduler in one unreviewable release unit. A replacement should be split: first publish and integration-test an authenticated/loopback matter.js sidecar image for amd64+arm64; then add a focused Go transport; then adapt flexible loads to internal/thermal with explicit comfort/failsafe tests. Do not port the current compose build block.

@frahlg frahlg closed this Jul 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants