catprinter 0.1.0

control ur cat printer using this module (MXW01 only :3)
Documentation
use crate::dithering::{atkinson_dither, bayer_dither, halftone_dither, ImageDithering};
use crate::printer::PrinterStatus;
use crate::protocol::{build_control_packet, chunk_data, pack_1bpp_pixels, parse_notification};
use async_trait::async_trait;
use btleplug::api::{
    Central as _, Characteristic, Manager as _, Peripheral as _, ScanFilter, WriteType,
};
use btleplug::platform::{Manager, Peripheral};
use futures::stream::StreamExt;
use std::time::Duration;
use tokio::time;
use uuid::Uuid;

#[derive(Debug, Clone)]
pub struct DeviceInfo {
    pub id: String,
    pub name: Option<String>,
}

#[derive(Debug)]
struct BtleCharacteristics {
    control: Characteristic, // AE01
    notify: Characteristic,  // AE02
    data: Characteristic,    // AE03
}

#[async_trait]
pub trait TransportAsync: Send + Sync {
    async fn write_control(&self, data: &[u8]) -> Result<(), String>;
    async fn write_data(&self, data: &[u8]) -> Result<(), String>;
    async fn read_notification(&self, timeout: Duration) -> Result<Vec<u8>, String>;
}

/// Scans for CatPrinter-compatible BLE devices.
///
/// - `timeout`: scan duration
///
/// Returns Vec<DeviceInfo> on success
pub async fn scan(timeout: Duration) -> Result<Vec<DeviceInfo>, String> {
    let manager = Manager::new()
        .await
        .map_err(|e| format!("manager error: {:?}", e))?;
    let adapters = manager
        .adapters()
        .await
        .map_err(|e| format!("adapter list error: {:?}", e))?;
    let adapter = adapters
        .into_iter()
        .next()
        .ok_or_else(|| "no BLE adapters found".to_string())?;
    adapter
        .start_scan(ScanFilter::default())
        .await
        .map_err(|e| format!("scan start error: {:?}", e))?;
    time::sleep(timeout).await;
    let peripherals = adapter
        .peripherals()
        .await
        .map_err(|e| format!("peripherals error: {:?}", e))?;
    let mut list = vec![];
    for p in peripherals {
        let id = p.id().to_string();
        let name = p
            .properties()
            .await
            .ok()
            .flatten()
            .and_then(|props| props.local_name);
        list.push(DeviceInfo { id, name });
    }
    Ok(list)
}

/// Connects to a CatPrinter BLE device by ID.
///
/// - `device_id`: device identifier string
/// - `_timeout`: connection timeout (unused)
///
/// Returns CatPrinterAsync on success
pub async fn connect(device_id: &str, _timeout: Duration) -> Result<CatPrinterAsync, String> {
    let manager = Manager::new()
        .await
        .map_err(|e| format!("manager error: {:?}", e))?;
    let adapters = manager
        .adapters()
        .await
        .map_err(|e| format!("adapter list error: {:?}", e))?;
    let adapter = adapters
        .into_iter()
        .next()
        .ok_or_else(|| "no BLE adapters found".to_string())?;
    let peripherals = adapter
        .peripherals()
        .await
        .map_err(|e| format!("peripherals error: {:?}", e))?;
    let maybe = peripherals
        .into_iter()
        .find(|p| p.id().to_string() == device_id);
    let peripheral = maybe.ok_or_else(|| format!("device {} not found", device_id))?;
    if !peripheral
        .is_connected()
        .await
        .map_err(|e| format!("{:?}", e))?
    {
        peripheral
            .connect()
            .await
            .map_err(|e| format!("connect error: {:?}", e))?;
    }
    peripheral
        .discover_services()
        .await
        .map_err(|e| format!("discover error: {:?}", e))?;

    let ae01 = Uuid::parse_str("0000ae01-0000-1000-8000-00805f9b34fb").unwrap();
    let ae02 = Uuid::parse_str("0000ae02-0000-1000-8000-00805f9b34fb").unwrap();
    let ae03 = Uuid::parse_str("0000ae03-0000-1000-8000-00805f9b34fb").unwrap();

    let chars = peripheral.characteristics();
    let mut control_c: Option<Characteristic> = None;
    let mut notify_c: Option<Characteristic> = None;
    let mut data_c: Option<Characteristic> = None;
    for c in &chars {
        if c.uuid == ae01 {
            control_c = Some(c.clone());
        }
        if c.uuid == ae02 {
            notify_c = Some(c.clone());
        }
        if c.uuid == ae03 {
            data_c = Some(c.clone());
        }
    }
    let control = control_c.ok_or_else(|| "AE01 control characteristic not found".to_string())?;
    let notify = notify_c.ok_or_else(|| "AE02 notify characteristic not found".to_string())?;
    let data = data_c.ok_or_else(|| "AE03 data characteristic not found".to_string())?;

    peripheral
        .subscribe(&notify)
        .await
        .map_err(|e| format!("subscribe error: {:?}", e))?;

    let transport = BtleTransport::new(
        peripheral.clone(),
        control.clone(),
        notify.clone(),
        data.clone(),
    );
    let cat = CatPrinterAsync::new(Box::new(transport));
    Ok(cat)
}

