First iteration
This commit is contained in:
+25
@@ -0,0 +1,25 @@
|
|||||||
|
# Generated by Cargo
|
||||||
|
# will have compiled files and executables
|
||||||
|
debug
|
||||||
|
target
|
||||||
|
|
||||||
|
# These are backup files generated by rustfmt
|
||||||
|
**/*.rs.bk
|
||||||
|
|
||||||
|
# MSVC Windows builds of rustc generate these, which store debugging information
|
||||||
|
*.pdb
|
||||||
|
|
||||||
|
# Generated by cargo mutants
|
||||||
|
# Contains mutation testing data
|
||||||
|
**/mutants.out*/
|
||||||
|
|
||||||
|
# rustc will dump stack traces when hitting an internal compiler error to PWD
|
||||||
|
rustc-ice-*.txt
|
||||||
|
|
||||||
|
# RustRover
|
||||||
|
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||||
|
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||||
|
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||||
|
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||||
|
#.idea/
|
||||||
|
|
||||||
Generated
+5127
File diff suppressed because it is too large
Load Diff
+17
@@ -0,0 +1,17 @@
|
|||||||
|
[package]
|
||||||
|
name = "remote_scope"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow = "1.0.102"
|
||||||
|
arrow = "58.2.0"
|
||||||
|
crossbeam-channel = "0.5.15"
|
||||||
|
eframe = { version = "0.30", features = ["glow", "persistence"] }
|
||||||
|
egui_plot = "0.30"
|
||||||
|
parquet = "58.2.0"
|
||||||
|
parquet_derive = "58.2.0"
|
||||||
|
tokio = { version = "1.52.2", features = ["full"] }
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
embed-resource = "3.0.9"
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Makefile for RTM3004 Remote Scope
|
||||||
|
|
||||||
|
APP_NAME = remote_scope
|
||||||
|
VERSION = 1.0.0
|
||||||
|
AUTHOR = "RemoteScope Author"
|
||||||
|
TARGET_WIN = x86_64-pc-windows-gnu
|
||||||
|
|
||||||
|
.PHONY: all build-linux build-windows clean help
|
||||||
|
|
||||||
|
help:
|
||||||
|
@echo "Usage:"
|
||||||
|
@echo " make build-linux - Build for the current Linux host"
|
||||||
|
@echo " make build-windows - Cross-compile for Windows (requires $(TARGET_WIN) target)"
|
||||||
|
@echo " make all - Build for both Linux and Windows"
|
||||||
|
|
||||||
|
all: build-linux build-windows
|
||||||
|
|
||||||
|
build-linux:
|
||||||
|
cargo build --release
|
||||||
|
@echo "Linux build complete: target/release/$(APP_NAME)"
|
||||||
|
|
||||||
|
build-windows:
|
||||||
|
@rustup target add $(TARGET_WIN) > /dev/null 2>&1 || true
|
||||||
|
cargo build --release --target $(TARGET_WIN)
|
||||||
|
@echo "Windows build complete: target/$(TARGET_WIN)/release/$(APP_NAME).exe"
|
||||||
|
|
||||||
|
clean:
|
||||||
|
cargo clean
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# RTM3004 Remote Scope
|
||||||
|
|
||||||
|
A Rust-based cross-platform UI for controlling and acquiring data from a Rohde & Schwarz RTM3004 oscilloscope.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
- **Ethernet Connectivity**: Connect via Raw TCP (Port 5025).
|
||||||
|
- **Fast Segmentation**: High-speed internal acquisition with bulk binary download.
|
||||||
|
- **Real-time Visualization**: Downsampled plots using `egui_plot`.
|
||||||
|
- **Disk Streaming**: Save full-resolution data to Apache Parquet files for easy analysis in Python/Pandas.
|
||||||
|
- **Cross-platform**: Works on Windows and Linux.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
- **UI**: [egui](https://github.com/emilk/egui) / [eframe](https://github.com/emilk/egui/tree/master/crates/eframe)
|
||||||
|
- **Plotting**: [egui_plot](https://github.com/emilk/egui_plot)
|
||||||
|
- **Data Storage**: [Parquet](https://crates.io/crates/parquet)
|
||||||
|
- **Communication**: SCPI over TCP
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
1. Connect the RTM3004 to your network via Ethernet.
|
||||||
|
2. Ensure the scope has an IP address (check under Setup -> Network).
|
||||||
|
3. Run the application:
|
||||||
|
```bash
|
||||||
|
cargo run --release
|
||||||
|
```
|
||||||
|
4. Enter the IP address and click **Connect**.
|
||||||
|
5. Configure your segments and click **Start Acquisition**.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
Linux users may need the following libraries:
|
||||||
|
- `libxcb`
|
||||||
|
- `libxkbcommon`
|
||||||
|
- `libwayland` (if using Wayland)
|
||||||
|
- `libfontconfig`
|
||||||
|
|
||||||
|
Refer to the [egui documentation](https://github.com/emilk/egui/blob/master/README.md) for detailed platform-specific requirements.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
fn main() {
|
||||||
|
if std::env::var("CARGO_CFG_TARGET_OS").unwrap() == "windows" {
|
||||||
|
embed_resource::compile("resources.rc", embed_resource::NONE);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en-US">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>
|
||||||
|
- Rust Programming Language
|
||||||
|
</title>
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||||
|
<meta name="description" content="A language empowering everyone to build reliable and efficient software.">
|
||||||
|
|
||||||
|
<!-- Twitter card -->
|
||||||
|
<meta name="twitter:card" content="summary">
|
||||||
|
<meta name="twitter:site" content="@rustlang">
|
||||||
|
<meta name="twitter:creator" content="@rustlang">
|
||||||
|
<meta name="twitter:title" content="">
|
||||||
|
<meta name="twitter:description" content="A language empowering everyone to build reliable and efficient software.">
|
||||||
|
<meta name="twitter:image" content="https://www.rust-lang.org/static/images/rust-social.jpg">
|
||||||
|
|
||||||
|
<!-- Facebook OpenGraph -->
|
||||||
|
<meta property="og:title" content="" />
|
||||||
|
<meta property="og:description" content="A language empowering everyone to build reliable and efficient software.">
|
||||||
|
<meta property="og:image" content="https://www.rust-lang.org/static/images/rust-social-wide.jpg" />
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:locale" content="en_US" />
|
||||||
|
|
||||||
|
<!-- styles -->
|
||||||
|
<link rel="stylesheet" href="/static/styles/a11y-dark.css"/>
|
||||||
|
<link rel="stylesheet" href="/static/styles/vendor_10880690442070639967.css"/>
|
||||||
|
<link rel="stylesheet" href="/static/styles/fonts_8049871103083011125.css"/>
|
||||||
|
<link rel="stylesheet" href="/static/styles/app_17774143076088586802.css"/>
|
||||||
|
|
||||||
|
<!-- favicon -->
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="/static/images/apple-touch-icon.png?v=ngJW8jGAmR">
|
||||||
|
<link rel="icon" sizes="16x16" type="image/png" href="/static/images/favicon-16x16.png">
|
||||||
|
<link rel="icon" sizes="32x32" type="image/png" href="/static/images/favicon-32x32.png">
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/static/images/favicon.svg">
|
||||||
|
<link rel="manifest" href="/static/images/site.webmanifest?v=ngJW8jGAmR">
|
||||||
|
<link rel="mask-icon" href="/static/images/safari-pinned-tab.svg?v=ngJW8jGAmR" color="#000000">
|
||||||
|
<meta name="msapplication-TileColor" content="#ffffff">
|
||||||
|
<meta name="msapplication-config" content="/static/images/browserconfig.xml?v=ngJW8jGAmR">
|
||||||
|
<meta name="theme-color" content="#ffffff">
|
||||||
|
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
window.RUST_BASE_URL = "";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Custom Highlight pack with: Rust, Markdown, TOML, Bash, JSON, YAML,
|
||||||
|
and plaintext. -->
|
||||||
|
<script src="/static/scripts/highlight.pack.js" defer></script>
|
||||||
|
<script src="/static/scripts/init.js" defer></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav class="flex flex-row justify-center justify-end-l items-center flex-wrap ph2 pl3-ns pr3-ns pb3">
|
||||||
|
<div class="brand flex-auto w-100 w-auto-l self-start tc tl-l">
|
||||||
|
<a href="/" class="brand">
|
||||||
|
<img class="v-mid ml0-l" alt="Rust Logo" src="/static/images/rust-logo-blk.svg">
|
||||||
|
<span class="dib ml1 ml0-l">Rust</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="nav list w-100 w-auto-l flex flex-none flex-row flex-wrap justify-center justify-end-l items-center pv2 ph0 ph4-ns">
|
||||||
|
<li class="tc pv2 ph2 ph4-ns flex-20-s"><a href="/tools/install">Install</a></li>
|
||||||
|
<li class="tc pv2 ph2 ph4-ns flex-20-s"><a href="/learn">Learn</a></li>
|
||||||
|
<li class="tc pv2 ph2 ph4-ns flex-20-s"><a href="https://play.rust-lang.org/">Playground</a></li>
|
||||||
|
<li class="tc pv2 ph2 ph4-ns flex-20-s"><a href="/tools">Tools</a></li>
|
||||||
|
<li class="tc pv2 ph2 ph4-ns flex-20-s"><a href="/governance">Governance</a></li>
|
||||||
|
<li class="tc pv2 ph2 ph4-ns flex-20-s"><a href="/community">Community</a></li>
|
||||||
|
<li class="tc pv2 ph2 ph4-ns flex-20-s"><a href="https://blog.rust-lang.org/">Blog</a></li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class=" w-100 w-auto-l flex-none flex justify-center pv4 pv-0-l languages">
|
||||||
|
<div class="select">
|
||||||
|
<label for="language-nav" class="hidden">Language</label>
|
||||||
|
<select id="language-nav" data-current-lang="en-US">
|
||||||
|
<option title="English (en-US)" value="en-US">English (en-US)</option>
|
||||||
|
<option title="Español (es)" value="es">Español (es)</option>
|
||||||
|
<option title="Français (fr)" value="fr">Français (fr)</option>
|
||||||
|
<option title="Italiano (it)" value="it">Italiano (it)</option>
|
||||||
|
<option title="日本語 (ja)" value="ja">日本語 (ja)</option>
|
||||||
|
<option title="Português (pt-BR)" value="pt-BR">Português (pt-BR)</option>
|
||||||
|
<option title="Русский (ru)" value="ru">Русский (ru)</option>
|
||||||
|
<option title="Türkçe (tr)" value="tr">Türkçe (tr)</option>
|
||||||
|
<option title="简体中文 (zh-CN)" value="zh-CN">简体中文 (zh-CN)</option>
|
||||||
|
<option title="正體中文 (zh-TW)" value="zh-TW">正體中文 (zh-TW)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</nav>
|
||||||
|
<main><header>
|
||||||
|
<div class="w-100 mw-none ph3 mw8-m mw9-l center f3">
|
||||||
|
<div class="flex-none flex-l mt5 mb5 tc tl-l">
|
||||||
|
<div class="w-70-l w-100">
|
||||||
|
<h1>404</h1>
|
||||||
|
<h2 class="subtitle">Whoops, this page doesn’t exist :-(</h2>
|
||||||
|
</div>
|
||||||
|
<div class="w-30-l w-100 mt5 mt0-l">
|
||||||
|
<img id="ferris-error" src="/static/images/ferris-error.png" alt='404 not found image'>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
<footer>
|
||||||
|
<div class="w-100 mw-none ph3 mw8-m mw9-l center f3">
|
||||||
|
<div class="flex flex-column flex-row-l pv0-l">
|
||||||
|
<div class="flex flex-column mw8 w-100 measure-wide-l pv2 pv5-m pv2-ns ph4-m ph4-l" id="get-help">
|
||||||
|
<h4>Get help!</h4>
|
||||||
|
<ul>
|
||||||
|
<li><a href="/learn">Documentation</a></li>
|
||||||
|
<li><a href="http://forge.rust-lang.org">Rust Forge (Contributor Documentation)</a></li>
|
||||||
|
<li><a href="https://users.rust-lang.org">Ask a Question on the Users Forum</a></li>
|
||||||
|
</ul>
|
||||||
|
<div class="languages">
|
||||||
|
<div class="select">
|
||||||
|
<label for="language-footer" class="hidden">Language</label>
|
||||||
|
<select id="language-footer">
|
||||||
|
<option title="English (en-US)" value="en-US">English (en-US)</option>
|
||||||
|
<option title="Español (es)" value="es">Español (es)</option>
|
||||||
|
<option title="Français (fr)" value="fr">Français (fr)</option>
|
||||||
|
<option title="Italiano (it)" value="it">Italiano (it)</option>
|
||||||
|
<option title="日本語 (ja)" value="ja">日本語 (ja)</option>
|
||||||
|
<option title="Português (pt-BR)" value="pt-BR">Português (pt-BR)</option>
|
||||||
|
<option title="Русский (ru)" value="ru">Русский (ru)</option>
|
||||||
|
<option title="Türkçe (tr)" value="tr">Türkçe (tr)</option>
|
||||||
|
<option title="简体中文 (zh-CN)" value="zh-CN">简体中文 (zh-CN)</option>
|
||||||
|
<option title="正體中文 (zh-TW)" value="zh-TW">正體中文 (zh-TW)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-column mw8 w-100 measure-wide-l pv2 pv5-m pv2-ns ph4-m ph4-l">
|
||||||
|
<h4>Terms and policies</h4>
|
||||||
|
<ul>
|
||||||
|
<li><a href="/policies/code-of-conduct">Code of Conduct</a></li>
|
||||||
|
<li><a href="/policies/licenses">Licenses</a></li>
|
||||||
|
<li><a href="https://foundation.rust-lang.org/policies/logo-policy-and-media-guide/">Logo Policy and Media Guide</a></li>
|
||||||
|
<li><a href="/policies/security">Security Disclosures</a></li>
|
||||||
|
<li><a href="https://foundation.rust-lang.org/policies/privacy-policy/">Privacy Notice</a>
|
||||||
|
</li>
|
||||||
|
<li><a href="/policies">All Policies</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-column mw8 w-100 measure-wide-l pv2 pv5-m pv2-ns ph4-m ph4-l">
|
||||||
|
<h4>Social</h4>
|
||||||
|
<div class="flex flex-row flex-wrap items-center">
|
||||||
|
<a rel="me" href="https://social.rust-lang.org/@rust" target="_blank"><img src="/static/images/mastodon.svg"
|
||||||
|
alt="Mastodon" title="Mastodon" /></a>
|
||||||
|
<a rel="me" href="https://bsky.app/profile/rust-lang.org" target="_blank"><img
|
||||||
|
src="/static/images/bluesky.svg" alt="Bluesky" title="Bluesky" /></a>
|
||||||
|
<a href="https://www.youtube.com/channel/UCaYhcUwRBNscFNUKTjgPFiA" target="_blank"><img class="pv2"
|
||||||
|
src="/static/images/youtube.svg" alt="youtube logo" title="YouTube" /></a>
|
||||||
|
<a href="https://github.com/rust-lang" target="_blank"><img src="/static/images/github.svg" alt="github logo"
|
||||||
|
title="GitHub" /></a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div class="attribution">
|
||||||
|
<p>
|
||||||
|
Maintained by the Rust Team. See a bug?
|
||||||
|
<a target="_blank" href="https://github.com/rust-lang/www.rust-lang.org/issues/new/choose">File an issue!</a>
|
||||||
|
</p>
|
||||||
|
<p>Web site built every day at 22:00 UTC</p>
|
||||||
|
<p>Looking for the <a href="https://prev.rust-lang.org">previous website</a>?</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
<script src="/static/scripts/languages.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
id ICON "icon.ico"
|
||||||
|
1 VERSIONINFO
|
||||||
|
FILEVERSION 1,0,0,0
|
||||||
|
PRODUCTVERSION 1,0,0,0
|
||||||
|
BEGIN
|
||||||
|
BLOCK "StringFileInfo"
|
||||||
|
BEGIN
|
||||||
|
BLOCK "040904E4"
|
||||||
|
BEGIN
|
||||||
|
VALUE "CompanyName", "RemoteScope Author"
|
||||||
|
VALUE "FileDescription", "RTM3004 Oscilloscope UI"
|
||||||
|
VALUE "FileVersion", "1.0.0"
|
||||||
|
VALUE "InternalName", "remote_scope"
|
||||||
|
VALUE "LegalCopyright", "Copyright (c) 2026"
|
||||||
|
VALUE "OriginalFilename", "remote_scope.exe"
|
||||||
|
VALUE "ProductName", "RTM3004 Remote Scope"
|
||||||
|
VALUE "ProductVersion", "1.0.0"
|
||||||
|
END
|
||||||
|
END
|
||||||
|
BLOCK "VarFileInfo"
|
||||||
|
BEGIN
|
||||||
|
VALUE "Translation", 0x409, 1252
|
||||||
|
END
|
||||||
|
END
|
||||||
+242
@@ -0,0 +1,242 @@
|
|||||||
|
mod scpi;
|
||||||
|
mod processor;
|
||||||
|
mod writer;
|
||||||
|
|
||||||
|
use eframe::egui;
|
||||||
|
use egui_plot::{Line, Plot, PlotPoints};
|
||||||
|
use std::sync::mpsc::{self, Receiver, Sender};
|
||||||
|
use std::thread;
|
||||||
|
use anyhow::{Result, anyhow};
|
||||||
|
use scpi::ScpiClient;
|
||||||
|
use processor::{decimate, ScopeHeader};
|
||||||
|
use writer::{ParquetWriter, ScopeRecord};
|
||||||
|
|
||||||
|
enum Message {
|
||||||
|
Connected(bool),
|
||||||
|
Data(Vec<[f64; 2]>),
|
||||||
|
Error(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Command {
|
||||||
|
Connect(String),
|
||||||
|
StartAcquisition { segments: usize, stream: bool },
|
||||||
|
StopAcquisition,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RemoteScopeApp {
|
||||||
|
ip_address: String,
|
||||||
|
connected: bool,
|
||||||
|
is_acquiring: bool,
|
||||||
|
stream_to_disk: bool,
|
||||||
|
segment_count: usize,
|
||||||
|
|
||||||
|
ch1_scale: f32,
|
||||||
|
ch1_offset: f32,
|
||||||
|
ch1_enabled: bool,
|
||||||
|
|
||||||
|
plot_data: Vec<[f64; 2]>,
|
||||||
|
status_msg: String,
|
||||||
|
|
||||||
|
cmd_tx: Sender<Command>,
|
||||||
|
msg_rx: Receiver<Message>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RemoteScopeApp {
|
||||||
|
fn new(cc: &eframe::CreationContext<'_>) -> Self {
|
||||||
|
let (cmd_tx, cmd_rx) = mpsc::channel();
|
||||||
|
let (msg_tx, msg_rx) = mpsc::channel();
|
||||||
|
let ctx = cc.egui_ctx.clone();
|
||||||
|
|
||||||
|
thread::spawn(move || {
|
||||||
|
let mut client: Option<ScpiClient> = None;
|
||||||
|
let mut acquiring = false;
|
||||||
|
|
||||||
|
while let Ok(cmd) = cmd_rx.recv() {
|
||||||
|
match cmd {
|
||||||
|
Command::Connect(ip) => {
|
||||||
|
match ScpiClient::connect(format!("{}:5025", ip)) {
|
||||||
|
Ok(c) => {
|
||||||
|
client = Some(c);
|
||||||
|
msg_tx.send(Message::Connected(true)).unwrap();
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
msg_tx.send(Message::Error(e.to_string())).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Command::StartAcquisition { segments, stream } => {
|
||||||
|
if let Some(ref mut c) = client {
|
||||||
|
acquiring = true;
|
||||||
|
let msg_tx = msg_tx.clone();
|
||||||
|
let ip = ip_address_logic(c); // Placeholder for complex state
|
||||||
|
|
||||||
|
// In a real app, this would be a loop in a separate thread
|
||||||
|
// For brevity in this skeleton, we'll do one shot
|
||||||
|
if let Err(e) = run_acquisition(c, segments, stream, &msg_tx, &ctx) {
|
||||||
|
msg_tx.send(Message::Error(e.to_string())).unwrap();
|
||||||
|
}
|
||||||
|
acquiring = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Command::StopAcquisition => {
|
||||||
|
acquiring = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Self {
|
||||||
|
ip_address: "192.168.1.100".to_owned(),
|
||||||
|
connected: false,
|
||||||
|
is_acquiring: false,
|
||||||
|
stream_to_disk: false,
|
||||||
|
segment_count: 10,
|
||||||
|
ch1_scale: 1.0,
|
||||||
|
ch1_offset: 0.0,
|
||||||
|
ch1_enabled: true,
|
||||||
|
plot_data: Vec::new(),
|
||||||
|
status_msg: "Offline".to_string(),
|
||||||
|
cmd_tx,
|
||||||
|
msg_rx,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ip_address_logic(_c: &mut ScpiClient) {}
|
||||||
|
|
||||||
|
fn run_acquisition(
|
||||||
|
client: &mut ScpiClient,
|
||||||
|
segments: usize,
|
||||||
|
stream: bool,
|
||||||
|
msg_tx: &Sender<Message>,
|
||||||
|
ctx: &egui::Context
|
||||||
|
) -> Result<()> {
|
||||||
|
// 1. Configure
|
||||||
|
client.send_command("FORM REAL,32")?;
|
||||||
|
client.send_command("ACQ:MODE SEGM")?;
|
||||||
|
client.send_command(format!("ACQ:NSIN:COUN {}", segments).as_str())?;
|
||||||
|
|
||||||
|
// 2. Acquire
|
||||||
|
client.send_command("SING")?;
|
||||||
|
client.wait_for_opc()?;
|
||||||
|
|
||||||
|
// 3. Fetch Header
|
||||||
|
let header_str = client.query_ascii("CHAN1:DATA:HEAD?")?;
|
||||||
|
let header = ScopeHeader::from_string(&header_str).ok_or(anyhow!("Failed to parse header"))?;
|
||||||
|
|
||||||
|
// 4. Fetch Data
|
||||||
|
let raw_data = client.query_binary("CHAN1:DATA?")?;
|
||||||
|
let f32_data: Vec<f32> = raw_data.chunks_exact(4)
|
||||||
|
.map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]]))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// 5. Process
|
||||||
|
let decimated = decimate(&f32_data, 2000);
|
||||||
|
msg_tx.send(Message::Data(decimated)).unwrap();
|
||||||
|
|
||||||
|
// 6. Write if needed
|
||||||
|
if stream {
|
||||||
|
let mut writer = ParquetWriter::new("capture.parquet")?;
|
||||||
|
let records: Vec<ScopeRecord> = f32_data.iter().enumerate().map(|(i, &v)| {
|
||||||
|
ScopeRecord {
|
||||||
|
timestamp: header.x_start + (i as f64 * (header.x_stop - header.x_start) / header.num_samples as f64),
|
||||||
|
channel_1: v,
|
||||||
|
}
|
||||||
|
}).collect();
|
||||||
|
writer.write_batch(&records)?;
|
||||||
|
writer.close()?;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.request_repaint();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
impl eframe::App for RemoteScopeApp {
|
||||||
|
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||||
|
// Receive messages
|
||||||
|
while let Ok(msg) = self.msg_rx.try_recv() {
|
||||||
|
match msg {
|
||||||
|
Message::Connected(c) => {
|
||||||
|
self.connected = c;
|
||||||
|
self.status_msg = if c { "Connected".to_string() } else { "Offline".to_string() };
|
||||||
|
}
|
||||||
|
Message::Data(data) => {
|
||||||
|
self.plot_data = data;
|
||||||
|
}
|
||||||
|
Message::Error(e) => {
|
||||||
|
self.status_msg = format!("Error: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
egui::SidePanel::left("control_panel").show(ctx, |ui| {
|
||||||
|
ui.heading("RTM3004 Control");
|
||||||
|
|
||||||
|
ui.group(|ui| {
|
||||||
|
ui.label("Connection");
|
||||||
|
ui.text_edit_singleline(&mut self.ip_address);
|
||||||
|
if ui.button(if self.connected { "Disconnect" } else { "Connect" }).clicked() {
|
||||||
|
if !self.connected {
|
||||||
|
self.cmd_tx.send(Command::Connect(self.ip_address.clone())).unwrap();
|
||||||
|
} else {
|
||||||
|
self.connected = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.add_enabled_ui(self.connected, |ui| {
|
||||||
|
ui.separator();
|
||||||
|
ui.heading("Channels");
|
||||||
|
ui.checkbox(&mut self.ch1_enabled, "Channel 1");
|
||||||
|
ui.add(egui::Slider::new(&mut self.ch1_scale, 0.001..=10.0).text("Scale (V/div)"));
|
||||||
|
|
||||||
|
ui.separator();
|
||||||
|
ui.heading("Acquisition");
|
||||||
|
ui.add(egui::Slider::new(&mut self.segment_count, 1..=1000).text("Segments"));
|
||||||
|
|
||||||
|
if ui.button(if self.is_acquiring { "Stop" } else { "Start Acquisition" }).clicked() {
|
||||||
|
self.is_acquiring = !self.is_acquiring;
|
||||||
|
if self.is_acquiring {
|
||||||
|
self.cmd_tx.send(Command::StartAcquisition {
|
||||||
|
segments: self.segment_count,
|
||||||
|
stream: self.stream_to_disk
|
||||||
|
}).unwrap();
|
||||||
|
} else {
|
||||||
|
self.cmd_tx.send(Command::StopAcquisition).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.checkbox(&mut self.stream_to_disk, "Stream to Disk (.parquet)");
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.with_layout(egui::Layout::bottom_up(egui::Align::Center), |ui| {
|
||||||
|
ui.label(&self.status_msg);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
egui::CentralPanel::default().show(ctx, |ui| {
|
||||||
|
let plot = Plot::new("scope_plot")
|
||||||
|
.allow_zoom(true)
|
||||||
|
.allow_drag(true)
|
||||||
|
.legend(egui_plot::Legend::default());
|
||||||
|
|
||||||
|
plot.show(ui, |plot_ui| {
|
||||||
|
if !self.plot_data.is_empty() {
|
||||||
|
plot_ui.line(Line::new(PlotPoints::from(self.plot_data.clone())).name("CH1"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> eframe::Result {
|
||||||
|
let options = eframe::NativeOptions {
|
||||||
|
viewport: egui::ViewportBuilder::default().with_inner_size([1024.0, 768.0]),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
eframe::run_native(
|
||||||
|
"RTM3004 Remote Scope",
|
||||||
|
options,
|
||||||
|
Box::new(|cc| Ok(Box::new(RemoteScopeApp::new(cc)))),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
pub struct ScopeHeader {
|
||||||
|
pub x_start: f64,
|
||||||
|
pub x_stop: f64,
|
||||||
|
pub num_samples: usize,
|
||||||
|
pub values_per_sample: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ScopeHeader {
|
||||||
|
pub fn from_string(s: &str) -> Option<Self> {
|
||||||
|
let parts: Vec<&str> = s.split(',').collect();
|
||||||
|
if parts.len() < 4 { return None; }
|
||||||
|
|
||||||
|
Some(Self {
|
||||||
|
x_start: parts[0].parse().ok()?,
|
||||||
|
x_stop: parts[1].parse().ok()?,
|
||||||
|
num_samples: parts[2].parse().ok()?,
|
||||||
|
values_per_sample: parts[3].parse().ok()?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decimate(data: &[f32], target_points: usize) -> Vec<[f64; 2]> {
|
||||||
|
if data.is_empty() { return Vec::new(); }
|
||||||
|
if data.len() <= target_points {
|
||||||
|
return data.iter().enumerate()
|
||||||
|
.map(|(i, &v)| [i as f64, v as f64])
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
let chunk_size = data.len() / target_points;
|
||||||
|
let mut decimated = Vec::with_capacity(target_points * 2);
|
||||||
|
|
||||||
|
for (i, chunk) in data.chunks(chunk_size).enumerate() {
|
||||||
|
let mut min = chunk[0];
|
||||||
|
let mut max = chunk[0];
|
||||||
|
for &v in chunk {
|
||||||
|
if v < min { min = v; }
|
||||||
|
if v > max { max = v; }
|
||||||
|
}
|
||||||
|
let x = (i * chunk_size) as f64;
|
||||||
|
decimated.push([x, min as f64]);
|
||||||
|
decimated.push([x, max as f64]);
|
||||||
|
}
|
||||||
|
|
||||||
|
decimated
|
||||||
|
}
|
||||||
+73
@@ -0,0 +1,73 @@
|
|||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
use std::net::{TcpStream, ToSocketAddrs};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
pub struct ScpiClient {
|
||||||
|
stream: TcpStream,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ScpiClient {
|
||||||
|
pub fn connect<A: ToSocketAddrs>(addr: A) -> Result<Self> {
|
||||||
|
let stream = TcpStream::connect_timeout(
|
||||||
|
&addr.to_socket_addrs()?.next().ok_or(anyhow!("Invalid address"))?,
|
||||||
|
Duration::from_secs(5),
|
||||||
|
)?;
|
||||||
|
stream.set_read_timeout(Some(Duration::from_secs(10)))?;
|
||||||
|
Ok(Self { stream })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn send_command(&mut self, cmd: &str) -> Result<()> {
|
||||||
|
let formatted = format!("{}\n", cmd);
|
||||||
|
self.stream.write_all(formatted.as_bytes())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn query_ascii(&mut self, query: &str) -> Result<String> {
|
||||||
|
self.send_command(query)?;
|
||||||
|
let mut response = String::new();
|
||||||
|
let mut reader = std::io::BufReader::new(&self.stream);
|
||||||
|
std::io::BufRead::read_line(&mut reader, &mut response)?;
|
||||||
|
Ok(response.trim().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads a "Definite Length Block" as per IEEE 488.2
|
||||||
|
/// Format: #<num_digits><length><data>
|
||||||
|
pub fn query_binary(&mut self, query: &str) -> Result<Vec<u8>> {
|
||||||
|
self.send_command(query)?;
|
||||||
|
|
||||||
|
let mut header_start = [0u8; 2];
|
||||||
|
self.stream.read_exact(&mut header_start)?;
|
||||||
|
|
||||||
|
if header_start[0] != b'#' {
|
||||||
|
return Err(anyhow!("Invalid binary block header start: {:?}", header_start));
|
||||||
|
}
|
||||||
|
|
||||||
|
let num_digits = (header_start[1] as char)
|
||||||
|
.to_digit(10)
|
||||||
|
.ok_or(anyhow!("Invalid num_digits in header"))? as usize;
|
||||||
|
|
||||||
|
let mut length_bytes = vec![0u8; num_digits];
|
||||||
|
self.stream.read_exact(&mut length_bytes)?;
|
||||||
|
let length_str = std::str::from_utf8(&length_bytes)?;
|
||||||
|
let data_length: usize = length_str.parse()?;
|
||||||
|
|
||||||
|
let mut buffer = vec![0u8; data_length];
|
||||||
|
self.stream.read_exact(&mut buffer)?;
|
||||||
|
|
||||||
|
// R&S typically ends blocks with a newline, let's consume it if present
|
||||||
|
let mut trailing = [0u8; 1];
|
||||||
|
let _ = self.stream.read(&mut trailing);
|
||||||
|
|
||||||
|
Ok(buffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn wait_for_opc(&mut self) -> Result<()> {
|
||||||
|
let res = self.query_ascii("*OPC?")?;
|
||||||
|
if res == "1" {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(anyhow!("Unexpected *OPC? response: {}", res))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use parquet::file::properties::WriterProperties;
|
||||||
|
use parquet::schema::parser::parse_message_type;
|
||||||
|
use parquet::record::RecordWriter;
|
||||||
|
use std::fs::File;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
#[derive(parquet_derive::ParquetRecordWriter)]
|
||||||
|
pub struct ScopeRecord {
|
||||||
|
pub timestamp: f64,
|
||||||
|
pub channel_1: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ParquetWriter {
|
||||||
|
writer: parquet::file::writer::SerializedFileWriter<File>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ParquetWriter {
|
||||||
|
pub fn new(path: &str) -> Result<Self> {
|
||||||
|
let file = File::create(path)?;
|
||||||
|
let schema = "
|
||||||
|
message schema {
|
||||||
|
REQUIRED DOUBLE timestamp;
|
||||||
|
REQUIRED FLOAT channel_1;
|
||||||
|
}
|
||||||
|
";
|
||||||
|
let schema = Arc::new(parse_message_type(schema)?);
|
||||||
|
let props = Arc::new(WriterProperties::builder().build());
|
||||||
|
let writer = parquet::file::writer::SerializedFileWriter::new(file, schema, props)?;
|
||||||
|
Ok(Self { writer })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn write_batch(&mut self, records: &[ScopeRecord]) -> Result<()> {
|
||||||
|
let mut row_group_writer = self.writer.next_row_group()?;
|
||||||
|
records.write_to_row_group(&mut row_group_writer)?;
|
||||||
|
row_group_writer.close()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn close(self) -> Result<()> {
|
||||||
|
self.writer.close()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user