From 588cc8d7a1d1b6a1fd349b8d16030c7523b26ef9 Mon Sep 17 00:00:00 2001 From: Tom Quist Date: Sat, 25 Jan 2025 16:28:43 +0100 Subject: [PATCH] Add support for emulating Shelly devices (#16) 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 --- README.md | 19 +-- b2500/__init__.py | 1 - ct001/__init__.py | 1 + b2500/b2500.py => ct001/ct001.py | 2 +- ha_addon/config.yaml | 7 +- ha_addon/run.sh | 1 + main.py | 211 +++++++++++++++++++------------ shelly/__init__.py | 1 + shelly/shelly.py | 129 +++++++++++++++++++ 9 files changed, 278 insertions(+), 94 deletions(-) delete mode 100644 b2500/__init__.py create mode 100644 ct001/__init__.py rename b2500/b2500.py => ct001/ct001.py (99%) create mode 100644 shelly/__init__.py create mode 100644 shelly/shelly.py diff --git a/README.md b/README.md index 022c02d..759dac2 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 ``` @@ -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** diff --git a/b2500/__init__.py b/b2500/__init__.py deleted file mode 100644 index e716085..0000000 --- a/b2500/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .b2500 import B2500 diff --git a/ct001/__init__.py b/ct001/__init__.py new file mode 100644 index 0000000..44a5349 --- /dev/null +++ b/ct001/__init__.py @@ -0,0 +1 @@ +from .ct001 import CT001 diff --git a/b2500/b2500.py b/ct001/ct001.py similarity index 99% rename from b2500/b2500.py rename to ct001/ct001.py index d9ab73b..0de0f1e 100644 --- a/b2500/b2500.py +++ b/ct001/ct001.py @@ -3,7 +3,7 @@ import time -class B2500: +class CT001: def __init__( self, udp_port=12345, diff --git a/ha_addon/config.yaml b/ha_addon/config.yaml index a56ceaf..5a7f0e6 100644 --- a/ha_addon/config.yaml +++ b/ha_addon/config.yaml @@ -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 \ No newline at end of file + disable_sum_phases: bool + device_types: str \ No newline at end of file diff --git a/ha_addon/run.sh b/ha_addon/run.sh index 73e066d..d966669 100644 --- a/ha_addon/run.sh +++ b/ha_addon/run.sh @@ -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')" diff --git a/main.py b/main.py index fb7b60b..7b7019f 100644 --- a/main.py +++ b/main.py @@ -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]) @@ -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__": diff --git a/shelly/__init__.py b/shelly/__init__.py new file mode 100644 index 0000000..a3657eb --- /dev/null +++ b/shelly/__init__.py @@ -0,0 +1 @@ +from .shelly import Shelly diff --git a/shelly/shelly.py b/shelly/shelly.py new file mode 100644 index 0000000..45bafa3 --- /dev/null +++ b/shelly/shelly.py @@ -0,0 +1,129 @@ +import socket +import threading +import json +import time +import math + + +class Shelly: + def __init__( + self, + powermeter, + udp_port=1010, + device_id="shellypro3em-ec4609c439c1", + ): + self._udp_port = udp_port + self._device_id = device_id + self._powermeter = powermeter + self._udp_thread = None + self._stop = False + self._value_mutex = threading.Lock() + + def _calculate_derived_values(self, power): + decimal_point_enforcer = 0.001 + if abs(power) < 0.1: + return decimal_point_enforcer + + return round( + power + + (decimal_point_enforcer if power == round(power) or power == 0 else 0), + 1, + ) + + def _create_em_response(self, request_id, powers): + if len(powers) == 1: + powers = [powers[0], 0, 0] + elif len(powers) != 3: + powers = [0, 0, 0] + + a = self._calculate_derived_values(powers[0]) + b = self._calculate_derived_values(powers[1]) + c = self._calculate_derived_values(powers[2]) + + total_act_power = round(sum(powers), 3) + total_act_power = total_act_power + ( + 0.001 + if total_act_power == round(total_act_power) or total_act_power == 0 + else 0 + ) + + return { + "id": request_id, + "src": self._device_id, + "dst": "unknown", + "result": { + "a_act_power": a, + "b_act_power": b, + "c_act_power": c, + "total_act_power": total_act_power, + }, + } + + def _create_em1_response(self, request_id, powers): + total_power = round(sum(powers), 3) + total_power = total_power + ( + 0.001 if total_power == round(total_power) or total_power == 0 else 0 + ) + + return { + "id": request_id, + "src": self._device_id, + "dst": "unknown", + "result": { + "act_power": total_power, + }, + } + + def udp_server(self): + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.bind(("", self._udp_port)) + print(f"Shelly emulator listening on UDP port {self._udp_port}...") + + try: + while not self._stop: + data, addr = sock.recvfrom(1024) + request_str = data.decode() + print(f"Received UDP message: {request_str}") + print(f"From: {addr[0]}:{addr[1]}") + + try: + request = json.loads(request_str) + print(f"Parsed request: {json.dumps(request, indent=2)}") + if isinstance(request.get("params", {}).get("id"), int): + powers = self._powermeter.get_powermeter_watts() + + if request.get("method") == "EM.GetStatus": + response = self._create_em_response(request["id"], powers) + elif request.get("method") == "EM1.GetStatus": + response = self._create_em1_response(request["id"], powers) + else: + continue + + response_json = json.dumps(response, separators=(",", ":")) + print(f"Sending response: {response_json}") + response_data = response_json.encode() + sock.sendto(response_data, addr) + except json.JSONDecodeError: + print(f"Error: Invalid JSON") + except Exception as e: + print(f"Error processing message: {e}") + + finally: + sock.close() + + def start(self): + if self._udp_thread: + return + self._stop = False + self._udp_thread = threading.Thread(target=self.udp_server) + self._udp_thread.start() + + def join(self): + if self._udp_thread: + self._udp_thread.join() + + def stop(self): + self._stop = True + if self._udp_thread: + self._udp_thread.join() + self._udp_thread = None