Skip to content

Commit

Permalink
Add TD-3511
Browse files Browse the repository at this point in the history
  • Loading branch information
hunzikch committed Aug 15, 2024
1 parent 209848b commit 94d23bf
Show file tree
Hide file tree
Showing 4 changed files with 408 additions and 0 deletions.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
8 changes: 8 additions & 0 deletions smartmeter_datacollector/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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:
Expand Down
259 changes: 259 additions & 0 deletions smartmeter_datacollector/smartmeter/siemens_td3511.py
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit 94d23bf

Please sign in to comment.