262 lines
8.4 KiB
Rust
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());
|
|
}
|
|
}
|