Skip to main content

streamer/
main.rs

1//! The `streamer` binary: streams LabJack T7 analog input samples into NATS JetStream.
2//!
3//! The streamer is the first stage of the data path: streamer -> local NATS
4//! JetStream -> archiver -> Parquet, with the exporter serving the Parquet files as
5//! CSV over NATS. It reads its active configuration from a local KV bucket
6//! (`avenabox` by default), optionally mirrors that entry from a central NATS
7//! deployment, opens the LabJack over Ethernet, and publishes one FlatBuffer
8//! `Scan` message per channel per LJM read. Structured configurations publish to
9//! `<root>.<site>.<box>.<source>.live.chNN` (for example
10//! `avenars.<site>.<box>.<source>.live.chNN`); legacy configurations publish to
11//! `<root>.<asset>.data.chNN` (see the `subjects` module).
12//!
13//! A configuration change in KV that alters [`SampleConfig`] (channels, rates, identity
14//! and subject fields, `rotate_secs` or `labjack_on_off`) stops the active LabJack
15//! stream and restarts it with the new settings. Calibrations, filters and label fields
16//! are not part of it, so changing them leaves the stream running. Setting
17//! `labjack_on_off` to `false` stops sampling until the configuration changes again.
18//!
19//! # Configuration
20//!
21//! * `NATS_SERVERS` - Comma-separated local NATS server URLs. Default:
22//!   `nats://127.0.0.1:4222`.
23//! * `NATS_CREDS_FILE` - Credentials file for the local NATS connection. Default:
24//!   `apt.creds`.
25//! * `JS_DOMAIN` - JetStream domain for the local connection. Unset or empty uses
26//!   the default domain.
27//! * `CFG_BUCKET` - Local KV bucket that holds the streamer configuration. Also the
28//!   fallback for `CENTRAL_CFG_BUCKET`. Default: `avenabox`.
29//! * `CFG_KEY` - Key of the configuration entry in that bucket. Also the fallback
30//!   for `CENTRAL_CFG_KEY`. Default: `unknown-site.macbook.unknown-source.config`.
31//! * `CENTRAL_NATS_SERVERS` - Comma-separated central NATS server URLs. When neither
32//!   this nor `CFG_NATS_SERVERS` is set, central mirroring is off.
33//! * `CFG_NATS_SERVERS` - Fallback for `CENTRAL_NATS_SERVERS`.
34//! * `CENTRAL_NATS_CREDS_FILE` - Credentials file for the central connection.
35//!   Default: the value of `NATS_CREDS_FILE`, or `apt.creds`.
36//! * `CENTRAL_CFG_BUCKET` - Central KV bucket to mirror from. Default: `CFG_BUCKET`,
37//!   or `avenabox`.
38//! * `CENTRAL_CFG_KEY` - Central KV key to mirror from. Default: `CFG_KEY`, or
39//!   `unknown-site.macbook.unknown-source.config`.
40//! * `CENTRAL_JS_DOMAIN` - JetStream domain on the central server. Falls back to
41//!   `CFG_JS_DOMAIN`; unset uses the default domain.
42//! * `STREAM_MAX_BYTES` - Byte limit for the JetStream stream, greater than zero.
43//!   Unset means unlimited (`-1`).
44//! * `STREAMER_MAX_LABJACK_FAILURES` - Consecutive sampler failures before the
45//!   process exits. Values that do not parse or are zero use the default. Default:
46//!   `5`.
47//! * `STREAMER_LABJACK_RETRY_DELAY_SECS` - Delay before restarting the sampler after
48//!   a failure, in seconds. Default: `5`.
49//! * `LABJACK_IP` - IPv4 address of the LabJack T7. Required unless
50//!   `LABJACK_IDENTIFIER` holds an IPv4 address.
51//! * `LABJACK_IDENTIFIER` - Fallback for `LABJACK_IP`, used only when it parses as
52//!   an IPv4 address.
53//! * `LABJACK_SERIAL` - Expected serial number. When set, a device with another
54//!   serial is rejected. `ANY` or a non-numeric value disables the check.
55//! * `LABJACK_NAME` - Logical device name. Only logged.
56//! * `LJM_PATH` - Path to the LJM shared library (`dynlink` builds only). When unset,
57//!   no path is passed to `ljmrs`.
58//!
59//! # Design
60//!
61//! * Timestamps are derived, not read. The LJM stream API returns values without
62//!   timestamps, so [`StreamClock`] numbers the batches and computes each batch's
63//!   first-sample time from the actual scan rate. It slews the timeline toward the
64//!   system clock by at most 1 ms a minute, so drift between the LabJack crystal
65//!   and the host clock does not grow over runs that last weeks, without chasing
66//!   read-latency noise (see [`StreamClock`]).
67//! * Publishing does not wait for JetStream acknowledgements. The acks for a batch
68//!   are checked on a spawned task so a slow ack does not delay the next LabJack
69//!   read, which would risk a device buffer overflow.
70//! * Scans that LJM fills with `-9999.0` after a buffer overflow are published as
71//!   NaN so they are not archived as real readings.
72//! * After `STREAMER_MAX_LABJACK_FAILURES` consecutive failures the process exits
73//!   with success, so a systemd unit that restarts only on failure leaves it down
74//!   instead of looping on a missing device.
75//! * Central configuration is parsed before it is written to local KV, so an invalid
76//!   central value cannot replace the last working local one.
77
78use serde::{Deserialize, Serialize};
79use std::time::{SystemTime, UNIX_EPOCH};
80use tokio::sync::{mpsc, watch};
81use tokio::time::Duration;
82
83use ljmrs::handle::DeviceType;
84use ljmrs::{LJMError, LJMLibrary};
85
86use async_nats::ConnectOptions;
87use async_nats::jetstream::kv::Operation;
88use async_nats::jetstream::{self, kv, stream::Config as StreamConfig};
89use flatbuffers::FlatBufferBuilder;
90use futures_util::StreamExt;
91
92mod labjack;
93mod ljm_mode;
94mod nats_config;
95mod subjects;
96mod sample_data_generated {
97    #![allow(dead_code, unused_imports)]
98    include!("data_generated.rs");
99}
100use sample_data_generated::sampler::{self, ScanArgs};
101
102#[allow(dead_code)]
103#[derive(Debug, Clone, Deserialize)]
104/// Raw top-level configuration document stored in NATS KV.
105///
106/// The web dashboard writes this nested shape. The streamer normalizes it into
107/// [`SampleConfig`] with [`sample_config_from_nested`] before applying it to LabJack
108/// and NATS runtime state. Fields the streamer does not use are still parsed, so a
109/// document missing them is rejected.
110struct NestedConfig {
111    /// Logical LabJack name. Used as the source token in structured subjects when
112    /// `source_id` is not set.
113    labjack_name: String,
114    /// Asset number used in legacy subjects (`<root>.<asset>.data.chNN`).
115    asset_number: u32,
116    /// Maximum channel count shown by the dashboard. Not used by the streamer.
117    max_channels: u32,
118    /// Site identifier for the structured subject namespace.
119    #[serde(default)]
120    site_id: Option<String>,
121    /// Box identifier for the structured subject namespace.
122    #[serde(default)]
123    box_id: Option<String>,
124    /// Source type. Carried through to [`SampleConfig`] but not used in subjects.
125    #[serde(default)]
126    source_type: Option<String>,
127    /// Source identifier for the structured subject namespace.
128    #[serde(default)]
129    source_id: Option<String>,
130    /// Subject root, for example `avenars`.
131    nats_subject: String,
132    /// Name of the JetStream stream that stores the live samples.
133    nats_stream: String,
134    /// File rotation period in seconds. Used by the archiver, not by the streamer.
135    rotate_secs: u64,
136    /// LabJack acquisition settings.
137    sensor_settings: SensorSettings,
138}
139
140#[allow(dead_code)]
141#[derive(Debug, Clone, Deserialize)]
142/// Raw sensor settings section from the dashboard configuration document.
143///
144/// Field aliases preserve compatibility with older configuration names while
145/// letting the runtime use the clearer `scans_per_read` and `scan_rate_hz`
146/// names internally.
147struct SensorSettings {
148    /// Scans returned by each LJM stream read. Legacy name: `scan_rate`.
149    #[serde(rename = "scans_per_read", alias = "scan_rate")]
150    scans_per_read: i32,
151    /// Requested scan rate in Hz (scans per second across all channels). Legacy
152    /// name: `sampling_rate`.
153    #[serde(rename = "scan_rate_hz", alias = "sampling_rate")]
154    scan_rate_hz: f64,
155    /// Analog input numbers to stream (`7` means `AIN7`), in scan order.
156    channels_enabled: Vec<u8>,
157    /// Gain setting from the dashboard. Not used by the streamer.
158    gains: i32,
159    /// Per-channel data format labels. Not used by the streamer.
160    data_formats: Vec<String>,
161    /// Per-channel unit labels. Not used by the streamer.
162    measurement_units: Vec<String>,
163    /// Whether sampling is enabled. `false` keeps the LabJack stream stopped.
164    labjack_on_off: bool,
165}
166
167#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
168/// Normalized streamer configuration used by the sampling loop.
169///
170/// This is the compact runtime shape derived from the dashboard's nested JSON.
171/// Equality is used by the KV watcher ([`watch_kv_config`]) to decide whether a new
172/// value should restart the LabJack stream, so a change to any field, including
173/// ones the streamer does not use such as `rotate_secs`, restarts it.
174struct SampleConfig {
175    /// Whether sampling is enabled.
176    labjack_on_off: bool,
177    /// Scans returned by each LJM stream read.
178    scans_per_read: i32,
179    /// Requested scan rate in Hz. The device may run at a slightly different rate.
180    scan_rate_hz: f64,
181    /// Analog input numbers to stream, in scan order.
182    channels: Vec<u8>,
183    /// Asset number used in legacy subjects.
184    asset_number: u32,
185    /// Logical LabJack name, used as the fallback source token in subjects.
186    labjack_name: String,
187    /// Site identifier for structured subjects.
188    site_id: Option<String>,
189    /// Box identifier for structured subjects.
190    box_id: Option<String>,
191    /// Source type. Not used in subjects.
192    source_type: Option<String>,
193    /// Source identifier for structured subjects.
194    source_id: Option<String>,
195    /// Subject root.
196    nats_subject: String,
197    /// JetStream stream name.
198    nats_stream: String,
199    /// Archiver file rotation period in seconds. Not used by the streamer.
200    rotate_secs: u64,
201}
202
203/// Converts the dashboard JSON shape into the runtime sampling configuration.
204///
205/// Copies the used fields and drops `max_channels`, `gains`, `data_formats` and
206/// `measurement_units`.
207///
208/// # Arguments
209///
210/// * `nested` - Parsed KV document.
211fn sample_config_from_nested(nested: NestedConfig) -> SampleConfig {
212    let raw = nested.sensor_settings;
213    SampleConfig {
214        labjack_on_off: raw.labjack_on_off,
215        scans_per_read: raw.scans_per_read,
216        scan_rate_hz: raw.scan_rate_hz,
217        channels: raw.channels_enabled,
218        asset_number: nested.asset_number,
219        labjack_name: nested.labjack_name,
220        site_id: nested.site_id,
221        box_id: nested.box_id,
222        source_type: nested.source_type,
223        source_id: nested.source_id,
224        nats_subject: nested.nats_subject,
225        nats_stream: nested.nats_stream,
226        rotate_secs: nested.rotate_secs,
227    }
228}
229
230/// Parses a NATS KV JSON value into a runtime sampling config.
231///
232/// This is the deserialization boundary for streamer configuration. It accepts
233/// the dashboard's nested schema ([`NestedConfig`]) and reports JSON errors as
234/// `LJMError` values so the rest of the streamer can use one error type. No checks
235/// beyond deserialization are done (for example, an empty channel list is accepted).
236///
237/// # Arguments
238///
239/// * `bytes` - Raw KV value, UTF-8 JSON.
240///
241/// # Errors
242///
243/// Returns `LJMError::LibraryError` if the bytes are not valid JSON, a required
244/// field is missing, or a field has the wrong type.
245fn sample_config_from_json(bytes: &[u8]) -> Result<SampleConfig, LJMError> {
246    let nested_cfg: NestedConfig = serde_json::from_slice(bytes)
247        .map_err(|e| LJMError::LibraryError(format!("Config JSON parse error: {}", e)))?;
248    Ok(sample_config_from_nested(nested_cfg))
249}
250
251#[derive(Debug, Clone)]
252/// Connection and key details for mirroring configuration from central NATS.
253///
254/// Built by [`central_kv_sync_config_from_env`].
255struct CentralKvSyncConfig {
256    /// Central NATS server addresses.
257    servers: Vec<async_nats::ServerAddr>,
258    /// Credentials file for the central connection.
259    creds_path: String,
260    /// Central KV bucket name.
261    bucket: String,
262    /// Central KV key to mirror.
263    key: String,
264    /// JetStream domain on the central server, if any.
265    domain: Option<String>,
266}
267
268/// RAII guard that stops streaming and closes the LabJack handle on drop.
269///
270/// The sampling loop exits through several error and shutdown paths, so cleanup
271/// is tied to ownership of the handle instead of each branch remembering to
272/// close the device.
273struct LabJackGuard {
274    /// LJM device handle to stop and close.
275    handle: i32,
276}
277
278impl Drop for LabJackGuard {
279    /// Stops any active LabJack stream and closes the device handle.
280    ///
281    /// Errors from LJM are ignored, since there is nothing useful to do with them
282    /// during cleanup.
283    fn drop(&mut self) {
284        let _ = LJMLibrary::stream_stop(self.handle);
285        let _ = LJMLibrary::close_jack(self.handle);
286    }
287}
288
289/// Reads the optional JetStream stream byte limit from `STREAM_MAX_BYTES`.
290///
291/// A missing or empty value returns `-1`, JetStream's value for no limit.
292///
293/// # Returns
294///
295/// The byte limit, or `-1` when unset.
296///
297/// # Errors
298///
299/// Returns an error if the value is not an integer or is not greater than zero.
300fn stream_max_bytes_from_env() -> Result<i64, LJMError> {
301    let Some(raw) = env_nonempty("STREAM_MAX_BYTES") else {
302        return Ok(-1);
303    };
304
305    let parsed = raw.parse::<i64>().map_err(|e| {
306        LJMError::LibraryError(format!("Invalid STREAM_MAX_BYTES value '{}': {}", raw, e))
307    })?;
308
309    if parsed <= 0 {
310        return Err(LJMError::LibraryError(format!(
311            "Invalid STREAM_MAX_BYTES value '{}': must be greater than zero",
312            raw
313        )));
314    }
315
316    Ok(parsed)
317}
318
319/// Reads the number of consecutive sampler failures after which the streamer exits.
320///
321/// Reads `STREAMER_MAX_LABJACK_FAILURES`. A missing, unparsable or zero value gives
322/// the default of 5.
323fn max_labjack_failures_from_env() -> usize {
324    std::env::var("STREAMER_MAX_LABJACK_FAILURES")
325        .ok()
326        .and_then(|value| value.trim().parse::<usize>().ok())
327        .filter(|value| *value > 0)
328        .unwrap_or(5)
329}
330
331/// Reads the delay between sampler restarts after a failed run.
332///
333/// Reads `STREAMER_LABJACK_RETRY_DELAY_SECS` in whole seconds. A missing or
334/// unparsable value gives the default of 5 s. `0` is accepted and means no delay.
335fn labjack_retry_delay_from_env() -> Duration {
336    let secs = std::env::var("STREAMER_LABJACK_RETRY_DELAY_SECS")
337        .ok()
338        .and_then(|value| value.trim().parse::<u64>().ok())
339        .unwrap_or(5);
340    Duration::from_secs(secs)
341}
342
343/// Creates or reconciles the JetStream stream used for live LabJack samples.
344///
345/// The stream is configured with file storage, limit retention, old-message
346/// discard, no message or consumer limits, the byte limit from `STREAM_MAX_BYTES`,
347/// and the subject wildcard needed for the current source. Existing subjects are
348/// kept during namespace migrations so historical consumers remain valid until they
349/// are deliberately retired. If the stream already has these settings, nothing is
350/// written.
351///
352/// # Arguments
353///
354/// * `js` - JetStream context of the local NATS server.
355/// * `stream_name` - Stream name from the configuration (`nats_stream`).
356/// * `subject` - Subject wildcard for this source, from
357///   `subjects::live_labjack_stream_subject`.
358///
359/// # Errors
360///
361/// Returns an error if `STREAM_MAX_BYTES` is invalid or if JetStream rejects the
362/// create or update request.
363async fn ensure_stream_exists(
364    js: &jetstream::Context,
365    stream_name: &str,
366    subject: &str,
367) -> Result<(), LJMError> {
368    let max_bytes = stream_max_bytes_from_env()?;
369    let mut desired_subjects = vec![subject.to_string()];
370
371    if let Ok(stream) = js.get_stream(stream_name).await {
372        let info = stream.cached_info();
373        desired_subjects = info.config.subjects.clone();
374        if !desired_subjects.iter().any(|existing| existing == subject) {
375            desired_subjects.push(subject.to_string());
376        }
377        let already_configured = info.config.subjects == desired_subjects
378            && info.config.storage == jetstream::stream::StorageType::File
379            && info.config.retention == jetstream::stream::RetentionPolicy::Limits
380            && info.config.max_bytes == max_bytes
381            && info.config.discard == jetstream::stream::DiscardPolicy::Old;
382
383        if already_configured {
384            println!(
385                "JetStream stream '{}' already matches subject(s) {:?}, storage {:?}, max_bytes {}, discard {:?}.",
386                stream_name,
387                info.config.subjects,
388                info.config.storage,
389                info.config.max_bytes,
390                info.config.discard
391            );
392            return Ok(());
393        }
394
395        println!(
396            "Reconciling JetStream stream '{}': subjects {:?} -> {:?}, max_bytes {} -> {}.",
397            stream_name, info.config.subjects, desired_subjects, info.config.max_bytes, max_bytes
398        );
399    }
400
401    println!(
402        "Ensuring JetStream stream '{}' is configured for subject '{}'",
403        stream_name, subject
404    );
405
406    let config = StreamConfig {
407        name: stream_name.to_string(),
408        subjects: desired_subjects,
409        storage: jetstream::stream::StorageType::File,
410        retention: jetstream::stream::RetentionPolicy::Limits,
411        max_consumers: -1,
412        max_messages: -1,
413        max_bytes,
414        discard: jetstream::stream::DiscardPolicy::Old,
415        ..Default::default()
416    };
417
418    js.create_or_update_stream(config).await.map_err(|e| {
419        LJMError::LibraryError(format!(
420            "Failed to create or update JetStream stream '{}': {}",
421            stream_name, e
422        ))
423    })?;
424
425    Ok(())
426}
427
428/// Opens a KV bucket, creating it if it does not exist.
429///
430/// A new bucket keeps 5 revisions per key; other settings use the `async_nats`
431/// defaults. Used for both the local bucket and the central one, so the streamer
432/// also creates the central bucket if it is missing.
433///
434/// # Arguments
435///
436/// * `js` - JetStream context of the server that holds the bucket.
437/// * `bucket` - Bucket name.
438///
439/// # Errors
440///
441/// Returns an error if the bucket does not exist and cannot be created.
442async fn ensure_kv_bucket(js: &jetstream::Context, bucket: &str) -> Result<kv::Store, LJMError> {
443    if let Ok(store) = js.get_key_value(bucket).await {
444        println!("KV bucket '{}' already exists.", bucket);
445        return Ok(store);
446    }
447    println!("Creating KV bucket '{}'", bucket);
448    let cfg = kv::Config {
449        bucket: bucket.to_string(),
450        history: 5,
451        ..Default::default()
452    };
453    js.create_key_value(cfg)
454        .await
455        .map_err(|e| LJMError::LibraryError(format!("Failed to create KV bucket: {}", e)))
456}
457
458/// Loads and parses the initial streamer configuration from local NATS KV.
459///
460/// # Arguments
461///
462/// * `store` - Local KV bucket.
463/// * `key` - Configuration key.
464///
465/// # Errors
466///
467/// Returns an error if the key does not exist, the KV read fails, or the value does
468/// not parse (see [`sample_config_from_json`]).
469async fn load_config_from_kv(store: &kv::Store, key: &str) -> Result<SampleConfig, LJMError> {
470    match store.entry(key).await {
471        Ok(Some(entry)) => sample_config_from_json(entry.value.as_ref()),
472        Ok(None) => Err(LJMError::LibraryError(format!(
473            "KV key '{}' not found",
474            key
475        ))),
476        Err(e) => Err(LJMError::LibraryError(format!(
477            "KV entry error for '{}': {}",
478            key, e
479        ))),
480    }
481}
482
483/// Returns a trimmed environment variable value when it is set and non-empty.
484fn env_nonempty(name: &str) -> Option<String> {
485    std::env::var(name)
486        .ok()
487        .map(|value| value.trim().to_string())
488        .filter(|value| !value.is_empty())
489}
490
491/// Returns the credentials file used for local NATS connections.
492///
493/// Reads `NATS_CREDS_FILE`, defaulting to `apt.creds`.
494fn local_creds_path_from_env() -> String {
495    std::env::var("NATS_CREDS_FILE").unwrap_or_else(|_| "apt.creds".into())
496}
497
498/// Builds central-to-local KV mirroring settings from environment variables.
499///
500/// If neither `CENTRAL_NATS_SERVERS` nor `CFG_NATS_SERVERS` is set, the streamer
501/// runs against local KV only and this returns `Ok(None)`. Each `CENTRAL_*`
502/// variable falls back to its `CFG_*` counterpart; see the module docs for the full
503/// list and defaults.
504///
505/// # Errors
506///
507/// Returns an error if the server list contains an invalid URL or no usable URL.
508fn central_kv_sync_config_from_env() -> Result<Option<CentralKvSyncConfig>, LJMError> {
509    let Some(raw_servers) =
510        env_nonempty("CENTRAL_NATS_SERVERS").or_else(|| env_nonempty("CFG_NATS_SERVERS"))
511    else {
512        return Ok(None);
513    };
514
515    let servers = nats_config::servers_from_env_var("CENTRAL_NATS_SERVERS", &raw_servers)
516        .map_err(LJMError::LibraryError)?;
517    let creds_path =
518        env_nonempty("CENTRAL_NATS_CREDS_FILE").unwrap_or_else(local_creds_path_from_env);
519    let bucket = env_nonempty("CENTRAL_CFG_BUCKET")
520        .or_else(|| env_nonempty("CFG_BUCKET"))
521        .unwrap_or_else(|| "avenabox".to_string());
522    let key = env_nonempty("CENTRAL_CFG_KEY")
523        .or_else(|| env_nonempty("CFG_KEY"))
524        .unwrap_or_else(|| "unknown-site.macbook.unknown-source.config".to_string());
525    let domain = env_nonempty("CENTRAL_JS_DOMAIN").or_else(|| env_nonempty("CFG_JS_DOMAIN"));
526
527    Ok(Some(CentralKvSyncConfig {
528        servers,
529        creds_path,
530        bucket,
531        key,
532        domain,
533    }))
534}
535
536/// Performs one central-to-local KV mirror pass during streamer bootstrap.
537///
538/// Connects to central NATS, opens (or creates) the central bucket, and copies the
539/// configured key into local KV with [`mirror_remote_kv_entry_to_local`]. The
540/// connection is dropped afterward; [`run_central_kv_sync`] keeps its own.
541///
542/// # Arguments
543///
544/// * `sync_cfg` - Central connection and key details.
545/// * `local_store` - Local KV bucket to write into.
546/// * `local_key` - Local configuration key.
547///
548/// # Returns
549///
550/// `true` if the local entry was written, `false` if it already matched.
551///
552/// # Errors
553///
554/// Returns an error if the connection, bucket setup, or mirror step fails.
555async fn mirror_central_kv_once(
556    sync_cfg: &CentralKvSyncConfig,
557    local_store: &kv::Store,
558    local_key: &str,
559) -> Result<bool, LJMError> {
560    let client =
561        connect_nats_with_creds(sync_cfg.servers.clone(), sync_cfg.creds_path.clone()).await?;
562    let remote_js = nats_config::jetstream_context_for_domain(client, sync_cfg.domain.as_deref());
563    let remote_store = ensure_kv_bucket(&remote_js, &sync_cfg.bucket).await?;
564    mirror_remote_kv_entry_to_local(
565        &remote_store,
566        &sync_cfg.bucket,
567        &sync_cfg.key,
568        local_store,
569        local_key,
570    )
571    .await
572}
573
574/// Connects to NATS using a credentials file and explicit server list.
575///
576/// # Arguments
577///
578/// * `servers` - Server addresses to try.
579/// * `creds_path` - Path to the NATS credentials file.
580///
581/// # Errors
582///
583/// Returns an error if the credentials file cannot be loaded or the connection
584/// fails.
585async fn connect_nats_with_creds(
586    servers: Vec<async_nats::ServerAddr>,
587    creds_path: String,
588) -> Result<async_nats::Client, LJMError> {
589    let opts = ConnectOptions::with_credentials_file(creds_path)
590        .await
591        .map_err(|e| LJMError::LibraryError(format!("Failed to load creds: {}", e)))?;
592
593    opts.connect(servers)
594        .await
595        .map_err(|e| LJMError::LibraryError(format!("NATS connect failed: {}", e)))
596}
597
598/// Copies one remote KV configuration entry into the local KV bucket if needed.
599///
600/// The remote value is parsed before it is written locally so an invalid central
601/// configuration cannot replace the last known local configuration. The write is
602/// skipped when the local bytes already equal the remote bytes, so an unchanged
603/// value does not add a KV revision.
604///
605/// # Arguments
606///
607/// * `remote_store` - Central KV bucket.
608/// * `remote_bucket` - Central bucket name, used only in the log line.
609/// * `remote_key` - Central key to read.
610/// * `local_store` - Local KV bucket.
611/// * `local_key` - Local key to write.
612///
613/// # Returns
614///
615/// `true` if the local entry was written, `false` if it already matched.
616///
617/// # Errors
618///
619/// Returns an error if the remote key is missing or cannot be read, the remote
620/// value does not parse, the local key cannot be read, or the local write fails.
621async fn mirror_remote_kv_entry_to_local(
622    remote_store: &kv::Store,
623    remote_bucket: &str,
624    remote_key: &str,
625    local_store: &kv::Store,
626    local_key: &str,
627) -> Result<bool, LJMError> {
628    let remote_entry = match remote_store.entry(remote_key).await {
629        Ok(Some(entry)) => entry,
630        Ok(None) => {
631            return Err(LJMError::LibraryError(format!(
632                "Remote KV key '{}' not found",
633                remote_key
634            )));
635        }
636        Err(e) => {
637            return Err(LJMError::LibraryError(format!(
638                "Remote KV entry error for '{}': {}",
639                remote_key, e
640            )));
641        }
642    };
643
644    sample_config_from_json(remote_entry.value.as_ref())?;
645
646    let local_matches = match local_store.entry(local_key).await {
647        Ok(Some(local_entry)) => local_entry.value.as_ref() == remote_entry.value.as_ref(),
648        Ok(None) => false,
649        Err(e) => {
650            return Err(LJMError::LibraryError(format!(
651                "Local KV entry error for '{}': {}",
652                local_key, e
653            )));
654        }
655    };
656
657    if local_matches {
658        return Ok(false);
659    }
660
661    local_store
662        .put(local_key, remote_entry.value.clone())
663        .await
664        .map_err(|e| {
665            LJMError::LibraryError(format!(
666                "Failed to mirror remote KV '{}' into local key '{}': {}",
667                remote_key, local_key, e
668            ))
669        })?;
670
671    println!(
672        "[central_kv_sync] mirrored '{}:{}' into local '{}'",
673        remote_bucket, remote_key, local_key
674    );
675    Ok(true)
676}
677
678/// Watches central NATS KV and mirrors valid updates into the local bucket.
679///
680/// Each pass connects, opens the central bucket, mirrors the current value once,
681/// and then watches the key. Only `Put` operations are mirrored; deletes and purges
682/// are logged and ignored, and invalid values are skipped. After a connection,
683/// bucket, or watch failure, or when the watch ends, the task waits 5 s and starts a
684/// new pass. It returns when the shutdown channel becomes `true`.
685///
686/// Writes to local KV are then picked up by [`watch_kv_config`] like any other
687/// update.
688///
689/// # Arguments
690///
691/// * `sync_cfg` - Central connection and key details.
692/// * `local_store` - Local KV bucket to write into.
693/// * `local_key` - Local configuration key.
694/// * `shutdown_rx` - Shutdown flag shared with the rest of the process.
695async fn run_central_kv_sync(
696    sync_cfg: CentralKvSyncConfig,
697    local_store: kv::Store,
698    local_key: String,
699    mut shutdown_rx: watch::Receiver<bool>,
700) {
701    loop {
702        if *shutdown_rx.borrow() {
703            println!("[central_kv_sync] shutdown");
704            break;
705        }
706
707        let connect_result =
708            connect_nats_with_creds(sync_cfg.servers.clone(), sync_cfg.creds_path.clone()).await;
709        let client = match connect_result {
710            Ok(client) => client,
711            Err(err) => {
712                eprintln!("[central_kv_sync] connect failed: {:?}", err);
713                tokio::select! {
714                    _ = tokio::time::sleep(Duration::from_secs(5)) => {}
715                    _ = shutdown_rx.changed() => {
716                        if *shutdown_rx.borrow() {
717                            println!("[central_kv_sync] shutdown");
718                            break;
719                        }
720                    }
721                }
722                continue;
723            }
724        };
725
726        let remote_js =
727            nats_config::jetstream_context_for_domain(client, sync_cfg.domain.as_deref());
728        let remote_store = match ensure_kv_bucket(&remote_js, &sync_cfg.bucket).await {
729            Ok(store) => store,
730            Err(err) => {
731                eprintln!("[central_kv_sync] remote bucket setup failed: {:?}", err);
732                tokio::select! {
733                    _ = tokio::time::sleep(Duration::from_secs(5)) => {}
734                    _ = shutdown_rx.changed() => {
735                        if *shutdown_rx.borrow() {
736                            println!("[central_kv_sync] shutdown");
737                            break;
738                        }
739                    }
740                }
741                continue;
742            }
743        };
744
745        if let Err(err) = mirror_remote_kv_entry_to_local(
746            &remote_store,
747            &sync_cfg.bucket,
748            &sync_cfg.key,
749            &local_store,
750            &local_key,
751        )
752        .await
753        {
754            eprintln!("[central_kv_sync] initial mirror failed: {:?}", err);
755        }
756
757        let mut watch = match remote_store.watch(&sync_cfg.key).await {
758            Ok(watch) => watch,
759            Err(err) => {
760                eprintln!("[central_kv_sync] watch setup failed: {}", err);
761                tokio::select! {
762                    _ = tokio::time::sleep(Duration::from_secs(5)) => {}
763                    _ = shutdown_rx.changed() => {
764                        if *shutdown_rx.borrow() {
765                            println!("[central_kv_sync] shutdown");
766                            break;
767                        }
768                    }
769                }
770                continue;
771            }
772        };
773
774        println!(
775            "[central_kv_sync] watching remote '{}:{}' for local key '{}'",
776            sync_cfg.bucket, sync_cfg.key, local_key
777        );
778
779        loop {
780            tokio::select! {
781                maybe = watch.next() => {
782                    match maybe {
783                        Some(Ok(entry)) => {
784                            if entry.operation == Operation::Put {
785                                if let Err(err) = sample_config_from_json(entry.value.as_ref()) {
786                                    eprintln!(
787                                        "[central_kv_sync] ignoring invalid remote config for key '{}': {:?}",
788                                        entry.key, err
789                                    );
790                                    continue;
791                                }
792
793                                match local_store.entry(local_key.as_str()).await {
794                                    Ok(Some(local_entry)) if local_entry.value.as_ref() == entry.value.as_ref() => {}
795                                    Ok(_) => {
796                                        if let Err(err) = local_store.put(local_key.as_str(), entry.value.clone()).await {
797                                            eprintln!(
798                                                "[central_kv_sync] failed to mirror remote update for key '{}': {}",
799                                                entry.key, err
800                                            );
801                                        } else {
802                                            println!(
803                                                "[central_kv_sync] mirrored remote update rev {} for '{}'",
804                                                entry.revision, entry.key
805                                            );
806                                        }
807                                    }
808                                    Err(err) => {
809                                        eprintln!(
810                                            "[central_kv_sync] failed to inspect local key '{}': {}",
811                                            local_key, err
812                                        );
813                                    }
814                                }
815                            } else {
816                                eprintln!(
817                                    "[central_kv_sync] {:?} for remote key '{}', ignoring.",
818                                    entry.operation, entry.key
819                                );
820                            }
821                        }
822                        Some(Err(err)) => {
823                            eprintln!("[central_kv_sync] watch stream error: {}", err);
824                            break;
825                        }
826                        None => {
827                            eprintln!("[central_kv_sync] watch ended");
828                            break;
829                        }
830                    }
831                }
832                _ = shutdown_rx.changed() => {
833                    if *shutdown_rx.borrow() {
834                        println!("[central_kv_sync] shutdown");
835                        return;
836                    }
837                }
838            }
839        }
840
841        tokio::select! {
842            _ = tokio::time::sleep(Duration::from_secs(5)) => {}
843            _ = shutdown_rx.changed() => {
844                if *shutdown_rx.borrow() {
845                    println!("[central_kv_sync] shutdown");
846                    break;
847                }
848            }
849        }
850    }
851}
852
853/// Watches local NATS KV for streamer configuration updates.
854///
855/// Parsed changes are sent through a watch channel so the active sampler can
856/// stop cleanly and restart with the new settings. A value is sent only when it
857/// differs from the last one in the channel. Non-`Put` operations, values that do
858/// not parse, and watch stream errors are logged and skipped. The task returns when
859/// the watch cannot be set up, the watch ends, or shutdown is signaled.
860///
861/// # Arguments
862///
863/// * `store` - Local KV bucket.
864/// * `key` - Configuration key to watch.
865/// * `config_tx` - Sender read by [`run_sampler`] and [`sample_with_config`].
866/// * `shutdown_rx` - Shutdown flag shared with the rest of the process.
867async fn watch_kv_config(
868    store: kv::Store,
869    key: String,
870    config_tx: tokio::sync::watch::Sender<SampleConfig>,
871    mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
872) {
873    let mut watch = match store.watch(&key).await {
874        Ok(w) => w,
875        Err(e) => {
876            eprintln!("[watch_kv_config] watch error: {}", e);
877            return;
878        }
879    };
880    println!("[watch_kv_config] Watching KV key '{}'", key);
881
882    loop {
883        tokio::select! {
884            maybe = watch.next() => {
885                match maybe {
886                    Some(Ok(entry)) => {
887                        if entry.operation == Operation::Put {
888                            match sample_config_from_json(entry.value.as_ref()) {
889                                Ok(new_cfg) => {
890                                    if new_cfg != *config_tx.borrow() {
891                                        println!(
892                                            "[watch_kv_config] KV config updated (rev {}): {:?}",
893                                            entry.revision, new_cfg
894                                        );
895                                        let _ = config_tx.send(new_cfg);
896                                    }
897                                }
898                                Err(e) => {
899                                    eprintln!("[watch_kv_config] Failed to parse JSON for key '{}': {:?}", entry.key, e);
900                                }
901                            }
902                        } else {
903                            eprintln!("[watch_kv_config] {:?} for key '{}', ignoring.", entry.operation, entry.key);
904                        }
905                    }
906                    Some(Err(e)) => eprintln!("[watch_kv_config] stream err: {}", e),
907                    None => { eprintln!("[watch_kv_config] watch ended"); break; }
908                }
909            }
910            _ = shutdown_rx.changed() => {
911                if *shutdown_rx.borrow() { println!("[watch_kv_config] shutdown"); break; }
912            }
913        }
914    }
915}
916
917use std::sync::{
918    Arc,
919    atomic::{AtomicBool, Ordering},
920};
921
922/// Length of each window over which clock drift is measured, in nanoseconds (60 s).
923///
924/// Long enough that at least one read in the window usually arrives with low
925/// latency, so the window minimum approximates drift rather than scheduling delay.
926const CLOCK_CHECK_WINDOW_NS: u128 = 60_000_000_000;
927/// Smallest window offset that is acted on, in nanoseconds (5 ms).
928const CLOCK_SLEW_THRESHOLD_NS: i128 = 5_000_000;
929/// Largest adjustment made in one window when slewing, in nanoseconds (1 ms).
930///
931/// The actual limit is the smaller of this and half the sample interval (see
932/// [`StreamClock::max_slew_ns`]), so an adjustment never makes timestamps go
933/// backwards or collide. One millisecond per minute follows crystal drift of up
934/// to about 16 ppm at 100 Hz, and a quarter millisecond (the limit at 2 kHz) up to
935/// about 4 ppm; MU1 measured about 2 ppm.
936const CLOCK_MAX_SLEW_NS: i128 = 1_000_000;
937/// Windows over which the slewing offset is taken as the smallest window offset
938/// (15, about a quarter of an hour).
939///
940/// A single minute is not enough on a busy host: on MU1 the read latency stayed
941/// high for several minutes at a time before dropping back, so each minute's
942/// minimum overstated the offset. Over 15 minutes at least one fast read almost
943/// always occurs, while 2 ppm of drift moves the offset by under 2 ms.
944const CLOCK_OFFSET_HISTORY_WINDOWS: usize = 15;
945/// Offset treated as a system clock step rather than drift, in nanoseconds (2 s).
946const CLOCK_STEP_THRESHOLD_NS: i128 = 2_000_000_000;
947/// Consecutive windows a step-sized offset must persist before the timeline jumps.
948const CLOCK_STEP_WINDOWS: u32 = 3;
949/// Windows between summary log lines about slewing (60, about one hour).
950const CLOCK_LOG_EVERY_WINDOWS: u32 = 60;
951
952#[derive(Debug, Clone)]
953/// Tracks timestamp continuity for successive LabJack stream batches.
954///
955/// The LabJack stream API returns value batches without per-sample timestamps,
956/// so the streamer derives the first timestamp and sequence number for each
957/// published batch from the actual stream rate.
958///
959/// The LabJack crystal and the system clock disagree by a few ppm, which added
960/// up to about 4-5 s over a 26-day run on MU1. To stop that accumulating, the
961/// clock compares each batch's last-sample time with the system clock and keeps
962/// the smallest difference seen in each 60 s window.
963///
964/// That minimum is not a clean drift measurement on a busy host. On MU1, with the
965/// camera software loading the CPU, read latency wandered by tens to hundreds of
966/// milliseconds from one minute to the next. An earlier version jumped the
967/// timeline by the full window minimum and so chased that noise, stepping the
968/// timestamps by up to 430 ms every minute. The clock therefore never jumps for
969/// ordinary offsets. It slews:
970///
971/// * The offset used for slewing is the smallest window minimum over the last 15
972///   windows, because on a busy host a whole minute can pass without a fast read.
973/// * An offset of at least 5 ms that points the same way in two consecutive
974///   windows moves the timeline by at most 1 ms (less at high sample rates, see
975///   [`StreamClock::max_slew_ns`]). Genuine drift is steady and gets followed;
976///   latency noise changes sign or size and mostly does not.
977/// * Only an offset of 2 s or more that persists for three windows, which is what
978///   a system clock step looks like (for example chronyd correcting a badly set
979///   clock), moves the timeline in one jump. That jump is logged.
980///
981/// The first batch of a run is anchored to the system clock at the moment it
982/// arrives, so whatever delay that one read had is built into the timeline. At the
983/// end of the first window the clock therefore corrects the anchor once, in full,
984/// by the smallest offset seen in that window, and logs it. After that only the
985/// rules above apply.
986///
987/// Slewing is summarized in the log about once an hour.
988///
989/// A new clock is created for every sampling run, so sequence numbers restart at 0
990/// and the first batch of each run is anchored to the system clock again.
991struct StreamClock {
992    /// Time between consecutive scans, in nanoseconds, from the actual scan rate.
993    sample_interval_ns: u64,
994    /// Expected Unix timestamp (ns) of the first sample of the next batch.
995    next_first_sample_unix_ns: u64,
996    /// Sequence number that the next batch will get.
997    sequence: u64,
998    /// Samples per channel in the most recent batch. Recorded but not read.
999    last_batch_samples: usize,
1000    /// Whether the first batch has been timestamped.
1001    run_started: bool,
1002    /// System time (Unix ns) at which the current drift window started.
1003    window_start_unix_ns: u128,
1004    /// Smallest receive-time minus last-sample-time seen in the current window, in
1005    /// nanoseconds. `None` until the window has a measurement.
1006    window_min_error_ns: Option<i128>,
1007    /// Window minimum of the previous window, in nanoseconds.
1008    previous_window_error_ns: Option<i128>,
1009    /// Consecutive windows whose offset was at least [`CLOCK_STEP_THRESHOLD_NS`]
1010    /// in the same direction.
1011    step_windows: u32,
1012    /// This window's own minimum from the previous window, for step detection.
1013    previous_raw_offset_ns: Option<i128>,
1014    /// Total slew applied since the last summary log line, in nanoseconds.
1015    slewed_since_log_ns: i128,
1016    /// Whether the first window has ended and the initial anchor was checked.
1017    anchor_checked: bool,
1018    /// Window minima of the most recent windows, oldest first, at most
1019    /// [`CLOCK_OFFSET_HISTORY_WINDOWS`].
1020    recent_offsets_ns: std::collections::VecDeque<i128>,
1021    /// Windows completed since the last summary log line.
1022    windows_since_log: u32,
1023}
1024
1025/// What the clock decided to do at the end of one drift window.
1026#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1027enum ClockAdjustment {
1028    /// Leave the timeline alone.
1029    None,
1030    /// Move the timeline by this many nanoseconds, at most [`CLOCK_MAX_SLEW_NS`].
1031    Slew(i128),
1032    /// Move the timeline by this many nanoseconds in one jump (a clock step).
1033    Step(i128),
1034    /// Correct the initial anchor by this many nanoseconds, once per run.
1035    Anchor(i128),
1036}
1037
1038/// Decides how to adjust the timeline from two consecutive window offsets.
1039///
1040/// # Arguments
1041///
1042/// * `current_ns` - Minimum receive-time minus sample-time in the window that
1043///   just ended, in nanoseconds. Positive means the timeline is behind the system
1044///   clock.
1045/// * `previous_ns` - The same measure for the window before, if there was one.
1046/// * `step_windows` - Consecutive windows, including this one, whose offset was at
1047///   least [`CLOCK_STEP_THRESHOLD_NS`] in this direction.
1048/// * `max_slew_ns` - Largest slew allowed, in nanoseconds (positive).
1049///
1050/// # Returns
1051///
1052/// [`ClockAdjustment::Step`] when a step-sized offset has lasted
1053/// [`CLOCK_STEP_WINDOWS`] windows, [`ClockAdjustment::Slew`] when an offset of at
1054/// least [`CLOCK_SLEW_THRESHOLD_NS`] points the same way as in the previous window,
1055/// and [`ClockAdjustment::None`] otherwise.
1056///
1057/// # Examples
1058///
1059/// ```text
1060/// decide_adjustment(64 ms, Some(60 ms), 0, 1 ms)   -> Slew(+1 ms)
1061/// decide_adjustment(64 ms, Some(-400 ms), 0, 1 ms) -> None     (direction changed)
1062/// decide_adjustment(3 s, Some(3 s), 3, 1 ms)       -> Step(+3 s)
1063/// ```
1064fn decide_adjustment(
1065    current_ns: i128,
1066    previous_ns: Option<i128>,
1067    step_windows: u32,
1068    max_slew_ns: i128,
1069) -> ClockAdjustment {
1070    if current_ns.abs() >= CLOCK_STEP_THRESHOLD_NS {
1071        return if step_windows >= CLOCK_STEP_WINDOWS {
1072            ClockAdjustment::Step(current_ns)
1073        } else {
1074            ClockAdjustment::None
1075        };
1076    }
1077    let persistent = previous_ns.is_some_and(|previous| {
1078        previous.abs() >= CLOCK_SLEW_THRESHOLD_NS && previous.signum() == current_ns.signum()
1079    });
1080    if current_ns.abs() >= CLOCK_SLEW_THRESHOLD_NS && persistent {
1081        ClockAdjustment::Slew(current_ns.clamp(-max_slew_ns, max_slew_ns))
1082    } else {
1083        ClockAdjustment::None
1084    }
1085}
1086
1087impl StreamClock {
1088    /// Creates a stream clock that has not yet seen a batch.
1089    ///
1090    /// # Arguments
1091    ///
1092    /// * `sample_interval_ns` - Time between scans in nanoseconds, from
1093    ///   [`derive_sample_interval_ns`].
1094    fn new(sample_interval_ns: u64) -> Self {
1095        Self {
1096            sample_interval_ns,
1097            next_first_sample_unix_ns: 0,
1098            sequence: 0,
1099            last_batch_samples: 0,
1100            run_started: false,
1101            window_start_unix_ns: 0,
1102            window_min_error_ns: None,
1103            previous_window_error_ns: None,
1104            step_windows: 0,
1105            previous_raw_offset_ns: None,
1106            slewed_since_log_ns: 0,
1107            windows_since_log: 0,
1108            anchor_checked: false,
1109            recent_offsets_ns: std::collections::VecDeque::new(),
1110        }
1111    }
1112
1113    /// Returns the largest slew allowed per window, in nanoseconds.
1114    ///
1115    /// The smaller of [`CLOCK_MAX_SLEW_NS`] and half the sample interval, so that
1116    /// even a backward slew leaves consecutive timestamps strictly increasing.
1117    fn max_slew_ns(&self) -> i128 {
1118        CLOCK_MAX_SLEW_NS
1119            .min(self.sample_interval_ns as i128 / 2)
1120            .max(1)
1121    }
1122
1123    /// Returns the first sample timestamp and sequence for the next batch.
1124    ///
1125    /// Reads the system clock and calls [`Self::next_batch_at`]. The first batch is
1126    /// anchored so that its last sample falls at the current wall-clock time. Each
1127    /// later batch starts where the previous one ended, with the drift handling
1128    /// described in the type docs.
1129    ///
1130    /// # Arguments
1131    ///
1132    /// * `batch_samples` - Samples per channel in the batch (scans in the batch).
1133    ///
1134    /// # Returns
1135    ///
1136    /// The Unix timestamp of the batch's first sample in nanoseconds, and the
1137    /// batch sequence number.
1138    ///
1139    /// # Errors
1140    ///
1141    /// Returns an error if `batch_samples` is zero, if the system clock is before
1142    /// the Unix epoch, or if a timestamp overflows `u64`.
1143    fn next_batch(&mut self, batch_samples: usize) -> Result<(u64, u64), LJMError> {
1144        let now_ns = unix_time_now_ns()?;
1145        self.next_batch_at(batch_samples, now_ns)
1146    }
1147
1148    /// [`Self::next_batch`] with an explicit receive time, for testing.
1149    ///
1150    /// For every batch after the first, it measures the receive time minus the
1151    /// expected time of the batch's last sample and keeps the window minimum. When
1152    /// the window has lasted [`CLOCK_CHECK_WINDOW_NS`], [`decide_adjustment`]
1153    /// chooses whether to slew or step, the adjustment is added to this batch's
1154    /// first-sample time, and later batches continue from there. The window then
1155    /// restarts at `now_ns`.
1156    ///
1157    /// # Arguments
1158    ///
1159    /// * `batch_samples` - Samples per channel in the batch.
1160    /// * `now_ns` - Receive time of the batch, Unix nanoseconds.
1161    ///
1162    /// # Returns
1163    ///
1164    /// The Unix timestamp of the batch's first sample in nanoseconds, and the
1165    /// batch sequence number.
1166    ///
1167    /// # Errors
1168    ///
1169    /// Returns an error if `batch_samples` is zero or if the initial, corrected, or
1170    /// next timestamp does not fit in `u64`.
1171    fn next_batch_at(&mut self, batch_samples: usize, now_ns: u64) -> Result<(u64, u64), LJMError> {
1172        if batch_samples == 0 {
1173            return Err(LJMError::LibraryError(
1174                "Received empty batch; refusing to guess timestamps".to_string(),
1175            ));
1176        }
1177
1178        let last_offset_ns = (batch_samples.saturating_sub(1) as u128)
1179            .saturating_mul(self.sample_interval_ns as u128);
1180        let mut first_sample_unix_ns = if !self.run_started {
1181            let first = (now_ns as u128).saturating_sub(last_offset_ns);
1182            self.window_start_unix_ns = now_ns as u128;
1183            u64::try_from(first).map_err(|_| {
1184                LJMError::LibraryError("Initial stream timestamp overflowed u64".to_string())
1185            })?
1186        } else {
1187            self.next_first_sample_unix_ns
1188        };
1189
1190        if self.run_started {
1191            let last_sample_ns = first_sample_unix_ns as i128 + last_offset_ns as i128;
1192            let error_ns = now_ns as i128 - last_sample_ns;
1193            self.window_min_error_ns = Some(match self.window_min_error_ns {
1194                Some(min) => min.min(error_ns),
1195                None => error_ns,
1196            });
1197
1198            if (now_ns as u128).saturating_sub(self.window_start_unix_ns) >= CLOCK_CHECK_WINDOW_NS {
1199                let offset_ns = self.window_min_error_ns.take().unwrap_or(0);
1200                self.window_start_unix_ns = now_ns as u128;
1201
1202                let same_direction = self
1203                    .previous_raw_offset_ns
1204                    .is_some_and(|previous| previous.signum() == offset_ns.signum());
1205                self.step_windows = if offset_ns.abs() >= CLOCK_STEP_THRESHOLD_NS {
1206                    if same_direction {
1207                        self.step_windows + 1
1208                    } else {
1209                        1
1210                    }
1211                } else {
1212                    0
1213                };
1214
1215                self.previous_raw_offset_ns = Some(offset_ns);
1216                let adjustment = if !self.anchor_checked {
1217                    // First window: fix the latency built into the initial anchor.
1218                    self.anchor_checked = true;
1219                    if offset_ns.abs() >= CLOCK_SLEW_THRESHOLD_NS {
1220                        println!(
1221                            "[clock] Corrected the initial anchor by {:+.3} ms after the first minute",
1222                            offset_ns as f64 / 1e6
1223                        );
1224                        ClockAdjustment::Anchor(offset_ns)
1225                    } else {
1226                        ClockAdjustment::None
1227                    }
1228                } else {
1229                    self.recent_offsets_ns.push_back(offset_ns);
1230                    if self.recent_offsets_ns.len() > CLOCK_OFFSET_HISTORY_WINDOWS {
1231                        self.recent_offsets_ns.pop_front();
1232                    }
1233                    let floor_ns = self
1234                        .recent_offsets_ns
1235                        .iter()
1236                        .copied()
1237                        .min()
1238                        .unwrap_or(offset_ns);
1239                    // A clock step shows in every recent window at once, so step detection
1240                    // uses this window; slewing uses the floor over the recent history.
1241                    let decision_ns = if offset_ns.abs() >= CLOCK_STEP_THRESHOLD_NS {
1242                        offset_ns
1243                    } else {
1244                        floor_ns
1245                    };
1246                    decide_adjustment(
1247                        decision_ns,
1248                        self.previous_window_error_ns,
1249                        self.step_windows,
1250                        self.max_slew_ns(),
1251                    )
1252                };
1253                self.previous_window_error_ns = Some(
1254                    self.recent_offsets_ns
1255                        .iter()
1256                        .copied()
1257                        .min()
1258                        .unwrap_or(offset_ns),
1259                );
1260
1261                let applied_ns = match adjustment {
1262                    ClockAdjustment::None => 0,
1263                    ClockAdjustment::Slew(ns) => {
1264                        self.slewed_since_log_ns += ns;
1265                        ns
1266                    }
1267                    ClockAdjustment::Anchor(ns) => {
1268                        // The anchor is now corrected; start the persistence rule afresh.
1269                        self.previous_window_error_ns = None;
1270                        ns
1271                    }
1272                    ClockAdjustment::Step(ns) => {
1273                        println!(
1274                            "[clock] Offset of {:+.3} s persisted for {} minutes; stepped stream timestamps at sequence {} (system clock step?)",
1275                            ns as f64 / 1e9,
1276                            CLOCK_STEP_WINDOWS,
1277                            self.sequence
1278                        );
1279                        self.step_windows = 0;
1280                        self.previous_window_error_ns = None;
1281                        ns
1282                    }
1283                };
1284                if applied_ns != 0 {
1285                    // Offsets measured before the adjustment are now off by applied_ns.
1286                    for past in self.recent_offsets_ns.iter_mut() {
1287                        *past -= applied_ns;
1288                    }
1289                    if let Some(previous) = self.previous_window_error_ns.as_mut() {
1290                        *previous -= applied_ns;
1291                    }
1292                    let corrected = first_sample_unix_ns as i128 + applied_ns;
1293                    first_sample_unix_ns = u64::try_from(corrected).map_err(|_| {
1294                        LJMError::LibraryError(
1295                            "Corrected stream timestamp overflowed u64".to_string(),
1296                        )
1297                    })?;
1298                }
1299
1300                self.windows_since_log += 1;
1301                if self.windows_since_log >= CLOCK_LOG_EVERY_WINDOWS {
1302                    if self.slewed_since_log_ns != 0 {
1303                        println!(
1304                            "[clock] Slewed stream timestamps by {:+.3} ms over the last {} minutes; latest window offset {:+.3} ms",
1305                            self.slewed_since_log_ns as f64 / 1e6,
1306                            self.windows_since_log,
1307                            offset_ns as f64 / 1e6
1308                        );
1309                    }
1310                    self.slewed_since_log_ns = 0;
1311                    self.windows_since_log = 0;
1312                }
1313            }
1314        }
1315
1316        let batch_span_ns = (batch_samples as u128).saturating_mul(self.sample_interval_ns as u128);
1317        let next = (first_sample_unix_ns as u128).saturating_add(batch_span_ns);
1318        self.next_first_sample_unix_ns = u64::try_from(next).map_err(|_| {
1319            LJMError::LibraryError("Next stream timestamp overflowed u64".to_string())
1320        })?;
1321
1322        let sequence = self.sequence;
1323        self.sequence = self.sequence.saturating_add(1);
1324        self.last_batch_samples = batch_samples;
1325        self.run_started = true;
1326
1327        Ok((first_sample_unix_ns, sequence))
1328    }
1329}
1330
1331/// Value LJM inserts for scans lost to a stream buffer overflow when it
1332/// auto-recovers (LJM's `LJM_DUMMY_VALUE` constant).
1333const LJM_DUMMY_VALUE: f64 = -9999.0;
1334
1335/// Replaces LJM auto-recovery placeholders with NaN and returns how many.
1336///
1337/// LJM keeps the scan count intact when it recovers, so timestamps stay
1338/// correct, but the placeholder would otherwise be archived as a real
1339/// -9999 V reading.
1340///
1341/// # Arguments
1342///
1343/// * `batch` - Interleaved samples from one LJM read, changed in place.
1344///
1345/// # Returns
1346///
1347/// The number of values replaced, across all channels.
1348///
1349/// # Examples
1350///
1351/// ```text
1352/// [3.72, -9999.0, 3.71]  ->  [3.72, NaN, 3.71], returns 1
1353/// ```
1354fn replace_dummy_samples(batch: &mut [f64]) -> usize {
1355    let mut replaced = 0;
1356    for v in batch.iter_mut() {
1357        if *v == LJM_DUMMY_VALUE {
1358            *v = f64::NAN;
1359            replaced += 1;
1360        }
1361    }
1362    replaced
1363}
1364
1365/// Copies one channel's samples out of an interleaved LJM batch into `out`.
1366///
1367/// LJM returns scans interleaved (`ch0, ch1, ..., ch0, ch1, ...`). `out` is cleared
1368/// first, so one buffer can be reused for every channel of every read.
1369///
1370/// # Arguments
1371///
1372/// * `batch` - Interleaved samples from one LJM read; its length must be a
1373///   multiple of `num_channels` (a trailing partial scan is ignored).
1374/// * `num_channels` - Channels per scan, greater than zero.
1375/// * `channel` - Position of the channel in the scan list, below `num_channels`.
1376/// * `out` - Receives the channel's samples in scan order.
1377///
1378/// # Examples
1379///
1380/// ```text
1381/// batch [a0, b0, a1, b1], 2 channels, channel 1  ->  out [b0, b1]
1382/// ```
1383fn deinterleave_channel(batch: &[f64], num_channels: usize, channel: usize, out: &mut Vec<f64>) {
1384    out.clear();
1385    out.extend(batch.chunks_exact(num_channels).map(|scan| scan[channel]));
1386}
1387
1388/// Returns the current Unix timestamp in nanoseconds.
1389///
1390/// # Errors
1391///
1392/// Returns an error if the system clock is before the Unix epoch or the value does
1393/// not fit in `u64`.
1394fn unix_time_now_ns() -> Result<u64, LJMError> {
1395    let duration = SystemTime::now()
1396        .duration_since(UNIX_EPOCH)
1397        .map_err(|e| LJMError::LibraryError(format!("system clock before Unix epoch: {e}")))?;
1398    u64::try_from(duration.as_nanos())
1399        .map_err(|_| LJMError::LibraryError("system time nanoseconds overflowed u64".to_string()))
1400}
1401
1402/// Converts the actual LabJack scan rate into a sample interval in nanoseconds.
1403///
1404/// The interval is `1e9 / actual_rate`, rounded to the nearest nanosecond.
1405///
1406/// # Arguments
1407///
1408/// * `actual_rate` - Scan rate in Hz returned by `stream_start`.
1409///
1410/// # Errors
1411///
1412/// Returns an error if the rate is not finite or not positive, or if the rounded
1413/// interval is zero.
1414///
1415/// # Examples
1416///
1417/// ```text
1418/// 5000.0 Hz  ->  200_000 ns
1419/// ```
1420fn derive_sample_interval_ns(actual_rate: f64) -> Result<u64, LJMError> {
1421    if !actual_rate.is_finite() || actual_rate <= 0.0 {
1422        return Err(LJMError::LibraryError(format!(
1423            "Invalid actual scan rate returned by stream_start: {actual_rate}"
1424        )));
1425    }
1426
1427    let interval = (1_000_000_000.0 / actual_rate).round();
1428    if !interval.is_finite() || interval <= 0.0 {
1429        return Err(LJMError::LibraryError(format!(
1430            "Failed to derive sample interval from actual scan rate: {actual_rate}"
1431        )));
1432    }
1433
1434    Ok(interval as u64)
1435}
1436
1437/// Runs one LabJack streaming session with a fixed configuration.
1438///
1439/// In order, the session:
1440///
1441/// 1. Creates or updates the JetStream stream ([`ensure_stream_exists`]).
1442/// 2. Opens and checks the LabJack (`labjack::open_labjack_from_env`) and wraps
1443///    the handle in a [`LabJackGuard`].
1444/// 3. Configures all analog inputs as single-ended (T7 only), +/-10 V range,
1445///    resolution index 0 and `STREAM_SETTLING_US` 0.
1446/// 4. Starts the hardware stream and derives the sample interval from the rate the
1447///    device actually runs at.
1448/// 5. Reads batches on a blocking thread, replaces LJM dummy values with NaN,
1449///    splits each batch by channel, encodes one FlatBuffer `Scan` per channel, and
1450///    publishes it to the channel's subject.
1451///
1452/// It runs until the configuration changes, shutdown is signaled, or an error
1453/// occurs. A publish that fails is logged and that channel's message for the batch
1454/// is dropped; it does not end the session.
1455///
1456/// # Arguments
1457///
1458/// * `run_id` - Run counter from [`run_sampler`], used in log lines.
1459/// * `cfg` - Configuration for this session.
1460/// * `config_rx` - Configuration channel; any change ends the session.
1461/// * `shutdown_rx` - Shutdown flag; `true` ends the session.
1462/// * `js` - JetStream context of the local NATS server.
1463///
1464/// # Returns
1465///
1466/// `Ok(())` when the session stopped because of a configuration change or
1467/// shutdown.
1468///
1469/// # Errors
1470///
1471/// Returns an error if stream setup fails, the LabJack cannot be opened or
1472/// configured, a channel number has no `AIN` register, `stream_start` fails or
1473/// reports an invalid rate, the reader stops (for example on an LJM read error),
1474/// a batch is empty or not a multiple of the channel count, or a timestamp cannot
1475/// be computed.
1476async fn sample_with_config(
1477    run_id: usize,
1478    cfg: SampleConfig,
1479    config_rx: &mut watch::Receiver<SampleConfig>,
1480    shutdown_rx: &mut watch::Receiver<bool>,
1481    js: &jetstream::Context,
1482) -> Result<(), LJMError> {
1483    ensure_stream_exists(
1484        &js,
1485        &cfg.nats_stream,
1486        &subjects::live_labjack_stream_subject(
1487            &cfg.nats_subject,
1488            cfg.asset_number,
1489            cfg.site_id.as_deref(),
1490            cfg.box_id.as_deref(),
1491            Some(&cfg.labjack_name),
1492            cfg.source_type.as_deref(),
1493            cfg.source_id.as_deref(),
1494        ),
1495    )
1496    .await?;
1497
1498    let handle = labjack::open_labjack_from_env()?;
1499    // Stops the stream and closes the handle on every return path below.
1500    let _guard = LabJackGuard { handle };
1501    let info = labjack::handle_info(handle)?;
1502    let ip = labjack::handle_ip_address(&info)?.unwrap_or_else(|| "N/A".to_string());
1503    println!(
1504        "[labjack] connected via {:?}, serial {}, ip {}",
1505        info.connection_type, info.serial_number, ip
1506    );
1507    println!(
1508        "[run #{run_id}] Connected to {:?} (serial {})",
1509        info.device_type, info.serial_number
1510    );
1511
1512    // 199 selects GND as the negative input, i.e. single-ended measurements.
1513    if matches!(info.device_type, DeviceType::T7) {
1514        LJMLibrary::write_name(handle, "AIN_ALL_NEGATIVE_CH", 199_u32)?;
1515    }
1516    LJMLibrary::write_name(handle, "AIN_ALL_RANGE", 10.0_f64)?;
1517    LJMLibrary::write_name(handle, "AIN_ALL_RESOLUTION_INDEX", 0_u32)?;
1518    LJMLibrary::write_name(handle, "STREAM_SETTLING_US", 0_u32)?;
1519
1520    let channel_addresses: Result<Vec<i32>, LJMError> = cfg
1521        .channels
1522        .iter()
1523        .map(|ch| {
1524            LJMLibrary::name_to_address(&format!("AIN{}", ch))
1525                .map(|(addr, _)| addr)
1526                .map_err(|e| LJMError::LibraryError(format!("Invalid channel {}: {:?}", ch, e)))
1527        })
1528        .collect();
1529    let channel_addresses = channel_addresses?;
1530    let num_channels = channel_addresses.len();
1531
1532    let actual_rate = LJMLibrary::stream_start(
1533        handle,
1534        cfg.scans_per_read,
1535        cfg.scan_rate_hz,
1536        channel_addresses.clone(),
1537    )?;
1538    println!(
1539        "[run #{run_id}] Streaming started: {} scans/read @ {} Hz",
1540        cfg.scans_per_read, actual_rate
1541    );
1542    let sample_interval_ns = derive_sample_interval_ns(actual_rate)?;
1543    println!(
1544        "[run #{run_id}] Derived sample interval: {} ns from actual scan rate {} Hz",
1545        sample_interval_ns, actual_rate
1546    );
1547
1548    // Up to 32 raw batches can wait between the reader thread and this task.
1549    let (scan_tx, mut scan_rx) = mpsc::channel::<Vec<f64>>(32);
1550
1551    // Shared running flag for stopping the blocking loop
1552    let running = Arc::new(AtomicBool::new(true));
1553    let running_reader = running.clone();
1554    let scan_tx_reader = scan_tx.clone();
1555
1556    // Single long-lived blocking task for reading
1557    let read_handle = tokio::task::spawn_blocking(move || {
1558        while running_reader.load(Ordering::Relaxed) {
1559            match LJMLibrary::stream_read(handle) {
1560                Ok(batch) => {
1561                    if scan_tx_reader.blocking_send(batch).is_err() {
1562                        break; // receiver gone
1563                    }
1564                }
1565                Err(e) => {
1566                    eprintln!("[run #{run_id}] Error reading stream: {:?}", e);
1567                    break;
1568                }
1569            }
1570        }
1571    });
1572    drop(scan_tx);
1573
1574    // Reused for every message of the run, so encoding a batch allocates only the
1575    // payloads handed to the NATS client.
1576    let mut builder = FlatBufferBuilder::new();
1577    let mut channel_values: Vec<f64> = Vec::new();
1578    let mut clock = StreamClock::new(sample_interval_ns);
1579    // Built once per run as `Subject`s: cloning one per message only bumps a
1580    // reference count, where a `String` would be copied twice per publish.
1581    let channel_subjects: Vec<async_nats::Subject> = cfg
1582        .channels
1583        .iter()
1584        .map(|ch| {
1585            subjects::live_labjack_channel_subject(
1586                &cfg.nats_subject,
1587                cfg.asset_number,
1588                *ch,
1589                cfg.site_id.as_deref(),
1590                cfg.box_id.as_deref(),
1591                Some(&cfg.labjack_name),
1592                cfg.source_type.as_deref(),
1593                cfg.source_id.as_deref(),
1594            )
1595            .into()
1596        })
1597        .collect();
1598
1599    loop {
1600        tokio::select! {
1601            maybe_batch = scan_rx.recv() => {
1602                let Some(batch) = maybe_batch else {
1603                    eprintln!(
1604                        "[run #{run_id}] Stream reader ended unexpectedly at sequence {}. Next expected first sample ns {}",
1605                        clock.sequence,
1606                        clock.next_first_sample_unix_ns
1607                    );
1608                    running.store(false, Ordering::Relaxed);
1609                    let _ = LJMLibrary::stream_stop(handle);
1610                    let _ = read_handle.await;
1611                    return Err(LJMError::LibraryError(
1612                        "Stream reader terminated unexpectedly".to_string(),
1613                    ));
1614                };
1615                if batch.is_empty() {
1616                    eprintln!("[run #{run_id}] Received empty batch; stopping run.");
1617                    running.store(false, Ordering::Relaxed);
1618                    let _ = LJMLibrary::stream_stop(handle);
1619                    let _ = read_handle.await;
1620                    return Err(LJMError::LibraryError(
1621                        "Received empty batch from stream_read".to_string(),
1622                    ));
1623                }
1624                if batch.len() % num_channels != 0 {
1625                    eprintln!(
1626                        "[run #{run_id}] Batch length {} is not divisible by channel count {}; stopping run as discontinuity.",
1627                        batch.len(),
1628                        num_channels
1629                    );
1630                    running.store(false, Ordering::Relaxed);
1631                    let _ = LJMLibrary::stream_stop(handle);
1632                    let _ = read_handle.await;
1633                    return Err(LJMError::LibraryError(format!(
1634                        "Malformed stream batch: {} values for {} channels",
1635                        batch.len(),
1636                        num_channels
1637                    )));
1638                }
1639
1640                // LJM returns scans interleaved: ch0, ch1, ..., ch0, ch1, ...
1641                let batch_samples = batch.len() / num_channels;
1642                let (first_sample_unix_ns, sequence) = clock.next_batch(batch_samples)?;
1643                let mut batch = batch;
1644                let skipped = replace_dummy_samples(&mut batch);
1645                if skipped > 0 {
1646                    eprintln!(
1647                        "[run #{run_id}] LabJack skipped {} sample(s) in sequence {}; stored as NaN",
1648                        skipped, sequence
1649                    );
1650                }
1651                let mut acks = Vec::with_capacity(num_channels);
1652                // Each await only queues the message on the client's command
1653                // channel; it waits only if that channel is full, and keeps the
1654                // publishes in channel order.
1655                for (channel, subject) in channel_subjects.iter().enumerate() {
1656                    deinterleave_channel(&batch, num_channels, channel, &mut channel_values);
1657                    builder.reset();
1658                    let values_fb = builder.create_vector(&channel_values);
1659                    let scan_args = ScanArgs {
1660                        first_sample_unix_ns,
1661                        sample_interval_ns,
1662                        actual_scan_rate_hz: actual_rate,
1663                        sequence,
1664                        values: Some(values_fb),
1665                    };
1666                    let scan_offset = sampler::Scan::create(&mut builder, &scan_args);
1667                    builder.finish(scan_offset, None);
1668
1669                    let data = builder.finished_data().to_vec();
1670
1671                    match js.publish(subject.clone(), data.into()).await {
1672                        Ok(ack) => acks.push(ack),
1673                        Err(e) => eprintln!("[run #{run_id}] Failed to publish to NATS: {}", e),
1674                    }
1675                }
1676                // Confirm JetStream stored every channel's message without
1677                // holding up the next LabJack read.
1678                tokio::spawn(async move {
1679                    let mut failed = 0usize;
1680                    let mut last_error = None;
1681                    for ack in acks {
1682                        if let Err(e) = ack.await {
1683                            failed += 1;
1684                            last_error = Some(e);
1685                        }
1686                    }
1687                    if let Some(e) = last_error {
1688                        eprintln!(
1689                            "[run #{run_id}] JetStream did not confirm {failed} message(s) of sequence {sequence}: {e}"
1690                        );
1691                    }
1692                });
1693            }
1694            _ = config_rx.changed() => {
1695                println!(
1696                    "[run #{run_id}] Config change detected. Stopping stream at sequence {}. Next expected first sample ns {}",
1697                    clock.sequence,
1698                    clock.next_first_sample_unix_ns
1699                );
1700                running.store(false, Ordering::Relaxed);
1701                let _ = LJMLibrary::stream_stop(handle);
1702                let _ = read_handle.await;
1703                return Ok(());
1704            }
1705            _ = shutdown_rx.changed() => {
1706                if *shutdown_rx.borrow() {
1707                    println!(
1708                        "[run #{run_id}] Shutdown signal received. Stopping stream at sequence {}. Next expected first sample ns {}",
1709                        clock.sequence,
1710                        clock.next_first_sample_unix_ns
1711                    );
1712                    running.store(false, Ordering::Relaxed);
1713                    let _ = LJMLibrary::stream_stop(handle);
1714                    let _ = read_handle.await;
1715                    return Ok(());
1716                }
1717            }
1718        }
1719    }
1720}
1721
1722/// Supervises LabJack sampling across config changes and transient failures.
1723///
1724/// Runs [`sample_with_config`] in a loop with the latest configuration. After a
1725/// configuration change it restarts at once; after an error it waits for
1726/// `STREAMER_LABJACK_RETRY_DELAY_SECS` first. While `labjack_on_off` is `false` it
1727/// waits for the next configuration change instead of opening the device. Any
1728/// session error counts as a failure, including NATS stream setup errors. The
1729/// failure count resets after a session that ends without error and while sampling
1730/// is disabled.
1731///
1732/// # Arguments
1733///
1734/// * `config_rx` - Configuration channel fed by [`watch_kv_config`].
1735/// * `shutdown_rx` - Shutdown flag shared with the rest of the process.
1736/// * `js` - JetStream context of the local NATS server.
1737///
1738/// # Returns
1739///
1740/// `true` when consecutive failures reached `STREAMER_MAX_LABJACK_FAILURES`, which
1741/// lets the process exit cleanly instead of relying on service restarts. `false`
1742/// on shutdown, or when the configuration channel closes while sampling is
1743/// disabled.
1744async fn run_sampler(
1745    mut config_rx: tokio::sync::watch::Receiver<SampleConfig>,
1746    mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
1747    js: jetstream::Context,
1748) -> bool {
1749    let mut run_id = 0;
1750    let mut consecutive_failures = 0usize;
1751    let max_failures = max_labjack_failures_from_env();
1752    let retry_delay = labjack_retry_delay_from_env();
1753    loop {
1754        if *shutdown_rx.borrow() {
1755            println!("[run_sampler] Sampler shutting down...");
1756            return false;
1757        }
1758        run_id += 1;
1759        let cfg = config_rx.borrow().clone();
1760        if !cfg.labjack_on_off {
1761            consecutive_failures = 0;
1762            println!("[run_sampler] LabJack stream disabled; waiting for config update.");
1763            tokio::select! {
1764                changed = config_rx.changed() => {
1765                    if changed.is_err() {
1766                        eprintln!("[run_sampler] Config channel closed while disabled.");
1767                        return false;
1768                    }
1769                    continue;
1770                }
1771                _ = shutdown_rx.changed() => {
1772                    if *shutdown_rx.borrow() {
1773                        println!("[run_sampler] Sampler shutting down while disabled...");
1774                        return false;
1775                    }
1776                }
1777            }
1778            continue;
1779        }
1780        println!(
1781            "[run_sampler] Starting sampler run #{run_id} with {:?}",
1782            cfg
1783        );
1784
1785        let mut had_error = false;
1786        match sample_with_config(run_id, cfg, &mut config_rx, &mut shutdown_rx, &js).await {
1787            Ok(()) => {
1788                consecutive_failures = 0;
1789            }
1790            Err(e) => {
1791                had_error = true;
1792                consecutive_failures = consecutive_failures.saturating_add(1);
1793                eprintln!(
1794                    "[run_sampler] Sampler error {}/{}: {:?}",
1795                    consecutive_failures, max_failures, e
1796                );
1797                if consecutive_failures >= max_failures {
1798                    eprintln!(
1799                        "[run_sampler] Reached {} consecutive LabJack sampler failures; exiting cleanly so systemd does not auto-restart the streamer.",
1800                        max_failures
1801                    );
1802                    return true;
1803                }
1804            }
1805        }
1806
1807        if *shutdown_rx.borrow() {
1808            println!("[run_sampler] Shutdown detected after sampler error/config change");
1809            return false;
1810        }
1811
1812        if had_error {
1813            println!(
1814                "[run_sampler] Restarting sampler after error in {}s...",
1815                retry_delay.as_secs()
1816            );
1817            tokio::select! {
1818                _ = tokio::time::sleep(retry_delay) => {}
1819                _ = shutdown_rx.changed() => {
1820                    if *shutdown_rx.borrow() {
1821                        println!("[run_sampler] Sampler shutting down during retry delay...");
1822                        return false;
1823                    }
1824                }
1825            }
1826        } else {
1827            println!("[run_sampler] Restarting sampler after config change...");
1828        }
1829    }
1830}
1831
1832#[tokio::main]
1833/// Starts the streamer service, configuration watchers, and sampler supervisor.
1834///
1835/// Connects to local NATS, opens the configuration bucket, mirrors the central
1836/// configuration once if central sync is configured (a failure is logged and the
1837/// local value is used), loads the configuration, initializes LJM, and then spawns
1838/// [`run_sampler`], [`watch_kv_config`] and, if configured, [`run_central_kv_sync`].
1839/// It waits for the sampler to stop or for Ctrl+C, sets the shutdown flag, and gives
1840/// the tasks 300 ms to stop before returning.
1841///
1842/// # Errors
1843///
1844/// Returns an error if the local credentials cannot be loaded, `NATS_SERVERS` is
1845/// invalid, the local connection fails, the KV bucket cannot be opened or created,
1846/// the central server list is invalid, the configuration key is missing or invalid,
1847/// LJM initialization fails, the sampler task panics, or the Ctrl+C handler cannot
1848/// be installed. Stopping after repeated sampler failures is not an error.
1849async fn main() -> Result<(), LJMError> {
1850    let creds_path = std::env::var("NATS_CREDS_FILE").unwrap_or_else(|_| "apt.creds".into());
1851    let sample_opts = ConnectOptions::with_credentials_file(creds_path.clone())
1852        .await
1853        .map_err(|e| LJMError::LibraryError(format!("Failed to load creds: {}", e)))?;
1854
1855    let sample_servers = nats_config::servers_from_env().map_err(LJMError::LibraryError)?;
1856
1857    let sample_nc = sample_opts
1858        .connect(sample_servers)
1859        .await
1860        .map_err(|e| LJMError::LibraryError(format!("NATS connect failed: {}", e)))?;
1861
1862    println!("Connected to sample NATS via creds!");
1863    let sample_js = nats_config::jetstream_context(sample_nc);
1864
1865    let bucket = std::env::var("CFG_BUCKET").unwrap_or_else(|_| "avenabox".into());
1866    let key = std::env::var("CFG_KEY")
1867        .unwrap_or_else(|_| "unknown-site.macbook.unknown-source.config".into());
1868
1869    let store = ensure_kv_bucket(&sample_js, &bucket).await?;
1870    let central_sync_cfg = central_kv_sync_config_from_env()?;
1871    if let Some(sync_cfg) = central_sync_cfg.as_ref() {
1872        if let Err(err) = mirror_central_kv_once(sync_cfg, &store, &key).await {
1873            eprintln!("[bootstrap] Central-to-local KV mirror failed: {:?}", err);
1874        }
1875    }
1876    let cfg = load_config_from_kv(&store, &key).await?;
1877    println!(
1878        "[bootstrap] Loaded initial config from KV '{}:{}': {:?}",
1879        bucket, key, cfg
1880    );
1881
1882    let (config_tx, config_rx) = watch::channel(cfg);
1883    let (shutdown_tx, shutdown_rx) = watch::channel(false);
1884
1885    unsafe {
1886        ljm_mode::init_ljm()?;
1887    }
1888
1889    let sampler_handle = tokio::spawn(run_sampler(
1890        config_rx.clone(),
1891        shutdown_rx.clone(),
1892        sample_js.clone(),
1893    ));
1894    tokio::spawn(watch_kv_config(
1895        store.clone(),
1896        key.clone(),
1897        config_tx.clone(),
1898        shutdown_rx.clone(),
1899    ));
1900    if let Some(sync_cfg) = central_sync_cfg {
1901        tokio::spawn(run_central_kv_sync(
1902            sync_cfg,
1903            store.clone(),
1904            key.clone(),
1905            shutdown_rx.clone(),
1906        ));
1907    }
1908
1909    let sampler_stopped_after_failures = tokio::select! {
1910        result = sampler_handle => {
1911            match result {
1912                Ok(stopped_after_failures) => stopped_after_failures,
1913                Err(err) => {
1914                    return Err(LJMError::LibraryError(format!(
1915                        "Sampler task failed to join: {err}"
1916                    )));
1917                }
1918            }
1919        }
1920        signal = tokio::signal::ctrl_c() => {
1921            signal.map_err(|e| LJMError::LibraryError(format!("Failed to listen for Ctrl+C: {}", e)))?;
1922            false
1923        }
1924    };
1925
1926    if sampler_stopped_after_failures {
1927        eprintln!("Streamer stopped after repeated LabJack sampler failures.");
1928    } else {
1929        println!("Shutting down...");
1930    }
1931    let _ = shutdown_tx.send(true);
1932    tokio::time::sleep(Duration::from_millis(300)).await;
1933    Ok(())
1934}
1935
1936#[cfg(test)]
1937mod tests {
1938    use super::*;
1939
1940    /// Builds a KV configuration document with the given scan field names and values.
1941    fn sample_kv_json(
1942        scans_field: &str,
1943        scans_value: &str,
1944        rate_field: &str,
1945        rate_value: &str,
1946    ) -> String {
1947        format!(
1948            r#"{{
1949  "labjack_name": "Unit A",
1950  "asset_number": 1456,
1951  "max_channels": 14,
1952  "nats_subject": "avenabox",
1953  "nats_stream": "labjacks",
1954  "rotate_secs": 300,
1955  "sensor_settings": {{
1956    "{scans_field}": {scans_value},
1957    "{rate_field}": {rate_value},
1958    "channels_enabled": [7, 11],
1959    "gains": 1,
1960    "data_formats": ["voltage", "voltage"],
1961    "measurement_units": ["V", "V"],
1962    "labjack_on_off": true
1963  }}
1964}}"#
1965        )
1966    }
1967
1968    /// Checks that 5 kHz gives a 200,000 ns interval.
1969    #[test]
1970    fn derive_interval_from_actual_rate() {
1971        let interval = derive_sample_interval_ns(5_000.0).expect("valid rate");
1972        assert_eq!(interval, 200_000);
1973    }
1974
1975    /// Checks that consecutive batches are contiguous and numbered 0, 1.
1976    #[test]
1977    fn clock_advances_monotonically_after_first_batch() {
1978        let mut clock = StreamClock::new(200_000);
1979        let (first, seq0) = clock.next_batch(10).expect("first batch");
1980        let (second, seq1) = clock.next_batch(10).expect("second batch");
1981        assert_eq!(seq0, 0);
1982        assert_eq!(seq1, 1);
1983        assert_eq!(second, first + (10 * 200_000));
1984    }
1985
1986    /// Checks that a new clock starts at sequence 0.
1987    #[test]
1988    fn clock_resets_sequence_on_new_run() {
1989        let mut clock = StreamClock::new(1_000);
1990        let _ = clock.next_batch(3).expect("first batch");
1991        let _ = clock.next_batch(3).expect("second batch");
1992
1993        let reset_clock = StreamClock::new(1_000);
1994        assert_eq!(reset_clock.sequence, 0);
1995    }
1996
1997    /// Checks that read-latency jitter under 5 ms never moves timestamps.
1998    #[test]
1999    fn clock_ignores_latency_jitter_below_threshold() {
2000        // 100 Hz, 100 scans per read: one batch per second.
2001        let mut clock = StreamClock::new(10_000_000);
2002        let t0 = 1_790_000_000_000_000_000_u64;
2003        let (first0, _) = clock.next_batch_at(100, t0).expect("first batch");
2004        let mut expected_first = first0;
2005        for k in 1..=120_u64 {
2006            // Each batch arrives 0-3 ms after its last sample.
2007            let jitter = (k % 4) * 1_000_000;
2008            let now = t0 + k * 1_000_000_000 + jitter;
2009            let (first, _) = clock.next_batch_at(100, now).expect("batch");
2010            expected_first += 1_000_000_000;
2011            assert_eq!(first, expected_first, "no correction expected at batch {k}");
2012        }
2013    }
2014
2015    /// One simulated batch: the timestamp the clock gave its first sample, and the
2016    /// system-clock time at which that sample was really taken.
2017    struct SimBatch {
2018        stamped_ns: i128,
2019        true_ns: i128,
2020    }
2021
2022    /// Runs a clock through `batches` reads and records what it stamped.
2023    ///
2024    /// The device clock runs `drift_ppm` slow, each batch arrives
2025    /// `latency_ns(k)` after its last sample, and the system clock jumps by
2026    /// `clock_step` (batch index, nanoseconds) if given.
2027    fn simulate(
2028        interval_ns: u64,
2029        scans: usize,
2030        batches: u64,
2031        drift_ppm: f64,
2032        latency_ns: impl Fn(u64) -> i128,
2033        clock_step: Option<(u64, i128)>,
2034    ) -> Vec<SimBatch> {
2035        let mut clock = StreamClock::new(interval_ns);
2036        let t0: i128 = 1_790_000_000_000_000_000;
2037        let real_interval = interval_ns as f64 * (1.0 + drift_ppm * 1e-6);
2038        let mut out = Vec::new();
2039        for k in 0..batches {
2040            let offset = match clock_step {
2041                Some((at, ns)) if k >= at => ns,
2042                _ => 0,
2043            };
2044            let first_true = t0 + (k as f64 * scans as f64 * real_interval) as i128 + offset;
2045            let last_true = first_true + ((scans - 1) as f64 * real_interval) as i128;
2046            let now = (last_true + latency_ns(k)) as u64;
2047            let (stamped, _) = clock.next_batch_at(scans, now).expect("batch");
2048            out.push(SimBatch {
2049                stamped_ns: stamped as i128,
2050                true_ns: first_true,
2051            });
2052        }
2053        out
2054    }
2055
2056    /// Largest change between consecutive batch start times beyond the nominal span.
2057    fn largest_adjustment(batches: &[SimBatch], span_ns: i128) -> i128 {
2058        batches
2059            .windows(2)
2060            .map(|w| (w[1].stamped_ns - w[0].stamped_ns - span_ns).abs())
2061            .max()
2062            .unwrap_or(0)
2063    }
2064
2065    /// Largest difference between stamped and true times after `skip` batches.
2066    fn largest_error(batches: &[SimBatch], skip: usize) -> i128 {
2067        batches[skip..]
2068            .iter()
2069            .map(|b| (b.stamped_ns - b.true_ns).abs())
2070            .max()
2071            .unwrap_or(0)
2072    }
2073
2074    /// Checks that latency wander like MU1's under CPU load never jumps the timeline.
2075    #[test]
2076    fn clock_does_not_chase_latency_wander() {
2077        // 100 Hz, 100 scans per read, three hours. Latency ramps by 64 ms per
2078        // minute and resets every seven minutes, with a 300 ms stall every 37
2079        // batches: the pattern seen on MU1 while the camera loaded the CPU.
2080        let latency = |k: u64| -> i128 {
2081            let ramp = (k % 420) as i128 * 64_000_000 / 60;
2082            let stall = if k % 37 == 0 { 300_000_000 } else { 0 };
2083            2_000_000 + ramp + stall
2084        };
2085        let batches = simulate(10_000_000, 100, 3 * 3600, 0.0, latency, None);
2086        // The first batch arrived during a stall; the anchor is fixed once after the
2087        // first minute, and every later adjustment is a small slew.
2088        assert!(largest_adjustment(&batches[61..], 1_000_000_000) <= 1_000_000);
2089        // No real drift, so the timeline must stay close to the truth.
2090        assert!(
2091            largest_error(&batches, 61) < 25_000_000,
2092            "{}",
2093            largest_error(&batches, 61)
2094        );
2095    }
2096
2097    /// Checks that steady crystal drift is followed by small slews.
2098    #[test]
2099    fn clock_follows_steady_drift_with_small_slews() {
2100        // The device runs 10 ppm slow (0.6 ms per minute, five times MU1's measured
2101        // drift), with 0-3 ms of latency. Uncorrected, the error would reach 108 ms
2102        // after three hours. The steady-state error is the 5 ms threshold plus the
2103        // drift over the 15-minute history (9 ms) plus latency.
2104        let batches = simulate(
2105            10_000_000,
2106            100,
2107            3 * 3600,
2108            10.0,
2109            |k| (k % 4) as i128 * 1_000_000,
2110            None,
2111        );
2112        assert!(largest_adjustment(&batches, 1_000_000_000) <= 1_000_000 + 10_000);
2113        assert!(
2114            largest_error(&batches, 1200) < 20_000_000,
2115            "{}",
2116            largest_error(&batches, 1200)
2117        );
2118
2119        // At MU1's measured 2 ppm the error stays within a few milliseconds.
2120        let batches = simulate(
2121            10_000_000,
2122            100,
2123            6 * 3600,
2124            2.0,
2125            |k| (k % 4) as i128 * 1_000_000,
2126            None,
2127        );
2128        assert!(
2129            largest_error(&batches, 0) < 10_000_000,
2130            "{}",
2131            largest_error(&batches, 0)
2132        );
2133    }
2134
2135    /// Checks that at 2 kHz timestamps stay strictly increasing through slews.
2136    #[test]
2137    fn clock_keeps_timestamps_increasing_at_high_rates() {
2138        // 2 kHz, 100 scans per read (50 ms batches), 5 ppm drift, jittery latency.
2139        let latency = |k: u64| -> i128 { ((k * 7919) % 40) as i128 * 1_000_000 };
2140        let batches = simulate(500_000, 100, 20 * 60 * 30, 5.0, latency, None);
2141        let span = 100 * 500_000;
2142        assert!(largest_adjustment(&batches, span) <= 250_000 + 10_000);
2143        // The last sample of each batch comes before the first of the next.
2144        for w in batches.windows(2) {
2145            assert!(w[1].stamped_ns > w[0].stamped_ns + 99 * 500_000);
2146        }
2147    }
2148
2149    /// Checks that a persistent system clock step moves the timeline in one jump.
2150    #[test]
2151    fn clock_steps_after_a_persistent_system_clock_jump() {
2152        // The system clock jumps forward 3 s ten minutes in.
2153        let batches = simulate(
2154            10_000_000,
2155            100,
2156            30 * 60,
2157            0.0,
2158            |_| 2_000_000,
2159            Some((600, 3_000_000_000)),
2160        );
2161        let jumps: Vec<(usize, i128)> = batches
2162            .windows(2)
2163            .enumerate()
2164            .map(|(i, w)| (i + 1, w[1].stamped_ns - w[0].stamped_ns - 1_000_000_000))
2165            .filter(|(_, d)| d.abs() > 1_000_000)
2166            .collect();
2167        assert_eq!(jumps.len(), 1, "exactly one jump: {jumps:?}");
2168        let (at, size) = jumps[0];
2169        assert!(
2170            (600..600 + 5 * 60).contains(&at),
2171            "jump within five windows, at {at}"
2172        );
2173        assert!(
2174            (size - 3_000_000_000).abs() < 10_000_000,
2175            "jump of about 3 s: {size}"
2176        );
2177        assert!(largest_error(&batches, at + 1) < 10_000_000);
2178    }
2179
2180    /// Checks the slew and step decisions on single inputs.
2181    #[test]
2182    fn adjustment_decisions() {
2183        let ms = 1_000_000_i128;
2184        assert_eq!(
2185            decide_adjustment(64 * ms, Some(60 * ms), 0, ms),
2186            ClockAdjustment::Slew(ms)
2187        );
2188        assert_eq!(
2189            decide_adjustment(-64 * ms, Some(-9 * ms), 0, ms),
2190            ClockAdjustment::Slew(-ms)
2191        );
2192        assert_eq!(
2193            decide_adjustment(64 * ms, Some(-400 * ms), 0, ms),
2194            ClockAdjustment::None
2195        );
2196        assert_eq!(
2197            decide_adjustment(64 * ms, None, 0, ms),
2198            ClockAdjustment::None
2199        );
2200        assert_eq!(
2201            decide_adjustment(4 * ms, Some(4 * ms), 0, ms),
2202            ClockAdjustment::None
2203        );
2204        assert_eq!(
2205            decide_adjustment(3 * ms / 2 * 4, Some(8 * ms), 0, ms / 4),
2206            ClockAdjustment::Slew(ms / 4)
2207        );
2208        assert_eq!(
2209            decide_adjustment(3000 * ms, Some(3000 * ms), 2, ms),
2210            ClockAdjustment::None
2211        );
2212        assert_eq!(
2213            decide_adjustment(3000 * ms, Some(3000 * ms), 3, ms),
2214            ClockAdjustment::Step(3000 * ms)
2215        );
2216    }
2217
2218    /// Checks that LJM dummy values become NaN and are counted.
2219    #[test]
2220    fn dummy_samples_become_nan_and_are_counted() {
2221        let mut batch = vec![3.72, LJM_DUMMY_VALUE, 3.71, LJM_DUMMY_VALUE];
2222        assert_eq!(replace_dummy_samples(&mut batch), 2);
2223        assert_eq!(batch[0], 3.72);
2224        assert!(batch[1].is_nan());
2225        assert_eq!(batch[2], 3.71);
2226        assert!(batch[3].is_nan());
2227        let mut clean = vec![-3.25, 0.0];
2228        assert_eq!(replace_dummy_samples(&mut clean), 0);
2229    }
2230
2231    /// Checks that each channel gets its own samples in scan order, and that the
2232    /// reused buffer holds only the latest channel.
2233    #[test]
2234    fn deinterleave_channel_splits_scans_and_reuses_buffer() {
2235        // Three channels, three scans: value = 10 * channel + scan.
2236        let batch = [0.0, 10.0, 20.0, 1.0, 11.0, 21.0, 2.0, 12.0, 22.0];
2237        let mut out = Vec::new();
2238        deinterleave_channel(&batch, 3, 0, &mut out);
2239        assert_eq!(out, [0.0, 1.0, 2.0]);
2240        deinterleave_channel(&batch, 3, 2, &mut out);
2241        assert_eq!(out, [20.0, 21.0, 22.0]);
2242        deinterleave_channel(&batch[..3], 3, 1, &mut out);
2243        assert_eq!(out, [10.0]);
2244    }
2245
2246    /// Checks that an empty batch is an error.
2247    #[test]
2248    fn clock_rejects_empty_batch() {
2249        let mut clock = StreamClock::new(1_000);
2250        assert!(clock.next_batch(0).is_err());
2251    }
2252
2253    /// Checks parsing with `scans_per_read` and `scan_rate_hz`.
2254    #[test]
2255    fn kv_config_parses_canonical_field_names() {
2256        let config = sample_config_from_json(
2257            sample_kv_json("scans_per_read", "200", "scan_rate_hz", "5000").as_bytes(),
2258        )
2259        .expect("canonical config should parse");
2260
2261        assert_eq!(config.scans_per_read, 200);
2262        assert_eq!(config.scan_rate_hz, 5000.0);
2263        assert_eq!(config.channels, vec![7, 11]);
2264        assert_eq!(config.asset_number, 1456);
2265        assert_eq!(config.nats_subject, "avenabox");
2266    }
2267
2268    /// Checks parsing with the legacy `scan_rate` and `sampling_rate` names.
2269    #[test]
2270    fn kv_config_parses_legacy_field_names() {
2271        let config = sample_config_from_json(
2272            sample_kv_json("scan_rate", "200", "sampling_rate", "1000").as_bytes(),
2273        )
2274        .expect("legacy config should parse");
2275
2276        assert_eq!(config.scans_per_read, 200);
2277        assert_eq!(config.scan_rate_hz, 1000.0);
2278        assert_eq!(config.rotate_secs, 300);
2279    }
2280
2281    /// Checks that `labjack_on_off` is carried into the runtime config.
2282    #[test]
2283    fn kv_config_preserves_labjack_enabled_state() {
2284        let config = sample_config_from_json(
2285            sample_kv_json("scans_per_read", "200", "scan_rate_hz", "5000").as_bytes(),
2286        )
2287        .expect("config should parse");
2288
2289        assert!(config.labjack_on_off);
2290    }
2291}