1use 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#[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#[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 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 pub fn bio_channels(&self, mode: WorkingMode) -> usize {
109 let base = self.nch_setting();
110 if mode == WorkingMode::Bipolar {
111 base / 2 } else {
113 base
114 }
115 }
116
117 pub fn total_channels(&self, mode: WorkingMode) -> usize {
119 self.bio_channels(mode) + 4 }
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#[derive(Debug, Clone, Copy, PartialEq)]
138pub enum WorkingMode {
139 Monopolar = 0b000,
140 Bipolar = 0b001,
141 Differential = 0b010,
142 Accelerometers = 0b011,
143 BipolarAD4x8SP = 0b100, 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 pub fn external_gain(&self) -> f32 {
169 match self {
170 WorkingMode::BipolarAD4x8SP => 5.0, _ => 1.0,
172 }
173 }
174}
175
176#[derive(Debug, Clone, Copy, PartialEq)]
178pub enum Gain {
179 Gain2 = 0b00, 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, }
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#[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 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 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 fn convert_data__f32(&self, raw_data: &[i32]) -> Vec<f32> {
400 let factor = self.get_conversion_factor() / 1000.0; raw_data.iter().map(|&x| x as f32 * factor).collect()
402 }
403
404 fn convert_data__i32(&self, raw_data: &[i32]) -> Vec<i32> {
406 raw_data.to_vec()
407 }
408
409 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 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 let mut chns = desc.append_child("channels");
445
446 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 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 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 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 fn configure_device(&self, stream: &mut TcpStream) -> Result<()> {
535 let control_byte_0: u8 = 0u8 << 7 | (self.sampling_frequency as u8) << 5
538 | (self.channel_count as u8) << 3
539 | (self.working_mode as u8);
540
541 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 | 0 << 1 | 1; 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 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 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 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 if raw & 0x00800000 != 0 {
607 raw | 0xFF000000u32 as i32
608 } else {
609 raw
610 }
611 } else {
612 cursor.read_i16::<BigEndian>()? as i32
614 };
615 raw_sample.push(value);
616 }
617
618 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 let control_byte_0 = 0u8; let control_byte_1 = 0u8; let _ = stream.write_all(&[control_byte_0, control_byte_1]);
642
643 Ok(())
644 }
645}
646
647fn main() -> Result<()> {
648 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 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 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 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 if working_mode == WorkingMode::Accelerometers {
693 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 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}