This commit is contained in:
Dominic Grimm 2024-11-15 23:02:35 +01:00
commit 76b43a0386
38 changed files with 5003 additions and 0 deletions

2
.cargo/config.toml Normal file
View file

@ -0,0 +1,2 @@
[target.armv7-unknown-linux-musleabihf]
rustflags = ["-C", "target-cpu=cortex-a8", "-C", "target-feature=-crt-static"]

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/target

3204
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

7
Cargo.toml Normal file
View file

@ -0,0 +1,7 @@
[workspace]
resolver = "2"
members = ["kdash_client", "kdash_protocol", "kdash_server"]
[profile.release]
codegen-units = 1
lto = true

2
Cross.toml Normal file
View file

@ -0,0 +1,2 @@
[build]
default-target = "armv7-unknown-linux-musleabihf"

29
Dockerfile Normal file
View file

@ -0,0 +1,29 @@
ARG RUST_VERSION="1.82.0"
FROM git.dergrimm.net/dergrimm/muslrust:${RUST_VERSION} AS chef
FROM chef AS planner
WORKDIR /usr/src
RUN mkdir -p kdash_client/src kdash_protocol/src kdash_server/src && touch kdash_client/src/main.rs kdash_protocol/src/lib.rs kdash_server/src/main.rs
COPY Cargo.toml Cargo.lock ./
COPY kdash_client/Cargo.toml kdash_client/Cargo.toml
COPY kdash_protocol/Cargo.toml kdash_protocol/Cargo.toml
COPY kdash_server/Cargo.toml kdash_server/Cargo.toml
RUN cargo chef prepare --recipe-path recipe.json
FROM chef AS builder
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
WORKDIR /usr/src
RUN mkdir bin/
COPY .cargo .cargo
COPY --from=planner /usr/src/recipe.json .
RUN cargo chef cook --release --recipe-path recipe.json
COPY kdash_client kdash_client
COPY kdash_protocol kdash_protocol
COPY kdash_server kdash_server
RUN cargo build --package kdash_server --release && mv target/x86_64-unknown-linux-musl/release/kdash_server bin/kdash_server
FROM docker.io/alpine:3
WORKDIR /usr/src
COPY --from=builder /usr/src/bin/kdash_server .
ENTRYPOINT [ "/usr/src/kdash_server" ]
EXPOSE 8080

12
README.md Normal file
View file

@ -0,0 +1,12 @@
# kdash
kdash is a dashboard management system for jailbroken Kindles. It consists of two main components:
- **`kdash_client`**: Runs on the kindle, polls config and the current dashboard image
- **`kdash_server`**: Manages where to get the dashboard image and the Kindle device config
It is currently only tested against the Kindle 4 NT (ARM Cortex A8).
<hr>
*It works™*

15
docker-compose.yml Normal file
View file

@ -0,0 +1,15 @@
services:
kdash:
image: git.dergrimm.net/dergrimm/kdash:latest
build: .
restart: always
command: --config /config/config.json --private-key /config/private.key --public-key /config/public.crt start
environment:
- RUST_BACKTRACE=full
- RUST_LOG=info
ports:
- 8080:8080
volumes:
- ./config/config.json:/config/config.json:ro
- ./config/private.key:/config/private.key:ro
- ./config/public.crt:/config/public.crt:ro

1
kdash_client/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/target

31
kdash_client/Cargo.toml Normal file
View file

@ -0,0 +1,31 @@
[package]
name = "kdash_client"
version = "0.1.0"
edition = "2021"
[dependencies]
anyhow = { version = "1.0.92", features = ["backtrace"] }
chrono = "0.4.38"
embedded-graphics = "0.8.1"
embedded-layout = "0.4.1"
env_logger = "0.11.5"
envconfig = "0.11.0"
framebuffer = "0.3.1"
futures = "0.3.31"
image = { version = "0.25.5", default-features = false, features = [
"png",
"bmp",
] }
kdash_protocol = { path = "../kdash_protocol" }
lazy_static = "1.5.0"
log = "0.4.22"
num-traits = "0.2.19"
openlipc_dyn = { git = "https://git.dergrimm.net/dergrimm/openlipc_dyn.git" }
reqwest = { version = "0.12.9", default-features = false, features = [
"json",
"rustls-tls",
"stream",
] }
tinybmp = "0.6.0"
tokio = { version = "1.41.0", features = ["full"] }
url = "2.5.3"

View file

@ -0,0 +1,67 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="300"
height="300"
viewBox="0 0 79.375 79.375"
version="1.1"
id="svg1"
inkscape:version="1.3.2 (1:1.3.2+202311252150+091e20ef0f)"
sodipodi:docname="error.svg"
inkscape:export-filename="error_low_battery.png"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
inkscape:zoom="0.72515432"
inkscape:cx="396.46733"
inkscape:cy="339.23814"
inkscape:window-width="1904"
inkscape:window-height="996"
inkscape:window-x="1928"
inkscape:window-y="396"
inkscape:window-maximized="0"
inkscape:current-layer="layer1"
inkscape:export-bgcolor="#ffffffff" />
<defs
id="defs1" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<g
id="g1"
inkscape:label="network"
style="display:none"
transform="scale(0.5)">
<path
d="m 79.374996,119.0625 q 3.373446,0 5.655472,-2.28204 2.282025,-2.28202 2.282025,-5.65547 0,-3.37344 -2.282025,-5.65546 -2.282026,-2.28204 -5.655472,-2.28204 -3.373436,0 -5.655468,2.28204 -2.282031,2.28202 -2.282031,5.65546 0,3.37345 2.282031,5.65547 2.282032,2.28204 5.655468,2.28204 z M 71.437497,87.312495 H 87.312493 V 39.687499 H 71.437497 Z m 7.937499,71.437495 q -16.470308,0 -30.956247,-6.25078 -14.485938,-6.25078 -25.20156,-16.9664 Q 12.501564,124.81718 6.2507824,110.33125 0,95.84531 0,79.374996 0,62.904684 6.2507824,48.418747 12.501564,33.932811 23.217189,23.217186 33.932811,12.501561 48.418749,6.2507785 62.904688,0 79.374996,0 q 16.470315,0 30.956244,6.2507785 14.48594,6.2507825 25.20158,16.9664075 10.71561,10.715625 16.96638,25.201561 6.2508,14.485937 6.2508,30.956249 0,16.470314 -6.2508,30.956254 -6.25077,14.48593 -16.96638,25.20156 -10.71564,10.71562 -25.20158,16.9664 -14.485929,6.25078 -30.956244,6.25078 z m 0,-15.875 q 26.590634,0 45.045314,-18.45468 18.45468,-18.45469 18.45468,-45.045314 0,-26.590625 -18.45468,-45.045311 -18.45468,-18.454686 -45.045314,-18.454686 -26.590623,0 -45.045309,18.454686 -18.454686,18.454686 -18.454686,45.045311 0,26.590624 18.454686,45.045314 18.454686,18.45468 45.045309,18.45468 z m 0,-63.499994 z"
id="path1"
style="display:inline;fill:#000000;stroke-width:0.198437"
inkscape:label="path1" />
</g>
<g
id="g2"
inkscape:label="battery"
style="display:inline"
transform="scale(0.5)">
<path
d="m 23.812503,119.0625 q -3.37344,0 -5.655481,-2.28203 -2.28204,-2.28203 -2.28204,-5.65547 V 95.25 H 0 V 63.499997 h 15.874982 v -15.875 q 0,-3.373437 2.28204,-5.655469 2.282041,-2.282031 5.655481,-2.282031 H 150.81251 q 3.37344,0 5.65545,2.282031 2.28204,2.282032 2.28204,5.655469 V 111.125 q 0,3.37344 -2.28204,5.65547 -2.28201,2.28203 -5.65545,2.28203 z m 7.937491,-15.875 H 119.06248 V 55.562497 H 31.749994 Z"
id="path1-5"
style="display:inline;fill:#000000;stroke-width:0.198437" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

