From 94d23bf10ae3fbbe24c587930493a95a15d42f41 Mon Sep 17 00:00:00 2001 From: Christoph Hunziker Date: Thu, 15 Aug 2024 11:47:26 +0200 Subject: [PATCH 1/4] Add TD-3511 --- README.md | 2 + smartmeter_datacollector/factory.py | 8 + .../smartmeter/siemens_td3511.py | 259 ++++++++++++++++++ tests/test_siemens_td3511.py | 139 ++++++++++ 4 files changed, 408 insertions(+) create mode 100644 smartmeter_datacollector/smartmeter/siemens_td3511.py create mode 100644 tests/test_siemens_td3511.py 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 From 7cfa8cf8dc8772624d627d243755b990aa57fb38 Mon Sep 17 00:00:00 2001 From: Christoph Hunziker Date: Mon, 26 Aug 2024 19:27:16 +0200 Subject: [PATCH 2/4] Changes proposed by custom checks: isort_check, lint_check --- smartmeter_datacollector/factory.py | 2 +- .../smartmeter/siemens_td3511.py | 20 +++++++++---------- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/smartmeter_datacollector/factory.py b/smartmeter_datacollector/factory.py index 0d8755e..3aeaac2 100644 --- a/smartmeter_datacollector/factory.py +++ b/smartmeter_datacollector/factory.py @@ -19,8 +19,8 @@ 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 +from .smartmeter.siemens_td3511 import SiemensTD3511 def build_meters(config: ConfigParser) -> List[Meter]: diff --git a/smartmeter_datacollector/smartmeter/siemens_td3511.py b/smartmeter_datacollector/smartmeter/siemens_td3511.py index 39e06a5..55a1a05 100644 --- a/smartmeter_datacollector/smartmeter/siemens_td3511.py +++ b/smartmeter_datacollector/smartmeter/siemens_td3511.py @@ -8,7 +8,6 @@ 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 @@ -85,7 +84,7 @@ def __init__(self, serial_config: SerialConfig, callback: Callable[[bytes], None ) except aioserial.SerialException as ex: raise ReaderError(ex) from ex - self._serialSettings = self._serial.get_settings() + self._serial_settings = self._serial.get_settings() self.meter_id = None async def start_and_listen(self) -> None: @@ -103,16 +102,17 @@ async def start_and_listen(self) -> None: 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) + self._serial_settings['baudrate'] = SiemensSerialReader.BAUDRATE_INIT + self._serial.apply_settings(self._serial_settings) + await asyncio.sleep(5.0) 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) + self._serial_settings['baudrate'] = SiemensSerialReader.BAUDRATE_DATA + self._serial.apply_settings(self._serial_settings) return async def _get_f001_dataset(self): @@ -199,11 +199,9 @@ def __init__(self, use_system_time: bool = False) -> None: 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 @@ -212,7 +210,7 @@ def parse_data_objects(self, timestamp: datetime): result = re.search(SiemensParser.REGEX, data) if result is None: continue - obis, value, unit = result.groups() + obis, value, _ = result.groups() # Extract meter id (common source id for all data points) if obis == "0.0.0": @@ -223,7 +221,7 @@ def parse_data_objects(self, timestamp: datetime): 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: + except ValueError: self._meter_time = None self._meter_date = None LOGGER.warning("Invalid timestamp received: %s. Using system time instead.", value) @@ -236,7 +234,7 @@ def parse_data_objects(self, timestamp: datetime): result = re.search(SiemensParser.REGEX, data) if result is None: continue - obis, value, unit = result.groups() + obis, value, _ = result.groups() if value is None: LOGGER.warning("No value received for %s.", obis) From 423eaf09a028b74e601916bbbfd2f414372af249 Mon Sep 17 00:00:00 2001 From: Christoph Hunziker Date: Fri, 20 Sep 2024 21:14:12 +0200 Subject: [PATCH 3/4] Codereview raymar9 --- smartmeter_datacollector/factory.py | 1 - .../smartmeter/siemens_td3511.py | 48 +++++++++---------- tests/test_siemens_td3511.py | 6 +-- 3 files changed, 26 insertions(+), 29 deletions(-) diff --git a/smartmeter_datacollector/factory.py b/smartmeter_datacollector/factory.py index 3aeaac2..0f58c62 100644 --- a/smartmeter_datacollector/factory.py +++ b/smartmeter_datacollector/factory.py @@ -68,7 +68,6 @@ def build_meters(config: ConfigParser) -> List[Meter]: 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: diff --git a/smartmeter_datacollector/smartmeter/siemens_td3511.py b/smartmeter_datacollector/smartmeter/siemens_td3511.py index 55a1a05..23b6502 100644 --- a/smartmeter_datacollector/smartmeter/siemens_td3511.py +++ b/smartmeter_datacollector/smartmeter/siemens_td3511.py @@ -24,11 +24,10 @@ class SiemensTD3511(Meter): - BAUDRATE = 300 + BAUDRATE = 19200 def __init__(self, port: str, baudrate: int = BAUDRATE, - decryption_key: Optional[str] = None, use_system_time: bool = False) -> None: super().__init__() serial_config = SerialConfig( @@ -65,14 +64,18 @@ def _data_received(self, received_data: bytes) -> None: class SiemensSerialReader(Reader): - """Serial reader according to IEC62056-21, Mode C""" + """Serial reader for Siemens TD-3511. Communication is based on IEC62056-21, Mode C.""" TERMINATION_FLAG = b'!\r\n' BAUDRATE_INIT = 300 - BAUDRATE_DATA = 19200 + METER_ID_REQ = b'/?!\r\n' + METER_PRG_MODE_REQ = '063036310D0A' + METER_F001_REQ = '015232024630303103160D0A' + METER_F009_REQ = '0152320246303039031E0D0A' def __init__(self, serial_config: SerialConfig, callback: Callable[[bytes], None]) -> None: super().__init__(callback) self._termination = serial_config.termination + self._baudrate = serial_config.baudrate self.timestamp = None try: self._serial = aioserial.AioSerial( @@ -84,8 +87,6 @@ def __init__(self, serial_config: SerialConfig, callback: Callable[[bytes], None ) except aioserial.SerialException as ex: raise ReaderError(ex) from ex - self._serial_settings = self._serial.get_settings() - self.meter_id = None async def start_and_listen(self) -> None: while True: @@ -102,23 +103,20 @@ async def start_and_listen(self) -> None: async def _enter_prg_mode(self): LOGGER.info("Try to set meter into programming mode.") - self._serial_settings['baudrate'] = SiemensSerialReader.BAUDRATE_INIT - self._serial.apply_settings(self._serial_settings) - await asyncio.sleep(5.0) - 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()) + self._serial.baudrate = SiemensSerialReader.BAUDRATE_INIT + await self._serial.write_async(SiemensSerialReader.METER_ID_REQ) + meter_id = await self._serial.readline_async(size=-1) + LOGGER.debug("Meter response to init sequence: %s", meter_id.decode()) await asyncio.sleep(0.2) - await self._serial.write_async(bytes.fromhex("063036310D0A")) + await self._serial.write_async(bytes.fromhex(SiemensSerialReader.METER_PRG_MODE_REQ)) await asyncio.sleep(0.2) - self._serial_settings['baudrate'] = SiemensSerialReader.BAUDRATE_DATA - self._serial.apply_settings(self._serial_settings) + self._serial.baudrate = self._baudrate return async def _get_f001_dataset(self): # Read dataset F001 self.timestamp = datetime.now(timezone.utc) - await self._serial.write_async(bytes.fromhex('015232024630303103160D0A')) + await self._serial.write_async(bytes.fromhex(SiemensSerialReader.METER_F001_REQ)) data: bytes = await self._serial.readline_async(size=-1) self._callback(data) while True: @@ -134,7 +132,7 @@ async def _get_f001_dataset(self): async def _get_f009_dataset(self): # Read dataset F009 self.timestamp = datetime.now(timezone.utc) - await self._serial.write_async(bytes.fromhex('0152320246303039031E0D0A')) + await self._serial.write_async(bytes.fromhex(SiemensSerialReader.METER_F009_REQ)) data: bytes = await self._serial.readline_async(size=-1) self._callback(data) while True: @@ -190,8 +188,6 @@ class SiemensParser(): 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 = [] @@ -206,6 +202,8 @@ def clear_buffer(self): def parse_data_objects(self, timestamp: datetime): # Extract timestamp and meter id self._timestamp = timestamp + meter_time = None + meter_date = None for data in self._buffer: result = re.search(SiemensParser.REGEX, data) if result is None: @@ -218,15 +216,15 @@ def parse_data_objects(self, timestamp: datetime): # Extract date and time try: if obis == "0.9.1": - self._meter_time = datetime.strptime(value, "%H:%M:%S").time() + meter_time = datetime.strptime(value, "%H:%M:%S").time() if obis == "0.9.2": - self._meter_date = datetime.strptime(value, "%y-%m-%d").date() + meter_date = datetime.strptime(value, "%y-%m-%d").date() except ValueError: - self._meter_time = None - self._meter_date = None + meter_time = None + 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) + if meter_date is not None and meter_time is not None and not self._use_system_time: + self._timestamp = datetime.combine(meter_date, meter_time).astimezone(timezone.utc) # Extract register data data_points: List[MeterDataPoint] = [] diff --git a/tests/test_siemens_td3511.py b/tests/test_siemens_td3511.py index f5d3bbc..8100523 100644 --- a/tests/test_siemens_td3511.py +++ b/tests/test_siemens_td3511.py @@ -36,7 +36,7 @@ async def test_siemens_td3511_initialization(mocker: MockerFixture): @pytest.fixture def unencrypted_valid_data_siemens() -> List[bytes]: - data_str: List[str] = [] + data_str: List[bytes] = [] 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') @@ -99,12 +99,12 @@ def data_received(): 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) + assert all(data.timestamp.astimezone().strftime(r"%m/%d/%y %H:%M:%S") == "03/21/24 21:10:29" for data in values) @pytest.fixture def unencrypted_invalid_data_siemens() -> List[bytes]: - data_str: List[str] = [] + data_str: List[bytes] = [] 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') From 47aed95eb915756ade13899cb2906d8cd1beae27 Mon Sep 17 00:00:00 2001 From: Christoph Hunziker Date: Mon, 23 Sep 2024 20:31:27 +0200 Subject: [PATCH 4/4] Codereview raymar9 v2 --- .../smartmeter/siemens_td3511.py | 18 +++++++----------- tests/test_siemens_td3511.py | 1 - 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/smartmeter_datacollector/smartmeter/siemens_td3511.py b/smartmeter_datacollector/smartmeter/siemens_td3511.py index 23b6502..b3ed570 100644 --- a/smartmeter_datacollector/smartmeter/siemens_td3511.py +++ b/smartmeter_datacollector/smartmeter/siemens_td3511.py @@ -57,7 +57,7 @@ def _data_received(self, received_data: bytes) -> None: self._parser.append_to_buffer(received_data) return - data_points = self._parser.parse_data_objects(self._serial.timestamp) + data_points = self._parser.parse_data_objects() if not data_points: return self._notify_observers(data_points) @@ -76,7 +76,6 @@ def __init__(self, serial_config: SerialConfig, callback: Callable[[bytes], None super().__init__(callback) self._termination = serial_config.termination self._baudrate = serial_config.baudrate - self.timestamp = None try: self._serial = aioserial.AioSerial( port=serial_config.port, @@ -115,7 +114,6 @@ async def _enter_prg_mode(self): async def _get_f001_dataset(self): # Read dataset F001 - self.timestamp = datetime.now(timezone.utc) await self._serial.write_async(bytes.fromhex(SiemensSerialReader.METER_F001_REQ)) data: bytes = await self._serial.readline_async(size=-1) self._callback(data) @@ -131,7 +129,6 @@ async def _get_f001_dataset(self): async def _get_f009_dataset(self): # Read dataset F009 - self.timestamp = datetime.now(timezone.utc) await self._serial.write_async(bytes.fromhex(SiemensSerialReader.METER_F009_REQ)) data: bytes = await self._serial.readline_async(size=-1) self._callback(data) @@ -188,8 +185,6 @@ class SiemensParser(): def __init__(self, use_system_time: bool = False) -> None: self._use_system_time = use_system_time - self._timestamp = None - self._meter_id = None self._buffer = [] self._register_obis = {r.obis: r for r in DEFAULT_REGISTER_MAPPING} @@ -199,11 +194,12 @@ def append_to_buffer(self, received_data): def clear_buffer(self): self._buffer = [] - def parse_data_objects(self, timestamp: datetime): + def parse_data_objects(self): # Extract timestamp and meter id - self._timestamp = timestamp + timestamp = datetime.now(timezone.utc) meter_time = None meter_date = None + meter_id = 'unknown' for data in self._buffer: result = re.search(SiemensParser.REGEX, data) if result is None: @@ -212,7 +208,7 @@ def parse_data_objects(self, timestamp: datetime): # Extract meter id (common source id for all data points) if obis == "0.0.0": - self._meter_id = value + meter_id = value # Extract date and time try: if obis == "0.9.1": @@ -224,7 +220,7 @@ def parse_data_objects(self, timestamp: datetime): meter_date = None LOGGER.warning("Invalid timestamp received: %s. Using system time instead.", value) if meter_date is not None and meter_time is not None and not self._use_system_time: - self._timestamp = datetime.combine(meter_date, meter_time).astimezone(timezone.utc) + timestamp = datetime.combine(meter_date, meter_time).astimezone(timezone.utc) # Extract register data data_points: List[MeterDataPoint] = [] @@ -249,7 +245,7 @@ def parse_data_objects(self, timestamp: datetime): 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)) + data_points.append(MeterDataPoint(data_point_type, scaled_value, meter_id, timestamp)) self.clear_buffer() return data_points diff --git a/tests/test_siemens_td3511.py b/tests/test_siemens_td3511.py index 8100523..8c83804 100644 --- a/tests/test_siemens_td3511.py +++ b/tests/test_siemens_td3511.py @@ -125,7 +125,6 @@ async def test_siemens_td3511_do_not_provide_invalid_data(mocker: MockerFixture, 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():