Browse Source

Added AsyncIO version

Stan Janssen 6 years ago
parent
commit
f273ff6016
1 changed files with 230 additions and 49 deletions
  1. 230 49
      energymeter.py

+ 230 - 49
energymeter.py

@@ -9,14 +9,16 @@ __email__   = 'stanjanssen@finetuned.nl'
 __url__     = 'https://finetuned.nl/'
 __license__ = 'Apache License, Version 2.0'
 
-__version__ = '0.73'
+__version__ = '1.0.1'
 __status__  = 'Beta'
 
 import random
 import socket
 import struct
+import asyncio
 import time
 import minimalmodbus
+from collections import Iterable
 
 class ABBMeter:
     """Meter class that uses a minimalmodbus Instrument to query an ABB meter."""
@@ -118,7 +120,7 @@ class ABBMeter:
 
     def _batch_read(self, registers):
         """
-        Read multiple registers in batches, limiting each batch to at most 128 registers.
+        Read multiple registers in batches, limiting each batch to at most 125 registers.
 
         Arguments:
             * A list of registers (complete structs)
@@ -131,7 +133,7 @@ class ABBMeter:
         batch = []
         results = {}
         for register in registers:
-            if register['start'] + register['length'] - start_reg < 128:
+            if register['start'] + register['length'] - start_reg <= 125:
                 batch.append(register)
             else:
                 results.update(self._read_multiple(batch))
@@ -539,26 +541,27 @@ class ABBMeter:
                }
     NULLS = [pow(2, n) - 1 for n in (64, 63, 32, 31, 16, 15)]
 
-class SMAMeter:
-    """
-    Implementation for a Sunny Boy SMA Modbus over TCP meter.
 
+class ModbusTCPMeter:
+    """
+    Implementation for a Modbus TCP Energy Meter.
     """
     def __init__(self, port, tcp_port=502, slaveaddress=126, type=None, baudrate=None):
-        self.device = socket.create_connection(address=(port, tcp_port))
+        self.port = port
+        self.tcp_port = tcp_port
         self.device_id = slaveaddress
 
     def read(self, regnames=None):
         if regnames is None:
-            registers = SMAMeter.REGS
+            registers = self.REGS
             return self._read_multiple(registers)
+
         if type(regnames) is str:
-            registers = [register for register in SMAMeter.REGS if register['name'] == regnames]
-            if len(registers) == 0:
-                return "Register not found on device."
+            registers = [register for register in self.REGS if register['name'] == regnames]
             return self._read_single(registers[0])
+
         if type(regnames) is list:
-            registers = [register for register in SMAMeter.REGS if register['name'] in regnames]
+            registers = [register for register in self.REGS if register['name'] in regnames]
             return self._read_multiple(registers)
 
     def _read_single(self, register):
@@ -568,23 +571,43 @@ class SMAMeter:
 
     def _read_multiple(self, registers):
         registers.sort(key=lambda reg: reg['start'])
-        first_reg = min([register['start'] for register in registers])
-        num_regs = max([register['start'] + register['length'] for register in registers]) - first_reg
-        message = self._modbus_message(start_reg=first_reg, num_regs=num_regs)
-        data = self._perform_request(message)
-        return self._interpret_result(data, registers)
+        results = {}
+        for reg_range in self._split_ranges(registers):
+            first_reg = min([register['start'] for register in reg_range])
+            num_regs = max([register['start'] + register['length'] for register in reg_range]) - first_reg
+            message = self._modbus_message(start_reg=first_reg, num_regs=num_regs)
+            data = self._perform_request(message)
+            results.update(self._interpret_result(data, reg_range))
+        return results
+
+    def _split_ranges(self, registers):
+        """
+        Generator that splits the registers list into continuous parts.
+        """
+        reg_list = []
+        prev_end = registers[0]['start'] - 1
+        for r in registers:
+            if r['start'] - prev_end > 1:
+                yield reg_list
+                reg_list = []
+            reg_list.append(r)
+            prev_end = r['start'] + r['length']
+        yield reg_list
+
 
     def _modbus_message(self, start_reg, num_regs):
         transaction_id = random.randint(1, 2**16 - 1)
         return struct.pack(">HHHBBHH", transaction_id,
-                                       SMAMeter.PROTOCOL_CODE,
+                                       self.PROTOCOL_CODE,
                                        6,
                                        self.device_id,
-                                       SMAMeter.FUNCTION_CODE,
-                                       start_reg - SMAMeter.REG_OFFSET,
+                                       self.FUNCTION_CODE,
+                                       start_reg - self.REG_OFFSET,
                                        num_regs)
 
     def _perform_request(self, message):
