- Python 3.12+
- uv (
brew install uvon macOS)
# Clone and enter the repo
git clone https://github.com/SpanPanel/simulator.git
cd simulator
# Create venv and install all dependencies (runtime + dev)
# uv reads pyproject.toml and uv.lock, creates .venv/ automatically
uv sync --group dev
# Install pre-commit hooks
uv run pre-commit installThat's it. The .venv/ directory is created in the project root and uv.lock pins exact versions for reproducible installs.
# Run the simulator
uv run span-simulator
# Run tests
uv run pytest
# Lint + format
uv run ruff check --fix src/ tests/
uv run ruff format src/ tests/
# Type check
uv run mypy --strict src/span_panel_simulator/
# Add a new dependency
uv add <package> # runtime
uv add --group dev <pkg> # dev onlyEvery commit is validated by:
| Hook | What it checks |
|---|---|
| ruff | Lint rules (E, F, W, I, UP, B, SIM, TCH, RUF) with auto-fix |
| ruff-format | Consistent formatting |
| mypy --strict | Full strict type checking across all source files |
| trailing-whitespace | No trailing whitespace |
| end-of-file-fixer | Files end with a newline |
| check-yaml | Valid YAML syntax |
| check-added-large-files | Prevents accidental large file commits |
There are three ways to run the simulator locally, depending on what you're testing:
Runs directly on the host with full mDNS visibility. Best for iterating on simulator code and testing integration discovery.
./scripts/run-local.shTo connect to a Home Assistant instance for entity discovery and recorder statistics (needed for profile import, cost modeling, and the data acquisition layer), pass your HA credentials:
./scripts/run-local.sh --ha-url http://192.168.1.10:8123 --ha-token YOUR_LONG_LIVED_TOKENOr via environment variables:
export HA_URL=http://192.168.1.10:8123
export HA_TOKEN=YOUR_LONG_LIVED_TOKEN
./scripts/run-local.shWhen running as an HA add-on, the Supervisor injects SUPERVISOR_TOKEN automatically and these are not needed. See ha_api/client.py for the dual-mode
detection logic.
Builds and runs the same image that CI pushes to GHCR. Useful for verifying the container works before pushing. mDNS auto-discovery won't work on macOS (VM networking), but the integration can connect by manual configuration. On Linux, Docker with host networking works.
# Build (use aarch64 base on Apple Silicon, amd64 on Intel/Linux)
docker build -f span_panel_simulator/Dockerfile \
--build-arg BUILD_FROM=ghcr.io/home-assistant/aarch64-base-python:3.13-alpine3.21 \
-t span-panel-simulator:local .
# Run
mkdir -p .local/addon-test
cat > .local/addon-test/options.json <<'EOF'
{
"config_file": "span_simulator/default_config.yaml",
"tick_interval": 1.0,
"log_level": "INFO",
"advertise_address": "",
"dashboard_enabled": true
}
EOF
docker run --rm \
-p 18883:18883 -p 8081:8081 -p 18080:18080 \
-v $(pwd)/configs:/config/span_simulator \
-v $(pwd)/.local/addon-test:/data \
span-panel-simulator:localRequires a Home Assistant instance running HA OS or a supervised install. Add the repo URL as a custom repository and install from the Add-on Store. This is the only way to test the full add-on lifecycle.
The simulator can load multiple configs, but each panel shares the same host IP and HTTP server. Since a real SPAN panel has its own IP, the integration's discovery flow deduplicates panels that resolve to the same address.
For true multi-panel simulation, assign separate IPs to the host:
# macOS — add an alias IP
sudo ifconfig en0 alias 192.168.7.27 255.255.255.0
# Run one simulator per IP
ADVERTISE_ADDRESS=192.168.7.26 CONFIG_DIR=./configs/panel1 ./scripts/run-local.sh
ADVERTISE_ADDRESS=192.168.7.27 CONFIG_DIR=./configs/panel2 ./scripts/run-local.shpanel_config:
serial_number: str # Unique panel serial (e.g., "SPAN-SIM-001")
total_tabs: int # Breaker tab count (8, 32, 64)
main_size: int # Main breaker amps (100, 150, 200)
latitude: float # Degrees north (default: 37.7)
longitude: float # Degrees east (default: -122.4)
time_zone: str # IANA timezone (default: resolved from lat/lon)
soc_shed_threshold: float # SOC % for SOC_THRESHOLD shedding (default: 20)
circuit_templates: # Reusable template definitions
template_name:
energy_profile:
mode: str # "consumer" | "producer" | "bidirectional"
power_range: [min, max] # Watts (negative = production)
typical_power: float # Base power in watts
power_variation: float # Fraction (0.1 = +/-10%)
efficiency: float # 0.0-1.0 (optional, PV/battery)
nameplate_capacity_w: float # PV nameplate rating in watts
initial_consumed_energy_wh: float # Seed consumed energy (from clone)
initial_produced_energy_wh: float # Seed produced energy (from clone)
relay_behavior: str # "controllable" | "non_controllable"
priority: str # "NEVER" | "SOC_THRESHOLD" | "OFF_GRID"
device_type: str # "circuit" | "evse" | "pv" (default: "circuit")
breaker_rating: int # Amps (derived from power_range if not set)
# Optional behavioral modules
cycling_pattern:
on_duration: int # Seconds on (explicit mode)
off_duration: int # Seconds off (explicit mode)
duty_cycle: float # 0.0-1.0 — fraction of cycle spent on (from HA stats)
period: int # Total cycle length in seconds (default: 2700)
hvac_type: str # "central_ac" | "heat_pump" | "heat_pump_aux"
monthly_factors: # Month (1-12) -> multiplier (1.0 = peak month)
1: 0.6 # Takes precedence over hvac_type seasonal model
7: 1.0
time_of_day_profile:
enabled: bool
peak_hours: [int] # Hours 0-23
hour_factors: # Per-hour multiplier (0.0-1.0)
0: 1.0
6: 0.0
18: 1.0
hourly_multipliers: # Alternate per-hour override
6: 0.1
12: 1.0
smart_behavior:
responds_to_grid: bool
max_power_reduction: float # 0.0-1.0
battery_behavior:
enabled: bool
charge_mode: str # "custom" | "solar-gen" | "solar-excess"
nameplate_capacity_kwh: float # Total battery capacity (default: 13.5)
backup_reserve_pct: float # Normal discharge floor % (default: 20)
charge_efficiency: float # 0.0-1.0 (default: 0.95)
discharge_efficiency: float # 0.0-1.0 (default: 0.95)
charge_power: float
discharge_power: float
idle_power: float
max_charge_power: float # Used by solar-gen/solar-excess modes
max_discharge_power: float
charge_hours: [int]
discharge_hours: [int]
idle_hours: [int]
circuits:
- id: str # Unique identifier
name: str # Human-readable name
template: str # References a circuit_templates key
tabs: [int] # Tab positions ([1] = 120V, [1, 3] = 240V)
breaker_rating: int # Per-circuit override (optional)
overrides: # Override any template field
typical_power: 500.0
unmapped_tabs: [int] # Tab numbers with no circuit assigned
simulation_params:
update_interval: int # Seconds between snapshots (default: 5)
time_acceleration: float # 1.0 = real-time, 2.0 = double speed
noise_factor: float # Random noise fraction (0.02 = +/-2%)
enable_realistic_behaviors: bool
# Clone provenance (written by the clone pipeline)
panel_source:
origin_serial: str # Real panel's serial (immutable)
host: str # IP or hostname of source panel
passphrase: str | null # Proximity code (null for door-bypass)
last_synced: str # ISO 8601 timestampCircuit shed priority controls backup behavior when the grid disconnects, matching the Homie v2 schema (shed-priority property):
| Priority | Behavior |
|---|---|
NEVER |
Never shed — stays on as long as battery has power |
OFF_GRID |
Shed immediately when dominant power source leaves GRID |
SOC_THRESHOLD |
Shed when battery SOC drops below soc_shed_threshold |
The soc_shed_threshold in panel_config (default 20%) sets the SOC percentage that triggers shedding for SOC_THRESHOLD circuits.
User relay overrides (from dashboard or MQTT) take precedence over shedding — if a user closes a shed relay, shedding will not reopen it.
These endpoints match the real SPAN panel's API exactly.
| Method | Path | Description |
|---|---|---|
GET |
/api/v2/status |
Panel identity (serialNumber, firmwareVersion) |
POST |
/api/v2/auth/register |
Returns MQTT credentials and broker details |
GET |
/api/v2/certificate/ca |
Self-signed CA certificate (PEM) |
GET |
/api/v2/homie/schema |
Homie v5 property schema |
Query /api/v2/status?serial=XXX to target a specific panel when multiple are loaded.
| Method | Path | Description |
|---|---|---|
POST |
/admin/reload |
Hot-reload configs (add/remove/update panels) |
GET |
/admin/panels |
List all running panels |
# Reload after editing configs
curl -X POST http://192.168.7.26:8081/admin/reload
# List panels
curl http://192.168.7.26:8081/admin/panelsThe simulator publishes Homie v5 messages on the eBus topic namespace:
ebus/5/{serial}/{node}/{property}
| Node | Description |
|---|---|
core |
Panel state: door, relay, voltages, grid status, dominant power source |
upstream-lugs |
Grid-facing: power, currents, energy |
downstream-lugs |
Load-facing: feedthrough power, currents |
{circuit-uuid} |
Per-circuit: relay, power, energy, shed-priority |
bess-0 |
Battery: SOC, grid-state, capacity |
pv-0 |
Solar inverter: nameplate capacity |
evse-0 |
EV charger: status, lock state, advertised current |
power-flows |
Aggregated: PV, battery, grid, site power |
Control circuits by publishing to /set topics:
# Open a circuit relay
mosquitto_pub -t "ebus/5/SPAN-TEST-001/{uuid}/relay/set" -m "OPEN"
# Change shed priority
mosquitto_pub -t "ebus/5/SPAN-TEST-001/{uuid}/shed-priority/set" -m "OFF_GRID"
# Change dominant power source (triggers load shedding)
mosquitto_pub -t "ebus/5/SPAN-TEST-001/core/dominant-power-source/set" -m "BATTERY"Relay and priority changes made via MQTT are reflected in the dashboard in real time.
The simulator can clone a real SPAN panel's configuration over its eBus. Cloning is initiated from the dashboard's Clone panel form. The simulator authenticates with the target panel, scrapes its MQTT topics, translates the eBus description into a simulator YAML config, and hot-reloads.
- Panel identity (
sim-{serial}-clone), main breaker rating, panel size - All circuits: name, tab position, breaker rating, relay behavior, priority
- Energy profile mode inferred from device feeds (PV -> producer, BESS -> bidirectional, EVSE -> bidirectional)
- Energy accumulators seeded from the panel's imported/exported energy values
- Battery behavior with sensible schedule defaults
- PV nameplate capacity and production profile
- EVSE night-charging time-of-day profile
- Source panel credentials stored in
panel_sourcefor on-demand refresh
When connected to Home Assistant, the simulator can derive per-circuit usage profiles from the HA recorder's long-term statistics. This replaces the clone's point-in-time power readings with patterns grounded in actual household behavior:
- typical_power -- mean of hourly means over 30 days
- power_variation -- coefficient of variation (stddev/mean)
- hour_factors -- 24-hour shape normalized to peak = 1.0
- duty_cycle -- mean/max ratio (skipped if >= 0.8)
- monthly_factors -- 12-month seasonality (requires 3+ months of data)
The cloned config is a faithful starting point. Behavioral tuning (profiles, cycling patterns, smart behavior) can be adjusted via the dashboard after cloning.
- Apply relay and priority overrides (immediate effect)
- Check relay state (open = 0W)
- Apply base power from
typical_powerwithpower_variationrandomness - Producers: geographic sine-curve solar model with weather degradation
- Consumers: modulate by time-of-day profile / hour factors (if configured)
- HVAC seasonal modulation (latitude-aware temperature model scales power by season)
- Apply cycling pattern on/off state (if configured)
- Apply battery charge/discharge schedule or solar charge mode (if configured)
- Apply smart grid response (if configured)
- Add noise (
noise_factor) - Apply load shedding overlays (if grid offline with battery)
The engine separates two distinct power measurements:
- Site power = net demand at the panel lugs (loads - solar), independent of any upstream BESS. This is what the SPAN panel's feedthrough CTs measure.
- Grid power = what the utility meter sees (loads - solar +/- battery). When a BESS is present upstream, battery discharge offsets grid import and battery charge increases it.
See SPAN API Client Docs for the panel's physical topology.
PV circuits use a geographic sine-based model:
- Sunrise/sunset computed from latitude, longitude, and date
- Solar elevation angle determines instantaneous production factor
- Daily weather degradation from Open-Meteo historical cloud cover data
- Falls back to deterministic seed-based weather when no API data available
Circuits with hvac_type set automatically adjust power draw by season using a latitude-aware sinusoidal temperature model:
| HVAC Type | Summer | Winter | Why |
|---|---|---|---|
central_ac |
Full compressor (~100%) | Blower fan only (~15%) | Gas furnace handles heating |
heat_pump |
Full compressor (~100%) | COP reduces draw (~45%) | Heat pump efficiency in cold |
heat_pump_aux |
Full compressor (~100%) | Aux strips exceed cooling (~140%) | Resistive backup below ~35F |
The Battery Storage Energy Equipment tracks real state-of-energy by integrating power over time:
- Charging:
SOE += power * dt * charge_efficiency - Discharging:
SOE -= power * dt / discharge_efficiency - Backup reserve: Normal discharge stops at
backup_reserve_pct(default 20%); only grid-disconnect emergencies drain to the 5% hard floor - Charge modes: Custom (hour-based schedule), Solar Generation (tracks PV curve), Solar Excess (surplus after loads)
Energy integrates over time in watt-hours:
delta_energy = power_watts * delta_seconds / 3600
Consumed and produced energy are tracked separately per circuit.
Only changed property values are republished each tick. Unchanged values are not retransmitted.
The span_panel_simulator/ directory must match the slug field in config.yaml. The HA Supervisor uses the directory name to identify the add-on —
renaming it will break discovery. If you need to change the slug, update both the directory name and the slug field together.
The GitHub Actions workflow (.github/workflows/build-addon.yaml) builds the Docker image from the repo root as the build context (not from the add-on
subdirectory). This is necessary because the Dockerfile needs access to pyproject.toml, src/, and mosquitto/ which live at the repo root.
The HA Supervisor would normally build from the add-on subdirectory, which can't reach parent files — that's why we use the image: field to pull pre-built
images instead.
The workflow:
- Triggers on pushes to
mainthat touch source, config, or workflow files - Builds per-architecture images (amd64, aarch64) using the appropriate HA base image
- Pushes to
ghcr.io/SpanPanel/simulator/{arch}:{version}
Certificates are generated automatically on first run and cached in the cert directory. The server certificate SAN includes:
span-simulator(hostname)localhost127.0.0.1- The
ADVERTISE_ADDRESSIP (if set)
If the host IP changes, certificates are automatically regenerated on the next startup. Delete .local/certs/ to force regeneration.
simulator/
repository.yaml # HA app repository metadata (current YAML format)
repository.json # Legacy HA add-on repository metadata (JSON compatibility file)
configs/ # Panel YAML configurations
span_panel_simulator/ # HA add-on (dir name must match slug)
config.yaml # Add-on metadata, image ref, options schema
build.yaml # Per-architecture base images
Dockerfile # Used by CI (build context is repo root)
run.sh # Container entry point
DOCS.md # User-facing add-on documentation
translations/en.yaml # Option labels for HA UI
.github/workflows/
build-addon.yaml # CI: build and push images to GHCR
docs/images/ # Screenshots
scripts/
run-local.sh # macOS native (recommended)
entrypoint.sh # Docker entrypoint (Linux)
src/span_panel_simulator/
__main__.py # CLI and entry point
app.py # Multi-panel orchestrator
panel.py # Single panel lifecycle
engine.py # Power/energy simulation
circuit.py # Per-circuit state and snapshot
clock.py # Simulation clock with acceleration
bsee.py # Battery storage equipment (BESS/GFE)
hvac.py # Seasonal HVAC power modulation
solar.py # Geographic sine-curve solar model
weather.py # Open-Meteo historical weather
publisher.py # Homie MQTT publisher (with diffing)
bootstrap.py # HTTP API server
discovery.py # mDNS advertisement
certs.py # TLS certificate generation
models.py # Snapshot dataclasses
config_types.py # YAML schema TypedDicts
recorder.py # HA recorder data source and replay
ha_api/ # Home Assistant API client (dual-mode)
client.py # REST API client (Supervisor or local)
entity_discovery.py # SPAN device -> circuit entity mapping
profile_builder.py # Recorder stats -> usage profiles
dashboard/ # Web dashboard (port 18080)
routes.py # HTMX route handlers
config_store.py # In-memory config state
presets.py # Profile and schedule presets
defaults.py # Entity type defaults
solar.py # Solar curve computation
templates/ # Jinja2 templates
static/ # CSS, JS (htmx, Chart.js, noUiSlider)
.local/ # Runtime state (gitignored)
certs/ # Generated TLS certificates
mosquitto/ # Mosquitto config and passwd
pids/ # Process ID files