This thingy emulates a Shelly Pro 3EM for multiple Solakon One Inverter with split grid usage based on PV Production and Battery SoC
  • Python 98.1%
  • Dockerfile 1%
  • Shell 0.9%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-03-10 22:18:36 +01:00
docs feat: modbus inverter error handling 2026-02-15 17:10:41 +01:00
src/shelly_emulator feat: battery recover range 2026-03-10 22:18:36 +01:00
.dockerignore chore: initial commit 2025-09-28 16:56:48 +02:00
.gitignore chore: initial commit 2025-09-28 16:56:48 +02:00
build.sh chore: multiarch docker image 2025-09-28 20:32:07 +02:00
build_multiarch.sh feat: multi vlan + hardware master/slave support 2026-03-02 17:36:53 +01:00
CLAUDE.md feat: battery recover range 2026-03-10 22:18:36 +01:00
docker-compose.example.yml feat: battery recover range 2026-03-10 22:18:36 +01:00
Dockerfile feat: remodeling 2026-02-15 15:18:11 +01:00
pyproject.toml feat: remodeling 2026-02-15 15:18:11 +01:00
README.md feat: battery recover range 2026-03-10 22:18:36 +01:00
requirements.txt fix: mdns leakage 2026-03-10 17:25:13 +01:00
uv.lock feat: remodeling 2026-02-15 15:18:11 +01:00

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 strategiessmart_priority (default, PV-weighted), soc_priority, master_slave, round_robin
  • Per-VLAN mDNS isolation — custom mDNS responder using SO_BINDTODEVICE prevents 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

  1. UDP listener receives real-time house power consumption from an IR meter reader (Shelly-compatible JSON)
  2. Modbus poller (background thread) reads each inverter's PV power, battery power, SoC, active power, temperature, status, and alarms via Modbus TCP
  3. Load balancer classifies inverters and computes an individual power share for each one
  4. HTTP server identifies the requesting inverter by IP, returns its share as Shelly Pro 3EMcompatible JSON
  5. mDNS responder advertises _shelly._tcp and _http._tcp services — 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.

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 log
  • http_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_RAW is granted (check for SO_BINDTODEVICE log 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=DEBUG for mDNS and connection details

Cross-VLAN mDNS Leakage

  • Verify MAC_ADDRESSES and MDNS_INTERFACES are correctly paired
  • Check logs for SO_BINDTODEVICE → <ifname> messages
  • Ensure CAP_NET_RAW is enabled — without it, SO_BINDTODEVICE silently 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, and BATTERY_FULL_SOC thresholds
  • 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_TIMEOUT seconds 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.