Skip to main content

streamer/
labjack.rs

1//! LabJack T7 connection and handle-inspection helpers for the `streamer` binary.
2//!
3//! The streamer only supports direct Ethernet opens by IP address; it never runs LJM
4//! device discovery. These helpers handle environment parsing, device verification,
5//! stale stream cleanup, and conversion of LabJack handle metadata into readable
6//! values.
7//!
8//! # Configuration
9//!
10//! * `LABJACK_IP` - IPv4 address of the T7. Required unless `LABJACK_IDENTIFIER`
11//!   holds an IPv4 address.
12//! * `LABJACK_IDENTIFIER` - Fallback for `LABJACK_IP`, used only when it parses as
13//!   an IPv4 address.
14//! * `LABJACK_SERIAL` - Expected serial number. When set, a device with another
15//!   serial is rejected. `ANY` or a non-numeric value disables the check.
16//! * `LABJACK_NAME` - Logical device name. Only logged.
17//!
18//! For all of these, surrounding whitespace is trimmed, and an empty value or `ANY`
19//! (any case) counts as unset.
20
21use std::net::Ipv4Addr;
22use std::str::FromStr;
23
24use ljmrs::handle::{ConnectionType, DeviceHandleInfo, DeviceType};
25use ljmrs::{LJMError, LJMLibrary};
26
27/// LJM error code returned when a stream is already active on the handle
28/// (`STREAM_IS_ACTIVE`).
29///
30/// It shows up when an earlier session left a stream running on the device. The
31/// self-test write in [`open_streamer_labjack_from_env`] then fails with this code
32/// until the stream is stopped.
33const STREAM_IS_ACTIVE_ERROR: i32 = 2605;
34
35/// Returns a trimmed environment variable value when set and non-empty.
36///
37/// # Arguments
38///
39/// * `name` - Environment variable name.
40fn env_var(name: &str) -> Option<String> {
41    std::env::var(name)
42        .ok()
43        .map(|value| value.trim().to_string())
44        .filter(|value| !value.is_empty())
45}
46
47/// Returns an environment identifier unless it is empty or the wildcard `ANY`.
48///
49/// The `ANY` check ignores case.
50///
51/// # Arguments
52///
53/// * `name` - Environment variable name.
54fn env_identifier(name: &str) -> Option<String> {
55    env_var(name).filter(|value| !value.eq_ignore_ascii_case("ANY"))
56}
57
58/// Parses an IPv4 string used by the direct Ethernet LabJack path.
59///
60/// # Arguments
61///
62/// * `value` - Dotted-quad text, for example `192.168.1.102`.
63///
64/// # Returns
65///
66/// The address, or `None` if the text is not a valid IPv4 address.
67fn parse_ipv4(value: &str) -> Option<Ipv4Addr> {
68    Ipv4Addr::from_str(value).ok()
69}
70
71/// Reads the required LabJack IP address from environment variables.
72///
73/// `LABJACK_IP` is preferred. `LABJACK_IDENTIFIER` is accepted only when it is
74/// an IPv4 address because this deployment path avoids broad discovery.
75/// `LABJACK_IP` itself is not validated here.
76///
77/// # Errors
78///
79/// Returns `LJMError::LibraryError` if neither variable yields an address.
80fn required_labjack_ip_from_env() -> Result<String, LJMError> {
81    env_identifier("LABJACK_IP")
82        .or_else(|| {
83            env_identifier("LABJACK_IDENTIFIER").filter(|value| parse_ipv4(value).is_some())
84        })
85        .ok_or_else(|| {
86            LJMError::LibraryError(
87                "LABJACK_IP is required; direct Ethernet IP open is the only supported path"
88                    .to_string(),
89            )
90        })
91}
92
93/// Reads an optional expected LabJack serial number from `LABJACK_SERIAL`.
94///
95/// # Returns
96///
97/// The serial number, or `None` if the variable is unset, `ANY`, or not an `i32`.
98/// A value that does not parse is ignored rather than reported.
99fn requested_labjack_serial_from_env() -> Option<i32> {
100    env_identifier("LABJACK_SERIAL").and_then(|value| value.parse::<i32>().ok())
101}
102
103/// Extracts the numeric LJM code from an `LJMError` when present.
104///
105/// # Arguments
106///
107/// * `err` - Error returned by an `ljmrs` call.
108///
109/// # Returns
110///
111/// The code for `LJMError::ErrorCode`, or `None` for other variants.
112fn ljm_error_code(err: &LJMError) -> Option<i32> {
113    match err {
114        LJMError::ErrorCode(code, _) => Some(code.into()),
115        _ => None,
116    }
117}
118
119/// Returns true when an LJM error indicates a stale active stream.
120///
121/// # Arguments
122///
123/// * `err` - Error returned by an `ljmrs` call.
124fn is_stream_active_error(err: &LJMError) -> bool {
125    ljm_error_code(err) == Some(STREAM_IS_ACTIVE_ERROR)
126}
127
128/// Opens the configured LabJack for the default streamer path.
129///
130/// Calls [`open_streamer_labjack_from_env`].
131///
132/// # Returns
133///
134/// The LJM device handle.
135///
136/// # Errors
137///
138/// Same as [`open_streamer_labjack_from_env`].
139pub fn open_labjack_from_env() -> Result<i32, LJMError> {
140    open_streamer_labjack_from_env()
141}
142
143/// Opens, verifies, and self-tests the LabJack configured by environment.
144///
145/// Opens a T7 over Ethernet at the configured IP address, then checks the device
146/// type, the IP reported by the handle (skipped when LJM reports none), and the
147/// optional serial number. As a self-test it reads `STREAM_SETTLING_US` and writes
148/// the same value back. If a stale active stream blocks the write, it sends
149/// `stream_stop` once and retries. If any check fails, the handle is closed before
150/// the error is returned.
151///
152/// # Returns
153///
154/// The LJM device handle. The caller owns it and must close it.
155///
156/// # Errors
157///
158/// Returns `LJMError::LibraryError` if no IP address is configured, the device
159/// cannot be opened, the handle info cannot be read, the device is not a T7, its
160/// IP or serial number does not match, or the self-test read or write fails
161/// (including after `stream_stop`, or if `stream_stop` itself fails).
162pub fn open_streamer_labjack_from_env() -> Result<i32, LJMError> {
163    let requested_ip = required_labjack_ip_from_env()?;
164    let expected_serial = requested_labjack_serial_from_env();
165    let requested_name = env_identifier("LABJACK_NAME");
166
167    println!("[labjack] trying ethernet identifier '{requested_ip}'");
168    if let Some(name) = requested_name.as_deref() {
169        println!("[labjack] requested logical device name '{name}'");
170    }
171
172    let handle = LJMLibrary::open_jack(
173        DeviceType::T7,
174        ConnectionType::ETHERNET,
175        requested_ip.as_str(),
176    )
177    .map_err(|err| {
178        LJMError::LibraryError(format!(
179            "Could not open LabJack via LABJACK_IP='{}': {:?}",
180            requested_ip, err
181        ))
182    })?;
183
184    let verification = (|| -> Result<DeviceHandleInfo, LJMError> {
185        let info = handle_info(handle).map_err(|err| {
186            LJMError::LibraryError(format!(
187                "Opened LabJack at '{}' but failed to read handle info: {:?}",
188                requested_ip, err
189            ))
190        })?;
191
192        if !matches!(info.device_type, DeviceType::T7) {
193            return Err(LJMError::LibraryError(format!(
194                "Connected device at '{}' is not a T7: {:?}",
195                requested_ip, info.device_type
196            )));
197        }
198
199        let actual_ip = handle_ip_address(&info)?.unwrap_or_else(|| "N/A".to_string());
200        if actual_ip != "N/A" && actual_ip != requested_ip {
201            return Err(LJMError::LibraryError(format!(
202                "Connected device IP mismatch: requested '{}', got '{}'",
203                requested_ip, actual_ip
204            )));
205        }
206
207        if let Some(expected_serial) = expected_serial {
208            if info.serial_number != expected_serial {
209                return Err(LJMError::LibraryError(format!(
210                    "Connected LabJack serial mismatch at '{}': expected {}, got {}",
211                    requested_ip, expected_serial, info.serial_number
212                )));
213            }
214        }
215
216        let settling_us = LJMLibrary::read_name(handle, "STREAM_SETTLING_US").map_err(|err| {
217            LJMError::LibraryError(format!(
218                "LabJack self-test read failed for '{}': STREAM_SETTLING_US: {:?}",
219                requested_ip, err
220            ))
221        })?;
222
223        match LJMLibrary::write_name(handle, "STREAM_SETTLING_US", settling_us) {
224            Ok(_) => {}
225            Err(err) if is_stream_active_error(&err) => {
226                eprintln!(
227                    "[labjack] stale active stream detected on '{}'; sending stream_stop and retrying self-test",
228                    requested_ip
229                );
230                LJMLibrary::stream_stop(handle).map_err(|stop_err| {
231                    LJMError::LibraryError(format!(
232                        "LabJack stream_stop failed for stale stream on '{}': {:?}",
233                        requested_ip, stop_err
234                    ))
235                })?;
236                LJMLibrary::write_name(handle, "STREAM_SETTLING_US", settling_us).map_err(
237                    |retry_err| {
238                        LJMError::LibraryError(format!(
239                            "LabJack self-test write failed after stream_stop for '{}': STREAM_SETTLING_US={}: {:?}",
240                            requested_ip, settling_us, retry_err
241                        ))
242                    },
243                )?;
244            }
245            Err(err) => {
246                return Err(LJMError::LibraryError(format!(
247                    "LabJack self-test write failed for '{}': STREAM_SETTLING_US={}: {:?}",
248                    requested_ip, settling_us, err
249                )));
250            }
251        }
252
253        println!(
254            "[labjack] connected via {:?}, serial {}, ip {}, self-test ok",
255            info.connection_type, info.serial_number, actual_ip
256        );
257
258        Ok(info)
259    })();
260
261    if let Err(err) = verification {
262        let _ = LJMLibrary::close_jack(handle);
263        return Err(err);
264    }
265
266    Ok(handle)
267}
268
269/// Reads LabJack metadata for an open handle.
270///
271/// # Arguments
272///
273/// * `handle` - LJM device handle.
274///
275/// # Errors
276///
277/// Returns the LJM error if the handle info cannot be read.
278pub fn handle_info(handle: i32) -> Result<DeviceHandleInfo, LJMError> {
279    LJMLibrary::get_handle_info(handle)
280}
281
282#[allow(dead_code)]
283/// Converts the signed IPv4 bits in LabJack handle info to dotted decimal text.
284///
285/// LJM reports the address as an `i32`; its bits are reinterpreted as `u32`, so
286/// addresses at or above `128.0.0.0` (negative as `i32`) convert correctly.
287///
288/// # Arguments
289///
290/// * `info` - Handle info from [`handle_info`].
291///
292/// # Returns
293///
294/// The address, or `None` when LJM reports `0` (no IP address).
295///
296/// # Errors
297///
298/// Never returns an error. The `Result` return type is kept for callers that use
299/// `?`.
300pub fn handle_ip_address(info: &DeviceHandleInfo) -> Result<Option<String>, LJMError> {
301    if info.ip_address == 0 {
302        return Ok(None);
303    }
304
305    let ip = Ipv4Addr::from(info.ip_address as u32);
306    Ok(Some(ip.to_string()))
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312
313    /// Checks that a negative `i32` address converts to the right dotted quad.
314    #[test]
315    fn handle_ip_address_converts_signed_ipv4_bits() {
316        let info = DeviceHandleInfo {
317            device_type: DeviceType::T7,
318            connection_type: ConnectionType::ETHERNET,
319            ip_address: -1062731418,
320            max_bytes_per_megabyte: 0,
321            serial_number: 0,
322            port: 0,
323        };
324
325        let ip = handle_ip_address(&info).expect("conversion should succeed");
326        assert_eq!(ip.as_deref(), Some("192.168.1.102"));
327    }
328
329    /// Checks that address 0 is treated as missing.
330    #[test]
331    fn handle_ip_address_returns_none_for_zero() {
332        let info = DeviceHandleInfo {
333            device_type: DeviceType::T7,
334            connection_type: ConnectionType::ETHERNET,
335            ip_address: 0,
336            max_bytes_per_megabyte: 0,
337            serial_number: 0,
338            port: 0,
339        };
340
341        let ip = handle_ip_address(&info).expect("zero should be treated as missing");
342        assert_eq!(ip, None);
343    }
344}