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}