Skip to main content

emotiv_cortex_v2/
headset.rs

1//! # Headset Model Identification & Channel Configuration
2//!
3//! Provides [`HeadsetModel`] for identifying Emotiv headset variants from
4//! their Cortex ID string, and [`HeadsetChannelConfig`] for getting the
5//! standard EEG channel layout for each model.
6//!
7//! ## Supported Headsets
8//!
9//! | Model | Channels | Sample Rate | Electrode Positions |
10//! |-------|----------|-------------|---------------------|
11//! | Insight | 5 | 128 Hz | AF3, AF4, T7, T8, Pz |
12//! | EPOC+ | 14 | 128 Hz | Full 10-20 coverage |
13//! | EPOC X | 14 | 256 Hz | Full 10-20 coverage |
14//! | EPOC Flex | 14 | 128 Hz | Full 10-20 coverage |
15//!
16//! ## Usage
17//!
18//! ```
19//! use emotiv_cortex_v2::headset::HeadsetModel;
20//!
21//! let model = HeadsetModel::from_headset_id("INSIGHT-A1B2C3D4");
22//! assert_eq!(model, HeadsetModel::Insight);
23//! assert_eq!(model.num_channels(), 5);
24//! assert_eq!(model.sampling_rate_hz(), 128.0);
25//! ```
26
27use serde::{Deserialize, Serialize};
28
29use crate::protocol::headset::HeadsetInfo;
30
31/// Emotiv headset model identifier.
32///
33/// Inferred from the headset ID string returned by `queryHeadsets`.
34/// Emotiv headset IDs follow patterns like `INSIGHT-XXXXXXXX`,
35/// `EPOCX-XXXXXXXX`, `EPOCPLUS-XXXXXXXX`, etc.
36#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
37pub enum HeadsetModel {
38    /// Emotiv Insight — 5 EEG channels at 128 Hz.
39    /// Channels: AF3, AF4, T7, T8, Pz.
40    Insight,
41
42    /// Emotiv EPOC+ — 14 EEG channels at 128 Hz.
43    /// Full 10-20 coverage.
44    EpocPlus,
45
46    /// Emotiv EPOC X — 14 EEG channels at 256 Hz.
47    /// Same electrode positions as EPOC+, higher sampling rate.
48    EpocX,
49
50    /// Emotiv EPOC Flex — configurable up to 32 channels.
51    /// Default configuration uses the same 14-channel EPOC+ layout.
52    EpocFlex,
53
54    /// Unknown or unrecognized Emotiv headset.
55    Unknown(String),
56}
57
58/// EEG channel configuration for a headset model.
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct HeadsetChannelConfig {
61    /// Per-channel information (name, electrode position).
62    pub channels: Vec<ChannelInfo>,
63
64    /// Sampling rate in Hz (e.g. 128.0 for Insight, 256.0 for EPOC X).
65    pub sampling_rate_hz: f64,
66
67    /// ADC resolution in bits.
68    pub resolution_bits: u32,
69}
70
71/// Information about a single EEG channel.
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct ChannelInfo {
74    /// Channel name (e.g. "AF3", "T7", "Pz").
75    pub name: String,
76
77    /// Standard 10-20 system electrode position.
78    /// Same as `name` for standard placements.
79    pub position_10_20: Option<String>,
80}
81
82// ─── Insight channel names ──────────────────────────────────────────────
83
84const INSIGHT_CHANNELS: &[&str] = &["AF3", "AF4", "T7", "T8", "Pz"];
85
86// ─── EPOC 14-channel layout (EPOC+, EPOC X, EPOC Flex default) ─────────
87
88const EPOC_CHANNELS: &[&str] = &[
89    "AF3", "F7", "F3", "FC5", "T7", "P7", "O1", "O2", "P8", "T8", "FC6", "F4", "F8", "AF4",
90];
91
92// ─── HeadsetModel impl ─────────────────────────────────────────────────
93
94impl HeadsetModel {
95    /// Infer the headset model from a headset ID string.
96    ///
97    /// Emotiv headset IDs follow the pattern `MODEL-SERIAL` where MODEL
98    /// is one of INSIGHT, EPOCPLUS, EPOCX, EPOCFLEX, EPOC+, EPOC-X, etc.
99    ///
100    /// ```
101    /// use emotiv_cortex_v2::headset::HeadsetModel;
102    ///
103    /// assert_eq!(HeadsetModel::from_headset_id("INSIGHT-12345678"), HeadsetModel::Insight);
104    /// assert_eq!(HeadsetModel::from_headset_id("EPOCX-AABBCCDD"), HeadsetModel::EpocX);
105    /// assert_eq!(HeadsetModel::from_headset_id("EPOCPLUS-99887766"), HeadsetModel::EpocPlus);
106    /// ```
107    #[must_use]
108    pub fn from_headset_id(headset_id: &str) -> Self {
109        let id_upper = headset_id.to_uppercase();
110
111        if id_upper.starts_with("INSIGHT") {
112            HeadsetModel::Insight
113        } else if id_upper.starts_with("EPOCX") || id_upper.starts_with("EPOC-X") {
114            HeadsetModel::EpocX
115        } else if id_upper.starts_with("EPOCFLEX") {
116            HeadsetModel::EpocFlex
117        } else if id_upper.starts_with("EPOCPLUS")
118            || id_upper.starts_with("EPOC+")
119            || id_upper.starts_with("EPOC")
120        {
121            // Generic EPOC — assume EPOC+ layout
122            HeadsetModel::EpocPlus
123        } else {
124            HeadsetModel::Unknown(headset_id.to_string())
125        }
126    }
127
128    /// Infer the headset model from a [`HeadsetInfo`] response.
129    #[must_use]
130    pub fn from_headset_info(info: &HeadsetInfo) -> Self {
131        Self::from_headset_id(&info.id)
132    }
133
134    /// Get the standard EEG channel configuration for this headset model.
135    ///
136    /// # Examples
137    ///
138    /// ```
139    /// use emotiv_cortex_v2::headset::HeadsetModel;
140    ///
141    /// let config = HeadsetModel::EpocX.channel_config();
142    /// assert_eq!(config.channels.len(), 14);
143    /// assert_eq!(config.sampling_rate_hz, 256.0);
144    /// ```
145    #[must_use]
146    pub fn channel_config(&self) -> HeadsetChannelConfig {
147        let (names, rate): (&[&str], f64) = match self {
148            HeadsetModel::Insight | HeadsetModel::Unknown(_) => (INSIGHT_CHANNELS, 128.0),
149            HeadsetModel::EpocPlus | HeadsetModel::EpocFlex => (EPOC_CHANNELS, 128.0),
150            HeadsetModel::EpocX => (EPOC_CHANNELS, 256.0),
151        };
152
153        HeadsetChannelConfig {
154            channels: names
155                .iter()
156                .map(|&n| ChannelInfo {
157                    name: n.to_string(),
158                    position_10_20: Some(n.to_string()),
159                })
160                .collect(),
161            sampling_rate_hz: rate,
162            resolution_bits: 14,
163        }
164    }
165
166    /// Number of EEG channels for this headset model.
167    ///
168    /// # Examples
169    ///
170    /// ```
171    /// use emotiv_cortex_v2::headset::HeadsetModel;
172    ///
173    /// assert_eq!(HeadsetModel::Insight.num_channels(), 5);
174    /// assert_eq!(HeadsetModel::EpocX.num_channels(), 14);
175    /// ```
176    #[must_use]
177    pub fn num_channels(&self) -> usize {
178        match self {
179            HeadsetModel::Insight | HeadsetModel::Unknown(_) => INSIGHT_CHANNELS.len(),
180            HeadsetModel::EpocPlus | HeadsetModel::EpocX | HeadsetModel::EpocFlex => {
181                EPOC_CHANNELS.len()
182            }
183        }
184    }
185
186    /// Sampling rate in Hz for this headset model.
187    #[must_use]
188    pub fn sampling_rate_hz(&self) -> f64 {
189        match self {
190            HeadsetModel::EpocX => 256.0,
191            _ => 128.0,
192        }
193    }
194
195    /// Channel names for this headset model.
196    ///
197    /// # Examples
198    ///
199    /// ```
200    /// use emotiv_cortex_v2::headset::HeadsetModel;
201    ///
202    /// let names = HeadsetModel::Insight.channel_names();
203    /// assert_eq!(names, &["AF3", "AF4", "T7", "T8", "Pz"]);
204    /// ```
205    #[must_use]
206    pub fn channel_names(&self) -> &[&str] {
207        match self {
208            HeadsetModel::Insight | HeadsetModel::Unknown(_) => INSIGHT_CHANNELS,
209            HeadsetModel::EpocPlus | HeadsetModel::EpocX | HeadsetModel::EpocFlex => EPOC_CHANNELS,
210        }
211    }
212}
213
214impl std::fmt::Display for HeadsetModel {
215    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
216        match self {
217            HeadsetModel::Insight => write!(f, "Emotiv Insight"),
218            HeadsetModel::EpocPlus => write!(f, "Emotiv EPOC+"),
219            HeadsetModel::EpocX => write!(f, "Emotiv EPOC X"),
220            HeadsetModel::EpocFlex => write!(f, "Emotiv EPOC Flex"),
221            HeadsetModel::Unknown(id) => write!(f, "Unknown Emotiv ({id})"),
222        }
223    }
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230    // ─── Model inference ────────────────────────────────────────────────
231
232    #[test]
233    fn test_infer_insight() {
234        assert_eq!(
235            HeadsetModel::from_headset_id("INSIGHT-A1B2C3D4"),
236            HeadsetModel::Insight
237        );
238    }
239
240    #[test]
241    fn test_infer_insight_lowercase() {
242        assert_eq!(
243            HeadsetModel::from_headset_id("insight-12345678"),
244            HeadsetModel::Insight
245        );
246    }
247
248    #[test]
249    fn test_infer_epocx() {
250        assert_eq!(
251            HeadsetModel::from_headset_id("EPOCX-12345678"),
252            HeadsetModel::EpocX
253        );
254    }
255
256    #[test]
257    fn test_infer_epoc_dash_x() {
258        assert_eq!(
259            HeadsetModel::from_headset_id("EPOC-X-12345678"),
260            HeadsetModel::EpocX
261        );
262    }
263
264    #[test]
265    fn test_infer_epocplus() {
266        assert_eq!(
267            HeadsetModel::from_headset_id("EPOCPLUS-AABBCCDD"),
268            HeadsetModel::EpocPlus
269        );
270    }
271
272    #[test]
273    fn test_infer_epoc_plus_symbol() {
274        assert_eq!(
275            HeadsetModel::from_headset_id("EPOC+-AABBCCDD"),
276            HeadsetModel::EpocPlus
277        );
278    }
279
280    #[test]
281    fn test_infer_epocflex() {
282        assert_eq!(
283            HeadsetModel::from_headset_id("EPOCFLEX-11223344"),
284            HeadsetModel::EpocFlex
285        );
286    }
287
288    #[test]
289    fn test_infer_generic_epoc() {
290        assert_eq!(
291            HeadsetModel::from_headset_id("EPOC-DEADBEEF"),
292            HeadsetModel::EpocPlus
293        );
294    }
295
296    #[test]
297    fn test_infer_unknown() {
298        assert_eq!(
299            HeadsetModel::from_headset_id("MNEXYZ-12345678"),
300            HeadsetModel::Unknown("MNEXYZ-12345678".into())
301        );
302    }
303
304    #[test]
305    fn test_from_headset_info() {
306        let info = HeadsetInfo {
307            status: "connected".into(),
308            id: "INSIGHT-AAAA0000".into(),
309            connected_by: None,
310            custom_name: None,
311            dongle_serial: None,
312            firmware: None,
313            motion_sensors: None,
314            sensors: None,
315            settings: None,
316            flex_mapping: None,
317            headband_position: None,
318            is_virtual: None,
319            mode: None,
320            battery_percent: None,
321            signal_strength: None,
322            power: None,
323            virtual_headset_id: None,
324            firmware_display: None,
325            is_dfu_mode: None,
326            dfu_types: None,
327            system_up_time: None,
328            uptime: None,
329            bluetooth_up_time: None,
330            counter: None,
331            extra: std::collections::HashMap::new(),
332        };
333        assert_eq!(
334            HeadsetModel::from_headset_info(&info),
335            HeadsetModel::Insight
336        );
337    }
338
339    // ─── Channel config ─────────────────────────────────────────────────
340
341    #[test]
342    fn test_insight_channels() {
343        let model = HeadsetModel::Insight;
344        assert_eq!(model.num_channels(), 5);
345        assert_eq!(model.sampling_rate_hz(), 128.0);
346
347        let config = model.channel_config();
348        assert_eq!(config.channels.len(), 5);
349        assert_eq!(config.sampling_rate_hz, 128.0);
350        assert_eq!(config.resolution_bits, 14);
351        assert_eq!(config.channels[0].name, "AF3");
352        assert_eq!(config.channels[4].name, "Pz");
353    }
354
355    #[test]
356    fn test_epocplus_channels() {
357        let model = HeadsetModel::EpocPlus;
358        assert_eq!(model.num_channels(), 14);
359        assert_eq!(model.sampling_rate_hz(), 128.0);
360
361        let config = model.channel_config();
362        assert_eq!(config.channels.len(), 14);
363        assert_eq!(config.sampling_rate_hz, 128.0);
364        assert_eq!(config.channels[0].name, "AF3");
365        assert_eq!(config.channels[13].name, "AF4");
366    }
367
368    #[test]
369    fn test_epocx_channels() {
370        let model = HeadsetModel::EpocX;
371        assert_eq!(model.num_channels(), 14);
372        assert_eq!(model.sampling_rate_hz(), 256.0);
373
374        let config = model.channel_config();
375        assert_eq!(config.channels.len(), 14);
376        assert_eq!(config.sampling_rate_hz, 256.0);
377    }
378
379    #[test]
380    fn test_epocflex_channels() {
381        let model = HeadsetModel::EpocFlex;
382        assert_eq!(model.num_channels(), 14);
383        assert_eq!(model.sampling_rate_hz(), 128.0);
384    }
385
386    #[test]
387    fn test_unknown_falls_back_to_insight() {
388        let model = HeadsetModel::Unknown("FOO-123".into());
389        assert_eq!(model.num_channels(), 5);
390        assert_eq!(model.sampling_rate_hz(), 128.0);
391    }
392
393    // ─── Channel names ──────────────────────────────────────────────────
394
395    #[test]
396    fn test_channel_names() {
397        assert_eq!(HeadsetModel::Insight.channel_names(), INSIGHT_CHANNELS);
398        assert_eq!(HeadsetModel::EpocPlus.channel_names(), EPOC_CHANNELS);
399        assert_eq!(HeadsetModel::EpocX.channel_names(), EPOC_CHANNELS);
400    }
401
402    // ─── Display ────────────────────────────────────────────────────────
403
404    #[test]
405    fn test_display() {
406        assert_eq!(HeadsetModel::Insight.to_string(), "Emotiv Insight");
407        assert_eq!(HeadsetModel::EpocPlus.to_string(), "Emotiv EPOC+");
408        assert_eq!(HeadsetModel::EpocX.to_string(), "Emotiv EPOC X");
409        assert_eq!(HeadsetModel::EpocFlex.to_string(), "Emotiv EPOC Flex");
410        assert_eq!(
411            HeadsetModel::Unknown("FOO".into()).to_string(),
412            "Unknown Emotiv (FOO)"
413        );
414    }
415}