neurosky 0.0.1

Rust library and TUI for NeuroSky MindWave EEG headsets via the ThinkGear serial protocol
Documentation
//! Real-time MindWave EEG terminal UI.
//!
//! Layout:
//! ```text
//! ┌─ NeuroSky MindWave │ Attention: 72 │ Meditation: 55 │ Signal: ✓ good │ q=quit ─┐
//! ├──────────────────────────────────────┬──────────────────────────────────────────┤
//! │                                      │  ── Computed from raw (512 Hz) ──        │
//! │   Raw EEG waveform (Braille chart)   │  δ Delta │████████░░░░│ 38%              │
//! │                                      │  θ Theta │████░░░░░░░░│ 22%              │
//! │                                      │  α Alpha │██████░░░░░░│ 30%              │
//! │                                      │  β Beta  │███░░░░░░░░░│ 14%              │
//! │                                      │  γ Gamma │█░░░░░░░░░░░│  6%              │
//! │                                      │  ── ASIC hardware ──                     │
//! │                                      │  δ │ 45231  θ │ 28100                    │
//! └──────────────────────────────────────┴──────────────────────────────────────────┘
//! ```

use std::io;
use std::time::{Duration, Instant};

use neurosky::prelude::*;

use crossterm::{
    event::{self, Event, KeyCode, KeyEventKind},
    execute,
    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{prelude::*, widgets::*};

const RAW_WINDOW: usize = 512;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    env_logger::init();

    let ports = MindWaveDevice::find()?;
    if ports.is_empty() {
        eprintln!("No MindWave device found.");
        return Ok(());
    }
    println!("Opening {}", ports[0]);
    let mut device = MindWaveDevice::open(&ports[0])?;
    device.auto_connect()?;

    enable_raw_mode()?;
    let mut stdout = io::stdout();
    execute!(stdout, EnterAlternateScreen)?;
    let mut terminal = Terminal::new(CrosstermBackend::new(stdout))?;

    let mut raw_ring: Vec<f64>   = Vec::new();
    let mut attn     = 0u8;
    let mut med      = 0u8;
    let mut sig      = 0u8;
    let mut asic: Option<AsicEeg> = None;
    let mut bp       = BandPowers::default();
    let mut extractor = BandPowerExtractor::new(512.0, 50.0, RAW_WINDOW);

    let tick = Duration::from_millis(33); // ~30 fps
    let mut last_tick = Instant::now();

    loop {
        for pkt in device.read().unwrap_or_default() {
            match pkt {
                Packet::RawValue(v) => {
                    if raw_ring.len() >= RAW_WINDOW { raw_ring.remove(0); }
                    raw_ring.push(v as f64);
                    bp = extractor.push(v);
                }
                Packet::Attention(v)  => attn = v,
                Packet::Meditation(v) => med  = v,
                Packet::PoorSignal(v) => sig  = v,
                Packet::AsicEeg(e)    => asic  = Some(e),
                _ => {}
            }
        }

        terminal.draw(|f| render(f, &raw_ring, attn, med, sig, &bp, &asic))?;

        let timeout = tick.checked_sub(last_tick.elapsed()).unwrap_or_default();
        if event::poll(timeout)? {
            if let Event::Key(k) = event::read()? {
                if k.kind == KeyEventKind::Press && k.code == KeyCode::Char('q') {
                    break;
                }
            }
        }
        if last_tick.elapsed() >= tick { last_tick = Instant::now(); }
    }

    disable_raw_mode()?;
    execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
    terminal.show_cursor()?;
    Ok(())
}