67
kdash_client/daemon.sh Normal file
View file

@ -0,0 +1,67 @@
#!/usr/bin/env sh
DAEMON_PATH="/mnt/us/kdash"
DAEMON_ENV_FILE="${DAEMON_PATH}/kdash.env"
DAEMON="./kdash_client"
DAEMONOPTS=""
NAME="kdash"
# DESC="kdash client daemon"
PIDFILE="${DAEMON_PATH}/${DAEMON}.pid"
# SCRIPTNAME="/etc/init.d/${NAME}"
case "$1" in
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"
else
echo "$PID" >"$PIDFILE"
printf "%s\n" "Ok"
fi
;;
status)
printf "%-50s" "Checking $NAME..."
if [ -f $PIDFILE ]; then
PID=$(cat $PIDFILE)
if [ -z "$(ps axf | grep "${PID}" | grep -v grep)" ]; then
printf "%s\n" "Process dead but pidfile exists"
else
echo "Running"
fi
else
printf "%s\n" "Service not running"
fi
;;
stop)
printf "%-50s" "Stopping $NAME"
PID=$(cat $PIDFILE)
cd $DAEMON_PATH || exit
if [ -f $PIDFILE ]; then
kill -HUP "$PID"
printf "%s\n" "Ok"
rm -f $PIDFILE
else
printf "%s\n" "pidfile not found"
fi
;;
restart)
$0 stop
$0 start
;;
*)
echo "Usage: $0 {status|start|stop|restart}"
exit 1
;;
esac

81
kdash_client/src/api.rs Normal file
View file

@ -0,0 +1,81 @@
use anyhow::{bail, Result};
use kdash_protocol::Orientation;
use url::Url;
#[derive(Debug)]
pub struct Api {
pub jwt: String,
pub config_url: Url,
pub image_url: Url,
}
impl Api {
pub fn new(jwt: String, url: Url) -> Result<Self> {
Ok(Self {
jwt,
config_url: url.join("config")?,
image_url: url.join("image")?,
})
}
fn authorization_bearer(bearer: &str) -> String {
format!("Bearer {}", bearer)
}
pub async fn fetch_config(&self) -> Result<kdash_protocol::Config, reqwest::Error> {
let config = reqwest::Client::new()
.get(self.config_url.to_owned())
.header(
reqwest::header::AUTHORIZATION,
Self::authorization_bearer(&self.jwt),
)
.send()
.await?
.json::<kdash_protocol::Config>()
.await?;
Ok(config)
}
pub async fn fetch_image<'a>(
&self,
size: (u32, u32),
orientation: Orientation,
) -> Result<image::ImageBuffer<image::Luma<u8>, Vec<u8>>> {
let response = reqwest::Client::new()
.get(self.image_url.to_owned())
.header(
reqwest::header::AUTHORIZATION,
Self::authorization_bearer(&self.jwt),
)
.send()
.await?;
let content_type = match response.headers().get(reqwest::header::CONTENT_TYPE) {
Some(value) => value.to_str()?,
None => bail!("Image response returned no Content-Type header"),
};
let image_format = match image::ImageFormat::from_mime_type(content_type) {
Some(x) => x,
None => bail!("Invalid image MIME type: {}", content_type),
};
if image_format != image::ImageFormat::Png && image_format != image::ImageFormat::Bmp {
bail!("Invalid image MIME type: {}", image_format.to_mime_type())
}
let data = response.bytes().await?;
let reader = image::ImageReader::with_format(std::io::Cursor::new(data), image_format);
let mut img = reader.decode()?.crop_imm(0, 0, size.0, size.1);
match orientation {
Orientation::PortraitUp | Orientation::PortraitDown => {}
Orientation::LandscapeLeft | Orientation::LandscapeRight => {
img = img.rotate270();
}
}
Ok(img.into_luma8())
}
}

24
kdash_client/src/app.rs Normal file
View file

@ -0,0 +1,24 @@
use std::{net::IpAddr, path::PathBuf, rc::Rc, sync::Mutex};
use openlipc_dyn::Lipc;
use crate::api;
pub struct State {
pub api: api::Api,
pub app_config: AppConfig,
pub config: Rc<Mutex<kdash_protocol::Config>>,
pub lipc: Rc<Mutex<Lipc>>,
}
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)
}
}

View file

