- pg.PlotWidget (2×2 grid) with DateAxisItem for proper time axis - Built-in pan (left drag), zoom (right drag / scroll wheel), Reset zoom button - pg.InfiniteLine cursor with pg.SignalProxy for smooth 30 fps tracking - Cursor value label shows nearest-point values colour-coded per trace - Legend auto-updated via pi.legend.clear() + named plot() calls - matplotlib removed from requirements.txt Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
548 lines
20 KiB
Python
548 lines
20 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, QComboBox, QFormLayout, QGridLayout, QGroupBox,
|
||
QHBoxLayout, QLabel, QLineEdit, QMainWindow, QPushButton, QSizePolicy,
|
||
QSpinBox, QStatusBar, QTabWidget, QTreeWidget, QTreeWidgetItem,
|
||
QVBoxLayout, QWidget,
|
||
)
|
||
|
||
import pyqtgraph as pg
|
||
|
||
pg.setConfigOption("background", "w")
|
||
pg.setConfigOption("foreground", "k")
|
||
|
||
MAX_PLOT_POINTS = 200
|
||
|
||
|
||
# ── 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)
|
||
self._sock.settimeout(None) # blocking reads after connect
|
||
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):
|
||
NUM_PLOTS = 4
|
||
TRACES = 4
|
||
COLORS = ["#1f77b4", "#ff7f0e", "#2ca02c", "#d62728"]
|
||
|
||
def __init__(self):
|
||
super().__init__()
|
||
layout = QVBoxLayout(self)
|
||
layout.setContentsMargins(4, 4, 4, 4)
|
||
layout.setSpacing(4)
|
||
|
||
# ── trace selectors ───────────────────────────────────────────────────
|
||
sel = QWidget()
|
||
sel.setMaximumHeight(120)
|
||
sel_grid = QGridLayout(sel)
|
||
sel_grid.setContentsMargins(0, 0, 0, 0)
|
||
sel_grid.setSpacing(3)
|
||
|
||
self._combos: list[list[QComboBox]] = []
|
||
for i in range(self.NUM_PLOTS):
|
||
lbl = QLabel(f"<b>Plot {i + 1}</b>")
|
||
lbl.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
||
sel_grid.addWidget(lbl, i, 0)
|
||
row: list[QComboBox] = []
|
||
for j in range(self.TRACES):
|
||
cb = QComboBox()
|
||
cb.setMinimumWidth(160)
|
||
cb.addItem("—")
|
||
cb.currentIndexChanged.connect(self._on_selection_changed)
|
||
sel_grid.addWidget(cb, i, j + 1)
|
||
row.append(cb)
|
||
self._combos.append(row)
|
||
|
||
layout.addWidget(sel)
|
||
|
||
# ── toolbar row ───────────────────────────────────────────────────────
|
||
tb = QWidget()
|
||
tb_layout = QHBoxLayout(tb)
|
||
tb_layout.setContentsMargins(0, 0, 0, 0)
|
||
reset_btn = QPushButton("Reset zoom")
|
||
reset_btn.setFixedWidth(100)
|
||
reset_btn.clicked.connect(self._reset_zoom)
|
||
tb_layout.addWidget(reset_btn)
|
||
self._cursor_label = QLabel("")
|
||
self._cursor_label.setTextFormat(Qt.RichText)
|
||
self._cursor_label.setStyleSheet("font-size: 11px; color: #333;")
|
||
tb_layout.addWidget(self._cursor_label)
|
||
tb_layout.addStretch()
|
||
layout.addWidget(tb)
|
||
|
||
# ── 2×2 pyqtgraph plot grid ───────────────────────────────────────────
|
||
plot_container = QWidget()
|
||
plot_grid = QGridLayout(plot_container)
|
||
plot_grid.setContentsMargins(0, 0, 0, 0)
|
||
plot_grid.setSpacing(4)
|
||
|
||
self._plot_widgets: list[pg.PlotWidget] = []
|
||
self._vlines: list[pg.InfiniteLine] = []
|
||
self._proxies: list = [] # keep SignalProxy refs alive
|
||
|
||
for i in range(self.NUM_PLOTS):
|
||
pw = pg.PlotWidget(
|
||
background="white",
|
||
axisItems={"bottom": pg.DateAxisItem(orientation="bottom")},
|
||
)
|
||
pw.showGrid(x=True, y=True, alpha=0.3)
|
||
pw.getAxis("bottom").setStyle(tickFont=QFont("", 7))
|
||
pw.getAxis("left").setStyle(tickFont=QFont("", 7))
|
||
pw.addLegend(offset=(0, 0), labelTextSize="7pt")
|
||
pw.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||
|
||
vl = pg.InfiniteLine(
|
||
angle=90, movable=False,
|
||
pen=pg.mkPen("gray", width=1, style=Qt.DashLine),
|
||
)
|
||
vl.setVisible(False)
|
||
pw.addItem(vl)
|
||
self._vlines.append(vl)
|
||
|
||
proxy = pg.SignalProxy(
|
||
pw.scene().sigMouseMoved,
|
||
rateLimit=30,
|
||
slot=lambda evt, idx=i: self._on_mouse_move(evt, idx),
|
||
)
|
||
self._proxies.append(proxy)
|
||
|
||
plot_grid.addWidget(pw, i // 2, i % 2)
|
||
self._plot_widgets.append(pw)
|
||
|
||
layout.addWidget(plot_container)
|
||
|
||
# ── data ──────────────────────────────────────────────────────────────
|
||
self._series: dict[str, deque] = {}
|
||
self._units: dict[str, str] = {}
|
||
|
||
# ── public update called on every new snapshot ────────────────────────────
|
||
|
||
def update(self, snapshot: dict):
|
||
ts_raw = snapshot.get("ts", "")
|
||
try:
|
||
ts = datetime.fromisoformat(ts_raw).timestamp()
|
||
except (ValueError, TypeError):
|
||
ts = datetime.utcnow().timestamp()
|
||
|
||
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._refresh_combos()
|
||
|
||
self._redraw()
|
||
|
||
# ── combo management ──────────────────────────────────────────────────────
|
||
|
||
def _refresh_combos(self):
|
||
signals = sorted(self._series.keys())
|
||
for row in self._combos:
|
||
for cb in row:
|
||
current = cb.currentText()
|
||
cb.blockSignals(True)
|
||
cb.clear()
|
||
cb.addItem("—")
|
||
for s in signals:
|
||
cb.addItem(s)
|
||
idx = cb.findText(current)
|
||
cb.setCurrentIndex(idx if idx >= 0 else 0)
|
||
cb.blockSignals(False)
|
||
|
||
def _on_selection_changed(self):
|
||
self._redraw()
|
||
|
||
# ── drawing ───────────────────────────────────────────────────────────────
|
||
|
||
def _redraw(self):
|
||
for i, pw in enumerate(self._plot_widgets):
|
||
pi = pw.getPlotItem()
|
||
pi.clear()
|
||
pi.addItem(self._vlines[i])
|
||
if pi.legend:
|
||
pi.legend.clear()
|
||
|
||
for j, cb in enumerate(self._combos[i]):
|
||
key = cb.currentText()
|
||
if key == "—" or key not in self._series:
|
||
continue
|
||
series = self._series[key]
|
||
if not series:
|
||
continue
|
||
times, values = zip(*series)
|
||
label = f"{key.split('.')[-1]} [{self._units.get(key, '')}]"
|
||
pw.plot(
|
||
list(times), list(values),
|
||
pen=pg.mkPen(self.COLORS[j], width=1.5),
|
||
name=label,
|
||
)
|
||
|
||
# ── zoom reset ────────────────────────────────────────────────────────────
|
||
|
||
def _reset_zoom(self):
|
||
for pw in self._plot_widgets:
|
||
pw.getViewBox().autoRange()
|
||
|
||
# ── cursor tracing ────────────────────────────────────────────────────────
|
||
|
||
def _on_mouse_move(self, evt, plot_idx: int):
|
||
pos = evt[0]
|
||
pw = self._plot_widgets[plot_idx]
|
||
vl = self._vlines[plot_idx]
|
||
|
||
if not pw.sceneBoundingRect().contains(pos):
|
||
vl.setVisible(False)
|
||
self._cursor_label.setText("")
|
||
return
|
||
|
||
x = pw.getPlotItem().vb.mapSceneToView(pos).x()
|
||
vl.setPos(x)
|
||
vl.setVisible(True)
|
||
|
||
parts = []
|
||
for j, cb in enumerate(self._combos[plot_idx]):
|
||
key = cb.currentText()
|
||
if key == "—" or key not in self._series:
|
||
continue
|
||
series = list(self._series[key])
|
||
if not series:
|
||
continue
|
||
_, nearest_v = min(series, key=lambda p: abs(p[0] - x))
|
||
label = key.split(".")[-1]
|
||
unit = self._units.get(key, "")
|
||
color = self.COLORS[j]
|
||
parts.append(
|
||
f'<span style="color:{color}">■</span>'
|
||
f' {label}: <b>{nearest_v:.2f}</b> {unit}'
|
||
)
|
||
|
||
self._cursor_label.setText(" ".join(parts))
|
||
|
||
|
||
# ── 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()
|