diff --git a/.gitignore b/.gitignore
index 820b725..aff7af4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -177,3 +177,7 @@ data/state.json
# Go binaries
/simulator
+
+# Windows bash crash dumps
+bash.exe.stackdump
+*.stackdump
diff --git a/CLAUDE.md b/CLAUDE.md
index 98cc62d..453f229 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -46,6 +46,7 @@ make build-linux
- Modbus TCP: 5000+
- MQTT: 1883+
- OCPP: 9000+
+- Matter (UDP): 5540+
### Run Tests
@@ -107,7 +108,7 @@ HTTP API (port 80 in Docker, 8762 in desktop mode):
```
GET / # Dashboard UI
GET /api/simulators # List all simulators
-POST /api/simulators # Create simulator {type: "inverter"|"energy_meter"|"v2x_charger"|"ocpp_charger"}
+POST /api/simulators # Create simulator {type: "inverter"|"energy_meter"|"v2x_charger"|"ocpp_charger"|"matter_*"}
DELETE /api/simulators/{id} # Delete simulator
GET /api/simulators/{id} # Get simulator details
POST /api/simulators/{id}/config # Update serial/slave ID
@@ -121,6 +122,7 @@ GET /api/sites # List all sites
POST /api/sites # Create site
GET /api/system # Get system info (local IP)
GET /api/version # Get app version
+GET /api/matter/status # Matter availability (Node.js required)
GET /api/update/check # Check for updates
POST /api/update/apply # Apply update
```
@@ -202,9 +204,27 @@ func (a *App) CheckForUpdate() (*UpdateInfo, error)
| MODBUS_BASE_PORT | 5000 | First Modbus port |
| MQTT_BASE_PORT | 1883 | First MQTT port |
| OCPP_BASE_PORT | 9000 | First OCPP port |
+| MATTER_BASE_PORT | 5540 | First Matter (UDP) operational port |
| HTTP_PORT | 80 (Docker) / 8762 (Desktop) | Web dashboard port |
| WAILS_DESKTOP | 0 | Set to 1 to force desktop mode |
| HOST_IP | auto-detected | Override displayed host IP |
+| MATTER_NODE_PATH | auto-detected | Override path to the `node` binary for Matter |
+| MATTER_STORAGE_PATH | OS config dir | Matter fabric/credential storage root |
+| MATTER_LOG_LEVEL | notice | matter.js bridge log level (debug/info/notice/warn/error/fatal) |
+
+## Matter support (matter.js)
+
+Matter devices are emulated via [matter.js](https://github.com/matter-js/matter.js)
+(`@matter/main` v0.16, Matter 1.4.2). Because matter.js is Node.js, the app spawns and
+supervises a Node bridge (`internal/matter/bridge`, vendored as `node_modules.tgz` and
+embedded). **Node.js (v18+, v20+ recommended) must be installed on the host**; if absent,
+Matter is disabled gracefully β see `GET /api/matter/status`. Device types:
+`matter_tempsensor`, `matter_lightsensor`, `matter_smartplug`, `matter_evse`,
+`matter_thermostat`, `matter_heatpump`, `matter_dishwasher`, `matter_laundrywasher`.
+Each is commissionable (manual pairing code + `MT:` QR). Devices use the CSA **test**
+Vendor ID `0xFFF1`, so pairing into Home Assistant requires enabling its **test/Test-Net
+DCL** option. `go test ./...` stays Node-free; real-Node tests run via
+`make test-matter` (`-tags matter_integration`). Re-vendor deps with `make vendor-matter`.
## CI/CD (GitHub Actions)
diff --git a/Makefile b/Makefile
index 0954582..1cfcf05 100644
--- a/Makefile
+++ b/Makefile
@@ -1,6 +1,6 @@
.PHONY: up down restart build logs clean test env \
build-macos build-linux build-linux-server build-windows \
- run-macos package help
+ run-macos package help vendor-matter test-matter
# Auto-detect host IP for Mac (en0 = WiFi, en1 = Ethernet)
HOST_IP ?= $(shell ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1 2>/dev/null || echo "")
@@ -32,6 +32,18 @@ clean:
test:
go test ./...
+# Vendor the matter.js bridge dependencies and repackage them for embedding.
+# Run this after changing the bridge's package.json (e.g. bumping matter.js).
+# Produces internal/matter/bridge/node_modules.tgz, which is embedded into the
+# Go binary; the raw node_modules/ is gitignored.
+vendor-matter:
+ cd internal/matter/bridge && npm ci --omit=dev && tar czf node_modules.tgz node_modules
+ @echo "Vendored matter.js -> internal/matter/bridge/node_modules.tgz"
+
+# Run the real-Node Matter integration tests (skipped without Node installed).
+test-matter:
+ go test -tags matter_integration ./internal/matter/...
+
# Build macOS desktop app (native, requires macOS)
build-macos:
@echo "Building macOS desktop app..."
diff --git a/cmd/simulator/app.go b/cmd/simulator/app.go
index aabbeb8..29b9026 100644
--- a/cmd/simulator/app.go
+++ b/cmd/simulator/app.go
@@ -7,6 +7,7 @@ import (
"math"
"time"
+ "github.com/srcfl/device-simulator/internal/matter"
"github.com/srcfl/device-simulator/internal/modbus"
"github.com/srcfl/device-simulator/internal/modbus/devices"
"github.com/srcfl/device-simulator/internal/settings"
@@ -117,6 +118,17 @@ func (a *App) GetSimulators(filters map[string]string) []map[string]interface{}
entry["total_power"] = state["totalPower"]
entry["has_active_transaction"] = state["hasActiveTransaction"]
entry["max_power_kw"] = state["maxPowerKW"]
+ case "matter":
+ if sim.MatterServer != nil {
+ state := sim.MatterServer.GetState()
+ entry["device_type"] = state["device_type"]
+ entry["label"] = state["label"]
+ entry["pairing_code"] = state["pairing_code"]
+ entry["qr"] = state["qr"]
+ entry["commissioned"] = state["commissioned"]
+ entry["fabric_count"] = state["fabric_count"]
+ entry["bridge_up"] = state["bridge_up"]
+ }
}
sim.mu.RUnlock()
list = append(list, entry)
@@ -198,11 +210,27 @@ func (a *App) GetSimulator(id int) (map[string]interface{}, error) {
state := sim.OCPPServer.GetState()
result["state"] = state
result["ocpp_url"] = sim.OCPPServer.GetOCPPURL()
+ case "matter":
+ if sim.MatterServer != nil {
+ result["state"] = sim.MatterServer.GetState()
+ result["matter_type"] = sim.MatterType
+ }
}
return result, nil
}
+// MatterStatus reports whether Matter is available (Node.js detected) so the UI
+// can enable/disable Matter device creation and explain why.
+func (a *App) MatterStatus() map[string]interface{} {
+ available, reason := matter.Shared().Available()
+ return map[string]interface{}{
+ "available": available,
+ "reason": reason,
+ "node_version": matter.Shared().NodeVersion(),
+ }
+}
+
// UpdateConfig updates simulator configuration
func (a *App) UpdateConfig(id int, config map[string]interface{}) error {
sim := getSimulator(id)
@@ -228,6 +256,10 @@ func (a *App) UpdateConfig(id int, config map[string]interface{}) error {
if sim.OCPPServer != nil {
sim.OCPPServer.SetSerialNumber(serial)
}
+ case "matter":
+ if sim.MatterServer != nil {
+ sim.MatterServer.SetSerialNumber(serial)
+ }
}
}
if slaveID, ok := config["slave_id"].(float64); ok {
@@ -1181,15 +1213,15 @@ func (a *App) CreateSimulatorInSite(siteId int, simType string) (map[string]inte
a.emitSiteUpdate()
return map[string]interface{}{
- "id": sim.ID,
- "serial": sim.Serial,
- "protocol": sim.Protocol,
- "category": sim.Category,
- "port": sim.Port,
- "running": sim.Running,
- "site_id": siteId,
- "device_on": sim.DeviceOn,
- "mdns_hostname": mdnsAdvertiser.ActiveHostname(sim.ID),
+ "id": sim.ID,
+ "serial": sim.Serial,
+ "protocol": sim.Protocol,
+ "category": sim.Category,
+ "port": sim.Port,
+ "running": sim.Running,
+ "site_id": siteId,
+ "device_on": sim.DeviceOn,
+ "mdns_hostname": mdnsAdvertiser.ActiveHostname(sim.ID),
}, nil
}
diff --git a/cmd/simulator/main.go b/cmd/simulator/main.go
index 257552f..5485b11 100644
--- a/cmd/simulator/main.go
+++ b/cmd/simulator/main.go
@@ -21,10 +21,11 @@ import (
"time"
"github.com/srcfl/device-simulator/internal/device"
+ "github.com/srcfl/device-simulator/internal/matter"
+ "github.com/srcfl/device-simulator/internal/mcp"
simmdns "github.com/srcfl/device-simulator/internal/mdns"
"github.com/srcfl/device-simulator/internal/modbus"
"github.com/srcfl/device-simulator/internal/modbus/devices"
- "github.com/srcfl/device-simulator/internal/mcp"
"github.com/srcfl/device-simulator/internal/mqtt"
"github.com/srcfl/device-simulator/internal/ocpp"
"github.com/srcfl/device-simulator/internal/ports"
@@ -42,25 +43,27 @@ var embeddedFS embed.FS
// Simulator represents a single simulator instance
type Simulator struct {
- ID int `json:"id"`
- Serial string `json:"serial"`
- Protocol string `json:"protocol"`
- Category string `json:"category"`
- SlaveID uint8 `json:"slave_id,omitempty"`
- Port int `json:"port"`
- Running bool `json:"running"`
- SiteID int `json:"site_id"` // Which site this simulator belongs to (0 = orphan)
- DeviceOn bool `json:"device_on"` // Whether device is contributing power (can be off but still on site)
- BatteryCapacityKWh float64 `json:"battery_capacity_kwh"`
- Server *modbus.Server `json:"-"`
- MQTTServer *mqtt.Server `json:"-"`
- OCPPServer *ocpp.Server `json:"-"`
- Device device.DeviceServer `json:"-"`
- ProfilesManager *profiles.Manager `json:"-"`
- RegisterSet *modbus.RegisterSet `json:"-"` // Device-specific register definitions
- Automation *AutomationState `json:"-"` // Legacy - will be removed once migration complete
- internalSOC float64 // High-precision SOC accumulator (avoids register truncation)
- socInitialized bool // Whether internalSOC has been initialized from register
+ ID int `json:"id"`
+ Serial string `json:"serial"`
+ Protocol string `json:"protocol"`
+ Category string `json:"category"`
+ SlaveID uint8 `json:"slave_id,omitempty"`
+ Port int `json:"port"`
+ Running bool `json:"running"`
+ SiteID int `json:"site_id"` // Which site this simulator belongs to (0 = orphan)
+ DeviceOn bool `json:"device_on"` // Whether device is contributing power (can be off but still on site)
+ BatteryCapacityKWh float64 `json:"battery_capacity_kwh"`
+ Server *modbus.Server `json:"-"`
+ MQTTServer *mqtt.Server `json:"-"`
+ OCPPServer *ocpp.Server `json:"-"`
+ MatterServer *matter.Server `json:"-"`
+ MatterType string `json:"matter_type,omitempty"` // e.g. "matter_thermostat"
+ Device device.DeviceServer `json:"-"`
+ ProfilesManager *profiles.Manager `json:"-"`
+ RegisterSet *modbus.RegisterSet `json:"-"` // Device-specific register definitions
+ Automation *AutomationState `json:"-"` // Legacy - will be removed once migration complete
+ internalSOC float64 // High-precision SOC accumulator (avoids register truncation)
+ socInitialized bool // Whether internalSOC has been initialized from register
mu sync.RWMutex
}
@@ -167,10 +170,10 @@ var (
simulatorsMu sync.RWMutex
nextSimulatorID int
nextIDMu sync.Mutex
- portAllocator *ports.Allocator
- mdnsAdvertiser *simmdns.Advertiser
- isDesktopMode bool
- runtimeSettings *settings.AppSettings
+ portAllocator *ports.Allocator
+ mdnsAdvertiser *simmdns.Advertiser
+ isDesktopMode bool
+ runtimeSettings *settings.AppSettings
stateDirty bool
stateMu sync.Mutex
stateWarningsMu sync.Mutex
@@ -187,6 +190,7 @@ func main() {
modbusBasePort := flag.Int("modbus-base", 5000, "Base port for Modbus servers")
mqttBasePort := flag.Int("mqtt-base", 1883, "Base port for MQTT brokers")
ocppBasePort := flag.Int("ocpp-base", 9000, "Base port for OCPP servers")
+ matterBasePort := flag.Int("matter-base", 5540, "Base port for Matter devices")
httpPort := flag.Int("http", 8762, "HTTP server port")
desktopMode := flag.Bool("desktop", false, "Run in desktop mode (Wails)")
flag.Parse()
@@ -204,6 +208,9 @@ func main() {
if os.Getenv("OCPP_BASE_PORT") == "" {
*ocppBasePort = appSettings.OCPPBasePort
}
+ if os.Getenv("MATTER_BASE_PORT") == "" && appSettings.MatterBasePort != 0 {
+ *matterBasePort = appSettings.MatterBasePort
+ }
// Environment variable overrides (takes precedence over settings)
if env := os.Getenv("MODBUS_BASE_PORT"); env != "" {
@@ -221,6 +228,11 @@ func main() {
*ocppBasePort = n
}
}
+ if env := os.Getenv("MATTER_BASE_PORT"); env != "" {
+ if n, err := strconv.Atoi(env); err == nil {
+ *matterBasePort = n
+ }
+ }
if env := os.Getenv("HTTP_PORT"); env != "" {
if n, err := strconv.Atoi(env); err == nil {
*httpPort = n
@@ -262,6 +274,7 @@ func main() {
ModbusBasePort: *modbusBasePort,
MQTTBasePort: *mqttBasePort,
OCPPBasePort: *ocppBasePort,
+ MatterBasePort: *matterBasePort,
ModbusDisplayOffset: modbusDisplayOffset,
MQTTDisplayOffset: mqttDisplayOffset,
OCPPDisplayOffset: ocppDisplayOffset,
@@ -269,10 +282,10 @@ func main() {
}
log.Printf("Starting Device Simulator")
- log.Printf("Port ranges: Modbus %d+, MQTT %d+, OCPP %d+", *modbusBasePort, *mqttBasePort, *ocppBasePort)
+ log.Printf("Port ranges: Modbus %d+, MQTT %d+, OCPP %d+, Matter %d+", *modbusBasePort, *mqttBasePort, *ocppBasePort, *matterBasePort)
// Initialize port allocator
- portAllocator = ports.NewAllocator(*modbusBasePort, *mqttBasePort, *ocppBasePort)
+ portAllocator = ports.NewAllocator(*modbusBasePort, *mqttBasePort, *ocppBasePort, *matterBasePort)
// Initialize mDNS advertiser (works in desktop mode; Docker bridge doesn't support multicast)
isDesktopMode = *desktopMode
@@ -304,7 +317,10 @@ func runHTTPServer(httpPort int) {
// Start HTTP server
go startHTTPServer(httpPort)
- log.Println("Ready. Use the web UI to create simulators.")
+ log.Printf("Ready. Open the web UI: http://localhost:%d", httpPort)
+ if ip := getLocalIP(); ip != "" && ip != "localhost" {
+ log.Printf("On your network: http://%s:%d", ip, httpPort)
+ }
// Wait for shutdown signal
sigCh := make(chan os.Signal, 1)
@@ -318,6 +334,7 @@ func runHTTPServer(httpPort int) {
}
simulatorsMu.RUnlock()
mdnsAdvertiser.Shutdown()
+ matter.Shared().Shutdown()
saveStateIfDirty()
}
@@ -365,6 +382,7 @@ func runWailsApp(httpPort int) {
}
simulatorsMu.RUnlock()
mdnsAdvertiser.Shutdown()
+ matter.Shared().Shutdown()
saveStateIfDirty()
}
@@ -465,7 +483,12 @@ func createSimulator(simType string) (*Simulator, error) {
protocol = "ocpp"
category = "ocpp_charger"
default:
- return nil, fmt.Errorf("unknown simulator type: %s", simType)
+ if matter.IsMatterType(simType) {
+ protocol = "matter"
+ category = string(device.CategoryMatter)
+ } else {
+ return nil, fmt.Errorf("unknown simulator type: %s", simType)
+ }
}
port := 0
@@ -590,6 +613,21 @@ func createSimulator(simType string) (*Simulator, error) {
sim.OCPPServer = ocppServer
sim.Device = ocppServer
sim.Automation.Scenario = "idle"
+
+ case "matter":
+ // Fail cleanly (no dangling simulator) when Node.js is unavailable.
+ if ok, reason := matter.Shared().Available(); !ok {
+ return nil, &matter.ErrNodeUnavailable{Reason: reason}
+ }
+ matterServer, err := matter.NewServer(id, simType)
+ if err != nil {
+ return nil, err
+ }
+ sim.Serial = matterServer.SerialNumber()
+ sim.MatterServer = matterServer
+ sim.MatterType = simType
+ sim.Device = matterServer
+ sim.Automation.Scenario = "idle"
}
simulatorsMu.Lock()
@@ -823,6 +861,30 @@ func restoreSimulator(saved state.SimulatorState) (*Simulator, error) {
if sim.Automation.Scenario == "" {
sim.Automation.Scenario = "idle"
}
+ case "matter":
+ matterType := saved.MatterType
+ if matterType == "" {
+ return nil, fmt.Errorf("matter simulator missing device type")
+ }
+ if ok, reason := matter.Shared().Available(); !ok {
+ return nil, &matter.ErrNodeUnavailable{Reason: reason}
+ }
+ matterServer, err := matter.NewServer(sim.ID, matterType)
+ if err != nil {
+ return nil, err
+ }
+ if saved.Serial != "" {
+ matterServer.SetSerialNumber(saved.Serial)
+ sim.Serial = saved.Serial
+ } else {
+ sim.Serial = matterServer.SerialNumber()
+ }
+ sim.MatterServer = matterServer
+ sim.MatterType = matterType
+ sim.Device = matterServer
+ if sim.Automation.Scenario == "" {
+ sim.Automation.Scenario = "idle"
+ }
default:
return nil, fmt.Errorf("unsupported protocol: %s", protocol)
}
@@ -1056,6 +1118,10 @@ func startSimulatorServer(sim *Simulator) error {
if sim.OCPPServer != nil {
sim.OCPPServer.SetPort(sim.Port)
}
+ case "matter":
+ if sim.MatterServer != nil {
+ sim.MatterServer.SetPort(sim.Port)
+ }
}
var err error
@@ -1086,6 +1152,13 @@ func startSimulatorServer(sim *Simulator) error {
return fmt.Errorf("ocpp start error: %v", err)
}
log.Printf("[%s] Started OCPP server on port %d", sim.Serial, sim.Port)
+
+ case "matter":
+ if err = sim.MatterServer.Start(); err != nil {
+ portAllocator.Release(sim.Port)
+ return fmt.Errorf("matter start error: %v", err)
+ }
+ log.Printf("[%s] Started Matter device on port %d (pairing %s)", sim.Serial, sim.Port, sim.MatterServer.PairingCode())
}
sim.Running = true
@@ -1125,6 +1198,13 @@ func stopSimulatorServer(sim *Simulator) {
case "ocpp":
sim.OCPPServer.Stop()
log.Printf("[%s] Stopped OCPP server", sim.Serial)
+ case "matter":
+ if sim.MatterServer != nil {
+ if err := sim.MatterServer.Stop(); err != nil {
+ log.Printf("[%s] Matter stop error: %v", sim.Serial, err)
+ }
+ }
+ log.Printf("[%s] Stopped Matter device", sim.Serial)
}
sim.Running = false
@@ -1174,6 +1254,7 @@ func buildHTTPMux() *http.ServeMux {
// Update endpoints
mux.HandleFunc("/api/version", handleVersion)
+ mux.HandleFunc("/api/matter/status", handleMatterStatus)
mux.HandleFunc("/api/update/check", handleUpdateCheck)
mux.HandleFunc("/api/update/apply", handleUpdateApply)
mux.HandleFunc("/api/restart", handleRestart)
@@ -1262,6 +1343,18 @@ func handleVersion(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(GetVersionInfo())
}
+// handleMatterStatus reports whether Matter device emulation is available
+// (Node.js is required on the host) so the UI can enable/disable it.
+func handleMatterStatus(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ available, reason := matter.Shared().Available()
+ json.NewEncoder(w).Encode(map[string]interface{}{
+ "available": available,
+ "reason": reason,
+ "node_version": matter.Shared().NodeVersion(),
+ })
+}
+
func handleSettings(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
@@ -1469,13 +1562,13 @@ func handleSimulatorsCreate(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
- "id": sim.ID,
- "serial": sim.Serial,
- "protocol": sim.Protocol,
- "category": sim.Category,
- "port": sim.Port,
- "running": sim.Running,
- "mdns_hostname": mdnsAdvertiser.ActiveHostname(sim.ID),
+ "id": sim.ID,
+ "serial": sim.Serial,
+ "protocol": sim.Protocol,
+ "category": sim.Category,
+ "port": sim.Port,
+ "running": sim.Running,
+ "mdns_hostname": mdnsAdvertiser.ActiveHostname(sim.ID),
})
}
@@ -1497,17 +1590,17 @@ func handleSimulatorsListGet(w http.ResponseWriter, r *http.Request) {
sim.mu.RLock()
entry := map[string]interface{}{
- "id": sim.ID,
- "serial": sim.Serial,
- "protocol": sim.Protocol,
- "category": sim.Category,
- "port": sim.Port,
- "running": sim.Running,
- "automation": sim.Automation.Enabled,
- "profile": sim.ProfilesManager.GetActiveName(),
- "site_id": sim.SiteID,
- "device_on": sim.DeviceOn,
- "mdns_hostname": mdnsAdvertiser.ActiveHostname(sim.ID),
+ "id": sim.ID,
+ "serial": sim.Serial,
+ "protocol": sim.Protocol,
+ "category": sim.Category,
+ "port": sim.Port,
+ "running": sim.Running,
+ "automation": sim.Automation.Enabled,
+ "profile": sim.ProfilesManager.GetActiveName(),
+ "site_id": sim.SiteID,
+ "device_on": sim.DeviceOn,
+ "mdns_hostname": mdnsAdvertiser.ActiveHostname(sim.ID),
}
// Add protocol-specific fields
@@ -1541,6 +1634,17 @@ func handleSimulatorsListGet(w http.ResponseWriter, r *http.Request) {
entry["total_power"] = state["totalPower"]
entry["has_active_transaction"] = state["hasActiveTransaction"]
entry["max_power_kw"] = state["maxPowerKW"]
+ case "matter":
+ if sim.MatterServer != nil {
+ state := sim.MatterServer.GetState()
+ entry["device_type"] = state["device_type"]
+ entry["label"] = state["label"]
+ entry["pairing_code"] = state["pairing_code"]
+ entry["qr"] = state["qr"]
+ entry["commissioned"] = state["commissioned"]
+ entry["fabric_count"] = state["fabric_count"]
+ entry["bridge_up"] = state["bridge_up"]
+ }
}
sim.mu.RUnlock()
@@ -1687,6 +1791,11 @@ func handleSimulatorStatus(w http.ResponseWriter, r *http.Request, sim *Simulato
state := sim.OCPPServer.GetState()
status["state"] = state
status["ocpp_url"] = sim.OCPPServer.GetOCPPURL()
+ case "matter":
+ if sim.MatterServer != nil {
+ status["state"] = sim.MatterServer.GetState()
+ status["matter_type"] = sim.MatterType
+ }
}
sim.mu.RUnlock()
@@ -2190,6 +2299,12 @@ func handleState(w http.ResponseWriter, r *http.Request, sim *Simulator) {
err = sim.MQTTServer.SetValue(req.Key, req.Value)
case "ocpp":
err = sim.OCPPServer.SetValue(req.Key, req.Value)
+ case "matter":
+ if sim.MatterServer == nil {
+ http.Error(w, "matter device not initialized", http.StatusBadRequest)
+ return
+ }
+ err = sim.MatterServer.SetValue(req.Key, req.Value)
default:
http.Error(w, "State changes not supported for this protocol", http.StatusBadRequest)
return
@@ -3451,6 +3566,11 @@ func generateValues(sim *Simulator, simTime time.Time) {
sim.OCPPServer.GenerateValues(simTime)
}
return
+ case "matter":
+ if sim.MatterServer != nil {
+ sim.MatterServer.GenerateValues(simTime)
+ }
+ return
}
// Energy meters don't generate their own values - they are controlled by site aggregation
@@ -3720,6 +3840,7 @@ func buildStateSnapshot() *state.AppState {
DeviceOn: sim.DeviceOn,
BatteryCapacityKWh: sim.BatteryCapacityKWh,
Profile: sim.ProfilesManager.GetActiveName(),
+ MatterType: sim.MatterType,
Automation: state.AutomationState{
Enabled: sim.Automation.Enabled,
TimeMultiplier: sim.Automation.TimeMultiplier,
@@ -4293,6 +4414,11 @@ func generateValuesForSite(sim *Simulator, simTime time.Time, scenario string) {
sim.OCPPServer.GenerateValues(simTime)
}
return
+ case "matter":
+ if sim.MatterServer != nil {
+ sim.MatterServer.GenerateValues(simTime)
+ }
+ return
}
// Energy meters don't generate their own values - they are controlled by site aggregation
@@ -4486,8 +4612,8 @@ func calculateAndUpdateMeter(site *Site, simDelta time.Duration) {
// If meter is an inverter, read its PV/Battery values and include them in the calculation
// Distribute PV/battery contributions using phase factors
if meterCategory == "inverter" {
- pvPower := getSemanticValue(meterSim, "pv_power") // PV power
- batteryPower := getBatterySignedPower(meterSim) // Signed battery power
+ pvPower := getSemanticValue(meterSim, "pv_power") // PV power
+ batteryPower := getBatterySignedPower(meterSim) // Signed battery power
// Distribute PV generation (reduces load) and battery across phases
pvBatteryNet := -pvPower + batteryPower
diff --git a/cmd/simulator/static/css/base.css b/cmd/simulator/static/css/base.css
index 72fd2a5..8b6724a 100644
--- a/cmd/simulator/static/css/base.css
+++ b/cmd/simulator/static/css/base.css
@@ -154,6 +154,7 @@ body {
.protocol-badge.modbus { background: var(--color-modbus); color: #fff; }
.protocol-badge.mqtt { background: var(--color-mqtt); color: #fff; }
.protocol-badge.ocpp { background: var(--color-ocpp); color: #000; }
+.protocol-badge.matter { background: #6c5ce7; color: #fff; }
.protocol-badge.load { background: var(--color-warning); color: #000; }
/* Status Indicators */
diff --git a/cmd/simulator/static/js/stores.js b/cmd/simulator/static/js/stores.js
index dcca676..d5234dc 100644
--- a/cmd/simulator/static/js/stores.js
+++ b/cmd/simulator/static/js/stores.js
@@ -858,9 +858,23 @@ const categories = {
inverter: { name: 'Inverters', icon: 'β‘', collapsed: false },
energy_meter: { name: 'Energy Meters', icon: 'π', collapsed: false },
v2x_charger: { name: 'V2X Chargers', icon: 'π', collapsed: false },
- ocpp_charger: { name: 'OCPP Chargers', icon: 'π', collapsed: false }
+ ocpp_charger: { name: 'OCPP Chargers', icon: 'π', collapsed: false },
+ matter: { name: 'Matter Devices', icon: 'π ', collapsed: false }
};
+// Matter device types offered in the create dialog. Each is a commissionable
+// matter.js device (see /api/matter/status for availability).
+const matterDeviceTypes = [
+ { type: 'matter_tempsensor', name: 'Temperature Sensor', icon: 'π‘οΈ', desc: 'Reports temperature (diurnal curve)' },
+ { type: 'matter_lightsensor', name: 'Light Sensor', icon: 'π‘', desc: 'Reports illuminance (daylight curve)' },
+ { type: 'matter_smartplug', name: 'Smart Plug', icon: 'π', desc: 'On/off plug with power & energy metering' },
+ { type: 'matter_evse', name: 'EVSE Charger', icon: 'π', desc: 'EV charger with session energy' },
+ { type: 'matter_thermostat', name: 'Thermostat', icon: 'ποΈ', desc: 'Heating/cooling with setpoints' },
+ { type: 'matter_heatpump', name: 'Heat Pump', icon: 'β¨οΈ', desc: 'Thermostat with electrical power & energy' },
+ { type: 'matter_dishwasher', name: 'Dishwasher', icon: 'π½οΈ', desc: 'Timed wash cycle with operational state' },
+ { type: 'matter_laundrywasher', name: 'Washing Machine', icon: 'π§Ί', desc: 'Timed wash cycle with operational state' }
+];
+
// Register Alpine stores on alpine:init
document.addEventListener('alpine:init', () => {
// Confirmation dialog store
@@ -940,6 +954,8 @@ document.addEventListener('alpine:init', () => {
selectedView: null, // 'site_load' or null (for normal view)
localIP: '--',
categories: categories,
+ matterDeviceTypes: matterDeviceTypes,
+ matterStatus: { available: false, reason: 'Checkingβ¦', node_version: '' },
addMenuOpen: false,
refreshInterval: null,
globalRefreshInterval: null,
@@ -959,6 +975,22 @@ document.addEventListener('alpine:init', () => {
ocppDisplayOffset: 0,
warnings: [],
+ // Fetch Matter availability (Node.js must be installed on the host).
+ async fetchMatterStatus() {
+ try {
+ let status;
+ if (isWails) {
+ status = await window.go.main.App.MatterStatus();
+ } else {
+ const resp = await fetch('/api/matter/status');
+ status = await resp.json();
+ }
+ if (status) this.matterStatus = status;
+ } catch (e) {
+ this.matterStatus = { available: false, reason: 'Could not determine Matter status', node_version: '' };
+ }
+ },
+
async init() {
// Fetch version, local IP, and settings from backend
try {
@@ -992,6 +1024,7 @@ document.addEventListener('alpine:init', () => {
// Check for updates (non-blocking)
this.checkForUpdate();
this.loadWarnings();
+ this.fetchMatterStatus();
try {
const serverSims = await api.getSimulators();
diff --git a/cmd/simulator/static/openapi.json b/cmd/simulator/static/openapi.json
index dec0416..772a7ce 100644
--- a/cmd/simulator/static/openapi.json
+++ b/cmd/simulator/static/openapi.json
@@ -20,10 +20,25 @@
"summary": "Create a simulator",
"requestBody": {
"required": true,
- "content": { "application/json": { "schema": { "type": "object" } } }
+ "content": { "application/json": { "schema": {
+ "type": "object",
+ "required": ["type"],
+ "properties": {
+ "type": {
+ "type": "string",
+ "description": "Simulator type. Matter types require Node.js on the host (see GET /api/matter/status).",
+ "enum": [
+ "inverter", "energy_meter", "v2x_charger", "ocpp_charger",
+ "matter_tempsensor", "matter_lightsensor", "matter_smartplug", "matter_evse",
+ "matter_thermostat", "matter_heatpump", "matter_dishwasher", "matter_laundrywasher"
+ ]
+ }
+ }
+ } } }
},
"responses": {
- "200": { "description": "Created", "content": { "application/json": { "schema": { "type": "object" } } } }
+ "200": { "description": "Created", "content": { "application/json": { "schema": { "type": "object" } } } },
+ "400": { "description": "Bad request (e.g. Matter requested but Node.js unavailable)" }
}
}
},
@@ -433,6 +448,22 @@
"responses": { "200": { "description": "OK" } }
}
},
+ "/api/matter/status": {
+ "get": {
+ "summary": "Get Matter availability",
+ "description": "Reports whether Matter device emulation is available. Matter requires Node.js (v18+, v20+ recommended) on the host; when unavailable the reason explains why.",
+ "responses": {
+ "200": { "description": "OK", "content": { "application/json": { "schema": {
+ "type": "object",
+ "properties": {
+ "available": { "type": "boolean" },
+ "reason": { "type": "string" },
+ "node_version": { "type": "string" }
+ }
+ } } } }
+ }
+ }
+ },
"/api/settings": {
"get": {
"summary": "Get runtime settings",
diff --git a/cmd/simulator/templates/docs.html b/cmd/simulator/templates/docs.html
index aacb961..68aee80 100644
--- a/cmd/simulator/templates/docs.html
+++ b/cmd/simulator/templates/docs.html
@@ -43,6 +43,41 @@
API Reference (OpenAPI)
Use this as the root for API requests (e.g. /api/simulators).
+
+
+
+ The simulator can emulate commissionable Matter devices via
+ matter.js:
+ temperature & light sensors, smart plug (with energy), EVSE charger, thermostat, heat pump,
+ dishwasher and washing machine. Create one with
+ POST /api/simulators using a matter_* type.
+
+
+
Requires Node.js on the host (v18+, v20+ recommended). The app spawns a
+ managed Node bridge; if Node is missing, Matter is disabled β check
+ GET /api/matter/status.
+
Each device exposes a manual pairing code and a MT: QR payload
+ for commissioning into Home Assistant / Apple Home / Google Home.
+
+
+
Test Vendor ID / DCL test-net: simulated devices use the CSA test
+ Vendor ID 0xFFF1 and a test Product ID. These are not registered in the
+ production Distributed Compliance Ledger (DCL).
+
+ To pair into Home Assistant, enable the Matter Server's
+ test / Test-Net DCL option (allow non-certified / test devices). Apple Home and
+ Google Home will warn about an "uncertified accessory"; use a developer/test controller for
+ reliable testing.
+
+
+
+ Matter commissioning needs LAN multicast (mDNS). It works in desktop mode and Docker
+ host networking; under Docker bridge networking devices won't be discoverable.
+
+
+
diff --git a/cmd/simulator/templates/index.html b/cmd/simulator/templates/index.html
index d2571e1..b2bd086 100644
--- a/cmd/simulator/templates/index.html
+++ b/cmd/simulator/templates/index.html
@@ -2296,6 +2296,31 @@ Add Device
OCPP 1.6J charge point with remote start/stop
+
+
+
+
diff --git a/internal/device/device.go b/internal/device/device.go
index 586fb6b..bafc6c8 100644
--- a/internal/device/device.go
+++ b/internal/device/device.go
@@ -9,6 +9,7 @@ const (
ProtocolModbus Protocol = "modbus"
ProtocolMQTT Protocol = "mqtt"
ProtocolOCPP Protocol = "ocpp"
+ ProtocolMatter Protocol = "matter"
)
// Category represents the high-level device category
@@ -18,6 +19,9 @@ const (
CategoryInverter Category = "inverter"
CategoryV2XCharger Category = "v2x_charger"
CategoryOCPPCharger Category = "ocpp_charger"
+ // CategoryMatter groups all Matter devices under a single UI category; the
+ // specific Matter device type (thermostat, evse, ...) is carried separately.
+ CategoryMatter Category = "matter"
)
// LogEntry represents a protocol operation log entry
diff --git a/internal/matter/bridge/.gitignore b/internal/matter/bridge/.gitignore
new file mode 100644
index 0000000..2722919
--- /dev/null
+++ b/internal/matter/bridge/.gitignore
@@ -0,0 +1,5 @@
+# The raw dependency tree is large (~114MB, 23k files). We commit the
+# compressed archive (node_modules.tgz) instead, which is what gets embedded
+# into the Go binary and extracted at runtime. Regenerate both with:
+# make vendor-matter
+node_modules/
diff --git a/internal/matter/bridge/API_REFERENCE.md b/internal/matter/bridge/API_REFERENCE.md
new file mode 100644
index 0000000..4e2102f
--- /dev/null
+++ b/internal/matter/bridge/API_REFERENCE.md
@@ -0,0 +1,101 @@
+# matter.js v0.16.11 Bridge API Reference
+
+> Live-verified against `@matter/main@0.16.11` (Node v22). Every snippet below
+> was confirmed by booting a real `ServerNode`. Keep this in sync when bumping
+> matter.js.
+
+## Imports
+All re-exported from `@matter/main`: `ServerNode`, `Endpoint`, `Environment`, `StorageService`, `VendorId`.
+Devices: `@matter/main/devices/`. Behaviors: `@matter/main/behaviors/`. Clusters: `@matter/main/clusters/`. PascalCase subpaths do NOT resolve.
+
+## ServerNode lifecycle
+```js
+const node = await ServerNode.create(ServerNode.RootEndpoint, {
+ environment, // Environment.default
+ id: "", // becomes storage sub-folder
+ network: { port },
+ commissioning: { passcode, discriminator },
+ productDescription: { name, deviceType: SomeDevice.deviceType },
+ basicInformation: { vendorId: VendorId(0xfff1), vendorName, productId, productName, nodeLabel, serialNumber },
+});
+await node.add(endpoint);
+await node.start(); // resolves once online (non-blocking)
+const { manualPairingCode, qrPairingCode } = node.state.commissioning.pairingCodes;
+node.lifecycle.isCommissioned; // boolean
+Object.keys(node.state.commissioning.fabrics).length; // fabric count
+await node.close();
+```
+Storage dir: set env `MATTER_STORAGE_PATH` (== var `storage.path`) before create; node `id` namespaces each device under it, so fabrics persist across restarts.
+
+### Events
+```js
+node.events.commissioning.commissioned.on(() => {});
+node.events.commissioning.decommissioned.on(() => {});
+node.events.commissioning.fabricsChanged.on((fabricIndex, action /* added|deleted|updated */) => {});
+```
+
+## Device types (verified imports)
+| key | export | path | deviceType id |
+|---|---|---|---|
+| tempsensor | `TemperatureSensorDevice` | devices/temperature-sensor | 770 |
+| lightsensor | `LightSensorDevice` | devices/light-sensor | 262 |
+| smartplug | `OnOffPlugInUnitDevice` | devices/on-off-plug-in-unit | 266 |
+| evse | `EnergyEvseDevice` | devices/energy-evse | 1292 |
+| thermostat | `ThermostatDevice` | devices/thermostat | 769 |
+| heatpump | `HeatPumpDevice` | devices/heat-pump | 777 |
+| dishwasher | `DishwasherDevice` | devices/dishwasher | 117 |
+| laundrywasher | `LaundryWasherDevice` | devices/laundry-washer | 115 |
+
+## Composition / mandatory initial state
+- Thermostat cluster is NOT auto-configured: `ThermostatDevice.with(ThermostatRequirements.ThermostatServer.with(Thermostat.Feature.Heating, .Cooling, .AutoMode))`.
+- Electrical metering (plug/evse/heatpump): `.with(ElectricalPowerMeasurementServer.with(ElectricalPowerMeasurement.Feature.AlternatingCurrent), ElectricalEnergyMeasurementServer, PowerTopologyServer.with(PowerTopology.Feature.NodeTopology))`.
+ - EPM initial state (mandatory): `{ powerMode: PowerMode.Ac, numberOfMeasurementTypes: 1, accuracy: [accuracyStruct(ActivePower)], activePower: 0 }`. `accuracy` is an ARRAY (len == numberOfMeasurementTypes).
+ - EEM initial state: `{ accuracy: accuracyStruct(ElectricalEnergy) }`. `accuracy` is a SINGLE struct (not array). Cumulative energy is set as `{ energy: }`.
+ - accuracyStruct = `{ measurementType, measured: true, minMeasuredValue: 0, maxMeasuredValue: , accuracyRanges: [{ rangeMin: 0, rangeMax: , fixedMax: 1 }] }`.
+- Mode clusters (`EnergyEvseMode`/`DishwasherMode`/`LaundryWasherMode`) need a `supportedModes` list (β₯2) + `currentMode`. ModeOptionStruct = `{ label, mode, modeTags: [{ value: }] }`.
+- `OperationalState` needs `operationalStateList` (+ `operationalState`); `phaseList`/`currentPhase`/`countdownTime` may be null.
+
+## Runtime attribute R/W + subscription
+```js
+await endpoint.set({ onOff: { onOff: true } }); // push simulated value
+const v = endpoint.state.temperatureMeasurement.measuredValue; // read
+endpoint.events.onOff.onOff$Changed.on((value, oldValue, context) => {
+ const external = context?.fabric !== undefined; // true => controller-driven
+});
+```
+
+## Encodings (matter.js does NOT auto-scale β write the scaled integer)
+- Temperature / thermostat setpoints: Β°C Γ 100 (Int16).
+- Humidity: %RH Γ 100.
+- Illuminance: `10000Β·log10(lux) + 1` (logarithmic), 0 = too low.
+- Power cluster: mV / mA / mW / mHz (SI Γ 1000).
+- Energy: mWh (Wh Γ 1000) inside `EnergyMeasurementStruct.energy`.
+- EVSE currents: mA; sessionEnergyCharged: mWh.
+
+## Test vendor ID / DCL test-net (commissioning into real ecosystems)
+
+Simulated devices use the **CSA test Vendor ID `0xFFF1` (65521)** and a test
+Product ID (`0x8000`). These are the standard *test* identifiers β they are **not
+registered in the production Distributed Compliance Ledger (DCL)**, so a
+controller that only trusts certified/production devices will reject them.
+
+To commission a simulated device into a real ecosystem you must allow
+uncertified / test devices:
+
+- **Home Assistant** (Matter Server add-on): enable the **test/Test-Net DCL**
+ option (a.k.a. "Allow non-certified / test devices"). Without it, pairing a
+ `0xFFF1` device fails at attestation.
+- **Apple Home / Google Home**: pairing an uncertified test device shows an
+ "uncertified accessory" warning that must be accepted; some production builds
+ refuse test VIDs entirely β use a developer/test controller (e.g. chip-tool,
+ Home Assistant) for reliable testing.
+
+The manual pairing code and `MT:` QR payload are surfaced per device by the
+simulator (and via `GET /api/matter/status` for availability).
+
+## Key enums
+- `Thermostat.SystemMode`: Off=0, Auto=1, Cool=3, Heat=4, EmergencyHeat=5, Precooling=6, FanOnly=7, Dry=8, Sleep=9.
+- `Thermostat.ControlSequenceOfOperation`: CoolingOnly=0, HeatingOnly=2, CoolingAndHeating=4.
+- `EnergyEvse.State`: NotPluggedIn=0, PluggedInNoDemand=1, PluggedInDemand=2, PluggedInCharging=3, PluggedInDischarging=4, SessionEnding=5, Fault=6.
+- `EnergyEvse.SupplyState`: Disabled=0, ChargingEnabled=1, DischargingEnabled=2, DisabledError=3, DisabledDiagnostics=4, Enabled=5.
+- `OperationalState.OperationalStateEnum`: Stopped=0, Running=1, Paused=2, Error=3.
diff --git a/internal/matter/bridge/_selftest.mjs b/internal/matter/bridge/_selftest.mjs
new file mode 100644
index 0000000..e92dd36
--- /dev/null
+++ b/internal/matter/bridge/_selftest.mjs
@@ -0,0 +1,54 @@
+// Boots every device type once to verify the factories produce a clean,
+// commissionable ServerNode. Not embedded (underscore prefix). Run with:
+// node _selftest.mjs
+process.env.MATTER_LOG_LEVEL = 'fatal';
+process.env.MATTER_STORAGE_PATH = './.selftest-storage';
+
+const { buildDevice } = await import('./lib/devices/index.mjs');
+const { rmSync } = await import('node:fs');
+
+const types = [
+ 'tempsensor', 'lightsensor', 'smartplug', 'evse',
+ 'thermostat', 'heatpump', 'dishwasher', 'laundrywasher',
+];
+
+let port = 5700;
+let failures = 0;
+
+for (const type of types) {
+ const params = {
+ nodeId: `selftest-${type}`,
+ type,
+ serial: `SELFTEST-${type}`,
+ port: port++,
+ passcode: 20202021,
+ discriminator: 3840,
+ deviceName: type,
+ defaults: {},
+ };
+ try {
+ const dev = await buildDevice(params, () => {});
+ const ok = dev.pairingCode && dev.qrCode;
+ console.log(`${ok ? 'OK ' : 'NO-CODE'} ${type.padEnd(14)} pairing=${dev.pairingCode} qr=${dev.qrCode.slice(0, 16)}...`);
+ if (!ok) failures++;
+ await dev.node.close();
+ } catch (e) {
+ failures++;
+ console.log(`FAIL ${type.padEnd(14)} ${String(e && e.message || e)}`);
+ const causes = collectCauses(e);
+ for (const c of causes) console.log(` cause: ${c}`);
+ }
+}
+
+function collectCauses(e, depth = 0, out = []) {
+ if (!e || depth > 6) return out;
+ if (e.message) out.push(e.message.split('\n')[0]);
+ if (Array.isArray(e.errors)) for (const sub of e.errors) collectCauses(sub, depth + 1, out);
+ if (e.cause) collectCauses(e.cause, depth + 1, out);
+ return out;
+}
+
+try { rmSync('./.selftest-storage', { recursive: true, force: true }); } catch {}
+
+console.log(`\n${types.length - failures}/${types.length} device types booted cleanly`);
+process.exit(failures > 0 ? 1 : 0);
diff --git a/internal/matter/bridge/index.mjs b/internal/matter/bridge/index.mjs
new file mode 100644
index 0000000..69682bc
--- /dev/null
+++ b/internal/matter/bridge/index.mjs
@@ -0,0 +1,98 @@
+// Device Simulator β matter.js bridge.
+//
+// This Node program is spawned and supervised by the Go side. It speaks
+// newline-delimited JSON-RPC over stdio:
+// * stdin : requests { id, method, params }
+// * stdout : responses { id, result } | { id, error } AND events { event, ... }
+// * stderr : human-readable diagnostics ONLY (never control frames)
+//
+// One process hosts many matter.js ServerNodes, keyed by nodeId. Device-type
+// specifics live in ./lib/devices/*.mjs; this file is just the transport +
+// dispatch and is intentionally free of matter.js cluster knowledge.
+
+import './lib/log.mjs'; // must be first: pins matter.js logging to stderr
+import readline from 'node:readline';
+import { createDevice, removeDevice, setAttributes, getState, shutdownAll } from './lib/registry.mjs';
+
+function send(obj) {
+ process.stdout.write(JSON.stringify(obj) + '\n');
+}
+
+// emit is passed down to device modules so they can push events (commissioning
+// changes, external attribute writes, logs) back to Go.
+export function emit(eventObj) {
+ send(eventObj);
+}
+
+function logErr(message) {
+ process.stderr.write(`[matter-bridge] ${message}\n`);
+}
+
+const handlers = {
+ async createDevice(params) {
+ return await createDevice(params, emit);
+ },
+ async setAttributes(params) {
+ return await setAttributes(params);
+ },
+ async getState(params) {
+ return await getState(params);
+ },
+ async removeDevice(params) {
+ return await removeDevice(params);
+ },
+ async shutdown() {
+ await shutdownAll();
+ // Allow the response to flush before exiting.
+ setImmediate(() => process.exit(0));
+ return { ok: true };
+ },
+};
+
+async function dispatch(msg) {
+ const handler = handlers[msg.method];
+ if (!handler) {
+ send({ id: msg.id, error: `unknown method: ${msg.method}` });
+ return;
+ }
+ try {
+ const result = await handler(msg.params || {});
+ send({ id: msg.id, result });
+ } catch (e) {
+ send({ id: msg.id, error: String((e && e.message) || e) });
+ }
+}
+
+const rl = readline.createInterface({ input: process.stdin });
+rl.on('line', (line) => {
+ const text = line.trim();
+ if (!text) return;
+ let msg;
+ try {
+ msg = JSON.parse(text);
+ } catch {
+ logErr(`ignoring non-JSON line: ${text.slice(0, 120)}`);
+ return;
+ }
+ dispatch(msg);
+});
+
+rl.on('close', async () => {
+ // stdin closed (Go side went away) β shut down cleanly.
+ try {
+ await shutdownAll();
+ } catch (e) {
+ logErr(`shutdown on stdin close failed: ${e}`);
+ }
+ process.exit(0);
+});
+
+process.on('uncaughtException', (err) => {
+ logErr(`uncaughtException: ${err && err.stack ? err.stack : err}`);
+});
+process.on('unhandledRejection', (err) => {
+ logErr(`unhandledRejection: ${err && err.stack ? err.stack : err}`);
+});
+
+// Announce readiness once the runtime is up.
+send({ event: 'ready', bridge: 'device-simulator', node: process.version });
diff --git a/internal/matter/bridge/node_modules.tgz b/internal/matter/bridge/node_modules.tgz
new file mode 100644
index 0000000..c3801eb
Binary files /dev/null and b/internal/matter/bridge/node_modules.tgz differ
diff --git a/internal/matter/bridge/package-lock.json b/internal/matter/bridge/package-lock.json
new file mode 100644
index 0000000..1ad6c1e
--- /dev/null
+++ b/internal/matter/bridge/package-lock.json
@@ -0,0 +1,128 @@
+{
+ "name": "device-simulator-matter-bridge",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "device-simulator-matter-bridge",
+ "version": "0.1.0",
+ "dependencies": {
+ "@matter/main": "^0.16.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@matter/general": {
+ "version": "0.16.11",
+ "resolved": "https://registry.npmjs.org/@matter/general/-/general-0.16.11.tgz",
+ "integrity": "sha512-iOnCR7azYgRzr4p/WQRRmbediZFYsqfaqSIp7yyGhnfn2gx386F/Wh96HGXYWdprTWet/7IsFV6uiA9jcDNHvA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@noble/curves": "^2.0.1"
+ }
+ },
+ "node_modules/@matter/main": {
+ "version": "0.16.11",
+ "resolved": "https://registry.npmjs.org/@matter/main/-/main-0.16.11.tgz",
+ "integrity": "sha512-FDFhTix4AcH7t7TP7PnU2smFayza2RrI3uppSRzEmJ+Vxy8btelJMpDjBcxBbNb2Iao7jX7W8DLPMMuH6AzMQg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@matter/general": "0.16.11",
+ "@matter/model": "0.16.11",
+ "@matter/node": "0.16.11",
+ "@matter/protocol": "0.16.11",
+ "@matter/types": "0.16.11"
+ },
+ "optionalDependencies": {
+ "@matter/nodejs": "0.16.11"
+ }
+ },
+ "node_modules/@matter/model": {
+ "version": "0.16.11",
+ "resolved": "https://registry.npmjs.org/@matter/model/-/model-0.16.11.tgz",
+ "integrity": "sha512-7a64fUnf3EFwfqt5C/p4aR9RrQBWgNYnP/FKPAI4wC6cp3OyOm9Yt7fqE//Q6ikPpU867kMLxhMHwmgvntMgtA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@matter/general": "0.16.11"
+ }
+ },
+ "node_modules/@matter/node": {
+ "version": "0.16.11",
+ "resolved": "https://registry.npmjs.org/@matter/node/-/node-0.16.11.tgz",
+ "integrity": "sha512-Y+ji+A8iWRCaG2HI7yXlvgOizwePIt3lSZV+wVo+Pyf86vwtDLxxY225nfRcV/Iwawmm/l2WM8aoPaNh37C0iw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@matter/general": "0.16.11",
+ "@matter/model": "0.16.11",
+ "@matter/protocol": "0.16.11",
+ "@matter/types": "0.16.11"
+ }
+ },
+ "node_modules/@matter/nodejs": {
+ "version": "0.16.11",
+ "resolved": "https://registry.npmjs.org/@matter/nodejs/-/nodejs-0.16.11.tgz",
+ "integrity": "sha512-Q/kOWerSnHHUI5A/FnD6Tz61asO0C4Rz/hemF3L7DAWu4nEEq6O2msH+RTaj3NrHK9ef28PGpTMS35PbMpFOyg==",
+ "license": "Apache-2.0",
+ "optional": true,
+ "dependencies": {
+ "@matter/general": "0.16.11",
+ "@matter/node": "0.16.11",
+ "@matter/protocol": "0.16.11",
+ "@matter/types": "0.16.11"
+ },
+ "engines": {
+ "node": ">=20.19.0 <22.0.0 || >=22.13.0"
+ }
+ },
+ "node_modules/@matter/protocol": {
+ "version": "0.16.11",
+ "resolved": "https://registry.npmjs.org/@matter/protocol/-/protocol-0.16.11.tgz",
+ "integrity": "sha512-+Q6s+Cmvcm5BJEFBtS6m0vUVrHdxTBteDcJ+VfpNna1ST910371lVqtDwYFclqECQMlDYxoNjkfcYNv4pZfNPg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@matter/general": "0.16.11",
+ "@matter/model": "0.16.11",
+ "@matter/types": "0.16.11"
+ }
+ },
+ "node_modules/@matter/types": {
+ "version": "0.16.11",
+ "resolved": "https://registry.npmjs.org/@matter/types/-/types-0.16.11.tgz",
+ "integrity": "sha512-RcZk5N4AeoqKaLzlC6M6+J8YGJgxnLYpF/3WL7CeIM1DpA9ebkPQB9B8of+RJnqy8kgr6eqL/3ATmPGDA46fwg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@matter/general": "0.16.11",
+ "@matter/model": "0.16.11"
+ }
+ },
+ "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/"
+ }
+ }
+ }
+}
diff --git a/internal/matter/bridge/package.json b/internal/matter/bridge/package.json
new file mode 100644
index 0000000..20ce00a
--- /dev/null
+++ b/internal/matter/bridge/package.json
@@ -0,0 +1,17 @@
+{
+ "name": "device-simulator-matter-bridge",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "description": "Node.js bridge that drives matter.js ServerNodes on behalf of the Go device-simulator",
+ "main": "index.mjs",
+ "scripts": {
+ "start": "node index.mjs"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "dependencies": {
+ "@matter/main": "^0.16.0"
+ }
+}
diff --git a/internal/matter/bridge_assets.go b/internal/matter/bridge_assets.go
new file mode 100644
index 0000000..4a83deb
--- /dev/null
+++ b/internal/matter/bridge_assets.go
@@ -0,0 +1,186 @@
+package matter
+
+import (
+ "archive/tar"
+ "compress/gzip"
+ "crypto/sha256"
+ "embed"
+ "encoding/hex"
+ "fmt"
+ "io"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "sync"
+)
+
+// The Node bridge program (source) and its dependencies are embedded into the
+// Go binary. Dependencies are shipped as a single compressed archive
+// (node_modules.tgz, ~12MB) rather than ~23k loose files, which keeps build
+// times and the binary reasonable. Regenerate the archive with `make
+// vendor-matter`.
+//
+//go:embed bridge/index.mjs bridge/package.json bridge/lib bridge/node_modules.tgz
+var bridgeFS embed.FS
+
+var (
+ bridgeOnce sync.Once
+ bridgeDir string
+ bridgeErr error
+)
+
+// ensureBridgeExtracted extracts the embedded bridge (source + node_modules) to
+// a per-user cache directory exactly once, keyed by a content hash so a new
+// build re-extracts but repeated launches do not. Returns the directory that
+// contains index.mjs and node_modules.
+func ensureBridgeExtracted() (string, error) {
+ bridgeOnce.Do(func() {
+ bridgeDir, bridgeErr = extractBridge()
+ })
+ return bridgeDir, bridgeErr
+}
+
+func extractBridge() (string, error) {
+ hash, err := bridgeContentHash()
+ if err != nil {
+ return "", err
+ }
+
+ root := bridgeCacheRoot()
+ dir := filepath.Join(root, "bridge-"+hash)
+ marker := filepath.Join(dir, ".extracted")
+
+ if _, err := os.Stat(marker); err == nil {
+ return dir, nil // already extracted for this build
+ }
+
+ // Fresh extraction: remove any partial dir, then write source + deps.
+ _ = os.RemoveAll(dir)
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ return "", err
+ }
+
+ if err := writeEmbeddedSource(dir); err != nil {
+ return "", fmt.Errorf("write bridge source: %w", err)
+ }
+ if err := extractNodeModules(dir); err != nil {
+ return "", fmt.Errorf("extract node_modules: %w", err)
+ }
+
+ if err := os.WriteFile(marker, []byte(hash), 0o644); err != nil {
+ return "", err
+ }
+ return dir, nil
+}
+
+// writeEmbeddedSource copies index.mjs, package.json and lib/** out of the
+// embedded FS, preserving the directory layout (minus the leading "bridge/").
+func writeEmbeddedSource(dir string) error {
+ return fs.WalkDir(bridgeFS, "bridge", func(path string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ if path == "bridge" {
+ return nil
+ }
+ rel, _ := filepath.Rel("bridge", path)
+ // node_modules.tgz is handled separately by extractNodeModules.
+ if rel == "node_modules.tgz" {
+ return nil
+ }
+ target := filepath.Join(dir, rel)
+ if d.IsDir() {
+ return os.MkdirAll(target, 0o755)
+ }
+ data, err := bridgeFS.ReadFile(path)
+ if err != nil {
+ return err
+ }
+ if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
+ return err
+ }
+ return os.WriteFile(target, data, 0o644)
+ })
+}
+
+// extractNodeModules untars the embedded node_modules.tgz into dir.
+func extractNodeModules(dir string) error {
+ f, err := bridgeFS.Open("bridge/node_modules.tgz")
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+
+ gz, err := gzip.NewReader(f)
+ if err != nil {
+ return err
+ }
+ defer gz.Close()
+
+ tr := tar.NewReader(gz)
+ for {
+ hdr, err := tr.Next()
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ return err
+ }
+
+ target := filepath.Join(dir, filepath.Clean(hdr.Name))
+ // Guard against path traversal in archive entries.
+ if rel, err := filepath.Rel(dir, target); err != nil || rel == ".." || hasDotDotPrefix(rel) {
+ return fmt.Errorf("invalid archive path: %s", hdr.Name)
+ }
+
+ switch hdr.Typeflag {
+ case tar.TypeDir:
+ if err := os.MkdirAll(target, 0o755); err != nil {
+ return err
+ }
+ case tar.TypeReg:
+ if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
+ return err
+ }
+ out, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
+ if err != nil {
+ return err
+ }
+ if _, err := io.Copy(out, tr); err != nil {
+ out.Close()
+ return err
+ }
+ out.Close()
+ }
+ }
+ return nil
+}
+
+func hasDotDotPrefix(rel string) bool {
+ return len(rel) >= 3 && rel[0] == '.' && rel[1] == '.' && (rel[2] == filepath.Separator || rel[2] == '/')
+}
+
+// bridgeContentHash hashes index.mjs and the dependency archive so the cache
+// key changes whenever the bridge or its deps change.
+func bridgeContentHash() (string, error) {
+ h := sha256.New()
+ for _, name := range []string{"bridge/index.mjs", "bridge/node_modules.tgz"} {
+ f, err := bridgeFS.Open(name)
+ if err != nil {
+ return "", err
+ }
+ if _, err := io.Copy(h, f); err != nil {
+ f.Close()
+ return "", err
+ }
+ f.Close()
+ }
+ return hex.EncodeToString(h.Sum(nil))[:16], nil
+}
+
+func bridgeCacheRoot() string {
+ if dir, err := os.UserCacheDir(); err == nil {
+ return filepath.Join(dir, "device-simulator", "matter")
+ }
+ return filepath.Join(os.TempDir(), "device-simulator-matter-bridge")
+}
diff --git a/internal/matter/bridge_integration_test.go b/internal/matter/bridge_integration_test.go
new file mode 100644
index 0000000..dcd2888
--- /dev/null
+++ b/internal/matter/bridge_integration_test.go
@@ -0,0 +1,97 @@
+//go:build matter_integration
+
+// Real end-to-end tests that spawn the embedded matter.js bridge with a live
+// Node.js runtime. Excluded from the default build so `go test ./...` stays
+// Node-free on CI. Run with:
+//
+// go test -tags matter_integration ./internal/matter/...
+package matter
+
+import (
+ "os/exec"
+ "testing"
+ "time"
+)
+
+func requireNode(t *testing.T) {
+ t.Helper()
+ if info := DetectNode(); !info.Available {
+ t.Skipf("node not available: %s", info.Reason)
+ }
+ if _, err := exec.LookPath("node"); err != nil {
+ t.Skip("node not on PATH")
+ }
+}
+
+func TestIntegrationCommissionAllTypes(t *testing.T) {
+ requireNode(t)
+ t.Setenv("MATTER_STORAGE_PATH", t.TempDir())
+ t.Setenv("MATTER_LOG_LEVEL", "fatal")
+
+ m := Shared()
+ t.Cleanup(m.Shutdown)
+
+ port := 5740
+ seen := map[string]bool{}
+ for _, dt := range deviceTypes {
+ dt := dt
+ t.Run(dt.Key, func(t *testing.T) {
+ s, err := NewServer(port, dt.Key)
+ if err != nil {
+ t.Fatal(err)
+ }
+ s.SetPort(port)
+ port++
+
+ if err := s.Start(); err != nil {
+ t.Fatalf("Start %s: %v", dt.Key, err)
+ }
+ t.Cleanup(func() { _ = s.Stop() })
+
+ code := s.PairingCode()
+ qr := s.QRCode()
+ if len(code) != 11 {
+ t.Errorf("%s: manual pairing code = %q (want 11 digits)", dt.Key, code)
+ }
+ if len(qr) < 5 || qr[:3] != "MT:" {
+ t.Errorf("%s: qr payload = %q (want MT:...)", dt.Key, qr)
+ }
+ if seen[code] {
+ t.Errorf("%s: duplicate pairing code %q (should be unique per device)", dt.Key, code)
+ }
+ seen[code] = true
+
+ // Drive one simulation tick; must not error.
+ s.GenerateValues(time.Now())
+ })
+ }
+}
+
+func TestIntegrationSmartPlugControl(t *testing.T) {
+ requireNode(t)
+ t.Setenv("MATTER_STORAGE_PATH", t.TempDir())
+ t.Setenv("MATTER_LOG_LEVEL", "fatal")
+
+ m := Shared()
+ t.Cleanup(m.Shutdown)
+
+ s, err := NewServer(5800, "smartplug")
+ if err != nil {
+ t.Fatal(err)
+ }
+ s.SetPort(5800)
+ if err := s.Start(); err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = s.Stop() })
+
+ if err := s.SetValue("on", true); err != nil {
+ t.Fatalf("turn on: %v", err)
+ }
+ for i := 0; i < 3; i++ {
+ s.GenerateValues(time.Now())
+ }
+ if s.getF("energy_wh", 0) <= 0 {
+ t.Error("expected energy to accumulate after turning plug on")
+ }
+}
diff --git a/internal/matter/detect.go b/internal/matter/detect.go
new file mode 100644
index 0000000..54bae7c
--- /dev/null
+++ b/internal/matter/detect.go
@@ -0,0 +1,97 @@
+package matter
+
+import (
+ "fmt"
+ "os"
+ "os/exec"
+ "strconv"
+ "strings"
+ "sync"
+)
+
+// minNodeMajor is the minimum Node.js major version matter.js 0.16 supports.
+// 18 is the floor; 20+ LTS is recommended.
+const minNodeMajor = 18
+
+// NodeInfo describes the detected Node.js runtime.
+type NodeInfo struct {
+ Path string
+ Version string // raw, e.g. "v22.23.1"
+ Major int
+ Available bool
+ Reason string // human-readable explanation when Available is false
+}
+
+// Indirection points for testing without a real Node install.
+var (
+ lookPath = exec.LookPath
+ runVersion = func(path string) (string, error) {
+ out, err := exec.Command(path, "--version").Output()
+ return string(out), err
+ }
+)
+
+var (
+ detectOnce sync.Once
+ detected NodeInfo
+)
+
+// DetectNode resolves and caches the Node.js runtime once per process.
+func DetectNode() NodeInfo {
+ detectOnce.Do(func() { detected = detectNode() })
+ return detected
+}
+
+// detectNode performs the (uncached) detection. Kept separate so tests can
+// exercise it directly via the lookPath/runVersion indirection.
+func detectNode() NodeInfo {
+ path := os.Getenv("MATTER_NODE_PATH")
+ if path == "" {
+ var err error
+ path, err = lookPath("node")
+ if err != nil {
+ return NodeInfo{
+ Available: false,
+ Reason: "Node.js not found on PATH β install Node.js v20+ to use Matter",
+ }
+ }
+ }
+
+ verStr, err := runVersion(path)
+ if err != nil {
+ return NodeInfo{
+ Path: path,
+ Available: false,
+ Reason: "failed to run node --version: " + err.Error(),
+ }
+ }
+
+ ver := strings.TrimSpace(verStr)
+ major := parseNodeMajor(ver)
+ if major < minNodeMajor {
+ return NodeInfo{
+ Path: path,
+ Version: ver,
+ Major: major,
+ Available: false,
+ Reason: fmt.Sprintf("Node.js %s is too old β install v20+ to use Matter", ver),
+ }
+ }
+
+ return NodeInfo{Path: path, Version: ver, Major: major, Available: true}
+}
+
+// parseNodeMajor extracts the major version from strings like "v22.23.1" or
+// "22.23.1". Returns 0 when it cannot be parsed.
+func parseNodeMajor(ver string) int {
+ v := strings.TrimSpace(ver)
+ v = strings.TrimPrefix(v, "v")
+ if i := strings.IndexByte(v, '.'); i >= 0 {
+ v = v[:i]
+ }
+ n, err := strconv.Atoi(strings.TrimSpace(v))
+ if err != nil {
+ return 0
+ }
+ return n
+}
diff --git a/internal/matter/detect_test.go b/internal/matter/detect_test.go
new file mode 100644
index 0000000..d126906
--- /dev/null
+++ b/internal/matter/detect_test.go
@@ -0,0 +1,62 @@
+package matter
+
+import (
+ "errors"
+ "testing"
+)
+
+func TestParseNodeMajor(t *testing.T) {
+ cases := map[string]int{
+ "v22.23.1": 22,
+ "22.23.1": 22,
+ "v18.0.0": 18,
+ " v20.5.0": 20,
+ "garbage": 0,
+ "": 0,
+ }
+ for in, want := range cases {
+ if got := parseNodeMajor(in); got != want {
+ t.Errorf("parseNodeMajor(%q) = %d, want %d", in, got, want)
+ }
+ }
+}
+
+func TestDetectNode(t *testing.T) {
+ origLook, origRun := lookPath, runVersion
+ defer func() { lookPath, runVersion = origLook, origRun }()
+
+ t.Run("not found", func(t *testing.T) {
+ lookPath = func(string) (string, error) { return "", errors.New("not found") }
+ info := detectNode()
+ if info.Available {
+ t.Fatal("expected unavailable when node missing")
+ }
+ if info.Reason == "" {
+ t.Error("expected a reason")
+ }
+ })
+
+ t.Run("too old", func(t *testing.T) {
+ lookPath = func(string) (string, error) { return "/usr/bin/node", nil }
+ runVersion = func(string) (string, error) { return "v16.0.0\n", nil }
+ info := detectNode()
+ if info.Available {
+ t.Fatal("expected unavailable for old node")
+ }
+ if info.Major != 16 {
+ t.Errorf("major = %d, want 16", info.Major)
+ }
+ })
+
+ t.Run("ok", func(t *testing.T) {
+ lookPath = func(string) (string, error) { return "/usr/bin/node", nil }
+ runVersion = func(string) (string, error) { return "v20.11.0\n", nil }
+ info := detectNode()
+ if !info.Available {
+ t.Fatalf("expected available, reason=%q", info.Reason)
+ }
+ if info.Major != 20 {
+ t.Errorf("major = %d, want 20", info.Major)
+ }
+ })
+}
diff --git a/internal/matter/devicetypes.go b/internal/matter/devicetypes.go
new file mode 100644
index 0000000..d85ae91
--- /dev/null
+++ b/internal/matter/devicetypes.go
@@ -0,0 +1,431 @@
+package matter
+
+import (
+ "math"
+ "strings"
+ "time"
+)
+
+// deviceType describes one Matter device type the simulator supports: its
+// bridge key, UI metadata, the Matter attribute defaults sent at creation, the
+// initial physics state, and the functions that drive/control it. Device
+// physics live here in Go; the bridge only mirrors the resulting attributes.
+type deviceType struct {
+ Key string // bridge key, e.g. "thermostat"
+ SimType string // createSimulator type, e.g. "matter_thermostat"
+ Label string // UI label
+ Icon string // UI icon
+ SerialTag string // short serial infix
+ MatterDefaults map[string]float64
+ InitPhys map[string]float64
+ Generate func(s *Server, t time.Time) []AttrChange
+ SetValue func(s *Server, key string, value interface{}) ([]AttrChange, bool)
+ OnExternal func(s *Server, cluster, attribute string, value interface{})
+}
+
+// energyChange wraps a cumulative imported energy value (mWh) for the bridge.
+func energyChange(wh float64) AttrChange {
+ return AttrChange{Cluster: "electricalEnergyMeasurement", Attribute: "cumulativeEnergyImported", Value: int64(wh * 1000)}
+}
+
+func powerChanges(watts, voltage float64) []AttrChange {
+ current := 0.0
+ if voltage > 0 {
+ current = watts / voltage
+ }
+ return []AttrChange{
+ {Cluster: "electricalPowerMeasurement", Attribute: "activePower", Value: int64(watts * 1000)},
+ {Cluster: "electricalPowerMeasurement", Attribute: "rmsVoltage", Value: int64(voltage * 1000)},
+ {Cluster: "electricalPowerMeasurement", Attribute: "rmsCurrent", Value: int64(current * 1000)},
+ }
+}
+
+const mainsVoltage = 230.0
+
+var deviceTypes = map[string]*deviceType{
+ "tempsensor": {
+ Key: "tempsensor", SimType: "matter_tempsensor", Label: "Temperature Sensor", Icon: "π‘οΈ", SerialTag: "TS",
+ MatterDefaults: map[string]float64{"measuredValue": 2000},
+ InitPhys: map[string]float64{"base_temp": 20, "amplitude": 6},
+ Generate: func(s *Server, t time.Time) []AttrChange {
+ var tempC float64
+ if s.getF("override", 0) != 0 {
+ tempC = s.getF("override_c", 20)
+ } else {
+ hour := float64(t.Hour()) + float64(t.Minute())/60
+ tempC = s.getF("base_temp", 20) + s.getF("amplitude", 6)*math.Sin((hour-9)/24*2*math.Pi)
+ }
+ s.setF("temperature_c", round1(tempC))
+ return []AttrChange{{Cluster: "temperatureMeasurement", Attribute: "measuredValue", Value: int(math.Round(tempC * 100))}}
+ },
+ SetValue: func(s *Server, key string, value interface{}) ([]AttrChange, bool) {
+ switch key {
+ case "temperature_c", "value":
+ if f, ok := toFloat(value); ok {
+ s.setF("override", 1)
+ s.setF("override_c", f)
+ s.setF("temperature_c", round1(f))
+ return []AttrChange{{Cluster: "temperatureMeasurement", Attribute: "measuredValue", Value: int(math.Round(f * 100))}}, true
+ }
+ case "auto":
+ s.setF("override", 0)
+ return nil, true
+ }
+ return nil, false
+ },
+ },
+
+ "lightsensor": {
+ Key: "lightsensor", SimType: "matter_lightsensor", Label: "Light Sensor", Icon: "π‘", SerialTag: "LS",
+ MatterDefaults: map[string]float64{"measuredValue": 1},
+ InitPhys: map[string]float64{"max_lux": 50000, "sunrise": 6, "sunset": 20},
+ Generate: func(s *Server, t time.Time) []AttrChange {
+ var lux float64
+ if s.getF("override", 0) != 0 {
+ lux = s.getF("override_lux", 0)
+ } else {
+ hour := float64(t.Hour()) + float64(t.Minute())/60
+ sunrise, sunset := s.getF("sunrise", 6), s.getF("sunset", 20)
+ if hour > sunrise && hour < sunset {
+ lux = s.getF("max_lux", 50000) * math.Sin((hour-sunrise)/(sunset-sunrise)*math.Pi)
+ }
+ }
+ s.setF("lux", math.Round(lux))
+ return []AttrChange{{Cluster: "illuminanceMeasurement", Attribute: "measuredValue", Value: luxToMeasured(lux)}}
+ },
+ SetValue: func(s *Server, key string, value interface{}) ([]AttrChange, bool) {
+ switch key {
+ case "lux", "value":
+ if f, ok := toFloat(value); ok {
+ s.setF("override", 1)
+ s.setF("override_lux", f)
+ s.setF("lux", math.Round(f))
+ return []AttrChange{{Cluster: "illuminanceMeasurement", Attribute: "measuredValue", Value: luxToMeasured(f)}}, true
+ }
+ case "auto":
+ s.setF("override", 0)
+ return nil, true
+ }
+ return nil, false
+ },
+ },
+
+ "smartplug": {
+ Key: "smartplug", SimType: "matter_smartplug", Label: "Smart Plug", Icon: "π", SerialTag: "SP",
+ MatterDefaults: map[string]float64{"onOff": 0},
+ InitPhys: map[string]float64{"on": 0, "load_w": 200, "energy_wh": 0},
+ Generate: func(s *Server, t time.Time) []AttrChange {
+ on := s.getBool("on", false)
+ var watts float64
+ if on {
+ load := s.getF("load_w", 200)
+ watts = load * (1 + 0.02*math.Sin(float64(t.Unix())))
+ s.addF("energy_wh", watts/3600.0)
+ }
+ s.setF("power_w", round1(watts))
+ changes := powerChanges(watts, mainsVoltage)
+ changes = append(changes, energyChange(s.getF("energy_wh", 0)))
+ return changes
+ },
+ SetValue: func(s *Server, key string, value interface{}) ([]AttrChange, bool) {
+ switch key {
+ case "on":
+ on := toBool(value)
+ s.setBool("on", on)
+ return []AttrChange{{Cluster: "onOff", Attribute: "onOff", Value: on}}, true
+ case "load_w":
+ if f, ok := toFloat(value); ok {
+ s.setF("load_w", f)
+ return nil, true
+ }
+ }
+ return nil, false
+ },
+ OnExternal: func(s *Server, cluster, attribute string, value interface{}) {
+ if cluster == "onOff" && attribute == "onOff" {
+ s.setBool("on", toBool(value))
+ }
+ },
+ },
+
+ "evse": {
+ Key: "evse", SimType: "matter_evse", Label: "EVSE Charger", Icon: "π", SerialTag: "EV",
+ MatterDefaults: map[string]float64{"minimumChargeCurrent": 6000, "maximumChargeCurrent": 32000},
+ InitPhys: map[string]float64{"session": 0, "max_current_a": 32, "energy_wh": 0, "session_wh": 0},
+ Generate: func(s *Server, t time.Time) []AttrChange {
+ session := int(s.getF("session", 0)) // 0 unplugged, 2 plugged, 3 charging
+ var watts float64
+ if session == 3 {
+ watts = s.getF("max_current_a", 32) * mainsVoltage
+ s.addF("energy_wh", watts/3600.0)
+ s.addF("session_wh", watts/3600.0)
+ }
+ s.setF("power_w", round1(watts))
+ changes := []AttrChange{
+ {Cluster: "energyEvse", Attribute: "state", Value: session},
+ {Cluster: "energyEvse", Attribute: "sessionEnergyCharged", Value: int64(s.getF("session_wh", 0) * 1000)},
+ }
+ changes = append(changes, powerChanges(watts, mainsVoltage)...)
+ changes = append(changes, energyChange(s.getF("energy_wh", 0)))
+ return changes
+ },
+ SetValue: func(s *Server, key string, value interface{}) ([]AttrChange, bool) {
+ switch key {
+ case "plugged":
+ if toBool(value) {
+ s.setF("session", 2)
+ } else {
+ s.setF("session", 0)
+ s.setF("session_wh", 0)
+ }
+ return []AttrChange{{Cluster: "energyEvse", Attribute: "state", Value: int(s.getF("session", 0))}}, true
+ case "charging":
+ if toBool(value) {
+ s.setF("session", 3)
+ } else if s.getF("session", 0) == 3 {
+ s.setF("session", 2)
+ }
+ return []AttrChange{{Cluster: "energyEvse", Attribute: "state", Value: int(s.getF("session", 0))}}, true
+ case "max_current_a":
+ if f, ok := toFloat(value); ok {
+ s.setF("max_current_a", f)
+ return nil, true
+ }
+ }
+ return nil, false
+ },
+ },
+
+ "thermostat": {
+ Key: "thermostat", SimType: "matter_thermostat", Label: "Thermostat", Icon: "ποΈ", SerialTag: "TH",
+ MatterDefaults: map[string]float64{
+ "localTemperature": 2100, "occupiedHeatingSetpoint": 2100,
+ "occupiedCoolingSetpoint": 2600, "systemMode": 4,
+ },
+ InitPhys: map[string]float64{"local_c": 21, "ambient_c": 18, "heat_sp_c": 21, "cool_sp_c": 26, "system_mode": 4},
+ Generate: func(s *Server, t time.Time) []AttrChange {
+ return thermostatStep(s, nil)
+ },
+ SetValue: thermostatSetValue,
+ OnExternal: thermostatOnExternal,
+ },
+
+ "heatpump": {
+ Key: "heatpump", SimType: "matter_heatpump", Label: "Heat Pump", Icon: "β¨οΈ", SerialTag: "HP",
+ MatterDefaults: map[string]float64{
+ "localTemperature": 2100, "occupiedHeatingSetpoint": 2100,
+ "occupiedCoolingSetpoint": 2600, "systemMode": 4,
+ },
+ InitPhys: map[string]float64{
+ "local_c": 21, "ambient_c": 5, "heat_sp_c": 21, "cool_sp_c": 26,
+ "system_mode": 4, "cop": 3.5, "thermal_w": 4000, "energy_wh": 0,
+ },
+ Generate: func(s *Server, t time.Time) []AttrChange {
+ running := 0
+ changes := thermostatStep(s, &running)
+ var watts float64
+ if running != 0 {
+ watts = s.getF("thermal_w", 4000) / s.getF("cop", 3.5)
+ s.addF("energy_wh", watts/3600.0)
+ }
+ s.setF("power_w", round1(watts))
+ changes = append(changes, powerChanges(watts, mainsVoltage)...)
+ changes = append(changes, energyChange(s.getF("energy_wh", 0)))
+ return changes
+ },
+ SetValue: thermostatSetValue,
+ OnExternal: thermostatOnExternal,
+ },
+
+ "dishwasher": {
+ Key: "dishwasher", SimType: "matter_dishwasher", Label: "Dishwasher", Icon: "π½οΈ", SerialTag: "DW",
+ InitPhys: map[string]float64{"running": 0, "countdown": 0, "cycle_len": 5400},
+ Generate: applianceGenerate,
+ SetValue: applianceSetValue,
+ },
+
+ "laundrywasher": {
+ Key: "laundrywasher", SimType: "matter_laundrywasher", Label: "Washing Machine", Icon: "π§Ί", SerialTag: "WM",
+ InitPhys: map[string]float64{"running": 0, "countdown": 0, "cycle_len": 7200},
+ Generate: applianceGenerate,
+ SetValue: applianceSetValue,
+ },
+}
+
+// thermostatStep advances a first-order thermal plant toward the active
+// setpoint. When runningOut is non-nil it receives the running-state bitmap.
+func thermostatStep(s *Server, runningOut *int) []AttrChange {
+ local := s.getF("local_c", 21)
+ ambient := s.getF("ambient_c", 18)
+ mode := int(s.getF("system_mode", 4))
+ heatSp := s.getF("heat_sp_c", 21)
+ coolSp := s.getF("cool_sp_c", 26)
+
+ local += (ambient - local) * 0.01 // passive drift toward ambient
+ running := 0
+ if (mode == 4 || mode == 1) && local < heatSp { // Heat or Auto
+ local += math.Min(0.2, (heatSp-local)*0.2)
+ running |= 0x01 // heat
+ }
+ if (mode == 3 || mode == 1) && local > coolSp { // Cool or Auto
+ local -= math.Min(0.2, (local-coolSp)*0.2)
+ running |= 0x02 // cool
+ }
+ s.setF("local_c", round1(local))
+ if runningOut != nil {
+ *runningOut = running
+ }
+ return []AttrChange{
+ {Cluster: "thermostat", Attribute: "localTemperature", Value: int(math.Round(local * 100))},
+ {Cluster: "thermostat", Attribute: "thermostatRunningState", Value: running},
+ }
+}
+
+func thermostatSetValue(s *Server, key string, value interface{}) ([]AttrChange, bool) {
+ f, ok := toFloat(value)
+ switch key {
+ case "heating_setpoint_c":
+ if ok {
+ s.setF("heat_sp_c", f)
+ return []AttrChange{{Cluster: "thermostat", Attribute: "occupiedHeatingSetpoint", Value: int(math.Round(f * 100))}}, true
+ }
+ case "cooling_setpoint_c":
+ if ok {
+ s.setF("cool_sp_c", f)
+ return []AttrChange{{Cluster: "thermostat", Attribute: "occupiedCoolingSetpoint", Value: int(math.Round(f * 100))}}, true
+ }
+ case "system_mode":
+ if ok {
+ s.setF("system_mode", f)
+ return []AttrChange{{Cluster: "thermostat", Attribute: "systemMode", Value: int(f)}}, true
+ }
+ }
+ return nil, false
+}
+
+func thermostatOnExternal(s *Server, cluster, attribute string, value interface{}) {
+ if cluster != "thermostat" {
+ return
+ }
+ f, ok := toFloat(value)
+ if !ok {
+ return
+ }
+ switch attribute {
+ case "occupiedHeatingSetpoint":
+ s.setF("heat_sp_c", f/100)
+ case "occupiedCoolingSetpoint":
+ s.setF("cool_sp_c", f/100)
+ case "systemMode":
+ s.setF("system_mode", f)
+ }
+}
+
+// applianceGenerate drives the dishwasher / washing-machine timed cycle.
+func applianceGenerate(s *Server, t time.Time) []AttrChange {
+ if !s.getBool("running", false) {
+ return nil
+ }
+ countdown := s.addF("countdown", -1)
+ if countdown <= 0 {
+ s.setBool("running", false)
+ s.setF("countdown", 0)
+ return []AttrChange{
+ {Cluster: "operationalState", Attribute: "operationalState", Value: 0}, // Stopped
+ {Cluster: "operationalState", Attribute: "countdownTime", Value: 0},
+ }
+ }
+ return []AttrChange{
+ {Cluster: "operationalState", Attribute: "operationalState", Value: 1}, // Running
+ {Cluster: "operationalState", Attribute: "countdownTime", Value: int(countdown)},
+ }
+}
+
+func applianceSetValue(s *Server, key string, value interface{}) ([]AttrChange, bool) {
+ switch key {
+ case "start":
+ s.setBool("running", true)
+ s.setF("countdown", s.getF("cycle_len", 5400))
+ return []AttrChange{
+ {Cluster: "operationalState", Attribute: "operationalState", Value: 1},
+ {Cluster: "operationalState", Attribute: "countdownTime", Value: int(s.getF("cycle_len", 5400))},
+ }, true
+ case "stop":
+ s.setBool("running", false)
+ s.setF("countdown", 0)
+ return []AttrChange{
+ {Cluster: "operationalState", Attribute: "operationalState", Value: 0},
+ {Cluster: "operationalState", Attribute: "countdownTime", Value: 0},
+ }, true
+ }
+ return nil, false
+}
+
+// lookupDeviceType accepts both the short key ("thermostat") and the full
+// createSimulator type ("matter_thermostat").
+func lookupDeviceType(key string) *deviceType {
+ key = strings.TrimPrefix(key, "matter_")
+ return deviceTypes[key]
+}
+
+// SimTypes returns every supported createSimulator type string.
+func SimTypes() []string {
+ out := make([]string, 0, len(deviceTypes))
+ for _, dt := range deviceTypes {
+ out = append(out, dt.SimType)
+ }
+ return out
+}
+
+// IsMatterType reports whether simType is a supported Matter device type.
+func IsMatterType(simType string) bool {
+ return lookupDeviceType(simType) != nil
+}
+
+// luxToMeasured encodes lux into the IlluminanceMeasurement.measuredValue
+// logarithmic scale: 10000*log10(lux)+1, min 1.
+func luxToMeasured(lux float64) int {
+ if lux < 1 {
+ return 1
+ }
+ v := 10000*math.Log10(lux) + 1
+ if v < 1 {
+ return 1
+ }
+ if v > 0xFFFE {
+ return 0xFFFE
+ }
+ return int(math.Round(v))
+}
+
+func round1(v float64) float64 { return math.Round(v*10) / 10 }
+
+func toFloat(v interface{}) (float64, bool) {
+ switch x := v.(type) {
+ case float64:
+ return x, true
+ case float32:
+ return float64(x), true
+ case int:
+ return float64(x), true
+ case int64:
+ return float64(x), true
+ case bool:
+ return boolToF(x), true
+ }
+ return 0, false
+}
+
+func toBool(v interface{}) bool {
+ switch x := v.(type) {
+ case bool:
+ return x
+ case float64:
+ return x != 0
+ case int:
+ return x != 0
+ case string:
+ return x == "true" || x == "on" || x == "1"
+ }
+ return false
+}
diff --git a/internal/matter/devicetypes_test.go b/internal/matter/devicetypes_test.go
new file mode 100644
index 0000000..f98e4fb
--- /dev/null
+++ b/internal/matter/devicetypes_test.go
@@ -0,0 +1,173 @@
+package matter
+
+import (
+ "testing"
+ "time"
+)
+
+// newPhysServer builds a Server for physics tests without touching the bridge.
+func newPhysServer(t *testing.T, typeKey string) *Server {
+ t.Helper()
+ s, err := NewServer(1, typeKey)
+ if err != nil {
+ t.Fatalf("NewServer(%q): %v", typeKey, err)
+ }
+ return s
+}
+
+func findChange(changes []AttrChange, cluster, attr string) (AttrChange, bool) {
+ for _, c := range changes {
+ if c.Cluster == cluster && c.Attribute == attr {
+ return c, true
+ }
+ }
+ return AttrChange{}, false
+}
+
+func TestAllDeviceTypesResolve(t *testing.T) {
+ want := []string{
+ "matter_tempsensor", "matter_lightsensor", "matter_smartplug", "matter_evse",
+ "matter_thermostat", "matter_heatpump", "matter_dishwasher", "matter_laundrywasher",
+ }
+ for _, st := range want {
+ if !IsMatterType(st) {
+ t.Errorf("expected %q to be a matter type", st)
+ }
+ if lookupDeviceType(st) == nil {
+ t.Errorf("lookupDeviceType(%q) == nil", st)
+ }
+ }
+ if len(SimTypes()) != len(want) {
+ t.Errorf("SimTypes len = %d, want %d", len(SimTypes()), len(want))
+ }
+}
+
+func TestSmartPlugPhysics(t *testing.T) {
+ s := newPhysServer(t, "smartplug")
+ dt := s.dt
+ now := time.Date(2026, 6, 25, 12, 0, 0, 0, time.UTC)
+
+ // Off: no power.
+ changes := dt.Generate(s, now)
+ if c, ok := findChange(changes, "electricalPowerMeasurement", "activePower"); !ok || c.Value.(int64) != 0 {
+ t.Errorf("off activePower = %v, want 0", c.Value)
+ }
+
+ // Turn on via SetValue.
+ setChanges, handled := dt.SetValue(s, "on", true)
+ if !handled {
+ t.Fatal("SetValue on not handled")
+ }
+ if c, ok := findChange(setChanges, "onOff", "onOff"); !ok || c.Value != true {
+ t.Errorf("onOff change = %v", c.Value)
+ }
+
+ // On: power flows and energy accumulates.
+ changes = dt.Generate(s, now)
+ c, ok := findChange(changes, "electricalPowerMeasurement", "activePower")
+ if !ok || c.Value.(int64) <= 0 {
+ t.Errorf("on activePower = %v, want > 0", c.Value)
+ }
+ if s.getF("energy_wh", 0) <= 0 {
+ t.Error("expected energy to accumulate while on")
+ }
+
+ // External off reflects into physics.
+ dt.OnExternal(s, "onOff", "onOff", false)
+ if s.getBool("on", true) {
+ t.Error("external off did not update physics")
+ }
+}
+
+func TestThermostatStepHeats(t *testing.T) {
+ s := newPhysServer(t, "thermostat")
+ s.setF("local_c", 15)
+ s.setF("heat_sp_c", 22)
+ s.setF("system_mode", 4) // Heat
+
+ running := 0
+ thermostatStep(s, &running)
+ if running&0x01 == 0 {
+ t.Error("expected heat running bit set")
+ }
+ if s.getF("local_c", 0) <= 15 {
+ t.Error("expected local temperature to rise toward setpoint")
+ }
+
+ // Setpoint change via SetValue emits a Matter attribute write.
+ changes, handled := s.dt.SetValue(s, "heating_setpoint_c", 23.0)
+ if !handled {
+ t.Fatal("setpoint not handled")
+ }
+ if c, ok := findChange(changes, "thermostat", "occupiedHeatingSetpoint"); !ok || c.Value.(int) != 2300 {
+ t.Errorf("occupiedHeatingSetpoint = %v, want 2300", c.Value)
+ }
+}
+
+func TestEvseChargingAccumulates(t *testing.T) {
+ s := newPhysServer(t, "evse")
+ now := time.Now()
+ if _, handled := s.dt.SetValue(s, "charging", true); !handled {
+ t.Fatal("charging not handled")
+ }
+ changes := s.dt.Generate(s, now)
+ if c, ok := findChange(changes, "energyEvse", "state"); !ok || c.Value.(int) != 3 {
+ t.Errorf("evse state = %v, want 3 (charging)", c.Value)
+ }
+ if s.getF("session_wh", 0) <= 0 {
+ t.Error("expected session energy to accumulate while charging")
+ }
+}
+
+func TestApplianceCycle(t *testing.T) {
+ s := newPhysServer(t, "dishwasher")
+ s.setF("cycle_len", 3) // short cycle for the test
+ changes, handled := s.dt.SetValue(s, "start", nil)
+ if !handled {
+ t.Fatal("start not handled")
+ }
+ if c, ok := findChange(changes, "operationalState", "operationalState"); !ok || c.Value.(int) != 1 {
+ t.Errorf("start operationalState = %v, want 1 (Running)", c.Value)
+ }
+ // Tick down to completion; capture the last emitted change (the stop event,
+ // after which Generate returns nil).
+ var last []AttrChange
+ for i := 0; i < 5; i++ {
+ if ch := s.dt.Generate(s, time.Now()); ch != nil {
+ last = ch
+ }
+ }
+ if c, ok := findChange(last, "operationalState", "operationalState"); !ok || c.Value.(int) != 0 {
+ t.Errorf("ended operationalState = %v, want 0 (Stopped)", c.Value)
+ }
+ if s.getBool("running", true) {
+ t.Error("expected cycle to stop")
+ }
+}
+
+func TestLuxToMeasured(t *testing.T) {
+ if luxToMeasured(0) != 1 {
+ t.Errorf("luxToMeasured(0) = %d, want 1", luxToMeasured(0))
+ }
+ dark := luxToMeasured(10)
+ bright := luxToMeasured(50000)
+ if !(bright > dark) {
+ t.Errorf("expected brighter lux to encode higher: dark=%d bright=%d", dark, bright)
+ }
+}
+
+func TestPasscodeAndDiscriminatorStableAndValid(t *testing.T) {
+ for id := 0; id < 50; id++ {
+ p := derivePasscode(id)
+ if p < 1 || p > 99999998 {
+ t.Errorf("passcode %d out of range for id %d", p, id)
+ }
+ d := deriveDiscriminator(id)
+ if d < 0 || d > 0x0FFF {
+ t.Errorf("discriminator %d out of range for id %d", d, id)
+ }
+ if derivePasscode(id) != p || deriveDiscriminator(id) != d {
+ t.Errorf("derivation not stable for id %d", id)
+ }
+ }
+}
diff --git a/internal/matter/manager.go b/internal/matter/manager.go
new file mode 100644
index 0000000..8a5a36d
--- /dev/null
+++ b/internal/matter/manager.go
@@ -0,0 +1,316 @@
+package matter
+
+import (
+ "bufio"
+ "encoding/json"
+ "fmt"
+ "io"
+ "log"
+ "os"
+ "os/exec"
+ "sync"
+ "time"
+)
+
+// readyTimeout bounds how long we wait for the bridge to announce readiness.
+const readyTimeout = 20 * time.Second
+
+// Manager owns the single Node.js bridge process that hosts every Matter
+// ServerNode. Per-device matter.Server adapters delegate to it by nodeId. It is
+// a process-wide singleton, started lazily when the first Matter device starts.
+type Manager struct {
+ mu sync.Mutex
+
+ node NodeInfo
+ storageRoot string
+
+ cmd *exec.Cmd
+ rpc *rpcConn
+ started bool
+ readyCh chan struct{}
+
+ devices map[string]*Server // nodeId -> Server (for event routing & replay)
+
+ shuttingDown bool
+}
+
+var (
+ sharedManager *Manager
+ sharedOnce sync.Once
+)
+
+// Shared returns the process-wide bridge manager.
+func Shared() *Manager {
+ sharedOnce.Do(func() {
+ sharedManager = &Manager{
+ node: DetectNode(),
+ storageRoot: StorageRoot(),
+ devices: make(map[string]*Server),
+ }
+ })
+ return sharedManager
+}
+
+// Available reports whether Matter can be used and, if not, a human-readable
+// reason for the UI/API to surface.
+func (m *Manager) Available() (bool, string) {
+ if !m.node.Available {
+ return false, m.node.Reason
+ }
+ return true, ""
+}
+
+// NodeVersion returns the detected Node.js version (may be empty).
+func (m *Manager) NodeVersion() string { return m.node.Version }
+
+// register/unregister track live devices for event routing and crash replay.
+func (m *Manager) register(s *Server) {
+ m.mu.Lock()
+ m.devices[s.nodeID] = s
+ m.mu.Unlock()
+}
+
+func (m *Manager) unregister(nodeID string) {
+ m.mu.Lock()
+ delete(m.devices, nodeID)
+ m.mu.Unlock()
+}
+
+// ensureStarted lazily spawns the bridge and waits for readiness.
+func (m *Manager) ensureStarted() error {
+ if ok, reason := m.Available(); !ok {
+ return &ErrNodeUnavailable{Reason: reason}
+ }
+
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ if m.started {
+ return nil
+ }
+ return m.spawnLocked()
+}
+
+// spawnLocked starts the Node process. Caller holds m.mu.
+func (m *Manager) spawnLocked() error {
+ bridgeDir, err := ensureBridgeExtracted()
+ if err != nil {
+ return fmt.Errorf("prepare matter bridge: %w", err)
+ }
+ if err := os.MkdirAll(m.storageRoot, 0o755); err != nil {
+ return fmt.Errorf("create matter storage: %w", err)
+ }
+
+ cmd := exec.Command(m.node.Path, "index.mjs")
+ cmd.Dir = bridgeDir
+ cmd.Env = append(os.Environ(),
+ "MATTER_STORAGE_PATH="+m.storageRoot,
+ "MATTER_LOG_LEVEL="+logLevel(),
+ )
+
+ stdin, err := cmd.StdinPipe()
+ if err != nil {
+ return err
+ }
+ stdout, err := cmd.StdoutPipe()
+ if err != nil {
+ return err
+ }
+ stderr, err := cmd.StderrPipe()
+ if err != nil {
+ return err
+ }
+
+ if err := cmd.Start(); err != nil {
+ return fmt.Errorf("start matter bridge: %w", err)
+ }
+
+ m.cmd = cmd
+ m.readyCh = make(chan struct{})
+ m.rpc = newRPCConn(stdin, stdout, m.handleEvent)
+ go m.drainStderr(stderr)
+ go m.watch(cmd)
+
+ // Wait for the bridge's "ready" event.
+ ready := m.readyCh
+ m.mu.Unlock()
+ select {
+ case <-ready:
+ m.mu.Lock()
+ m.started = true
+ log.Printf("[matter] bridge ready (node %s)", m.node.Version)
+ return nil
+ case <-time.After(readyTimeout):
+ m.mu.Lock()
+ _ = cmd.Process.Kill()
+ return fmt.Errorf("matter bridge did not become ready within %s", readyTimeout)
+ }
+}
+
+// call proxies an RPC to the bridge.
+func (m *Manager) call(method string, params interface{}) (json.RawMessage, error) {
+ m.mu.Lock()
+ rpc := m.rpc
+ started := m.started
+ m.mu.Unlock()
+ if !started || rpc == nil {
+ return nil, fmt.Errorf("matter bridge not started")
+ }
+ return rpc.call(method, params)
+}
+
+// createDevice creates a ServerNode for the given config and returns pairing info.
+func (m *Manager) createDevice(cfg DeviceConfig) (CommissioningInfo, error) {
+ if err := m.ensureStarted(); err != nil {
+ return CommissioningInfo{}, err
+ }
+ raw, err := m.call("createDevice", cfg)
+ if err != nil {
+ return CommissioningInfo{}, err
+ }
+ var info CommissioningInfo
+ if err := json.Unmarshal(raw, &info); err != nil {
+ return CommissioningInfo{}, err
+ }
+ return info, nil
+}
+
+func (m *Manager) setAttributes(nodeID string, changes []AttrChange) error {
+ _, err := m.call("setAttributes", map[string]interface{}{
+ "nodeId": nodeID,
+ "changes": changes,
+ })
+ return err
+}
+
+func (m *Manager) removeDevice(nodeID string) error {
+ _, err := m.call("removeDevice", map[string]interface{}{"nodeId": nodeID})
+ return err
+}
+
+// handleEvent routes a push event from the bridge to the right Server.
+func (m *Manager) handleEvent(ev event) {
+ if ev.Name == "ready" {
+ m.mu.Lock()
+ ch := m.readyCh
+ m.mu.Unlock()
+ if ch != nil {
+ select {
+ case <-ch:
+ default:
+ close(ch)
+ }
+ }
+ return
+ }
+
+ var probe struct {
+ NodeID string `json:"nodeId"`
+ }
+ _ = json.Unmarshal(ev.Raw, &probe)
+ if probe.NodeID == "" {
+ return
+ }
+ m.mu.Lock()
+ s := m.devices[probe.NodeID]
+ m.mu.Unlock()
+ if s != nil {
+ s.handleEvent(ev)
+ }
+}
+
+// drainStderr captures matter.js/bridge diagnostics and forwards them to the
+// owning device's log (best-effort).
+func (m *Manager) drainStderr(r io.Reader) {
+ sc := bufio.NewScanner(r)
+ sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
+ for sc.Scan() {
+ line := sc.Text()
+ if line == "" {
+ continue
+ }
+ log.Printf("[matter-bridge] %s", line)
+ }
+}
+
+// watch waits for the process to exit and, unless we're shutting down, restarts
+// it and replays all live devices so commissioning (persisted on disk) is
+// restored.
+func (m *Manager) watch(cmd *exec.Cmd) {
+ err := cmd.Wait()
+
+ m.mu.Lock()
+ if m.shuttingDown || cmd != m.cmd {
+ m.mu.Unlock()
+ return
+ }
+ m.started = false
+ if m.rpc != nil {
+ m.rpc.markClosed()
+ }
+ log.Printf("[matter] bridge exited unexpectedly (%v); restarting", err)
+ // mark all devices bridge-down
+ devices := make([]*Server, 0, len(m.devices))
+ for _, s := range m.devices {
+ s.setBridgeUp(false)
+ devices = append(devices, s)
+ }
+ m.mu.Unlock()
+
+ backoff := time.Second
+ for attempt := 0; attempt < 10; attempt++ {
+ time.Sleep(backoff)
+ m.mu.Lock()
+ if m.shuttingDown {
+ m.mu.Unlock()
+ return
+ }
+ err := m.spawnLocked()
+ m.mu.Unlock()
+ if err == nil {
+ break
+ }
+ log.Printf("[matter] bridge restart attempt %d failed: %v", attempt+1, err)
+ if backoff < 30*time.Second {
+ backoff *= 2
+ }
+ }
+
+ // Replay device creation against the fresh process.
+ for _, s := range devices {
+ if _, err := m.createDevice(s.config()); err != nil {
+ log.Printf("[matter] replay of %s failed: %v", s.nodeID, err)
+ continue
+ }
+ s.setBridgeUp(true)
+ }
+}
+
+// Shutdown stops the bridge process (called on app exit).
+func (m *Manager) Shutdown() {
+ m.mu.Lock()
+ m.shuttingDown = true
+ rpc := m.rpc
+ cmd := m.cmd
+ m.started = false
+ m.mu.Unlock()
+
+ if rpc != nil {
+ _, _ = rpc.call("shutdown", nil)
+ }
+ if cmd != nil && cmd.Process != nil {
+ done := make(chan struct{})
+ go func() { _ = cmd.Wait(); close(done) }()
+ select {
+ case <-done:
+ case <-time.After(5 * time.Second):
+ _ = cmd.Process.Kill()
+ }
+ }
+}
+
+func logLevel() string {
+ if v := os.Getenv("MATTER_LOG_LEVEL"); v != "" {
+ return v
+ }
+ return "notice"
+}
diff --git a/internal/matter/rpc.go b/internal/matter/rpc.go
new file mode 100644
index 0000000..bd9e20e
--- /dev/null
+++ b/internal/matter/rpc.go
@@ -0,0 +1,163 @@
+package matter
+
+import (
+ "bufio"
+ "encoding/json"
+ "errors"
+ "io"
+ "sync"
+ "sync/atomic"
+ "time"
+)
+
+// callTimeout bounds how long a single RPC waits for its response.
+const callTimeout = 15 * time.Second
+
+// event is a push message from the bridge (no id). Raw holds the full line so
+// handlers can decode the type-specific fields they care about.
+type event struct {
+ Name string
+ Raw json.RawMessage
+}
+
+// rpcResponse carries a correlated response back to the waiting caller.
+type rpcResponse struct {
+ Result json.RawMessage
+ Err string
+}
+
+// rpcConn implements newline-delimited JSON-RPC over an io.Writer (requests)
+// and io.Reader (responses + events). It is transport-agnostic: the manager
+// wires it to a child process's stdin/stdout, while tests wire it to an
+// in-process fake bridge via io.Pipe.
+type rpcConn struct {
+ w io.Writer
+ writeMu sync.Mutex
+
+ idSeq int64
+
+ mu sync.Mutex
+ pending map[int64]chan rpcResponse
+
+ onEvent func(event)
+
+ closeOnce sync.Once
+ closed chan struct{}
+}
+
+func newRPCConn(w io.Writer, r io.Reader, onEvent func(event)) *rpcConn {
+ c := &rpcConn{
+ w: w,
+ pending: make(map[int64]chan rpcResponse),
+ onEvent: onEvent,
+ closed: make(chan struct{}),
+ }
+ go c.readLoop(r)
+ return c
+}
+
+// call sends a request and blocks until the matching response, the timeout, or
+// connection close.
+func (c *rpcConn) call(method string, params interface{}) (json.RawMessage, error) {
+ id := atomic.AddInt64(&c.idSeq, 1)
+ ch := make(chan rpcResponse, 1)
+
+ c.mu.Lock()
+ c.pending[id] = ch
+ c.mu.Unlock()
+ defer func() {
+ c.mu.Lock()
+ delete(c.pending, id)
+ c.mu.Unlock()
+ }()
+
+ msg := struct {
+ ID int64 `json:"id"`
+ Method string `json:"method"`
+ Params interface{} `json:"params,omitempty"`
+ }{ID: id, Method: method, Params: params}
+
+ line, err := json.Marshal(msg)
+ if err != nil {
+ return nil, err
+ }
+ line = append(line, '\n')
+
+ c.writeMu.Lock()
+ _, err = c.w.Write(line)
+ c.writeMu.Unlock()
+ if err != nil {
+ return nil, err
+ }
+
+ select {
+ case resp := <-ch:
+ if resp.Err != "" {
+ return nil, errors.New(resp.Err)
+ }
+ return resp.Result, nil
+ case <-time.After(callTimeout):
+ return nil, errors.New("matter bridge call " + method + " timed out")
+ case <-c.closed:
+ return nil, errors.New("matter bridge connection closed")
+ }
+}
+
+// readLoop parses each line: responses (have "id") are routed to the waiting
+// caller; events (have "event") are dispatched to onEvent.
+func (c *rpcConn) readLoop(r io.Reader) {
+ defer c.markClosed()
+
+ sc := bufio.NewScanner(r)
+ // matter.js QR/state payloads can be large; allow generous line size.
+ sc.Buffer(make([]byte, 0, 64*1024), 8*1024*1024)
+
+ for sc.Scan() {
+ line := sc.Bytes()
+ if len(line) == 0 {
+ continue
+ }
+
+ var probe struct {
+ ID *int64 `json:"id"`
+ Event string `json:"event"`
+ Result json.RawMessage `json:"result"`
+ Error string `json:"error"`
+ }
+ if err := json.Unmarshal(line, &probe); err != nil {
+ // Not a control frame (stray output) β ignore.
+ continue
+ }
+
+ if probe.Event != "" {
+ if c.onEvent != nil {
+ raw := make([]byte, len(line))
+ copy(raw, line)
+ c.onEvent(event{Name: probe.Event, Raw: raw})
+ }
+ continue
+ }
+
+ if probe.ID != nil {
+ c.mu.Lock()
+ ch := c.pending[*probe.ID]
+ c.mu.Unlock()
+ if ch != nil {
+ ch <- rpcResponse{Result: probe.Result, Err: probe.Error}
+ }
+ }
+ }
+}
+
+func (c *rpcConn) markClosed() {
+ c.closeOnce.Do(func() { close(c.closed) })
+}
+
+func (c *rpcConn) isClosed() bool {
+ select {
+ case <-c.closed:
+ return true
+ default:
+ return false
+ }
+}
diff --git a/internal/matter/rpc_test.go b/internal/matter/rpc_test.go
new file mode 100644
index 0000000..8529b9c
--- /dev/null
+++ b/internal/matter/rpc_test.go
@@ -0,0 +1,180 @@
+package matter
+
+import (
+ "bufio"
+ "encoding/json"
+ "fmt"
+ "io"
+ "testing"
+ "time"
+)
+
+// startScriptedBridge runs a goroutine that reads NDJSON requests from r and
+// writes responses (via handle) to w. It returns a function to push events.
+func startScriptedBridge(r io.Reader, w io.Writer, handle func(method string, params json.RawMessage) (interface{}, string)) func(ev map[string]interface{}) {
+ go func() {
+ sc := bufio.NewScanner(r)
+ sc.Buffer(make([]byte, 0, 64*1024), 1<<20)
+ for sc.Scan() {
+ var req struct {
+ ID int64 `json:"id"`
+ Method string `json:"method"`
+ Params json.RawMessage `json:"params"`
+ }
+ if err := json.Unmarshal(sc.Bytes(), &req); err != nil {
+ continue
+ }
+ res, errStr := handle(req.Method, req.Params)
+ resp := map[string]interface{}{"id": req.ID}
+ if errStr != "" {
+ resp["error"] = errStr
+ } else {
+ resp["result"] = res
+ }
+ b, _ := json.Marshal(resp)
+ b = append(b, '\n')
+ _, _ = w.Write(b)
+ }
+ }()
+ return func(ev map[string]interface{}) {
+ b, _ := json.Marshal(ev)
+ b = append(b, '\n')
+ _, _ = w.Write(b)
+ }
+}
+
+func TestRPCRoundTrip(t *testing.T) {
+ prReq, pwReq := io.Pipe()
+ prResp, pwResp := io.Pipe()
+ events := make(chan event, 4)
+
+ c := newRPCConn(pwReq, prResp, func(e event) { events <- e })
+ push := startScriptedBridge(prReq, pwResp, func(method string, params json.RawMessage) (interface{}, string) {
+ if method == "fail" {
+ return nil, "boom"
+ }
+ return map[string]string{"echo": method}, ""
+ })
+
+ raw, err := c.call("ping", nil)
+ if err != nil {
+ t.Fatalf("call: %v", err)
+ }
+ var res struct{ Echo string }
+ if err := json.Unmarshal(raw, &res); err != nil || res.Echo != "ping" {
+ t.Fatalf("unexpected result %s (%v)", raw, err)
+ }
+
+ if _, err := c.call("fail", nil); err == nil {
+ t.Error("expected error from failing call")
+ }
+
+ push(map[string]interface{}{"event": "ready"})
+ select {
+ case e := <-events:
+ if e.Name != "ready" {
+ t.Errorf("event = %q, want ready", e.Name)
+ }
+ case <-time.After(2 * time.Second):
+ t.Fatal("timed out waiting for event")
+ }
+}
+
+func waitFor(t *testing.T, cond func() bool) {
+ t.Helper()
+ deadline := time.Now().Add(2 * time.Second)
+ for time.Now().Before(deadline) {
+ if cond() {
+ return
+ }
+ time.Sleep(5 * time.Millisecond)
+ }
+ t.Fatal("condition not met within timeout")
+}
+
+// newFakeManager builds a Manager backed by an in-process scripted bridge, with
+// no real Node process.
+func newFakeManager(t *testing.T, handle func(method string, params json.RawMessage) (interface{}, string)) (*Manager, func(ev map[string]interface{})) {
+ t.Helper()
+ prReq, pwReq := io.Pipe()
+ prResp, pwResp := io.Pipe()
+ m := &Manager{
+ node: NodeInfo{Available: true, Version: "vtest"},
+ storageRoot: t.TempDir(),
+ devices: make(map[string]*Server),
+ started: true,
+ }
+ m.rpc = newRPCConn(pwReq, prResp, m.handleEvent)
+ push := startScriptedBridge(prReq, pwResp, handle)
+ return m, push
+}
+
+func TestServerStartAndEvents(t *testing.T) {
+ m, push := newFakeManager(t, func(method string, params json.RawMessage) (interface{}, string) {
+ switch method {
+ case "createDevice":
+ var cfg DeviceConfig
+ _ = json.Unmarshal(params, &cfg)
+ return map[string]interface{}{
+ "nodeId": cfg.NodeID, "manualPairingCode": "34970112332",
+ "qrPairingCode": "MT:TESTQR", "port": cfg.Port,
+ "commissioned": false, "fabricCount": 0,
+ }, ""
+ case "setAttributes", "removeDevice":
+ return map[string]interface{}{"ok": true}, ""
+ }
+ return nil, fmt.Sprintf("unknown method %s", method)
+ })
+
+ s, err := NewServer(7, "smartplug")
+ if err != nil {
+ t.Fatal(err)
+ }
+ s.mgr = m
+ s.SetPort(5599)
+
+ if err := s.Start(); err != nil {
+ t.Fatalf("Start: %v", err)
+ }
+ if s.PairingCode() != "34970112332" || s.QRCode() != "MT:TESTQR" {
+ t.Errorf("pairing=%q qr=%q", s.PairingCode(), s.QRCode())
+ }
+
+ // External commissioning event should flip commissioned state.
+ push(map[string]interface{}{"event": "commissioned", "nodeId": s.nodeID, "fabricCount": 1})
+ waitFor(t, func() bool {
+ st := s.GetState()
+ return st["commissioned"] == true && st["fabric_count"] == 1
+ })
+
+ // SetValue pushes a setAttributes RPC without error.
+ if err := s.SetValue("on", true); err != nil {
+ t.Errorf("SetValue: %v", err)
+ }
+
+ if err := s.Stop(); err != nil {
+ t.Errorf("Stop: %v", err)
+ }
+}
+
+func TestServerStartUnavailable(t *testing.T) {
+ s, err := NewServer(1, "thermostat")
+ if err != nil {
+ t.Fatal(err)
+ }
+ s.mgr = &Manager{node: NodeInfo{Available: false, Reason: "no node"}, devices: map[string]*Server{}}
+ err = s.Start()
+ var unavailable *ErrNodeUnavailable
+ if err == nil || !errorsAs(err, &unavailable) {
+ t.Fatalf("expected ErrNodeUnavailable, got %v", err)
+ }
+}
+
+// errorsAs is a tiny local helper to avoid importing errors in multiple files.
+func errorsAs(err error, target **ErrNodeUnavailable) bool {
+ if e, ok := err.(*ErrNodeUnavailable); ok {
+ *target = e
+ return true
+ }
+ return false
+}
diff --git a/internal/matter/server.go b/internal/matter/server.go
new file mode 100644
index 0000000..e5e9f38
--- /dev/null
+++ b/internal/matter/server.go
@@ -0,0 +1,366 @@
+package matter
+
+import (
+ "encoding/json"
+ "fmt"
+ "sync"
+ "time"
+
+ "github.com/srcfl/device-simulator/internal/device"
+)
+
+// Server is the per-device DeviceServer/Automatable adapter. It holds the
+// simulated physics state (the Go side is the source of truth) and delegates
+// Matter protocol concerns to the shared bridge Manager via nodeId.
+type Server struct {
+ mu sync.RWMutex
+
+ id int
+ serial string
+ dt *deviceType
+ port int
+ nodeID string
+ passcode int
+ discriminator int
+ mgr *Manager
+
+ // commissioning surface (populated by Start + push events)
+ pairingCode string
+ qrCode string
+ commissioned bool
+ fabricCount int
+ bridgeUp bool
+
+ phys map[string]float64 // physics scalars (bools stored as 0/1)
+ extras map[string]interface{} // non-numeric reported values
+
+ logs []device.LogEntry
+ maxLogs int
+ running bool
+}
+
+// NewServer builds a Matter server for the given device-type key
+// ("matter_thermostat", etc. β or the short key "thermostat").
+func NewServer(id int, deviceTypeKey string) (*Server, error) {
+ dt := lookupDeviceType(deviceTypeKey)
+ if dt == nil {
+ return nil, fmt.Errorf("unknown matter device type: %s", deviceTypeKey)
+ }
+
+ s := &Server{
+ id: id,
+ serial: fmt.Sprintf("MATTER%s%02d", dt.SerialTag, id),
+ dt: dt,
+ nodeID: fmt.Sprintf("%s-%d", dt.Key, id),
+ passcode: derivePasscode(id),
+ discriminator: deriveDiscriminator(id),
+ mgr: Shared(),
+ phys: map[string]float64{},
+ extras: map[string]interface{}{},
+ logs: make([]device.LogEntry, 0, 64),
+ maxLogs: 500,
+ }
+ for k, v := range dt.InitPhys {
+ s.phys[k] = v
+ }
+ return s, nil
+}
+
+// DeviceTypeKey returns the short bridge key (e.g. "thermostat").
+func (s *Server) DeviceTypeKey() string { return s.dt.Key }
+
+// Label returns the human-friendly device-type label.
+func (s *Server) Label() string { return s.dt.Label }
+
+func (s *Server) SetPort(port int) {
+ s.mu.Lock()
+ s.port = port
+ s.mu.Unlock()
+}
+
+// config builds the DeviceConfig sent to the bridge (also used for replay).
+func (s *Server) config() DeviceConfig {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ return DeviceConfig{
+ NodeID: s.nodeID,
+ Type: s.dt.Key,
+ Serial: s.serial,
+ Port: s.port,
+ Passcode: s.passcode,
+ Discriminator: s.discriminator,
+ DeviceName: s.dt.Label,
+ VendorID: DefaultVendorID,
+ ProductID: DefaultProductID,
+ Defaults: s.dt.MatterDefaults,
+ }
+}
+
+// Start creates the Matter ServerNode via the bridge and records pairing info.
+func (s *Server) Start() error {
+ if ok, reason := s.mgr.Available(); !ok {
+ return &ErrNodeUnavailable{Reason: reason}
+ }
+ info, err := s.mgr.createDevice(s.config())
+ if err != nil {
+ return err
+ }
+
+ s.mu.Lock()
+ s.pairingCode = info.ManualPairingCode
+ s.qrCode = info.QRPairingCode
+ s.commissioned = info.Commissioned
+ s.fabricCount = info.FabricCount
+ s.bridgeUp = true
+ s.running = true
+ s.mu.Unlock()
+
+ s.mgr.register(s)
+ s.addLog("commissioning", map[string]interface{}{
+ "pairing_code": info.ManualPairingCode,
+ "commissioned": info.Commissioned,
+ })
+ return nil
+}
+
+// Stop tears down the ServerNode (storage is retained so re-pairing isn't
+// required on a later Start).
+func (s *Server) Stop() error {
+ s.mu.Lock()
+ running := s.running
+ s.running = false
+ s.bridgeUp = false
+ s.mu.Unlock()
+ if !running {
+ return nil
+ }
+ s.mgr.unregister(s.nodeID)
+ if err := s.mgr.removeDevice(s.nodeID); err != nil {
+ return err
+ }
+ s.addLog("stopped", nil)
+ return nil
+}
+
+// GetState returns the current device state for the UI/API.
+func (s *Server) GetState() map[string]interface{} {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+
+ state := map[string]interface{}{
+ "device_type": s.dt.Key,
+ "label": s.dt.Label,
+ "node_id": s.nodeID,
+ "pairing_code": s.pairingCode,
+ "qr": s.qrCode,
+ "commissioned": s.commissioned,
+ "fabric_count": s.fabricCount,
+ "bridge_up": s.bridgeUp,
+ }
+ for k, v := range s.phys {
+ state[k] = v
+ }
+ for k, v := range s.extras {
+ state[k] = v
+ }
+ return state
+}
+
+// SetValue applies a control change from the UI/API (e.g. toggle plug, set
+// thermostat setpoint, start a wash cycle) and pushes the resulting Matter
+// attribute writes to the bridge.
+func (s *Server) SetValue(key string, value interface{}) error {
+ if s.dt.SetValue == nil {
+ return fmt.Errorf("device type %s has no settable values", s.dt.Key)
+ }
+ changes, handled := s.dt.SetValue(s, key, value)
+ if !handled {
+ return fmt.Errorf("unknown value key for %s: %s", s.dt.Key, key)
+ }
+ s.addLog("set_value", map[string]interface{}{"key": key, "value": value})
+ return s.pushChanges(changes)
+}
+
+// GenerateValues advances the simulated physics once per tick and pushes the
+// changed Matter attributes.
+func (s *Server) GenerateValues(simTime time.Time) {
+ if s.dt.Generate == nil {
+ return
+ }
+ s.mu.RLock()
+ running, bridgeUp := s.running, s.bridgeUp
+ s.mu.RUnlock()
+ if !running || !bridgeUp {
+ return
+ }
+ changes := s.dt.Generate(s, simTime)
+ if err := s.pushChanges(changes); err != nil {
+ s.addLog("error", map[string]interface{}{"op": "generate", "error": err.Error()})
+ }
+}
+
+func (s *Server) pushChanges(changes []AttrChange) error {
+ if len(changes) == 0 {
+ return nil
+ }
+ s.mu.RLock()
+ up := s.bridgeUp && s.running
+ s.mu.RUnlock()
+ if !up {
+ return nil
+ }
+ return s.mgr.setAttributes(s.nodeID, changes)
+}
+
+// handleEvent processes a push event routed from the bridge.
+func (s *Server) handleEvent(ev event) {
+ switch ev.Name {
+ case "commissioned", "decommissioned", "fabricsChanged":
+ var e struct {
+ FabricCount int `json:"fabricCount"`
+ }
+ _ = json.Unmarshal(ev.Raw, &e)
+ s.mu.Lock()
+ s.fabricCount = e.FabricCount
+ s.commissioned = e.FabricCount > 0
+ s.mu.Unlock()
+ s.addLog(ev.Name, map[string]interface{}{"fabric_count": e.FabricCount})
+ case "attributeChanged":
+ var e struct {
+ Cluster string `json:"cluster"`
+ Attribute string `json:"attribute"`
+ Value interface{} `json:"value"`
+ }
+ _ = json.Unmarshal(ev.Raw, &e)
+ // Reflect external control into physics so the Go simulation stays in
+ // sync (e.g. a Home app toggled the plug off).
+ if s.dt.OnExternal != nil {
+ s.dt.OnExternal(s, e.Cluster, e.Attribute, e.Value)
+ }
+ s.addLog("external_change", map[string]interface{}{
+ "cluster": e.Cluster, "attribute": e.Attribute, "value": e.Value,
+ })
+ }
+}
+
+func (s *Server) setBridgeUp(up bool) {
+ s.mu.Lock()
+ s.bridgeUp = up
+ s.mu.Unlock()
+}
+
+// --- DeviceServer metadata ---
+
+func (s *Server) GetLogs(limit int) []device.LogEntry {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ if limit <= 0 || limit > len(s.logs) {
+ limit = len(s.logs)
+ }
+ out := make([]device.LogEntry, limit)
+ copy(out, s.logs[len(s.logs)-limit:])
+ return out
+}
+
+func (s *Server) Protocol() device.Protocol { return device.ProtocolMatter }
+func (s *Server) Category() device.Category { return device.CategoryMatter }
+
+func (s *Server) Address() string {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ return fmt.Sprintf("matter://0.0.0.0:%d", s.port)
+}
+
+func (s *Server) SerialNumber() string {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ return s.serial
+}
+
+func (s *Server) SetSerialNumber(sn string) {
+ s.mu.Lock()
+ s.serial = sn
+ s.mu.Unlock()
+}
+
+// PairingCode / QRCode expose commissioning info to the app layer.
+func (s *Server) PairingCode() string {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ return s.pairingCode
+}
+
+func (s *Server) QRCode() string {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ return s.qrCode
+}
+
+func (s *Server) addLog(operation string, details map[string]interface{}) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.logs = append(s.logs, device.LogEntry{
+ Timestamp: time.Now(),
+ Operation: operation,
+ Details: details,
+ })
+ if len(s.logs) > s.maxLogs {
+ s.logs = s.logs[1:]
+ }
+}
+
+// --- physics state helpers (used by devicetypes.go) ---
+
+func (s *Server) getF(key string, def float64) float64 {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ if v, ok := s.phys[key]; ok {
+ return v
+ }
+ return def
+}
+
+func (s *Server) setF(key string, val float64) {
+ s.mu.Lock()
+ s.phys[key] = val
+ s.mu.Unlock()
+}
+
+func (s *Server) addF(key string, delta float64) float64 {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.phys[key] += delta
+ return s.phys[key]
+}
+
+func (s *Server) getBool(key string, def bool) bool {
+ v := s.getF(key, boolToF(def))
+ return v != 0
+}
+
+func (s *Server) setBool(key string, val bool) {
+ s.setF(key, boolToF(val))
+}
+
+func boolToF(b bool) float64 {
+ if b {
+ return 1
+ }
+ return 0
+}
+
+// derivePasscode/deriveDiscriminator produce stable, valid commissioning
+// credentials from the simulator id so pairing codes are unique per device and
+// stable across restarts. Passcode avoids the trivial/invalid values; the test
+// passcode 20202021 is the base.
+func derivePasscode(id int) int {
+ p := 20202021 + id*131
+ if p > 99999998 {
+ p = 1 + (p % 99999998)
+ }
+ return p
+}
+
+func deriveDiscriminator(id int) int {
+ return (0xF00 + id) & 0x0FFF
+}
diff --git a/internal/matter/storage.go b/internal/matter/storage.go
new file mode 100644
index 0000000..8bb747d
--- /dev/null
+++ b/internal/matter/storage.go
@@ -0,0 +1,64 @@
+package matter
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+// StorageRoot returns the base directory under which each Matter device keeps
+// its matter.js storage (fabric tables, credentials, passcode). Persisting this
+// means commissioning survives app restarts and bridge crashes.
+//
+// Resolution order mirrors internal/state so a single DATA_DIR controls both:
+// 1. MATTER_STORAGE_ROOT (explicit override)
+// 2. DEVICE_SIMULATOR_DATA_DIR/matter
+// 3. /Device Simulator/matter
+// 4. /device-simulator-matter (last resort)
+func StorageRoot() string {
+ if explicit := os.Getenv("MATTER_STORAGE_ROOT"); explicit != "" {
+ return explicit
+ }
+ if dir := os.Getenv("DEVICE_SIMULATOR_DATA_DIR"); dir != "" {
+ return filepath.Join(dir, "matter")
+ }
+ if configDir, err := os.UserConfigDir(); err == nil {
+ return filepath.Join(configDir, "Device Simulator", "matter")
+ }
+ return filepath.Join(os.TempDir(), "device-simulator-matter")
+}
+
+// deviceStorageDir returns (creating if needed) the per-device storage subdir.
+func deviceStorageDir(serial string) (string, error) {
+ dir := filepath.Join(StorageRoot(), sanitizeSegment(serial))
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ return "", err
+ }
+ return dir, nil
+}
+
+// removeDeviceStorage deletes a device's storage subdir (used on explicit
+// decommission/delete so a re-created device gets a fresh pairing).
+func removeDeviceStorage(serial string) error {
+ dir := filepath.Join(StorageRoot(), sanitizeSegment(serial))
+ return os.RemoveAll(dir)
+}
+
+// sanitizeSegment makes a string safe to use as a single path segment.
+func sanitizeSegment(s string) string {
+ if s == "" {
+ return "_"
+ }
+ replacer := func(r rune) rune {
+ switch {
+ case r >= 'a' && r <= 'z',
+ r >= 'A' && r <= 'Z',
+ r >= '0' && r <= '9',
+ r == '-', r == '_', r == '.':
+ return r
+ default:
+ return '_'
+ }
+ }
+ return strings.Map(replacer, s)
+}
diff --git a/internal/matter/types.go b/internal/matter/types.go
new file mode 100644
index 0000000..1cc1d1e
--- /dev/null
+++ b/internal/matter/types.go
@@ -0,0 +1,59 @@
+package matter
+
+// This package drives real, commissionable matter.js ServerNodes via a
+// supervised Node.js child process. The Go side is the source of truth for
+// device state and physics; the Node bridge is a thin Matter shim. See
+// internal/matter/bridge for the Node program and doc.go for the protocol.
+
+// DeviceConfig is the payload sent to the bridge to create a Matter ServerNode.
+// The Go side assigns NodeID, Passcode and Discriminator so that pairing codes
+// remain stable across bridge restarts / replays.
+type DeviceConfig struct {
+ NodeID string `json:"nodeId"`
+ Type string `json:"type"` // bridge device-type key, see devicetypes.go
+ Serial string `json:"serial"`
+ Port int `json:"port"`
+ StorageDir string `json:"storageDir"`
+ VendorID int `json:"vendorId"`
+ ProductID int `json:"productId"`
+ Passcode int `json:"passcode"`
+ Discriminator int `json:"discriminator"`
+ DeviceName string `json:"deviceName"`
+ Defaults map[string]float64 `json:"defaults,omitempty"`
+}
+
+// CommissioningInfo holds the pairing details returned by the bridge after a
+// ServerNode is created, plus its live commissioning status.
+type CommissioningInfo struct {
+ NodeID string `json:"nodeId"`
+ ManualPairingCode string `json:"manualPairingCode"`
+ QRPairingCode string `json:"qrPairingCode"` // "MT:..." payload, render as QR client-side
+ Port int `json:"port"`
+ Commissioned bool `json:"commissioned"`
+ FabricCount int `json:"fabricCount"`
+}
+
+// AttrChange is a single Matter attribute write pushed from Go to the bridge.
+type AttrChange struct {
+ Cluster string `json:"cluster"`
+ Attribute string `json:"attribute"`
+ Value interface{} `json:"value"`
+}
+
+// ErrNodeUnavailable is returned when Node.js or the matter.js bridge cannot be
+// used, so callers (createSimulator, the HTTP/MCP layer) can degrade gracefully
+// instead of crashing.
+type ErrNodeUnavailable struct {
+ Reason string
+}
+
+func (e *ErrNodeUnavailable) Error() string {
+ return "matter unavailable: " + e.Reason
+}
+
+// Default Matter identity values. Vendor 0xFFF1 (65521) and product 0x8000 are
+// the CSA test vendor/product IDs, appropriate for a simulator.
+const (
+ DefaultVendorID = 0xFFF1
+ DefaultProductID = 0x8000
+)
diff --git a/internal/mcp/server.go b/internal/mcp/server.go
index 5bb7491..6dfb787 100644
--- a/internal/mcp/server.go
+++ b/internal/mcp/server.go
@@ -359,8 +359,8 @@ func buildToolSpecs() ([]Tool, map[string]ToolSpec) {
Description: "Create a simulator",
InputSchema: schemaObject(map[string]interface{}{
"type": map[string]interface{}{
- "type": "string",
- "description": "Simulator type (inverter, energy_meter, v2x_charger, ocpp_charger)",
+ "type": "string",
+ "description": "Simulator type: inverter, energy_meter, v2x_charger, ocpp_charger, or a Matter device (matter_tempsensor, matter_lightsensor, matter_smartplug, matter_evse, matter_thermostat, matter_heatpump, matter_dishwasher, matter_laundrywasher). Matter types require Node.js on the host.",
},
}, []string{"type"}),
},
@@ -397,10 +397,10 @@ func buildToolSpecs() ([]Tool, map[string]ToolSpec) {
Name: "simulators.update_config",
Description: "Update simulator config",
InputSchema: schemaObject(map[string]interface{}{
- "id": schemaInteger("Simulator ID"),
- "slave_id": schemaInteger("Modbus slave ID"),
- "serial": schemaString("Simulator serial"),
- "ocpp_url": schemaString("OCPP URL"),
+ "id": schemaInteger("Simulator ID"),
+ "slave_id": schemaInteger("Modbus slave ID"),
+ "serial": schemaString("Simulator serial"),
+ "ocpp_url": schemaString("OCPP URL"),
"battery_capacity_kwh": schemaNumber("Battery capacity in kWh"),
}, []string{"id"}),
},
@@ -519,10 +519,10 @@ func buildToolSpecs() ([]Tool, map[string]ToolSpec) {
Name: "simulators.automation.config",
Description: "Configure simulator automation",
InputSchema: schemaObject(map[string]interface{}{
- "id": schemaInteger("Simulator ID"),
- "time_multiplier": schemaNumber("Time multiplier"),
- "scenario": schemaString("Scenario"),
- "profile": schemaString("Profile"),
+ "id": schemaInteger("Simulator ID"),
+ "time_multiplier": schemaNumber("Time multiplier"),
+ "scenario": schemaString("Scenario"),
+ "profile": schemaString("Profile"),
"update_interval_ms": schemaInteger("Update interval ms"),
}, []string{"id"}),
},
@@ -686,11 +686,11 @@ func buildToolSpecs() ([]Tool, map[string]ToolSpec) {
Name: "sites.update_config",
Description: "Update site config",
InputSchema: schemaObject(map[string]interface{}{
- "id": schemaInteger("Site ID"),
- "name": schemaString("Site name"),
- "base_load_w": schemaNumber("Base load in watts"),
- "fuse_size": schemaInteger("Fuse size"),
- "fuse_profile": schemaString("Fuse profile"),
+ "id": schemaInteger("Site ID"),
+ "name": schemaString("Site name"),
+ "base_load_w": schemaNumber("Base load in watts"),
+ "fuse_size": schemaInteger("Fuse size"),
+ "fuse_profile": schemaString("Fuse profile"),
"phase_factors": schemaObject(nil, nil),
"fuse_blown": schemaObject(nil, nil),
}, []string{"id"}),
@@ -911,6 +911,15 @@ func buildToolSpecs() ([]Tool, map[string]ToolSpec) {
Method: http.MethodGet,
PathTemplate: "/api/version",
},
+ {
+ Tool: Tool{
+ Name: "matter.status",
+ Description: "Get Matter availability (requires Node.js on the host)",
+ InputSchema: schemaObject(nil, nil),
+ },
+ Method: http.MethodGet,
+ PathTemplate: "/api/matter/status",
+ },
{
Tool: Tool{
Name: "settings.get",
@@ -1055,7 +1064,7 @@ func schemaBoolean(description string) map[string]interface{} {
func schemaArray(description string, itemType string) map[string]interface{} {
schema := map[string]interface{}{
- "type": "array",
+ "type": "array",
"items": map[string]interface{}{"type": itemType},
}
if description != "" {
diff --git a/internal/ports/allocator.go b/internal/ports/allocator.go
index 6618c04..4587c1d 100644
--- a/internal/ports/allocator.go
+++ b/internal/ports/allocator.go
@@ -13,6 +13,7 @@ const (
ProtocolModbus Protocol = "modbus"
ProtocolMQTT Protocol = "mqtt"
ProtocolOCPP Protocol = "ocpp"
+ ProtocolMatter Protocol = "matter"
)
// Allocator manages dynamic port allocation for simulators
@@ -21,15 +22,17 @@ type Allocator struct {
modbusBase int
mqttBase int
ocppBase int
+ matterBase int
allocatedPorts map[int]bool
}
// NewAllocator creates a new port allocator with the given base ports
-func NewAllocator(modbusBase, mqttBase, ocppBase int) *Allocator {
+func NewAllocator(modbusBase, mqttBase, ocppBase, matterBase int) *Allocator {
return &Allocator{
modbusBase: modbusBase,
mqttBase: mqttBase,
ocppBase: ocppBase,
+ matterBase: matterBase,
allocatedPorts: make(map[int]bool),
}
}
@@ -48,6 +51,8 @@ func (a *Allocator) Allocate(protocol Protocol) int {
base = a.mqttBase
case ProtocolOCPP:
base = a.ocppBase
+ case ProtocolMatter:
+ base = a.matterBase
default:
return 0
}
@@ -95,6 +100,8 @@ func (a *Allocator) AllocateAvailable(protocol Protocol) int {
base = a.mqttBase
case ProtocolOCPP:
base = a.ocppBase
+ case ProtocolMatter:
+ base = a.matterBase
default:
return 0
}
@@ -145,8 +152,8 @@ func (a *Allocator) AllocatedCount() int {
}
// GetBasePorts returns the configured base ports for each protocol
-func (a *Allocator) GetBasePorts() (modbus, mqtt, ocpp int) {
- return a.modbusBase, a.mqttBase, a.ocppBase
+func (a *Allocator) GetBasePorts() (modbus, mqtt, ocpp, matter int) {
+ return a.modbusBase, a.mqttBase, a.ocppBase, a.matterBase
}
func isPortAvailable(port int) bool {
diff --git a/internal/ports/allocator_test.go b/internal/ports/allocator_test.go
index 241e276..1d07418 100644
--- a/internal/ports/allocator_test.go
+++ b/internal/ports/allocator_test.go
@@ -6,9 +6,9 @@ import (
)
func TestNewAllocator(t *testing.T) {
- a := NewAllocator(5000, 1883, 9000)
+ a := NewAllocator(5000, 1883, 9000, 5540)
- modbus, mqtt, ocpp := a.GetBasePorts()
+ modbus, mqtt, ocpp, matter := a.GetBasePorts()
if modbus != 5000 {
t.Errorf("Expected modbus base 5000, got %d", modbus)
}
@@ -18,10 +18,13 @@ func TestNewAllocator(t *testing.T) {
if ocpp != 9000 {
t.Errorf("Expected ocpp base 9000, got %d", ocpp)
}
+ if matter != 5540 {
+ t.Errorf("Expected matter base 5540, got %d", matter)
+ }
}
func TestAllocateModbus(t *testing.T) {
- a := NewAllocator(5000, 1883, 9000)
+ a := NewAllocator(5000, 1883, 9000, 5540)
// First allocation should return base port
port := a.Allocate(ProtocolModbus)
@@ -43,7 +46,7 @@ func TestAllocateModbus(t *testing.T) {
}
func TestAllocateMQTT(t *testing.T) {
- a := NewAllocator(5000, 1883, 9000)
+ a := NewAllocator(5000, 1883, 9000, 5540)
port := a.Allocate(ProtocolMQTT)
if port != 1883 {
@@ -57,7 +60,7 @@ func TestAllocateMQTT(t *testing.T) {
}
func TestAllocateOCPP(t *testing.T) {
- a := NewAllocator(5000, 1883, 9000)
+ a := NewAllocator(5000, 1883, 9000, 5540)
port := a.Allocate(ProtocolOCPP)
if port != 9000 {
@@ -71,7 +74,7 @@ func TestAllocateOCPP(t *testing.T) {
}
func TestAllocateUnknownProtocol(t *testing.T) {
- a := NewAllocator(5000, 1883, 9000)
+ a := NewAllocator(5000, 1883, 9000, 5540)
port := a.Allocate("unknown")
if port != 0 {
@@ -80,7 +83,7 @@ func TestAllocateUnknownProtocol(t *testing.T) {
}
func TestAllocateString(t *testing.T) {
- a := NewAllocator(5000, 1883, 9000)
+ a := NewAllocator(5000, 1883, 9000, 5540)
// Test string-based allocation (backward compatibility)
port := a.AllocateString("modbus")
@@ -100,7 +103,7 @@ func TestAllocateString(t *testing.T) {
}
func TestRelease(t *testing.T) {
- a := NewAllocator(5000, 1883, 9000)
+ a := NewAllocator(5000, 1883, 9000, 5540)
// Allocate a port
port1 := a.Allocate(ProtocolModbus)
@@ -125,7 +128,7 @@ func TestRelease(t *testing.T) {
}
func TestIsAllocated(t *testing.T) {
- a := NewAllocator(5000, 1883, 9000)
+ a := NewAllocator(5000, 1883, 9000, 5540)
// Initially not allocated
if a.IsAllocated(5000) {
@@ -150,7 +153,7 @@ func TestIsAllocated(t *testing.T) {
}
func TestAllocatedCount(t *testing.T) {
- a := NewAllocator(5000, 1883, 9000)
+ a := NewAllocator(5000, 1883, 9000, 5540)
if a.AllocatedCount() != 0 {
t.Errorf("Expected 0 allocated ports initially, got %d", a.AllocatedCount())
@@ -174,7 +177,7 @@ func TestAllocatedCount(t *testing.T) {
}
func TestMixedProtocolAllocation(t *testing.T) {
- a := NewAllocator(5000, 1883, 9000)
+ a := NewAllocator(5000, 1883, 9000, 5540)
// Allocate ports from different protocols
modbus1 := a.Allocate(ProtocolModbus)
@@ -200,7 +203,7 @@ func TestMixedProtocolAllocation(t *testing.T) {
}
func TestConcurrentAllocation(t *testing.T) {
- a := NewAllocator(5000, 1883, 9000)
+ a := NewAllocator(5000, 1883, 9000, 5540)
var wg sync.WaitGroup
allocatedPorts := make(chan int, 100)
@@ -245,7 +248,7 @@ func TestConcurrentAllocation(t *testing.T) {
}
func TestReleaseNonExistentPort(t *testing.T) {
- a := NewAllocator(5000, 1883, 9000)
+ a := NewAllocator(5000, 1883, 9000, 5540)
// Should not panic or error
a.Release(9999)
@@ -261,7 +264,7 @@ func TestReleaseNonExistentPort(t *testing.T) {
func TestCustomBasePorts(t *testing.T) {
// Test with non-standard base ports
- a := NewAllocator(10000, 20000, 30000)
+ a := NewAllocator(10000, 20000, 30000, 40000)
modbus := a.Allocate(ProtocolModbus)
mqtt := a.Allocate(ProtocolMQTT)
diff --git a/internal/settings/settings.go b/internal/settings/settings.go
index 1b7c496..52471dc 100644
--- a/internal/settings/settings.go
+++ b/internal/settings/settings.go
@@ -12,6 +12,7 @@ type AppSettings struct {
ModbusBasePort int `json:"modbusBasePort"`
MQTTBasePort int `json:"mqttBasePort"`
OCPPBasePort int `json:"ocppBasePort"`
+ MatterBasePort int `json:"matterBasePort"`
ModbusDisplayOffset int `json:"modbusDisplayOffset"`
MQTTDisplayOffset int `json:"mqttDisplayOffset"`
OCPPDisplayOffset int `json:"ocppDisplayOffset"`
@@ -24,6 +25,7 @@ func DefaultSettings() *AppSettings {
ModbusBasePort: 5000,
MQTTBasePort: 1883,
OCPPBasePort: 9000,
+ MatterBasePort: 5540,
ModbusDisplayOffset: 0,
MQTTDisplayOffset: 0,
OCPPDisplayOffset: 0,
diff --git a/internal/state/state.go b/internal/state/state.go
index 74884db..17e8d19 100644
--- a/internal/state/state.go
+++ b/internal/state/state.go
@@ -29,6 +29,7 @@ type SimulatorState struct {
BatteryCapacityKWh float64 `json:"battery_capacity_kwh"`
Profile string `json:"profile"`
OCPPURL string `json:"ocpp_url"`
+ MatterType string `json:"matter_type,omitempty"`
Automation AutomationState `json:"automation"`
}