diff options
| -rw-r--r-- | .gitignore | 14 | ||||
| -rw-r--r-- | api_layers/arduino_layer.py | 224 | ||||
| -rw-r--r-- | api_layers/firmware/LabUI_firmware.ino | 218 | ||||
| -rw-r--r-- | core/debug_log.py | 65 | ||||
| -rw-r--r-- | devices/base_device.py | 1 | ||||
| -rw-r--r-- | main.py | 1 | ||||
| -rw-r--r-- | scripts/release/.tufup-repo-config | 34 | ||||
| -rwxr-xr-x | scripts/release/repository/metadata/1.root.json | 71 | ||||
| -rwxr-xr-x | scripts/release/repository/metadata/root.json | 71 | ||||
| -rwxr-xr-x | scripts/release/repository/metadata/snapshot.json | 19 | ||||
| -rwxr-xr-x | scripts/release/repository/metadata/targets.json | 15 | ||||
| -rwxr-xr-x | scripts/release/repository/metadata/timestamp.json | 19 | ||||
| -rw-r--r-- | ui/control_panel.py | 13 | ||||
| -rw-r--r-- | ui/main_window.py | 359 | ||||
| -rw-r--r-- | ui/style_dark.qss | 31 | ||||
| -rw-r--r-- | ui/style_light.qss | 8 | ||||
| -rw-r--r-- | ui/windows/config_window.py | 504 | ||||
| -rw-r--r-- | ui/windows/debug_window.py | 65 | ||||
| -rw-r--r-- | ui/windows/settings_window.py | 25 |
19 files changed, 1220 insertions, 537 deletions
@@ -1,20 +1,20 @@ logs/* -__pychache__/ +__pycache__/ *.pyc *.pyo -.claude/settings.local.json -plugins/enabled.json -plugins/enabled.json -plugins/enabled.json -plugins/enabled.json # PyInstaller build output build/ dist/ -# tufup release artifacts — private keys must never be committed; +# TUF release keystore (private signing keys) # generated repository/ is republished to GitHub Releases, not tracked here scripts/release/keystore/ scripts/release/repository/ scripts/release/.tufup-repo-config + +# Claude project memory +.claude/ +.claude/settings.local.json +plugins/enabled.json diff --git a/api_layers/arduino_layer.py b/api_layers/arduino_layer.py index 96e0f5a..88a2655 100644 --- a/api_layers/arduino_layer.py +++ b/api_layers/arduino_layer.py @@ -569,226 +569,6 @@ class ArduinoLayer: # ═══════════════════════════════════════════════════════════════════════════ -# ARDUINO FIRMWARE — upload this to your board +# Arduino firmware source: api_layers/firmware/LabUI_firmware.ino +# Upload to board via Arduino IDE (115200 baud). # ═══════════════════════════════════════════════════════════════════════════ - -ARDUINO_FIRMWARE = r""" -/* - * LabUI Arduino Firmware v1.1 - * ───────────────────────────────────────────────────────────────────────── - * Upload this sketch to your Arduino. - * Set baud rate to 115200 in both this sketch and LabUI. - * - * WHAT IT DOES - * Continuously streams analog + digital readings to the PC. - * Listens for commands from the PC and executes them. - * - * RECEIVED COMMANDS (PC → Arduino): - * W:D13:1 digitalWrite(13, HIGH) - * W:D6:0 digitalWrite(6, LOW) - * P:D9:128 analogWrite(9, 128) → ~50% PWM - * C:SETPOINT:75.0 store named parameter - * Q:A0 reply immediately with A0 reading - * R:ALL send full data frame immediately - * X:STOP set all outputs LOW - * X:RESET reset to defaults - * - * SENT DATA (Arduino → PC): - * A0:3.142,A1:0.015,D2:0,D3:1\n (every SEND_INTERVAL ms) - * ACK:W:D13:1\n (after each command) - * ERR:Unknown command\n (on parse failure) - * ───────────────────────────────────────────────────────────────────────── - */ - -// ── Configuration ───────────────────────────────────────────────────────── -int ANALOG_PINS[6] = {A0, A1, A2, A3, A4, A5}; // actual pin numbers (reconfigured by APIN:) -int ANALOG_IDX[6] = {0, 1, 2, 3, 4, 5}; // label indices used in output (A0, A1, …) -int DIGITAL_IN[16] = {2, 3, 4}; // reconfigured at runtime via DPIN:IN: -int DIGITAL_OUT[16] = {5, 6, 7, 9, 10, 11}; // reconfigured via DPIN:OUT: -int N_ANALOG = 0; // set by APIN: command (0 = inactive until configured by PC) -int N_DIG_IN = 0; // set by DPIN:IN: command (0 = inactive until configured) -int N_DIG_OUT = 0; // set by DPIN:OUT: command (0 = inactive until configured) -const int SEND_INTERVAL = 50; // ms between data frames (50 = 20 Hz) -const long BAUD_RATE = 115200; - -// ── Named parameters (set via C: commands) ────────────────────────────── -float param_setpoint = 0.0; -float param_kp = 1.0; -float param_ki = 0.0; -float param_kd = 0.0; -int param_mode = 0; - -unsigned long lastSend = 0; - -// ── Setup ───────────────────────────────────────────────────────────────── -void setup() { - Serial.begin(BAUD_RATE); - for (int i = 0; i < N_DIG_IN; i++) pinMode(DIGITAL_IN[i], INPUT_PULLUP); - for (int i = 0; i < N_DIG_OUT; i++) pinMode(DIGITAL_OUT[i], OUTPUT); -} - -// ── Main loop ───────────────────────────────────────────────────────────── -void loop() { - handleCommands(); - sendData(); -} - -// ── Command handler ─────────────────────────────────────────────────────── -void handleCommands() { - if (!Serial.available()) return; - - String cmd = Serial.readStringUntil('\n'); - cmd.trim(); - if (cmd.length() == 0) return; - - char type = cmd.charAt(0); - - // W:Dxx:val — digital write - if (type == 'W') { - int c1 = cmd.indexOf(':', 2); - if (c1 < 0) { Serial.println("ERR:Bad W format"); return; } - String pinStr = cmd.substring(2, c1); - int val = cmd.substring(c1 + 1).toInt(); - int pin = pinStr.substring(1).toInt(); // strip 'D' - digitalWrite(pin, val ? HIGH : LOW); - Serial.print("ACK:"); Serial.println(cmd); - } - - // P:Dxx:duty — PWM write (0-255) - else if (type == 'P') { - int c1 = cmd.indexOf(':', 2); - if (c1 < 0) { Serial.println("ERR:Bad P format"); return; } - int pin = cmd.substring(2, c1).substring(1).toInt(); - int duty = constrain(cmd.substring(c1 + 1).toInt(), 0, 255); - analogWrite(pin, duty); - Serial.print("ACK:"); Serial.println(cmd); - } - - // C:NAME:value — set named parameter - else if (type == 'C') { - int c1 = cmd.indexOf(':', 2); - int c2 = cmd.indexOf(':', c1 + 1); - if (c1 < 0 || c2 < 0) { Serial.println("ERR:Bad C format"); return; } - String name = cmd.substring(2, c1); - float val = cmd.substring(c2 + 1).toFloat(); - if (name == "SETPOINT") param_setpoint = val; - else if (name == "KP") param_kp = val; - else if (name == "KI") param_ki = val; - else if (name == "KD") param_kd = val; - else if (name == "MODE") param_mode = (int)val; - // Add your own parameters here: - // else if (name == "SPEED") motor_speed = val; - Serial.print("ACK:"); Serial.println(cmd); - } - - // Q:NAME — immediate query - else if (type == 'Q') { - String name = cmd.substring(2); - if (name == "SETPOINT") { Serial.print("SETPOINT:"); Serial.println(param_setpoint, 3); } - else if (name == "KP") { Serial.print("KP:"); Serial.println(param_kp, 4); } - else { - // Try to read as analog pin Q:A0 - if (name.charAt(0) == 'A') { - int pin = name.substring(1).toInt(); - float v = analogRead(pin) * (5.0 / 1023.0); - Serial.print(name); Serial.print(":"); Serial.println(v, 3); - } - } - } - - // R:ALL — send full frame immediately - else if (type == 'R') { - sendFrame(); - } - - // X:STOP / X:RESET — emergency stop - else if (type == 'X') { - String sub = cmd.substring(2); - for (int i = 0; i < N_DIG_OUT; i++) digitalWrite(DIGITAL_OUT[i], LOW); - Serial.print("ACK:"); Serial.println(cmd); - } - - // APIN:0,1,3 — reconfigure analog inputs at runtime (indices 0–5 into A0–A5) - else if (type == 'A') { - if (!cmd.startsWith("APIN:")) { Serial.println("ERR:Bad A command"); return; } - String pinList = cmd.substring(5); // "0,1,3" - const int _APINS[] = {A0,A1,A2,A3,A4,A5}; - N_ANALOG = 0; - int start = 0; - for (int i = 0; i <= (int)pinList.length(); i++) { - if (i == (int)pinList.length() || pinList[i] == ',') { - String tok = pinList.substring(start, i); tok.trim(); - if (tok.length() > 0 && N_ANALOG < 6) { - int idx = tok.toInt(); - if (idx >= 0 && idx < 6) { - ANALOG_PINS[N_ANALOG] = _APINS[idx]; - ANALOG_IDX[N_ANALOG] = idx; - N_ANALOG++; - } - } - start = i + 1; - } - } - Serial.print("ACK:"); Serial.println(cmd); - } - - // DPIN:IN:2,3,8 or DPIN:OUT:5,6,7 — reconfigure digital I/O pins at runtime - else if (type == 'D') { - int c1 = cmd.indexOf(':', 2); - int c2 = (c1 >= 0) ? cmd.indexOf(':', c1 + 1) : -1; - if (c1 < 0 || c2 < 0) { Serial.println("ERR:Bad DPIN format"); return; } - String dir = cmd.substring(2, c1); // "IN" or "OUT" - String pinList = cmd.substring(c2 + 1); // "2,3,8" - bool isIn = (dir == "IN"); - int* arr = isIn ? DIGITAL_IN : DIGITAL_OUT; - int* cnt = isIn ? &N_DIG_IN : &N_DIG_OUT; - int mode = isIn ? INPUT_PULLUP : OUTPUT; - *cnt = 0; - int start = 0; - for (int i = 0; i <= (int)pinList.length(); i++) { - if (i == (int)pinList.length() || pinList[i] == ',') { - String tok = pinList.substring(start, i); tok.trim(); - if (tok.length() > 0 && *cnt < 16) { - arr[*cnt] = tok.toInt(); - pinMode(arr[*cnt], mode); - (*cnt)++; - } - start = i + 1; - } - } - Serial.print("ACK:"); Serial.println(cmd); - } - - else { - Serial.print("ERR:Unknown command: "); Serial.println(cmd); - } -} - -// ── Data sender ─────────────────────────────────────────────────────────── -void sendData() { - if (millis() - lastSend < SEND_INTERVAL) return; - lastSend = millis(); - sendFrame(); -} - -void sendFrame() { - String out = ""; - bool first = true; - // Analog inputs — converted to 0.0–5.0 V - for (int i = 0; i < N_ANALOG; i++) { - float v = analogRead(ANALOG_PINS[i]) * (5.0 / 1023.0); - if (!first) out += ","; - out += "A" + String(ANALOG_IDX[i]) + ":" + String(v, 3); - first = false; - } - // Digital inputs (INPUT_PULLUP — invert so pressed=1) - // These share the same output line as analog — one line per frame. - for (int i = 0; i < N_DIG_IN; i++) { - if (!first) out += ","; - out += "D" + String(DIGITAL_IN[i]) + ":" + String(!digitalRead(DIGITAL_IN[i])); - first = false; - } - Serial.println(out); - // Example combined output: A0:3.142,D2:0,D3:1 -} -""" diff --git a/api_layers/firmware/LabUI_firmware.ino b/api_layers/firmware/LabUI_firmware.ino new file mode 100644 index 0000000..8f1b252 --- /dev/null +++ b/api_layers/firmware/LabUI_firmware.ino @@ -0,0 +1,218 @@ +/* + * LabUI Arduino Firmware v1.1 + * ───────────────────────────────────────────────────────────────────────── + * Upload this sketch to your Arduino. + * Set baud rate to 115200 in both this sketch and LabUI. + * + * WHAT IT DOES + * Continuously streams analog + digital readings to the PC. + * Listens for commands from the PC and executes them. + * + * RECEIVED COMMANDS (PC → Arduino): + * W:D13:1 digitalWrite(13, HIGH) + * W:D6:0 digitalWrite(6, LOW) + * P:D9:128 analogWrite(9, 128) → ~50% PWM + * C:SETPOINT:75.0 store named parameter + * Q:A0 reply immediately with A0 reading + * R:ALL send full data frame immediately + * X:STOP set all outputs LOW + * X:RESET reset to defaults + * + * SENT DATA (Arduino → PC): + * A0:3.142,A1:0.015,D2:0,D3:1\n (every SEND_INTERVAL ms) + * ACK:W:D13:1\n (after each command) + * ERR:Unknown command\n (on parse failure) + * ───────────────────────────────────────────────────────────────────────── + */ + +// ── Configuration ───────────────────────────────────────────────────────── +int ANALOG_PINS[6] = {A0, A1, A2, A3, A4, A5}; // actual pin numbers (reconfigured by APIN:) +int ANALOG_IDX[6] = {0, 1, 2, 3, 4, 5}; // label indices used in output (A0, A1, …) +int DIGITAL_IN[16] = {2, 3, 4}; // reconfigured at runtime via DPIN:IN: +int DIGITAL_OUT[16] = {5, 6, 7, 9, 10, 11}; // reconfigured via DPIN:OUT: +int N_ANALOG = 0; // set by APIN: command (0 = inactive until configured by PC) +int N_DIG_IN = 0; // set by DPIN:IN: command (0 = inactive until configured) +int N_DIG_OUT = 0; // set by DPIN:OUT: command (0 = inactive until configured) +const int SEND_INTERVAL = 50; // ms between data frames (50 = 20 Hz) +const long BAUD_RATE = 115200; + +// ── Named parameters (set via C: commands) ────────────────────────────── +float param_setpoint = 0.0; +float param_kp = 1.0; +float param_ki = 0.0; +float param_kd = 0.0; +int param_mode = 0; + +unsigned long lastSend = 0; + +// ── Setup ───────────────────────────────────────────────────────────────── +void setup() { + Serial.begin(BAUD_RATE); + for (int i = 0; i < N_DIG_IN; i++) pinMode(DIGITAL_IN[i], INPUT_PULLUP); + for (int i = 0; i < N_DIG_OUT; i++) pinMode(DIGITAL_OUT[i], OUTPUT); +} + +// ── Main loop ───────────────────────────────────────────────────────────── +void loop() { + handleCommands(); + sendData(); +} + +// ── Command handler ─────────────────────────────────────────────────────── +void handleCommands() { + if (!Serial.available()) return; + + String cmd = Serial.readStringUntil('\n'); + cmd.trim(); + if (cmd.length() == 0) return; + + char type = cmd.charAt(0); + + // W:Dxx:val — digital write + if (type == 'W') { + int c1 = cmd.indexOf(':', 2); + if (c1 < 0) { Serial.println("ERR:Bad W format"); return; } + String pinStr = cmd.substring(2, c1); + int val = cmd.substring(c1 + 1).toInt(); + int pin = pinStr.substring(1).toInt(); // strip 'D' + digitalWrite(pin, val ? HIGH : LOW); + Serial.print("ACK:"); Serial.println(cmd); + } + + // P:Dxx:duty — PWM write (0-255) + else if (type == 'P') { + int c1 = cmd.indexOf(':', 2); + if (c1 < 0) { Serial.println("ERR:Bad P format"); return; } + int pin = cmd.substring(2, c1).substring(1).toInt(); + int duty = constrain(cmd.substring(c1 + 1).toInt(), 0, 255); + analogWrite(pin, duty); + Serial.print("ACK:"); Serial.println(cmd); + } + + // C:NAME:value — set named parameter + else if (type == 'C') { + int c1 = cmd.indexOf(':', 2); + int c2 = cmd.indexOf(':', c1 + 1); + if (c1 < 0 || c2 < 0) { Serial.println("ERR:Bad C format"); return; } + String name = cmd.substring(2, c1); + float val = cmd.substring(c2 + 1).toFloat(); + if (name == "SETPOINT") param_setpoint = val; + else if (name == "KP") param_kp = val; + else if (name == "KI") param_ki = val; + else if (name == "KD") param_kd = val; + else if (name == "MODE") param_mode = (int)val; + // Add your own parameters here: + // else if (name == "SPEED") motor_speed = val; + Serial.print("ACK:"); Serial.println(cmd); + } + + // Q:NAME — immediate query + else if (type == 'Q') { + String name = cmd.substring(2); + if (name == "SETPOINT") { Serial.print("SETPOINT:"); Serial.println(param_setpoint, 3); } + else if (name == "KP") { Serial.print("KP:"); Serial.println(param_kp, 4); } + else { + // Try to read as analog pin Q:A0 + if (name.charAt(0) == 'A') { + int pin = name.substring(1).toInt(); + float v = analogRead(pin) * (5.0 / 1023.0); + Serial.print(name); Serial.print(":"); Serial.println(v, 3); + } + } + } + + // R:ALL — send full frame immediately + else if (type == 'R') { + sendFrame(); + } + + // X:STOP / X:RESET — emergency stop + else if (type == 'X') { + String sub = cmd.substring(2); + for (int i = 0; i < N_DIG_OUT; i++) digitalWrite(DIGITAL_OUT[i], LOW); + Serial.print("ACK:"); Serial.println(cmd); + } + + // APIN:0,1,3 — reconfigure analog inputs at runtime (indices 0–5 into A0–A5) + else if (type == 'A') { + if (!cmd.startsWith("APIN:")) { Serial.println("ERR:Bad A command"); return; } + String pinList = cmd.substring(5); // "0,1,3" + const int _APINS[] = {A0,A1,A2,A3,A4,A5}; + N_ANALOG = 0; + int start = 0; + for (int i = 0; i <= (int)pinList.length(); i++) { + if (i == (int)pinList.length() || pinList[i] == ',') { + String tok = pinList.substring(start, i); tok.trim(); + if (tok.length() > 0 && N_ANALOG < 6) { + int idx = tok.toInt(); + if (idx >= 0 && idx < 6) { + ANALOG_PINS[N_ANALOG] = _APINS[idx]; + ANALOG_IDX[N_ANALOG] = idx; + N_ANALOG++; + } + } + start = i + 1; + } + } + Serial.print("ACK:"); Serial.println(cmd); + } + + // DPIN:IN:2,3,8 or DPIN:OUT:5,6,7 — reconfigure digital I/O pins at runtime + else if (type == 'D') { + int c1 = cmd.indexOf(':', 2); + int c2 = (c1 >= 0) ? cmd.indexOf(':', c1 + 1) : -1; + if (c1 < 0 || c2 < 0) { Serial.println("ERR:Bad DPIN format"); return; } + String dir = cmd.substring(2, c1); // "IN" or "OUT" + String pinList = cmd.substring(c2 + 1); // "2,3,8" + bool isIn = (dir == "IN"); + int* arr = isIn ? DIGITAL_IN : DIGITAL_OUT; + int* cnt = isIn ? &N_DIG_IN : &N_DIG_OUT; + int mode = isIn ? INPUT_PULLUP : OUTPUT; + *cnt = 0; + int start = 0; + for (int i = 0; i <= (int)pinList.length(); i++) { + if (i == (int)pinList.length() || pinList[i] == ',') { + String tok = pinList.substring(start, i); tok.trim(); + if (tok.length() > 0 && *cnt < 16) { + arr[*cnt] = tok.toInt(); + pinMode(arr[*cnt], mode); + (*cnt)++; + } + start = i + 1; + } + } + Serial.print("ACK:"); Serial.println(cmd); + } + + else { + Serial.print("ERR:Unknown command: "); Serial.println(cmd); + } +} + +// ── Data sender ─────────────────────────────────────────────────────────── +void sendData() { + if (millis() - lastSend < SEND_INTERVAL) return; + lastSend = millis(); + sendFrame(); +} + +void sendFrame() { + String out = ""; + bool first = true; + // Analog inputs — converted to 0.0–5.0 V + for (int i = 0; i < N_ANALOG; i++) { + float v = analogRead(ANALOG_PINS[i]) * (5.0 / 1023.0); + if (!first) out += ","; + out += "A" + String(ANALOG_IDX[i]) + ":" + String(v, 3); + first = false; + } + // Digital inputs (INPUT_PULLUP — invert so pressed=1) + // These share the same output line as analog — one line per frame. + for (int i = 0; i < N_DIG_IN; i++) { + if (!first) out += ","; + out += "D" + String(DIGITAL_IN[i]) + ":" + String(!digitalRead(DIGITAL_IN[i])); + first = false; + } + Serial.println(out); + // Example combined output: A0:3.142,D2:0,D3:1 +} diff --git a/core/debug_log.py b/core/debug_log.py deleted file mode 100644 index 86a8a59..0000000 --- a/core/debug_log.py +++ /dev/null @@ -1,65 +0,0 @@ -""" -core/debug_log.py - -Tees stdout/stderr into an in-memory ring buffer + Qt signal so the Debug -window can show everything the app has printed since startup — including -messages from background poll threads (e.g. "[CMLLayer] poll error: ...") — -not just whatever gets printed while the window happens to be open. - -install() should be called once, early, before anything prints. The real -streams are still written to, so running from a terminal is unaffected. -""" - -from __future__ import annotations - -import sys -from collections import deque -from typing import Optional - -from PyQt6.QtCore import QObject, pyqtSignal - - -class _Broadcaster(QObject): - line_written = pyqtSignal(str) - - -class _StreamTee: - def __init__(self, real_stream, lines: deque, broadcaster: _Broadcaster): - self._real = real_stream - self._lines = lines - self._broadcaster = broadcaster - - def write(self, text: str) -> None: - self._real.write(text) - if text: - self._lines.append(text) - self._broadcaster.line_written.emit(text) - - def flush(self) -> None: - self._real.flush() - - def isatty(self) -> bool: - return False - - -_lines: Optional[deque] = None -_broadcaster: Optional[_Broadcaster] = None - - -def install(max_lines: int = 2000) -> None: - """Redirect sys.stdout/sys.stderr through the tee. Safe to call once.""" - global _lines, _broadcaster - if _broadcaster is not None: - return - _lines = deque(maxlen=max_lines) - _broadcaster = _Broadcaster() - sys.stdout = _StreamTee(sys.stdout, _lines, _broadcaster) - sys.stderr = _StreamTee(sys.stderr, _lines, _broadcaster) - - -def get_broadcaster() -> Optional[_Broadcaster]: - return _broadcaster - - -def get_history() -> str: - return "".join(_lines) if _lines is not None else "" diff --git a/devices/base_device.py b/devices/base_device.py index 83b8536..9803867 100644 --- a/devices/base_device.py +++ b/devices/base_device.py @@ -28,6 +28,7 @@ class ChannelConfig: min_value: float = 0.0 max_value: float = 100.0 enabled: bool = True + is_output: bool = False color: str = "#00d4ff" writable: bool = False # True if this channel accepts write_channel() calls extra: Dict[str, Any] = field(default_factory=dict) @@ -18,7 +18,6 @@ if os.path.isdir(_PLUGIN_PACKAGES) and _PLUGIN_PACKAGES not in sys.path: from PyQt6.QtWidgets import QApplication from ui.main_window import MainWindow -from core.debug_log import install as install_debug_log def main(): diff --git a/scripts/release/.tufup-repo-config b/scripts/release/.tufup-repo-config new file mode 100644 index 0000000..e072c2a --- /dev/null +++ b/scripts/release/.tufup-repo-config @@ -0,0 +1,34 @@ +{ + "app_name": "labui", + "app_version_attr": null, + "binary_diff": null, + "encrypted_keys": [], + "expiration_days": { + "root": 365, + "snapshot": 90, + "targets": 90, + "timestamp": 90 + }, + "key_map": { + "root": [ + "root" + ], + "snapshot": [ + "snapshot" + ], + "targets": [ + "targets" + ], + "timestamp": [ + "timestamp" + ] + }, + "keys_dir": "keystore", + "repo_dir": "repository", + "thresholds": { + "root": 1, + "snapshot": 1, + "targets": 1, + "timestamp": 1 + } +}
\ No newline at end of file diff --git a/scripts/release/repository/metadata/1.root.json b/scripts/release/repository/metadata/1.root.json new file mode 100755 index 0000000..343b2e2 --- /dev/null +++ b/scripts/release/repository/metadata/1.root.json @@ -0,0 +1,71 @@ +{ + "signatures": [ + { + "keyid": "25e91fe88ce247418835d22b18c63a8166038a16b2b9f26f319bc65a8b2687ed", + "sig": "cb0c1b0078781b4b87a181deaa03122a7e1f08dd39fab4eac842acd43397e0c5b268009825fa942e86b368b4edd651e69967182981e8be746a60566b42d36109" + } + ], + "signed": { + "_type": "root", + "consistent_snapshot": false, + "expires": "2027-08-01T23:55:23Z", + "keys": { + "25e91fe88ce247418835d22b18c63a8166038a16b2b9f26f319bc65a8b2687ed": { + "keytype": "ed25519", + "keyval": { + "public": "be8f719618189eb74b524ee92e85eea99a5228fafc7257356b5d8acad2829b24" + }, + "scheme": "ed25519" + }, + "40e5301cd4b92e4a9c2e63dcca99ffa4a5627c25ac6c254f272bb52c9a076cfc": { + "keytype": "ed25519", + "keyval": { + "public": "cbde8cdde12b19b39a786da7128214d01e0f4415e590df34768530fad2bf4702" + }, + "scheme": "ed25519" + }, + "4edf46a4cf08f9b2c59cbe9a435024a8498ce68cc63208f1fbb8b69e74768d42": { + "keytype": "ed25519", + "keyval": { + "public": "1559cdea5a3295593d4c08e6ee625561f7cd96a1dcc0d553b9961734491b801a" + }, + "scheme": "ed25519" + }, + "998b28525eec351c6cdd16d6da3854876e84ab450595feac782ae83d3a8226d6": { + "keytype": "ed25519", + "keyval": { + "public": "6ac7a3b9d83f0d1835ea6fdc0307fe340ea422bc15763fdc0bc197383a61d79b" + }, + "scheme": "ed25519" + } + }, + "roles": { + "root": { + "keyids": [ + "25e91fe88ce247418835d22b18c63a8166038a16b2b9f26f319bc65a8b2687ed" + ], + "threshold": 1 + }, + "snapshot": { + "keyids": [ + "40e5301cd4b92e4a9c2e63dcca99ffa4a5627c25ac6c254f272bb52c9a076cfc" + ], + "threshold": 1 + }, + "targets": { + "keyids": [ + "998b28525eec351c6cdd16d6da3854876e84ab450595feac782ae83d3a8226d6" + ], + "threshold": 1 + }, + "timestamp": { + "keyids": [ + "4edf46a4cf08f9b2c59cbe9a435024a8498ce68cc63208f1fbb8b69e74768d42" + ], + "threshold": 1 + } + }, + "spec_version": "1.0.31", + "version": 1 + } +}
\ No newline at end of file diff --git a/scripts/release/repository/metadata/root.json b/scripts/release/repository/metadata/root.json new file mode 100755 index 0000000..343b2e2 --- /dev/null +++ b/scripts/release/repository/metadata/root.json @@ -0,0 +1,71 @@ +{ + "signatures": [ + { + "keyid": "25e91fe88ce247418835d22b18c63a8166038a16b2b9f26f319bc65a8b2687ed", + "sig": "cb0c1b0078781b4b87a181deaa03122a7e1f08dd39fab4eac842acd43397e0c5b268009825fa942e86b368b4edd651e69967182981e8be746a60566b42d36109" + } + ], + "signed": { + "_type": "root", + "consistent_snapshot": false, + "expires": "2027-08-01T23:55:23Z", + "keys": { + "25e91fe88ce247418835d22b18c63a8166038a16b2b9f26f319bc65a8b2687ed": { + "keytype": "ed25519", + "keyval": { + "public": "be8f719618189eb74b524ee92e85eea99a5228fafc7257356b5d8acad2829b24" + }, + "scheme": "ed25519" + }, + "40e5301cd4b92e4a9c2e63dcca99ffa4a5627c25ac6c254f272bb52c9a076cfc": { + "keytype": "ed25519", + "keyval": { + "public": "cbde8cdde12b19b39a786da7128214d01e0f4415e590df34768530fad2bf4702" + }, + "scheme": "ed25519" + }, + "4edf46a4cf08f9b2c59cbe9a435024a8498ce68cc63208f1fbb8b69e74768d42": { + "keytype": "ed25519", + "keyval": { + "public": "1559cdea5a3295593d4c08e6ee625561f7cd96a1dcc0d553b9961734491b801a" + }, + "scheme": "ed25519" + }, + "998b28525eec351c6cdd16d6da3854876e84ab450595feac782ae83d3a8226d6": { + "keytype": "ed25519", + "keyval": { + "public": "6ac7a3b9d83f0d1835ea6fdc0307fe340ea422bc15763fdc0bc197383a61d79b" + }, + "scheme": "ed25519" + } + }, + "roles": { + "root": { + "keyids": [ + "25e91fe88ce247418835d22b18c63a8166038a16b2b9f26f319bc65a8b2687ed" + ], + "threshold": 1 + }, + "snapshot": { + "keyids": [ + "40e5301cd4b92e4a9c2e63dcca99ffa4a5627c25ac6c254f272bb52c9a076cfc" + ], + "threshold": 1 + }, + "targets": { + "keyids": [ + "998b28525eec351c6cdd16d6da3854876e84ab450595feac782ae83d3a8226d6" + ], + "threshold": 1 + }, + "timestamp": { + "keyids": [ + "4edf46a4cf08f9b2c59cbe9a435024a8498ce68cc63208f1fbb8b69e74768d42" + ], + "threshold": 1 + } + }, + "spec_version": "1.0.31", + "version": 1 + } +}
\ No newline at end of file diff --git a/scripts/release/repository/metadata/snapshot.json b/scripts/release/repository/metadata/snapshot.json new file mode 100755 index 0000000..03821d1 --- /dev/null +++ b/scripts/release/repository/metadata/snapshot.json @@ -0,0 +1,19 @@ +{ + "signatures": [ + { + "keyid": "40e5301cd4b92e4a9c2e63dcca99ffa4a5627c25ac6c254f272bb52c9a076cfc", + "sig": "bd89d38c63ddee6537276a13657a2716a1a88346421a8d51db9fa8c12b9dc6b0db57a48bbab616930b6aaaa91c4a12a54909cd9722e07b23632638d68bd08307" + } + ], + "signed": { + "_type": "snapshot", + "expires": "2026-10-30T23:55:23Z", + "meta": { + "targets.json": { + "version": 1 + } + }, + "spec_version": "1.0.31", + "version": 1 + } +}
\ No newline at end of file diff --git a/scripts/release/repository/metadata/targets.json b/scripts/release/repository/metadata/targets.json new file mode 100755 index 0000000..6296ebe --- /dev/null +++ b/scripts/release/repository/metadata/targets.json @@ -0,0 +1,15 @@ +{ + "signatures": [ + { + "keyid": "998b28525eec351c6cdd16d6da3854876e84ab450595feac782ae83d3a8226d6", + "sig": "88ebf0eac3bea958b3d5d99e713a2af1a8e6d230c414bca1b22f1bf64ce2a8734b3db5eaa80dcc9fdd31c1954864af2828c122bf6d4354b1a06136e478e6d00d" + } + ], + "signed": { + "_type": "targets", + "expires": "2026-10-30T23:55:23Z", + "spec_version": "1.0.31", + "targets": {}, + "version": 1 + } +}
\ No newline at end of file diff --git a/scripts/release/repository/metadata/timestamp.json b/scripts/release/repository/metadata/timestamp.json new file mode 100755 index 0000000..12227b6 --- /dev/null +++ b/scripts/release/repository/metadata/timestamp.json @@ -0,0 +1,19 @@ +{ + "signatures": [ + { + "keyid": "4edf46a4cf08f9b2c59cbe9a435024a8498ce68cc63208f1fbb8b69e74768d42", + "sig": "b11ee59ed85d21b52d25e676a98232c9d63813ac5236493c11d47264065647933f1c28bc9748f8c880174a3ab3bcb27f6786c1c4a596ef80b70fc11b40f9f002" + } + ], + "signed": { + "_type": "timestamp", + "expires": "2026-10-30T23:55:23Z", + "meta": { + "snapshot.json": { + "version": 1 + } + }, + "spec_version": "1.0.31", + "version": 1 + } +}
\ No newline at end of file diff --git a/ui/control_panel.py b/ui/control_panel.py index 4c4f1fd..aef0f89 100644 --- a/ui/control_panel.py +++ b/ui/control_panel.py @@ -573,19 +573,6 @@ class ControlPanel(QWidget): layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) - # Header with Add button - hdr_widget = QWidget(); hdr_widget.setObjectName("controlPanelHeader") - hdr_widget.setFixedHeight(30) - hdr_lay = QHBoxLayout(hdr_widget) - hdr_lay.setContentsMargins(8, 0, 6, 0) - hdr_lbl = QLabel("CONTROLS"); hdr_lbl.setObjectName("panelHeader") - hdr_lay.addWidget(hdr_lbl, 1) - add_btn = QPushButton("+"); add_btn.setObjectName("devicesSmallBtn") - add_btn.setFixedSize(24, 22); add_btn.setToolTip("Add control widget") - add_btn.clicked.connect(self._on_add) - hdr_lay.addWidget(add_btn) - layout.addWidget(hdr_widget) - scroll = QScrollArea() scroll.setWidgetResizable(True) scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) diff --git a/ui/main_window.py b/ui/main_window.py index efbb486..c7e4953 100644 --- a/ui/main_window.py +++ b/ui/main_window.py @@ -8,11 +8,13 @@ Toolbar (left→right): from PyQt6.QtWidgets import ( QMainWindow, QWidget, QHBoxLayout, QVBoxLayout, - QSplitter, QStatusBar, QLabel, QPushButton, + QDockWidget, QStatusBar, QLabel, QPushButton, QToolBar, QSizePolicy, QApplication, QFrame, - QAbstractSpinBox, QComboBox, + QAbstractSpinBox, QComboBox, QMenu, ) -from PyQt6.QtCore import Qt, QTimer, QObject, QEvent, pyqtSlot +from PyQt6.QtCore import Qt, QTimer, QObject, QEvent, QRect, pyqtSignal, pyqtSlot +from PyQt6.QtGui import QPainter, QColor, QFont, QAction +import logging import os @@ -27,6 +29,71 @@ class _WheelBlocker(QObject): return True return super().eventFilter(obj, event) +class _CtrlTabStrip(QWidget): + """Thin vertical strip shown on the left edge when the Control Panel dock is hidden. + Clicking it restores the dock. Safety feature — operator can never lose the panel.""" + + clicked = pyqtSignal() + + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("ctrlTabStrip") + self.setFixedWidth(22) + self.setCursor(Qt.CursorShape.PointingHandCursor) + self.setToolTip("Show Control Panel") + + def mousePressEvent(self, event): + if event.button() == Qt.MouseButton.LeftButton: + self.clicked.emit() + super().mousePressEvent(event) + + def paintEvent(self, _event): + p = QPainter(self) + p.setRenderHint(QPainter.RenderHint.Antialiasing) + p.fillRect(self.rect(), QColor("#3b82f6")) + p.setPen(QColor("#ffffff")) + font = QFont() + font.setPointSize(8) + font.setBold(True) + p.setFont(font) + p.translate(self.width() / 2.0, self.height() / 2.0) + p.rotate(-90.0) + text_rect = QRect(-self.height() // 2, -self.width() // 2, + self.height(), self.width()) + p.drawText(text_rect, Qt.AlignmentFlag.AlignCenter, "CONTROL PANEL ▶") + p.end() + + +class _DockTitleBar(QWidget): + """Custom title bar for the Control Panel QDockWidget. + Replaces native title bar; provides Add, Float, and Close buttons.""" + + def __init__(self, dock: QDockWidget, add_callback, parent=None): + super().__init__(parent) + self.setObjectName("controlPanelHeader") + self.setFixedHeight(30) + lay = QHBoxLayout(self) + lay.setContentsMargins(8, 0, 4, 0); lay.setSpacing(4) + + lbl = QLabel("CONTROL PANEL"); lbl.setObjectName("panelHeader") + lay.addWidget(lbl, 1) + + add_btn = QPushButton("+"); add_btn.setObjectName("devicesSmallBtn") + add_btn.setFixedSize(24, 22); add_btn.setToolTip("Add control widget") + add_btn.clicked.connect(add_callback) + lay.addWidget(add_btn) + + float_btn = QPushButton("⧉"); float_btn.setObjectName("devicesSmallBtn") + float_btn.setFixedSize(22, 22); float_btn.setToolTip("Float / dock") + float_btn.clicked.connect(lambda: dock.setFloating(not dock.isFloating())) + lay.addWidget(float_btn) + + close_btn = QPushButton("✕"); close_btn.setObjectName("devicesSmallBtn") + close_btn.setFixedSize(22, 22); close_btn.setToolTip("Hide Control Panel") + close_btn.clicked.connect(dock.hide) + lay.addWidget(close_btn) + + from devices.arduino_device import ArduinoDevice from devices.nidaqmx_device import NidaqmxDevice from devices.serial_device import SerialDevice @@ -38,9 +105,8 @@ from core.profile import Profile, ProfileManager from ui.control_panel import ControlPanel from ui.strip_chart import StripChartWidget from ui.profile_manager_ui import ProfileButton -from ui.windows.devices_window import DevicesWindow -from ui.windows.channels_window import ChannelsWindow -from ui.windows.plot_window import PlotWindow, build_default_layout +from ui.windows.config_window import ConfigWindow +from ui.windows.plot_window import build_default_layout from ui.windows.settings_window import SettingsWindow from plugins.plugin_manager import PluginManager @@ -64,11 +130,8 @@ class MainWindow(QMainWindow): self._settings = load_settings(SettingsWindow._defaults) self._elapsed = 0 - self._win_devices = None - self._win_channels = None - self._win_plot = None + self._win_config = None self._win_settings = None - self._win_debug = None import sys as _sys _user_plugins = os.path.join(os.path.expanduser("~"), ".labui", "plugins") @@ -122,6 +185,40 @@ class MainWindow(QMainWindow): apply_profile=self._profile_apply, ) tb.addWidget(self._file_btn) + + # ── ⊞ View menu ─────────────────────────────────────────────────── + view_btn = QPushButton("⊞ View ▾"); view_btn.setObjectName("toolbarSectionBtn") + self._view_menu = QMenu(self) + + self._act_ctrl_panel = QAction("Control Panel", self) + self._act_ctrl_panel.setCheckable(True); self._act_ctrl_panel.setChecked(True) + self._act_ctrl_panel.triggered.connect(self._on_view_ctrl_panel) + self._view_menu.addAction(self._act_ctrl_panel) + self._view_menu.addSeparator() + + for label, tab_idx in [ + ("Devices", 0), + ("Channels", 1), + ("Plot Builder", 2), + ]: + act = QAction(label, self); act.setCheckable(True) + act.triggered.connect( + lambda checked, ti=tab_idx: + self._open_config(ti) if checked else + (self._win_config and self._win_config.hide()) + ) + self._view_menu.addAction(act) + self._act_devices = self._view_menu.actions()[-3] + self._act_channels = self._view_menu.actions()[-2] + self._act_plot = self._view_menu.actions()[-1] + + self._view_plugin_sep = self._view_menu.addSeparator() + self._view_plugin_sep.setVisible(False) + + view_btn.clicked.connect( + lambda: self._view_menu.exec(view_btn.mapToGlobal(view_btn.rect().bottomLeft())) + ) + tb.addWidget(view_btn) tb.addWidget(_sep()) self._run_btn = QPushButton("▶ RUN") @@ -141,23 +238,6 @@ class MainWindow(QMainWindow): tb.addWidget(_sep()) - dev_btn = QPushButton("⊞ Devices") - dev_btn.setObjectName("toolbarSectionBtn"); dev_btn.setCheckable(True) - dev_btn.clicked.connect(lambda c: self._toggle_win("devices", c, dev_btn)) - tb.addWidget(dev_btn); self._btn_devices = dev_btn - - sig_btn = QPushButton("⚗ Channels") - sig_btn.setObjectName("toolbarSectionBtn"); sig_btn.setCheckable(True) - sig_btn.clicked.connect(lambda c: self._toggle_win("channels", c, sig_btn)) - tb.addWidget(sig_btn); self._btn_channels = sig_btn - - plot_btn = QPushButton("📐 Plot") - plot_btn.setObjectName("toolbarSectionBtn"); plot_btn.setCheckable(True) - plot_btn.clicked.connect(lambda c: self._toggle_win("plot", c, plot_btn)) - tb.addWidget(plot_btn); self._btn_plot = plot_btn - - tb.addWidget(_sep()) - # Plugin buttons are inserted here at runtime (between this sep and spacer) self._plugin_sep_action = tb.addWidget(_sep()) self._plugin_sep_action.setVisible(False) @@ -174,32 +254,43 @@ class MainWindow(QMainWindow): set_btn = QPushButton("⚙ Settings") set_btn.setObjectName("toolbarSectionBtn"); set_btn.setCheckable(True) - set_btn.clicked.connect(lambda c: self._toggle_win("settings", c, set_btn)) + set_btn.clicked.connect(lambda c: self._open_settings()) tb.addWidget(set_btn); self._btn_settings = set_btn - debug_btn = QPushButton("🐞 Debug") - debug_btn.setObjectName("toolbarSectionBtn"); debug_btn.setCheckable(True) - debug_btn.clicked.connect(lambda c: self._toggle_win("debug", c, debug_btn)) - tb.addWidget(debug_btn); self._btn_debug = debug_btn - debug_btn.setVisible(False) # shown/hidden per developer-mode setting - # ── Central ─────────────────────────────────────────────────────── central = QWidget(); self.setCentralWidget(central) root = QHBoxLayout(central); root.setContentsMargins(0,0,0,0); root.setSpacing(0) - hsplit = QSplitter(Qt.Orientation.Horizontal); hsplit.setHandleWidth(3) + # Side tab strip — shown when Control Panel dock is hidden + self._ctrl_tab = _CtrlTabStrip() + self._ctrl_tab.setVisible(False) + self._ctrl_tab.clicked.connect(self._show_ctrl_panel) + root.addWidget(self._ctrl_tab) + + self._chart = StripChartWidget(self.engine, self.registry, self.processor) + root.addWidget(self._chart, 1) + # ── Control Panel dock ──────────────────────────────────────────── self._ctrl = ControlPanel(self.registry, processor=self.processor) self._ctrl.setMinimumWidth(200); self._ctrl.setMaximumWidth(340) self._ctrl._add_demo_widgets() - hsplit.addWidget(self._ctrl) - - self._chart = StripChartWidget(self.engine, self.registry, self.processor) - hsplit.addWidget(self._chart) - hsplit.setSizes([260, 1000]) - root.addWidget(hsplit) + self._ctrl_dock = QDockWidget("Control Panel", self) + self._ctrl_dock.setObjectName("controlPanelDock") + self._ctrl_dock.setWidget(self._ctrl) + self._ctrl_dock.setFeatures( + QDockWidget.DockWidgetFeature.DockWidgetMovable | + QDockWidget.DockWidgetFeature.DockWidgetFloatable | + QDockWidget.DockWidgetFeature.DockWidgetClosable + ) + self._ctrl_dock.setAllowedAreas( + Qt.DockWidgetArea.LeftDockWidgetArea | + Qt.DockWidgetArea.RightDockWidgetArea + ) + self.addDockWidget(Qt.DockWidgetArea.LeftDockWidgetArea, self._ctrl_dock) + self._ctrl_dock.setTitleBarWidget(_DockTitleBar(self._ctrl_dock, self._ctrl._on_add)) + self._ctrl_dock.visibilityChanged.connect(self._on_ctrl_dock_visibility) sb = QStatusBar(); self.setStatusBar(sb) self._status = QLabel("Ready"); sb.addWidget(self._status) @@ -215,6 +306,21 @@ class MainWindow(QMainWindow): self._rec_blink = QTimer(self); self._rec_blink.setInterval(600) self._rec_blink.timeout.connect(self._tick_rec_blink) + def _on_ctrl_dock_visibility(self, visible: bool): + self._ctrl_tab.setVisible(not visible) + if hasattr(self, "_act_ctrl_panel"): + self._act_ctrl_panel.setChecked(visible) + + def _on_view_ctrl_panel(self, checked: bool): + if checked: + self._ctrl_dock.show(); self._ctrl_dock.raise_() + else: + self._ctrl_dock.hide() + + def _show_ctrl_panel(self): + self._ctrl_dock.show() + self._ctrl_dock.raise_() + def _connect_signals(self): self.engine.new_data.connect(self.processor.on_raw_data) self.processor.processed_data.connect(self._chart.on_new_data) @@ -273,8 +379,8 @@ class MainWindow(QMainWindow): self._plugin_toolbar_actions[plugin.plugin_id] = tb_actions self._chart.refresh() - if self._win_plot: - self._win_plot.refresh_channels() + if self._win_config: + self._win_config.refresh_plot() self._status.setText(f"Plugin enabled: {plugin.name}") def _uninstall_plugin(self, plugin_id: str): @@ -309,8 +415,8 @@ class MainWindow(QMainWindow): self.engine.remove_device(dev_id) self._chart.refresh() - if self._win_plot: - self._win_plot.refresh_channels() + if self._win_config: + self._win_config.refresh_plot() self._status.setText(f"Plugin disabled: {plugin.name}") def plugin_enable(self, plugin_id: str): @@ -404,57 +510,29 @@ class MainWindow(QMainWindow): # ── Window management ───────────────────────────────────────────────── - def _toggle_win(self, name: str, checked: bool, btn: QPushButton): - creators = { - "devices": self._open_devices, - "channels": self._open_channels, - "plot": self._open_plot, - "settings": self._open_settings, - "debug": self._open_debug, - } - wins = { - "devices": "_win_devices", - "channels": "_win_channels", - "plot": "_win_plot", - "settings": "_win_settings", - "debug": "_win_debug", - } - if checked: - creators[name]() - else: - win = getattr(self, wins[name], None) - if win: win.hide() - - def _open_devices(self): - if self._win_devices is None: - self._win_devices = DevicesWindow(self.registry, self.engine, self) - self._win_devices.device_added.connect(self._on_device_added) - self._win_devices.device_removed.connect(self._on_device_removed) - self._win_devices.device_reconfigured.connect(self._on_device_reconfigured) - self._win_devices.channel_visibility_changed.connect(self._on_channel_visibility_changed) - self._win_devices.channel_name_changed.connect(self._on_channel_name_changed) - self._win_devices.channel_unit_changed.connect(self._on_channel_unit_changed) - self._win_devices.closed.connect(lambda: self._btn_devices.setChecked(False)) - self._show_win(self._win_devices, "right") - - def _open_channels(self): - if self._win_channels is None: - self._win_channels = ChannelsWindow(self.registry, self.processor, self) - self._win_channels.derived_changed.connect(self._on_derived_changed) - self._win_channels.closed.connect(lambda: self._btn_channels.setChecked(False)) - self._show_win(self._win_channels, "right") - - def _open_plot(self): - if self._win_plot is None: - self._win_plot = PlotWindow(self.registry, self.processor, - self._chart._cfg, self) - self._win_plot.layout_applied.connect(self._chart.apply_layout) - self._win_plot.closed.connect(lambda: self._btn_plot.setChecked(False)) - self._win_plot.refresh_channels() # sync any derived channels - else: - self._win_plot.cfg = self._chart._cfg - self._win_plot.refresh_channels() - self._show_win(self._win_plot, "below") + def _open_config(self, tab: int = 0): + if self._win_config is None: + self._win_config = ConfigWindow( + self.registry, self.engine, self.processor, + self._chart._cfg, self, + ) + self._win_config.device_added.connect(self._on_device_added) + self._win_config.device_removed.connect(self._on_device_removed) + self._win_config.device_reconfigured.connect(self._on_device_reconfigured) + self._win_config.channel_visibility_changed.connect(self._on_channel_visibility_changed) + self._win_config.channel_name_changed.connect(self._on_channel_name_changed) + self._win_config.channel_unit_changed.connect(self._on_channel_unit_changed) + self._win_config.pipeline_changed.connect(lambda: None) + self._win_config.derived_changed.connect(self._on_derived_changed) + self._win_config.layout_applied.connect(self._chart.apply_layout) + self._win_config.closed.connect(self._on_config_closed) + self._win_config.tabs.setCurrentIndex(tab) + self._show_win(self._win_config, "right") + + def _on_config_closed(self): + self._act_devices.setChecked(False) + self._act_channels.setChecked(False) + self._act_plot.setChecked(False) def _open_settings(self): if self._win_settings is None: @@ -468,22 +546,6 @@ class MainWindow(QMainWindow): self._win_settings.closed.connect(lambda: self._btn_settings.setChecked(False)) self._show_win(self._win_settings, "right") - def _open_debug(self): - from ui.windows.debug_window import DebugWindow - if self._win_debug is None: - self._win_debug = DebugWindow(self) - self._win_debug.closed.connect(lambda: self._btn_debug.setChecked(False)) - self._show_win(self._win_debug, "right") - - def _update_debug_btn_visibility(self): - from core.app_settings import is_developer_mode - on = is_developer_mode() - self._btn_debug.setVisible(on) - if not on: - self._btn_debug.setChecked(False) - if self._win_debug: - self._win_debug.hide() - def _show_win(self, win: QWidget, position: str = "right"): if not win.isVisible(): screen = QApplication.screenAt(self.geometry().center()) @@ -529,15 +591,13 @@ class MainWindow(QMainWindow): # Clear controls self._ctrl.clear_widgets() - # Refresh open windows - if self._win_devices: - self._win_devices.refresh() - if self._win_channels: - self._win_channels.refresh_derived() - if self._win_plot: - self._win_plot.refresh_channels() + if self._win_config: + self._win_config.refresh_devices() + self._win_config.refresh_derived() + self._win_config.refresh_plot() - self._chart.refresh() + self._chart.apply_layout(build_default_layout(self.registry, self.processor)) + self._clear_history() self._status.setText("New profile — blank slate.") def _profile_capture(self, name: str = "Profile") -> Profile: @@ -618,55 +678,52 @@ class MainWindow(QMainWindow): self._chart.apply_layout(plot_cfg) else: self._chart.refresh() - # Refresh open windows - if self._win_devices: - self._win_devices.refresh() - if self._win_channels: - self._win_channels.refresh_derived() - if self._win_plot: - self._win_plot.refresh_channels() + if self._win_config: + self._win_config.refresh_devices() + self._win_config.refresh_derived() + self._win_config.refresh_plot(self._chart._cfg) self._status.setText(f"Profile loaded: {profile.name}") # ── Device / signal events ──────────────────────────────────────────── def _on_device_added(self): self._chart.refresh() - if self._win_plot: self._win_plot.refresh_channels() - if self._win_channels: self._win_channels.on_device_added() + if self._win_config: + self._win_config.on_device_added() + self._win_config.refresh_plot() self._status.setText("Device added.") def _on_device_removed(self, dev_id: str): self._chart.refresh() - if self._win_channels: self._win_channels.on_device_removed(dev_id) + if self._win_config: + self._win_config.on_device_removed(dev_id) self._status.setText(f"Device '{dev_id}' removed.") def _on_device_reconfigured(self, dev_id: str): self._chart.refresh() - if self._win_plot: self._win_plot.refresh_channels() - if self._win_channels: self._win_channels.on_device_reconfigured(dev_id) + if self._win_config: + self._win_config.on_device_reconfigured(dev_id) + self._win_config.refresh_plot() self._status.setText(f"Device '{dev_id}' reconfigured.") def _on_channel_visibility_changed(self, dev_id: str, ch_id: str, enabled: bool): self._chart.on_channel_enabled_changed(dev_id, ch_id, enabled) - if self._win_channels: - self._win_channels.on_channel_enabled_changed(dev_id, ch_id, enabled) - if self._win_plot: - self._win_plot.refresh_channels() + if self._win_config: + self._win_config.on_channel_enabled_changed(dev_id, ch_id, enabled) + self._win_config.refresh_plot() def _on_channel_name_changed(self, dev_id: str, ch_id: str, name: str): - if self._win_channels: - self._win_channels.on_channel_name_changed(dev_id, ch_id, name) + if self._win_config: + self._win_config.on_channel_name_changed(dev_id, ch_id, name) def _on_channel_unit_changed(self, dev_id: str, ch_id: str, unit: str): - if self._win_channels: - self._win_channels.on_channel_unit_changed(dev_id, ch_id, unit) + if self._win_config: + self._win_config.on_channel_unit_changed(dev_id, ch_id, unit) def _on_derived_changed(self): self._chart.refresh() - if self._win_plot: - # Ensure plot window works on current chart cfg, then sync derived panes - self._win_plot.cfg = self._chart._cfg - self._win_plot.refresh_channels() + if self._win_config: + self._win_config.refresh_plot(self._chart._cfg) # ── Run / Log ───────────────────────────────────────────────────────── @@ -722,14 +779,17 @@ class MainWindow(QMainWindow): def _apply_settings_on_startup(self): self._apply_theme(self._settings.get("theme", "dark")) self.engine._interval = self._settings.get("poll_ms", 100) / 1000.0 - set_developer_mode(self._settings.get("developer_mode", True)) - self._update_debug_btn_visibility() + self._apply_developer_mode(self._settings.get("developer_mode", False)) + + def _apply_developer_mode(self, enabled: bool): + level = logging.DEBUG if enabled else logging.WARNING + logging.getLogger().setLevel(level) + set_developer_mode(enabled) def _on_settings(self, cfg: dict): self._settings.update(cfg) self.engine._interval = cfg.get("poll_ms", 100) / 1000.0 - set_developer_mode(self._settings.get("developer_mode", True)) - self._update_debug_btn_visibility() + self._apply_developer_mode(cfg.get("developer_mode", False)) save_settings(self._settings) def _tick(self): @@ -738,8 +798,7 @@ class MainWindow(QMainWindow): self._time_lbl.setText(f"{h:02d}:{m:02d}:{s:02d}") def closeEvent(self, event): - for w in (self._win_devices, self._win_channels, - self._win_plot, self._win_settings): + for w in (self._win_config, self._win_settings): if w: w.close() for plugin in list(self._plugin_mgr.get_loaded()): try: diff --git a/ui/style_dark.qss b/ui/style_dark.qss index 431459d..146c162 100644 --- a/ui/style_dark.qss +++ b/ui/style_dark.qss @@ -324,8 +324,8 @@ QComboBox#traceStyleCb, QDoubleSpinBox#traceWidthSpin { font-family: "IBM Plex Mono", monospace; font-size: 11px; background: #0b0e13; border: 1px solid #1c2540; border-radius: 3px; color: #8b9dc3; padding: 2px 4px; } -QToolButton#traceRemoveBtn { background: transparent; color: #334155; border: none; font-size: 13px; border-radius: 3px; } -QToolButton#traceRemoveBtn:hover { background-color: #450a0a; color: #ef4444; } +QPushButton#traceRemoveBtn { background: transparent; color: #8b9dc3; border: none; font-size: 13px; border-radius: 3px; padding: 0; } +QPushButton#traceRemoveBtn:hover { background-color: #450a0a; color: #ef4444; } QPushButton#addTraceBtn { background-color: #1c2540; color: #8b9dc3; @@ -607,3 +607,30 @@ QLabel#layoutTileLabel { color: #8b9dc3; background: transparent; } + +/* ── Control Panel Dock ──────────────────────────────────────────── */ +QDockWidget#controlPanelDock { + border: none; +} +QDockWidget#controlPanelDock::title { + background-color: #0f1521; + color: #8b9dc3; + padding: 4px 8px; + text-align: left; + font-size: 11px; + font-weight: bold; + letter-spacing: 1px; + border-bottom: 1px solid #2a3558; +} +QDockWidget#controlPanelDock::close-button, +QDockWidget#controlPanelDock::float-button { + background: transparent; + border: none; + padding: 2px; + subcontrol-position: top right; +} +QDockWidget#controlPanelDock::close-button:hover, +QDockWidget#controlPanelDock::float-button:hover { + background: #2a3558; + border-radius: 2px; +} diff --git a/ui/style_light.qss b/ui/style_light.qss index ff204fb..19d8025 100644 --- a/ui/style_light.qss +++ b/ui/style_light.qss @@ -119,6 +119,8 @@ QPushButton#digitalOutBtn:checked { background:#dcfce7; border-color:#22c55e; co QWidget#controlPanelHeader { background:#f1f5f9; border-bottom:1px solid #e2e8f0; } QPushButton#devicesSmallBtn { background:transparent; color:#94a3b8; border:1px solid #e2e8f0; border-radius:3px; font-size:13px; padding:0; } QPushButton#devicesSmallBtn:hover { background:#f0f9ff; color:#3b82f6; border-color:#3b82f6; } +QPushButton#traceRemoveBtn { background:transparent; color:#94a3b8; border:none; font-size:13px; border-radius:3px; padding:0; } +QPushButton#traceRemoveBtn:hover { background:#fee2e2; color:#ef4444; } QPushButton#plotCfgButton { background:#f0f9ff; color:#1d4ed8; border:1px solid #93c5fd; border-radius:4px; padding:5px 14px; font-weight:600; } QPushButton#plotCfgButton:hover { background:#dbeafe; border-color:#3b82f6; } QPushButton#plotCfgButton:checked { background:#dbeafe; border-color:#3b82f6; } @@ -135,3 +137,9 @@ QFrame#layoutTile { background-color:#e2e8f0; border:1px solid #94a3b8; border-r QFrame#layoutTile:hover { border-color:#4338ca; background-color:#c7d2fe; } QFrame#layoutTileDragging { background-color:#bfdbfe; border:2px solid #3b82f6; border-radius:5px; } QLabel#layoutTileLabel { font-family:"IBM Plex Mono",monospace; font-size:11px; font-weight:700; color:#334155; background:transparent; } + +/* Control Panel Dock */ +QDockWidget#controlPanelDock { border: none; } +QDockWidget#controlPanelDock::title { background-color:#f1f5f9; color:#475569; padding:4px 8px; text-align:left; font-size:11px; font-weight:bold; letter-spacing:1px; border-bottom:1px solid #e2e8f0; } +QDockWidget#controlPanelDock::close-button, QDockWidget#controlPanelDock::float-button { background:transparent; border:none; padding:2px; } +QDockWidget#controlPanelDock::close-button:hover, QDockWidget#controlPanelDock::float-button:hover { background:#e2e8f0; border-radius:2px; } diff --git a/ui/windows/config_window.py b/ui/windows/config_window.py new file mode 100644 index 0000000..05cc1fb --- /dev/null +++ b/ui/windows/config_window.py @@ -0,0 +1,504 @@ +""" +ui/windows/config_window.py + +Unified Configuration window — Devices | Channels | Plot in one tabbed window. + +Channels tab layout (2 columns): + Left — signal table (Device / Signal ID / Dir / On / Name / Unit / Min / Max) + ⚙ button per row opens that channel's filter/calibration pipeline dialog + Right — derived / virtual channels +""" + +from __future__ import annotations +from copy import deepcopy +from typing import List, Optional, Set, Tuple + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QToolButton, + QScrollArea, QFrame, QTabWidget, QTableWidget, QTableWidgetItem, + QHeaderView, QCheckBox, QMessageBox, QAbstractItemView, QSplitter, + QDoubleSpinBox, QDialog, QDialogButtonBox, +) +from PyQt6.QtCore import Qt, pyqtSignal, QTimer +from PyQt6.QtGui import QCloseEvent, QColor + +from devices.device_registry import DeviceRegistry +from core.acquisition import AcquisitionEngine +from core.signal_processor import SignalProcessor + +from ui.windows.devices_window import DeviceRow, ChannelsTab +from ui.windows.channels_window import PipelineTab, ChannelPipelineBlock +from ui.windows.plot_window import ( + PlotWindow, LayoutConfig, LayoutCanvas, PaneBlock, + build_default_layout, +) + + +# ══════════════════════════════════════════════════════════════════════════════ +# Pipeline dialog — per-channel filter/calibration popup +# ══════════════════════════════════════════════════════════════════════════════ + +class _PipelineDialog(QDialog): + """Shows one ChannelPipelineBlock inside a dialog for a single signal.""" + + def __init__(self, dev_id: str, ch_id: str, ch_name: str, unit: str, + processor: SignalProcessor, parent=None): + super().__init__(parent) + self.setWindowTitle(f"Pipeline — {dev_id} / {ch_id}") + self.setMinimumWidth(460) + self.resize(500, 420) + + lay = QVBoxLayout(self) + lay.setContentsMargins(0, 0, 0, 12) + + self._block = ChannelPipelineBlock(dev_id, ch_id, ch_name, unit, processor) + self._block._toggle() # start expanded + lay.addWidget(self._block, 1) + + btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Close) + btns.rejected.connect(self.accept) + lay.addWidget(btns) + + +# ══════════════════════════════════════════════════════════════════════════════ +# Enhanced signals table — adds Dir badge and pipeline ⚙ button +# ══════════════════════════════════════════════════════════════════════════════ + +class _SignalsTab(ChannelsTab): + """ChannelsTab with Dir (IN/OUT) badge and per-row ⚙ pipeline button.""" + + _C_DEVICE = 0 + _C_CH_ID = 1 + _C_DIR = 2 + _C_ENABLED = 3 + _C_NAME = 4 + _C_UNIT = 5 + _C_MIN = 6 + _C_MAX = 7 + _C_PIPE = 8 # ⚙ pipeline button + + def __init__(self, registry: DeviceRegistry, processor: SignalProcessor): + self._processor = processor + super().__init__(registry) + + def _build(self): + lay = QVBoxLayout(self) + lay.setContentsMargins(0, 0, 0, 0) + + self._table = QTableWidget() + self._table.setObjectName("channelTable") + self._table.setColumnCount(9) + self._table.setHorizontalHeaderLabels( + ["Device", "Signal ID", "Dir", "On", "Name", "Unit", "Min", "Max", ""] + ) + hdr = self._table.horizontalHeader() + hdr.setSectionResizeMode(self._C_NAME, QHeaderView.ResizeMode.Stretch) + hdr.setSectionResizeMode(self._C_DEVICE, QHeaderView.ResizeMode.ResizeToContents) + hdr.setSectionResizeMode(self._C_CH_ID, QHeaderView.ResizeMode.ResizeToContents) + hdr.setSectionResizeMode(self._C_DIR, QHeaderView.ResizeMode.ResizeToContents) + hdr.setSectionResizeMode(self._C_ENABLED, QHeaderView.ResizeMode.ResizeToContents) + hdr.setSectionResizeMode(self._C_PIPE, QHeaderView.ResizeMode.ResizeToContents) + self._table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) + self._table.setAlternatingRowColors(True) + self._table.verticalHeader().setVisible(False) + self._table.cellChanged.connect(self._on_cell_changed) + lay.addWidget(self._table) + + def refresh(self): + self._table.blockSignals(True) + self._table.setRowCount(0) + row = 0 + for dev in self.registry.all_instances(): + for ch in dev.info.channels: + self._table.insertRow(row) + + dev_item = QTableWidgetItem(f"{dev.info.icon} {dev.info.device_id}") + dev_item.setFlags(dev_item.flags() & ~Qt.ItemFlag.ItemIsEditable) + dev_item.setForeground(QColor("#64748b")) + self._table.setItem(row, self._C_DEVICE, dev_item) + + ch_item = QTableWidgetItem(ch.channel_id) + ch_item.setFlags(ch_item.flags() & ~Qt.ItemFlag.ItemIsEditable) + ch_item.setForeground(QColor(ch.color)) + self._table.setItem(row, self._C_CH_ID, ch_item) + + is_out = getattr(ch, "is_output", False) + dir_item = QTableWidgetItem("OUT" if is_out else "IN") + dir_item.setFlags(dir_item.flags() & ~Qt.ItemFlag.ItemIsEditable) + dir_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter) + dir_item.setForeground(QColor("#f59e0b") if is_out else QColor("#22c55e")) + self._table.setItem(row, self._C_DIR, dir_item) + + # Enable toggle — disabled for output channels + chk_w = QWidget() + chk_l = QHBoxLayout(chk_w) + chk_l.setContentsMargins(0, 0, 0, 0) + chk_l.setAlignment(Qt.AlignmentFlag.AlignCenter) + chk = QCheckBox() + chk.setChecked(True if is_out else ch.enabled) + if is_out: + chk.setEnabled(False) + else: + dev_id_cap = dev.info.device_id + ch_id_cap = ch.channel_id + chk.toggled.connect( + lambda checked, d=dev_id_cap, c=ch_id_cap: + self._on_enabled(d, c, checked) + ) + chk_l.addWidget(chk) + self._table.setCellWidget(row, self._C_ENABLED, chk_w) + + name_item = QTableWidgetItem(ch.name) + name_item.setData(Qt.ItemDataRole.UserRole, (dev.info.device_id, ch.channel_id)) + self._table.setItem(row, self._C_NAME, name_item) + + self._table.setItem(row, self._C_UNIT, QTableWidgetItem(ch.unit)) + self._table.setItem(row, self._C_MIN, QTableWidgetItem(str(ch.min_value))) + self._table.setItem(row, self._C_MAX, QTableWidgetItem(str(ch.max_value))) + + # Pipeline ⚙ button + pipe_w = QWidget() + pipe_l = QHBoxLayout(pipe_w) + pipe_l.setContentsMargins(2, 0, 2, 0) + pipe_l.setAlignment(Qt.AlignmentFlag.AlignCenter) + pipe_btn = QToolButton() + pipe_btn.setText("⚙") + pipe_btn.setObjectName("traceRemoveBtn") + pipe_btn.setFixedSize(22, 22) + pipe_btn.setToolTip("Configure filters / calibration") + d_cap, c_cap, n_cap, u_cap = ( + dev.info.device_id, ch.channel_id, ch.name, ch.unit + ) + pipe_btn.clicked.connect( + lambda _=False, d=d_cap, c=c_cap, n=n_cap, u=u_cap: + self._open_pipeline(d, c, n, u) + ) + pipe_l.addWidget(pipe_btn) + self._table.setCellWidget(row, self._C_PIPE, pipe_w) + + row += 1 + + self._table.blockSignals(False) + + def _open_pipeline(self, dev_id: str, ch_id: str, ch_name: str, unit: str): + dlg = _PipelineDialog(dev_id, ch_id, ch_name, unit, self._processor, self) + dlg.exec() + + +# ══════════════════════════════════════════════════════════════════════════════ +# Plot tab — PlotWindow embedded without floating chrome +# ══════════════════════════════════════════════════════════════════════════════ + +class _PlotTab(PlotWindow): + def __init__(self, registry: DeviceRegistry, processor: SignalProcessor, + current: Optional[LayoutConfig] = None, parent=None): + QWidget.__init__(self, parent) + self.registry = registry + self.processor = processor + self.cfg = deepcopy(current) if current else build_default_layout(registry, processor) + self._blocks: List[PaneBlock] = [] + self._build() + self._populate() + + def _build(self): + root = QVBoxLayout(self); root.setContentsMargins(0, 0, 0, 0); root.setSpacing(0) + + glob = QWidget(); glob.setObjectName("cfgGlobalBar") + gl = QHBoxLayout(glob); gl.setContentsMargins(12, 7, 12, 7); gl.setSpacing(12) + gl.addWidget(QLabel("Window:")) + self._win = QDoubleSpinBox(); self._win.setRange(1, 3600) + self._win.setSuffix(" s"); self._win.setValue(self.cfg.time_window_s) + self._win.setObjectName("cfgGlobalSpin") + self._win.valueChanged.connect(lambda v: setattr(self.cfg, "time_window_s", v)) + gl.addWidget(self._win) + self._linkx = QCheckBox("Link X"); self._linkx.setChecked(self.cfg.link_x) + self._linkx.setObjectName("cfgLiveChk") + self._linkx.toggled.connect(lambda v: setattr(self.cfg, "link_x", v)) + gl.addWidget(self._linkx) + self._legend = QCheckBox("Legend"); self._legend.setChecked(self.cfg.show_legend) + self._legend.setObjectName("cfgLiveChk") + self._legend.toggled.connect(lambda v: setattr(self.cfg, "show_legend", v)) + gl.addWidget(self._legend) + self._live = QCheckBox("Live preview"); self._live.setObjectName("cfgLiveChk") + gl.addWidget(self._live) + gl.addStretch() + add_btn = QPushButton("+ Add Pane"); add_btn.setObjectName("addDeviceButton") + add_btn.clicked.connect(self._add_pane); gl.addWidget(add_btn) + root.addWidget(glob) + + canvas_bar = QWidget(); canvas_bar.setObjectName("layoutCanvasBar") + cb_lay = QVBoxLayout(canvas_bar) + cb_lay.setContentsMargins(10, 6, 10, 8); cb_lay.setSpacing(4) + clbl = QLabel( + "LAYOUT — edge: split (blue) | " + "edge gap: insert between (amber) | center: move (green)" + ) + clbl.setObjectName("layoutCanvasLabel") + cb_lay.addWidget(clbl) + self._canvas = LayoutCanvas(self.cfg.panes, self.cfg.tree, self) + self._canvas._cfg_ref = self.cfg + self._canvas.arrangement_changed.connect(self._maybe_live) + cb_lay.addWidget(self._canvas) + root.addWidget(canvas_bar) + + div = QFrame(); div.setFrameShape(QFrame.Shape.HLine) + div.setObjectName("devWindowDivider"); root.addWidget(div) + + scroll = QScrollArea(); scroll.setWidgetResizable(True) + scroll.setObjectName("deviceScroll") + self._cont = QWidget() + self._blay = QVBoxLayout(self._cont) + self._blay.setContentsMargins(10, 10, 10, 10); self._blay.setSpacing(12) + self._blay.addStretch() + scroll.setWidget(self._cont) + root.addWidget(scroll, 1) + + btm = QWidget(); btm.setObjectName("cfgBottomBar") + bl = QHBoxLayout(btm); bl.setContentsMargins(12, 8, 12, 8) + for lbl, fn in [("⟳ Rebuild from Channels", self._reset), + ("↓ Export JSON", self._export), + ("↑ Import JSON", self._import_json)]: + b = QPushButton(lbl); b.setObjectName("configButton") + b.clicked.connect(fn); bl.addWidget(b) + bl.addStretch() + ap = QPushButton("✓ Apply"); ap.setObjectName("applyButton") + ap.clicked.connect(self._apply); bl.addWidget(ap) + root.addWidget(btm) + + +# ══════════════════════════════════════════════════════════════════════════════ +# ConfigWindow +# ══════════════════════════════════════════════════════════════════════════════ + +class ConfigWindow(QWidget): + device_added = pyqtSignal() + device_removed = pyqtSignal(str) + device_reconfigured = pyqtSignal(str) + channel_visibility_changed = pyqtSignal(str, str, bool) + channel_name_changed = pyqtSignal(str, str, str) + channel_unit_changed = pyqtSignal(str, str, str) + pipeline_changed = pyqtSignal() + derived_changed = pyqtSignal() + layout_applied = pyqtSignal(object) + closed = pyqtSignal() + + def __init__(self, registry: DeviceRegistry, engine: AcquisitionEngine, + processor: SignalProcessor, + current_layout: Optional[LayoutConfig] = None, parent=None): + super().__init__(parent, Qt.WindowType.Window) + self.registry = registry + self.engine = engine + self.processor = processor + self._dev_rows: dict = {} + + self.setWindowTitle("Configuration") + self.setMinimumSize(900, 580) + self.resize(1200, 760) + self._build(current_layout) + + # ── Build ──────────────────────────────────────────────────────────────── + + def _build(self, current_layout: Optional[LayoutConfig]): + root = QVBoxLayout(self); root.setContentsMargins(0, 0, 0, 0); root.setSpacing(0) + self.tabs = QTabWidget() + self.tabs.setObjectName("signalBuilderTabs") + root.addWidget(self.tabs, 1) + self._build_devices_tab() + self._build_channels_tab() + self._build_plot_tab(current_layout) + self.tabs.currentChanged.connect(self._on_tab_changed) + + # ── Tab 0: Devices ─────────────────────────────────────────────────────── + + def _build_devices_tab(self): + w = QWidget() + lay = QVBoxLayout(w); lay.setContentsMargins(0, 0, 0, 0); lay.setSpacing(0) + + bar = QWidget(); bar.setObjectName("cfgGlobalBar") + bl = QHBoxLayout(bar); bl.setContentsMargins(10, 7, 10, 7); bl.setSpacing(6) + lbl = QLabel("Physical Devices"); lbl.setObjectName("devWindowTitle") + bl.addWidget(lbl, 1) + add_btn = QPushButton("+ Add Device"); add_btn.setObjectName("addDeviceButton") + add_btn.clicked.connect(self._on_add_device) + bl.addWidget(add_btn) + lay.addWidget(bar) + + div = QFrame(); div.setFrameShape(QFrame.Shape.HLine) + div.setObjectName("devWindowDivider"); lay.addWidget(div) + + scroll = QScrollArea(); scroll.setWidgetResizable(True) + scroll.setObjectName("deviceScroll") + scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self._dev_container = QWidget() + self._dev_inner = QVBoxLayout(self._dev_container) + self._dev_inner.setContentsMargins(10, 10, 10, 10); self._dev_inner.setSpacing(6) + self._dev_inner.addStretch() + scroll.setWidget(self._dev_container) + lay.addWidget(scroll, 1) + + self.tabs.addTab(w, " Devices ") + + self._dev_poll = QTimer(self) + self._dev_poll.setInterval(2000) + self._dev_poll.timeout.connect(self._refresh_device_status) + self._dev_poll.start() + + self.refresh_devices() + + def refresh_devices(self): + for row in self._dev_rows.values(): + self._dev_inner.removeWidget(row); row.deleteLater() + self._dev_rows.clear() + for dev in self.registry.all_instances(): + row = DeviceRow(dev) + row.configure_clicked.connect(self._on_configure_device) + row.remove_clicked.connect(self._on_remove_device) + self._dev_inner.insertWidget(self._dev_inner.count() - 1, row) + self._dev_rows[dev.info.device_id] = row + + def _refresh_device_status(self): + for row in self._dev_rows.values(): + row.refresh() + + def _on_add_device(self): + from ui.add_device_dialog import AddDeviceDialog + dlg = AddDeviceDialog(self.registry, self) + if dlg.exec(): + dev = dlg.created_device + if dev: + dev.connect() + self.registry.add_instance(dev) + self.engine.add_device(dev) + self.refresh_devices() + self._signals_tab.refresh() + self.device_added.emit() + + def _on_configure_device(self, device_id: str): + from ui.config_dialog import DeviceConfigDialog + dev = self.registry.get_instance(device_id) + if dev: + DeviceConfigDialog(dev, self).exec() + self._signals_tab.refresh() + self.device_reconfigured.emit(device_id) + + def _on_remove_device(self, device_id: str): + reply = QMessageBox.question( + self, "Remove Device", + f"Remove '{device_id}'? This cannot be undone.", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + ) + if reply == QMessageBox.StandardButton.Yes: + self.engine.remove_device(device_id) + self.registry.remove_instance(device_id) + self.refresh_devices() + self._signals_tab.refresh() + self._pipeline_tab.on_device_removed(device_id) + self.device_removed.emit(device_id) + + # ── Tab 1: Channels ────────────────────────────────────────────────────── + # Left — signal table (metadata + enable + ⚙ pipeline button per row) + # Right — derived / virtual channels (PipelineTab with physical side hidden) + + def _build_channels_tab(self): + w = QWidget() + lay = QVBoxLayout(w); lay.setContentsMargins(0, 0, 0, 0); lay.setSpacing(0) + + splitter = QSplitter(Qt.Orientation.Horizontal) + splitter.setObjectName("channelSplitter") + lay.addWidget(splitter, 1) + + # Left: signals table + sig_w = QWidget() + sig_l = QVBoxLayout(sig_w); sig_l.setContentsMargins(0, 0, 0, 0); sig_l.setSpacing(0) + + sig_bar = QWidget(); sig_bar.setObjectName("cfgGlobalBar") + sb_lay = QHBoxLayout(sig_bar); sb_lay.setContentsMargins(10, 7, 10, 7) + sb_lbl = QLabel("Signals"); sb_lbl.setObjectName("devWindowTitle") + sb_lay.addWidget(sb_lbl, 1) + sig_l.addWidget(sig_bar) + + sig_div = QFrame(); sig_div.setFrameShape(QFrame.Shape.HLine) + sig_div.setObjectName("devWindowDivider"); sig_l.addWidget(sig_div) + + self._signals_tab = _SignalsTab(self.registry, self.processor) + self._signals_tab.channel_visibility_changed.connect(self._on_sig_visibility) + self._signals_tab.channel_name_changed.connect(self._on_sig_name) + self._signals_tab.channel_unit_changed.connect(self._on_sig_unit) + sig_l.addWidget(self._signals_tab, 1) + splitter.addWidget(sig_w) + + # Right: derived channels only + self._pipeline_tab = PipelineTab(self.registry, self.processor) + self._pipeline_tab.changed.connect(self.pipeline_changed) + self._pipeline_tab.derived_changed.connect(self.derived_changed) + inner_splitters = self._pipeline_tab.findChildren(QSplitter) + if inner_splitters: + inner_splitters[0].widget(0).setVisible(False) + splitter.addWidget(self._pipeline_tab) + + splitter.setStretchFactor(0, 2) + splitter.setStretchFactor(1, 1) + + self.tabs.addTab(w, " Channels ") + + def _on_sig_visibility(self, dev_id: str, ch_id: str, enabled: bool): + self._pipeline_tab.on_channel_enabled_changed(dev_id, ch_id, enabled) + self.channel_visibility_changed.emit(dev_id, ch_id, enabled) + + def _on_sig_name(self, dev_id: str, ch_id: str, name: str): + self._pipeline_tab.on_channel_name_changed(dev_id, ch_id, name) + self.channel_name_changed.emit(dev_id, ch_id, name) + + def _on_sig_unit(self, dev_id: str, ch_id: str, unit: str): + self._pipeline_tab.on_channel_unit_changed(dev_id, ch_id, unit) + self.channel_unit_changed.emit(dev_id, ch_id, unit) + + def on_device_added(self): + self._signals_tab.refresh() + self._pipeline_tab.on_device_added() + + def on_device_removed(self, dev_id: str): + self._pipeline_tab.on_device_removed(dev_id) + + def on_device_reconfigured(self, dev_id: str): + self._signals_tab.refresh() + self._pipeline_tab.on_device_reconfigured(dev_id) + + def on_channel_enabled_changed(self, dev_id: str, ch_id: str, enabled: bool): + self._pipeline_tab.on_channel_enabled_changed(dev_id, ch_id, enabled) + + def on_channel_name_changed(self, dev_id: str, ch_id: str, name: str): + self._pipeline_tab.on_channel_name_changed(dev_id, ch_id, name) + + def on_channel_unit_changed(self, dev_id: str, ch_id: str, unit: str): + self._pipeline_tab.on_channel_unit_changed(dev_id, ch_id, unit) + + def refresh_derived(self): + self._pipeline_tab.refresh_derived() + + # ── Tab 2: Plot ────────────────────────────────────────────────────────── + + def _build_plot_tab(self, current_layout: Optional[LayoutConfig]): + self._plot_tab = _PlotTab(self.registry, self.processor, current_layout, self) + self._plot_tab.layout_applied.connect(self.layout_applied) + self.tabs.addTab(self._plot_tab, " Plot ") + + def refresh_plot(self, cfg: Optional[LayoutConfig] = None): + if cfg is not None: + self._plot_tab.cfg = cfg + self._plot_tab.refresh_channels() + + # ── Tab switch ─────────────────────────────────────────────────────────── + + def _on_tab_changed(self, idx: int): + if idx == 1: + self._signals_tab.refresh() + elif idx == 2: + self._plot_tab.refresh_channels() + + # ── Close ──────────────────────────────────────────────────────────────── + + def closeEvent(self, e: QCloseEvent): + self._dev_poll.stop() + self.closed.emit() + e.accept() diff --git a/ui/windows/debug_window.py b/ui/windows/debug_window.py deleted file mode 100644 index 0e891ed..0000000 --- a/ui/windows/debug_window.py +++ /dev/null @@ -1,65 +0,0 @@ -""" -ui/windows/debug_window.py - -DEBUG window — developer-mode only. - -Minimal first pass: a live console showing everything the app has printed -via core.debug_log (stdout/stderr tee), so debugging doesn't require a -terminal. Not wired to any other diagnostics yet — extend as needed. -""" - -from PyQt6.QtWidgets import ( - QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, - QTextEdit, QFrame, -) -from PyQt6.QtCore import Qt, pyqtSignal -from PyQt6.QtGui import QFont, QCloseEvent - -from core.debug_log import get_broadcaster, get_history - - -class DebugWindow(QWidget): - closed = pyqtSignal() - - def __init__(self, parent=None): - super().__init__(parent, Qt.WindowType.Window | Qt.WindowType.Tool) - self.setWindowTitle("Debug") - self.setMinimumSize(560, 420) - self.resize(700, 500) - self._build() - - broadcaster = get_broadcaster() - if broadcaster is not None: - broadcaster.line_written.connect(self._append) - - def _build(self): - root = QVBoxLayout(self); root.setContentsMargins(0, 0, 0, 0); root.setSpacing(0) - - hdr = QWidget(); hdr.setObjectName("devWindowTitleBar"); hdr.setFixedHeight(44) - hl = QHBoxLayout(hdr); hl.setContentsMargins(14, 0, 14, 0) - title = QLabel("DEBUG"); title.setObjectName("devWindowTitle") - hl.addWidget(title, 1) - clear_btn = QPushButton("Clear"); clear_btn.setObjectName("configButton") - clear_btn.clicked.connect(lambda: self._console.clear()) - hl.addWidget(clear_btn) - root.addWidget(hdr) - - div = QFrame(); div.setFrameShape(QFrame.Shape.HLine) - div.setObjectName("devWindowDivider"); root.addWidget(div) - - self._console = QTextEdit(); self._console.setObjectName("codeEditor") - self._console.setReadOnly(True) - mono = QFont("IBM Plex Mono, Consolas, Monospace") - mono.setStyleHint(QFont.StyleHint.Monospace) - self._console.setFont(mono) - self._console.setPlainText(get_history()) - self._console.verticalScrollBar().setValue(self._console.verticalScrollBar().maximum()) - root.addWidget(self._console, 1) - - def _append(self, text: str): - self._console.insertPlainText(text) - sb = self._console.verticalScrollBar() - sb.setValue(sb.maximum()) - - def closeEvent(self, e: QCloseEvent): - self.closed.emit(); e.accept() diff --git a/ui/windows/settings_window.py b/ui/windows/settings_window.py index 96a99a5..a45413d 100644 --- a/ui/windows/settings_window.py +++ b/ui/windows/settings_window.py @@ -40,7 +40,7 @@ class SettingsWindow(QWidget): "show_grid": True, "font_size": 12, "antialias": True, - "developer_mode": True, + "developer_mode": False, } def __init__(self, registry: DeviceRegistry, @@ -114,8 +114,9 @@ class SettingsWindow(QWidget): self._dev_chk = QCheckBox() self._dev_chk.setChecked(self.cfg["developer_mode"]) self._dev_chk.setToolTip( - "Controls whether the Debug window and each device's Simulation\n" - "Mode option are available. Off = real-hardware-only, no debug tools." + "Controls whether each device's Simulation Mode option is\n" + "available, and enables verbose DEBUG output in the terminal.\n" + "Off = real-hardware-only, no debug tools." ) lay.addRow("Developer mode:", self._dev_chk) @@ -408,16 +409,16 @@ class SettingsWindow(QWidget): def _apply(self): theme = self._theme_cb.currentText().lower() self.cfg.update({ - "theme": theme, - "font_size": self._font_sp.value(), - "antialias": self._aa_chk.isChecked(), + "theme": theme, + "font_size": self._font_sp.value(), + "antialias": self._aa_chk.isChecked(), "developer_mode": self._dev_chk.isChecked(), - "poll_ms": self._poll_sp.value(), - "buffer_size": self._buf_sp.value(), - "log_dir": self._log_edit.text(), - "time_window_s": self._tw_sp.value(), - "show_legend": self._legend_chk.isChecked(), - "show_grid": self._grid_chk.isChecked(), + "poll_ms": self._poll_sp.value(), + "buffer_size": self._buf_sp.value(), + "log_dir": self._log_edit.text(), + "time_window_s": self._tw_sp.value(), + "show_legend": self._legend_chk.isChecked(), + "show_grid": self._grid_chk.isChecked(), }) self.theme_changed.emit(theme) self.settings_changed.emit(dict(self.cfg)) |