@ -0,0 +1,13 @@
use embedded_graphics::pixelcolor::Gray8;
use lazy_static::lazy_static;
use tinybmp::Bmp;
const ERROR_NETWORK_IMAGE_BUF: &[u8] = include_bytes!("assets/error_network.bmp");
const ERROR_BATTERY_LOW_IMAGE_BUF: &[u8] = include_bytes!("assets/error_battery_low.bmp");
lazy_static! {
pub static ref ERROR_NETWORK_IMAGE: Bmp<'static, Gray8> =
Bmp::from_slice(ERROR_NETWORK_IMAGE_BUF).unwrap();
pub static ref ERROR_BATTERY_LOW_IMAGE: Bmp<'static, Gray8> =
Bmp::from_slice(ERROR_BATTERY_LOW_IMAGE_BUF).unwrap();
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

View file

@ -0,0 +1,59 @@
use anyhow::Result;
use std::fs;
use crate::{parse, utils};
#[derive(Debug)]
pub struct BatteryStatus {
pub is_charging: bool,
pub percentage: u8,
pub current: i16,
}
pub fn get_battery_status() -> Result<BatteryStatus> {
log::info!("Getting battery status");
let charging_file = utils::kdb_get("system/driver/charger/SYS_CHARGING_FILE")?;
let is_charging_str = fs::read_to_string(charging_file)?;
let is_charging = parse::parse_number_from_start_unsigned::<u8>(&is_charging_str)? == 1;
let charge_str = utils::exec_command("gasgauge-info", &["-c"])?;
let charge = parse::parse_number_from_start_unsigned::<u8>(&charge_str)?;
let charge_current_str = utils::exec_command("gasgauge-info", &["-l"])?;
let charge_current = parse::parse_number_from_start_signed::<i16>(&charge_current_str)?;
let battery = BatteryStatus {
is_charging,
percentage: charge,
current: charge_current,
};
log::info!("Got battery status: {:?}", battery);
Ok(battery)
}
pub fn restart_powerd() -> Result<()> {
log::info!("Restarting powerd");
utils::exec_command_discard("/etc/init.d/powerd", &["restart"])
}
pub fn restart_powerd_config_condition(
battery: &BatteryStatus,
battery_config: &kdash_protocol::BatteryConfig,
) -> Result<()> {
if battery.is_charging
&& battery.percentage <= *battery_config.restart_powerd_threshold
&& battery.current <= 0
{
log::info!(
"Battery charge below threshold ({} <= {}): restarting powerd",
battery.percentage,
*battery_config.restart_powerd_threshold
);
restart_powerd()
} else {
Ok(())
}
}

View file

@ -0,0 +1,21 @@
use envconfig::Envconfig;
use std::{net, path::PathBuf};
use url::Url;
#[derive(Envconfig, Debug)]
pub struct Config {
#[envconfig(from = "KDASH_URL")]
pub kdash_url: Url,
#[envconfig(from = "KDASH_JWT")]
pub kdash_jwt: String,
#[envconfig(from = "ROUTER_IP")]
pub router_ip: net::IpAddr,
#[envconfig(from = "NET")]
pub net: String,
#[envconfig(from = "ASSETS")]
pub assets: PathBuf,
}

204
kdash_client/src/fb/mod.rs Normal file
View file

@ -0,0 +1,204 @@
use anyhow::Result;
use embedded_graphics::{
image::{ImageRaw, ImageRawBE},
pixelcolor::Gray8,
prelude::*,
primitives::Rectangle,
};
use embedded_layout::View;
use std::{fs, io::Write, ops::Range};
use kdash_protocol::Orientation;
use crate::utils;
pub mod widgets;
pub fn eips_clear() -> Result<()> {
utils::exec_command_discard("eips", &["-c"])
}
pub fn image_buf_to_raw<'a>(
buf: &'a image::ImageBuffer<image::Luma<u8>, Vec<u8>>,
) -> ImageRawBE<'a, Gray8> {
ImageRaw::new(buf.as_raw(), buf.dimensions().0)
}
pub const DEFAULT_FB: &str = "/dev/fb0";
const EINK_FB_UPDATE_DISPLAY: &str = "/proc/eink_fb/update_display";
type FbIdxFn = fn(width: usize, height: usize, x: usize, y: usize) -> usize;
const fn fb_idx_portrait_up(width: usize, _height: usize, x: usize, y: usize) -> usize {
(y * width) + x
}
const fn fb_idx_portrait_down(width: usize, height: usize, x: usize, y: usize) -> usize {
((height - 1 - y) * width) + (width - 1 - x)
}
const fn fb_idx_landscape_left(width: usize, height: usize, x: usize, y: usize) -> usize {
((height - 1 - x) * width) + y
}
const fn fb_idx_landscape_right(width: usize, _height: usize, x: usize, y: usize) -> usize {
((x + 1) * width) - 1 - y
}
const FB_IDX_PORTRAIT_UP_FN: FbIdxFn = fb_idx_portrait_up;
const FB_IDX_PORTRAIT_DOWN_FN: FbIdxFn = fb_idx_portrait_down;
const FB_IDX_LANDSCAPE_LEFT_FN: FbIdxFn = fb_idx_landscape_left;
const FB_IDX_LANDSCAPE_RIGHT_FN: FbIdxFn = fb_idx_landscape_right;
pub struct FramebufferOrientation {
pub orientation: Orientation,
pub virtual_x: u32,
pub virtual_y: u32,
range_x: Range<i32>,
range_y: Range<i32>,
fb_idx_fn: FbIdxFn,
}
impl FramebufferOrientation {
pub fn new(fb: &framebuffer::Framebuffer, orientation: Orientation) -> Result<Self> {
Ok(match orientation {
Orientation::PortraitUp => Self {
orientation,
virtual_x: fb.var_screen_info.xres_virtual,
virtual_y: fb.var_screen_info.yres_virtual,
range_x: 0..i32::try_from(fb.var_screen_info.xres_virtual)?,
range_y: 0..i32::try_from(fb.var_screen_info.yres_virtual)?,
fb_idx_fn: FB_IDX_PORTRAIT_UP_FN,
},
Orientation::PortraitDown => Self {
orientation,
virtual_x: fb.var_screen_info.xres_virtual,
virtual_y: fb.var_screen_info.yres_virtual,
range_x: 0..i32::try_from(fb.var_screen_info.xres_virtual)?,
range_y: 0..i32::try_from(fb.var_screen_info.yres_virtual)?,
fb_idx_fn: FB_IDX_PORTRAIT_DOWN_FN,
},
Orientation::LandscapeLeft => Self {
orientation,
virtual_x: fb.var_screen_info.yres_virtual,
virtual_y: fb.var_screen_info.xres_virtual,
range_x: 0..i32::try_from(fb.var_screen_info.yres_virtual)?,
range_y: 0..i32::try_from(fb.var_screen_info.xres_virtual)?,
fb_idx_fn: FB_IDX_LANDSCAPE_LEFT_FN,
},
Orientation::LandscapeRight => Self {
orientation,
virtual_x: fb.var_screen_info.yres_virtual,
virtual_y: fb.var_screen_info.xres_virtual,
range_x: 0..i32::try_from(fb.var_screen_info.yres_virtual)?,
range_y: 0..i32::try_from(fb.var_screen_info.xres_virtual)?,
fb_idx_fn: FB_IDX_LANDSCAPE_RIGHT_FN,
},
})
}
}
pub struct FramebufferDisplay {
pub fb_orientation: FramebufferOrientation,
pub fb: framebuffer::Framebuffer,
update_display_file: fs::File,
buf: Vec<u8>,
}
impl FramebufferDisplay {
pub fn new(fb_path: &str, orientation: Orientation) -> Result<Self> {
let fb = framebuffer::Framebuffer::new(fb_path)?;
let update_display_file = fs::OpenOptions::new()
.write(true)
.open(EINK_FB_UPDATE_DISPLAY)?;
let fb_orientation = FramebufferOrientation::new(&fb, orientation)?;
Ok(Self {
buf: vec![
0u8;
(fb.var_screen_info.xres_virtual as usize)
* (fb.var_screen_info.yres_virtual as usize)
],
fb,
fb_orientation,
update_display_file,
})
}
pub fn set_orientation(&mut self, orientation: Orientation) -> Result<()> {
self.fb_orientation = FramebufferOrientation::new(&self.fb, orientation)?;
Ok(())
}
fn luma_to_epd_color(color: Gray8) -> u8 {
0xff - color.luma()
}
pub fn clear(&mut self) {
self.buf.fill(0);
}
pub fn flush(&mut self, update_display_buf: &[u8]) -> Result<()> {
self.fb.write_frame(&self.buf);
self.update_display_file.write_all(update_display_buf)?;
Ok(())
}
pub fn flush_partial_update(&mut self) -> Result<()> {
self.flush(b"1")
}
pub fn flush_full_update(&mut self) -> Result<()> {
self.flush(b"2")
}
}
impl DrawTarget for FramebufferDisplay {
type Color = Gray8;
type Error = core::convert::Infallible;
fn draw_iter<I>(&mut self, pixels: I) -> std::result::Result<(), Self::Error>
where
I: IntoIterator<Item = Pixel<Self::Color>>,
{
for Pixel(coord, color) in pixels.into_iter() {
if self.fb_orientation.range_x.contains(&coord.x)
&& self.fb_orientation.range_y.contains(&coord.y)
{
let idx = (self.fb_orientation.fb_idx_fn)(
self.fb.var_screen_info.xres_virtual as usize,
self.fb.var_screen_info.yres_virtual as usize,
coord.x as usize,
coord.y as usize,
);
self.buf[idx] = Self::luma_to_epd_color(color);
}
}
Ok(())
}
fn clear(&mut self, color: Self::Color) -> std::result::Result<(), Self::Error> {
self.buf.fill(Self::luma_to_epd_color(color));
Ok(())
}
}
impl OriginDimensions for FramebufferDisplay {
fn size(&self) -> Size {
Size::new(
self.fb_orientation.virtual_x as u32,
self.fb_orientation.virtual_y as u32,
)
}
}
impl View for FramebufferDisplay {
fn bounds(&self) -> Rectangle {
Rectangle::new(Point::zero(), OriginDimensions::size(self))
}
fn translate_impl(&mut self, _by: Point) {
unimplemented!()
}
}

