From 814c41098fea7664e1927c8652e83844d0872826 Mon Sep 17 00:00:00 2001 From: osiu97 Date: Fri, 2 Jan 2026 10:58:02 +0100 Subject: [PATCH] add daemon mode --- .gitignore | 2 + Hopi.py | 39 ++++++++++++++++- hopi/data_handler.py | 61 +++++++++++++++++++++++++- importtime.txt | 101 ------------------------------------------- 4 files changed, 100 insertions(+), 103 deletions(-) delete mode 100644 importtime.txt diff --git a/.gitignore b/.gitignore index c3c0905..f823643 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,5 @@ htmlcov/ # Editors / OS .vscode/ .DS_Store +importtime.txt +hopi_readings.csv diff --git a/Hopi.py b/Hopi.py index 53fee70..29344a6 100644 --- a/Hopi.py +++ b/Hopi.py @@ -1,4 +1,6 @@ import argparse +import time +from datetime import datetime, timezone from hopi.data_handler import PowerMeterDataHandler from hopi.serial_client import ModbusRtuSerialClient, SerialConfig @@ -15,6 +17,9 @@ def build_arg_parser() -> argparse.ArgumentParser: parser.add_argument("--port", default="/dev/ttyUSB0") parser.add_argument("--baud", type=int, default=9600) parser.add_argument("--timeout", type=float, default=0.1) + parser.add_argument("--daemon", action="store_true", help="Run continuously until stopped (Ctrl+C)") + parser.add_argument("--csv", default="hopi_readings.csv", help="CSV output path for daemon mode") + parser.add_argument("--interval", type=float, default=1.0, help="Seconds between reads in daemon mode") return parser @@ -25,6 +30,39 @@ def main() -> None: client = ModbusRtuSerialClient(config) handler = PowerMeterDataHandler() + if args.daemon: + if args.timeout <= 0: + raise ValueError("--timeout must be > 0") + if args.interval <= 0: + raise ValueError("--interval must be > 0") + if args.interval <= args.timeout: + raise ValueError("--interval must be > --timeout") + + next_deadline = time.monotonic() + with client, handler.open_csv_logger(args.csv) as logger: + try: + while True: + 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) + + row = {"timestamp": datetime.now(timezone.utc).isoformat()} + row.update(handler.readings_to_flat_dict(readings)) + logger.log(row) + + next_deadline += args.interval + sleep_for = next_deadline - time.monotonic() + if sleep_for > 0: + time.sleep(sleep_for) + except KeyboardInterrupt: + return + with client: response = client.read_holding_registers( slave_id=SLAVE_ID, @@ -36,7 +74,6 @@ def main() -> None: registers = handler.parse_read_holding_registers_response(response) readings = handler.decode_readings(registers) - handler.print_readings_json(readings) diff --git a/hopi/data_handler.py b/hopi/data_handler.py index f9a4e5b..ab2706a 100644 --- a/hopi/data_handler.py +++ b/hopi/data_handler.py @@ -1,11 +1,45 @@ +import csv import json +import os import math import struct -from typing import Iterable, List +from typing import List, Optional from .models import PowerMeterReadings +class ReadingsCsvLogger: + def __init__(self, file_path: str, fieldnames: List[str]): + self._file_path = file_path + self._fieldnames = fieldnames + self._fp = None + self._writer: Optional[csv.DictWriter] = None + + def __enter__(self) -> "ReadingsCsvLogger": + is_new_file = (not os.path.exists(self._file_path)) or os.path.getsize(self._file_path) == 0 + self._fp = open(self._file_path, "a", newline="", encoding="utf-8") + self._writer = csv.DictWriter(self._fp, fieldnames=self._fieldnames) + if is_new_file: + self._writer.writeheader() + self._fp.flush() + return self + + def __exit__(self, exc_type, exc, tb) -> None: + if self._fp is not None: + try: + self._fp.flush() + finally: + self._fp.close() + self._fp = None + self._writer = None + + def log(self, row: dict) -> None: + if self._writer is None or self._fp is None: + raise RuntimeError("CSV logger is not open") + self._writer.writerow(row) + self._fp.flush() + + class PowerMeterDataHandler: """Parses Modbus RTU responses and computes derived power metrics.""" @@ -90,6 +124,31 @@ class PowerMeterDataHandler: def print_readings_json(self, readings: PowerMeterReadings) -> None: print(self.readings_to_json(readings, ensure_ascii=False)) + def open_csv_logger(self, file_path: str) -> ReadingsCsvLogger: + fieldnames = ["timestamp"] + self._flat_readings_keys() + return ReadingsCsvLogger(file_path=file_path, fieldnames=fieldnames) + + @staticmethod + def _flat_readings_keys() -> List[str]: + # Keep order stable for CSV columns. + return [ + "active_power_w", + "rms_current_a", + "voltage_v", + "frequency_hz", + "power_factor", + "annual_power_consumption_kwh", + "active_consumption_kwh", + "reactive_consumption_kwh", + "load_time_hours", + "work_hours_per_day", + "device_address", + "apparent_power_vi_va", + "apparent_power_pf_va", + "reactive_power_var", + "apparent_consumption_kvah", + ] + @staticmethod def _bytes_to_registers_be(data: bytes) -> List[int]: registers: List[int] = [] diff --git a/importtime.txt b/importtime.txt deleted file mode 100644 index 5877f65..0000000 --- a/importtime.txt +++ /dev/null @@ -1,101 +0,0 @@ -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