+        if self.device is None:
+            self._connect()
         self.device.send(message)
         data = bytes()
         expect_bytes = 9 + 2 * struct.unpack(">H", message[-2:])[0]
@@ -658,11 +681,20 @@ class SMAMeter:
 
         value = struct.unpack(formatcode_o, bytes(values))[0]
 
-        if value in SMAMeter.NULLS:
+        if value in self.NULLS:
             return None
         else:
             return float(value) / 10 ** decimals
 
+    def _connect(self):
+        self.device = socket.create_connection(address=(self.port, self.tcp_port))
+
+
+
+class SMAMeter(ModbusTCPMeter):
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
     REGS = [
             {'name': 'current_ac', 'start': 40188, 'length': 1, 'signed': False, 'decimals': 1},
             {'name': 'current_l1', 'start': 40189, 'length': 1, 'signed': False, 'decimals': 1},
@@ -691,31 +723,180 @@ class SMAMeter:
     FUNCTION_CODE = 3
     NULLS = [2**16 - 1, 2**15 - 1, 2**15, -2**15]
 
-if __name__ == "__main__":
-    import argparse
-
-    parser = argparse.ArgumentParser(description='Read energy meters.')
-    parser.add_argument('--device', '-d', dest='device', required=True, type=str, help='the serial port or IP-address for the device')
-    parser.add_argument('--baud', '-b', dest='baud', required=False, default=38400, type=int, help='the serial baudrate')
-    parser.add_argument('--type', '-t', dest='devtype', required=True, type=str, help='the type of energy meter: abb or sma')
-    parser.add_argument('--slaveaddress', '-a', dest='slaveaddress', default=1, nargs=1, type=int, help='the modbus slave address (only when using serial)')
-    parser.add_argument('--registers', '-r', dest='regs', type=str, help='the register you wish to read (default: all)', nargs='+')
-    args = parser.parse_args()
-
-    types = {'abb': ABBMeter,
-             'sma': SMAMeter}
-
-    meter = types[args.devtype](port=args.device, baudrate=args.baud, slaveaddress=args.slaveaddress[0])
-
-    if args.regs is None:
-        result = meter.read()
-    elif len(args.regs) == 1:
-        result = meter.read(args.regs[0])
-    else:
-        result = meter.read(args.regs)
-
-    if args.regs is None or len(args.regs) > 1:
-        for key in result:
-            print(key + ": " + str(result[key]))
-    else:
-        print(result)
+
+class MulticubeMeter(ModbusTCPMeter):
+    """
+    Implementation for a Multicube energy meter over Modbus TCP.
+    """
+    def __init__(self, port, tcp_port=1502, slaveaddress=1, type=None, baudrate=None, auto_scale=True):
+        super().__init__(port, tcp_port, slaveaddress, type, baudrate)
+        self.device = socket.create_connection(address=(port, tcp_port))
+        self.device_id = slaveaddress
+        self.REGS = [
+            {'name': 'energy_scale', 'start': 512, 'length': 2, 'decimals': 0, 'signed': True},
+            {'name': 'active_net', 'start': 514, 'length': 2, 'decimals': 0, 'signed': True},
+            {'name': 'apparent_net', 'start': 516, 'length': 2, 'decimals': 0, 'signed': True},
+            {'name': 'reactive_net', 'start': 518, 'length': 2, 'decimals': 0, 'signed': True},
+            {'name': 'active_power_total', 'start': 2816, 'length': 1, 'decimals': 0, 'signed': True},
+            {'name': 'apparent_power_total', 'start': 2817, 'length': 1, 'decimals': 0, 'signed': True},
+            {'name': 'reactive_power_total', 'start': 2818, 'length': 1, 'decimals': 0, 'signed': True},
+            {'name': 'power_factor_total', 'start': 2819, 'length': 1, 'decimals': 3, 'signed': True},
+            {'name': 'frequency', 'start': 2820, 'length': 1, 'decimals': 1, 'signed': True},
+            {'name': 'voltage_l1_n', 'start': 2821, 'length': 1, 'decimals': 0, 'signed': True},
+            {'name': 'current_l1', 'start': 2822, 'length': 1, 'decimals': 0, 'signed': True},
+            {'name': 'active_power_l1', 'start': 2823, 'length': 1, 'decimals': 0, 'signed': True},
+            {'name': 'voltage_l2_n', 'start': 2824, 'length': 1, 'decimals': 0, 'signed': True},
+            {'name': 'current_l2', 'start': 2825, 'length': 1, 'decimals': 0, 'signed': True},
+            {'name': 'active_power_l2', 'start': 2826, 'length': 1, 'decimals': 0, 'signed': True},
+            {'name': 'voltage_l3_n', 'start': 2827, 'length': 1, 'decimals': 0, 'signed': True},
+            {'name': 'current_l3', 'start': 2828, 'length': 1, 'decimals': 0, 'signed': True},
+            {'name': 'active_power_l3', 'start': 2829, 'length': 1, 'decimals': 0, 'signed': True},
+            {'name': 'power_factor_l1', 'start': 2830, 'length': 1, 'decimals': 0, 'signed': True},
+            {'name': 'power_factor_l2', 'start': 2831, 'length': 1, 'decimals': 0, 'signed': True},
+            {'name': 'power_factor_l3', 'start': 2832, 'length': 1, 'decimals': 0, 'signed': True},
+            {'name': 'voltage_l1_l2', 'start': 2833, 'length': 1, 'decimals': 0, 'signed': True},
+            {'name': 'voltage_l2_l3', 'start': 2834, 'length': 1, 'decimals': 0, 'signed': True},
+            {'name': 'voltage_l3_l1', 'start': 2835, 'length': 1, 'decimals': 0, 'signed': True},
+            {'name': 'current_n', 'start': 2836, 'length': 1, 'decimals': 0, 'signed': True},
+            {'name': 'amps_scale', 'start': 2837, 'length': 1, 'decimals': 0, 'signed': True},
+            {'name': 'phase_volts_scale', 'start': 2838, 'length': 1, 'decimals': 0, 'signed': True},
+            {'name': 'line_volts_scale', 'start': 2839, 'length': 1, 'decimals': 0, 'signed': True},
+            {'name': 'power_scale', 'start': 2840, 'length': 1, 'decimals': 0, 'signed': True},
+            {'name': 'apparent_power_l1', 'start': 3072, 'length': 1, 'decimals': 0, 'signed': True},
+            {'name': 'apparent_power_l2', 'start': 3073, 'length': 1, 'decimals': 0, 'signed': True},
+            {'name': 'apparent_power_l3', 'start': 3074, 'length': 1, 'decimals': 0, 'signed': True},
+            {'name': 'reactive_power_l1', 'start': 3075, 'length': 1, 'decimals': 0, 'signed': True},
+            {'name': 'reactive_power_l2', 'start': 3076, 'length': 1, 'decimals': 0, 'signed': True},
+            {'name': 'reactive_power_l3', 'start': 3077, 'length': 1, 'decimals': 0, 'signed': True},
+            {'name': 'peak_current_l1', 'start': 3078, 'length': 1, 'decimals': 0, 'signed': True},
+            {'name': 'peak_current_l2', 'start': 3079, 'length': 1, 'decimals': 0, 'signed': True},
+            {'name': 'peak_current_l3', 'start': 3080, 'length': 1, 'decimals': 0, 'signed': True},
+            {'name': 'current_l1_thd', 'start': 3081, 'length': 1, 'decimals': 3, 'signed': True},
+            {'name': 'current_l2_thd', 'start': 3082, 'length': 1, 'decimals': 3, 'signed': True},
+            {'name': 'current_l3_thd', 'start': 3083, 'length': 1, 'decimals': 3, 'signed': True}
+           ]
+        if auto_scale:
+            self.set_scaling()
+
+    def set_scaling(self):
+        """
+        Call this function before reading anything to set up the correct scaling for this meter.
+        """
+        decimals_mapping = {1: 2, 2: 1, 3: 0, 4: -1, 5: -2, 6: -3, 7: -4}
+
+        a_registers = ['current_l1', 'current_l2', 'current_l3', 'current_n']
+        scale = int(self.read('amps_scale'))
+        for r in self.REGS:
+            if r['name'] in a_registers:
+                r['decimals'] = decimals_mapping[scale]
+
+        pv_registers = ['voltage_l1_n', 'voltage_l2_n', 'voltage_l3_n']
+        scale = int(self.read('phase_volts_scale'))
+        for r in self.REGS:
+            if r['name'] in pv_registers:
+                r['decimals'] = decimals_mapping[scale]
+
+        lv_registers = ['voltage_l1_l2', 'voltage_l2_l3', 'voltage_l3_l1']
+        scale = int(self.read('line_volts_scale'))
+        for r in self.REGS:
+            if r['name'] in lv_registers:
+                r['decimals'] = decimals_mapping[scale]
+
+        p_registers = ['active_power_total',
+                       'reactive_power_total',
+                       'apparent_power_total',
+                       'active_power_l1',
+                       'active_power_l2',
+                       'active_power_l3',
+                       'apparent_power_l1',
+                       'apparent_power_l2',
+                       'apparent_power_l3',
+                       'reactive_power_l1',
+                       'reactive_power_l2',
+                       'reactive_power_l3']
+        scale = int(self.read('power_scale'))
+        for r in self.REGS:
+            if r['name'] in p_registers:
+                r['decimals'] = decimals_mapping[scale]
+
+        e_registers = ['active_net', 'apparent_net', 'reactive_net']
+        decimals_mapping = {3: 3, 4: 2, 5: 1, 6: 0, 7: -1}
+        scale = int(self.read('energy_scale'))
+        for r in self.REGS:
+            if r['name'] in e_registers:
+                r['decimals'] = decimals_mapping[scale]
+
+    REG_OFFSET = 0
+    PROTOCOL_CODE = 0
+    FUNCTION_CODE = 3
+    NULLS = [2**16 - 1, 2**15 - 1, 2**15, -2**15]
+
+
+class AsyncModbusTCPMeter(ModbusTCPMeter):
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.reader = self.writer = None
+
+    async def _connect(self):
+        self.reader, self.writer = await asyncio.open_connection(host=self.port, port=self.tcp_port)
+
+    async def read(self, regnames=None):
+        if regnames is None:
+            registers = self.REGS
+            return await self.read_multiple(registers)
+
+        if isinstance(regnames, str):
+            registers = [register for register in self.REGS if register['name'] == regnames]
+            if len(registers) == 0:
+                return "Register not found on device."
+            return await self.read_single(registers[0])
+
+        if isinstance(regnames, Iterable):
+            registers = [register for register in self.REGS if register['name'] in regnames]
+            return await self._read_multiple(registers)
+
+    async def _read_single(self, register):
+        message = self._modbus_message(start_reg=register['start'], num_regs=register['length'])
+        if self.writer is None:
+            await self._connect()
+        self.writer.write(message)
+        await self.writer.drain()
+
+        data = await self.reader.read_exactly(9 + 2 * num_regs)
+        return self._convert_value(data, signed=register['signed'], decimals=register['decimals'])
+
+    async def _read_multiple(self, registers):
+        registers.sort(key=lambda reg: reg['start'])
+        results = {}
+        for reg_range in self._split_ranges(registers):
+            # Prepare the request
+            first_reg = min([register['start'] for register in reg_range])
+            num_regs = max([register['start'] + register['length'] for register in reg_range]) - first_reg
+
+            if self.writer is None:
+                await self._connect()
+            self.writer.write(self._modbus_message(first_reg, num_regs))
+            await self.writer.drain()
+
+            # Receive the response
+
+            data = await self.reader.readexactly(9 + 2 * num_regs)
+            results.update(self._interpret_result(data[9:], reg_range))
+        return results
+
+
+class AsyncABBTCPMeter(AsyncModbusTCPMeter):
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        if type in ABBMeter.REGSETS:
+            self.registers = [register for register in ABBMeter.REGS if register['name'] in ABBMeter.REGSETS[type]]
+        else:
+            self.registers = ABBMeter.REGS
+
+    REGS = ABBMeter.REGS
+    REGSETS = ABBMeter.REGSETS
+    NULLS = ABBMeter.NULLS
+    PROTOCOL_CODE = 0
+    FUNCTION_CODE = 3
+    REG_OFFSET = 0