add crude mqtt read capabality
This commit is contained in:
parent
5231f262e9
commit
8d8e1cc5eb
106
Hopi.py
106
Hopi.py
|
|
@ -4,6 +4,7 @@ from datetime import datetime, timezone
|
|||
|
||||
from hopi.data_handler import PowerMeterDataHandler
|
||||
from hopi.gui_plotter import PlotConfig, RealtimePlotter
|
||||
from hopi.mqtt_client import MqttConfig, MqttJsonSubscriber
|
||||
from hopi.serial_client import ModbusRtuSerialClient, SerialConfig
|
||||
|
||||
|
||||
|
|
@ -15,9 +16,23 @@ 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(
|
||||
"--source",
|
||||
choices=["usb", "mqtt"],
|
||||
default="usb",
|
||||
help="Data source: 'usb' (Modbus RTU over serial) or 'mqtt' (JSON over MQTT)",
|
||||
)
|
||||
|
||||
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("--mqtt-server", default="localhost:1883", help="MQTT broker host or host:port")
|
||||
parser.add_argument("--mqtt-topic", default="pico-usb-host-modbus/modbus/readings", help="MQTT topic to subscribe to")
|
||||
parser.add_argument("--mqtt-username", default=None)
|
||||
parser.add_argument("--mqtt-password", default=None)
|
||||
parser.add_argument("--mqtt-client-id", default=None)
|
||||
|
||||
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")
|
||||
|
|
@ -29,16 +44,20 @@ def build_arg_parser() -> argparse.ArgumentParser:
|
|||
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()
|
||||
|
||||
if args.source == "usb":
|
||||
config = SerialConfig(port=args.port, baudrate=args.baud, timeout=args.timeout)
|
||||
client = ModbusRtuSerialClient(config)
|
||||
else:
|
||||
client = None
|
||||
|
||||
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:
|
||||
if args.source == "usb" and args.interval <= args.timeout:
|
||||
raise ValueError("--interval must be > --timeout")
|
||||
|
||||
t0 = time.monotonic()
|
||||
|
|
@ -50,22 +69,61 @@ def main() -> None:
|
|||
plotter = RealtimePlotter(series_names, config=PlotConfig(max_points=max(10, args.max_points)))
|
||||
plotter.open()
|
||||
|
||||
with client, handler.open_csv_logger(args.csv) as logger:
|
||||
if args.source == "usb":
|
||||
assert client is not None
|
||||
with client, handler.open_csv_logger(args.csv) as logger:
|
||||
try:
|
||||
while True:
|
||||
if plotter is not None and not plotter.is_open():
|
||||
return
|
||||
|
||||
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()}
|
||||
flat = handler.readings_to_flat_dict(readings)
|
||||
row.update(flat)
|
||||
logger.log(row)
|
||||
|
||||
if plotter is not None:
|
||||
plotter.update(time.monotonic() - t0, flat)
|
||||
plotter.process_events()
|
||||
|
||||
next_deadline += args.interval
|
||||
sleep_for = next_deadline - time.monotonic()
|
||||
if sleep_for > 0:
|
||||
time.sleep(sleep_for)
|
||||
except KeyboardInterrupt:
|
||||
return
|
||||
|
||||
mqtt_cfg = MqttConfig(
|
||||
server=args.mqtt_server,
|
||||
topic=args.mqtt_topic,
|
||||
username=args.mqtt_username,
|
||||
password=args.mqtt_password,
|
||||
client_id=args.mqtt_client_id,
|
||||
)
|
||||
|
||||
with MqttJsonSubscriber(mqtt_cfg) as sub, handler.open_csv_logger(args.csv) as logger:
|
||||
try:
|
||||
while True:
|
||||
if plotter is not None and not plotter.is_open():
|
||||
return
|
||||
|
||||
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)
|
||||
payload = sub.get(timeout=args.interval)
|
||||
if payload is None:
|
||||
if plotter is not None:
|
||||
plotter.process_events()
|
||||
continue
|
||||
|
||||
readings = handler.decode_readings_from_mqtt_payload(payload)
|
||||
row = {"timestamp": datetime.now(timezone.utc).isoformat()}
|
||||
flat = handler.readings_to_flat_dict(readings)
|
||||
row.update(flat)
|
||||
|
|
@ -74,14 +132,26 @@ def main() -> None:
|
|||
if plotter is not None:
|
||||
plotter.update(time.monotonic() - t0, flat)
|
||||
plotter.process_events()
|
||||
|
||||
next_deadline += args.interval
|
||||
sleep_for = next_deadline - time.monotonic()
|
||||
if sleep_for > 0:
|
||||
time.sleep(sleep_for)
|
||||
except KeyboardInterrupt:
|
||||
return
|
||||
|
||||
if args.source == "mqtt":
|
||||
mqtt_cfg = MqttConfig(
|
||||
server=args.mqtt_server,
|
||||
topic=args.mqtt_topic,
|
||||
username=args.mqtt_username,
|
||||
password=args.mqtt_password,
|
||||
client_id=args.mqtt_client_id,
|
||||
)
|
||||
with MqttJsonSubscriber(mqtt_cfg) as sub:
|
||||
payload = sub.get(timeout=max(15.0, args.timeout))
|
||||
if payload is None:
|
||||
raise RuntimeError("No MQTT message received (timeout)")
|
||||
readings = handler.decode_readings_from_mqtt_payload(payload)
|
||||
handler.print_readings_json(readings)
|
||||
return
|
||||
|
||||
assert client is not None
|
||||
with client:
|
||||
response = client.read_holding_registers(
|
||||
slave_id=SLAVE_ID,
|
||||
|
|
|
|||
|
|
@ -43,6 +43,63 @@ class ReadingsCsvLogger:
|
|||
class PowerMeterDataHandler:
|
||||
"""Parses Modbus RTU responses and computes derived power metrics."""
|
||||
|
||||
def decode_readings_from_mqtt_payload(self, payload: dict) -> PowerMeterReadings:
|
||||
"""Decode a JSON dict coming from MQTT into PowerMeterReadings.
|
||||
|
||||
Accepts both this app's internal naming as well as the device's shorter keys.
|
||||
Example device payload keys:
|
||||
- annual_kwh, active_kwh, reactive_kwh, load_time_h, hours_day, device_addr
|
||||
"""
|
||||
|
||||
active_power = self._get_float(payload, "active_power_w")
|
||||
rms_current = self._get_float(payload, "rms_current_a")
|
||||
voltage = self._get_float(payload, "voltage_v")
|
||||
frequency = self._get_float(payload, "frequency_hz")
|
||||
power_factor = self._get_float(payload, "power_factor")
|
||||
|
||||
annual_power_consumption = self._get_float(
|
||||
payload,
|
||||
"annual_power_consumption_kwh",
|
||||
"annual_kwh",
|
||||
)
|
||||
active_consumption = self._get_float(
|
||||
payload,
|
||||
"active_consumption_kwh",
|
||||
"active_kwh",
|
||||
)
|
||||
reactive_consumption = self._get_float(
|
||||
payload,
|
||||
"reactive_consumption_kwh",
|
||||
"reactive_kwh",
|
||||
)
|
||||
|
||||
load_time_hours = self._get_float(payload, "load_time_hours", "load_time_h")
|
||||
work_hours_per_day = self._get_int(payload, "work_hours_per_day", "hours_day")
|
||||
device_address = self._get_int(payload, "device_address", "device_addr")
|
||||
|
||||
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 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.
|
||||
|
|
@ -196,3 +253,25 @@ class PowerMeterDataHandler:
|
|||
if active_kwh == 0 and reactive_kwh == 0:
|
||||
return 0.0
|
||||
return math.sqrt(active_kwh**2 + reactive_kwh**2)
|
||||
|
||||
@staticmethod
|
||||
def _get_float(payload: dict, *keys: str, default: float = 0.0) -> float:
|
||||
for key in keys:
|
||||
if key in payload and payload[key] is not None:
|
||||
value = payload[key]
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
return default
|
||||
|
||||
@staticmethod
|
||||
def _get_int(payload: dict, *keys: str, default: int = 0) -> int:
|
||||
for key in keys:
|
||||
if key in payload and payload[key] is not None:
|
||||
value = payload[key]
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
return default
|
||||
|
|
|
|||
|
|
@ -0,0 +1,139 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import queue
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MqttConfig:
|
||||
server: str # host or host:port
|
||||
topic: str
|
||||
username: Optional[str] = None
|
||||
password: Optional[str] = None
|
||||
client_id: Optional[str] = None
|
||||
keepalive: int = 60
|
||||
|
||||
|
||||
def _parse_server(server: str) -> tuple[str, int]:
|
||||
server = server.strip()
|
||||
if not server:
|
||||
raise ValueError("MQTT server must not be empty")
|
||||
|
||||
# Accept host or host:port.
|
||||
if ":" in server:
|
||||
host, port_s = server.rsplit(":", 1)
|
||||
host = host.strip()
|
||||
if not host:
|
||||
raise ValueError("MQTT server host must not be empty")
|
||||
try:
|
||||
port = int(port_s)
|
||||
except ValueError as exc:
|
||||
raise ValueError("MQTT server port must be an integer") from exc
|
||||
return host, port
|
||||
|
||||
return server, 1883
|
||||
|
||||
|
||||
class MqttJsonSubscriber:
|
||||
"""Subscribes to a topic and yields decoded JSON payloads."""
|
||||
|
||||
def __init__(self, config: MqttConfig):
|
||||
self._config = config
|
||||
self._queue: queue.Queue[Dict[str, Any]] = queue.Queue(maxsize=1000)
|
||||
self._client = None
|
||||
|
||||
def __enter__(self) -> "MqttJsonSubscriber":
|
||||
self.open()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb) -> None:
|
||||
self.close()
|
||||
|
||||
def open(self) -> None:
|
||||
if self._client is not None:
|
||||
return
|
||||
|
||||
try:
|
||||
import paho.mqtt.client as mqtt
|
||||
except Exception as exc: # pragma: no cover
|
||||
raise RuntimeError(
|
||||
"MQTT requires paho-mqtt. Install it (pip install paho-mqtt) or run with --source usb."
|
||||
) from exc
|
||||
|
||||
host, port = _parse_server(self._config.server)
|
||||
|
||||
client = mqtt.Client(client_id=self._config.client_id)
|
||||
if self._config.username is not None:
|
||||
client.username_pw_set(self._config.username, self._config.password)
|
||||
|
||||
connected = threading.Event()
|
||||
connect_rc: dict[str, int] = {"rc": -1}
|
||||
|
||||
def on_connect(_client, _userdata, _flags, rc):
|
||||
connect_rc["rc"] = int(rc)
|
||||
connected.set()
|
||||
if rc != 0:
|
||||
return
|
||||
_client.subscribe(self._config.topic)
|
||||
|
||||
def on_message(_client, _userdata, msg):
|
||||
try:
|
||||
payload = msg.payload.decode("utf-8", errors="strict")
|
||||
data = json.loads(payload)
|
||||
if isinstance(data, dict):
|
||||
try:
|
||||
self._queue.put_nowait(data)
|
||||
except queue.Full:
|
||||
# Drop oldest to keep latest values moving.
|
||||
try:
|
||||
_ = self._queue.get_nowait()
|
||||
except queue.Empty:
|
||||
pass
|
||||
try:
|
||||
self._queue.put_nowait(data)
|
||||
except queue.Full:
|
||||
pass
|
||||
except Exception:
|
||||
# Ignore invalid JSON messages.
|
||||
return
|
||||
|
||||
client.on_connect = on_connect
|
||||
client.on_message = on_message
|
||||
|
||||
client.connect(host, port, keepalive=self._config.keepalive)
|
||||
client.loop_start()
|
||||
|
||||
if not connected.wait(timeout=5.0):
|
||||
client.loop_stop()
|
||||
client.disconnect()
|
||||
raise RuntimeError("MQTT connect timed out")
|
||||
|
||||
if connect_rc["rc"] != 0:
|
||||
client.loop_stop()
|
||||
client.disconnect()
|
||||
raise RuntimeError(f"MQTT connect failed (rc={connect_rc['rc']})")
|
||||
|
||||
self._client = client
|
||||
|
||||
def close(self) -> None:
|
||||
client = self._client
|
||||
self._client = None
|
||||
if client is None:
|
||||
return
|
||||
try:
|
||||
client.loop_stop()
|
||||
finally:
|
||||
try:
|
||||
client.disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def get(self, *, timeout: Optional[float] = None) -> Optional[Dict[str, Any]]:
|
||||
"""Get the next JSON message dict, or None on timeout."""
|
||||
try:
|
||||
return self._queue.get(timeout=timeout)
|
||||
except queue.Empty:
|
||||
return None
|
||||
|
|
@ -1,2 +1,3 @@
|
|||
pyserial>=3.5
|
||||
matplotlib>=3.5
|
||||
paho-mqtt>=1.6
|
||||
|
|
|
|||
Loading…
Reference in New Issue