pub struct BtleTransport {
    peripheral: Peripheral,
    control: Characteristic,
    notify: Characteristic,
    data: Characteristic,
}

impl BtleTransport {
    pub fn new(
        peripheral: Peripheral,
        control: Characteristic,
        notify: Characteristic,
        data: Characteristic,
    ) -> Self {
        Self {
            peripheral,
            control,
            notify,
            data,
        }
    }
}

#[async_trait]
impl TransportAsync for BtleTransport {
    async fn write_control(&self, data: &[u8]) -> Result<(), String> {
        self.peripheral
            .write(&self.control, data, WriteType::WithoutResponse)
            .await
            .map_err(|e| format!("write_control error: {:?}", e))
    }
    async fn write_data(&self, data: &[u8]) -> Result<(), String> {
        self.peripheral
            .write(&self.data, data, WriteType::WithoutResponse)
            .await
            .map_err(|e| format!("write_data error: {:?}", e))
    }
    async fn read_notification(&self, timeout: Duration) -> Result<Vec<u8>, String> {
        let mut notifications = self
            .peripheral
            .notifications()
            .await
            .map_err(|e| format!("notifications stream error: {:?}", e))?;
        let deadline = time::Instant::now() + timeout;
        loop {
            let remaining = deadline
                .checked_duration_since(time::Instant::now())
                .unwrap_or_else(|| Duration::from_secs(0));
            let maybe = time::timeout(remaining, notifications.next()).await;
            match maybe {
                Ok(Some(note)) => {
                    if note.uuid == self.notify.uuid {
                        return Ok(note.value.clone());
                    } else {
                        continue;
                    }
                }
                Ok(None) => return Err("notifications stream ended".to_string()),
                Err(_) => return Err("timeout waiting for notification".to_string()),
            }
        }
    }
}

/// Asynchronous CatPrinter API for printing text and images.
///
/// - `transport`: implements TransportAsync trait (BLE)
/// - `chunk_size`: bytes per data chunk (default: 180)
pub struct CatPrinterAsync {
    pub transport: Box<dyn TransportAsync + Send + Sync>,
    chunk_size: usize,
}

impl CatPrinterAsync {
    pub fn new(transport: Box<dyn TransportAsync + Send + Sync>) -> Self {
        Self {
            transport,
            chunk_size: 180,
        }
    }

    pub fn with_chunk_size(mut self, size: usize) -> Self {
        self.chunk_size = size;
        self
    }

    pub async fn get_status(&self, timeout: Duration) -> Result<PrinterStatus, String> {
        let req = build_control_packet(0xA1, &[0x00]);
        self.transport.write_control(&req).await?;
        let raw = self.transport.read_notification(timeout).await?;
        println!("DEBUG: Raw status notification bytes (0xA1): {:?}", raw);
        let notif = parse_notification(&raw).map_err(|e| e.to_string())?;
        Ok(crate::protocol::parse_printer_status(&notif.payload))
    }