View file

@ -0,0 +1,2 @@
pub mod padding;
pub mod status_box;

View file

@ -0,0 +1,79 @@
use embedded_graphics::{
prelude::*,
primitives::{PrimitiveStyleBuilder, Rectangle},
};
use embedded_layout::prelude::*;
pub struct Paddding<T, C>
where
T: Drawable<Color = C> + View,
C: PixelColor,
{
pub bounds: Rectangle,
pub color: Option<C>,
pub widget: T,
}
impl<T, C> Paddding<T, C>
where
T: Drawable<Color = C> + View,
C: PixelColor,
{
pub fn new(top_left: Point, padding: Size, color: Option<C>, widget: T) -> Self {
let bounds = Rectangle::new(
top_left,
Size::new(
widget.size().width + (2 * padding.width),
widget.size().height + (2 * padding.height),
),
);
Self {
color,
widget: widget.align_to(&bounds, horizontal::Center, vertical::Center),
bounds,
}
}
}
impl<T, C> View for Paddding<T, C>
where
T: Drawable<Color = C> + View,
C: PixelColor,
{
fn bounds(&self) -> Rectangle {
self.bounds
}
fn size(&self) -> Size {
self.bounds.size
}
fn translate_impl(&mut self, by: Point) {
self.bounds.translate_impl(by);
self.widget.translate_impl(by);
}
}
impl<T, C> Drawable for Paddding<T, C>
where
T: Drawable<Color = C> + View,
C: PixelColor,
{
type Color = C;
type Output = ();
fn draw<D>(&self, target: &mut D) -> Result<Self::Output, D::Error>
where
D: DrawTarget<Color = Self::Color>,
{
if let Some(color) = self.color {
let style = PrimitiveStyleBuilder::new().fill_color(color).build();
self.bounds.into_styled(style).draw(target)?;
}
self.widget.draw(target)?;
Ok(())
}
}

View file

