diff --git a/README.md b/README.md index 16f616e..e791bd2 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,8 @@ The following smart meters are supported (see [Wiki/Home](https://github.com/scs Data pushed by smart meter over P1 interface (HDLC, DLMS/COSEM only, no DSMR). * Kamstrup OMNIPOWER with HAN-NVE: \ Data pushed by smart meter over inserted [HAN-NVE module](https://www.kamstrup.com/en-en/electricity-solutions/meters-devices/modules/hannve) (wired M-Bus, HDLC, DLMS/COSEM). +* Siemens TD-351x: \ + Data fetched over bidirectional IR interface (IEC 62056-21, Mode C, unencrypted). Note: All smart meters integrated so far push binary data encoded with HDLC (IEC 62056-46) and DLMS/COSEM. Both unencrypted and encrypted DLMS messages are accepted by the software. diff --git a/smartmeter_datacollector/factory.py b/smartmeter_datacollector/factory.py index 5da08e7..0d8755e 100644 --- a/smartmeter_datacollector/factory.py +++ b/smartmeter_datacollector/factory.py @@ -19,6 +19,7 @@ from .smartmeter.lge360 import LGE360 from .smartmeter.lge450 import LGE450 from .smartmeter.lge570 import LGE570 +from .smartmeter.siemens_td3511 import SiemensTD3511 from .smartmeter.meter import Meter, MeterError @@ -63,6 +64,13 @@ def build_meters(config: ConfigParser) -> List[Meter]: decryption_key=meter_config.get('key'), use_system_time=meter_config.getboolean('systemtime', False) )) + elif meter_type == "siemens_td3511": + meters.append(SiemensTD3511( + port=meter_config.get('port', "/dev/ttyUSB0"), + baudrate=meter_config.getint('baudrate', SiemensTD3511.BAUDRATE), + decryption_key=meter_config.get('key'), + use_system_time=meter_config.getboolean('systemtime', False) + )) else: raise InvalidConfigError(f"'type' is invalid or missing: {meter_type}") except MeterError as ex: diff --git a/smartmeter_datacollector/smartmeter/siemens_td3511.py b/smartmeter_datacollector/smartmeter/siemens_td3511.py new file mode 100644 index 0000000..39e06a5 --- /dev/null +++ b/smartmeter_datacollector/smartmeter/siemens_td3511.py @@ -0,0 +1,259 @@ +# +# Copyright (C) 2024 IBW Technik AG +# This file is part of smartmeter-datacollector. +# +# SPDX-License-Identifier: GPL-2.0-only +# See LICENSES/README.md for more information. +# +import asyncio +import logging +import re +from abc import ABC, abstractmethod +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Callable, List, Optional + +import aioserial +import serial + +from .meter import Meter, MeterError +from .meter_data import MeterDataPoint, MeterDataPointType, MeterDataPointTypes +from .reader import Reader, ReaderError +from .serial_reader import SerialConfig + +LOGGER = logging.getLogger("smartmeter") + + +class SiemensTD3511(Meter): + BAUDRATE = 300 + + def __init__(self, port: str, + baudrate: int = BAUDRATE, + decryption_key: Optional[str] = None, + use_system_time: bool = False) -> None: + super().__init__() + serial_config = SerialConfig( + port=port, + baudrate=baudrate, + data_bits=serial.SEVENBITS, + parity=serial.PARITY_EVEN, + stop_bits=serial.STOPBITS_ONE, + termination=b"\r\n" + ) + try: + self._parser = SiemensParser(use_system_time) + self._serial = SiemensSerialReader(serial_config, self._data_received) + except ReaderError as ex: + LOGGER.fatal("Unable to setup serial reader for Siemens TD3511. '%s'", ex) + raise MeterError("Failed setting up Siemens TD3511.") from ex + + LOGGER.info("Successfully set up Siemens TD3511 smart meter on '%s'.", port) + + async def start(self) -> None: + await self._serial.start_and_listen() + + def _data_received(self, received_data: bytes) -> None: + if not received_data: + return + if received_data != self._serial.TERMINATION_FLAG: + self._parser.append_to_buffer(received_data) + return + + data_points = self._parser.parse_data_objects(self._serial.timestamp) + if not data_points: + return + self._notify_observers(data_points) + + +class SiemensSerialReader(Reader): + """Serial reader according to IEC62056-21, Mode C""" + TERMINATION_FLAG = b'!\r\n' + BAUDRATE_INIT = 300 + BAUDRATE_DATA = 19200 + + def __init__(self, serial_config: SerialConfig, callback: Callable[[bytes], None]) -> None: + super().__init__(callback) + self._termination = serial_config.termination + self.timestamp = None + try: + self._serial = aioserial.AioSerial( + port=serial_config.port, + baudrate=serial_config.baudrate, + bytesize=serial_config.data_bits, + parity=serial_config.parity, + stopbits=serial_config.stop_bits + ) + except aioserial.SerialException as ex: + raise ReaderError(ex) from ex + self._serialSettings = self._serial.get_settings() + self.meter_id = None + + async def start_and_listen(self) -> None: + while True: + try: + await asyncio.wait_for(self._enter_prg_mode(), timeout=5.0) + while True: + await asyncio.wait_for(self._get_f001_dataset(), timeout=5.0) + await asyncio.wait_for(self._get_f009_dataset(), timeout=5.0) + except asyncio.exceptions.TimeoutError: + self._callback(SiemensSerialReader.TERMINATION_FLAG) + LOGGER.warning("Meter dataset not received within timeout.") + continue + return + + async def _enter_prg_mode(self): + LOGGER.info("Try to set meter into programming mode.") + self._serialSettings['baudrate'] = SiemensSerialReader.BAUDRATE_INIT + self._serial.apply_settings(self._serialSettings) + await self._serial.write_async(b"/?!\r\n") + self.meter_id = await self._serial.readline_async(size=-1) + LOGGER.debug("Meter response to init sequence: %s", self.meter_id.decode()) + await asyncio.sleep(0.2) + await self._serial.write_async(bytes.fromhex("063036310D0A")) + await asyncio.sleep(0.2) + self._serialSettings['baudrate'] = SiemensSerialReader.BAUDRATE_DATA + self._serial.apply_settings(self._serialSettings) + return + + async def _get_f001_dataset(self): + # Read dataset F001 + self.timestamp = datetime.now(timezone.utc) + await self._serial.write_async(bytes.fromhex('015232024630303103160D0A')) + data: bytes = await self._serial.readline_async(size=-1) + self._callback(data) + while True: + try: + data: bytes = await asyncio.wait_for(self._serial.readline_async(size=-1), timeout=0.2) + self._callback(data) + except asyncio.exceptions.TimeoutError: + LOGGER.debug("Finished reading dataset F001") + self._callback(SiemensSerialReader.TERMINATION_FLAG) + break + return + + async def _get_f009_dataset(self): + # Read dataset F009 + self.timestamp = datetime.now(timezone.utc) + await self._serial.write_async(bytes.fromhex('0152320246303039031E0D0A')) + data: bytes = await self._serial.readline_async(size=-1) + self._callback(data) + while True: + try: + data: bytes = await asyncio.wait_for(self._serial.readline_async(size=-1), timeout=0.2) + self._callback(data) + except asyncio.exceptions.TimeoutError: + LOGGER.debug("Finished reading dataset F009") + self._callback(SiemensSerialReader.TERMINATION_FLAG) + break + return + + +@dataclass +class RegisterDataPoint: + obis: str + data_point_type: MeterDataPointType + scaling: float = 1.0 + + +DEFAULT_REGISTER_MAPPING = [ + RegisterDataPoint("1.7.0", MeterDataPointTypes.ACTIVE_POWER_P.value, 1000), + RegisterDataPoint("2.7.0", MeterDataPointTypes.ACTIVE_POWER_N.value, 1000), + RegisterDataPoint("3.7.0", MeterDataPointTypes.REACTIVE_POWER_P.value, 1000), + RegisterDataPoint("4.7.0", MeterDataPointTypes.REACTIVE_POWER_N.value, 1000), + RegisterDataPoint("14.7", MeterDataPointTypes.NET_FREQUENCY.value), + + RegisterDataPoint("31.7", MeterDataPointTypes.CURRENT_L1.value), + RegisterDataPoint("32.7", MeterDataPointTypes.VOLTAGE_L1.value), + RegisterDataPoint("81.7.4", MeterDataPointTypes.ANGLE_UI_L1.value, 3.141592653589793 / 180), + + RegisterDataPoint("51.7", MeterDataPointTypes.CURRENT_L2.value), + RegisterDataPoint("52.7", MeterDataPointTypes.VOLTAGE_L2.value), + RegisterDataPoint("81.7.15", MeterDataPointTypes.ANGLE_UI_L2.value, 3.141592653589793 / 180), + + RegisterDataPoint("71.7", MeterDataPointTypes.CURRENT_L3.value), + RegisterDataPoint("72.7", MeterDataPointTypes.VOLTAGE_L3.value), + RegisterDataPoint("81.7.26", MeterDataPointTypes.ANGLE_UI_L3.value, 3.141592653589793 / 180), + + RegisterDataPoint("1.8.0", MeterDataPointTypes.ACTIVE_ENERGY_P.value, 1000), + RegisterDataPoint("1.8.1", MeterDataPointTypes.ACTIVE_ENERGY_P_T1.value, 1000), + RegisterDataPoint("1.8.2", MeterDataPointTypes.ACTIVE_ENERGY_P_T2.value, 1000), + RegisterDataPoint("2.8.0", MeterDataPointTypes.ACTIVE_ENERGY_N.value, 1000), + RegisterDataPoint("2.8.1", MeterDataPointTypes.ACTIVE_ENERGY_N_T1.value, 1000), + RegisterDataPoint("2.8.2", MeterDataPointTypes.ACTIVE_ENERGY_N_T2.value, 1000), + RegisterDataPoint("3.8.1", MeterDataPointTypes.REACTIVE_ENERGY_P.value, 1000), + RegisterDataPoint("4.8.1", MeterDataPointTypes.REACTIVE_ENERGY_N.value, 1000), +] + + +class SiemensParser(): + REGEX = r"(.{3,20})\(([\d\-\.:]{3,20})[*\)](.{0,10}[^\)\r\n])?" + + def __init__(self, use_system_time: bool = False) -> None: + self._use_system_time = use_system_time + self._meter_time = None + self._meter_date = None + self._timestamp = None + self._meter_id = None + self._buffer = [] + self._register_obis = {r.obis: r for r in DEFAULT_REGISTER_MAPPING} + + def append_to_buffer(self, received_data): + self._buffer.append(received_data.decode()) + return + + def clear_buffer(self): + self._buffer = [] + return + + def parse_data_objects(self, timestamp: datetime): + # Extract timestamp and meter id + self._timestamp = timestamp + for data in self._buffer: + result = re.search(SiemensParser.REGEX, data) + if result is None: + continue + obis, value, unit = result.groups() + + # Extract meter id (common source id for all data points) + if obis == "0.0.0": + self._meter_id = value + # Extract date and time + try: + if obis == "0.9.1": + self._meter_time = datetime.strptime(value, "%H:%M:%S").time() + if obis == "0.9.2": + self._meter_date = datetime.strptime(value, "%y-%m-%d").date() + except BaseException: + self._meter_time = None + self._meter_date = None + LOGGER.warning("Invalid timestamp received: %s. Using system time instead.", value) + if self._meter_date is not None and self._meter_time is not None and not self._use_system_time: + self._timestamp = datetime.combine(self._meter_date, self._meter_time).astimezone(timezone.utc) + + # Extract register data + data_points: List[MeterDataPoint] = [] + for data in self._buffer: + result = re.search(SiemensParser.REGEX, data) + if result is None: + continue + obis, value, unit = result.groups() + + if value is None: + LOGGER.warning("No value received for %s.", obis) + continue + + reg_type = self._register_obis.get(obis, None) + if reg_type is None: + continue + data_point_type = reg_type.data_point_type + + try: + scaled_value = float(value) * reg_type.scaling + except (TypeError, ValueError, OverflowError): + LOGGER.warning("Invalid register value '%s'. Skipping register.", str(value)) + continue + + data_points.append(MeterDataPoint(data_point_type, scaled_value, self._meter_id, self._timestamp)) + + self.clear_buffer() + return data_points diff --git a/tests/test_siemens_td3511.py b/tests/test_siemens_td3511.py new file mode 100644 index 0000000..f5d3bbc --- /dev/null +++ b/tests/test_siemens_td3511.py @@ -0,0 +1,139 @@ +# +# Copyright (C) 2024 IBW Technik AG +# This file is part of smartmeter-datacollector. +# +# SPDX-License-Identifier: GPL-2.0-only +# See LICENSES/README.md for more information. +# +import sys +from datetime import datetime +from typing import List + +import pytest +from pytest_mock.plugin import MockerFixture + +from smartmeter_datacollector.smartmeter.meter_data import MeterDataPointTypes +from smartmeter_datacollector.smartmeter.siemens_td3511 import SiemensTD3511 + +from .utils import * + + +@pytest.mark.skipif(sys.version_info < (3, 8), reason="Python3.7 does not support AsyncMock.") +@pytest.mark.asyncio +async def test_siemens_td3511_initialization(mocker: MockerFixture): + observer = mocker.stub() + test_bytes = bytes([1, 2, 3]) + serial_mock = mocker.patch("smartmeter_datacollector.smartmeter.siemens_td3511.SiemensSerialReader", + autospec=True).return_value + meter = SiemensTD3511("/test/port") + serial_mock.start_and_listen.side_effect = meter._data_received(test_bytes) + meter.register(observer) + await meter.start() + + serial_mock.start_and_listen.assert_awaited_once() + observer.assert_not_called + + +@pytest.fixture +def unencrypted_valid_data_siemens() -> List[bytes]: + data_str: List[str] = [] + data_str.append(b'0.0.0(110002267)\r\n') + data_str.append(b'1.8.0(31550.191*kWh)\r\n') + data_str.append(b'1.8.1(12853.433*kWh)\r\n') + data_str.append(b'1.8.2(18696.758*kWh)\r\n') + data_str.append(b'2.8.0(22309.592*kWh)\r\n') + data_str.append(b'2.8.1(16717.051*kWh)\r\n') + data_str.append(b'2.8.2(5592.541*kWh)\r\n') + data_str.append(b'3.8.1(68.340*kvarh)\r\n') + data_str.append(b'4.8.1(29332.587*kvarh)\r\n') + data_str.append(b'0.9.1(21:10:29)\r\n') + data_str.append(b'0.9.2(24-03-21)\r\n') + data_str.append(b'1.7.0(0.386*kW)\r\n') + data_str.append(b'2.7.0(0.000*kW)\r\n') + data_str.append(b'3.7.0(0.000*kvar)\r\n') + data_str.append(b'4.7.0(0.727*kvar)\r\n') + data_str.append(b'0.9.1(21:10:29)\r\n') + data_str.append(b'0.9.2(24-03-21)\r\n') + data_str.append(b'14.7(49.96*Hz)\r\n') + data_str.append(b'32.7(238.3*V)\r\n') + data_str.append(b'52.7(240.2*V)\r\n') + data_str.append(b'72.7(240.0*V)\r\n') + data_str.append(b'31.7(1.58*A)\r\n') + data_str.append(b'51.7(1.50*A)\r\n') + data_str.append(b'71.7(0.77*A)\r\n') + data_str.append(b'81.7.4(-80.7*Deg)\r\n') + data_str.append(b'81.7.15(-33.3*Deg)\r\n') + data_str.append(b'81.7.26(-74.5*Deg)\r\n') + data_str.append(b'!\r\n') + return data_str + + +@pytest.mark.skipif(sys.version_info < (3, 8), reason="Python3.7 does not support AsyncMock.") +@pytest.mark.asyncio +async def test_siemens_td3511_parse_and_provide_unencrypted_data(mocker: MockerFixture, + unencrypted_valid_data_siemens: List[bytes]): + observer = mocker.stub("collector_mock") + observer.mock_add_spec(['notify']) + serial_mock = mocker.patch("smartmeter_datacollector.smartmeter.siemens_td3511.SiemensSerialReader", + autospec=True).return_value + serial_mock.TERMINATION_FLAG = b'!\r\n' + serial_mock.timestamp=datetime.now() + meter = SiemensTD3511("/test/port") + meter.register(observer) + + def data_received(): + for frame in unencrypted_valid_data_siemens: + meter._data_received(frame) + serial_mock.start_and_listen.side_effect = data_received + + await meter.start() + + serial_mock.start_and_listen.assert_awaited_once() + observer.notify.assert_called_once() + values = observer.notify.call_args.args[0] + assert isinstance(values, list) + assert any(data.type == MeterDataPointTypes.ACTIVE_POWER_P.value for data in values) + assert any(data.type == MeterDataPointTypes.ACTIVE_POWER_N.value for data in values) + assert any(data.type == MeterDataPointTypes.REACTIVE_POWER_P.value for data in values) + assert any(data.type == MeterDataPointTypes.REACTIVE_POWER_N.value for data in values) + assert any(data.type == MeterDataPointTypes.ACTIVE_ENERGY_P.value for data in values) + assert any(data.type == MeterDataPointTypes.ACTIVE_ENERGY_N.value for data in values) + assert all(data.source == "110002267" for data in values) + assert all(data.timestamp.strftime(r"%m/%d/%y %H:%M:%S") == "03/21/24 20:10:29" for data in values) + + +@pytest.fixture +def unencrypted_invalid_data_siemens() -> List[bytes]: + data_str: List[str] = [] + data_str.append(b'0.0.0(110002267)\r\n') + data_str.append(b'13.8.0(31550.191*kWh)\r\n') + data_str.append(b'13.8.1(12853.433*kWh)\r\n') + data_str.append(b'0.8.2(18696.758*kWh)\r\n') + data_str.append(b'0.9.1(21:10:29)\r\n') + data_str.append(b'0.9.2(24-03-21)\r\n') + data_str.append(b'!\r\n') + return data_str + +@pytest.mark.skipif(sys.version_info < (3, 8), reason="Python3.7 does not support AsyncMock.") +@pytest.mark.asyncio +async def test_siemens_td3511_do_not_provide_invalid_data(mocker: MockerFixture, + unencrypted_invalid_data_siemens: List[bytes]): + observer = mocker.stub("collector_mock") + observer.mock_add_spec(['notify']) + serial_mock = mocker.patch("smartmeter_datacollector.smartmeter.siemens_td3511.SiemensSerialReader", + autospec=True).return_value + serial_mock.TERMINATION_FLAG = b'!\r\n' + serial_mock.timestamp=datetime.now() + meter = SiemensTD3511("/test/port") + meter.timestamp=datetime.now() + meter.register(observer) + + def data_received(): + for frame in unencrypted_invalid_data_siemens: + meter._data_received(frame) + serial_mock.start_and_listen.side_effect = data_received + + await meter.start() + + serial_mock.start_and_listen.assert_awaited_once() + observer.notify.assert_not_called