diff options
| author | Christian Kolset <christian.kolset@gmail.com> | 2026-04-13 15:22:56 -0600 |
|---|---|---|
| committer | Christian Kolset <christian.kolset@gmail.com> | 2026-04-13 15:22:56 -0600 |
| commit | 2ed9d37da7b27d25173535550fb92702225ac14e (patch) | |
| tree | d6ff360a656e577629d3cefdaa1ae02af7255864 /api_layers/arduino_layer.py | |
| parent | 8d6acf3a8ea4b37f86b321dbf430be5be01b1267 (diff) | |
Removed QtPy5 snippet and fixed directories
Diffstat (limited to 'api_layers/arduino_layer.py')
| -rw-r--r-- | api_layers/arduino_layer.py | 295 |
1 files changed, 295 insertions, 0 deletions
diff --git a/api_layers/arduino_layer.py b/api_layers/arduino_layer.py new file mode 100644 index 0000000..dea2783 --- /dev/null +++ b/api_layers/arduino_layer.py @@ -0,0 +1,295 @@ +""" +api_layers/arduino_layer.py + +Arduino serial API layer. + +Protocol (default): Arduino sends newline-terminated CSV strings: + "A0:1.23,A1:4.56,A2:0.12\n" (analog) + "D2:1,D3:0,D4:1\n" (digital) + +The Arduino firmware sketch is provided at the bottom of this file +as a multi-line string for reference / deployment. + +Swap for a different protocol by subclassing ArduinoLayer and +overriding `_parse_line()` and `_build_write_cmd()`. + +Usage: + from api_layers.arduino_layer import ArduinoLayer + layer = ArduinoLayer(port="COM3", baud=115200, simulate=False) + if layer.connect(): + data = layer.read() # {"A0": 3.14, "A1": 1.07, ...} + layer.write("D13", 1) + layer.disconnect() +""" + +import math +import random +import re +import threading +import time +from typing import Dict, List, Optional, Tuple + + +# ── Try pyserial ───────────────────────────────────────────────────────────── +try: + import serial # type: ignore + import serial.tools.list_ports # type: ignore + _SERIAL_AVAILABLE = True +except ImportError: + _SERIAL_AVAILABLE = False + + +class ArduinoLayer: + """ + Serial communication layer for Arduino-based DAQ nodes. + + Supports: + - Auto-detect available serial ports + - Configurable baud rate / timeout + - Background read thread with latest-value cache + - Digital output writes + - Full simulation mode (no hardware required) + """ + + DEFAULT_ANALOG_PINS = ["A0", "A1", "A2", "A3", "A4", "A5"] + DEFAULT_DIGITAL_PINS = ["D2", "D3", "D4", "D5", "D6", "D7"] + + def __init__( + self, + port: str = "COM3", + baud: int = 115200, + timeout: float = 0.5, + 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 or not _SERIAL_AVAILABLE + + 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 + + # Sim waveform params + self._sim_params = { + pin: { + "freq": 0.1 + i * 0.13, + "amp": 2.5, + "offset": 2.5, + "noise": 0.01, + "phase": i * 1.1, + } + for i, pin in enumerate(self.analog_pins) + } + + # ── Lifecycle ──────────────────────────────────────────────────────── + + def connect(self) -> bool: + self._t0 = time.time() + if self.simulate: + self._running = True + self._thread = threading.Thread(target=self._sim_loop, daemon=True) + self._thread.start() + return True + if not _SERIAL_AVAILABLE: + print("[ArduinoLayer] pyserial not installed.") + return False + try: + self._ser = serial.Serial( + port=self.port, baudrate=self.baud, timeout=self.timeout + ) + time.sleep(2.0) # Allow Arduino reset + self._ser.reset_input_buffer() + self._running = True + self._thread = threading.Thread(target=self._read_loop, daemon=True) + self._thread.start() + return True + except Exception as e: + print(f"[ArduinoLayer] connect() failed: {e}") + return False + + def disconnect(self) -> None: + self._running = False + if self._thread: + self._thread.join(timeout=2.0) + if self._ser: + try: + self._ser.close() + except Exception: + pass + self._ser = None + + # ── Read / Write ──────────────────────────────────────────────────── + + def read(self) -> Dict[str, float]: + """Return cached latest values for all pins.""" + with self._lock: + return dict(self._cache) + + def write(self, pin: str, value: int) -> bool: + """ + Send digital write command to Arduino. + Format sent: "W:D13:1\n" + """ + if self.simulate: + return True + if self._ser and self._ser.is_open: + try: + cmd = f"W:{pin}:{int(bool(value))}\n" + self._ser.write(cmd.encode()) + return True + except Exception as e: + print(f"[ArduinoLayer] write() failed: {e}") + return False + + # ── Background threads ─────────────────────────────────────────────── + + def _read_loop(self): + """Background thread: reads lines from serial port.""" + while self._running and self._ser and self._ser.is_open: + try: + line = self._ser.readline().decode("utf-8", errors="replace").strip() + if line: + parsed = self._parse_line(line) + with self._lock: + self._cache.update(parsed) + except Exception: + time.sleep(0.05) + + def _sim_loop(self): + """Background thread: generates simulated waveforms.""" + while self._running: + t = time.time() - self._t0 + update = {} + for pin, p in self._sim_params.items(): + val = p["amp"] * math.sin(2 * math.pi * p["freq"] * t + p["phase"]) + val += p["offset"] + val += random.gauss(0, p["noise"] * p["amp"]) + # Clamp to 0-5V (Arduino ADC range) + update[pin] = round(max(0.0, min(5.0, val)), 4) + with self._lock: + self._cache.update(update) + time.sleep(0.05) + + # ── Protocol helpers ───────────────────────────────────────────────── + + def _parse_line(self, line: str) -> Dict[str, float]: + """ + Parse "A0:1.23,A1:4.56,D2:1" → {"A0": 1.23, "A1": 4.56, "D2": 1.0} + Also handles plain CSV "1.23,4.56,0.12" mapped to analog_pins in order. + """ + result: Dict[str, float] = {} + # Key:value pairs + for token in line.split(","): + token = token.strip() + if ":" in token: + parts = token.split(":", 1) + try: + result[parts[0].strip()] = float(parts[1].strip()) + except ValueError: + pass + else: + # plain CSV fallback + try: + idx = len(result) + if idx < len(self.analog_pins): + result[self.analog_pins[idx]] = float(token) + except ValueError: + pass + return result + + def _build_write_cmd(self, pin: str, value: int) -> str: + return f"W:{pin}:{int(bool(value))}\n" + + # ── Utilities ──────────────────────────────────────────────────────── + + @staticmethod + def list_ports() -> List[str]: + """Return available serial port names.""" + if not _SERIAL_AVAILABLE: + return [] + return [p.device for p in serial.tools.list_ports.comports()] + + @property + def is_simulated(self) -> bool: + return self.simulate + + 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 Reference Sketch +# ════════════════════════════════════════════════════════════════════════════ +ARDUINO_SKETCH = """ +/* + * LabDAQ Arduino Firmware + * Upload this to your Arduino to communicate with the Python DAQ system. + * + * Protocol: + * SEND (Arduino → PC): "A0:3.14,A1:2.71,A2:1.41,D2:1,D3:0\\n" + * RECV (PC → Arduino): "W:D13:1\\n" to set digital outputs + * + * Analog values are converted from 10-bit ADC (0-1023) to 0.0-5.0 V. + */ + +const int ANALOG_PINS[] = {A0, A1, A2, A3, A4, A5}; +const int DIGITAL_IN[] = {2, 3, 4}; +const int DIGITAL_OUT[] = {5, 6, 7, 13}; +const int N_ANALOG = 6; +const int N_DIG_IN = 3; +const int N_DIG_OUT = 4; +const int SEND_INTERVAL = 50; // ms between transmissions + +unsigned long lastSend = 0; + +void setup() { + Serial.begin(115200); + 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); +} + +void loop() { + // ── Handle incoming commands ──────────────────────────── + if (Serial.available()) { + String cmd = Serial.readStringUntil('\\n'); + cmd.trim(); + if (cmd.startsWith("W:")) { + // W:D13:1 → set pin 13 HIGH + int colon1 = cmd.indexOf(':', 2); + int colon2 = cmd.indexOf(':', colon1 + 1); + if (colon1 > 0 && colon2 > 0) { + String pinStr = cmd.substring(colon1 + 1, colon2); + int val = cmd.substring(colon2 + 1).toInt(); + int pin = pinStr.substring(1).toInt(); // strip 'D' + digitalWrite(pin, val ? HIGH : LOW); + } + } + } + + // ── Transmit data ──────────────────────────────────────── + unsigned long now = millis(); + if (now - lastSend >= SEND_INTERVAL) { + lastSend = now; + String out = ""; + for (int i = 0; i < N_ANALOG; i++) { + float v = analogRead(ANALOG_PINS[i]) * (5.0 / 1023.0); + out += "A" + String(i) + ":" + String(v, 3); + if (i < N_ANALOG - 1) out += ","; + } + for (int i = 0; i < N_DIG_IN; i++) { + out += ",D" + String(DIGITAL_IN[i]) + ":" + String(!digitalRead(DIGITAL_IN[i])); + } + Serial.println(out); + } +} +*/ +""" |
