sessantaquattro_lsl_interface/
main.rs

1//! Sessantaquattro bioelectrical device TCP server and LSL streamer
2//!
3//! This module implements a TCP server that connects to OT Bioelettronica Sessantaquattro devices
4//! and streams multi-channel bioelectrical data via Lab Streaming Layer (LSL). The Sessantaquattro
5//! is a high-density wireless acquisition system supporting up to 64 bioelectrical channels plus
6//! auxiliary and accessory channels.
7//!
8//! ## Features
9//! - TCP server with configurable host/port and timeouts
10//! - Support for multiple acquisition modes (monopolar, bipolar, differential, accelerometers, impedance check, test)
11//! - Configurable sampling rates (500, 1000, 2000, 4000 Hz)
12//! - Variable channel counts (8, 16, 32, 64 bioelectrical + 2 AUX + 2 accessory)
13//! - 16-bit or 24-bit resolution
14//! - Configurable gain settings
15//! - High-pass filter option
16//! - Real-time data conversion from raw ADC values to volts
17//! - LSL streaming with proper channel metadata
18//! - Builder pattern API for easy configuration
19//!
20//! ## Device Protocol
21//! The Sessantaquattro uses a 13-byte configuration protocol with detailed control over
22//! acquisition parameters. Data streaming occurs after sending configuration commands.
23//!
24//! For complete device documentation and specifications, see:
25//! TCP Communication Protocol v1.8 (included with device)
26
27use byteorder::{BigEndian, ReadBytesExt};
28use chrono::Datelike;
29use clap::Parser;
30use lsl::Pushable;
31use socket2::{Domain, Socket, Type};
32use std::io::{Read, Write};
33use std::net::{SocketAddr, TcpListener, TcpStream};
34use std::time::{Duration, Instant};
35use thiserror::Error;
36
37#[derive(Error, Debug)]
38pub enum SessantaquattroError {
39    #[error("IO error: {0}")]
40    Io(#[from] std::io::Error),
41    #[error("LSL error: {0}")]
42    Lsl(#[from] lsl::Error),
43    #[error("Connection timeout: No connection received within {0} seconds")]
44    ConnectionTimeout(u64),
45    #[error("Data timeout: No data received within {0} seconds")]
46    DataTimeout(u64),
47    #[error("Configuration error: {0}")]
48    Configuration(String),
49}
50
51type Result<T> = std::result::Result<T, SessantaquattroError>;
52
53/// Sampling frequency options
54#[derive(Debug, Clone, Copy, PartialEq)]
55pub enum SamplingFrequency {
56    Hz4000 = 0b11,
57    Hz2000 = 0b10,
58    Hz1000 = 0b01,
59    Hz500 = 0b00,
60}
61
62impl SamplingFrequency {
63    pub fn as_f64(&self) -> f64 {
64        match self {
65            SamplingFrequency::Hz500 => 500.0,
66            SamplingFrequency::Hz1000 => 1000.0,
67            SamplingFrequency::Hz2000 => 2000.0,
68            SamplingFrequency::Hz4000 => 4000.0,
69        }
70    }
71
72    pub fn from_u16(value: u16) -> Result<Self> {
73        match value {
74            500 => Ok(SamplingFrequency::Hz500),
75            1000 => Ok(SamplingFrequency::Hz1000),
76            2000 => Ok(SamplingFrequency::Hz2000),
77            4000 => Ok(SamplingFrequency::Hz4000),
78            _ => Err(SessantaquattroError::Configuration(format!(
79                "Invalid sampling frequency: {}. Must be 500, 1000, 2000, or 4000",
80                value
81            ))),
82        }
83    }
84}
85
86/// Number of channels configuration
87#[derive(Debug, Clone, Copy, PartialEq)]
88pub enum ChannelCount {
89    Ch64 = 0b11,
90    Ch32 = 0b10,
91    Ch16 = 0b01,
92    Ch8 = 0b00,
93}
94
95impl ChannelCount {
96    /// Returns the NCH setting value (not the actual channel count)
97    pub fn nch_setting(&self) -> usize {
98        match self {
99            ChannelCount::Ch8 => 8,
100            ChannelCount::Ch16 => 16,
101            ChannelCount::Ch32 => 32,
102            ChannelCount::Ch64 => 64,
103        }
104    }
105
106    /// Returns actual bioelectrical channels for a given mode
107    /// In Bipolar mode (001), channel count is halved
108    pub fn bio_channels(&self, mode: WorkingMode) -> usize {
109        let base = self.nch_setting();
110        if mode == WorkingMode::Bipolar {
111            base / 2 // Bipolar mode halves the channel count
112        } else {
113            base
114        }
115    }
116
117    /// Returns total channels (bio + 2 AUX + 2 accessory) for a given mode
118    pub fn total_channels(&self, mode: WorkingMode) -> usize {
119        self.bio_channels(mode) + 4 // bio + 2 AUX + 2 accessory
120    }
121
122    pub fn from_u8(value: u8) -> Result<Self> {
123        match value {
124            8 => Ok(ChannelCount::Ch8),
125            16 => Ok(ChannelCount::Ch16),
126            32 => Ok(ChannelCount::Ch32),
127            64 => Ok(ChannelCount::Ch64),
128            _ => Err(SessantaquattroError::Configuration(format!(
129                "Invalid channel count: {}. Must be 8, 16, 32, or 64",
130                value
131            ))),
132        }
133    }
134}
135
136/// Working mode options
137#[derive(Debug, Clone, Copy, PartialEq)]
138pub enum WorkingMode {
139    Monopolar = 0b000,
140    Bipolar = 0b001,
141    Differential = 0b010,
142    Accelerometers = 0b011,
143    BipolarAD4x8SP = 0b100, // Sessantaquattro+ only: External preamps with gain of 5
144    ImpedanceCheckAdvanced = 0b101,
145    ImpedanceCheck = 0b110,
146    TestMode = 0b111,
147}
148
149impl WorkingMode {
150    pub fn from_str(s: &str) -> Result<Self> {
151        match s.to_lowercase().as_str() {
152            "monopolar" => Ok(WorkingMode::Monopolar),
153            "bipolar" => Ok(WorkingMode::Bipolar),
154            "differential" => Ok(WorkingMode::Differential),
155            "accelerometers" | "accel" => Ok(WorkingMode::Accelerometers),
156            "bipolar-ad4x8sp" | "ad4x8sp" => Ok(WorkingMode::BipolarAD4x8SP),
157            "impedance-advanced" => Ok(WorkingMode::ImpedanceCheckAdvanced),
158            "impedance" => Ok(WorkingMode::ImpedanceCheck),
159            "test" => Ok(WorkingMode::TestMode),
160            _ => Err(SessantaquattroError::Configuration(format!(
161                "Invalid mode: {}. Valid modes: monopolar, bipolar, differential, accelerometers, bipolar-ad4x8sp, impedance, impedance-advanced, test",
162                s
163            ))),
164        }
165    }
166
167    /// Returns the external gain factor for modes with external preamps
168    pub fn external_gain(&self) -> f32 {
169        match self {
170            WorkingMode::BipolarAD4x8SP => 5.0, // AD4x8SP has external preamps with gain of 5
171            _ => 1.0,
172        }
173    }
174}
175
176/// Preamp gain options
177#[derive(Debug, Clone, Copy, PartialEq)]
178pub enum Gain {
179    Gain2 = 0b00, // Only when HRES=1
180    Gain4 = 0b01,
181    Gain6 = 0b10,
182    Gain8 = 0b11,
183}
184
185impl Gain {
186    pub fn from_u8(value: u8) -> Result<Self> {
187        match value {
188            2 => Ok(Gain::Gain2),
189            4 => Ok(Gain::Gain4),
190            6 => Ok(Gain::Gain6),
191            8 => Ok(Gain::Gain8),
192            _ => Err(SessantaquattroError::Configuration(format!(
193                "Invalid gain: {}. Must be 2, 4, 6, or 8",
194                value
195            ))),
196        }
197    }
198
199    #[allow(non_snake_case)]
200    pub fn get_resolution__nV(&self, high_res: bool) -> f32 {
201        match (self, high_res) {
202            (Gain::Gain8, true) => 71.5,
203            (Gain::Gain8, false) => 286.1,
204            (Gain::Gain6, true) => 95.4,
205            (Gain::Gain6, false) => 381.5,
206            (Gain::Gain4, true) => 143.0,
207            (Gain::Gain4, false) => 572.2,
208            (Gain::Gain2, true) => 286.1,
209            (Gain::Gain2, false) => 286.1, // Same as gain 8 when HRES=0
210        }
211    }
212}
213
214#[derive(Parser)]
215#[command(name = "sessantaquattro-lsl-interface")]
216#[command(
217    about = "Sessantaquattro LSL Interface - streams bioelectrical data via Lab Streaming Layer"
218)]
219struct Args {
220    #[arg(long, default_value = "0.0.0.0", help = "Host to bind to")]
221    host: String,
222
223    #[arg(long, default_value = "45454", help = "Port to listen on")]
224    port: u16,
225
226    #[arg(long, default_value = "30", help = "Connection timeout in seconds")]
227    connection_timeout: u64,
228
229    #[arg(long, default_value = "5", help = "Data timeout in seconds")]
230    data_timeout: u64,
231
232    #[arg(
233        long,
234        default_value = "2000",
235        help = "Sampling frequency in Hz (500, 1000, 2000, 4000)"
236    )]
237    sampling_frequency: u16,
238
239    #[arg(
240        long,
241        default_value = "64",
242        help = "Number of bioelectrical channels (8, 16, 32, 64)"
243    )]
244    channels: u8,
245
246    #[arg(
247        long,
248        default_value = "8",
249        help = "Preamp gain (2, 4, 6, 8). Note: gain 2 only available with --high-res"
250    )]
251    gain: u8,
252
253    #[arg(long, help = "Enable high resolution mode (24-bit)")]
254    high_res: bool,
255
256    #[arg(long, help = "Enable high pass filter")]
257    hpf: bool,
258
259    #[arg(
260        long,
261        default_value = "monopolar",
262        help = "Working mode: monopolar, bipolar, differential, accelerometers, bipolar-ad4x8sp, impedance, impedance-advanced, test"
263    )]
264    mode: String,
265
266    #[arg(long, help = "Stream raw ADC values instead of volts")]
267    no_conversion: bool,
268
269    #[arg(
270        long,
271        default_value = "sessantaquattro-001",
272        help = "LSL stream source ID"
273    )]
274    lsl_uuid: String,
275}
276
277/// A TCP server that connects to Sessantaquattro devices and streams data via LSL.
278#[allow(non_snake_case)]
279pub struct SessantaquattroReader {
280    host: String,
281    port: u16,
282    connection_timeout__s: u64,
283    data_timeout__s: u64,
284    sampling_frequency: SamplingFrequency,
285    channel_count: ChannelCount,
286    working_mode: WorkingMode,
287    high_res: bool,
288    hpf: bool,
289    gain: Gain,
290    apply_conversion: bool,
291    lsl_uuid: String,
292}
293
294impl Default for SessantaquattroReader {
295    fn default() -> Self {
296        Self {
297            host: "0.0.0.0".to_string(),
298            port: 54320,
299            connection_timeout__s: 30,
300            data_timeout__s: 5,
301            sampling_frequency: SamplingFrequency::Hz2000,
302            channel_count: ChannelCount::Ch64,
303            working_mode: WorkingMode::Monopolar,
304            high_res: false,
305            hpf: true,
306            gain: Gain::Gain4,
307            apply_conversion: true,
308            lsl_uuid: "sessantaquattro-001".to_string(),
309        }
310    }
311}
312
313#[allow(non_snake_case)]
314impl SessantaquattroReader {
315    pub fn new() -> Self {
316        Self::default()
317    }
318
319    pub fn host(mut self, host: &str) -> Self {
320        self.host = host.to_string();
321        self
322    }
323
324    pub fn port(mut self, port: u16) -> Self {
325        self.port = port;
326        self
327    }
328
329    pub fn connection_timeout(mut self, timeout_s: u64) -> Self {
330        self.connection_timeout__s = timeout_s;
331        self
332    }
333
334    pub fn data_timeout(mut self, timeout_s: u64) -> Self {
335        self.data_timeout__s = timeout_s;
336        self
337    }
338
339    pub fn sampling_frequency(mut self, freq: SamplingFrequency) -> Self {
340        self.sampling_frequency = freq;
341        self
342    }
343
344    pub fn channel_count(mut self, count: ChannelCount) -> Self {
345        self.channel_count = count;
346        self
347    }
348
349    pub fn working_mode(mut self, mode: WorkingMode) -> Self {
350        self.working_mode = mode;
351        self
352    }
353
354    pub fn high_res(mut self, high_res: bool) -> Self {
355        self.high_res = high_res;
356        self
357    }
358
359    pub fn hpf(mut self, hpf: bool) -> Self {
360        self.hpf = hpf;
361        self
362    }
363
364    pub fn gain(mut self, gain: Gain) -> Self {
365        self.gain = gain;
366        self
367    }
368
369    pub fn apply_conversion(mut self, apply_conversion: bool) -> Self {
370        self.apply_conversion = apply_conversion;
371        self
372    }
373
374    pub fn lsl_uuid(mut self, lsl_uuid: &str) -> Self {
375        self.lsl_uuid = lsl_uuid.to_string();
376        self
377    }
378
379    /// Returns the actual sampling frequency, accounting for mode-specific multipliers
380    /// In accelerometers mode, FSAMP encoding represents 4x the normal frequency
381    fn get_actual_sampling_frequency(&self) -> f64 {
382        let base_freq = self.sampling_frequency.as_f64();
383        if self.working_mode == WorkingMode::Accelerometers {
384            base_freq * 4.0
385        } else {
386            base_freq
387        }
388    }
389
390    /// Returns the conversion factor from ADC values to microvolts
391    /// Accounts for both internal preamp gain and external gain (e.g., AD4x8SP)
392    fn get_conversion_factor(&self) -> f32 {
393        let base_resolution = self.gain.get_resolution__nV(self.high_res);
394        let external_gain = self.working_mode.external_gain();
395        base_resolution * external_gain
396    }
397
398    /// Converts raw ADC values to microvolts
399    fn convert_data__f32(&self, raw_data: &[i32]) -> Vec<f32> {
400        let factor = self.get_conversion_factor() / 1000.0; // Convert nV to microvolts
401        raw_data.iter().map(|&x| x as f32 * factor).collect()
402    }
403
404    /// Returns raw ADC values without conversion
405    fn convert_data__i32(&self, raw_data: &[i32]) -> Vec<i32> {
406        raw_data.to_vec()
407    }
408
409    /// Creates LSL stream info with proper channel metadata
410    fn create_stream_info(&self) -> Result<lsl::StreamInfo> {
411        let total_channels = self.channel_count.total_channels(self.working_mode);
412        let bio_channels = self.channel_count.bio_channels(self.working_mode);
413
414        let mut info = lsl::StreamInfo::new(
415            "SESSANTAQUATTRO",
416            "SESSANTAQUATTRO_DATA",
417            total_channels as u32,
418            self.get_actual_sampling_frequency(),
419            if self.apply_conversion {
420                lsl::ChannelFormat::Float32
421            } else {
422                lsl::ChannelFormat::Int32
423            },
424            &self.lsl_uuid,
425        )?;
426
427        // Add device metadata
428        let mut desc = info.desc();
429        desc.append_child_value("manufacturer", "OTBioelettronica");
430        desc.append_child_value("device", "Sessantaquattro");
431        desc.append_child_value(
432            "conversion_factor_nv",
433            &self.get_conversion_factor().to_string(),
434        );
435        desc.append_child_value(
436            "resolution",
437            if self.high_res { "24-bit" } else { "16-bit" },
438        );
439        desc.append_child_value("gain", &format!("{:?}", self.gain));
440        desc.append_child_value("hpf", &self.hpf.to_string());
441        desc.append_child_value("mode", &format!("{:?}", self.working_mode));
442
443        // Add channel information
444        let mut chns = desc.append_child("channels");
445
446        // Bioelectrical channels
447        for i in 0..bio_channels {
448            let mut ch = chns.append_child("channel");
449            ch.append_child_value("label", &format!("CH {}", i + 1));
450            ch.append_child_value(
451                "unit",
452                if self.apply_conversion {
453                    "microvolts"
454                } else {
455                    "dimensionless"
456                },
457            );
458            ch.append_child_value("type", "BIO");
459        }
460
461        // AUX channels
462        for i in 0..2 {
463            let mut ch = chns.append_child("channel");
464            ch.append_child_value("label", &format!("AUX {}", i + 1));
465            ch.append_child_value("unit", "dimensionless");
466            ch.append_child_value("type", "AUX");
467        }
468
469        // Accessory channels
470        for i in 0..2 {
471            let mut ch = chns.append_child("channel");
472            ch.append_child_value("label", &format!("ACC {}", i + 1));
473            ch.append_child_value("unit", "dimensionless");
474            ch.append_child_value("type", "ACCESSORY");
475        }
476
477        Ok(info)
478    }
479
480    /// Waits for a TCP connection from the Sessantaquattro device
481    fn wait_for_connection(&self) -> Result<TcpStream> {
482        let address: SocketAddr = format!("{}:{}", self.host, self.port)
483            .parse::<SocketAddr>()
484            .map_err(|_| {
485                std::io::Error::new(std::io::ErrorKind::InvalidInput, "Invalid address")
486            })?;
487
488        let socket = Socket::new(Domain::IPV4, Type::STREAM, None)?;
489        socket.set_reuse_address(true)?;
490        socket.bind(&address.into())?;
491        socket.listen(1)?;
492
493        let listener: TcpListener = socket.into();
494        listener.set_nonblocking(true)?;
495
496        println!(
497            "Waiting for Sessantaquattro connection on {}:{} (timeout: {}s)",
498            self.host, self.port, self.connection_timeout__s
499        );
500
501        let start_time = Instant::now();
502
503        loop {
504            if start_time.elapsed().as_secs() > self.connection_timeout__s {
505                return Err(SessantaquattroError::ConnectionTimeout(
506                    self.connection_timeout__s,
507                ));
508            }
509
510            match listener.accept() {
511                Ok((stream, addr)) => {
512                    println!("Sessantaquattro connected from {}", addr);
513                    stream.set_nonblocking(false)?;
514                    stream.set_nodelay(true)?;
515                    stream.set_read_timeout(Some(Duration::from_secs(self.data_timeout__s)))?;
516                    stream.set_write_timeout(Some(Duration::from_secs(self.data_timeout__s)))?;
517                    return Ok(stream);
518                }
519                Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
520                    std::thread::sleep(Duration::from_millis(100));
521                    continue;
522                }
523                #[cfg(windows)]
524                Err(ref e) if e.raw_os_error() == Some(10035) => {
525                    std::thread::sleep(Duration::from_millis(100));
526                    continue;
527                }
528                Err(e) => return Err(SessantaquattroError::Io(e)),
529            }
530        }
531    }
532
533    /// Sends device configuration command
534    fn configure_device(&self, stream: &mut TcpStream) -> Result<()> {
535        // CONTROL BYTE 0: [GETSET][FSAMP1][FSAMP0][NCH1][NCH0][MODE2][MODE1][MODE0]
536        let control_byte_0: u8 = 0u8 << 7 // GETSET = 0 (SET command)
537            | (self.sampling_frequency as u8) << 5
538            | (self.channel_count as u8) << 3
539            | (self.working_mode as u8);
540
541        // CONTROL BYTE 1: [HRES][HPF][GAIN1][GAIN0][TRIG1][TRIG0][REC][GO/STOP]
542        let control_byte_1: u8 = (self.high_res as u8) << 7
543            | (self.hpf as u8) << 6
544            | (self.gain as u8) << 4
545            | 0b00 << 2 // TRIG = 00 (controlled by GO/STOP bit)
546            | 0 << 1 // REC = 0 (no SD card recording)
547            | 1; // GO/STOP = 1 (start streaming)
548
549        println!("Configuring device:");
550        println!(
551            "\tSampling frequency: {} Hz",
552            self.get_actual_sampling_frequency()
553        );
554        println!(
555            "\tChannels: {} bio + 2 AUX + 2 accessory",
556            self.channel_count.bio_channels(self.working_mode)
557        );
558        println!("\tMode: {:?}", self.working_mode);
559        println!(
560            "\tResolution: {}",
561            if self.high_res { "24-bit" } else { "16-bit" }
562        );
563        println!("\tHPF: {}", if self.hpf { "enabled" } else { "disabled" });
564        println!("\tGain: {:?}", self.gain);
565        println!(
566            "\tControl bytes: 0x{:02X} 0x{:02X}",
567            control_byte_0, control_byte_1
568        );
569
570        stream.write_all(&[control_byte_0, control_byte_1])?;
571        stream.flush()?;
572
573        Ok(())
574    }
575
576    /// Runs the main acquisition loop
577    pub fn run(&self) -> Result<()> {
578        let mut stream = self.wait_for_connection()?;
579
580        let outlet = lsl::StreamOutlet::new(&self.create_stream_info()?, 0, 360)?;
581
582        self.configure_device(&mut stream)?;
583
584        let total_channels = self.channel_count.total_channels(self.working_mode);
585        let bytes_per_sample = if self.high_res { 3 } else { 2 };
586        let buffer_size = total_channels * bytes_per_sample;
587        let mut buffer: Vec<u8> = vec![0u8; buffer_size];
588
589        println!("Reading data... (Ctrl+C to stop)");
590
591        loop {
592            match stream.read_exact(&mut buffer) {
593                Ok(()) => {
594                    // Parse data based on resolution
595                    let mut raw_sample: Vec<i32> = Vec::with_capacity(total_channels);
596                    let mut cursor = std::io::Cursor::new(&buffer);
597
598                    for _ in 0..total_channels {
599                        let value = if self.high_res {
600                            // 24-bit signed integer (big-endian: MSB first)
601                            let byte1 = cursor.read_u8()?;
602                            let byte2 = cursor.read_u8()?;
603                            let byte3 = cursor.read_u8()?;
604                            let raw = (byte1 as i32) << 16 | (byte2 as i32) << 8 | (byte3 as i32);
605                            // Sign extend from 24-bit to 32-bit
606                            if raw & 0x00800000 != 0 {
607                                raw | 0xFF000000u32 as i32
608                            } else {
609                                raw
610                            }
611                        } else {
612                            // 16-bit signed integer (big-endian: MSB first)
613                            cursor.read_i16::<BigEndian>()? as i32
614                        };
615                        raw_sample.push(value);
616                    }
617
618                    // Convert and push to LSL (let LSL assign timestamp automatically)
619                    if self.apply_conversion {
620                        let converted = self.convert_data__f32(&raw_sample);
621                        outlet.push_sample(&converted)?;
622                    } else {
623                        let converted = self.convert_data__i32(&raw_sample);
624                        outlet.push_sample(&converted)?;
625                    }
626                }
627                Err(e) if e.kind() == std::io::ErrorKind::TimedOut => {
628                    return Err(SessantaquattroError::DataTimeout(self.data_timeout__s));
629                }
630                Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
631                    println!("Connection closed by Sessantaquattro");
632                    break;
633                }
634                Err(e) => return Err(SessantaquattroError::Io(e)),
635            }
636        }
637
638        // Stop streaming
639        let control_byte_0 = 0u8; // GETSET = 0
640        let control_byte_1 = 0u8; // GO/STOP = 0
641        let _ = stream.write_all(&[control_byte_0, control_byte_1]);
642
643        Ok(())
644    }
645}
646
647fn main() -> Result<()> {
648    // Display GPL license notice
649    let current_year = chrono::Utc::now().year();
650    let copyright_year = if current_year == 2025 {
651        "2025".to_string()
652    } else {
653        format!("2025-{}", current_year)
654    };
655
656    println!(
657        "sessantaquattro-lsl-interface Copyright (C) {} Raul C. Sîmpetru",
658        copyright_year
659    );
660    println!("This program comes with ABSOLUTELY NO WARRANTY; for details see");
661    println!("https://www.gnu.org/licenses/gpl-3.0.html#license-text.");
662    println!("This is free software, and you are welcome to redistribute it");
663    println!(
664        "under certain conditions; for details see https://www.gnu.org/licenses/gpl-3.0.html#license-text."
665    );
666    println!();
667
668    let args = Args::parse();
669
670    // Parse and validate arguments
671    let sampling_frequency = SamplingFrequency::from_u16(args.sampling_frequency)?;
672    let mut channel_count = ChannelCount::from_u8(args.channels)?;
673    let gain = Gain::from_u8(args.gain)?;
674    let working_mode = WorkingMode::from_str(&args.mode)?;
675
676    // Validate gain 2 only with high resolution
677    if gain == Gain::Gain2 && !args.high_res {
678        eprintln!("Error: Gain 2 is only available with --high-res");
679        std::process::exit(1);
680    }
681
682    // Bipolar mode halves the channel count
683    if working_mode == WorkingMode::Bipolar {
684        let actual_channels = channel_count.bio_channels(working_mode);
685        println!(
686            "Note: Bipolar mode provides {} bio channels (pairs from {} inputs: Ch3-Ch1, Ch4-Ch2, etc.)",
687            actual_channels, channel_count.nch_setting()
688        );
689    }
690
691    // Accelerometers mode has special behavior
692    if working_mode == WorkingMode::Accelerometers {
693        // Forces 8 channels regardless of NCH setting
694        if channel_count != ChannelCount::Ch8 {
695            println!(
696                "Warning: Accelerometers mode forces 8 channels (you specified {}). Using 8 channels.",
697                channel_count.nch_setting()
698            );
699            channel_count = ChannelCount::Ch8;
700        }
701        // FSAMP has different meaning: 500→2000, 1000→4000, 2000→8000, 4000→16000 Hz
702        let actual_freq = sampling_frequency.as_f64() * 4.0;
703        println!(
704            "Note: In accelerometers mode, sampling frequency is {}Hz (4x the normal rate)",
705            actual_freq
706        );
707    }
708
709    let reader = SessantaquattroReader::new()
710        .host(&args.host)
711        .port(args.port)
712        .connection_timeout(args.connection_timeout)
713        .data_timeout(args.data_timeout)
714        .sampling_frequency(sampling_frequency)
715        .channel_count(channel_count)
716        .working_mode(working_mode)
717        .high_res(args.high_res)
718        .hpf(args.hpf)
719        .gain(gain)
720        .apply_conversion(!args.no_conversion)
721        .lsl_uuid(&args.lsl_uuid);
722
723    match reader.run() {
724        Ok(()) => println!("Connection closed"),
725        Err(e) => {
726            eprintln!("Error: {}", e);
727            std::process::exit(1);
728        }
729    }
730
731    Ok(())
732}