├── .gitignore ├── images ├── hass.png └── main.d2 ├── src ├── common │ ├── mod.rs │ ├── serial.rs │ ├── checksum.rs │ ├── codec.rs │ └── packet.rs ├── led │ ├── README.md │ ├── mod.rs │ ├── model.rs │ ├── controller.rs │ ├── tests.rs │ └── patterns.rs ├── frozen │ ├── mod.rs │ ├── README.md │ ├── command.rs │ ├── profile.rs │ ├── state.rs │ ├── manager.rs │ └── packet.rs ├── sensor │ ├── mod.rs │ ├── README.md │ ├── presence.rs │ ├── command.rs │ ├── state.rs │ ├── manager.rs │ └── packet.rs ├── README.md ├── config │ ├── tests.rs │ ├── mod.rs │ └── mqtt.rs ├── reset.rs ├── main.rs └── mqtt.rs ├── .cargo └── config.toml ├── opensleep.service ├── .github └── workflows │ └── rust.yml ├── Cargo.toml ├── SETUP.md ├── example_solo.ron ├── example_couples.ron ├── BACKGROUND.md ├── HASS.md ├── MQTT.md └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | config.ron 3 | /archive 4 | /build_send 5 | -------------------------------------------------------------------------------- /images/hass.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LiamSnow/opensleep/HEAD/images/hass.png -------------------------------------------------------------------------------- /src/common/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod checksum; 2 | pub mod codec; 3 | pub mod packet; 4 | pub mod serial; 5 | -------------------------------------------------------------------------------- /src/led/README.md: -------------------------------------------------------------------------------- 1 | # IS31FL3194 I2C LED Controller 2 | 3 | [Reference](https://www.lumissil.com/assets/pdf/core/IS31FL3194_DS.pdf) 4 | 5 | -------------------------------------------------------------------------------- /src/frozen/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod command; 2 | pub mod manager; 3 | pub mod packet; 4 | mod profile; 5 | pub mod state; 6 | 7 | pub use command::FrozenCommand; 8 | pub use manager::{PORT, run}; 9 | pub use packet::FrozenPacket; 10 | -------------------------------------------------------------------------------- /src/sensor/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod command; 2 | pub mod manager; 3 | pub mod packet; 4 | pub mod presence; 5 | pub mod state; 6 | 7 | pub use command::SensorCommand; 8 | pub use manager::{PORT, run}; 9 | pub use packet::SensorPacket; 10 | -------------------------------------------------------------------------------- /.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [target.aarch64-unknown-linux-gnu] 2 | linker = "aarch64-linux-gnu-gcc" 3 | 4 | [target.aarch64-unknown-linux-musl] 5 | linker = "aarch64-linux-gnu-gcc" 6 | 7 | [profile.release] 8 | strip = true 9 | lto = true 10 | codegen-units = 1 11 | -------------------------------------------------------------------------------- /src/led/mod.rs: -------------------------------------------------------------------------------- 1 | //! Reference: [https://www.lumissil.com/assets/pdf/core/IS31FL3194_DS.pdf] 2 | 3 | pub mod controller; 4 | mod model; 5 | pub mod patterns; 6 | #[cfg(test)] 7 | mod tests; 8 | 9 | pub use controller::IS31FL3194Controller; 10 | pub use model::{CurrentBand, IS31FL3194Config}; 11 | pub use patterns::LedPattern; 12 | -------------------------------------------------------------------------------- /opensleep.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=opensleep 3 | After=NetworkManager.service 4 | 5 | [Install] 6 | WantedBy=multi-user.target 7 | 8 | [Service] 9 | Environment=RUST_LOG=info 10 | ExecStart=/opt/opensleep/opensleep 11 | WorkingDirectory=/opt/opensleep 12 | Restart=always 13 | Type=simple 14 | RestartSec=5 15 | StartLimitInterval=60 16 | StartLimitBurst=3 17 | -------------------------------------------------------------------------------- /.github/workflows/rust.yml: -------------------------------------------------------------------------------- 1 | name: Rust 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | - name: Build 20 | run: cargo build --verbose 21 | - name: Run tests 22 | run: cargo test --verbose 23 | -------------------------------------------------------------------------------- /src/README.md: -------------------------------------------------------------------------------- 1 | # Structure 2 | 3 | `main.rs`: program entry 4 | 5 | `reset.rs`: handles resetting and enabling subsystems via the PCAL6416A I2C GPIO Expander 6 | 7 | `mqtt.rs`: 8 | - MQTT event loop 9 | - actions & top level publishing (`device/`, `result/`, `availability`) 10 | - initialization of MQTT `AsyncClient` 11 | 12 | `common/`: common serial and protocol handling for both Sensor and Frozen (checksum, codec, shared packets, ..) 13 | 14 | `config/`: config model & MQTT publishing 15 | 16 | `led/`: controller & model for the IS31FL3194 I2C LED controller 17 | 18 | `sensor/`: communication with Sensor subsystem & presence detection 19 | 20 | `frozen/`: communication with Frozen subsystem & temperature profile 21 | -------------------------------------------------------------------------------- /src/frozen/README.md: -------------------------------------------------------------------------------- 1 | # Frozen 2 | 3 | `manager.rs`: top level manager for Frozen 4 | - maintains connection with Frozen 5 | - schedules commands (priming, setting temps, ..) 6 | - sometimes Frozen goes to sleep when its not doing anything, so this also wakes it up before sending commands 7 | - changes LED based on profile state 8 | 9 | `state.rs`: state management for manager 10 | - takes in packets and updates it's `FrozenState` + publishes changes to MQTT 11 | 12 | `command.rs`: all Frozen cmds (`FrozenCommand`) & serialization 13 | 14 | `packet.rs`: all Frozen packets (`FrozenPacket`) & deserialization 15 | 16 | `profile.rs`: calculates temperature profile 17 | - takes current Time and returns target temperatures 18 | -------------------------------------------------------------------------------- /src/sensor/README.md: -------------------------------------------------------------------------------- 1 | # Sensor 2 | 3 | `manager.rs`: top level manager for Sensor 4 | - runs discovery to try and connect to Sensor in firmware (high baud) or bootloader (low baud) mode 5 | - maintains connection with Sensor, continously reading sensor data 6 | - schedules commands to be sends (enabling vibration motors, setting piezo gain, ..) 7 | 8 | `state.rs`: state management for manager 9 | - takes in packets and updates it's `SensorState` + publishes changes to MQTT 10 | 11 | `command.rs`: all Sensor cmds (`SensorCommand`) & serialization 12 | 13 | `packet.rs`: all Sensor packets (`SensorPacket`) & deserialization 14 | 15 | `presence.rs`: presense detection & calibration 16 | - takes in `CapacitanceData` from `manager.rs` and outputs state to MQTT 17 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "opensleep" 3 | version = "2.0.0" 4 | edition = "2024" 5 | 6 | [dependencies] 7 | async-trait = "0.1.88" 8 | ron = "0.10.1" 9 | rumqttc = "0.24.0" 10 | bytes = "1.10.1" 11 | csv = "1.3.1" 12 | futures-util = "0.3.31" 13 | hex-literal = "1.0.0" 14 | memchr = "2.7.5" 15 | rustfft = "6.4.0" 16 | thiserror = "2.0.12" 17 | tokio = { version = "1.45.1", features = ["full", "mio"] } 18 | tokio-serial = "5.4.5" 19 | tokio-util = { version = "0.7.15", features = ["codec"] } 20 | jiff = { version = "0.2.15", features = ["serde", "tzdb-bundle-always"] } 21 | log = "0.4.27" 22 | serde = { version = "1.0.219", features = ["derive"] } 23 | env_logger = "0.11.8" 24 | serde_json = "1.0.142" 25 | linux-embedded-hal = "0.4.0" 26 | embedded-hal = "1.0.0" 27 | strum_macros = "0.27.2" 28 | strum = "0.27.2" 29 | cbor4ii = { version = "1.0.0", features = ["use_std", "serde1"] } 30 | -------------------------------------------------------------------------------- /src/config/tests.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | // TODO more testing (esp for MQTT) 4 | 5 | #[tokio::test] 6 | async fn test_load_solo_config() { 7 | let config = Config::load("example_solo.ron").await.unwrap(); 8 | assert_eq!(config.timezone.iana_name().unwrap(), "America/New_York"); 9 | assert!(!config.away_mode); 10 | match &config.profile { 11 | SidesConfig::Solo(profile) => { 12 | assert_eq!(profile.temperatures, vec![27., 29., 31.]); 13 | } 14 | _ => panic!("Expected solo profile"), 15 | } 16 | } 17 | 18 | #[tokio::test] 19 | async fn test_load_couples_config() { 20 | let config = Config::load("example_couples.ron").await.unwrap(); 21 | assert_eq!(config.timezone.iana_name().unwrap(), "America/New_York"); 22 | assert!(!config.away_mode); 23 | match &config.profile { 24 | SidesConfig::Couples { left, right } => { 25 | assert_eq!(left.temperatures, vec![27., 29., 31.]); 26 | assert_eq!(right.temperatures, vec![27., 29., 31.]); 27 | } 28 | _ => panic!("Expected couples profile"), 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/common/serial.rs: -------------------------------------------------------------------------------- 1 | use super::codec::PacketCodec; 2 | use super::packet::Packet; 3 | use std::time::Duration; 4 | use strum_macros::Display; 5 | use thiserror::Error; 6 | use tokio_serial::{DataBits, FlowControl, Parity, SerialPortBuilderExt, SerialStream, StopBits}; 7 | use tokio_util::codec::Framed; 8 | 9 | #[derive(Error, Debug)] 10 | pub enum SerialError { 11 | #[error("IO: {0}")] 12 | Io(#[from] std::io::Error), 13 | #[error("Serial: {0}")] 14 | Serial(#[from] tokio_serial::Error), 15 | } 16 | 17 | #[derive(Debug, Clone, Copy, PartialEq, Default, Display)] 18 | pub enum DeviceMode { 19 | #[default] 20 | Unknown, 21 | Bootloader, 22 | Firmware, 23 | } 24 | 25 | impl DeviceMode { 26 | pub fn from_pong(in_firmware: bool) -> Self { 27 | if in_firmware { 28 | Self::Firmware 29 | } else { 30 | Self::Bootloader 31 | } 32 | } 33 | } 34 | 35 | pub fn create_port(port_path: &str, baud_rate: u32) -> Result { 36 | let port = tokio_serial::new(port_path, baud_rate) 37 | .data_bits(DataBits::Eight) 38 | .flow_control(FlowControl::None) 39 | .parity(Parity::None) 40 | .stop_bits(StopBits::One) 41 | .timeout(Duration::from_millis(1000)) 42 | .open_native_async()?; 43 | 44 | Ok(port) 45 | } 46 | 47 | pub fn create_framed_port( 48 | port_path: &str, 49 | baud_rate: u32, 50 | ) -> Result>, SerialError> { 51 | let port = create_port(port_path, baud_rate)?; 52 | Ok(Framed::new(port, PacketCodec::new())) 53 | } 54 | -------------------------------------------------------------------------------- /src/common/checksum.rs: -------------------------------------------------------------------------------- 1 | // Implementation of CRC-CCITT (0x1D0F) referenced from `libstd` 2 | 3 | const CRC_START: u16 = 0x1D0F; 4 | const CRC_POLY_CCITT: u16 = 0x1021; 5 | const CRC_TABLE: [u16; 256] = make_crc_table(); 6 | 7 | /// precompute CRC table 8 | const fn make_crc_table() -> [u16; 256] { 9 | let mut table = [0u16; 256]; 10 | let mut i = 0; 11 | while i < 256 { 12 | let mut crc = 0u16; 13 | let mut c = (i as u16) << 8; 14 | let mut j = 0; 15 | while j < 8 { 16 | if (crc ^ c) & 0x8000 != 0 { 17 | crc = (crc << 1) ^ CRC_POLY_CCITT; 18 | } else { 19 | crc <<= 1; 20 | } 21 | c <<= 1; 22 | j += 1; 23 | } 24 | table[i] = crc; 25 | i += 1; 26 | } 27 | table 28 | } 29 | 30 | pub const fn compute(input: &[u8]) -> u16 { 31 | let mut crc = CRC_START; 32 | let mut i = 0; 33 | while i < input.len() { 34 | let byte = input[i]; 35 | let index = ((crc >> 8) ^ (byte as u16)) & 0x00FF; 36 | crc = (crc << 8) ^ CRC_TABLE[index as usize]; 37 | i += 1; 38 | } 39 | crc 40 | } 41 | 42 | #[cfg(test)] 43 | mod tests { 44 | use hex_literal::hex; 45 | 46 | #[test] 47 | fn test_checksum() { 48 | assert_eq!(super::compute(&hex!("40 0001 0E10")), 0xE6A8); 49 | assert_eq!(super::compute(&hex!("40 0101 0A14")), 0x1C5C); 50 | assert_eq!(super::compute(&hex!("40 0000 1194")), 0x13d9); 51 | assert_eq!(super::compute(&hex!("40 0000 10e0")), 0x1efb); 52 | assert_eq!(super::compute(&hex!("40 0000 0d5c")), 0x0d83); 53 | assert_eq!(super::compute(&hex!("40 0000 0a8c")), 0x5f69); 54 | assert_eq!(super::compute(&hex!("40 0000 03e8")), 0xc9d3); 55 | assert_eq!(super::compute(&hex!("40 0000 0834")), 0x1fd8); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /SETUP.md: -------------------------------------------------------------------------------- 1 | # opensleep setup 2 | 3 | ## SSH setup 4 | | Pod | Setup | 5 | | --- | ----- | 6 | | Pod 1 | ❌ not possible | 7 | | Pod 2 | ❌ not possible | 8 | | Pod 3 (with sd card) | see [below](#pod-3-with-sd-card) | 9 | | Pod 3 (no sd card) | see [free-sleep tutorial](https://github.com/throwaway31265/free-sleep/blob/main/INSTALLATION.md) | 10 | | Pod 4 | see [free-sleep tutorial](https://github.com/throwaway31265/free-sleep/blob/main/INSTALLATION.md) | 11 | | Pod 5 | see [free-sleep tutorial](https://github.com/throwaway31265/free-sleep/blob/main/INSTALLATION.md) | 12 | 13 | WARNING: opensleep has only been tested with Pod 3. Pod 4 and 5 specific features are NOT implemented. 14 | If you would like to help add Pod 4 & 5 support please contact me! 15 | 16 | ### Pod 3 with SD Card 17 | 18 | Eventually I will add thorough tutorial for this, but for now I would recommend cross-referencing: 19 | - [Bo Lopker's Tutorial](https://blopker.com/writing/04-zerosleep-1/#disassembly-overview) 20 | - [ninesleep instructions](https://github.com/bobobo1618/ninesleep?tab=readme-ov-file#instructions) 21 | 22 | Basically this involves: 23 | 1. Partially disassembling the Pod 24 | 2. Removing the SD card 25 | 3. Modifying the `rootfs.tar.gz` file on the SD card, adding your SSH keys, WiFi network, and own password 26 | 4. Reinserting the SD card 27 | 5. Powering the Pod up with the small button pressed in (factory resetting the Pod to your new `rootfs.tar.gz` file) 28 | 29 | Notes: 30 | - Default SSH port is `8822` 31 | - Updates will reset your system, disable the updater with: `systemctl disable --now swupdate-progress swupdate defibrillator` 32 | 33 | ## Adding opensleep 34 | 1. Make a new `config.ron` file based of the examples: `example_couples.ron` or `example_solo.ron`. 35 | 2. Stop services: `systemctl disable --now dac frank capybara swupdate-progress swupdate defibrillator` 36 | 3. Place the binary `opensleep` and `config.ron` at `/opt/opensleep` 37 | 4. Place the service `opensleep.service` at `/lib/systemd/system` 38 | 5. Enable opensleep `systemctl enable --now opensleep` 39 | -------------------------------------------------------------------------------- /example_solo.ron: -------------------------------------------------------------------------------- 1 | ( 2 | timezone: "America/New_York", 3 | 4 | // Away mode will disable temperature control and alarms 5 | away_mode: false, 6 | 7 | // What time to prime the bed. Make sure this does not interfere with the sleep profile. 8 | prime: "15:00", 9 | 10 | led: ( 11 | // SlowBreath(r, g, b) 12 | // FastBreath(r, g, b) 13 | // Off 14 | // Fixed(r, g, b) 15 | // FastRainbowBreath 16 | // SlowRainbowBreath 17 | // .. see more in `src/led/patterns.rs` 18 | idle: Fixed(0, 255, 0), 19 | active: SlowBreath(255, 0, 0), 20 | 21 | // One: 0mA\~10mA, Imax=10mA 22 | // Two: 0mA\~20mA, Imax=20mA 23 | // Three: 0mA\~30mA, Imax=30mA 24 | // Four: 0mA\~40mA, Imax=40mA 25 | band: Three, 26 | ), 27 | 28 | mqtt: ( 29 | server: "homeassistant.local", 30 | port: 1883, 31 | user: "example", 32 | password: "1234" 33 | ), 34 | 35 | profile: Solo( 36 | ( 37 | // Define as many points you want. They are evenly spread apart 38 | // Temperatures will be linearly interpolated between 39 | // This is in degrees celcius. You may provide floating-point numbers. 40 | // A "nuetral" temp is 29, but I prefer much colder at like 20 41 | temperatures: [27, 29, 31], 42 | 43 | // Time to start temperature profile 44 | sleep: "22:00", 45 | 46 | // Time to end temperature profile 47 | wake: "10:30", 48 | 49 | // Vibration alarm 50 | alarm: ( 51 | // Double or Single 52 | pattern: Double, 53 | 54 | // Intensity 0-100 55 | intensity: 80, 56 | 57 | // Duration in seconds 58 | duration: 600, 59 | 60 | // Offset from wake time in seconds. 61 | // In this case it will trigger at 10:25 62 | offset: 300, 63 | ), 64 | ) 65 | ), 66 | ) 67 | -------------------------------------------------------------------------------- /src/reset.rs: -------------------------------------------------------------------------------- 1 | use embedded_hal::i2c::I2c; 2 | use linux_embedded_hal::I2cdev; 3 | use std::{error::Error, time::Duration}; 4 | use tokio::time::sleep; 5 | 6 | const DEV: &str = "/dev/i2c-1"; 7 | 8 | const ADDR: u8 = 0x20; 9 | 10 | const REG_OUTPUT_PORT_0: u8 = 0x02; 11 | const REG_CONFIG_PORT_0: u8 = 0x06; 12 | const REG_CONFIG_PORT_1: u8 = 0x07; 13 | 14 | const PORT_0_CONFIG: u8 = 0b1111_1100; // pins 0,1 as outputs 15 | const PORT_1_CONFIG: u8 = 0b0011_0001; 16 | const OUTPUT_RESET: u8 = 0b1111_1111; 17 | const OUTPUT_ENABLED: u8 = 0b1111_1101; 18 | 19 | /// Reset Controller using the PCAL6416A (16-bit I2C Expander) 20 | /// Datasheet: 21 | /// 22 | /// ## Reset/Boot State 23 | /// 1b 0e ff 3f 00 00 fc 31 XX XX XX XX XX XX XX XX 24 | /// 25 | /// ## Enabled State 26 | /// 19 0e fd 3f 00 00 fc 31 XX XX XX XX XX XX XX XX 27 | pub struct ResetController { 28 | dev: I2cdev, 29 | } 30 | 31 | impl ResetController { 32 | pub fn new() -> Result> { 33 | Ok(Self { 34 | dev: I2cdev::new(DEV)?, 35 | }) 36 | } 37 | 38 | fn write_reg(&mut self, reg: u8, value: u8) -> Result<(), Box> { 39 | self.dev.write(ADDR, &[reg, value])?; 40 | Ok(()) 41 | } 42 | 43 | /// resets and enables subsystems (Frozen + Sensor?) 44 | pub async fn reset_subsystems(&mut self) -> Result<(), Box> { 45 | log::info!("Resetting Subsystems..."); 46 | 47 | // config ports 48 | self.write_reg(REG_CONFIG_PORT_0, PORT_0_CONFIG)?; 49 | self.write_reg(REG_CONFIG_PORT_1, PORT_1_CONFIG)?; 50 | sleep(Duration::from_millis(10)).await; 51 | 52 | // assert reset 53 | self.write_reg(REG_OUTPUT_PORT_0, OUTPUT_RESET)?; 54 | sleep(Duration::from_millis(100)).await; 55 | 56 | // de-assert reset (enable) 57 | self.write_reg(REG_OUTPUT_PORT_0, OUTPUT_ENABLED)?; 58 | sleep(Duration::from_millis(100)).await; 59 | 60 | Ok(()) 61 | } 62 | 63 | pub fn take(self) -> I2cdev { 64 | self.dev 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /example_couples.ron: -------------------------------------------------------------------------------- 1 | ( 2 | timezone: "America/New_York", 3 | 4 | // Away mode will disable temperature control and alarms 5 | away_mode: false, 6 | 7 | // What time to prime the bed. Make sure this does not interfere with the sleep profile. 8 | prime: "15:00", 9 | 10 | led: ( 11 | // SlowBreath(r, g, b) 12 | // FastBreath(r, g, b) 13 | // Off 14 | // Fixed(r, g, b) 15 | // FastRainbowBreath 16 | // SlowRainbowBreath 17 | // .. see more in `src/led/patterns.rs` 18 | idle: Fixed(0, 255, 0), 19 | active: SlowBreath(255, 0, 0), 20 | 21 | // One: 0mA\~10mA, Imax=10mA 22 | // Two: 0mA\~20mA, Imax=20mA 23 | // Three: 0mA\~30mA, Imax=30mA 24 | // Four: 0mA\~40mA, Imax=40mA 25 | band: Three, 26 | ), 27 | 28 | mqtt: ( 29 | server: "homeassistant.local", 30 | port: 1883, 31 | user: "example", 32 | password: "1234" 33 | ), 34 | 35 | profile: Couples( 36 | left: ( 37 | // Define as many points you want. They are evenly spread apart 38 | // Temperatures will be linearly interpolated between 39 | // This is in degrees celcius. You may provide floating-point numbers. 40 | // A "nuetral" temp is 29, but I prefer much colder at like 20 41 | temperatures: [27, 29, 31], 42 | 43 | // Time to start temperature profile 44 | sleep: "22:00", 45 | 46 | // Time to end temperature profile 47 | wake: "10:30", 48 | 49 | // Vibration alarm 50 | alarm: ( 51 | // Double or Single 52 | pattern: Double, 53 | 54 | // Intensity 0-100 55 | intensity: 80, 56 | 57 | // Duration in seconds 58 | duration: 600, 59 | 60 | // Offset from wake time in seconds. 61 | // In this case it will trigger at 10:25 62 | offset: 300, 63 | ), 64 | ), 65 | right: ( 66 | temperatures: [27, 29, 31], 67 | sleep: "22:00", 68 | wake: "10:30", 69 | vibration: ( 70 | pattern: Rise, 71 | intensity: 80, 72 | duration: 600, 73 | offset: 300, 74 | ), 75 | ), 76 | ), 77 | ) 78 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | mod common; 2 | mod config; 3 | mod frozen; 4 | mod led; 5 | mod mqtt; 6 | mod reset; 7 | mod sensor; 8 | 9 | use std::fs; 10 | 11 | use config::Config; 12 | use tokio::sync::{mpsc, watch}; 13 | 14 | use crate::{led::IS31FL3194Controller, mqtt::MqttManager, reset::ResetController}; 15 | 16 | pub const VERSION: &str = "2.0.0"; 17 | pub const NAME: &str = "opensleep"; 18 | 19 | #[tokio::main] 20 | pub async fn main() { 21 | env_logger::init(); 22 | log::info!("Starting opensleep..."); 23 | 24 | // read device label 25 | let device_label = 26 | fs::read_to_string("/home/dac/app/sewer/device-label").unwrap_or("unknown".to_string()); 27 | 28 | // read config 29 | let config = Config::load("config.ron").await.unwrap(); 30 | log::info!("`config.ron` loaded"); 31 | let (config_tx, config_rx) = watch::channel(config.clone()); 32 | log::info!( 33 | "Using timezone: {}", 34 | config.timezone.iana_name().unwrap_or("ERROR") 35 | ); 36 | 37 | // reset 38 | let mut resetter = ResetController::new().unwrap(); 39 | resetter.reset_subsystems().await.unwrap(); 40 | let led = IS31FL3194Controller::new(resetter.take()); 41 | 42 | let (calibrate_tx, calibrate_rx) = mpsc::channel(32); 43 | 44 | let mut mqtt_man = MqttManager::new( 45 | config_tx.clone(), 46 | config_rx.clone(), 47 | calibrate_tx, 48 | device_label, 49 | ); 50 | 51 | if mqtt_man.wait_for_conn().await.is_err() { 52 | log::error!("Fatal error starting MQTT. Shutting down..."); 53 | return; 54 | } 55 | 56 | tokio::select! { 57 | res = frozen::run( 58 | frozen::PORT, 59 | config_rx.clone(), 60 | led, 61 | mqtt_man.client.clone() 62 | ) => { 63 | match res { 64 | Ok(_) => log::error!("Frozen task unexpectedly exited"), 65 | Err(e) => log::error!("Frozen task failed: {e}"), 66 | } 67 | } 68 | 69 | res = sensor::run( 70 | sensor::PORT, 71 | config_tx, 72 | config_rx, 73 | calibrate_rx, 74 | mqtt_man.client.clone() 75 | ) => { 76 | match res { 77 | Ok(_) => log::error!("Sensor task unexpectedly exited"), 78 | Err(e) => log::error!("Sensor task failed: {e}"), 79 | } 80 | } 81 | 82 | _ = mqtt_man.run() => { 83 | log::error!("MQTT manager unexpectedly exited"); 84 | } 85 | } 86 | 87 | log::info!("Shutting down opensleep..."); 88 | } 89 | -------------------------------------------------------------------------------- /BACKGROUND.md: -------------------------------------------------------------------------------- 1 | # Eight Sleep Pod 3 Background 2 | 3 | ## Hardware 4 | Linux SOM ([VAR-SOM-MX8M-MINI_V1.3](https://www.variscite.com/system-on-module-som/i-mx-8/i-mx-8m-mini/var-som-mx8m-mini/)) running pretty minimal Yocto build. 5 | - Systems runs off 8GB eMMC normally 6 | - Micro SD card (16GB industrial SanDisk) contains 3 partitions (p1 to boot from, p3 for persistent storage) 7 | - If the small button is held in during boot, the SOM will boot from the SD card p1 8 | - It will run a script that will copy `/opt/images/Yocto/rootfs.tar.gz` onto the eMMC, then reboots from eMMC 9 | 10 | ### Subsystems 11 | 12 | #### "Frozen" (STM32F030CCT6) on the main PCB 13 | - Manages: 14 | - 2x TECs for water temperature control with PID 15 | - 2x Pumps for moving water 16 | - Solenoid in tank 17 | - Water tank level sensor 18 | - Reed Switch 19 | - Does priming, water temp control, safety 20 | - USART control over `/dev/ttymxc0` at 38400 baud 21 | - Firmware: `/opt/eight/lib/subsystem_updates/firmware-frozen.bbin` 22 | 23 | #### "Sensor board" on the bed control unit (connected over USB) 24 | - Manages: 25 | - 6x capacitance sensors (2Hz) 26 | - 8x bed temperature sensors 27 | - Ambient sensor (temp + humidity) 28 | - ADC connected to 2x piezo sensors (1000kHz) 29 | - Vibration alarm motors 30 | - USART control over `/dev/ttymxc2` at 38400 baud in bootloader mode and 115200 in firmware mode 31 | - Firmware: `/opt/eight/lib/subsystem_updates/firmware-sensor.bbin` 32 | 33 | 34 | ## Services 35 | ### Frank (`/opt/eight/bin/frakenfirmware`) 36 | C++ with simple UNIX socket commands. Controls: 37 | - LEDs over I2C (IS31FL3194) 38 | - Also controlled by other processes 39 | - Sensor Unit (STM32F030CCT6) over UART (`/dev/ttymxc0`), flashes `firmware-sensor.bbin` 40 | - 6 capacitance sensors, 1x/second 41 | - 2 Piezo sensors, 500x/second 42 | - Bed temp (microcontroller's temp, ambient temp, humidity, 6 on bed) 43 | - Freezer temp (ambient, hs, left/right) 44 | - Vibration alarms 45 | - Takes in a left and right ADC gain parameter (default `400`) 46 | - "Frozen" over UART (`/dev/ttymxc2`), flashes `firmware-frozen.bbin` 47 | - Takes l/r temperatures and durations 48 | - Uploading Raw sensor data + logs to `raw-api-upload.8slp.net:1337` 49 | - Ex. `https://update-api.8slp.net/v1/updates/p1/1\?deviceId\=1\¤tRev\=1` points to a bucket 50 | - What the RAT thermosistor? "ERR:00114015 Thermostat.cpp:220 checkHeatingPowerLevel|RAT bad thermistor, CompTrig set to min 60.00" 51 | 52 | ### Device-API-Client (DAC)/PizzaRat (`/home/dac/app`) 53 | Node TypeScript 54 | - CoAP for device API `device-api.8slp.net:5684` 55 | - Basically just a wrapper for Frank 56 | 57 | ### SWUpdate 58 | Gets software updates from `update-api.8slp.net:443` 59 | 60 | ### Capybara (`/opt/eight/bin/Eight.Capybara`) 61 | .NET 62 | - Handles initial setup via Bluetooth 63 | - Writes `/deviceinfo` 64 | - Has a loopback with the sensor UART (for debugging?) 65 | - Enables Subsystems (Frozen + Sensor) over `/dev/i2c-1` `0x20` which is a PCAL6416 66 | - Restarts Frozen when `/persistent/frozen.heartbeat` is old 67 | 68 | 69 | 70 | [.dts changes](https://github.com/varigit/linux-imx/commit/593a62b5dcd311f4e469fa2dad91cf1b8865c6fb?diff=unified) 71 | 72 | 73 | 74 | -------------------------------------------------------------------------------- /HASS.md: -------------------------------------------------------------------------------- 1 | # Home Assistant opensleep Setup 2 | 3 | ## configuration.yaml 4 | 5 | ```yaml 6 | ... 7 | mqtt: 8 | - text: 9 | name: "opensleep sleep time" 10 | command_topic: "opensleep/actions/set_profile" 11 | command_template: > 12 | {% if value %} 13 | both.sleep={{ value }} 14 | {% endif %} 15 | retain: false 16 | pattern: ^([01][0-9]|2[0-3]):[0-5][0-9](?::[0-5][0-9])?$ 17 | state_topic: "opensleep/state/config/profile/left/sleep" 18 | - text: 19 | name: "opensleep wake time" 20 | command_topic: "opensleep/actions/set_profile" 21 | command_template: > 22 | {% if value %} 23 | both.wake={{ value }} 24 | {% endif %} 25 | retain: false 26 | pattern: ^([01][0-9]|2[0-3]):[0-5][0-9](?::[0-5][0-9])?$ 27 | state_topic: "opensleep/state/config/profile/left/wake" 28 | - text: 29 | name: "opensleep alarm" 30 | command_topic: "opensleep/actions/set_profile" 31 | command_template: > 32 | {% if value %} 33 | both.alarm={{ value }} 34 | {% endif %} 35 | retain: false 36 | state_topic: "opensleep/state/config/profile/left/alarm" 37 | - text: 38 | name: "opensleep target temp" 39 | command_topic: "opensleep/state/frozen/left_target_temp" # RO 40 | retain: false 41 | state_topic: "opensleep/state/frozen/left_target_temp" 42 | - text: 43 | name: "opensleep current temp" 44 | command_topic: "opensleep/state/frozen/left_temp" # RO 45 | retain: false 46 | state_topic: "opensleep/state/frozen/left_temp" 47 | - switch: 48 | name: "opensleep away mode" 49 | command_topic: "opensleep/set_away_mode" 50 | state_topic: "opensleep/state/config/away_mode" 51 | retain: false 52 | - text: 53 | name: "opensleep temperatures" 54 | command_topic: "opensleep/actions/set_profile" 55 | command_template: > 56 | {% if value %} 57 | both.temperatures={{ value }} 58 | {% endif %} 59 | retain: false 60 | state_topic: "opensleep/state/config/profile/left/temperatures" 61 | ``` 62 | 63 | ## Dashboard 64 | 65 | ```yaml 66 | views: 67 | - title: # ... 68 | # ... 69 | cards: 70 | - type: entities 71 | entities: 72 | - entity: text.opensleep_sleep_time 73 | name: sleep time 74 | icon: mdi:bed-clock 75 | - entity: text.opensleep_wake_time 76 | icon: mdi:sun-clock 77 | name: wake time 78 | - entity: text.opensleep_temperatures 79 | name: temperatures 80 | icon: mdi:thermometer 81 | - entity: text.opensleep_alarm 82 | icon: mdi:alarm 83 | name: alarm 84 | ``` 85 | 86 | ## Automations 87 | 88 | ```yaml 89 | alias: "opensleep leave bed" 90 | description: "" 91 | triggers: 92 | - trigger: mqtt 93 | topic: opensleep/state/presence/any 94 | payload: "false" 95 | conditions: [] 96 | actions: 97 | - action: light.turn_on 98 | metadata: {} 99 | data: 100 | brightness_pct: 100 101 | target: 102 | area_id: 107d 103 | mode: single 104 | ``` 105 | -------------------------------------------------------------------------------- /src/led/model.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use strum_macros::{Display, EnumString}; 3 | 4 | #[derive(Clone)] 5 | pub struct IS31FL3194Config { 6 | pub enabled: bool, 7 | pub mode: OperatingMode, 8 | pub band: CurrentBand, 9 | } 10 | 11 | #[derive(Clone)] 12 | pub struct PatternConfig { 13 | pub timing: Timing, 14 | pub colors: [ColorConfig; 3], 15 | pub next: PatternNext, 16 | pub gamma: Gamma, 17 | /// how many pulses to do 18 | pub multipulse_repeat: Repeat, 19 | /// how many times to repeat entire pattern 20 | pub pattern_repeat: Repeat, 21 | } 22 | 23 | #[derive(Clone, Default)] 24 | pub struct ColorConfig { 25 | pub enabled: bool, 26 | pub r: u8, 27 | pub g: u8, 28 | pub b: u8, 29 | pub repeat: ColorRepeat, 30 | } 31 | 32 | #[derive(Clone)] 33 | #[repr(u8)] 34 | pub enum OperatingMode { 35 | /// $I_{OUT}=\frac{I_{MAX}}{256}\cdot\sum_{n=0}^{7}{D[n]\cdot2^n}$ 36 | /// where $D[n]$ stands for the individual bit value, $I_{MAX}$ is set by `.band` 37 | /// Ex: 0b10110101 -> $I_{OUT}=I_{MAX}\frac{2^7+2^5+2^4+2^2+2^0}{256}$ 38 | CurrentLevel(u8, u8, u8), 39 | Pattern( 40 | Option, 41 | Option, 42 | Option, 43 | ), 44 | } 45 | 46 | #[derive(Clone, Default, Serialize, Deserialize, PartialEq, Debug, EnumString, Display)] 47 | #[repr(u8)] 48 | pub enum CurrentBand { 49 | /// 0mA\~10mA, Imax=10mA 50 | #[allow(dead_code)] 51 | One = 0b00, 52 | /// 0mA\~20mA, Imax=20mA 53 | #[allow(dead_code)] 54 | Two = 0b01, 55 | /// 0mA\~30mA, Imax=30mA 56 | #[default] 57 | Three = 0b10, 58 | /// 0mA\~40mA, Imax=40mA 59 | #[allow(dead_code)] 60 | Four = 0b11, 61 | } 62 | 63 | #[derive(Clone, Default)] 64 | #[repr(u8)] 65 | pub enum ColorRepeat { 66 | #[default] 67 | Endless = 0b00, 68 | Once = 0b01, 69 | #[allow(dead_code)] 70 | Twice = 0b10, 71 | #[allow(dead_code)] 72 | Thrice = 0b11, 73 | } 74 | 75 | #[derive(Clone)] 76 | pub enum PatternNext { 77 | #[allow(dead_code)] 78 | Stop, 79 | /// goto next pattern 80 | Next, 81 | /// goto previous pattern, NOT valid for P1 82 | #[allow(dead_code)] 83 | Prev, 84 | } 85 | 86 | #[derive(Clone)] 87 | #[repr(u8)] 88 | pub enum Gamma { 89 | /// gamma = 2.4 90 | Gamma2_4 = 0b00, 91 | /// gamma = 3.5 92 | #[allow(dead_code)] 93 | Gamma3_5 = 0b01, 94 | #[allow(dead_code)] 95 | Linearity = 0b10, 96 | } 97 | 98 | #[derive(Clone)] 99 | #[repr(u8)] 100 | pub enum Repeat { 101 | Endless, 102 | /// 1-15 103 | Count(u8), 104 | } 105 | 106 | /// 0000 0.03s 107 | /// 0001 0.13s 108 | /// 0010 0.26s 109 | /// 0011 0.38s 110 | /// 0100 0.51s 111 | /// 0101 0.77s 112 | /// 0110 1.04s 113 | /// 0111 1.60s 114 | /// 1000 2.10s 115 | /// 1001 2.60s 116 | /// 1010 3.10s 117 | /// 1011 4.20s 118 | /// 1100 5.20s 119 | /// 1101 6.20s 120 | /// 1110 7.30s 121 | /// 1111 8.30s 122 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] 123 | pub struct Timing { 124 | pub start: u8, 125 | pub rise: u8, 126 | pub hold: u8, 127 | pub fall: u8, 128 | /// fix this name 129 | pub between_pulses: u8, 130 | pub off: u8, 131 | } 132 | -------------------------------------------------------------------------------- /MQTT.md: -------------------------------------------------------------------------------- 1 | # MQTT Spec 2 | 3 | - `opensleep/` 4 | 5 | - `availability`: `string` ("online") 6 | 7 | - `device/` 8 | - `name`: `string` ("opensleep") 9 | - `version`: `string` 10 | - `label`: `string` (ex. "20500-0000-F00-00001234") 11 | 12 | - `state/` 13 | - `presence/`: Person Presense Detection 14 | - `any`: `bool` 15 | - `left`: `bool` 16 | - `right`: `bool` 17 | 18 | - `sensor/` Sensor Subsystem Info 19 | - `mode`: `DeviceMode` 20 | - `hwinfo`: `HardwareInfo` 21 | - `piezo_ok`: `bool` 22 | - `vibration_enabled`: `bool` 23 | - `bed_temp`: `[centidegrees_celcius; 6]` 24 | - `ambient_temp`: `centidegrees_celcius` 25 | - `humidity`: `u16` 26 | - `mcu_temp`: `centidegrees_celcius` 27 | 28 | - `frozen/`: Frozen Subsystem Info 29 | - `mode`: `DeviceMode` 30 | - `hwinfo`: `HardwareInfo` 31 | - `left_temp`: `centidegrees_celcius` (left side water temperature) 32 | - `right_temp`: `centidegrees_celcius` 33 | - `heatsink_temp`: `centidegrees_celcius` 34 | - `left_target_temp`: `centidegrees_celcius`|`disabled` (target left side water temperature) 35 | - `right_target_temp`: `centidegrees_celcius`|`disabled` 36 | 37 | - `config/`: Published config from `config.ron`. Modifications will be saved back to `config.ron`. 38 | - `timezone`: `string` 39 | - `away_mode`: `bool` 40 | - `prime`: `time` 41 | - `led/` 42 | - `idle`: `LedPattern` 43 | - `active`: `LedPattern` 44 | - `band`: `CurrentBand` 45 | - `profile/` 46 | - `type`: `string` ("couples" or "solo") 47 | - `left/`, `right/` (solo mode only publishes to `left/`) 48 | - `sleep`: `time` 49 | - `wake`: `time` 50 | - `temperatures`: `Vec` 51 | - `alarm/`: `AlarmConfig` 52 | - `presence/` 53 | - `baselines`: `[u16; 6]` 54 | - `threshold`: `u16` 55 | - `debounce_count`: `u8` 56 | 57 | - `actions/` NOTE any changes to config here will be saved back to the `config.ron` file. 58 | - `calibrate`: triggers presence calibration, do not sit on the bed during this time 59 | - `set_away_mode` (`bool`): sets away mode config 60 | - `set_prime` (`time`): sets time to prime 61 | - `set_profile` (`TARGET.FIELD=VALUE`) 62 | - `TARGET` must be `left` or `right` for couples mode or `both` for solo 63 | - `FIELD` is one of `sleep`, `wake`, `temperatures`, `alarm` 64 | - Ex: `left.sleep=20:30` 65 | - `set_presence_config` (`FIELD=VALUE`) 66 | - `FIELD` must be one of `baselines`, `threshold`, `debounce_count` 67 | - Ex: `threshold=50` 68 | 69 | - `result/` 70 | - `action`: `string` (ex "set_away_mode") 71 | - `status`: `string` ("success" or "error") 72 | - `message`: `string` 73 | 74 | ## Types 75 | `time` is a zero-padding 24-hour time string. For example: 76 | - `12:00`, `06:00` valid 77 | - `6:00`, `5:00 PM`, `5:00pm` invalid 78 | 79 | `Vec` is a comma separated list. For example: `111,146,160,185,192,209` 80 | 81 | `[T; N]` is a fixed-size comma separated list. 82 | 83 | `LedPattern` is a string representation of the Rust enum: 84 | - `Fixed( 0, 255, 0, )` 85 | - `SlowBreath( 255, 0, 0, )` 86 | - `FastBreath( 255, 0, 0, )` 87 | - See code for more patterns 88 | 89 | `CurrentBand`: 90 | - `One`: 0mA\~10mA, Imax=10mA 91 | - `Two`: 0mA\~20mA, Imax=20mA 92 | - `Three`: 0mA\~30mA, Imax=30mA 93 | - `Four`: 0mA\~40mA, Imax=40mA 94 | 95 | `AlarmConfig` may be "disabled" or a comma-separated list of config, where `PATTERN,INTENSITY,DURATION,OFFSET`. For example: 96 | - `double,80,600,0` 97 | - `single,20,600,0` 98 | 99 | `centidegrees_celcius` a u16 representing a temperature in centidegrees celcius IE `deg C * 100` 100 | 101 | `celcius` an f32 representing a temperature in degrees celcius 102 | 103 | `DeviceMode` one of `Unknown`, `Bootloader`, `Firmware`. `Firmware` means the device is initialized and working properly. 104 | 105 | 106 | `HardwareInfo`: ex. `SN 000157e2 PN 20500 SKU 2 HWREV 0502 FACTORYFLAG 1 DATECODE 16070c` 107 | -------------------------------------------------------------------------------- /src/common/codec.rs: -------------------------------------------------------------------------------- 1 | use super::{checksum, packet::Packet}; 2 | use bytes::{Buf, BufMut, BytesMut}; 3 | use std::marker::PhantomData; 4 | use tokio_util::codec::{Decoder, Encoder}; 5 | 6 | pub const START: u8 = 0x7E; 7 | 8 | pub struct PacketCodec { 9 | _phantom: PhantomData