@ -0,0 +1,154 @@
use embedded_graphics::{
mono_font::{
ascii::{FONT_10X20, FONT_6X12},
MonoFont, MonoTextStyle,
},
pixelcolor::Gray8,
prelude::*,
primitives::{CornerRadiiBuilder, PrimitiveStyleBuilder, Rectangle, RoundedRectangle},
text::Text,
};
use embedded_layout::prelude::*;
pub const DEFAULT_PADDING: Size = Size::new(20, 20);
pub struct StatusBox<'a> {
pub text: &'a str,
pub font: &'a MonoFont<'a>,
pub bounds: Rectangle,
pub bar_bounds: Rectangle,
pub bar_text_bounds: Rectangle,
pub bar_font: &'a MonoFont<'a>,
pub bar_text: &'a str,
pub inside_bounds: Rectangle,
}
impl<'a> StatusBox<'a> {
pub fn new(
top_left: Point,
padding: Size,
border_stroke_width: u32,
bar_font: &'a MonoFont,
bar_text: &'a str,
font: &'a MonoFont,
text: &'a str,
) -> Self {
let text_width = (text.len() as u32) * (font.character_size.width + font.character_spacing);
let text_height = font.character_size.width;
let bar_height = bar_font.character_size.height + (2 * border_stroke_width);
let bounds = Rectangle::new(
top_left,
Size::new(
text_width + (2 * padding.width),
text_height + (2 * padding.height) + bar_height,
),
);
Self {
text,
font,
bounds,
bar_bounds: Rectangle::new(bounds.top_left, Size::new(bounds.size.width, bar_height)),
bar_text_bounds: Rectangle::new(
Point::new(
bounds.top_left.x + (border_stroke_width as i32),
bounds.top_left.y + (border_stroke_width as i32),
),
Size::new(
bounds.size.width - (2 * border_stroke_width),
bar_height - (2 * border_stroke_width),
),
),
bar_font,
bar_text,
inside_bounds: Rectangle::new(
Point::new(top_left.x, top_left.y + (bar_height as i32)),
Size::new(
text_width + (2 * padding.width),
text_height + (2 * padding.height),
),
),
}
}
pub fn new_with_default_style(top_left: Point, bar_text: &'a str, text: &'a str) -> Self {
Self::new(
top_left,
Size::new(30, 30),
5,
&FONT_6X12,
bar_text,
&FONT_10X20,
text,
)
}
}
impl View for StatusBox<'_> {
fn translate_impl(&mut self, by: Point) {
Transform::translate_mut(&mut self.bounds, by);
Transform::translate_mut(&mut self.bar_bounds, by);
Transform::translate_mut(&mut self.bar_text_bounds, by);
Transform::translate_mut(&mut self.inside_bounds, by);
}
fn bounds(&self) -> Rectangle {
self.bounds
}
}
impl Drawable for StatusBox<'_> {
type Color = Gray8;
type Output = ();
fn draw<D>(&self, target: &mut D) -> std::result::Result<Self::Output, D::Error>
where
D: DrawTarget<Color = Self::Color>,
{
let corner_radius = Size::new(10, 10);
let rect_style = PrimitiveStyleBuilder::new()
.stroke_width(5)
.stroke_color(Gray8::BLACK)
.fill_color(Gray8::WHITE)
.build();
let rect = RoundedRectangle::with_equal_corners(self.bounds, corner_radius)
.into_styled(rect_style);
let bar_style = PrimitiveStyleBuilder::new()
.fill_color(Gray8::BLACK)
.build();
let bar_radii = CornerRadiiBuilder::new()
.top_left(corner_radius)
.top_right(corner_radius)
.bottom(Size::zero())
.build();
let bar = RoundedRectangle::new(self.bar_bounds, bar_radii).into_styled(bar_style);
let bar_text = Text::new(
self.bar_text,
Point::zero(),
MonoTextStyle::new(self.bar_font, Gray8::WHITE),
)
.align_to(&self.bar_text_bounds, horizontal::Right, vertical::Center);
let text = Text::new(
self.text,
Point::zero(),
MonoTextStyle::new(self.font, Gray8::BLACK),
)
.align_to(&self.inside_bounds, horizontal::Center, vertical::Center);
rect.draw(target)?;
bar.draw(target)?;
bar_text.draw(target)?;
text.draw(target)?;
Ok(())
}
}

148
kdash_client/src/lib.rs Normal file
View file

@ -0,0 +1,148 @@
use anyhow::Result;
use chrono::prelude::*;
use embedded_graphics::{image::Image, prelude::*};
use embedded_layout::prelude::*;
use std::{thread, time::Duration};
pub mod api;
pub mod app;
pub mod assets;
pub mod battery;
pub mod config;
pub mod fb;
mod parse;
pub mod rtc;
pub mod utils;
pub mod wifi;
pub async fn run_once(
state: &app::State,
display: &mut fb::FramebufferDisplay,
) -> Result<Option<kdash_protocol::Config>> {
let config = state.config.lock().unwrap();
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 {
log::info!(
"Battery low: {}% <= {}%",
battery.percentage,
*config.battery.low
);
Image::new(&*assets::ERROR_BATTERY_LOW_IMAGE, Point::zero())
.align_to(&*display, horizontal::Center, vertical::Center)
.draw(display)?;
fb::eips_clear()?;
display.flush_full_update()?;
thread::sleep(Duration::from_secs(1));
rtc::sleep(config.time.delay_low_battery.to_std()?.as_secs())?;
thread::sleep(Duration::from_secs(30));
}
log::info!("Enabling and checking WiFi");
wifi::set_wifid_enable(&state.lipc.lock().unwrap(), true)?;
let wlan_connected =
wifi::check_connection(&state.lipc.lock().unwrap(), &state.app_config.net)?;
wifi::log_connection(&state.lipc.lock().unwrap(), &state.app_config.net)?;
let mut new_config: Option<kdash_protocol::Config> = None;
if wlan_connected {
log::info!("Connected to WiFi");
let gateway = wifi::get_gateway(&state.app_config.net)?;
log::info!("Found default gateway: {:?}", gateway);
if gateway.is_none() {
log::info!("Default gateway lost after sleep");
log::info!("Setting default gateway to: {}", state.app_config.router_ip);
wifi::route_add_default_gateway_router_ip(state.app_config.router_ip)?;
}
log::info!("Fetching config");
let fetch_succ = match state.api.fetch_config().await {
Ok(value) => {
new_config = Some(value);
log::info!("Downloading and drawing image");
match state
.api
.fetch_image(
(
display.fb.var_screen_info.xres_virtual,
display.fb.var_screen_info.yres_virtual,
),
display.fb_orientation.orientation,
)
.await
{
Ok(buf) => {
let raw = fb::image_buf_to_raw(&buf);
Image::new(&raw, Point::zero()).draw(display)?;
true
}
Err(e) => {
log::error!("Downloaded image with error: {}", e);
false
}
}
}
Err(e) => {
dbg!(&e);
log::error!("Fetched config with error: {}", e);
false
}
};
if !fetch_succ {
Image::new(&*assets::ERROR_NETWORK_IMAGE, Point::zero())
.align_to(&*display, horizontal::Center, vertical::Center)
.draw(display)?;
}
if battery.percentage <= *config.battery.alert {
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(
Point::zero(),
&now_str,
&text,
);
let mut padding = fb::widgets::padding::Paddding::new(
Point::zero(),
fb::widgets::status_box::DEFAULT_PADDING,
None,
status_box,
);
match config.device.status_box_location {
kdash_protocol::Corner::TopLeft => {}
kdash_protocol::Corner::TopRight => {
padding.align_to_mut(display, horizontal::Right, vertical::Top);
}
kdash_protocol::Corner::BottomLeft => {
padding.align_to_mut(display, horizontal::Left, vertical::Bottom);
}
kdash_protocol::Corner::BottomRight => {
padding.align_to_mut(display, horizontal::Right, vertical::Bottom);
}
}
padding.draw(display)?;
}
if config.device.full_refresh {
fb::eips_clear()?;
}
display.flush_full_update()?;
}
Ok(new_config)
}

113
kdash_client/src/main.rs Normal file
View file

