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
26 changes: 25 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,31 @@ 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_ROOT | 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. Device physics live in Go (`internal/matter/devicetypes.go`) and run in the
per-simulator automation loop (`POST /api/simulators/{id}/automation/start`); devices are
controlled via `POST /api/simulators/{id}/state` with `{key, value}` (e.g. `on`,
`load_w`, `charging`, `heating_setpoint_c`, `start`). Full user guide: `docs/MATTER.md`.
`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
35 changes: 32 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

> *"In the beginning the Universe was created. This has made a lot of people very angry and been widely regarded as a bad move."* - But Modbus? Modbus is mostly harmless.

A **Mostly Harmless** multi-protocol device simulator for testing solar inverters, batteries, EV chargers, and energy systems. Don't Panic - it's just registers.
A **Mostly Harmless** multi-protocol device simulator for testing solar inverters, batteries, EV chargers, energy systems — and now commissionable **Matter** smart-home devices. Don't Panic - it's just registers.

## Quick Start

Expand Down Expand Up @@ -50,13 +50,17 @@ docker compose up --build
| 5000+ | ✓ | ✓ | Modbus TCP servers |
| 1883+ | ✓ | ✓ | MQTT brokers (V2X chargers) |
| 9000+ | ✓ | ✓ | OCPP WebSocket servers |
| 5540+ (UDP) | ✓* | ✓ | Matter devices (matter.js) |

- **Docker:** Open http://localhost
- **Desktop:** The app opens automatically with a native window

\* Matter commissioning needs LAN multicast (mDNS) — use Docker *host* networking or the desktop app for pairing.

## Features

- **Multi-Protocol Support** - Modbus TCP, MQTT, and OCPP in one app
- **Multi-Protocol Support** - Modbus TCP, MQTT, OCPP, and Matter in one app
- **Matter Devices** - Commissionable sensors, smart plugs, EVSE, thermostats, heat pumps, and appliances via [matter.js](https://github.com/matter-js/matter.js) — pair them into Home Assistant and control them like real hardware ([details](docs/MATTER.md))
- **Desktop & Server Modes** - Native app or Docker container
- **Auto-Updates** - Desktop app updates itself from GitHub Releases
- **Dynamic Simulators** - Create/delete simulators on the fly
Expand All @@ -83,14 +87,34 @@ docker compose up --build

> **Disclaimer:** Device Simulator is an independent testing tool and is **not affiliated with, endorsed by, or sponsored by** any of the manufacturers listed above. Product and company names are trademarks of their respective owners and are used only to identify the device behavior being simulated.

## Matter Devices

The simulator emulates **real, commissionable Matter devices** using
[matter.js](https://github.com/matter-js/matter.js): temperature and light sensors,
smart plugs and EVSE chargers with energy metering, thermostats, heat pumps,
dishwashers, and washing machines. Each device gets its own pairing code and QR, can
be paired into Home Assistant / Apple Home / Google Home, reports live measurements
and accumulating energy while its built-in simulation runs, and reacts to controller
commands (toggles, setpoints) with the changes reflected back into the simulation.

Requires **Node.js v18+ on the host** (matter.js itself is embedded — no npm needed);
without Node, Matter is simply disabled and everything else works. Devices use the CSA
*test* Vendor ID, so enable Home Assistant's **Test-Net DCL** option to pair.

**→ Full guide: [docs/MATTER.md](docs/MATTER.md)** — device types, control API,
built-in behavior, pairing, and architecture.

## API Endpoints

All simulators are accessible through a unified API:

```
GET /api/simulators # List all simulators
POST /api/simulators # Create simulator (Modbus/MQTT/OCPP/Matter types)
GET /api/simulators/{id} # Get simulator details
POST /api/simulators/{id}/config # Update serial/slave ID
POST /api/simulators/{id}/state # Control device state {key, value} (see docs/MATTER.md)
GET /api/matter/status # Matter availability (Node.js detection)
GET /api/simulators/{id}/registers # Get all registers
POST /api/simulators/{id}/registers/{addr} # Write register value
GET /api/simulators/{id}/logs # Get Modbus operation logs
Expand All @@ -109,6 +133,10 @@ GET /health # Health check (always 42... I mean
|---------------------|---------|-------------|
| NUM_INSTANCES | 10 | Number of simulator instances |
| MODBUS_BASE_PORT | 5000 | First Modbus port |
| MATTER_BASE_PORT | 5540 | First Matter (UDP) operational port |
| MATTER_NODE_PATH | auto-detected | Path to the `node` binary for Matter |
| MATTER_STORAGE_ROOT | OS config dir | Matter fabric/credential storage root |
| MATTER_LOG_LEVEL | notice | Matter bridge log level |
| HTTP_PORT | 80 | Web dashboard port |

## Testing Modbus Connections
Expand Down Expand Up @@ -186,7 +214,8 @@ device-simulator (single process)
├── HTTP API Server (:80 Docker, :8762 Desktop)
├── Modbus TCP Servers (:5000+)
├── MQTT Brokers (:1883+)
└── OCPP WebSocket Servers (:9000+)
├── OCPP WebSocket Servers (:9000+)
└── Matter Bridge (supervised Node.js child, matter.js, UDP :5540+)
```

**Dual-Mode Operation:**
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