subscriber/subscriber.rs
1//! Diagnostic subscriber that writes live NATS LabJack samples to CSV files.
2//!
3//! The streamer publishes each channel's scans as FlatBuffer `sampler::Scan` messages
4//! on NATS, and the archiver normally turns them into Parquet. This binary is for
5//! inspecting that live stream without running the archiver. It makes a plain
6//! (non-JetStream) core NATS subscription to the live subject wildcard from
7//! [`subjects::live_labjack_stream_subject`], decodes each scan, and appends one CSV
8//! file per asset and channel. It only sees messages published while it is running.
9//!
10//! Each CSV file is named `labjack_<asset>_<channel>.csv` (for example
11//! `labjack_001_ch03.csv`) and has the header `sequence,timestamp,raw_value`. The
12//! asset is taken from the subject for legacy subjects and from `ASSET_NUMBER`
13//! otherwise. Every sample becomes one row: the scan's sequence number, the sample time as RFC 3339
14//! UTC, and the value as published.
15//!
16//! # Configuration
17//!
18//! * `NATS_SUBJECT` - Subject root. Default: `avenabox`.
19//! * `ASSET_NUMBER` - Asset number used in CSV file names for structured subjects,
20//! and as the source when neither `SOURCE_ID` nor `LABJACK_NAME` is set.
21//! Unparseable values fall back to the default. Default: `1`.
22//! * `SITE_ID` - Site ID for the structured subject layout.
23//! * `BOX_ID` - Box ID for the structured subject layout.
24//! * `LABJACK_NAME` - LabJack name, used as the source when `SOURCE_ID` is unset.
25//! * `SOURCE_TYPE` - Read and passed on, but does not affect the subject.
26//! * `SOURCE_ID` - Source ID for the structured subject layout.
27//! * `OUTPUT_DIR` - Directory for the CSV files, created if missing. Default:
28//! `outputs`.
29//! * `NATS_SERVERS` - Comma-separated NATS server URLs. Default:
30//! `nats://127.0.0.1:4222`.
31//! * `NATS_CREDS_FILE` - NATS credentials file. Default: `apt.creds`.
32//!
33//! With the defaults the subscription is the legacy wildcard `avenabox.*.data.*`,
34//! which matches every asset; each file is named after the asset in its subject.
35//! Setting any of `SITE_ID`, `BOX_ID` or `SOURCE_ID`, or `NATS_SUBJECT=avenars`,
36//! switches to `<root>.<site>.<box>.<source>.live.*`.
37
38use async_nats::{self, ConnectOptions};
39use flatbuffers::root;
40use futures_util::stream::StreamExt;
41use std::collections::HashMap;
42use std::error::Error;
43use std::fs::{File, OpenOptions};
44use std::io::Write;
45use std::path::{Path, PathBuf};
46
47mod sample_data_generated {
48 #![allow(dead_code, unused_imports)]
49 include!("data_generated.rs");
50}
51mod nats_config;
52mod subjects;
53use sample_data_generated::sampler;
54
55/// Extracts the final subject token, which is expected to be the channel name.
56///
57/// # Arguments
58///
59/// * `subject` - Subject of a received message.
60///
61/// # Returns
62///
63/// The text after the last `.` (the whole subject if it has no `.`). With
64/// [`str::split`] this is always `Some`, so the `None` case in [`main`] is not reached
65/// in practice.
66///
67/// # Examples
68///
69/// ```text
70/// extract_channel_token("avenars.i69.i69-mu1.i69-lj2.live.ch11") -> Some("ch11")
71/// extract_channel_token("avenabox.1456.data.ch03") -> Some("ch03")
72/// ```
73fn extract_channel_token(subject: &str) -> Option<String> {
74 subject.split('.').last().map(|s| s.to_string())
75}
76
77/// Finds the asset number a message belongs to.
78///
79/// Legacy subjects carry the asset as their own token (`<root>.<asset>.data.chNN`),
80/// and the legacy wildcard receives every asset under the root, so the asset is read
81/// from the subject. Structured subjects (`<root>.<site>.<box>.<source>.live.chNN`)
82/// have no asset token, but the structured wildcard only receives one source, so the
83/// configured asset is used for them.
84///
85/// # Arguments
86///
87/// * `subject` - Subject of a received message.
88/// * `default_asset` - Asset number from `ASSET_NUMBER`.
89///
90/// # Returns
91///
92/// The asset number from a legacy subject, or `default_asset` when the subject is
93/// not legacy or its asset token is not a number.
94///
95/// # Examples
96///
97/// ```text
98/// asset_for_subject("avenabox.1456.data.ch03", 1) -> 1456
99/// asset_for_subject("avenabox.007.data.ch03", 1) -> 7
100/// asset_for_subject("avenars.i69.i69-mu1.i69-lj2.live.ch11", 1) -> 1
101/// ```
102fn asset_for_subject(subject: &str, default_asset: u32) -> u32 {
103 let tokens: Vec<&str> = subject.split('.').collect();
104 match tokens.as_slice() {
105 [.., asset, "data", _channel] => asset.parse().unwrap_or(default_asset),
106 _ => default_asset,
107 }
108}
109
110/// Opens or creates the per-channel CSV file and writes its header if needed.
111///
112/// The file is `<out_dir>/labjack_<asset>_<ch_token>.csv`, with the asset formatted by
113/// [`subjects::pad_asset`], and is opened for appending. The header
114/// `sequence,timestamp,raw_value` is written when the file did not exist or is empty.
115///
116/// # Arguments
117///
118/// * `out_dir` - Output directory; must already exist.
119/// * `asset` - Asset number used in the file name.
120/// * `ch_token` - Channel token from the subject, for example `ch03`.
121///
122/// # Errors
123///
124/// Returns an error if the file cannot be opened or created, its metadata cannot be
125/// read, or the header cannot be written.
126fn open_csv_for_channel(out_dir: &Path, asset: u32, ch_token: &str) -> std::io::Result<File> {
127 let path = out_dir.join(csv_file_name(asset, ch_token));
128 let need_header = !path.exists();
129
130 let mut file = OpenOptions::new().create(true).append(true).open(&path)?;
131 if need_header || file.metadata()?.len() == 0 {
132 writeln!(file, "sequence,timestamp,raw_value")?;
133 }
134 Ok(file)
135}
136
137/// Builds the CSV file name for one asset and channel.
138///
139/// # Arguments
140///
141/// * `asset` - Asset number, formatted by [`subjects::pad_asset`].
142/// * `ch_token` - Channel token from the subject, for example `ch03`.
143///
144/// # Examples
145///
146/// ```text
147/// csv_file_name(1, "ch03") -> "labjack_001_ch03.csv"
148/// csv_file_name(1456, "ch11") -> "labjack_1456_ch11.csv"
149/// ```
150fn csv_file_name(asset: u32, ch_token: &str) -> String {
151 format!("labjack_{}_{}.csv", subjects::pad_asset(asset), ch_token)
152}
153
154/// Converts a Unix nanosecond timestamp into RFC 3339 text in UTC.
155///
156/// # Arguments
157///
158/// * `timestamp_unix_ns` - Time since the Unix epoch, in nanoseconds.
159///
160/// # Returns
161///
162/// The formatted time, for example `2026-09-01T00:00:00.000500+00:00`. Every `i64`
163/// value is representable, so this always returns `Some`.
164fn timestamp_unix_ns_to_rfc3339(timestamp_unix_ns: i64) -> Option<String> {
165 Some(chrono::DateTime::<chrono::Utc>::from_timestamp_nanos(timestamp_unix_ns).to_rfc3339())
166}
167
168#[tokio::main]
169/// Starts the live NATS subscriber and appends decoded samples to CSV.
170///
171/// Reads the configuration listed in the module docs, creates the output directory,
172/// connects to NATS with the credentials file, and subscribes to the live wildcard.
173/// For each message it decodes a `sampler::Scan` and writes one row per value, with
174/// sample `i` timestamped `first_sample_unix_ns + i * sample_interval_ns`. CSV files are
175/// opened on first use per asset and channel token (see [`asset_for_subject`]) and kept
176/// open. The file is flushed after each scan.
177///
178/// Messages that fail FlatBuffer decoding are logged to stderr and skipped. Scans
179/// without a `values` vector are skipped silently. If a timestamp does not fit in
180/// `i64`, the rest of that scan is dropped with a message on stderr.
181///
182/// Runs until the subscription ends.
183///
184/// # Errors
185///
186/// Returns an error if the output directory cannot be created, `NATS_SERVERS` is
187/// invalid, the credentials file cannot be loaded, the connection or subscription
188/// fails, or writing or flushing a CSV file fails.
189///
190/// # Panics
191///
192/// Panics if a per-channel CSV file cannot be opened or its header written.
193async fn main() -> Result<(), Box<dyn Error>> {
194 // match JSON config keys
195 let subject_prefix = std::env::var("NATS_SUBJECT").unwrap_or_else(|_| "avenabox".to_string());
196 let asset_number: u32 = std::env::var("ASSET_NUMBER")
197 .ok()
198 .and_then(|s| s.parse().ok())
199 .unwrap_or(1);
200 let site_id = std::env::var("SITE_ID").ok();
201 let box_id = std::env::var("BOX_ID").ok();
202 let labjack_name = std::env::var("LABJACK_NAME").ok();
203 let source_type = std::env::var("SOURCE_TYPE").ok();
204 let source_id = std::env::var("SOURCE_ID").ok();
205
206 let out_dir_str = std::env::var("OUTPUT_DIR").unwrap_or_else(|_| "outputs".to_string());
207 let out_dir = PathBuf::from(&out_dir_str);
208 if !out_dir.exists() {
209 std::fs::create_dir_all(&out_dir)?;
210 println!("Created output directory: {}", out_dir.display());
211 } else {
212 println!("Using output directory: {}", out_dir.display());
213 }
214
215 // Subscribe to all per-channel subjects for this asset
216 let wildcard = subjects::live_labjack_stream_subject(
217 &subject_prefix,
218 asset_number,
219 site_id.as_deref(),
220 box_id.as_deref(),
221 labjack_name.as_deref(),
222 source_type.as_deref(),
223 source_id.as_deref(),
224 );
225 println!("Subscribing to subject '{}'", wildcard);
226
227 let servers = nats_config::servers_from_env()
228 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?;
229
230 // Connect using creds
231 let creds_path = std::env::var("NATS_CREDS_FILE").unwrap_or_else(|_| "apt.creds".into());
232 let opts = ConnectOptions::with_credentials_file(creds_path)
233 .await
234 .map_err(|e| format!("Failed to load creds: {}", e))?;
235
236 let nc = opts
237 .connect(servers)
238 .await
239 .map_err(|e| format!("NATS connect failed: {}", e))?;
240
241 println!("Connected to NATS with creds, subscribed at '{}'", wildcard);
242
243 let mut sub = nc.subscribe(wildcard.clone()).await?;
244 let mut files: HashMap<(u32, String), File> = HashMap::new();
245
246 while let Some(msg) = sub.next().await {
247 let ch_token = match extract_channel_token(&msg.subject) {
248 Some(tok) => tok,
249 None => {
250 eprintln!("Subject '{}' missing channel token; skipping.", msg.subject);
251 continue;
252 }
253 };
254
255 match root::<sampler::Scan>(&msg.payload) {
256 Ok(scan) => {
257 let values = match scan.values() {
258 Some(values) => values,
259 None => continue,
260 };
261 let sequence = scan.sequence();
262 let first_sample_unix_ns = scan.first_sample_unix_ns();
263 let sample_interval_ns = scan.sample_interval_ns();
264
265 let asset = asset_for_subject(&msg.subject, asset_number);
266 let out_dir_clone = out_dir.clone();
267 let file = files
268 .entry((asset, ch_token.clone()))
269 .or_insert_with(move || {
270 open_csv_for_channel(&out_dir_clone, asset, &ch_token)
271 .expect("failed to open per-channel csv")
272 });
273
274 for (index, value) in values.iter().enumerate() {
275 let timestamp_unix_ns = (first_sample_unix_ns as u128)
276 .saturating_add((sample_interval_ns as u128).saturating_mul(index as u128));
277 let timestamp_unix_ns = match i64::try_from(timestamp_unix_ns) {
278 Ok(ts) => ts,
279 Err(_) => {
280 eprintln!(
281 "timestamp overflow for subject '{}' sequence {} sample {}",
282 msg.subject, sequence, index
283 );
284 break;
285 }
286 };
287 let Some(timestamp) = timestamp_unix_ns_to_rfc3339(timestamp_unix_ns) else {
288 continue;
289 };
290 writeln!(file, "{},{},{}", sequence, timestamp, value)?;
291 }
292 file.flush()?;
293 }
294 Err(e) => {
295 eprintln!(
296 "FlatBuffer decode error ({:?}) for subject '{}'",
297 e, msg.subject
298 );
299 }
300 }
301 }
302
303 Ok(())
304}
305
306#[cfg(test)]
307mod tests {
308 use super::*;
309
310 /// Legacy subjects name files after their own asset; structured ones use the default.
311 #[test]
312 fn file_names_follow_the_asset_in_the_subject() {
313 assert_eq!(asset_for_subject("avenabox.1456.data.ch03", 1), 1456);
314 assert_eq!(asset_for_subject("avenabox.007.data.ch11", 1), 7);
315 assert_eq!(
316 asset_for_subject("avenars.i69.i69-mu1.i69-lj2.live.ch11", 1001),
317 1001
318 );
319 assert_eq!(asset_for_subject("avenabox.abc.data.ch03", 5), 5);
320
321 let a = csv_file_name(asset_for_subject("avenabox.1456.data.ch03", 1), "ch03");
322 let b = csv_file_name(asset_for_subject("avenabox.1457.data.ch03", 1), "ch03");
323 assert_eq!(a, "labjack_1456_ch03.csv");
324 assert_eq!(b, "labjack_1457_ch03.csv");
325 }
326}