├── .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 | 
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 |
--------------------------------------------------------------------------------