summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore15
-rw-r--r--api_layers/arduino_layer.py224
-rw-r--r--api_layers/firmware/LabUI_firmware.ino218
-rw-r--r--devices/base_device.py1
-rw-r--r--scripts/release/.tufup-repo-config34
-rwxr-xr-xscripts/release/repository/metadata/1.root.json71
-rwxr-xr-xscripts/release/repository/metadata/root.json71
-rwxr-xr-xscripts/release/repository/metadata/snapshot.json19
-rwxr-xr-xscripts/release/repository/metadata/targets.json15
-rwxr-xr-xscripts/release/repository/metadata/timestamp.json19
-rw-r--r--ui/control_panel.py13
-rw-r--r--ui/main_window.py371
-rw-r--r--ui/style_dark.qss31
-rw-r--r--ui/style_light.qss8
-rw-r--r--ui/windows/config_window.py504
-rw-r--r--ui/windows/settings_window.py23
16 files changed, 1241 insertions, 396 deletions
diff --git a/.gitignore b/.gitignore
index 7a03d40..b4f9947 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,10 +1,17 @@
logs/*
-__pychache__/
+__pycache__/
*.pyc
*.pyo
+
+# Build artifacts
+build/
+dist/
+
+# TUF release keystore (private signing keys)
+scripts/release/keystore/
+
+# Claude project memory
+.claude/
.claude/settings.local.json
plugins/enabled.json
-plugins/enabled.json
-plugins/enabled.json
-plugins/enabled.json
diff --git a/api_layers/arduino_layer.py b/api_layers/arduino_layer.py
index 4f8cee4..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"""
-/*
- * LabDAQ Arduino Firmware v1.1
- * ─────────────────────────────────────────────────────────────────────────
- * Upload this sketch to your Arduino.
- * Set baud rate to 115200 in both this sketch and LabDAQ.
- *
- * 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/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)
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 90294ac..01e0505 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,10 +105,10 @@ 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 ui.windows.debug_window import DebugWindow
from plugins.plugin_manager import PluginManager
from plugins.base_plugin import PluginContext
@@ -64,9 +131,7 @@ 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
@@ -117,6 +182,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")
@@ -136,23 +235,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)
@@ -169,32 +251,49 @@ 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))
+ debug_btn.clicked.connect(lambda c: self._open_debug())
tb.addWidget(debug_btn); self._btn_debug = debug_btn
- debug_btn.setVisible(False) # shown/hidden per developer-mode setting
+ debug_btn.setVisible(False) # shown/hidden by _update_debug_btn_visibility 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)
@@ -206,6 +305,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)
@@ -264,8 +378,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):
@@ -300,8 +414,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):
@@ -367,57 +481,35 @@ 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 _open_debug(self):
+ 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 _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:
@@ -431,22 +523,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())
@@ -492,15 +568,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:
@@ -540,55 +614,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 ─────────────────────────────────────────────────────────
@@ -644,14 +715,27 @@ 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._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)
self._update_debug_btn_visibility()
+ 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 _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):
@@ -660,8 +744,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, self._win_debug):
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 9fd7198..15af970 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;
@@ -605,3 +605,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 265894d..a7b3cb6 100644
--- a/ui/style_light.qss
+++ b/ui/style_light.qss
@@ -118,6 +118,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; }
@@ -134,3 +136,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/settings_window.py b/ui/windows/settings_window.py
index 290d9e0..09b7a1a 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,7 +114,8 @@ class SettingsWindow(QWidget):
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."
+ "Mode option are available, and enables verbose DEBUG output in\n"
+ "the terminal. Off = real-hardware-only, no debug tools."
)
lay.addRow("Developer mode:", self._dev_chk)
@@ -305,16 +306,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))