@ -0,0 +1,113 @@
use anyhow::{bail, Result};
use chrono::prelude::*;
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();
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 lipc = Lipc::load(None)?;
let state = kdash_client::app::State {
api,
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)),
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,
)?;
kdash_client::utils::kill_kindle()?;
let mut error_count: usize = 0;
loop {
display.clear();
let mut error_suspend = false;
match kdash_client::run_once(&state, &mut display).await {
Ok(Some(new_config)) => {
log::info!("Updating config");
let mut config = state.config.lock().unwrap();
if display.fb_orientation.orientation != new_config.device.orientation {
log::info!(
"Updating orientation: {:?} -> {:?}",
display.fb_orientation.orientation,
new_config.device.orientation
);
display.set_orientation(config.device.orientation)?;
}
*config = new_config;
}
Ok(None) => {
log::info!("Did not fetch config");
error_suspend = true;
}
Err(e) => {
log::error!("Error during main loop: {}", e);
error_suspend = true;
}
}
let config = state.config.lock().unwrap();
thread::sleep(config.time.delay_before_suspend.to_std()?);
log::info!("Calculate next timer and going to sleep");
if error_suspend {
error_count += 1;
log::error!("An error has occured",);
if error_count >= 10 {
log::info!("Reboot because of {} errors", error_count);
kdash_client::utils::exec_command_discard("reboot", &[])?;
bail!("Rebooting");
}
if config.device.use_rtc {
kdash_client::rtc::sleep(config.time.delay_on_error.to_std()?.as_secs())?;
} else {
thread::sleep(config.time.delay_on_error.to_std()?);
}
} else {
error_count = 0;
log::info!("Success");
let upcoming = config
.time
.update_schedule
.upcoming(config.time.timezone)
.next();
let dur = match upcoming {
Some(dt) => (dt.to_utc().naive_utc() - Utc::now().naive_utc()).to_std()?,
None => time::Duration::from_secs(60),
};
if config.device.use_rtc {
kdash_client::rtc::sleep(dur.as_secs())?;
} else {
thread::sleep(dur);
}
}
}
}

33
kdash_client/src/parse.rs Normal file
View file

@ -0,0 +1,33 @@
pub fn parse_number_from_start<T>(input: &str, signed: bool) -> Result<T, T::Err>
where
T: num_traits::PrimInt + std::str::FromStr,
<T as std::str::FromStr>::Err: std::error::Error + Send + Sync + 'static,
{
let end_number_idx = input
.chars()
.position(if signed {
|c: char| !c.is_ascii_digit() && c != '-'
} else {
|c: char| !c.is_ascii_digit()
})
.unwrap_or(input.len());
let res = input[..end_number_idx].parse::<T>()?;
Ok(res)
}
pub fn parse_number_from_start_unsigned<T>(input: &str) -> Result<T, T::Err>
where
T: num_traits::PrimInt + num_traits::Unsigned + std::str::FromStr,
<T as std::str::FromStr>::Err: std::error::Error + Send + Sync + 'static,
{
parse_number_from_start(input, false)
}
pub fn parse_number_from_start_signed<T>(input: &str) -> Result<T, T::Err>
where
T: num_traits::PrimInt + num_traits::Signed + std::str::FromStr,
<T as std::str::FromStr>::Err: std::error::Error + Send + Sync + 'static,
{
parse_number_from_start(input, true)
}

22
kdash_client/src/rtc.rs Normal file
View file

@ -0,0 +1,22 @@
use anyhow::Result;
use std::{fs, io::Write};
use crate::parse;
const RTC: &str = "/sys/devices/platform/mxc_rtc.0/wakeup_enable";
const POWER_STATE: &str = "/sys/power/state";
pub fn sleep(secs: u64) -> Result<()> {
let content_str = fs::read_to_string(RTC)?;
let content: u64 = parse::parse_number_from_start_unsigned(&content_str)?;
if content == 0 {
let mut rtc_file = fs::OpenOptions::new().write(true).open(RTC)?;
rtc_file.write_all(secs.to_string().as_bytes())?;
}
let mut power_state_file = fs::OpenOptions::new().write(true).open(POWER_STATE)?;
power_state_file.write_all(b"mem")?;
Ok(())
}

90
kdash_client/src/utils.rs Normal file
View file

@ -0,0 +1,90 @@
use anyhow::{bail, Result};
use openlipc_dyn::Lipc;
use std::fs;
use std::io::Write;
use std::process;
pub fn exec_command_discard(cmd: &str, args: &[&str]) -> Result<()> {
log::debug!(
"Executing command (discarding output): {:?} {:?}",
cmd,
args
);
let output = process::Command::new(cmd)
.args(args)
.stdout(process::Stdio::inherit())
.stderr(process::Stdio::inherit())
.output()?;
if !output.status.success() {
bail!("Command threw error: {}", output.status);
}
Ok(())
}
pub fn exec_command(cmd: &str, args: &[&str]) -> Result<String> {
log::debug!("Executing command: {:?} {:?}", cmd, args);
let output = process::Command::new(cmd).args(args).output()?;
if !output.status.success() {
bail!("Command threw error: {}", output.status);
}
let s = String::from_utf8(output.stdout)?;
Ok(s)
}
pub fn kdb_get(name: &str) -> Result<String> {
log::debug!("kdb get: {:?}", name);
let mut s = exec_command("kdb", &["get", name])?;
if s.ends_with('\n') {
s = s.trim_end_matches('\n').to_string();
}
Ok(s)
}
const KILL_KINDLE_COMMANDS: &[(&str, &[&str])] = &[
("/etc/init.d/framework", &["stop"]),
("/etc/init.d/cmd", &["stop"]),
("/etc/init.d/phd", &["stop"]),
("/etc/init.d/volumd", &["stop"]),
("/etc/init.d/tmd", &["stop"]),
("/etc/init.d/webreaderd", &["stop"]),
];
const KILL_KINDLE_KILLALL: (&str, &[&str]) = ("killall", &["-q", "lipc-wait-event"]);
pub fn kill_kindle() -> Result<()> {
log::info!("Killing Kindle");
for (cmd, args) in KILL_KINDLE_COMMANDS {
exec_command_discard(cmd, args)?;
}
let _ = exec_command_discard(KILL_KINDLE_KILLALL.0, KILL_KINDLE_KILLALL.1);
Ok(())
}
const CPU_SCALING_GOVERNOR: &str = "/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor";
const CPU_SCALING_GOVERNOR_VALUE: &[u8] = "powersave".as_bytes();
pub fn set_cpu_powersaving() -> Result<()> {
log::info!("Setting CPU into powersaving mode");
let mut file = fs::OpenOptions::new()
.write(true)
.open(CPU_SCALING_GOVERNOR)?;
file.write_all(CPU_SCALING_GOVERNOR_VALUE)?;
Ok(())
}
pub fn prevent_screensaver(lipc: &Lipc) -> Result<()> {
log::info!("Preventing screensaver");
lipc.set_int_prop("com.lab126.powerd", "preventScreenSaver", 1)?;
Ok(())
}

86
kdash_client/src/wifi.rs Normal file
View file

