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

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()],
)
}