//! 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 { 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()); } }