From 8b1e4d709a14249cbee67df831549d0238d4e95e Mon Sep 17 00:00:00 2001 From: Holger Kupke <105754586+DeltaHotelKilo@users.noreply.github.com> Date: Mon, 12 Feb 2024 22:08:04 +0100 Subject: [PATCH] Initial commit --- .gitignore | 3 + dws7612.cfg | 32 ++++ dws7612.py | 396 ++++++++++++++++++++++++++++++++++++++++++++++++ dws7612.service | 13 ++ dws7612.sql | 97 ++++++++++++ 5 files changed, 541 insertions(+) create mode 100644 .gitignore create mode 100644 dws7612.cfg create mode 100644 dws7612.py create mode 100644 dws7612.service create mode 100644 dws7612.sql diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..13ad2dc --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +# ignore all .bak files +*.bak + diff --git a/dws7612.cfg b/dws7612.cfg new file mode 100644 index 0000000..cb41696 --- /dev/null +++ b/dws7612.cfg @@ -0,0 +1,32 @@ +[General] +# meter-reading-cycle in seconds, default: 60 +# valid values: 2 or greater +# sanity check: yes +cycle=60 + +[Meter] +# usb device, default: /dev/ttyUSB0 +# sanity check: no +device=/dev/ttyUSB0 + +[MySQL] +# MySQL parameters. There are no defaults! Logging can be disabled +# by either specifying the argument [-n] or [--nosql] in the command line, +# or by leaving one or more of the following parameters empty. +# sanity checks: no + +hostname= +username= +password= +database= + +# For security reasons, you should setup a specific mysql-user for +# the logger and grant SELECT and INSERT rights to the specific database only. + +# You may use the default database stucture by just executing the +# available script (dws7612.sql) on the corresponding mysql-host. If you do so, you +# do not need to edit the python script itself. + +# If you wish to use your own database structure, the function '_log_data' +# needs to be modified accordingly. + diff --git a/dws7612.py b/dws7612.py new file mode 100644 index 0000000..8d28465 --- /dev/null +++ b/dws7612.py @@ -0,0 +1,396 @@ +#!/usr/bin/python3 -u +# -*- coding: utf-8 -*- + +######################################################################### +# +# License: GNU General Public License 3 +# +# Copyright (C) 2024 Holger Kupke +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +######################################################################### + +"""Read and decode SML messages of a DWS7612.2 electric power meter. + Store the meter readings for positive active energy (1.8.0) and + negative active energy (2.8.0) in a MySQL database. + + Usage example: + python3 dws7612.py [-v] [-n] [--nosql] + + Design goals: + * Simplicity: no full SML decoder - DWS7612.2 specific format + * Run as a service: see file 'dws7612.service' + * Configurable: via 'dws7612.cfg' + * MySQL integration: readings can be stored in a mysql database + * + + References and remarks: + * Helmut Schmidt (https://github.com/huirad). + His scripts helped a lot decoding the SML messages and parts + of this script have been derived by his ED300L-script. + + * Klaus J. Mueller (https://volkszaehler.org) + I am using the vz-software for a very long time. The database + structure and functionalty is still based on hiis software. +""" + +__author__ = 'Holger Kupke' +__copyright__ = 'Copyright (\xa9) 2024, Holger Kupke, License: GNU GPLv3' +__version__ = '1.0.0' +__license__ = 'GNU General Public License 3' + +######################################################################### +# +# Module-History +# Date Author Reason +# 10-Feb-2024 Holger Kupke v1.0.0 Initial version +# +######################################################################### + +import os +import sys +import serial +import signal +import argparse +import threading +import configparser + +from time import sleep, time_ns + +import pymysql + +########################### class definitions ########################### + +class DWS7612Logger(threading.Thread): + # Obis IDs + _OID_180 = b'\x07\x01\x00\x01\x08\x00\xff' #Positive Active Energy + _OID_280 = b'\x07\x01\x00\x02\x08\x00\xff' #Negative Active Energy + + def __init__(self, port, cycle, hostname='', username='', password='', database='', verbose=False): + threading.Thread.__init__(self) + + # counters + self._positive = 0.0 + self._negative = 0.0 + + # USB port + self._port = port + + # read cycle + self._cycle = cycle + + + self._mysql = False + # mysql parameters + self._hostname = hostname + self._username = username + self._password = password + self._database = database + + if len(self._hostname) and \ + len(self._username) and \ + len(self._password) and \ + len(self._database): + self._mysql = True + + # diverse + self._verbose = verbose + self._running = False + self._run = True + + # public functions + def get_positive(self): + v = None + for x in range(10): + if self._running: + break + sleep(1) + + v = self._positive + return v + + def get_negative(self): + v = None + for x in range(10): + if self._running: + break + sleep(1) + return v + + def stop(self): + self._run = False + + # non-public functions + def _log_data(self): + if self._mysql: + try: + conn = pymysql.connect(host=self._hostname, + user=self._username, + password=self._password, + database=self._database, + cursorclass=pymysql.cursors.DictCursor) + + with conn: + with conn.cursor() as cursor: + ts = int(time_ns()/1000000) + sql = "INSERT INTO `data` (`entity_id`, `time`, `value`) VALUES (%s, %s, %s)" + cursor.execute(sql, ('2', str(ts), self._positive)) + sql = "INSERT INTO `data` (`entity_id`, `time`, `value`) VALUES (%s, %s, %s)" + cursor.execute(sql, ('3', str(ts), self._negative)) + conn.commit() + except (pymysql.Error) as e: + print('MySQL Error: %s\n' % e) + + def _get_octet_string(buffer, offset): + result = None + if (len(buffer)-offset) < 2: + pass + elif (buffer[offset] & 0xF0) == 0x00: # octet string up to 15 bytes + size = (buffer[offset] & 0x0F) # size including the 1-byte tag + if (len(buffer)-offset) >= size: + result = buffer[offset+1:offset+size] + elif (buffer[offset] & 0xF0) == 0x80: # larger octet string + pass # not yet handled + return result + + def _get_int(self, buffer, offset): + result = None + if (len(buffer)-offset) < 2: + pass + elif (buffer[offset] & 0xF0) == 0x50: # signed integer + size = (buffer[offset] & 0x0F) # size including the 1-byte tag + if (len(buffer)-offset) >= size: + tmp = buffer[offset+1:offset+size] + result = int.from_bytes(tmp, byteorder='big', signed=True) + elif (buffer[offset] & 0xF0) == 0x60: # unsigned integer + size = (buffer[offset] & 0x0F) # size including the 1-byte tag + if (len(buffer)-offset) >= size: + tmp = buffer[offset+1:offset+size] + result = int.from_bytes(tmp, byteorder='big', signed=False) + return result + + def _read_sml_message(self, ser): + if self._verbose: + print('Reading SML message...') + + start_seq = b'\x1b\x1b\x1b\x1b\x01\x01\x01\x01' + stop_seq = b'\x1b\x1b\x1b\x1b\x1a' + + msg = None + + while True: + data = ser.read_until(stop_seq) + data += ser.read(3) + start_idx = data.find(start_seq) + if start_idx >= 0: + break + + data = b'' + continue + + stop_idx = data.find(stop_seq, start_idx) + if stop_idx > start_idx: + msg = data[start_idx :(stop_idx + len(stop_seq) + 3)] + + return msg + + def run(self): + while self._run: + telegram = b'' + data = b'' + + try: + ser = serial.Serial(self._port, 9600, timeout=3) + msg = self._read_sml_message(ser) + ser.close() + + if len(msg) and self._run: + if self._verbose: + print('Message length: %d' % len(msg)) + print(msg.hex()) + print() + + # decode positive active energy (1.8.0) + offset = msg.find(self._OID_180) + self._positive = 0.0 + if offset > 0: + value = self._get_int(msg, offset+20) + if value == None: + value = 0 + self._positive = round((value/10000),3) + if self._verbose: + print('1.8.0: %s kWh' % str('%.3f' % (self._positive)).rjust(10)) + + # decode negative active energy (2.8.0) + offset = msg.find(self._OID_280) + self._negative = 0.0 + if offset > 0: + value = self._get_int(msg, offset+17) + if value == None: + value = 0 + self._negative = round((value/10000),3) + if self._verbose: + print('2.8.0: %s kWh' % str('%.3f' % (self._negative)).rjust(10)) + + if self._verbose: + print() + + # log the meter readings + self._log_data() + else: + if self._run: + print('Error: reading serial port (%s)' % (self._port)) + print() + except serial.SerialException as e: + print('Error: ' + str(e)) + if self._run == False: + break + sleep(2) + continue + + # stop() has been call, so let's exit the thread + if self._run == False: + break + + # ensure the meter has at least been read once + if self._running == False: + self._running = True + + # sleep loop + i = self._cycle * 100 + while i > 0: + if self._run == False: + break + i -=1 + sleep(0.01) + +class cfg: + #section [General] + cycle='' #read cycle in seconds - default: 60 + #section [DWS7612] + device='' #USB device - default: /dev/ttyUSB0 + #section [MySQL] + mysql_host='' #host name or ip address + mysql_user='' #user name + mysql_pwd='' #user password + mysql_db='' #database name + +########################### global functions ############################ + +def assert_python3(): + """ Assert that at least Python 3.5 is used + """ + assert(sys.version_info.major == 3) + assert(sys.version_info.minor >= 5) + +def signalHandler(num, frame): + if(num == signal.SIGINT): + print('\r \r', end='') + print('Interrupted by user.') + + sys.exit(0) + +def read_cfg(nosql=False): + cfg_file = os.path.dirname(os.path.abspath(__file__)) + '/dws7612.cfg' + print('Config: %s\n' % cfg_file) + + parser = configparser.ConfigParser() + parser.read(cfg_file) + + cfg.cycle = parser.getint('General', 'cycle', fallback=60) + if cfg.cycle < 2: + cfg.cycle = 60 + + cfg.device = parser.get('Meter', 'device', fallback='/dev/ttyUSB0') + + global mysql_logging + mysql_logging = False + + if nosql == False: + cfg.mysql_host = parser.get('MySQL', 'hostname', fallback='') + cfg.mysql_user = parser.get('MySQL', 'username', fallback='') + cfg.mysql_pwd = parser.get('MySQL', 'password', fallback='') + cfg.mysql_db = parser.get('MySQL', 'database', fallback='') + + if len(cfg.mysql_host) and \ + len(cfg.mysql_user) and \ + len(cfg.mysql_pwd) and \ + len(cfg.mysql_db): + mysql_logging = True; + +################################# main ################################## + +def main(): + + #setting up signal handlers + signal.signal(signal.SIGINT, signalHandler) + signal.signal(signal.SIGTERM, signalHandler) + + print('\033[1mDWS7612\033[0m - Electrical Meter Logger - v' + __version__) + print('----------------------------------------------------') + print(__copyright__ + '\n') + + global logger + logger = None + + global args + args = None + + parser = argparse.ArgumentParser() + parser.add_argument('-1', '--once', action='store_true', help='Implies -v: Read the meter data just once and exit.') + parser.add_argument('-v', '--verbose', action='store_true', help='Display runtime-information.') + parser.add_argument('-n', '--nosql', action='store_true', help='Disable mysql logging.') + args = parser.parse_args() + + if args.once: + args.verbose = True + + read_cfg(args.nosql) + + print('Device: ' + cfg.device) + print('Cycle: ' + str(cfg.cycle)) + if args.nosql: + print('Logging: \033[1;31mdisabled\033[0m\n') + else: + print('Logging: \033[1;32menabled\033[0m\n') + + if mysql_logging: + logger = DWS7612Logger(cfg.device, cfg.cycle, cfg.mysql_host, cfg.mysql_user, cfg.mysql_pwd, cfg.mysql_db, args.verbose) + else: + logger = DWS7612Logger(cfg.device, cfg.cycle, verbose=args.verbose) + logger.start() + + if args.once: + logger.get_positive() + else: + while True: + sleep(int(cfg.cycle)) + +if __name__ == '__main__': + try: + assert_python3() + main() + finally: + if logger != None: + if args.verbose: + print("Stopping logger thread...", end="") + + logger.stop() + logger.join() + + if args.verbose: + print("\033[0;32mdone\033[0m.") + + print('Bye.') diff --git a/dws7612.service b/dws7612.service new file mode 100644 index 0000000..9f7c8fb --- /dev/null +++ b/dws7612.service @@ -0,0 +1,13 @@ +[Unit] +Description=DWS7612 - Electrical Meter Logger +After=network.target + +[Service] +ExecStart=/usr/local/bin/dws7612/dws7612.py +ExecReload=/bin/kill -HUP $MAINPID +KillMode=process +Restart=on-failure + +[Install] +WantedBy=multi-user.target + diff --git a/dws7612.sql b/dws7612.sql new file mode 100644 index 0000000..d72b0a5 --- /dev/null +++ b/dws7612.sql @@ -0,0 +1,97 @@ +-- phpMyAdmin SQL Dump +-- version 5.2.1deb1 +-- https://www.phpmyadmin.net/ +-- +-- Host: localhost:3306 +-- Erstellungszeit: 12. Feb 2024 um 16:35 +-- Server-Version: 10.11.4-MariaDB-1~deb12u1 +-- PHP-Version: 8.2.7 + +SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO"; +START TRANSACTION; +SET time_zone = "+00:00"; + + +/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */; +/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */; +/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */; +/*!40101 SET NAMES utf8mb4 */; + +-- +-- Datenbank: `meter` +-- +CREATE DATABASE IF NOT EXISTS `meter` DEFAULT CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci; +USE `meter`; + +-- -------------------------------------------------------- + +-- +-- Tabellenstruktur für Tabelle `data` +-- + +CREATE TABLE IF NOT EXISTS `data` ( + `id` int(11) UNSIGNED NOT NULL AUTO_INCREMENT, + `entity_id` int(11) UNSIGNED NOT NULL, + `time` bigint(20) UNSIGNED NOT NULL, + `value` double NOT NULL, + PRIMARY KEY (`id`), + KEY `TIME` (`time`), + KEY `entity_id` (`entity_id`) USING BTREE +) ENGINE=InnoDB AUTO_INCREMENT=81906 DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_general_ci; + + +-- +-- Tabellenstruktur für Tabelle `entities` +-- + +CREATE TABLE IF NOT EXISTS `entities` ( + `id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT, + `description` varchar(128) NOT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_general_ci; + +-- +-- Daten für Tabelle `entities` +-- + +INSERT INTO `entities` (`id`, `description`) VALUES +(1, 'gas meter'), +(2, 'electric meter - 1.8.0'), +(3, 'electric meter - 2.8.0'); + +-- -------------------------------------------------------- + +-- +-- Tabellenstruktur für Tabelle `properties` +-- + +CREATE TABLE IF NOT EXISTS `properties` ( + `id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT, + `entity_id` int(10) UNSIGNED NOT NULL, + `pkey` varchar(128) NOT NULL, + `value` tinytext NOT NULL, + PRIMARY KEY (`id`), + KEY `entity_id` (`entity_id`) +) ENGINE=InnoDB AUTO_INCREMENT=70 DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_general_ci; + +-- +-- Daten für Tabelle `properties` +-- + +INSERT INTO `properties` (`id`, `entity_id`, `pkey`, `value`) VALUES +(1, 1, 'title', 'Gas'), +(2, 1, 'start', '2810.000'), +(3, 1, 'z', '0.9491'), +(4, 1, 'calorific', '11.563'), +(5, 1, 'rate', '0.1868'), +(6, 1, 'base', '113.05'), +(7, 2, 'title', 'Power (import)'), +(8, 2, 'rate', '30.73'), +(9, 2, 'base', '106.92'), +(10, 3, 'title', 'Power (export)'), +(11, 3, 'rate', '8.11'); +COMMIT; + +/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */; +/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */; +/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;