├── .gitignore ├── LICENSE ├── README.md ├── dpkg ├── .gitignore ├── build_pkg.sh └── content │ └── debian │ ├── changelog │ ├── compat │ ├── control │ ├── install │ ├── postinst │ ├── postrm │ ├── rules │ └── source │ └── format ├── img └── connection.png ├── kaifareader.py ├── meter_template.json └── systemd └── kaifareader.service /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # PyInstaller 10 | # Usually these files are written by a python script from a template 11 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 12 | *.manifest 13 | *.spec 14 | 15 | # Installer logs 16 | pip-log.txt 17 | pip-delete-this-directory.txt 18 | 19 | # idea workspace 20 | .idea/ 21 | 22 | # files that should not be pushed (privacy) 23 | meter.json 24 | 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Stefan Felkel 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Kaifa MA309 Smart Meter Logger 2 | 3 | ## Overview 4 | 5 | This script was made to read out the new Smart Meter "Kaifa MA309" 6 | used by Austrian power grid operators, here tested with TINETZ and EVN. 7 | 8 | Specification of the interface: 9 | https://www.tinetz.at/fileadmin/user_upload/Kundenservice/pdf/Beschreibung_Kundenschnittstelle_Smart_Meter_TINETZ.pdf 10 | 11 | Discussion about this script: 12 | https://www.photovoltaikforum.com/thread/157476-stromz%C3%A4hler-kaifa-ma309-welches-mbus-usb-kabel/?pageNo=2#post2350873 13 | 14 | Useful description of TiNetz frames: 15 | https://www.gurux.fi/node/18232 16 | 17 | This script was only tested with the above meter and the mentioned suppliers. 18 | 19 | ## Required Hardware 20 | 21 | ![Picture of wiring](img/connection.png) 22 | 23 | 1. RJ12 6P6C Plug 24 | 25 | e.g. https://www.amazon.de/6P6C-Stecker-6-polige-Schraubklemmen-Adapterstecker-CCTV-Adapterstecker/dp/B07KFGS3BF 26 |

27 | **Note**: you have to cut off the plastic shell of the RJ12 plug to fit it into the socket of the MA309. 28 |

