add daemon mode

This commit is contained in:
osiu97 2026-01-02 10:58:02 +01:00
parent 10d00c8fbb
commit 814c41098f
4 changed files with 100 additions and 103 deletions

2
.gitignore vendored
View File

@ -21,3 +21,5 @@ htmlcov/
# Editors / OS
.vscode/
.DS_Store
importtime.txt
hopi_readings.csv

39
Hopi.py
View File

@ -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)

View File

@ -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] = []

View File

@ -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