1use serde::{Deserialize, Serialize};
28
29use crate::protocol::headset::HeadsetInfo;
30
31#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
37pub enum HeadsetModel {
38 Insight,
41
42 EpocPlus,
45
46 EpocX,
49
50 EpocFlex,
53
54 Unknown(String),
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct HeadsetChannelConfig {
61 pub channels: Vec<ChannelInfo>,
63
64 pub sampling_rate_hz: f64,
66
67 pub resolution_bits: u32,
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct ChannelInfo {
74 pub name: String,
76
77 pub position_10_20: Option<String>,
80}
81
82const INSIGHT_CHANNELS: &[&str] = &["AF3", "AF4", "T7", "T8", "Pz"];
85
86const EPOC_CHANNELS: &[&str] = &[
89 "AF3", "F7", "F3", "FC5", "T7", "P7", "O1", "O2", "P8", "T8", "FC6", "F4", "F8", "AF4",
90];
91
92impl HeadsetModel {
95 #[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 HeadsetModel::EpocPlus
123 } else {
124 HeadsetModel::Unknown(headset_id.to_string())
125 }
126 }
127
128 #[must_use]
130 pub fn from_headset_info(info: &HeadsetInfo) -> Self {
131 Self::from_headset_id(&info.id)
132 }
133
134 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}