summaryrefslogtreecommitdiff
path: root/api_layers
diff options
context:
space:
mode:
Diffstat (limited to 'api_layers')
-rw-r--r--api_layers/__pycache__/__init__.cpython-312.pycbin0 -> 322 bytes
-rw-r--r--api_layers/__pycache__/__init__.cpython-314.pycbin327 -> 0 bytes
-rw-r--r--api_layers/__pycache__/arduino_layer.cpython-312.pycbin0 -> 30786 bytes
-rw-r--r--api_layers/__pycache__/arduino_layer.cpython-314.pycbin15422 -> 0 bytes
-rw-r--r--api_layers/__pycache__/nidaqmx_layer.cpython-312.pycbin0 -> 8512 bytes
-rw-r--r--api_layers/__pycache__/nidaqmx_layer.cpython-314.pycbin10254 -> 0 bytes
-rw-r--r--api_layers/arduino_layer.py561
7 files changed, 478 insertions, 83 deletions
diff --git a/api_layers/__pycache__/__init__.cpython-312.pyc b/api_layers/__pycache__/__init__.cpython-312.pyc
new file mode 100644
index 0000000..8aa354b
--- /dev/null
+++ b/api_layers/__pycache__/__init__.cpython-312.pyc
Binary files differ
diff --git a/api_layers/__pycache__/__init__.cpython-314.pyc b/api_layers/__pycache__/__init__.cpython-314.pyc
deleted file mode 100644
index 01ed696..0000000
--- a/api_layers/__pycache__/__init__.cpython-314.pyc
+++ /dev/null
Binary files differ
diff --git a/api_layers/__pycache__/arduino_layer.cpython-312.pyc b/api_layers/__pycache__/arduino_layer.cpython-312.pyc
new file mode 100644
index 0000000..f6c3ff9
--- /dev/null
+++ b/api_layers/__pycache__/arduino_layer.cpython-312.pyc
Binary files differ
diff --git a/api_layers/__pycache__/arduino_layer.cpython-314.pyc b/api_layers/__pycache__/arduino_layer.cpython-314.pyc
deleted file mode 100644
index c1b7e5b..0000000
--- a/api_layers/__pycache__/arduino_layer.cpython-314.pyc
+++ /dev/null
Binary files differ
diff --git a/api_layers/__pycache__/nidaqmx_layer.cpython-312.pyc b/api_layers/__pycache__/nidaqmx_layer.cpython-312.pyc
new file mode 100644
index 0000000..de2c034
--- /dev/null
+++ b/api_layers/__pycache__/nidaqmx_layer.cpython-312.pyc
Binary files differ
diff --git a/api_layers/__pycache__/nidaqmx_layer.cpython-314.pyc b/api_layers/__pycache__/nidaqmx_layer.cpython-314.pyc
deleted file mode 100644
index e028b5e..0000000
--- a/api_layers/__pycache__/nidaqmx_layer.cpython-314.pyc
+++ /dev/null
Binary files differ
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);
+}
+"""