This commit is contained in:
Dominic Grimm 2024-11-22 12:38:00 +01:00
parent 76b43a0386
commit 220a446fb6
21 changed files with 677 additions and 111 deletions

167
Cargo.lock generated
View file

@ -548,6 +548,15 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" 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]] [[package]]
name = "cookie" name = "cookie"
version = "0.16.2" version = "0.16.2"
@ -666,7 +675,7 @@ version = "0.99.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f33878137e4dafd7fa914ad4e259e18a4e8e532b9617a2d0150262bf53abfce" checksum = "5f33878137e4dafd7fa914ad4e259e18a4e8e532b9617a2d0150262bf53abfce"
dependencies = [ dependencies = [
"convert_case", "convert_case 0.4.0",
"proc-macro2 1.0.89", "proc-macro2 1.0.89",
"quote 1.0.37", "quote 1.0.37",
"rustc_version", "rustc_version",
@ -884,6 +893,17 @@ dependencies = [
"num-traits", "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]] [[package]]
name = "fnv" name = "fnv"
version = "1.0.7" version = "1.0.7"
@ -1204,10 +1224,10 @@ dependencies = [
"http 1.1.0", "http 1.1.0",
"hyper", "hyper",
"hyper-util", "hyper-util",
"rustls", "rustls 0.23.16",
"rustls-pki-types", "rustls-pki-types",
"tokio", "tokio",
"tokio-rustls", "tokio-rustls 0.26.0",
"tower-service", "tower-service",
"webpki-roots", "webpki-roots",
] ]
@ -1537,9 +1557,9 @@ dependencies = [
"chrono", "chrono",
"chrono-tz", "chrono-tz",
"cron", "cron",
"nutype",
"serde", "serde",
"serde_with", "serde_with",
"validated_newtype",
] ]
[[package]] [[package]]
@ -1553,16 +1573,42 @@ dependencies = [
"clap", "clap",
"cron", "cron",
"env_logger", "env_logger",
"envconfig",
"futures",
"futures-util",
"jsonwebtoken", "jsonwebtoken",
"kdash_protocol", "kdash_protocol",
"reqwest", "reqwest",
"rumqttc",
"serde", "serde",
"serde_json", "serde_json",
"serde_with", "serde_with",
"tokio",
"url", "url",
"uuid", "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]] [[package]]
name = "language-tags" name = "language-tags"
version = "0.3.2" version = "0.3.2"
@ -1744,6 +1790,29 @@ dependencies = [
"autocfg", "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]] [[package]]
name = "object" name = "object"
version = "0.36.5" version = "0.36.5"
@ -1980,7 +2049,7 @@ dependencies = [
"quinn-proto", "quinn-proto",
"quinn-udp", "quinn-udp",
"rustc-hash", "rustc-hash",
"rustls", "rustls 0.23.16",
"socket2", "socket2",
"thiserror", "thiserror",
"tokio", "tokio",
@ -1997,7 +2066,7 @@ dependencies = [
"rand", "rand",
"ring", "ring",
"rustc-hash", "rustc-hash",
"rustls", "rustls 0.23.16",
"slab", "slab",
"thiserror", "thiserror",
"tinyvec", "tinyvec",
@ -2138,7 +2207,7 @@ dependencies = [
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"quinn", "quinn",
"rustls", "rustls 0.23.16",
"rustls-pemfile", "rustls-pemfile",
"rustls-pki-types", "rustls-pki-types",
"serde", "serde",
@ -2148,7 +2217,7 @@ dependencies = [
"system-configuration", "system-configuration",
"tokio", "tokio",
"tokio-native-tls", "tokio-native-tls",
"tokio-rustls", "tokio-rustls 0.26.0",
"tokio-util", "tokio-util",
"tower-service", "tower-service",
"url", "url",
@ -2175,6 +2244,24 @@ dependencies = [
"windows-sys 0.52.0", "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]] [[package]]
name = "rustc-demangle" name = "rustc-demangle"
version = "0.1.24" version = "0.1.24"
@ -2209,6 +2296,20 @@ dependencies = [
"windows-sys 0.52.0", "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]] [[package]]
name = "rustls" name = "rustls"
version = "0.23.16" version = "0.23.16"
@ -2223,6 +2324,19 @@ dependencies = [
"zeroize", "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]] [[package]]
name = "rustls-pemfile" name = "rustls-pemfile"
version = "2.2.0" version = "2.2.0"
@ -2453,6 +2567,9 @@ name = "spin"
version = "0.9.8" version = "0.9.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
dependencies = [
"lock_api",
]
[[package]] [[package]]
name = "stable_deref_trait" name = "stable_deref_trait"
@ -2672,13 +2789,24 @@ dependencies = [
"tokio", "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]] [[package]]
name = "tokio-rustls" name = "tokio-rustls"
version = "0.26.0" version = "0.26.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c7bc40d0e5a97695bb96e27995cd3a08538541b0a846f65bba7a359f36700d4" checksum = "0c7bc40d0e5a97695bb96e27995cd3a08538541b0a846f65bba7a359f36700d4"
dependencies = [ dependencies = [
"rustls", "rustls 0.23.16",
"rustls-pki-types", "rustls-pki-types",
"tokio", "tokio",
] ]
@ -2740,6 +2868,12 @@ version = "1.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe"
[[package]]
name = "unicode-segmentation"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
[[package]] [[package]]
name = "unicode-xid" name = "unicode-xid"
version = "0.1.0" version = "0.1.0"
@ -2764,6 +2898,12 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "urlencoding"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]] [[package]]
name = "utf16_iter" name = "utf16_iter"
version = "1.0.5" version = "1.0.5"
@ -2791,15 +2931,6 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "validated_newtype"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "160f1ca3dc12c9e48f60b5b7a56092e372bda4ee2805f6c84ae337dd582fe12f"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "vcpkg" name = "vcpkg"
version = "0.2.15" version = "0.2.15"

View file

@ -25,6 +25,8 @@ reqwest = { version = "0.12.9", default-features = false, features = [
"json", "json",
"rustls-tls", "rustls-tls",
"stream", "stream",
"http2",
"charset",
] } ] }
tinybmp = "0.6.0" tinybmp = "0.6.0"
tokio = { version = "1.41.0", features = ["full"] } tokio = { version = "1.41.0", features = ["full"] }

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension>
<information>
<name>kdash</name>
<version>0.1.0</version>
<author>dergrimm</author>
<id>kdash</id>
</information>
<menus>
<menu type="json" dynamic="false">menu.json</menu>
</menus>
</extension>

View file

@ -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_ENV_FILE="${DAEMON_PATH}/kdash.env"
DAEMON_ENABLED_FILE="${DAEMON_PATH}/ENABLED"
DAEMON="./kdash_client" DAEMON="./kdash_client"
DAEMONOPTS="" DAEMONOPTS=""
@ -13,7 +14,14 @@ PIDFILE="${DAEMON_PATH}/${DAEMON}.pid"
# SCRIPTNAME="/etc/init.d/${NAME}" # SCRIPTNAME="/etc/init.d/${NAME}"
case "$1" in case "$1" in
enable)
touch "$DAEMON_ENABLED_FILE"
;;
disable)
rm -f "$DAEMON_ENABLED_FILE"
;;
start) start)
if [ -e "$DAEMON_ENABLED_FILE" ]; then
printf "%-50s" "Starting $NAME..." printf "%-50s" "Starting $NAME..."
cd "$DAEMON_PATH" || exit cd "$DAEMON_PATH" || exit
. "$DAEMON_ENV_FILE" . "$DAEMON_ENV_FILE"
@ -28,6 +36,9 @@ start)
echo "$PID" >"$PIDFILE" echo "$PID" >"$PIDFILE"
printf "%s\n" "Ok" printf "%s\n" "Ok"
fi fi
else
echo "Service not enabled. ENABLED file not found"
fi
;; ;;
status) status)
printf "%-50s" "Checking $NAME..." printf "%-50s" "Checking $NAME..."

