From 10d00c8fbbb2c6a7d667a083b079e347b68a34d8 Mon Sep 17 00:00:00 2001 From: osiu97 Date: Fri, 2 Jan 2026 10:46:26 +0100 Subject: [PATCH] Copilot refactor --- .gitignore | 23 +++++++ Hopi.py | 44 +++++++++++++ hopi/data_handler.py | 136 +++++++++++++++++++++++++++++++++++++++ hopi/models.py | 21 ++++++ hopi/serial_client.py | 115 +++++++++++++++++++++++++++++++++ importtime.txt | 101 +++++++++++++++++++++++++++++ requirements.txt | 1 + test.py | 146 ------------------------------------------ 8 files changed, 441 insertions(+), 146 deletions(-) create mode 100644 .gitignore create mode 100644 Hopi.py create mode 100644 hopi/data_handler.py create mode 100644 hopi/models.py create mode 100644 hopi/serial_client.py create mode 100644 importtime.txt create mode 100644 requirements.txt delete mode 100644 test.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c3c0905 --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +# Python bytecode +__pycache__/ +*.py[cod] +*$py.class + +# Virtual environments +.venv/ +venv/ +ENV/ + +# Packaging / builds +build/ +dist/ +*.egg-info/ + +# Test / coverage +.pytest_cache/ +.coverage +htmlcov/ + +# Editors / OS +.vscode/ +.DS_Store diff --git a/Hopi.py b/Hopi.py new file mode 100644 index 0000000..53fee70 --- /dev/null +++ b/Hopi.py @@ -0,0 +1,44 @@ +import argparse + +from hopi.data_handler import PowerMeterDataHandler +from hopi.serial_client import ModbusRtuSerialClient, SerialConfig + + +SLAVE_ID = 1 +START_REG = 0 +NUM_WORDS = 20 +READ_MAX_BYTES = 5 + (2 * NUM_WORDS) + + +def build_arg_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Read and print power meter values over Modbus RTU.") + parser.add_argument("--port", default="/dev/ttyUSB0") + parser.add_argument("--baud", type=int, default=9600) + parser.add_argument("--timeout", type=float, default=0.1) + return parser + + +def main() -> None: + args = build_arg_parser().parse_args() + + config = SerialConfig(port=args.port, baudrate=args.baud, timeout=args.timeout) + client = ModbusRtuSerialClient(config) + handler = PowerMeterDataHandler() + + with client: + response = client.read_holding_registers( + slave_id=SLAVE_ID, + start_register=START_REG, + register_count=NUM_WORDS, + read_max_bytes=READ_MAX_BYTES, + exact_response_length=True, + ) + + registers = handler.parse_read_holding_registers_response(response) + readings = handler.decode_readings(registers) + + handler.print_readings_json(readings) + + +if __name__ == "__main__": + main() diff --git a/hopi/data_handler.py b/hopi/data_handler.py new file mode 100644 index 0000000..f9a4e5b --- /dev/null +++ b/hopi/data_handler.py @@ -0,0 +1,136 @@ +import json +import math +import struct +from typing import Iterable, List + +from .models import PowerMeterReadings + + +class PowerMeterDataHandler: + """Parses Modbus RTU responses and computes derived power metrics.""" + + def parse_read_holding_registers_response(self, response: bytes) -> List[int]: + # Response layout: [slave][func][byte_count][data...][crc_lo][crc_hi] + # We keep validation intentionally lightweight to match prior script behavior. + if len(response) < 5: + raise RuntimeError("No valid response") + + byte_count = response[2] + data_start = 3 + data_end = data_start + byte_count + if len(response) < data_end: + raise RuntimeError("Truncated response") + + data = response[data_start:data_end] + if len(data) % 2 != 0: + raise RuntimeError("Invalid byte_count (must be even)") + + return self._bytes_to_registers_be(data) + + def decode_readings(self, registers: List[int]) -> PowerMeterReadings: + active_power = self.float_dcba(registers, 0) + rms_current = self.float_dcba(registers, 2) + voltage = self.float_dcba(registers, 4) + frequency = self.float_dcba(registers, 6) + power_factor = self.float_dcba(registers, 8) + annual_power_consumption = self.float_dcba(registers, 10) + active_consumption = self.float_dcba(registers, 12) + reactive_consumption = self.float_dcba(registers, 14) + load_time_hours = self.float_dcba(registers, 16) / 60.0 + work_hours_per_day = int(registers[18]) + device_address = int(registers[19]) + + s_from_vi = self.apparent_from_vi(voltage, rms_current) + s_from_pf = self.apparent_from_pf(active_power, power_factor) + q_calculated = self.reactive_from_p_s(active_power, s_from_pf) + s_from_pq = self.apparent_from_p_q(active_consumption, reactive_consumption) + + return PowerMeterReadings( + active_power_w=active_power, + rms_current_a=rms_current, + voltage_v=voltage, + frequency_hz=frequency, + power_factor=power_factor, + annual_power_consumption_kwh=annual_power_consumption, + active_consumption_kwh=active_consumption, + reactive_consumption_kwh=reactive_consumption, + load_time_hours=load_time_hours, + work_hours_per_day=work_hours_per_day, + device_address=device_address, + apparent_power_vi_va=s_from_vi, + apparent_power_pf_va=s_from_pf, + reactive_power_var=q_calculated, + apparent_consumption_kvah=s_from_pq, + ) + + def readings_to_flat_dict(self, readings: PowerMeterReadings) -> dict: + # dataclasses.asdict() is fine, but we keep this local to the data layer + # so the entrypoint stays minimal. + return { + "active_power_w": readings.active_power_w, + "rms_current_a": readings.rms_current_a, + "voltage_v": readings.voltage_v, + "frequency_hz": readings.frequency_hz, + "power_factor": readings.power_factor, + "annual_power_consumption_kwh": readings.annual_power_consumption_kwh, + "active_consumption_kwh": readings.active_consumption_kwh, + "reactive_consumption_kwh": readings.reactive_consumption_kwh, + "load_time_hours": readings.load_time_hours, + "work_hours_per_day": readings.work_hours_per_day, + "device_address": readings.device_address, + "apparent_power_vi_va": readings.apparent_power_vi_va, + "apparent_power_pf_va": readings.apparent_power_pf_va, + "reactive_power_var": readings.reactive_power_var, + "apparent_consumption_kvah": readings.apparent_consumption_kvah, + } + + def readings_to_json(self, readings: PowerMeterReadings, *, ensure_ascii: bool = False) -> str: + return json.dumps(self.readings_to_flat_dict(readings), ensure_ascii=ensure_ascii) + + def print_readings_json(self, readings: PowerMeterReadings) -> None: + print(self.readings_to_json(readings, ensure_ascii=False)) + + @staticmethod + def _bytes_to_registers_be(data: bytes) -> List[int]: + registers: List[int] = [] + for i in range(0, len(data), 2): + registers.append((data[i] << 8) | data[i + 1]) + return registers + + @staticmethod + def float_dcba(regs: List[int], index: int) -> float: + w1 = regs[index] + w2 = regs[index + 1] + b = bytes( + [ + (w2 & 0xFF), + (w2 >> 8) & 0xFF, + (w1 & 0xFF), + (w1 >> 8) & 0xFF, + ] + ) + return struct.unpack(">f", b)[0] + + @staticmethod + def apparent_from_vi(voltage_v: float, current_a: float) -> float: + if voltage_v == 0 or current_a == 0: + return 0.0 + return voltage_v * current_a + + @staticmethod + def apparent_from_pf(active_power_w: float, power_factor: float) -> float: + if power_factor == 0: + return 0.0 + return active_power_w / power_factor + + @staticmethod + def reactive_from_p_s(active_power_w: float, apparent_power_va: float) -> float: + if apparent_power_va < active_power_w: + return 0.0 + return math.sqrt(apparent_power_va**2 - active_power_w**2) + + @staticmethod + def apparent_from_p_q(active_kwh: float, reactive_kwh: float) -> float: + if active_kwh == 0 and reactive_kwh == 0: + return 0.0 + return math.sqrt(active_kwh**2 + reactive_kwh**2) diff --git a/hopi/models.py b/hopi/models.py new file mode 100644 index 0000000..9ae5ab8 --- /dev/null +++ b/hopi/models.py @@ -0,0 +1,21 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class PowerMeterReadings: + active_power_w: float + rms_current_a: float + voltage_v: float + frequency_hz: float + power_factor: float + annual_power_consumption_kwh: float + active_consumption_kwh: float + reactive_consumption_kwh: float + load_time_hours: float + work_hours_per_day: int + device_address: int + + apparent_power_vi_va: float + apparent_power_pf_va: float + reactive_power_var: float + apparent_consumption_kvah: float diff --git a/hopi/serial_client.py b/hopi/serial_client.py new file mode 100644 index 0000000..5dee59e --- /dev/null +++ b/hopi/serial_client.py @@ -0,0 +1,115 @@ +from dataclasses import dataclass +from typing import Optional + +import serial + + +@dataclass(frozen=True) +class SerialConfig: + port: str = "/dev/ttyUSB0" + baudrate: int = 9600 + timeout: float = 0.1 + bytesize: int = serial.EIGHTBITS + parity: str = serial.PARITY_NONE + stopbits: int = serial.STOPBITS_ONE + + +class ModbusRtuSerialClient: + """Minimal Modbus RTU client over a serial port. + + Only implements function 0x03 (Read Holding Registers). + """ + + def __init__(self, config: SerialConfig): + self._config = config + self._ser: Optional[serial.Serial] = None + + def __enter__(self) -> "ModbusRtuSerialClient": + self.open() + return self + + def __exit__(self, exc_type, exc, tb) -> None: + self.close() + + @property + def is_open(self) -> bool: + return self._ser is not None and self._ser.is_open + + def open(self) -> None: + if self.is_open: + return + self._ser = serial.Serial( + port=self._config.port, + baudrate=self._config.baudrate, + bytesize=self._config.bytesize, + parity=self._config.parity, + stopbits=self._config.stopbits, + timeout=self._config.timeout, + ) + self._ser.reset_input_buffer() + self._ser.reset_output_buffer() + + def close(self) -> None: + if self._ser is not None: + try: + self._ser.close() + finally: + self._ser = None + + def read_holding_registers( + self, + slave_id: int, + start_register: int, + register_count: int, + *, + read_max_bytes: int = 64, + exact_response_length: bool = True, + ) -> bytes: + if not self.is_open: + raise RuntimeError("Serial port is not open") + if not (0 <= slave_id <= 247): + raise ValueError("slave_id must be in range 0..247") + if not (0 <= start_register <= 0xFFFF): + raise ValueError("start_register must be in range 0..65535") + if not (1 <= register_count <= 125): + raise ValueError("register_count must be in range 1..125") + + request_wo_crc = bytes( + [ + slave_id, + 0x03, + (start_register >> 8) & 0xFF, + start_register & 0xFF, + (register_count >> 8) & 0xFF, + register_count & 0xFF, + ] + ) + request = request_wo_crc + self._crc16_modbus_le(request_wo_crc) + + ser = self._ser + assert ser is not None + ser.reset_input_buffer() + ser.reset_output_buffer() + ser.write(request) + ser.flush() + + if exact_response_length: + # Expected RTU response length for 0x03: + # slave(1) + func(1) + byte_count(1) + data(2*N) + crc(2) + expected_len = 5 + (2 * register_count) + return ser.read(expected_len) + + return ser.read(read_max_bytes) + + @staticmethod + def _crc16_modbus_le(payload: bytes) -> bytes: + """Compute Modbus CRC16 and return as little-endian 2 bytes.""" + crc = 0xFFFF + for byte in payload: + crc ^= byte + for _ in range(8): + if crc & 0x0001: + crc = (crc >> 1) ^ 0xA001 + else: + crc >>= 1 + return bytes([crc & 0xFF, (crc >> 8) & 0xFF]) diff --git a/importtime.txt b/importtime.txt new file mode 100644 index 0000000..5877f65 --- /dev/null +++ b/importtime.txt @@ -0,0 +1,101 @@ +import time: self [us] | cumulative | imported package +import time: 268 | 268 | _io +import time: 54 | 54 | marshal +import time: 407 | 407 | posix +import time: 892 | 1619 | _frozen_importlib_external +import time: 92 | 92 | time +import time: 298 | 389 | zipimport +import time: 51 | 51 | _codecs +import time: 532 | 582 | codecs +import time: 436 | 436 | encodings.aliases +import time: 582 | 1599 | encodings +import time: 199 | 199 | encodings.utf_8 +import time: 91 | 91 | _signal +import time: 36 | 36 | _abc +import time: 167 | 202 | abc +import time: 163 | 365 | io +import time: 44 | 44 | _stat +import time: 107 | 150 | stat +import time: 929 | 929 | _collections_abc +import time: 60 | 60 | errno +import time: 93 | 93 | genericpath +import time: 159 | 311 | posixpath +import time: 524 | 1912 | os +import time: 109 | 109 | _sitebuiltins +import time: 427 | 427 | encodings.utf_8_sig +import time: 642 | 642 | _distutils_hack +import time: 306 | 306 | types +import time: 152 | 152 | importlib +import time: 184 | 184 | importlib._abc +import time: 199 | 534 | importlib.util +import time: 62 | 62 | importlib.machinery +import time: 60 | 60 | sitecustomize +import time: 51 | 51 | usercustomize +import time: 1437 | 5537 | site +import time: 345 | 345 | itertools +import time: 137 | 137 | keyword +import time: 85 | 85 | _operator +import time: 284 | 369 | operator +import time: 227 | 227 | reprlib +import time: 75 | 75 | _collections +import time: 833 | 1984 | collections +import time: 79 | 79 | _functools +import time: 650 | 2712 | functools +import time: 1875 | 4587 | enum +import time: 113 | 113 | _sre +import time: 309 | 309 | re._constants +import time: 503 | 811 | re._parser +import time: 123 | 123 | re._casefix +import time: 960 | 2006 | re._compiler +import time: 186 | 186 | copyreg +import time: 632 | 7409 | re +import time: 1521 | 1521 | gettext +import time: 1146 | 10075 | argparse +import time: 114 | 114 | hopi +import time: 213 | 213 | _json +import time: 355 | 567 | json.scanner +import time: 467 | 1034 | json.decoder +import time: 390 | 390 | json.encoder +import time: 210 | 1633 | json +import time: 208 | 208 | math +import time: 169 | 169 | _struct +import time: 144 | 313 | struct +import time: 50 | 50 | _typing +import time: 2816 | 2865 | typing +import time: 235 | 235 | _weakrefset +import time: 446 | 680 | weakref +import time: 210 | 890 | copy +import time: 1398 | 1398 | _ast +import time: 609 | 609 | contextlib +import time: 1228 | 3233 | ast +import time: 211 | 211 | _opcode +import time: 231 | 231 | _opcode_metadata +import time: 309 | 750 | opcode +import time: 908 | 1657 | dis +import time: 167 | 167 | linecache +import time: 180 | 180 | token +import time: 42 | 42 | _tokenize +import time: 815 | 1037 | tokenize +import time: 2370 | 8462 | inspect +import time: 716 | 10066 | dataclasses +import time: 1404 | 11470 | hopi.models +import time: 401 | 17002 | hopi.data_handler +import time: 144 | 144 | __future__ +import time: 438 | 438 | serial.serialutil +import time: 168 | 168 | fcntl +import time: 165 | 165 | select +import time: 216 | 216 | termios +import time: 180 | 180 | array +import time: 488 | 1215 | serial.serialposix +import time: 215 | 2011 | serial +import time: 951 | 2962 | hopi.serial_client +import time: 81 | 81 | _locale +import time: 909 | 990 | locale +import time: 170 | 170 | fnmatch +import time: 343 | 343 | zlib +import time: 211 | 211 | _compression +import time: 226 | 226 | _bz2 +import time: 271 | 708 | bz2 +import time: 265 | 265 | _lzma +import time: 266 | 531 | lzma +import time: 951 | 2700 | shutil diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..7ad05ef --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +pyserial>=3.5 diff --git a/test.py b/test.py deleted file mode 100644 index 693f732..0000000 --- a/test.py +++ /dev/null @@ -1,146 +0,0 @@ -import serial -import struct -import math - -PORT = "/dev/ttyUSB0" -BAUD = 9600 -SLAVE_ID = 1 -START_REG = 0 -NUM_WORDS = 20 - -# --- build Modbus RTU request manually --- -# 01 03 00 00 00 14 CRC_LO CRC_HI -request = bytes([ - 0x01, # slave - 0x03, # function - 0x00, 0x00, # start register - 0x00, 0x14, # number of registers (20) - 0x45, 0xC5 # CRC (we don't care if it's right here) -]) - -# --- open serial port --- -ser = serial.Serial( - port=PORT, - baudrate=BAUD, - bytesize=serial.EIGHTBITS, - parity=serial.PARITY_NONE, - stopbits=serial.STOPBITS_ONE, - timeout=0.1 # read timeout (seconds) -) - -# flush buffers -ser.reset_input_buffer() -ser.reset_output_buffer() - -# send request -ser.write(request) -ser.flush() - -# small delay to allow response to arrive -#time.sleep(0.1) - -# read whatever is available -response = ser.read(64) - -ser.close() - -#print("Raw response bytes:") -#print(" ".join(f"{b:02X}" for b in response)) - -# --- basic sanity check --- -if len(response) < 3: - raise RuntimeError("No valid response") - -slave = response[0] -func = response[1] -byte_count = response[2] - -# data bytes start at index 3 -data = response[3:3 + byte_count] - -# convert data to 16-bit registers (big endian) -registers = [] -for i in range(0, len(data), 2): - registers.append((data[i] << 8) | data[i+1]) - -def float_dcba(regs, index): - w1 = regs[index] - w2 = regs[index + 1] - b = bytes([ - (w2 & 0xFF), (w2 >> 8), - (w1 & 0xFF), (w1 >> 8) - ]) - return struct.unpack(">f", b)[0] - -def apparent_from_VI(V, I): - """ - Calculate apparent power (S) from voltage and current. - Handles zero current edge case. - """ - if V == 0 or I == 0: - return 0.0 - return V * I - -def apparent_from_PF(P, PF): - """ - Calculate apparent power (S) from active power and power factor. - Handles zero or near-zero PF edge case. - """ - if PF == 0: - return 0.0 - return P / PF - -def reactive_from_P_S(P, S): - """ - Calculate reactive power (Q) from active power and apparent power. - Handles S smaller than P (possible due to rounding errors). - """ - if S < P: - return 0.0 - return math.sqrt(S**2 - P**2) - -def apparent_from_P_Q(P, Q): - """ - Calculate apparent power (S) from active and reactive power. - Handles edge case where both P and Q are zero. - """ - if P == 0 and Q == 0: - return 0.0 - return math.sqrt(P**2 + Q**2) - - -active_power = float_dcba(registers, 0) # W (float) -rms_current = float_dcba(registers, 2) # A (float) -voltage = float_dcba(registers, 4) # V (float) -frequency = float_dcba(registers, 6) # Hz (float) -power_factor = float_dcba(registers, 8) # pf (float) -annual_power_consumption = float_dcba(registers, 10) # KWH (float) -active_consumption = float_dcba(registers, 12) # KWH (float) -reactive_consumption = float_dcba(registers, 14) # KWH (float) -load_time_hours = float_dcba(registers, 16) / 60.0 # Hrs (float) -work_hours_per_day = int(registers[18]) # Hrs (int) -device_address = int(registers[19]) # Device Address (int) - - -S_from_VI = apparent_from_VI(voltage, rms_current) -S_from_PF = apparent_from_PF(active_power, power_factor) -Q_calculated = reactive_from_P_S(active_power, S_from_PF) -S_from_PQ = apparent_from_P_Q(active_consumption, reactive_consumption) - -# Now print using the stored variables -print(f"{active_power:10.5f} W Active Power") -print(f"{S_from_VI:10.5f} VA Apparent Power (V*I)") -print(f"{S_from_PF:10.5f} VA Apparent Power (P/PF)") -print(f"{Q_calculated:10.5f} VAR Reactive Power (from P & S)") -print(f"{rms_current:10.5f} A RMS Current") -print(f"{voltage:10.5f} V Voltage") -print(f"{frequency:10.5f} Hz Frequency") -print(f"{power_factor:10.5f} pf Power Factor") -print(f"{annual_power_consumption:10.5f} KWH Annual Power Consumption") -print(f"{active_consumption:10.5f} KWH Active Consumption") -print(f"{reactive_consumption:10.5f} KWH Reactive Consumption") -print(f"{S_from_PQ:10.5f} KVAh Apparent Power (from P & Q consumption)") -print(f"{load_time_hours:10.5f} Hrs Load Time") -print(f"{work_hours_per_day:10d} Hrs Work Hours per Day") -print(f"{device_address:10d} Device Address") -