Skip to content

Commit

Permalink
Add support for emulating Shelly devices (#16)
Browse files Browse the repository at this point in the history
This adds support for emulating 3 Shelly devices in a way that is
compatible to Marstek storages:
- Shelly Pro 3EM
- Shelly EM Gen3
- Shelly Pro EM50
  • Loading branch information
tomquist authored Jan 25, 2025
1 parent 0ba5167 commit 588cc8d
Show file tree
Hide file tree
Showing 9 changed files with 278 additions and 94 deletions.
19 changes: 10 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# B2500 Meter

This project replicates a Smart Meter device for a B2500 energy storage system while allowing integration with various smart meters.
This project emulates Smart Meter devices for Marstek storages such as the B2500, Marstek Jupiter and Marstek Venus energy storage system while allowing integration with almost any smart meters.

## Getting Started

Expand Down Expand Up @@ -68,21 +68,21 @@ When the script is running, switch your B2500 to "Self-Adaptation" mode to enabl

## Configuration

The configuration is managed using an `ini` file called `config.ini`. Below, you'll find the configuration settings required for each supported powermeter type.
Configuration is managed via `config.ini`. Each powermeter type has specific settings.

### General Configuration

Optionally add a general section with the option to enable or disable summation of phase values.

```ini
[GENERAL]
# By default, the script will sum the power values of all phases and report them as a single value on phase 1. To disable this behavior, add the following configuration to the `config.ini` file
DISABLE_SUM_PHASES = False
# Setting this to true, disables the powermeter test at the beginning of the script.
# Comma-separated list of device types to emulate (ct001, shellypro3em, shellyemg3, shellyproem50)
DEVICE_TYPE = ct001
# Skip initial powermeter test on startup
SKIP_POWERMETER_TEST = False
# By default, the script sends an absolute value of the measured power. This seems to be necessary for the storage system, since it can't handle negative values (results in an integer overflow). Set this to true to clamp the values to 0 instead of sending the absolute value.
# Sum power values of all phases and report on phase 1 (ct001 only and default is False)
DISABLE_SUM_PHASES = False
# Send absolute values (necessary for storage system) (ct001 only and default is False)
DISABLE_ABSOLUTE_VALUES = False
# Sets the interval at which the script sends new power values to the B2500 in seconds. The original Smart Meter sends new values every second.
# Interval for sending power values in seconds (ct001 only and default is 1)
POLL_INTERVAL = 1
```

Expand Down Expand Up @@ -289,6 +289,7 @@ You can install the B2500 Meter add-on either through the Home Assistant reposit
3. **Configure the Add-on**
- After installation, go to the add-on's Configuration tab
- Set the `Power Input Alias` and optionally the `Power Output Alias` to the entity IDs of your power sensors in Home Assistant
- Set `Device Types` (comma-separated list) to the device types you want to emulate (ct001, shellypro3em, shellyemg3, shellyproem50)
- Click "Save" to apply the configuration

4. **Start the Add-on**
Expand Down
1 change: 0 additions & 1 deletion b2500/__init__.py

This file was deleted.

1 change: 1 addition & 0 deletions ct001/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from .ct001 import CT001
2 changes: 1 addition & 1 deletion b2500/b2500.py → ct001/ct001.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import time


class B2500:
class CT001:
def __init__(
self,
udp_port=12345,
Expand Down
7 changes: 6 additions & 1 deletion ha_addon/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,20 @@ host_network: true
ports:
12345/tcp: 12345
12345/udp: 12345
1010/udp: 1010
2222/udp: 2222
2223/udp: 2223
options:
power_input_alias: "sensor.current_power_in"
power_output_alias: ""
poll_interval: 1
disable_absolute_values: false
disable_sum_phases: false
device_types: "ct001"
schema:
power_input_alias: str
power_output_alias: str?
poll_interval: int(1,)
disable_absolute_values: bool
disable_sum_phases: bool
disable_sum_phases: bool
device_types: str
1 change: 1 addition & 0 deletions ha_addon/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ CONFIG="/app/config.ini"

{
echo "[GENERAL]"
echo "DEVICE_TYPE=$(bashio::config 'device_types')"
echo "POLL_INTERVAL=$(bashio::config 'poll_interval')"
echo "DISABLE_ABSOLUTE_VALUES=$(bashio::config 'disable_absolute_values')"
echo "DISABLE_SUM_PHASES=$(bashio::config 'disable_sum_phases')"
Expand Down
211 changes: 129 additions & 82 deletions main.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,15 @@
import configparser
import argparse
from concurrent.futures import ThreadPoolExecutor
from typing import Optional
from config.config_loader import create_powermeter
from b2500 import B2500

# Define ports
UDP_PORT = 12345
TCP_PORT = 12345
from ct001 import CT001
from shelly import Shelly


def test_powermeter(powermeter):
try:
print("Testing powermeter configuration...")
# Wait for the MQTT client to receive the first message
powermeter.wait_for_message(timeout=120)
value = powermeter.get_powermeter_watts()
value_with_units = " | ".join([f"{v}W" for v in value])
Expand All @@ -21,102 +19,151 @@ def test_powermeter(powermeter):
exit(1)


def run_device(
device_type: str,
cfg: configparser.ConfigParser,
args: argparse.Namespace,
powermeter,
device_id: Optional[str] = None,
):
print(f"Starting device: {device_type}")

if device_type == "ct001":
disable_sum = (
args.disable_sum
if args.disable_sum is not None
else cfg.getboolean("GENERAL", "DISABLE_SUM_PHASES", fallback=False)
)
disable_absolute = (
args.disable_absolute
if args.disable_absolute is not None
else cfg.getboolean("GENERAL", "DISABLE_ABSOLUTE_VALUES", fallback=False)
)
poll_interval = (
args.poll_interval
if args.poll_interval is not None
else cfg.getint("GENERAL", "POLL_INTERVAL", fallback=1)
)

print(f"CT001 Settings for {device_id}:")
print(f"Disable Sum Phases: {disable_sum}")
print(f"Disable Absolute Values: {disable_absolute}")
print(f"Poll Interval: {poll_interval}")

device = CT001(poll_interval=poll_interval)

def update_readings(addr):
values = powermeter.get_powermeter_watts()
value1 = values[0] if len(values) > 0 else 0
value2 = values[1] if len(values) > 1 else 0
value3 = values[2] if len(values) > 2 else 0

if not disable_sum:
value1 = value1 + value2 + value3
value2 = value3 = 0

if not disable_absolute:
value1, value2, value3 = map(abs, (value1, value2, value3))

device.value = [value1, value2, value3]

device.before_send = update_readings

elif device_type == "shellypro3em":
print(f"Shelly Pro 3EM Settings:")
print(f"Device ID: {device_id}")
device = Shelly(powermeter=powermeter, device_id=device_id, udp_port=1010)

elif device_type == "shellyemg3":
print(f"Shelly EM Gen3 Settings:")
print(f"Device ID: {device_id}")
device = Shelly(powermeter=powermeter, device_id=device_id, udp_port=2222)

elif device_type == "shellyproem50":
print(f"Shelly Pro EM 50 Settings:")
print(f"Device ID: {device_id}")
device = Shelly(powermeter=powermeter, device_id=device_id, udp_port=2223)

else:
raise ValueError(f"Unsupported device type: {device_type}")

try:
device.start()
device.join()
finally:
device.stop()


def main():
# Parse command line arguments
parser = argparse.ArgumentParser(description="Power meter configuration")
parser.add_argument(
"-c",
"--config",
default="config.ini",
help="Path to the configuration file",
type=str,
)
parser.add_argument(
"-s", "--disable-sum", help="Disable sum of all phases", type=bool, default=None
)
parser.add_argument(
"-a",
"--disable-absolute",
help="Disable absolute values",
type=bool,
default=None,
)
parser = argparse.ArgumentParser(description="Power meter device emulator")
parser.add_argument(
"-t",
"--skip-powermeter-test",
help="Skip powermeter test on start",
type=bool,
default=None,
"-c", "--config", default="config.ini", help="Path to the configuration file"
)
parser.add_argument("-t", "--skip-powermeter-test", type=bool)
parser.add_argument(
"-p", "--poll-interval", help="Poll interval in seconds", type=int
"-d",
"--device-types",
nargs="+",
choices=["ct001", "shellypro3em", "shellyemg3", "shellyproem50"],
help="List of device types to emulate",
)
args = parser.parse_args()
parser.add_argument("--device-ids", nargs="+", help="List of device IDs")

# B2500-specific arguments
parser.add_argument("-s", "--disable-sum", type=bool)
parser.add_argument("-a", "--disable-absolute", type=bool)
parser.add_argument("-p", "--poll-interval", type=int)

# Load configuration
args = parser.parse_args()
cfg = configparser.ConfigParser()
cfg.read(args.config)
powermeter = create_powermeter(cfg)
disable_sum_phases = (
args.disable_sum
if args.disable_sum is not None
else cfg.getboolean("GENERAL", "DISABLE_SUM_PHASES", fallback=False)
)
disable_absolut_values = (
args.disable_absolute
if args.disable_absolute is not None
else cfg.getboolean("GENERAL", "DISABLE_ABSOLUTE_VALUES", fallback=False)

# Load general settings
device_types = (
args.device_types
if args.device_types is not None
else [
dt.strip()
for dt in cfg.get("GENERAL", "DEVICE_TYPE", fallback="ct001").split(",")
]
)
skip_test = (
args.skip_powermeter_test
if args.skip_powermeter_test is not None
else cfg.getboolean("GENERAL", "SKIP_POWERMETER_TEST", fallback=False)
)
poll_interval = (
args.poll_interval
if args.poll_interval is not None
else cfg.getint("GENERAL", "POLL_INTERVAL", fallback=1)
)

print(f"General Settings:")
print(f"Disable Sum Phases: {disable_sum_phases}")
print(f"Disable Absolute Values: {disable_absolut_values}")
device_ids = args.device_ids if args.device_ids is not None else []
# Fill missing device IDs with default format
while len(device_ids) < len(device_types):
device_type = device_types[len(device_ids)]
if device_type in ["shellypro3em", "shellyemg3", "shellyproem50"]:
device_ids.append(f"{device_type}-ec4609c439c{len(device_ids) + 1}")
else:
device_ids.append(f"device-{len(device_ids) + 1}")

print(f"Device Types: {device_types}")
print(f"Device IDs: {device_ids}")
print(f"Skip Test: {skip_test}")
print(f"Poll Interval: {poll_interval}")

# Fetch powermeter values once to check if the configuration is correct
# Create powermeter
powermeter = create_powermeter(cfg)
if not skip_test:
test_powermeter(powermeter)

smart_meter = B2500(poll_interval=poll_interval)

try:
listen(smart_meter, powermeter, disable_sum_phases, disable_absolut_values)
finally:
smart_meter.stop()


def listen(smart_meter, powermeter, disable_sum_phases, disable_absolut_values):
def update_readings(addr):
values = powermeter.get_powermeter_watts()
value1 = values[0] if len(values) > 0 else 0
value2 = values[1] if len(values) > 1 else 0
value3 = values[2] if len(values) > 2 else 0
if not disable_sum_phases:
value1 = value1 + value2 + value3
value2 = 0
value3 = 0

if not disable_absolut_values:
value1 = abs(value1)
value2 = abs(value2)
value3 = abs(value3)

smart_meter.value = [value1, value2, value3]

smart_meter.before_send = update_readings
smart_meter.start()
smart_meter.join()
# Run devices in parallel
with ThreadPoolExecutor(max_workers=len(device_types)) as executor:
futures = []
for device_type, device_id in zip(device_types, device_ids):
futures.append(
executor.submit(
run_device, device_type, cfg, args, powermeter, device_id
)
)

# Wait for all devices to complete
for future in futures:
future.result()


if __name__ == "__main__":
Expand Down
1 change: 1 addition & 0 deletions shelly/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from .shelly import Shelly
Loading

0 comments on commit 588cc8d

Please sign in to comment.