1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
|
"""
ui/namespace_panel.py
NAMESPACE panel — dockable, live browser of everything referenceable in a
script: Signals (raw device endpoints), Channels (processed/derived
values), and vars (the shared dict bridging Control Panel On Action
Scripts and Channel scripts). Read-only reference, not an editor — editing
still happens in Configuration > Channels.
Rows support multi-select (Ctrl/Shift-click). selected_references() exposes
the currently-selected Signal/Channel rows as (dev_id, ch_id, name, unit,
color) tuples — used by ConfigWindow's "Create Channel" button (Channels
tab, config_window.py), which sits below this panel rather than inside it:
this widget stays a plain reference browser with no create-channel
knowledge of its own, reusable as-is for the read-only dockable instance
on the main window (ui/main_window.py).
Docked opposite the Control Panel (ui/main_window.py), built the same way:
a QDockWidget you can drag, float, redock, or close.
"""
from __future__ import annotations
from typing import Dict, List, Tuple
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel,
QTableWidget, QTableWidgetItem, QHeaderView, QAbstractItemView,
QApplication,
)
from PyQt6.QtCore import QTimer, pyqtSlot
from PyQt6.QtGui import QColor
from devices.device_registry import DeviceRegistry
from core.signal_processor import SignalProcessor
_VARS_POLL_MS = 400
_C_KIND = 0
_C_ID = 1
_C_NAME = 2
_C_DIR = 3
_C_VALUE = 4
_C_SOURCE = 5
def _fmt_value(v) -> str:
if v is None:
return "—"
if isinstance(v, float):
return f"{v:.4g}"
return str(v)
def _fmt_ref(dev_id: str, ch_id: str) -> str:
"""Absolute reference for a source — device_id/channel_id for a Signal
(device_id is stable across machines even when the COM/tty port isn't,
see ProfileManager.apply()'s port re-detection), or the bare channel_id
for another Channel (derived channels have no device to qualify with)."""
if dev_id == "derived":
return ch_id
return f"{dev_id}/{ch_id}"
class NamespacePanel(QWidget):
def __init__(self, registry: DeviceRegistry, processor: SignalProcessor):
super().__init__()
self.registry = registry
self.processor = processor
# row key -> row index. Keys:
# ("signal", dev_id, ch_id) | ("channel", channel_id) | ("var", name)
self._row_index: Dict[Tuple, int] = {}
self._build()
self._rebuild_rows()
self._poll = QTimer(self)
self._poll.setInterval(_VARS_POLL_MS)
self._poll.timeout.connect(self._on_poll)
self._poll.start()
def _build(self):
lay = QVBoxLayout(self)
lay.setContentsMargins(0, 0, 0, 0)
lay.setSpacing(0)
hint = QLabel(
"Double-click a row to copy reference to clipboard · "
"select rows to use as sources for Create Channel"
)
hint.setObjectName("traceSource")
hint.setWordWrap(True)
hint.setContentsMargins(8, 6, 8, 6)
lay.addWidget(hint)
self._table = QTableWidget()
self._table.setObjectName("channelTable")
self._table.setColumnCount(6)
self._table.setHorizontalHeaderLabels(["Kind", "ID", "Name", "Dir", "Value", "Source"])
hdr = self._table.horizontalHeader()
hdr.setSectionResizeMode(_C_SOURCE, QHeaderView.ResizeMode.Stretch)
hdr.setSectionResizeMode(_C_KIND, QHeaderView.ResizeMode.ResizeToContents)
hdr.setSectionResizeMode(_C_ID, QHeaderView.ResizeMode.ResizeToContents)
hdr.setSectionResizeMode(_C_NAME, QHeaderView.ResizeMode.ResizeToContents)
hdr.setSectionResizeMode(_C_DIR, QHeaderView.ResizeMode.ResizeToContents)
hdr.setSectionResizeMode(_C_VALUE, QHeaderView.ResizeMode.ResizeToContents)
self._table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self._table.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
self._table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
self._table.setAlternatingRowColors(True)
self._table.verticalHeader().setVisible(False)
self._table.cellDoubleClicked.connect(self._on_double_clicked)
lay.addWidget(self._table)
def natural_width(self) -> int:
"""Sum of current column widths (Source, being Stretch, counted at
a fixed placeholder rather than its live stretched width, which
would be circular) — how wide this panel would need to be to show
every column without a horizontal scrollbar. A query only, not an
enforced constraint: this panel never sets its own minimum width
(that fights QMainWindow's dock layout when this class is docked
stacked/tabbed with another dock, e.g. Control Panel, in
ui/main_window.py). A caller that wants "start small but readable"
sizing — see ConfigWindow's Channels tab — uses this once for
initial splitter sizing instead."""
total = 0
for i in range(self._table.columnCount()):
total += 80 if i == _C_SOURCE else self._table.columnWidth(i)
return total + 24
# ── Selection ────────────────────────────────────────────────────────────
def selected_references(self) -> List[Tuple[str, str, str, str, str]]:
"""Currently-selected Signal/Channel rows as (dev_id, ch_id, name,
unit, color) — the shape ConfigWindow's "Create Channel" button
needs to build a new Channel's sources. Selected "var" rows and any
row whose underlying Signal/Channel has since vanished are skipped
silently (vars aren't valid Channel sources; a vanished row means
the selection is stale)."""
rows = {idx.row() for idx in self._table.selectionModel().selectedRows()}
out: List[Tuple[str, str, str, str, str]] = []
for key, row in self._row_index.items():
if row not in rows:
continue
if key[0] == "signal":
_, dev_id, ch_id = key
dev = self.registry.get_instance(dev_id)
ch = dev.get_signal(ch_id) if dev else None
if ch is not None:
out.append((dev_id, ch_id, ch.name, ch.unit, ch.color))
elif key[0] == "channel":
_, channel_id = key
dc = next((d for d in self.processor.get_derived()
if d.channel_id == channel_id), None)
if dc is not None:
out.append(("derived", channel_id, dc.name, dc.unit, dc.color))
return out
# ── Row-set (structural) rebuild — devices/channels/vars added or removed ──
def _desired_keys(self):
keys = []
for dev in self.registry.all_instances():
for ch in dev.info.signals:
if ch.enabled:
keys.append(("signal", dev.info.device_id, ch.signal_id))
for dc in self.processor.get_derived():
keys.append(("channel", dc.channel_id))
for name in self.processor.list_vars():
keys.append(("var", name))
return keys
def _rebuild_rows(self):
keys = self._desired_keys()
if list(self._row_index.keys()) == keys:
return # no structural change — values refresh independently
self._row_index.clear()
self._table.setRowCount(len(keys))
for row, key in enumerate(keys):
self._row_index[key] = row
self._paint_row(row, key)
def _paint_row(self, row: int, key: Tuple):
kind = key[0]
if kind == "signal":
_, dev_id, ch_id = key
dev = self.registry.get_instance(dev_id)
ch = dev.get_signal(ch_id) if dev else None
name = ch.name if ch else ch_id
is_wr = bool(ch and ch.writable)
self._set_row(row, "Signal", ch_id, name, "WRITE" if is_wr else "READ",
None, _fmt_ref(dev_id, ch_id))
elif kind == "channel":
_, channel_id = key
dc = next((d for d in self.processor.get_derived()
if d.channel_id == channel_id), None)
name = dc.name if dc else channel_id
if dc and dc.sources:
# Absolute reference(s) this Channel is chained from — same
# format as another row's ID column, so a chain (Channel 1 ->
# Channel 2 -> Channel 3) can be traced by eye: Channel 3's
# Source literally matches Channel 2's ID.
src = ", ".join(_fmt_ref(d, c) for d, c in dc.sources)
else:
src = dc.kind if dc else ""
self._set_row(row, "Channel", channel_id, name, "READ", None, src)
else: # var
_, name = key
val = self.processor.get_var(name, None)
self._set_row(row, "var", name, name, "R/W", val, "shared (vars)")
def _set_row(self, row: int, kind: str, id_ref: str, name: str,
direction: str, value, source: str):
self._table.setItem(row, _C_KIND, QTableWidgetItem(kind))
id_item = QTableWidgetItem(id_ref)
id_item.setForeground(QColor("#94a3b8"))
self._table.setItem(row, _C_ID, id_item)
self._table.setItem(row, _C_NAME, QTableWidgetItem(name))
dir_item = QTableWidgetItem(direction)
dir_item.setForeground(_dir_color(direction))
self._table.setItem(row, _C_DIR, dir_item)
self._table.setItem(row, _C_VALUE, QTableWidgetItem(_fmt_value(value)))
self._table.setItem(row, _C_SOURCE, QTableWidgetItem(source))
# ── Live value updates ──────────────────────────────────────────────────
@pyqtSlot(str, str, float, float)
def on_processed_data(self, device_id: str, channel_id: str, ts: float, value: float):
key = ("channel", channel_id) if device_id == "derived" else ("signal", device_id, channel_id)
row = self._row_index.get(key)
if row is None:
return
item = self._table.item(row, _C_VALUE)
if item is not None:
item.setText(_fmt_value(value))
def _on_poll(self):
self._rebuild_rows()
for name in self.processor.list_vars():
row = self._row_index.get(("var", name))
if row is None:
continue
item = self._table.item(row, _C_VALUE)
if item is not None:
item.setText(_fmt_value(self.processor.get_var(name, None)))
# ── Copy-to-clipboard reference ─────────────────────────────────────────
def _on_double_clicked(self, row: int, _col: int):
key = next((k for k, r in self._row_index.items() if r == row), None)
if key is None:
return
if key[0] == "var":
snippet = f'vars.get("{key[1]}", 0)'
elif key[0] == "signal":
snippet = f'channels.get("{key[2]}", 0)'
else: # channel
snippet = f'channels.get("{key[1]}", 0)'
QApplication.clipboard().setText(snippet)
def _dir_color(direction: str) -> QColor:
if direction == "WRITE":
return QColor("#f59e0b")
if direction == "R/W":
return QColor("#818cf8")
return QColor("#22c55e")
|