flowrs-engine/crates/flowrs-transport/src/redpitaya.rs
flowRS fd958c0547 flowRS engine, cut from flowRS 3451b065
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 19:04:39 -05:00

262 lines
8.4 KiB
Rust

//! Red Pitaya — the SCPI command set for the open-hardware STEMlab boards, over a browser relay.
//!
//! A Red Pitaya exposes its signal generator and oscilloscope over a SCPI server on TCP port
//! 5000. The generic [`scpi`](crate::scpi) framing already speaks that protocol; what a Red
//! Pitaya adds is a *vocabulary* — `SOUR1:FUNC SINE`, `ACQ:TRIG NOW`, `ACQ:SOUR1:DATA?` — and this
//! module builds those command strings and parses the two Red-Pitaya-specific replies (the trigger
//! status and the `{…}` sample block). It holds no I/O, in keeping with the crate.
//!
//! A browser tab cannot open a raw TCP socket to port 5000, so reaching a board from the web goes
//! through a **WebSocket-to-TCP relay**: the tab sends each command line over a WebSocket and the
//! relay forwards it to `:5000`. That relay is a driver; the command *strings* it carries are what
//! this module owns and tests.
//!
//! # Verified, not recalled
//!
//! The command strings (`GEN:RST`, `SOUR{ch}:FUNC`, `SOUR{ch}:FREQ:FIX`, `SOUR{ch}:VOLT`,
//! `OUTPUT{ch}:STATE`, `ACQ:RST`, `ACQ:DEC`, `ACQ:START`, `ACQ:TRIG`, `ACQ:TRIG:STAT?`,
//! `ACQ:SOUR{ch}:GAIN`, `ACQ:SOUR{ch}:DATA?`) and the trigger tokens `NOW`/`CH1_PE` were read this
//! session from Red Pitaya's own SCPI test scripts. The remaining trigger sources and the
//! [`Waveform`] tokens (`SINE`/`SQUARE`/…) are Red Pitaya's documented command-list values.
//! Nothing here has been run against a board — first contact is the validation step.
/// A waveform for the signal generator (`SOUR{ch}:FUNC` argument).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Waveform {
/// Sine.
Sine,
/// Square.
Square,
/// Triangle.
Triangle,
/// Rising sawtooth.
SawUp,
/// Falling sawtooth.
SawDown,
/// Pulse-width-modulated.
Pwm,
/// Constant DC.
Dc,
/// Arbitrary, from a loaded buffer.
Arbitrary,
}
impl Waveform {
/// The SCPI token.
pub fn token(self) -> &'static str {
match self {
Waveform::Sine => "SINE",
Waveform::Square => "SQUARE",
Waveform::Triangle => "TRIANGLE",
Waveform::SawUp => "SAWU",
Waveform::SawDown => "SAWD",
Waveform::Pwm => "PWM",
Waveform::Dc => "DC",
Waveform::Arbitrary => "ARBITRARY",
}
}
}
/// An acquisition trigger source (`ACQ:TRIG` argument). `PE`/`NE` are the positive/negative edge.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TrigSource {
/// Trigger immediately.
Now,
/// Channel 1, positive edge.
Ch1Pe,
/// Channel 1, negative edge.
Ch1Ne,
/// Channel 2, positive edge.
Ch2Pe,
/// Channel 2, negative edge.
Ch2Ne,
/// External, positive edge.
ExtPe,
/// External, negative edge.
ExtNe,
}
impl TrigSource {
/// The SCPI token.
pub fn token(self) -> &'static str {
match self {
TrigSource::Now => "NOW",
TrigSource::Ch1Pe => "CH1_PE",
TrigSource::Ch1Ne => "CH1_NE",
TrigSource::Ch2Pe => "CH2_PE",
TrigSource::Ch2Ne => "CH2_NE",
TrigSource::ExtPe => "EXT_PE",
TrigSource::ExtNe => "EXT_NE",
}
}
}
/// Input gain / voltage range for an acquisition channel (`ACQ:SOUR{ch}:GAIN`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Gain {
/// Low voltage (±1 V range).
Lv,
/// High voltage (±20 V range).
Hv,
}
impl Gain {
/// The SCPI token.
pub fn token(self) -> &'static str {
match self {
Gain::Lv => "LV",
Gain::Hv => "HV",
}
}
}
/// Format a number as SCPI expects it — no trailing `.0` on a whole value.
fn num(v: f64) -> String {
if v.fract() == 0.0 && v.abs() < 1e15 {
format!("{}", v as i64)
} else {
format!("{v}")
}
}
// --- generation -------------------------------------------------------------------------------
/// Reset the signal generator to defaults: `GEN:RST`.
pub fn gen_reset() -> String {
"GEN:RST".to_string()
}
/// Set channel `ch`'s output waveform: `SOUR{ch}:FUNC {waveform}`.
pub fn waveform(ch: u8, w: Waveform) -> String {
format!("SOUR{ch}:FUNC {}", w.token())
}
/// Set channel `ch`'s frequency in Hz: `SOUR{ch}:FREQ:FIX {hz}`.
pub fn frequency(ch: u8, hz: f64) -> String {
format!("SOUR{ch}:FREQ:FIX {}", num(hz))
}
/// Set channel `ch`'s amplitude in volts: `SOUR{ch}:VOLT {v}`.
pub fn amplitude(ch: u8, volts: f64) -> String {
format!("SOUR{ch}:VOLT {}", num(volts))
}
/// Enable or disable channel `ch`'s output: `OUTPUT{ch}:STATE {ON|OFF}`.
pub fn output(ch: u8, on: bool) -> String {
format!("OUTPUT{ch}:STATE {}", if on { "ON" } else { "OFF" })
}
// --- acquisition ------------------------------------------------------------------------------
/// Reset the acquisition subsystem: `ACQ:RST`.
pub fn acq_reset() -> String {
"ACQ:RST".to_string()
}
/// Set the acquisition decimation factor: `ACQ:DEC {n}`.
pub fn decimation(n: u32) -> String {
format!("ACQ:DEC {n}")
}
/// Start acquiring into the buffer: `ACQ:START`.
pub fn acq_start() -> String {
"ACQ:START".to_string()
}
/// Arm the trigger with a source: `ACQ:TRIG {source}`.
pub fn trigger(source: TrigSource) -> String {
format!("ACQ:TRIG {}", source.token())
}
/// Query the trigger state: `ACQ:TRIG:STAT?` (the reply is `TD` when triggered, else `WAIT`).
pub fn trigger_status() -> String {
"ACQ:TRIG:STAT?".to_string()
}
/// Set the trigger level in volts: `ACQ:TRIG:LEV {v}`.
pub fn trigger_level(volts: f64) -> String {
format!("ACQ:TRIG:LEV {}", num(volts))
}
/// Set the input gain (voltage range) of channel `ch`: `ACQ:SOUR{ch}:GAIN {LV|HV}`.
pub fn gain(ch: u8, g: Gain) -> String {
format!("ACQ:SOUR{ch}:GAIN {}", g.token())
}
/// Read the whole sample buffer of channel `ch`: `ACQ:SOUR{ch}:DATA?`.
pub fn read_data(ch: u8) -> String {
format!("ACQ:SOUR{ch}:DATA?")
}
/// Initialise the board: `RP:INIT`.
pub fn init() -> String {
"RP:INIT".to_string()
}
// --- reply parsing ----------------------------------------------------------------------------
/// Whether a `ACQ:TRIG:STAT?` reply reports the board has triggered (`TD`).
pub fn is_triggered(reply: &str) -> bool {
reply.trim() == "TD"
}
/// Parse a `ACQ:SOUR{ch}:DATA?` reply — a brace-wrapped, comma-separated list like
/// `{1.5,-2.0,3.25}` — into samples. Malformed entries are skipped rather than failing the block.
pub fn parse_data(reply: &str) -> Vec<f32> {
reply
.trim()
.trim_start_matches('{')
.trim_end_matches('}')
.split(',')
.filter_map(|s| {
let s = s.trim();
if s.is_empty() { None } else { s.parse().ok() }
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn generation_commands_match_the_documented_strings() {
assert_eq!(gen_reset(), "GEN:RST");
assert_eq!(waveform(1, Waveform::Sine), "SOUR1:FUNC SINE");
assert_eq!(waveform(2, Waveform::Square), "SOUR2:FUNC SQUARE");
assert_eq!(frequency(1, 1000.0), "SOUR1:FREQ:FIX 1000");
assert_eq!(frequency(1, 1234.5), "SOUR1:FREQ:FIX 1234.5");
assert_eq!(amplitude(1, 0.5), "SOUR1:VOLT 0.5");
assert_eq!(output(1, true), "OUTPUT1:STATE ON");
assert_eq!(output(2, false), "OUTPUT2:STATE OFF");
}
#[test]
fn acquisition_commands_match_the_documented_strings() {
assert_eq!(acq_reset(), "ACQ:RST");
assert_eq!(decimation(8), "ACQ:DEC 8");
assert_eq!(acq_start(), "ACQ:START");
assert_eq!(trigger(TrigSource::Now), "ACQ:TRIG NOW");
assert_eq!(trigger(TrigSource::Ch1Pe), "ACQ:TRIG CH1_PE");
assert_eq!(trigger_status(), "ACQ:TRIG:STAT?");
assert_eq!(trigger_level(0.0), "ACQ:TRIG:LEV 0");
assert_eq!(gain(1, Gain::Lv), "ACQ:SOUR1:GAIN LV");
assert_eq!(read_data(1), "ACQ:SOUR1:DATA?");
assert_eq!(init(), "RP:INIT");
}
#[test]
fn the_trigger_status_reply_is_read() {
assert!(is_triggered("TD"));
assert!(is_triggered("TD\r\n"));
assert!(!is_triggered("WAIT"));
}
#[test]
fn a_data_block_parses_into_samples() {
assert_eq!(parse_data("{1.5,-2.0,3.25}"), vec![1.5, -2.0, 3.25]);
// Whitespace and a trailing comma are tolerated.
assert_eq!(parse_data("{ 1.0 , 2.0 , }"), vec![1.0, 2.0]);
assert!(parse_data("{}").is_empty());
}
}