Files
we_monitor/gui_client.py
T
jensandClaude Sonnet 4.6 f0e0c9c6ae Add PyQt5 GUI client with Connector, Dashboard, and Plot tabs
- Connector tab: host/port fields, connect/disconnect button, status
- Dashboard tab: live QTreeWidget of the full snapshot hierarchy;
  physical values show value and unit in separate columns;
  expand state preserved across refreshes
- Plot tab: matplotlib time-series subplots (2 columns) for every
  physical {value, unit} field, scrollable, auto-layout on new keys
- TcpReader runs in a daemon thread and delivers messages via
  Qt signals so the UI never blocks

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 20:06:50 +02:00

430 lines
15 KiB
Python

#!/usr/bin/env python3
"""
WeConnect GUI client.
Connects to the collect.py TCP push server and displays live data.
Tabs
----
Connector — server address, connect / disconnect, status
Dashboard — live tree view of the latest snapshot
Plot — time-series charts of all physical {value, unit} fields
"""
import json
import socket
import sys
import threading
from collections import deque
from datetime import datetime
from PyQt5.QtCore import QObject, Qt, pyqtSignal
from PyQt5.QtGui import QColor, QFont
from PyQt5.QtWidgets import (
QApplication, QFormLayout, QGroupBox, QHBoxLayout, QLabel,
QLineEdit, QMainWindow, QPushButton, QScrollArea, QSizePolicy,
QSpinBox, QStatusBar, QTabWidget, QTreeWidget, QTreeWidgetItem,
QVBoxLayout, QWidget,
)
import matplotlib
matplotlib.use("Qt5Agg")
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
from matplotlib.figure import Figure
MAX_PLOT_POINTS = 200
PLOT_COLS = 2
PLOT_ROW_INCHES = 3.0
# ── TCP reader ────────────────────────────────────────────────────────────────
class TcpReader(QObject):
"""Background thread that emits decoded JSON messages via Qt signals."""
message = pyqtSignal(dict)
status_changed = pyqtSignal(str) # emitted only on unexpected loss
def __init__(self):
super().__init__()
self._sock = None
self._running = False
def connect_to(self, host: str, port: int) -> str | None:
"""Open connection; returns error string or None on success."""
try:
self._sock = socket.create_connection((host, port), timeout=5)
except OSError as exc:
return str(exc)
self._running = True
threading.Thread(target=self._read_loop, daemon=True).start()
return None
def disconnect(self):
self._running = False
if self._sock:
try:
self._sock.close()
except OSError:
pass
self._sock = None
def _read_loop(self):
buf = ""
try:
while self._running:
chunk = self._sock.recv(4096)
if not chunk:
break
buf += chunk.decode(errors="replace")
while "\n" in buf:
line, buf = buf.split("\n", 1)
line = line.strip()
if line:
try:
self.message.emit(json.loads(line))
except json.JSONDecodeError:
pass
except OSError:
pass
if self._running:
self._running = False
self.status_changed.emit("Connection lost")
# ── helpers ───────────────────────────────────────────────────────────────────
def _is_phys(v) -> bool:
return isinstance(v, dict) and "value" in v and "unit" in v
def _extract_phys(data: dict, prefix: str = "") -> dict[str, dict]:
"""Return a flat dict of path → {value, unit} for every physical leaf."""
result = {}
for k, v in data.items():
if k == "ts":
continue
path = f"{prefix}.{k}" if prefix else k
if _is_phys(v):
result[path] = v
elif isinstance(v, dict):
result.update(_extract_phys(v, path))
return result
# ── Connector tab ─────────────────────────────────────────────────────────────
class ConnectorTab(QWidget):
connect_requested = pyqtSignal(str, int)
disconnect_requested = pyqtSignal()
def __init__(self):
super().__init__()
layout = QVBoxLayout(self)
layout.setAlignment(Qt.AlignTop)
grp = QGroupBox("Server")
form = QFormLayout(grp)
self._host = QLineEdit("127.0.0.1")
self._port = QSpinBox()
self._port.setRange(1, 65535)
self._port.setValue(9999)
form.addRow("Host:", self._host)
form.addRow("Port:", self._port)
btn_row = QHBoxLayout()
self._btn = QPushButton("Connect")
self._btn.setFixedWidth(110)
self._btn.clicked.connect(self._toggle)
btn_row.addWidget(self._btn)
btn_row.addStretch()
form.addRow(btn_row)
self._status = QLabel("Disconnected")
self._status.setStyleSheet("color: gray;")
form.addRow("Status:", self._status)
layout.addWidget(grp)
def _toggle(self):
if self._btn.text() == "Connect":
self.connect_requested.emit(self._host.text(), self._port.value())
else:
self.disconnect_requested.emit()
def set_connected(self, host: str, port: int):
self._btn.setText("Disconnect")
self._host.setEnabled(False)
self._port.setEnabled(False)
self._status.setText(f"Connected → {host}:{port}")
self._status.setStyleSheet("color: green; font-weight: bold;")
def set_disconnected(self, reason: str = ""):
self._btn.setText("Connect")
self._host.setEnabled(True)
self._port.setEnabled(True)
if reason:
self._status.setText(f"Disconnected ({reason})")
self._status.setStyleSheet("color: red;")
else:
self._status.setText("Disconnected")
self._status.setStyleSheet("color: gray;")
# ── Dashboard tab ─────────────────────────────────────────────────────────────
class DashboardTab(QWidget):
def __init__(self):
super().__init__()
layout = QVBoxLayout(self)
self._ts = QLabel("Last update: —")
self._ts.setAlignment(Qt.AlignRight)
self._ts.setStyleSheet("color: gray; font-size: 11px;")
layout.addWidget(self._ts)
self._tree = QTreeWidget()
self._tree.setColumnCount(3)
self._tree.setHeaderLabels(["Field", "Value", "Unit"])
self._tree.setColumnWidth(0, 320)
self._tree.setColumnWidth(1, 180)
self._tree.setColumnWidth(2, 80)
self._tree.setAlternatingRowColors(True)
self._tree.setRootIsDecorated(True)
layout.addWidget(self._tree)
def update(self, snapshot: dict):
self._ts.setText(f"Last update: {snapshot.get('ts', '—')}")
expanded = self._expanded_paths()
self._tree.clear()
bold = QFont()
bold.setBold(True)
domain_color = QColor("#1a4d99")
for domain, domain_data in snapshot.items():
if domain == "ts" or not isinstance(domain_data, dict):
continue
d_item = QTreeWidgetItem([domain.upper()])
d_item.setFont(0, bold)
d_item.setForeground(0, domain_color)
for obj_name, fields in domain_data.items():
if not isinstance(fields, dict):
continue
obj_item = QTreeWidgetItem([obj_name])
obj_item.setFont(0, bold)
self._add_fields(obj_item, fields, f"{domain}/{obj_name}")
d_item.addChild(obj_item)
self._tree.addTopLevelItem(d_item)
self._tree.expandAll()
self._restore_expanded(expanded)
def _add_fields(self, parent: QTreeWidgetItem, data: dict, path: str):
for k, v in data.items():
child_path = f"{path}/{k}"
if _is_phys(v):
item = QTreeWidgetItem([k, str(v["value"]), v["unit"]])
item.setTextAlignment(1, Qt.AlignRight | Qt.AlignVCenter)
parent.addChild(item)
elif isinstance(v, dict):
node = QTreeWidgetItem([k])
self._add_fields(node, v, child_path)
parent.addChild(node)
else:
item = QTreeWidgetItem([k, str(v), ""])
item.setTextAlignment(1, Qt.AlignRight | Qt.AlignVCenter)
parent.addChild(item)
# ── preserve expand state across refreshes ──────────────────────────────
def _expanded_paths(self) -> set[str]:
paths = set()
root = self._tree.invisibleRootItem()
self._collect_expanded(root, "", paths)
return paths
def _collect_expanded(self, item, prefix, paths):
for i in range(item.childCount()):
child = item.child(i)
path = f"{prefix}/{child.text(0)}"
if child.isExpanded():
paths.add(path)
self._collect_expanded(child, path, paths)
def _restore_expanded(self, paths: set[str]):
root = self._tree.invisibleRootItem()
self._apply_expanded(root, "", paths)
def _apply_expanded(self, item, prefix, paths):
for i in range(item.childCount()):
child = item.child(i)
path = f"{prefix}/{child.text(0)}"
if path in paths:
child.setExpanded(True)
self._apply_expanded(child, path, paths)
# ── Plot tab ──────────────────────────────────────────────────────────────────
class PlotTab(QWidget):
def __init__(self):
super().__init__()
outer = QVBoxLayout(self)
outer.setContentsMargins(0, 0, 0, 0)
scroll = QScrollArea()
scroll.setWidgetResizable(True)
outer.addWidget(scroll)
container = QWidget()
inner = QVBoxLayout(container)
inner.setContentsMargins(4, 4, 4, 4)
scroll.setWidget(container)
self._fig = Figure(tight_layout=True)
self._canvas = FigureCanvas(self._fig)
self._canvas.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
inner.addWidget(self._canvas)
self._series: dict[str, deque] = {} # path → deque[(datetime, float)]
self._units: dict[str, str] = {} # path → unit string
self._axes: dict[str, object] = {} # path → matplotlib Axes
def update(self, snapshot: dict):
ts_raw = snapshot.get("ts", "")
try:
ts = datetime.fromisoformat(ts_raw)
except (ValueError, TypeError):
ts = datetime.utcnow()
phys = _extract_phys(snapshot)
if not phys:
return
new_keys = [k for k in phys if k not in self._series]
for k in new_keys:
self._series[k] = deque(maxlen=MAX_PLOT_POINTS)
for k, pv in phys.items():
self._units[k] = pv.get("unit", "")
try:
self._series[k].append((ts, float(pv["value"])))
except (TypeError, ValueError):
pass
if new_keys:
self._rebuild_layout()
self._redraw()
def _rebuild_layout(self):
keys = list(self._series.keys())
n = len(keys)
cols = min(PLOT_COLS, n)
rows = (n + cols - 1) // cols
self._fig.clear()
self._axes = {}
height = max(3.0, rows * PLOT_ROW_INCHES)
self._fig.set_size_inches(10, height)
self._canvas.setMinimumHeight(int(height * self._fig.dpi))
for i, k in enumerate(keys):
ax = self._fig.add_subplot(rows, cols, i + 1)
label = k.split(".")[-1]
ax.set_title(label, fontsize=9, pad=4)
ax.set_ylabel(self._units.get(k, ""), fontsize=8)
ax.tick_params(axis="both", labelsize=7)
ax.grid(True, linewidth=0.4, alpha=0.5)
self._axes[k] = ax
def _redraw(self):
for k, ax in self._axes.items():
series = self._series.get(k)
if not series:
continue
times, values = zip(*series)
ax.clear()
ax.set_title(k.split(".")[-1], fontsize=9, pad=4)
ax.set_ylabel(self._units.get(k, ""), fontsize=8)
ax.tick_params(axis="both", labelsize=7)
ax.grid(True, linewidth=0.4, alpha=0.5)
ax.plot(times, values, linewidth=1.4, color="#1a6eb5")
if len(times) > 1:
ax.set_xlim(times[0], times[-1])
self._fig.autofmt_xdate(rotation=25, ha="right")
self._canvas.draw_idle()
# ── Main window ───────────────────────────────────────────────────────────────
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("WeConnect Monitor")
self.resize(960, 700)
self._reader = TcpReader()
self._reader.message.connect(self._on_message)
self._reader.status_changed.connect(self._on_connection_lost)
self._connector = ConnectorTab()
self._dashboard = DashboardTab()
self._plot = PlotTab()
tabs = QTabWidget()
tabs.addTab(self._connector, "Connector")
tabs.addTab(self._dashboard, "Dashboard")
tabs.addTab(self._plot, "Plot")
self.setCentralWidget(tabs)
self._statusbar = QStatusBar()
self.setStatusBar(self._statusbar)
self._statusbar.showMessage("Disconnected")
self._connector.connect_requested.connect(self._connect)
self._connector.disconnect_requested.connect(self._disconnect)
def _connect(self, host: str, port: int):
err = self._reader.connect_to(host, port)
if err:
self._connector.set_disconnected(err)
self._statusbar.showMessage(f"Connection failed: {err}")
else:
self._connector.set_connected(host, port)
self._statusbar.showMessage(f"Connected to {host}:{port}")
def _disconnect(self):
self._reader.disconnect()
self._connector.set_disconnected()
self._statusbar.showMessage("Disconnected")
def _on_message(self, data: dict):
self._dashboard.update(data)
self._plot.update(data)
self._statusbar.showMessage(
f"Last snapshot: {data.get('ts', '?')}"
)
def _on_connection_lost(self, reason: str):
self._connector.set_disconnected(reason)
self._statusbar.showMessage(reason)
# ── entry point ───────────────────────────────────────────────────────────────
def main():
app = QApplication(sys.argv)
app.setStyle("Fusion")
win = MainWindow()
win.show()
sys.exit(app.exec_())
if __name__ == "__main__":
main()