diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 60e24ff..1c432ee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,7 @@ jobs: - uses: actions/checkout@v5 - uses: awalsh128/cache-apt-pkgs-action@latest with: - packages: libwebkit2gtk-4.1-dev + packages: libwebkit2gtk-4.1-dev libudev-dev version: 1.0 - uses: actions-rust-lang/setup-rust-toolchain@v1 with: @@ -42,9 +42,9 @@ jobs: - uses: actions/checkout@v5 - uses: awalsh128/cache-apt-pkgs-action@latest with: - packages: libwebkit2gtk-4.1-dev + packages: libwebkit2gtk-4.1-dev libudev-dev version: 1.0 - uses: taiki-e/install-action@v2 with: tool: nextest - - run: cargo nextest run --workspace --all-features --profile ci \ No newline at end of file + - run: cargo nextest run --workspace --all-features --profile ci diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 563a632..0a6f18b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -27,7 +27,7 @@ jobs: if: contains(matrix.platform, 'ubuntu') run: | sudo apt-get update - sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils + sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils libudev-dev - name: Install Rust Nightly uses: dtolnay/rust-toolchain@nightly diff --git a/Cargo.lock b/Cargo.lock index ef453ac..15b8e6b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,15 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "addr2line" -version = "0.24.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" -dependencies = [ - "gimli", -] - [[package]] name = "adler2" version = "2.0.0" @@ -127,6 +118,16 @@ dependencies = [ "tokio", ] +[[package]] +name = "arksync-sensor" +version = "0.1.0" +dependencies = [ + "chrono", + "eyre", + "serialport", + "tokio", +] + [[package]] name = "arksync-ui" version = "0.1.0" @@ -362,21 +363,6 @@ version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" -[[package]] -name = "backtrace" -version = "0.3.74" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" -dependencies = [ - "addr2line", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", - "windows-targets 0.52.6", -] - [[package]] name = "base64" version = "0.21.7" @@ -1149,7 +1135,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1741,12 +1727,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "gimli" -version = "0.31.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" - [[package]] name = "gio" version = "0.18.4" @@ -2141,7 +2121,7 @@ dependencies = [ "http-body", "hyper", "pin-project-lite", - "socket2", + "socket2 0.5.8", "tokio", "tower-service", "tracing", @@ -2368,6 +2348,16 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71dd52191aae121e8611f1e8dc3e324dd0dd1dee1e6dd91d10ee07a3cfb4d9d8" +[[package]] +name = "io-kit-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617ee6cf8e3f66f3b4ea67a4058564628cde41901316e19f559e14c7c72c5e7b" +dependencies = [ + "core-foundation-sys", + "mach2", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -2735,9 +2725,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.170" +version = "0.2.180" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "875b3680cb2f8f71bdcf9a30f38d48282f5d3c95cbf9b3fa57269bb5d5c06828" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" [[package]] name = "libloading" @@ -2760,6 +2750,26 @@ dependencies = [ "redox_syscall", ] +[[package]] +name = "libudev" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78b324152da65df7bb95acfcaab55e3097ceaab02fb19b228a9eb74d55f135e0" +dependencies = [ + "libc", + "libudev-sys", +] + +[[package]] +name = "libudev-sys" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c8469b4a23b962c1396b9b451dda50ef5b283e8dd309d69033475fa9b334324" +dependencies = [ + "libc", + "pkg-config", +] + [[package]] name = "linear-map" version = "1.2.0" @@ -2809,6 +2819,15 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + [[package]] name = "manyhow" version = "0.11.4" @@ -2988,6 +3007,17 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60993920e071b0c9b66f14e2b32740a4e27ffc82854dcd72035887f336a09a28" +[[package]] +name = "nix" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" +dependencies = [ + "bitflags 1.3.2", + "cfg-if", + "libc", +] + [[package]] name = "nix" version = "0.29.0" @@ -3319,15 +3349,6 @@ dependencies = [ "objc2-foundation 0.3.0", ] -[[package]] -name = "object" -version = "0.36.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" -dependencies = [ - "memchr", -] - [[package]] name = "oco_ref" version = "0.2.0" @@ -3933,7 +3954,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2", + "socket2 0.5.8", "thiserror 2.0.12", "tokio", "tracing", @@ -3968,16 +3989,16 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2", + "socket2 0.5.8", "tracing", "windows-sys 0.59.0", ] [[package]] name = "quote" -version = "1.0.41" +version = "1.0.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" dependencies = [ "proc-macro2", ] @@ -4362,12 +4383,6 @@ dependencies = [ "serde_json", ] -[[package]] -name = "rustc-demangle" -version = "0.1.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" - [[package]] name = "rustc-hash" version = "2.1.1" @@ -4710,6 +4725,26 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "serialport" +version = "4.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21f60a586160667241d7702c420fc223939fb3c0bb8d3fac84f78768e8970dee" +dependencies = [ + "bitflags 2.9.0", + "cfg-if", + "core-foundation", + "core-foundation-sys", + "io-kit-sys", + "libudev", + "mach2", + "nix 0.26.4", + "quote", + "scopeguard", + "unescaper", + "windows-sys 0.52.0", +] + [[package]] name = "server_fn" version = "0.8.2" @@ -4863,6 +4898,16 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "socket2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + [[package]] name = "softbuffer" version = "0.4.6" @@ -5602,17 +5647,30 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.44.1" +version = "1.49.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f382da615b842244d4b8738c82ed1275e6c5dd90c459a30941cd07080b06c91a" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" dependencies = [ - "backtrace", "bytes", "libc", "mio", + "parking_lot", "pin-project-lite", - "socket2", - "windows-sys 0.52.0", + "signal-hook-registry", + "socket2 0.6.1", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", ] [[package]] @@ -5829,6 +5887,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "unescaper" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4064ed685c487dbc25bd3f0e9548f2e34bab9d18cefc700f9ec2dba74ba1138e" +dependencies = [ + "thiserror 2.0.12", +] + [[package]] name = "unic-char-property" version = "0.9.0" @@ -6478,6 +6545,15 @@ dependencies = [ "windows-targets 0.53.5", ] +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-targets" version = "0.42.2" @@ -6930,7 +7006,7 @@ dependencies = [ "futures-core", "futures-lite", "hex", - "nix", + "nix 0.29.0", "ordered-stream", "serde", "serde_repr", diff --git a/Cargo.toml b/Cargo.toml index 5eb8c54..3fa8ac7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,7 @@ eyre = "0.6" ndarray = "0.17.1" [workspace] -members = ["src-tauri"] +members = ["src-tauri", "sensor"] # Waiting for a more stable version of the software, we allow some dead code and # unused variables. diff --git a/bash_scripts/setup/99-ttyusb.rules b/bash_scripts/setup/99-ttyusb.rules new file mode 100644 index 0000000..0833a48 --- /dev/null +++ b/bash_scripts/setup/99-ttyusb.rules @@ -0,0 +1 @@ +KERNEL=="ttyUSB0", GROUP="${USER}", MODE="0666" diff --git a/bash_scripts/setup/setup.sh b/bash_scripts/setup/setup.sh new file mode 100644 index 0000000..aa56442 --- /dev/null +++ b/bash_scripts/setup/setup.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +# sudo addgroup dialout +# sudo usermod -a -G dialout $USER +# +# GID 100 or 1000 depending on distro +# The others are reserved +# +# cp ./99-ttyusb.rules /etc/udev/rules.d + +sudo udevadm control --reload-rules +sudo udevadm trigger diff --git a/sensor/Cargo.toml b/sensor/Cargo.toml new file mode 100644 index 0000000..df1a3fd --- /dev/null +++ b/sensor/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "arksync-sensor" +version = "0.1.0" +edition = "2021" +license = "AGPL-3.0-or-later" + +# [lib] +# name = "arksync_lib" +# crate-type = ["staticlib", "cdylib", "rlib"] + +[dependencies] +serialport = "4.8.1" +tokio = { version = "1.49.0", features = ["full"] } +chrono = "0.4" +eyre = "0.6.12" diff --git a/sensor/src/error.rs b/sensor/src/error.rs new file mode 100644 index 0000000..2116529 --- /dev/null +++ b/sensor/src/error.rs @@ -0,0 +1,41 @@ +use std::error::Error; +use std::fmt; + +#[derive(Debug)] +pub enum SensorError { + Message(String), + Source(Box), +} + +impl SensorError { + pub fn message(msg: impl Into) -> Self { + SensorError::Message(msg.into()) + } + + pub fn source(err: E) -> Self + where + E: Error + Send + Sync + 'static, + { + SensorError::Source(Box::new(err)) + } +} + +impl fmt::Display for SensorError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + SensorError::Message(msg) => write!(f, "{msg}"), + SensorError::Source(err) => write!(f, "{err}"), + } + } +} + +impl Error for SensorError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + SensorError::Message(_) => None, + SensorError::Source(err) => Some(err.as_ref()), + } + } +} + +pub type Result = std::result::Result; diff --git a/sensor/src/ezo/driver/error.rs b/sensor/src/ezo/driver/error.rs new file mode 100644 index 0000000..adb8648 --- /dev/null +++ b/sensor/src/ezo/driver/error.rs @@ -0,0 +1,25 @@ +use std::fmt; + +#[allow(unused)] +#[derive(Debug)] +pub enum DriverError { + Connection(String), + UnknownDevice(String), + Read(String), + Write(String), +} + +impl fmt::Display for DriverError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + DriverError::Connection(msg) => write!(f, "Connection error: {}", msg), + DriverError::UnknownDevice(msg) => write!(f, "Unknown device: {}", msg), + DriverError::Read(msg) => write!(f, "Read error: {}", msg), + DriverError::Write(msg) => write!(f, "Write error: {}", msg), + } + } +} + +impl std::error::Error for DriverError {} + +pub type Result = std::result::Result; diff --git a/sensor/src/ezo/driver/i2c.rs b/sensor/src/ezo/driver/i2c.rs new file mode 100644 index 0000000..3ba845c --- /dev/null +++ b/sensor/src/ezo/driver/i2c.rs @@ -0,0 +1,6 @@ +use crate::i2c_bus::I2cConnection; + +#[allow(unused)] +pub struct I2cDriver { + pub connection: I2cConnection, +} diff --git a/sensor/src/ezo/driver/mod.rs b/sensor/src/ezo/driver/mod.rs new file mode 100644 index 0000000..4dc80c5 --- /dev/null +++ b/sensor/src/ezo/driver/mod.rs @@ -0,0 +1,57 @@ +mod error; +pub mod i2c; +pub mod uart; + +use crate::sensor::SensorConnection; + +pub use self::error::*; + +#[derive(Debug, Clone, Copy)] +pub enum DeviceType { + Rtd, +} + +impl TryFrom<&str> for DeviceType { + type Error = DriverError; + + fn try_from(value: &str) -> std::result::Result { + match value { + "RTD" => Ok(DeviceType::Rtd), + other => Err(DriverError::UnknownDevice(other.to_string())), + } + } +} + +#[derive(Debug, Clone)] +pub struct DeviceInfo { + pub device_type: DeviceType, + pub firmware_version: f64, +} + +#[allow(unused)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Status { + PoweredOn, + SoftwareReset, + BrownOut, + Watchdog, + Unknown, +} + +pub trait CommandTransport { + fn read(&mut self) -> Result; + fn write(&mut self, buf: &[u8]) -> Result<()>; + + fn send_command(&mut self, command: &[u8]) -> Result { + self.write(command)?; + self.read() + } +} + +/// Commands common to both UART and I2C drivers. +pub trait Driver: CommandTransport { + fn connection_info(&self) -> SensorConnection; + fn device_info(&mut self) -> Result; + #[allow(unused)] + fn status(&mut self) -> Result; +} diff --git a/sensor/src/ezo/driver/uart.rs b/sensor/src/ezo/driver/uart.rs new file mode 100644 index 0000000..8e21696 --- /dev/null +++ b/sensor/src/ezo/driver/uart.rs @@ -0,0 +1,99 @@ +use super::{CommandTransport, DeviceInfo, DeviceType, Driver, DriverError, Result}; +use crate::{ + ezo::driver::Status, + sensor::SensorConnection, + serial_port::{SerialPortConnection, SerialPortMetadata}, +}; + +pub struct UartDriver { + pub connection: SerialPortConnection, +} + +impl UartDriver { + pub fn new(serial_port: &SerialPortMetadata) -> Result { + let connection = SerialPortConnection::open(serial_port) + .map_err(|err| DriverError::Connection(err.to_string()))?; + + Ok(UartDriver { connection }) + } +} + +impl CommandTransport for UartDriver { + fn read(&mut self) -> Result { + self.connection + .read_until_carrier() + .map_err(|err| DriverError::Read(err.to_string())) + } + + fn write(&mut self, buf: &[u8]) -> Result<()> { + self.connection + .write_command(buf) + .map_err(|err| DriverError::Write(err.to_string())) + } +} + +impl Driver for UartDriver { + fn connection_info(&self) -> SensorConnection { + SensorConnection::Uart(self.connection.metadata.clone()) + } + + /// Get device information (firmware version, device type) + /// + /// Retries up to 3 times if we get unexpected data (like temperature readings) + fn device_info(&mut self) -> Result { + const MAX_RETRIES: usize = 3; + + for attempt in 1..=MAX_RETRIES { + // Send "i" command to get device information + let response = self.send_command(b"i")?; + + // Atlas Scientific response format: ?I,RTD,1.0 + // Format: ?I,, + if response.starts_with("?I,") { + let parts: Vec<&str> = response.trim_start_matches("?I,").split(',').collect(); + + if parts.len() >= 2 { + return Ok(DeviceInfo { + device_type: DeviceType::try_from(parts[0])?, + firmware_version: parts[1].parse().unwrap_or(0.0), + }); + } + } + + // Got unexpected response (possibly temperature reading or stale data) + eprintln!( + "Attempt {}/{}: Unexpected response to 'i' command: '{}' - retrying...", + attempt, MAX_RETRIES, response + ); + + // Small delay before retry + std::thread::sleep(std::time::Duration::from_millis(100)); + } + + Err(DriverError::Read( + "Failed to get valid device info after multiple attempts".to_string(), + )) + } + + fn status(&mut self) -> Result { + let response = self.send_command(b"Status")?; + + // Atlas Scientific status response format: ?STATUS, + // Codes: P (powered off and restarted), S (software reset), B (brown out), W (watchdog), U (unknown) + if response.starts_with("?STATUS,") { + let code = response.chars().nth(8).unwrap_or('U'); + Ok(match code { + 'P' => Status::PoweredOn, + 'S' => Status::SoftwareReset, + 'B' => Status::BrownOut, + 'W' => Status::Watchdog, + _ => Status::Unknown, + }) + } else { + Err(DriverError::Read(format!( + "Unexpected response to 'Status' command: '{}'", + response + ))) + } + } +} diff --git a/sensor/src/ezo/ezo_sensor.rs b/sensor/src/ezo/ezo_sensor.rs new file mode 100644 index 0000000..89a367f --- /dev/null +++ b/sensor/src/ezo/ezo_sensor.rs @@ -0,0 +1,46 @@ +use std::sync::Mutex; + +use crate::error::{Result, SensorError}; +use crate::ezo::driver::DriverError; +use crate::ezo::driver::{CommandTransport, Driver}; +use crate::sensor::{Sensor, SensorInfo}; + +pub trait EzoSensor: Send + Sync + 'static { + type DriverType: Driver; + + fn data(&self) -> &SensorInfo; + fn driver(&self) -> &Mutex; + + /// Measurement command for this sensor. + fn measurement_command(&self) -> &'static [u8] { + b"R" + } + + /// EZO measurement command (`R`) parsed as `f64`. + fn read_measurement(&self) -> Result { + let mut driver = self + .driver() + .lock() + .map_err(|err| SensorError::source(DriverError::Read(err.to_string())))?; + + driver + .send_command(self.measurement_command()) + .map_err(SensorError::source)? + .trim() + .parse::() + .map_err(|err| SensorError::source(DriverError::Read(err.to_string()))) + } +} + +impl Sensor for T +where + T: EzoSensor, +{ + fn info(&self) -> &SensorInfo { + EzoSensor::data(self) + } + + fn read_measurement(&self) -> Result { + EzoSensor::read_measurement(self) + } +} diff --git a/sensor/src/ezo/mod.rs b/sensor/src/ezo/mod.rs new file mode 100644 index 0000000..80f8819 --- /dev/null +++ b/sensor/src/ezo/mod.rs @@ -0,0 +1,3 @@ +pub mod driver; +pub mod ezo_sensor; +pub mod rtd; diff --git a/sensor/src/ezo/rtd.rs b/sensor/src/ezo/rtd.rs new file mode 100644 index 0000000..1a3f45a --- /dev/null +++ b/sensor/src/ezo/rtd.rs @@ -0,0 +1,44 @@ +use chrono::Utc; +use std::sync::Mutex; + +use crate::ezo::driver::{uart::UartDriver, Driver}; +use crate::ezo::ezo_sensor::EzoSensor; +use crate::sensor::{SensorInfo, SensorName, SensorState}; + +pub struct Rtd { + data: SensorInfo, + driver: Mutex, +} + +impl EzoSensor for Rtd { + type DriverType = D; + + fn data(&self) -> &SensorInfo { + &self.data + } + + fn driver(&self) -> &Mutex { + &self.driver + } +} + +impl Rtd { + pub fn new(driver: D, firmware: f64) -> Self { + Self { + data: SensorInfo { + firmware, + name: SensorName::Unnamed, + state: SensorState::Initializing, + last_activity: Utc::now(), + connection: driver.connection_info(), + }, + driver: Mutex::new(driver), + } + } +} + +impl Rtd { + pub fn from_uart(driver: UartDriver, firmware: f64) -> Self { + Self::new(driver, firmware) + } +} diff --git a/sensor/src/i2c_bus.rs b/sensor/src/i2c_bus.rs new file mode 100644 index 0000000..80071c4 --- /dev/null +++ b/sensor/src/i2c_bus.rs @@ -0,0 +1,5 @@ +#[derive(Debug)] +/// Active i2c connection for communication +pub struct I2cConnection { + pub address: u8, +} diff --git a/sensor/src/lib.rs b/sensor/src/lib.rs new file mode 100644 index 0000000..39c7708 --- /dev/null +++ b/sensor/src/lib.rs @@ -0,0 +1,5 @@ +pub mod error; +pub mod ezo; +pub mod i2c_bus; +pub mod sensor; +pub mod serial_port; diff --git a/sensor/src/main.rs b/sensor/src/main.rs new file mode 100644 index 0000000..0d22451 --- /dev/null +++ b/sensor/src/main.rs @@ -0,0 +1,299 @@ +mod error; +mod ezo; +mod i2c_bus; +mod sensor; +mod serial_port; + +use ezo::driver::uart::UartDriver; +use ezo::driver::{DeviceType, Driver}; +use ezo::rtd::Rtd; +use sensor::Sensor; +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; +use tokio::sync::mpsc::Sender; +use tokio::sync::{mpsc, oneshot}; +use tokio::task::JoinHandle; +use tokio::time::{interval, Duration as TokioDuration}; + +use crate::sensor::SensorConnection; +use crate::serial_port::SerialPortMetadata; + +/// A sensor list compatible with both UART and I2C protocols. +pub type SensorList = HashMap>; + +#[allow(unused)] +enum ServiceCommand { + /// Add sensors in the registry (no replacement) + AddSensors { + sensors: Vec<(String, Arc)>, + }, + /// Remove sensors from the registry + RemoveSensors { uuids: Vec }, + /// Get a specific sensor by serial number + FindSensor { + serial_number: String, + respond_to: oneshot::Sender>>, + }, + /// Get all sensors (snapshot) + AllSensors { + respond_to: oneshot::Sender>, + }, +} + +pub struct CommandChannel { + tx: mpsc::Sender, + rx: mpsc::Receiver, +} + +/// Supervisor service that maintains the list of sensors +pub struct UartService { + sensors: SensorList, + sensor_tasks: HashMap>, + cmd_channel: CommandChannel, +} + +impl Default for UartService { + fn default() -> Self { + Self::new() + } +} + +impl UartService { + pub fn new() -> Self { + let (tx, rx) = mpsc::channel(100); + + Self { + sensors: HashMap::new(), + sensor_tasks: HashMap::new(), + cmd_channel: CommandChannel { tx, rx }, + } + } + + /// Main supervisor loop - maintains sensor registry + pub async fn run(mut self) { + let cmd_tx = self.cmd_channel.tx.clone(); + + // Spawn tasks to detect (un)plugged sensors and update the registry + self.detect_plugged_sensors(cmd_tx.clone()); + self.detect_unplugged_sensors(cmd_tx.clone()); + + println!("UartService started - maintaining sensor registry"); + + // Main event loop - just manages the HashMap + loop { + tokio::select! { + Some(cmd) = self.cmd_channel.rx.recv() => { + self.handle_cmd(cmd); + } + + _ = tokio::signal::ctrl_c() => { + println!("Shutting down sensor registry..."); + self.abort_all_sensor_tasks(); + break; + } + } + } + } + + /// Handle commands to maintain sensor list + fn handle_cmd(&mut self, cmd: ServiceCommand) { + match cmd { + ServiceCommand::AddSensors { sensors } => { + println!("Registry: Adding up to {} sensors", sensors.len()); + for (uuid, sensor) in sensors { + if self.sensors.contains_key(&uuid) { + println!("Registry: Sensor {uuid} already exists, skipping add"); + continue; + } + + let task = Arc::clone(&sensor).run(); + self.sensor_tasks.insert(uuid.clone(), task); + self.sensors.insert(uuid, sensor); + } + println!("Registry: Total sensors = {}", self.sensors.len()); + } + + ServiceCommand::RemoveSensors { uuids } => { + println!("Registry: Removing {} sensors", uuids.len()); + for uuid in &uuids { + if let Some(task) = self.sensor_tasks.remove(uuid) { + task.abort(); + } + self.sensors.remove(uuid); + } + println!("Registry: Total sensors = {}", self.sensors.len()); + } + + ServiceCommand::FindSensor { + serial_number, + respond_to, + } => { + let sensor = self.sensors.get(&serial_number).cloned(); + let _ = respond_to.send(sensor); + } + + ServiceCommand::AllSensors { respond_to } => { + println!( + "Registry: Providing snapshot of all sensors ({} total)", + self.sensors.len() + ); + let _ = respond_to.send(Arc::new(self.sensors.clone())); + } + } + } + + fn abort_all_sensor_tasks(&mut self) { + for (_, task) in self.sensor_tasks.drain() { + task.abort(); + } + } + + /// Listen for plugged sensors. + /// + /// Finds new USB sensors and adds them to registry. + fn detect_plugged_sensors(&self, cmd_tx: Sender) { + tokio::spawn(async move { + let mut interval = interval(TokioDuration::from_secs(2)); + + loop { + interval.tick().await; + + println!("Detector: Scanning for sensors..."); + let asc_ports = serial_port::find_asc_port(); + + if !asc_ports.is_empty() { + println!("Detector: Found {} ASC ports", asc_ports.len()); + } + + // Get current sensor list + let (respond_to, rx) = oneshot::channel(); + let _ = cmd_tx.send(ServiceCommand::AllSensors { respond_to }).await; + let current_sensors = rx.await; + + if let Ok(current_sensors) = current_sensors { + let mut new_sensors: Vec<(String, Arc)> = Vec::new(); + + for port in asc_ports.iter() { + if !current_sensors.contains_key(&port.serial_number) { + let sensor = create_sensor_from_port(port); + println!( + "Detector: Created sensor {}: {:#?}", + port.serial_number, + sensor.as_ref().map(|s| s.info()) + ); + + match sensor { + Ok(sensor) => { + let data = sensor.info(); + println!( + "Detector: Created sensor - firmware v{}", + data.firmware + ); + new_sensors.push((port.serial_number.clone(), sensor)); + } + Err(e) => { + eprintln!( + "Detector: Failed to create sensor {}: {}", + port.serial_number, e + ); + } + } + } + } + + if !new_sensors.is_empty() { + let _ = cmd_tx + .send(ServiceCommand::AddSensors { + sensors: new_sensors, + }) + .await; + } + } + } + }); + } + + /// Listen for unplugged sensors. + /// + /// Compare the list of sensors'connection with OS connections for both + /// UART and I2C and remove stale sensor from the list. + fn detect_unplugged_sensors(&self, cmd_tx: Sender) { + tokio::spawn(async move { + let mut interval = interval(TokioDuration::from_secs(2)); + + loop { + interval.tick().await; + + let available_asc_ports = serial_port::find_asc_port(); + let available_port_serials: HashSet<_> = available_asc_ports + .iter() + .map(|port| &port.serial_number) + .collect(); + + // Get current sensor list + let (respond_to, rx) = oneshot::channel(); + let _ = cmd_tx.send(ServiceCommand::AllSensors { respond_to }).await; + let current_sensors = rx.await; + + if let Ok(sensors) = current_sensors { + for sensor in sensors.values() { + let connection_info = &sensor.info().connection; + + match connection_info { + SensorConnection::Uart(port_metadata) => { + if !available_port_serials.contains(&port_metadata.serial_number) { + println!( + "Detector: Sensor {} is unplugged, removing from registry", + port_metadata.serial_number + ); + let _ = cmd_tx + .send(ServiceCommand::RemoveSensors { + uuids: vec![port_metadata.serial_number.clone()], + }) + .await; + } + } + SensorConnection::I2c(_) => { + unimplemented!("No I2C sensor handling yet"); + } + } + } + } + } + }); + } +} + +/// Factory function to create a sensor from a serial port +/// +/// This function: +/// 1. Creates a temporary UART driver +/// 2. Queries device info to determine sensor type +/// 3. Creates the appropriate sensor with the correct driver +/// 4. Returns it as Arc +fn create_sensor_from_port( + port: &SerialPortMetadata, +) -> Result, Box> { + // Create temporary driver to query device type + let mut uart_driver = UartDriver::new(port)?; + let device_info = uart_driver.device_info()?; + + println!( + "Factory: Detected {:?} sensor v{}", + device_info.device_type, device_info.firmware_version + ); + + // Create appropriate sensor based on device type + match device_info.device_type { + DeviceType::Rtd => { + let rtd = Rtd::::from_uart(uart_driver, device_info.firmware_version); + Ok(Arc::new(rtd) as Arc) + } + } +} + +#[tokio::main] +async fn main() { + println!("Starting ArkSync Sensor Service..."); + UartService::new().run().await; +} diff --git a/sensor/src/sensor.rs b/sensor/src/sensor.rs new file mode 100644 index 0000000..86ed72a --- /dev/null +++ b/sensor/src/sensor.rs @@ -0,0 +1,68 @@ +use chrono::{DateTime, Utc}; +use std::sync::Arc; +use tokio::task::JoinHandle; +use tokio::time::{interval, Duration}; + +use crate::error::Result; +use crate::i2c_bus::I2cConnection; +use crate::serial_port::SerialPortMetadata; + +#[derive(Debug, Clone, Default)] +pub enum SensorName { + #[default] + Unnamed, + Named(String), +} + +#[derive(Default, Debug, Clone, Copy)] +pub enum SensorState { + Active, + Degraded, + #[default] + Initializing, + Unreachable, +} + +#[derive(Debug)] +pub enum SensorConnection { + Uart(SerialPortMetadata), + I2c(I2cConnection), +} + +#[derive(Debug)] +pub struct SensorInfo { + pub firmware: f64, + pub name: SensorName, + pub state: SensorState, + pub last_activity: DateTime, + pub connection: SensorConnection, +} + +pub trait Sensor: Send + Sync + 'static { + fn info(&self) -> &SensorInfo; + fn read_measurement(&self) -> Result; + + /// Spawn the main background task for this sensor. + fn run(self: Arc) -> JoinHandle<()> { + tokio::spawn(async move { + // This is based on Atlas Scientific read time, plus some time to not + // be at the edge of the value disponibility + let mut ticker = interval(Duration::from_millis(1200)); + + loop { + ticker.tick().await; + + // TODO: Retry with backoff strategy: we allow some I/O error but after a specific threshold we start to update + // the state of the sensor to Degraded then Unresponsive. + match self.read_measurement() { + Ok(value) => { + println!("Sensor reading: {value:.3}"); + // TODO: If it's successful then we update the last activity so the supervisor healthcheck get an + // up-to-date information. As long as we can read, then we are active. + } + Err(err) => eprintln!("Sensor read error: {err:#?}"), + } + } + }) + } +} diff --git a/sensor/src/serial_port.rs b/sensor/src/serial_port.rs new file mode 100644 index 0000000..0cf9891 --- /dev/null +++ b/sensor/src/serial_port.rs @@ -0,0 +1,147 @@ +use serialport::{SerialPortInfo, SerialPortType}; +use std::io::{Read, Write}; +use std::time::Duration; + +// Atlas Scientific RTD Sensor Configuration +// Based on datasheet specifications +pub const DEFAULT_BAUD_RATE: u32 = 9600; +pub const SERIAL_PORT_CONN_TIMEOUT: u64 = 1000; // Timeout acts as safety net for response-based reading + +/// Metadata about a serial port (no active connection) +#[derive(Debug, Clone)] +pub struct SerialPortMetadata { + pub port_name: String, + pub serial_number: String, + pub baud_rate: u32, +} + +/// Active serial port connection for communication +pub struct SerialPortConnection { + pub port: Box, + pub metadata: SerialPortMetadata, +} + +impl SerialPortConnection { + /// Open a serial port connection with Atlas Scientific RTD defaults + /// - Baud rate: 9600 + /// - Timeout: 1000ms (safety net for unresponsive sensors) + /// - Encoding: ASCII + /// - Terminator: Carriage return (\r) + /// - Decimal places: 3 + /// - Temperature unit: Celsius (default) + pub fn open(serial_port_metadata: &SerialPortMetadata) -> Result { + let SerialPortMetadata { + port_name, + baud_rate, + .. + } = serial_port_metadata; + + let port = serialport::new(port_name, *baud_rate) + .timeout(Duration::from_millis(SERIAL_PORT_CONN_TIMEOUT)) + .open()?; + + // Flush any stale data in the input buffer + // Atlas Scientific sensors might have leftover readings or responses + let _ = port.clear(serialport::ClearBuffer::Input); + + Ok(Self { + port, + metadata: serial_port_metadata.clone(), + }) + } + + /// Write a command to the sensor + pub fn write_command(&mut self, command: &[u8]) -> std::io::Result<()> { + // Flush any stale data before writing + self.flush_input()?; + + self.port.write_all(command)?; + self.port.write_all(b"\r")?; // Atlas Scientific expects carriage return + + self.port.flush()?; + Ok(()) + } + + /// Read response from the sensor until carriage return terminator + /// + /// Atlas Scientific sensors terminate responses with \r + /// This method blocks until \r is received or timeout occurs + pub fn read_until_carrier(&mut self) -> std::io::Result { + let mut buffer = Vec::new(); + let mut single_byte = [0u8; 1]; + + // Read byte-by-byte until carriage return + loop { + match self.port.read_exact(&mut single_byte) { + Ok(_) => { + if single_byte[0] == b'\r' { + break; + } + buffer.push(single_byte[0]); + } + Err(e) => return Err(e), + } + } + + // Convert to string and trim whitespace + let response = String::from_utf8_lossy(&buffer).trim().to_string(); + + Ok(response) + } + + /// Flush the input buffer to clear any stale data + pub fn flush_input(&mut self) -> std::io::Result<()> { + self.port + .clear(serialport::ClearBuffer::Input) + .map_err(std::io::Error::other) + } +} + +impl std::fmt::Debug for SerialPortConnection { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SerialPortConnection") + .field("port", &"") + .finish() + } +} + +pub fn find_asc_port() -> Vec { + serialport::available_ports() + .unwrap_or_default() + .into_iter() + .filter(filter_asc_device) + .filter_map(filter_map_usb_serial) + .collect::>() +} + +/// Checks if a port is an Atlas Scientific device +fn filter_asc_device(port: &SerialPortInfo) -> bool { + match &port.port_type { + SerialPortType::UsbPort(usb_info) => { + // Atlas Scientific USB devices typically use FTDI chips + // FTDI Vendor ID: 0x0403 + // Common Product IDs: 0x6001 (FT232), 0x6015 (FT231X) + usb_info.vid == 0x0403 && (usb_info.pid == 0x6001 || usb_info.pid == 0x6015) + } + _ => false, + } +} + +fn filter_map_usb_serial(port: SerialPortInfo) -> Option { + let SerialPortInfo { + port_name, + port_type, + } = port; + + let SerialPortType::UsbPort(usb_port) = port_type else { + return None; + }; + + usb_port + .serial_number + .map(|serial_number| SerialPortMetadata { + port_name, + serial_number, + baud_rate: DEFAULT_BAUD_RATE, + }) +} diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 24ca344..d4d4aea 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -24,7 +24,7 @@ tauri-plugin-opener = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" rand = "0.9" -tokio = { version = "1.44", features = ["time"] } +tokio = { version = "1.44.2", features = ["time"] } tauri-plugin-log = "2" log = "0.4" eyre = "0.6.12"