- Python 98.1%
- Dockerfile 1%
- Shell 0.9%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| docs | ||
| src/shelly_emulator | ||
| .dockerignore | ||
| .gitignore | ||
| build.sh | ||
| build_multiarch.sh | ||
| CLAUDE.md | ||
| docker-compose.example.yml | ||
| Dockerfile | ||
| pyproject.toml | ||
| README.md | ||
| requirements.txt | ||
| uv.lock | ||
Shelly Emulator for Solakon One Inverters
A smart power distribution system that emulates a Shelly Pro 3EM energy meter to coordinate multiple Solakon One hybrid inverters for zero-feed-in solar operation.
Overview
When multiple Solakon One inverters share a single smart meter, they all receive the same power consumption value and compete — causing a "ping-pong" effect where inverters constantly overshoot and undershoot. This emulator solves the problem by presenting itself as a Shelly Pro 3EM via mDNS and giving each inverter an individually calculated power value based on its PV production, battery SoC, fault state, and a configurable load-balancing strategy.
Key Features
- Smart load distribution — classifies inverters as excess producer (battery full), normal, or inactive (offline / fault / standby / SoC cutoff) and distributes demand accordingly
- 4 strategies —
smart_priority(default, PV-weighted),soc_priority,master_slave,round_robin - Per-VLAN mDNS isolation — custom mDNS responder using
SO_BINDTODEVICEprevents cross-VLAN leakage when inverters live on separate networks - Fault & standby detection — reads Modbus Status 1 register; faulted/standby inverters are excluded from distribution
- Soft-fault detection — detects inverters that receive commands but don't respond, excluding them from distribution after 3 consecutive cycles
- Dynamic capacity — uses the inverter's Available Import Power register instead of a hardcoded 800 W limit
- Feed-in reduction — sends negative power values to signal excess production; inverters reduce output proportionally
- Safety features — mDNS auto-deregisters when grid data becomes stale; optional Modbus export-power-limit as hardware backup
- Web dashboard — real-time overview with sparkline history at
http://<host>/ - File logging — optional rotating log files with gzip compression
How It Works
- UDP listener receives real-time house power consumption from an IR meter reader (Shelly-compatible JSON)
- Modbus poller (background thread) reads each inverter's PV power, battery power, SoC, active power, temperature, status, and alarms via Modbus TCP
- Load balancer classifies inverters and computes an individual power share for each one
- HTTP server identifies the requesting inverter by IP, returns its share as Shelly Pro 3EM–compatible JSON
- mDNS responder advertises
_shelly._tcpand_http._tcpservices — one per VLAN when configured, or a single instance in host-networking mode
Quick Start
Docker Compose
services:
shelly-emulator:
container_name: shellyemu
hostname: shellyscript.unbk.net
image: git.unbk.net/henning/shelly-emulator:latest
restart: unless-stopped
environment:
- HTTP_PORT=80
- MAC_ADDRESSES=02643A8482B1,02643A8482B2
- MDNS_INTERFACES=10.42.235.2,10.42.235.18
- INVERTER_HOSTS=pv-garten.unbk.net,pv-schuppen.unbk.net
- MODBUS_PORT=502
- MODBUS_POLL_INTERVAL=30
- READER_IP=10.40.235.44
- READER_PORT=1010
- BATTERY_CUTOFF_SOC=15
- BATTERY_RESUME_SOC=20
- TZ=Europe/Berlin
- LOG_LEVEL=INFO
- LOG_TO_FILE=true
- HARDWARE_MASTER_SLAVE=false
volumes:
- /opt/stacks/shelly-emulator/logs:/logs
cap_add:
- CAP_NET_RAW
networks:
shellyemu:
ipv4_address: 10.40.235.50
ipv6_address: fdc2:621f:ec2a:40:64:3aff:fe84:82a6
mac_address: 02:64:3A:84:82:A6
solar1:
ipv4_address: 10.42.235.2
mac_address: 02:64:3A:84:82:B1
solar2:
ipv4_address: 10.42.235.18
mac_address: 02:64:3A:84:82:B2
networks:
shellyemu:
driver: macvlan
driver_opts:
parent: ens19
ipam:
config:
- subnet: 10.40.235.0/24
gateway: 10.40.235.1
- subnet: fdc2:621f:ec2a:40::/64
gateway: fdc2:621f:ec2a:40::1
enable_ipv6: true
solar1:
driver: macvlan
driver_opts:
parent: ens21
ipam:
config:
- subnet: 10.42.235.0/28
enable_ipv6: false
solar2:
driver: macvlan
driver_opts:
parent: ens22
ipam:
config:
- subnet: 10.42.235.16/28
enable_ipv6: false
This example uses three macvlan networks:
- shellyemu — main network (for UDP meter data and general connectivity)
- solar1 — isolated VLAN for the first inverter (
pv-garten) - solar2 — isolated VLAN for the second inverter (
pv-schuppen)
MAC_ADDRESSES / MDNS_INTERFACES control which Shelly mDNS instance appears on which VLAN. Each inverter only sees its own Shelly device.
Configuration
Required
| Variable | Description | Example |
|---|---|---|
INVERTER_HOSTS |
Comma-separated inverter hostnames (FQDN) or IPs | "INV1.fritz.box,INV2.fritz.box" |
READER_IP |
IP address of your electricity meter reader | "10.40.235.44" |
Important Notes
Hostnames — use Fully Qualified Domain Names when running inside Docker:
- ✅
INVERTER1.fritz.box,INVERTER3.home.arpa - ❌
INVERTER1(won't resolve inside Docker) - Alternative: use IP addresses directly
MAC Address — controls the Shelly device ID visible to inverters:
- Host Networking: use your host interface MAC (e.g.
AABBCC112233) - Macvlan: use a locally administered MAC starting with
02(e.g.02AABBCC1122) - Format: 12 hex chars without separators
- Auto-generated if not set
Load Balancing Strategies
| Strategy | Env Value | Description |
|---|---|---|
| Smart Priority (default) | smart_priority |
PV-weighted allocation; excess producers (battery full) get priority, then SoC·2+PV-weighted distribution to normal inverters. Negative grid → charge signal weighted by free capacity. |
| SoC Priority | soc_priority |
Proportional distribution based on battery SoC |
| Master-Slave | master_slave |
Master inverter up to capacity, remainder to slaves. Set MASTER_INVERTER_INDEX. |
| Round Robin | round_robin |
Equal distribution among all online inverters |
| Hardware M/S | HARDWARE_MASTER_SLAVE=true |
Bypasses all load balancing — raw grid power to all inverters (for hardware-level master/slave setups) |
All Environment Variables
| Variable | Default | Description |
|---|---|---|
| Inverter | ||
INVERTER_HOSTS |
(required) | Comma-separated list of inverter hostnames/IPs |
MODBUS_PORT |
502 |
Modbus TCP port |
MODBUS_POLL_INTERVAL |
30 |
Seconds between inverter polls |
MODBUS_TIMEOUT |
10 |
Modbus connection timeout (seconds) |
MODBUS_RETRIES |
3 |
Retry count per register read |
MODBUS_RETRY_DELAY |
2.0 |
Delay between retries (seconds) |
| Load Balancing | ||
LOAD_BALANCE_STRATEGY |
smart_priority |
smart_priority, soc_priority, master_slave, round_robin |
MASTER_INVERTER_INDEX |
0 |
Master index for master_slave strategy |
MIN_SOC_THRESHOLD |
20 |
SoC below which inverter gets lower priority (%) |
BATTERY_CUTOFF_SOC |
10 |
SoC at or below which the inverter outputs no power (%) |
BATTERY_RESUME_SOC |
20 |
SoC to reach before resuming full power after cutoff (hysteresis) |
BATTERY_RECOVERY_FACTOR |
0.90 |
PV power factor in recovery zone (0.90 = 90% PV, 10% charges battery) |
BATTERY_FULL_SOC |
98 |
SoC at or above which inverter is classified as excess producer (%) |
HARDWARE_MASTER_SLAVE |
false |
Bypass load balancing, pass raw grid power to all |
| Grid / Feed-in | ||
READER_IP |
(required) | IP of your electricity meter reader |
READER_PORT |
1010 |
UDP port of the meter reader |
GRID_DATA_TIMEOUT |
10 |
Seconds without data before mDNS deregistration |
| Export Limit (safety backup) | ||
ENABLE_EXPORT_LIMIT |
false |
Write max export power to inverter via Modbus on startup |
EXPORT_POWER_LIMIT |
0 |
Max export power in watts (written to register 46504) |
| mDNS | ||
MAC_ADDRESS |
Auto-generated | MAC for single-instance mDNS (format: AABBCC112233) |
MAC_ADDRESSES |
(empty) | Comma-separated MACs for multi-VLAN mode |
MDNS_INTERFACES |
(empty) | Comma-separated IPs matching MAC_ADDRESSES |
| Logging | ||
LOG_LEVEL |
INFO |
INFO or DEBUG |
LOG_TO_FILE |
false |
Enable rotating log files |
LOG_DIR |
/logs |
Directory for log files |
| Server | ||
HTTP_PORT |
80 |
HTTP listen port |
TZ |
(system) | Timezone for log timestamps |
Network Setup
The emulator needs to be reachable by the inverters via mDNS and HTTP, and needs access to the meter reader via UDP. There are two main approaches:
Host Networking (simplest)
network_mode: host
cap_add:
- CAP_NET_RAW
Suitable when all inverters are on the same network as the host. CAP_NET_RAW is needed for SO_BINDTODEVICE which the mDNS responder uses to restrict multicast to a single interface.
Macvlan with Multi-VLAN Isolation (recommended for isolated inverters)
When each inverter lives on its own isolated VLAN, use separate macvlan networks and configure one mDNS instance per VLAN:
environment:
- MAC_ADDRESSES=02643A8482B1,02643A8482B2
- MDNS_INTERFACES=10.42.235.2,10.42.235.18
cap_add:
- CAP_NET_RAW
networks:
shellyemu:
ipv4_address: 10.40.235.50 # main network (meter reader, DNS)
solar1:
ipv4_address: 10.42.235.2 # VLAN for inverter 1
mac_address: 02:64:3A:84:82:B1
solar2:
ipv4_address: 10.42.235.18 # VLAN for inverter 2
mac_address: 02:64:3A:84:82:B2
MAC_ADDRESSES and MDNS_INTERFACES must have the same length. Each pair gets its own SO_BINDTODEVICE-bound mDNS responder, so inverter 1 on solar1 only sees ShellyPro3EM-02643A8482B1 and inverter 2 on solar2 only sees ShellyPro3EM-02643A8482B2. HTTP responses also return the matching MAC/device-ID based on the local interface the request arrived on.
Data Sources
The emulator receives UDP packets in Shelly JSON format:
{
"result": {
"total_act_power": 1200.5
}
}
Tasmota IR Reader
Use a Tasmota device running the SML Shelly Emulator Script by otello9.
Important: Comment out the mDNS announcement line in the Tasmota script — the emulator handles mDNS itself:
;res=mdns("shellypro3em-" "-" "shelly")
Compatible Sources
- Tasmota IR readers (with Shelly emulation script)
- Shelly 3EM devices (direct UDP streaming)
- Any device sending Shelly-format JSON via UDP
HTTP API
All endpoints return 404 when grid data is stale.
| Endpoint | Description |
|---|---|
GET / |
Web dashboard with real-time inverter status and sparklines |
GET /api/status |
JSON API for dashboard data |
GET /shelly |
Shelly device info |
GET /rpc/EM.GetStatus?id=<id> |
Per-phase power data (main endpoint for inverters) |
GET /rpc/Shelly.GetDeviceInfo |
Device info (fw 1.7.4) |
GET /rpc/Sys.GetStatus |
System uptime and status |
POST /rpc |
Unified RPC endpoint (EM.GetStatus, Shelly.GetDeviceInfo, Sys.GetStatus) |
Monitoring
Web Dashboard
Access http://<host>/ for a real-time dashboard showing:
- Grid power (consuming / feeding)
- Per-inverter: SoC, PV power, battery power, active power, temperature, work mode, commanded value
- SoC and PV sparklines (5 min history)
- Dark/light theme toggle
Log Levels
INFO — startup config, inverter data summaries, mDNS events, faults, warnings
DEBUG — additionally: per-request HTTP details, Modbus reads, mDNS query/response details
File Logging
LOG_TO_FILE: 'true'
volumes:
- ./logs:/logs
Creates rotating log files (10 MB, 9 backups, gzip-compressed):
shelly_emulator.log— main application loghttp_access.log— HTTP access log (aiohttp format)
Example Log Output
2025-09-28 15:06:20 - INFO - Starting Shelly Emulator...
2025-09-28 15:06:20 - INFO - Load Balance Strategy: smart_priority
2025-09-28 15:06:20 - INFO - Battery Cutoff SoC: 10% (inverter outputs NO power below this)
2025-09-28 15:06:21 - INFO - Modbus [INV1.fritz.box]: SoC=85%, PV=400W, Batt=-200W, Active=320W, Temp=38.5°C
2025-09-28 15:06:21 - INFO - Reporting INV1.fritz.box: 350W (PV: 320W + Battery: 30W, SoC: 85%)
2025-09-28 15:06:22 - INFO - Grid data available - registering mDNS services...
2025-09-28 15:06:22 - INFO - === Grid=+12W | .INV1[SoC=85% PV=400W out=320W] | .INV2[SoC=72% PV=280W out=250W] | Cmd: .INV1→350W .INV2→262W ===
Troubleshooting
Inverters Not Discovered
- Ensure
CAP_NET_RAWis granted (check forSO_BINDTODEVICElog messages) - Verify host networking or correct macvlan IP
- Use FQDN hostnames or IP addresses in
INVERTER_HOSTS - Verify hostnames resolve from within the container
- Ensure Modbus port 502 is reachable
- Set
LOG_LEVEL=DEBUGfor mDNS and connection details
Cross-VLAN mDNS Leakage
- Verify
MAC_ADDRESSESandMDNS_INTERFACESare correctly paired - Check logs for
SO_BINDTODEVICE → <ifname>messages - Ensure
CAP_NET_RAWis enabled — without it,SO_BINDTODEVICEsilently fails
Uneven Power Distribution
- Check inverter SoC levels and PV production in the dashboard
- Verify all inverters are online (not fault/standby)
- Adjust
BATTERY_CUTOFF_SOC,BATTERY_RESUME_SOC, andBATTERY_FULL_SOCthresholds - Review strategy choice
No Grid Data
- Verify UDP packets are reaching
READER_IP:READER_PORT - Check data format (must be
{"result": {"total_act_power": ...}}) - mDNS services auto-deregister after
GRID_DATA_TIMEOUTseconds without data - Look for "No UDP data received" warnings in logs
Poor WiFi / Modbus Timeouts
MODBUS_TIMEOUT: '15'
MODBUS_RETRIES: '5'
MODBUS_RETRY_DELAY: '3.0'
After 3 consecutive failures per inverter, the Modbus connection is dropped and re-established.
Architecture
Module Structure
src/shelly_emulator/
├── __main__.py — Entry point, thread startup, mDNS, asyncio.run(main())
├── config.py — Environment variables, constants, logging setup
├── state.py — Shared mutable state (locks, dicts, timers)
├── modbus.py — Modbus client pool, register R/W, polling, fault detection
├── udp.py — UDP listener for meter data
├── load_balancing.py — 4 distribution strategies, inverter classification
├── http_handlers.py — aiohttp handlers, Shelly API emulation, web dashboard
└── mdns.py — Custom per-interface mDNS responder (SO_BINDTODEVICE)
Threading Model
- Main asyncio loop: HTTP server, mDNS responders, UDP listener
- Background daemon thread: Modbus polling (blocking I/O)
data_lock(threading.Lock): protects all shared state
Dependencies
- aiohttp — async HTTP server
- pymodbus — Modbus TCP client
No zeroconf dependency — mDNS is handled by a custom implementation for per-interface isolation.
Building from Source
# Install dependencies (uses uv package manager)
uv sync
# Run directly
uv run python -m shelly_emulator
# Docker build (single arch)
./build.sh
# Docker multi-arch build (amd64, arm64)
./build_multiarch.sh
For multi-arch builds, you need qemu-user-static installed. See the docker buildx documentation.