@ -0,0 +1,86 @@
use anyhow::Result;
use openlipc_dyn::Lipc;
use std::{net, thread, time};
use crate::utils;
pub fn set_wifid_enable(lipc: &Lipc, value: bool) -> Result<()> {
log::info!("Enabling wifi");
lipc.set_int_prop("com.lab126.wifid", "enable", if value { 1 } else { 0 })?;
Ok(())
}
fn get_cm_state(lipc: &Lipc) -> Result<String> {
lipc.get_str_prop("com.lab126.wifid", "cmState")
}
fn get_connected(lipc: &Lipc) -> Result<bool> {
Ok(get_cm_state(lipc)? == "CONNECTED")
}
fn get_signal_strength(lipc: &Lipc) -> Result<String> {
lipc.get_str_prop("com.lab126.wifid", "signalStrength")
}
fn reconnect(net: &str) -> Result<()> {
log::info!("Reconnecting WiFi");
utils::exec_command_discard("/usr/bin/wpa_cli", &["-i", net, "reconnect"])?;
Ok(())
}
pub fn log_connection(lipc: &Lipc, net: &str) -> Result<()> {
let ifconfig = utils::exec_command("ifconfig", &[net])?;
log::info!("ifconfig: {}", ifconfig);
let cm_state = get_cm_state(lipc)?;
log::info!("cmState: {}", cm_state);
let signal_strength = get_signal_strength(lipc)?;
log::info!("signalStrength: {}", signal_strength);
Ok(())
}
pub fn check_connection(lipc: &Lipc, net: &str) -> Result<bool> {
let mut wlan_connected = true;
let mut wlan_counter: u64 = 0;
while !get_connected(lipc)? {
if wlan_counter > 5 {
log::info!("Trying WiFi reconnect");
reconnect(net)?;
} else if wlan_counter > 30 {
log::info!("No known WiFi found");
log_connection(lipc, net)?;
wlan_connected = false;
// TODO: eips wifi error
break;
}
wlan_counter += 1;
log::info!("Waiting for WiFi: {}", wlan_counter);
thread::sleep(time::Duration::from_secs(wlan_counter));
}
Ok(wlan_connected)
}
pub fn get_gateway(net: &str) -> Result<Option<net::IpAddr>> {
let routes = utils::exec_command("ip", &["route"])?;
for line in routes.lines() {
if line.contains("default") && line.contains(net) {
if let Some(gateway) = line.split_whitespace().nth(2) {
return Ok(Some(gateway.parse()?));
}
}
}
Ok(None)
}
pub fn route_add_default_gateway_router_ip(router_ip: net::IpAddr) -> Result<()> {
utils::exec_command_discard(
"route",
&["add", "default", "gw", router_ip.to_string().as_str()],
)
}

1
kdash_protocol/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/target

12
kdash_protocol/Cargo.toml Normal file
View file

@ -0,0 +1,12 @@
[package]
name = "kdash_protocol"
version = "0.1.0"
edition = "2021"
[dependencies]
chrono = { version = "0.4.38", features = ["serde"] }
chrono-tz = { version = "0.10.0", features = ["serde"] }
cron = { version = "0.13.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"

68
kdash_protocol/src/lib.rs Normal file
View file

@ -0,0 +1,68 @@
use chrono::Duration;
use chrono_tz::Tz;
use cron::Schedule;
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"
}
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
pub struct Config {
pub name: String,
pub battery: BatteryConfig,
pub time: TimeConfig,
pub device: DeviceConfig,
}
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
pub struct BatteryConfig {
pub alert: Percent,
pub low: Percent,
pub restart_powerd_threshold: Percent,
}
#[serde_with::serde_as]
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
pub struct TimeConfig {
pub timezone: Tz,
pub update_schedule: Schedule,
#[serde_as(as = "serde_with::DurationSeconds<i64>")]
pub delay_on_error: Duration,
#[serde_as(as = "serde_with::DurationSeconds<i64>")]
pub delay_before_suspend: Duration,
#[serde_as(as = "serde_with::DurationSeconds<i64>")]
pub delay_low_battery: Duration,
}
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
pub struct DeviceConfig {
pub use_rtc: bool,
pub full_refresh: bool,
pub orientation: Orientation,
pub status_box_location: Corner,
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
pub enum Orientation {
PortraitUp,
PortraitDown,
LandscapeLeft,
LandscapeRight,
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
pub enum Corner {
TopLeft,
TopRight,
BottomLeft,
BottomRight,
}

1
kdash_server/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/target

21
kdash_server/Cargo.toml Normal file
View file

@ -0,0 +1,21 @@
[package]
name = "kdash_server"
version = "0.1.0"
edition = "2021"
[dependencies]
actix-web = "4.9.0"
anyhow = { version = "1.0.93", features = ["backtrace"] }
chrono = "0.4.38"
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"
jsonwebtoken = "9.3.0"
kdash_protocol = { path = "../kdash_protocol" }
reqwest = { version = "0.12.9", features = ["stream"] }
serde = { version = "1.0.215", features = ["derive"] }
serde_json = "1.0.132"
serde_with = { version = "3.11.0", features = ["chrono_0_4"] }
url = { version = "2.5.3", features = ["serde"] }
uuid = { version = "1.11.0", features = ["serde"] }

View file

@ -0,0 +1,97 @@
use chrono::Duration;
use chrono_tz::Tz;
use cron::Schedule;
use serde::{Deserialize, Serialize};
use url::Url;
use kdash_protocol::Percent;
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct Config {
pub name: String,
pub image: ImageConfiguration,
pub battery: BatteryConfig,
pub time: TimeConfig,
pub device: DeviceConfig,
}
impl From<Config> for kdash_protocol::Config {
fn from(value: Config) -> Self {
Self {
name: value.name,
battery: value.battery.into(),
time: value.time.into(),
device: value.device.into(),
}
}
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct ImageConfiguration {
pub url: Url,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct BatteryConfig {
pub alert: Percent,
pub low: Percent,
pub restart_powerd_threshold: Percent,
}
impl From<BatteryConfig> for kdash_protocol::BatteryConfig {
fn from(value: BatteryConfig) -> Self {
Self {
alert: value.alert,
low: value.low,
restart_powerd_threshold: value.restart_powerd_threshold,
}
}
}
#[serde_with::serde_as]
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct TimeConfig {
pub timezone: Tz,
pub update_schedule: Schedule,
#[serde_as(as = "serde_with::DurationSeconds<i64>")]
pub delay_on_error: Duration,
#[serde_as(as = "serde_with::DurationSeconds<i64>")]
pub delay_before_suspend: Duration,
#[serde_as(as = "serde_with::DurationSeconds<i64>")]
pub delay_low_battery: Duration,
}
impl From<TimeConfig> for kdash_protocol::TimeConfig {
fn from(value: TimeConfig) -> Self {
Self {
timezone: value.timezone,
update_schedule: value.update_schedule,
delay_on_error: value.delay_on_error,
delay_before_suspend: value.delay_before_suspend,
delay_low_battery: value.delay_low_battery,
}
}
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct DeviceConfig {
pub use_rtc: bool,
pub full_refresh: bool,
pub orientation: kdash_protocol::Orientation,
pub status_box_location: kdash_protocol::Corner,
}
impl From<DeviceConfig> for kdash_protocol::DeviceConfig {
fn from(value: DeviceConfig) -> Self {
Self {
use_rtc: value.use_rtc,
full_refresh: value.full_refresh,
orientation: value.orientation,
status_box_location: value.status_box_location,
}
}
}

View file

@ -0,0 +1,97 @@
use actix_web::{dev::PeerAddr, get, http, web, Error, HttpRequest, HttpResponse};
use crate::{AppState, Claims};
#[get("/config")]
pub async fn get_config(req: HttpRequest, data: web::Data<AppState>) -> 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,
None => return HttpResponse::Unauthorized().finish(),
};
let decoded =
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();
}
let buf = match data.devices_api_json.get(&decoded.claims.sub) {
Some(x) => x,
None => return HttpResponse::InternalServerError().finish(),
};
HttpResponse::Ok()
.content_type("application/json")
.body(buf.clone())
}
#[get("/image")]
pub async fn get_image(
req: HttpRequest,
peer_addr: Option<PeerAddr>,
data: web::Data<AppState>,
client: web::Data<reqwest::Client>,
) -> Result<HttpResponse, Error> {
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,
None => return Ok(HttpResponse::Unauthorized().finish()),
};
let decoded =
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());
}
let config = match data.devices.get(&decoded.claims.sub) {
Some(x) => x,
None => return Ok(HttpResponse::InternalServerError().finish()),
};
let mut forwarded_req = client.get(config.image.url.to_owned());
forwarded_req = match peer_addr {
Some(PeerAddr(addr)) => forwarded_req.header("x-forwarded-for", addr.ip().to_string()),
None => forwarded_req,
};
let resp = forwarded_req
.send()
.await
.map_err(actix_web::error::ErrorInternalServerError)?;
let mut client_resp = HttpResponse::build(
actix_web::http::StatusCode::from_u16(resp.status().as_u16())
.map_err(actix_web::error::ErrorInternalServerError)?,
);
// Remove `Connection` as per
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Connection#Directives
for (header_name, header_value) in resp.headers().iter().filter(|(h, _)| *h != "connection") {
client_resp.insert_header((
actix_web::http::header::HeaderName::from_bytes(header_name.as_ref()).unwrap(),
actix_web::http::header::HeaderValue::from_bytes(header_value.as_ref()).unwrap(),
));
}
Ok(client_resp.streaming(resp.bytes_stream()))
}