, 10 | } 11 | 12 | impl PacketCodec

{ 13 | pub fn new() -> Self { 14 | Self { 15 | _phantom: PhantomData, 16 | } 17 | } 18 | } 19 | 20 | impl Default for PacketCodec

{ 21 | fn default() -> Self { 22 | Self::new() 23 | } 24 | } 25 | 26 | impl Decoder for PacketCodec

{ 27 | type Item = P; 28 | type Error = std::io::Error; 29 | 30 | fn decode(&mut self, src: &mut BytesMut) -> Result, Self::Error> { 31 | loop { 32 | let start_pos = memchr::memchr(START, src); 33 | 34 | match start_pos { 35 | Some(pos) => { 36 | // skip bytes until pos 37 | if pos > 0 { 38 | src.advance(pos); 39 | } 40 | 41 | if src.len() < 2 { 42 | return Ok(None); // need more data 43 | } 44 | 45 | let len = src[1] as usize; 46 | let total_packet_size = 1 + 1 + len + 2; // start + len + payload + checksum 47 | 48 | if src.len() < total_packet_size { 49 | return Ok(None); // need more data 50 | } 51 | 52 | // get payload 53 | let payload_start = 2; 54 | let payload_end = 2 + len; 55 | let payload = &src[payload_start..payload_end]; 56 | if payload.is_empty() { 57 | log::error!("Empty packet"); 58 | src.advance(1); 59 | continue; 60 | } 61 | 62 | // validate checksum wo/ consuming bytes 63 | let checksum_bytes = &src[payload_end..payload_end + 2]; 64 | let actual_checksum = 65 | u16::from_be_bytes([checksum_bytes[0], checksum_bytes[1]]); 66 | let expected_checksum = checksum::compute(payload); 67 | 68 | if actual_checksum != expected_checksum { 69 | // bad checksum -> skip only start byte and try again 70 | src.advance(1); 71 | continue; 72 | } 73 | 74 | // checksum is valid -> try to parse packet 75 | src.advance(2); // skip start & len 76 | let payload = src.split_to(len); // take payload out 77 | src.advance(2); // skip checksum 78 | 79 | match P::parse(payload) { 80 | Ok(packet) => { 81 | // consume valid packets 82 | return Ok(Some(packet)); 83 | } 84 | Err(e) => { 85 | log::error!("{e}"); 86 | continue; 87 | } 88 | } 89 | } 90 | None => { 91 | // no start byte found -> clear buffer 92 | src.clear(); 93 | return Ok(None); 94 | } 95 | } 96 | } 97 | } 98 | } 99 | 100 | pub fn command(mut payload: Vec) -> Vec { 101 | let mut res = Vec::with_capacity(payload.len() + 4); 102 | let checksum = checksum::compute(&payload); 103 | res.push(START); 104 | res.push(payload.len() as u8); 105 | res.append(&mut payload); 106 | res.push((checksum >> 8) as u8); 107 | res.push(checksum as u8); 108 | res 109 | } 110 | 111 | pub trait CommandTrait { 112 | fn to_bytes(&self) -> Vec; 113 | } 114 | 115 | impl Encoder for PacketCodec

{ 116 | type Error = std::io::Error; 117 | 118 | fn encode(&mut self, item: C, dst: &mut BytesMut) -> Result<(), Self::Error> { 119 | dst.put_slice(&item.to_bytes()); 120 | Ok(()) 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/frozen/command.rs: -------------------------------------------------------------------------------- 1 | use strum_macros::{AsRefStr, Display, IntoStaticStr}; 2 | 3 | use crate::{ 4 | common::{ 5 | codec::{CommandTrait, command}, 6 | packet::BedSide, 7 | }, 8 | frozen::packet::FrozenTarget, 9 | }; 10 | 11 | #[derive(Debug, Clone, Display, AsRefStr, IntoStaticStr)] 12 | pub enum FrozenCommand { 13 | Ping, 14 | GetHardwareInfo, 15 | #[allow(dead_code)] 16 | GetFirmware, 17 | JumpToFirmware, 18 | #[allow(dead_code)] 19 | Prime, 20 | #[allow(dead_code)] 21 | /// call every 10 seconds 22 | SetTargetTemperature { 23 | side: BedSide, 24 | tar: FrozenTarget, 25 | }, 26 | GetTemperatures, 27 | Random(u8), 28 | } 29 | 30 | impl CommandTrait for FrozenCommand { 31 | fn to_bytes(&self) -> Vec { 32 | use FrozenCommand::*; 33 | match self { 34 | // 0x05 is sometimes the first command at boot unclear purpose 35 | Ping => command(vec![0x01]), 36 | GetHardwareInfo => command(vec![0x02]), 37 | GetFirmware => command(vec![0x04]), 38 | JumpToFirmware => command(vec![0x10]), 39 | GetTemperatures => command(vec![0x41]), 40 | 41 | /* 42 | 43 | After sending 0x50 command, we get back: 44 | 45 | Response In Test #1: 46 | D0 00 47 | 28 FF C2 E9 A5 21 56 F3 07 FB 48 | 28 FF 3A CF 23 22 31 12 09 34 49 | 28 FF CE 0B 2C E2 23 56 0A 0F 50 | 28 FF 07 E5 2C E2 20 39 0A 0F 51 | 52 | Response In Test #2: 53 | D0 00 54 | 28 FF C2 E9 A5 21 56 F3 08 08 55 | 28 FF 3A CF 23 22 31 12 09 3A 56 | 28 FF CE 0B 2C E2 23 56 0A 15 57 | 28 FF 07 E5 2C E2 20 39 0A 15 58 | 59 | 60 | <- GOT 0x50 RESPONSE SHOWN ABOVE #2 -> 61 | Temperature update - Left: 2581, Right: 2581, Heatsink: 2362, Error: 8 62 | Message: FW: pid[heatsink] 3.062500 0.693750 0.693750 0.000000 0.000000 63 | Message: FW: pump[left] slow @ 6.030475V 0.169202A 64 | Message: FW: pump[right] slow @ 6.044009V 0.161510A 65 | Message: FW: pid[left] 25.812500 0.090498 -0.003750 0.094248 0.000000 66 | Message: FW: pid[right] 25.812500 0.094561 -0.003750 0.098311 0.000000 67 | 68 | */ 69 | 70 | /* 71 | 0x51 -> D1 00 (Flash/calibration status?) 72 | 73 | UTF-8 decode error: invalid utf-8 sequence of 1 bytes from index 16 74 | Message: FW: flash locked 75 | Message: FW: cal_info valid 76 | 77 | */ 78 | Prime => command(vec![0x52]), 79 | Random(cmd) => command(vec![*cmd]), 80 | SetTargetTemperature { side, tar } => command(vec![ 81 | 0x40, 82 | *side as u8, 83 | tar.enabled as u8, 84 | (tar.temp >> 8) as u8, 85 | tar.temp as u8, 86 | ]), 87 | } 88 | } 89 | } 90 | 91 | #[cfg(test)] 92 | mod tests { 93 | use super::*; 94 | use crate::common::codec::CommandTrait; 95 | use hex_literal::hex; 96 | 97 | #[test] 98 | fn test_ping() { 99 | assert_eq!( 100 | FrozenCommand::Ping.to_bytes(), 101 | hex!("7E 01 01 DC BD").to_vec() 102 | ); 103 | } 104 | 105 | #[test] 106 | fn test_gethardwareinfo() { 107 | assert_eq!( 108 | FrozenCommand::GetHardwareInfo.to_bytes(), 109 | hex!("7E 01 02 EC DE").to_vec() 110 | ); 111 | } 112 | 113 | #[test] 114 | fn test_getfirmware() { 115 | assert_eq!( 116 | FrozenCommand::GetFirmware.to_bytes(), 117 | hex!("7E 01 04 8C 18").to_vec() 118 | ); 119 | } 120 | 121 | #[test] 122 | fn test_jumptofirmware() { 123 | assert_eq!( 124 | FrozenCommand::JumpToFirmware.to_bytes(), 125 | hex!("7E 01 10 DE AD").to_vec() 126 | ); 127 | } 128 | 129 | #[test] 130 | fn test_prime() { 131 | assert_eq!( 132 | FrozenCommand::Prime.to_bytes(), 133 | hex!("7E 01 52 b6 2b").to_vec() 134 | ); 135 | } 136 | 137 | #[test] 138 | fn test_temp() { 139 | let cmd = FrozenCommand::SetTargetTemperature { 140 | side: BedSide::Left, 141 | tar: FrozenTarget { 142 | enabled: true, 143 | temp: 3600, 144 | }, 145 | }; 146 | assert_eq!(cmd.to_bytes(), hex!("7E 05 40 00 01 0E 10 E6 A8").to_vec()); 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # opensleep 2 | 3 | Open-source Rust firmware for the Eight Sleep Pod 3 that completely replaces Eight Sleep's proprietary software stack. 4 | 5 | ## Disclaimer 6 | 7 | This project is for educational and research purposes only. It is for personal, non-commercial use and is not affiliated with, endorsed by, or sponsored by Eight Sleep. The Eight Sleep name and Pod are trademarks of Eight Sleep, Inc. 8 | 9 | Using opensleep will prevent the Eight Sleep mobile app from working and may (though unlikely) permanently alter or damage your device. **Use at your own risk.** 10 | 11 | ## What is Eight Sleep? 12 | 13 | The Eight Sleep Pod 3 is a smart mattress cover that uses water circulation to control temperature (55°F to 110°F) and tracks sleep metrics. While powerful, Eight Sleep's default firmware streams all sleep data to their servers, including when you're in bed, how many people are in bed, and your sleep patterns. 14 | 15 | ## Why Replace the Firmware? 16 | 17 | 1. **Complete Privacy**: All data stays local. 18 | 2. **Smart Home Integration**: Direct control through Home Assistant, MQTT, or custom automations instead of being locked to Eight Sleep's app. 19 | 3. **Enhanced Features**: Custom temperature profiles, alarms, etc. 20 | 21 | ## Features 22 | 23 | 1. **MQTT** interface for remote configuration and state monitoring 24 | 2. Configuration via **[Ron](https://github.com/ron-rs/ron)** file 25 | 3. Presence detection 26 | 4. Custom temperature profiles with unlimited points, automatically distributed between `sleep` and `wake` times 27 | 5. Vibration alarms relative to `wake` time (offsets and vibration settings are configurable) 28 | 6. `Solo` or `Couples` modes 29 | 7. LED control with custom effects 30 | 8. Daily priming 31 | 32 | ## How This Differs from Other Projects 33 | 34 | Other projects like [ninesleep](https://github.com/bobobo1618/ninesleep) and [freesleep](https://github.com/throwaway31265/free-sleep) work by replacing the Device-API-Client (DAC). 35 | By replacing the DAC, they can communicate directly with frankenfirmware and send temperature commands, 36 | receive sleep tracking data, set alarms, etc. 37 | 38 | **opensleep replaces all Eight Sleep programs**, communicating directly with the microcontrollers that manage sensors and temperature control. This enables real-time sensor data access and complete control over the Pod's behavior. 39 | 40 | ![Diagram showing opensleep architecture](images/main.svg) 41 | 42 | ## Technical Overview 43 | 44 | The Eight Sleep Pod 3 consists of: 45 | 46 | - **SOM** (System-On-Module): A Variscite i.MX 8M Mini running Yocto Linux, acting as the master controller 47 | - **Sensor Subsystem**: STM32 microcontroller managing temperature sensors, capacitance sensors (for presence), piezoelectric sensors, and vibration motors 48 | - **Frozen Subsystem**: STM32 microcontroller managing thermoelectric coolers, water pumps, and priming components 49 | 50 | Eight Sleep's stock firmware runs three main programs on the SOM: 51 | - **DAC** (Device-API-Client): Communicates with Eight Sleep's servers 52 | - **Frank** (frankenfirmware): Controls both subsystems via USART 53 | - **Capybara**: Manages LEDs and Bluetooth setup 54 | 55 | opensleep replaces all three programs, communicating directly with the Sensor and Frozen subsystems via their USART protocols. 56 | 57 | For detailed technical information, see [BACKGROUND.md](BACKGROUND.md). 58 | 59 | ## Setup 60 | 61 | Setting up opensleep requires SSH access to the Pod's SOM, which involves hardware modification. This is **not trivial** and requires technical expertise. Some Pod variants require specialized tools. 62 | 63 | **Compatibility:** 64 | - ✅ **Pod 3**: Fully supported (the only version tested with opensleep) 65 | - ⚠️ **Pod 4/5**: Untested. SSH setup possible but Pod-specific features not implemented. If you'd like to help add support, please contact me. 66 | - ❌ **Pod 1/2**: Not possible 67 | 68 | See detailed setup instructions in [SETUP.md](SETUP.md). 69 | 70 | ## MQTT Integration 71 | 72 | opensleep exposes full control and monitoring through MQTT. You can: 73 | - Monitor presence, temperature, and sensor data in real-time 74 | - Adjust temperature profiles, alarms, and settings 75 | - Trigger actions like calibration (for presence detection) 76 | 77 | See the complete MQTT specification in [MQTT.md](MQTT.md). 78 | 79 | ## Home Assistant 80 | 81 | opensleep integrates cleanly with Home Assistant via MQTT. You can build custom dashboards and automations. 82 | 83 | See Home Assistant configuration examples in [HASS.md](HASS.md). 84 | 85 | ![Home Assistant Dashboard](images/hass.png) 86 | 87 | ## Roadmap 88 | 89 | - [ ] Use Sensor subsystem bed temperature readings to improve Frozen temperature control 90 | - [ ] Sleep Tracking: Heart rate, HRV, and breathing rate analysis 91 | - [ ] Advanced LED patterns using direct current level control 92 | 93 | ## Development 94 | 95 | Run in debug mode: 96 | ```bash 97 | RUST_LOG=debug,rumqttc=info ./opensleep 98 | ``` 99 | 100 | (This prevents rumqttc from spamming logs) 101 | 102 | ## Contact 103 | 104 | If you encounter issues, please open an issue on this repository. For other inquiries, contact me at [mail@liamsnow.com](mailto:mail@liamsnow.com). 105 | 106 | -------------------------------------------------------------------------------- /src/config/mod.rs: -------------------------------------------------------------------------------- 1 | use jiff::{civil::Time, tz::TimeZone}; 2 | use ron::extensions::Extensions; 3 | use serde::{Deserialize, Deserializer, Serialize, Serializer}; 4 | use thiserror::Error; 5 | use tokio::fs; 6 | 7 | use crate::common::packet::BedSide; 8 | use crate::led::{CurrentBand, LedPattern}; 9 | use crate::sensor::command::AlarmPattern; 10 | 11 | pub mod mqtt; 12 | #[cfg(test)] 13 | mod tests; 14 | 15 | const CONFIG_FILE: &str = "config.ron"; 16 | 17 | #[derive(Debug, Error)] 18 | pub enum ConfigError { 19 | #[error("Failed to read config file: {0}")] 20 | Io(#[from] std::io::Error), 21 | #[error("Failed to parse RON: {0}")] 22 | Ron(#[from] ron::error::SpannedError), 23 | } 24 | 25 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 26 | pub struct LEDConfig { 27 | pub idle: LedPattern, 28 | pub active: LedPattern, 29 | pub band: CurrentBand, 30 | } 31 | 32 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 33 | pub struct MqttConfig { 34 | pub server: String, 35 | pub port: u16, 36 | pub user: String, 37 | pub password: String, 38 | } 39 | 40 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 41 | pub struct AlarmConfig { 42 | pub pattern: AlarmPattern, 43 | pub intensity: u8, 44 | /// duration in seconds (TODO plz verify) 45 | pub duration: u32, 46 | pub offset: u32, 47 | } 48 | 49 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 50 | pub struct PresenceConfig { 51 | pub baselines: [u16; 6], 52 | pub threshold: u16, 53 | pub debounce_count: u8, 54 | } 55 | 56 | fn time_de<'de, D: Deserializer<'de>>(deserializer: D) -> Result { 57 | let s = String::deserialize(deserializer)?; 58 | Time::strptime("%H:%M", &s).map_err(serde::de::Error::custom) 59 | } 60 | 61 | fn time_ser(time: &Time, serializer: S) -> Result { 62 | serializer.serialize_str(&time.strftime("%H:%M").to_string()) 63 | } 64 | 65 | fn timezone_de<'de, D: Deserializer<'de>>(deserializer: D) -> Result { 66 | let tzname = String::deserialize(deserializer)?; 67 | TimeZone::get(&tzname).map_err(serde::de::Error::custom) 68 | } 69 | 70 | fn timezone_ser(tz: &TimeZone, serializer: S) -> Result { 71 | serializer.serialize_str(tz.iana_name().unwrap()) 72 | } 73 | 74 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 75 | pub struct SideConfig { 76 | /// degrees celcius 77 | pub temperatures: Vec, 78 | #[serde(deserialize_with = "time_de", serialize_with = "time_ser")] 79 | pub sleep: Time, 80 | #[serde(deserialize_with = "time_de", serialize_with = "time_ser")] 81 | pub wake: Time, 82 | #[serde(default, skip_serializing_if = "Option::is_none")] 83 | pub alarm: Option, 84 | } 85 | 86 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 87 | pub enum SidesConfig { 88 | Solo(SideConfig), 89 | Couples { left: SideConfig, right: SideConfig }, 90 | } 91 | 92 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 93 | pub struct Config { 94 | #[serde(deserialize_with = "timezone_de", serialize_with = "timezone_ser")] 95 | pub timezone: TimeZone, 96 | pub away_mode: bool, 97 | #[serde(deserialize_with = "time_de", serialize_with = "time_ser")] 98 | pub prime: Time, 99 | pub led: LEDConfig, 100 | pub mqtt: MqttConfig, 101 | pub profile: SidesConfig, 102 | #[serde(default, skip_serializing_if = "Option::is_none")] 103 | pub presence: Option, 104 | } 105 | 106 | impl Config { 107 | pub async fn load(path: &str) -> Result { 108 | let content = fs::read_to_string(path).await?; 109 | let opts = ron::Options::default().with_default_extension(Extensions::IMPLICIT_SOME); 110 | let config = opts.from_str(&content)?; 111 | Ok(config) 112 | } 113 | 114 | pub async fn save(&self, path: &str) -> Result<(), ConfigError> { 115 | let content = ron::ser::to_string_pretty(self, ron::ser::PrettyConfig::default()) 116 | .map_err(|e| ConfigError::Io(std::io::Error::other(e)))?; 117 | fs::write(path, content).await?; 118 | Ok(()) 119 | } 120 | } 121 | 122 | impl SidesConfig { 123 | pub fn get_side(&self, side: &BedSide) -> &SideConfig { 124 | match self { 125 | SidesConfig::Solo(cfg) => cfg, 126 | SidesConfig::Couples { left, right } => match side { 127 | BedSide::Left => left, 128 | BedSide::Right => right, 129 | }, 130 | } 131 | } 132 | 133 | pub fn is_solo(&self) -> bool { 134 | matches!(self, SidesConfig::Solo(_)) 135 | } 136 | 137 | pub fn is_couples(&self) -> bool { 138 | matches!(self, SidesConfig::Couples { .. }) 139 | } 140 | 141 | pub fn unwrap_solo_mut(&mut self) -> &mut SideConfig { 142 | match self { 143 | SidesConfig::Solo(c) => c, 144 | SidesConfig::Couples { left: _, right: _ } => panic!(), 145 | } 146 | } 147 | 148 | pub fn unwrap_left_mut(&mut self) -> &mut SideConfig { 149 | match self { 150 | SidesConfig::Solo(_) => panic!(), 151 | SidesConfig::Couples { left, right: _ } => left, 152 | } 153 | } 154 | 155 | pub fn unwrap_right_mut(&mut self) -> &mut SideConfig { 156 | match self { 157 | SidesConfig::Solo(_) => panic!(), 158 | SidesConfig::Couples { left: _, right } => right, 159 | } 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /src/frozen/profile.rs: -------------------------------------------------------------------------------- 1 | use jiff::{SignedDuration, Timestamp, civil::Time, tz::TimeZone}; 2 | 3 | use crate::{ 4 | common::packet::BedSide, 5 | config::{SideConfig, SidesConfig}, 6 | frozen::packet::FrozenTarget, 7 | }; 8 | 9 | impl FrozenTarget { 10 | pub fn calc_wanted( 11 | timezone: &TimeZone, 12 | away_mode: &bool, 13 | side_config: &SidesConfig, 14 | side: &BedSide, 15 | ) -> Self { 16 | if *away_mode { 17 | // disabled 18 | return FrozenTarget::default(); 19 | } 20 | 21 | let now = Timestamp::now().to_zoned(timezone.clone()).time(); 22 | 23 | side_config.get_side(side).calc_target(now) 24 | } 25 | } 26 | 27 | impl SideConfig { 28 | fn calc_target(&self, now: Time) -> FrozenTarget { 29 | if !self.temperatures.is_empty() 30 | && let Some(t) = self.calc_progress(now) 31 | { 32 | return FrozenTarget { 33 | enabled: true, 34 | // NOTE: also converts celcius -> centideg celcius 35 | temp: self.lerp(t), 36 | }; 37 | } 38 | 39 | // disabled 40 | FrozenTarget::default() 41 | } 42 | 43 | /// Finds the current progress into the profile (0-1) 44 | /// Returns None if not in the profile 45 | fn calc_progress(&self, now: Time) -> Option { 46 | let profile_duration = forward_duration(self.sleep, self.wake); 47 | let relative_progress = forward_duration(self.sleep, now); 48 | if relative_progress > profile_duration { 49 | None 50 | } else { 51 | Some(relative_progress.div_duration_f32(profile_duration)) 52 | } 53 | } 54 | 55 | /// Linearly interpolates temperature profile with `t` from 0.-1. 56 | /// `profile` is in degrees celcius, return value is centidegrees celcius 57 | #[inline] 58 | fn lerp(&self, t: f32) -> u16 { 59 | assert!( 60 | !self.temperatures.is_empty(), 61 | "lerp called with empty `self.temperatures`!" 62 | ); 63 | 64 | assert!((0.0..=1.0).contains(&t), "lerp called with invalid `t`!"); 65 | 66 | let len = self.temperatures.len(); 67 | if len == 1 { 68 | return (self.temperatures[0] * 100.0) as u16; 69 | } 70 | 71 | let pos = t * (len - 1) as f32; 72 | let lo_idx = pos as usize; 73 | let lo_val = self.temperatures[lo_idx]; 74 | 75 | if lo_idx == len - 1 { 76 | // last el 77 | (lo_val * 100.0) as u16 78 | } else { 79 | let hi_val = self.temperatures[lo_idx + 1]; 80 | let frac = pos - lo_idx as f32; 81 | (frac.mul_add((hi_val - lo_val) * 100.0, lo_val * 100.0)) as u16 82 | } 83 | } 84 | } 85 | 86 | /// Finds the duration between two civil times, forward from A 87 | /// Ex: 88 | /// 1. a=18:00, b=6:00 -> 12 hours 89 | /// 2. a=16:00, b=6:00 -> 14 hours 90 | /// 3. a=4:00, b=5:00 -> 1 hour 91 | fn forward_duration(a: Time, b: Time) -> SignedDuration { 92 | if b >= a { 93 | b.duration_until(a).abs() 94 | } else { 95 | let to_midnight = a.duration_until(Time::MAX); 96 | let from_midnight = Time::MIN.duration_until(b); 97 | to_midnight + from_midnight + SignedDuration::from_nanos(1) 98 | } 99 | } 100 | 101 | #[cfg(test)] 102 | mod tests { 103 | use jiff::civil::time; 104 | 105 | use super::*; 106 | 107 | #[test] 108 | fn test_lerp() { 109 | let prof = SideConfig { 110 | temperatures: vec![0.0, 10.0, 20.0], 111 | sleep: time(18, 0, 0, 0), 112 | wake: time(6, 0, 0, 0), 113 | alarm: None, 114 | }; 115 | 116 | assert_eq!(prof.lerp(0.0), 0); 117 | assert_eq!(prof.lerp(0.25), 500); 118 | assert_eq!(prof.lerp(0.5), 1000); 119 | assert_eq!(prof.lerp(0.75), 1500); 120 | assert_eq!(prof.lerp(1.0), 2000); 121 | } 122 | 123 | #[test] 124 | fn test_calc_profile_progress() { 125 | let prof = SideConfig { 126 | temperatures: vec![], 127 | sleep: time(18, 0, 0, 0), 128 | wake: time(6, 0, 0, 0), 129 | alarm: None, 130 | }; 131 | 132 | assert_eq!(prof.calc_progress(time(17, 0, 0, 0)), None); 133 | assert_eq!(prof.calc_progress(time(18, 0, 0, 0)), Some(0.0)); 134 | assert_eq!(prof.calc_progress(time(21, 0, 0, 0)), Some(0.25)); 135 | assert_eq!(prof.calc_progress(time(0, 0, 0, 0)), Some(0.5)); 136 | assert_eq!(prof.calc_progress(time(3, 0, 0, 0)), Some(0.75)); 137 | assert_eq!(prof.calc_progress(time(6, 0, 0, 0)), Some(1.00)); 138 | assert_eq!(prof.calc_progress(time(7, 0, 0, 0)), None); 139 | } 140 | 141 | #[test] 142 | fn test_normalize() { 143 | let sleep_time = time(18, 0, 0, 0); 144 | let wake_time = time(6, 0, 0, 0); 145 | let now = Time::midnight(); 146 | 147 | let wake_time_norm = Time::midnight() + forward_duration(sleep_time, wake_time); 148 | let now_norm = Time::midnight() + forward_duration(sleep_time, now); 149 | 150 | assert_eq!(wake_time_norm, time(12, 0, 0, 0)); 151 | assert_eq!(now_norm, time(6, 0, 0, 0)); 152 | } 153 | 154 | #[test] 155 | fn test_forward_duration() { 156 | assert_eq!( 157 | forward_duration(time(18, 0, 0, 0), time(6, 0, 0, 0)), 158 | SignedDuration::from_hours(12) 159 | ); 160 | assert_eq!( 161 | forward_duration(time(16, 0, 0, 0), time(6, 0, 0, 0)), 162 | SignedDuration::from_hours(14) 163 | ); 164 | assert_eq!( 165 | forward_duration(time(4, 0, 0, 0), time(5, 0, 0, 0)), 166 | SignedDuration::from_hours(1) 167 | ); 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /src/sensor/presence.rs: -------------------------------------------------------------------------------- 1 | use crate::config::{Config, PresenceConfig}; 2 | use crate::mqtt::publish_high_freq; 3 | use crate::sensor::packet::CapacitanceData; 4 | use rumqttc::AsyncClient; 5 | use std::time::{Duration, Instant}; 6 | use tokio::sync::watch; 7 | 8 | const DEFAULT_THRESHOLD: u16 = 50; 9 | const DEFAULT_DEBOUNCE: u8 = 5; 10 | const CALIBRATION_DURATION: Duration = Duration::from_secs(10); 11 | 12 | const TOPIC_ANY: &str = "opensleep/state/presence/any"; 13 | const TOPIC_LEFT: &str = "opensleep/state/presence/left"; 14 | const TOPIC_RIGHT: &str = "opensleep/state/presence/right"; 15 | pub const TOPIC_CALIBRATE: &str = "opensleep/actions/calibrate"; 16 | 17 | #[derive(Debug, Clone, PartialEq, Default)] 18 | pub struct PresenceState { 19 | pub any: bool, 20 | pub left: bool, 21 | pub right: bool, 22 | } 23 | 24 | pub struct PresenseManager { 25 | config_tx: watch::Sender, 26 | config_rx: watch::Receiver, 27 | config: Option, 28 | client: AsyncClient, 29 | calibration_end: Option, 30 | calibration_samples: Vec<[u16; 6]>, 31 | debounce: [u8; 6], 32 | last_state: Option, 33 | } 34 | 35 | impl PresenseManager { 36 | pub fn new( 37 | config_tx: watch::Sender, 38 | config_rx: watch::Receiver, 39 | client: AsyncClient, 40 | ) -> Self { 41 | PresenseManager { 42 | config: { 43 | let b = config_rx.borrow(); 44 | if b.presence.is_none() { 45 | log::warn!( 46 | "No presence config found. Please calibrate using 'opensleep/command/calibrate' endpoint." 47 | ); 48 | } 49 | b.presence.as_ref().cloned() 50 | }, 51 | config_tx, 52 | config_rx, 53 | client, 54 | calibration_end: None, 55 | calibration_samples: Vec::new(), 56 | debounce: [0u8; 6], 57 | last_state: None, 58 | } 59 | } 60 | 61 | pub fn update(&mut self, data: &CapacitanceData) { 62 | if self.config.is_some() { 63 | self.update_presence(data); 64 | } 65 | 66 | if self.calibration_end.is_some() { 67 | self.update_calibration(data); 68 | } 69 | } 70 | 71 | fn update_presence(&mut self, data: &CapacitanceData) { 72 | let config = self.config.as_mut().unwrap(); 73 | 74 | for i in 0..6 { 75 | if data.values[i] > config.baselines[i] + config.threshold { 76 | self.debounce[i] = self.debounce[i].saturating_add(1); 77 | } else { 78 | self.debounce[i] = 0; 79 | } 80 | } 81 | 82 | let left_present = self.debounce[0..3] 83 | .iter() 84 | .any(|&c| c >= config.debounce_count); 85 | let right_present = self.debounce[3..6] 86 | .iter() 87 | .any(|&c| c >= config.debounce_count); 88 | 89 | let state = PresenceState { 90 | any: left_present || right_present, 91 | left: left_present, 92 | right: right_present, 93 | }; 94 | 95 | if self.last_state.as_ref() != Some(&state) { 96 | self.update_mqtt(&state); 97 | self.last_state = Some(state); 98 | } 99 | } 100 | 101 | fn update_mqtt(&mut self, state: &PresenceState) { 102 | publish_high_freq(&mut self.client, TOPIC_ANY, state.any.to_string()); 103 | publish_high_freq(&mut self.client, TOPIC_LEFT, state.left.to_string()); 104 | publish_high_freq(&mut self.client, TOPIC_RIGHT, state.right.to_string()); 105 | } 106 | 107 | pub fn start_calibration(&mut self) { 108 | log::info!("Running calibration for {}", CALIBRATION_DURATION.as_secs()); 109 | self.calibration_end = Some(Instant::now() + CALIBRATION_DURATION); 110 | self.calibration_samples = vec![]; 111 | } 112 | 113 | fn update_calibration(&mut self, data: &CapacitanceData) { 114 | self.calibration_samples.push(data.values); 115 | 116 | if Instant::now() > self.calibration_end.unwrap() { 117 | self.calibration_end = None; 118 | 119 | if self.calibration_samples.is_empty() { 120 | log::error!("Calibration failed, no samples collected."); 121 | return; 122 | } 123 | 124 | log::info!("Calibration finished. Updating config.."); 125 | 126 | let baselines = Self::calculate_baselines(&self.calibration_samples); 127 | let new_cfg = PresenceConfig { 128 | baselines, 129 | threshold: DEFAULT_THRESHOLD, 130 | debounce_count: DEFAULT_DEBOUNCE, 131 | }; 132 | 133 | // reset 134 | self.calibration_samples = vec![]; 135 | self.calibration_end = None; 136 | 137 | // update our config 138 | self.config = Some(new_cfg.clone()); 139 | 140 | // update config file 141 | let mut config = self.config_rx.borrow_and_update().clone(); 142 | config.presence = Some(new_cfg.clone()); 143 | if let Err(e) = self.config_tx.send(config) { 144 | log::error!("Failed to update config: {e}"); 145 | } else { 146 | log::info!("Config updated: {baselines:?}"); 147 | } 148 | } 149 | } 150 | 151 | fn calculate_baselines(samples: &[[u16; 6]]) -> [u16; 6] { 152 | let mut sums = [0u32; 6]; 153 | for sample in samples { 154 | for (sum, &value) in sums.iter_mut().zip(sample) { 155 | *sum += value as u32; 156 | } 157 | } 158 | let count = samples.len() as u32; 159 | sums.map(|sum| (sum / count) as u16) 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /images/main.d2: -------------------------------------------------------------------------------- 1 | direction: down 2 | 3 | original: { 4 | cloud1: Eight Sleep Cloud { 5 | shape: cloud 6 | style.fill: "#e3f2fd" 7 | style.font-size: 20 8 | } 9 | 10 | app1: Mobile App { 11 | shape: rectangle 12 | style.fill: "#fff3e0" 13 | style.font-size: 18 14 | } 15 | 16 | main_unit1: Pod { 17 | style.fill: "#f5f5f5" 18 | style.stroke-width: 3 19 | style.font-size: 22 20 | 21 | som1: "Main Computer (SOM)" { 22 | style.fill: "#e8eaf6" 23 | style.font-size: 18 24 | 25 | dac1: "DAC\n(Receives commands)" { 26 | style.fill: "#c8e6c9" 27 | } 28 | 29 | frank1: "Frank\n(Controls everything)" { 30 | style.fill: "#bbdefb" 31 | } 32 | 33 | capybara1: "Capybara\n(Setup & LEDs)" { 34 | style.fill: "#ffe0b2" 35 | } 36 | } 37 | 38 | frozen1: "Frozen Subsystem" { 39 | style.fill: "#e0f7fa" 40 | style.font-size: 16 41 | 42 | water1: "Water Control" { 43 | shape: text 44 | style.font-size: 14 45 | label: |md 46 | - Heating/cooling (TECs) 47 | - 2x Water pumps 48 | - Tank management 49 | | 50 | } 51 | } 52 | } 53 | 54 | mattress1: "Mattress Cover" { 55 | style.fill: "#fce4ec" 56 | style.stroke-width: 3 57 | style.font-size: 22 58 | 59 | sensor1: "Sensor Unit/Subsystem" { 60 | style.fill: "#f8bbd0" 61 | style.font-size: 16 62 | 63 | sensors1: "Sensors" { 64 | shape: text 65 | style.font-size: 14 66 | label: |md 67 | - 8 temperature 68 | - 6 capacitance 69 | - 2 piezoelectric 70 | - Vibration motors 71 | | 72 | } 73 | } 74 | } 75 | 76 | app1 <-> cloud1: "Settings &\nSleep data" 77 | cloud1 -> main_unit1.som1.dac1: "Commands" 78 | main_unit1.som1.frank1 -> cloud1: "Sensor data" 79 | main_unit1.som1.dac1 -> main_unit1.som1.frank1: "Unix Socket" 80 | main_unit1.som1.frank1 <-> main_unit1.frozen1: "Temperature\ncontrol" 81 | main_unit1.som1.frank1 <-> mattress1.sensor1: "Sensor data &\nalarm control" 82 | main_unit1 -- mattress1: "USB Cable" 83 | } 84 | 85 | ninesleep: { 86 | lan2: LAN { 87 | style.fill: "#e3f2fd" 88 | style.font-size: 20 89 | } 90 | 91 | cloud2: Eight Sleep Cloud { 92 | shape: cloud 93 | style.fill: "#e3f2fd" 94 | style.font-size: 20 95 | } 96 | 97 | main_unit2: Pod { 98 | style.fill: "#f5f5f5" 99 | style.stroke-width: 3 100 | style.font-size: 22 101 | 102 | som2: "Main Computer (SOM)" { 103 | style.fill: "#e8eaf6" 104 | style.font-size: 18 105 | 106 | ninesleep2: "ninesleep" { 107 | style.fill: "#c8e6c9" 108 | } 109 | 110 | frank2: "Frank\n(Controls everything)" { 111 | style.fill: "#bbdefb" 112 | } 113 | 114 | capybara2: "Capybara\n(Setup & LEDs)" { 115 | style.fill: "#ffe0b2" 116 | } 117 | } 118 | 119 | frozen2: "Frozen Subsystem" { 120 | style.fill: "#e0f7fa" 121 | style.font-size: 16 122 | 123 | water2: "Water Control" { 124 | shape: text 125 | style.font-size: 14 126 | label: |md 127 | - Heating/cooling (TECs) 128 | - 2x Water pumps 129 | - Tank management 130 | | 131 | } 132 | } 133 | } 134 | 135 | mattress2: "Mattress Cover" { 136 | style.fill: "#fce4ec" 137 | style.stroke-width: 3 138 | style.font-size: 22 139 | 140 | sensor2: "Sensor Unit/Subsystem" { 141 | style.fill: "#f8bbd0" 142 | style.font-size: 16 143 | 144 | sensors2: "Sensors" { 145 | shape: text 146 | style.font-size: 14 147 | label: |md 148 | - 8 temperature 149 | - 6 capacitance 150 | - 2 piezoelectric 151 | - Vibration motors 152 | | 153 | } 154 | } 155 | } 156 | 157 | lan2 -> main_unit2.som2.ninesleep2: "Commands" 158 | main_unit2.som2.frank2 -> cloud2: "Sensor data" 159 | main_unit2.som2.ninesleep2 -> main_unit2.som2.frank2: "Unix Socket" 160 | main_unit2.som2.frank2 <-> main_unit2.frozen2: "Temperature\ncontrol" 161 | main_unit2.som2.frank2 <-> mattress2.sensor2: "Sensor data &\nalarm control" 162 | main_unit2 -- mattress2: "USB Cable" 163 | } 164 | 165 | opensleep: { 166 | broker3: MQTT Broker { 167 | shape: rectangle 168 | style.fill: "#e3f2fd" 169 | style.font-size: 20 170 | } 171 | 172 | main_unit3: Pod { 173 | style.fill: "#f5f5f5" 174 | style.stroke-width: 3 175 | style.font-size: 22 176 | 177 | som3: "Main Computer (SOM)" { 178 | style.fill: "#e8eaf6" 179 | style.font-size: 18 180 | 181 | opensleep3: "opensleep" { 182 | style.fill: "#bbdefb" 183 | } 184 | } 185 | 186 | frozen3: "Frozen Subsystem" { 187 | style.fill: "#e0f7fa" 188 | style.font-size: 16 189 | 190 | water3: "Water Control" { 191 | shape: text 192 | style.font-size: 14 193 | label: |md 194 | - Heating/cooling (TECs) 195 | - 2x Water pumps 196 | - Tank management 197 | | 198 | } 199 | } 200 | } 201 | 202 | mattress3: "Mattress Cover" { 203 | style.fill: "#fce4ec" 204 | style.stroke-width: 3 205 | style.font-size: 22 206 | 207 | sensor3: "Sensor Unit/Subsystem" { 208 | style.fill: "#f8bbd0" 209 | style.font-size: 16 210 | 211 | sensors3: "Sensors" { 212 | shape: text 213 | style.font-size: 14 214 | label: |md 215 | - 8 temperature 216 | - 6 capacitance 217 | - 2 piezoelectric 218 | - Vibration motors 219 | | 220 | } 221 | } 222 | } 223 | 224 | broker3 -> main_unit3.som3.opensleep3: "Config" 225 | main_unit3.som3.opensleep3 -> broker3: "Status" 226 | main_unit3.som3.opensleep3 <-> main_unit3.frozen3: "Temperature\ncontrol" 227 | main_unit3.som3.opensleep3 <-> mattress3.sensor3: "Sensor data &\nalarm control" 228 | main_unit3 -- mattress3: "USB Cable" 229 | } 230 | -------------------------------------------------------------------------------- /src/frozen/state.rs: -------------------------------------------------------------------------------- 1 | use rumqttc::AsyncClient; 2 | 3 | use crate::{ 4 | common::{ 5 | packet::{BedSide, HardwareInfo}, 6 | serial::DeviceMode, 7 | }, 8 | frozen::packet::{FrozenPacket, FrozenTarget, TemperatureUpdate}, 9 | mqtt::{publish_guaranteed_wait, publish_high_freq}, 10 | }; 11 | 12 | #[derive(Clone, Debug, Default, PartialEq)] 13 | pub struct FrozenState { 14 | pub device_mode: DeviceMode, 15 | pub temp: Option, 16 | pub left_target: Option, 17 | pub right_target: Option, 18 | pub hardware_info: Option, 19 | pub is_priming: bool, 20 | } 21 | 22 | const TOPIC_MODE: &str = "opensleep/state/frozen/mode"; 23 | const TOPIC_HWINFO: &str = "opensleep/state/frozen/hwinfo"; 24 | const TOPIC_LEFT_TEMP: &str = "opensleep/state/frozen/left_temp"; 25 | const TOPIC_RIGHT_TEMP: &str = "opensleep/state/frozen/right_temp"; 26 | const TOPIC_HEATSINK_TEMP: &str = "opensleep/state/frozen/heatsink_temp"; 27 | const TOPIC_LEFT_TARGET_TEMP: &str = "opensleep/state/frozen/left_target_temp"; 28 | const TOPIC_RIGHT_TARGET_TEMP: &str = "opensleep/state/frozen/right_target_temp"; 29 | 30 | impl FrozenState { 31 | pub fn is_awake(&self) -> bool { 32 | self.device_mode == DeviceMode::Firmware 33 | } 34 | 35 | pub async fn set_device_mode(&mut self, client: &mut AsyncClient, mode: DeviceMode) { 36 | let prev = self.device_mode; 37 | self.device_mode = mode; 38 | 39 | if prev != mode { 40 | log::info!("Device mode: {prev:?} -> {mode:?}"); 41 | publish_guaranteed_wait(client, TOPIC_MODE, false, mode.to_string()).await; 42 | } 43 | } 44 | 45 | pub fn is_active(&self) -> bool { 46 | self.left_target.as_ref().is_some_and(|t| t.enabled) 47 | || self.right_target.as_ref().is_some_and(|t| t.enabled) 48 | } 49 | 50 | pub async fn publish_reset(&self, client: &mut AsyncClient) { 51 | publish_guaranteed_wait(client, TOPIC_MODE, false, DeviceMode::Unknown.to_string()).await; 52 | } 53 | 54 | pub async fn handle_packet(&mut self, client: &mut AsyncClient, packet: FrozenPacket) { 55 | match packet { 56 | FrozenPacket::Pong(in_firmware) => { 57 | self.set_device_mode(client, DeviceMode::from_pong(in_firmware)) 58 | .await; 59 | } 60 | FrozenPacket::TemperatureUpdate(u) => { 61 | log::debug!( 62 | "Temperature update - Left: {:.1}, Right: {:.1}, Heatsink: {:.1}, Error: {}", 63 | u.left_temp, 64 | u.right_temp, 65 | u.heatsink_temp, 66 | u.error 67 | ); 68 | 69 | publish_high_freq(client, TOPIC_LEFT_TEMP, u.left_temp.to_string()); 70 | publish_high_freq(client, TOPIC_RIGHT_TEMP, u.right_temp.to_string()); 71 | publish_high_freq(client, TOPIC_HEATSINK_TEMP, u.heatsink_temp.to_string()); 72 | 73 | self.temp = Some(u); 74 | } 75 | FrozenPacket::TargetUpdate((side, u)) => { 76 | log::debug!( 77 | "Target update - Side: {:?}, Enabled: {}, Temp: {:.1}", 78 | side, 79 | u.enabled, 80 | u.temp 81 | ); 82 | let payload = match u.enabled { 83 | true => &u.temp.to_string(), 84 | false => "disabled", 85 | }; 86 | let topic = match side { 87 | BedSide::Left => { 88 | self.left_target = Some(u); 89 | TOPIC_LEFT_TARGET_TEMP 90 | } 91 | BedSide::Right => { 92 | self.right_target = Some(u); 93 | TOPIC_RIGHT_TARGET_TEMP 94 | } 95 | }; 96 | publish_high_freq(client, topic, payload); 97 | } 98 | FrozenPacket::HardwareInfo(info) => { 99 | log::info!("Hardware info: {info}"); 100 | publish_guaranteed_wait(client, TOPIC_HWINFO, true, info.to_string()).await; 101 | self.hardware_info = Some(info); 102 | } 103 | FrozenPacket::JumpingToFirmware(code) => { 104 | log::debug!("Jumping to firmware with code: 0x{code:02X}"); 105 | self.set_device_mode(client, DeviceMode::Firmware).await; 106 | } 107 | FrozenPacket::Message(msg) => { 108 | if msg == "FW: water empty -> full" { 109 | log::warn!("Water tank reinserted"); 110 | } else if msg == "FW: water full -> empty" { 111 | log::warn!("Water tank removed"); 112 | } else if let Some(stripped) = msg.strip_prefix("FW: [priming] ") { 113 | // done because empty 114 | // done 115 | // empty stage pause pumps for %u ms 116 | // empty phase (%u remaining; runtime %u ms) 117 | // empty stage finished w/ %u successful purge 118 | // purge phase 119 | // purge.fast (%u ms) 120 | // purge_fast stage purged? %u 121 | // start 122 | // %u consecutive failed purges; %u total failed 123 | // purge phase (%u iterations remaining) 124 | // purge phase complete. now final empty stage 125 | // purge.wait 126 | // purge.side (%s: %s) 127 | // purge.empty, both pumps at 12v 128 | log::info!("Priming Message: {stripped}"); 129 | 130 | match stripped { 131 | "done" | "done because empty" => self.is_priming = false, 132 | "start" => self.is_priming = true, 133 | _ => {} 134 | } 135 | } else { 136 | log::debug!("Message: {msg}") 137 | } 138 | } 139 | FrozenPacket::PrimingStarted => { 140 | log::info!("Priming started!"); 141 | } 142 | _ => {} 143 | } 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /src/sensor/command.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use strum_macros::{Display, EnumString, FromRepr}; 3 | 4 | use crate::common::{ 5 | codec::{CommandTrait, command}, 6 | packet::BedSide, 7 | }; 8 | 9 | #[derive(Debug, Clone, PartialEq, Eq)] 10 | pub enum SensorCommand { 11 | Ping, 12 | GetHardwareInfo, 13 | #[allow(dead_code)] 14 | GetFirmwareHash, 15 | JumpToFirmware, 16 | SetPiezoGain(u16, u16), 17 | #[allow(dead_code)] 18 | GetPiezoFreq, 19 | SetPiezoFreq(u32), 20 | EnablePiezo, 21 | // TODO add resp packet + 0x80 22 | #[allow(dead_code)] 23 | DisablePiezo, 24 | EnableVibration, 25 | #[allow(dead_code)] 26 | ProbeTemperature, 27 | SetAlarm(AlarmCommand), 28 | // TODO add resp packet + 0x80 29 | /// UNVERIFIED probably doesn't actually exist or requires some payload, seems to be crashing the mcu, or maybe its just a constant vibration mode idk 30 | #[allow(dead_code)] 31 | ClearAlarm, 32 | // TODO add resp packet + 0x80 33 | #[allow(dead_code)] 34 | GetHeaterOffset, 35 | #[allow(dead_code)] 36 | Random(Vec), 37 | } 38 | 39 | impl CommandTrait for SensorCommand { 40 | fn to_bytes(&self) -> Vec { 41 | use SensorCommand::*; 42 | match self { 43 | Ping => command(vec![0x01]), 44 | GetHardwareInfo => command(vec![0x02]), 45 | GetFirmwareHash => command(vec![0x04]), 46 | JumpToFirmware => command(vec![0x10]), 47 | GetPiezoFreq => command(vec![0x20]), 48 | SetPiezoFreq(freq) => command(vec![ 49 | 0x21, 50 | (*freq >> 24) as u8, 51 | (*freq >> 16) as u8, 52 | (*freq >> 8) as u8, 53 | *freq as u8, 54 | ]), 55 | EnablePiezo => command(vec![0x28]), 56 | DisablePiezo => command(vec![0x29]), 57 | SetPiezoGain(gain1, gain2) => command(vec![ 58 | 0x2B, 59 | (*gain1 >> 8) as u8, 60 | *gain1 as u8, 61 | (*gain2 >> 8) as u8, 62 | *gain2 as u8, 63 | ]), 64 | EnableVibration => command(vec![0x2E]), 65 | ProbeTemperature => command(vec![0x2F, 0xFF]), 66 | GetHeaterOffset => command(vec![0x2A]), 67 | Random(cmd) => command(cmd.clone()), 68 | SetAlarm(cmd) => { 69 | let payload = vec![ 70 | 0x2C, 71 | cmd.side as u8, 72 | cmd.intensity, 73 | cmd.pattern.clone() as u8, 74 | (cmd.duration >> 24) as u8, 75 | (cmd.duration >> 16) as u8, 76 | (cmd.duration >> 8) as u8, 77 | cmd.duration as u8, 78 | ]; 79 | command(payload) 80 | } 81 | ClearAlarm => command(vec![0x2D]), 82 | } 83 | } 84 | } 85 | 86 | #[derive(Debug, Clone, Serialize, Deserialize, Display, EnumString, FromRepr, PartialEq, Eq)] 87 | #[strum(serialize_all = "lowercase")] 88 | #[repr(u8)] 89 | pub enum AlarmPattern { 90 | Single = 0b00, 91 | Double = 0b01, 92 | Unkown1 = 0b10, 93 | Unkown2 = 0b11, 94 | // 0b100 breaks it 95 | // 0b101+ seems to work tho? 96 | } 97 | 98 | #[derive(Debug, Clone, PartialEq, Eq)] 99 | pub struct AlarmCommand { 100 | pub side: BedSide, 101 | pub intensity: u8, // percentage 0-100 102 | pub duration: u32, // seconds 103 | pub pattern: AlarmPattern, 104 | } 105 | 106 | impl AlarmCommand { 107 | #[allow(dead_code)] 108 | pub fn new(side: BedSide, intensity: u8, duration: u32, pattern: AlarmPattern) -> Self { 109 | Self { 110 | side, 111 | intensity, 112 | duration, 113 | pattern, 114 | } 115 | } 116 | } 117 | 118 | #[cfg(test)] 119 | mod tests { 120 | use super::*; 121 | use hex_literal::hex; 122 | 123 | #[test] 124 | fn test_sensor_commands() { 125 | assert_eq!( 126 | SensorCommand::Ping.to_bytes(), 127 | hex!("7E 01 01 DC BD").to_vec() 128 | ); 129 | assert_eq!( 130 | SensorCommand::GetHardwareInfo.to_bytes(), 131 | hex!("7E 01 02 EC DE").to_vec() 132 | ); 133 | assert_eq!( 134 | SensorCommand::GetFirmwareHash.to_bytes(), 135 | hex!("7E 01 04 8C 18").to_vec() 136 | ); 137 | assert_eq!( 138 | SensorCommand::JumpToFirmware.to_bytes(), 139 | hex!("7E 01 10 DE AD").to_vec() 140 | ); 141 | assert_eq!( 142 | SensorCommand::SetPiezoFreq(1000).to_bytes(), 143 | hex!("7E 05 21 00 00 03 E8 7A 5E").to_vec() 144 | ); 145 | assert_eq!( 146 | SensorCommand::EnablePiezo.to_bytes(), 147 | hex!("7E 01 28 69 F6").to_vec() 148 | ); 149 | assert_eq!( 150 | SensorCommand::SetPiezoGain(400, 400).to_bytes(), 151 | hex!("7E 05 2B 01 90 01 90 AB 80").to_vec() 152 | ); 153 | assert_eq!( 154 | SensorCommand::EnableVibration.to_bytes(), 155 | hex!("7E 01 2E 09 30").to_vec() 156 | ); 157 | assert_eq!( 158 | SensorCommand::ProbeTemperature.to_bytes(), 159 | hex!("7E 02 2F FF 8C E8").to_vec() 160 | ); 161 | } 162 | 163 | #[test] 164 | fn test_alarm_command() { 165 | // side, intensity, pattern, duration x4 166 | // 01 64 00 00 00 00 00 167 | // 01 64 00 00 00 00 14 168 | // 00 64 01 00 00 00 0c 169 | // 00 64 01 00 00 00 00 170 | // 01 32 00 00 00 00 00 171 | // 01 32 00 00 00 00 14 172 | // 01 64 01 00 00 00 00 173 | let alarm1 = AlarmCommand::new(BedSide::Right, 100, 20, AlarmPattern::Single); 174 | assert_eq!( 175 | SensorCommand::SetAlarm(alarm1).to_bytes(), 176 | hex!("7e 08 2c 01 64 00 00 00 00 14 38 8b").to_vec() 177 | ); 178 | 179 | let alarm2 = AlarmCommand::new(BedSide::Left, 50, 50, AlarmPattern::Single); 180 | assert_eq!( 181 | SensorCommand::SetAlarm(alarm2).to_bytes(), 182 | hex!("7e 08 2c 00 32 00 00 00 00 32 39 3b").to_vec() 183 | ); 184 | 185 | let alarm3 = AlarmCommand::new(BedSide::Left, 50, 0, AlarmPattern::Double); 186 | assert_eq!( 187 | SensorCommand::SetAlarm(alarm3).to_bytes(), 188 | hex!("7e 08 2c 00 32 01 00 00 00 00 85 7b").to_vec() 189 | ); 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /src/frozen/manager.rs: -------------------------------------------------------------------------------- 1 | use crate::common::{ 2 | codec::PacketCodec, 3 | packet::BedSide, 4 | serial::{SerialError, create_framed_port}, 5 | }; 6 | use crate::config::{Config, SidesConfig}; 7 | use crate::frozen::{FrozenCommand, FrozenPacket, packet::FrozenTarget, state::FrozenState}; 8 | use crate::led::{IS31FL3194Config, IS31FL3194Controller}; 9 | use futures_util::{SinkExt, StreamExt, stream::SplitSink}; 10 | use jiff::{SignedDuration, Timestamp, civil::Time, tz::TimeZone}; 11 | use linux_embedded_hal::I2cdev; 12 | use rumqttc::AsyncClient; 13 | use thiserror::Error; 14 | use tokio::sync::watch; 15 | use tokio::time::{Duration, Instant, interval, sleep}; 16 | use tokio_serial::SerialStream; 17 | use tokio_util::codec::Framed; 18 | 19 | pub const PORT: &str = "/dev/ttymxc2"; 20 | const BAUD: u32 = 38400; 21 | 22 | const HWINFO_INT: Duration = Duration::from_secs(1); 23 | const TEMP_INT: Duration = Duration::from_secs(10); 24 | const MAX_WAKE_ATTEMPTS: u32 = 5; 25 | 26 | struct CommandTimers { 27 | last_wake: Instant, 28 | last_hwinfo: Instant, 29 | last_left_temp: Instant, 30 | last_right_temp: Instant, 31 | last_prime: Instant, 32 | } 33 | 34 | #[derive(Error, Debug)] 35 | pub enum FrozenError { 36 | #[error("Serial: {0}")] 37 | Serial(#[from] SerialError), 38 | #[error("Failed to wake up Frozen")] 39 | FailedToWake, 40 | } 41 | 42 | type Writer = SplitSink>, FrozenCommand>; 43 | 44 | pub async fn run( 45 | port: &'static str, 46 | mut config_rx: watch::Receiver, 47 | mut led: IS31FL3194Controller, 48 | mut client: AsyncClient, 49 | ) -> Result<(), FrozenError> { 50 | log::info!("Initializing Frozen Subsystem..."); 51 | 52 | let cfg = config_rx.borrow_and_update(); 53 | let led_idle = cfg.led.idle.get_config(cfg.led.band.clone()); 54 | let led_active = cfg.led.active.get_config(cfg.led.band.clone()); 55 | set_led(&mut led, &led_idle); 56 | let timezone = cfg.timezone.clone(); 57 | let mut away_mode = cfg.away_mode; 58 | let mut prime = cfg.prime; 59 | let mut side_config = cfg.profile.clone(); 60 | drop(cfg); 61 | 62 | let (mut writer, mut reader) = create_framed_port::(port, BAUD)?.split(); 63 | 64 | let mut state = FrozenState::default(); 65 | state.publish_reset(&mut client).await; 66 | 67 | // grab hwinfo @ boot 68 | send_command(&mut writer, FrozenCommand::Ping).await; 69 | sleep(Duration::from_millis(200)).await; 70 | send_command(&mut writer, FrozenCommand::GetHardwareInfo).await; 71 | 72 | let mut interval = interval(Duration::from_millis(20)); 73 | let mut timers = CommandTimers::default(); 74 | let mut was_active = false; 75 | let mut wake_attempts = 0; 76 | 77 | loop { 78 | tokio::select! { 79 | Some(result) = reader.next() => match result { 80 | Ok(packet) => { 81 | state.handle_packet(&mut client, packet).await; 82 | 83 | if state.is_active() != was_active { 84 | if was_active { 85 | log::info!("Profile ended!"); 86 | set_led(&mut led, &led_idle); 87 | } else { 88 | log::info!("Starting profile!"); 89 | set_led(&mut led, &led_active); 90 | } 91 | was_active = !was_active; 92 | } 93 | } 94 | Err(e) => { 95 | log::error!("Packet decode error: {e}"); 96 | } 97 | }, 98 | 99 | // sends commands separated by 20ms 100 | // before sending any commands, wakes the device by sending ping + jump fw 101 | _ = interval.tick() => if let Some(cmd) = get_next_command( 102 | &mut timers, 103 | &state, 104 | &timezone, 105 | &away_mode, 106 | &prime, 107 | &side_config 108 | ) { 109 | let now = Instant::now(); 110 | 111 | // ready to send command 112 | if state.is_awake() { 113 | wake_attempts = 0; 114 | send_command(&mut writer, cmd).await; 115 | } 116 | 117 | // keep trying to wake it up, give it 2 seconds every attempt 118 | else if now.duration_since(timers.last_wake) > Duration::from_secs(2) { 119 | timers.last_wake = now; 120 | wake_attempts += 1; 121 | 122 | if wake_attempts > MAX_WAKE_ATTEMPTS { 123 | break Err(FrozenError::FailedToWake) 124 | } 125 | 126 | if let Err(e) = writer.send(FrozenCommand::Ping).await { 127 | log::error!("Failed to ping: {e}"); 128 | } 129 | sleep(Duration::from_millis(200)).await; 130 | if let Err(e) = writer.send(FrozenCommand::JumpToFirmware).await { 131 | log::error!("Failed to send JumpToFirmware: {e}"); 132 | } 133 | } 134 | }, 135 | 136 | Ok(_) = config_rx.changed() => { 137 | let cfg = config_rx.borrow(); 138 | away_mode = cfg.away_mode; 139 | prime = cfg.prime; 140 | side_config = cfg.profile.clone(); 141 | } 142 | } 143 | } 144 | } 145 | 146 | fn get_next_command( 147 | timers: &mut CommandTimers, 148 | state: &FrozenState, 149 | timezone: &TimeZone, 150 | away_mode: &bool, 151 | prime_time: &Time, 152 | side_config: &SidesConfig, 153 | ) -> Option { 154 | let now = Instant::now(); 155 | 156 | if state.hardware_info.is_none() && now.duration_since(timers.last_hwinfo) > HWINFO_INT { 157 | timers.last_hwinfo = now; 158 | return Some(FrozenCommand::GetHardwareInfo); 159 | } 160 | 161 | if now.duration_since(timers.last_left_temp) > TEMP_INT { 162 | let wanted_left = 163 | FrozenTarget::calc_wanted(timezone, away_mode, side_config, &BedSide::Left); 164 | timers.last_left_temp = now; 165 | if state.left_target.as_ref() != Some(&wanted_left) { 166 | return Some(FrozenCommand::SetTargetTemperature { 167 | side: BedSide::Left, 168 | tar: wanted_left, 169 | }); 170 | } 171 | } 172 | 173 | if now.duration_since(timers.last_right_temp) > TEMP_INT { 174 | let wanted_right = 175 | FrozenTarget::calc_wanted(timezone, away_mode, side_config, &BedSide::Right); 176 | timers.last_right_temp = now; 177 | 178 | if state.right_target.as_ref() != Some(&wanted_right) { 179 | return Some(FrozenCommand::SetTargetTemperature { 180 | side: BedSide::Right, 181 | tar: wanted_right, 182 | }); 183 | } 184 | } 185 | 186 | let now_local = Timestamp::now().to_zoned(timezone.clone()).time(); 187 | 188 | // TODO verify it actually started priming 189 | if !away_mode 190 | // prime if we are within 30 seconds of prime time AND we havn't tried to prime in the last minute 191 | && now.duration_since(timers.last_prime) > Duration::from_secs(60) 192 | && now_local.duration_until(*prime_time).abs() < SignedDuration::from_secs(30) 193 | { 194 | timers.last_prime = now; 195 | return Some(FrozenCommand::Prime); 196 | } 197 | 198 | None 199 | } 200 | 201 | async fn send_command(writer: &mut Writer, cmd: FrozenCommand) { 202 | let name = cmd.to_string(); 203 | log::debug!(" -> {name}"); 204 | if let Err(e) = writer.send(cmd).await { 205 | log::error!("Failed to write {name}: {e}"); 206 | } 207 | } 208 | 209 | fn set_led(led: &mut IS31FL3194Controller, cfg: &IS31FL3194Config) { 210 | if let Err(e) = led.set(cfg) { 211 | log::error!("Failed to set LED: {e}"); 212 | } 213 | } 214 | 215 | impl Default for CommandTimers { 216 | fn default() -> Self { 217 | let now = Instant::now(); 218 | let ago = now - Duration::from_secs(60); 219 | Self { 220 | last_wake: now, 221 | last_hwinfo: now, 222 | last_left_temp: ago, 223 | last_right_temp: ago, 224 | last_prime: ago, 225 | } 226 | } 227 | } 228 | -------------------------------------------------------------------------------- /src/sensor/state.rs: -------------------------------------------------------------------------------- 1 | use rumqttc::AsyncClient; 2 | 3 | use crate::{ 4 | common::{ 5 | packet::{BedSide, HardwareInfo}, 6 | serial::DeviceMode, 7 | }, 8 | mqtt::{publish_guaranteed_wait, publish_high_freq}, 9 | sensor::packet::SensorPacket, 10 | }; 11 | 12 | #[derive(Clone, Debug, Default, PartialEq)] 13 | pub struct SensorState { 14 | pub device_mode: DeviceMode, 15 | pub hardware_info: Option, 16 | pub vibration_enabled: bool, 17 | pub piezo_gain: Option<(u16, u16)>, 18 | pub piezo_freq: Option, 19 | pub piezo_enabled: bool, 20 | pub alarm_left_running: bool, 21 | pub alarm_right_running: bool, 22 | } 23 | 24 | pub const PIEZO_GAIN: u16 = 400; 25 | const PIEZO_TOLERANCE: i16 = 6; 26 | pub const PIEZO_FREQ: u32 = 1000; 27 | 28 | const TOPIC_MODE: &str = "opensleep/state/sensor/mode"; 29 | const TOPIC_HWINFO: &str = "opensleep/state/sensor/hwinfo"; 30 | const TOPIC_PIEZO_OK: &str = "opensleep/state/sensor/piezo_ok"; 31 | const TOPIC_VIBRATION_ENABLED: &str = "opensleep/state/sensor/vibration_enabled"; 32 | const TOPIC_BED_TEMP: &str = "opensleep/state/sensor/bed_temp"; 33 | const TOPIC_AMBIENT_TEMP: &str = "opensleep/state/sensor/ambient_temp"; 34 | const TOPIC_HUMIDITY: &str = "opensleep/state/sensor/humidity"; 35 | const TOPIC_MCU_TEMP: &str = "opensleep/state/sensor/mcu_temp"; 36 | 37 | impl SensorState { 38 | pub fn piezo_gain_ok(&self) -> bool { 39 | match self.piezo_gain { 40 | Some((l, r)) => { 41 | (PIEZO_GAIN as i16 - l as i16).abs() < PIEZO_TOLERANCE 42 | && (PIEZO_GAIN as i16 - r as i16).abs() < PIEZO_TOLERANCE 43 | } 44 | None => false, 45 | } 46 | } 47 | 48 | pub fn piezo_freq_ok(&self) -> bool { 49 | self.piezo_freq == Some(PIEZO_FREQ) 50 | } 51 | 52 | pub fn piezo_ok(&self) -> bool { 53 | self.piezo_enabled && self.piezo_gain_ok() && self.piezo_freq_ok() 54 | } 55 | 56 | pub async fn set_device_mode(&mut self, client: &mut AsyncClient, mode: DeviceMode) { 57 | let prev = self.device_mode; 58 | self.device_mode = mode; 59 | 60 | if prev != mode { 61 | log::info!("Device mode: {prev:?} -> {mode:?}"); 62 | publish_guaranteed_wait(client, TOPIC_MODE, false, mode.to_string()).await; 63 | } 64 | } 65 | 66 | pub fn get_alarm_for_side(&self, side: &BedSide) -> bool { 67 | match side { 68 | BedSide::Left => self.alarm_left_running, 69 | BedSide::Right => self.alarm_right_running, 70 | } 71 | } 72 | 73 | async fn publish_piezo_ok(&self, client: &mut AsyncClient) { 74 | publish_guaranteed_wait(client, TOPIC_PIEZO_OK, false, self.piezo_ok().to_string()).await; 75 | } 76 | 77 | pub async fn publish_reset(&self, client: &mut AsyncClient) { 78 | publish_guaranteed_wait(client, TOPIC_MODE, false, DeviceMode::Unknown.to_string()).await; 79 | } 80 | 81 | /// [%s] off 82 | /// [%s] start: power %u, pattern %u, dur %u ms 83 | /// [%s] no longer running (max duration) 84 | /// [%s] new sequence run. ramp power to %u 85 | fn handle_alarm_msg(&mut self, msg: &str) { 86 | // TODO test 87 | let (bedside, rest) = if let Some(start) = msg.find('[') { 88 | if let Some(end) = msg.find(']') { 89 | let bedside = &msg[start + 1..end]; 90 | let remaining = &msg[end + 1..]; 91 | if bedside != "left" && bedside != "right" { 92 | log::warn!("Unknown bedside in alarm message: {}", bedside); 93 | return; 94 | } 95 | (bedside.to_string(), remaining.trim()) 96 | } else { 97 | log::warn!("Alarm message missing closing bracket: {}", msg); 98 | return; 99 | } 100 | } else { 101 | log::warn!("Alarm message missing opening bracket: {}", msg); 102 | return; 103 | }; 104 | 105 | let alarm_running = if bedside == "left" { 106 | &mut self.alarm_left_running 107 | } else { 108 | &mut self.alarm_right_running 109 | }; 110 | 111 | if rest == "off" { 112 | log::info!("Alarm[{bedside}] off"); 113 | *alarm_running = false; 114 | } else if rest == "no longer running (max duration)" { 115 | log::info!("Alarm[{bedside}] duration complete"); 116 | *alarm_running = false; 117 | } else if let Some(rest) = rest.strip_prefix("start: ") { 118 | log::info!("Alarm[{bedside}] started: {rest}"); 119 | *alarm_running = true; 120 | } else if let Some(val) = rest.strip_prefix("new sequence run. ramp power to ") { 121 | log::debug!("Alarm[{bedside}] ramping power to {val}"); 122 | *alarm_running = true; 123 | } else { 124 | log::warn!("Unknown alarm message: {msg}"); 125 | } 126 | } 127 | 128 | pub async fn handle_packet(&mut self, client: &mut AsyncClient, packet: SensorPacket) { 129 | match packet { 130 | SensorPacket::Pong(in_firmware) => { 131 | log::debug!(" <-- Pong"); 132 | self.set_device_mode(client, DeviceMode::from_pong(in_firmware)) 133 | .await; 134 | } 135 | SensorPacket::HardwareInfo(info) => { 136 | log::info!("Hardware info: {info}"); 137 | publish_guaranteed_wait(client, TOPIC_HWINFO, true, info.to_string()).await; 138 | self.hardware_info = Some(info); 139 | } 140 | SensorPacket::JumpingToFirmware(code) => { 141 | log::debug!("Jumping to firmware with code: 0x{code:02X}"); 142 | self.set_device_mode(client, DeviceMode::Firmware).await; 143 | } 144 | SensorPacket::Message(msg) => { 145 | if let Some(stripped) = msg.strip_prefix("FW: alarm") { 146 | self.handle_alarm_msg(stripped); 147 | } else { 148 | log::debug!("Message: {msg}"); 149 | } 150 | } 151 | SensorPacket::PiezoGainSet(l, r) => { 152 | log::info!("Piezo Gain Set: {l},{r}"); 153 | self.publish_piezo_ok(client).await; 154 | self.piezo_gain = Some((l, r)); 155 | } 156 | SensorPacket::PiezoEnabled(val) => { 157 | log::info!("Piezo Enabled {val:02X}"); 158 | self.publish_piezo_ok(client).await; 159 | self.piezo_enabled = true; 160 | } 161 | SensorPacket::VibrationEnabled(_, _) => { 162 | log::info!("Vibration Enabled"); 163 | publish_guaranteed_wait(client, TOPIC_VIBRATION_ENABLED, false, "true").await; 164 | self.vibration_enabled = true; 165 | } 166 | SensorPacket::Capacitance(_) => {} 167 | SensorPacket::Temperature(u) => { 168 | publish_high_freq( 169 | client, 170 | TOPIC_BED_TEMP, 171 | format!( 172 | "{},{},{},{},{},{}", 173 | u.bed[0], u.bed[1], u.bed[2], u.bed[3], u.bed[4], u.bed[5] 174 | ), 175 | ); 176 | publish_high_freq(client, TOPIC_AMBIENT_TEMP, u.ambient.to_string()); 177 | publish_high_freq(client, TOPIC_HUMIDITY, u.humidity.to_string()); 178 | publish_high_freq(client, TOPIC_MCU_TEMP, u.microcontroller.to_string()); 179 | } 180 | SensorPacket::Piezo(u) => { 181 | let (enabled_changed, gain_changed, freq_changed); 182 | { 183 | enabled_changed = !self.piezo_enabled; 184 | gain_changed = self.piezo_gain != Some(u.gain); 185 | freq_changed = self.piezo_freq != Some(u.freq); 186 | self.piezo_enabled = true; 187 | self.piezo_gain = Some(u.gain); 188 | self.piezo_freq = Some(u.freq); 189 | } 190 | if gain_changed || freq_changed || enabled_changed { 191 | self.publish_piezo_ok(client).await; 192 | } 193 | } 194 | SensorPacket::AlarmSet(v) => { 195 | log::info!("Alarm Set: {v}"); 196 | } 197 | SensorPacket::Init(v) => { 198 | log::warn!("Init: {v}"); 199 | } 200 | _ => {} 201 | } 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /src/led/controller.rs: -------------------------------------------------------------------------------- 1 | use embedded_hal::i2c::I2c; 2 | 3 | use super::model::*; 4 | 5 | /// I2C wrapper for the IS31FL3194 LED controller 6 | /// Forced to RGB mode 7 | pub struct IS31FL3194Controller { 8 | pub(crate) dev: T, 9 | } 10 | 11 | impl IS31FL3194Controller { 12 | pub fn new(dev: T) -> IS31FL3194Controller { 13 | Self { dev } 14 | } 15 | 16 | fn write_reg(&mut self, reg: u8, value: u8) -> Result<(), T::Error> { 17 | const ADDR: u8 = 0x53; 18 | self.dev.write(ADDR, &[reg, value])?; 19 | Ok(()) 20 | } 21 | 22 | #[allow(dead_code)] 23 | pub fn reset(&mut self) -> Result<(), T::Error> { 24 | const REG_RESET: u8 = 0x4F; 25 | const RESET_VALUE: u8 = 0xC5; 26 | self.write_reg(REG_RESET, RESET_VALUE)?; 27 | Ok(()) 28 | } 29 | 30 | pub fn set(&mut self, cfg: &IS31FL3194Config) -> Result<(), T::Error> { 31 | // self.reset()?; 32 | 33 | self.set_mode(&cfg.mode)?; 34 | self.set_current_band(cfg.band.clone())?; 35 | self.set_out_enabled(cfg.enabled)?; 36 | 37 | match &cfg.mode { 38 | OperatingMode::CurrentLevel(r, g, b) => self.current_level(*r, *g, *b)?, 39 | OperatingMode::Pattern(p1, p2, p3) => self.patterns([p1, p2, p3])?, 40 | } 41 | 42 | self.update_colors() 43 | } 44 | 45 | fn set_current_band(&mut self, band: CurrentBand) -> Result<(), T::Error> { 46 | const REG_CURRENT_BAND: u8 = 0x03; 47 | let band = band as u8; 48 | self.write_reg(REG_CURRENT_BAND, (band << 4) | (band << 2) | band) 49 | } 50 | 51 | fn set_out_enabled(&mut self, enabled: bool) -> Result<(), T::Error> { 52 | const REG_OUT_CONFIG: u8 = 0x02; 53 | self.write_reg( 54 | REG_OUT_CONFIG, 55 | ((enabled as u8) << 2) | ((enabled as u8) << 1) | (enabled as u8), 56 | ) 57 | } 58 | 59 | fn set_mode(&mut self, mode: &OperatingMode) -> Result<(), T::Error> { 60 | const REG_OP_CONFIG: u8 = 0x01; 61 | use OperatingMode::*; 62 | let out_mode = match mode { 63 | CurrentLevel(..) => 0b000, 64 | Pattern(..) => 0b111, 65 | }; 66 | let led_mode = match mode { 67 | // Single mode 68 | CurrentLevel(..) => 0b00, 69 | // RGB mode 70 | Pattern(..) => 0b10, 71 | }; 72 | self.write_reg( 73 | REG_OP_CONFIG, 74 | (out_mode << 4) | 75 | (led_mode << 1) | 76 | // 0 = software shutdown, 1 = normal operation 77 | 0b1, 78 | ) 79 | } 80 | 81 | fn patterns(&mut self, patterns: [&Option; 3]) -> Result<(), T::Error> { 82 | for (pn, pattern) in patterns.into_iter().enumerate() { 83 | let pn = pn as u8; 84 | 85 | if let Some(pattern) = pattern { 86 | self.pattern_enable_colors( 87 | pn, 88 | pattern.colors[0].enabled, 89 | pattern.colors[1].enabled, 90 | pattern.colors[2].enabled, 91 | )?; 92 | 93 | self.pattern_color_repeat( 94 | pn, 95 | pattern.colors[0].repeat.clone(), 96 | pattern.colors[1].repeat.clone(), 97 | pattern.colors[2].repeat.clone(), 98 | )?; 99 | 100 | for (cn, color) in pattern.colors.iter().enumerate() { 101 | self.pattern_color(pn, cn as u8, color.r, color.g, color.b)?; 102 | } 103 | 104 | self.pattern_nxt( 105 | pn, 106 | &pattern.next, 107 | &pattern.gamma, 108 | &pattern.multipulse_repeat, 109 | )?; 110 | self.pattern_repeat(pn, &pattern.pattern_repeat)?; 111 | 112 | self.pattern_update_run(pn)?; 113 | 114 | self.pattern_timing(pn, &pattern.timing)?; 115 | } 116 | } 117 | 118 | // self.pattern_update_run(0)?; 119 | 120 | Ok(()) 121 | } 122 | 123 | fn pattern_repeat(&mut self, pattern: u8, repeat: &Repeat) -> Result<(), T::Error> { 124 | assert!(pattern <= 2, "`pattern` must be 0-2"); 125 | let reg = 0x1F + (pattern * 0x10); 126 | self.write_reg( 127 | reg, 128 | match repeat { 129 | Repeat::Endless => 0, 130 | Repeat::Count(n) => *n, 131 | }, 132 | ) 133 | } 134 | 135 | pub(crate) fn pattern_color( 136 | &mut self, 137 | pattern: u8, 138 | color_number: u8, 139 | r: u8, 140 | g: u8, 141 | b: u8, 142 | ) -> Result<(), T::Error> { 143 | assert!(pattern <= 2, "`pattern` must be 0-2"); 144 | assert!(color_number <= 2, "`color_number` must be 0-2"); 145 | // pattern 1, color 1: 10~12 146 | // pattern 1, color 2: 13~15 147 | // pattern 2, color 1: 20~22 148 | // eight sleep messed up PCB so its BRG 149 | let offset = (pattern * 0x10) + (color_number * 3); 150 | let reg_b = offset + 0x10; 151 | let reg_r = offset + 0x11; 152 | let reg_g = offset + 0x12; 153 | self.write_reg(reg_b, b)?; 154 | self.write_reg(reg_r, r)?; 155 | self.write_reg(reg_g, g) 156 | } 157 | 158 | /// pattern 0-2 159 | pub(crate) fn pattern_timing(&mut self, pattern: u8, timing: &Timing) -> Result<(), T::Error> { 160 | assert!(pattern <= 2, "`pattern` must be 0-2"); 161 | let offset = pattern * 0x10; 162 | let reg_pn_start_rise = offset + 0x19; 163 | let reg_pn_hold_fall = offset + 0x1A; 164 | let reg_pn_pulse_off = offset + 0x1B; 165 | // [7:3 rise time], [4:0 start time] 166 | self.write_reg(reg_pn_start_rise, (timing.rise << 4) | timing.start)?; 167 | // [7:3 fall time], [4:0 hold time] 168 | self.write_reg(reg_pn_hold_fall, (timing.fall << 4) | timing.hold)?; 169 | // [7:3 off time], [4:0 btw pulses] 170 | self.write_reg(reg_pn_pulse_off, (timing.off << 4) | timing.between_pulses) 171 | } 172 | 173 | pub(crate) fn pattern_enable_colors( 174 | &mut self, 175 | pattern: u8, 176 | c1_en: bool, 177 | c2_en: bool, 178 | c3_en: bool, 179 | ) -> Result<(), T::Error> { 180 | assert!(pattern <= 2, "`pattern` must be 0-2"); 181 | let reg = (pattern * 0x10) + 0x1C; 182 | self.write_reg( 183 | reg, 184 | ((c3_en as u8) << 2) | ((c2_en as u8) << 1) | (c1_en as u8), 185 | ) 186 | } 187 | 188 | fn pattern_color_repeat( 189 | &mut self, 190 | pattern: u8, 191 | c1_repeat: ColorRepeat, 192 | c2_repeat: ColorRepeat, 193 | c3_repeat: ColorRepeat, 194 | ) -> Result<(), T::Error> { 195 | assert!(pattern <= 2, "`pattern` must be 0-2"); 196 | let reg = (pattern * 0x10) + 0x1D; 197 | // [5:4] c3, [3:2] c2, [1:0] c1 198 | self.write_reg( 199 | reg, 200 | ((c3_repeat as u8) << 4) | ((c2_repeat as u8) << 2) | (c1_repeat as u8), 201 | ) 202 | } 203 | 204 | fn pattern_nxt( 205 | &mut self, 206 | pattern: u8, 207 | next: &PatternNext, 208 | gamma: &Gamma, 209 | repeat: &Repeat, 210 | ) -> Result<(), T::Error> { 211 | assert!(pattern <= 2, "`pattern` must be 0-2"); 212 | let reg = (pattern * 0x10) + 0x1E; 213 | 214 | let mtply = match repeat { 215 | Repeat::Endless => 0, 216 | Repeat::Count(n) => *n, 217 | }; 218 | 219 | let next = match next { 220 | PatternNext::Stop => 0b00, 221 | PatternNext::Next => { 222 | if pattern == 1 { 223 | 0b10 224 | } else { 225 | 0b01 226 | } 227 | } 228 | PatternNext::Prev => match pattern { 229 | 0 => panic!("Pattern 0 cannot have Prev"), 230 | 1 => 0b01, 231 | 2 => 0b10, 232 | _ => unreachable!(), 233 | }, 234 | }; 235 | 236 | // [7:4] Multy, [3:2] Gam, [1:0] Next 237 | self.write_reg(reg, (mtply << 4) | ((gamma.clone() as u8) << 2) | next) 238 | } 239 | 240 | fn pattern_update_run(&mut self, pattern: u8) -> Result<(), T::Error> { 241 | assert!(pattern <= 2, "`pattern` must be 0-2"); 242 | const UPDATE_VALUE: u8 = 0xC5; 243 | let reg = 0x41 + pattern; 244 | self.write_reg(reg, UPDATE_VALUE) 245 | } 246 | 247 | /// updates data of 10h\~18h, 20h\~28h, 30h\~38h 248 | fn update_colors(&mut self) -> Result<(), T::Error> { 249 | const REG_COLOR_UPDATE: u8 = 0x40; 250 | const UPDATE_VALUE: u8 = 0xC5; 251 | self.write_reg(REG_COLOR_UPDATE, UPDATE_VALUE) 252 | } 253 | 254 | fn current_level(&mut self, r: u8, g: u8, b: u8) -> Result<(), T::Error> { 255 | const REG_B_CURRENT_LEVEL: u8 = 0x10; 256 | const REG_R_CURRENT_LEVEL: u8 = 0x21; 257 | const REG_G_CURRENT_LEVEL: u8 = 0x32; 258 | self.write_reg(REG_R_CURRENT_LEVEL, r)?; 259 | self.write_reg(REG_G_CURRENT_LEVEL, g)?; 260 | self.write_reg(REG_B_CURRENT_LEVEL, b) 261 | } 262 | } 263 | -------------------------------------------------------------------------------- /src/led/tests.rs: -------------------------------------------------------------------------------- 1 | use crate::led::model::{ 2 | ColorConfig, ColorRepeat, CurrentBand, Gamma, IS31FL3194Config, OperatingMode, PatternConfig, 3 | PatternNext, Repeat, Timing, 4 | }; 5 | 6 | use super::*; 7 | use embedded_hal::i2c::{I2c, Operation}; 8 | use std::collections::HashMap; 9 | 10 | const I2C_ADDR: u8 = 0x53; 11 | const REG_OP_CONFIG: u8 = 0x01; 12 | const REG_OUT_CONFIG: u8 = 0x02; 13 | const REG_CURRENT_BAND: u8 = 0x03; 14 | const REG_COLOR_UPDATE: u8 = 0x40; 15 | const REG_RESET: u8 = 0x4F; 16 | 17 | #[derive(Default)] 18 | struct MockI2c { 19 | regs: HashMap, 20 | } 21 | 22 | impl MockI2c { 23 | fn expect_all(&self, rv_pairs: Vec<(u8, u8)>) { 24 | for (reg, value) in rv_pairs { 25 | self.expect(reg, value); 26 | } 27 | } 28 | 29 | fn expect(&self, reg: u8, value: u8) { 30 | assert_eq!( 31 | self.regs.get(®), 32 | Some(&value), 33 | "Expected register {reg:02X}h to be {value:08b}" 34 | ); 35 | } 36 | } 37 | 38 | impl I2c for MockI2c { 39 | fn write(&mut self, addr: u8, bytes: &[u8]) -> Result<(), Self::Error> { 40 | assert_eq!(addr, I2C_ADDR, "Write to wrong address"); 41 | self.regs.insert(bytes[0], bytes[1]); 42 | Ok(()) 43 | } 44 | 45 | fn read(&mut self, _addr: u8, _buffer: &mut [u8]) -> Result<(), Self::Error> { 46 | panic!() 47 | } 48 | 49 | fn write_read( 50 | &mut self, 51 | _addr: u8, 52 | _bytes: &[u8], 53 | _buffer: &mut [u8], 54 | ) -> Result<(), Self::Error> { 55 | panic!() 56 | } 57 | 58 | fn transaction( 59 | &mut self, 60 | _addr: u8, 61 | _operations: &mut [Operation<'_>], 62 | ) -> Result<(), Self::Error> { 63 | panic!() 64 | } 65 | } 66 | 67 | impl embedded_hal::i2c::ErrorType for MockI2c { 68 | type Error = std::convert::Infallible; 69 | } 70 | 71 | #[test] 72 | fn test_reset() { 73 | let mock = MockI2c::default(); 74 | 75 | let mut controller = IS31FL3194Controller::new(mock); 76 | controller.reset().unwrap(); 77 | 78 | controller.dev.expect(REG_RESET, 0xC5); 79 | } 80 | 81 | #[test] 82 | fn test_current_level_mode() { 83 | let mock = MockI2c::default(); 84 | 85 | let mut controller = IS31FL3194Controller::new(mock); 86 | 87 | let config = IS31FL3194Config { 88 | enabled: true, 89 | mode: OperatingMode::CurrentLevel(100, 200, 128), 90 | band: CurrentBand::Two, 91 | }; 92 | 93 | controller.set(&config).unwrap(); 94 | 95 | controller.dev.expect_all(vec![ 96 | // config regs 97 | (REG_OP_CONFIG, 0b00000001), // current level mode, single led mode, enabled 98 | (REG_OUT_CONFIG, 0b00000111), // all outputs enabled 99 | (REG_CURRENT_BAND, 0b00010101), // band 2 = 01 for all 100 | // current level regs 101 | (0x21, 100), 102 | (0x32, 200), 103 | (0x10, 128), 104 | ]); 105 | } 106 | 107 | #[test] 108 | fn test_single_pattern_mode() { 109 | let mock = MockI2c::default(); 110 | 111 | let mut controller = IS31FL3194Controller::new(mock); 112 | 113 | let config = IS31FL3194Config { 114 | enabled: true, 115 | mode: OperatingMode::Pattern( 116 | Some(PatternConfig { 117 | timing: Timing { 118 | start: 1, 119 | rise: 2, 120 | hold: 3, 121 | fall: 4, 122 | off: 5, 123 | between_pulses: 6, 124 | }, 125 | colors: [ 126 | ColorConfig { 127 | enabled: true, 128 | r: 255, 129 | g: 100, 130 | b: 50, 131 | repeat: ColorRepeat::Endless, 132 | }, 133 | ColorConfig::default(), 134 | ColorConfig::default(), 135 | ], 136 | next: PatternNext::Stop, 137 | gamma: Gamma::Gamma2_4, 138 | multipulse_repeat: Repeat::Count(3), 139 | pattern_repeat: Repeat::Count(1), 140 | }), 141 | None, 142 | None, 143 | ), 144 | band: CurrentBand::Four, 145 | }; 146 | 147 | controller.set(&config).unwrap(); 148 | 149 | controller.dev.expect_all(vec![ 150 | // config 151 | (REG_OP_CONFIG, 0b01110101), // pattern mode all, RGB, enabled 152 | (REG_OUT_CONFIG, 0b00000111), // all outputs enabled 153 | (REG_CURRENT_BAND, 0b00111111), // band 4 (11) for all 154 | // P1 155 | (0x1C, 0b00000001), 156 | // P1 color repeat 157 | (0x1D, 0b00000000), 158 | // P1 C1 BRG 159 | (0x10, 50), 160 | (0x11, 255), 161 | (0x12, 100), 162 | // P1 C2 163 | (0x13, 0), 164 | (0x14, 0), 165 | (0x15, 0), 166 | // P1 C3 167 | (0x16, 0), 168 | (0x17, 0), 169 | (0x18, 0), 170 | (0x1E, 0b00110000), // 3 loops, gamma 2.4, stop 171 | (0x1F, 1), 172 | (0x41, 0xC5), 173 | // P1 timing 174 | (0x19, 0b00100001), 175 | (0x1A, 0b01000011), 176 | (0x1B, 0b01010110), 177 | (REG_COLOR_UPDATE, 0xC5), 178 | ]); 179 | } 180 | 181 | #[test] 182 | fn test_multi_pattern_transitions() { 183 | let mock = MockI2c::default(); 184 | 185 | let mut controller = IS31FL3194Controller::new(mock); 186 | 187 | let config = IS31FL3194Config { 188 | enabled: true, 189 | mode: OperatingMode::Pattern( 190 | Some(PatternConfig { 191 | timing: Timing { 192 | start: 0, 193 | rise: 0, 194 | hold: 0, 195 | fall: 0, 196 | off: 0, 197 | between_pulses: 0, 198 | }, 199 | colors: [ 200 | ColorConfig { 201 | enabled: true, 202 | r: 0, 203 | g: 0, 204 | b: 255, 205 | repeat: ColorRepeat::Endless, 206 | }, 207 | ColorConfig { 208 | enabled: true, 209 | r: 255, 210 | g: 0, 211 | b: 0, 212 | repeat: ColorRepeat::Endless, 213 | }, 214 | ColorConfig::default(), 215 | ], 216 | next: PatternNext::Next, 217 | gamma: Gamma::Gamma2_4, 218 | multipulse_repeat: Repeat::Endless, 219 | pattern_repeat: Repeat::Count(1), 220 | }), 221 | Some(PatternConfig { 222 | timing: Timing { 223 | start: 2, 224 | rise: 3, 225 | hold: 0, 226 | fall: 0, 227 | off: 0, 228 | between_pulses: 0, 229 | }, 230 | colors: [ 231 | ColorConfig { 232 | enabled: true, 233 | r: 0, 234 | g: 255, 235 | b: 0, 236 | repeat: ColorRepeat::Endless, 237 | }, 238 | ColorConfig::default(), 239 | ColorConfig::default(), 240 | ], 241 | next: PatternNext::Next, 242 | gamma: Gamma::Linearity, 243 | multipulse_repeat: Repeat::Endless, 244 | pattern_repeat: Repeat::Count(1), 245 | }), 246 | None, 247 | ), 248 | band: CurrentBand::Three, 249 | }; 250 | 251 | controller.set(&config).unwrap(); 252 | 253 | controller.dev.expect_all(vec![ 254 | // config 255 | (REG_OP_CONFIG, 0b01110101), // pattern mode, RGB, enabled 256 | (REG_OUT_CONFIG, 0b00000111), // all outputs enabled 257 | (REG_CURRENT_BAND, 0b00101010), // band 3 for all 258 | // P1 colors 259 | (0x1C, 0b00000011), // enable colors 1 and 2 260 | // P1 color repeat 261 | (0x1D, 0x00), 262 | // P1 C1 263 | (0x10, 255), 264 | (0x11, 0), 265 | (0x12, 0), 266 | // P1 C2 267 | (0x13, 0), 268 | (0x14, 255), 269 | (0x15, 0), 270 | // P1 C3 271 | (0x16, 0), 272 | (0x17, 0), 273 | (0x18, 0), 274 | (0x1E, 0b00000001), // endless, gamma 2.4, goto next 275 | (0x1F, 1), // repeat once 276 | (0x41, 0xC5), 277 | (0x19, 0b00000000), 278 | (0x1A, 0b00000000), 279 | (0x1B, 0b00000000), 280 | // P2 colors 281 | (0x2C, 0b00000001), 282 | // P2 color repeat 283 | (0x2D, 0b00000000), 284 | // P2 C1 285 | (0x20, 0), 286 | (0x21, 0), 287 | (0x22, 255), 288 | // P2 C2 289 | (0x23, 0), 290 | (0x24, 0), 291 | (0x25, 0), 292 | // P2 C3 293 | (0x26, 0), 294 | (0x27, 0), 295 | (0x28, 0), 296 | (0x2E, 0b00001010), // endless, linearity, goto next 297 | (0x2F, 1), //repeat once 298 | (0x42, 0xC5), 299 | (0x29, 0b00110010), 300 | (0x2A, 0b00000000), 301 | (0x2B, 0b00000000), 302 | (REG_COLOR_UPDATE, 0xC5), 303 | ]); 304 | } 305 | -------------------------------------------------------------------------------- /src/led/patterns.rs: -------------------------------------------------------------------------------- 1 | use super::model::*; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | // plz make a PR if you find some more cool LED patterns!! 5 | // certainly a lot more room for expansion here 6 | 7 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 8 | pub enum LedPattern { 9 | SlowBreath(u8, u8, u8), 10 | FastBreath(u8, u8, u8), 11 | 12 | Off, 13 | 14 | Fixed(u8, u8, u8), 15 | 16 | FastRainbowBreath, 17 | SlowRainbowBreath, 18 | FreakyRainbow, 19 | 20 | CustomBasic(u8, u8, u8, Timing), 21 | CustomRainbow(Timing), 22 | 23 | // these are mostly troll 24 | FastPulse(u8, u8, u8), 25 | Pulse(u8, u8, u8), 26 | SlowPulse(u8, u8, u8), 27 | } 28 | 29 | impl LedPattern { 30 | pub fn get_config(&self, band: CurrentBand) -> IS31FL3194Config { 31 | match self { 32 | LedPattern::Off => IS31FL3194Config { 33 | enabled: false, 34 | mode: OperatingMode::CurrentLevel(0, 0, 0), 35 | band, 36 | }, 37 | 38 | LedPattern::CustomBasic(r, g, b, timing) => { 39 | make_basic(*r, *g, *b, timing.clone(), band) 40 | } 41 | LedPattern::CustomRainbow(timing) => make_rainbow(timing.clone(), band), 42 | 43 | LedPattern::SlowPulse(r, g, b) => make_basic( 44 | *r, 45 | *g, 46 | *b, 47 | Timing { 48 | start: 0, 49 | rise: 0, 50 | hold: 0, 51 | fall: 0, 52 | between_pulses: 6, 53 | off: 0, 54 | }, 55 | band, 56 | ), 57 | 58 | LedPattern::Pulse(r, g, b) => make_basic( 59 | *r, 60 | *g, 61 | *b, 62 | Timing { 63 | start: 0, 64 | rise: 0, 65 | hold: 0, 66 | fall: 0, 67 | between_pulses: 2, 68 | off: 0, 69 | }, 70 | band, 71 | ), 72 | 73 | LedPattern::FastPulse(r, g, b) => make_basic( 74 | *r, 75 | *g, 76 | *b, 77 | Timing { 78 | start: 0, 79 | rise: 0, 80 | hold: 0, 81 | fall: 0, 82 | between_pulses: 1, 83 | off: 0, 84 | }, 85 | band, 86 | ), 87 | 88 | LedPattern::SlowBreath(r, g, b) => make_basic( 89 | *r, 90 | *g, 91 | *b, 92 | Timing { 93 | start: 6, 94 | rise: 7, 95 | hold: 6, 96 | fall: 7, 97 | between_pulses: 0, 98 | off: 6, 99 | }, 100 | band, 101 | ), 102 | 103 | LedPattern::FastBreath(r, g, b) => make_basic( 104 | *r, 105 | *g, 106 | *b, 107 | Timing { 108 | start: 1, 109 | rise: 3, 110 | hold: 1, 111 | fall: 3, 112 | between_pulses: 0, 113 | off: 2, 114 | }, 115 | band, 116 | ), 117 | 118 | LedPattern::Fixed(r, g, b) => IS31FL3194Config { 119 | enabled: true, 120 | mode: OperatingMode::CurrentLevel(*r, *g, *b), 121 | band, 122 | }, 123 | 124 | LedPattern::SlowRainbowBreath => make_rainbow( 125 | Timing { 126 | start: 0, 127 | rise: 7, 128 | hold: 0, 129 | fall: 7, 130 | between_pulses: 0, 131 | off: 0, 132 | }, 133 | band, 134 | ), 135 | 136 | LedPattern::FastRainbowBreath => make_rainbow( 137 | Timing { 138 | start: 0, 139 | rise: 4, 140 | hold: 0, 141 | fall: 4, 142 | between_pulses: 0, 143 | off: 0, 144 | }, 145 | band, 146 | ), 147 | 148 | LedPattern::FreakyRainbow => make_rainbow( 149 | Timing { 150 | start: 1, 151 | rise: 1, 152 | hold: 0, 153 | fall: 1, 154 | between_pulses: 0, 155 | off: 0, 156 | }, 157 | band, 158 | ), 159 | } 160 | } 161 | } 162 | 163 | fn make_basic(r: u8, g: u8, b: u8, timing: Timing, band: CurrentBand) -> IS31FL3194Config { 164 | IS31FL3194Config { 165 | enabled: true, 166 | mode: OperatingMode::Pattern( 167 | Some(PatternConfig { 168 | timing, 169 | colors: [ 170 | ColorConfig { 171 | enabled: true, 172 | r, 173 | g, 174 | b, 175 | repeat: ColorRepeat::Endless, 176 | }, 177 | ColorConfig::default(), 178 | ColorConfig::default(), 179 | ], 180 | next: PatternNext::Next, 181 | gamma: Gamma::Gamma2_4, 182 | multipulse_repeat: Repeat::Endless, 183 | pattern_repeat: Repeat::Endless, 184 | }), 185 | None, 186 | None, 187 | ), 188 | band, 189 | } 190 | } 191 | 192 | fn make_rainbow(timing: Timing, band: CurrentBand) -> IS31FL3194Config { 193 | IS31FL3194Config { 194 | enabled: true, 195 | mode: OperatingMode::Pattern( 196 | Some(PatternConfig { 197 | timing: timing.clone(), 198 | colors: [ 199 | ColorConfig { 200 | enabled: true, 201 | r: 255, 202 | g: 0, 203 | b: 0, 204 | repeat: ColorRepeat::Once, 205 | }, 206 | ColorConfig { 207 | enabled: true, 208 | r: 255, 209 | g: 128, 210 | b: 0, 211 | repeat: ColorRepeat::Once, 212 | }, 213 | ColorConfig { 214 | enabled: true, 215 | r: 255, 216 | g: 255, 217 | b: 0, 218 | repeat: ColorRepeat::Once, 219 | }, 220 | ], 221 | next: PatternNext::Next, 222 | gamma: Gamma::Gamma2_4, 223 | multipulse_repeat: Repeat::Count(1), 224 | pattern_repeat: Repeat::Count(1), 225 | }), 226 | Some(PatternConfig { 227 | timing: timing.clone(), 228 | colors: [ 229 | ColorConfig { 230 | enabled: true, 231 | r: 128, 232 | g: 255, 233 | b: 0, 234 | repeat: ColorRepeat::Once, 235 | }, 236 | ColorConfig { 237 | enabled: true, 238 | r: 0, 239 | g: 255, 240 | b: 0, 241 | repeat: ColorRepeat::Once, 242 | }, 243 | ColorConfig { 244 | enabled: true, 245 | r: 0, 246 | g: 255, 247 | b: 128, 248 | repeat: ColorRepeat::Once, 249 | }, 250 | ], 251 | next: PatternNext::Next, 252 | gamma: Gamma::Gamma2_4, 253 | multipulse_repeat: Repeat::Count(1), 254 | pattern_repeat: Repeat::Count(1), 255 | }), 256 | Some(PatternConfig { 257 | timing, 258 | colors: [ 259 | ColorConfig { 260 | enabled: true, 261 | r: 0, 262 | g: 128, 263 | b: 255, 264 | repeat: ColorRepeat::Once, 265 | }, 266 | ColorConfig { 267 | enabled: true, 268 | r: 0, 269 | g: 0, 270 | b: 255, 271 | repeat: ColorRepeat::Once, 272 | }, 273 | ColorConfig { 274 | enabled: true, 275 | r: 128, 276 | g: 0, 277 | b: 255, 278 | repeat: ColorRepeat::Once, 279 | }, 280 | ], 281 | next: PatternNext::Next, 282 | gamma: Gamma::Gamma2_4, 283 | multipulse_repeat: Repeat::Count(1), 284 | pattern_repeat: Repeat::Count(1), 285 | }), 286 | ), 287 | band, 288 | } 289 | } 290 | -------------------------------------------------------------------------------- /src/frozen/packet.rs: -------------------------------------------------------------------------------- 1 | use bytes::BytesMut; 2 | 3 | use crate::common::packet::{ 4 | self, BedSide, HardwareInfo, Packet, PacketError, invalid_structure, validate_packet_size, 5 | }; 6 | 7 | #[derive(Debug, PartialEq)] 8 | pub enum FrozenPacket { 9 | /// next state (in_firmware) 10 | Pong(bool), 11 | HardwareInfo(HardwareInfo), 12 | /// unknown value 13 | JumpingToFirmware(u8), 14 | Message(String), 15 | /// unknown value, always (0,1) 16 | Heartbeat(u8, u8), 17 | TargetUpdate((BedSide, FrozenTarget)), 18 | TemperatureUpdate(TemperatureUpdate), 19 | GetTemperature(GetTemperature), 20 | PrimingStarted, 21 | GetFirmware, 22 | } 23 | 24 | #[derive(Clone, Debug, PartialEq, Eq, Default)] 25 | pub struct FrozenTarget { 26 | pub enabled: bool, 27 | /// centidegrees celcius 28 | pub temp: u16, 29 | } 30 | 31 | #[derive(Debug, PartialEq, Clone)] 32 | pub struct TemperatureUpdate { 33 | /// centidegrees celcius 34 | pub left_temp: u16, 35 | /// centidegrees celcius 36 | pub right_temp: u16, 37 | /// centidegrees celcius 38 | pub heatsink_temp: u16, 39 | /// error in deg celcius 40 | pub error: u8, 41 | /// wrapping measurement count 42 | pub count: u8, 43 | } 44 | 45 | #[derive(Debug, PartialEq, Clone)] 46 | pub struct GetTemperature { 47 | /// centidegrees celcius 48 | pub left_temp: u16, 49 | /// centidegrees celcius 50 | pub right_temp: u16, 51 | /// centidegrees celcius 52 | pub unknown_temp: u16, 53 | /// centidegrees celcius 54 | pub heatsink_temp: u16, 55 | } 56 | 57 | impl Packet for FrozenPacket { 58 | fn parse(buf: BytesMut) -> Result { 59 | // responses are cmd + 0x80 60 | match buf[0] { 61 | 0x07 => packet::parse_message("Frozen/Message", buf).map(FrozenPacket::Message), 62 | 0x41 => Self::parse_temperature_update(buf), 63 | 0xC1 => Self::parse_get_temperature(buf), 64 | 0x53 => Self::parse_heartbeat(buf), 65 | 0x81 => packet::parse_pong("Frozen/Pong", buf).map(FrozenPacket::Pong), 66 | 0x82 => packet::parse_hardware_info("Frozen/HardwareInfo", buf) 67 | .map(FrozenPacket::HardwareInfo), 68 | 0x84 => Ok(FrozenPacket::GetFirmware), 69 | 0x90 => packet::parse_jumping_to_firmware("Frozen/JumpingToFirmware", buf) 70 | .map(FrozenPacket::JumpingToFirmware), 71 | 0xC0 => Self::parse_target_update(buf), 72 | 0xD2 => Self::parse_priming_started(buf), 73 | _ => Err(PacketError::Unexpected { 74 | subsystem_name: "Frozen", 75 | buf: buf.freeze(), 76 | }), 77 | } 78 | } 79 | } 80 | 81 | impl FrozenPacket { 82 | fn parse_priming_started(buf: BytesMut) -> Result { 83 | validate_packet_size("Frozen/PrimingStarted", &buf, 2)?; 84 | if buf[1] != 0 { 85 | log::warn!("PrimingStarted had unexpected value {}", buf[1]); 86 | } 87 | Ok(FrozenPacket::PrimingStarted) 88 | } 89 | 90 | fn parse_heartbeat(buf: BytesMut) -> Result { 91 | validate_packet_size("Frozen/Heartbeat", &buf, 3)?; 92 | Ok(FrozenPacket::Heartbeat(buf[1], buf[2])) 93 | } 94 | 95 | /// 0xC0, 00, side, state, temp_high, temp_low 96 | fn parse_target_update(buf: BytesMut) -> Result { 97 | validate_packet_size("Frozen/TargetUpdate", &buf, 6)?; 98 | 99 | let temp = u16::from_be_bytes([buf[4], buf[5]]); 100 | let side = BedSide::from_repr(buf[2]).ok_or(PacketError::InvalidBedSide { 101 | packet_name: "Frozen/TargetUpdate", 102 | bed_side: buf[2], 103 | })?; 104 | let state = buf[3] != 0; 105 | 106 | Ok(FrozenPacket::TargetUpdate(( 107 | side, 108 | FrozenTarget { 109 | enabled: state, 110 | temp, 111 | }, 112 | ))) 113 | } 114 | 115 | fn parse_temperature_update(buf: BytesMut) -> Result { 116 | validate_packet_size("Frozen/TemperatureUpdate", &buf, 9)?; 117 | 118 | Ok(FrozenPacket::TemperatureUpdate(TemperatureUpdate { 119 | left_temp: u16::from_be_bytes([buf[1], buf[2]]), 120 | right_temp: u16::from_be_bytes([buf[3], buf[4]]), 121 | heatsink_temp: u16::from_be_bytes([buf[5], buf[6]]), 122 | error: buf[7], 123 | count: buf[8], 124 | })) 125 | } 126 | 127 | /// C1 00 01 0A 15 02 0A 0F 03 07 F5 04 09 3A 128 | /// 0 1 2 3 4 5 6 7 8 9 10 11 12 13 129 | fn parse_get_temperature(buf: BytesMut) -> Result { 130 | validate_packet_size("Frozen/GetTemperature", &buf, 27)?; 131 | 132 | let indices_valid = 133 | buf[1] == 0 && buf[2] == 1 && buf[5] == 2 && buf[8] == 3 && buf[11] == 4; 134 | 135 | if !indices_valid { 136 | return Err(invalid_structure( 137 | "Frozen/GetTemperature", 138 | "invalid indices".to_string(), 139 | buf, 140 | )); 141 | } 142 | 143 | Ok(Self::GetTemperature(GetTemperature { 144 | left_temp: u16::from_be_bytes([buf[3], buf[4]]), 145 | right_temp: u16::from_be_bytes([buf[6], buf[7]]), 146 | unknown_temp: u16::from_be_bytes([buf[9], buf[10]]), 147 | heatsink_temp: u16::from_be_bytes([buf[12], buf[13]]), 148 | })) 149 | } 150 | } 151 | 152 | #[cfg(test)] 153 | mod tests { 154 | use super::*; 155 | use bytes::{Bytes, BytesMut}; 156 | use hex_literal::hex; 157 | 158 | #[test] 159 | fn test_pong() { 160 | assert_eq!( 161 | FrozenPacket::parse(BytesMut::from(&hex!("81 00 46")[..])), 162 | Ok(FrozenPacket::Pong(true)) 163 | ); 164 | 165 | assert_eq!( 166 | FrozenPacket::parse(BytesMut::from(&hex!("81 00 42")[..])), 167 | Ok(FrozenPacket::Pong(false)) 168 | ); 169 | 170 | assert!(FrozenPacket::parse(BytesMut::from(&hex!("81 00 FF")[..])).is_err()); 171 | assert!(FrozenPacket::parse(BytesMut::from(&hex!("81 00")[..])).is_err()); 172 | } 173 | 174 | #[test] 175 | fn test_jumping_to_firmware() { 176 | assert_eq!( 177 | FrozenPacket::parse(BytesMut::from(&[0x90, 0x00][..])), 178 | Ok(FrozenPacket::JumpingToFirmware(0x00)) 179 | ); 180 | assert_eq!( 181 | FrozenPacket::parse(BytesMut::from(&[0x90, 0x10][..])), 182 | Ok(FrozenPacket::JumpingToFirmware(0x10)) 183 | ); 184 | assert!(FrozenPacket::parse(BytesMut::from(&[0x90][..])).is_err()); 185 | } 186 | 187 | #[test] 188 | fn test_message() { 189 | let data = hex!("07 00 48 65 6C 6C 6F"); 190 | assert_eq!( 191 | FrozenPacket::parse(BytesMut::from(&data[..])), 192 | Ok(FrozenPacket::Message("Hello".into())) 193 | ); 194 | 195 | let invalid_utf8 = hex!("07 00 FF FE FD"); 196 | assert!(FrozenPacket::parse(BytesMut::from(&invalid_utf8[..])).is_err()); 197 | 198 | assert!(FrozenPacket::parse(BytesMut::from(&hex!("07 00")[..])).is_err()); 199 | } 200 | 201 | #[test] 202 | fn test_heartbeat() { 203 | assert_eq!( 204 | FrozenPacket::parse(BytesMut::from(&[0x53, 0x00, 0x01][..])), 205 | Ok(FrozenPacket::Heartbeat(0x00, 0x01)) 206 | ); 207 | assert_eq!( 208 | FrozenPacket::parse(BytesMut::from(&[0x53, 0x01, 0x00][..])), 209 | Ok(FrozenPacket::Heartbeat(0x01, 0x00)) 210 | ); 211 | assert!(FrozenPacket::parse(BytesMut::from(&[0x53, 0x00][..])).is_err()); 212 | } 213 | 214 | #[test] 215 | fn test_target_update() { 216 | let data = hex!("C0 00 00 01 0B B8"); 217 | assert_eq!( 218 | FrozenPacket::parse(BytesMut::from(&data[..])), 219 | Ok(FrozenPacket::TargetUpdate(( 220 | BedSide::Left, 221 | FrozenTarget { 222 | enabled: true, 223 | temp: 3000 224 | } 225 | ))) 226 | ); 227 | 228 | let data = hex!("C0 00 01 00 0A C0"); 229 | assert_eq!( 230 | FrozenPacket::parse(BytesMut::from(&data[..])), 231 | Ok(FrozenPacket::TargetUpdate(( 232 | BedSide::Right, 233 | FrozenTarget { 234 | enabled: false, 235 | temp: 2752 236 | } 237 | ))) 238 | ); 239 | 240 | // invalid bed side 241 | let data = hex!("C0 00 02 01 0B B8"); 242 | let result = FrozenPacket::parse(BytesMut::from(&data[..])); 243 | assert!(matches!(result, Err(PacketError::InvalidBedSide { .. }))); 244 | 245 | assert!(FrozenPacket::parse(BytesMut::from(&hex!("C0 00 00")[..])).is_err()); 246 | } 247 | 248 | #[test] 249 | fn test_state_update() { 250 | let data = hex!("41 09 F6 0A 73 08 FC 09 00"); 251 | let result = FrozenPacket::parse(BytesMut::from(&data[..])).unwrap(); 252 | match result { 253 | FrozenPacket::TemperatureUpdate(state) => { 254 | assert_eq!(state.left_temp, 2550); 255 | assert_eq!(state.right_temp, 2675); 256 | assert_eq!(state.heatsink_temp, 2300); 257 | assert_eq!(state.error, 9); 258 | assert_eq!(state.count, 0); 259 | } 260 | _ => panic!("Wrong packet type"), 261 | } 262 | 263 | let data = hex!("41 0B B8 0C 1C 0A 8C 0A FF"); 264 | let result = FrozenPacket::parse(BytesMut::from(&data[..])).unwrap(); 265 | match result { 266 | FrozenPacket::TemperatureUpdate(state) => { 267 | assert_eq!(state.left_temp, 3000); 268 | assert_eq!(state.right_temp, 3100); 269 | assert_eq!(state.heatsink_temp, 2700); 270 | assert_eq!(state.error, 10); 271 | assert_eq!(state.count, 255); 272 | } 273 | _ => panic!("Wrong packet type"), 274 | } 275 | 276 | assert!(FrozenPacket::parse(BytesMut::from(&hex!("41 00 00 00")[..])).is_err()); 277 | } 278 | 279 | #[test] 280 | fn test_get_firmware() { 281 | assert_eq!( 282 | FrozenPacket::parse(BytesMut::from(&[0x84][..])), 283 | Ok(FrozenPacket::GetFirmware) 284 | ); 285 | assert_eq!( 286 | FrozenPacket::parse(BytesMut::from(&[0x84, 0xFF, 0xFF][..])), 287 | Ok(FrozenPacket::GetFirmware) 288 | ); 289 | } 290 | 291 | #[test] 292 | fn test_unexpected() { 293 | assert_eq!( 294 | FrozenPacket::parse(BytesMut::from(&[0x99, 0x01, 0x02][..])), 295 | Err(PacketError::Unexpected { 296 | subsystem_name: "Frozen", 297 | buf: Bytes::from(&[0x99, 0x01, 0x02][..]) 298 | }) 299 | ); 300 | assert_eq!( 301 | FrozenPacket::parse(BytesMut::from(&[0xFF][..])), 302 | Err(PacketError::Unexpected { 303 | subsystem_name: "Frozen", 304 | buf: Bytes::from(&[0xFF][..]) 305 | }) 306 | ); 307 | } 308 | } 309 | -------------------------------------------------------------------------------- /src/common/packet.rs: -------------------------------------------------------------------------------- 1 | use std::fmt; 2 | 3 | use bytes::{Buf, Bytes, BytesMut}; 4 | use serde::{Deserialize, Serialize}; 5 | use strum_macros::{Display, FromRepr}; 6 | use thiserror::Error; 7 | 8 | #[derive(Error, Debug, PartialEq)] 9 | pub enum PacketError { 10 | #[error("{packet_name} wrong size: expected {expected}, got {actual}. Buffer: {buf:02X?}")] 11 | InvalidSize { 12 | packet_name: &'static str, 13 | expected: usize, 14 | actual: usize, 15 | buf: Bytes, 16 | }, 17 | #[error( 18 | "{packet_name} too small: expected at least {min_size}, got {actual}. Buffer: {buf:02X?}" 19 | )] 20 | TooSmall { 21 | packet_name: &'static str, 22 | min_size: usize, 23 | actual: usize, 24 | buf: Bytes, 25 | }, 26 | #[error("{packet_name} had invalid structure: {error}. Buffer: {buf:02X?}")] 27 | InvalidStructure { 28 | packet_name: &'static str, 29 | error: String, 30 | buf: Bytes, 31 | }, 32 | #[error("UTF-8 decode error: {0}")] 33 | Utf8Error(#[from] std::string::FromUtf8Error), 34 | #[error("Unexpected pong code: 0x{0:0X}")] 35 | UnexpectedPongCode(u8), 36 | #[error("{packet_name} had invalid bed side value: {bed_side}")] 37 | InvalidBedSide { 38 | packet_name: &'static str, 39 | bed_side: u8, 40 | }, 41 | #[error("{subsystem_name} got unexpected packet: {buf:02X?}")] 42 | Unexpected { 43 | subsystem_name: &'static str, 44 | buf: Bytes, 45 | }, 46 | } 47 | 48 | #[derive(Debug, Clone, Copy, Serialize, Deserialize, Display, FromRepr, PartialEq, Eq)] 49 | #[repr(u8)] 50 | pub enum BedSide { 51 | Left = 0x00, 52 | Right = 0x01, 53 | } 54 | 55 | pub trait Packet: Sized { 56 | fn parse(buf: BytesMut) -> Result; 57 | } 58 | 59 | pub fn validate_packet_size( 60 | packet_name: &'static str, 61 | buf: &BytesMut, 62 | expected: usize, 63 | ) -> Result<(), PacketError> { 64 | if buf.len() != expected { 65 | return Err(PacketError::InvalidSize { 66 | packet_name, 67 | expected, 68 | actual: buf.len(), 69 | buf: buf.clone().freeze(), 70 | }); 71 | } 72 | Ok(()) 73 | } 74 | 75 | pub fn validate_packet_at_least( 76 | packet_name: &'static str, 77 | buf: &BytesMut, 78 | min_size: usize, 79 | ) -> Result<(), PacketError> { 80 | if buf.len() < min_size { 81 | return Err(PacketError::TooSmall { 82 | packet_name, 83 | min_size, 84 | actual: buf.len(), 85 | buf: buf.clone().freeze(), 86 | }); 87 | } 88 | Ok(()) 89 | } 90 | 91 | pub fn invalid_structure(packet_name: &'static str, error: String, buf: BytesMut) -> PacketError { 92 | PacketError::InvalidStructure { 93 | packet_name, 94 | error, 95 | buf: buf.freeze(), 96 | } 97 | } 98 | 99 | /// returns true next state is firmware mode 100 | pub fn parse_pong(packet_name: &'static str, buf: BytesMut) -> Result { 101 | validate_packet_size(packet_name, &buf, 3)?; 102 | 103 | if buf[0] == 0x81 && buf[1] != 0 { 104 | return Err(invalid_structure( 105 | "Pong", 106 | "missing reserved bytes".to_string(), 107 | buf, 108 | )); 109 | } 110 | 111 | match buf[2] { 112 | 0b0100_0110 => Ok(true), 113 | 0b0100_0010 => Ok(false), 114 | _ => Err(PacketError::UnexpectedPongCode(buf[2])), 115 | } 116 | } 117 | 118 | pub fn parse_message(packet_name: &'static str, mut buf: BytesMut) -> Result { 119 | validate_packet_at_least(packet_name, &buf, 3)?; 120 | buf.advance(2); 121 | Ok(String::from_utf8(buf.into())?) 122 | } 123 | 124 | pub fn parse_jumping_to_firmware( 125 | packet_name: &'static str, 126 | buf: BytesMut, 127 | ) -> Result { 128 | validate_packet_size(packet_name, &buf, 2)?; 129 | Ok(buf[1]) 130 | } 131 | 132 | pub fn parse_hardware_info( 133 | packet_name: &'static str, 134 | buf: BytesMut, 135 | ) -> Result { 136 | let (status_code, hardware_info): (u8, HardwareInfo) = cbor4ii::serde::from_slice(&buf) 137 | .map_err(|e| { 138 | invalid_structure( 139 | packet_name, 140 | format!("Failed to parse CBOR hardware info: {e}"), 141 | buf, 142 | ) 143 | })?; 144 | 145 | if status_code != 0 { 146 | log::warn!("Unexpected {packet_name} status code: {status_code}"); 147 | } 148 | 149 | Ok(hardware_info) 150 | } 151 | 152 | #[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] 153 | pub struct HardwareInfo { 154 | #[serde(rename = "devicesn")] 155 | pub serial_number: u32, 156 | #[serde(rename = "pn")] 157 | pub part_number: u32, 158 | pub sku: u32, 159 | pub hwrev: u32, 160 | pub factoryline: u32, 161 | pub datecode: u32, 162 | } 163 | 164 | impl fmt::Display for HardwareInfo { 165 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 166 | write!( 167 | f, 168 | "SN {:08x} PN {} SKU {} HWREV {:04x} FACTORYFLAG {} DATECODE {:06x}", 169 | self.serial_number, 170 | self.part_number, 171 | self.sku, 172 | self.hwrev, 173 | self.factoryline, 174 | self.datecode 175 | ) 176 | } 177 | } 178 | 179 | #[cfg(test)] 180 | mod tests { 181 | use super::*; 182 | use bytes::BytesMut; 183 | use hex_literal::hex; 184 | 185 | #[test] 186 | fn test_parse_pong_firmware() { 187 | let buf = BytesMut::from(&[0x81, 0x00, 0b0100_0110][..]); 188 | assert!(parse_pong("Test/Pong", buf).unwrap()); 189 | } 190 | 191 | #[test] 192 | fn test_parse_pong_bootloader() { 193 | let buf = BytesMut::from(&[0x81, 0x00, 0b0100_0010][..]); 194 | assert!(!parse_pong("Test/Pong", buf).unwrap()); 195 | } 196 | 197 | #[test] 198 | fn test_parse_pong_frozen_format() { 199 | let buf = BytesMut::from(&[0x81, 0x00, 0b0100_0110][..]); 200 | assert!(parse_pong("Test/Pong", buf).unwrap()); 201 | } 202 | 203 | #[test] 204 | fn test_parse_pong_invalid_size() { 205 | let buf = BytesMut::from(&[0x81, 0x00][..]); 206 | match parse_pong("Test/Pong", buf) { 207 | Err(PacketError::InvalidSize { 208 | packet_name: _, 209 | expected, 210 | actual, 211 | buf: _, 212 | }) => { 213 | assert_eq!(expected, 3); 214 | assert_eq!(actual, 2); 215 | } 216 | _ => panic!("Expected InvalidSize error"), 217 | } 218 | } 219 | 220 | #[test] 221 | fn test_parse_pong_invalid_spacer() { 222 | let buf = BytesMut::from(&[0x81, 0xFF, 0b0100_0110][..]); 223 | match parse_pong("Test/Pong", buf) { 224 | Err(PacketError::InvalidStructure { .. }) => {} 225 | _ => panic!("Expected InvalidStructure error"), 226 | } 227 | } 228 | 229 | #[test] 230 | fn test_parse_pong_unexpected_code() { 231 | let buf = BytesMut::from(&[0x81, 0x00, 0xFF][..]); 232 | match parse_pong("Test/Pong", buf) { 233 | Err(PacketError::UnexpectedPongCode(code)) => { 234 | assert_eq!(code, 0xFF); 235 | } 236 | _ => panic!("Expected UnexpectedPongCode error"), 237 | } 238 | } 239 | 240 | #[test] 241 | fn test_parse_message_valid() { 242 | let msg = "Hello World"; 243 | let mut buf = BytesMut::from(&[0x07, 0x00][..]); 244 | buf.extend_from_slice(msg.as_bytes()); 245 | 246 | assert_eq!(parse_message("Test/Message", buf).unwrap(), "Hello World"); 247 | } 248 | 249 | #[test] 250 | fn test_parse_message_empty() { 251 | let buf = BytesMut::from(&[0x07, 0x00][..]); 252 | match parse_message("Test/Message", buf) { 253 | Err(PacketError::TooSmall { 254 | packet_name: _, 255 | min_size, 256 | actual, 257 | buf: _, 258 | }) => { 259 | assert_eq!(min_size, 3); 260 | assert_eq!(actual, 2); 261 | } 262 | _ => panic!("Expected InvalidSize error"), 263 | } 264 | } 265 | 266 | #[test] 267 | fn test_parse_message_utf8_valid() { 268 | let msg = "Hello 世界"; 269 | let mut buf = BytesMut::from(&[0x07, 0x00][..]); 270 | buf.extend_from_slice(msg.as_bytes()); 271 | 272 | assert_eq!(parse_message("Test/Message", buf).unwrap(), "Hello 世界"); 273 | } 274 | 275 | #[test] 276 | fn test_parse_message_invalid_utf8() { 277 | let buf = BytesMut::from(&[0x07, 0x00, 0xFF, 0xFE, 0xFD, 0xFC, 0xFB][..]); 278 | match parse_message("Test/Message", buf) { 279 | Err(PacketError::Utf8Error(_)) => {} 280 | _ => panic!("Expected Utf8Error"), 281 | } 282 | } 283 | 284 | #[test] 285 | fn test_parse_jumping_to_firmware_valid() { 286 | let buf = BytesMut::from(&[0x90, 0x00][..]); 287 | assert_eq!( 288 | parse_jumping_to_firmware("Test/JumpingToFirmware", buf).unwrap(), 289 | 0x00 290 | ); 291 | 292 | let buf = BytesMut::from(&[0x90, 0x10][..]); 293 | assert_eq!( 294 | parse_jumping_to_firmware("Test/JumpingToFirmware", buf).unwrap(), 295 | 0x10 296 | ); 297 | 298 | let buf = BytesMut::from(&[0x90, 0xFF][..]); 299 | assert_eq!( 300 | parse_jumping_to_firmware("Test/JumpingToFirmware", buf).unwrap(), 301 | 0xFF 302 | ); 303 | } 304 | 305 | #[test] 306 | fn test_parse_jumping_to_firmware_invalid_size() { 307 | let buf = BytesMut::from(&[0x90][..]); 308 | match parse_jumping_to_firmware("Test/JumpingToFirmware", buf) { 309 | Err(PacketError::InvalidSize { 310 | packet_name: _, 311 | expected, 312 | actual, 313 | buf: _, 314 | }) => { 315 | assert_eq!(expected, 2); 316 | assert_eq!(actual, 1); 317 | } 318 | _ => panic!("Expected InvalidSize error"), 319 | } 320 | 321 | let buf = BytesMut::from(&[0x90, 0x00, 0x00][..]); 322 | match parse_jumping_to_firmware("Test/JumpingToFirmware", buf) { 323 | Err(PacketError::InvalidSize { 324 | packet_name: _, 325 | expected, 326 | actual, 327 | buf: _, 328 | }) => { 329 | assert_eq!(expected, 2); 330 | assert_eq!(actual, 3); 331 | } 332 | _ => panic!("Expected InvalidSize error"), 333 | } 334 | } 335 | 336 | #[test] 337 | fn test_hardware_info() { 338 | let data = hex!( 339 | " 340 | 82 00 A6 63 73 6B 75 01 68 64 61 74 65 341 | 63 6F 64 65 1A 00 16 01 0D 6B 66 61 63 342 | 74 6F 72 79 6C 69 6E 65 01 65 68 77 72 343 | 65 76 19 05 00 62 70 6E 19 50 78 68 64 344 | 65 76 69 63 65 73 6E 1A 00 01 08 9C FF 345 | FF FF FF FF FF FF FF FF FF FF FF FF FF 346 | FF FF FF FF FF FF FF FF FF FF FF FF FF 347 | FF FF FF FF FF FF FF FF FF FF FF FF FF 348 | FF FF FF FF FF FF FF FF FF FF FF FF FF 349 | FF FF FF FF FF FF FF FF FF FF FF FF FF 350 | " 351 | ); 352 | 353 | let result = parse_hardware_info("Test/HardwareInfo", BytesMut::from(&data[..])).unwrap(); 354 | assert_eq!(result.serial_number, 0x0001089C); 355 | assert_eq!(result.part_number, 20600); 356 | assert_eq!(result.sku, 1); 357 | assert_eq!(result.hwrev, 0x0500); 358 | assert_eq!(result.factoryline, 1); 359 | assert_eq!(result.datecode, 0x16010D); 360 | } 361 | } 362 | -------------------------------------------------------------------------------- /src/mqtt.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | NAME, VERSION, 3 | config::{ 4 | self, Config, 5 | mqtt::{TOPIC_SET_AWAY_MODE, TOPIC_SET_PRESENCE, TOPIC_SET_PRIME, TOPIC_SET_PROFILE}, 6 | }, 7 | sensor::presence::TOPIC_CALIBRATE, 8 | }; 9 | use rumqttc::{ 10 | AsyncClient, ConnectionError, Event, EventLoop, LastWill, MqttOptions, Packet, Publish, QoS, 11 | }; 12 | use std::{fmt::Display, time::Duration}; 13 | use tokio::{ 14 | sync::{mpsc, watch}, 15 | time::{sleep, timeout}, 16 | }; 17 | 18 | const TOPIC_AVAILABILITY: &str = "opensleep/availability"; 19 | const ONLINE: &str = "online"; 20 | const OFFLINE: &str = "offline"; 21 | 22 | const TOPIC_DEVICE_NAME: &str = "opensleep/device/name"; 23 | const TOPIC_DEVICE_VERSION: &str = "opensleep/device/version"; 24 | const TOPIC_DEVICE_LABEL: &str = "opensleep/device/label"; 25 | 26 | const TOPIC_RESULT_ACTION: &str = "opensleep/result/action"; 27 | const TOPIC_RESULT_STATUS: &str = "opensleep/result/status"; 28 | const TOPIC_RESULT_MSG: &str = "opensleep/result/message"; 29 | 30 | const SUCCESS: &str = "success"; 31 | const ERROR: &str = "error"; 32 | 33 | pub struct MqttManager { 34 | config_tx: watch::Sender, 35 | config_rx: watch::Receiver, 36 | calibrate_tx: mpsc::Sender<()>, 37 | pub client: AsyncClient, 38 | eventloop: EventLoop, 39 | device_label: String, 40 | reconnect_attempts: u32, 41 | } 42 | 43 | impl MqttManager { 44 | pub fn new( 45 | config_tx: watch::Sender, 46 | config_rx: watch::Receiver, 47 | calibrate_tx: mpsc::Sender<()>, 48 | device_label: String, 49 | ) -> Self { 50 | log::info!("Initializing MQTT..."); 51 | 52 | let cfg = config_rx.borrow().mqtt.clone(); 53 | 54 | log::info!( 55 | "Connecting to MQTT broker at {}:{} as user '{}'", 56 | cfg.server, 57 | cfg.port, 58 | cfg.user 59 | ); 60 | 61 | let mut opts = MqttOptions::new("opensleep", &cfg.server, cfg.port); 62 | opts.set_keep_alive(Duration::from_secs(60)); 63 | opts.set_credentials(&cfg.user, &cfg.password); 64 | opts.set_last_will(LastWill { 65 | topic: TOPIC_AVAILABILITY.to_string(), 66 | message: OFFLINE.into(), 67 | qos: QoS::ExactlyOnce, 68 | retain: false, 69 | }); 70 | 71 | let (client, eventloop) = AsyncClient::new(opts, 10); 72 | 73 | Self { 74 | config_tx, 75 | config_rx, 76 | calibrate_tx, 77 | client, 78 | eventloop, 79 | device_label, 80 | reconnect_attempts: 0, 81 | } 82 | } 83 | 84 | pub async fn wait_for_conn(&mut self) -> Result<(), ()> { 85 | loop { 86 | let evt = self.eventloop.poll().await; 87 | match self.handle_event(evt).await { 88 | Ok(true) => return Ok(()), 89 | // keep waiting for connection 90 | Ok(false) => {} 91 | // fatal error 92 | Err(_) => return Err(()), 93 | } 94 | } 95 | } 96 | 97 | pub async fn run(&mut self) { 98 | loop { 99 | let evt = self.eventloop.poll().await; 100 | if self.handle_event(evt).await.is_err() { 101 | // only errors on fatal errors, so `run` should 102 | // quit, shutting down all of opensleep 103 | return; 104 | } 105 | } 106 | } 107 | 108 | /// returns Ok(true) on ConnAck, Err(()) for fatal errors 109 | async fn handle_event(&mut self, msg: Result) -> Result { 110 | match msg { 111 | Ok(Event::Incoming(Packet::ConnAck(_))) => { 112 | log::info!("MQTT broker connected"); 113 | self.reconnect_attempts = 0; 114 | self.spawn_new_conn_task().await; 115 | return Ok(true); 116 | } 117 | Ok(Event::Incoming(Packet::Disconnect)) => { 118 | log::warn!("MQTT broker disconnected"); 119 | } 120 | Ok(Event::Incoming(Packet::Publish(publ))) => { 121 | self.handle_action(publ).await; 122 | } 123 | Ok(_) => {} 124 | 125 | // connection errors 126 | Err(ConnectionError::Io(e)) => { 127 | self.reconnect_attempts += 1; 128 | let backoff = self.calc_backoff(); 129 | log::error!("I/O error: {e}. Reconnecting in {backoff:?}..."); 130 | sleep(backoff).await; 131 | } 132 | Err(ConnectionError::ConnectionRefused(code)) => { 133 | self.reconnect_attempts += 1; 134 | let backoff = self.calc_backoff(); 135 | log::error!("Connection refused ({code:?}). Reconnecting in {backoff:?}..."); 136 | sleep(backoff).await; 137 | } 138 | Err(ConnectionError::NetworkTimeout) => { 139 | self.reconnect_attempts += 1; 140 | let backoff = self.calc_backoff(); 141 | log::error!("Network timeout. Reconnecting in {backoff:?}..."); 142 | sleep(backoff).await; 143 | } 144 | Err(ConnectionError::Tls(e)) => { 145 | self.reconnect_attempts += 1; 146 | let backoff = self.calc_backoff(); 147 | log::error!("TLS error: {e}. Reconnecting in {backoff:?}..."); 148 | sleep(backoff).await; 149 | } 150 | 151 | // state errors 152 | Err(ConnectionError::MqttState(e)) => { 153 | log::error!("State error: {e}"); 154 | sleep(Duration::from_millis(100)).await; 155 | } 156 | Err(ConnectionError::FlushTimeout) => { 157 | log::error!("Flush timeout"); 158 | sleep(Duration::from_millis(100)).await; 159 | } 160 | 161 | // fatal errors 162 | Err(ConnectionError::RequestsDone) => { 163 | log::info!("Requests channel closed"); 164 | return Err(()); 165 | } 166 | 167 | // other 168 | Err(ConnectionError::NotConnAck(packet)) => { 169 | log::error!("Expected ConnAck, got: {packet:?}"); 170 | } 171 | } 172 | Ok(false) 173 | } 174 | 175 | fn calc_backoff(&self) -> Duration { 176 | let secs = (2u64.pow(self.reconnect_attempts.saturating_sub(1))).min(60); 177 | Duration::from_secs(secs) 178 | } 179 | 180 | /// this must be in its own task because publishing 181 | /// topics requires someone polling the event loop 182 | async fn spawn_new_conn_task(&mut self) { 183 | let config = { 184 | let c = self.config_rx.borrow(); 185 | c.clone() 186 | }; 187 | let mut client = self.client.clone(); 188 | let device_label = self.device_label.clone(); 189 | tokio::spawn(async move { 190 | subscribe(&mut client, TOPIC_CALIBRATE).await; 191 | subscribe(&mut client, TOPIC_SET_AWAY_MODE).await; 192 | subscribe(&mut client, TOPIC_SET_PRIME).await; 193 | subscribe(&mut client, TOPIC_SET_PROFILE).await; 194 | subscribe(&mut client, TOPIC_SET_PRESENCE).await; 195 | 196 | config.publish(&mut client).await; 197 | 198 | publish_guaranteed_wait(&mut client, TOPIC_AVAILABILITY, true, ONLINE).await; 199 | publish_guaranteed_wait(&mut client, TOPIC_DEVICE_NAME, true, NAME).await; 200 | publish_guaranteed_wait(&mut client, TOPIC_DEVICE_VERSION, true, VERSION).await; 201 | publish_guaranteed_wait(&mut client, TOPIC_DEVICE_LABEL, true, device_label).await; 202 | }); 203 | } 204 | 205 | /// handles a published action 206 | /// MUST exit quickly without calling any MQTT commands (unless in another task) 207 | async fn handle_action(&mut self, publ: Publish) { 208 | if publ.topic == TOPIC_CALIBRATE { 209 | let (status, msg) = if let Err(e) = self.calibrate_tx.try_send(()) { 210 | let msg = format!("Failed to send to calibrate channel: {e}"); 211 | log::error!("{msg}"); 212 | (ERROR, msg) 213 | } else { 214 | (SUCCESS, "started calibration".to_string()) 215 | }; 216 | let mut client = self.client.clone(); 217 | tokio::spawn(async move { 218 | publish_result(&mut client, "calibrate", status, msg).await; 219 | }); 220 | } else if publ.topic.starts_with("opensleep/actions/set_") { 221 | self.handle_set_action(publ).await; 222 | } else { 223 | log::error!("Unkown action published: {}", publ.topic); 224 | let mut client = self.client.clone(); 225 | tokio::spawn(async move { 226 | publish_result( 227 | &mut client, 228 | "unknown", 229 | ERROR, 230 | format!("unknown action: {}", publ.topic), 231 | ) 232 | .await; 233 | }); 234 | } 235 | } 236 | 237 | /// handles any set_ actions (config changes) 238 | /// MUST exit quickly without calling any MQTT commands (unless in another task) 239 | async fn handle_set_action(&mut self, publ: Publish) { 240 | let mut client = self.client.clone(); 241 | let cfg = self.config_rx.borrow().clone(); 242 | let mut config_tx = self.config_tx.clone(); 243 | 244 | tokio::spawn(async move { 245 | let action = publ.topic.strip_prefix("opensleep/actions/").unwrap(); 246 | let topic = publ.topic.clone(); 247 | let payload = String::from_utf8_lossy(&publ.payload); 248 | 249 | let (status, msg) = match config::mqtt::handle_action( 250 | &mut client, 251 | &topic, 252 | payload.clone(), 253 | &mut config_tx, 254 | cfg, 255 | ) 256 | .await 257 | { 258 | Ok(_) => (SUCCESS, "successfully edited configuration".to_string()), 259 | 260 | Err(e) => { 261 | log::error!("Error handling set action: {e}"); 262 | (ERROR, e.to_string()) 263 | } 264 | }; 265 | 266 | publish_result(&mut client, action, status, msg).await; 267 | }); 268 | } 269 | } 270 | 271 | async fn publish_result(client: &mut AsyncClient, action: &str, status: &str, msg: String) { 272 | publish_guaranteed_wait(client, TOPIC_RESULT_ACTION, false, action).await; 273 | publish_guaranteed_wait(client, TOPIC_RESULT_STATUS, false, status).await; 274 | publish_guaranteed_wait(client, TOPIC_RESULT_MSG, false, msg).await; 275 | } 276 | 277 | async fn subscribe(client: &mut AsyncClient, topic: &'static str) { 278 | log::debug!("Subscribing to {topic}"); 279 | match client.subscribe(topic, QoS::AtLeastOnce).await { 280 | Ok(_) => { 281 | log::debug!("Subscribed to {topic}"); 282 | } 283 | Err(e) => { 284 | log::error!("Failed to subscribe to {topic}: {e}"); 285 | } 286 | } 287 | } 288 | 289 | pub async fn publish_guaranteed_wait( 290 | client: &mut AsyncClient, 291 | topic: S, 292 | retain: bool, 293 | payload: V, 294 | ) where 295 | S: Into + Display + Clone, 296 | V: Into>, 297 | { 298 | let fut = client.publish(topic.clone(), QoS::ExactlyOnce, retain, payload); 299 | 300 | match timeout(Duration::from_millis(100), fut).await { 301 | Ok(Ok(())) => {} 302 | Ok(Err(e)) => { 303 | log::error!("Error publishing {topic}: {e}"); 304 | } 305 | Err(_) => { 306 | log::error!("Timed out publishing {topic}"); 307 | } 308 | } 309 | } 310 | 311 | pub fn publish_high_freq(client: &mut AsyncClient, topic: S, payload: V) 312 | where 313 | S: Into + Display + Clone, 314 | V: Into>, 315 | { 316 | if let Err(e) = client.try_publish(topic.clone(), QoS::AtMostOnce, false, payload) { 317 | log::error!("Error publishing to {topic}: {e}",); 318 | } 319 | } 320 | -------------------------------------------------------------------------------- /src/config/mqtt.rs: -------------------------------------------------------------------------------- 1 | use std::{borrow::Cow, error::Error}; 2 | 3 | use crate::{ 4 | config::{PresenceConfig, SideConfig}, 5 | mqtt::publish_guaranteed_wait, 6 | }; 7 | 8 | use super::{AlarmConfig, CONFIG_FILE, Config, SidesConfig}; 9 | use jiff::civil::Time; 10 | use rumqttc::AsyncClient; 11 | use tokio::sync::watch; 12 | 13 | const TOPIC_TIMEZONE: &str = "opensleep/state/config/timezone"; 14 | const TOPIC_AWAY_MODE: &str = "opensleep/state/config/away_mode"; 15 | const TOPIC_PRIME: &str = "opensleep/state/config/prime"; 16 | 17 | const TOPIC_LED_IDLE: &str = "opensleep/state/config/led/idle"; 18 | const TOPIC_LED_ACTIVE: &str = "opensleep/state/config/led/active"; 19 | const TOPIC_LED_BAND: &str = "opensleep/state/config/led/band"; 20 | 21 | const TOPIC_PROFILE_TYPE: &str = "opensleep/state/config/profile/type"; 22 | 23 | const TOPIC_PROFILE_LEFT_SLEEP: &str = "opensleep/state/config/profile/left/sleep"; 24 | const TOPIC_PROFILE_LEFT_WAKE: &str = "opensleep/state/config/profile/left/wake"; 25 | const TOPIC_PROFILE_LEFT_TEMPERATURES: &str = "opensleep/state/config/profile/left/temperatures"; 26 | const TOPIC_PROFILE_LEFT_ALARM: &str = "opensleep/state/config/profile/left/alarm"; 27 | 28 | const TOPIC_PROFILE_RIGHT_SLEEP: &str = "opensleep/state/config/profile/right/sleep"; 29 | const TOPIC_PROFILE_RIGHT_WAKE: &str = "opensleep/state/config/profile/right/wake"; 30 | const TOPIC_PROFILE_RIGHT_TEMPERATURES: &str = "opensleep/state/config/profile/right/temperatures"; 31 | const TOPIC_PROFILE_RIGHT_ALARM: &str = "opensleep/state/config/profile/right/alarm"; 32 | 33 | const TOPIC_PRESENCE_BASELINES: &str = "opensleep/state/config/presence/baselines"; 34 | const TOPIC_PRESENCE_THRESHOLD: &str = "opensleep/state/config/presence/threshold"; 35 | const TOPIC_PRESENCE_DEBOUNCE_COUNT: &str = "opensleep/state/config/presence/debounce_count"; 36 | 37 | pub const TOPIC_SET_AWAY_MODE: &str = "opensleep/actions/set_away_mode"; 38 | pub const TOPIC_SET_PRIME: &str = "opensleep/actions/set_prime"; 39 | pub const TOPIC_SET_PROFILE: &str = "opensleep/actions/set_profile"; 40 | pub const TOPIC_SET_PRESENCE: &str = "opensleep/actions/set_presence_config"; 41 | 42 | impl PresenceConfig { 43 | async fn publish(&self, client: &mut AsyncClient) { 44 | publish_guaranteed_wait( 45 | client, 46 | TOPIC_PRESENCE_BASELINES, 47 | true, 48 | self.baselines 49 | .iter() 50 | .map(|e| e.to_string()) 51 | .collect::>() 52 | .join(","), 53 | ) 54 | .await; 55 | 56 | publish_guaranteed_wait( 57 | client, 58 | TOPIC_PRESENCE_THRESHOLD, 59 | true, 60 | self.threshold.to_string(), 61 | ) 62 | .await; 63 | publish_guaranteed_wait( 64 | client, 65 | TOPIC_PRESENCE_DEBOUNCE_COUNT, 66 | true, 67 | self.debounce_count.to_string(), 68 | ) 69 | .await; 70 | } 71 | } 72 | 73 | impl SidesConfig { 74 | async fn publish(&self, client: &mut AsyncClient) { 75 | match &self { 76 | SidesConfig::Solo(solo) => { 77 | publish_guaranteed_wait(client, TOPIC_PROFILE_TYPE, true, "solo").await; 78 | publish_left_profile(client, solo).await; 79 | } 80 | SidesConfig::Couples { left, right } => { 81 | publish_guaranteed_wait(client, TOPIC_PROFILE_TYPE, true, "couples").await; 82 | 83 | publish_left_profile(client, left).await; 84 | 85 | publish_profile( 86 | client, 87 | right, 88 | TOPIC_PROFILE_RIGHT_SLEEP, 89 | TOPIC_PROFILE_RIGHT_WAKE, 90 | TOPIC_PROFILE_RIGHT_TEMPERATURES, 91 | TOPIC_PROFILE_RIGHT_ALARM, 92 | ) 93 | .await; 94 | } 95 | } 96 | } 97 | } 98 | 99 | impl Config { 100 | pub async fn publish(&self, client: &mut AsyncClient) { 101 | log::debug!("Publishing config.."); 102 | publish_guaranteed_wait( 103 | client, 104 | TOPIC_TIMEZONE, 105 | true, 106 | self.timezone.iana_name().unwrap_or("ERROR"), 107 | ) 108 | .await; 109 | 110 | publish_away_mode(client, self.away_mode).await; 111 | 112 | publish_prime(client, self.prime).await; 113 | 114 | // led 115 | publish_guaranteed_wait(client, TOPIC_LED_IDLE, true, format!("{:?}", self.led.idle)).await; 116 | publish_guaranteed_wait( 117 | client, 118 | TOPIC_LED_ACTIVE, 119 | true, 120 | format!("{:?}", self.led.active), 121 | ) 122 | .await; 123 | publish_guaranteed_wait(client, TOPIC_LED_BAND, true, self.led.band.to_string()).await; 124 | 125 | // presence 126 | if let Some(presence) = &self.presence { 127 | presence.publish(client).await; 128 | } 129 | 130 | self.profile.publish(client).await; 131 | 132 | log::debug!("Published config"); 133 | } 134 | } 135 | 136 | async fn publish_prime(client: &mut AsyncClient, value: Time) { 137 | publish_guaranteed_wait(client, TOPIC_PRIME, true, value.to_string()).await; 138 | } 139 | 140 | async fn publish_away_mode(client: &mut AsyncClient, mode: bool) { 141 | publish_guaranteed_wait(client, TOPIC_AWAY_MODE, true, mode.to_string()).await; 142 | } 143 | 144 | async fn publish_left_profile(client: &mut AsyncClient, side: &SideConfig) { 145 | publish_profile( 146 | client, 147 | side, 148 | TOPIC_PROFILE_LEFT_SLEEP, 149 | TOPIC_PROFILE_LEFT_WAKE, 150 | TOPIC_PROFILE_LEFT_TEMPERATURES, 151 | TOPIC_PROFILE_LEFT_ALARM, 152 | ) 153 | .await; 154 | } 155 | 156 | async fn publish_profile( 157 | client: &mut AsyncClient, 158 | side: &SideConfig, 159 | topic_sleep: &'static str, 160 | topic_wake: &'static str, 161 | topic_temps: &'static str, 162 | topic_alarm: &'static str, 163 | ) { 164 | publish_guaranteed_wait(client, topic_sleep, true, side.sleep.to_string()).await; 165 | publish_guaranteed_wait(client, topic_wake, true, side.wake.to_string()).await; 166 | publish_guaranteed_wait( 167 | client, 168 | topic_temps, 169 | true, 170 | temps_to_string(&side.temperatures), 171 | ) 172 | .await; 173 | publish_guaranteed_wait(client, topic_alarm, true, alarm_to_string(&side.alarm)).await; 174 | } 175 | 176 | pub async fn handle_action( 177 | client: &mut AsyncClient, 178 | topic: &str, 179 | payload: Cow<'_, str>, 180 | config_tx: &mut watch::Sender, 181 | mut cfg: Config, 182 | ) -> Result<(), Box> { 183 | // modify config 184 | match topic { 185 | TOPIC_SET_AWAY_MODE => { 186 | cfg.away_mode = payload.trim().parse()?; 187 | log::info!("Set away_mode to {}", cfg.away_mode); 188 | publish_away_mode(client, cfg.away_mode).await; 189 | } 190 | 191 | TOPIC_SET_PRIME => { 192 | cfg.prime = payload.trim().parse()?; 193 | log::info!("Set prime time to {}", cfg.prime); 194 | publish_prime(client, cfg.prime).await; 195 | } 196 | 197 | TOPIC_SET_PROFILE => { 198 | // TARGET.FIELD=VALUE 199 | let (target, rhs) = payload 200 | .trim() 201 | .split_once('.') 202 | .ok_or("Invalid input. Requires `TARGET.FIELD=VALUE`")?; 203 | 204 | let (field, value) = rhs 205 | .trim() 206 | .split_once('=') 207 | .ok_or("Invalid input. Requires `TARGET.FIELD=VALUE`")?; 208 | 209 | if ["left", "right"].contains(&target) && cfg.profile.is_solo() { 210 | return Err( 211 | "Cannot modify profile in `couples` mode (currently in `solo` mode)".into(), 212 | ); 213 | } 214 | 215 | let profile = match target { 216 | "left" => cfg.profile.unwrap_left_mut(), 217 | "right" => cfg.profile.unwrap_right_mut(), 218 | "both" => { 219 | if cfg.profile.is_couples() { 220 | return Err( 221 | "Cannot modify profile in `solo` mode (currently in `couples` mode)" 222 | .into(), 223 | ); 224 | } 225 | 226 | cfg.profile.unwrap_solo_mut() 227 | } 228 | _ => return Err("Invalid TARGET. Must be `left`, `right`, or `both`".into()), 229 | }; 230 | 231 | match field { 232 | "sleep" => { 233 | profile.sleep = value.parse()?; 234 | } 235 | "wake" => { 236 | profile.wake = value.parse()?; 237 | } 238 | "temperatures" => { 239 | profile.temperatures = parse_temperatures(value)?; 240 | } 241 | "alarm" => { 242 | profile.alarm = parse_alarm(value)?; 243 | } 244 | _ => { 245 | return Err( 246 | "Invalid FIELD. Must be `sleep`, `wake`, `temperatures`, or `alarm`".into(), 247 | ); 248 | } 249 | } 250 | 251 | log::info!("Updated profile ({target}::{field} -> {value})"); 252 | cfg.profile.publish(client).await; 253 | } 254 | 255 | TOPIC_SET_PRESENCE => { 256 | if cfg.presence.is_none() { 257 | return Err("Cannot modify non-existant presense configuration. Please call `actions/calibrate` first!".into()); 258 | } 259 | 260 | let (field, value) = payload 261 | .trim() 262 | .split_once('=') 263 | .ok_or("Invalid input. Requires `FIELD=VALUE`")?; 264 | 265 | match field { 266 | "baselines" => { 267 | cfg.presence.as_mut().unwrap().baselines = parse_baselines(value)?; 268 | } 269 | "threshold" => { 270 | cfg.presence.as_mut().unwrap().threshold = value.trim().parse()?; 271 | } 272 | "debounce_count" => { 273 | cfg.presence.as_mut().unwrap().debounce_count = value.trim().parse()?; 274 | } 275 | _ => return Err("Unknown field".into()), 276 | } 277 | 278 | log::info!("Update presence config ({field} -> {value})"); 279 | cfg.presence.as_ref().unwrap().publish(client).await; 280 | } 281 | 282 | topic => { 283 | return Err(format!("Publish to unknown config topic: {topic}").into()); 284 | } 285 | } 286 | 287 | // notify others 288 | if let Err(e) = config_tx.send(cfg.clone()) { 289 | return Err(format!("Error sending to config watch channel: {e}").into()); 290 | } 291 | 292 | // save to file 293 | if let Err(e) = cfg.save(CONFIG_FILE).await { 294 | return Err(format!("Failed to save config: {e}").into()); 295 | } 296 | log::debug!("Config saved to disk"); 297 | 298 | Ok(()) 299 | } 300 | 301 | fn parse_temperatures(value: &str) -> Result, String> { 302 | value 303 | .trim() 304 | .split(',') 305 | .map(|s| s.trim().parse::().map_err(|e| e.to_string())) 306 | .collect() 307 | } 308 | 309 | fn parse_alarm(value: &str) -> Result, String> { 310 | let trimmed = value.trim(); 311 | 312 | if trimmed == "disabled" { 313 | return Ok(None); 314 | } 315 | 316 | let parts: Vec<&str> = trimmed.split(',').collect(); 317 | if parts.len() != 4 { 318 | return Err(format!( 319 | "Expected 4 comma-separated values or 'disabled', got {}", 320 | parts.len() 321 | )); 322 | } 323 | 324 | let pattern = parts[0] 325 | .trim() 326 | .parse() 327 | .map_err(|e| format!("Invalid pattern: {e}"))?; 328 | let intensity = parts[1] 329 | .trim() 330 | .parse() 331 | .map_err(|e| format!("Invalid intensity: {e}"))?; 332 | let duration = parts[2] 333 | .trim() 334 | .parse() 335 | .map_err(|e| format!("Invalid duration: {e}"))?; 336 | let offset = parts[3] 337 | .trim() 338 | .parse() 339 | .map_err(|e| format!("Invalid offset: {e}"))?; 340 | 341 | Ok(Some(AlarmConfig { 342 | pattern, 343 | intensity, 344 | duration, 345 | offset, 346 | })) 347 | } 348 | 349 | fn parse_baselines(value: &str) -> Result<[u16; 6], String> { 350 | let values: Result, _> = value 351 | .trim() 352 | .split(',') 353 | .map(|s| s.trim().parse::().map_err(|e| e.to_string())) 354 | .collect(); 355 | 356 | let values = values?; 357 | 358 | if values.len() != 6 { 359 | return Err(format!( 360 | "Expected exactly 6 baseline values, got {}", 361 | values.len() 362 | )); 363 | } 364 | 365 | Ok([ 366 | values[0], values[1], values[2], values[3], values[4], values[5], 367 | ]) 368 | } 369 | 370 | fn alarm_to_string(alarm: &Option) -> String { 371 | match alarm { 372 | Some(a) => { 373 | format!("{},{},{},{}", a.pattern, a.intensity, a.duration, a.offset) 374 | } 375 | None => "disabled".to_string(), 376 | } 377 | } 378 | 379 | fn temps_to_string(temps: &Vec) -> String { 380 | temps 381 | .iter() 382 | .map(|e| e.to_string()) 383 | .collect::>() 384 | .join(",") 385 | } 386 | -------------------------------------------------------------------------------- /src/sensor/manager.rs: -------------------------------------------------------------------------------- 1 | use std::io::ErrorKind; 2 | use std::time::Duration; 3 | 4 | use crate::common::codec::PacketCodec; 5 | use crate::common::packet::BedSide; 6 | use crate::common::serial::{DeviceMode, SerialError, create_framed_port}; 7 | use crate::config::{Config, SidesConfig}; 8 | use crate::sensor::command::{AlarmCommand, AlarmPattern}; 9 | use crate::sensor::presence::PresenseManager; 10 | use crate::sensor::state::{PIEZO_FREQ, PIEZO_GAIN, SensorState}; 11 | use crate::sensor::{SensorCommand, SensorPacket}; 12 | use futures_util::stream::{SplitSink, SplitStream}; 13 | use futures_util::{SinkExt, StreamExt}; 14 | use jiff::civil::Time; 15 | use jiff::{Span, Timestamp}; 16 | use rumqttc::AsyncClient; 17 | use thiserror::Error; 18 | use tokio::sync::{mpsc, watch}; 19 | use tokio::time::{Instant, interval, timeout}; 20 | use tokio_serial::SerialStream; 21 | use tokio_util::codec::Framed; 22 | 23 | pub const PORT: &str = "/dev/ttymxc0"; 24 | const BOOTLOADER_BAUD: u32 = 38400; 25 | const FIRMWARE_BAUD: u32 = 115200; 26 | const TIMEOUT: Duration = Duration::from_secs(5); 27 | 28 | type Reader = SplitStream>>; 29 | type Writer = SplitSink>, SensorCommand>; 30 | type CommandCheck = fn(&SensorState, &Time, &bool, &SidesConfig) -> Option; 31 | 32 | struct CommandScheduler { 33 | cmds: Vec, 34 | away_mode: bool, 35 | sides_config: SidesConfig, 36 | writer: Writer, 37 | } 38 | 39 | struct RegisteredCommand { 40 | name: &'static str, 41 | interval: Duration, 42 | last_run: Instant, 43 | can_run: CommandCheck, 44 | } 45 | 46 | #[derive(Error, Debug)] 47 | pub enum SensorError { 48 | #[error("Serial: {0}")] 49 | Serial(#[from] SerialError), 50 | #[error("Sensor not responding")] 51 | Timeout, 52 | } 53 | 54 | pub async fn run( 55 | port: &'static str, 56 | config_tx: watch::Sender, 57 | mut config_rx: watch::Receiver, 58 | mut calibrate_rx: mpsc::Receiver<()>, 59 | mut client: AsyncClient, 60 | ) -> Result<(), SensorError> { 61 | log::info!("Initializing Sensor Subsystem..."); 62 | 63 | let mut presense_man = PresenseManager::new(config_tx, config_rx.clone(), client.clone()); 64 | 65 | let mut state = SensorState::default(); 66 | state.publish_reset(&mut client).await; 67 | 68 | let (writer, mut reader) = run_discovery(port, &mut client, &mut state).await?; 69 | log::info!("Connected"); 70 | 71 | let cfg = config_rx.borrow_and_update(); 72 | let timezone = cfg.timezone.clone(); 73 | let mut scheduler = CommandScheduler::new(cfg.away_mode, cfg.profile.clone(), writer); 74 | drop(cfg); 75 | 76 | let mut interval = interval(Duration::from_millis(50)); 77 | let mut last_recv = Instant::now(); 78 | 79 | loop { 80 | tokio::select! { 81 | Some(result) = reader.next() => match result { 82 | Ok(packet) => { 83 | if let SensorPacket::Capacitance(data) = &packet { 84 | presense_man.update(data); 85 | } 86 | 87 | state.handle_packet(&mut client, packet).await; 88 | 89 | last_recv = Instant::now(); 90 | } 91 | Err(e) => { 92 | log::error!("Packet decode error: {e}"); 93 | } 94 | }, 95 | 96 | _ = interval.tick() => { 97 | // this is not expensive so its fine to do at 20hz 98 | let now = Timestamp::now().to_zoned(timezone.clone()).time(); 99 | let _ = scheduler.update(&state, &now).await?; 100 | 101 | if Instant::now().duration_since(last_recv) > TIMEOUT { 102 | break Err(SensorError::Timeout); 103 | } 104 | } 105 | 106 | Some(_) = calibrate_rx.recv() => presense_man.start_calibration(), 107 | 108 | Ok(_) = config_rx.changed() => { 109 | let cfg = config_rx.borrow(); 110 | scheduler.away_mode = cfg.away_mode; 111 | scheduler.sides_config = cfg.profile.clone(); 112 | } 113 | } 114 | } 115 | } 116 | 117 | impl CommandScheduler { 118 | fn new(away_mode: bool, sides_config: SidesConfig, writer: Writer) -> Self { 119 | let now = Instant::now(); 120 | const CONFIG_RES_TIME: Duration = Duration::from_millis(800); 121 | Self { 122 | away_mode, 123 | sides_config, 124 | writer, 125 | cmds: vec![ 126 | RegisteredCommand { 127 | name: "ping", 128 | interval: Duration::from_secs(4), 129 | last_run: now, 130 | can_run: |_, _, _, _| Some(SensorCommand::Ping), 131 | }, 132 | RegisteredCommand { 133 | name: "probe_temperature", 134 | interval: Duration::from_secs(4), 135 | // stagger 136 | last_run: now + Duration::from_millis(2500), 137 | can_run: |_, _, _, _| Some(SensorCommand::ProbeTemperature), 138 | }, 139 | RegisteredCommand { 140 | name: "hwinfo", 141 | interval: CONFIG_RES_TIME, 142 | last_run: now, 143 | can_run: |state, _, _, _| { 144 | if state.hardware_info.is_none() { 145 | Some(SensorCommand::GetHardwareInfo) 146 | } else { 147 | None 148 | } 149 | }, 150 | }, 151 | RegisteredCommand { 152 | name: "enable_vibration", 153 | interval: CONFIG_RES_TIME, 154 | last_run: now, 155 | can_run: |s, _, _, _| { 156 | if !s.vibration_enabled { 157 | Some(SensorCommand::EnableVibration) 158 | } else { 159 | None 160 | } 161 | }, 162 | }, 163 | RegisteredCommand { 164 | name: "piezo_gain", 165 | interval: CONFIG_RES_TIME, 166 | last_run: now, 167 | can_run: |state, _, _, _| { 168 | if !state.piezo_gain_ok() { 169 | Some(SensorCommand::SetPiezoGain(PIEZO_GAIN, PIEZO_GAIN)) 170 | } else { 171 | None 172 | } 173 | }, 174 | }, 175 | RegisteredCommand { 176 | name: "piezo_freq", 177 | interval: CONFIG_RES_TIME, 178 | last_run: now, 179 | can_run: |state, _, _, _| { 180 | if state.piezo_enabled && !state.piezo_freq_ok() { 181 | Some(SensorCommand::SetPiezoFreq(PIEZO_FREQ)) 182 | } else { 183 | None 184 | } 185 | }, 186 | }, 187 | RegisteredCommand { 188 | name: "enable_piezo", 189 | interval: CONFIG_RES_TIME, 190 | last_run: now, 191 | can_run: |s, _, _, _| { 192 | if !s.piezo_enabled { 193 | Some(SensorCommand::EnablePiezo) 194 | } else { 195 | None 196 | } 197 | }, 198 | }, 199 | RegisteredCommand { 200 | name: "left_alarm", 201 | interval: Duration::from_secs(5), 202 | last_run: now, 203 | can_run: |state, now, away, sides_cfg| { 204 | if state.vibration_enabled && !away { 205 | get_alarm_cmd(state, now, sides_cfg, &BedSide::Left) 206 | } else { 207 | None 208 | } 209 | }, 210 | }, 211 | RegisteredCommand { 212 | name: "right_alarm", 213 | interval: Duration::from_secs(5), 214 | last_run: now, 215 | can_run: |state, now, away, sides_cfg| { 216 | if state.vibration_enabled && !away { 217 | get_alarm_cmd(state, now, sides_cfg, &BedSide::Right) 218 | } else { 219 | None 220 | } 221 | }, 222 | }, 223 | ], 224 | } 225 | } 226 | 227 | /// finds the first command to send and sends it 228 | /// returns if it send a command 229 | async fn update(&mut self, state: &SensorState, time: &Time) -> Result { 230 | let now = Instant::now(); 231 | 232 | // find command to send 233 | for reg_cmd in &mut self.cmds { 234 | if now.duration_since(reg_cmd.last_run) > reg_cmd.interval 235 | && let Some(sen_cmd) = 236 | (reg_cmd.can_run)(state, time, &self.away_mode, &self.sides_config) 237 | { 238 | reg_cmd.last_run = now; 239 | log::debug!(" -> {:?} (from {})", sen_cmd, reg_cmd.name); 240 | if let Err(e) = self.writer.send(sen_cmd).await { 241 | log::error!("Failed to send {}: {e}", reg_cmd.name); 242 | } 243 | return Ok(true); 244 | } 245 | } 246 | 247 | Ok(false) 248 | } 249 | } 250 | 251 | /// alarm runs from (wake - alarm_offset) to ((wake - alarm_offset) + alarm_duration) 252 | fn get_alarm_cmd( 253 | state: &SensorState, 254 | now: &Time, 255 | sides_config: &SidesConfig, 256 | side: &BedSide, 257 | ) -> Option { 258 | let cfg = sides_config.get_side(side); 259 | let alarm_cfg = cfg.alarm.as_ref()?; 260 | let alarm_start = cfg.wake - Span::new().seconds(alarm_cfg.offset); 261 | let alarm_end = alarm_start + Span::new().seconds(alarm_cfg.duration); 262 | let alarm_running = state.get_alarm_for_side(side); 263 | 264 | if now > &alarm_start && now < &alarm_end { 265 | if !alarm_running { 266 | log::info!("Alarm[{side}] requesting to start"); 267 | return Some(SensorCommand::SetAlarm(AlarmCommand { 268 | side: *side, 269 | intensity: alarm_cfg.intensity, 270 | duration: alarm_cfg.duration, 271 | pattern: alarm_cfg.pattern.clone(), 272 | })); 273 | } 274 | } else if alarm_running { 275 | log::info!("Alarm[{side}] should NOT be running, but is. Trying to cancel."); 276 | // FIXME TODO not working 277 | // return Some(SensorCommand::ClearAlarm); 278 | return Some(SensorCommand::SetAlarm(AlarmCommand { 279 | side: *side, 280 | intensity: 0, 281 | duration: 0, 282 | pattern: AlarmPattern::Double, 283 | })); 284 | } 285 | 286 | None 287 | } 288 | 289 | /// tries to connect to the Sensor subsystem at either bootloader baud or firmware baud 290 | async fn run_discovery( 291 | port: &'static str, 292 | client: &mut AsyncClient, 293 | state: &mut SensorState, 294 | ) -> Result<(Writer, Reader), SerialError> { 295 | // try bootloader first 296 | if let Ok((mut writer, mut reader)) = 297 | ping_device(port, client, state, DeviceMode::Bootloader).await 298 | { 299 | writer 300 | .send(SensorCommand::JumpToFirmware) 301 | .await 302 | .map_err(|e| SerialError::Io(std::io::Error::other(e)))?; 303 | 304 | // wait for mode switch 305 | wait_for_mode(&mut reader, client, state, DeviceMode::Firmware).await?; 306 | 307 | return Ok(create_framed_port::(port, FIRMWARE_BAUD)?.split()); 308 | } 309 | 310 | // try firmware (happens if program was recently running) 311 | log::info!("Trying Firmware mode"); 312 | ping_device(port, client, state, DeviceMode::Firmware).await 313 | } 314 | 315 | async fn ping_device( 316 | port: &'static str, 317 | client: &mut AsyncClient, 318 | state: &mut SensorState, 319 | mode: DeviceMode, 320 | ) -> Result<(Writer, Reader), SerialError> { 321 | let baud = if mode == DeviceMode::Bootloader { 322 | BOOTLOADER_BAUD 323 | } else { 324 | FIRMWARE_BAUD 325 | }; 326 | let (mut writer, mut reader) = create_framed_port::(port, baud)?.split(); 327 | 328 | for _ in 0..3 { 329 | writer 330 | .send(SensorCommand::Ping) 331 | .await 332 | .map_err(|e| SerialError::Io(std::io::Error::other(e)))?; 333 | 334 | if let Ok(Some(Ok(packet))) = timeout(Duration::from_millis(500), reader.next()).await { 335 | state.set_device_mode(client, mode).await; 336 | state.handle_packet(client, packet).await; 337 | return Ok((writer, reader)); 338 | } 339 | } 340 | 341 | Err(SerialError::Io(std::io::Error::new( 342 | ErrorKind::NotFound, 343 | "Sensor not responding", 344 | ))) 345 | } 346 | 347 | async fn wait_for_mode( 348 | reader: &mut Reader, 349 | client: &mut AsyncClient, 350 | state: &mut SensorState, 351 | target_mode: DeviceMode, 352 | ) -> Result<(), SerialError> { 353 | let timeout_duration = Duration::from_secs(5); 354 | let start = std::time::Instant::now(); 355 | 356 | while state.device_mode != target_mode { 357 | if start.elapsed() > timeout_duration { 358 | return Err(SerialError::Io(std::io::Error::new( 359 | ErrorKind::TimedOut, 360 | "Timed out waiting for mode change", 361 | ))); 362 | } 363 | 364 | if let Some(Ok(packet)) = reader.next().await { 365 | state.handle_packet(client, packet).await; 366 | } 367 | } 368 | 369 | Ok(()) 370 | } 371 | -------------------------------------------------------------------------------- /src/sensor/packet.rs: -------------------------------------------------------------------------------- 1 | use bytes::BytesMut; 2 | use hex_literal::hex; 3 | 4 | use crate::common::packet::{ 5 | self, HardwareInfo, Packet, PacketError, invalid_structure, validate_packet_at_least, 6 | validate_packet_size, 7 | }; 8 | 9 | #[derive(Debug, PartialEq)] 10 | pub enum SensorPacket { 11 | /// next state, where bootloader = false, firmware = true 12 | Pong(bool), 13 | Message(String), 14 | HardwareInfo(HardwareInfo), 15 | /// unknown value 16 | JumpingToFirmware(u8), 17 | PiezoGainSet(u16, u16), 18 | /// unknown value, always (0,2) 19 | VibrationEnabled(u8, u8), 20 | /// unknown value, always 4 21 | GetFirmware(u8), 22 | /// unknown value, always 0 23 | PiezoFreqSet(u8), 24 | /// unknown value, always 0 25 | PiezoEnabled(u8), 26 | /// occurs in BL -> FW transition 27 | Init(u16), 28 | Capacitance(CapacitanceData), 29 | Piezo(PiezoData), 30 | Temperature(TemperatureData), 31 | /// unknown value 32 | AlarmSet(u8), 33 | } 34 | 35 | #[derive(Debug, PartialEq, Clone)] 36 | pub struct CapacitanceData { 37 | pub sequence: u32, 38 | /// ordered LTR 39 | pub values: [u16; 6], 40 | } 41 | 42 | #[derive(Debug, PartialEq, Clone)] 43 | pub struct TemperatureData { 44 | /// ordered LTR 45 | /// centidegrees celcius 46 | pub bed: [u16; 8], 47 | /// centidegrees celcius 48 | pub ambient: u16, 49 | /// centidegrees celcius 50 | pub humidity: u16, 51 | /// centidegrees celcius 52 | pub microcontroller: u16, 53 | } 54 | 55 | #[derive(Debug, PartialEq, Clone)] 56 | pub struct PiezoData { 57 | pub freq: u32, 58 | pub sequence: u32, 59 | pub gain: (u16, u16), 60 | pub left_samples: Vec, 61 | pub right_samples: Vec, 62 | } 63 | 64 | impl Packet for SensorPacket { 65 | // responses are cmd + 0x80 66 | fn parse(buf: BytesMut) -> Result { 67 | match buf[0] { 68 | 0x07 => packet::parse_message("Sensor/Message", buf).map(SensorPacket::Message), 69 | 0x31 => Self::parse_init(buf), 70 | 0x32 => Self::parse_piezo(buf), 71 | 0x33 => Self::parse_capacitance(buf), 72 | 0x81 => packet::parse_pong("Sensor/Pong", buf).map(SensorPacket::Pong), 73 | 0x82 => packet::parse_hardware_info("Sensor/HardwareInfo", buf) 74 | .map(SensorPacket::HardwareInfo), 75 | 0x84 => Self::parse_get_firmware(buf), 76 | 0x90 => packet::parse_jumping_to_firmware("Sensor/JumpingToFirmware", buf) 77 | .map(SensorPacket::JumpingToFirmware), 78 | 0xA1 => Self::parse_piezo_freq_set(buf), 79 | 0xA8 => Self::parse_piezo_enabled(buf), 80 | 0xAB => Self::parse_piezo_gain_set(buf), 81 | 0xAC => Self::parse_alarm_set(buf), 82 | 0xAE => Self::parse_vibration_enabled(buf), 83 | 0xAF => Self::parse_temperature(buf), 84 | _ => Err(PacketError::Unexpected { 85 | subsystem_name: "Sensor", 86 | buf: buf.freeze(), 87 | }), 88 | } 89 | } 90 | } 91 | 92 | impl SensorPacket { 93 | fn parse_get_firmware(buf: BytesMut) -> Result { 94 | validate_packet_size("Sensor/GetFirmware", &buf, 2)?; 95 | Ok(SensorPacket::GetFirmware(buf[1])) 96 | } 97 | 98 | fn parse_alarm_set(buf: BytesMut) -> Result { 99 | validate_packet_size("Sensor/AlarmSet", &buf, 2)?; 100 | Ok(SensorPacket::AlarmSet(buf[1])) 101 | } 102 | 103 | fn parse_piezo_gain_set(buf: BytesMut) -> Result { 104 | validate_packet_size("Sensor/PiezoGainSet", &buf, 6)?; 105 | Ok(SensorPacket::PiezoGainSet( 106 | u16::from_be_bytes([buf[2], buf[3]]), 107 | u16::from_be_bytes([buf[4], buf[5]]), 108 | )) 109 | } 110 | 111 | fn parse_piezo_freq_set(buf: BytesMut) -> Result { 112 | validate_packet_size("Sensor/PiezoFreqSet", &buf, 2)?; 113 | Ok(SensorPacket::PiezoFreqSet(buf[1])) 114 | } 115 | 116 | fn parse_piezo_enabled(buf: BytesMut) -> Result { 117 | validate_packet_size("Sensor/PiezoEnabled", &buf, 2)?; 118 | Ok(SensorPacket::PiezoEnabled(buf[1])) 119 | } 120 | 121 | fn parse_vibration_enabled(buf: BytesMut) -> Result { 122 | validate_packet_size("Sensor/VibrationEnabled", &buf, 3)?; 123 | Ok(SensorPacket::VibrationEnabled(buf[1], buf[2])) 124 | } 125 | 126 | // TODO FIXME new packet 31 00 00 00 0c 00 00 1d 22 00 127 | /// 31 00 00 00 0b 00 00 XX XX 00 128 | fn parse_init(buf: BytesMut) -> Result { 129 | validate_packet_size("Sensor/Init", &buf, 10)?; 130 | 131 | if buf[1..=6] != hex!("00 00 00 0b 00 00") || buf[9] != 0 { 132 | log::warn!("Unexpected init packet: {buf:02X?}"); 133 | } 134 | 135 | Ok(SensorPacket::Init(u16::from_be_bytes([buf[7], buf[8]]))) 136 | } 137 | 138 | /// Direct indexing is pretty nasty here, but _should_ be faster than using BytesMut as a buffer. 139 | /// Strict tests are used to enforce behavior. 140 | /// If you have a better method please reach out to me!! 141 | fn parse_capacitance(buf: BytesMut) -> Result { 142 | // example bad packet: 33 08 46 30 0c 00 00 00 00 00 00 7d 5d 01 00 a3 02 00 fc 03 01 18 04 01 c3 05 01 143 | 144 | validate_packet_size("Sensor/Capacitance", &buf, 27)?; 145 | 146 | let indices_valid = buf[9] == 0 147 | && buf[12] == 1 148 | && buf[15] == 2 149 | && buf[18] == 3 150 | && buf[21] == 4 151 | && buf[24] == 5; 152 | 153 | if !indices_valid { 154 | return Err(invalid_structure( 155 | "Sensor/Capacitance", 156 | "invalid indices".to_string(), 157 | buf, 158 | )); 159 | } 160 | 161 | Ok(Self::Capacitance(CapacitanceData { 162 | sequence: u32::from_be_bytes([buf[1], buf[2], buf[3], buf[4]]), 163 | values: [ 164 | u16::from_be_bytes([buf[10], buf[11]]), 165 | u16::from_be_bytes([buf[13], buf[14]]), 166 | u16::from_be_bytes([buf[16], buf[17]]), 167 | u16::from_be_bytes([buf[19], buf[20]]), 168 | u16::from_be_bytes([buf[22], buf[23]]), 169 | u16::from_be_bytes([buf[25], buf[26]]), 170 | ], 171 | })) 172 | } 173 | 174 | /// see parse_capacitance doc comment 175 | fn parse_temperature(buf: BytesMut) -> Result { 176 | validate_packet_size("Sensor/Temperature", &buf, 35)?; 177 | 178 | let indices_valid = buf[1] == 0 179 | && buf[2] == 0 180 | && buf[5] == 1 181 | && buf[8] == 2 182 | && buf[11] == 3 183 | && buf[14] == 4 184 | && buf[17] == 5 185 | && buf[20] == 6 186 | && buf[23] == 7 187 | && buf[26] == 8 188 | && buf[29] == 9 189 | && buf[32] == 10; 190 | 191 | if !indices_valid { 192 | return Err(invalid_structure( 193 | "Sensor/Temperature", 194 | "invalid indices or spacer".to_string(), 195 | buf, 196 | )); 197 | } 198 | 199 | Ok(SensorPacket::Temperature(TemperatureData { 200 | bed: [ 201 | u16::from_be_bytes([buf[3], buf[4]]), 202 | u16::from_be_bytes([buf[6], buf[7]]), 203 | u16::from_be_bytes([buf[9], buf[10]]), 204 | u16::from_be_bytes([buf[12], buf[13]]), 205 | u16::from_be_bytes([buf[15], buf[16]]), 206 | u16::from_be_bytes([buf[18], buf[19]]), 207 | u16::from_be_bytes([buf[21], buf[22]]), 208 | u16::from_be_bytes([buf[24], buf[25]]), 209 | ], 210 | ambient: u16::from_be_bytes([buf[27], buf[28]]), 211 | humidity: u16::from_be_bytes([buf[30], buf[31]]), 212 | microcontroller: u16::from_be_bytes([buf[33], buf[34]]), 213 | })) 214 | } 215 | 216 | /// see parse_capacitance doc comment 217 | /// common sizes: 174, 254, 202, 142, 178 218 | fn parse_piezo(buf: BytesMut) -> Result { 219 | validate_packet_at_least("Sensor/Piezo", &buf, 20)?; 220 | 221 | if buf[1] != 0x02 { 222 | log::warn!("Unexpected Piezo header: {:02X}", buf[1]); 223 | } 224 | 225 | let freq = u32::from_be_bytes([buf[2], buf[3], buf[4], buf[5]]); 226 | let sequence = u32::from_be_bytes([buf[6], buf[7], buf[8], buf[9]]); 227 | let gain = ( 228 | u16::from_be_bytes([buf[10], buf[11]]), 229 | u16::from_be_bytes([buf[12], buf[13]]), 230 | ); 231 | 232 | let num_samples = (buf.len() - 14) >> 2; 233 | let mut left_samples = Vec::with_capacity(num_samples); 234 | let mut right_samples = Vec::with_capacity(num_samples); 235 | 236 | for sample_num in 0..num_samples { 237 | let idx = 14 + (sample_num << 2); 238 | left_samples.push(u16::from_be_bytes([buf[idx], buf[idx + 1]])); 239 | right_samples.push(u16::from_be_bytes([buf[idx + 2], buf[idx + 3]])); 240 | } 241 | 242 | Ok(SensorPacket::Piezo(PiezoData { 243 | freq, 244 | sequence, 245 | gain, 246 | left_samples, 247 | right_samples, 248 | })) 249 | } 250 | } 251 | 252 | #[cfg(test)] 253 | mod tests { 254 | use super::*; 255 | use bytes::{Bytes, BytesMut}; 256 | use hex_literal::hex; 257 | 258 | #[test] 259 | fn test_pong() { 260 | assert_eq!( 261 | SensorPacket::parse(BytesMut::from(&hex!("81 00 42")[..])), 262 | Ok(SensorPacket::Pong(false)) 263 | ); 264 | 265 | assert_eq!( 266 | SensorPacket::parse(BytesMut::from(&hex!("81 00 46")[..])), 267 | Ok(SensorPacket::Pong(true)) 268 | ); 269 | 270 | assert!(SensorPacket::parse(BytesMut::from(&hex!("81 01 01")[..])).is_err()); 271 | assert!(SensorPacket::parse(BytesMut::from(&hex!("81 00 01")[..])).is_err()); 272 | } 273 | 274 | #[test] 275 | fn test_jumping_to_firmware() { 276 | assert_eq!( 277 | SensorPacket::parse(BytesMut::from(&[0x90, 0x01][..])), 278 | Ok(SensorPacket::JumpingToFirmware(1)) 279 | ); 280 | assert!(SensorPacket::parse(BytesMut::from(&[0x90][..])).is_err()); 281 | } 282 | 283 | #[test] 284 | fn test_set_gain() { 285 | let data = hex!("AB 00 01 95 01 95"); 286 | assert_eq!( 287 | SensorPacket::parse(BytesMut::from(&data[..])), 288 | Ok(SensorPacket::PiezoGainSet(405, 405)) 289 | ); 290 | assert!(SensorPacket::parse(BytesMut::from(&hex!("AB 01")[..])).is_err()); 291 | } 292 | 293 | #[test] 294 | fn test_vibration_enabled() { 295 | assert_eq!( 296 | SensorPacket::parse(BytesMut::from(&[0xAE, 0, 2][..])), 297 | Ok(SensorPacket::VibrationEnabled(0, 2)) 298 | ); 299 | assert!(SensorPacket::parse(BytesMut::from(&[0xAE, 1][..])).is_err()); 300 | } 301 | 302 | #[test] 303 | fn test_get_fw() { 304 | assert_eq!( 305 | SensorPacket::parse(BytesMut::from(&[0x84, 4][..])), 306 | Ok(SensorPacket::GetFirmware(4)) 307 | ); 308 | assert!(SensorPacket::parse(BytesMut::from(&[0x84][..])).is_err()); 309 | } 310 | 311 | #[test] 312 | fn test_message() { 313 | let data = hex!("07 00 48 65 6C 6C 6F"); 314 | assert_eq!( 315 | SensorPacket::parse(BytesMut::from(&data[..])), 316 | Ok(SensorPacket::Message("Hello".into())) 317 | ); 318 | 319 | let invalid_utf8 = hex!("07 00 FF"); 320 | assert!(SensorPacket::parse(BytesMut::from(&invalid_utf8[..])).is_err()); 321 | } 322 | 323 | #[test] 324 | fn test_capacitance() { 325 | let mut data = hex!( 326 | "33 01 02 03 04 00 00 00 00" 327 | "00 01 02" 328 | "01 03 04" 329 | "02 05 06" 330 | "03 07 08" 331 | "04 09 0A" 332 | "05 0B 0C" 333 | ); 334 | assert_eq!( 335 | SensorPacket::parse(BytesMut::from(&data[..])), 336 | Ok(SensorPacket::Capacitance(CapacitanceData { 337 | sequence: 0x01020304, 338 | values: [0x0102, 0x0304, 0x0506, 0x0708, 0x090A, 0x0B0C] 339 | })) 340 | ); 341 | 342 | // test bad index 343 | data[9] = 99; 344 | assert!(SensorPacket::parse(BytesMut::from(&data[..])).is_err()); 345 | } 346 | 347 | #[test] 348 | fn test_piezo() { 349 | let data = hex!( 350 | "32 02 00 00" 351 | "03 E8" 352 | "00 00 00 01" 353 | "00 01" 354 | "00 01" 355 | "00 01 00 02" 356 | "00 03 00 04" 357 | ); 358 | let parsed = SensorPacket::parse(BytesMut::from(&data[..])).unwrap(); 359 | match parsed { 360 | SensorPacket::Piezo(piezo) => { 361 | assert_eq!(piezo.freq, 1000); 362 | assert_eq!(piezo.sequence, 1); 363 | assert_eq!(piezo.gain, (1, 1)); 364 | assert_eq!(piezo.left_samples, vec![1, 3]); 365 | assert_eq!(piezo.right_samples, vec![2, 4]); 366 | } 367 | _ => panic!("Wrong packet type"), 368 | } 369 | 370 | assert!(SensorPacket::parse(BytesMut::from(&hex!("32 02 00 00")[..])).is_err()); 371 | } 372 | 373 | #[test] 374 | fn test_bed_temp() { 375 | let data = hex!( 376 | "AF 00" 377 | "00 01 02" 378 | "01 03 04" 379 | "02 05 06" 380 | "03 07 08" 381 | "04 09 0A" 382 | "05 0B 0C" 383 | "06 0D 0E" 384 | "07 0F 10" 385 | "08 11 12" 386 | "09 13 14" 387 | "0A 15 16" 388 | ); 389 | let parsed = SensorPacket::parse(BytesMut::from(&data[..])).unwrap(); 390 | match parsed { 391 | SensorPacket::Temperature(temp) => { 392 | assert_eq!( 393 | temp.bed, 394 | [ 395 | 0x0102, 0x0304, 0x0506, 0x0708, 0x090A, 0x0B0C, 0x0D0E, 0x0F10 396 | ] 397 | ); 398 | assert_eq!(temp.ambient, 0x1112); 399 | assert_eq!(temp.humidity, 0x1314); 400 | assert_eq!(temp.microcontroller, 0x1516); 401 | } 402 | _ => panic!("Wrong packet type"), 403 | } 404 | 405 | let mut bad_index = data; 406 | bad_index[32] = 0x99; 407 | let result = SensorPacket::parse(BytesMut::from(&bad_index[..])); 408 | assert!(result.is_err()); 409 | } 410 | 411 | #[test] 412 | fn test_alarm_set() { 413 | assert_eq!( 414 | SensorPacket::parse(BytesMut::from(&[0xAC, 0x01][..])), 415 | Ok(SensorPacket::AlarmSet(0x01)) 416 | ); 417 | assert!(SensorPacket::parse(BytesMut::from(&[0xAC][..])).is_err()); 418 | } 419 | 420 | #[test] 421 | fn test_unexpected() { 422 | assert_eq!( 423 | SensorPacket::parse(BytesMut::from(&[0x99][..])), 424 | Err(PacketError::Unexpected { 425 | subsystem_name: "Sensor", 426 | buf: Bytes::from(&[0x99][..]) 427 | }) 428 | ); 429 | } 430 | } 431 | --------------------------------------------------------------------------------