    pub async fn get_battery(&self, timeout: Duration) -> Result<u8, String> {
        let req = build_control_packet(0xAB, &[0x00]);
        self.transport.write_control(&req).await?;
        let raw = self.transport.read_notification(timeout).await?;
        println!("DEBUG: Raw battery notification bytes (0xAB): {:?}", raw);
        let notif = parse_notification(&raw).map_err(|e| e.to_string())?;
        // Print all payload bytes for debugging
        println!("DEBUG: Parsed battery payload: {:?}", notif.payload);
        // Try to extract battery percent from payload
        if !notif.payload.is_empty() {
            Ok(notif.payload[0])
        } else {
            Err("Battery payload too short".to_string())
        }
    }

    pub async fn print_text(&self, main: &str, author: &str) -> Result<(), String> {
        let width = 384usize;
        let pixels = crate::protocol::render_text_to_pixels(main, author, width);
        let height = pixels.len() / width;
        let rotated_pixels = crate::protocol::rotate_mirror_pixels(&pixels, width, height);
        self.print_image(&rotated_pixels, width, height, 0x00, None)
            .await
    }

    pub async fn print_image_from_path(
        &self,
        path: &str,
        dithering: ImageDithering,
    ) -> Result<(), String> {
        let img = image::open(path).map_err(|e| e.to_string())?;
        let width = 384;
        let resized = img.thumbnail(width, u32::MAX);
        let mut gray = resized.to_luma8();

        match dithering {
            ImageDithering::FloydSteinberg => {
                image::imageops::dither(&mut gray, &image::imageops::BiLevel);
            }
            ImageDithering::Atkinson => {
                atkinson_dither(&mut gray);
            }
            ImageDithering::Bayer => {
                bayer_dither(&mut gray);
            }
            ImageDithering::Halftone => {
                gray = halftone_dither(&gray);
            }
            ImageDithering::Threshold => {
                // manual thresholding
                for pixel in gray.pixels_mut() {
                    if pixel[0] > 127 {
                        pixel[0] = 255;
                    } else {
                        pixel[0] = 0;
                    }
                }
            }
        }

        let (width, height) = gray.dimensions();
        let pixels = gray.as_raw();
        self.print_image(pixels, width as usize, height as usize, 0x00, None)
            .await
    }

    pub async fn print_image(
        &self,
        pixels: &[u8],
        width: usize,
        height: usize,
        mode: u8,
        chunk_size: Option<usize>,
    ) -> Result<(), String> {
        let packed = pack_1bpp_pixels(pixels, width, height).map_err(|e| e.to_string())?;
        let line_count: u16 = height as u16;
        let mut a9_payload = Vec::new();
        a9_payload.extend_from_slice(&line_count.to_le_bytes());
        a9_payload.push(0x30);
        a9_payload.push(mode);
        let a9 = build_control_packet(0xA9, &a9_payload);
        self.transport.write_control(&a9).await?;
        let resp = self
            .transport
            .read_notification(Duration::from_secs(2))
            .await?;
        let parsed = parse_notification(&resp).map_err(|e| e.to_string())?;
        if parsed.command_id != 0xA9 || parsed.payload.first() == Some(&0x01u8) {
            return Err("printer rejected print request".into());
        }
        let size = chunk_size.unwrap_or(self.chunk_size);
        for chunk in chunk_data(&packed, size) {
            self.transport.write_data(chunk).await?;
            time::sleep(Duration::from_millis(10)).await;
        }
        let ad = build_control_packet(0xAD, &[0x00]);
        self.transport.write_control(&ad).await?;
        let deadline = time::Instant::now() + Duration::from_secs(60);
        loop {
            let remaining = deadline
                .checked_duration_since(time::Instant::now())
                .unwrap_or_else(|| Duration::from_secs(0));
            if remaining.is_zero() {
                return Err("timed out waiting for print complete".into());
            }
            let raw = self.transport.read_notification(remaining).await?;
            let notif = parse_notification(&raw).map_err(|e| e.to_string())?;
            if notif.command_id == 0xAA {
                return Ok(());
            }
        }
    }
}