Split into server/ and client/ with separate requirements.txt

Move client.py and gui_client.py into client/, split root requirements.txt
into server/requirements.txt and client/requirements.txt, update install.sh
to reference server/requirements.txt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-27 18:26:19 +02:00
co-authored by Claude Sonnet 4.6
parent cc2c52a827
commit 423e6d3027
5 changed files with 4 additions and 4 deletions
+882
View File
@@ -0,0 +1,882 @@
#!/usr/bin/env python3
"""
WeConnect GUI client.
Connects to the collect.py TCP push server and displays live vehicle data.
Tabs
----
Connector — host / port / diff-mode form; connect / disconnect
Dashboard — live tree view of the latest snapshot (domain → object → field)
Plot — 8 independent time-series plots in a scrollable 4×2 grid
Plot interactions
-----------------
Scroll wheel — zoom X axis in/out, centred on cursor
Shift + scroll wheel — zoom Y axis in/out, centred on cursor
Click + drag — pan
Crosshair + tooltip — dashed vertical line + floating value readout per trace
"""
import json
import socket
import copy
import sys
import threading
import time
from collections import deque
from datetime import datetime
from pathlib import Path
from PyQt5.QtCore import QObject, QTimer, Qt, pyqtSignal
from PyQt5.QtGui import QColor, QFont
from PyQt5.QtWidgets import (
QApplication, QCheckBox, QComboBox, QFormLayout, QGridLayout, QGroupBox,
QHBoxLayout, QLabel, QLineEdit, QMainWindow, QPushButton, QScrollArea,
QSizePolicy, QSpinBox, QStatusBar, QTabWidget, QTreeWidget, QTreeWidgetItem,
QVBoxLayout, QWidget,
)
import pyqtgraph as pg
from jaydiff.merge import merge_full as jay_merge_full
pg.setConfigOption("background", "#12121e")
pg.setConfigOption("foreground", "#aaaacc")
MAX_PLOT_POINTS = 2880 # 24 h at 30 s polling interval
DARK_STYLE = """
QWidget { background:#1a1a2e; color:#aaaacc; font-size:12px; }
QMainWindow { background:#1a1a2e; }
QTabWidget::pane { border:1px solid #2a2a4a; background:#1a1a2e; }
QTabBar::tab { background:#12121e; color:#aaaacc; padding:5px 12px;
border:1px solid #2a2a4a; border-bottom:none; }
QTabBar::tab:selected { background:#1a1a2e; color:#ddddee; }
QTabBar::tab:hover { background:#2a2a4a; }
QGroupBox { border:1px solid #2a2a4a; border-radius:4px;
margin-top:8px; padding-top:8px; color:#aaaacc; }
QGroupBox::title { subcontrol-origin:margin; left:8px; color:#aaaacc; }
QLabel { color:#aaaacc; background:transparent; }
QLineEdit, QSpinBox { background:#12121e; border:1px solid #3a3a5e;
border-radius:3px; color:#ddddee; padding:2px 4px; }
QLineEdit:focus, QSpinBox:focus{ border:1px solid #555577; }
QSpinBox::up-button,
QSpinBox::down-button { background:#2a2a4a; border:none; }
QPushButton { background:#2a2a4a; color:#aaaacc; border:1px solid #3a3a5e;
border-radius:3px; padding:4px 10px; }
QPushButton:hover { background:#3a3a5e; }
QPushButton:pressed { background:#12121e; }
QPushButton:disabled { color:#555577; }
QComboBox { background:#12121e; border:1px solid #3a3a5e;
border-radius:3px; color:#ddddee; padding:2px 4px; }
QComboBox::drop-down { border:none; width:16px; }
QComboBox QAbstractItemView { background:#1a1a2e; color:#ddddee;
selection-background-color:#2a2a4a; }
QTreeWidget { background:#12121e; alternate-background-color:#1a1a2e;
color:#aaaacc; border:1px solid #2a2a4a; }
QTreeWidget::item:selected { background:#2a2a4a; color:#ddddee; }
QHeaderView::section { background:#12121e; color:#aaaacc;
border:1px solid #2a2a4a; padding:4px; }
QScrollArea { border:none; }
QScrollBar:vertical { background:#12121e; width:10px; margin:0; }
QScrollBar::handle:vertical { background:#3a3a5e; border-radius:4px; min-height:20px; }
QScrollBar::handle:vertical:hover { background:#555577; }
QScrollBar:horizontal { background:#12121e; height:10px; margin:0; }
QScrollBar::handle:horizontal { background:#3a3a5e; border-radius:4px; min-width:20px; }
QScrollBar::handle:horizontal:hover { background:#555577; }
QScrollBar::add-line,
QScrollBar::sub-line { width:0; height:0; }
QStatusBar { background:#12121e; color:#aaaacc; }
"""
_SETTINGS_PATH = Path.home() / ".config" / "we_monitor" / "plot_settings.json"
# Maps full dotted trace key → short display label used in chips, legends and
# cursor readout. Falls back to the last path component for unknown keys.
_TRACE_LABELS: dict[str, str] = {
"charging.power": "charge power",
"charging.remain": "charge time remain",
"charging.settings.target_level": "battery SOC set",
"climatisation.remain": "clima time remain",
"climatisation.settings.target_temperature": "clima temperature set",
"climatisation.environment.temperature_outside": "outside temperature",
"electric_drive.battery.soc": "battery soc",
"electric_drive.range": "range",
"electric_drive.range_full": "range (full)",
"electric_drive.battery.temperature_max": "battery temperature (max)",
"electric_drive.battery.temperature_min": "battery temperature (min)",
"electric_drive.odometer.odometer": "odometer",
"procedural.range_at_100": "range 100%",
}
def _trace_label(key: str) -> str:
return _TRACE_LABELS.get(key, key.split(".")[-1])
# ── 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
self._diff_mode = False
self._state: dict = {}
def connect_to(self, host: str, port: int, diff_mode: bool = False) -> 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)
except OSError as exc:
return str(exc)
self._diff_mode = diff_mode
self._state = {}
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:
data = json.loads(line)
if self._diff_mode:
self._state = jay_merge_full(self._state, data)
self.message.emit(copy.deepcopy(self._state))
else:
self.message.emit(data)
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)
self._diff_cb = QCheckBox("Diff mode")
self._load_conn_settings()
form.addRow("Host:", self._host)
form.addRow("Port:", self._port)
form.addRow(self._diff_cb)
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: #9E9E9E;")
form.addRow("Status:", self._status)
layout.addWidget(grp)
def _toggle(self):
if self._btn.text() == "Connect":
self._save_conn_settings()
self.connect_requested.emit(self._host.text(), self._port.value())
else:
self.disconnect_requested.emit()
@property
def diff_mode(self) -> bool:
return self._diff_cb.isChecked()
def _save_conn_settings(self):
try:
_SETTINGS_PATH.parent.mkdir(parents=True, exist_ok=True)
data = {}
try:
data = json.loads(_SETTINGS_PATH.read_text())
except (OSError, json.JSONDecodeError):
pass
data["host"] = self._host.text()
data["port"] = self._port.value()
data["diff_mode"] = self._diff_cb.isChecked()
_SETTINGS_PATH.write_text(json.dumps(data, indent=2))
except OSError:
pass
def _load_conn_settings(self):
try:
data = json.loads(_SETTINGS_PATH.read_text())
if "host" in data:
self._host.setText(data["host"])
if "port" in data:
self._port.setValue(int(data["port"]))
if "diff_mode" in data:
self._diff_cb.setChecked(bool(data["diff_mode"]))
except (OSError, json.JSONDecodeError, KeyError, ValueError):
pass
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: #4CAF50; 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: #F44336;")
else:
self._status.setText("Disconnected")
self._status.setStyleSheet("color: #9E9E9E;")
# ── 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: #9E9E9E; 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("#aaaacc")
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 _is_phys(fields):
item = QTreeWidgetItem([obj_name, str(fields["value"]), fields["unit"]])
item.setTextAlignment(1, Qt.AlignRight | Qt.AlignVCenter)
d_item.addChild(item)
elif isinstance(fields, dict):
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 TimeAxisItem(pg.AxisItem):
"""X-axis that shows HH:MM:SS, suppressing any date prefix."""
def tickStrings(self, values, scale, spacing):
return [datetime.fromtimestamp(v).strftime("%H:%M:%S") for v in values]
class PlotViewBox(pg.ViewBox):
"""ViewBox with axis-selective wheel zoom: plain wheel → X, Shift+wheel → Y."""
def wheelEvent(self, ev, axis=None):
if ev.modifiers() & Qt.ShiftModifier:
axis = 1 # Y only
else:
axis = 0 # X only
super().wheelEvent(ev, axis=axis)
class PlotTooltip(QWidget):
"""Floating value readout that follows the crosshair cursor."""
def __init__(self, parent: QWidget):
super().__init__(parent)
self.setAttribute(Qt.WA_TransparentForMouseEvents)
self.setStyleSheet(
"QWidget { background: rgba(18,18,30,215); border: 1px solid #444466;"
" border-radius: 3px; }"
"QLabel { color: #ddddee; font-size: 10px; border: none; }"
)
lay = QVBoxLayout(self)
lay.setContentsMargins(6, 4, 6, 4)
lay.setSpacing(1)
self._ts_label = QLabel()
self._ts_label.setStyleSheet("color: #888899; font-size: 9px;")
lay.addWidget(self._ts_label)
self._labels: list[QLabel] = []
self.hide()
def set_values(self, ts_str: str, entries: list):
if not entries:
self.hide()
return
self._ts_label.setText(ts_str)
while len(self._labels) < len(entries):
lbl = QLabel()
lbl.setTextFormat(Qt.RichText)
self.layout().addWidget(lbl)
self._labels.append(lbl)
for j, lbl in enumerate(self._labels):
if j < len(entries):
color, name, val = entries[j]
lbl.setText(f'<span style="color:{color};">&#9679;</span> {name}: {val}')
lbl.show()
else:
lbl.hide()
self.adjustSize()
self.show()
self.raise_()
class PlotTab(QWidget):
NUM_PLOTS = 8
MAX_TRACES = 4
COLORS = ["#2196F3", "#F44336", "#4CAF50", "#FF9800",
"#9C27B0", "#00BCD4", "#FFEB3B", "#795548"]
def __init__(self):
super().__init__()
main_layout = QVBoxLayout(self)
main_layout.setContentsMargins(4, 4, 4, 4)
main_layout.setSpacing(4)
# ── data ──────────────────────────────────────────────────────────────
self._tw_spins: list[QSpinBox] = []
self._traces: list[list[str]] = [[] for _ in range(self.NUM_PLOTS)]
self._saved_traces, _saved_tw = self._load_settings()
self._series: dict[str, deque] = {}
self._units: dict[str, str] = {}
self._redraw_timer = QTimer(self)
self._redraw_timer.setSingleShot(True)
self._redraw_timer.setInterval(100)
self._redraw_timer.timeout.connect(self._redraw)
# ── 4×2 scrollable plot grid (each cell: control bar + plot) ─────────
plot_container = QWidget()
plot_grid = QGridLayout(plot_container)
plot_grid.setContentsMargins(0, 0, 0, 0)
plot_grid.setSpacing(4)
for row in range(self.NUM_PLOTS // 2):
plot_grid.setRowMinimumHeight(row, 260)
self._plot_widgets: list[pg.PlotWidget] = []
self._vlines: list[pg.InfiniteLine] = []
self._proxies: list = []
self._add_combos: list[QComboBox] = []
self._chip_bars: list[QHBoxLayout] = []
self._date_labels: list[QLabel] = []
self._tooltips: list[PlotTooltip] = []
self._needs_autozoom: list[bool] = [True] * self.NUM_PLOTS
for i in range(self.NUM_PLOTS):
cell = QWidget()
cell_vbox = QVBoxLayout(cell)
cell_vbox.setContentsMargins(0, 0, 0, 0)
cell_vbox.setSpacing(2)
# control bar
ctrl = QWidget()
ctrl.setMaximumHeight(30)
ctrl_row = QHBoxLayout(ctrl)
ctrl_row.setContentsMargins(0, 0, 0, 0)
ctrl_row.setSpacing(4)
add_combo = QComboBox()
add_combo.setMinimumWidth(120)
add_combo.setSizeAdjustPolicy(QComboBox.AdjustToContents)
add_combo.addItem("— add trace —")
add_combo.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
self._add_combos.append(add_combo)
ctrl_row.addWidget(add_combo)
add_btn = QPushButton("+")
add_btn.setFixedSize(26, 22)
add_btn.clicked.connect(lambda checked, idx=i: self._add_trace(idx))
ctrl_row.addWidget(add_btn)
tw_spin = QSpinBox()
tw_spin.setRange(1, 24)
tw_spin.setSuffix(" h")
tw_spin.setFixedWidth(60)
tw_spin.setValue(_saved_tw[i])
tw_spin.valueChanged.connect(lambda _, idx=i: self._on_time_window_changed(idx))
self._tw_spins.append(tw_spin)
ctrl_row.addWidget(tw_spin)
ctrl_row.addSpacing(4)
chip_bar = QHBoxLayout()
chip_bar.setSpacing(4)
chip_bar.setContentsMargins(0, 0, 0, 0)
self._chip_bars.append(chip_bar)
ctrl_row.addLayout(chip_bar)
ctrl_row.addStretch()
date_lbl = QLabel("")
date_lbl.setStyleSheet("color: #666688; font-size: 10px;")
date_lbl.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
self._date_labels.append(date_lbl)
ctrl_row.addWidget(date_lbl)
cell_vbox.addWidget(ctrl)
# plot
pw = pg.PlotWidget(
background="#12121e",
axisItems={"bottom": TimeAxisItem(orientation="bottom")},
viewBox=PlotViewBox(),
)
pw.showGrid(x=True, y=True, alpha=0.3)
pw.addLegend(offset=(0, 0), labelTextSize="8pt")
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, ignoreBounds=True)
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)
cell_vbox.addWidget(pw)
self._plot_widgets.append(pw)
self._tooltips.append(PlotTooltip(pw))
plot_grid.addWidget(cell, i // 2, i % 2)
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setWidget(plot_container)
main_layout.addWidget(scroll)
# ── trace management ──────────────────────────────────────────────────────
def _add_trace(self, plot_idx: int):
key = self._add_combos[plot_idx].currentData()
traces = self._traces[plot_idx]
if key is None or key not in self._series:
return
if key in traces or len(traces) >= self.MAX_TRACES:
return
traces.append(key)
self._add_chip(plot_idx, key, len(traces) - 1)
self._needs_autozoom[plot_idx] = True
self._save_settings()
self._redraw()
def _add_chip(self, plot_idx: int, key: str, color_idx: int):
short = _trace_label(key)
color = self.COLORS[color_idx]
btn = QPushButton(f"{short} ×")
btn.setFixedHeight(22)
btn.setStyleSheet(
f"QPushButton {{ background:{color}; color:white; border-radius:3px;"
f" padding:1px 6px; font-size:11px; }}"
f"QPushButton:hover {{ background:#555577; }}"
)
btn.clicked.connect(lambda: self._remove_trace(plot_idx, key))
self._chip_bars[plot_idx].addWidget(btn)
def _remove_trace(self, plot_idx: int, key: str):
if key not in self._traces[plot_idx]:
return
self._traces[plot_idx].remove(key)
self._rebuild_chips(plot_idx)
self._needs_autozoom[plot_idx] = True
self._save_settings()
self._redraw()
def _rebuild_chips(self, plot_idx: int):
bar = self._chip_bars[plot_idx]
while bar.count():
item = bar.takeAt(0)
if item.widget():
item.widget().deleteLater()
for j, key in enumerate(self._traces[plot_idx]):
self._add_chip(plot_idx, key, j)
# ── public API ────────────────────────────────────────────────────────────
def clear_data(self):
self._redraw_timer.stop()
self._series.clear()
self._units.clear()
for tt in self._tooltips:
tt.hide()
self._needs_autozoom = [True] * self.NUM_PLOTS
self._redraw()
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_timer.start()
# ── combo refresh + saved-trace restore ──────────────────────────────────
def _refresh_combos(self):
signals = sorted(self._series.keys())
for combo in self._add_combos:
current = combo.currentData()
combo.blockSignals(True)
combo.clear()
combo.addItem("— add trace —")
for s in signals:
combo.addItem(_trace_label(s), s)
idx = combo.findData(current)
combo.setCurrentIndex(idx if idx >= 0 else 0)
combo.blockSignals(False)
for i, saved in enumerate(self._saved_traces):
for key in list(saved):
if key not in self._series:
continue
traces = self._traces[i]
if key not in traces and len(traces) < self.MAX_TRACES:
traces.append(key)
self._add_chip(i, key, len(traces) - 1)
saved.remove(key)
# ── settings persistence ──────────────────────────────────────────────────
def _save_settings(self):
try:
_SETTINGS_PATH.parent.mkdir(parents=True, exist_ok=True)
_SETTINGS_PATH.write_text(json.dumps({
"traces": [list(t) for t in self._traces],
"time_windows": [sp.value() for sp in self._tw_spins],
}, indent=2))
except OSError:
pass
def _load_settings(self) -> tuple[list[list[str]], list[int]]:
try:
data = json.loads(_SETTINGS_PATH.read_text())
saved = data.get("traces", [])
while len(saved) < self.NUM_PLOTS:
saved.append([])
tws = data.get("time_windows", [])
while len(tws) < self.NUM_PLOTS:
tws.append(1)
tws = [max(1, min(24, int(v))) for v in tws[: self.NUM_PLOTS]]
return [list(t) for t in saved[: self.NUM_PLOTS]], tws
except (OSError, json.JSONDecodeError, KeyError):
return [[] for _ in range(self.NUM_PLOTS)], [1] * self.NUM_PLOTS
# ── drawing ───────────────────────────────────────────────────────────────
def _on_time_window_changed(self, plot_idx: int):
self._save_settings()
self._redraw()
def _redraw(self):
now = time.time()
for i, pw in enumerate(self._plot_widgets):
cutoff = now - self._tw_spins[i].value() * 3600
pi = pw.getPlotItem()
pi.clear()
pi.addItem(self._vlines[i], ignoreBounds=True)
if pi.legend:
pi.legend.clear()
x_min = None
y_all: list[float] = []
for j, key in enumerate(self._traces[i]):
if key not in self._series:
continue
series = [(t, v) for t, v in self._series[key] if t >= cutoff]
if not series:
continue
times, values = zip(*series)
x_min = times[0] if x_min is None else min(x_min, times[0])
y_all.extend(values)
label = f"{_trace_label(key)} [{self._units.get(key, '')}]"
pw.plot(
list(times), list(values),
pen=pg.mkPen(self.COLORS[j], width=1.5),
name=label,
)
x_lo = x_min if x_min is not None else cutoff
pw.setXRange(x_lo, now, padding=0.02)
if y_all and self._needs_autozoom[i]:
y_min, y_max = min(y_all), max(y_all)
margin = max((y_max - y_min) * 0.1, 1.0)
pw.setYRange(y_min - margin, y_max + margin, padding=0)
self._needs_autozoom[i] = False
d_lo = datetime.fromtimestamp(x_lo).date()
d_hi = datetime.fromtimestamp(now).date()
if d_lo == d_hi:
date_str = d_lo.strftime("%Y-%m-%d")
else:
date_str = f"{d_lo.strftime('%Y-%m-%d')} {d_hi.strftime('%Y-%m-%d')}"
self._date_labels[i].setText(date_str)
# ── cursor tracing ────────────────────────────────────────────────────────
def _y_at(self, key: str, x: float):
series = self._series.get(key)
if not series:
return None
return min(series, key=lambda p: abs(p[0] - x))[1]
def _on_mouse_move(self, evt, plot_idx: int):
pos = evt[0]
pw = self._plot_widgets[plot_idx]
vl = self._vlines[plot_idx]
tt = self._tooltips[plot_idx]
if not pw.sceneBoundingRect().contains(pos):
vl.setVisible(False)
tt.hide()
return
x = pw.getPlotItem().vb.mapSceneToView(pos).x()
vl.setPos(x)
vl.setVisible(True)
entries = []
for j, key in enumerate(self._traces[plot_idx]):
y = self._y_at(key, x)
if y is None:
continue
unit = self._units.get(key, "")
val_str = (f"{y:g} {unit}").strip()
entries.append((self.COLORS[j], _trace_label(key), val_str))
ts_str = datetime.fromtimestamp(x).strftime("%H:%M:%S")
tt.set_values(ts_str, entries)
if entries:
wpos = pw.mapFromScene(pos)
tx = int(wpos.x()) + 14
ty = int(wpos.y()) - tt.height() // 2
tx = min(tx, pw.width() - tt.width() - 4)
ty = max(4, min(ty, pw.height() - tt.height() - 4))
tt.move(tx, ty)
# ── 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)
self._record_count = 0
def _connect(self, host: str, port: int):
self._plot.clear_data()
err = self._reader.connect_to(host, port, self._connector.diff_mode)
if err:
self._connector.set_disconnected(err)
self._statusbar.showMessage(f"Connection failed: {err}")
else:
self._record_count = 0
self._connector.set_connected(host, port)
self._statusbar.showMessage(f"Connected to {host}:{port} | Records: 0")
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._record_count += 1
self._statusbar.showMessage(
f"Last snapshot: {data.get('ts', '?')} | Records: {self._record_count}"
)
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")
app.setStyleSheet(DARK_STYLE)
win = MainWindow()
win.show()
sys.exit(app.exec_())
if __name__ == "__main__":
main()