Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -177,3 +177,7 @@ data/state.json

# Go binaries
/simulator

# Windows bash crash dumps
bash.exe.stackdump
*.stackdump
22 changes: 21 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ make build-linux
- Modbus TCP: 5000+
- MQTT: 1883+
- OCPP: 9000+
- Matter (UDP): 5540+

### Run Tests

Expand Down Expand Up @@ -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
Expand All @@ -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
```
Expand Down Expand Up @@ -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)

Expand Down
14 changes: 13 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -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 "")
Expand Down Expand Up @@ -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..."
Expand Down
50 changes: 41 additions & 9 deletions cmd/simulator/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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 {
Expand Down Expand Up @@ -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
}

Expand Down
Loading