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).
+
+
+

Matter devices (matter.js)

+
+

+ 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
+ + +
+ 🏠 Matter (Smart Home) + + +
+
+ +
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"` }