diff options
| author | Christian Kolset <christian.kolset@gmail.com> | 2026-04-21 13:43:52 -0600 |
|---|---|---|
| committer | Christian Kolset <christian.kolset@gmail.com> | 2026-04-21 13:57:46 -0600 |
| commit | 2a634dca0c7962b90004f75c4cdac6225201bb5e (patch) | |
| tree | 3e1f0cd4ff165e77d17ebec5278c9bed885bb1da /api_layers/arduino_layer.py | |
| parent | b1a61fd29e2282110bc4f4bc4616c55ed9d88dbb (diff) | |
V13
This version contains the profile feature. Allowins users to save the
channels, signals and plots for specific labs.
Diffstat (limited to 'api_layers/arduino_layer.py')
| -rw-r--r-- | api_layers/arduino_layer.py | 561 |
1 files changed, 478 insertions, 83 deletions
diff --git a/api_layers/arduino_layer.py b/api_layers/arduino_layer.py index f2b8241..fb09a16 100644 --- a/api_layers/arduino_layer.py +++ b/api_layers/arduino_layer.py @@ -1,25 +1,66 @@ """ api_layers/arduino_layer.py -Arduino serial API layer. +Arduino serial communication layer. -Protocol: Arduino sends newline-terminated lines, e.g.: - "A0:1.23,A1:4.56\n" key:value pairs (default) - "1.23,4.56\n" plain CSV - {"A0":1.23,"A1":4.56} JSON +═══════════════════════════════════════════════════════════════ +PROTOCOL SPECIFICATION (v1.1) +═══════════════════════════════════════════════════════════════ -connect() is non-blocking — serial open is done, then a background -thread reads continuously. The 2-second Arduino-reset wait is done -inside the thread so the UI never freezes. +PC → Arduino (commands, newline-terminated): +───────────────────────────────────────────── + W:<pin>:<0|1> Digital write + W:D13:1 → digitalWrite(13, HIGH) + W:D6:0 → digitalWrite(6, LOW) -Swap protocol by subclassing and overriding _parse_line(). + P:<pin>:<0-255> PWM (analogWrite) + P:D9:128 → analogWrite(9, 128) ~50% duty + + V:<pin>:<voltage> Analog voltage out (DAC, 0.0–5.0) + V:DAC0:2.5 → set DAC channel 0 to 2.5 V + + S:<id>:<angle> Servo position (0–180 degrees) + S:SERVO0:90 → center servo 0 + + C:<name>:<value> Named setpoint / parameter + C:SETPOINT:75.0 + C:KP:1.2 + C:MODE:1 + + Q:<name> Request current reading by name (Arduino replies immediately) + Q:TEMP → Arduino sends TEMP:23.45\n + + R:ALL Request full data frame immediately (don't wait for interval) + + X:STOP Emergency stop — disable all outputs + X:RESET Reset all outputs to default state + +Arduino → PC (data, newline-terminated): +───────────────────────────────────────── + <key>:<value>[,<key>:<value>...]\n Continuous stream + A0:3.142,A1:0.015,D2:1,TEMP:23.4\n + + ACK:<command>\n Acknowledgement after command executed + ACK:W:D13:1\n + ACK:C:SETPOINT:75.0\n + + ERR:<message>\n Error response + ERR:Unknown command\n + +═══════════════════════════════════════════════════════════════ +ARDUINO FIRMWARE (copy into Arduino IDE) +═══════════════════════════════════════════════════════════════ + +See ARDUINO_FIRMWARE string at the bottom of this file. +Upload it to your board, set baud to 115200. """ import math import random +import json import threading import time -from typing import Dict, List, Optional, Tuple +from typing import Any, Callable, Dict, List, Optional, Tuple try: @@ -37,27 +78,32 @@ class ArduinoLayer: def __init__( self, - port: str = "COM3", - baud: int = 115200, - timeout: float = 1.0, - analog_pins: List[str] = None, - digital_pins: List[str] = None, - simulate: bool = True, + port: str = "COM3", + baud: int = 115200, + timeout: float = 1.0, + analog_pins: List[str] = None, + digital_pins: List[str] = None, + simulate: bool = True, ): self.port = port self.baud = baud self.timeout = timeout self.analog_pins = analog_pins or self.DEFAULT_ANALOG_PINS self.digital_pins = digital_pins or [] - self.simulate = simulate # stored exactly as given — no override + self.simulate = simulate + + self._ser: Optional[object] = None + self._cache: Dict[str, float] = {} + self._ack_cache: Dict[str, str] = {} # last ACK per command type + self._lock = threading.Lock() + self._running = False + self._thread: Optional[threading.Thread] = None + self._t0 = 0.0 + self._last_error = "" - self._ser: Optional[object] = None - self._cache: Dict[str, float] = {} - self._lock = threading.Lock() - self._running = False - self._thread: Optional[threading.Thread] = None - self._t0 = 0.0 - self._last_error: str = "" # surfaced to UI for diagnosis + # Callbacks: registered by higher layers to receive ACK/ERR/data + self._ack_callbacks: List[Callable] = [] # fn(command: str) + self._err_callbacks: List[Callable] = [] # fn(message: str) self._sim_params = { pin: { @@ -69,18 +115,12 @@ class ArduinoLayer: } for i, pin in enumerate(self.analog_pins) } + # Simulated output state + self._sim_outputs: Dict[str, Any] = {} # ── Lifecycle ───────────────────────────────────────────────────────── def connect(self) -> bool: - """ - Open the connection. - Simulation: starts waveform thread immediately → returns True. - Hardware: opens serial port synchronously (fast), then starts - read thread which handles the Arduino reset wait. - Returns True on success, False on failure. - Check self.last_error for the reason on failure. - """ self._t0 = time.time() self._last_error = "" @@ -102,7 +142,7 @@ class ArduinoLayer: self._ser.port = self.port self._ser.baudrate = self.baud self._ser.timeout = self.timeout - self._ser.open() # raises SerialException on failure + self._ser.open() except Exception as e: self._last_error = str(e) print(f"[ArduinoLayer] connect() failed on {self.port}: {e}") @@ -140,40 +180,135 @@ class ArduinoLayer: def last_error(self) -> str: return self._last_error - # ── Read / Write ────────────────────────────────────────────────────── + # ── READ ────────────────────────────────────────────────────────────── def read(self) -> Dict[str, float]: + """Return latest cached values for all channels.""" with self._lock: return dict(self._cache) + # ── WRITE COMMANDS ──────────────────────────────────────────────────── + + def digital_write(self, pin: str, value: int) -> bool: + """ + Set a digital pin HIGH or LOW. + pin: "D13", "D6", etc. + value: 1 = HIGH, 0 = LOW + Sends: W:D13:1\n + """ + return self._send(f"W:{pin}:{int(bool(value))}") + + def pwm_write(self, pin: str, duty: int) -> bool: + """ + Set PWM duty cycle on a pin. + pin: "D9", "D10", "D11" (PWM-capable pins) + duty: 0–255 (0 = off, 255 = full on) + Sends: P:D9:128\n + """ + duty = max(0, min(255, int(duty))) + return self._send(f"P:{pin}:{duty}") + + def pwm_write_pct(self, pin: str, pct: float) -> bool: + """Convenience: PWM by percentage 0.0–100.0.""" + return self.pwm_write(pin, int(pct / 100.0 * 255)) + + def analog_voltage(self, pin: str, voltage: float) -> bool: + """ + Set analog output voltage (requires DAC, e.g. Arduino Due/Zero/MKR). + pin: "DAC0", "DAC1" + voltage: 0.0–5.0 V (or 0.0–3.3 V depending on board) + Sends: V:DAC0:2.500\n + """ + return self._send(f"V:{pin}:{voltage:.3f}") + + def servo_write(self, servo_id: str, angle: int) -> bool: + """ + Set servo position. + servo_id: "SERVO0", "SERVO1", or "S0" etc. + angle: 0–180 degrees + Sends: S:SERVO0:90\n + """ + angle = max(0, min(180, int(angle))) + return self._send(f"S:{servo_id}:{angle}") + + def set_parameter(self, name: str, value: Any) -> bool: + """ + Send a named setpoint or configuration parameter. + name: any string your firmware recognises, e.g. "SETPOINT", "KP", "MODE" + value: numeric or string + Sends: C:SETPOINT:75.000\n + """ + if isinstance(value, float): + return self._send(f"C:{name}:{value:.4f}") + return self._send(f"C:{name}:{value}") + + def request_value(self, name: str) -> bool: + """ + Ask the Arduino to send the current value of a named sensor immediately. + Sends: Q:TEMP\n + Arduino replies: TEMP:23.45\n (parsed into cache automatically) + """ + return self._send(f"Q:{name}") + + def request_frame(self) -> bool: + """Ask the Arduino to send a full data frame immediately.""" + return self._send("R:ALL") + + def emergency_stop(self) -> bool: + """Disable all outputs immediately. Sends: X:STOP\n""" + return self._send("X:STOP") + + def reset_outputs(self) -> bool: + """Reset all outputs to default state. Sends: X:RESET\n""" + return self._send("X:RESET") + + # ── Legacy compat ───────────────────────────────────────────────────── + def write(self, pin: str, value: int) -> bool: + """ + Backward-compatible write — calls digital_write(). + Pin names starting with 'D' → digital. + Pin names starting with 'P' → PWM. + """ + if pin.upper().startswith("P"): + return self.pwm_write(pin, value) + return self.digital_write(pin, value) + + # ── Callbacks ───────────────────────────────────────────────────────── + + def on_ack(self, callback: Callable[[str], None]): + """Register callback called when Arduino sends ACK:<command>.""" + self._ack_callbacks.append(callback) + + def on_error(self, callback: Callable[[str], None]): + """Register callback called when Arduino sends ERR:<message>.""" + self._err_callbacks.append(callback) + + # ── Internal send ────────────────────────────────────────────────────── + + def _send(self, command: str) -> bool: + """Send a command string + newline to the Arduino.""" if self.simulate: + self._sim_handle_command(command) return True - if self._ser and self._ser.is_open: - try: - self._ser.write(f"W:{pin}:{int(bool(value))}\n".encode()) - return True - except Exception as e: - print(f"[ArduinoLayer] write() failed: {e}") - return False + if not (self._ser and self._ser.is_open): + return False + try: + self._ser.write(f"{command}\n".encode("utf-8")) + return True + except Exception as e: + print(f"[ArduinoLayer] send '{command}' failed: {e}") + return False # ── Background threads ──────────────────────────────────────────────── def _read_loop(self): - """ - Hardware read thread. - Waits 2 s for Arduino reset, then reads lines continuously. - All errors are caught so the thread never crashes silently. - """ - # Wait for Arduino to reset after serial open + """Hardware read thread — waits for Arduino reset then reads continuously.""" deadline = time.time() + 2.5 while time.time() < deadline and self._running: time.sleep(0.05) - if not self._running: return - - # Flush any garbage from reset try: self._ser.reset_input_buffer() except Exception: @@ -188,23 +323,20 @@ class ArduinoLayer: if not raw: continue line = raw.decode("utf-8", errors="replace").strip() - if not line: - continue - parsed = self._parse_line(line) - if parsed: - with self._lock: - self._cache.update(parsed) - consecutive_errors = 0 + if line: + self._handle_line(line) + consecutive_errors = 0 except Exception as e: consecutive_errors += 1 if consecutive_errors <= 3: print(f"[ArduinoLayer] read error: {e}") if consecutive_errors > 20: - print(f"[ArduinoLayer] too many errors, stopping read loop") + print(f"[ArduinoLayer] too many errors, stopping") break time.sleep(0.1) def _sim_loop(self): + """Simulation thread — generates waveforms for analog pins.""" while self._running: t = time.time() - self._t0 update = {} @@ -217,55 +349,150 @@ class ArduinoLayer: self._cache.update(update) time.sleep(0.05) - # ── Protocol ───────────────────────────────────────────────────────── + def _sim_handle_command(self, command: str): + """Simulate Arduino response to a command.""" + with self._lock: + parts = command.split(":") + if len(parts) >= 3: + cmd_type = parts[0] + target = parts[1] + value = ":".join(parts[2:]) + self._sim_outputs[target] = value + # Echo back as cached value for digital/pwm outputs + try: + self._cache[target] = float(value) + except ValueError: + pass + elif parts[0] == "X": + self._sim_outputs.clear() + # Fire ACK callbacks + for cb in self._ack_callbacks: + try: + cb(command) + except Exception: + pass + + # ── Line parser ──────────────────────────────────────────────────────── + + def _handle_line(self, line: str): + """Route a line from the Arduino to cache, ACK, or ERR handlers.""" + # ACK response + if line.startswith("ACK:"): + cmd = line[4:] + with self._lock: + self._ack_cache[cmd.split(":")[0]] = cmd + for cb in self._ack_callbacks: + try: + cb(cmd) + except Exception: + pass + return + + # ERR response + if line.startswith("ERR:"): + msg = line[4:] + print(f"[Arduino ERR] {msg}") + for cb in self._err_callbacks: + try: + cb(msg) + except Exception: + pass + return + + # Data line — parse into cache + parsed = self._parse_line(line) + if parsed: + with self._lock: + self._cache.update(parsed) def _parse_line(self, line: str) -> Dict[str, float]: """ - Parse common Arduino output formats: - "A0:1.23,A1:4.56" → key:value pairs - "1.23,4.56" → positional CSV mapped to analog_pins - {"A0":1.23} → JSON + Parse Arduino serial output into {key: float}. + + Handles all of these formats transparently: + Standard protocol: A0:3.142,A1:0.015 + With unit suffix: A0:0.04 V,A1:1.23 mA + With line prefix: Analog:8, A0:0.04 V + Plain CSV: 3.142,0.015 + JSON: {"A0":3.142,"A1":0.015} + + Prefixes like "Analog:8" (where value is an integer channel index) + are recognised and skipped so they don't pollute the channel cache. """ line = line.strip() - result: Dict[str, float] = {} + if not line: + return {} + + # Store raw line for diagnostics (keep last 5) + with self._lock: + if not hasattr(self, '_raw_lines'): + self._raw_lines = [] + self._raw_lines.append(line) + if len(self._raw_lines) > 5: + self._raw_lines.pop(0) # JSON if line.startswith("{"): try: - import json - d = json.loads(line) - return {k: float(v) for k, v in d.items()} + return {k: float(v) for k, v in json.loads(line).items()} except Exception: return {} - # Key:value CSV + result: Dict[str, float] = {} + positional_idx = 0 + for token in line.split(","): token = token.strip() if not token: continue + if ":" in token: - parts = token.split(":", 1) + key_raw, _, val_raw = token.partition(":") + key = key_raw.strip() + val_raw = val_raw.strip() + + # Strip unit suffix — take only the first word (the number) + # "0.04 V" → "0.04", "1.23 mA" → "1.23", "3.14" → "3.14" + numeric_part = val_raw.split()[0] if val_raw else "" + try: - result[parts[0].strip()] = float(parts[1].strip()) - except (ValueError, IndexError): - pass + val = float(numeric_part) + except ValueError: + continue + + # Skip prefix tokens where the "value" is actually a channel + # count or index, not a real measurement. + # Heuristic: key is a generic word (Analog, Digital, Chan, Ch) + # AND value is a small integer that looks like a count. + _SKIP_KEYS = {"analog", "digital", "chan", "channel", "ch", + "sensor", "input", "output", "port", "pin"} + if key.lower() in _SKIP_KEYS and val == int(val) and val < 32: + continue + + result[key] = val + else: - idx = len(result) - if idx < len(self.analog_pins): - try: - result[self.analog_pins[idx]] = float(token) - except ValueError: - pass + # Plain positional value — map to analog_pins in order + try: + val = float(token.split()[0]) # strip any trailing unit + if positional_idx < len(self.analog_pins): + result[self.analog_pins[positional_idx]] = val + positional_idx += 1 + except ValueError: + pass + return result - # ── Utilities ───────────────────────────────────────────────────────── + @property + def raw_lines(self) -> list: + """Last few raw lines received — useful for diagnostics.""" + with self._lock: + return list(getattr(self, "_raw_lines", [])) + + # ── Utilities ────────────────────────────────────────────────────────── @staticmethod def list_ports() -> List[Tuple[str, str]]: - """ - Return list of (device, description) for all detected serial ports. - Returns [] if pyserial is not installed. - """ if not _SERIAL_AVAILABLE: return [] try: @@ -284,3 +511,171 @@ class ArduinoLayer: def __repr__(self): mode = "SIM" if self.simulate else f"HW:{self.port}@{self.baud}" return f"<ArduinoLayer {mode} pins={self.analog_pins}>" + + +# ═══════════════════════════════════════════════════════════════════════════ +# ARDUINO FIRMWARE — upload this to your board +# ═══════════════════════════════════════════════════════════════════════════ + +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 ───────────────────────────────────────────────────────── +const int ANALOG_PINS[] = {A0, A1, A2, A3, A4, A5}; +const int DIGITAL_IN[] = {2, 3, 4}; +const int DIGITAL_OUT[] = {5, 6, 7, 9, 10, 11}; // 9,10,11 are PWM-capable +const int N_ANALOG = 1; // ← set to how many analog pins you use +const int N_DIG_IN = 0; // ← set to how many digital inputs you use +const int N_DIG_OUT = 0; // ← set to how many digital outputs you use +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); + } + + 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 = ""; + // 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 (i > 0) out += ","; + out += "A" + String(i) + ":" + String(v, 3); + } + // Digital inputs (INPUT_PULLUP — invert so pressed=1) + for (int i = 0; i < N_DIG_IN; i++) { + out += ",D" + String(DIGITAL_IN[i]) + ":" + String(!digitalRead(DIGITAL_IN[i])); + } + Serial.println(out); +} +""" |
