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}