Skip to main content

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}