pax_global_header00006660000000000000000000000064150200250540014503gustar00rootroot0000000000000052 comment=dd6666b49e6193ed1355f68902b08b08612e06ea Iskramis-pyiskra-dd6666b/000077500000000000000000000000001502002505400153705ustar00rootroot00000000000000Iskramis-pyiskra-dd6666b/.github/000077500000000000000000000000001502002505400167305ustar00rootroot00000000000000Iskramis-pyiskra-dd6666b/.github/workflows/000077500000000000000000000000001502002505400207655ustar00rootroot00000000000000Iskramis-pyiskra-dd6666b/.github/workflows/python-publish.yml000066400000000000000000000020741502002505400245000ustar00rootroot00000000000000# This workflow will upload a Python Package using Twine when a release is created # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries # This workflow uses actions that are not certified by GitHub. # They are provided by a third-party and are governed by # separate terms of service, privacy policy, and support # documentation. name: Upload Python Package on: release: types: [published] permissions: contents: read jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v3 with: python-version: '3.x' - name: Install dependencies run: | python -m pip install --upgrade pip pip install build - name: Build package run: python -m build - name: Publish package uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 with: user: __token__ password: ${{ secrets.PYPI_API_TOKEN }} Iskramis-pyiskra-dd6666b/.gitignore000066400000000000000000000000461502002505400173600ustar00rootroot00000000000000__pycache__/ dist/ build/ *.egg-info/ Iskramis-pyiskra-dd6666b/README.md000066400000000000000000000043741502002505400166570ustar00rootroot00000000000000# PyIskra PyIskra is a Python package designed to provide interface for Iskra devices using SG and it's RestAPI or ModbusTCP/RTU. ## Installation You can install PyIskra using pip: ```bash pip install pyiskra ``` ## Features - Object-oriented mapping for energy meters - Access to measurements - Access to Energy counters ## Supported devices - Smart Gateway - IE38 - IE14 - MCXXX - iMCXXX - MTXXX - iMTXXX ## Usage Here's a basic example of how to use PyIskra: ``` import asyncio from pyiskra.devices import Device from pyiskra.adapters import RestAPI device_ip = "192.168.1.1" device_auth = {"username": "admin", "password": "iskra"} async def main(): # Set device IP address # Create adapter adapter = RestAPI(ip_address=device_ip, authentication=device_auth) # Create device device = await Device.create_device(adapter) # Initalize device await device.init() devices = [device] if device.is_gateway: devices += device.get_child_devices() for device in devices: # Update device status print(f"Updating status for {device.model} {device.serial}") await device.update_status() if device.supports_measurements: for index, phase in enumerate(device.measurements.phases): print(f"Phase {index+1} - U: {phase.voltage.value} {phase.voltage.units}, I: {phase.current.value} {phase.current.units} P: {phase.active_power.value} {phase.active_power.units} Q: {phase.reactive_power.value} {phase.reactive_power.units} S: {phase.apparent_power.value} {phase.apparent_power.units} PF: {phase.power_factor.value} {phase.power_factor.units} PA: {phase.power_angle.value} {phase.power_angle.units} THD U: {phase.thd_voltage.value} {phase.thd_voltage.units} THD I: {phase.thd_current.value} {phase.thd_current.units}") if device.supports_counters: for counter in device.counters.non_resettable : print(f"Non-resettable counter - Name: {counter.name}, Value: {counter.value} {counter.units}, Direction: {counter.direction}") for counter in device.counters.resettable: print(f"Resettable counter - Name: {counter.name}, Value: {counter.value} {counter.units}, Direction: {counter.direction}") asyncio.run(main()) ``` Iskramis-pyiskra-dd6666b/examples/000077500000000000000000000000001502002505400172065ustar00rootroot00000000000000Iskramis-pyiskra-dd6666b/examples/Discovery.py000066400000000000000000000072711502002505400215360ustar00rootroot00000000000000from pyiskra.discovery import Discovery from pyiskra.devices.BaseDevice import Device from pyiskra.adapters import RestAPI, Modbus from pyiskra.exceptions import DeviceConnectionError, ProtocolNotSupported import asyncio import logging import netifaces logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) authentication = {"username": "admin", "password": "iskra"} async def main(): discovery = Discovery() # Get all network interfaces interfaces = netifaces.interfaces() # Get the broadcast addresses for each interface broadcast_addresses = [] for interface in interfaces: addresses = netifaces.ifaddresses(interface) if netifaces.AF_INET in addresses: ipv4_addresses = addresses[netifaces.AF_INET] for address in ipv4_addresses: if "broadcast" in address: broadcast_addresses.append(address["broadcast"]) discovered_devices = await discovery.get_devices(broadcast_addresses) if not discovered_devices: logger.warning("No Iskra devices discovered.") return devices = [] for device in discovered_devices: try: iskra_device = await Device.create_device( RestAPI(ip_address=device.ip_address, authentication=authentication) ) except (DeviceConnectionError, ProtocolNotSupported) as e: try: iskra_device = await Device.create_device( Modbus( protocol="tcp", ip_address=device.ip_address, modbus_address=device.modbus_address, ) ) except Exception as e: logger.error( f"Failed to create Device object for {device.model} {device.serial}: {e}" ) continue try: await iskra_device.init() devices.append(iskra_device) except Exception as e: logger.error( f"Failed to create Device object for {device.model} {device.serial}: {e}" ) for device in devices.copy(): if device.is_gateway: devices += device.get_child_devices() while True: for device in devices: logger.info(f"Updating status for {device.model} {device.serial}") await device.update_status() message = "" if device.supports_measurements: message += f"Timestamp: {device.measurements.timestamp}\n" for index, phase in enumerate(device.measurements.phases): message += f"Phase {index+1} - U: {phase.voltage.value} {phase.voltage.units}, I: {phase.current.value} {phase.current.units} P: {phase.active_power.value} {phase.active_power.units} Q: {phase.reactive_power.value} {phase.reactive_power.units} S: {phase.apparent_power.value} {phase.apparent_power.units} PF: {phase.power_factor.value} {phase.power_factor.units} PA: {phase.power_angle.value} {phase.power_angle.units} THD U: {phase.thd_voltage.value} {phase.thd_voltage.units} THD I: {phase.thd_current.value} {phase.thd_current.units}\n" if device.supports_counters: for counter in device.counters.non_resettable: message += f"Non-resettable counter, Value: {counter.value}{counter.units}, Direction: {counter.direction} \n" for counter in device.counters.resettable: message += f"Resettable counter, Value: {counter.value}{counter.units}, Direction: {counter.direction} \n" logger.info(message) await asyncio.sleep(5) if __name__ == "__main__": asyncio.run(main()) Iskramis-pyiskra-dd6666b/examples/ModbusRTU.py000066400000000000000000000030541502002505400214060ustar00rootroot00000000000000import asyncio from pyiskra.devices import Device from pyiskra.adapters import Modbus async def main(): # Create adapter adapter = Modbus(protocol="rtu", port="COM4") await adapter.get_basic_info() # Create device device = await Device.create_device(adapter) # Initalize device await device.init() # Update device status print(f"Updating status for {device.model} {device.serial}") await device.update_status() if device.supports_measurements: for index, phase in enumerate(device.measurements.phases): print( f"Phase {index+1} - U: {phase.voltage.value}{phase.voltage.units}, I: {phase.current.value}{phase.current.units} P: {phase.active_power.value}{phase.active_power.units} Q: {phase.reactive_power.value}{phase.reactive_power.units} S: {phase.apparent_power.value}{phase.apparent_power.units} PF: {phase.power_factor.value}{phase.power_factor.units} PA: {phase.power_angle.value}{phase.power_angle.units} THD U: {phase.thd_voltage.value}{phase.thd_voltage.units} THD I: {phase.thd_current.value}{phase.thd_current.units}" ) if device.supports_counters: for counter in device.counters.non_resettable: print( f"Non-resettable counter, Value: {counter.value}{counter.units}, Direction: {counter.direction}" ) for counter in device.counters.resettable: print( f"Resettable counter Value: {counter.value}{counter.units}, Direction: {counter.direction}" ) asyncio.run(main()) Iskramis-pyiskra-dd6666b/examples/ModbusTCP.py000066400000000000000000000034561502002505400213700ustar00rootroot00000000000000import asyncio from pyiskra.devices import Device from pyiskra.adapters import Modbus Device_ip = "10.34.11.104" async def main(): # Set device IP address # Create adapter adapter = Modbus(protocol="tcp", ip_address=Device_ip) await adapter.get_basic_info() # Create device device = await Device.create_device(adapter) # Initalize device await device.init() devices = [device] if device.is_gateway: devices += device.get_child_devices() for device in devices: # Update device status print(f"Updating status for {device.model} {device.serial}") await device.update_status() if device.supports_measurements: for index, phase in enumerate(device.measurements.phases): print( f"Phase {index+1} - U: {phase.voltage.value}{phase.voltage.units}, I: {phase.current.value}{phase.current.units} P: {phase.active_power.value}{phase.active_power.units} Q: {phase.reactive_power.value}{phase.reactive_power.units} S: {phase.apparent_power.value}{phase.apparent_power.units} PF: {phase.power_factor.value}{phase.power_factor.units} PA: {phase.power_angle.value}{phase.power_angle.units} THD U: {phase.thd_voltage.value}{phase.thd_voltage.units} THD I: {phase.thd_current.value}{phase.thd_current.units}" ) if device.supports_counters: for counter in device.counters.non_resettable: print( f"Non-resettable counter, Value: {counter.value}{counter.units}, Direction: {counter.direction}" ) for counter in device.counters.resettable: print( f"Resettable counter Value: {counter.value}{counter.units}, Direction: {counter.direction}" ) asyncio.run(main()) Iskramis-pyiskra-dd6666b/examples/RestAPI.py000066400000000000000000000035341502002505400210340ustar00rootroot00000000000000import asyncio from pyiskra.devices import Device from pyiskra.adapters import RestAPI device_ip = "10.34.94.12" device_auth = {"username": "admin", "password": "iskra"} async def main(): # Set device IP address # Create adapter adapter = RestAPI(ip_address=device_ip, authentication=device_auth) # Create device device = await Device.create_device(adapter) # Initalize device await device.init() devices = [device] if device.is_gateway: devices += device.get_child_devices() for device in devices: # Update device status print(f"Updating status for {device.model} {device.serial}") await device.update_status() if device.supports_measurements: for index, phase in enumerate(device.measurements.phases): print( f"Phase {index+1} - U: {phase.voltage.value} {phase.voltage.units}, I: {phase.current.value} {phase.current.units} P: {phase.active_power.value} {phase.active_power.units} Q: {phase.reactive_power.value} {phase.reactive_power.units} S: {phase.apparent_power.value} {phase.apparent_power.units} PF: {phase.power_factor.value} {phase.power_factor.units} PA: {phase.power_angle.value} {phase.power_angle.units} THD U: {phase.thd_voltage.value} {phase.thd_voltage.units} THD I: {phase.thd_current.value} {phase.thd_current.units}" ) if device.supports_counters: for counter in device.counters.non_resettable: print( f"Non-resettable counter, Value: {counter.value} {counter.units}, Direction: {counter.direction}" ) for counter in device.counters.resettable: print( f"Resettable counter, Value: {counter.value} {counter.units}, Direction: {counter.direction}" ) asyncio.run(main()) Iskramis-pyiskra-dd6666b/pyiskra/000077500000000000000000000000001502002505400170525ustar00rootroot00000000000000Iskramis-pyiskra-dd6666b/pyiskra/__init__.py000066400000000000000000000000611502002505400211600ustar00rootroot00000000000000from .helper import CounterType, MeasurementType Iskramis-pyiskra-dd6666b/pyiskra/adapters/000077500000000000000000000000001502002505400206555ustar00rootroot00000000000000Iskramis-pyiskra-dd6666b/pyiskra/adapters/BaseAdapter.py000066400000000000000000000003001502002505400233730ustar00rootroot00000000000000class Adapter: """Base class for all adapters.""" async def get_basic_info(self): """Init status.""" # Re-defined in all sub-classes raise NotImplementedError Iskramis-pyiskra-dd6666b/pyiskra/adapters/Modbus.py000066400000000000000000000164601502002505400224670ustar00rootroot00000000000000import logging from pymodbus.client import AsyncModbusTcpClient from pymodbus.client import AsyncModbusSerialClient from .BaseAdapter import Adapter from ..helper import BasicInfo, ModbusMapper from ..exceptions import InvalidResponseCode, DeviceConnectionError log = logging.getLogger(__name__) class Modbus(Adapter): """Adapter class for making REST API calls.""" def __init__( self, protocol: str, ip_address=None, modbus_address=33, port=10001, stopbits=2, bytesize=8, parity="N", baudrate=115200, ): """ Initialize the RestAPI adapter. Args: ip_address (str): The IP address of the REST API. device_index (int, optional): The index of the device. Defaults to None. authentication (dict, optional): The authentication credentials. Defaults to None. """ self.modbus_address = modbus_address self.ip_address = ip_address self.port = port if protocol == "tcp": self.protocol = "tcp" self.client = AsyncModbusTcpClient(host=ip_address, port=port, timeout=1) elif protocol == "rtu": self.protocol = "rtu" self.client = AsyncModbusSerialClient( port=port, stopbits=stopbits, bytesize=bytesize, parity=parity, baudrate=baudrate, timeout=1, ) else: raise ValueError("Invalid protocol") @staticmethod def convert_registers_to_string(registers): """Converts a list of 16-bit registers to a string, separating each 8 bits of the register for each character.""" string = "" for register in registers: high_byte = register >> 8 low_byte = register & 0xFF string += chr(high_byte) + chr(low_byte) return string.split("\0")[0].strip() async def open_connection(self): """Connects to the device.""" log.debug(f"Connecting to the device {self.ip_address}") if not self.client: raise DeviceConnectionError("The connection is not configured") await self.client.connect() if not self.connected: if self.protocol == "tcp": raise DeviceConnectionError( f"Failed to connect to the device {self.ip_address} on port {self.port}" ) elif self.protocol == "rtu": raise DeviceConnectionError( f"Failed to connect to the device on port {self.port}" ) async def close_connection(self): """Closes the connection to the device.""" log.debug(f"Closing the connection to the device {self.ip_address}") if not self.client: raise DeviceConnectionError("The connection is not configured") self.client.close() if self.connected: if self.protocol == "tcp": raise DeviceConnectionError( f"Failed to close the connection to the device {self.ip_address} on port {self.port}" ) elif self.protocol == "rtu": raise DeviceConnectionError( f"Failed to close the connection to the device on port {self.port}" ) @property def connected(self) -> bool: """Returns the connection status.""" return self.client.connected async def get_basic_info(self): """ Retrieves basic information about the device. Returns: BasicInfo: An object containing the basic information of the device. """ basic_info = {} # Open the connection await self.open_connection() try: data = await self.read_input_registers(1, 14) mapper = ModbusMapper(data, 1) basic_info["model"] = mapper.get_string_range(1, 8) basic_info["serial"] = mapper.get_string_range(9, 4) basic_info["sw_ver"] = mapper.get_uint16(13) / 100 data = await self.read_holding_registers(101, 40) mapper = ModbusMapper(data, 101) except Exception as e: await self.close_connection() raise DeviceConnectionError(f"Failed to read basic info: {e}") from e # Close the connection await self.close_connection() basic_info["description"] = mapper.get_string_range(101, 20) basic_info["location"] = mapper.get_string_range(121, 20) return BasicInfo(**basic_info) async def read_holding_registers(self, start, count, max_registers_per_read=120): """ Reads any number of registers by splitting large requests into chunks. Args: start (int): The starting address of the registers. count (int): The total number of registers to read. max_registers_per_read (int): Maximum registers per Modbus request (default: 120). Returns: list: Combined list of all read registers. """ handle_connection = not self.connected if handle_connection: await self.open_connection() registers = [] try: for offset in range(0, count, max_registers_per_read): chunk_start = start + offset remaining = count - offset chunk_count = min(remaining, max_registers_per_read) response = await self.client.read_holding_registers( chunk_start, count=chunk_count, slave=self.modbus_address ) registers.extend(response.registers) except Exception as e: raise DeviceConnectionError(f"Failed to read holding registers: {e}") from e finally: if handle_connection: await self.close_connection() return registers async def read_input_registers(self, start, count, max_registers_per_read=120): """ Reads any number of input registers by splitting large requests into chunks. Args: start (int): The starting address of the registers. count (int): The total number of registers to read. max_registers_per_read (int): Maximum registers per Modbus request (default: 120). Returns: list: Combined list of all read registers. """ handle_connection = not self.connected if handle_connection: await self.open_connection() registers = [] try: for offset in range(0, count, max_registers_per_read): chunk_start = start + offset remaining = count - offset chunk_count = min(remaining, max_registers_per_read) response = await self.client.read_input_registers( chunk_start, count=chunk_count, slave=self.modbus_address ) registers.extend(response.registers) except Exception as e: raise DeviceConnectionError(f"Failed to read input registers: {e}") from e finally: if handle_connection: await self.close_connection() return registersIskramis-pyiskra-dd6666b/pyiskra/adapters/RestAPI.py000066400000000000000000000327501502002505400225050ustar00rootroot00000000000000import aiohttp import base64 import logging from aiohttp import ClientConnectionError, ServerTimeoutError from .BaseAdapter import Adapter from ..helper import ( BasicInfo, Measurements, Measurement, Phase_Measurements, Total_Measurements, Counters, Counter, get_counter_type, ) from ..exceptions import ( NotAuthorised, ProtocolNotSupported, InvalidResponseCode, DeviceConnectionError, DeviceTimeoutError, ) log = logging.getLogger(__name__) class RestAPI(Adapter): """Adapter class for making REST API calls.""" def __init__(self, ip_address, device_index=None, authentication=None): """ Initialize the RestAPI adapter. Args: ip_address (str): The IP address of the REST API. device_index (int, optional): The index of the device. Defaults to None. authentication (dict, optional): The authentication credentials. Defaults to None. """ self.ip_address = ip_address self.device_index = device_index self.authentication = authentication async def get_resource(self, resource): """ Get a resource from the REST API. Args: resource (str): The resource path. Returns: dict: The parsed JSON response. Raises: Exception: If an error occurs while making the REST API call. """ headers = {} if self.authentication: authentication = self.authentication if (password := authentication.get("password")) and ( username := authentication.get("username") ): headers["cookie"] = "Authorization=Basic " + base64.b64encode( (username + ":" + password).encode("utf-8") ).decode("utf-8") # Set a timeout for the REST API call try: timeout = aiohttp.ClientTimeout(total=10) async with aiohttp.ClientSession(timeout=timeout) as session: async with session.get( f"http://{self.ip_address}/{resource}", headers=headers ) as response: return await RestAPI.handle_response(response) except ServerTimeoutError as e: raise DeviceTimeoutError( f"Timeout occurred while connecting to the RestAPI: {e}" ) from e except ClientConnectionError as e: raise DeviceConnectionError( f"Error occurred while connecting to the RestAPI: {e}" ) from e except NotAuthorised as e: raise NotAuthorised(f"Not authorised to access the device: {e}") from e except ProtocolNotSupported as e: raise ProtocolNotSupported(f"{e}") from e except InvalidResponseCode: raise DeviceConnectionError( f"Invalid response code while connecting to the RestAPI" ) except Exception as e: raise DeviceConnectionError( f"Error occurred while connecting to the RestAPI: {e}" ) from e @staticmethod async def handle_response(response): """ Handle the HTTP response. Args: response: The HTTP response object. Returns: dict: The parsed JSON response. Raises: Exception: If the response status is unexpected. """ if response.status == 200: return await RestAPI.parse_resource(response) elif response.status == 403: raise NotAuthorised("Not authorised") elif response.status == 404: raise ProtocolNotSupported("Device not supported by RestAPI") else: raise InvalidResponseCode("Invalid response code") @staticmethod async def parse_resource(response): """ Parse the HTTP response. Args: response: The HTTP response object. Returns: dict: The parsed JSON response. """ return await response.json() async def get_devices(self): """ Get the list of devices. Returns: list: The list of devices. """ child_devices = (await self.get_resource("api/devices")).get("devices", []) return child_devices def parse_measurement(self, measurement, default_unit=None): # New format: dict with 'value' and 'unit' if ( isinstance(measurement, dict) and "value" in measurement and "unit" in measurement ): return Measurement(float(measurement["value"]), measurement["unit"]) elif isinstance(measurement, str): parts = measurement.split() if len(parts) == 2: return Measurement(float(parts[0]), parts[1]) elif len(parts) == 1 and default_unit: return Measurement(float(parts[0]), default_unit) # Already a float/int, use default unit if provided elif isinstance(measurement, (float, int)) and default_unit: return Measurement(float(measurement), default_unit) return None async def get_measurements(self): """ Get the measurements. Returns: Measurements: The measurements object. Raises: ConnectionError: If an error occurs while connecting to the RestAPI. """ json_data = None if self.device_index is not None and self.device_index >= 0: json_data = await self.get_resource( "api/measurement/" + str(self.device_index) ) json_data = json_data.get("measurements", {}) phases = [] total = None frequency = None temperature = None for phase in json_data.get("Phases", []): voltage = None current = None active_power = None reactive_power = None apparent_power = None power_factor = None power_angle = None thd_voltage = None thd_current = None temp = phase.get("U", phase.get("voltage", None)) if temp: voltage = self.parse_measurement(temp) temp = phase.get("I", phase.get("current", None)) if temp: current = self.parse_measurement(temp) temp = phase.get("P", phase.get("active_power", None)) if temp: active_power = self.parse_measurement(temp) temp = phase.get("Q", phase.get("reactive_power", None)) if temp: reactive_power = self.parse_measurement(temp) temp = phase.get("S", phase.get("apparent_power", None)) if temp: apparent_power = self.parse_measurement(temp) temp = phase.get("PF", phase.get("power_factor", None)) if temp: power_factor = self.parse_measurement(temp) temp = phase.get("PA", phase.get("power_angle", None)) if temp: power_angle = self.parse_measurement(temp, "°") temp = phase.get("THDUp", phase.get("THD_voltage", None)) if temp: thd_voltage = self.parse_measurement(temp, "%") temp = phase.get("THDI", phase.get("THD_current", None)) if temp: thd_current = self.parse_measurement(temp, "%") phases.append( Phase_Measurements( voltage, current, active_power, reactive_power, apparent_power, power_factor, power_angle, thd_voltage, thd_current, ) ) if json_data.get("Total"): total_measurements = json_data.get("Total", {}) temp = total_measurements.get( "P", total_measurements.get("active_power", None) ) if temp: active_power = self.parse_measurement(temp) temp = total_measurements.get( "Q", total_measurements.get("reactive_power", None) ) if temp: reactive_power = self.parse_measurement(temp) temp = total_measurements.get( "S", total_measurements.get("apparent_power", None) ) if temp: apparent_power = self.parse_measurement(temp) temp = total_measurements.get( "PF", total_measurements.get("power_factor", None) ) if temp: power_factor = self.parse_measurement(temp) temp = total_measurements.get( "PA", total_measurements.get("power_angle", None) ) if temp: power_angle = self.parse_measurement(temp, "°") total = Total_Measurements( active_power, reactive_power, apparent_power, power_factor, power_angle, ) temp = json_data.get("Frequency", json_data.get("frequency", None)) if temp: frequency = self.parse_measurement(temp, "Hz") temp = json_data.get("Temperature", json_data.get("temperature", None)) if temp: temperature = self.parse_measurement(temp, "°C") return Measurements( phases=phases, total=total, frequency=frequency, temperature=temperature, ) return None async def get_counters(self): """ Get the counters. Returns: tuple: A tuple containing the non-resettable, resettable counters. Raises: ConnectionError: If an error occurs while connecting to the RestAPI. """ json_data = None json_data = await self.get_resource("api/counter/" + str(self.device_index)) json_data = json_data.get("counters", {}) non_resettable = [] resettable = [] if json_data.get("non_resettable"): for counter in json_data.get("non_resettable", []): try: conunter_type = get_counter_type( counter.get("direction"), counter.get("unit") ) non_resettable.append( Counter( counter.get("value"), counter.get("unit"), counter.get("direction"), conunter_type, ) ) except Exception as e: log.error( "Failed to parse non-resettable counter: %s: %s", counter.get("name"), e, ) if json_data.get("resettable"): for counter in json_data.get("resettable", []): try: conunter_type = get_counter_type( counter.get("direction"), counter.get("unit") ) resettable.append( Counter( counter.get("value"), counter.get("unit"), counter.get("direction"), conunter_type, ) ) except Exception as e: log.error( "Failed to parse resettable counter: %s: %s", counter.get("name"), e, ) return Counters(non_resettable, resettable) async def get_basic_info(self): """ Retrieves basic information about the device. Returns: BasicInfo: An object containing the basic information of the device. """ json_data = None if self.device_index is not None and self.device_index >= 0: try: child_devices = await self.get_devices() except Exception as e: raise DeviceConnectionError( f"Failed to connect to the device: {e}" ) from e # filter out the right IR device child_devices = [ device for device in child_devices if "Right IR" not in device["interface"] ] json_data = child_devices[self.device_index] else: json_data = (await self.get_resource("api")).get("device", {}) # fix syntax to be consistent with other devices json_data["model"] = json_data["model_type"].strip() json_data["serial"] = json_data["serial_number"].strip() del json_data["model_type"] del json_data["serial_number"] return BasicInfo( serial=json_data.get("serial"), model=json_data.get("model"), description=json_data.get("description"), location=json_data.get("location"), sw_ver=json_data.get("sw_ver"), ) Iskramis-pyiskra-dd6666b/pyiskra/adapters/__init__.py000066400000000000000000000000701502002505400227630ustar00rootroot00000000000000from .RestAPI import RestAPI from .Modbus import Modbus Iskramis-pyiskra-dd6666b/pyiskra/devices/000077500000000000000000000000001502002505400204745ustar00rootroot00000000000000Iskramis-pyiskra-dd6666b/pyiskra/devices/BaseDevice.py000066400000000000000000000112701502002505400230410ustar00rootroot00000000000000import asyncio import socket import re from ..exceptions import DeviceConnectionError, DeviceNotSupported, ProtocolNotSupported from ..adapters import RestAPI class Device: """ Represents a base device in the Iskra system. Attributes: model (str): The model of the device. serial (str): The serial number of the device. description (str): The description of the device. location (str): The location of the device. supports_measurements (bool): Indicates whether the device supports measurements. supports_counters (bool): Indicates whether the device supports counters. measurements (None or dict): The measurements data of the device. counters (None or dict): The counters data of the device. update_timestamp (int): The timestamp of the last update. """ DEVICE_PARAMETERS = {} model = "" serial = "" description = "" location = "" is_gateway = False supports_measurements = False supports_counters = False measurements = None counters = None phases = 0 non_resettable_counters = 0 resettable_counters = 0 fw_version = None parent_device = None update_timestamp = 0 @staticmethod async def create_device(adapter, parent_device=None): """ Creates a device based on the adapter. Args: adapter: The adapter used to communicate with the device. Returns: An instance of the appropriate device subclass based on the model. Raises: ConnectionError: If failed to get basic info from the adapter. ValueError: If the device model is unsupported. """ basic_info = await adapter.get_basic_info() model = basic_info.model from .IMPACT import Impact if any( re.match(model_pattern, model) for model_pattern in Impact.DEVICE_PARAMETERS.keys() ): return Impact(adapter, parent_device) from .WM import WM if any( re.match(model_pattern, model) for model_pattern in WM.DEVICE_PARAMETERS.keys() ): return WM(adapter, parent_device) from .MeasuringCenter import MeasuringCentre if any( re.match(model_pattern, model) for model_pattern in MeasuringCentre.DEVICE_PARAMETERS.keys() ): return MeasuringCentre(adapter, parent_device) from .SmartGateway import SmartGateway if any( re.match(model_pattern, model) for model_pattern in SmartGateway.DEVICE_PARAMETERS.keys() ): if isinstance(adapter, RestAPI): return SmartGateway(adapter, parent_device) else: # only REST API is supported for SmartGateway raise ProtocolNotSupported(f"Unsupported device model: {model}") raise DeviceNotSupported(f"Unsupported device model: {model}") def __init__(self, adapter, parent_device=None): """ Initializes the Iskra Device. Args: adapter: The adapter used to communicate with the device. """ self.adapter = adapter self.update_lock = asyncio.Lock() self.parent_device = parent_device async def get_basic_info(self): """ Retrieves basic information from the device. Returns: dict: A dictionary containing the basic information. """ basic_info = await self.adapter.get_basic_info() self.serial = basic_info.serial self.model = basic_info.model self.description = basic_info.description # Use regular expressions to match the model name and assign parameters accordingly for model_pattern, parameters in self.DEVICE_PARAMETERS.items(): if re.match(model_pattern, self.model): self.phases = parameters["phases"] self.resettable_counters = parameters["resettable_counters"] self.non_resettable_counters = parameters["non_resettable_counters"] break return basic_info async def update_status(self): """ Updates the status of the device. This method needs to be re-defined in all sub-classes. """ raise NotImplementedError async def init(self): """ Initializes the status of the device. This method needs to be re-defined in all sub-classes. """ raise NotImplementedError @property def ip_address(self): """ Returns the IP address of the device. Returns: The IP address of the device. """ return self.adapter.ip_address Iskramis-pyiskra-dd6666b/pyiskra/devices/IMPACT.py000066400000000000000000000255341502002505400220340ustar00rootroot00000000000000import logging import time import asyncio import struct import re from .BaseDevice import Device from ..adapters import RestAPI, Modbus from ..helper import ( IntervalMeasurementStats, MeasurementType, ModbusMapper, Measurements, Measurement, Phase_Measurements, Total_Measurements, Counter, Counters, counter_units, get_counter_direction, get_counter_type, ) from ..exceptions import MeasurementTypeNotSupported log = logging.getLogger(__name__) class Impact(Device): """ Represents an Impact device. Attributes: supports_measurements (bool): Indicates whether the device supports measurements. supports_counters (bool): Indicates whether the device supports counters. fw_version (float): The firmware version of the device. """ DEVICE_PARAMETERS = { "IE38": {"phases": 3, "resettable_counters": 16, "non_resettable_counters": 4}, "IE14": {"phases": 1, "resettable_counters": 8, "non_resettable_counters": 4}, # Add more models as needed } supports_measurements = True supports_counters = True supports_interval_measurements = True async def init(self): """ Initializes the Impact device. This method retrieves basic information, updates the status, and logs a success message. """ await self.get_basic_info() await self.update_status() log.debug(f"Successfully initialized {self.model} {self.serial}") async def get_measurements( self, measurement_type: MeasurementType = MeasurementType.ACTUAL_MEASUREMENTS ): """ Retrieves measurements from the device. Returns: dict: A dictionary containing the measurements. """ if ( measurement_type != MeasurementType.ACTUAL_MEASUREMENTS and self.supports_interval_measurements == False ): raise MeasurementTypeNotSupported( f"{measurement_type} is not supported by {self.model}" ) if isinstance(self.adapter, RestAPI): log.debug( f"Getting measurements from Rest API for {self.model} {self.serial}" ) return await self.adapter.get_measurements() elif isinstance(self.adapter, Modbus): log.debug( f"Getting measurements from Modbus for {self.model} {self.serial}" ) offset = 0 last_interval_duration = None time_since_last_measurement = None avg_measurement_counter = None # Other measurement type registers are just shifted if measurement_type == MeasurementType.AVERAGE_MEASUREMENTS: offset = 5400 elif measurement_type == MeasurementType.MAX_MEASUREMENTS: offset = 5500 elif measurement_type == MeasurementType.MAX_MEASUREMENTS: offset = 5600 interval_stats = None data = await self.adapter.read_input_registers(100 + offset, 91) mapper = ModbusMapper(data, 100) if measurement_type != MeasurementType.ACTUAL_MEASUREMENTS: interval_stats = IntervalMeasurementStats() interval_data = await self.adapter.read_input_registers(5500, 2) interval_stats_mapper = ModbusMapper(interval_data, 100) interval_stats.last_interval_duration = ( interval_stats_mapper.get_uint16(100) / 10 ) interval_stats.time_since_last_measurement = ( interval_stats_mapper.get_int16(101) / 10 ) phases = [] for phase in range(self.phases): voltage = Measurement( mapper.get_t5(107 + 2 * phase), "V", ) current = Measurement( mapper.get_t5(126 + 2 * phase), "A", ) active_power = Measurement( mapper.get_t6(142 + 2 * phase), "W", ) reactive_power = Measurement( mapper.get_t6(150 + 2 * phase), "var", ) apparent_power = Measurement( mapper.get_t5(158 + 2 * phase), "VA", ) power_factor = Measurement( mapper.get_t7(166 + 2 * phase)["value"], "", ) power_angle = Measurement( mapper.get_int16(173 + phase) / 100, "°", ) thd_voltage = Measurement( mapper.get_uint16(182 + phase) / 100, "%", ) thd_current = Measurement( mapper.get_uint16(188 + phase) / 100, "%", ) phases.append( Phase_Measurements( voltage, current, active_power, reactive_power, apparent_power, power_factor, power_angle, thd_voltage, thd_current, ) ) active_power_total = Measurement( mapper.get_t6(140), "W", ) reactive_power_total = Measurement( mapper.get_t6(148), "var", ) apparent_power_total = Measurement( mapper.get_t5(156), "VA", ) power_factor_total = Measurement( mapper.get_t7(164)["value"], "", ) power_angle_total = Measurement( mapper.get_int16(172) / 100, "°", ) frequency = Measurement( mapper.get_t5(105), "Hz", ) temperature = Measurement( mapper.get_int16(181) / 100, "°C", ) total = Total_Measurements( active_power_total, reactive_power_total, apparent_power_total, power_factor_total, power_angle_total, ) return Measurements(phases, total, frequency, temperature, interval_stats) async def get_counters(self): """ Retrieves counters from the device. Returns: dict: A dictionary containing the counters. """ if isinstance(self.adapter, RestAPI): log.debug(f"Getting counters from Rest API for {self.model} {self.serial}") return await self.adapter.get_counters() elif isinstance(self.adapter, Modbus): # Open the connection handle_connection = not self.adapter.connected if handle_connection: await self.adapter.open_connection() log.debug(f"Getting counters from Modbus for {self.model} {self.serial}") data = await self.adapter.read_input_registers(2750, 96) data_mapper = ModbusMapper(data, 2750) direction_settings = await self.adapter.read_holding_registers(151, 1) non_resettable_counter_settings = await self.adapter.read_holding_registers( 421, 16 ) non_resettable_settings_mapper = ModbusMapper( non_resettable_counter_settings, 421 ) resettable_counter_settings = await self.adapter.read_holding_registers( 437, 64 ) resettable_settings_mapper = ModbusMapper(resettable_counter_settings, 437) if handle_connection: await self.adapter.close_connection() non_resettable = [] resettable = [] reverse_connection = False if direction_settings[0] & 2: reverse_connection = True for counter in range(self.non_resettable_counters): units = counter_units[ non_resettable_settings_mapper.get_uint16(421 + 4 * counter) ] direction = get_counter_direction( non_resettable_settings_mapper.get_uint16(422 + 4 * counter), reverse_connection, ) counter_type = get_counter_type(direction, units) non_resettable.append( Counter( data_mapper.get_float(2752 + 2 * counter), units, direction, counter_type, ) ) for counter in range(self.resettable_counters): units = counter_units[ resettable_settings_mapper.get_uint16(437 + 4 * counter) ] direction = get_counter_direction( resettable_settings_mapper.get_uint16(438 + 4 * counter), reverse_connection, ) counter_type = get_counter_type(direction, units) resettable.append( Counter( data_mapper.get_float(2760 + 2 * counter), units, direction, counter_type, ) ) return Counters(non_resettable, resettable) async def update_status(self): """ Updates the status of the device. This method acquires a lock to ensure that only one update is running at a time. It retrieves measurements and counters, updates the corresponding attributes, and sets the update timestamp. """ # If update is already running, wait for it to finish and then return if self.update_lock.locked(): log.debug("Update already running for %s %s" % (self.model, self.serial)) while self.update_lock.locked(): await asyncio.sleep(0.1) return # If update is not running, acquire the lock and update async with self.update_lock: log.debug("Updating status for %s %s" % (self.model, self.serial)) # if the adapter is Modbus, open the connection if isinstance(self.adapter, Modbus): await self.adapter.open_connection() self.measurements = await self.get_measurements() self.counters = await self.get_counters() # if the adapter is Modbus, close the connection if isinstance(self.adapter, Modbus): await self.adapter.close_connection() self.update_timestamp = time.time() Iskramis-pyiskra-dd6666b/pyiskra/devices/MeasuringCenter.py000066400000000000000000000245041502002505400241460ustar00rootroot00000000000000import logging import time import asyncio import struct from pyiskra.exceptions import InvalidResponseCode, MeasurementTypeNotSupported from .BaseDevice import Device from ..adapters import RestAPI, Modbus from ..helper import ( MeasurementType, ModbusMapper, Measurements, Measurement, Phase_Measurements, Total_Measurements, Counter, Counters, counter_units, get_counter_direction, get_counter_type, ) log = logging.getLogger(__name__) class MeasuringCentre(Device): """ Represents an Impact device. Attributes: supports_measurements (bool): Indicates whether the device supports measurements. supports_counters (bool): Indicates whether the device supports counters. fw_version (float): The firmware version of the device. """ DEVICE_PARAMETERS = { "MT": {"phases": 3, "resettable_counters": 4, "non_resettable_counters": 0}, "iMT": {"phases": 3, "resettable_counters": 4, "non_resettable_counters": 0}, "MC": {"phases": 3, "resettable_counters": 4, "non_resettable_counters": 0}, "iMC": {"phases": 3, "resettable_counters": 4, "non_resettable_counters": 0}, # Add more models as needed } supports_measurements = True supports_counters = True supports_interval_measurements = False async def init(self): """ Initializes the Impact device. This method retrieves basic information, updates the status, and logs a success message. """ await self.get_basic_info() await self.update_status() log.debug(f"Successfully initialized {self.model} {self.serial}") async def get_measurements( self, measurement_type: MeasurementType = MeasurementType.ACTUAL_MEASUREMENTS ): """ Retrieves measurements from the device. Returns: dict: A dictionary containing the measurements. """ if ( measurement_type != MeasurementType.ACTUAL_MEASUREMENTS and self.supports_interval_measurements == False ): raise MeasurementTypeNotSupported( f"{measurement_type} is not supported by {self.model}" ) if isinstance(self.adapter, RestAPI): log.debug( f"Getting measurements from Rest API for {self.model} {self.serial}" ) return await self.adapter.get_measurements() elif isinstance(self.adapter, Modbus): log.debug( f"Getting measurements from Modbus for {self.model} {self.serial}" ) data = await self.adapter.read_input_registers(2500, 106) mapper = ModbusMapper(data, 2500) data_temperature = await self.adapter.read_input_registers(2658, 2) temperature_mapper = ModbusMapper(data_temperature, 2658) phases = [] for phase in range(self.phases): voltage = Measurement( mapper.get_float(2500 + 2 * phase), "V", ) current = Measurement( mapper.get_float(2516 + 2 * phase), "A", ) active_power = Measurement( mapper.get_float(2530 + 2 * phase), "W", ) reactive_power = Measurement( mapper.get_float(2538 + 2 * phase), "var", ) apparent_power = Measurement( mapper.get_float(2546 + 2 * phase), "VA", ) power_factor = Measurement( mapper.get_float(2554 + 2 * phase), "", ) power_angle = Measurement( mapper.get_float(2570 + 2 * phase), "°", ) thd_current = Measurement( mapper.get_float(2588 + 2 * phase), "%", ) thd_voltage = Measurement( mapper.get_float(2594 + 2 * phase), "%", ) phases.append( Phase_Measurements( voltage, current, active_power, reactive_power, apparent_power, power_factor, power_angle, thd_voltage, thd_current, ) ) active_power_total = Measurement( mapper.get_float(2536), "W", ) reactive_power_total = Measurement( mapper.get_float(2544), "var", ) apparent_power_total = Measurement( mapper.get_float(2552), "VA", ) power_factor_total = Measurement( mapper.get_float(2560), "", ) power_angle_total = Measurement( mapper.get_float(2576), "°", ) frequency = Measurement( mapper.get_float(2584), "Hz", ) temperature = Measurement( temperature_mapper.get_float(2658), "°C", ) total = Total_Measurements( active_power_total, reactive_power_total, apparent_power_total, power_factor_total, power_angle_total, ) return Measurements(phases, total, frequency, temperature) async def get_counters(self): """ Retrieves counters from the device. Returns: dict: A dictionary containing the counters. """ if isinstance(self.adapter, RestAPI): log.debug(f"Getting counters from Rest API for {self.model} {self.serial}") return await self.adapter.get_counters() elif isinstance(self.adapter, Modbus): log.debug( f"Getting measurements from Modbus for {self.model} {self.serial}" ) # Open the connection handle_connection = not self.adapter.connected if handle_connection: await self.adapter.open_connection() data = await self.adapter.read_input_registers(400, 94) data_mapper = ModbusMapper(data, 400) direction_settings = await self.adapter.read_holding_registers(151, 1) counter_settings = await self.adapter.read_holding_registers(421, 94) counter_settings_mapper = ModbusMapper(counter_settings, 421) # Close the connection if handle_connection: await self.adapter.close_connection() non_resettable = [] resettable = [] reverse_connection = False if direction_settings[0] & 2: reverse_connection = True for counter in range(self.resettable_counters): units = counter_units[ counter_settings_mapper.get_uint16(421 + 10 * counter) & 0x3 ] direction = get_counter_direction( counter_settings_mapper.get_uint16(422 + 10 * counter), reverse_connection, ) counter_type = get_counter_type(direction, units) value = data_mapper.get_int32(406 + 2 * counter) exponent = data_mapper.get_int16(401 + counter) resettable.append( Counter( value * (10**exponent), units, direction, counter_type, ) ) for counter in range(self.non_resettable_counters): units = counter_units[ counter_settings_mapper.get_uint16( 421 + 10 * (counter + self.resettable_counters) ) & 0x3 ] direction = get_counter_direction( counter_settings_mapper.get_uint16( 422 + 10 * (counter + self.resettable_counters) ), reverse_connection, ) counter_type = get_counter_type(direction, units) value = data_mapper.get_int32( 406 + 2 * (counter + self.resettable_counters) ) exponent = data_mapper.get_int16( 401 + (counter + self.resettable_counters) ) non_resettable.append( Counter( value * (10**exponent), units, direction, counter_type, ) ) return Counters(non_resettable, resettable) async def update_status(self): """ Updates the status of the device. This method acquires a lock to ensure that only one update is running at a time. It retrieves measurements and counters, updates the corresponding attributes, and sets the update timestamp. """ # If update is already running, wait for it to finish and then return if self.update_lock.locked(): log.debug("Update already running for %s %s" % (self.model, self.serial)) while self.update_lock.locked(): await asyncio.sleep(0.1) return # If update is not running, acquire the lock and update async with self.update_lock: log.debug("Updating status for %s %s" % (self.model, self.serial)) # if the adapter is Modbus, open the connection if isinstance(self.adapter, Modbus): await self.adapter.open_connection() self.measurements = await self.get_measurements() self.counters = await self.get_counters() # if the adapter is Modbus, close the connection if isinstance(self.adapter, Modbus): await self.adapter.close_connection() self.update_timestamp = time.time() Iskramis-pyiskra-dd6666b/pyiskra/devices/SmartGateway.py000066400000000000000000000076351502002505400234710ustar00rootroot00000000000000from .BaseDevice import Device from ..adapters.RestAPI import RestAPI from ..exceptions import DeviceNotSupported, DeviceConnectionError import logging log = logging.getLogger(__name__) class SmartGateway(Device): """ Represents a smart gateway device. Attributes: supports_measurements (bool): Indicates if the device supports measurements. supports_counters (bool): Indicates if the device supports counters. serial (str): The serial number of the device. model (str): The model of the device. description (str): The description of the device. location (str): The location of the device. fw_version (str): The firmware version of the device. child_devices (list): A list of child devices connected to the gateway. """ DEVICE_PARAMETERS = { "SG": {"phases": 0, "resettable_counters": 0, "non_resettable_counters": 0} } is_gateway = True child_devices = [] supports_measurements = False supports_counters = False async def init(self): """ Initializes the smart gateway device. This method retrieves basic information about the device, such as serial number, model, description, location, and firmware version. It also initializes the child devices connected to the gateway. """ basic_info = await self.adapter.get_basic_info() self.serial = basic_info.serial self.model = basic_info.model self.description = basic_info.description self.location = basic_info.location self.fw_version = basic_info.sw_ver await self.update_child_devices() log.debug(f"Successfully initialized {self.model} {self.serial}") async def update_child_devices(self): self.child_devices = [] child_devices = await self.adapter.get_devices() child_devices = [ device for device in child_devices if "Right IR" not in device["interface"] ] for i, device in enumerate(child_devices): if device["model"] != "Disabled" and device["model"] != "Not Detected": log.debug( f"Found device {device['model']} {device['serial']} connected to {self.model} {self.serial}" ) adapter = RestAPI( ip_address=self.adapter.ip_address, authentication=self.adapter.authentication, device_index=i, ) try: dev = await Device.create_device(adapter, self) await dev.init() self.child_devices.append(dev) except DeviceNotSupported as e: log.error( f"Failed to create device {device['model']} {device['serial']}: {e}" ) except DeviceConnectionError as e: log.error( f"Failed to connect to device {device['model']} {device['serial']}: {e}" ) def get_child_devices(self): """ Returns the list of child devices connected to the gateway. Returns: list: A list of child devices. """ return self.child_devices async def get_measurements(self): """ Retrieves the measurements from the smart gateway. Returns: dict: A dictionary containing the measurements. """ return await self.adapter.get_measurements() async def get_counters(self): """ Retrieves the counters from the smart gateway. Returns: dict: A dictionary containing the counters. """ return await self.adapter.get_counters() async def update_status(self): """ Updates the status of the smart gateway. """ log.debug(f"Updating status for {self.model} {self.serial}") # await self.update_child_devices() Iskramis-pyiskra-dd6666b/pyiskra/devices/WM.py000066400000000000000000000263231502002505400213770ustar00rootroot00000000000000import logging import time import asyncio import struct from pyiskra.exceptions import InvalidResponseCode, MeasurementTypeNotSupported from .BaseDevice import Device from ..adapters import RestAPI, Modbus from ..helper import ( ModbusMapper, Measurements, Measurement, Phase_Measurements, Total_Measurements, Counter, Counters, counter_units, get_counter_direction, get_counter_type, IntervalMeasurementStats, MeasurementType, ) log = logging.getLogger(__name__) class WM(Device): """ Represents an Impact device. Attributes: supports_measurements (bool): Indicates whether the device supports measurements. supports_counters (bool): Indicates whether the device supports counters. fw_version (float): The firmware version of the device. """ DEVICE_PARAMETERS = { "WM3M4": {"phases": 3, "resettable_counters": 0, "non_resettable_counters": 2}, "WM3": {"phases": 3, "resettable_counters": 4, "non_resettable_counters": 4}, "WM1": {"phases": 1, "resettable_counters": 4, "non_resettable_counters": 0}, # Add more models as needed } supports_measurements = True supports_counters = True supports_interval_measurements = True async def init(self): """ Initializes the Impact device. This method retrieves basic information, updates the status, and logs a success message. """ await self.get_basic_info() await self.update_status() log.debug(f"Successfully initialized {self.model} {self.serial}") async def get_measurements( self, measurement_type: MeasurementType = MeasurementType.ACTUAL_MEASUREMENTS ): """ Retrieves measurements from the device. Returns: dict: A dictionary containing the measurements. """ if ( measurement_type != MeasurementType.ACTUAL_MEASUREMENTS and self.supports_interval_measurements == False ): raise MeasurementTypeNotSupported( f"{measurement_type} is not supported by {self.model}" ) if isinstance(self.adapter, RestAPI): log.debug( f"Getting measurements from Rest API for {self.model} {self.serial}" ) return await self.adapter.get_measurements() elif isinstance(self.adapter, Modbus): log.debug( f"Getting measurements from Modbus for {self.model} {self.serial}" ) offset = 0 last_interval_duration = None time_since_last_measurement = None avg_measurement_counter = None # Other measurement type registers are just shifted if measurement_type == MeasurementType.AVERAGE_MEASUREMENTS: offset = 5400 elif measurement_type == MeasurementType.MAX_MEASUREMENTS: offset = 5500 elif measurement_type == MeasurementType.MAX_MEASUREMENTS: offset = 5600 interval_stats = None data = await self.adapter.read_input_registers(100 + offset, 91) mapper = ModbusMapper(data, 100) if measurement_type != MeasurementType.ACTUAL_MEASUREMENTS: interval_stats = IntervalMeasurementStats() interval_data = await self.adapter.read_input_registers(5500, 2) interval_stats_mapper = ModbusMapper(interval_data, 100) interval_stats.last_interval_duration = ( interval_stats_mapper.get_uint16(100) / 10 ) interval_stats.time_since_last_measurement = ( interval_stats_mapper.get_int16(101) / 10 ) phases = [] for phase in range(self.phases): voltage = Measurement( mapper.get_t5(107 + 2 * phase), "V", ) current = Measurement( mapper.get_t5(126 + 2 * phase), "A", ) active_power = Measurement( mapper.get_t6(142 + 2 * phase), "W", ) reactive_power = Measurement( mapper.get_t6(150 + 2 * phase), "var", ) apparent_power = Measurement( mapper.get_t5(158 + 2 * phase), "VA", ) power_factor = Measurement( mapper.get_t7(166 + 2 * phase)["value"], "", ) power_angle = Measurement( mapper.get_int16(173 + phase) / 100, "°", ) thd_voltage = Measurement( mapper.get_uint16(182 + phase) / 100, "%", ) thd_current = Measurement( mapper.get_uint16(188 + phase) / 100, "%", ) phases.append( Phase_Measurements( voltage, current, active_power, reactive_power, apparent_power, power_factor, power_angle, thd_voltage, thd_current, ) ) active_power_total = Measurement( mapper.get_t6(140), "W", ) reactive_power_total = Measurement( mapper.get_t6(148), "var", ) apparent_power_total = Measurement( mapper.get_t5(156), "VA", ) power_factor_total = Measurement( mapper.get_t7(164)["value"], "", ) power_angle_total = Measurement( mapper.get_int16(172) / 100, "°", ) frequency = Measurement( mapper.get_t5(105), "Hz", ) temperature = Measurement( mapper.get_int16(181) / 100, "°C", ) total = Total_Measurements( active_power_total, reactive_power_total, apparent_power_total, power_factor_total, power_angle_total, ) return Measurements(phases, total, frequency, temperature, interval_stats) async def get_counters(self): """ Retrieves counters from the device. Returns: dict: A dictionary containing the counters. """ if isinstance(self.adapter, RestAPI): log.debug(f"Getting counters from Rest API for {self.model} {self.serial}") return await self.adapter.get_counters() elif isinstance(self.adapter, Modbus): log.debug( f"Getting measurements from Modbus for {self.model} {self.serial}" ) # Open the connection handle_connection = not self.adapter.connected if handle_connection: await self.adapter.open_connection() data = await self.adapter.read_input_registers(400, 64) data_mapper = ModbusMapper(data, 400) direction_settings = await self.adapter.read_holding_registers(151, 1) counter_settings = await self.adapter.read_holding_registers(421, 36) counter_settings_mapper = ModbusMapper(counter_settings, 421) # Close the connection if handle_connection: await self.adapter.close_connection() non_resettable = [] resettable = [] reverse_connection = False if direction_settings[0] & 2: reverse_connection = True for counter in range(self.resettable_counters): units = counter_units[ counter_settings_mapper.get_uint16(421 + 10 * counter) & 0x3 ] direction = get_counter_direction( counter_settings_mapper.get_uint16(422 + 10 * counter), reverse_connection, ) counter_type = get_counter_type(direction, units) value = data_mapper.get_int32(406 + 2 * counter) exponent = data_mapper.get_int16(401 + counter) resettable.append( Counter( value * (10**exponent), units, direction, counter_type, ) ) for counter in range(self.non_resettable_counters): units = counter_units[ counter_settings_mapper.get_uint16( 421 + 10 * (counter + self.resettable_counters) ) & 0x3 ] direction = get_counter_direction( counter_settings_mapper.get_uint16( 422 + 10 * (counter + self.resettable_counters) ), reverse_connection, ) counter_type = get_counter_type(direction, units) value = data_mapper.get_int32( 406 + 2 * (counter + self.resettable_counters) ) exponent = data_mapper.get_int16( 401 + (counter + self.resettable_counters) ) non_resettable.append( Counter( value * (10**exponent), units, direction, counter_type, ) ) return Counters(non_resettable, resettable) async def update_status(self): """ Updates the status of the device. This method acquires a lock to ensure that only one update is running at a time. It retrieves measurements and counters, updates the corresponding attributes, and sets the update timestamp. """ # If update is already running, wait for it to finish and then return if self.update_lock.locked(): log.debug("Update already running for %s %s" % (self.model, self.serial)) while self.update_lock.locked(): await asyncio.sleep(0.1) return # If update is not running, acquire the lock and update async with self.update_lock: log.debug("Updating status for %s %s" % (self.model, self.serial)) # if the adapter is Modbus, open the connection if isinstance(self.adapter, Modbus): await self.adapter.open_connection() self.measurements = await self.get_measurements() self.counters = await self.get_counters() # if the adapter is Modbus, close the connection if isinstance(self.adapter, Modbus): await self.adapter.close_connection() self.update_timestamp = time.time() Iskramis-pyiskra-dd6666b/pyiskra/devices/__init__.py000066400000000000000000000000371502002505400226050ustar00rootroot00000000000000from .BaseDevice import Device Iskramis-pyiskra-dd6666b/pyiskra/discovery.py000066400000000000000000000113131502002505400214320ustar00rootroot00000000000000import asyncio import socket import logging log = logging.getLogger(__name__) UDP_DST_PORT = 33333 RCV_BUFSIZ = 1024 GRACE_SECONDS = 2 DISCOVERY_MSG = b"\x00\x00\x00\x1a" class DiscoveredDevice: @staticmethod def parse_UDP_discovery_info(discovery_info): data = {} if discovery_info[3] != int.from_bytes(b"\x1b"): raise AttributeError("Not device info") data["ssid"] = discovery_info[8:20].decode("utf-8").replace("\x00", "").strip() data["mac"] = discovery_info[20:26].hex(":") data["tcp"] = int.from_bytes(discovery_info[26:27]) data["model"] = ( discovery_info[28:42].decode("utf-8").replace("\x00", "").strip() ) data["serial"] = ( discovery_info[43:51].decode("utf-8").replace("\x00", "").strip() ) data["modbus_address"] = discovery_info[136] data["sw_ver"] = discovery_info[52] / 100 data["hw_ver"] = chr(discovery_info[54]) try: data["description"] = ( discovery_info[55:94].decode("utf-8").replace("\x00", "").strip() ) data["location"] = ( discovery_info[95:134].decode("utf-8").replace("\x00", "").strip() ) except: pass return data def __init__(self, ip, port, basic_info_string): self.values = {} self.values["ip_address"] = ip self.values["port"] = port self.values.update(self.parse_UDP_discovery_info(basic_info_string)) def __getattr__(self, name): return self.values[name] class Discovery: def __init__(self): self.loop = asyncio.get_event_loop() self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) self.sock.bind(("", 0)) self.sock.settimeout(GRACE_SECONDS) self.cancel_event = asyncio.Event() self.dev = {} async def broadcast_udp_packet(self, ip_address): try: log.debug(f"Sending Broadcast to {ip_address}") await self.loop.sock_sendto( self.sock, DISCOVERY_MSG, (ip_address, UDP_DST_PORT) ) except Exception as e: log.error(f"Error broadcasting to {ip_address}: {e}") raise e async def poll(self, broadcast_addresses, stop_if_found=None): try: # Broadcast UDP packets to all IPs concurrently await asyncio.gather( *[ self.broadcast_udp_packet(ip_address) for ip_address in broadcast_addresses ] ) start_time = self.loop.time() while ( not self.cancel_event.is_set() and (self.loop.time() - start_time) < GRACE_SECONDS ): try: # Use asyncio.wait_for to set a timeout for sock_recvfrom data, addr = await asyncio.wait_for( self.loop.sock_recvfrom(self.sock, RCV_BUFSIZ), timeout=1.0 ) device = DiscoveredDevice(addr[0], addr[1], data) log.info( f"Found device {device.model} {device.serial} {device.ip_address}" ) new_mac = device.mac self.dev[new_mac] = device if ( stop_if_found is not None and device.serial.lower() == stop_if_found.lower() ): return list(self.dev.values()) except asyncio.TimeoutError: # Handle timeout and continue the loop continue except ValueError as ve: log.error(f"Error parsing discovery info: {ve}") raise except Exception as e: log.error(f"Error during discovery: {e}") raise return list(self.dev.values()) async def get_devices(self, broadcast_addresses): try: devices = await self.poll(broadcast_addresses) return devices except Exception as e: log.error(f"Error getting devices: {e}") raise async def get_serial(self, broadcast_addresses, serial): try: devices = await self.poll(broadcast_addresses, serial) for device in devices: if device.serial.lower() == serial.lower(): return device return None except Exception as e: log.error(f"Error getting serial: {e}") raise Iskramis-pyiskra-dd6666b/pyiskra/exceptions.py000066400000000000000000000006071502002505400216100ustar00rootroot00000000000000# Description: Custom exceptions for pyiskra class NotAuthorised(Exception): pass class DeviceNotSupported(Exception): pass class ProtocolNotSupported(Exception): pass class DeviceConnectionError(Exception): pass class DeviceTimeoutError(Exception): pass class InvalidResponseCode(Exception): pass class MeasurementTypeNotSupported(Exception): pass Iskramis-pyiskra-dd6666b/pyiskra/helper.py000066400000000000000000000233031502002505400207040ustar00rootroot00000000000000from datetime import datetime import struct import time from enum import Enum class IntervalMeasurementStats: last_interval_duration: int time_since_last_measurement: int class BasicInfo: def __init__( self, serial, model, description, location, sw_ver, ): self.serial = serial self.model = model self.description = description self.location = location self.sw_ver = sw_ver class Measurement: value: float units: str def __init__(self, value=None, units=None): self.value = value self.units = units class Phase_Measurements: voltage: Measurement current: Measurement active_power: Measurement reactive_power: Measurement apparent_power: Measurement power_factor: Measurement power_angle: Measurement thd_voltage: Measurement thd_current: Measurement def __init__( self, voltage=None, current=None, active_power=None, reactive_power=None, apparent_power=None, power_factor=None, power_angle=None, thd_voltage=None, thd_current=None, ): self.voltage = voltage self.current = current self.active_power = active_power self.reactive_power = reactive_power self.apparent_power = apparent_power self.power_factor = power_factor self.power_angle = power_angle self.thd_voltage = thd_voltage self.thd_current = thd_current class Total_Measurements: active_power: Measurement reactive_power: Measurement apparent_power: Measurement power_factor: Measurement power_angle: Measurement def __init__( self, active_power=None, reactive_power=None, apparent_power=None, power_factor=None, power_angle=None, ): self.active_power = active_power self.reactive_power = reactive_power self.apparent_power = apparent_power self.power_factor = power_factor self.power_angle = power_angle class Measurements: phases: list[Phase_Measurements] total: Total_Measurements frequency: Measurement temperature: Measurement interval_stats: IntervalMeasurementStats def __init__( self, phases=None, total=None, frequency=None, temperature=None, interval_stats=None, ): self.timestamp = time.time() self.phases = phases self.total = total self.frequency = frequency self.temperature = temperature self.interval_stats = interval_stats class CounterType(Enum): ACTIVE_IMPORT = "active_import" ACTIVE_EXPORT = "active_export" REACTIVE_IMPORT = "reactive_import" REACTIVE_EXPORT = "reactive_export" APPARENT_IMPORT = "apparent_import" APPARENT_EXPORT = "apparent_export" UNKNOWN = "unknown" class MeasurementType(Enum): ACTUAL_MEASUREMENTS = "actual_measuremtns" AVERAGE_MEASUREMENTS = "average_measurements" MIN_MEASUREMENTS = "min_measurements" MAX_MEASUREMENTS = "max_measurements" class Counter: value: float units: str direction: str counter_type: CounterType def __init__( self, value=None, units=None, direction=None, counter_type=None, ): self.value = value self.units = units self.direction = direction self.counter_type = counter_type class Counters: non_resettable: list[Counter] resettable: list[Counter] def __init__(self, non_resettable=None, resettable=None): self.timestamp = time.time() self.non_resettable = non_resettable if non_resettable is not None else [] self.resettable = resettable if resettable is not None else [] counter_units = ["", "Wh", "varh", "VAh"] def get_counter_direction(quadrants, reverse_connection): quadrants = quadrants & 0x0F direction = 0 if quadrants == 9 or quadrants == 3: direction = "export" elif quadrants == 6 or quadrants == 12: direction = "import" elif quadrants == 15: direction = "bidirectional" if reverse_connection: if direction == "import": direction = "export" elif direction == "export": direction = "import" return direction def get_counter_type(direction, units): if direction == "import": if units == "Wh": return CounterType.ACTIVE_IMPORT elif units == "varh": return CounterType.REACTIVE_IMPORT elif units == "VAh": return CounterType.APPARENT_IMPORT elif direction == "export": if units == "Wh": return CounterType.ACTIVE_EXPORT elif units == "varh": return CounterType.REACTIVE_EXPORT elif units == "VAh": return CounterType.APPARENT_EXPORT return CounterType.UNKNOWN class ModbusMapper: def __init__(self, register_values, start_address): self.register_values = register_values self.start_address = start_address def get_value(self, desired_address): if ( desired_address < self.start_address or desired_address >= self.start_address + len(self.register_values) ): raise Exception("desired address out of range") index = desired_address - self.start_address return self.register_values[index] def get_t5(self, desired_address, word_swap=False): high_word = self.get_value(desired_address) low_word = self.get_value(desired_address + 1) combined = (high_word << 16) | low_word if word_swap: combined = (low_word << 16) | high_word value = combined & 0xFFFFFF # bits 0-23 exponent = (combined >> 24) & 0xFF # bits 24-31 if exponent & 0x80: # if the sign bit is set exponent -= 0x100 # convert to signed return round(value * (10**exponent), 3) def get_t6(self, desired_address, word_swap=False): high_word = self.get_value(desired_address) low_word = self.get_value(desired_address + 1) combined = (high_word << 16) | low_word if word_swap: combined = (low_word << 16) | high_word value = combined & 0xFFFFFF # bits 0-23 exponent = (combined >> 24) & 0xFF # bits 24-31 if exponent & 0x80: # if the sign bit is set exponent -= 0x100 # convert to signed if value & 0x800000: # if the sign bit is set value -= 0x1000000 # convert to signed return round(value * (10**exponent), 3) def get_t7(self, desired_address, word_swap=False): high_word = self.get_value(desired_address) low_word = self.get_value(desired_address + 1) combined = (high_word << 16) | low_word if word_swap: combined = (low_word << 16) | high_word value = combined & 0xFFFF # bits 0-15 inductive_capacitive = (combined >> 16) & 0xFF # bits 16-23 import_export = (combined >> 24) & 0xFF # bits 24-31 inductive_capacitive_str = ( "inductive" if inductive_capacitive == 0x00 else "capacitive" ) import_export_str = "import" if import_export == 0x00 else "export" return { "value": value / 10000, "inductive_capacitive": inductive_capacitive_str, "import_export": import_export_str, } def get_uint16(self, desired_address): value = self.get_value(desired_address) return value def get_int16(self, desired_address): value = self.get_value(desired_address) if value is None: return None if value > 32767: value -= 65536 return value def get_timestamp(self, desired_address): value = self.get_uint32(desired_address) return time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime(value)) def get_float(self, desired_address, word_swap=False): value = self.get_value(desired_address) next_value = self.get_value(desired_address + 1) if value is None or next_value is None: return None # Combine the two 16-bit register values into a 32-bit integer combined = (value << 16) | next_value if word_swap: combined = (next_value << 16) | value # Convert the 32-bit integer to a float return round(struct.unpack("!f", struct.pack("!I", combined))[0], 3) def get_uint32(self, desired_address, word_swap=False): high_word = self.get_value(desired_address) low_word = self.get_value(desired_address + 1) if word_swap: return (low_word << 16) + high_word return (high_word << 16) + low_word def get_int32(self, desired_address, word_swap=False): high_word = self.get_value(desired_address) low_word = self.get_value(desired_address + 1) value = (high_word << 16) + low_word if word_swap: value = (low_word << 16) + high_word if value & 0x80000000: # if the sign bit is set value -= 0x100000000 # convert to signed return value def get_string(self, desired_address): value = self.get_value(desired_address) high_byte = (value >> 8) & 0xFF low_byte = value & 0xFF return "".join([chr(high_byte), chr(low_byte)]) def get_string_range(self, desired_address, size): return "".join( [ self.get_string(register) for register in range(desired_address, desired_address + size) ] ) def dump(self): for i, value in enumerate(self.register_values): print(f"Address {self.start_address + i}: {value} 0x{value:04X}") Iskramis-pyiskra-dd6666b/pyproject.toml000066400000000000000000000015021502002505400203020ustar00rootroot00000000000000[build-system] requires = ["setuptools", "wheel"] [tool.poetry] name = "pyiskra" version = "0.1.21" description = "Python Iskra devices interface" authors = ["Iskra d.o.o. "] license = "GPL" readme = "README.md" homepage = "https://github.com/Iskramis/pyiskra" repository = "https://github.com/Iskramis/pyiskra" documentation = "https://github.com/Iskramis/pyiskra" keywords = ["homeautomation", "iskra", "energy meter"] classifiers = [ "Programming Language :: Python :: 3", "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", "Operating System :: OS Independent", "Topic :: Software Development :: Libraries :: Application Frameworks", "Topic :: Home Automation", ] [tool.poetry.dependencies] python = "^3.8" aiohttp = "*" pymodbus = "3.8.3" [tool.poetry.dev-dependencies]Iskramis-pyiskra-dd6666b/requirements.txt000066400000000000000000000000271502002505400206530ustar00rootroot00000000000000aiohttp pymodbus==3.9.2Iskramis-pyiskra-dd6666b/setup.py000066400000000000000000000017431502002505400171070ustar00rootroot00000000000000#!/usr/bin/env python3 from setuptools import setup, find_packages with open("README.md", "r") as fh: long_description = fh.read() setup( name="pyiskra", version="0.1.21", description="Python Iskra devices interface", long_description=long_description, long_description_content_type="text/markdown", author="Iskra d.o.o.", author_email="razvoj.mis@iskra.eu", maintainer=", ".join(("Iskra ",)), license="GPL", url="https://github.com/Iskramis/pyiskra", python_requires=">=3.8", packages=find_packages(), keywords=["homeautomation", "iskra", "energy meter"], classifiers=[ "Programming Language :: Python :: 3", "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", "Operating System :: OS Independent", "Topic :: Software Development :: Libraries :: Application Frameworks", "Topic :: Home Automation", ], install_requires=["aiohttp", "pymodbus"], )