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 # Editors / OS
.vscode/ .vscode/
.DS_Store .DS_Store
importtime.txt
hopi_readings.csv

39
Hopi.py
View File

@ -1,4 +1,6 @@
import argparse import argparse
import time
from datetime import datetime, timezone
from hopi.data_handler import PowerMeterDataHandler from hopi.data_handler import PowerMeterDataHandler
from hopi.serial_client import ModbusRtuSerialClient, SerialConfig 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("--port", default="/dev/ttyUSB0")
parser.add_argument("--baud", type=int, default=9600) parser.add_argument("--baud", type=int, default=9600)
parser.add_argument("--timeout", type=float, default=0.1) 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 return parser
@ -25,6 +30,39 @@ def main() -> None:
client = ModbusRtuSerialClient(config) client = ModbusRtuSerialClient(config)
handler = PowerMeterDataHandler() 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: with client:
response = client.read_holding_registers( response = client.read_holding_registers(
slave_id=SLAVE_ID, slave_id=SLAVE_ID,
@ -36,7 +74,6 @@ def main() -> None:
registers = handler.parse_read_holding_registers_response(response) registers = handler.parse_read_holding_registers_response(response)
readings = handler.decode_readings(registers) readings = handler.decode_readings(registers)
handler.print_readings_json(readings) handler.print_readings_json(readings)

View File

@ -1,11 +1,45 @@
import csv
import json import json
import os
import math import math
import struct import struct
from typing import Iterable, List from typing import List, Optional
from .models import PowerMeterReadings 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: class PowerMeterDataHandler:
"""Parses Modbus RTU responses and computes derived power metrics.""" """Parses Modbus RTU responses and computes derived power metrics."""
@ -90,6 +124,31 @@ class PowerMeterDataHandler:
def print_readings_json(self, readings: PowerMeterReadings) -> None: def print_readings_json(self, readings: PowerMeterReadings) -> None:
print(self.readings_to_json(readings, ensure_ascii=False)) 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 @staticmethod
def _bytes_to_registers_be(data: bytes) -> List[int]: def _bytes_to_registers_be(data: bytes) -> List[int]:
registers: 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