37
kdash_server/src/lib.rs Normal file
View file

@ -0,0 +1,37 @@
use anyhow::Result;
use jsonwebtoken::{DecodingKey, Validation};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::Path;
use uuid::Uuid;
pub mod device;
pub mod handlers;
#[derive(Debug, Deserialize, Clone)]
pub struct Config {
pub devices: HashMap<Uuid, device::Config>,
}
pub fn read_config(path: &Path) -> Result<Config> {
let buf = fs::read(path)?;
let config = serde_json::from_slice(&buf)?;
Ok(config)
}
pub struct AppState {
pub devices: HashMap<Uuid, device::Config>,
pub devices_api: HashMap<Uuid, kdash_protocol::Config>,
pub devices_api_json: HashMap<Uuid, Vec<u8>>,
pub device_ids: HashSet<Uuid>,
pub jwt_decoding_key: DecodingKey,
pub jwt_validation: Validation,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Claims {
pub exp: i64,
pub sub: Uuid,
}

102
kdash_server/src/main.rs Normal file
View file

@ -0,0 +1,102 @@
use actix_web::{middleware::Logger, web, App, HttpServer};
use anyhow::Result;
use chrono::prelude::*;
use clap::{Parser, Subcommand};
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation};
use std::{
collections::{HashMap, HashSet},
fs,
path::PathBuf,
};
use uuid::Uuid;
#[derive(Parser)]
#[command(author, version, about, long_about = None)]
struct Cli {
#[arg(long, value_name = "FILE")]
config: PathBuf,
#[arg(long, value_name = "FILE")]
private_key: PathBuf,
#[arg(long, value_name = "FILE")]
public_key: PathBuf,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
GenJwt { id: Uuid },
Start,
}
const ALGORITHM: Algorithm = Algorithm::RS512;
#[actix_web::main]
async fn main() -> Result<()> {
env_logger::init();
let cli = Cli::parse();
let config = kdash_server::read_config(&cli.config)?;
let sk = fs::read(cli.private_key)?;
let encoding_key = EncodingKey::from_rsa_pem(&sk)?;
let pk = fs::read(cli.public_key)?;
let decoding_key = DecodingKey::from_rsa_pem(&pk)?;
match cli.command {
Commands::GenJwt { id: sub } => {
let exp = Utc::now() + chrono::Duration::days(10 * 365);
let exp_ts = exp.timestamp();
let token = jsonwebtoken::encode(
&Header::new(ALGORITHM),
&kdash_server::Claims { exp: exp_ts, sub },
&encoding_key,
)?;
println!("{}", token);
}
Commands::Start => {
HttpServer::new(move || {
let devices = config.devices.to_owned();
let device_ids: HashSet<Uuid> = config.devices.keys().cloned().collect();
let devices_api: HashMap<Uuid, kdash_protocol::Config> = devices
.iter()
.map(|(k, v)| (k.to_owned(), kdash_protocol::Config::from(v.to_owned())))
.collect();
let devices_api_json: HashMap<Uuid, Vec<u8>> = devices_api
.iter()
.map(|(k, v)| Ok((k.to_owned(), serde_json::to_vec(v)?)))
.collect::<Result<_>>()
.unwrap();
let reqwest_client = reqwest::Client::default();
App::new()
.app_data(web::Data::new(kdash_server::AppState {
devices,
devices_api,
devices_api_json,
device_ids,
jwt_decoding_key: decoding_key.to_owned(),
jwt_validation: Validation::new(ALGORITHM),
}))
.app_data(web::Data::new(reqwest_client))
.wrap(Logger::default())
.service(kdash_server::handlers::get_config)
.service(kdash_server::handlers::get_image)
})
.bind(("0.0.0.0", 8080))?
.run()
.await?;
}
}
Ok(())
}