summaryrefslogtreecommitdiff
path: root/api_layers/arduino_layer.py
diff options
context:
space:
mode:
authorChristian Kolset <christian.kolset@gmail.com>2026-04-20 16:55:57 -0600
committerChristian Kolset <christian.kolset@gmail.com>2026-04-20 16:55:57 -0600
commitd5acb04b88373d33b038bb59945fb5ab8b4f543b (patch)
tree21c37f2b54258ef20edc1b9b7226327d5f5b1a3a /api_layers/arduino_layer.py
parent425ba78ee1f760978b23a09fe8acbbc9b8b5dae4 (diff)
V8
Diffstat (limited to 'api_layers/arduino_layer.py')
-rw-r--r--api_layers/arduino_layer.py337
1 files changed, 164 insertions, 173 deletions
diff --git a/api_layers/arduino_layer.py b/api_layers/arduino_layer.py
index dea2783..f2b8241 100644
--- a/api_layers/arduino_layer.py
+++ b/api_layers/arduino_layer.py
@@ -3,81 +3,62 @@ 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()
+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
+
+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.
+
+Swap protocol by subclassing and overriding _parse_line().
"""
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
+ 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,
+ 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 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
+ 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._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
- # Sim waveform params
self._sim_params = {
pin: {
"freq": 0.1 + i * 0.13,
@@ -89,82 +70,141 @@ class ArduinoLayer:
for i, pin in enumerate(self.analog_pins)
}
- # ── Lifecycle ────────────────────────────────────────────────────────
+ # ── 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 = ""
+
if self.simulate:
self._running = True
- self._thread = threading.Thread(target=self._sim_loop, daemon=True)
+ self._thread = threading.Thread(
+ target=self._sim_loop, daemon=True, name="ArduinoSim"
+ )
self._thread.start()
return True
+
if not _SERIAL_AVAILABLE:
- print("[ArduinoLayer] pyserial not installed.")
+ self._last_error = "pyserial not installed — run: pip install pyserial"
+ print(f"[ArduinoLayer] {self._last_error}")
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
+ self._ser = serial.Serial()
+ self._ser.port = self.port
+ self._ser.baudrate = self.baud
+ self._ser.timeout = self.timeout
+ self._ser.open() # raises SerialException on failure
except Exception as e:
- print(f"[ArduinoLayer] connect() failed: {e}")
+ self._last_error = str(e)
+ print(f"[ArduinoLayer] connect() failed on {self.port}: {e}")
+ self._ser = None
return False
+ self._running = True
+ self._thread = threading.Thread(
+ target=self._read_loop, daemon=True, name=f"Arduino-{self.port}"
+ )
+ self._thread.start()
+ return True
+
def disconnect(self) -> None:
self._running = False
if self._thread:
self._thread.join(timeout=2.0)
+ self._thread = None
if self._ser:
try:
self._ser.close()
except Exception:
pass
- self._ser = None
+ self._ser = None
+ with self._lock:
+ self._cache.clear()
- # ── Read / Write ────────────────────────────────────────────────────
+ @property
+ def is_connected(self) -> bool:
+ if self.simulate:
+ return self._running
+ return self._ser is not None and self._ser.is_open
+
+ @property
+ def last_error(self) -> str:
+ return self._last_error
+
+ # ── 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())
+ 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
- # ── Background threads ───────────────────────────────────────────────
+ # ── Background threads ────────────────────────────────────────────────
def _read_loop(self):
- """Background thread: reads lines from serial port."""
- while self._running and self._ser and self._ser.is_open:
+ """
+ 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
+ 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:
+ pass
+
+ consecutive_errors = 0
+ while self._running:
try:
- line = self._ser.readline().decode("utf-8", errors="replace").strip()
- if line:
- parsed = self._parse_line(line)
+ if not self._ser or not self._ser.is_open:
+ break
+ raw = self._ser.readline()
+ 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)
- except Exception:
- time.sleep(0.05)
+ 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")
+ break
+ time.sleep(0.1)
def _sim_loop(self):
- """Background thread: generates simulated waveforms."""
while self._running:
t = time.time() - self._t0
update = {}
@@ -172,124 +212,75 @@ class ArduinoLayer:
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 ─────────────────────────────────────────────────
+ # ── Protocol ─────────────────────────────────────────────────────────
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.
+ 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
"""
+ line = line.strip()
result: Dict[str, float] = {}
- # Key:value pairs
+
+ # JSON
+ if line.startswith("{"):
+ try:
+ import json
+ d = json.loads(line)
+ return {k: float(v) for k, v in d.items()}
+ except Exception:
+ return {}
+
+ # Key:value CSV
for token in line.split(","):
token = token.strip()
+ if not token:
+ continue
if ":" in token:
parts = token.split(":", 1)
try:
result[parts[0].strip()] = float(parts[1].strip())
- except ValueError:
+ except (ValueError, IndexError):
pass
else:
- # plain CSV fallback
- try:
- idx = len(result)
- if idx < len(self.analog_pins):
+ idx = len(result)
+ if idx < len(self.analog_pins):
+ try:
result[self.analog_pins[idx]] = float(token)
- except ValueError:
- pass
+ except ValueError:
+ pass
return result
- def _build_write_cmd(self, pin: str, value: int) -> str:
- return f"W:{pin}:{int(bool(value))}\n"
-
- # ── Utilities ────────────────────────────────────────────────────────
+ # ── Utilities ─────────────────────────────────────────────────────────
@staticmethod
- def list_ports() -> List[str]:
- """Return available serial port names."""
+ 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 []
- return [p.device for p in serial.tools.list_ports.comports()]
+ try:
+ return [
+ (p.device, p.description or "")
+ for p in serial.tools.list_ports.comports()
+ ]
+ except Exception as e:
+ print(f"[ArduinoLayer] list_ports() error: {e}")
+ return []
- @property
- def is_simulated(self) -> bool:
- return self.simulate
+ @staticmethod
+ def is_pyserial_available() -> bool:
+ return _SERIAL_AVAILABLE
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);
- }
-}
-*/
-"""