View file

@ -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" }
]
}
]
}

View file

@ -0,0 +1,4 @@
#!/bin/sh
sleep 120
/mnt/us/extensions/kdash/daemon.sh start

View file

@ -0,0 +1,3 @@
#!/bin/sh
/mnt/us/extensions/kdash/startup.sh &

View file

@ -2,6 +2,8 @@ use anyhow::{bail, Result};
use kdash_protocol::Orientation; use kdash_protocol::Orientation;
use url::Url; use url::Url;
use crate::battery::BatteryStatus;
#[derive(Debug)] #[derive(Debug)]
pub struct Api { pub struct Api {
pub jwt: String, pub jwt: String,
@ -22,7 +24,7 @@ impl Api {
format!("Bearer {}", bearer) format!("Bearer {}", bearer)
} }
pub async fn fetch_config(&self) -> Result<kdash_protocol::Config, reqwest::Error> { pub async fn fetch_config(&self) -> Result<kdash_protocol::Config> {
let config = reqwest::Client::new() let config = reqwest::Client::new()
.get(self.config_url.to_owned()) .get(self.config_url.to_owned())
.header( .header(
@ -37,6 +39,30 @@ impl Api {
Ok(config) Ok(config)
} }
pub async fn fetch_config_with_post_device_state(
&self,
battery: &BatteryStatus,
) -> Result<kdash_protocol::Config> {
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::<kdash_protocol::Config>()
.await?;
Ok(config)
}
pub async fn fetch_image<'a>( pub async fn fetch_image<'a>(
&self, &self,
size: (u32, u32), size: (u32, u32),

View file

@ -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; use openlipc_dyn::Lipc;
@ -7,18 +7,11 @@ use crate::api;
pub struct State { pub struct State {
pub api: api::Api, pub api: api::Api,
pub app_config: AppConfig, pub app_config: AppConfig,
pub config: Rc<Mutex<kdash_protocol::Config>>, pub config: Rc<tokio::sync::Mutex<kdash_protocol::Config>>,
pub lipc: Rc<Mutex<Lipc>>, pub lipc: Rc<Mutex<Lipc>>,
} }
pub struct AppConfig { pub struct AppConfig {
pub net: String, pub net: String,
pub router_ip: IpAddr, pub router_ip: IpAddr,
pub assets_path: PathBuf,
}
impl AppConfig {
pub fn asset(&self, path: &str) -> PathBuf {
self.assets_path.join(path)
}
} }

View file

@ -8,6 +8,7 @@ pub struct BatteryStatus {
pub is_charging: bool, pub is_charging: bool,
pub percentage: u8, pub percentage: u8,
pub current: i16, pub current: i16,
pub voltage: i16,
} }
pub fn get_battery_status() -> Result<BatteryStatus> { pub fn get_battery_status() -> Result<BatteryStatus> {
@ -23,10 +24,14 @@ pub fn get_battery_status() -> Result<BatteryStatus> {
let charge_current_str = utils::exec_command("gasgauge-info", &["-l"])?; let charge_current_str = utils::exec_command("gasgauge-info", &["-l"])?;
let charge_current = parse::parse_number_from_start_signed::<i16>(&charge_current_str)?; let charge_current = parse::parse_number_from_start_signed::<i16>(&charge_current_str)?;
let voltage_str = utils::exec_command("gasgauge-info", &["-v"])?;
let voltage = parse::parse_number_from_start_signed::<i16>(&voltage_str)?;
let battery = BatteryStatus { let battery = BatteryStatus {
is_charging, is_charging,
percentage: charge, percentage: charge,
current: charge_current, current: charge_current,
voltage,
}; };
log::info!("Got battery status: {:?}", battery); log::info!("Got battery status: {:?}", battery);
@ -44,13 +49,13 @@ pub fn restart_powerd_config_condition(
battery_config: &kdash_protocol::BatteryConfig, battery_config: &kdash_protocol::BatteryConfig,
) -> Result<()> { ) -> Result<()> {
if battery.is_charging if battery.is_charging
&& battery.percentage <= *battery_config.restart_powerd_threshold && battery.percentage <= battery_config.restart_powerd_threshold.into_inner()
&& battery.current <= 0 && battery.current <= 0
{ {
log::info!( log::info!(
"Battery charge below threshold ({} <= {}): restarting powerd", "Battery charge below threshold ({} <= {}): restarting powerd",
battery.percentage, battery.percentage,
*battery_config.restart_powerd_threshold battery_config.restart_powerd_threshold.into_inner()
); );
restart_powerd() restart_powerd()
} else { } else {

View file

@ -1,5 +1,5 @@
use envconfig::Envconfig; use envconfig::Envconfig;
use std::{net, path::PathBuf}; use std::net;
use url::Url; use url::Url;
#[derive(Envconfig, Debug)] #[derive(Envconfig, Debug)]
@ -15,7 +15,4 @@ pub struct Config {
#[envconfig(from = "NET")] #[envconfig(from = "NET")]
pub net: String, pub net: String,
#[envconfig(from = "ASSETS")]
pub assets: PathBuf,
} }

View file

@ -18,9 +18,9 @@ pub fn eips_clear() -> Result<()> {
utils::exec_command_discard("eips", &["-c"]) utils::exec_command_discard("eips", &["-c"])
} }
pub fn image_buf_to_raw<'a>( pub fn image_buf_to_raw(
buf: &'a image::ImageBuffer<image::Luma<u8>, Vec<u8>>, buf: &image::ImageBuffer<image::Luma<u8>, Vec<u8>>,
) -> ImageRawBE<'a, Gray8> { ) -> ImageRawBE<'_, Gray8> {
ImageRaw::new(buf.as_raw(), buf.dimensions().0) ImageRaw::new(buf.as_raw(), buf.dimensions().0)
} }
@ -186,10 +186,7 @@ impl DrawTarget for FramebufferDisplay {
impl OriginDimensions for FramebufferDisplay { impl OriginDimensions for FramebufferDisplay {
fn size(&self) -> Size { fn size(&self) -> Size {
Size::new( Size::new(self.fb_orientation.virtual_x, self.fb_orientation.virtual_y)
self.fb_orientation.virtual_x as u32,
self.fb_orientation.virtual_y as u32,
)
} }
} }

View file

@ -19,18 +19,18 @@ pub async fn run_once(
state: &app::State, state: &app::State,
display: &mut fb::FramebufferDisplay, display: &mut fb::FramebufferDisplay,
) -> Result<Option<kdash_protocol::Config>> { ) -> Result<Option<kdash_protocol::Config>> {
let config = state.config.lock().unwrap(); let config = state.config.lock().await;
utils::set_cpu_powersaving()?; utils::set_cpu_powersaving()?;
utils::prevent_screensaver(&state.lipc.lock().unwrap())?; utils::prevent_screensaver(&state.lipc.lock().unwrap())?;
let battery = battery::get_battery_status()?; let battery = battery::get_battery_status()?;
battery::restart_powerd_config_condition(&battery, &config.battery)?; battery::restart_powerd_config_condition(&battery, &config.battery)?;
if battery.percentage <= *config.battery.low { if battery.percentage <= config.battery.low.into_inner() {
log::info!( log::info!(
"Battery low: {}% <= {}%", "Battery low: {}% <= {}%",
battery.percentage, battery.percentage,
*config.battery.low config.battery.low.into_inner()
); );
Image::new(&*assets::ERROR_BATTERY_LOW_IMAGE, Point::zero()) Image::new(&*assets::ERROR_BATTERY_LOW_IMAGE, Point::zero())
@ -66,7 +66,11 @@ pub async fn run_once(
log::info!("Fetching config"); 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) => { Ok(value) => {
new_config = Some(value); new_config = Some(value);
@ -107,7 +111,7 @@ pub async fn run_once(
.draw(display)?; .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 now_str = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true);
let text = format!("Battery at {}%, please charge!", battery.percentage); let text = format!("Battery at {}%, please charge!", battery.percentage);
let status_box = fb::widgets::status_box::StatusBox::new_with_default_style( let status_box = fb::widgets::status_box::StatusBox::new_with_default_style(

View file

@ -4,8 +4,6 @@ use envconfig::Envconfig;
use openlipc_dyn::Lipc; use openlipc_dyn::Lipc;
use std::{rc::Rc, sync::Mutex, thread, time}; use std::{rc::Rc, sync::Mutex, thread, time};
use kdash_client;
#[tokio::main] #[tokio::main]
async fn main() -> Result<()> { async fn main() -> Result<()> {
env_logger::init(); env_logger::init();
@ -13,7 +11,9 @@ async fn main() -> Result<()> {
let env_config = kdash_client::config::Config::init_from_env().unwrap(); 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 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)?; let lipc = Lipc::load(None)?;
@ -22,15 +22,14 @@ async fn main() -> Result<()> {
app_config: kdash_client::app::AppConfig { app_config: kdash_client::app::AppConfig {
net: env_config.net, net: env_config.net,
router_ip: env_config.router_ip, 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)), lipc: Rc::new(Mutex::new(lipc)),
}; };
let mut display = kdash_client::fb::FramebufferDisplay::new( let mut display = kdash_client::fb::FramebufferDisplay::new(
kdash_client::fb::DEFAULT_FB, kdash_client::fb::DEFAULT_FB,
state.config.lock().unwrap().device.orientation, state.config.lock().await.device.orientation,
)?; )?;
kdash_client::utils::kill_kindle()?; kdash_client::utils::kill_kindle()?;
@ -44,7 +43,7 @@ async fn main() -> Result<()> {
match kdash_client::run_once(&state, &mut display).await { match kdash_client::run_once(&state, &mut display).await {
Ok(Some(new_config)) => { Ok(Some(new_config)) => {
log::info!("Updating 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 { if display.fb_orientation.orientation != new_config.device.orientation {
log::info!( log::info!(
"Updating orientation: {:?} -> {:?}", "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()?); thread::sleep(config.time.delay_before_suspend.to_std()?);

View file

@ -7,6 +7,6 @@ edition = "2021"
chrono = { version = "0.4.38", features = ["serde"] } chrono = { version = "0.4.38", features = ["serde"] }
chrono-tz = { version = "0.10.0", features = ["serde"] } chrono-tz = { version = "0.10.0", features = ["serde"] }
cron = { version = "0.13.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 = { version = "1.0.214", features = ["derive"] }
serde_with = { version = "3.11.0", features = ["chrono_0_4"] } serde_with = { version = "3.11.0", features = ["chrono_0_4"] }
validated_newtype = "0.1.1"

View file

@ -1,14 +1,17 @@
use chrono::Duration; use chrono::Duration;
use chrono_tz::Tz; use chrono_tz::Tz;
use cron::Schedule; use cron::Schedule;
use nutype::nutype;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use validated_newtype::validated_newtype;
validated_newtype! { #[nutype(
#[derive(Debug, PartialOrd, Ord, PartialEq, Eq, Serialize, Clone, Copy)] derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq),
u8 => pub Percent validate(predicate = is_valid_percent)
if |n: &u8| *n <= 100; )]
error "percent must in range 0-100" pub struct Percent(u8);
fn is_valid_percent(n: &u8) -> bool {
*n <= 100
} }
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] #[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
@ -66,3 +69,11 @@ pub enum Corner {
BottomLeft, BottomLeft,
BottomRight, 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,
}

View file

@ -11,11 +11,16 @@ chrono-tz = { version = "0.10.0", features = ["serde"] }
clap = { version = "4.5.20", features = ["derive"] } clap = { version = "4.5.20", features = ["derive"] }
cron = { version = "0.13.0", features = ["serde"] } cron = { version = "0.13.0", features = ["serde"] }
env_logger = "0.11.5" env_logger = "0.11.5"
envconfig = "0.11.0"
futures = "0.3.31"
futures-util = "0.3.31"
jsonwebtoken = "9.3.0" jsonwebtoken = "9.3.0"
kdash_protocol = { path = "../kdash_protocol" } kdash_protocol = { path = "../kdash_protocol" }
reqwest = { version = "0.12.9", features = ["stream"] } reqwest = { version = "0.12.9", features = ["stream"] }
rumqttc = "0.24.0"
serde = { version = "1.0.215", features = ["derive"] } serde = { version = "1.0.215", features = ["derive"] }
serde_json = "1.0.132" serde_json = "1.0.132"
serde_with = { version = "3.11.0", features = ["chrono_0_4"] } serde_with = { version = "3.11.0", features = ["chrono_0_4"] }
tokio = { version = "1.41.1", features = ["full"] }
url = { version = "2.5.3", features = ["serde"] } url = { version = "2.5.3", features = ["serde"] }
uuid = { version = "1.11.0", features = ["serde"] } uuid = { version = "1.11.0", features = ["serde"] }

View file

@ -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<String>,
}

View file

@ -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")] #[get("/config")]
pub async fn get_config(req: HttpRequest, data: web::Data<AppState>) -> HttpResponse { pub async fn get_config(data: web::Data<AppState>, claims: Option<Claims>) -> HttpResponse {
let token = match req let claims = match claims {
.headers() Some(x) => x,
.get(http::header::AUTHORIZATION)
.and_then(|h| h.to_str().ok())
.filter(|s| s.starts_with("Bearer "))
.map(|s| &s[7..])
{
Some(s) => s,
None => return HttpResponse::Unauthorized().finish(), None => return HttpResponse::Unauthorized().finish(),
}; };
let decoded = if !data.device_ids.contains(&claims.sub) {
match jsonwebtoken::decode::<Claims>(token, &data.jwt_decoding_key, &data.jwt_validation) {
Ok(x) => x,
Err(_) => return HttpResponse::BadRequest().finish(),
};
if !data.device_ids.contains(&decoded.claims.sub) {
return HttpResponse::Unauthorized().finish(); 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, Some(x) => x,
None => return HttpResponse::InternalServerError().finish(), None => return HttpResponse::InternalServerError().finish(),
}; };
@ -35,35 +28,153 @@ pub async fn get_config(req: HttpRequest, data: web::Data<AppState>) -> HttpResp
.body(buf.clone()) .body(buf.clone())
} }
#[post("/config")]
pub async fn post_config(
claims: Option<Claims>,
data: web::Data<AppState>,
mqtt_client: web::Data<MqttClient>,
device_state: web::Json<kdash_protocol::DeviceStatePost>,
) -> 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")] #[get("/image")]
pub async fn get_image( pub async fn get_image(
req: HttpRequest, claims: Option<Claims>,
peer_addr: Option<PeerAddr>, peer_addr: Option<PeerAddr>,
data: web::Data<AppState>, data: web::Data<AppState>,
client: web::Data<reqwest::Client>, client: web::Data<reqwest::Client>,
) -> Result<HttpResponse, Error> { ) -> Result<HttpResponse, Error> {
let token = match req let claims = match claims {
.headers() Some(x) => x,
.get(http::header::AUTHORIZATION)
.and_then(|h| h.to_str().ok())
.filter(|s| s.starts_with("Bearer "))
.map(|s| &s[7..])
{
Some(s) => s,
None => return Ok(HttpResponse::Unauthorized().finish()), None => return Ok(HttpResponse::Unauthorized().finish()),
}; };
let decoded = if !data.device_ids.contains(&claims.sub) {
match jsonwebtoken::decode::<Claims>(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) {
return Ok(HttpResponse::Unauthorized().finish()); 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, Some(x) => x,
None => return Ok(HttpResponse::InternalServerError().finish()), None => return Ok(HttpResponse::InternalServerError().finish()),
}; };

View file

@ -1,14 +1,24 @@
use actix_web::dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform};
use actix_web::{http, FromRequest, HttpMessage};
use anyhow::Result; use anyhow::Result;
use futures::future::{err, ok, LocalBoxFuture};
use jsonwebtoken::{DecodingKey, Validation}; use jsonwebtoken::{DecodingKey, Validation};
use kdash_protocol::Percent;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::fs; use std::fs;
use std::future::{ready, Ready};
use std::marker::PhantomData;
use std::path::Path; use std::path::Path;
use std::sync::Arc;
use uuid::Uuid; use uuid::Uuid;
pub mod config;
pub mod device; pub mod device;
pub mod handlers; pub mod handlers;
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
#[derive(Debug, Deserialize, Clone)] #[derive(Debug, Deserialize, Clone)]
pub struct Config { pub struct Config {
pub devices: HashMap<Uuid, device::Config>, pub devices: HashMap<Uuid, device::Config>,
@ -35,3 +45,177 @@ pub struct Claims {
pub exp: i64, pub exp: i64,
pub sub: Uuid, pub sub: Uuid,
} }
impl FromRequest for Claims {
type Error = actix_web::Error;
type Future = futures::future::Ready<Result<Self, Self::Error>>;
fn from_request(
req: &actix_web::HttpRequest,
_payload: &mut actix_web::dev::Payload,
) -> Self::Future {
match req.extensions().get::<Claims>() {
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<Claims> {
pub auth: Arc<Authority>,
claims_marker: PhantomData<Claims>,
}
impl<Claims> JwtAuth<Claims> {
pub fn new(auth: Arc<Authority>) -> Self {
Self {
auth,
claims_marker: PhantomData,
}
}
}
impl<S, B, Claims> Transform<S, ServiceRequest> for JwtAuth<Claims>
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = actix_web::Error>,
S::Future: 'static,
B: 'static,
{
type Response = ServiceResponse<B>;
type Error = actix_web::Error;
type InitError = ();
type Transform = JwtAuthMiddleware<S, Claims>;
type Future = Ready<Result<Self::Transform, Self::InitError>>;
fn new_transform(&self, service: S) -> Self::Future {
ready(Ok(JwtAuthMiddleware {
service,
auth: Arc::clone(&self.auth),
claims_marker: PhantomData,
}))
}
}
pub struct JwtAuthMiddleware<S, Claims> {
service: S,
auth: Arc<Authority>,
claims_marker: PhantomData<Claims>,
}
impl<S, B, C> Service<ServiceRequest> for JwtAuthMiddleware<S, C>
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = actix_web::Error>,
S::Future: 'static,
B: 'static,
{
type Response = ServiceResponse<B>;
type Error = actix_web::Error;
type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
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::<Claims>(
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<String>,
}
#[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<String, MqttDeviceDiscoveryComponent<'a>>,
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,
}

View file

@ -2,11 +2,14 @@ use actix_web::{middleware::Logger, web, App, HttpServer};
use anyhow::Result; use anyhow::Result;
use chrono::prelude::*; use chrono::prelude::*;
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use envconfig::Envconfig;
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation}; use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation};
use std::{ use std::{
collections::{HashMap, HashSet}, collections::{HashMap, HashSet},
fs, fs,
path::PathBuf, path::PathBuf,
sync::Arc,
time::Duration,
}; };
use uuid::Uuid; use uuid::Uuid;
@ -40,6 +43,8 @@ async fn main() -> Result<()> {
let cli = Cli::parse(); let cli = Cli::parse();
let env_config = kdash_server::config::Config::init_from_env()?;
let config = kdash_server::read_config(&cli.config)?; let config = kdash_server::read_config(&cli.config)?;
let sk = fs::read(cli.private_key)?; let sk = fs::read(cli.private_key)?;
@ -61,6 +66,25 @@ async fn main() -> Result<()> {
println!("{}", token); println!("{}", token);
} }
Commands::Start => { 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 || { HttpServer::new(move || {
let devices = config.devices.to_owned(); let devices = config.devices.to_owned();
let device_ids: HashSet<Uuid> = config.devices.keys().cloned().collect(); let device_ids: HashSet<Uuid> = config.devices.keys().cloned().collect();
@ -88,8 +112,20 @@ async fn main() -> Result<()> {
jwt_validation: Validation::new(ALGORITHM), jwt_validation: Validation::new(ALGORITHM),
})) }))
.app_data(web::Data::new(reqwest_client)) .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(Logger::default())
.wrap(kdash_server::JwtAuth::<kdash_server::Claims>::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::get_config)
.service(kdash_server::handlers::post_config)
.service(kdash_server::handlers::get_image) .service(kdash_server::handlers::get_image)
}) })
.bind(("0.0.0.0", 8080))? .bind(("0.0.0.0", 8080))?