diff --git a/Hopi.py b/Hopi.py index 29344a6..3cc001e 100644 --- a/Hopi.py +++ b/Hopi.py @@ -3,6 +3,7 @@ import time from datetime import datetime, timezone from hopi.data_handler import PowerMeterDataHandler +from hopi.gui_plotter import PlotConfig, RealtimePlotter from hopi.serial_client import ModbusRtuSerialClient, SerialConfig @@ -20,6 +21,8 @@ def build_arg_parser() -> argparse.ArgumentParser: 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") + parser.add_argument("--gui", action="store_true", help="Show realtime plot in daemon mode") + parser.add_argument("--max-points", type=int, default=300, help="Max points to keep in the plot") return parser @@ -38,10 +41,21 @@ def main() -> None: if args.interval <= args.timeout: raise ValueError("--interval must be > --timeout") - next_deadline = time.monotonic() + t0 = time.monotonic() + next_deadline = t0 + + plotter = None + if args.gui: + series_names = handler.flat_readings_keys() # stable order + 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: 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, @@ -53,9 +67,14 @@ def main() -> None: readings = handler.decode_readings(registers) row = {"timestamp": datetime.now(timezone.utc).isoformat()} - row.update(handler.readings_to_flat_dict(readings)) + 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: diff --git a/hopi/data_handler.py b/hopi/data_handler.py index ab2706a..eae16ab 100644 --- a/hopi/data_handler.py +++ b/hopi/data_handler.py @@ -125,9 +125,12 @@ class PowerMeterDataHandler: print(self.readings_to_json(readings, ensure_ascii=False)) def open_csv_logger(self, file_path: str) -> ReadingsCsvLogger: - fieldnames = ["timestamp"] + self._flat_readings_keys() + fieldnames = ["timestamp"] + self.flat_readings_keys() return ReadingsCsvLogger(file_path=file_path, fieldnames=fieldnames) + def flat_readings_keys(self) -> List[str]: + return self._flat_readings_keys() + @staticmethod def _flat_readings_keys() -> List[str]: # Keep order stable for CSV columns. diff --git a/hopi/gui_plotter.py b/hopi/gui_plotter.py new file mode 100644 index 0000000..fab26f3 --- /dev/null +++ b/hopi/gui_plotter.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Dict, List, Optional + + +@dataclass +class PlotConfig: + max_points: int = 300 + + +class RealtimePlotter: + """Very small matplotlib-based realtime plotter with per-series toggles.""" + + def __init__(self, series_names: List[str], *, config: Optional[PlotConfig] = None): + self._series_names = list(series_names) + self._config = config or PlotConfig() + + self._t: List[float] = [] + self._y: Dict[str, List[float]] = {name: [] for name in self._series_names} + + self._plt = None + self._fig = None + self._ax = None + self._lines = {} + self._check = None + + def open(self) -> None: + try: + import matplotlib.pyplot as plt + from matplotlib.widgets import CheckButtons + except Exception as exc: # pragma: no cover + raise RuntimeError( + "GUI requires matplotlib. Install it (pip install matplotlib) or run without --gui." + ) from exc + + self._plt = plt + self._fig, self._ax = plt.subplots() + self._fig.canvas.manager.set_window_title("Hopi – Realtime Readings") + + # Leave space on the right for checkboxes. + self._fig.subplots_adjust(right=0.78) + + for name in self._series_names: + (line,) = self._ax.plot([], [], label=name, linewidth=1) + self._lines[name] = line + + self._ax.set_xlabel("t (s)") + self._ax.set_ylabel("value") + self._ax.grid(True, which="both", linewidth=0.5) + + # Checkboxes + rax = self._fig.add_axes([0.80, 0.10, 0.19, 0.85]) + visibility = [True] * len(self._series_names) + self._check = CheckButtons(rax, self._series_names, visibility) + + def _toggle(label: str) -> None: + line = self._lines.get(label) + if line is None: + return + line.set_visible(not line.get_visible()) + self._fig.canvas.draw_idle() + + self._check.on_clicked(_toggle) + + # Show legend for currently visible lines only; keep it minimal. + self._ax.legend(loc="upper left", fontsize="small") + plt.show(block=False) + + def is_open(self) -> bool: + if self._plt is None or self._fig is None: + return False + return bool(self._plt.fignum_exists(self._fig.number)) + + def update(self, t_seconds: float, readings: Dict[str, float]) -> None: + if not self.is_open() or self._ax is None: + return + + self._t.append(t_seconds) + for name in self._series_names: + self._y[name].append(float(readings.get(name, 0.0))) + + # Rolling window + if len(self._t) > self._config.max_points: + self._t = self._t[-self._config.max_points :] + for name in self._series_names: + self._y[name] = self._y[name][-self._config.max_points :] + + for name in self._series_names: + line = self._lines.get(name) + if line is not None: + line.set_data(self._t, self._y[name]) + + self._ax.relim() + self._ax.autoscale_view() + + def process_events(self) -> None: + if self._plt is None: + return + # Allow the GUI event loop to run. + self._plt.pause(0.001) diff --git a/requirements.txt b/requirements.txt index 7ad05ef..c6798ed 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ pyserial>=3.5 +matplotlib>=3.5