29 | 2. MBUS Slave module like this: 30 | 31 | https://www.amazon.de/JOYKK-USB-zu-MBUS-Slave-Modul-Master-Slave-Kommunikation-Debugging-Bus%C3%BCberwachung/dp/B07PDH2ZBV 32 | 33 | ## Config 34 | 35 | Create a file `meter.json` to configure your serial connection 36 | parameters and your AES key. 37 | 38 | A template file `meter_template.json` can be recycled for this. 39 | 40 | ``` 41 | { 42 | "loglevel": "logging.INFO", 43 | "logfile": "/var/log/kaifareader/kaifa.log", 44 | "port": "/dev/ttyUSB0", 45 | "baudrate": 2400, 46 | "parity": "serial.PARITY_NONE", 47 | "stopbits": "serial.STOPBITS_ONE", 48 | "bytesize": "serial.EIGHTBITS", 49 | "key_hex_string": "", 50 | "interval": 15, 51 | "supplier": "TINETZ", 52 | "export_format": "SOLARVIEW", 53 | "export_file_abspath": "/var/run/kaifareader/kaifa.txt", 54 | "export_mqtt_server": "mymqtt.examplebroker.com", 55 | "export_mqtt_port": 1883, 56 | "export_mqtt_user": "mymqttuser", 57 | "export_mqtt_password": "supersecretmqttpass", 58 | "export_mqtt_basetopic": "kaifareader" 59 | } 60 | ``` 61 | 62 | The AES key format is "hex string", e.g. `a4f2d...` 63 | 64 | Please provide your electricity supplier by the field "supplier". Because each supplier uses its own security standard, 65 | the telegrams differ. This script was tested with suppliers: 66 | - TINETZ 67 | - EVN 68 | 69 | ### Export 70 | 71 | **Solarview** 72 | 73 | Currently, the export to a file readable by Solarview (http://solarview.info/) 74 | is supported. 75 | 76 | - The config key `export_format` has to be set to `SOLARVIEW` 77 | - The config key `export_file_abspath` has to be set to the absolute file path 78 | 79 | **MQTT** 80 | 81 | - The config key `export_format` has to be set to `MQTT` 82 | - The config keys `export_mqtt_server`, `export_mqtt_port`, 83 | `export_mqtt_user`, `export_mqtt_password` and `export_mqtt_basetopic` 84 | have to be set. 85 | 86 | ## Installation 87 | 88 | ### Systemd automatic service 89 | 90 | This installs and automatically starts a systemd service. 91 | 92 | Install the debian package 93 | 94 | `sudo dpkg -i kaifareader_...deb` 95 | 96 | If there are problems on missing packages, execute afterwards: 97 | 98 | `sudo apt -f install` 99 | 100 | ## Start 101 | 102 | ### Automatically, if service is installed and running 103 | 104 | Startup done, automatically. 105 | 106 | Status of the service: 107 | 108 | `sudo systemctl status kaifareader` 109 | 110 | Start manually (e.g. after manually stopped) 111 | 112 | `sudo systemctl start kaifareader` 113 | 114 | ### Manually 115 | 116 | #### Foreground 117 | 118 | `python3 kaifa.py` 119 | 120 | #### Background 121 | 122 | `nohup python3 kaifa.py &` 123 | 124 | ## Stop 125 | 126 | ### If service is installed and running 127 | 128 | `sudo systemctl stop kaifareader` 129 | 130 | ### Foreground 131 | 132 | Press CTRL+C 133 | 134 | ### Background 135 | 136 | Possible by killing the process 137 | 138 | -------------------------------------------------------------------------------- /dpkg/.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore generated files 2 | *.deb 3 | *.dsc 4 | *.changes 5 | *.build 6 | *.buildinfo 7 | *.tar.gz 8 | *.tar.xz 9 | 10 | # Ignore files generated during build 11 | */debian/files 12 | */debian/*.substvars 13 | */debian/*.log 14 | */debian/*.debhelper 15 | */debian/*-stamp 16 | */debian/kaifareader 17 | 18 | # Only care about debian/ directory 19 | # (other files are just copied into the structure) 20 | */* 21 | !*/debian/ 22 | 23 | -------------------------------------------------------------------------------- /dpkg/build_pkg.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | cd content 4 | 5 | # copy files to debian package structure 6 | mkdir -p usr/lib/kaifareader/ 7 | mkdir -p etc/kaifareader/ 8 | mkdir -p lib/systemd/system/ 9 | cp -v ../../kaifareader.py usr/lib/kaifareader/ 10 | cp -v ../../meter_template.json etc/kaifareader/ 11 | cp -v ../../systemd/kaifareader.service lib/systemd/system/ 12 | 13 | dpkg-buildpackage -uc -us 14 | 15 | if [ $? -ne 0 ]; then 16 | echo "Error creating debian package" 17 | exit 1 18 | fi 19 | 20 | cd .. 21 | -------------------------------------------------------------------------------- /dpkg/content/debian/changelog: -------------------------------------------------------------------------------- 1 | kaifareader (0.6) unstable; urgency=medium 2 | 3 | * mqtt: only execute if configured (thanks to @boredomwontgetus) 4 | 5 | -- Stefan Felkel <16918854+tirolerstefan@users.noreply.github.com> Sun, 30 Jan 2022 20:24:53 +0100 6 | 7 | kaifareader (0.5) unstable; urgency=medium 8 | 9 | * added MQTT (thanks to @boredomwontgetus) 10 | 11 | -- Stefan Felkel <16918854+tirolerstefan@users.noreply.github.com> Wed, 19 Jan 2022 20:25:37 +0100 12 | 13 | kaifareader (0.4) unstable; urgency=medium 14 | 15 | * rework parser (thanks to @kitzler-walli) 16 | * support of EVN telegrams (thanks to @dbeinder) 17 | * supplier classes for each supplier 18 | 19 | -- Stefan Felkel <16918854+tirolerstefan@users.noreply.github.com> Thu, 21 Oct 2021 20:10:28 +0200 20 | 21 | kaifareader (0.3) unstable; urgency=medium 22 | 23 | * serial stream: reworked parser to find two succeeding telegram 24 | 25 | -- Stefan Felkel <16918854+tirolerstefan@users.noreply.github.com> Sun, 05 Sep 2021 20:31:35 +0200 26 | 27 | kaifareader (0.2) unstable; urgency=medium 28 | 29 | * systemd: created /var/run/kaifareader and /var/log/kaifareader 30 | 31 | -- Stefan Felkel <16918854+tirolerstefan@users.noreply.github.com> Sun, 29 Aug 2021 19:30:29 +0200 32 | 33 | kaifareader (0.1) unstable; urgency=medium 34 | 35 | * Initial release. 36 | 37 | -- <16918854+tirolerstefan@users.noreply.github.com> Sun, 29 Aug 2021 09:01:26 +0200 38 | -------------------------------------------------------------------------------- /dpkg/content/debian/compat: -------------------------------------------------------------------------------- 1 | 13 2 | -------------------------------------------------------------------------------- /dpkg/content/debian/control: -------------------------------------------------------------------------------- 1 | Source: kaifareader 2 | Priority: optional 3 | Maintainer: Stefan Felkel <16918854+tirolerstefan@users.noreply.github.com> 4 | Standards-Version: 4.5.1 5 | Homepage: https://github.com/tirolerstefan/kaifa 6 | 7 | Package: kaifareader 8 | Architecture: all 9 | Depends: ${misc:Depends}, ${shlibs:Depends}, python3, python3-serial, python3-pycryptodome, 10 | python3-paho-mqtt 11 | Description: Read out Kaifa M309 using serial connection. 12 | -------------------------------------------------------------------------------- /dpkg/content/debian/install: -------------------------------------------------------------------------------- 1 | etc/kaifareader/meter_template.json 2 | lib/systemd/system/kaifareader.service 3 | usr/lib/kaifareader/kaifareader.py 4 | 5 | 6 | -------------------------------------------------------------------------------- /dpkg/content/debian/postinst: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | # summary of how this script can be called: 6 | # * `configure' 7 | # * `abort-upgrade' 8 | # * `abort-remove' `in-favour' 9 | # 10 | # * `abort-remove' 11 | # * `abort-deconfigure' `in-favour' 12 | # `removing' 13 | # 14 | # for details, see http://www.debian.org/doc/debian-policy/ or 15 | # the debian-policy package 16 | 17 | case "$1" in 18 | configure) 19 | # Adapt permissions of working directory 20 | mkdir -p /var/run/kaifareader 21 | chmod 777 /var/run/kaifareader 22 | 23 | mkdir -p /var/log/kaifareader 24 | chmod 777 /var/log/kaifareader 25 | ;; 26 | 27 | abort-upgrade|abort-remove|abort-deconfigure) 28 | ;; 29 | 30 | *) 31 | echo "postinst called with unknown argument \`$1'" >&2 32 | exit 1 33 | ;; 34 | esac 35 | 36 | # Automatically added by dh_installsystemd/12.1.1 37 | if [ "$1" = "configure" ] || [ "$1" = "abort-upgrade" ] || [ "$1" = "abort-deconfigure" ] || [ "$1" = "abort-remove" ] ; then 38 | # This will only remove masks created by d-s-h on package removal. 39 | deb-systemd-helper unmask 'kaifareader.service' >/dev/null || true 40 | 41 | # was-enabled defaults to true, so new installations run enable. 42 | if deb-systemd-helper --quiet was-enabled 'kaifareader.service'; then 43 | # Enables the unit on first installation, creates new 44 | # symlinks on upgrades if the unit file has changed. 45 | deb-systemd-helper enable 'kaifareader.service' >/dev/null || true 46 | else 47 | # Update the statefile to add new symlinks (if any), which need to be 48 | # cleaned up on purge. Also remove old symlinks. 49 | deb-systemd-helper update-state 'kaifareader.service' >/dev/null || true 50 | fi 51 | fi 52 | # End automatically added section 53 | 54 | # Automatically added by dh_installsystemd/12.1.1 55 | if [ "$1" = "configure" ] || [ "$1" = "abort-upgrade" ] || [ "$1" = "abort-deconfigure" ] || [ "$1" = "abort-remove" ] ; then 56 | if [ -d /run/systemd/system ]; then 57 | systemctl --system daemon-reload >/dev/null || true 58 | if [ -n "$2" ]; then 59 | _dh_action=restart 60 | else 61 | _dh_action=start 62 | fi 63 | deb-systemd-invoke $_dh_action 'kaifareader.service' >/dev/null || true 64 | fi 65 | fi 66 | # End automatically added section -------------------------------------------------------------------------------- /dpkg/content/debian/postrm: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | # Automatically added by dh_installsystemd/12.1.1 6 | if [ -d /run/systemd/system ]; then 7 | systemctl --system daemon-reload >/dev/null || true 8 | fi 9 | # End automatically added section 10 | 11 | # Automatically added by dh_installsystemd/12.1.1 12 | if [ "$1" = "remove" ]; then 13 | if [ -x "/usr/bin/deb-systemd-helper" ]; then 14 | deb-systemd-helper mask 'kaifareader.service' >/dev/null || true 15 | fi 16 | fi 17 | 18 | if [ "$1" = "purge" ]; then 19 | if [ -x "/usr/bin/deb-systemd-helper" ]; then 20 | deb-systemd-helper purge 'kaifareader.service' >/dev/null || true 21 | deb-systemd-helper unmask 'kaifareader.service' >/dev/null || true 22 | fi 23 | fi 24 | # End automatically added section -------------------------------------------------------------------------------- /dpkg/content/debian/rules: -------------------------------------------------------------------------------- 1 | #!/usr/bin/make -f 2 | # See debhelper(7) (uncomment to enable) 3 | # output every command that modifies files on the build system. 4 | #DH_VERBOSE = 1 5 | 6 | %: 7 | dh $@ 8 | -------------------------------------------------------------------------------- /dpkg/content/debian/source/format: -------------------------------------------------------------------------------- 1 | 3.0 (native) 2 | -------------------------------------------------------------------------------- /img/connection.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tirolerstefan/kaifa/71aa1594a04fe089c0262f2491400d498b6ea1b1/img/connection.png -------------------------------------------------------------------------------- /kaifareader.py: -------------------------------------------------------------------------------- 1 | #/usr/bin/python3 2 | 3 | import sys 4 | import re 5 | import serial 6 | import binascii 7 | from Cryptodome.Cipher import AES 8 | import json 9 | import signal 10 | import logging 11 | from logging.handlers import RotatingFileHandler 12 | import paho.mqtt.client as mqtt 13 | 14 | # 15 | # Trap CTRL+C 16 | # 17 | def signal_handler(sig, frame): 18 | print('Aborted by user with Ctrl+C!') 19 | g_ser.close() 20 | sys.exit(0) 21 | 22 | 23 | # create signal handler 24 | signal.signal(signal.SIGINT, signal_handler) 25 | 26 | # global logging object will be initialized after config is parsed 27 | g_log = None 28 | 29 | 30 | class Logger: 31 | def __init__(self, logfile, level): 32 | self._logfile = logfile 33 | self._level = level 34 | self._logger = None 35 | 36 | def init(self): 37 | self._logger = logging.getLogger('kaifa') 38 | handler = RotatingFileHandler(self._logfile, maxBytes=1024*1024*2, backupCount=1) 39 | formatter = logging.Formatter('%(asctime)s [%(levelname)s]: %(message)s') 40 | handler.setFormatter(formatter) 41 | self._logger.addHandler(handler) 42 | self._logger.setLevel(self._level) 43 | 44 | def set_level(self, level): 45 | self._level = level 46 | self._logger.setLevel(level) 47 | 48 | def debug(self, s): 49 | self._logger.debug(s) 50 | 51 | def info(self, s): 52 | self._logger.info(s) 53 | 54 | def error(self, s): 55 | self._logger.error(s) 56 | 57 | 58 | class Supplier: 59 | name = None 60 | frame1_start_bytes_hex = '68fafa68' 61 | frame1_start_bytes = b'\x68\xfa\xfa\x68' # 68 FA FA 68 62 | frame2_end_bytes = b'\x16' 63 | ic_start_byte = None 64 | enc_data_start_byte = None 65 | 66 | 67 | class SupplierTINETZ(Supplier): 68 | name = "TINETZ" 69 | frame2_start_bytes_hex = '68727268' 70 | frame2_start_bytes = b'\x68\x72\x72\x68' # 68 72 72 68 71 | ic_start_byte = 23 72 | enc_data_start_byte = 27 73 | 74 | 75 | class SupplierEVN(Supplier): 76 | name = "EVN" 77 | frame2_start_bytes_hex = '68141468' 78 | frame2_start_bytes = b'\x68\x14\x14\x68' # 68 14 14 68 79 | ic_start_byte = 22 80 | enc_data_start_byte = 26 81 | 82 | 83 | class Constants: 84 | config_file = "/etc/kaifareader/meter.json" 85 | export_format_solarview = "SOLARVIEW" 86 | 87 | class DataType: 88 | NullData = 0x00 89 | Boolean = 0x03 90 | BitString = 0x04 91 | DoubleLong = 0x05 92 | DoubleLongUnsigned = 0x06 93 | OctetString = 0x09 94 | VisibleString = 0x0A 95 | Utf8String = 0x0C 96 | BinaryCodedDecimal = 0x0D 97 | Integer = 0x0F 98 | Long = 0x10 99 | Unsigned = 0x11 100 | LongUnsigned = 0x12 101 | Long64 = 0x14 102 | Long64Unsigned = 0x15 103 | Enum = 0x16 104 | Float32 = 0x17 105 | Float64 = 0x18 106 | DateTime = 0x19 107 | Date = 0x1A 108 | Time = 0x1B 109 | Array = 0x01 110 | Structure = 0x02 111 | CompactArray = 0x13 112 | 113 | class Config: 114 | def __init__(self, file): 115 | self._file = file 116 | self._config = {} 117 | 118 | def load(self): 119 | try: 120 | with open(self._file, "r") as f: 121 | self._config = json.load(f) 122 | except Exception as e: 123 | print("Error loading config file {}".format(self._file)) 124 | return False 125 | return True 126 | 127 | def get_config(self): 128 | return self._config 129 | 130 | # returns log level of logging facility (e.g. logging.DEBUG) 131 | def get_loglevel(self): 132 | return eval(self._config["loglevel"]) 133 | 134 | def get_logfile(self): 135 | return self._config["logfile"] 136 | 137 | def get_port(self): 138 | return self._config["port"] 139 | 140 | def get_baud(self): 141 | return self._config["baudrate"] 142 | 143 | def get_parity(self): 144 | return eval(self._config["parity"]) 145 | 146 | def get_stopbits(self): 147 | return eval(self._config["stopbits"]) 148 | 149 | def get_bytesize(self): 150 | return eval(self._config["bytesize"]) 151 | 152 | def get_key_hex_string(self): 153 | return self._config["key_hex_string"] 154 | 155 | def get_interval(self): 156 | return self._config["interval"] 157 | 158 | def get_supplier(self): 159 | return str(self._config["supplier"]) 160 | 161 | def get_export_format(self): 162 | if not "export_format" in self._config: 163 | return None 164 | else: 165 | return self._config["export_format"] 166 | 167 | def get_export_file_abspath(self): 168 | if not "export_file_abspath" in self._config: 169 | return None 170 | else: 171 | return self._config["export_file_abspath"] 172 | 173 | def get_export_mqtt_server(self): 174 | if not "export_mqtt_server" in self._config: 175 | return None 176 | else: 177 | return self._config["export_mqtt_server"] 178 | 179 | def get_export_mqtt_port(self): 180 | if not "export_mqtt_port" in self._config: 181 | return None 182 | else: 183 | return self._config["export_mqtt_port"] 184 | 185 | def get_export_mqtt_user(self): 186 | if not "export_mqtt_user" in self._config: 187 | return None 188 | else: 189 | return self._config["export_mqtt_user"] 190 | 191 | def get_export_mqtt_password(self): 192 | if not "export_mqtt_password" in self._config: 193 | return None 194 | else: 195 | return self._config["export_mqtt_password"] 196 | 197 | def get_export_mqtt_basetopic(self): 198 | if not "export_mqtt_basetopic" in self._config: 199 | return None 200 | else: 201 | return self._config["export_mqtt_basetopic"] 202 | 203 | 204 | class Obis: 205 | def to_bytes(code): 206 | return bytes([int(a) for a in code.split(".")]) 207 | VoltageL1 = to_bytes("01.0.32.7.0.255") 208 | VoltageL2 = to_bytes("01.0.52.7.0.255") 209 | VoltageL3 = to_bytes("01.0.72.7.0.255") 210 | CurrentL1 = to_bytes("1.0.31.7.0.255") 211 | CurrentL2 = to_bytes("1.0.51.7.0.255") 212 | CurrentL3 = to_bytes("1.0.71.7.0.255") 213 | RealPowerIn = to_bytes("1.0.1.7.0.255") 214 | RealPowerOut = to_bytes("1.0.2.7.0.255") 215 | RealEnergyIn = to_bytes("1.0.1.8.0.255") 216 | RealEnergyIn_S = '1.8.0' # String of Positive active energy (A+) total [Wh] (needed for export) 217 | RealEnergyOut = to_bytes("1.0.2.8.0.255") 218 | RealEnergyOut_S = '2.8.0' # String of Negative active energy (A-) total [Wh] (needed for export) 219 | ReactiveEnergyIn = to_bytes("1.0.3.8.0.255") 220 | ReactiveEnergyOut = to_bytes("1.0.4.8.0.255") 221 | Factor = to_bytes("01.0.13.7.0.255") 222 | 223 | 224 | class Exporter: 225 | def __init__(self, file, exp_format): 226 | self._file = file 227 | self._format = exp_format 228 | self._export_map = {} 229 | 230 | def set_value(self, obis_string, value): 231 | self._export_map[obis_string] = value 232 | 233 | def _write_out_solarview(self, file): 234 | file.write("/?!\n") # Start bytes 235 | file.write("/KAIFA\n") # Meter ID 236 | 237 | for key in self._export_map.keys(): 238 | # e.g. 1.8.0(005305.034*kWh) 239 | file.write("{}({:010.3F}*kWh)\n".format(key, self._export_map[key])) 240 | 241 | file.write("!\n") # End byte 242 | 243 | def write_out(self): 244 | try: 245 | with open(self._file, "w") as f: 246 | if self._format == Constants.export_format_solarview: 247 | self._write_out_solarview(f) 248 | except Exception as e: 249 | g_log.error("Error writing to file {}: {}".format(self._file, str(e))) 250 | return False 251 | 252 | return True 253 | 254 | 255 | # class Decrypt 256 | # with help of @micronano 257 | # https://www.photovoltaikforum.com/thread/157476-stromz%C3%A4hler-kaifa-ma309-welches-mbus-usb-kabel/?postID=2341069#post2341069 258 | class Decrypt: 259 | 260 | def __init__(self, supplier: Supplier, frame1, frame2, key_hex_string): 261 | g_log.debug("Decrypt: FRAME1:\n{}".format(binascii.hexlify(frame1))) 262 | g_log.debug("Decrypt: FRAME2:\n{}".format(binascii.hexlify(frame2))) 263 | key = binascii.unhexlify(key_hex_string) # convert to binary stream 264 | systitle = frame1[11:19] # systitle at byte 12, length 8 265 | g_log.debug("SYSTITLE: {}".format(binascii.hexlify(systitle))) 266 | ic = frame1[supplier.ic_start_byte:supplier.ic_start_byte+4] # invocation counter length 4 267 | g_log.debug("IC: {} / {}".format(binascii.hexlify(ic), int.from_bytes(ic,'big'))) 268 | iv = systitle + ic # initialization vector 269 | g_log.debug("IV: {}".format(binascii.hexlify(iv))) 270 | data_frame1 = frame1[supplier.enc_data_start_byte:len(frame1) - 2] # start at byte 26 or 27 (dep on supplier), excluding 2 bytes at end: checksum byte, end byte 0x16 271 | data_frame2 = frame2[9:len(frame2) - 2] # start at byte 10, excluding 2 bytes at end: checksum byte, end byte 0x16 272 | g_log.debug("DATA FRAME1\n{}".format(binascii.hexlify(data_frame1))) 273 | g_log.debug("DATA FRAME1\n{}".format(binascii.hexlify(data_frame2))) 274 | # print(binascii.hexlify(data_t1)) 275 | # print(binascii.hexlify(data_t2)) 276 | data_encrypted = data_frame1 + data_frame2 277 | cipher = AES.new(key, AES.MODE_GCM, nonce=iv) 278 | self._data_decrypted = cipher.decrypt(data_encrypted) 279 | self._data_decrypted_hex = binascii.hexlify(self._data_decrypted) 280 | 281 | g_log.debug(self._data_decrypted_hex) 282 | 283 | # init OBIS values 284 | self._act_energy_pos_kwh = 0 285 | self._act_energy_neg_kwh = 0 286 | 287 | def parse_all(self): 288 | decrypted = self._data_decrypted 289 | pos = 0 290 | total = len(decrypted) 291 | self.obis = {} 292 | while pos < total: 293 | if decrypted[pos] != DataType.OctetString: 294 | pos += 1 295 | continue 296 | if decrypted[pos + 1] != 6: 297 | pos += 1 298 | continue 299 | obis_code = decrypted[pos + 2 : pos + 2 + 6] 300 | data_type = decrypted[pos + 2 + 6] 301 | pos += 2 + 6 + 1 302 | 303 | g_log.debug("OBIS code {} DataType {}".format(binascii.hexlify(obis_code),data_type)) 304 | if data_type == DataType.DoubleLongUnsigned: 305 | value = int.from_bytes(decrypted[pos : pos + 4], "big") 306 | scale = decrypted[pos + 4 + 3] 307 | if scale > 128: scale -= 256 308 | pos += 2 + 8 309 | self.obis[obis_code] = value*(10**scale) 310 | g_log.debug("DLU: {}, {}, {}".format(value, scale, value*(10**scale))) 311 | #print(obis) 312 | elif data_type == DataType.LongUnsigned: 313 | value = int.from_bytes(decrypted[pos : pos + 2], "big") 314 | scale = decrypted[pos + 2 + 3] 315 | if scale > 128: scale -= 256 316 | pos += 8 317 | self.obis[obis_code] = value*(10**scale) 318 | g_log.debug("LU: {}, {}, {}".format(value, scale, value*(10**scale))) 319 | elif data_type == DataType.OctetString: 320 | octet_len = decrypted[pos] 321 | octet = decrypted[pos + 1 : pos + 1 + octet_len] 322 | pos += 1 + octet_len + 2 323 | self.obis[obis_code] = octet 324 | g_log.debug("OCTET: {}, {}".format(octet_len, octet)) 325 | 326 | def get_act_energy_pos_kwh(self): 327 | if Obis.RealEnergyIn in self.obis: 328 | return self.obis[Obis.RealEnergyIn] / 1000 329 | else: 330 | return None 331 | 332 | def get_act_energy_neg_kwh(self): 333 | if Obis.RealEnergyOut in self.obis: 334 | return self.obis[Obis.RealEnergyOut] / 1000 335 | else: 336 | return None 337 | 338 | 339 | def mqtt_on_connect(client, userdata, flags, rc): 340 | if rc == 0: 341 | g_log.info("MQTT: Client connected; rc={}".format(rc)) 342 | else: 343 | g_log.error("MQTT: Client bad RC; rc={}".format(rc)) 344 | 345 | def mqtt_on_disconnect(client, userdata, rc): 346 | g_log.info("MQTT: Client disconnected; rc={}".format(rc)) 347 | if rc != 0: 348 | g_log.info("MQTT: Trying auto-reconnect; rc={}".format(rc)) 349 | else: 350 | g_log.error("MQTT: Client bad RC; rc={}".format(rc)) 351 | 352 | # 353 | # Script Start 354 | # 355 | 356 | serial_read_chunk_size=100 357 | 358 | g_cfg = Config(Constants.config_file) 359 | 360 | if not g_cfg.load(): 361 | print("Could not load config file") 362 | sys.exit(10) 363 | 364 | g_log = Logger(g_cfg.get_logfile(), g_cfg.get_loglevel()) 365 | 366 | try: 367 | g_log.init() 368 | except Exception as e: 369 | print("Could not initialize logging system: " + str(e)) 370 | sys.exit(20) 371 | 372 | 373 | g_ser = serial.Serial( 374 | port = g_cfg.get_port(), 375 | baudrate = g_cfg.get_baud(), 376 | parity = g_cfg.get_parity(), 377 | stopbits = g_cfg.get_stopbits(), 378 | bytesize = g_cfg.get_bytesize(), 379 | timeout = g_cfg.get_interval()) 380 | 381 | if g_cfg.get_supplier().upper() == SupplierTINETZ.name: 382 | g_supplier = SupplierTINETZ() 383 | elif g_cfg.get_supplier().upper() == SupplierEVN.name: 384 | g_supplier = SupplierEVN() 385 | else: 386 | raise Exception("Supplier not supported: {}".format(g_cfg.get_supplier())) 387 | 388 | # connect to mqtt broker 389 | if g_cfg.get_export_format() == 'MQTT': 390 | try: 391 | mqtt_client = mqtt.Client("kaifareader") 392 | mqtt_client.on_connect = mqtt_on_connect 393 | mqtt_client.on_disconnect = mqtt_on_disconnect 394 | mqtt_client.username_pw_set(g_cfg.get_export_mqtt_user(), g_cfg.get_export_mqtt_password()) 395 | mqtt_client.connect(g_cfg.get_export_mqtt_server(), port=g_cfg.get_export_mqtt_port()) 396 | mqtt_client.loop_start() 397 | except Exception as e: 398 | print("Failed to connect: " + str(e)) 399 | sys.exit(40) 400 | 401 | # main task endless loop 402 | while True: 403 | stream = b'' # filled by serial device 404 | frame1 = b'' # parsed telegram1 405 | frame2 = b'' # parsed telegram2 406 | 407 | frame1_start_pos = -1 # pos of start bytes of telegram 1 (in stream) 408 | frame2_start_pos = -1 # pos of start bytes of telegram 2 (in stream) 409 | 410 | # "telegram fetching loop" (as long as we have found two full telegrams) 411 | # frame1 = first telegram (68fafa68), frame2 = second telegram (68727268) 412 | while True: 413 | 414 | # Read in chunks. Each chunk will wait as long as specified by 415 | # serial timeout. As the meters we tested send data every 5s the 416 | # timeout must be <5. Lower timeouts make us fail quicker. 417 | byte_chunk = g_ser.read(size=serial_read_chunk_size) 418 | stream += byte_chunk 419 | frame1_start_pos = stream.find(g_supplier.frame1_start_bytes) 420 | frame2_start_pos = stream.find(g_supplier.frame2_start_bytes) 421 | 422 | # fail as early as possible if we find the segment is not complete yet. 423 | if ( 424 | (stream.find(g_supplier.frame1_start_bytes) < 0) or 425 | (stream.find(g_supplier.frame2_start_bytes) <= 0) or 426 | (stream[-1:] != g_supplier.frame2_end_bytes) or 427 | (len(byte_chunk) == serial_read_chunk_size) 428 | ): 429 | g_log.debug("pos: {} | {}".format(frame1_start_pos, frame2_start_pos)) 430 | g_log.debug("incomplete segment: {} ".format(stream)) 431 | g_log.debug("received chunk: {} ".format(byte_chunk)) 432 | continue 433 | 434 | g_log.debug("pos: {} | {}".format(frame1_start_pos, frame2_start_pos)) 435 | 436 | if (frame2_start_pos != -1): 437 | # frame2_start_pos could be smaller than frame1_start_pos 438 | if frame2_start_pos < frame1_start_pos: 439 | # start over with the stream from frame1 pos 440 | stream = stream[frame1_start_pos:len(stream)] 441 | continue 442 | 443 | # we have found at least two complete telegrams 444 | regex = binascii.unhexlify('28'+g_supplier.frame1_start_bytes_hex+'7c'+g_supplier.frame2_start_bytes_hex+'29') # re = '(..|..)' 445 | l = re.split(regex, stream) 446 | l = list(filter(None, l)) # remove empty elements 447 | # l after split (here in following example in hex) 448 | # l = ['68fafa68', '53ff00...faecc16', '68727268', '53ff...3d16', '68fafa68', '53ff...d916', '68727268', '53ff.....'] 449 | 450 | g_log.debug(binascii.hexlify(stream)) 451 | g_log.debug(l) 452 | 453 | # take the first two matching telegrams 454 | for i, el in enumerate(l): 455 | if el == g_supplier.frame1_start_bytes: 456 | frame1 = l[i] + l[i+1] 457 | frame2 = l[i+2] + l[i+3] 458 | break 459 | 460 | # check for weird result -> exit 461 | if (len(frame1) == 0) or (len(frame2) == 0): 462 | g_log.error("Frame1 or Frame2 is empty: {} | {}".format(frame1, frame2)) 463 | sys.exit(30) 464 | 465 | g_log.debug("TELEGRAM1:\n{}\n".format(binascii.hexlify(frame1))) 466 | g_log.debug("TELEGRAM2:\n{}\n".format(binascii.hexlify(frame2))) 467 | 468 | break 469 | 470 | dec = Decrypt(g_supplier, frame1, frame2, g_cfg.get_key_hex_string()) 471 | dec.parse_all() 472 | 473 | g_log.info("1.8.0: {}".format(str(dec.get_act_energy_pos_kwh()))) 474 | g_log.info("2.8.0: {}".format(str(dec.get_act_energy_neg_kwh()))) 475 | 476 | # export solarview 477 | if g_cfg.get_export_format() == 'SOLARVIEW': 478 | exp = Exporter(g_cfg.get_export_file_abspath(), g_cfg.get_export_format()) 479 | exp.set_value(Obis.RealEnergyIn_S, dec.get_act_energy_pos_kwh()) 480 | exp.set_value(Obis.RealEnergyOut_S, dec.get_act_energy_neg_kwh()) 481 | if not exp.write_out(): 482 | g_log.error("Could not export data") 483 | sys.exit(50) 484 | 485 | # export mqtt 486 | if g_cfg.get_export_format() == 'MQTT': 487 | mqtt_pub_ret = mqtt_client.publish("{}/RealEnergyIn_S".format(g_cfg.get_export_mqtt_basetopic()), dec.get_act_energy_pos_kwh()) 488 | g_log.debug("MQTT: Publish message: rc: {} mid: {}".format(mqtt_pub_ret[0], mqtt_pub_ret[1])) 489 | mqtt_pub_ret = mqtt_client.publish("{}/RealEnergyOut_S".format(g_cfg.get_export_mqtt_basetopic()), dec.get_act_energy_neg_kwh()) 490 | g_log.debug("MQTT: Publish message: rc: {} mid: {}".format(mqtt_pub_ret[0], mqtt_pub_ret[1])) 491 | 492 | -------------------------------------------------------------------------------- /meter_template.json: -------------------------------------------------------------------------------- 1 | { 2 | "loglevel": "logging.INFO", 3 | "logfile": "/var/log/kaifareader/kaifa.log", 4 | "port": "/dev/ttyUSB0", 5 | "baudrate": 2400, 6 | "parity": "serial.PARITY_NONE", 7 | "stopbits": "serial.STOPBITS_ONE", 8 | "bytesize": "serial.EIGHTBITS", 9 | "key_hex_string": "", 10 | "interval": 1, 11 | "supplier": "TINETZ", 12 | "export_format": "SOLARVIEW", 13 | "export_file_abspath": "/var/run/kaifareader/kaifa.txt", 14 | "export_mqtt_server": "mymqtt.examplebroker.com", 15 | "export_mqtt_port": 1883, 16 | "export_mqtt_user": "mymqttuser", 17 | "export_mqtt_password": "supersecretmqttpass", 18 | "export_mqtt_basetopic": "kaifareader" 19 | } 20 | -------------------------------------------------------------------------------- /systemd/kaifareader.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=kaifareader 3 | After=syslog.target network.target ntp.service 4 | 5 | [Service] 6 | ExecStartPre=/bin/mkdir -p /var/run/kaifareader 7 | ExecStartPre=/bin/mkdir -p /var/log/kaifareader 8 | ExecStartPre=/bin/chmod 777 /var/run/kaifareader 9 | ExecStartPre=/bin/chmod 777 /var/log/kaifareader 10 | ExecStart=/usr/bin/python3 /usr/lib/kaifareader/kaifareader.py 11 | StandardOutput=null 12 | 13 | [Install] 14 | WantedBy=multi-user.target 15 | --------------------------------------------------------------------------------