diff --git a/Cargo.lock b/Cargo.lock index 17bebc4..85d776f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -548,6 +548,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "cookie" version = "0.16.2" @@ -666,7 +675,7 @@ version = "0.99.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f33878137e4dafd7fa914ad4e259e18a4e8e532b9617a2d0150262bf53abfce" dependencies = [ - "convert_case", + "convert_case 0.4.0", "proc-macro2 1.0.89", "quote 1.0.37", "rustc_version", @@ -884,6 +893,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + [[package]] name = "fnv" version = "1.0.7" @@ -1204,10 +1224,10 @@ dependencies = [ "http 1.1.0", "hyper", "hyper-util", - "rustls", + "rustls 0.23.16", "rustls-pki-types", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.0", "tower-service", "webpki-roots", ] @@ -1537,9 +1557,9 @@ dependencies = [ "chrono", "chrono-tz", "cron", + "nutype", "serde", "serde_with", - "validated_newtype", ] [[package]] @@ -1553,16 +1573,42 @@ dependencies = [ "clap", "cron", "env_logger", + "envconfig", + "futures", + "futures-util", "jsonwebtoken", "kdash_protocol", "reqwest", + "rumqttc", "serde", "serde_json", "serde_with", + "tokio", "url", "uuid", ] +[[package]] +name = "kinded" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce4bdbb2f423660b19f0e9f7115182214732d8dd5f840cd0a3aee3e22562f34c" +dependencies = [ + "kinded_macros", +] + +[[package]] +name = "kinded_macros" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13b4ddc5dcb32f45dac3d6f606da2a52fdb9964a18427e63cd5ef6c0d13288d" +dependencies = [ + "convert_case 0.6.0", + "proc-macro2 1.0.89", + "quote 1.0.37", + "syn 2.0.87", +] + [[package]] name = "language-tags" version = "0.3.2" @@ -1744,6 +1790,29 @@ dependencies = [ "autocfg", ] +[[package]] +name = "nutype" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8789358e2d6cdffb0cb170c7802ee7548beb8067ed643f3122fa36c335f3c64" +dependencies = [ + "nutype_macros", +] + +[[package]] +name = "nutype_macros" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93a3e222ba1f06a03552910fe89a232a1661dcf8ad4c837531fb199828d0916b" +dependencies = [ + "cfg-if", + "kinded", + "proc-macro2 1.0.89", + "quote 1.0.37", + "syn 2.0.87", + "urlencoding", +] + [[package]] name = "object" version = "0.36.5" @@ -1980,7 +2049,7 @@ dependencies = [ "quinn-proto", "quinn-udp", "rustc-hash", - "rustls", + "rustls 0.23.16", "socket2", "thiserror", "tokio", @@ -1997,7 +2066,7 @@ dependencies = [ "rand", "ring", "rustc-hash", - "rustls", + "rustls 0.23.16", "slab", "thiserror", "tinyvec", @@ -2138,7 +2207,7 @@ dependencies = [ "percent-encoding", "pin-project-lite", "quinn", - "rustls", + "rustls 0.23.16", "rustls-pemfile", "rustls-pki-types", "serde", @@ -2148,7 +2217,7 @@ dependencies = [ "system-configuration", "tokio", "tokio-native-tls", - "tokio-rustls", + "tokio-rustls 0.26.0", "tokio-util", "tower-service", "url", @@ -2175,6 +2244,24 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rumqttc" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1568e15fab2d546f940ed3a21f48bbbd1c494c90c99c4481339364a497f94a9" +dependencies = [ + "bytes", + "flume", + "futures-util", + "log", + "rustls-native-certs", + "rustls-pemfile", + "rustls-webpki", + "thiserror", + "tokio", + "tokio-rustls 0.25.0", +] + [[package]] name = "rustc-demangle" version = "0.1.24" @@ -2209,6 +2296,20 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rustls" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf4ef73721ac7bcd79b2b315da7779d8fc09718c6b3d2d1b2d94850eb8c18432" +dependencies = [ + "log", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + [[package]] name = "rustls" version = "0.23.16" @@ -2223,6 +2324,19 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5" +dependencies = [ + "openssl-probe", + "rustls-pemfile", + "rustls-pki-types", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pemfile" version = "2.2.0" @@ -2453,6 +2567,9 @@ name = "spin" version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] [[package]] name = "stable_deref_trait" @@ -2672,13 +2789,24 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-rustls" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775e0c0f0adb3a2f22a00c4745d728b479985fc15ee7ca6a2608388c5569860f" +dependencies = [ + "rustls 0.22.4", + "rustls-pki-types", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c7bc40d0e5a97695bb96e27995cd3a08538541b0a846f65bba7a359f36700d4" dependencies = [ - "rustls", + "rustls 0.23.16", "rustls-pki-types", "tokio", ] @@ -2740,6 +2868,12 @@ version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + [[package]] name = "unicode-xid" version = "0.1.0" @@ -2764,6 +2898,12 @@ dependencies = [ "serde", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf16_iter" version = "1.0.5" @@ -2791,15 +2931,6 @@ dependencies = [ "serde", ] -[[package]] -name = "validated_newtype" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "160f1ca3dc12c9e48f60b5b7a56092e372bda4ee2805f6c84ae337dd582fe12f" -dependencies = [ - "serde", -] - [[package]] name = "vcpkg" version = "0.2.15" diff --git a/kdash_client/Cargo.toml b/kdash_client/Cargo.toml index 6caf854..b0f83bd 100644 --- a/kdash_client/Cargo.toml +++ b/kdash_client/Cargo.toml @@ -25,6 +25,8 @@ reqwest = { version = "0.12.9", default-features = false, features = [ "json", "rustls-tls", "stream", + "http2", + "charset", ] } tinybmp = "0.6.0" tokio = { version = "1.41.0", features = ["full"] } diff --git a/kdash_client/extensions/kdash/config.xml b/kdash_client/extensions/kdash/config.xml new file mode 100644 index 0000000..ca62583 --- /dev/null +++ b/kdash_client/extensions/kdash/config.xml @@ -0,0 +1,12 @@ + + + + kdash + 0.1.0 + dergrimm + kdash + + + menu.json + + \ No newline at end of file diff --git a/kdash_client/daemon.sh b/kdash_client/extensions/kdash/daemon.sh similarity index 57% rename from kdash_client/daemon.sh rename to kdash_client/extensions/kdash/daemon.sh index 24e59f9..e2a815f 100644 --- a/kdash_client/daemon.sh +++ b/kdash_client/extensions/kdash/daemon.sh @@ -1,8 +1,9 @@ -#!/usr/bin/env sh +#!/bin/sh -DAEMON_PATH="/mnt/us/kdash" +DAEMON_PATH="/mnt/us/extensions/kdash" DAEMON_ENV_FILE="${DAEMON_PATH}/kdash.env" +DAEMON_ENABLED_FILE="${DAEMON_PATH}/ENABLED" DAEMON="./kdash_client" DAEMONOPTS="" @@ -13,20 +14,30 @@ PIDFILE="${DAEMON_PATH}/${DAEMON}.pid" # SCRIPTNAME="/etc/init.d/${NAME}" case "$1" in +enable) + touch "$DAEMON_ENABLED_FILE" + ;; +disable) + rm -f "$DAEMON_ENABLED_FILE" + ;; start) - printf "%-50s" "Starting $NAME..." - cd "$DAEMON_PATH" || exit - . "$DAEMON_ENV_FILE" - PID=$( - RUST_BACKTRACE=full RUST_LOG=debug $DAEMON "$DAEMONOPTS" >/dev/null 2>&1 & - echo $! - ) - #echo "Saving PID" $PID " to " $PIDFILE - if [ -z "$PID" ]; then - printf "%s\n" "Fail" + if [ -e "$DAEMON_ENABLED_FILE" ]; then + printf "%-50s" "Starting $NAME..." + cd "$DAEMON_PATH" || exit + . "$DAEMON_ENV_FILE" + PID=$( + RUST_BACKTRACE=full RUST_LOG=debug $DAEMON "$DAEMONOPTS" >/dev/null 2>&1 & + echo $! + ) + #echo "Saving PID" $PID " to " $PIDFILE + if [ -z "$PID" ]; then + printf "%s\n" "Fail" + else + echo "$PID" >"$PIDFILE" + printf "%s\n" "Ok" + fi else - echo "$PID" >"$PIDFILE" - printf "%s\n" "Ok" + echo "Service not enabled. ENABLED file not found" fi ;; status) diff --git a/kdash_client/extensions/kdash/menu.json b/kdash_client/extensions/kdash/menu.json new file mode 100644 index 0000000..1d7f18c --- /dev/null +++ b/kdash_client/extensions/kdash/menu.json @@ -0,0 +1,13 @@ +{ + "items": [ + { + "name": "kdash", + "items": [ + { "name": "Enable", "priority": 0, "action": "./daemon.sh enable" }, + { "name": "Disable", "priority": 0, "action": "./daemon.sh disable" }, + { "name": "Start", "priority": 0, "action": "./daemon.sh start" }, + { "name": "Stop", "priority": 0, "action": "./daemon.sh stop" } + ] + } + ] +} diff --git a/kdash_client/extensions/kdash/startup.sh b/kdash_client/extensions/kdash/startup.sh new file mode 100644 index 0000000..08994c0 --- /dev/null +++ b/kdash_client/extensions/kdash/startup.sh @@ -0,0 +1,4 @@ +#!/bin/sh + +sleep 120 +/mnt/us/extensions/kdash/daemon.sh start diff --git a/kdash_client/kite/onboot/kdash.sh b/kdash_client/kite/onboot/kdash.sh new file mode 100644 index 0000000..853ac9f --- /dev/null +++ b/kdash_client/kite/onboot/kdash.sh @@ -0,0 +1,3 @@ +#!/bin/sh + +/mnt/us/extensions/kdash/startup.sh & diff --git a/kdash_client/src/api.rs b/kdash_client/src/api.rs index c435d16..d0d2a5f 100644 --- a/kdash_client/src/api.rs +++ b/kdash_client/src/api.rs @@ -2,6 +2,8 @@ use anyhow::{bail, Result}; use kdash_protocol::Orientation; use url::Url; +use crate::battery::BatteryStatus; + #[derive(Debug)] pub struct Api { pub jwt: String, @@ -22,7 +24,7 @@ impl Api { format!("Bearer {}", bearer) } - pub async fn fetch_config(&self) -> Result { + pub async fn fetch_config(&self) -> Result { let config = reqwest::Client::new() .get(self.config_url.to_owned()) .header( @@ -37,6 +39,30 @@ impl Api { Ok(config) } + pub async fn fetch_config_with_post_device_state( + &self, + battery: &BatteryStatus, + ) -> Result { + let config = reqwest::Client::new() + .post(self.config_url.to_owned()) + .header( + reqwest::header::AUTHORIZATION, + Self::authorization_bearer(&self.jwt), + ) + .json(&kdash_protocol::DeviceStatePost { + battery_charging: battery.is_charging, + battery_level: kdash_protocol::Percent::try_new(battery.percentage)?, + battery_current: battery.current, + battery_voltage: battery.voltage, + }) + .send() + .await? + .json::() + .await?; + + Ok(config) + } + pub async fn fetch_image<'a>( &self, size: (u32, u32), diff --git a/kdash_client/src/app.rs b/kdash_client/src/app.rs index c8a9e82..488439d 100644 --- a/kdash_client/src/app.rs +++ b/kdash_client/src/app.rs @@ -1,4 +1,4 @@ -use std::{net::IpAddr, path::PathBuf, rc::Rc, sync::Mutex}; +use std::{net::IpAddr, rc::Rc, sync::Mutex}; use openlipc_dyn::Lipc; @@ -7,18 +7,11 @@ use crate::api; pub struct State { pub api: api::Api, pub app_config: AppConfig, - pub config: Rc>, + pub config: Rc>, pub lipc: Rc>, } pub struct AppConfig { pub net: String, pub router_ip: IpAddr, - pub assets_path: PathBuf, -} - -impl AppConfig { - pub fn asset(&self, path: &str) -> PathBuf { - self.assets_path.join(path) - } } diff --git a/kdash_client/src/battery.rs b/kdash_client/src/battery.rs index e2b3c3f..080fee7 100644 --- a/kdash_client/src/battery.rs +++ b/kdash_client/src/battery.rs @@ -8,6 +8,7 @@ pub struct BatteryStatus { pub is_charging: bool, pub percentage: u8, pub current: i16, + pub voltage: i16, } pub fn get_battery_status() -> Result { @@ -23,10 +24,14 @@ pub fn get_battery_status() -> Result { let charge_current_str = utils::exec_command("gasgauge-info", &["-l"])?; let charge_current = parse::parse_number_from_start_signed::(&charge_current_str)?; + let voltage_str = utils::exec_command("gasgauge-info", &["-v"])?; + let voltage = parse::parse_number_from_start_signed::(&voltage_str)?; + let battery = BatteryStatus { is_charging, percentage: charge, current: charge_current, + voltage, }; log::info!("Got battery status: {:?}", battery); @@ -44,13 +49,13 @@ pub fn restart_powerd_config_condition( battery_config: &kdash_protocol::BatteryConfig, ) -> Result<()> { if battery.is_charging - && battery.percentage <= *battery_config.restart_powerd_threshold + && battery.percentage <= battery_config.restart_powerd_threshold.into_inner() && battery.current <= 0 { log::info!( "Battery charge below threshold ({} <= {}): restarting powerd", battery.percentage, - *battery_config.restart_powerd_threshold + battery_config.restart_powerd_threshold.into_inner() ); restart_powerd() } else { diff --git a/kdash_client/src/config.rs b/kdash_client/src/config.rs index f58b93f..002d8fa 100644 --- a/kdash_client/src/config.rs +++ b/kdash_client/src/config.rs @@ -1,5 +1,5 @@ use envconfig::Envconfig; -use std::{net, path::PathBuf}; +use std::net; use url::Url; #[derive(Envconfig, Debug)] @@ -15,7 +15,4 @@ pub struct Config { #[envconfig(from = "NET")] pub net: String, - - #[envconfig(from = "ASSETS")] - pub assets: PathBuf, } diff --git a/kdash_client/src/fb/mod.rs b/kdash_client/src/fb/mod.rs index 12f8d7f..b88808a 100644 --- a/kdash_client/src/fb/mod.rs +++ b/kdash_client/src/fb/mod.rs @@ -18,9 +18,9 @@ pub fn eips_clear() -> Result<()> { utils::exec_command_discard("eips", &["-c"]) } -pub fn image_buf_to_raw<'a>( - buf: &'a image::ImageBuffer, Vec>, -) -> ImageRawBE<'a, Gray8> { +pub fn image_buf_to_raw( + buf: &image::ImageBuffer, Vec>, +) -> ImageRawBE<'_, Gray8> { ImageRaw::new(buf.as_raw(), buf.dimensions().0) } @@ -186,10 +186,7 @@ impl DrawTarget for FramebufferDisplay { impl OriginDimensions for FramebufferDisplay { fn size(&self) -> Size { - Size::new( - self.fb_orientation.virtual_x as u32, - self.fb_orientation.virtual_y as u32, - ) + Size::new(self.fb_orientation.virtual_x, self.fb_orientation.virtual_y) } } diff --git a/kdash_client/src/lib.rs b/kdash_client/src/lib.rs index 9858007..138ef19 100644 --- a/kdash_client/src/lib.rs +++ b/kdash_client/src/lib.rs @@ -19,18 +19,18 @@ pub async fn run_once( state: &app::State, display: &mut fb::FramebufferDisplay, ) -> Result> { - let config = state.config.lock().unwrap(); + let config = state.config.lock().await; utils::set_cpu_powersaving()?; utils::prevent_screensaver(&state.lipc.lock().unwrap())?; let battery = battery::get_battery_status()?; battery::restart_powerd_config_condition(&battery, &config.battery)?; - if battery.percentage <= *config.battery.low { + if battery.percentage <= config.battery.low.into_inner() { log::info!( "Battery low: {}% <= {}%", battery.percentage, - *config.battery.low + config.battery.low.into_inner() ); Image::new(&*assets::ERROR_BATTERY_LOW_IMAGE, Point::zero()) @@ -66,7 +66,11 @@ pub async fn run_once( log::info!("Fetching config"); - let fetch_succ = match state.api.fetch_config().await { + let fetch_succ = match state + .api + .fetch_config_with_post_device_state(&battery) + .await + { Ok(value) => { new_config = Some(value); @@ -107,7 +111,7 @@ pub async fn run_once( .draw(display)?; } - if battery.percentage <= *config.battery.alert { + if battery.percentage <= config.battery.alert.into_inner() { let now_str = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true); let text = format!("Battery at {}%, please charge!", battery.percentage); let status_box = fb::widgets::status_box::StatusBox::new_with_default_style( diff --git a/kdash_client/src/main.rs b/kdash_client/src/main.rs index 7f3aa75..8034a4c 100644 --- a/kdash_client/src/main.rs +++ b/kdash_client/src/main.rs @@ -4,8 +4,6 @@ use envconfig::Envconfig; use openlipc_dyn::Lipc; use std::{rc::Rc, sync::Mutex, thread, time}; -use kdash_client; - #[tokio::main] async fn main() -> Result<()> { env_logger::init(); @@ -13,7 +11,9 @@ async fn main() -> Result<()> { let env_config = kdash_client::config::Config::init_from_env().unwrap(); let api = kdash_client::api::Api::new(env_config.kdash_jwt, env_config.kdash_url)?; - let config = api.fetch_config().await?; + + let battery = kdash_client::battery::get_battery_status()?; + let config = api.fetch_config_with_post_device_state(&battery).await?; let lipc = Lipc::load(None)?; @@ -22,15 +22,14 @@ async fn main() -> Result<()> { app_config: kdash_client::app::AppConfig { net: env_config.net, router_ip: env_config.router_ip, - assets_path: env_config.assets, }, - config: Rc::new(Mutex::new(config)), + config: Rc::new(tokio::sync::Mutex::new(config)), lipc: Rc::new(Mutex::new(lipc)), }; let mut display = kdash_client::fb::FramebufferDisplay::new( kdash_client::fb::DEFAULT_FB, - state.config.lock().unwrap().device.orientation, + state.config.lock().await.device.orientation, )?; kdash_client::utils::kill_kindle()?; @@ -44,7 +43,7 @@ async fn main() -> Result<()> { match kdash_client::run_once(&state, &mut display).await { Ok(Some(new_config)) => { log::info!("Updating config"); - let mut config = state.config.lock().unwrap(); + let mut config = state.config.lock().await; if display.fb_orientation.orientation != new_config.device.orientation { log::info!( "Updating orientation: {:?} -> {:?}", @@ -65,7 +64,7 @@ async fn main() -> Result<()> { } } - let config = state.config.lock().unwrap(); + let config = state.config.lock().await; thread::sleep(config.time.delay_before_suspend.to_std()?); diff --git a/kdash_protocol/Cargo.toml b/kdash_protocol/Cargo.toml index e04c727..a72ecf6 100644 --- a/kdash_protocol/Cargo.toml +++ b/kdash_protocol/Cargo.toml @@ -7,6 +7,6 @@ edition = "2021" chrono = { version = "0.4.38", features = ["serde"] } chrono-tz = { version = "0.10.0", features = ["serde"] } cron = { version = "0.13.0", features = ["serde"] } +nutype = { version = "0.5.0", features = ["serde"] } serde = { version = "1.0.214", features = ["derive"] } serde_with = { version = "3.11.0", features = ["chrono_0_4"] } -validated_newtype = "0.1.1" diff --git a/kdash_protocol/src/lib.rs b/kdash_protocol/src/lib.rs index e2018d8..be0aaf5 100644 --- a/kdash_protocol/src/lib.rs +++ b/kdash_protocol/src/lib.rs @@ -1,14 +1,17 @@ use chrono::Duration; use chrono_tz::Tz; use cron::Schedule; +use nutype::nutype; use serde::{Deserialize, Serialize}; -use validated_newtype::validated_newtype; -validated_newtype! { - #[derive(Debug, PartialOrd, Ord, PartialEq, Eq, Serialize, Clone, Copy)] - u8 => pub Percent - if |n: &u8| *n <= 100; - error "percent must in range 0-100" +#[nutype( + derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq), + validate(predicate = is_valid_percent) +)] +pub struct Percent(u8); + +fn is_valid_percent(n: &u8) -> bool { + *n <= 100 } #[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] @@ -66,3 +69,11 @@ pub enum Corner { BottomLeft, BottomRight, } + +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] +pub struct DeviceStatePost { + pub battery_charging: bool, + pub battery_level: Percent, + pub battery_current: i16, + pub battery_voltage: i16, +} diff --git a/kdash_server/Cargo.toml b/kdash_server/Cargo.toml index e1ee860..21c0b18 100644 --- a/kdash_server/Cargo.toml +++ b/kdash_server/Cargo.toml @@ -11,11 +11,16 @@ chrono-tz = { version = "0.10.0", features = ["serde"] } clap = { version = "4.5.20", features = ["derive"] } cron = { version = "0.13.0", features = ["serde"] } env_logger = "0.11.5" +envconfig = "0.11.0" +futures = "0.3.31" +futures-util = "0.3.31" jsonwebtoken = "9.3.0" kdash_protocol = { path = "../kdash_protocol" } reqwest = { version = "0.12.9", features = ["stream"] } +rumqttc = "0.24.0" serde = { version = "1.0.215", features = ["derive"] } serde_json = "1.0.132" serde_with = { version = "3.11.0", features = ["chrono_0_4"] } +tokio = { version = "1.41.1", features = ["full"] } url = { version = "2.5.3", features = ["serde"] } uuid = { version = "1.11.0", features = ["serde"] } diff --git a/kdash_server/src/config.rs b/kdash_server/src/config.rs new file mode 100644 index 0000000..73d42bf --- /dev/null +++ b/kdash_server/src/config.rs @@ -0,0 +1,22 @@ +use envconfig::Envconfig; + +#[derive(Envconfig, Clone)] +pub struct Config { + #[envconfig(from = "KDASH_MQTT_SERVER")] + pub kdash_mqtt_server: String, + + #[envconfig(from = "KDASH_MQTT_PORT")] + pub kdash_mqtt_port: u16, + + #[envconfig(from = "KDASH_MQTT_USERNAME")] + pub kdash_mqtt_username: String, + + #[envconfig(from = "KDASH_MQTT_PASSWORD")] + pub kdash_mqtt_password: String, + + #[envconfig(from = "KDASH_MQTT_TOPIC")] + pub kdash_mqtt_topic: String, + + #[envconfig(from = "KDASH_MQTT_DISCOVERY_PREFIX")] + pub kdash_mqtt_discovery_prefix: Option, +} diff --git a/kdash_server/src/handlers.rs b/kdash_server/src/handlers.rs index d9deaff..83ff1cc 100644 --- a/kdash_server/src/handlers.rs +++ b/kdash_server/src/handlers.rs @@ -1,31 +1,24 @@ -use actix_web::{dev::PeerAddr, get, http, web, Error, HttpRequest, HttpResponse}; +use std::collections::HashMap; -use crate::{AppState, Claims}; +use actix_web::{dev::PeerAddr, get, post, web, Error, HttpResponse}; + +use crate::{ + AppState, Claims, MqttClient, MqttDeviceDiscovery, MqttDeviceDiscoveryComponent, + MqttDeviceDiscoveryDevice, MqttDeviceDiscoveryOrigin, MqttState, VERSION, +}; #[get("/config")] -pub async fn get_config(req: HttpRequest, data: web::Data) -> HttpResponse { - let token = match req - .headers() - .get(http::header::AUTHORIZATION) - .and_then(|h| h.to_str().ok()) - .filter(|s| s.starts_with("Bearer ")) - .map(|s| &s[7..]) - { - Some(s) => s, +pub async fn get_config(data: web::Data, claims: Option) -> HttpResponse { + let claims = match claims { + Some(x) => x, None => return HttpResponse::Unauthorized().finish(), }; - let decoded = - match jsonwebtoken::decode::(token, &data.jwt_decoding_key, &data.jwt_validation) { - Ok(x) => x, - Err(_) => return HttpResponse::BadRequest().finish(), - }; - - if !data.device_ids.contains(&decoded.claims.sub) { + if !data.device_ids.contains(&claims.sub) { return HttpResponse::Unauthorized().finish(); } - let buf = match data.devices_api_json.get(&decoded.claims.sub) { + let buf = match data.devices_api_json.get(&claims.sub) { Some(x) => x, None => return HttpResponse::InternalServerError().finish(), }; @@ -35,35 +28,153 @@ pub async fn get_config(req: HttpRequest, data: web::Data) -> HttpResp .body(buf.clone()) } +#[post("/config")] +pub async fn post_config( + claims: Option, + data: web::Data, + mqtt_client: web::Data, + device_state: web::Json, +) -> HttpResponse { + let claims = match claims { + Some(x) => x, + None => return HttpResponse::Unauthorized().finish(), + }; + + if !data.device_ids.contains(&claims.sub) { + return HttpResponse::Unauthorized().finish(); + } + + let (config, config_buf) = match ( + data.devices_api.get(&claims.sub), + data.devices_api_json.get(&claims.sub), + ) { + (Some(config), Some(config_buf)) => (config, config_buf), + _ => return HttpResponse::InternalServerError().finish(), + }; + + let device_id = claims.sub.to_string(); + + let state_topic = format!("{}/device/{}/state", mqtt_client.topic, device_id); + + let state_payload = match serde_json::to_vec(&MqttState { + battery_charging: device_state.battery_charging, + battery_level: device_state.battery_level, + battery_current: device_state.battery_current, + battery_voltage: device_state.battery_voltage, + }) { + Ok(x) => x, + Err(_) => return HttpResponse::InternalServerError().finish(), + }; + + if mqtt_client + .client + .publish( + &state_topic, + rumqttc::v5::mqttbytes::QoS::AtLeastOnce, + false, + state_payload, + ) + .await + .is_err() + { + return HttpResponse::InternalServerError().finish(); + } + + if let Some(discovery_topic) = &mqtt_client.discovery_topic { + let unique_id = format!("kdash_{}", device_id); + let unique_id_battery_level = format!("{}_battery_level", unique_id); + let unique_id_battery_current = format!("{}_battery_current", unique_id); + let unique_id_battery_voltage = format!("{}_battery_voltage", unique_id); + + let mut components = HashMap::new(); + components.insert( + "battery_level".to_string(), + MqttDeviceDiscoveryComponent { + platform: "sensor", + device_class: "battery", + unit_of_measurement: Some("%"), + value_template: "{{ value_json.battery_level }}", + unique_id: &unique_id_battery_level, + }, + ); + components.insert( + "battery_current".to_string(), + MqttDeviceDiscoveryComponent { + platform: "sensor", + device_class: "current", + unit_of_measurement: Some("mA"), + value_template: "{{ value_json.battery_current }}", + unique_id: &unique_id_battery_current, + }, + ); + components.insert( + "battery_voltage".to_string(), + MqttDeviceDiscoveryComponent { + platform: "sensor", + device_class: "voltage", + unit_of_measurement: Some("mV"), + value_template: "{{ value_json.battery_voltage }}", + unique_id: &unique_id_battery_voltage, + }, + ); + + let discovery = MqttDeviceDiscovery { + device: MqttDeviceDiscoveryDevice { + identifiers: &unique_id, + name: config.name.to_owned(), + }, + origin: MqttDeviceDiscoveryOrigin { + name: "kdash", + software_version: VERSION, + url: "https://git.dergrimm.net/dergrimm/kdash", + }, + components, + state_topic: &state_topic, + qos: 2, + }; + + let discovery_payload = match serde_json::to_vec(&discovery) { + Ok(x) => x, + Err(_) => return HttpResponse::InternalServerError().finish(), + }; + + if mqtt_client + .client + .publish( + format!("{}/device/kdash/{}/config", discovery_topic, device_id), + rumqttc::v5::mqttbytes::QoS::AtLeastOnce, + false, + discovery_payload, + ) + .await + .is_err() + { + return HttpResponse::InternalServerError().finish(); + } + } + + HttpResponse::Ok() + .content_type("application/json") + .body(config_buf.clone()) +} + #[get("/image")] pub async fn get_image( - req: HttpRequest, + claims: Option, peer_addr: Option, data: web::Data, client: web::Data, ) -> Result { - let token = match req - .headers() - .get(http::header::AUTHORIZATION) - .and_then(|h| h.to_str().ok()) - .filter(|s| s.starts_with("Bearer ")) - .map(|s| &s[7..]) - { - Some(s) => s, + let claims = match claims { + Some(x) => x, None => return Ok(HttpResponse::Unauthorized().finish()), }; - let decoded = - match jsonwebtoken::decode::(token, &data.jwt_decoding_key, &data.jwt_validation) { - Ok(x) => x, - Err(_) => return Ok(HttpResponse::BadRequest().finish()), - }; - - if !data.device_ids.contains(&decoded.claims.sub) { + if !data.device_ids.contains(&claims.sub) { return Ok(HttpResponse::Unauthorized().finish()); } - let config = match data.devices.get(&decoded.claims.sub) { + let config = match data.devices.get(&claims.sub) { Some(x) => x, None => return Ok(HttpResponse::InternalServerError().finish()), }; diff --git a/kdash_server/src/lib.rs b/kdash_server/src/lib.rs index d476813..c0e9cce 100644 --- a/kdash_server/src/lib.rs +++ b/kdash_server/src/lib.rs @@ -1,14 +1,24 @@ +use actix_web::dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform}; +use actix_web::{http, FromRequest, HttpMessage}; use anyhow::Result; +use futures::future::{err, ok, LocalBoxFuture}; use jsonwebtoken::{DecodingKey, Validation}; +use kdash_protocol::Percent; use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; use std::fs; +use std::future::{ready, Ready}; +use std::marker::PhantomData; use std::path::Path; +use std::sync::Arc; use uuid::Uuid; +pub mod config; pub mod device; pub mod handlers; +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); + #[derive(Debug, Deserialize, Clone)] pub struct Config { pub devices: HashMap, @@ -35,3 +45,177 @@ pub struct Claims { pub exp: i64, pub sub: Uuid, } + +impl FromRequest for Claims { + type Error = actix_web::Error; + type Future = futures::future::Ready>; + + fn from_request( + req: &actix_web::HttpRequest, + _payload: &mut actix_web::dev::Payload, + ) -> Self::Future { + match req.extensions().get::() { + Some(claims) => ok(claims.clone()), + None => err(actix_web::error::ErrorUnauthorized("Unauthorized")), + } + } +} + +pub struct Authority { + pub decoding_key: DecodingKey, + pub validation: Validation, +} + +pub struct JwtAuth { + pub auth: Arc, + claims_marker: PhantomData, +} + +impl JwtAuth { + pub fn new(auth: Arc) -> Self { + Self { + auth, + claims_marker: PhantomData, + } + } +} + +impl Transform for JwtAuth +where + S: Service, Error = actix_web::Error>, + S::Future: 'static, + B: 'static, +{ + type Response = ServiceResponse; + type Error = actix_web::Error; + type InitError = (); + type Transform = JwtAuthMiddleware; + type Future = Ready>; + + fn new_transform(&self, service: S) -> Self::Future { + ready(Ok(JwtAuthMiddleware { + service, + auth: Arc::clone(&self.auth), + claims_marker: PhantomData, + })) + } +} + +pub struct JwtAuthMiddleware { + service: S, + auth: Arc, + claims_marker: PhantomData, +} + +impl Service for JwtAuthMiddleware +where + S: Service, Error = actix_web::Error>, + S::Future: 'static, + B: 'static, +{ + type Response = ServiceResponse; + type Error = actix_web::Error; + type Future = LocalBoxFuture<'static, Result>; + + forward_ready!(service); + + fn call(&self, req: ServiceRequest) -> Self::Future { + let decoded = req + .headers() + .get(http::header::AUTHORIZATION) + .and_then(|h| h.to_str().ok()) + .filter(|s| s.starts_with("Bearer ")) + .map(|s| &s[7..]) + .and_then(|token| { + jsonwebtoken::decode::( + token, + &self.auth.decoding_key, + &self.auth.validation, + ) + .ok() + }); + + if let Some(data) = decoded { + req.extensions_mut().insert(data.claims); + dbg!(req.extensions()); + } + + Box::pin(self.service.call(req)) + } +} + +pub struct MqttClient { + pub client: rumqttc::v5::AsyncClient, + pub topic: String, + pub discovery_topic: Option, +} + +#[derive(Serialize)] +pub struct MqttState { + pub battery_charging: bool, + pub battery_level: Percent, + pub battery_current: i16, + pub battery_voltage: i16, +} + +#[derive(Serialize)] +pub struct MqttDeviceDiscovery<'a> { + #[serde(rename = "dev")] + pub device: MqttDeviceDiscoveryDevice<'a>, + + #[serde(rename = "o")] + pub origin: MqttDeviceDiscoveryOrigin<'a>, + + #[serde(rename = "cmps")] + pub components: HashMap>, + + pub state_topic: &'a str, + + pub qos: u8, +} + +#[derive(Serialize)] +pub struct MqttDeviceDiscoveryDevice<'a> { + #[serde(rename = "ids")] + pub identifiers: &'a str, + + pub name: String, + // #[serde(rename = "mf")] + // pub manufacturer: &'a str, + + // #[serde(rename = "mdl")] + // pub default_manufacturer: &'a str, + + // #[serde(rename = "sw")] + // pub sw_version: &'a str, + + // #[serde(rename = "sn")] + // pub serial_number: &'a str, + + // #[serde(rename = "hw")] + // pub hw_version: &'a str, +} + +#[derive(Serialize)] +pub struct MqttDeviceDiscoveryOrigin<'a> { + pub name: &'a str, + + #[serde(rename = "sw")] + pub software_version: &'a str, + + pub url: &'a str, +} + +#[derive(Serialize)] +pub struct MqttDeviceDiscoveryComponent<'a> { + #[serde(rename = "p")] + pub platform: &'a str, + pub device_class: &'a str, + + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default)] + pub unit_of_measurement: Option<&'a str>, + + pub value_template: &'a str, + pub unique_id: &'a str, +} diff --git a/kdash_server/src/main.rs b/kdash_server/src/main.rs index 986e13d..9a336e9 100644 --- a/kdash_server/src/main.rs +++ b/kdash_server/src/main.rs @@ -2,11 +2,14 @@ use actix_web::{middleware::Logger, web, App, HttpServer}; use anyhow::Result; use chrono::prelude::*; use clap::{Parser, Subcommand}; +use envconfig::Envconfig; use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation}; use std::{ collections::{HashMap, HashSet}, fs, path::PathBuf, + sync::Arc, + time::Duration, }; use uuid::Uuid; @@ -40,6 +43,8 @@ async fn main() -> Result<()> { let cli = Cli::parse(); + let env_config = kdash_server::config::Config::init_from_env()?; + let config = kdash_server::read_config(&cli.config)?; let sk = fs::read(cli.private_key)?; @@ -61,6 +66,25 @@ async fn main() -> Result<()> { println!("{}", token); } Commands::Start => { + let mut mqtt_options = rumqttc::v5::MqttOptions::new( + "kdash-server", + env_config.kdash_mqtt_server, + env_config.kdash_mqtt_port, + ); + mqtt_options.set_credentials( + env_config.kdash_mqtt_username, + env_config.kdash_mqtt_password, + ); + mqtt_options.set_keep_alive(Duration::from_secs(10)); + + let (mqtt_client, mut eventloop) = rumqttc::v5::AsyncClient::new(mqtt_options, 20); + + tokio::spawn(async move { + loop { + eventloop.poll().await.unwrap(); + } + }); + HttpServer::new(move || { let devices = config.devices.to_owned(); let device_ids: HashSet = config.devices.keys().cloned().collect(); @@ -88,8 +112,20 @@ async fn main() -> Result<()> { jwt_validation: Validation::new(ALGORITHM), })) .app_data(web::Data::new(reqwest_client)) + .app_data(web::Data::new(kdash_server::MqttClient { + client: mqtt_client.to_owned(), + topic: env_config.kdash_mqtt_topic.to_owned(), + discovery_topic: env_config.kdash_mqtt_discovery_prefix.to_owned(), + })) .wrap(Logger::default()) + .wrap(kdash_server::JwtAuth::::new( + Arc::new(kdash_server::Authority { + decoding_key: decoding_key.to_owned(), + validation: Validation::new(ALGORITHM), + }), + )) .service(kdash_server::handlers::get_config) + .service(kdash_server::handlers::post_config) .service(kdash_server::handlers::get_image) }) .bind(("0.0.0.0", 8080))?