fn render(
    f: &mut Frame,
    raw_ring: &[f64],
    attn: u8,
    med: u8,
    sig: u8,
    bp: &BandPowers,
    asic: &Option<AsicEeg>,
) {
    let area = f.area();

    // ── Outer split: header (3 rows) + content ────────────────────────────────
    let rows = Layout::default()
        .direction(Direction::Vertical)
        .constraints([Constraint::Length(3), Constraint::Min(5)])
        .split(area);

    // Header
    let sig_str = if sig == 0 { "✓ good".to_string() } else { format!("{sig}") };
    f.render_widget(
        Block::default()
            .borders(Borders::ALL)
            .title(format!(
                " NeuroSky MindWave │ Attention: {attn} │ Meditation: {med} \
                 │ Signal: {sig_str} │ q=quit "
            ))
            .style(Style::default().fg(Color::Cyan)),
        rows[0],
    );

    // ── Content: waveform (left 62%) + band powers (right 38%) ───────────────
    let cols = Layout::default()
        .direction(Direction::Horizontal)
        .constraints([Constraint::Percentage(62), Constraint::Percentage(38)])
        .split(rows[1]);

    // ── Left: raw EEG chart ───────────────────────────────────────────────────
    let data: Vec<(f64, f64)> = raw_ring.iter().enumerate()
        .map(|(i, &v)| (i as f64, v))
        .collect();
    let y_min = data.iter().map(|d| d.1).fold( f64::INFINITY, f64::min);
    let y_max = data.iter().map(|d| d.1).fold(f64::NEG_INFINITY, f64::max);
    let margin = (y_max - y_min).max(1.0) * 0.1;

    let ds = Dataset::default()
        .name("Raw EEG")
        .marker(symbols::Marker::Braille)
        .graph_type(GraphType::Line)
        .style(Style::default().fg(Color::Green))
        .data(&data);

    f.render_widget(
        Chart::new(vec![ds])
            .block(Block::default().borders(Borders::ALL).title(" Raw EEG (512 Hz) "))
            .x_axis(Axis::default().bounds([0.0, RAW_WINDOW as f64]))
            .y_axis(
                Axis::default()
                    .bounds([y_min - margin, y_max + margin])
                    .labels::<Vec<Line>>(vec![
                        format!("{:.0}", y_min - margin).into(),
                        format!("{:.0}", y_max + margin).into(),
                    ]),
            ),
        cols[0],
    );

    // ── Right: band powers ────────────────────────────────────────────────────
    let bar_w  = (cols[1].width as usize).saturating_sub(20).max(4);
    let bp_n   = bp.normalised();
    let bands  = [
        ("δ Delta", bp_n.delta),
        ("θ Theta", bp_n.theta),
        ("α Alpha", bp_n.alpha),
        ("β Beta ", bp_n.beta),
        ("γ Gamma", bp_n.gamma),
    ];

    let mut lines: Vec<Line> = vec![
        Line::from(Span::styled(" ── Computed from raw ──", Style::default().fg(Color::DarkGray))),
    ];
    for (label, val) in &bands {
        let filled = (*val * bar_w as f64) as usize;
        let bar    = "".repeat(filled) + &"".repeat(bar_w - filled);
        lines.push(Line::from(format!(" {}{}{:>3.0}%", label, bar, val * 100.0)));
    }

    if let Some(eeg) = asic {
        lines.push(Line::from(Span::styled(
            " ── ASIC hardware ──",
            Style::default().fg(Color::DarkGray),
        )));
        let hw      = eeg.as_array();
        let hw_max  = hw.iter().copied().max().unwrap_or(1).max(1) as f64;
        let hw_names = ["δ", "θ", "αL", "αH", "βL", "βH", "γL", "γM"];
        for (&name, &val) in hw_names.iter().zip(hw.iter()) {
            let filled = ((val as f64 / hw_max) * (bar_w / 2) as f64) as usize;
            let bar    = "".repeat(filled) + &"".repeat(bar_w / 2 - filled);
            lines.push(Line::from(format!(" {name}{bar}{val}")));
        }
    }

    f.render_widget(
        Paragraph::new(lines)
            .block(Block::default().borders(Borders::ALL).title(" Band Powers "))
            .style(Style::default().fg(Color::Yellow)),
        cols[1],
    );
}