├── src ├── app │ ├── web.rs │ └── mod.rs ├── operations │ ├── mod.rs │ ├── monitor.rs │ ├── power.rs │ ├── brew.rs │ ├── parameter.rs │ ├── recipe_list.rs │ └── ingredients.rs ├── prelude.rs ├── Info.plist ├── protocol │ ├── request │ │ ├── app_control.rs │ │ ├── profile.rs │ │ ├── monitor.rs │ │ ├── recipe.rs │ │ └── mod.rs │ ├── mod.rs │ ├── machine_enum.rs │ ├── packet.rs │ └── hardware_enums.rs ├── util.rs ├── ecam │ ├── packet_receiver.rs │ ├── mod.rs │ ├── driver.rs │ ├── ecam_subprocess.rs │ ├── stdin_stream.rs │ ├── packet_stream.rs │ ├── ecam_simulate.rs │ ├── ecam_bt.rs │ └── ecam_wrapper.rs ├── logging.rs ├── lib.rs ├── display.rs └── main.rs ├── .dockerignore ├── .gitignore ├── doc └── README.tpl ├── Dockerfile ├── Cargo.toml ├── examples └── bt_scan.rs └── README.md /src/app/web.rs: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/app/mod.rs: -------------------------------------------------------------------------------- 1 | mod web; 2 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | target 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | target/ 3 | apk/ 4 | -------------------------------------------------------------------------------- /doc/README.tpl: -------------------------------------------------------------------------------- 1 | # {{crate}} [![docs.rs](https://docs.rs/longshot/badge.svg)](https://docs.rs/longshot) [![crates.io](https://img.shields.io/crates/v/longshot.svg)](https://crates.io/crates/longshot) 2 | 3 | {{readme}} 4 | 5 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM rust:latest 2 | 3 | # We need this stuff to build 4 | RUN apt-get update && apt-get install -y libdbus-1-dev pkg-config 5 | # Warm the crates.io cache 6 | RUN cargo init --name temp && cargo add tokio && cargo build 7 | 8 | # Build 9 | COPY . /source/ 10 | WORKDIR /source/ 11 | RUN cargo build 12 | -------------------------------------------------------------------------------- /src/operations/mod.rs: -------------------------------------------------------------------------------- 1 | //! Coffee-related operations: brewing, monitoring, etc. 2 | 3 | mod brew; 4 | mod ingredients; 5 | mod monitor; 6 | mod parameter; 7 | mod power; 8 | mod recipe_list; 9 | 10 | pub use brew::*; 11 | pub use ingredients::*; 12 | pub use monitor::*; 13 | pub use parameter::*; 14 | pub use power::*; 15 | pub use recipe_list::*; 16 | -------------------------------------------------------------------------------- /src/prelude.rs: -------------------------------------------------------------------------------- 1 | //! Universal imports for this crate. 2 | 3 | use crate::ecam::EcamError; 4 | 5 | pub use std::future::Future; 6 | pub use std::{pin::Pin, sync::Arc, time::Duration}; 7 | pub use tokio_stream::{Stream, StreamExt}; 8 | 9 | pub use crate::util::CollectMapJoin; 10 | pub use crate::{info, trace_packet, trace_shutdown, warning}; 11 | 12 | pub type AsyncFuture<'a, T> = Pin> + Send + 'a>>; 13 | -------------------------------------------------------------------------------- /src/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleName 6 | longshot 7 | CFBundleIdentifier 8 | longshot 9 | NSBluetoothPeripheralUsageDescription 10 | Our app uses bluetooth to find, connect and transfer data between different devices 11 | NSBluetoothAlwaysUsageDescription 12 | Our app uses bluetooth to find, connect and transfer data between different devices 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/protocol/request/app_control.rs: -------------------------------------------------------------------------------- 1 | #![allow(dead_code)] 2 | 3 | use super::PartialEncode; 4 | 5 | /// Operations used by the application for various purposes. 6 | #[derive(Clone, Debug, Eq, PartialEq)] 7 | pub enum AppControl { 8 | /// Turns the machine on. 9 | TurnOn, 10 | /// Uncertain, but sent by the application. 11 | RefreshAppId, 12 | /// Custom command. 13 | Custom(u8, u8), 14 | } 15 | 16 | impl PartialEncode for AppControl { 17 | fn partial_encode(&self, out: &mut Vec) { 18 | match self { 19 | Self::TurnOn => out.extend_from_slice(&[2, 1]), 20 | Self::RefreshAppId => out.extend_from_slice(&[3, 2]), 21 | Self::Custom(a, b) => out.extend_from_slice(&[*a, *b]), 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/operations/monitor.rs: -------------------------------------------------------------------------------- 1 | use crate::prelude::*; 2 | use std::time::Instant; 3 | 4 | use crate::display::*; 5 | use crate::ecam::{Ecam, EcamError}; 6 | 7 | pub async fn monitor(ecam: Ecam) -> Result<(), EcamError> { 8 | let mut state = ecam.current_state().await?; 9 | display_status(state); 10 | let mut debounce = Instant::now(); 11 | while ecam.is_alive() { 12 | // Poll for current state 13 | let next_state = ecam.current_state().await?; 14 | if next_state != state || debounce.elapsed() > Duration::from_millis(250) { 15 | // println!("{:?}", next_state); 16 | display_status(next_state); 17 | state = next_state; 18 | debounce = Instant::now(); 19 | } 20 | } 21 | 22 | Ok(()) 23 | } 24 | -------------------------------------------------------------------------------- /src/util.rs: -------------------------------------------------------------------------------- 1 | pub trait CollectMapJoin { 2 | /// Utility function to collect an iterator, map it with a function, and join it into a final string. 3 | fn collect_map_join(self, sep: &str, f: fn(X) -> String) -> String; 4 | 5 | /// Utility function to collect an iterator, map/filter it with a function, and join it into a final string. 6 | fn collect_filter_map_join(self, sep: &str, f: fn(X) -> Option) -> String; 7 | } 8 | 9 | impl, X> CollectMapJoin for T { 10 | fn collect_map_join(self, sep: &str, f: fn(X) -> String) -> String { 11 | // When https://github.com/rust-lang/rust/issues/79524 is fixed, this can probably be simplified 12 | // self.map(f).intersperse(sep).collect() 13 | self.map(f).collect::>().join(sep) 14 | } 15 | 16 | fn collect_filter_map_join(self, sep: &str, f: fn(X) -> Option) -> String { 17 | self.filter_map(f).collect::>().join(sep) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/protocol/request/profile.rs: -------------------------------------------------------------------------------- 1 | use super::PartialDecode; 2 | 3 | /// Represents a recipe or profile name with an associate icon tucked into the last byte. 4 | #[derive(Clone, Debug, Eq, PartialEq)] 5 | pub struct WideStringWithIcon { 6 | name: String, 7 | icon: u8, 8 | } 9 | 10 | impl WideStringWithIcon { 11 | #[cfg(test)] 12 | pub fn new(name: &str, icon: u8) -> Self { 13 | WideStringWithIcon { 14 | name: name.to_owned(), 15 | icon, 16 | } 17 | } 18 | } 19 | 20 | impl PartialDecode for WideStringWithIcon { 21 | fn partial_decode(input: &mut &[u8]) -> Option { 22 | let mut s = vec![]; 23 | for _ in 0..10 { 24 | let b1 = ::partial_decode(input)? as u16; 25 | let b2 = ::partial_decode(input)? as u16; 26 | let char = char::from_u32(((b1 << 8) | b2) as u32).expect("Invalid character"); 27 | s.push(char); 28 | } 29 | Some(WideStringWithIcon { 30 | name: s 31 | .iter() 32 | .collect::() 33 | .trim_end_matches(['\0']) 34 | .to_owned(), 35 | icon: ::partial_decode(input)?, 36 | }) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "longshot" 3 | version = "0.1.13" 4 | authors = ["Matt Mastracci "] 5 | edition = "2024" 6 | description = "API and CLI for ECAM-based Delonghi machines" 7 | license = "Apache-2.0 OR MIT" 8 | repository = "https://github.com/mmastrac/longshot" 9 | readme = "README.md" 10 | 11 | [dependencies] 12 | btleplug = "0.11.8" 13 | embed_plist = "1" 14 | tokio = { version = "1.45", features = ["io-std", "io-util", "macros", "rt", "rt-multi-thread", "process"] } 15 | tokio-stream = { version = "0.1", features = ["sync", "io-util"] } 16 | pretty_env_logger = "0.5" 17 | uuid = "1.16" 18 | hex = "0.4" 19 | thiserror = "2" 20 | clap = { version = "4.5", features = ["cargo", "derive", "string"] } 21 | async-stream = "0.3" 22 | stream-cancel = "0.8" 23 | tuples = "1" 24 | futures = "0.3" 25 | num_enum = "0.7" 26 | colored = "3" 27 | # This may take some work to upgrade 28 | ariadne = "=0.1.5" 29 | crc = "3.3" 30 | serde = "1" 31 | keepcalm = { version = "0.3", features = ["serde", "global_experimental"] } 32 | 33 | [dev-dependencies] 34 | rstest = "0.25.0" 35 | const-decoder = "0" 36 | itertools = "0.14" 37 | 38 | [lib] 39 | name = "longshot" 40 | path = "src/lib.rs" 41 | 42 | [[bin]] 43 | name = "longshot" 44 | path = "src/main.rs" 45 | 46 | [[example]] 47 | name = "bt_scan" 48 | path = "examples/bt_scan.rs" 49 | -------------------------------------------------------------------------------- /src/ecam/packet_receiver.rs: -------------------------------------------------------------------------------- 1 | use crate::prelude::*; 2 | use keepcalm::SharedMut; 3 | use tokio::sync::mpsc::Receiver; 4 | 5 | use crate::ecam::{EcamDriverOutput, EcamError}; 6 | 7 | /// Converts a stream into something that can be more easily awaited. In addition, it can optionally add 8 | /// [`EcamDriverOutput::Ready`] and [`EcamDriverOutput::Done`] packets to the start and end of the stream. 9 | pub struct EcamPacketReceiver { 10 | rx: SharedMut>, 11 | } 12 | 13 | impl EcamPacketReceiver { 14 | pub fn from_stream + Unpin + Send + 'static>( 15 | mut stream: T, 16 | wrap_start_end: bool, 17 | ) -> Self { 18 | let (tx, rx) = tokio::sync::mpsc::channel(100); 19 | tokio::spawn(async move { 20 | if wrap_start_end { 21 | tx.send(EcamDriverOutput::Ready) 22 | .await 23 | .expect("Failed to forward notification"); 24 | } 25 | while let Some(m) = stream.next().await { 26 | tx.send(m).await.expect("Failed to forward notification"); 27 | } 28 | trace_shutdown!("EcamPacketReceiver"); 29 | if wrap_start_end { 30 | tx.send(EcamDriverOutput::Done) 31 | .await 32 | .expect("Failed to forward notification"); 33 | } 34 | }); 35 | 36 | EcamPacketReceiver { 37 | rx: SharedMut::new(rx), 38 | } 39 | } 40 | 41 | pub async fn recv(&self) -> Result, EcamError> { 42 | Ok(self.rx.write().recv().await) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /examples/bt_scan.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use btleplug::api::{Central, Characteristic, Manager as _, Peripheral as _, ScanFilter}; 4 | use btleplug::platform::{Manager, Peripheral}; 5 | use uuid::Uuid; 6 | 7 | const SERVICE_UUID: Uuid = Uuid::from_u128(0x00035b03_58e6_07dd_021a_08123a000300); 8 | const CHARACTERISTIC_UUID: Uuid = Uuid::from_u128(0x00035b03_58e6_07dd_021a_08123a000301); 9 | 10 | #[tokio::main] 11 | async fn main() -> Result<(), Box> { 12 | let manager = Manager::new().await?; 13 | let filter = ScanFilter { 14 | services: vec![SERVICE_UUID], 15 | }; 16 | 17 | eprintln!("Looking for coffeemakers..."); 18 | for adapter in manager.adapters().await? { 19 | adapter.start_scan(filter.clone()).await?; 20 | tokio::time::sleep(Duration::from_secs(10)).await; 21 | for peripheral in adapter.peripherals().await? { 22 | eprintln!("Found peripheral"); 23 | peripheral.connect().await?; 24 | peripheral.discover_services().await?; 25 | for service in peripheral.services() { 26 | for characteristic in service.characteristics { 27 | if service.uuid == SERVICE_UUID && characteristic.uuid == CHARACTERISTIC_UUID { 28 | run_with_peripheral(peripheral.clone(), characteristic).await?; 29 | } 30 | } 31 | } 32 | } 33 | } 34 | 35 | Ok(()) 36 | } 37 | 38 | async fn run_with_peripheral( 39 | peripheral: Peripheral, 40 | characteristic: Characteristic, 41 | ) -> Result<(), Box> { 42 | eprintln!("{:?}", peripheral.id()); 43 | eprintln!("{:?}", characteristic); 44 | Ok(()) 45 | } 46 | -------------------------------------------------------------------------------- /src/operations/power.rs: -------------------------------------------------------------------------------- 1 | use crate::display; 2 | use crate::ecam::{Ecam, EcamError, EcamStatus}; 3 | use crate::prelude::*; 4 | use crate::protocol::*; 5 | 6 | pub async fn power_on( 7 | ecam: Ecam, 8 | allow_off: bool, 9 | allow_alarms: bool, 10 | turn_on: bool, 11 | ) -> Result { 12 | match ecam.current_state().await? { 13 | EcamStatus::Ready => { 14 | return Ok(true); 15 | } 16 | EcamStatus::StandBy => { 17 | if allow_off { 18 | info!("Machine is off, but --allow-off will allow us to proceed"); 19 | return Ok(true); 20 | } else if !turn_on { 21 | info!("Machine is not on, pass --turn-on to turn it on before operation"); 22 | } else { 23 | info!("Waiting for the machine to turn on..."); 24 | ecam.write_request(Request::AppControl(AppControl::TurnOn)) 25 | .await?; 26 | ecam.wait_for_state(EcamStatus::Ready, display::display_status) 27 | .await?; 28 | return Ok(true); 29 | } 30 | } 31 | s => { 32 | if allow_alarms { 33 | return Ok(true); 34 | } 35 | info!( 36 | "Machine is in state {:?}, so we will cowardly refuse to brew coffee", 37 | s 38 | ); 39 | } 40 | } 41 | Ok(false) 42 | } 43 | 44 | pub async fn app_control(ecam: Ecam, a: u8, b: u8) -> Result<(), EcamError> { 45 | eprintln!("Sending app control command {:02x} {:02x}", a, b); 46 | ecam.write_request(Request::AppControl(AppControl::Custom(a, b))) 47 | .await?; 48 | tokio::time::sleep(std::time::Duration::from_secs(1)).await; 49 | Ok(()) 50 | } 51 | -------------------------------------------------------------------------------- /src/logging.rs: -------------------------------------------------------------------------------- 1 | //! Logging utilities. 2 | 3 | use std::sync::atomic::AtomicBool; 4 | pub(crate) static TRACE_ENABLED: AtomicBool = AtomicBool::new(false); 5 | 6 | /// Enable tracing display to standard error. 7 | pub fn enable_tracing() { 8 | TRACE_ENABLED.store(true, std::sync::atomic::Ordering::Relaxed); 9 | } 10 | 11 | /// Writes a trace of the given communication packet or event if [`enable_tracing`] has been called. 12 | #[macro_export] 13 | macro_rules! trace_packet { 14 | ($($arg:tt)*) => {{ 15 | if $crate::logging::TRACE_ENABLED.load(std::sync::atomic::Ordering::Relaxed) { 16 | $crate::display::log($crate::display::LogLevel::Trace, &format!("{}", std::format!($($arg)*))); 17 | } 18 | }}; 19 | } 20 | 21 | /// Writes a trace of the given shutdown event if [`enable_tracing`] has been called. 22 | #[macro_export] 23 | macro_rules! trace_shutdown { 24 | ($arg:literal) => {{ 25 | if $crate::logging::TRACE_ENABLED.load(std::sync::atomic::Ordering::Relaxed) { 26 | $crate::display::log( 27 | $crate::display::LogLevel::Trace, 28 | &format!("[SHUTDOWN] {}", $arg), 29 | ); 30 | } 31 | }}; 32 | } 33 | 34 | /// Writes a warning of the given event if [`enable_tracing`] has been called. 35 | #[macro_export] 36 | macro_rules! warning { 37 | ($($arg:tt)*) => {{ 38 | if $crate::logging::TRACE_ENABLED.load(std::sync::atomic::Ordering::Relaxed) { 39 | $crate::display::log($crate::display::LogLevel::Warning, &std::format!($($arg)*)); 40 | } 41 | }}; 42 | } 43 | 44 | /// Writes info text for the given event. 45 | #[macro_export] 46 | macro_rules! info { 47 | ($($arg:tt)*) => {{ 48 | $crate::display::log($crate::display::LogLevel::Info, &std::format!($($arg)*)); 49 | }}; 50 | } 51 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # longshot [![docs.rs](https://docs.rs/longshot/badge.svg)](https://docs.rs/longshot) [![crates.io](https://img.shields.io/crates/v/longshot.svg)](https://crates.io/crates/longshot) 2 | 3 | Brew coffee from the command-line! 4 | 5 | ## Details 6 | 7 | Longshot is an API and command-line application to brew coffee from the command-line (or whatever 8 | front-end is built). At this time it supports DeLonghi ECAM-based Bluetooth-Low-Energy devices, and has only been tested on the 9 | Dinamica Plus over Bluetooth. 10 | 11 | The protocol for status and monitoring has been mostly decoded, but at this time is only available in 12 | source form. 13 | 14 | ## Command-Line Examples 15 | 16 | Monitor the given device (will continue until you press Ctrl+C): 17 | 18 | ```console 19 | $ longshot monitor --device-name (device) 20 | Dispensing... [###############################===========] 21 | ``` 22 | 23 | Get the brew information for a given beverage: 24 | 25 | ```console 26 | $ longshot brew --device-name (device) --beverage regularcoffee 27 | ... 28 | ``` 29 | 30 | Brew a beverage: 31 | 32 | ```console 33 | $ longshot brew --device-name (device) --beverage regularcoffee --coffee 180 --taste strong 34 | Fetching recipe for RegularCoffee... 35 | Fetching recipes... 36 | Brewing RegularCoffee... 37 | ``` 38 | 39 | ## API Examples 40 | 41 | Brew a long coffee with 250 impulses of water (approximately the size of an average North American coffee mug, or slightly more). 42 | 43 | ```rust 44 | let ecam = ecam_lookup(device_name).await?; 45 | let req = Request::BeverageDispensingMode( 46 | EcamBeverageId::LongCoffee.into(), 47 | EcamOperationTrigger::Start.into(), 48 | vec![RecipeInfo::new(EcamIngredients::Coffee, 250)], 49 | EcamBeverageTasteType::Prepare.into(), 50 | ); 51 | ecam.write_request(req).await?; 52 | ``` 53 | 54 | ## Demo 55 | 56 | ![Demo of brewing a cappuccino](https://user-images.githubusercontent.com/512240/200137316-a09304e8-b34a-41ff-a847-af71af521ef8.gif) 57 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | //! Brew coffee from the command-line! 2 | //! 3 | //! # Details 4 | //! 5 | //! Longshot is an API and command-line application to brew coffee from the command-line (or whatever 6 | //! front-end is built). At this time it supports DeLonghi ECAM-based Bluetooth-Low-Energy devices, and has only been tested on the 7 | //! Dinamica Plus over Bluetooth. 8 | //! 9 | //! The protocol for status and monitoring has been mostly decoded, but at this time is only available in 10 | //! source form. 11 | //! 12 | //! # Command-Line Examples 13 | //! 14 | //! Monitor the given device (will continue until you press Ctrl+C): 15 | //! 16 | //! ```console 17 | //! $ longshot monitor --device-name (device) 18 | //! Dispensing... [###############################===========] 19 | //! ``` 20 | //! 21 | //! Get the brew information for a given beverage: 22 | //! 23 | //! ```console 24 | //! $ longshot brew --device-name (device) --beverage regularcoffee 25 | //! ... 26 | //! ``` 27 | //! 28 | //! Brew a beverage: 29 | //! 30 | //! ```console 31 | //! $ longshot brew --device-name (device) --beverage regularcoffee --coffee 180 --taste strong 32 | //! Fetching recipe for RegularCoffee... 33 | //! Fetching recipes... 34 | //! Brewing RegularCoffee... 35 | //! ``` 36 | //! 37 | //! # API Examples 38 | //! 39 | //! Brew a long coffee with 250 impulses of water (approximately the size of an average North American coffee mug, or slightly more). 40 | //! 41 | //! ```no_run 42 | //! # use longshot::{ecam::*, protocol::*}; 43 | //! # let _ = async { 44 | //! # let device_id = EcamId::Name("00000000-0000-0000-0000-000000000000".into()); 45 | //! let ecam = ecam_lookup(&device_id, false).await?; 46 | //! let req = Request::BeverageDispensingMode( 47 | //! EcamBeverageId::LongCoffee.into(), 48 | //! EcamOperationTrigger::Start.into(), 49 | //! vec![RecipeInfo::new(EcamIngredients::Coffee, 250)], 50 | //! EcamBeverageTasteType::Prepare.into(), 51 | //! ); 52 | //! ecam.write_request(req).await?; 53 | //! # Result::<(), EcamError>::Ok(()) 54 | //! # }; 55 | //! ``` 56 | //! 57 | //! # Demo 58 | //! 59 | //! ![Demo of brewing a cappuccino](https://user-images.githubusercontent.com/512240/200137316-a09304e8-b34a-41ff-a847-af71af521ef8.gif) 60 | #![warn(clippy::all)] 61 | 62 | pub mod display; 63 | pub mod ecam; 64 | pub mod logging; 65 | pub mod operations; 66 | mod prelude; 67 | pub mod protocol; 68 | pub mod util; 69 | -------------------------------------------------------------------------------- /src/ecam/mod.rs: -------------------------------------------------------------------------------- 1 | //! Low-level communication with ECAM-based devices. 2 | 3 | use std::fmt::Display; 4 | 5 | use crate::prelude::*; 6 | 7 | use thiserror::Error; 8 | 9 | mod driver; 10 | mod ecam_bt; 11 | mod ecam_simulate; 12 | mod ecam_subprocess; 13 | mod ecam_wrapper; 14 | mod packet_receiver; 15 | mod packet_stream; 16 | mod stdin_stream; 17 | 18 | pub use self::ecam_bt::EcamBT; 19 | pub use driver::{EcamDriver, EcamDriverOutput}; 20 | pub use ecam_simulate::get_ecam_simulator; 21 | pub use ecam_subprocess::connect as get_ecam_subprocess; 22 | pub use ecam_wrapper::{Ecam, EcamOutput, EcamStatus}; 23 | pub use packet_receiver::EcamPacketReceiver; 24 | pub use stdin_stream::pipe_stdin; 25 | 26 | /// Holds the device name we would like to communicate with. 27 | #[derive(Clone, PartialEq, Eq)] 28 | pub enum EcamId { 29 | /// 'sim' 30 | Simulator(String), 31 | /// 'any' 32 | Any, 33 | /// Any non-wildcard string 34 | Name(String), 35 | } 36 | 37 | impl Display for EcamId { 38 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 39 | match self { 40 | Self::Any => f.write_fmt(format_args!("{}", "any")), 41 | Self::Simulator(sim) => f.write_fmt(format_args!("{}", sim)), 42 | Self::Name(name) => f.write_fmt(format_args!("{}", name)), 43 | } 44 | } 45 | } 46 | 47 | impl> From for EcamId { 48 | fn from(value: S) -> Self { 49 | let value = value.as_ref(); 50 | if value.starts_with("sim") { 51 | Self::Simulator(value.to_string()) 52 | } else if value == "any" { 53 | Self::Any 54 | } else { 55 | Self::Name(value.to_string()) 56 | } 57 | } 58 | } 59 | 60 | pub async fn ecam_scan() -> Result<(String, EcamId), EcamError> { 61 | EcamBT::scan().await 62 | } 63 | 64 | pub async fn ecam_lookup(id: &EcamId, dump_packets: bool) -> Result { 65 | let driver = Box::new(get_ecam_subprocess(id).await?); 66 | trace_packet!("Got ECAM subprocess"); 67 | Ok(Ecam::new(driver, dump_packets).await) 68 | } 69 | 70 | #[derive(Error, Debug)] 71 | pub enum EcamError { 72 | #[error("not found")] 73 | NotFound, 74 | #[error(transparent)] 75 | BTError(#[from] btleplug::Error), 76 | #[error(transparent)] 77 | IOError(#[from] std::io::Error), 78 | #[error("Unknown error")] 79 | Unknown, 80 | } 81 | -------------------------------------------------------------------------------- /src/operations/brew.rs: -------------------------------------------------------------------------------- 1 | use crate::{display, prelude::*}; 2 | use crate::{ 3 | ecam::{Ecam, EcamError, EcamStatus}, 4 | operations::{ 5 | BrewIngredientInfo, IngredientCheckError, IngredientCheckMode, check_ingredients, 6 | list_recipies_for, 7 | }, 8 | protocol::*, 9 | }; 10 | 11 | /// Checks the arguments for the given beverage against the machine's recipes and returns a computed recipe. 12 | pub async fn validate_brew( 13 | ecam: Ecam, 14 | beverage: EcamBeverageId, 15 | ingredients: Vec, 16 | mode: IngredientCheckMode, 17 | ) -> Result>, EcamError> { 18 | info!("Fetching recipe for {:?}...", beverage); 19 | let recipe_list = list_recipies_for(ecam.clone(), Some(vec![beverage])).await?; 20 | let recipe = recipe_list.find(beverage); 21 | if let Some(recipe) = recipe { 22 | let ranges = recipe.fetch_ingredients(); 23 | match check_ingredients(mode, &ingredients, &ranges) { 24 | Err(IngredientCheckError { 25 | missing, 26 | extra, 27 | range_errors, 28 | }) => { 29 | for m in missing { 30 | info!("{}", m.to_arg_string().unwrap_or(format!("{:?}", m))); 31 | } 32 | for e in extra { 33 | info!("{}", e.to_arg_string()); 34 | } 35 | for r in range_errors { 36 | info!("{}", r.1); 37 | } 38 | Err(EcamError::Unknown) 39 | } 40 | Ok(result) => { 41 | info!( 42 | "Brewing {:?} with {}...", 43 | beverage, 44 | result 45 | .iter() 46 | .collect_filter_map_join(" ", BrewIngredientInfo::to_arg_string) 47 | ); 48 | Ok(result 49 | .iter() 50 | .map(BrewIngredientInfo::to_recipe_info) 51 | .collect()) 52 | } 53 | } 54 | } else { 55 | info!( 56 | "I wasn't able to fetch the recipe for {:?}. Perhaps this machine can't make it?", 57 | beverage 58 | ); 59 | Err(EcamError::NotFound) 60 | } 61 | } 62 | 63 | pub async fn brew( 64 | ecam: Ecam, 65 | skip_brew: bool, 66 | beverage: EcamBeverageId, 67 | recipe: Vec>, 68 | ) -> Result<(), EcamError> { 69 | let req = Request::BeverageDispensingMode( 70 | beverage.into(), 71 | EcamOperationTrigger::Start.into(), 72 | recipe, 73 | EcamBeverageTasteType::Prepare.into(), 74 | ); 75 | 76 | if skip_brew { 77 | info!("--skip-brew was passed, so we aren't going to brew anything"); 78 | } else { 79 | ecam.write_request(req).await?; 80 | } 81 | 82 | // Wait for not ready 83 | ecam.wait_for_not_state(EcamStatus::Ready, display::display_status) 84 | .await?; 85 | 86 | // Wait for not busy 87 | ecam.wait_for( 88 | |m| !matches!(EcamStatus::extract(m), EcamStatus::Busy(_)), 89 | display::display_status, 90 | ) 91 | .await?; 92 | 93 | display::log(display::LogLevel::Info, "Completed"); 94 | 95 | Ok(()) 96 | } 97 | -------------------------------------------------------------------------------- /src/protocol/request/monitor.rs: -------------------------------------------------------------------------------- 1 | use super::PartialDecode; 2 | use crate::protocol::*; 3 | 4 | /// The response to a monitor inquiry sent by [`Request::MonitorV2`]. 5 | /// 6 | /// Some fields appear not to be used and always appear to be zero. 7 | #[derive(Clone, Debug, Default, Eq, PartialEq)] 8 | pub struct MonitorV2Response { 9 | pub state: MachineEnum, 10 | pub accessory: MachineEnum, 11 | pub switches: SwitchSet, 12 | pub alarms: SwitchSet, 13 | pub progress: u8, 14 | pub percentage: u8, 15 | pub unknown0: u8, 16 | pub unknown1: u8, 17 | pub unknown2: u8, 18 | pub unknown3: u8, 19 | pub unknown4: u8, 20 | } 21 | 22 | impl> PartialDecode> for SwitchSet { 23 | fn partial_decode(input: &mut &[u8]) -> Option> { 24 | let a = ::partial_decode(input)? as u16; 25 | let b = ::partial_decode(input)? as u16; 26 | // Note that this is inverted from ::partial_decode 27 | Some(SwitchSet::from_u16((b << 8) | a)) 28 | } 29 | } 30 | 31 | impl> PartialEncode for SwitchSet { 32 | fn partial_encode(&self, out: &mut Vec) { 33 | self.value.partial_encode(out) 34 | } 35 | } 36 | 37 | impl PartialDecode for MonitorV2Response { 38 | fn partial_decode(input: &mut &[u8]) -> Option { 39 | Some(MonitorV2Response { 40 | accessory: >::partial_decode(input)?, 41 | switches: >::partial_decode(input)?, 42 | alarms: >::partial_decode(input)?, 43 | state: >::partial_decode(input)?, 44 | progress: ::partial_decode(input)?, 45 | percentage: ::partial_decode(input)?, 46 | unknown0: ::partial_decode(input)?, 47 | unknown1: ::partial_decode(input)?, 48 | unknown2: ::partial_decode(input)?, 49 | unknown3: ::partial_decode(input)?, 50 | unknown4: ::partial_decode(input)?, 51 | }) 52 | } 53 | } 54 | 55 | impl PartialEncode for MonitorV2Response { 56 | fn partial_encode(&self, out: &mut Vec) { 57 | out.push(self.accessory.into()); 58 | self.switches.partial_encode(out); 59 | self.alarms.partial_encode(out); 60 | out.push(self.state.into()); 61 | out.push(self.progress); 62 | out.push(self.percentage); 63 | out.push(self.unknown0); 64 | out.push(self.unknown1); 65 | out.push(self.unknown2); 66 | out.push(self.unknown3); 67 | out.push(self.unknown4); 68 | } 69 | } 70 | 71 | #[cfg(test)] 72 | mod test { 73 | use crate::protocol::EcamMachineSwitch; 74 | 75 | use super::SwitchSet; 76 | 77 | #[test] 78 | fn switch_set_test() { 79 | let switches = SwitchSet::::of(&[]); 80 | assert_eq!("(empty)", format!("{:?}", switches)); 81 | let switches = 82 | SwitchSet::of(&[EcamMachineSwitch::MotorDown, EcamMachineSwitch::WaterSpout]); 83 | assert_eq!("WaterSpout | MotorDown", format!("{:?}", switches)); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/ecam/driver.rs: -------------------------------------------------------------------------------- 1 | use crate::{prelude::*, protocol::*}; 2 | 3 | use super::EcamId; 4 | 5 | #[derive(Clone, Debug, Eq, PartialEq)] 6 | pub enum EcamDriverOutput { 7 | Ready, 8 | Packet(EcamDriverPacket), 9 | Done, 10 | } 11 | 12 | /// Async-ish traits for read/write. See 13 | /// for some tips on making async trait functions. 14 | pub trait EcamDriver: Send + Sync { 15 | /// Read one item from the ECAM. 16 | fn read(&self) -> AsyncFuture>; 17 | 18 | /// Write one item to the ECAM. 19 | fn write(&self, data: EcamDriverPacket) -> AsyncFuture<()>; 20 | 21 | /// Returns true if the driver is alive. 22 | fn alive(&self) -> AsyncFuture; 23 | 24 | /// Scan for the first matching device. 25 | fn scan<'a>() -> AsyncFuture<'a, (String, EcamId)> 26 | where 27 | Self: Sized; 28 | } 29 | 30 | #[cfg(test)] 31 | mod test { 32 | use super::*; 33 | use crate::ecam::EcamError; 34 | use keepcalm::SharedMut; 35 | 36 | struct EcamTest { 37 | pub read_items: SharedMut>, 38 | pub write_items: SharedMut>, 39 | } 40 | 41 | impl EcamTest { 42 | pub fn new(items: Vec) -> EcamTest { 43 | let mut read_items = vec![]; 44 | read_items.push(EcamDriverOutput::Ready); 45 | read_items.extend(items); 46 | read_items.push(EcamDriverOutput::Done); 47 | EcamTest { 48 | read_items: SharedMut::new(read_items), 49 | write_items: SharedMut::new(vec![]), 50 | } 51 | } 52 | } 53 | 54 | impl EcamDriver for EcamTest { 55 | fn read(&self) -> crate::prelude::AsyncFuture> { 56 | Box::pin(async { 57 | if self.read_items.read().is_empty() { 58 | Ok(None) 59 | } else { 60 | Ok(Some(self.read_items.write().remove(0))) 61 | } 62 | }) 63 | } 64 | 65 | fn write(&self, data: EcamDriverPacket) -> crate::prelude::AsyncFuture<()> { 66 | self.write_items.write().push(data); 67 | Box::pin(async { Ok(()) }) 68 | } 69 | 70 | fn alive(&self) -> AsyncFuture { 71 | Box::pin(async { Ok(true) }) 72 | } 73 | 74 | fn scan<'a>() -> crate::prelude::AsyncFuture<'a, (String, EcamId)> 75 | where 76 | Self: Sized, 77 | { 78 | Box::pin(async { Err(EcamError::NotFound) }) 79 | } 80 | } 81 | 82 | #[tokio::test] 83 | async fn test_read() -> Result<(), EcamError> { 84 | let test = EcamTest::new(vec![EcamDriverOutput::Packet( 85 | EcamDriverPacket::from_slice(&[]), 86 | )]); 87 | assert_eq!( 88 | EcamDriverOutput::Ready, 89 | test.read().await?.expect("expected item") 90 | ); 91 | assert_eq!( 92 | EcamDriverOutput::Packet(EcamDriverPacket::from_slice(&[])), 93 | test.read().await?.expect("expected item") 94 | ); 95 | assert_eq!( 96 | EcamDriverOutput::Done, 97 | test.read().await?.expect("expected item") 98 | ); 99 | assert_eq!(None, test.read().await?); 100 | Ok(()) 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/protocol/request/recipe.rs: -------------------------------------------------------------------------------- 1 | use crate::protocol::*; 2 | 3 | /// Recipe information returned from [`Request::RecipeQuantityRead`]. 4 | #[derive(Clone, Copy, Debug, Eq, PartialEq)] 5 | pub struct RecipeInfo { 6 | pub ingredient: MachineEnum, 7 | pub value: T, 8 | } 9 | 10 | impl RecipeInfo { 11 | pub fn new(ingredient: EcamIngredients, value: T) -> Self { 12 | RecipeInfo { 13 | ingredient: ingredient.into(), 14 | value, 15 | } 16 | } 17 | } 18 | 19 | impl PartialDecode> for RecipeInfo { 20 | fn partial_decode(input: &mut &[u8]) -> Option { 21 | let ingredient = >::partial_decode(input)?; 22 | if let MachineEnum::Value(known) = ingredient { 23 | if known.is_wide_encoding().expect("Unknown encoding") { 24 | return Some(RecipeInfo { 25 | ingredient, 26 | value: ::partial_decode(input)?, 27 | }); 28 | } else { 29 | return Some(RecipeInfo { 30 | ingredient, 31 | value: ::partial_decode(input)? as u16, 32 | }); 33 | } 34 | } 35 | panic!("Unhandled ingredient {:?}", ingredient); 36 | } 37 | } 38 | 39 | impl PartialEncode for RecipeInfo { 40 | fn partial_encode(&self, out: &mut Vec) { 41 | out.push(self.ingredient.into()); 42 | let ingredient: Option = self.ingredient.into(); 43 | if ingredient 44 | .and_then(|x| x.is_wide_encoding()) 45 | .expect("Unknown encoding") 46 | { 47 | out.push((self.value >> 8) as u8); 48 | } 49 | out.push(self.value as u8); 50 | } 51 | } 52 | 53 | /// Recipe information returned from [`Request::RecipeQuantityRead`]. 54 | #[derive(Clone, Copy, Debug, Eq, PartialEq)] 55 | pub struct RecipeMinMaxInfo { 56 | pub ingredient: MachineEnum, 57 | pub min: u16, 58 | pub value: u16, 59 | pub max: u16, 60 | } 61 | 62 | impl PartialDecode for RecipeMinMaxInfo { 63 | fn partial_decode(input: &mut &[u8]) -> Option { 64 | let ingredient = >::partial_decode(input)?; 65 | if let MachineEnum::Value(known) = ingredient { 66 | if known 67 | .is_wide_encoding() 68 | .unwrap_or_else(|| panic!("Unknown encoding for {:?}", known)) 69 | { 70 | return Some(RecipeMinMaxInfo { 71 | ingredient, 72 | min: ::partial_decode(input)?, 73 | value: ::partial_decode(input)?, 74 | max: ::partial_decode(input)?, 75 | }); 76 | } else { 77 | return Some(RecipeMinMaxInfo { 78 | ingredient, 79 | min: ::partial_decode(input)? as u16, 80 | value: ::partial_decode(input)? as u16, 81 | max: ::partial_decode(input)? as u16, 82 | }); 83 | } 84 | } 85 | panic!("Unhandled ingredient {:?}", ingredient); 86 | } 87 | } 88 | 89 | impl PartialEncode for RecipeMinMaxInfo { 90 | fn partial_encode(&self, out: &mut Vec) { 91 | out.push(self.ingredient.into()); 92 | let ingredient: Option = self.ingredient.into(); 93 | if ingredient 94 | .and_then(|x| x.is_wide_encoding()) 95 | .expect("Unknown encoding") 96 | { 97 | self.min.partial_encode(out); 98 | self.value.partial_encode(out); 99 | self.max.partial_encode(out); 100 | } else { 101 | (self.min as u8).partial_encode(out); 102 | (self.value as u8).partial_encode(out); 103 | (self.max as u8).partial_encode(out); 104 | } 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/ecam/ecam_subprocess.rs: -------------------------------------------------------------------------------- 1 | use crate::prelude::*; 2 | 3 | use async_stream::stream; 4 | use futures::TryFutureExt; 5 | use std::process::Stdio; 6 | use tokio::{ 7 | io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, 8 | process::ChildStdin, 9 | sync::Mutex, 10 | }; 11 | use tokio_stream::wrappers::LinesStream; 12 | 13 | use crate::{ 14 | ecam::{AsyncFuture, EcamDriver, EcamDriverOutput, EcamError, EcamPacketReceiver}, 15 | protocol::*, 16 | }; 17 | 18 | use super::EcamId; 19 | 20 | pub struct EcamSubprocess { 21 | stdin: Arc>, 22 | receiver: EcamPacketReceiver, 23 | alive: Arc>, 24 | } 25 | 26 | impl EcamSubprocess { 27 | async fn write_stdin(&self, data: EcamDriverPacket) -> Result<(), EcamError> { 28 | let s = data.stringify(); 29 | self.stdin 30 | .lock() 31 | .await 32 | .write(format!("S: {}\n", s).as_bytes()) 33 | .map_ok(|_| ()) 34 | .await?; 35 | Ok(()) 36 | } 37 | 38 | async fn is_alive(&self) -> Result { 39 | Ok(*self.alive.lock().await) 40 | } 41 | } 42 | 43 | impl EcamDriver for EcamSubprocess { 44 | fn read<'a>(&self) -> AsyncFuture> { 45 | Box::pin(self.receiver.recv()) 46 | } 47 | 48 | fn write<'a>(&self, data: EcamDriverPacket) -> AsyncFuture<()> { 49 | Box::pin(self.write_stdin(data)) 50 | } 51 | 52 | fn alive(&self) -> AsyncFuture { 53 | Box::pin(self.is_alive()) 54 | } 55 | 56 | fn scan<'a>() -> AsyncFuture<'a, (String, EcamId)> 57 | where 58 | Self: Sized, 59 | { 60 | unimplemented!() 61 | } 62 | } 63 | 64 | pub async fn stream( 65 | mut child: tokio::process::Child, 66 | alive: Arc>, 67 | ) -> Result, EcamError> { 68 | let mut stderr = 69 | LinesStream::new(BufReader::new(child.stderr.take().expect("stderr was missing")).lines()); 70 | let mut stdout = 71 | LinesStream::new(BufReader::new(child.stdout.take().expect("stdout was missing")).lines()); 72 | 73 | let stdout = stream! { 74 | while let Some(Ok(s)) = stdout.next().await { 75 | if s == "R: READY" { 76 | yield EcamDriverOutput::Ready; 77 | } else if let Some(s) = s.strip_prefix("R: ") { 78 | if let Ok(bytes) = hex::decode(s) { 79 | yield EcamDriverOutput::Packet(EcamDriverPacket::from_vec(bytes)); 80 | } else { 81 | trace_packet!("Failed to decode '{}'", s); 82 | } 83 | } else { 84 | trace_packet!("{{stdout}} {}", s); 85 | } 86 | } 87 | }; 88 | let stderr = stream! { 89 | while let Some(Ok(s)) = stderr.next().await { 90 | if let Some(s) = s.strip_prefix("[TRACE] ") { 91 | trace_packet!("{}", s); 92 | } else { 93 | trace_packet!("{{stderr}} {}", s); 94 | } 95 | } 96 | // TODO: we might have to spawn this 97 | if false { 98 | yield EcamDriverOutput::Ready; 99 | } 100 | }; 101 | 102 | let termination = stream! { 103 | let _ = child.wait().await; 104 | *alive.lock().await = false; 105 | yield EcamDriverOutput::Done 106 | }; 107 | 108 | Result::Ok(stdout.merge(stderr).merge(termination)) 109 | } 110 | 111 | pub async fn connect(id: &EcamId) -> Result { 112 | let mut cmd = tokio::process::Command::new(std::env::current_exe()?); 113 | cmd.arg("--trace"); 114 | cmd.arg("x-internal-pipe"); 115 | cmd.arg("--device-name"); 116 | cmd.arg(id.to_string()); 117 | cmd.stdin(Stdio::piped()); 118 | cmd.stdout(Stdio::piped()); 119 | cmd.stderr(Stdio::piped()); 120 | cmd.kill_on_drop(true); 121 | let mut child = cmd.spawn()?; 122 | let stdin = Arc::new(Mutex::new(child.stdin.take().expect("stdin was missing"))); 123 | 124 | let alive = Arc::new(Mutex::new(true)); 125 | let s = Box::pin(stream(child, alive.clone()).await?); 126 | Result::Ok(EcamSubprocess { 127 | stdin, 128 | receiver: EcamPacketReceiver::from_stream(s, false), 129 | alive, 130 | }) 131 | } 132 | -------------------------------------------------------------------------------- /src/protocol/mod.rs: -------------------------------------------------------------------------------- 1 | //! Protocols for communication with ECAM-based devices. 2 | 3 | mod hardware_enums; 4 | mod machine_enum; 5 | mod packet; 6 | mod request; 7 | 8 | pub use hardware_enums::*; 9 | pub use machine_enum::*; 10 | pub use packet::*; 11 | pub use request::*; 12 | 13 | #[cfg(test)] 14 | pub mod test { 15 | use const_decoder::Decoder; 16 | 17 | /// Packet received when a brew response is sent 18 | pub const RESPONSE_BREW_RECEIVED: [u8; 8] = Decoder::Hex.decode(b"d00783f0010064d9"); 19 | /// Packet received when pouring CAPPUCCINO milk 20 | pub const RESPONSE_STATUS_CAPPUCCINO_MILK: [u8; 19] = 21 | Decoder::Hex.decode(b"d012750f02040100400a040000000000004183"); 22 | /// Packet received after pouring a CAPPUCCINO but before cleaning 23 | pub const RESPONSE_STATUS_READY_AFTER_CAPPUCCINO: [u8; 19] = 24 | Decoder::Hex.decode(b"d012750f02040100400700000000000000d621"); 25 | /// Packet received during cleaing 26 | pub const RESPONSE_STATUS_CLEANING_AFTER_CAPPUCCINO: [u8; 19] = 27 | Decoder::Hex.decode(b"d012750f04050100400c030900000000001cf0"); 28 | /// Packet received when no alarms are present, and the water spout is removed. 29 | pub const RESPONSE_STATUS_STANDBY_NO_ALARMS: [u8; 19] = 30 | Decoder::Hex.decode(b"d012750f000000000000036400000000009080"); 31 | /// Packet received when the water tank is missing, and the water spout is removed. 32 | pub const RESPONSE_STATUS_STANDBY_NO_WATER_TANK: [u8; 19] = 33 | Decoder::Hex.decode(b"d012750f00100000000003640000000000a7d0"); 34 | /// Packet received when no alarms are present, and the water spout is present. 35 | pub const RESPONSE_STATUS_STANDBY_WATER_SPOUT: [u8; 19] = 36 | Decoder::Hex.decode(b"d012750f01010000000003640000000000d696"); 37 | /// Packet received when the coffee grounds container is missing, and the water spout is present. 38 | pub const RESPONSE_STATUS_STANDBY_NO_COFFEE_CONTAINER: [u8; 19] = 39 | Decoder::Hex.decode(b"d012750f01090000000003640000000000cd3e"); 40 | /// Packet received while shutting down. 41 | pub const RESPONSE_STATUS_SHUTTING_DOWN_1: [u8; 19] = 42 | Decoder::Hex.decode(b"d012750f000000000002016400000000007fc5"); 43 | /// Packet received while shutting down. 44 | pub const RESPONSE_STATUS_SHUTTING_DOWN_2: [u8; 19] = 45 | Decoder::Hex.decode(b"d012750f0002000000020364000000000019cc"); 46 | /// Packet received while shutting down. 47 | pub const RESPONSE_STATUS_SHUTTING_DOWN_3: [u8; 19] = 48 | Decoder::Hex.decode(b"d012750f000000000002066400000000006681"); 49 | /// Packet received during descaling. 50 | pub const RESPONSE_STATUS_DESCALING_A: [u8; 19] = 51 | Decoder::Hex.decode(b"d012750f010500040804040000000000001018"); 52 | pub const RESPONSE_STATUS_DESCALING_B: [u8; 19] = 53 | Decoder::Hex.decode(b"d012750f01450005080408000000000000f076"); 54 | pub const RESPONSE_STATUS_DESCALING_C: [u8; 19] = 55 | Decoder::Hex.decode(b"d012750f011500040804080000000000007523"); 56 | pub const RESPONSE_STATUS_DESCALING_D: [u8; 19] = 57 | Decoder::Hex.decode(b"d012750f01030004080409000000000000f12c"); 58 | pub const RESPONSE_STATUS_DESCALING_E: [u8; 19] = 59 | Decoder::Hex.decode(b"d012750f01010004080409000000000000f7c6"); 60 | pub const RESPONSE_STATUS_DESCALING_F: [u8; 19] = 61 | Decoder::Hex.decode(b"d012750f01050004080409000000000000fa12"); 62 | pub const RESPONSE_STATUS_DESCALING_G: [u8; 19] = 63 | Decoder::Hex.decode(b"d012750f014500050804090000000000004817"); 64 | pub const RESPONSE_STATUS_DESCALING_H: [u8; 19] = 65 | Decoder::Hex.decode(b"d012750f014500050804070000000000007a9f"); 66 | pub const RESPONSE_STATUS_DESCALING_I: [u8; 19] = 67 | Decoder::Hex.decode(b"d012750f01150004080407000000000000ffca"); 68 | pub const RESPONSE_STATUS_DESCALING_J: [u8; 19] = 69 | Decoder::Hex.decode(b"d012750f01050004080407000000000000c89a"); 70 | pub const RESPONSE_STATUS_DESCALING_K: [u8; 19] = 71 | Decoder::Hex.decode(b"d012750f014d000100041100000000000073a3"); 72 | pub const RESPONSE_STATUS_DESCALING_L: [u8; 19] = 73 | Decoder::Hex.decode(b"d012750f01450001000411000000000000680b"); 74 | pub const RESPONSE_STATUS_DESCALING_M: [u8; 19] = 75 | Decoder::Hex.decode(b"d012750f01150000000109640000000000588f"); 76 | pub const RESPONSE_STATUS_DESCALING_N: [u8; 19] = 77 | Decoder::Hex.decode(b"d012750f010500000001096400000000006fdf"); 78 | } 79 | -------------------------------------------------------------------------------- /src/ecam/stdin_stream.rs: -------------------------------------------------------------------------------- 1 | use crate::prelude::*; 2 | use async_stream::stream; 3 | use std::time::Duration; 4 | use tokio::join; 5 | use tokio_stream::{Stream, StreamExt, wrappers::ReceiverStream}; 6 | use tuples::*; 7 | 8 | use crate::protocol::EcamDriverPacket; 9 | 10 | use super::{EcamDriver, EcamDriverOutput, EcamError}; 11 | 12 | /// Converts a stdio line to an EcamDriverOutput. 13 | fn parse_line(s: &str) -> Option { 14 | if s == "R: READY" { 15 | Some(EcamDriverOutput::Ready) 16 | } else if let Some(s) = s.strip_prefix("S: ") { 17 | if let Ok(bytes) = hex::decode(s) { 18 | Some(EcamDriverOutput::Packet(EcamDriverPacket::from_vec(bytes))) 19 | } else { 20 | None 21 | } 22 | } else if s.starts_with("Q:") { 23 | Some(EcamDriverOutput::Done) 24 | } else { 25 | None 26 | } 27 | } 28 | 29 | /// Converts an EcamDriverOutput to a stdio line. 30 | fn to_line(output: EcamDriverOutput) -> String { 31 | match output { 32 | EcamDriverOutput::Ready => "R: READY".to_owned(), 33 | EcamDriverOutput::Done => "Q:".to_owned(), 34 | EcamDriverOutput::Packet(p) => format!("R: {}", p.stringify()), 35 | } 36 | } 37 | 38 | fn packet_stdio_stream() -> impl Stream { 39 | let (tx, rx) = tokio::sync::mpsc::channel(1); 40 | std::thread::spawn(move || { 41 | for l in std::io::stdin().lines() { 42 | if tx.blocking_send(l).is_err() { 43 | break; 44 | } 45 | } 46 | }); 47 | 48 | let mut lines = ReceiverStream::new(rx); 49 | stream! { 50 | loop { 51 | match tokio::time::timeout(Duration::from_millis(250), lines.next()).await { 52 | Ok(Some(Ok(s))) => { 53 | match parse_line(&s) { 54 | Some(EcamDriverOutput::Packet(v)) => { yield v; } 55 | Some(EcamDriverOutput::Done) => { break; } 56 | _ => { warning!("Input error"); } 57 | } 58 | }, 59 | Err(_) => { /* Elapsed */ } 60 | _ => { 61 | break; 62 | } 63 | } 64 | } 65 | trace_shutdown!("packet_stdio_stream()"); 66 | } 67 | } 68 | 69 | macro_rules! spawn_loop { 70 | ($name:literal, $tx:expr, $async:block) => {{ 71 | let tx = $tx.clone(); 72 | async move { 73 | while let Ok(_) = tx.send(true) { 74 | $async 75 | } 76 | trace_shutdown!($name); 77 | let _ = tx.send(false); 78 | Result::<(), EcamError>::Ok(()) 79 | } 80 | }}; 81 | } 82 | 83 | /// Pipes an EcamDriver to/from stdio. 84 | pub async fn pipe_stdin( 85 | ecam: T, 86 | ) -> Result<(), Box> { 87 | let mut bt_out = Box::pin(packet_stdio_stream()); 88 | let ecam = Arc::new(Box::new(ecam)); 89 | let (tx, rx) = std::sync::mpsc::sync_channel(1); 90 | 91 | // Watchdog timer: if we don't get _some_ event within the timeout, we assume that things havegone sideways 92 | // in the underlying driver. 93 | std::thread::spawn(move || { 94 | loop { 95 | match rx.recv_timeout(Duration::from_millis(500)) { 96 | Err(_) => { 97 | trace_shutdown!("pipe_stdin() (watchdog expired)"); 98 | std::process::exit(1); 99 | } 100 | Ok(false) => { 101 | break; 102 | } 103 | Ok(true) => {} 104 | } 105 | } 106 | trace_shutdown!("pipe_stdin() (watchdog)"); 107 | }); 108 | 109 | let ecam2 = ecam.clone(); 110 | let a = spawn_loop!("alive", tx, { 111 | if !(ecam2.alive().await?) { 112 | break; 113 | } 114 | tokio::time::sleep(Duration::from_millis(10)).await; 115 | }); 116 | let ecam2 = ecam.clone(); 117 | let b = spawn_loop!("device read", tx, { 118 | if let Some(p) = ecam2.read().await? { 119 | println!("{}", to_line(p)); 120 | } else { 121 | break; 122 | } 123 | }); 124 | let c = spawn_loop!("stdio read", tx, { 125 | if let Some(value) = bt_out.next().await { 126 | ecam.write(value).await?; 127 | } else { 128 | break; 129 | } 130 | }); 131 | 132 | let x: Result<_, EcamError> = join!(a, b, c).map(|x| x).transpose1(); 133 | x?; 134 | 135 | trace_shutdown!("pipe_stdin()"); 136 | 137 | Result::Ok(()) 138 | } 139 | -------------------------------------------------------------------------------- /src/protocol/machine_enum.rs: -------------------------------------------------------------------------------- 1 | use std::{fmt::Debug, hash::Hash, marker::PhantomData}; 2 | 3 | /// Helper trait that collects the requirements for a MachineEnum. 4 | pub trait MachineEnumerable: 5 | TryFrom + Into + Copy + Debug + Eq + PartialEq + Ord + PartialOrd + Hash + Sized 6 | where 7 | T: MachineEnumerable, 8 | { 9 | fn all_values() -> &'static [T]; 10 | fn to_arg_string(&self) -> String; 11 | fn lookup_by_name_case_insensitive(s: &str) -> Option; 12 | fn lookup_by_name(s: &str) -> Option; 13 | 14 | /// Iterates over all the values of the enumeration. 15 | fn all() -> core::iter::Copied> { 16 | Self::all_values().iter().copied() 17 | } 18 | } 19 | 20 | /// Wraps a machine enumeration that may have unknown values. 21 | #[derive(Copy, Clone, Eq, PartialEq, PartialOrd, Hash)] 22 | pub enum MachineEnum> { 23 | Value(T), 24 | Unknown(u8), 25 | } 26 | 27 | impl Default for MachineEnum 28 | where 29 | T: MachineEnumerable, 30 | { 31 | fn default() -> Self { 32 | MachineEnum::decode(0) 33 | } 34 | } 35 | 36 | impl MachineEnum 37 | where 38 | T: MachineEnumerable, 39 | { 40 | pub fn decode(value: u8) -> Self { 41 | if let Ok(value) = T::try_from(value) { 42 | MachineEnum::Value(value) 43 | } else { 44 | MachineEnum::Unknown(value) 45 | } 46 | } 47 | } 48 | 49 | impl From for MachineEnum 50 | where 51 | T: MachineEnumerable, 52 | { 53 | fn from(t: T) -> Self { 54 | MachineEnum::Value(t) 55 | } 56 | } 57 | 58 | impl> From> for u8 { 59 | fn from(v: MachineEnum) -> Self { 60 | match v { 61 | MachineEnum::Value(v) => v.into(), 62 | MachineEnum::Unknown(v) => v, 63 | } 64 | } 65 | } 66 | 67 | impl> From> for Option { 68 | fn from(v: MachineEnum) -> Self { 69 | match v { 70 | MachineEnum::Value(v) => Some(v), 71 | _ => None, 72 | } 73 | } 74 | } 75 | 76 | impl> Debug for MachineEnum { 77 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 78 | match self { 79 | Self::Value(t) => t.fmt(f), 80 | Self::Unknown(v) => format!("Unknown({})", v).fmt(f), 81 | } 82 | } 83 | } 84 | 85 | impl> PartialEq for MachineEnum { 86 | fn eq(&self, other: &T) -> bool { 87 | match self { 88 | Self::Value(t) => t.eq(other), 89 | Self::Unknown(_v) => false, 90 | } 91 | } 92 | } 93 | 94 | /// Represents a set of enum values, some potentially unknown. 95 | #[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd)] 96 | pub struct SwitchSet> { 97 | pub value: u16, 98 | phantom: PhantomData, 99 | } 100 | 101 | impl> Default for SwitchSet { 102 | fn default() -> Self { 103 | SwitchSet::empty() 104 | } 105 | } 106 | 107 | impl> SwitchSet { 108 | pub fn of(input: &[T]) -> Self { 109 | let mut v = 0u16; 110 | for t in input { 111 | v |= 1 << >::into(*t); 112 | } 113 | Self::from_u16(v) 114 | } 115 | 116 | pub fn empty() -> Self { 117 | Self::from_u16(0) 118 | } 119 | 120 | pub fn from_u16(v: u16) -> Self { 121 | SwitchSet { 122 | value: v, 123 | phantom: PhantomData, 124 | } 125 | } 126 | 127 | pub fn set(&self) -> Vec> { 128 | // TODO: This should be an iterator 129 | let mut v = vec![]; 130 | for i in 0..core::mem::size_of::() * 8 - 1 { 131 | if self.value & (1 << i) != 0 { 132 | let i = ::try_from(i).expect("This should have fit in a u8"); 133 | v.push(MachineEnum::::decode(i)); 134 | } 135 | } 136 | v 137 | } 138 | } 139 | 140 | impl> std::fmt::Debug for SwitchSet { 141 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 142 | if self.value == 0 { 143 | f.write_str("(empty)") 144 | } else { 145 | let mut sep = ""; 146 | for i in 0..core::mem::size_of::() * 8 - 1 { 147 | if self.value & (1 << i) != 0 { 148 | let i = ::try_from(i).expect("This should have fit in a u8"); 149 | f.write_fmt(format_args!("{}{:?}", sep, MachineEnum::::decode(i)))?; 150 | sep = " | "; 151 | } 152 | } 153 | Ok(()) 154 | } 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /src/protocol/packet.rs: -------------------------------------------------------------------------------- 1 | use crate::protocol::request::{PartialDecode, PartialEncode}; 2 | use crc::Crc; 3 | use std::fmt::Debug; 4 | 5 | #[derive(Clone, Eq, PartialEq)] 6 | /// A simple byte-based driver packet, with header, length and checksum. 7 | pub struct EcamDriverPacket { 8 | pub(crate) bytes: Vec, 9 | } 10 | 11 | impl Debug for EcamDriverPacket { 12 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 13 | f.write_str(&hexdump(&self.bytes)) 14 | } 15 | } 16 | 17 | impl EcamDriverPacket { 18 | pub fn from_slice(bytes: &[u8]) -> Self { 19 | EcamDriverPacket { 20 | bytes: bytes.into(), 21 | } 22 | } 23 | 24 | pub fn from_vec(bytes: Vec) -> Self { 25 | EcamDriverPacket { bytes } 26 | } 27 | 28 | pub fn stringify(&self) -> String { 29 | stringify(&self.bytes) 30 | } 31 | 32 | pub fn packetize(&self) -> Vec { 33 | packetize(&self.bytes) 34 | } 35 | } 36 | 37 | /// A packet that may have a representation attached, allowing us to parse a packet once and only once. 38 | #[derive(Clone, Debug, Eq, PartialEq)] 39 | pub struct EcamPacket { 40 | pub representation: Option, 41 | pub bytes: EcamDriverPacket, 42 | } 43 | 44 | impl EcamPacket { 45 | #[cfg(test)] 46 | pub fn from_raw(input: &[u8]) -> EcamPacket { 47 | let bytes = EcamDriverPacket::from_vec(input.to_vec()); 48 | EcamPacket { 49 | representation: None, 50 | bytes, 51 | } 52 | } 53 | } 54 | 55 | impl> EcamPacket { 56 | pub fn from_bytes(mut input: &[u8]) -> EcamPacket { 57 | let bytes = EcamDriverPacket::from_vec(input.to_vec()); 58 | let input = &mut input; 59 | let representation = ::partial_decode(input); 60 | EcamPacket { 61 | representation, 62 | bytes, 63 | } 64 | } 65 | } 66 | 67 | impl EcamPacket { 68 | pub fn from_represenation(representation: T) -> EcamPacket { 69 | let bytes = EcamDriverPacket::from_vec(representation.encode()); 70 | EcamPacket { 71 | representation: Some(representation), 72 | bytes, 73 | } 74 | } 75 | } 76 | 77 | impl> From for EcamPacket { 78 | fn from(packet: EcamDriverPacket) -> Self { 79 | EcamPacket::from_bytes(&packet.bytes) 80 | } 81 | } 82 | 83 | impl From> for EcamDriverPacket { 84 | fn from(packet: EcamPacket) -> Self { 85 | packet.bytes 86 | } 87 | } 88 | 89 | pub const CRC_ALGO: Crc = Crc::::new(&crc::CRC_16_SPI_FUJITSU); 90 | 91 | /// Computes the checksum from a partial packet. Note that the checksum used here is 92 | /// equivalent to the `CRC_16_SPI_FUJITSU` definition (initial 0x1d0f, poly 0x1021). 93 | pub fn checksum(buffer: &[u8]) -> [u8; 2] { 94 | let i = CRC_ALGO.checksum(buffer); 95 | [(i >> 8) as u8, (i & 0xff) as u8] 96 | } 97 | 98 | /// Returns the contents of the packet, minus header and checksum. 99 | pub fn unwrap_packet>(buffer: &T) -> &[u8] { 100 | let u: &[u8] = buffer.as_ref(); 101 | &u[2..u.len() - 2] 102 | } 103 | 104 | fn packetize(buffer: &[u8]) -> Vec { 105 | let mut out = [ 106 | &[ 107 | 0x0d, 108 | (buffer.len() + 3).try_into().expect("Packet too large"), 109 | ], 110 | buffer, 111 | ] 112 | .concat(); 113 | out.extend_from_slice(&checksum(&out)); 114 | out 115 | } 116 | 117 | fn stringify(buffer: &[u8]) -> String { 118 | buffer 119 | .iter() 120 | .map(|n| format!("{:02x}", n)) 121 | .collect::() 122 | } 123 | 124 | /// Dumps a packet to a readable hex form. 125 | pub fn hexdump(buffer: &[u8]) -> String { 126 | let maybe_space = |i| if i > 0 && i % 8 == 0 { " " } else { "" }; 127 | let s1: String = buffer 128 | .iter() 129 | .enumerate() 130 | .map(|(i, b)| format!("{}{:02x}", maybe_space(i), b)) 131 | .collect::(); 132 | let s2: String = buffer 133 | .iter() 134 | .map(|b| { 135 | if *b >= 32 && *b < 127 { 136 | *b as char 137 | } else { 138 | '.' 139 | } 140 | }) 141 | .collect::(); 142 | format!("|{}| |{}|", s1, s2) 143 | } 144 | 145 | #[cfg(test)] 146 | pub mod test { 147 | use super::{checksum, packetize}; 148 | 149 | pub fn from_hex_str(s: &str) -> Vec { 150 | hex::decode(s.replace(' ', "")).unwrap() 151 | } 152 | 153 | #[test] 154 | pub fn test_checksum() { 155 | assert_eq!( 156 | checksum(&from_hex_str("0d 0f 83 f0 02 01 01 00 67 02 02 00 00 06")), 157 | [0x77, 0xff] 158 | ); 159 | assert_eq!( 160 | checksum(&from_hex_str("0d 0d 83 f0 05 01 01 00 78 00 00 06")), 161 | [0xc4, 0x7e] 162 | ); 163 | assert_eq!(checksum(&from_hex_str("0d 07 84 0f 02 01")), [0x55, 0x12]); 164 | } 165 | 166 | #[test] 167 | pub fn test_packetize() { 168 | assert_eq!( 169 | packetize(&from_hex_str("83 f0 02 01 01 00 67 02 02 00 00 06")), 170 | from_hex_str("0d 0f 83 f0 02 01 01 00 67 02 02 00 00 06 77 ff") 171 | ); 172 | assert_eq!( 173 | packetize(&from_hex_str("83 f0 05 01 01 00 78 00 00 06")), 174 | from_hex_str("0d 0d 83 f0 05 01 01 00 78 00 00 06 c4 7e") 175 | ); 176 | assert_eq!( 177 | packetize(&from_hex_str("84 0f 02 01")), 178 | from_hex_str("0d 07 84 0f 02 01 55 12") 179 | ); 180 | assert_eq!( 181 | packetize(&from_hex_str("75 f0")), 182 | from_hex_str("0d 05 75 f0 c4 d5") 183 | ); 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /src/ecam/packet_stream.rs: -------------------------------------------------------------------------------- 1 | use crate::prelude::*; 2 | 3 | use async_stream::stream; 4 | use futures::{Stream, StreamExt}; 5 | 6 | use crate::protocol::{checksum, hexdump}; 7 | 8 | const SYNC_BYTE: u8 = 0xd0; 9 | /// Minimum packet length is four: length, one data byte, two bytes of checksum (sync byte doesn't count for length). 10 | const MIN_PACKET_LEN: u8 = 4; 11 | 12 | /// Builds a packet from collections of bytes and emits it if and only if the length and checksum are valid. 13 | /// 14 | /// The [`PacketBuilder`] assumes that packets are aligned to the input chunks, and that a starting chunk 15 | /// that doesn't start with the sync byte, is corrupted or orphaned. 16 | /// 17 | /// A starting chunk is defined as the next chunk recieved after a packet is emitted. 18 | #[derive(Default)] 19 | struct PacketBuilder { 20 | packet_buffer: Vec, 21 | offset: usize, 22 | } 23 | 24 | impl PacketBuilder { 25 | pub fn new() -> Self { 26 | PacketBuilder::default() 27 | } 28 | 29 | #[cfg(test)] 30 | pub fn is_empty(&self) -> bool { 31 | self.packet_buffer.is_empty() 32 | } 33 | 34 | /// Accumulates a single packet chunk, returning the entire packet as a [`Vec`] if it is complete. 35 | pub fn accumulate(&mut self, chunk: &[u8]) -> Option> { 36 | self.packet_buffer.extend_from_slice(chunk); 37 | let is_valid_packet = |p: &[u8]| p[0] == SYNC_BYTE && p[1] >= MIN_PACKET_LEN; 38 | 39 | 'reparse: loop { 40 | let p = self.current_packet(); 41 | 42 | // Don't bother parsing if we don't have a sync byte and length at least 43 | if p.len() < 2 { 44 | break; 45 | } 46 | 47 | // If we're not starting on a valid packet, eat bytes until we are. 48 | if !is_valid_packet(p) { 49 | self.offset += 1; 50 | continue 'reparse; 51 | } 52 | 53 | let packet_size = p[1] as usize; 54 | if packet_size < p.len() { 55 | let checksum = checksum(&p[..packet_size - 1]); 56 | // If the checksum doesn't match, assume these are spurious bytes and attempt to reparse one position forward 57 | if p[packet_size - 1..=packet_size] != checksum { 58 | trace_packet!( 59 | "Checksum mismatch: {:?} vs {:?}", 60 | &p[packet_size - 1..=packet_size], 61 | checksum 62 | ); 63 | self.offset += 1; 64 | continue 'reparse; 65 | } 66 | // We have a full packet, so take what we need 67 | let offset = std::mem::take(&mut self.offset); 68 | let packet_buffer = std::mem::take(&mut self.packet_buffer); 69 | // Optimization: we have exactly the packet we wanted, so just return the buffer 70 | if offset == 0 && packet_buffer.len() == packet_size + 1 { 71 | return Some(packet_buffer); 72 | } 73 | return Some(packet_buffer[offset..=offset + packet_size].to_vec()); 74 | } 75 | 76 | break; 77 | } 78 | 79 | None 80 | } 81 | 82 | fn current_packet(&self) -> &[u8] { 83 | &self.packet_buffer[self.offset..] 84 | } 85 | } 86 | 87 | /// Converts a stream of raw bytes into a stream of decoded packets. 88 | pub fn packet_stream(mut n: T) -> impl Stream> 89 | where 90 | T: Stream> + StreamExt + std::marker::Unpin, 91 | { 92 | stream! { 93 | let mut p = PacketBuilder::new(); 94 | while let Some(m) = n.next().await { 95 | trace_packet!("{{device->host}} {}", hexdump(&m)); 96 | if let Some(v) = p.accumulate(&m) { 97 | yield v; 98 | } 99 | } 100 | trace_shutdown!("packet_stream()"); 101 | } 102 | } 103 | 104 | #[cfg(test)] 105 | mod test { 106 | use super::*; 107 | use rstest::*; 108 | 109 | #[rstest] 110 | #[case(vec![SYNC_BYTE, 4, 10, 25, 22])] 111 | #[case(vec![SYNC_BYTE, 5, 10, 20, 240, 157])] 112 | #[case(vec![SYNC_BYTE, 6, 10, 20, 30, 26, 60])] 113 | #[case(vec![SYNC_BYTE, 18, 117, 15, 1, 1, 0, 0, 0, 0, 3, 100, 0, 0, 0, 0, 0, 214, 150])] 114 | fn packet_accumulate_exact(#[case] bytes: Vec) { 115 | let mut p = PacketBuilder::new(); 116 | assert_eq!(Some(bytes.clone()), p.accumulate(&bytes)); 117 | assert!(p.is_empty()); 118 | } 119 | 120 | /// Test that extra bytes are tossed away 121 | #[rstest] 122 | #[case(vec![SYNC_BYTE, 4, 10, 25, 22, 99])] 123 | #[case(vec![SYNC_BYTE, 5, 10, 20, 240, 157, 99, 99, 99])] 124 | fn packet_accumulate_too_many(#[case] bytes: Vec) { 125 | let mut p = PacketBuilder::new(); 126 | let len = bytes[1] as usize; 127 | let out = bytes[0..len + 1].to_vec(); 128 | assert_eq!(Some(out), p.accumulate(&bytes)); 129 | assert!(p.is_empty()); 130 | } 131 | 132 | /// Ensure that we parse this packet correctly regardless of how it is chunked, and with or without garbage before/after. 133 | #[rstest] 134 | fn chunked_packet( 135 | #[values(true, false)] garbage_before: bool, 136 | #[values(true, false)] garbage_after: bool, 137 | ) { 138 | let mut packet = vec![ 139 | SYNC_BYTE, 18, 117, 15, 1, 1, 0, 0, 0, 0, 3, 100, 0, 0, 0, 0, 0, 214, 150, 140 | ]; 141 | let expected = packet.clone(); 142 | if garbage_before { 143 | let mut tmp = vec![1, 2, 3]; 144 | tmp.splice(0..0, packet); 145 | packet = tmp; 146 | } 147 | if garbage_after { 148 | packet.extend_from_slice(&[1, 2, 3]); 149 | } 150 | for i in 0..12 { 151 | let mut p = PacketBuilder::new(); 152 | assert!(p.accumulate(&packet[..i]).is_none()); 153 | assert_eq!(Some(expected.clone()), p.accumulate(&packet[i..])); 154 | assert!(p.is_empty()); 155 | } 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /src/operations/parameter.rs: -------------------------------------------------------------------------------- 1 | use std::collections::BTreeMap; 2 | 3 | use crate::{ 4 | ecam::{Ecam, EcamError, EcamOutput}, 5 | prelude::*, 6 | protocol::{Request, Response}, 7 | }; 8 | 9 | pub async fn read_parameter_memory(ecam: Ecam) -> Result<(), EcamError> { 10 | let mut tap = ecam.packet_tap().await?; 11 | let mut last_all_zero = false; 12 | for i in 0..0x1000 { 13 | let param = i * 4; 14 | ecam.write_request(Request::ParameterReadExt(param, 4)) 15 | .await?; 16 | let now = std::time::Instant::now(); 17 | while now.elapsed() < Duration::from_millis(500) { 18 | match tokio::time::timeout(Duration::from_millis(50), tap.next()).await { 19 | Err(_) => {} 20 | Ok(None) => { 21 | eprintln!("No packet received for {:04x}", param); 22 | return Err(EcamError::Unknown); 23 | } 24 | Ok(Some(x)) => { 25 | if let Some(packet) = x.take_packet() { 26 | if let Response::ParameterReadExt(param, data) = packet { 27 | let all_zero = data.iter().all(|d| *d == 0); 28 | if all_zero { 29 | if last_all_zero { 30 | break; 31 | } 32 | last_all_zero = all_zero; 33 | println!("..."); 34 | break; 35 | } 36 | last_all_zero = all_zero; 37 | print!("{:04x}: ", param); 38 | for d in &data { 39 | print!("{:02x}", d); 40 | } 41 | print!(" "); 42 | for d in &data { 43 | if *d >= 32 && *d < 127 { 44 | print!("{}", *d as char); 45 | } else { 46 | print!("."); 47 | } 48 | } 49 | println!(); 50 | break; 51 | } else { 52 | eprintln!("Unexpected packet: {:?}", packet); 53 | return Err(EcamError::Unknown); 54 | } 55 | } else { 56 | eprintln!("No packet received for {:04x}", param); 57 | return Err(EcamError::Unknown); 58 | } 59 | } 60 | } 61 | } 62 | } 63 | 64 | Ok(()) 65 | } 66 | 67 | pub async fn read_parameter(ecam: Ecam, param: u16, len: u8) -> Result<(), EcamError> { 68 | let mut tap = ecam.packet_tap().await?; 69 | let ecam = ecam.clone(); 70 | let _handle = tokio::spawn(async move { 71 | while let Some(packet) = tap.next().await { 72 | // if dump_decoded_packets { 73 | trace_packet!("{:?}", packet); 74 | // } 75 | if packet == EcamOutput::Done { 76 | break; 77 | } 78 | } 79 | }); 80 | 81 | if len > 4 { 82 | ecam.write_request(Request::ParameterReadExt(param, len)) 83 | .await?; 84 | } else { 85 | ecam.write_request(Request::ParameterRead(param, len)) 86 | .await?; 87 | } 88 | 89 | while ecam.is_alive() { 90 | tokio::time::sleep(Duration::from_millis(100)).await; 91 | } 92 | 93 | Ok(()) 94 | } 95 | 96 | pub async fn read_statistic(ecam: Ecam, param: u16, len: u8) -> Result<(), EcamError> { 97 | let mut tap = ecam.packet_tap().await?; 98 | let ecam = ecam.clone(); 99 | let _handle = tokio::spawn(async move { 100 | while let Some(packet) = tap.next().await { 101 | // if dump_decoded_packets { 102 | trace_packet!("{:?}", packet); 103 | // } 104 | if packet == EcamOutput::Done { 105 | break; 106 | } 107 | } 108 | }); 109 | 110 | ecam.write_request(Request::StatisticsRead(param, len)) 111 | .await?; 112 | 113 | while ecam.is_alive() { 114 | tokio::time::sleep(Duration::from_millis(100)).await; 115 | } 116 | 117 | Ok(()) 118 | } 119 | 120 | /// Read all statistics from the device. The machine behaves strangely: 121 | /// 122 | /// - It will never return invalid statistics, so if you ask for statistic "1" 123 | /// and it doesn't exist, it'll jump to the next valid statistic. 124 | /// - It always behaves as if it read N statistics (N appears to be 9?) and 125 | /// then truncates the list to the length specified. To read all statistics, 126 | /// you need to use the maximum length because asking for the last statistic 127 | /// will result in the machine returning MAX - STAT_BATCH_SIZE as the first one 128 | /// and then truncating to the length specified. 129 | /// 130 | /// So what we need to do is: 131 | /// 132 | /// Ask for stat 1, length 16. This returns the first statistic clamped to the internal length (9). 133 | /// We then ask for the _last_ statistic in that batch, length 16, which gets the next batch. Continue until 134 | /// we get a response of zero length. 135 | pub async fn read_statistics(ecam: Ecam) -> Result<(), EcamError> { 136 | let mut tap = ecam.packet_tap().await?; 137 | 138 | // let mut last_stat = 0; 139 | let mut current_stat = 1; 140 | const BATCH_SIZE: u8 = 16; 141 | 142 | let mut all_stats = BTreeMap::new(); 143 | 144 | loop { 145 | ecam.write_request(Request::StatisticsRead(current_stat, BATCH_SIZE)) 146 | .await?; 147 | let now = std::time::Instant::now(); 148 | while now.elapsed() < Duration::from_millis(500) { 149 | match tokio::time::timeout(Duration::from_millis(50), tap.next()).await { 150 | Err(_) => {} 151 | Ok(None) => { 152 | eprintln!("No packet received for {:04x}", current_stat); 153 | return Err(EcamError::Unknown); 154 | } 155 | Ok(Some(x)) => { 156 | if let Some(packet) = x.take_packet() { 157 | if let Response::StatisticsRead(stats) = packet { 158 | if stats.is_empty() { 159 | return Ok(()); 160 | } 161 | for stat in &stats { 162 | if all_stats.insert(stat.stat, *stat).is_none() { 163 | println!( 164 | "{:>5}: {:08x} ({})", 165 | stat.stat, stat.value, stat.value 166 | ); 167 | } 168 | } 169 | current_stat = stats.last().unwrap().stat; 170 | } 171 | } 172 | } 173 | } 174 | } 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /src/protocol/hardware_enums.rs: -------------------------------------------------------------------------------- 1 | //! This file contains validated hardware enumerations and associated values. 2 | 3 | #![allow(dead_code)] 4 | use super::MachineEnumerable; 5 | use num_enum::{IntoPrimitive, TryFromPrimitive}; 6 | 7 | macro_rules! hardware_enum { 8 | ($comment:literal, $name:ident { $($(# [ doc = $item_comment:literal ])? $x:ident = $v:literal,)* } ) => { 9 | #[doc=$comment] 10 | #[repr(u8)] 11 | #[derive( 12 | Copy, 13 | Clone, 14 | Debug, 15 | PartialEq, 16 | PartialOrd, 17 | Ord, 18 | IntoPrimitive, 19 | TryFromPrimitive, 20 | Eq, 21 | Hash, 22 | )] 23 | pub enum $name { $($(#[doc=$item_comment])? $x = $v),* } 24 | 25 | impl $name { 26 | } 27 | 28 | impl MachineEnumerable<$name> for $name { 29 | /// Return a static slice of all possible enumeration values, useful for iteration. 30 | fn all_values() -> &'static[$name] { 31 | &[$(Self::$x),*] 32 | } 33 | 34 | fn lookup_by_name_case_insensitive(s: &str) -> Option<$name> { 35 | // TODO: Can use one of the static ToString crates to improve this 36 | Self::all().find(|e| format!("{:?}", e).eq_ignore_ascii_case(s)) 37 | } 38 | 39 | fn lookup_by_name(s: &str) -> Option<$name> { 40 | // TODO: Can use one of the static ToString crates to improve this 41 | Self::all().find(|e| format!("{:?}", e) == s) 42 | } 43 | 44 | /// Generate the argument-style string for this enum. Ideally we'd use a [`&str`], but the appropriate methods 45 | /// are not `const` at this time. 46 | fn to_arg_string(&self) -> String { 47 | match *self { 48 | $(Self::$x => { 49 | stringify!($x).to_ascii_lowercase() 50 | })* 51 | } 52 | } 53 | } 54 | }; 55 | } 56 | 57 | hardware_enum! {"Ingredients used for brew operations.", EcamIngredients { 58 | Temp = 0, // TEMP 59 | Coffee = 1, // COFFEE 60 | Taste = 2, // TASTE 61 | Granulometry = 3, // GRANULOMETRY 62 | Blend = 4, // BLEND 63 | InfusionSpeed = 5, // INFUSIONSPEED 64 | Preinfusion = 6, // PREINFUSIONE 65 | Crema = 7, // CREMA 66 | DueXPer = 8, // DUExPER 67 | Milk = 9, // MILK 68 | MilkTemp = 10, // MILKTEMP 69 | MilkFroth = 11, // MILKFROTH 70 | Inversion = 12, // INVERSION 71 | TheTemp = 13, // THETEMP 72 | TheProfile = 14, // THEPROFILE 73 | HotWater = 15, // HOTWATER 74 | MixVelocity = 16, // MIXVELOCITY 75 | MixDuration = 17, // MIXDURATION 76 | DensityMultiBeverage = 18, // DENSITYMULTIBEVERAGE 77 | TempMultiBeverage = 19, // TEMPMULTIBEVERAGE 78 | DecalcType = 20, // DECALCTYPE 79 | TempRisciaquo = 21, // TEMPRISCIACQUO 80 | WaterRisciaquo = 22, // WATERRISCIACQUO 81 | CleanType = 23, // CLEANTYPE 82 | Programmable = 24, // PROGRAMABLE 83 | Visible = 25, // VISIBLE 84 | VisibleInProgramming = 26, // VISIBLEINPROGRAMMING 85 | IndexLength = 27, // INDEXLENGTH 86 | Accessorio = 28, // ACCESSORIO 87 | }} 88 | 89 | impl EcamIngredients { 90 | /// Is this ingredient encoded as two bytes? Unknown encodings return None. 91 | pub fn is_wide_encoding(&self) -> Option { 92 | match self { 93 | EcamIngredients::Temp 94 | | EcamIngredients::Taste 95 | | EcamIngredients::Inversion 96 | | EcamIngredients::DueXPer 97 | | EcamIngredients::IndexLength 98 | | EcamIngredients::Visible 99 | | EcamIngredients::Programmable 100 | | EcamIngredients::Accessorio => Some(false), 101 | EcamIngredients::Coffee | EcamIngredients::Milk | EcamIngredients::HotWater => { 102 | Some(true) 103 | } 104 | _ => None, 105 | } 106 | } 107 | } 108 | 109 | hardware_enum! {"Beverage preparation mode.", EcamBeverageTasteType { 110 | Delete = 0, 111 | Save = 1, 112 | /// Prepare a beverage. This is the most likely enumeration value you'll want to use. 113 | Prepare = 2, 114 | PrepareAndSave = 3, 115 | SaveInversion = 5, 116 | PrepareInversion = 6, 117 | PrepareAndSaveInversion = 7, 118 | }} 119 | 120 | hardware_enum! {"Operation mode/trigger.", EcamOperationTrigger { 121 | DontCare = 0, 122 | /// Start preparing a beverage. This is the most likely enumeration value you'll want to use. 123 | Start = 1, 124 | /// This is STARTPROGRAM and STOPV2, but only STOPV2 appears to be used. 125 | StartProgramOrStopV2 = 2, 126 | NextStep = 3, 127 | Stop = 4, 128 | StopProgram = 5, 129 | ExitProgramOk = 6, 130 | AdvancedMode = 7, 131 | }} 132 | 133 | hardware_enum! {"Identifier determining the type of request and response (also referred to as the 'answer ID').", EcamRequestId { 134 | SetBtMode = 17, 135 | MonitorV0 = 96, 136 | MonitorV1 = 112, 137 | /// Send a monitor V2 packet to the machine. This is the only tested and working monitor functionality. 138 | MonitorV2 = 117, 139 | /// Brew a beverage, or interact with the profile saving functionality. 140 | BeverageDispensingMode = 131, 141 | /// (2, 1) for turn on, (3, 2) for refresh app ID. 142 | AppControl = 132, 143 | /// Read a parameter from the device. Used for reads less than or equal to 4 blocks, less than or equal to 10 blocks (each block is 2 bytes). 144 | ParameterRead = 149, 145 | ParameterWrite = 144, 146 | /// Read a parameter from the device. Used for reads longer than 4 blocks, less than or equal to 10 blocks (each block is 2 bytes). 147 | ParameterReadExt = 161, 148 | StatisticsRead = 162, 149 | Checksum = 163, 150 | ProfileNameRead = 164, 151 | ProfileNameWrite = 165, 152 | /// Read the default recipe for a beverage from the machine. 153 | RecipeQuantityRead = 166, 154 | /// Read the priority order of beverages from the machine. 155 | RecipePriorityRead = 168, 156 | ProfileSelection = 169, 157 | RecipeNameRead = 170, 158 | RecipeNameWrite = 171, 159 | SetFavoriteBeverages = 173, 160 | /// Request the min/max values for a given beverage. This may be a PIN operation in some other versions of the protocol. 161 | RecipeMinMaxSync = 176, 162 | PinSet = 177, 163 | BeanSystemSelect = 185, 164 | BeanSystemRead = 186, 165 | BeanSystemWrite = 187, 166 | PinRead = 210, 167 | SetTime = 226, 168 | }} 169 | 170 | hardware_enum! {"The temperature of the dispensed beverage.", EcamTemperature { 171 | Low = 0, 172 | Mid = 1, 173 | High = 2, 174 | VeryHigh = 3, 175 | }} 176 | 177 | hardware_enum! {"The strength of the dispensed beverage.", EcamBeverageTaste { 178 | Preground = 0, 179 | ExtraMild = 1, 180 | Mild = 2, 181 | Normal = 3, 182 | Strong = 4, 183 | ExtraStrong = 5, 184 | }} 185 | 186 | hardware_enum! {"The current state of the machine.", EcamMachineState { 187 | StandBy = 0, 188 | TurningOn = 1, 189 | ShuttingDown = 2, 190 | Descaling = 4, 191 | SteamPreparation = 5, 192 | Recovery = 6, 193 | ReadyOrDispensing = 7, 194 | Rinsing = 8, 195 | MilkPreparation = 10, 196 | HotWaterDelivery = 11, 197 | MilkCleaning = 12, 198 | ChocolatePreparation = 16, 199 | }} 200 | 201 | hardware_enum! {"The accessory that is connected to the accessory port.", EcamAccessory { 202 | None = 0, 203 | Water = 1, 204 | Milk = 2, 205 | Chocolate = 3, 206 | MilkClean = 4, 207 | }} 208 | 209 | hardware_enum! {"The type of beverage to prepare.", EcamBeverageId { 210 | EspressoCoffee = 1, 211 | RegularCoffee = 2, 212 | LongCoffee = 3, 213 | EspressoCoffee2X = 4, 214 | DoppioPlus = 5, 215 | Americano = 6, 216 | Cappuccino = 7, 217 | LatteMacchiato = 8, 218 | CaffeLatte = 9, 219 | FlatWhite = 10, 220 | EspressoMacchiato = 11, 221 | HotMilk = 12, 222 | CappuccinoDoppioPlus = 13, 223 | ColdMilk = 14, 224 | CappuccinoReverse = 15, 225 | HotWater = 16, 226 | Steam = 17, 227 | Ciocco = 18, 228 | Ristretto = 19, 229 | LongEspresso = 20, 230 | CoffeeCream = 21, 231 | Tea = 22, 232 | CoffeePot = 23, 233 | Cortado = 24, 234 | LongBlack = 25, 235 | TravelMug = 26, 236 | BrewOverIce = 27, 237 | Custom01 = 230, 238 | Custom02 = 231, 239 | Custom03 = 232, 240 | Custom04 = 233, 241 | Custom05 = 234, 242 | Custom06 = 235, 243 | Custom07 = 236, 244 | Custom08 = 237, 245 | Custom09 = 238, 246 | Custom10 = 239, 247 | }} 248 | 249 | hardware_enum! {"The set of alarms the machine can produce.", EcamMachineAlarm { 250 | EmptyWaterTank = 0, 251 | CoffeeWasteContainerFull = 1, 252 | DescaleAlarm = 2, 253 | ReplaceWaterFilter = 3, 254 | CoffeeGroundTooFine = 4, 255 | CoffeeBeansEmpty = 5, 256 | MachineToService = 6, 257 | CoffeeHeaterProbeFailure = 7, 258 | TooMuchCoffee = 8, 259 | CoffeeInfuserMotorNotWorking = 9, 260 | EmptyDripTray = 11, 261 | SteamerProbeFailure = 10, 262 | TankIsInPosition = 13, 263 | HydraulicCircuitProblem = 12, 264 | CoffeeBeansEmptyTwo = 15, 265 | CleanKnob = 14, 266 | BeanHopperAbsent = 17, 267 | TankTooFull = 16, 268 | InfuserSense = 19, 269 | GridPresence = 18, 270 | ExpansionCommProb = 21, 271 | NotEnoughCoffee = 20, 272 | GrindingUnit1Problem = 23, 273 | ExpansionSubmodulesProb = 22, 274 | CondenseFanProblem = 25, 275 | GrindingUnit2Problem = 24, 276 | SpiCommProblem = 27, 277 | ClockBtCommProblem = 26, 278 | }} 279 | 280 | hardware_enum! {"The various switches that the machine reads.", EcamMachineSwitch { 281 | WaterSpout = 0, 282 | MotorUp = 1, 283 | MotorDown = 2, 284 | CoffeeWasteContainer = 3, 285 | WaterTankAbsent = 4, 286 | Knob = 5, 287 | WaterLevelLow = 6, 288 | CoffeeJug = 7, 289 | IfdCaraffe = 8, 290 | CioccoTank = 9, 291 | CleanKnob = 10, 292 | DoorOpened = 13, 293 | PregroundDoorOpened = 14, 294 | }} 295 | -------------------------------------------------------------------------------- /src/ecam/ecam_simulate.rs: -------------------------------------------------------------------------------- 1 | use tokio::sync::Mutex; 2 | 3 | use crate::ecam::{EcamDriver, EcamDriverOutput, EcamError}; 4 | use crate::prelude::*; 5 | use crate::protocol::{ 6 | EcamAccessory, EcamBeverageId, EcamDriverPacket, EcamMachineState, EcamMachineSwitch, 7 | EcamRequestId, MonitorV2Response, PartialEncode, SwitchSet, hexdump, 8 | }; 9 | 10 | use super::EcamId; 11 | 12 | struct EcamSimulate { 13 | rx: Mutex>, 14 | tx: Mutex>, 15 | } 16 | 17 | /// These are the recipes the simulator will make 18 | fn get_recipes(beverage: EcamBeverageId) -> Option<(Vec, Vec)> { 19 | use EcamBeverageId::*; 20 | 21 | let (recipe, minmax) = match beverage { 22 | EspressoCoffee => ( 23 | "010028020308001b041901", 24 | "010014002800b4020003050800000118010101190101011b0004041c000000", 25 | ), 26 | RegularCoffee => ( 27 | "0100b402031b041901", 28 | "01006400b400f00200030518010101190101011b0004041c000000", 29 | ), 30 | LongCoffee => ( 31 | "0100fa02051b041901", 32 | "01007300a000fa0200030518010101190101011b0004041c000000", 33 | ), 34 | EspressoCoffee2X => ( 35 | "010050020308001b041901", 36 | "01002800500168020003050801010118000000190101011b0004041c000000", 37 | ), 38 | DoppioPlus => ( 39 | "01007802011b041901", 40 | "010050007800b40200010118010101190101011b0004041c000000", 41 | ), 42 | Cappuccino => ( 43 | "0100410900be02030c001b0419011c02", 44 | "010014004100b409003c00be03840200030518010101190101010c0000001c0002001b000404", 45 | ), 46 | LatteMacchiato => ( 47 | "01003c0900dc02030c001b0419011c02", 48 | "010014003c00b409003c00dc03840200030518010101190101010c0000001c0002001b000404", 49 | ), 50 | CaffeLatte => ( 51 | "01003c0901f402030c001b0419011c02", 52 | "010014003c00b409003201f403840200030518010101190101010c0000001c0002001b000404", 53 | ), 54 | FlatWhite => ( 55 | "01003c0901f402030c001b0419011c02", 56 | "010014003c00b409003c01f403840200030518010101190101010c0000001c0002001b000404", 57 | ), 58 | EspressoMacchiato => ( 59 | "01001e09003c02030c001b0419011c02", 60 | "010014001e00b409003c003c03840200030518010101190101010c0000001c0002001b000404", 61 | ), 62 | HotMilk => ( 63 | "0901c21c021b041901", 64 | "09003c01c2038418010101190101011c0002001b000404", 65 | ), 66 | CappuccinoDoppioPlus => ( 67 | "0100780900be02010c001b0419011c02", 68 | "010050007800b409003c00be03840200010118010101190101010c0000001c0002001b000404", 69 | ), 70 | CappuccinoReverse => ( 71 | "0100410900be02030c011b0419011c02", 72 | "010014004100b409003c00be03840200030518010101190101010c0101011c0002001b000404", 73 | ), 74 | HotWater => ("0f00fa19011c01", "0f001400fa01a418010101190101011c000100"), 75 | CoffeePot => ( 76 | "0100fa02030f00001b041901", 77 | "0100fa00fa00fa18000000020003050f000000000000190101011b000404", 78 | ), 79 | Cortado => ( 80 | "01006402000f00001b041901", 81 | "010028006400f018010101020003050f000000000000190101011b000404", 82 | ), 83 | Custom01 => ( 84 | "0100b409000002050c001c001b041901", 85 | "010014005000b409003200a003840200030518010101190000000c0000011c0000001b000404", 86 | ), 87 | Custom02 => ( 88 | "01002809000002050c001c001b041901", 89 | "010014005000b409003200a003840200030518010101190000000c0000011c0000001b000404", 90 | ), 91 | Custom03 => ( 92 | "01000009000002030c001c001b041900", 93 | "010014005000b409003200a003840200030518010101190000000c0000011c0000001b000404", 94 | ), 95 | Custom04 => ( 96 | "0100500900a002030c001c001b041900", 97 | "010014005000b409003200a003840200030518010101190000000c0000011c0000001b000404", 98 | ), 99 | Custom05 => ( 100 | "0100500900a002030c001c001b041900", 101 | "010014005000b409003200a003840200030518010101190000000c0000011c0000001b000404", 102 | ), 103 | Custom06 => ( 104 | "0100500900a002030c001c001b041900", 105 | "010014005000b409003200a003840200030518010101190000000c0000011c0000001b000404", 106 | ), 107 | _ => { 108 | return None; 109 | } 110 | }; 111 | 112 | Some(( 113 | hex::decode(recipe).expect("Failed to decode constant"), 114 | hex::decode(minmax).expect("Failed to decode constant"), 115 | )) 116 | } 117 | 118 | impl EcamDriver for EcamSimulate { 119 | fn read(&self) -> AsyncFuture> { 120 | Box::pin(async { 121 | let packet = self.rx.lock().await.recv().await; 122 | Ok(packet) 123 | }) 124 | } 125 | 126 | fn write(&self, data: crate::protocol::EcamDriverPacket) -> AsyncFuture<()> { 127 | trace_packet!("{{host->device}} {}", hexdump(&data.bytes)); 128 | Box::pin(async move { 129 | if data.bytes[0] == EcamRequestId::RecipeQuantityRead as u8 { 130 | let mut packet = vec![data.bytes[0], 0xf0, 1, data.bytes[3]]; 131 | if let Ok(beverage) = data.bytes[3].try_into() { 132 | if let Some((recipe, _)) = get_recipes(beverage) { 133 | packet = [packet, recipe].concat(); 134 | } 135 | } 136 | send(&*self.tx.lock().await, packet).await?; 137 | } 138 | if data.bytes[0] == EcamRequestId::RecipeMinMaxSync as u8 { 139 | let mut packet = vec![data.bytes[0], 0xf0, data.bytes[2]]; 140 | if let Ok(beverage) = data.bytes[2].try_into() { 141 | if let Some((_, minmax)) = get_recipes(beverage) { 142 | packet = [packet, minmax].concat(); 143 | } 144 | } 145 | send(&*self.tx.lock().await, packet).await?; 146 | } 147 | Ok(()) 148 | }) 149 | } 150 | 151 | fn alive(&self) -> AsyncFuture { 152 | Box::pin(async { Ok(true) }) 153 | } 154 | 155 | fn scan<'a>() -> AsyncFuture<'a, (String, EcamId)> 156 | where 157 | Self: Sized, 158 | { 159 | unimplemented!() 160 | } 161 | } 162 | 163 | /// Create a Vec that mocks a machine response. 164 | fn make_simulated_response(state: EcamMachineState, progress: u8, percentage: u8) -> Vec { 165 | let mut v = vec![EcamRequestId::MonitorV2.into(), 0xf0]; 166 | v.extend_from_slice( 167 | &MonitorV2Response { 168 | state: state.into(), 169 | accessory: EcamAccessory::None.into(), 170 | switches: SwitchSet::of(&[EcamMachineSwitch::WaterSpout]), 171 | alarms: SwitchSet::empty(), 172 | progress, 173 | percentage, 174 | ..Default::default() 175 | } 176 | .encode(), 177 | ); 178 | v 179 | } 180 | 181 | fn eat_errors_with_warning(e: T) -> EcamError { 182 | warning!("{:?}", e); 183 | EcamError::Unknown 184 | } 185 | 186 | async fn send_output( 187 | tx: &tokio::sync::mpsc::Sender, 188 | packet: EcamDriverOutput, 189 | ) -> Result<(), EcamError> { 190 | tx.send(packet).await.map_err(eat_errors_with_warning) 191 | } 192 | 193 | async fn send( 194 | tx: &tokio::sync::mpsc::Sender, 195 | v: Vec, 196 | ) -> Result<(), EcamError> { 197 | trace_packet!("{}", hexdump(&v)); 198 | send_output(tx, EcamDriverOutput::Packet(EcamDriverPacket::from_vec(v))).await 199 | } 200 | 201 | pub async fn get_ecam_simulator(id: &EcamId) -> Result, EcamError> { 202 | let simulator = if let EcamId::Simulator(simulator) = id { 203 | simulator 204 | } else { 205 | return Err(EcamError::NotFound); 206 | }; 207 | 208 | let (tx, rx) = tokio::sync::mpsc::channel(1); 209 | const DELAY: Duration = Duration::from_millis(250); 210 | send_output(&tx, EcamDriverOutput::Ready).await?; 211 | let tx_out = tx.clone(); 212 | let on = simulator.ends_with("[on]"); 213 | trace_packet!("Initializing simulator: {}", simulator); 214 | tokio::spawn(async move { 215 | if !on { 216 | // Start in standby 217 | for _ in 0..5 { 218 | send( 219 | &tx, 220 | make_simulated_response(EcamMachineState::StandBy, 0, 0), 221 | ) 222 | .await?; 223 | tokio::time::sleep(DELAY).await; 224 | } 225 | 226 | // Turning on 227 | for i in 0..5 { 228 | send( 229 | &tx, 230 | make_simulated_response(EcamMachineState::TurningOn, 0, i * 20), 231 | ) 232 | .await?; 233 | tokio::time::sleep(DELAY).await; 234 | } 235 | } 236 | 237 | // Ready 238 | for _ in 0..3 { 239 | send( 240 | &tx, 241 | make_simulated_response(EcamMachineState::ReadyOrDispensing, 0, 0), 242 | ) 243 | .await?; 244 | tokio::time::sleep(DELAY).await; 245 | } 246 | 247 | // Dispensing 248 | for i in 0..25 { 249 | send( 250 | &tx, 251 | make_simulated_response(EcamMachineState::ReadyOrDispensing, i, i * 4), 252 | ) 253 | .await?; 254 | tokio::time::sleep(DELAY).await; 255 | } 256 | 257 | // Ready forever 258 | for _ in 0..10 { 259 | send( 260 | &tx, 261 | make_simulated_response(EcamMachineState::ReadyOrDispensing, 0, 0), 262 | ) 263 | .await?; 264 | tokio::time::sleep(DELAY).await; 265 | } 266 | 267 | send_output(&tx, EcamDriverOutput::Done).await?; 268 | 269 | trace_shutdown!("EcamSimulate"); 270 | Result::<(), EcamError>::Ok(()) 271 | }); 272 | Ok(EcamSimulate { 273 | rx: Mutex::new(rx), 274 | tx: Mutex::new(tx_out), 275 | }) 276 | } 277 | -------------------------------------------------------------------------------- /src/ecam/ecam_bt.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashSet; 2 | 3 | use crate::ecam::{EcamDriver, EcamDriverOutput, EcamError, EcamPacketReceiver}; 4 | use crate::{prelude::*, protocol::*}; 5 | use btleplug::api::{ 6 | Central, CharPropFlags, Characteristic, Manager as _, Peripheral as _, ScanFilter, 7 | }; 8 | use btleplug::platform::{Adapter, Manager}; 9 | use stream_cancel::{StreamExt as _, Tripwire}; 10 | use tokio::time; 11 | 12 | use super::EcamId; 13 | use super::packet_stream::packet_stream; 14 | 15 | const SERVICE_UUID: uuid::Uuid = uuid::Uuid::from_u128(0x00035b03_58e6_07dd_021a_08123a000300); 16 | const CHARACTERISTIC_UUID: uuid::Uuid = 17 | uuid::Uuid::from_u128(0x00035b03_58e6_07dd_021a_08123a000301); 18 | 19 | /// The concrete peripheral type to avoid going crazy here managaing an unsized trait. 20 | type Peripheral = ::Peripheral; 21 | 22 | /// Bluetooth implementation of [`EcamDriver`], running on top of [`btleplug`]. 23 | pub struct EcamBT { 24 | peripheral: EcamPeripheral, 25 | notifications: EcamPacketReceiver, 26 | } 27 | 28 | impl EcamBT { 29 | /// Returns the given [`EcamBT`] instance identified by the [`EcamId`]. 30 | pub async fn get(id: EcamId) -> Result { 31 | let manager = Manager::new().await?; 32 | Self::get_ecam_from_manager(&manager, id).await 33 | } 34 | 35 | async fn get_ecam_from_manager(manager: &Manager, id: EcamId) -> Result { 36 | let adapter_list = manager.adapters().await?; 37 | if adapter_list.is_empty() { 38 | return Result::Err(EcamError::NotFound); 39 | } 40 | 41 | let (tx, mut rx) = tokio::sync::mpsc::channel(1); 42 | for adapter in adapter_list.into_iter() { 43 | let id = id.clone(); 44 | adapter 45 | .start_scan(ScanFilter { 46 | services: vec![SERVICE_UUID], 47 | }) 48 | .await?; 49 | let tx = tx.clone(); 50 | let _ = tokio::spawn(async move { 51 | trace_packet!("Looking for peripheral '{}'", id); 52 | let mut invalid_peripherals = HashSet::new(); 53 | 'outer: loop { 54 | for peripheral in adapter.peripherals().await? { 55 | let raw_id = peripheral.id(); 56 | if invalid_peripherals.contains(&raw_id) { 57 | continue; 58 | } 59 | let ecam_peripheral = if let Some(ecam_peripheral) = 60 | EcamPeripheral::validate(peripheral).await? 61 | { 62 | ecam_peripheral 63 | } else { 64 | trace_packet!("Found peripheral, not a match: {:?}", raw_id); 65 | invalid_peripherals.insert(raw_id); 66 | continue; 67 | }; 68 | 69 | if ecam_peripheral.matches(&id) { 70 | trace_packet!("Got peripheral: {:?}", ecam_peripheral.peripheral.id()); 71 | let peripheral = 72 | EcamPeripheral::connect(ecam_peripheral.peripheral).await?; 73 | trace_packet!("Connected"); 74 | let notifications = EcamPacketReceiver::from_stream( 75 | Box::pin(peripheral.notifications().await?), 76 | true, 77 | ); 78 | 79 | // Ignore errors here -- we just want the first peripheral that connects 80 | let _ = tx 81 | .send(EcamBT { 82 | peripheral, 83 | notifications, 84 | }) 85 | .await; 86 | break 'outer; 87 | } 88 | } 89 | } 90 | Result::<_, EcamError>::Ok(()) 91 | }) 92 | .await; 93 | } 94 | 95 | let ecam = rx.recv().await.expect("Failed to receive anything"); 96 | trace_packet!("Got ECAM!"); 97 | Ok(ecam) 98 | } 99 | 100 | /// Scans for ECAM devices. 101 | async fn scan() -> Result<(String, EcamId), EcamError> { 102 | let manager = Manager::new().await?; 103 | let adapter_list = manager.adapters().await?; 104 | for adapter in adapter_list.into_iter() { 105 | if let Ok(Some(p)) = Self::get_peripheral_matching(&adapter).await { 106 | return Ok((p.local_name.clone(), EcamId::Name(p.id()))); 107 | } 108 | } 109 | Err(EcamError::NotFound) 110 | } 111 | 112 | /// Searches an adapter for something that meets the definition of [`EcamPeripheral`]. 113 | async fn get_peripheral_matching( 114 | adapter: &Adapter, 115 | ) -> Result, EcamError> { 116 | trace_packet!("Starting scan on {}...", adapter.adapter_info().await?); 117 | let filter = ScanFilter { 118 | services: vec![SERVICE_UUID], 119 | }; 120 | adapter.start_scan(filter).await?; 121 | 122 | for _ in 0..10 { 123 | time::sleep(Duration::from_secs(1)).await; 124 | let peripherals = adapter.peripherals().await?; 125 | for peripheral in peripherals.into_iter() { 126 | trace_packet!("Found peripheral, address = {:?}", peripheral.address()); 127 | if let Some(peripheral) = EcamPeripheral::validate(peripheral).await? { 128 | return Ok(Some(peripheral)); 129 | } 130 | } 131 | } 132 | 133 | Ok(None) 134 | } 135 | } 136 | 137 | impl EcamDriver for EcamBT { 138 | fn read<'a>(&self) -> AsyncFuture> { 139 | Box::pin(self.notifications.recv()) 140 | } 141 | 142 | fn write<'a>(&self, data: EcamDriverPacket) -> AsyncFuture<()> { 143 | Box::pin(self.peripheral.write(data.packetize())) 144 | } 145 | 146 | fn alive(&self) -> AsyncFuture { 147 | Box::pin(self.peripheral.is_alive()) 148 | } 149 | 150 | fn scan<'a>() -> AsyncFuture<'a, (String, EcamId)> 151 | where 152 | Self: Sized, 153 | { 154 | Box::pin(Self::scan()) 155 | } 156 | } 157 | 158 | /// Holds most of the device BTLE communication functionality. 159 | #[derive(Clone)] 160 | struct EcamPeripheral { 161 | pub local_name: String, 162 | peripheral: Peripheral, 163 | characteristic: Characteristic, 164 | } 165 | 166 | impl EcamPeripheral { 167 | pub fn matches(&self, id: &EcamId) -> bool { 168 | match id { 169 | EcamId::Simulator(..) => false, 170 | EcamId::Any => true, 171 | EcamId::Name(name) => { 172 | let name = name.to_lowercase(); 173 | let btaddr = self.peripheral.address().to_string().to_lowercase(); 174 | btaddr.contains(&name) || format!("{:?}", self.peripheral.id()).contains(&name) 175 | } 176 | } 177 | } 178 | 179 | /// Write a packet to the device. We must set "WithResponse" to get data back from the device. 180 | pub async fn write(&self, data: Vec) -> Result<(), EcamError> { 181 | trace_packet!("{{host->device}} {}", hexdump(&data)); 182 | Result::Ok( 183 | self.peripheral 184 | .write( 185 | &self.characteristic, 186 | &data, 187 | btleplug::api::WriteType::WithResponse, 188 | ) 189 | .await?, 190 | ) 191 | } 192 | 193 | pub async fn is_alive(&self) -> Result { 194 | Ok(self.peripheral.is_connected().await?) 195 | } 196 | 197 | #[cfg(not(target_os = "macos"))] 198 | pub fn id(&self) -> String { 199 | self.peripheral.address().to_string() 200 | } 201 | 202 | #[cfg(target_os = "macos")] 203 | pub fn id(&self) -> String { 204 | let id = format!("{:?}", self.peripheral.id()); 205 | id[13..id.len() - 1].to_owned() 206 | } 207 | 208 | pub async fn notifications( 209 | &self, 210 | ) -> Result + use<>, EcamError> { 211 | self.peripheral.subscribe(&self.characteristic).await?; 212 | let peripheral = self.peripheral.clone(); 213 | let (trigger, tripwire) = Tripwire::new(); 214 | tokio::spawn(async move { 215 | while peripheral.is_connected().await.unwrap_or_default() { 216 | tokio::time::sleep(Duration::from_millis(50)).await; 217 | } 218 | trace_shutdown!("peripheral.is_connected"); 219 | drop(trigger); 220 | }); 221 | 222 | // Raw stream of bytes from device 223 | let notifications = self.peripheral.notifications().await?.map(|m| m.value); 224 | // Parse into packets and stop when device disconnected 225 | let n = packet_stream(notifications) 226 | .map(|v| EcamDriverOutput::Packet(EcamDriverPacket::from_slice(unwrap_packet(&v)))) 227 | .take_until_if(tripwire); 228 | Ok(n) 229 | } 230 | 231 | /// Assumes that a [`Peripheral`] is a valid ECAM, and connects to it. 232 | pub async fn connect(peripheral: Peripheral) -> Result { 233 | peripheral.connect().await?; 234 | let characteristic = Characteristic { 235 | uuid: CHARACTERISTIC_UUID, 236 | service_uuid: SERVICE_UUID, 237 | properties: CharPropFlags::WRITE | CharPropFlags::READ | CharPropFlags::INDICATE, 238 | descriptors: Default::default(), 239 | }; 240 | 241 | Ok(EcamPeripheral { 242 | local_name: "unknown".to_owned(), 243 | peripheral, 244 | characteristic, 245 | }) 246 | } 247 | 248 | /// Validates that a [`Peripheral`] is a valid ECAM, and returns `Ok(Some(EcamPeripheral))` if so. 249 | pub async fn validate(peripheral: Peripheral) -> Result, EcamError> { 250 | let properties = peripheral.properties().await?; 251 | let is_connected = peripheral.is_connected().await?; 252 | let properties = properties.ok_or(EcamError::Unknown)?; 253 | if let Some(local_name) = properties.local_name { 254 | if !is_connected { 255 | peripheral.connect().await? 256 | } 257 | peripheral.is_connected().await?; 258 | peripheral.discover_services().await?; 259 | for service in peripheral.services() { 260 | for characteristic in service.characteristics { 261 | if characteristic.uuid == CHARACTERISTIC_UUID { 262 | return Ok(Some(EcamPeripheral { 263 | local_name, 264 | peripheral, 265 | characteristic, 266 | })); 267 | } 268 | } 269 | } 270 | return Ok(None); 271 | } 272 | Ok(None) 273 | } 274 | } 275 | -------------------------------------------------------------------------------- /src/display.rs: -------------------------------------------------------------------------------- 1 | //! Status display utilities. 2 | 3 | use crate::ecam::EcamStatus; 4 | use colored::*; 5 | use keepcalm::SharedGlobalMut; 6 | use std::io::{IsTerminal, Write}; 7 | 8 | /// Initializes the global display based on the `TERM` and `COLORTERM` environment variables. 9 | static DISPLAY: SharedGlobalMut> = SharedGlobalMut::new_lazy_unsync(|| { 10 | let term = std::env::var("TERM").ok(); 11 | let colorterm = std::env::var("COLORTERM").ok(); 12 | 13 | if term.is_none() || !std::io::stdout().is_terminal() || !std::io::stderr().is_terminal() { 14 | Box::::default() 15 | } else if colorterm.is_some() { 16 | Box::new(ColouredStatusDisplay::new(80)) 17 | } else { 18 | Box::new(BasicStatusDisplay::new(80)) 19 | } 20 | }); 21 | 22 | /// Displays the [`EcamStatus`] according to the current mode. 23 | pub fn display_status(state: EcamStatus) { 24 | DISPLAY.write().display(state) 25 | } 26 | 27 | /// Clears the currently displayed status. 28 | pub fn clear_status() { 29 | DISPLAY.write().clear_status() 30 | } 31 | 32 | pub fn shutdown() { 33 | println!(); 34 | } 35 | 36 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] 37 | pub enum LogLevel { 38 | Trace, 39 | Info, 40 | Warning, 41 | Error, 42 | } 43 | 44 | impl LogLevel { 45 | pub fn prefix(&self) -> &'static str { 46 | match self { 47 | LogLevel::Trace => "[TRACE] ", 48 | LogLevel::Warning => "[WARNING] ", 49 | LogLevel::Error => "[ERROR] ", 50 | LogLevel::Info => "", 51 | } 52 | } 53 | } 54 | 55 | /// Logs the [`EcamStatus`] according to the current mode. 56 | pub fn log(level: LogLevel, s: &str) { 57 | DISPLAY.write().log(level, s) 58 | } 59 | 60 | trait StatusDisplay: Send { 61 | fn display(&mut self, state: EcamStatus); 62 | fn clear_status(&mut self); 63 | fn log(&mut self, level: LogLevel, s: &str); 64 | } 65 | 66 | /// [`StatusDisplay`] for basic terminals, or non-TTY stdio. 67 | #[derive(Default)] 68 | struct NoTtyStatusDisplay { 69 | last_state: Option, 70 | } 71 | 72 | impl StatusDisplay for NoTtyStatusDisplay { 73 | fn display(&mut self, state: EcamStatus) { 74 | if self.last_state == Some(state) { 75 | return; 76 | } 77 | println!("{:?}", state); 78 | self.last_state = Some(state); 79 | } 80 | 81 | fn clear_status(&mut self) { 82 | self.last_state = None; 83 | } 84 | 85 | fn log(&mut self, level: LogLevel, s: &str) { 86 | if level == LogLevel::Info { 87 | println!("{}", s); 88 | } else { 89 | eprintln!("{}{}", level.prefix(), s); 90 | } 91 | } 92 | } 93 | 94 | struct TtyStatus { 95 | pub activity: usize, 96 | pub width: usize, 97 | last_was_status: bool, 98 | last_status: Option, 99 | } 100 | 101 | impl TtyStatus { 102 | fn new(width: usize) -> Self { 103 | Self { 104 | activity: 0, 105 | width, 106 | last_was_status: false, 107 | last_status: None, 108 | } 109 | } 110 | 111 | fn log(&mut self, level: LogLevel, s: &str) { 112 | if std::mem::take(&mut self.last_was_status) { 113 | print!("\r{}\r", " ".repeat(self.width)); 114 | std::io::stdout().flush().unwrap(); 115 | } 116 | if level == LogLevel::Info { 117 | println!("{}", s); 118 | std::io::stdout().flush().unwrap(); 119 | } else { 120 | eprintln!("{}{}", level.prefix(), s); 121 | std::io::stderr().flush().unwrap(); 122 | } 123 | if let Some(s) = &self.last_status { 124 | print!("{}", s); 125 | self.last_was_status = true; 126 | std::io::stdout().flush().unwrap(); 127 | } 128 | } 129 | 130 | fn clear_status(&mut self) { 131 | if std::mem::take(&mut self.last_was_status) { 132 | print!("\r{}\r", " ".repeat(self.width)); 133 | std::io::stdout().flush().unwrap(); 134 | } 135 | self.last_status = None; 136 | } 137 | 138 | fn status(&mut self, s: &str) { 139 | self.last_status = Some(s.to_owned()); 140 | print!("{}", s); 141 | self.last_was_status = true; 142 | std::io::stdout().flush().unwrap(); 143 | } 144 | 145 | fn random>(&self, n: usize, i: usize) -> T { 146 | let n = (self.activity * 321 + 677 * i) % n; 147 | n.into() 148 | } 149 | 150 | fn pick_str<'a>(&self, s: &'a str, i: usize) -> &'a str { 151 | let r = self.random(s.len(), i); 152 | &s[r..=r] 153 | } 154 | } 155 | 156 | struct ColouredStatusDisplay { 157 | tty: TtyStatus, 158 | } 159 | 160 | impl ColouredStatusDisplay { 161 | pub fn new(width: usize) -> Self { 162 | Self { 163 | tty: TtyStatus::new(width), 164 | } 165 | } 166 | } 167 | 168 | impl StatusDisplay for ColouredStatusDisplay { 169 | fn log(&mut self, level: LogLevel, s: &str) { 170 | self.tty.log(level, s); 171 | } 172 | 173 | fn clear_status(&mut self) { 174 | self.tty.clear_status(); 175 | } 176 | 177 | fn display(&mut self, state: EcamStatus) { 178 | const BUBBLE_CHARS: &str = "⋅º.∘°⚬"; 179 | 180 | let (percent, emoji, status_text) = match state { 181 | EcamStatus::Ready => (0, "✅", "Ready".to_string()), 182 | EcamStatus::StandBy => (0, "💤", "Standby".to_string()), 183 | EcamStatus::Busy(percent) => (percent, "☕", format!("Dispensing... ({}%)", percent)), 184 | EcamStatus::Cleaning(percent) => (percent, "💧", format!("Cleaning... ({}%)", percent)), 185 | EcamStatus::Descaling => (0, "💧", "Descaling".to_string()), 186 | EcamStatus::TurningOn(percent) => { 187 | (percent, "💡", format!("Turning on... ({}%)", percent)) 188 | } 189 | EcamStatus::ShuttingDown(percent) => { 190 | (percent, "🛏", format!("Shutting down... ({}%)", percent)) 191 | } 192 | EcamStatus::Alarm(alarm) => (0, "🔔", format!("Alarm ({:?})", alarm)), 193 | EcamStatus::Fetching(percent) => (percent, "👓", format!("Fetching... ({}%)", percent)), 194 | }; 195 | 196 | let mut status = " ".to_owned() + &status_text; 197 | let pad = " ".repeat(self.tty.width - status.len() - 6); 198 | status = status + &pad; 199 | let temp_vec = vec![]; 200 | if percent == 0 { 201 | self.tty.status(&format!( 202 | "\r{} ▐{}▌ ", 203 | emoji, 204 | status.truecolor(153, 141, 109).on_truecolor(92, 69, 6) 205 | )); 206 | } else { 207 | let status = status.chars().collect::>(); 208 | 209 | // This isn't super pretty but it's visually what we need and Good Enough™️ 210 | let (left, right) = status.split_at((percent * status.len()) / 100); 211 | let (mid, right) = if right.len() <= 2 { 212 | (right, temp_vec.as_slice()) 213 | } else { 214 | right.split_at(2) 215 | }; 216 | let mut left = left.to_owned(); 217 | if left.len() > 10 { 218 | for i in 0..2 { 219 | // Pick a spot at random 220 | let pos = self.tty.random(left.len(), i); 221 | if pos < status_text.len() + 3 { 222 | continue; 223 | } 224 | let (a, b) = left.split_at(pos); 225 | if b[0] == ' ' { 226 | let mut temp = a.to_owned(); 227 | temp.extend(self.tty.pick_str(BUBBLE_CHARS, i).chars()); 228 | temp.extend_from_slice(&b[1..]); 229 | left = temp; 230 | } 231 | } 232 | } 233 | 234 | self.tty.status(&format!( 235 | "\r{} ▐{}{}{}▌ ", 236 | emoji, 237 | left.iter() 238 | .collect::() 239 | .truecolor(183, 161, 129) 240 | .on_truecolor(92, 69, 6), 241 | mid.iter().collect::().black().on_white(), 242 | right.iter().collect::().white().on_black() 243 | )); 244 | } 245 | } 246 | } 247 | 248 | struct BasicStatusDisplay { 249 | tty: TtyStatus, 250 | } 251 | 252 | fn make_bar(s: &str, width: usize, percent: Option) -> String { 253 | let mut s = s.to_owned(); 254 | if let Some(percent) = percent { 255 | let percent = percent.clamp(0, 100); 256 | s += " ["; 257 | let remaining = width - s.len() - 1; 258 | let count = (remaining * percent) / 100; 259 | s += &"#".repeat(count); 260 | s += &"=".repeat(remaining - count); 261 | s += "]"; 262 | s 263 | } else { 264 | // No bar, just pad w/spaces 265 | let pad = width - s.len(); 266 | s + &" ".repeat(pad) 267 | } 268 | } 269 | 270 | impl BasicStatusDisplay { 271 | pub fn new(width: usize) -> Self { 272 | Self { 273 | tty: TtyStatus::new(width), 274 | } 275 | } 276 | } 277 | 278 | impl StatusDisplay for BasicStatusDisplay { 279 | fn log(&mut self, level: LogLevel, s: &str) { 280 | self.tty.log(level, s); 281 | } 282 | 283 | fn clear_status(&mut self) { 284 | self.tty.clear_status(); 285 | } 286 | 287 | fn display(&mut self, state: EcamStatus) { 288 | let (bar, percent) = match state { 289 | EcamStatus::Ready => ("Ready".to_owned(), None), 290 | EcamStatus::StandBy => ("Standby".to_owned(), None), 291 | EcamStatus::TurningOn(percent) => ("Turning on...".to_owned(), Some(percent)), 292 | EcamStatus::ShuttingDown(percent) => ("Shutting down...".to_owned(), Some(percent)), 293 | EcamStatus::Busy(percent) => ("Dispensing...".to_owned(), Some(percent)), 294 | EcamStatus::Cleaning(percent) => ("Cleaning...".to_owned(), Some(percent)), 295 | EcamStatus::Descaling => ("Descaling...".to_owned(), None), 296 | EcamStatus::Alarm(alarm) => (format!("Alarm: {:?}", alarm), None), 297 | EcamStatus::Fetching(percent) => ("Fetching...".to_owned(), Some(percent)), 298 | }; 299 | 300 | self.tty.status(&format!( 301 | "\r{} {}", 302 | make_bar(&bar, self.tty.width - 2, percent), 303 | self.tty.pick_str("/-\\|", 0), 304 | )); 305 | } 306 | } 307 | 308 | #[cfg(test)] 309 | mod test { 310 | use super::{ColouredStatusDisplay, StatusDisplay, make_bar}; 311 | 312 | #[test] 313 | fn format_no_progress() { 314 | let none: Option = None; 315 | let test_cases = [ 316 | // 123456789012345678901234567890123456789 317 | ( 318 | "Description ", 319 | ("Description", none), 320 | ), 321 | ( 322 | "Description [######====================]", 323 | ("Description", Some(25)), 324 | ), 325 | ( 326 | "Description [#############=============]", 327 | ("Description", Some(50)), 328 | ), 329 | ( 330 | "Description [###################=======]", 331 | ("Description", Some(75)), 332 | ), 333 | ( 334 | "Description [##########################]", 335 | ("Description", Some(100)), 336 | ), 337 | ]; 338 | 339 | for (expected, (description, progress)) in test_cases.into_iter() { 340 | assert_eq!(expected, make_bar(description, 40, progress)); 341 | } 342 | } 343 | 344 | #[test] 345 | fn format_rich() { 346 | let mut display = ColouredStatusDisplay::new(60); 347 | for i in 0..=100 { 348 | display.display(crate::ecam::EcamStatus::Busy(i)); 349 | } 350 | } 351 | } 352 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | #![warn(clippy::all)] 2 | use clap::builder::{PossibleValue, PossibleValuesParser}; 3 | use clap::{Arg, ArgMatches, arg, command}; 4 | 5 | mod app; 6 | 7 | embed_plist::embed_info_plist!("Info.plist"); 8 | 9 | use longshot::ecam::{ 10 | Ecam, EcamBT, EcamError, EcamId, ecam_lookup, ecam_scan, get_ecam_simulator, pipe_stdin, 11 | }; 12 | use longshot::{operations::*, protocol::*}; 13 | 14 | fn enum_value_parser + 'static>() -> PossibleValuesParser { 15 | PossibleValuesParser::new(T::all().map(|x| PossibleValue::new(x.to_arg_string()))) 16 | } 17 | 18 | struct DeviceCommon { 19 | device_id: EcamId, 20 | dump_packets: bool, 21 | turn_on: bool, 22 | allow_off: bool, 23 | } 24 | 25 | impl DeviceCommon { 26 | fn args() -> [Arg; 4] { 27 | [ 28 | arg!(--"device-name" ) 29 | .help("Provides the name of the device") 30 | .required(true), 31 | arg!(--"dump-packets").help("Dumps decoded packets to the terminal for debugging"), 32 | arg!(--"turn-on") 33 | .help("Turn on the machine before running this operation") 34 | .conflicts_with("allow-off"), 35 | arg!(--"allow-off") 36 | .hide(true) 37 | .help("Allow brewing while machine is off") 38 | .conflicts_with("turn-on"), 39 | ] 40 | } 41 | 42 | fn parse(cmd: &ArgMatches) -> Self { 43 | Self { 44 | device_id: cmd 45 | .get_one::("device-name") 46 | .expect("Device name required") 47 | .into(), 48 | dump_packets: cmd.get_flag("dump-packets"), 49 | turn_on: cmd.get_flag("turn-on"), 50 | allow_off: cmd.get_flag("allow-off"), 51 | } 52 | } 53 | } 54 | 55 | async fn ecam(cmd: &ArgMatches, allow_off_and_alarms: bool) -> Result { 56 | let device_common = DeviceCommon::parse(cmd); 57 | let ecam = ecam_lookup(&device_common.device_id, device_common.dump_packets).await?; 58 | if !power_on( 59 | ecam.clone(), 60 | device_common.allow_off | allow_off_and_alarms, 61 | allow_off_and_alarms, 62 | device_common.turn_on, 63 | ) 64 | .await? 65 | { 66 | longshot::display::shutdown(); 67 | std::process::exit(1); 68 | } 69 | Ok(ecam) 70 | } 71 | 72 | fn command() -> clap::Command { 73 | command!() 74 | .arg(arg!(--"trace").help("Trace packets to/from device")) 75 | .subcommand( 76 | command!("brew") 77 | .about("Brew a coffee") 78 | .args(DeviceCommon::args()) 79 | .arg( 80 | arg!(--"beverage" ) 81 | .required(true) 82 | .help("The beverage to brew") 83 | .value_parser(enum_value_parser::()), 84 | ) 85 | .arg( 86 | arg!(--"coffee" ) 87 | .help("Amount of coffee to brew") 88 | .value_parser(0..=2500), 89 | ) 90 | .arg( 91 | arg!(--"milk" ) 92 | .help("Amount of milk to steam/pour") 93 | .value_parser(0..=2500), 94 | ) 95 | .arg( 96 | arg!(--"hotwater" ) 97 | .help("Amount of hot water to pour") 98 | .value_parser(0..=2500), 99 | ) 100 | .arg( 101 | arg!(--"taste" ) 102 | .help("The strength of the beverage") 103 | .value_parser(enum_value_parser::()), 104 | ) 105 | .arg( 106 | arg!(--"temperature" ) 107 | .help("The temperature of the beverage") 108 | .value_parser(enum_value_parser::()), 109 | ) 110 | .arg( 111 | arg!(--"allow-defaults") 112 | .help("Allow brewing if some parameters are not specified"), 113 | ) 114 | .arg(arg!(--"force").help("Allow brewing with parameters that do not validate")) 115 | .arg( 116 | arg!(--"skip-brew") 117 | .hide(true) 118 | .help("Does everything except actually brew the beverage"), 119 | ), 120 | ) 121 | .subcommand( 122 | command!("monitor") 123 | .about("Monitor the status of the device") 124 | .args(DeviceCommon::args()), 125 | ) 126 | .subcommand( 127 | command!("status") 128 | .about("Print the status of the device and then exit") 129 | .args(DeviceCommon::args()), 130 | ) 131 | .subcommand( 132 | command!("read-parameter") 133 | .about("Read a parameter from the device") 134 | .args(DeviceCommon::args()) 135 | .arg( 136 | arg!(--"parameter" ) 137 | .required(true) 138 | .help("The parameter ID"), 139 | ) 140 | .arg( 141 | arg!(--"length" ) 142 | .required(true) 143 | .help("The parameter length"), 144 | ), 145 | ) 146 | .subcommand( 147 | command!("read-statistic") 148 | .about("Read a statistic from the device") 149 | .args(DeviceCommon::args()) 150 | .arg( 151 | arg!(--"statistic" ) 152 | .required(true) 153 | .help("The statistic ID"), 154 | ) 155 | .arg( 156 | arg!(--"length" ) 157 | .required(true) 158 | .help("The statistic length"), 159 | ), 160 | ) 161 | .subcommand( 162 | command!("read-statistics") 163 | .about("Read all statistics from the device") 164 | .args(DeviceCommon::args()), 165 | ) 166 | .subcommand( 167 | command!("read-parameter-memory") 168 | .about("Read the parameter memory from the device") 169 | .args(DeviceCommon::args()), 170 | ) 171 | .subcommand( 172 | command!("list-recipes") 173 | .about("List recipes stored in the device") 174 | .args(DeviceCommon::args()) 175 | .arg(arg!(--"detail").help("Show detailed ingredient information")) 176 | .arg(arg!(--"raw").help("Show raw ingredient information")), 177 | ) 178 | .subcommand(command!("list").about("List all supported devices")) 179 | .subcommand( 180 | command!("x-internal-pipe") 181 | .about("Used to communicate with the device") 182 | .hide(true) 183 | .args(DeviceCommon::args()), 184 | ) 185 | .subcommand( 186 | command!("app-control") 187 | .about("Send a custom app-control command to the device (potentially dangerous)") 188 | .args(DeviceCommon::args()) 189 | .arg(arg!(--"a" ).help("The first byte of the command")) 190 | .arg(arg!(--"b" ).help("The second byte of the command")), 191 | ) 192 | } 193 | 194 | #[tokio::main] 195 | async fn main() -> Result<(), Box> { 196 | pretty_env_logger::init(); 197 | 198 | let matches = command().get_matches(); 199 | 200 | if matches.get_flag("trace") { 201 | longshot::logging::enable_tracing(); 202 | } 203 | 204 | let subcommand = matches.subcommand(); 205 | match subcommand { 206 | Some(("brew", cmd)) => { 207 | let skip_brew = cmd.get_flag("skip-brew"); 208 | let allow_defaults = cmd.get_flag("allow-defaults"); 209 | let force = cmd.get_flag("force"); 210 | 211 | let beverage: EcamBeverageId = EcamBeverageId::lookup_by_name_case_insensitive( 212 | cmd.get_one::("beverage").unwrap(), 213 | ) 214 | .expect("Beverage required"); 215 | 216 | let mut ingredients = vec![]; 217 | for arg in ["coffee", "milk", "hotwater", "taste", "temperature"] { 218 | if let Some(value) = cmd.get_raw(arg) { 219 | // Once clap has had a chance to validate the args, we go back to the underlying OsStr to parse it 220 | let value = value.into_iter().next().unwrap().to_str().unwrap(); 221 | if let Some(ingredient) = BrewIngredientInfo::from_arg(arg, value) { 222 | ingredients.push(ingredient); 223 | } else { 224 | eprintln!("Invalid value '{}' for argument '{}'", value, arg); 225 | return Ok(()); 226 | } 227 | } 228 | } 229 | 230 | let mode = match (allow_defaults, force) { 231 | (_, true) => IngredientCheckMode::Force, 232 | (true, false) => IngredientCheckMode::AllowDefaults, 233 | (false, false) => IngredientCheckMode::Strict, 234 | }; 235 | let ecam = ecam(cmd, false).await?; 236 | let recipe = validate_brew(ecam.clone(), beverage, ingredients, mode).await?; 237 | brew(ecam.clone(), skip_brew, beverage, recipe).await?; 238 | } 239 | Some(("monitor", cmd)) => { 240 | let ecam = ecam(cmd, true).await?; 241 | monitor(ecam).await?; 242 | } 243 | Some(("status", cmd)) => { 244 | let ecam = ecam(cmd, true).await?; 245 | eprintln!("Status = {:?}", ecam.current_state().await?); 246 | } 247 | Some(("list", _cmd)) => { 248 | let (s, uuid) = ecam_scan().await?; 249 | longshot::info!("{} {}", s, uuid); 250 | } 251 | Some(("list-recipes", cmd)) => { 252 | let ecam = ecam(cmd, true).await?; 253 | let detailed = cmd.get_flag("detail"); 254 | let raw = cmd.get_flag("raw"); 255 | if detailed { 256 | list_recipes_detailed(ecam).await?; 257 | } else if raw { 258 | list_recipes_raw(ecam).await?; 259 | } else { 260 | list_recipes(ecam).await?; 261 | } 262 | } 263 | Some(("read-parameter", cmd)) => { 264 | let parameter = cmd 265 | .get_one::("parameter") 266 | .map(|s| s.parse::().expect("Invalid number")) 267 | .expect("Required"); 268 | let length = cmd 269 | .get_one::("length") 270 | .map(|s| s.parse::().expect("Invalid number")) 271 | .expect("Required"); 272 | let ecam = ecam(cmd, true).await?; 273 | read_parameter(ecam, parameter, length).await?; 274 | } 275 | Some(("read-statistics", cmd)) => { 276 | let ecam = ecam(cmd, true).await?; 277 | read_statistics(ecam).await?; 278 | } 279 | Some(("read-statistic", cmd)) => { 280 | let parameter = cmd 281 | .get_one::("statistic") 282 | .map(|s| s.parse::().expect("Invalid number")) 283 | .expect("Required"); 284 | let length = cmd 285 | .get_one::("length") 286 | .map(|s| s.parse::().expect("Invalid number")) 287 | .expect("Required"); 288 | let ecam = ecam(cmd, true).await?; 289 | read_statistic(ecam, parameter, length).await?; 290 | } 291 | Some(("read-parameter-memory", cmd)) => { 292 | let ecam = ecam(cmd, true).await?; 293 | read_parameter_memory(ecam).await?; 294 | } 295 | Some(("app-control", cmd)) => { 296 | let ecam = ecam(cmd, true).await?; 297 | let a = cmd 298 | .get_one::("a") 299 | .map(|s| s.parse::().expect("Invalid number")) 300 | .expect("Required"); 301 | let b = cmd 302 | .get_one::("b") 303 | .map(|s| s.parse::().expect("Invalid number")) 304 | .expect("Required"); 305 | app_control(ecam, a, b).await?; 306 | } 307 | Some(("x-internal-pipe", cmd)) => match DeviceCommon::parse(cmd).device_id { 308 | id @ EcamId::Simulator(..) => { 309 | let ecam = get_ecam_simulator(&id).await?; 310 | pipe_stdin(ecam).await?; 311 | } 312 | id => { 313 | let ecam = EcamBT::get(id).await?; 314 | pipe_stdin(ecam).await?; 315 | } 316 | }, 317 | _ => { 318 | command().print_help()?; 319 | } 320 | } 321 | 322 | longshot::display::shutdown(); 323 | Ok(()) 324 | } 325 | -------------------------------------------------------------------------------- /src/protocol/request/mod.rs: -------------------------------------------------------------------------------- 1 | mod app_control; 2 | mod monitor; 3 | mod profile; 4 | mod recipe; 5 | 6 | use super::{hardware_enums::*, machine_enum::*}; 7 | pub use app_control::*; 8 | pub use monitor::*; 9 | pub use profile::*; 10 | pub use recipe::*; 11 | 12 | /// Implements the encode part of an encode/decode pair for a request or response. 13 | pub trait PartialEncode { 14 | fn partial_encode(&self, out: &mut Vec); 15 | 16 | fn encode(&self) -> Vec { 17 | let mut v = vec![]; 18 | self.partial_encode(&mut v); 19 | v 20 | } 21 | } 22 | 23 | impl PartialEncode for u8 { 24 | fn partial_encode(&self, out: &mut Vec) { 25 | out.push(*self); 26 | } 27 | } 28 | 29 | impl PartialEncode for u16 { 30 | fn partial_encode(&self, out: &mut Vec) { 31 | out.push((*self >> 8) as u8); 32 | out.push(*self as u8); 33 | } 34 | } 35 | 36 | impl PartialEncode for Vec { 37 | fn partial_encode(&self, out: &mut Vec) { 38 | for t in self.iter() { 39 | t.partial_encode(out); 40 | } 41 | } 42 | } 43 | 44 | impl> PartialEncode for &MachineEnum { 45 | fn partial_encode(&self, out: &mut Vec) { 46 | out.push((**self).into()) 47 | } 48 | } 49 | 50 | /// Implements the decode part of an encode/decode pair for a request or response. 51 | pub trait PartialDecode { 52 | /// Partially decodes this type from a buffer, advancing the input slice to the next item. 53 | fn partial_decode(input: &mut &[u8]) -> Option; 54 | 55 | /// Decode a buffer fully, returning the unparsed remainder if available 56 | fn decode(mut input: &[u8]) -> (Option, &[u8]) { 57 | let ret = Self::partial_decode(&mut input); 58 | (ret, input) 59 | } 60 | } 61 | 62 | impl> PartialDecode> for Vec { 63 | fn partial_decode(input: &mut &[u8]) -> Option { 64 | let mut v = vec![]; 65 | while !input.is_empty() { 66 | v.push(::partial_decode(input)?); 67 | } 68 | Some(v) 69 | } 70 | } 71 | 72 | impl> PartialDecode> for MachineEnum { 73 | fn partial_decode(input: &mut &[u8]) -> Option { 74 | let (head, tail) = input.split_first()?; 75 | *input = tail; 76 | Some(MachineEnum::decode(*head)) 77 | } 78 | } 79 | 80 | impl PartialDecode for u8 { 81 | fn partial_decode(input: &mut &[u8]) -> Option { 82 | let (head, tail) = input.split_first()?; 83 | *input = tail; 84 | Some(*head) 85 | } 86 | } 87 | 88 | impl PartialDecode for u16 { 89 | fn partial_decode(input: &mut &[u8]) -> Option { 90 | let a = ::partial_decode(input)? as u16; 91 | let b = ::partial_decode(input)? as u16; 92 | Some((a << 8) | b) 93 | } 94 | } 95 | 96 | impl PartialDecode for u32 { 97 | fn partial_decode(input: &mut &[u8]) -> Option { 98 | let a = ::partial_decode(input)? as u32; 99 | let b = ::partial_decode(input)? as u32; 100 | Some((a << 16) | b) 101 | } 102 | } 103 | 104 | macro_rules! packet_definition { 105 | ( 106 | $( 107 | $name:ident 108 | ( $( $req_name:tt $req_type:ty ),* $(,)? ) 109 | => 110 | ( $( $resp_name:tt $resp_type:ty ),* $(,)? ) 111 | ),* $(,)? ) => { 112 | 113 | /// A request sent from the host to device. 114 | #[allow(dead_code)] 115 | #[derive(Clone, Debug, Eq, PartialEq)] 116 | pub enum Request { 117 | $( 118 | $name( $($req_type),* ), 119 | )* 120 | } 121 | 122 | impl PartialEncode for Request { 123 | fn partial_encode(&self, mut out: &mut Vec) { 124 | match self { 125 | $( 126 | Self::$name( 127 | $( 128 | $req_name 129 | ),* 130 | ) => { 131 | out.push(EcamRequestId::$name as u8); 132 | if self.is_response_required() { 133 | out.push(0xf0); 134 | } else { 135 | out.push(0x0f); 136 | } 137 | $($req_name.partial_encode(&mut out); )* 138 | } 139 | )* 140 | } 141 | } 142 | } 143 | 144 | impl Request { 145 | pub fn ecam_request_id(&self) -> EcamRequestId { 146 | match self { 147 | $( Self::$name(..) => { EcamRequestId::$name } )* 148 | } 149 | } 150 | } 151 | 152 | /// A response sent from the device to the host. 153 | #[allow(dead_code)] 154 | #[derive(Clone, Debug, Eq, PartialEq)] 155 | pub enum Response { 156 | $( 157 | $name ( $($resp_type),* ), 158 | )* 159 | } 160 | 161 | impl Response { 162 | pub fn ecam_request_id(&self) -> EcamRequestId { 163 | match self { 164 | $( Self::$name(..) => { EcamRequestId::$name } )* 165 | } 166 | } 167 | } 168 | 169 | impl PartialDecode for Response { 170 | fn partial_decode(input: &mut &[u8]) -> Option { 171 | if input.len() < 2 { 172 | return None; 173 | } 174 | let id = EcamRequestId::try_from(input[0]); 175 | if let Ok(id) = id { 176 | let _ = input[1]; 177 | *input = &input[2..]; 178 | match id { 179 | $( 180 | EcamRequestId::$name => { 181 | $( 182 | let $resp_name = <$resp_type>::partial_decode(input)?; 183 | )* 184 | return Some(Self::$name( 185 | $( $resp_name ),* 186 | )); 187 | } 188 | )* 189 | } 190 | } 191 | None 192 | } 193 | } 194 | }; 195 | } 196 | 197 | packet_definition!( 198 | SetBtMode() => (), 199 | MonitorV0() => (), 200 | MonitorV1() => (), 201 | MonitorV2() => (response MonitorV2Response), 202 | BeverageDispensingMode( 203 | recipe MachineEnum, 204 | trigger MachineEnum, 205 | ingredients Vec>, 206 | mode MachineEnum) => (unknown0 u8, unknown1 u8), 207 | AppControl(request AppControl) => (), 208 | ParameterRead(parameter u16, len u8) => (), 209 | ParameterWrite() => (), 210 | ParameterReadExt(parameter u16, len u8) => (parameter u16, data Vec), 211 | StatisticsRead(parameter u16, len u8) => (data Vec), 212 | Checksum() => (), 213 | ProfileNameRead(start u8, end u8) => (names Vec), 214 | ProfileNameWrite() => (), 215 | RecipeQuantityRead(profile u8, recipe MachineEnum) 216 | => (profile u8, recipe MachineEnum, ingredients Vec>), 217 | RecipePriorityRead() => (priorities Vec), 218 | ProfileSelection() => (), 219 | RecipeNameRead(start u8, end u8) => (names Vec), 220 | RecipeNameWrite() => (), 221 | SetFavoriteBeverages(profile u8, recipies Vec) => (), 222 | RecipeMinMaxSync(recipe MachineEnum) => (recipe MachineEnum, bounds Vec), 223 | PinSet() => (), 224 | BeanSystemSelect() => (), 225 | BeanSystemRead() => (), 226 | BeanSystemWrite() => (), 227 | PinRead() => (), 228 | SetTime() => (), 229 | ); 230 | 231 | impl Request { 232 | fn is_response_required(&self) -> bool { 233 | !matches!( 234 | self, 235 | Request::AppControl(..) 236 | | Request::MonitorV0() 237 | | Request::MonitorV1() 238 | | Request::MonitorV2() 239 | | Request::StatisticsRead(..) 240 | ) 241 | } 242 | } 243 | 244 | /// A statistic read from the device. 245 | #[derive(Copy, Clone, Debug, Eq, PartialEq)] 246 | pub struct Statistic { 247 | pub stat: u16, 248 | pub value: u32, 249 | } 250 | 251 | impl PartialDecode for Statistic { 252 | fn partial_decode(input: &mut &[u8]) -> Option { 253 | let stat = ::partial_decode(input)?; 254 | let value = ::partial_decode(input)?; 255 | Some(Statistic { stat, value }) 256 | } 257 | } 258 | 259 | #[cfg(test)] 260 | mod test { 261 | use super::*; 262 | use crate::protocol::*; 263 | use rstest::*; 264 | 265 | #[rstest] 266 | #[case(&crate::protocol::test::RESPONSE_BREW_RECEIVED)] 267 | #[case(&crate::protocol::test::RESPONSE_STATUS_CAPPUCCINO_MILK)] 268 | #[case(&crate::protocol::test::RESPONSE_STATUS_READY_AFTER_CAPPUCCINO)] 269 | #[case(&crate::protocol::test::RESPONSE_STATUS_CLEANING_AFTER_CAPPUCCINO)] 270 | #[case(&crate::protocol::test::RESPONSE_STATUS_STANDBY_NO_ALARMS)] 271 | #[case(&crate::protocol::test::RESPONSE_STATUS_STANDBY_NO_WATER_TANK)] 272 | #[case(&crate::protocol::test::RESPONSE_STATUS_STANDBY_WATER_SPOUT)] 273 | #[case(&crate::protocol::test::RESPONSE_STATUS_STANDBY_NO_COFFEE_CONTAINER)] 274 | fn real_packets_decode_as_expected(#[case] bytes: &[u8]) { 275 | let (packet, remainder) = Response::decode(unwrap_packet(bytes)); 276 | let packet = packet.expect("Expected to decode something"); 277 | assert_eq!(remainder, &[]); 278 | // Not actually testing the decoding of these packets, but at least we can print it 279 | println!("{:?}", packet); 280 | } 281 | 282 | #[test] 283 | fn test_decode_monitor_packet() { 284 | let buf = [117_u8, 15, 1, 5, 0, 0, 0, 7, 0, 0, 0, 0, 0, 0, 0]; 285 | let input = &mut buf.as_slice(); 286 | assert_eq!( 287 | ::partial_decode(input).expect("Failed to decode"), 288 | Response::MonitorV2(MonitorV2Response { 289 | state: EcamMachineState::ReadyOrDispensing.into(), 290 | accessory: EcamAccessory::Water.into(), 291 | progress: 0, 292 | percentage: 0, 293 | switches: SwitchSet::of(&[ 294 | EcamMachineSwitch::WaterSpout, 295 | EcamMachineSwitch::MotorDown 296 | ]), 297 | alarms: SwitchSet::empty(), 298 | ..Default::default() 299 | }) 300 | ); 301 | } 302 | 303 | #[test] 304 | fn test_decode_monitor_packet_alarm() { 305 | let buf = [117_u8, 15, 1, 69, 0, 1, 0, 7, 0, 0, 0, 0, 0, 0, 0]; 306 | let input = &mut buf.as_slice(); 307 | assert_eq!( 308 | ::partial_decode(input).expect("Failed to decode"), 309 | Response::MonitorV2(MonitorV2Response { 310 | state: EcamMachineState::ReadyOrDispensing.into(), 311 | accessory: EcamAccessory::Water.into(), 312 | progress: 0, 313 | percentage: 0, 314 | switches: SwitchSet::of(&[ 315 | EcamMachineSwitch::WaterSpout, 316 | EcamMachineSwitch::MotorDown, 317 | EcamMachineSwitch::WaterLevelLow, 318 | ]), 319 | alarms: SwitchSet::of(&[EcamMachineAlarm::EmptyWaterTank]), 320 | ..Default::default() 321 | }) 322 | ); 323 | } 324 | 325 | #[test] 326 | fn test_decode_profile_packet() { 327 | let buf = [ 328 | 164_u8, 240, 0, 77, 0, 97, 0, 116, 0, 116, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 3, 0, 329 | 77, 0, 105, 0, 97, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8, 0, 80, 0, 82, 0, 79, 0, 330 | 70, 0, 73, 0, 76, 0, 69, 0, 32, 0, 51, 0, 0, 3, 331 | ]; 332 | let input = &mut buf.as_slice(); 333 | assert_eq!( 334 | ::partial_decode(input).expect("Failed to decode"), 335 | Response::ProfileNameRead(vec![ 336 | WideStringWithIcon::new("Matt", 3), 337 | WideStringWithIcon::new("Mia", 8), 338 | WideStringWithIcon::new("PROFILE 3", 3) 339 | ]) 340 | ) 341 | } 342 | 343 | #[test] 344 | fn test_brew_coffee() { 345 | let recipe = vec![ 346 | RecipeInfo::new(EcamIngredients::Coffee, 103), 347 | RecipeInfo::new(EcamIngredients::Taste, 2), 348 | RecipeInfo::new(EcamIngredients::Temp, 0), 349 | ]; 350 | assert_eq!( 351 | Request::BeverageDispensingMode( 352 | EcamBeverageId::RegularCoffee.into(), 353 | EcamOperationTrigger::Start.into(), 354 | recipe, 355 | EcamBeverageTasteType::PrepareInversion.into() 356 | ) 357 | .encode(), 358 | vec![ 359 | 0x83, 0xf0, 0x02, 0x01, 0x01, 0x00, 0x67, 0x02, 0x02, 0x00, 0x00, 0x06 360 | ] 361 | ); 362 | } 363 | } 364 | -------------------------------------------------------------------------------- /src/operations/recipe_list.rs: -------------------------------------------------------------------------------- 1 | use crate::{display, prelude::*}; 2 | use crate::{ 3 | ecam::{Ecam, EcamError}, 4 | operations::IngredientRangeInfo, 5 | protocol::*, 6 | }; 7 | use std::collections::HashMap; 8 | 9 | /// Accumulates recipe responses, allowing us to fetch them one-at-a-time and account for which ones went missing in transit. 10 | /// Note that this doesn't support profiles yet and currently requires the use of profile 1. 11 | pub struct RecipeAccumulator { 12 | recipe: HashMap>>, 13 | recipe_min_max: HashMap>, 14 | list: Vec, 15 | } 16 | 17 | impl Default for RecipeAccumulator { 18 | fn default() -> Self { 19 | Self::new() 20 | } 21 | } 22 | 23 | impl RecipeAccumulator { 24 | /// Creates a new accumulator for all recipes. 25 | pub fn new() -> Self { 26 | Self::limited_to(EcamBeverageId::all_values().to_vec()) 27 | } 28 | 29 | /// Creates a new accumulator limited to a smaller subset of [`EcamBeverageId`]s (potentially just one). 30 | pub fn limited_to(recipes: Vec) -> Self { 31 | RecipeAccumulator { 32 | list: recipes, 33 | recipe: HashMap::new(), 34 | recipe_min_max: HashMap::new(), 35 | } 36 | } 37 | 38 | /// Lists the [`EcamBeverageId`]s which we still need to fetch information for. 39 | pub fn get_remaining_beverages(&self) -> Vec { 40 | let mut remaining = vec![]; 41 | for beverage in self.list.iter() { 42 | if self.recipe.contains_key(beverage) && self.recipe_min_max.contains_key(beverage) { 43 | continue; 44 | } 45 | if self.is_empty(*beverage) { 46 | continue; 47 | } 48 | remaining.push(*beverage); 49 | } 50 | remaining 51 | } 52 | 53 | /// Returns the [`Request`] required to fetch this beverage. 54 | pub fn get_request_packets(&self, beverage: EcamBeverageId) -> Vec { 55 | vec![ 56 | Request::RecipeMinMaxSync(beverage.into()), 57 | Request::RecipeQuantityRead(1, beverage.into()), 58 | ] 59 | } 60 | 61 | /// Is our fetch complete for this [`EcamBeverageId`]. 62 | pub fn is_complete(&self, beverage: EcamBeverageId) -> bool { 63 | let recipe = self.recipe.get(&beverage); 64 | let recipe_min_max = self.recipe_min_max.get(&beverage); 65 | 66 | // If the recipe has empty ingredients, we're going to ignore it and say it's complete 67 | if let Some(recipe) = recipe { 68 | if recipe.is_empty() { 69 | return true; 70 | } 71 | } 72 | if let Some(recipe_min_max) = recipe_min_max { 73 | if recipe_min_max.is_empty() { 74 | return true; 75 | } 76 | } 77 | 78 | // Otherwise recipes are only complete if we have both recipe and min/max 79 | recipe.is_some() && recipe_min_max.is_some() 80 | } 81 | 82 | /// Is this [`EcamBeverageId`] empty, ie: is it unavailable for dispensing? 83 | pub fn is_empty(&self, beverage: EcamBeverageId) -> bool { 84 | let recipe = self.recipe.get(&beverage); 85 | let recipe_min_max = self.recipe_min_max.get(&beverage); 86 | 87 | // If the recipe has empty ingredients, we're going to ignore it and say it's complete 88 | if let Some(recipe) = recipe { 89 | if recipe.len() == 0 { 90 | return true; 91 | } 92 | } 93 | if let Some(recipe_min_max) = recipe_min_max { 94 | if recipe_min_max.len() == 0 { 95 | return true; 96 | } 97 | } 98 | 99 | false 100 | } 101 | 102 | /// Accumulate a [`Response`] for the given [`EcamBeverageId`]. 103 | pub fn accumulate_packet(&mut self, expected_beverage: EcamBeverageId, packet: Response) { 104 | match packet { 105 | Response::RecipeQuantityRead(_, beverage, ingredients) => { 106 | if beverage == expected_beverage { 107 | self.recipe.insert(expected_beverage, ingredients); 108 | } 109 | } 110 | Response::RecipeMinMaxSync(beverage, min_max) => { 111 | if beverage == expected_beverage { 112 | self.recipe_min_max.insert(expected_beverage, min_max); 113 | } 114 | } 115 | _ => { 116 | warning!("Spurious packet received? {:?}", packet); 117 | } 118 | } 119 | } 120 | 121 | /// Take the contents of this instance as a [`RecipeList`]. 122 | pub fn take(mut self) -> RecipeList { 123 | let mut list = RecipeList { recipes: vec![] }; 124 | for beverage in self.list.iter() { 125 | if self.is_empty(*beverage) { 126 | continue; 127 | } 128 | let recipe = self.recipe.remove(beverage); 129 | let recipe_min_max = self.recipe_min_max.remove(beverage); 130 | if let (Some(recipe), Some(recipe_min_max)) = (recipe, recipe_min_max) { 131 | list.recipes.push(RecipeDetails { 132 | beverage: *beverage, 133 | recipe, 134 | recipe_min_max, 135 | }); 136 | } else { 137 | warning!( 138 | "Recipe data seems to be out of sync, ignoring beverage {:?}", 139 | beverage 140 | ); 141 | } 142 | } 143 | list 144 | } 145 | 146 | pub fn get( 147 | &self, 148 | beverage: EcamBeverageId, 149 | ) -> (Option>>, Option>) { 150 | ( 151 | self.recipe.get(&beverage).cloned(), 152 | self.recipe_min_max.get(&beverage).cloned(), 153 | ) 154 | } 155 | } 156 | 157 | /// A completed list of [`RecipeDetails`], containing one [`RecipeDetails`] object for each valid [`EcamBeverageId`]. 158 | #[derive(Clone, Debug)] 159 | pub struct RecipeList { 160 | pub recipes: Vec, 161 | } 162 | 163 | impl RecipeList { 164 | /// Find the recipe for the given [`EcamBeverageId`], returning it as a [`RecipeDetails`]. 165 | pub fn find(&self, beverage: EcamBeverageId) -> Option<&RecipeDetails> { 166 | self.recipes.iter().find(|&r| r.beverage == beverage) 167 | } 168 | } 169 | 170 | /// The details for a given [`EcamBeverageId`]'s recipe. 171 | #[derive(Clone, Debug)] 172 | pub struct RecipeDetails { 173 | pub beverage: EcamBeverageId, 174 | recipe: Vec>, 175 | recipe_min_max: Vec, 176 | } 177 | 178 | impl RecipeDetails { 179 | /// Formats this recipe as an argument string. 180 | pub fn to_arg_string(&self) -> String { 181 | let args = self 182 | .fetch_ingredients() 183 | .iter() 184 | .collect_filter_map_join(" ", IngredientRangeInfo::to_arg_string); 185 | format!("--beverage {} {}", self.beverage.to_arg_string(), args) 186 | } 187 | 188 | /// Processes this [`RecipeDetails`] into a [`Vec`], suitable for dispensing. 189 | pub fn fetch_ingredients(&self) -> Vec { 190 | let mut v = vec![]; 191 | let mut m1 = HashMap::new(); 192 | let mut m2 = HashMap::new(); 193 | for r in self.recipe.iter() { 194 | m1.insert(r.ingredient, r); 195 | } 196 | for r in self.recipe_min_max.iter() { 197 | m2.insert(r.ingredient, r); 198 | } 199 | 200 | for ingredient in EcamIngredients::all() { 201 | let key = ingredient.into(); 202 | match IngredientRangeInfo::new( 203 | ingredient, 204 | m1.get(&key).map(|x| **x), 205 | m2.get(&key).map(|x| **x), 206 | ) { 207 | Err(s) => warning!("{}", s), 208 | Ok(Some(x)) => v.push(x), 209 | Ok(None) => {} 210 | } 211 | } 212 | v 213 | } 214 | } 215 | 216 | /// Lists recipes for either all recipes, or just the given ones. 217 | pub async fn list_recipies_for( 218 | ecam: Ecam, 219 | recipes: Option>, 220 | ) -> Result { 221 | Ok(accumulate_recipies_for(ecam, recipes).await?.take()) 222 | } 223 | 224 | /// Accumulates recipe min/max and ingredient info for either all recipes, or just the given ones. 225 | pub async fn accumulate_recipies_for( 226 | ecam: Ecam, 227 | recipes: Option>, 228 | ) -> Result { 229 | // Get the tap we'll use for reading responses 230 | let mut tap = ecam.packet_tap().await?; 231 | let mut recipes = if let Some(recipes) = recipes { 232 | RecipeAccumulator::limited_to(recipes) 233 | } else { 234 | RecipeAccumulator::new() 235 | }; 236 | let total = recipes.get_remaining_beverages().len(); 237 | for i in 0..3 { 238 | if i == 0 { 239 | info!("Fetching recipes..."); 240 | } else if !recipes.get_remaining_beverages().is_empty() { 241 | info!( 242 | "Fetching potentially missing recipes... {:?}", 243 | recipes.get_remaining_beverages() 244 | ); 245 | } 246 | 'outer: for beverage in recipes.get_remaining_beverages() { 247 | 'inner: for packet in recipes.get_request_packets(beverage) { 248 | crate::display::display_status(crate::ecam::EcamStatus::Fetching( 249 | (total - recipes.get_remaining_beverages().len()) * 100 / total, 250 | )); 251 | let request_id = packet.ecam_request_id(); 252 | ecam.write_request(packet).await?; 253 | let now = std::time::Instant::now(); 254 | while now.elapsed() < Duration::from_millis(500) { 255 | match tokio::time::timeout(Duration::from_millis(50), tap.next()).await { 256 | Err(_) => {} 257 | Ok(None) => {} 258 | Ok(Some(x)) => { 259 | if let Some(packet) = x.take_packet() { 260 | let response_id = packet.ecam_request_id(); 261 | recipes.accumulate_packet(beverage, packet); 262 | // If this recipe is totally complete, move to the next one 263 | if recipes.is_complete(beverage) { 264 | continue 'outer; 265 | } 266 | // If we got a response for the given request, move to the next packet/beverage 267 | if response_id == request_id { 268 | continue 'inner; 269 | } 270 | } 271 | } 272 | } 273 | } 274 | } 275 | } 276 | display::display_status(crate::ecam::EcamStatus::Fetching(100)); 277 | display::clear_status(); 278 | } 279 | Ok(recipes) 280 | } 281 | 282 | pub async fn list_recipes(ecam: Ecam) -> Result<(), EcamError> { 283 | // Wait for device to settle 284 | ecam.wait_for_connection().await?; 285 | let list = list_recipies_for(ecam, None).await?; 286 | info!("Beverages supported:"); 287 | for recipe in list.recipes { 288 | info!(" {}", recipe.to_arg_string()); 289 | } 290 | 291 | Ok(()) 292 | } 293 | 294 | fn enspacen(b: &[u8]) -> String { 295 | let mut s = "".to_owned(); 296 | let space = "·"; 297 | if let Some((head, tail)) = b.split_first() { 298 | s += &format!("{:02x}{}", head, space); 299 | s += &match tail.len() { 300 | 1 => format!("{:02x}", tail[0]), 301 | 2 => format!("{:02x}{:02x}", tail[0], tail[1]), 302 | 3 => format!( 303 | "{:02x}{}{:02x}{}{:02x}", 304 | tail[0], space, tail[1], space, tail[2] 305 | ), 306 | 6 => format!( 307 | "{:02x}{:02x}{}{:02x}{:02x}{}{:02x}{:02x}", 308 | tail[0], tail[1], space, tail[2], tail[3], space, tail[4], tail[5] 309 | ), 310 | _ => hex::encode(tail), 311 | }; 312 | } 313 | s 314 | } 315 | 316 | pub async fn list_recipes_detailed(ecam: Ecam) -> Result<(), EcamError> { 317 | use ariadne::{Color, Config, Label, Report, ReportBuilder, ReportKind, Source}; 318 | const LINE_LIMIT: usize = 100; 319 | 320 | // Wait for device to settle 321 | ecam.wait_for_connection().await?; 322 | let list = accumulate_recipies_for(ecam, None).await?; 323 | for beverage in EcamBeverageId::all() { 324 | let name = &format!("{:?}", beverage); 325 | let (recipe, minmax) = list.get(beverage); 326 | let mut s = "".to_owned(); 327 | let mut builder = Report::build(ReportKind::Custom("Beverage", Color::Cyan), name, 0) 328 | .with_message(format!("{:?} (id 0x{:02x})", beverage, beverage as u8)); 329 | let len = |s: &str| s.chars().count(); 330 | 331 | // Add a chunk of labelled text 332 | let add_labelled_text = 333 | |builder: ReportBuilder<_>, i: usize, s: &mut String, t: &str, msg: &str| { 334 | if i > 0 { 335 | if s.rfind('\n').unwrap_or_default() + LINE_LIMIT < s.len() { 336 | *s += "•\n↳ "; 337 | } else { 338 | *s += "•"; 339 | } 340 | } 341 | let start_len = len(s); 342 | *s += t; 343 | let end_len = len(s); 344 | let label = Label::new((name, start_len..end_len)) 345 | .with_message(msg) 346 | .with_order(-(i as i32)) 347 | .with_color([Color::Unset, Color::Cyan][i % 2]); 348 | builder.with_label(label) 349 | }; 350 | 351 | // Add a note about missing data 352 | let add_missing_note = |mut builder: ReportBuilder<_>, s: &mut String, msg| { 353 | builder = add_labelled_text(builder, 0, s, "", msg); 354 | builder.with_note("The recipe or min/max info is not correct, which means this recipe is likely not supported") 355 | }; 356 | 357 | // Print the recipe 358 | if let Some(recipe) = recipe { 359 | if recipe.is_empty() { 360 | builder = add_missing_note(builder, &mut s, "Empty recipe"); 361 | } 362 | for (i, recipe_info) in recipe.iter().enumerate() { 363 | builder = add_labelled_text( 364 | builder, 365 | i, 366 | &mut s, 367 | &enspacen(&recipe_info.encode()), 368 | &format!("{:?}={}", recipe_info.ingredient, recipe_info.value), 369 | ); 370 | } 371 | } else { 372 | builder = add_missing_note(builder, &mut s, "Missing recipe"); 373 | } 374 | s += "\n"; 375 | 376 | // Print the min/max info 377 | if let Some(minmax) = minmax { 378 | if minmax.is_empty() { 379 | builder = add_missing_note(builder, &mut s, "Empty min/max"); 380 | } 381 | for (i, minmax_info) in minmax.iter().enumerate() { 382 | builder = add_labelled_text( 383 | builder, 384 | i, 385 | &mut s, 386 | &enspacen(&minmax_info.encode()), 387 | &format!( 388 | "{:?}: {}<={}<={}", 389 | minmax_info.ingredient, minmax_info.min, minmax_info.value, minmax_info.max 390 | ), 391 | ); 392 | } 393 | } else { 394 | builder = add_missing_note(builder, &mut s, "Missing min/max"); 395 | } 396 | s += "\n"; 397 | 398 | builder 399 | .with_config(Config::default().with_underlines(true)) 400 | .finish() 401 | .print((name, Source::from(s)))?; 402 | } 403 | 404 | Ok(()) 405 | } 406 | 407 | pub async fn list_recipes_raw(ecam: Ecam) -> Result<(), EcamError> { 408 | // Wait for device to settle 409 | ecam.wait_for_connection().await?; 410 | let list = accumulate_recipies_for(ecam, None).await?; 411 | let mut s = "".to_owned(); 412 | 413 | for beverage in EcamBeverageId::all() { 414 | if !list.is_complete(beverage) { 415 | continue; 416 | } 417 | 418 | s += &format!("# {:?} (id=0x{:02x})\n", beverage, beverage as u8); 419 | let (recipe, minmax) = list.get(beverage); 420 | 421 | // Print the recipe 422 | if let Some(recipe) = recipe { 423 | for recipe_info in recipe.iter() { 424 | s += &hex::encode(recipe_info.encode()); 425 | } 426 | } 427 | s += "\n"; 428 | 429 | // Print the min/max info 430 | if let Some(minmax) = minmax { 431 | for minmax_info in minmax.iter() { 432 | s += &hex::encode(minmax_info.encode()); 433 | } 434 | } 435 | s += "\n"; 436 | } 437 | 438 | println!("{}", s); 439 | 440 | Ok(()) 441 | } 442 | -------------------------------------------------------------------------------- /src/ecam/ecam_wrapper.rs: -------------------------------------------------------------------------------- 1 | use crate::prelude::*; 2 | 3 | use tokio::sync::{Mutex, OwnedSemaphorePermit}; 4 | use tokio_stream::wrappers::BroadcastStream; 5 | 6 | use crate::ecam::{EcamDriver, EcamDriverOutput, EcamError}; 7 | use crate::protocol::*; 8 | 9 | #[derive(Copy, Clone, Debug, Eq, PartialEq)] 10 | pub enum EcamStatus { 11 | StandBy, 12 | TurningOn(usize), 13 | ShuttingDown(usize), 14 | Ready, 15 | Busy(usize), 16 | Cleaning(usize), 17 | Descaling, 18 | Alarm(MachineEnum), 19 | Fetching(usize), 20 | } 21 | 22 | #[derive(Clone, Debug, Eq, PartialEq)] 23 | pub enum EcamOutput { 24 | Ready, 25 | Packet(EcamPacket), 26 | Done, 27 | } 28 | 29 | impl EcamOutput { 30 | /// Takes the underlying packet, if it exists. 31 | pub fn take_packet(self) -> Option { 32 | if let Self::Packet(EcamPacket { 33 | representation: r, .. 34 | }) = self 35 | { 36 | r 37 | } else { 38 | None 39 | } 40 | } 41 | } 42 | 43 | impl From for EcamOutput { 44 | fn from(other: EcamDriverOutput) -> Self { 45 | match other { 46 | EcamDriverOutput::Done => EcamOutput::Done, 47 | EcamDriverOutput::Ready => EcamOutput::Ready, 48 | EcamDriverOutput::Packet(p) => EcamOutput::Packet(p.into()), 49 | } 50 | } 51 | } 52 | 53 | impl From for EcamDriverOutput { 54 | fn from(other: EcamOutput) -> EcamDriverOutput { 55 | match other { 56 | EcamOutput::Done => EcamDriverOutput::Done, 57 | EcamOutput::Ready => EcamDriverOutput::Ready, 58 | EcamOutput::Packet(p) => EcamDriverOutput::Packet(p.into()), 59 | } 60 | } 61 | } 62 | 63 | impl EcamStatus { 64 | pub fn extract(state: &MonitorV2Response) -> EcamStatus { 65 | if state.state == EcamMachineState::TurningOn { 66 | return EcamStatus::TurningOn(state.percentage as usize); 67 | } 68 | if state.state == EcamMachineState::ShuttingDown { 69 | if state.percentage < 100 { 70 | return EcamStatus::ShuttingDown(state.percentage as usize); 71 | } 72 | // Emulate status % using progress 73 | return EcamStatus::ShuttingDown((state.progress as usize * 10).clamp(0, 100)); 74 | } 75 | if state.state == EcamMachineState::MilkCleaning || state.state == EcamMachineState::Rinsing 76 | { 77 | return EcamStatus::Cleaning(state.percentage as usize); 78 | } 79 | if state.state == EcamMachineState::MilkPreparation 80 | || state.state == EcamMachineState::HotWaterDelivery 81 | || (state.state == EcamMachineState::ReadyOrDispensing && state.progress != 0) 82 | { 83 | return EcamStatus::Busy(state.percentage as usize); 84 | } 85 | if state.state == EcamMachineState::Descaling { 86 | return EcamStatus::Descaling; 87 | } 88 | #[allow(clippy::never_loop)] 89 | for alarm in state.alarms.set() { 90 | if alarm != MachineEnum::Value(EcamMachineAlarm::CleanKnob) { 91 | return EcamStatus::Alarm(alarm); 92 | } 93 | } 94 | if state.state == EcamMachineState::StandBy { 95 | return EcamStatus::StandBy; 96 | } 97 | EcamStatus::Ready 98 | } 99 | 100 | fn matches(&self, state: &MonitorV2Response) -> bool { 101 | *self == Self::extract(state) 102 | } 103 | } 104 | 105 | struct StatusInterest { 106 | count: Arc>, 107 | } 108 | 109 | struct StatusInterestHandle { 110 | count: Arc>, 111 | } 112 | 113 | /// Internal flag indicating there is interest in the status of the machine. 114 | impl StatusInterest { 115 | fn new() -> Self { 116 | StatusInterest { 117 | count: Arc::new(std::sync::Mutex::new(0)), 118 | } 119 | } 120 | 121 | fn lock(&mut self) -> StatusInterestHandle { 122 | *self.count.lock().unwrap() += 1; 123 | StatusInterestHandle { 124 | count: self.count.clone(), 125 | } 126 | } 127 | 128 | fn count(&self) -> usize { 129 | *self.count.lock().unwrap() 130 | } 131 | } 132 | 133 | impl Drop for StatusInterestHandle { 134 | fn drop(&mut self) { 135 | *self.count.lock().unwrap() -= 1; 136 | } 137 | } 138 | 139 | /// Internal struct determining if the interface is still alive. 140 | #[derive(Clone)] 141 | struct Alive(Arc>); 142 | 143 | impl Alive { 144 | fn new() -> Self { 145 | Self(Arc::new(std::sync::Mutex::new(true))) 146 | } 147 | 148 | fn is_alive(&self) -> bool { 149 | if let Ok(alive) = self.0.lock() { 150 | *alive 151 | } else { 152 | false 153 | } 154 | } 155 | 156 | fn deaden(&self) { 157 | if let Ok(mut alive) = self.0.lock() { 158 | if *alive { 159 | trace_shutdown!("Alive::deaden"); 160 | } 161 | *alive = false; 162 | } 163 | } 164 | } 165 | 166 | struct EcamDropHandle { 167 | alive: Alive, 168 | } 169 | 170 | impl Drop for EcamDropHandle { 171 | fn drop(&mut self) { 172 | trace_shutdown!("Ecam (dropped)"); 173 | self.alive.deaden() 174 | } 175 | } 176 | 177 | /// Handle that gives a user access to a machine. When all clones are dropped, the connection is closed. 178 | #[derive(Clone)] 179 | pub struct Ecam { 180 | driver: Arc>, 181 | internals: Arc>, 182 | alive: Alive, 183 | #[allow(unused)] 184 | drop_handle: Arc, 185 | } 186 | 187 | struct EcamInternals { 188 | last_status: tokio::sync::watch::Receiver>, 189 | packet_tap: Arc>, 190 | ready_lock: Arc, 191 | status_interest: StatusInterest, 192 | dump_packets: bool, 193 | started: bool, 194 | } 195 | 196 | impl Ecam { 197 | pub async fn new(driver: Box, dump_packets: bool) -> Self { 198 | let driver = Arc::new(driver); 199 | let (tx, rx) = tokio::sync::watch::channel(None); 200 | let (txb, _) = tokio::sync::broadcast::channel(100); 201 | 202 | // We want to lock the status until we've received at least one packet 203 | let ready_lock = Arc::new(tokio::sync::Semaphore::new(1)); 204 | let ready_lock_semaphore = Some( 205 | ready_lock 206 | .clone() 207 | .acquire_owned() 208 | .await 209 | .expect("Failed to lock mutex"), 210 | ); 211 | 212 | let internals = Arc::new(Mutex::new(EcamInternals { 213 | last_status: rx, 214 | packet_tap: Arc::new(txb), 215 | ready_lock, 216 | status_interest: StatusInterest::new(), 217 | started: false, 218 | dump_packets, 219 | })); 220 | let alive = Alive::new(); 221 | let ecam_result = Ecam { 222 | driver, 223 | internals, 224 | drop_handle: Arc::new(EcamDropHandle { 225 | alive: alive.clone(), 226 | }), 227 | alive, 228 | }; 229 | 230 | tokio::spawn(Self::operation_loop( 231 | ready_lock_semaphore, 232 | tx, 233 | ecam_result.driver.clone(), 234 | ecam_result.internals.clone(), 235 | ecam_result.alive.clone(), 236 | )); 237 | let (driver, alive) = (ecam_result.driver.clone(), ecam_result.alive.clone()); 238 | tokio::spawn(Self::alive_watch(driver, alive)); 239 | ecam_result 240 | } 241 | 242 | async fn alive_watch(driver: Arc>, alive: Alive) -> Result<(), EcamError> { 243 | while let Ok(b) = driver.alive().await { 244 | if !alive.is_alive() || !b { 245 | break; 246 | } 247 | // Don't spin on this if the alive check is cheap (ie: EcamSimulator) 248 | tokio::time::sleep(Duration::from_millis(10)).await; 249 | } 250 | trace_shutdown!("Ecam::alive_watch()"); 251 | alive.deaden(); 252 | Ok(()) 253 | } 254 | 255 | async fn operation_loop( 256 | mut ready_lock_semaphore: Option, 257 | tx: tokio::sync::watch::Sender>, 258 | driver: Arc>, 259 | internals: Arc>, 260 | alive: Alive, 261 | ) -> Result<(), EcamError> { 262 | let packet_tap_sender = internals.lock().await.packet_tap.clone(); 263 | let dump_packets = internals.lock().await.dump_packets; 264 | let mut started = false; 265 | while alive.is_alive() { 266 | // Treat end-of-stream as EcamOutput::Done, but we might want to reconsider this in the future 267 | let packet: EcamOutput = driver 268 | .read() 269 | .await? 270 | .unwrap_or(EcamDriverOutput::Done) 271 | .into(); 272 | let _ = packet_tap_sender.send(packet.clone()); 273 | if dump_packets { 274 | trace_packet!("{:?}", packet); 275 | } 276 | match packet { 277 | EcamOutput::Ready => { 278 | if started { 279 | warning!("Got multiple start requests"); 280 | } else { 281 | tokio::spawn(Self::write_monitor_loop( 282 | driver.clone(), 283 | internals.clone(), 284 | alive.clone(), 285 | )); 286 | started = true; 287 | internals.lock().await.started = true; 288 | } 289 | } 290 | EcamOutput::Done => { 291 | trace_shutdown!("Ecam::operation_loop (Done)"); 292 | break; 293 | } 294 | EcamOutput::Packet(EcamPacket { 295 | representation: Some(Response::MonitorV2(x)), 296 | .. 297 | }) => { 298 | if tx.send(Some(x)).is_err() { 299 | warning!("Failed to send a monitor response"); 300 | break; 301 | } 302 | ready_lock_semaphore.take(); 303 | } 304 | _ => {} 305 | } 306 | } 307 | trace_shutdown!("Ecam::operation_loop"); 308 | alive.deaden(); 309 | Ok(()) 310 | } 311 | 312 | /// Is this ECAM still alive? 313 | pub fn is_alive(&self) -> bool { 314 | self.alive.is_alive() 315 | } 316 | 317 | /// Blocks until the device state reaches our desired state. 318 | pub async fn wait_for_state( 319 | &self, 320 | state: EcamStatus, 321 | monitor: fn(EcamStatus) -> (), 322 | ) -> Result<(), EcamError> { 323 | self.wait_for(|status| state.matches(status), monitor).await 324 | } 325 | 326 | /// Blocks until the device state is not in the undesired state. 327 | pub async fn wait_for_not_state( 328 | &self, 329 | state: EcamStatus, 330 | monitor: fn(EcamStatus) -> (), 331 | ) -> Result<(), EcamError> { 332 | self.wait_for(|status| !state.matches(status), monitor) 333 | .await 334 | } 335 | 336 | /// Blocks until the state test function returns true. 337 | pub async fn wait_for(&self, f: F, monitor: fn(EcamStatus) -> ()) -> Result<(), EcamError> 338 | where 339 | F: Fn(&MonitorV2Response) -> bool, 340 | { 341 | let alive = self.alive.clone(); 342 | let mut internals = self.internals.lock().await; 343 | let mut rx = internals.last_status.clone(); 344 | let status_interest = internals.status_interest.lock(); 345 | drop(internals); 346 | while alive.is_alive() { 347 | if let Some(test) = rx.borrow().as_ref() { 348 | monitor(EcamStatus::extract(test)); 349 | if f(test) { 350 | drop(status_interest); 351 | return Ok(()); 352 | } 353 | } 354 | // TODO: timeout 355 | rx.changed().await.map_err(|_| EcamError::Unknown)?; 356 | } 357 | Err(EcamError::Unknown) 358 | } 359 | 360 | /// Wait for the connection to establish, but not any particular state. 361 | pub async fn wait_for_connection(&self) -> Result<(), EcamError> { 362 | let _ = self.current_state().await?; 363 | Ok(()) 364 | } 365 | 366 | /// Returns the current state, or blocks if we don't know what the current state is yet. 367 | pub async fn current_state(&self) -> Result { 368 | let mut internals = self.internals.lock().await; 369 | let status_interest = internals.status_interest.lock(); 370 | let rx = internals.last_status.clone(); 371 | let ready_lock = internals.ready_lock.clone(); 372 | drop(internals); 373 | drop( 374 | ready_lock 375 | .acquire_owned() 376 | .await 377 | .map_err(|_| EcamError::Unknown)?, 378 | ); 379 | let ret = if let Some(test) = rx.borrow().as_ref() { 380 | Ok(EcamStatus::extract(test)) 381 | } else { 382 | Err(EcamError::Unknown) 383 | }; 384 | drop(status_interest); 385 | ret 386 | } 387 | 388 | pub async fn write(&self, packet: EcamPacket) -> Result<(), EcamError> { 389 | let internals = self.internals.lock().await; 390 | if !internals.started { 391 | warning!("Packet sent before device was ready!"); 392 | } 393 | drop(internals); 394 | self.driver.write(packet.into()).await 395 | } 396 | 397 | /// Convenience method to skip the EcamPacket. 398 | pub async fn write_request(&self, r: Request) -> Result<(), EcamError> { 399 | self.write(EcamPacket::from_represenation(r)).await 400 | } 401 | 402 | pub async fn packet_tap(&self) -> Result + use<>, EcamError> { 403 | let internals = self.internals.lock().await; 404 | Ok(BroadcastStream::new(internals.packet_tap.subscribe()) 405 | .map(|x| x.expect("Unexpected receive error"))) 406 | } 407 | 408 | /// The monitor loop is booted when the underlying driver reports that it is ready. 409 | async fn write_monitor_loop( 410 | driver: Arc>, 411 | internals: Arc>, 412 | alive: Alive, 413 | ) -> Result<(), EcamError> { 414 | let status_request = EcamDriverPacket::from_vec(Request::MonitorV2().encode()); 415 | while alive.is_alive() { 416 | // Only send status update packets while there is status interest 417 | if internals.lock().await.status_interest.count() == 0 { 418 | tokio::time::sleep(Duration::from_millis(100)).await; 419 | continue; 420 | } 421 | 422 | match tokio::time::timeout( 423 | Duration::from_millis(250), 424 | driver.write(status_request.clone()), 425 | ) 426 | .await 427 | { 428 | Ok(Err(_)) => { 429 | warning!("Failed to request status"); 430 | } 431 | Err(_) => { 432 | warning!("Status request send timeout"); 433 | } 434 | _ => { 435 | tokio::time::sleep(Duration::from_millis(250)).await; 436 | } 437 | } 438 | } 439 | trace_shutdown!("Ecam::write_monitor_loop()"); 440 | alive.deaden(); 441 | Ok(()) 442 | } 443 | } 444 | 445 | #[cfg(test)] 446 | mod test { 447 | use super::*; 448 | use rstest::*; 449 | 450 | #[rstest] 451 | #[case(EcamStatus::Busy(0), &crate::protocol::test::RESPONSE_STATUS_CAPPUCCINO_MILK)] 452 | #[case(EcamStatus::Cleaning(9), &crate::protocol::test::RESPONSE_STATUS_CLEANING_AFTER_CAPPUCCINO)] 453 | // We removed the need to test the CleanKnob alarm since it's technically a warning - should handle this better 454 | // #[case(EcamStatus::Alarm(EcamMachineAlarm::CleanKnob.into()), &crate::protocol::test::RESPONSE_STATUS_READY_AFTER_CAPPUCCINO)] 455 | #[case(EcamStatus::StandBy, &crate::protocol::test::RESPONSE_STATUS_STANDBY_NO_ALARMS)] 456 | #[case(EcamStatus::StandBy, &crate::protocol::test::RESPONSE_STATUS_STANDBY_NO_WATER_TANK)] 457 | #[case(EcamStatus::StandBy, &crate::protocol::test::RESPONSE_STATUS_STANDBY_WATER_SPOUT)] 458 | #[case(EcamStatus::StandBy, &crate::protocol::test::RESPONSE_STATUS_STANDBY_NO_COFFEE_CONTAINER)] 459 | #[case(EcamStatus::ShuttingDown(10), &crate::protocol::test::RESPONSE_STATUS_SHUTTING_DOWN_1)] 460 | #[case(EcamStatus::ShuttingDown(30), &crate::protocol::test::RESPONSE_STATUS_SHUTTING_DOWN_2)] 461 | #[case(EcamStatus::ShuttingDown(60), &crate::protocol::test::RESPONSE_STATUS_SHUTTING_DOWN_3)] 462 | fn decode_ecam_status(#[case] expected_status: EcamStatus, #[case] bytes: &[u8]) { 463 | let response = Response::decode(unwrap_packet(bytes)) 464 | .0 465 | .expect("Expected to decode a response"); 466 | if let Response::MonitorV2(response) = response { 467 | let status = EcamStatus::extract(&response); 468 | assert_eq!(status, expected_status); 469 | } 470 | } 471 | } 472 | -------------------------------------------------------------------------------- /src/operations/ingredients.rs: -------------------------------------------------------------------------------- 1 | //! Translation of recipe ingredients provided by the device, as well as validation of provided ingredients 2 | //! for a brew request against the ingredients specified by the recipe. 3 | //! 4 | //! There's a lot of code here for some apparently simple things, but it allows us to keep the messy protocol stuff 5 | //! separated from the semi-clean CLI interface. We also validate ingredients as much as we can to avoid sending anything 6 | //! bad to the machine that might have unintended consequences (spilled milk, too little coffee, spectacular fire, etc). 7 | use std::collections::HashMap; 8 | use std::vec; 9 | 10 | use crate::prelude::*; 11 | use crate::protocol::*; 12 | 13 | /// The requested ingredients to brew, generally provided by an API user or CLI input. A [`Vec`] will 14 | /// be combined with the [`IngredientCheckMode`] and a [`Vec`] to create the final brew recipe to send 15 | /// to the machine. 16 | #[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)] 17 | pub enum BrewIngredientInfo { 18 | Coffee(u16), 19 | Milk(u16), 20 | HotWater(u16), 21 | Taste(EcamBeverageTaste), 22 | Temperature(EcamTemperature), 23 | Inversion(bool), 24 | Brew2(bool), 25 | } 26 | 27 | impl BrewIngredientInfo { 28 | pub fn to_arg_string(&self) -> Option { 29 | let number_arg = |name: &str, value| format!("--{} {}", name, value); 30 | match self { 31 | Self::Coffee(value) => Some(number_arg("coffee", value)), 32 | Self::Milk(value) => Some(number_arg("milk", value)), 33 | Self::HotWater(value) => Some(number_arg("hotwater", value)), 34 | Self::Taste(value) => Some(format!("--taste {}", value.to_arg_string(),)), 35 | Self::Temperature(value) => Some(format!("--temp {}", value.to_arg_string(),)), 36 | // We don't support these for now 37 | Self::Inversion(..) | Self::Brew2(..) => None, 38 | } 39 | } 40 | 41 | pub fn from_arg(key: &str, value: &str) -> Option { 42 | if key == "coffee" { 43 | return value.parse::().ok().map(BrewIngredientInfo::Coffee); 44 | } 45 | if key == "milk" { 46 | return value.parse::().ok().map(BrewIngredientInfo::Milk); 47 | } 48 | if key == "hotwater" { 49 | return value.parse::().ok().map(BrewIngredientInfo::HotWater); 50 | } 51 | if key == "taste" { 52 | return EcamBeverageTaste::lookup_by_name_case_insensitive(value) 53 | .map(BrewIngredientInfo::Taste); 54 | } 55 | if key == "temperature" { 56 | return EcamTemperature::lookup_by_name_case_insensitive(value) 57 | .map(BrewIngredientInfo::Temperature); 58 | } 59 | panic!("Unexpected argument {}", key); 60 | } 61 | 62 | pub fn ingredient(&self) -> EcamIngredients { 63 | match self { 64 | Self::Coffee(..) => EcamIngredients::Coffee, 65 | Self::Milk(..) => EcamIngredients::Milk, 66 | Self::HotWater(..) => EcamIngredients::HotWater, 67 | Self::Taste(..) => EcamIngredients::Taste, 68 | Self::Temperature(..) => EcamIngredients::Temp, 69 | Self::Inversion(..) => EcamIngredients::Inversion, 70 | Self::Brew2(..) => EcamIngredients::DueXPer, 71 | } 72 | } 73 | 74 | pub fn value_u16(&self) -> u16 { 75 | match self { 76 | Self::Coffee(x) => *x, 77 | Self::Milk(x) => *x, 78 | Self::HotWater(x) => *x, 79 | Self::Taste(x) => ::from(*x) as u16, 80 | Self::Temperature(x) => ::from(*x) as u16, 81 | Self::Inversion(x) => ::from(*x), 82 | Self::Brew2(x) => ::from(*x), 83 | } 84 | } 85 | 86 | pub fn to_recipe_info(&self) -> RecipeInfo { 87 | RecipeInfo::::new(self.ingredient(), self.value_u16()) 88 | } 89 | } 90 | 91 | /// The processed ingredients from the raw ECAM responses. Some ingredients are omitted as they are not useful for brewing. 92 | /// 93 | /// This could be done with the raw [`RecipeMinMaxInfo`], but an older attempt at this code tried that and it became a 94 | /// fairly decent mess. 95 | #[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)] 96 | pub enum IngredientRangeInfo { 97 | Coffee(u16, u16, u16), 98 | Milk(u16, u16, u16), 99 | HotWater(u16, u16, u16), 100 | Taste(EcamBeverageTaste), 101 | Temperature(EcamTemperature), 102 | Accessory(EcamAccessory), 103 | Inversion(bool, bool), 104 | Brew2(bool, bool), 105 | } 106 | 107 | impl IngredientRangeInfo { 108 | /// Attempts to parse a [`RecipeInfo`] and [`RecipeMinMaxInfo`] into an [`IngredientRangeInfo`]. If this fails, it returns a string with 109 | /// a human-readable error. 110 | pub fn new( 111 | ingredient: EcamIngredients, 112 | r1: Option>, 113 | r2: Option, 114 | ) -> Result, String> { 115 | // Ignore these types of ingredient 116 | if matches!( 117 | ingredient, 118 | EcamIngredients::Visible | EcamIngredients::IndexLength | EcamIngredients::Programmable 119 | ) { 120 | return Ok(None); 121 | } 122 | 123 | // Handle accessory separately, as it appears to differ between recipe and min/max 124 | if ingredient == EcamIngredients::Accessorio { 125 | return if let Some(r1) = r1 { 126 | match r1.value { 127 | 0 => Ok(None), 128 | 1 => Ok(Some(IngredientRangeInfo::Accessory(EcamAccessory::Water))), 129 | 2 => Ok(Some(IngredientRangeInfo::Accessory(EcamAccessory::Milk))), 130 | _ => Err(format!("Unknown accessory value {}", r1.value)), 131 | } 132 | } else { 133 | Ok(None) 134 | }; 135 | } 136 | 137 | macro_rules! error { 138 | ($msg:literal, $ingredient:expr, $r1:expr, $r2:expr) => { 139 | Err(format!( 140 | "Specified ingredient {:?} {} ({}<={}<={}, value={})", 141 | $ingredient, $msg, $r2.min, $r2.value, $r2.max, $r1.value 142 | )) 143 | }; 144 | } 145 | 146 | if let (Some(r1), Some(r2)) = (&r1, &r2) { 147 | if matches!( 148 | ingredient, 149 | EcamIngredients::Coffee | EcamIngredients::Milk | EcamIngredients::HotWater 150 | ) { 151 | // This appears to be the case for invalid ingredients in custom recipes 152 | if r1.value == 0 && r2.min > 0 { 153 | return error!("with invalid ranges", ingredient, r1, r2); 154 | } 155 | // This shows up on the Cortado recipe on the Dinamica Plus 156 | if r2.min == r2.value && r2.value == r2.max && r2.value == 0 { 157 | return error!("with zero ranges", ingredient, r1, r2); 158 | } 159 | } 160 | match ingredient { 161 | EcamIngredients::Coffee => { 162 | Ok(Some(IngredientRangeInfo::Coffee(r2.min, r1.value, r2.max))) 163 | } 164 | EcamIngredients::Milk => { 165 | Ok(Some(IngredientRangeInfo::Milk(r2.min, r1.value, r2.max))) 166 | } 167 | EcamIngredients::HotWater => Ok(Some(IngredientRangeInfo::HotWater( 168 | r2.min, r1.value, r2.max, 169 | ))), 170 | EcamIngredients::Taste => { 171 | if r2.min == 0 && r2.max == 5 { 172 | if let Ok(taste) = EcamBeverageTaste::try_from(r1.value as u8) { 173 | Ok(Some(IngredientRangeInfo::Taste(taste))) 174 | } else { 175 | error!("unknown", ingredient, r1, r2) 176 | } 177 | } else { 178 | error!("unknown range", ingredient, r1, r2) 179 | } 180 | } 181 | EcamIngredients::Temp => { 182 | Ok(Some(IngredientRangeInfo::Temperature(EcamTemperature::Low))) 183 | } 184 | EcamIngredients::Inversion => Ok(Some(IngredientRangeInfo::Inversion( 185 | r2.value == 1, 186 | r2.min == r2.max, 187 | ))), 188 | EcamIngredients::DueXPer => Ok(Some(IngredientRangeInfo::Brew2( 189 | r2.value == 1, 190 | r2.min == r2.max, 191 | ))), 192 | _ => error!("is unknown", ingredient, r1, r2), 193 | } 194 | } else if r1.is_some() ^ r2.is_some() { 195 | // If only one of min/max or recipe quantity comes back, that's bad 196 | Err(format!( 197 | "Mismatch for ingredient {:?} (recipe={:?} min_max={:?})", 198 | ingredient, r1, r2 199 | )) 200 | } else { 201 | // Otherwise it's just missing 202 | Ok(None) 203 | } 204 | } 205 | 206 | pub fn to_default(&self) -> BrewIngredientInfo { 207 | match self { 208 | Self::Coffee(_, x, _) => BrewIngredientInfo::Coffee(*x), 209 | Self::Milk(_, x, _) => BrewIngredientInfo::Milk(*x), 210 | Self::HotWater(_, x, _) => BrewIngredientInfo::HotWater(*x), 211 | Self::Taste(x) => BrewIngredientInfo::Taste(*x), 212 | Self::Temperature(x) => BrewIngredientInfo::Temperature(*x), 213 | Self::Inversion(x, _) => BrewIngredientInfo::Inversion(*x), 214 | Self::Brew2(x, _) => BrewIngredientInfo::Brew2(*x), 215 | Self::Accessory(..) => panic!("Invalid conversion"), 216 | } 217 | } 218 | 219 | pub fn to_arg_string(&self) -> Option { 220 | let number_arg = |name: &str, min, value, max| { 221 | format!("--{} <{}-{}, default {}>", name, min, max, value) 222 | }; 223 | 224 | match self { 225 | Self::Coffee(min, value, max) => Some(number_arg("coffee", min, value, max)), 226 | Self::Milk(min, value, max) => Some(number_arg("milk", min, value, max)), 227 | Self::HotWater(min, value, max) => Some(number_arg("hotwater", min, value, max)), 228 | Self::Taste(value) => Some(format!( 229 | "--taste <{}, default={}>", 230 | EcamBeverageTaste::all().collect_map_join("|", |x| x.to_arg_string()), 231 | value.to_arg_string(), 232 | )), 233 | Self::Temperature(value) => Some(format!( 234 | "--temp <{}, default={}>", 235 | EcamTemperature::all().collect_map_join("|", |x| x.to_arg_string()), 236 | value.to_arg_string(), 237 | )), 238 | // We don't support these for now 239 | Self::Accessory(..) | Self::Inversion(..) | Self::Brew2(..) => None, 240 | } 241 | } 242 | 243 | pub fn ingredient(&self) -> EcamIngredients { 244 | match self { 245 | Self::Coffee(..) => EcamIngredients::Coffee, 246 | Self::Milk(..) => EcamIngredients::Milk, 247 | Self::HotWater(..) => EcamIngredients::HotWater, 248 | Self::Taste(..) => EcamIngredients::Taste, 249 | Self::Temperature(..) => EcamIngredients::Temp, 250 | Self::Inversion(..) => EcamIngredients::Inversion, 251 | Self::Brew2(..) => EcamIngredients::DueXPer, 252 | Self::Accessory(..) => EcamIngredients::Accessorio, 253 | } 254 | } 255 | } 256 | 257 | /// Determines how ingredients are checked. 258 | #[derive(Clone, Copy, Debug, Eq, PartialEq)] 259 | pub enum IngredientCheckMode { 260 | /// Each ingredient is required, and must match one provided by the recipe. 261 | Strict, 262 | /// Ingredients are all optional and will be provided by the recipe. All ingredients must be present in the recipe. 263 | AllowDefaults, 264 | /// Disable all ingredient checking and process the ingredients as-is. CAUTION: this may have unintended results 265 | /// or cause damage to the machine. 266 | Force, 267 | } 268 | 269 | /// Error result of the [`check_ingredients`] call. 270 | #[derive(Clone, Debug, Eq, PartialEq)] 271 | pub struct IngredientCheckError { 272 | pub missing: Vec, 273 | pub extra: Vec, 274 | pub range_errors: Vec<(EcamIngredients, String)>, 275 | } 276 | 277 | /// Checks this [`BrewIngredientInfo`] against an [`IngredientRangeInfo`] and returns [`Ok(RecipeInfo)`] if valid. 278 | pub fn check_ingredients( 279 | mode: IngredientCheckMode, 280 | brew: &[BrewIngredientInfo], 281 | ranges: &[IngredientRangeInfo], 282 | ) -> Result, IngredientCheckError> { 283 | let mut v = vec![]; 284 | let mut extra = vec![]; 285 | let mut range_errors = vec![]; 286 | let mut ranges_map = HashMap::new(); 287 | for ingredient in ranges.iter() { 288 | if !matches!( 289 | ingredient, 290 | IngredientRangeInfo::Accessory(..) 291 | | IngredientRangeInfo::Brew2(..) 292 | | IngredientRangeInfo::Inversion(..) 293 | ) { 294 | ranges_map.insert(ingredient.ingredient(), ingredient); 295 | } 296 | } 297 | for ingredient in brew.iter() { 298 | let key = ingredient.ingredient(); 299 | if mode == IngredientCheckMode::Force { 300 | v.push(*ingredient) 301 | } else if let Some(range) = ranges_map.remove(&key) { 302 | match check_ingredient(ingredient, range) { 303 | Err(s) => range_errors.push((key, s)), 304 | Ok(r) => v.push(r), 305 | } 306 | } else { 307 | extra.push(ingredient.ingredient()); 308 | } 309 | } 310 | let mut missing: Vec<_> = ranges_map.values().map(|y| **y).collect::>(); 311 | if mode == IngredientCheckMode::AllowDefaults { 312 | for ingredient in missing.drain(..) { 313 | v.push(ingredient.to_default()) 314 | } 315 | } 316 | if mode == IngredientCheckMode::Force 317 | || (extra.is_empty() && missing.is_empty() && range_errors.is_empty()) 318 | { 319 | v.sort(); 320 | Ok(v) 321 | } else { 322 | extra.sort(); 323 | missing.sort(); 324 | range_errors.sort(); 325 | Err(IngredientCheckError { 326 | extra, 327 | missing, 328 | range_errors, 329 | }) 330 | } 331 | } 332 | 333 | pub fn check_ingredient( 334 | brew: &BrewIngredientInfo, 335 | range: &IngredientRangeInfo, 336 | ) -> Result { 337 | let ingredient = brew.ingredient(); 338 | let validate_u16 = |out: fn(u16) -> BrewIngredientInfo, min, value: u16, max| { 339 | if value.clamp(min, max) == value { 340 | Ok(out(value)) 341 | } else { 342 | Err(format!( 343 | "{:?} value out of range ({}<={}<={})", 344 | ingredient, min, value, max 345 | )) 346 | } 347 | }; 348 | 349 | match (*brew, *range) { 350 | (BrewIngredientInfo::Coffee(value), IngredientRangeInfo::Coffee(min, _, max)) => { 351 | validate_u16(BrewIngredientInfo::Coffee, min, value, max) 352 | } 353 | (BrewIngredientInfo::Milk(value), IngredientRangeInfo::Milk(min, _, max)) => { 354 | validate_u16(BrewIngredientInfo::Milk, min, value, max) 355 | } 356 | (BrewIngredientInfo::HotWater(value), IngredientRangeInfo::HotWater(min, _, max)) => { 357 | validate_u16(BrewIngredientInfo::HotWater, min, value, max) 358 | } 359 | (x @ BrewIngredientInfo::Taste(_), IngredientRangeInfo::Taste(_)) => Ok(x), 360 | (x @ BrewIngredientInfo::Temperature(_), IngredientRangeInfo::Temperature(_)) => Ok(x), 361 | (brew, range) => { 362 | panic!( 363 | "Incorrect pairing, likely an internal error: {:?} {:?}", 364 | brew, range 365 | ) 366 | } 367 | } 368 | } 369 | 370 | #[cfg(test)] 371 | mod test { 372 | use super::*; 373 | use itertools::*; 374 | use rstest::*; 375 | 376 | /// Basic espresso, just coffee. 377 | const ESPRESSO_RECIPE: [IngredientRangeInfo; 1] = [IngredientRangeInfo::Coffee(0, 100, 250)]; 378 | /// CAPPUCCINO with coffee and milk. 379 | const CAPPUCCINO_RECIPE: [IngredientRangeInfo; 3] = [ 380 | IngredientRangeInfo::Coffee(0, 100, 250), 381 | IngredientRangeInfo::Milk(0, 50, 750), 382 | IngredientRangeInfo::Taste(EcamBeverageTaste::Normal), 383 | ]; 384 | 385 | fn quick_arg_parse(s: &str) -> Vec { 386 | let mut v = vec![]; 387 | let mut iter = s.split_ascii_whitespace(); 388 | while let Some((name, value)) = iter.next_tuple() { 389 | v.push(BrewIngredientInfo::from_arg(name, value).expect("Failed to parse option")) 390 | } 391 | v 392 | } 393 | 394 | fn ingredients_to_string(v: &[BrewIngredientInfo]) -> String { 395 | v.iter().collect_map_join(" ", |x| { 396 | BrewIngredientInfo::to_arg_string(x) 397 | .unwrap() 398 | .strip_prefix("--") 399 | .unwrap() 400 | .to_owned() 401 | }) 402 | } 403 | 404 | fn error_to_string( 405 | IngredientCheckError { 406 | missing, 407 | extra, 408 | range_errors, 409 | }: &IngredientCheckError, 410 | ) -> String { 411 | format!( 412 | "missing={} extra={} range={}", 413 | missing 414 | .iter() 415 | .collect_map_join(" ", |x| x.ingredient().to_arg_string()), 416 | extra.iter().collect_map_join(" ", |x| x.to_arg_string()), 417 | range_errors 418 | .iter() 419 | .collect_map_join(" ", |x| x.0.to_arg_string()), 420 | ) 421 | } 422 | 423 | fn test_mode( 424 | mode: IngredientCheckMode, 425 | ranges: &[IngredientRangeInfo], 426 | input: &str, 427 | expected: Result<&str, (&str, &str, &str)>, 428 | ) { 429 | let ingredients = quick_arg_parse(input); 430 | let actual = check_ingredients(mode, &ingredients, ranges); 431 | match (expected, &actual) { 432 | (Ok(out1), Ok(out2)) => { 433 | assert_eq!(out1, ingredients_to_string(out2)); 434 | } 435 | 436 | (Err(out), Err(error)) => { 437 | assert_eq!( 438 | format!("missing={} extra={} range={}", out.0, out.1, out.2), 439 | error_to_string(error) 440 | ); 441 | } 442 | 443 | _ => { 444 | panic!("Output didn't match: {:?} {:?}", expected, actual); 445 | } 446 | } 447 | } 448 | 449 | #[rstest] 450 | #[case(&ESPRESSO_RECIPE, "", Err(("coffee", "", "")))] 451 | #[case(&ESPRESSO_RECIPE, "coffee 100", Ok("coffee 100"))] 452 | #[case(&ESPRESSO_RECIPE, "milk 100", Err(("coffee", "milk", "")))] 453 | #[case(&ESPRESSO_RECIPE, "coffee 100 milk 100", Err(("", "milk", "")))] 454 | #[case(&ESPRESSO_RECIPE, "coffee 1000 milk 100", Err(("", "milk", "coffee")))] 455 | #[case(&CAPPUCCINO_RECIPE, "coffee 100", Err(("milk taste", "", "")))] 456 | #[case(&CAPPUCCINO_RECIPE, "coffee 200 milk 50 taste strong", Ok("coffee 200 milk 50 taste strong"))] 457 | fn strict( 458 | #[case] ranges: &[IngredientRangeInfo], 459 | #[case] input: &str, 460 | #[case] expected: Result<&str, (&str, &str, &str)>, 461 | ) { 462 | test_mode(IngredientCheckMode::Strict, ranges, input, expected); 463 | } 464 | 465 | #[rstest] 466 | #[case(&ESPRESSO_RECIPE, "", Ok("coffee 100"))] 467 | #[case(&ESPRESSO_RECIPE, "coffee 100", Ok("coffee 100"))] 468 | #[case(&ESPRESSO_RECIPE, "milk 100", Err(("", "milk", "")))] 469 | #[case(&ESPRESSO_RECIPE, "coffee 100 milk 100", Err(("", "milk", "")))] 470 | #[case(&ESPRESSO_RECIPE, "coffee 1000 milk 100", Err(("", "milk", "coffee")))] 471 | #[case(&CAPPUCCINO_RECIPE, "coffee 100", Ok("coffee 100 milk 50 taste normal"))] 472 | #[case(&CAPPUCCINO_RECIPE, "coffee 200 milk 50 taste strong", Ok("coffee 200 milk 50 taste strong"))] 473 | fn allow_defaults( 474 | #[case] ranges: &[IngredientRangeInfo], 475 | #[case] input: &str, 476 | #[case] expected: Result<&str, (&str, &str, &str)>, 477 | ) { 478 | test_mode(IngredientCheckMode::AllowDefaults, ranges, input, expected); 479 | } 480 | } 481 | --------------------------------------------------------------------------------