This commit is contained in:
2026-08-08 13:05:55 +02:00
commit 8ff5cce9b2
19 changed files with 11999 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
/target
*.old
.env

5798
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 = "3"
members = ["dashboard","trekstor"]
default-members = ["dashboard"]
[workspace.dependencies]
slint = { version = "1.17.1", default-features = false, features = ["std", "compat-1-2", "libm", "renderer-software", "unsafe-single-threaded"] }

8
build Executable file
View File

@@ -0,0 +1,8 @@
#!/bin/bash
cargo b -r
if [ $? -ne 0 ]; then
exit 1
fi
scp target/release/dashboard trekstor:~/dashboard
ssh -t trekstor -- "systemctl --user restart dashboard"

1
dashboard/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/target

20
dashboard/Cargo.toml Normal file
View File

@@ -0,0 +1,20 @@
[package]
name = "dashboard"
version = "0.1.0"
edition = "2024"
[dependencies]
trekstor.path = "../trekstor"
slint.workspace = true
hass-rs = "0.5.0"
serde_json = "1.0.151"
tokio = { version = "1.53.1", features = ["full"] }
dotenvy = "0.15.7"
url = "2.5.8"
chrono = "0.4.45"
[features]
dev = ["slint/backend-winit-x11"]
[build-dependencies]
slint-build = "1.17.1"

9
dashboard/build.rs Normal file
View File

@@ -0,0 +1,9 @@
fn main() {
slint_build::compile_with_config(
"ui/main.slint",
slint_build::CompilerConfiguration::new()
.with_style("material-dark".into())
.embed_resources(slint_build::EmbedResourcesKind::EmbedFiles),
)
.unwrap();
}

78
dashboard/src/ha_ext.rs Normal file
View File

@@ -0,0 +1,78 @@
use crate::*;
use hass_rs::HassEntity;
use slint::ToSharedString;
pub trait HassEntityExt {
fn is_on(&self) -> bool;
fn available(&self) -> bool;
fn friendly_name(&self) -> &str;
fn unit_of_measurement(&self) -> &str;
}
impl HassEntityExt for HassEntity {
fn is_on(&self) -> bool {
self.state == "on"
}
fn available(&self) -> bool {
self.state != "unavailable"
}
fn friendly_name(&self) -> &str {
self.attributes
.get("friendly_name")
.and_then(serde_json::Value::as_str)
.unwrap_or(&self.entity_id)
}
fn unit_of_measurement(&self) -> &str {
self.attributes
.get("unit_of_measurement")
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
}
}
impl From<&HassEntity> for LightData {
fn from(value: &HassEntity) -> Self {
Self {
id: value.entity_id.clone().into(),
name: value.friendly_name().into(),
available: value.available(),
on: value.is_on(),
}
}
}
impl From<&HassEntity> for ThermometerData {
fn from(value: &HassEntity) -> Self {
let v: f32 = value.state.parse().unwrap_or_default();
Self {
id: value.entity_id.clone().into(),
name: value.friendly_name().into(),
value: format!("{:.1}", v).into(),
unit: value.unit_of_measurement().into(),
}
}
}
impl From<&HassEntity> for DateTimeData {
fn from(value: &HassEntity) -> Self {
if let Ok(datetime) =
chrono::NaiveDateTime::parse_from_str(&value.state, "%Y-%m-%dT%H:%M:%S")
{
return Self {
id: value.entity_id.clone().into(),
date: datetime.format("%a %m %b").to_shared_string(),
time: datetime.format("%H:%M").to_shared_string(),
};
}
Self {
id: value.entity_id.clone().into(),
date: "0-0-0000".into(),
time: "00:00".into(),
}
}
}

208
dashboard/src/main.rs Normal file
View File

@@ -0,0 +1,208 @@
mod ha_ext;
use std::sync::{Arc, RwLock};
use dotenvy::var;
use hass_rs::{HassClient, HassEntity};
use serde_json::{Value, json};
use slint::{ModelRc, VecModel, Weak};
use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender};
slint::include_modules!();
#[derive(Clone)]
enum C2sMessage {
CallService(String, String, Option<Value>),
}
#[derive(Clone)]
enum S2cMessage {
EntityUpdated,
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
dotenvy::dotenv().ok();
#[cfg(not(feature = "dev"))]
slint::platform::set_platform(Box::new(trekstor::Trekstor::new())).expect("set platform");
let app = AppWindow::new()?;
let state = State::default();
let (c2s_tx, c2s_rx) = mpsc::unbounded_channel::<C2sMessage>();
let (s2c_tx, mut s2c_rx) = mpsc::unbounded_channel::<S2cMessage>();
hass(s2c_tx, c2s_rx, state.clone()).await?;
let app_weak = app.as_weak();
apply_state(&app_weak, state.clone());
tokio::spawn({
let app_weak = app.as_weak();
let state = state.clone();
async move {
loop {
if let Some(S2cMessage::EntityUpdated) = s2c_rx.recv().await {
apply_state(&app_weak, state.clone());
};
}
}
});
app.on_toggle_light({
let state = state.clone();
move |entity_id| {
let id = entity_id.to_string();
let payload = Some(json!({
"entity_id": id
}));
if let Some(entity) = state.get(&id) {
let action = if entity.state == "on" {
"turn_off"
} else {
"turn_on"
};
c2s_tx
.send(C2sMessage::CallService(
"light".into(),
action.into(),
payload.clone(),
))
.ok();
}
}
});
app.run()?;
Ok(())
}
fn apply_state(app: &Weak<AppWindow>, state: State) {
app.upgrade_in_event_loop(move |app| {
// Lights
let lights = state
.domain("light")
.iter()
.filter_map(|light| {
if light.entity_id.ends_with("screen")
|| light.entity_id.find(|c: char| c.is_numeric()).is_some()
{
return None;
}
Some(light.into())
})
.collect::<Vec<LightData>>();
app.set_lights(ModelRc::new(VecModel::from(lights)));
// Thermometers
let badges = state
.domain("sensor")
.iter()
.filter_map(|sensor| {
if sensor.entity_id.ends_with("thermometer_temperature") {
return Some(sensor.into());
}
None
})
.collect::<Vec<ThermometerData>>();
app.set_badges(ModelRc::new(VecModel::from(badges)));
// Clock
if let Some(date_time_iso) = state.get("sensor.date_time_iso").as_ref() {
app.set_date_time(date_time_iso.into());
}
})
.ok();
}
#[derive(Default, Debug, Clone)]
struct State(Arc<RwLock<Vec<HassEntity>>>);
impl State {
pub fn update(&self, entity: &HassEntity) {
let mut entities = self.0.write().unwrap();
if let Some(existing) = entities
.iter_mut()
.find(|e| e.entity_id == entity.entity_id)
{
*existing = entity.clone();
} else {
entities.push(entity.clone());
}
}
pub fn domain(&self, domain: &str) -> Vec<HassEntity> {
let entities = self.0.read().unwrap();
entities
.iter()
.filter(|e| e.entity_id.starts_with(domain))
.cloned()
.collect::<Vec<_>>()
}
pub fn get(&self, entity_id: &str) -> Option<HassEntity> {
let entities = self.0.read().unwrap();
entities.iter().find(|e| e.entity_id == entity_id).cloned()
}
}
async fn hass(
s2c_tx: UnboundedSender<S2cMessage>,
mut c2s_rx: UnboundedReceiver<C2sMessage>,
state: State,
) -> Result<(), Box<dyn std::error::Error>> {
let Ok(url) = var("HASS_URL") else {
return Err("NO HASS_URL SET".into());
};
let Ok(hass_token) = var("HASS_TOKEN") else {
return Err("NO HASS_TOKEN SET".into());
};
let Ok(url) = url::Url::parse(&url) else {
return Err("INVALID HASS_URL".into());
};
let url = format!("ws://{}/api/websocket", url.authority());
let mut client = HassClient::new(&url).await?;
client.auth_with_longlivedtoken(&hass_token).await?;
let states = client.get_states().await?;
states.iter().for_each(|entity| {
state.update(entity);
});
let mut event_receiver = client.subscribe_event("state_changed").await?;
tokio::spawn(async move {
while let Some(message) = event_receiver.recv().await {
if let Some(entity) = &message.event.data.new_state {
state.update(entity);
s2c_tx.send(S2cMessage::EntityUpdated).ok();
}
}
});
tokio::spawn(async move {
loop {
if let Some(C2sMessage::CallService(domain, service, service_data)) =
c2s_rx.recv().await
{
client
.call_service(domain, service, service_data)
.await
.unwrap();
};
}
});
Ok(())
}

201
dashboard/ui/main.slint Normal file
View File

@@ -0,0 +1,201 @@
import {
Palette,
StyleMetrics,
Button,
ScrollView,
VerticalBox,
HorizontalBox,
StandardTableView,
} from "std-widgets.slint";
export struct LightData {
id: string,
name: string,
on: bool,
available: bool,
}
export struct ThermometerData {
id: string,
name: string,
value: string,
unit: string,
}
export struct DateTimeData {
id: string,
date: string,
time: string,
}
component Clock {
in property <DateTimeData> date-time;
VerticalLayout {
alignment: center;
padding-left: 5px;
padding-right: StyleMetrics.layout-padding;
Text {
text: date-time.date;
height: 16px;
font-weight: 100;
horizontal-alignment: right;
}
Text {
text: date-time.time;
font-size: 19px;
height: 19px;
font-weight: 500;
horizontal-alignment: right;
}
}
}
component Thermometer inherits Rectangle {
in property <ThermometerData> badge;
border-radius: 16px + 19px;
border-width: 2px;
border-color: Palette.alternate-background;
background: Palette.alternate-background;
VerticalLayout {
alignment: center;
padding-left: parent.border-radius / 2;
padding-right: parent.border-radius / 2;
Text {
text: badge.name;
height: 16px;
font-weight: 100;
}
Text {
text: "\{badge.value} \{badge.unit}";
height: 19px;
font-size: 19px;
font-weight: 500;
}
}
}
export component AppWindow inherits Window {
width: 1024px;
height: 600px;
default-font-size: 16px;
property <int> page: 0;
in property <DateTimeData> date-time;
in property <[ThermometerData]> badges;
in property <[LightData]> lights;
callback toggle-light(string);
VerticalLayout {
width: root.width;
height: root.height;
HorizontalLayout {
alignment: center;
height: 64px;
spacing: 15px;
padding: StyleMetrics.layout-padding / 2;
Rectangle {
preferred-width: parent.width;
height: parent.height;
}
for badge[index] in root.badges: Thermometer {
badge: badge;
}
Rectangle {
preferred-width: parent.width;
height: parent.height;
}
Clock {
date-time: date-time;
}
}
HorizontalLayout {
height: root.height - 64px;
Rectangle {
preferred-width: root.width;
VerticalLayout {
if root.page == 0: Rectangle {
StandardTableView {
width: parent.width;
height: parent.height;
columns: [
{ title: "Header 1" },
{ title: "Header 2" },
];
rows: [
[
{ text: "Item 1" },
{ text: "Item 2" },
],
[
{ text: "Item 1" },
{ text: "Item 2" },
],
[
{ text: "Item 1" },
{ text: "Item 2" },
]
];
}
}
if root.page == 1: Rectangle {
background: red;
// pages go here
}
if root.page == 2: Rectangle {
background: yellow;
// pages go here
}
Rectangle {
height: 64px;
HorizontalBox {
padding-top: 0;
Button {
text: "Climate";
clicked => { root.page = 0; }
primary: root.page == 0;
}
Button {
text: "Calendar";
clicked => { root.page = 1; }
primary: root.page == 1;
}
Button {
text: "Energy";
clicked => { root.page = 2; }
primary: root.page == 2;
}
}
}
}
}
VerticalBox {
preferred-width: 0;
for light[index] in root.lights: Button {
text: light.name;
primary: light.on;
enabled: light.available;
clicked => { toggle-light(light.id) }
}
}
}
}
}

152
samples Normal file
View File

@@ -0,0 +1,152 @@
Status Sample name
[G...] aarch64-ol7u9-linux-gnu
[G...] aarch64-ol8u10-linux-gnu
[G...] aarch64-ol8u6-linux-gnu
[G...] aarch64-ol8u7-linux-gnu
[G...] aarch64-ol8u8-linux-gnu
[G...] aarch64-ol8u9-linux-gnu
[G...] aarch64-ol9u2-linux-gnu
[G...] aarch64-ol9u3-linux-gnu
[G...] aarch64-ol9u4-linux-gnu
[G...] aarch64-ol9u5-linux-gnu
[G...] aarch64-rpi3-linux-gnu
[G...] aarch64-rpi4-linux-gnu
[G...] aarch64-unknown-linux-gnu
[G...] aarch64-unknown-linux-musl
[G...] aarch64-unknown-linux-uclibc
[G...] alphaev56-unknown-linux-gnu
[G...] alphaev67-unknown-linux-gnu
[G...] arc-arc700-linux-uclibc
[G...] arc-archs-linux-gnu
[G...] arc-multilib-elf32
[G...] arc-multilib-linux-gnu
[G...] arc-multilib-linux-uclibc
[G...] arm-bare_newlib_cortex_m3_nommu-eabi
[G...] arm-cortex_a15-linux-gnueabihf
[G..X] arm-cortexa5-linux-uclibcgnueabihf
[G...] arm-cortex_a8-linux-gnueabi
[G..X] arm-cortexa9_neon-linux-gnueabihf
[G..X] x86_64-w64-mingw32,arm-cortexa9_neon-linux-gnueabihf
[G...] armeb-unknown-eabi
[G...] armeb-unknown-linux-gnueabi
[G...] armeb-unknown-linux-uclibcgnueabi
[G...] arm-multilib-linux-uclibcgnueabi
[G...] arm-nano-eabi
[G...] arm-none-eabi
[G...] arm-ol7u9-linux-gnueabi
[G...] arm-ol7u9-linux-gnueabihf
[G..X] arm-picolibc-default
[G..X] arm-picolibc-eabi
[G..X] x86_64-unknown-linux-gnu,arm-picolibc-eabi
[G...] arm-unknown-eabi
[G...] arm-unknown-linux-gnueabi
[G..X] arm-unknown-linux-musleabi
[G...] arm-unknown-linux-uclibcgnueabi
[G..X] arm-unknown-linux-uclibcgnueabihf
[G...] armv6-nommu-linux-uclibcgnueabi
[G...] armv6-unknown-linux-gnueabi
[G...] armv6-unknown-linux-gnueabihf
[G...] armv7-rpi2-linux-gnueabihf
[G...] armv8-rpi3-linux-gnueabihf
[G...] armv8-rpi4-linux-gnueabihf
[G...] avr
[G...] bpf-unknown-none
[G..X] hppa-unknown-linux-gnu
[G...] i586-geode-linux-uclibc
[G...] i686-centos6-linux-gnu
[G...] i686-centos7-linux-gnu
[G...] i686-nptl-linux-gnu
[G...] i686-ol8u6-linux-gnu
[G...] i686-ol8u7-linux-gnu
[G...] i686-ol8u8-linux-gnu
[G...] i686-ol9u2-linux-gnu
[G...] i686-ubuntu14.04-linux-gnu
[G...] i686-ubuntu16.04-linux-gnu
[G..X] i686-w64-mingw32
[G..X] lm32-unknown-elf
[G..X] loongarch64-unknown-linux-gnu
[G..X] loongarch64-unknown-linux-musl
[G...] m68k-unknown-elf
[G...] m68k-unknown-linux-gnu
[G...] m68k-unknown-uclinux-uclibc
[G...] powerpc-unknown-linux-uclibc,m68k-unknown-uclinux-uclibc
[G...] mips64el-multilib-linux-uclibc
[G...] mips64-ol9u5-linux-gnu
[G...] mips64-unknown-linux-gnu
[G...] mips-ar2315-linux-gnu
[G...] mipsel-multilib-linux-gnu
[G...] mipsel-sde-elf
[G...] mipsel-unknown-linux-gnu
[G...] mips-malta-linux-gnu
[G...] mips-unknown-elf
[G...] mips-unknown-linux-gnu
[G...] mips-unknown-linux-uclibc
[G..X] moxie-unknown-elf
[G..X] moxie-unknown-moxiebox
[G..X] x86_64-multilib-linux-uclibc,moxie-unknown-moxiebox
[G..X] msp430-unknown-elf
[G...] nios2-altera-linux-gnu
[G..X] i686-w64-mingw32,nios2-spico-elf
[G...] nios2-unknown-elf
[G..X] or1k-unknown-elf
[G..X] or1k-unknown-linux-gnu
[G..X] or1k-unknown-linux-musl
[G...] powerpc-405-linux-gnu
[G...] powerpc64le-unknown-linux-gnu
[G...] powerpc64-multilib-linux-gnu
[G...] powerpc64-unknown-linux-gnu
[G...] powerpc64-unknown-linux-musl
[G...] powerpc-8540-linux-gnu
[G...] powerpc-860-linux-gnu
[G...] powerpc-e300c3-linux-gnu
[G...] powerpc-e500v2-linux-gnuspe
[G...] x86_64-multilib-linux-uclibc,powerpc-unknown-elf
[G...] powerpc-unknown-linux-gnu
[G...] powerpc-unknown-linux-uclibc
[G...] powerpc-unknown_nofpu-linux-gnu
[G...] pru
[G...] riscv32-hifive1-elf
[G...] riscv32-picolibc-elf
[G...] riscv32-unknown-elf
[G...] riscv64-multilib-elf
[G...] riscv64-unknown-elf
[G...] riscv64-unknown-linux-gnu
[G..X] rx-unknown-elf
[G..X] s390-ibm-linux-gnu
[G..X] s390-unknown-linux-gnu
[G...] s390x-ibm-linux-gnu
[G...] s390x-unknown-linux-gnu
[G...] sh4-multilib-linux-gnu
[G...] sh4-multilib-linux-uclibc
[G...] sh-unknown-elf
[G...] sparc64-multilib-linux-gnu
[G...] sparc-leon-linux-uclibc
[G...] sparc-unknown-linux-gnu
[G..X] tic6x-uclinux
[G..X] tricore-none-eabi
[G...] x86_64-centos7-linux-gnu
[G...] x86_64-multilib-linux-gnu
[G...] x86_64-multilib-linux-musl
[G...] x86_64-multilib-linux-uclibc
[G...] x86_64-ol8u10-linux-gnu
[G...] x86_64-ol8u6-linux-gnu
[G...] x86_64-ol8u7-linux-gnu
[G...] x86_64-ol8u8-linux-gnu
[G...] x86_64-ol8u9-linux-gnu
[G...] x86_64-ol9u2-linux-gnu
[G...] x86_64-ol9u3-linux-gnu
[G...] x86_64-ol9u4-linux-gnu
[G...] x86_64-ol9u5-linux-gnu
[G..X] x86_64-w64-mingw32,x86_64-pc-linux-gnu
[G...] x86_64-ubuntu14.04-linux-gnu
[G...] x86_64-ubuntu16.04-linux-gnu
[G...] x86_64-unknown-linux-gnu
[G...] x86_64-unknown-linux-uclibc
[G..X] x86_64-w64-mingw32
[G..X] xtensa-fsf-elf
[G...] xtensa-fsf-linux-uclibc
L (Local) : sample was found in current directory
G (Global) : sample was installed with crosstool-NG
X (EXPERIMENTAL): sample may use EXPERIMENTAL features
B (BROKEN) : sample is currently broken
O (OBSOLETE) : sample needs to be upgraded

293
tree Normal file
View File

@@ -0,0 +1,293 @@
arrayvec v0.7.8
├── kurbo v0.13.1
│ ├── svgtypes v0.16.1
│ │ ├── resvg v0.47.0
│ │ │ ├── i-slint-common v1.17.1
│ │ │ │ ├── i-slint-core v1.17.1
│ │ │ │ │ ├── i-slint-backend-selector v1.17.1
│ │ │ │ │ │ └── slint v1.17.1
│ │ │ │ │ │ ├── dashboard v0.1.0 (/home/avii/source/rust/trekstor/dashboard)
│ │ │ │ │ │ └── trekstor v0.1.0 (/home/avii/source/rust/trekstor/trekstor)
│ │ │ │ │ │ └── dashboard v0.1.0 (/home/avii/source/rust/trekstor/dashboard)
│ │ │ │ │ ├── i-slint-renderer-software v1.17.1
│ │ │ │ │ │ └── slint v1.17.1 (*)
│ │ │ │ │ └── slint v1.17.1 (*)
│ │ │ │ ├── i-slint-renderer-software v1.17.1 (*)
│ │ │ │ └── slint v1.17.1 (*)
│ │ │ └── i-slint-core v1.17.1 (*)
│ │ └── usvg v0.47.0
│ │ └── resvg v0.47.0 (*)
│ └── usvg v0.47.0 (*)
├── lyon_geom v1.0.19
│ ├── i-slint-core v1.17.1 (*)
│ └── lyon_path v1.0.19
│ ├── i-slint-core v1.17.1 (*)
│ ├── i-slint-renderer-software v1.17.1 (*)
│ ├── lyon_algorithms v1.0.20
│ │ └── i-slint-core v1.17.1 (*)
│ └── lyon_extra v1.1.0
│ └── i-slint-core v1.17.1 (*)
├── polycool v0.4.0
│ └── kurbo v0.13.1 (*)
├── taffy v0.10.1
│ └── i-slint-core v1.17.1 (*)
└── tiny-skia v0.12.0
└── resvg v0.47.0 (*)
arrayvec v0.7.8
└── lyon_geom v1.0.19
└── lyon_path v1.0.19
├── i-slint-compiler v1.17.1
│ └── slint-macros v1.17.1 (proc-macro)
│ └── slint v1.17.1 (*)
└── lyon_extra v1.1.0
└── i-slint-compiler v1.17.1 (*)
euclid v0.22.14
├── i-slint-core v1.17.1 (*)
├── i-slint-renderer-software v1.17.1 (*)
└── lyon_geom v1.0.19 (*)
euclid v0.22.14
└── lyon_geom v1.0.19 (*)
font-types v0.11.3
└── read-fonts v0.39.2
├── fontique v0.10.0
│ ├── i-slint-common v1.17.1 (*)
│ └── parley v0.10.0
│ └── i-slint-core v1.17.1 (*)
├── harfrust v0.8.4
│ └── parley v0.10.0 (*)
└── skrifa v0.42.1
├── i-slint-common v1.17.1 (*)
├── i-slint-core v1.17.1 (*)
├── i-slint-renderer-software v1.17.1 (*)
└── parley v0.10.0 (*)
font-types v0.12.2
└── read-fonts v0.41.0
└── skrifa v0.44.0
└── swash v0.2.10
├── i-slint-core v1.17.1 (*)
└── i-slint-renderer-software v1.17.1 (*)
hashbrown v0.14.5
└── rowan v0.16.1
└── i-slint-compiler v1.17.1 (*)
hashbrown v0.16.1
└── clru v0.6.3
├── i-slint-core v1.17.1 (*)
└── i-slint-renderer-software v1.17.1 (*)
hashbrown v0.17.1
└── indexmap v2.14.0
└── toml_edit v0.25.13+spec-1.1.0
└── proc-macro-crate v3.5.0
└── num_enum_derive v0.7.6 (proc-macro)
└── num_enum v0.7.6
└── i-slint-compiler v1.17.1 (*)
hashbrown v0.17.1
├── fontique v0.10.0 (*)
└── parley v0.10.0 (*)
i-slint-common v1.17.1 (*)
i-slint-common v1.17.1
└── i-slint-compiler v1.17.1 (*)
[build-dependencies]
└── i-slint-backend-selector v1.17.1 (*)
lyon_extra v1.1.0 (*)
lyon_extra v1.1.0 (*)
lyon_geom v1.0.19 (*)
lyon_geom v1.0.19 (*)
lyon_path v1.0.19 (*)
lyon_path v1.0.19 (*)
num-traits v0.2.19
├── chrono v0.4.45
│ └── i-slint-core v1.17.1 (*)
├── euclid v0.22.14 (*)
├── i-slint-core v1.17.1 (*)
├── i-slint-renderer-software v1.17.1 (*)
├── image v0.25.10
│ └── i-slint-core v1.17.1 (*)
├── integer-sqrt v0.1.5
│ └── i-slint-renderer-software v1.17.1 (*)
├── lyon_algorithms v1.0.20 (*)
├── lyon_geom v1.0.19 (*)
├── lyon_path v1.0.19 (*)
├── moxcms v0.8.1
│ └── image v0.25.10 (*)
└── slint v1.17.1 (*)
num-traits v0.2.19
├── euclid v0.22.14 (*)
├── lyon_geom v1.0.19 (*)
└── lyon_path v1.0.19 (*)
read-fonts v0.39.2 (*)
read-fonts v0.41.0 (*)
skrifa v0.42.1 (*)
skrifa v0.44.0 (*)
smallvec v1.15.2
└── idna v1.1.0
└── url v2.5.8
└── i-slint-compiler v1.17.1 (*)
smallvec v1.15.2
├── fixed_decimal v0.7.2
│ └── icu_decimal v2.2.0
│ └── i-slint-common v1.17.1 (*)
├── fontique v0.10.0 (*)
├── harfrust v0.8.4 (*)
├── icu_normalizer v2.2.0
│ ├── i-slint-compiler v1.17.1 (*)
│ ├── i-slint-core v1.17.1 (*)
│ ├── idna_adapter v1.2.2
│ │ └── idna v1.1.0 (*)
│ └── parley v0.10.0 (*)
├── kurbo v0.13.1 (*)
└── rustybuzz v0.20.1
└── usvg v0.47.0 (*)
syn v2.0.119
├── const-field-offset-macro v0.2.0 (proc-macro)
│ └── const-field-offset v0.2.0
│ ├── i-slint-core v1.17.1 (*)
│ ├── slint v1.17.1 (*)
│ └── vtable v0.4.0
│ ├── i-slint-core v1.17.1 (*)
│ └── slint v1.17.1 (*)
├── derive_more-impl v2.1.1 (proc-macro)
│ └── derive_more v2.1.1
│ ├── i-slint-common v1.17.1 (*)
│ ├── i-slint-common v1.17.1 (*)
│ ├── i-slint-compiler v1.17.1 (*)
│ ├── i-slint-core v1.17.1 (*)
│ └── i-slint-renderer-software v1.17.1 (*)
├── i-slint-core-macros v1.17.1 (proc-macro)
│ ├── i-slint-backend-selector v1.17.1 (*)
│ ├── i-slint-core v1.17.1 (*)
│ └── slint v1.17.1 (*)
├── num_enum_derive v0.7.6 (proc-macro) (*)
├── pin-project-internal v1.1.13 (proc-macro)
│ └── pin-project v1.1.13
│ └── i-slint-core v1.17.1 (*)
├── strum_macros v0.28.0 (proc-macro)
│ └── strum v0.28.0
│ ├── i-slint-compiler v1.17.1 (*)
│ └── i-slint-core v1.17.1 (*)
├── synstructure v0.13.2
│ ├── yoke-derive v0.8.2 (proc-macro)
│ │ └── yoke v0.8.3
│ │ ├── icu_collections v2.2.0
│ │ │ ├── icu_locale v2.2.0
│ │ │ │ ├── icu_decimal v2.2.0 (*)
│ │ │ │ └── icu_segmenter v2.2.0
│ │ │ │ └── parley v0.10.0 (*)
│ │ │ ├── icu_normalizer v2.2.0 (*)
│ │ │ ├── icu_properties v2.2.0
│ │ │ │ ├── idna_adapter v1.2.2 (*)
│ │ │ │ ├── parley v0.10.0 (*)
│ │ │ │ └── parley_data v0.10.0
│ │ │ │ └── parley v0.10.0 (*)
│ │ │ └── icu_segmenter v2.2.0 (*)
│ │ ├── icu_provider v2.2.0
│ │ │ ├── i-slint-common v1.17.1 (*)
│ │ │ ├── icu_decimal v2.2.0 (*)
│ │ │ ├── icu_locale v2.2.0 (*)
│ │ │ ├── icu_normalizer v2.2.0 (*)
│ │ │ ├── icu_properties v2.2.0 (*)
│ │ │ └── icu_segmenter v2.2.0 (*)
│ │ ├── zerotrie v0.2.4
│ │ │ ├── icu_properties v2.2.0 (*)
│ │ │ └── icu_provider v2.2.0 (*)
│ │ └── zerovec v0.11.6
│ │ ├── icu_collections v2.2.0 (*)
│ │ ├── icu_decimal v2.2.0 (*)
│ │ ├── icu_locale v2.2.0 (*)
│ │ ├── icu_locale_core v2.2.0
│ │ │ ├── i-slint-common v1.17.1 (*)
│ │ │ ├── icu_decimal v2.2.0 (*)
│ │ │ ├── icu_locale v2.2.0 (*)
│ │ │ ├── icu_properties v2.2.0 (*)
│ │ │ └── icu_provider v2.2.0 (*)
│ │ ├── icu_normalizer v2.2.0 (*)
│ │ ├── icu_properties v2.2.0 (*)
│ │ ├── icu_provider v2.2.0 (*)
│ │ ├── icu_segmenter v2.2.0 (*)
│ │ ├── potential_utf v0.1.5
│ │ │ ├── icu_collections v2.2.0 (*)
│ │ │ ├── icu_locale v2.2.0 (*)
│ │ │ └── icu_segmenter v2.2.0 (*)
│ │ └── tinystr v0.8.3
│ │ ├── icu_locale v2.2.0 (*)
│ │ └── icu_locale_core v2.2.0 (*)
│ └── zerofrom-derive v0.1.7 (proc-macro)
│ └── zerofrom v0.1.8
│ ├── icu_collections v2.2.0 (*)
│ ├── icu_provider v2.2.0 (*)
│ ├── yoke v0.8.3 (*)
│ ├── zerotrie v0.2.4 (*)
│ └── zerovec v0.11.6 (*)
├── vtable-macro v0.4.0 (proc-macro)
│ └── vtable v0.4.0 (*)
├── yoke-derive v0.8.2 (proc-macro) (*)
├── zerofrom-derive v0.1.7 (proc-macro) (*)
└── zerovec-derive v0.11.3 (proc-macro)
└── zerovec v0.11.6 (*)
syn v3.0.3
├── auto_enums v0.8.10 (proc-macro)
│ └── i-slint-core v1.17.1 (*)
├── bytemuck_derive v1.12.0 (proc-macro)
│ └── bytemuck v1.25.2
│ ├── font-types v0.11.3 (*)
│ ├── font-types v0.12.2 (*)
│ ├── harfrust v0.8.4 (*)
│ ├── i-slint-renderer-software v1.17.1 (*)
│ ├── image v0.25.10 (*)
│ ├── read-fonts v0.39.2 (*)
│ ├── read-fonts v0.41.0 (*)
│ ├── rgb v0.8.53
│ │ ├── i-slint-core v1.17.1 (*)
│ │ └── resvg v0.47.0 (*)
│ ├── rustybuzz v0.20.1 (*)
│ ├── skrifa v0.42.1 (*)
│ ├── skrifa v0.44.0 (*)
│ ├── tiny-skia v0.12.0 (*)
│ └── tiny-skia-path v0.12.0
│ ├── tiny-skia v0.12.0 (*)
│ └── usvg v0.47.0 (*)
├── derive_utils v0.16.0
│ └── auto_enums v0.8.10 (proc-macro) (*)
├── displaydoc v0.2.7 (proc-macro)
│ ├── fixed_decimal v0.7.2 (*)
│ ├── icu_collections v2.2.0 (*)
│ ├── icu_decimal v2.2.0 (*)
│ ├── icu_locale_core v2.2.0 (*)
│ ├── icu_provider v2.2.0 (*)
│ ├── tinystr v0.8.3 (*)
│ └── zerotrie v0.2.4 (*)
└── thiserror-impl v2.0.19 (proc-macro)
├── thiserror v2.0.19
│ └── lyon_extra v1.1.0 (*)
└── thiserror v2.0.19
└── lyon_extra v1.1.0 (*)
thiserror v2.0.19 (*)
thiserror v2.0.19 (*)

1
trekstor/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/target

4747
trekstor/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

10
trekstor/Cargo.toml Normal file
View File

@@ -0,0 +1,10 @@
[package]
name = "trekstor"
version = "0.1.0"
edition = "2024"
[dependencies]
slint.workspace = true
evdev = "0.13.2"
libc = "0.2.189"

View File

@@ -0,0 +1,74 @@
//! Kernel ABI: framebuffer + Kindle EPDC structs and ioctl numbers.
// Standard Linux framebuffer ioctl numbers (see <linux/fb.h>).
// Typed as c_ulong (not libc::Ioctl) so the crate still type-checks on
// non-Linux dev hosts where libc::Ioctl isn't defined, like macos
pub(super) const FBIOGET_VSCREENINFO: libc::c_ulong = 0x4600;
pub(super) const FBIOGET_FSCREENINFO: libc::c_ulong = 0x4602;
// These structs mirror the kernel's `fb_var_screeninfo` and `fb_fix_screeninfo`.
// We only read from them, fields we care about are `xres`, `yres` (visible
// resolution) and `line_length` (stride in bytes per row, which may be larger
// than xres due to alignment padding).
#[repr(C)]
#[derive(Default)]
pub(super) struct FbBitfield {
pub(super) offset: u32,
pub(super) length: u32,
pub(super) msb_right: u32,
}
#[repr(C)]
#[derive(Default)]
pub(super) struct FbVarScreeninfo {
pub(super) xres: u32,
pub(super) yres: u32,
pub(super) xres_virtual: u32,
pub(super) yres_virtual: u32,
pub(super) xoffset: u32,
pub(super) yoffset: u32,
pub(super) bits_per_pixel: u32,
pub(super) grayscale: u32,
pub(super) red: FbBitfield,
pub(super) green: FbBitfield,
pub(super) blue: FbBitfield,
pub(super) transp: FbBitfield,
pub(super) nonstd: u32,
pub(super) activate: u32,
pub(super) height: u32,
pub(super) width: u32,
pub(super) accel_flags: u32,
pub(super) pixclock: u32,
pub(super) left_margin: u32,
pub(super) right_margin: u32,
pub(super) upper_margin: u32,
pub(super) lower_margin: u32,
pub(super) hsync_len: u32,
pub(super) vsync_len: u32,
pub(super) sync: u32,
pub(super) vmode: u32,
pub(super) rotate: u32,
pub(super) colorspace: u32,
pub(super) reserved: [u32; 4],
}
#[repr(C)]
#[derive(Default)]
pub(super) struct FbFixScreeninfo {
pub(super) id: [u8; 16],
pub(super) smem_start: libc::c_ulong,
pub(super) smem_len: u32,
pub(super) type_: u32,
pub(super) type_aux: u32,
pub(super) visual: u32,
pub(super) xpanstep: u16,
pub(super) ypanstep: u16,
pub(super) ywrapstep: u16,
pub(super) line_length: u32,
pub(super) mmio_start: libc::c_ulong,
pub(super) mmio_len: u32,
pub(super) accel: u32,
pub(super) capabilities: u16,
pub(super) reserved: [u16; 2],
}

View File

@@ -0,0 +1,135 @@
mod ffi;
use std::os::fd::AsRawFd;
use ffi::{FBIOGET_FSCREENINFO, FBIOGET_VSCREENINFO, FbFixScreeninfo, FbVarScreeninfo};
/// Memory-mapped handle to the Kindle's e-ink framebuffer.
///
/// Pixel format is 8-bit grayscale (one byte per pixel). The `stride` may be
/// wider than `width` due to hardware alignment requirements.
pub(crate) struct Framebuffer {
map: *mut u8,
len: usize,
pub(crate) width: u32,
pub(crate) height: u32,
}
// SAFETY: The mmap is process-wide and we only access it from the event loop thread.
unsafe impl Send for Framebuffer {}
impl Framebuffer {
/// Open the framebuffer device and query its geometry from the kernel.
///
/// This works on any Kindle model - the resolution and stride are read at
/// runtime rather than being hardcoded.
pub(crate) fn open() -> std::io::Result<Self> {
let file = std::fs::OpenOptions::new()
.read(true)
.write(true)
.open("/dev/fb0")?;
let fd = file.as_raw_fd();
let mut vinfo = FbVarScreeninfo::default();
if unsafe {
libc::ioctl(
fd,
FBIOGET_VSCREENINFO as _,
&mut vinfo as *mut _ as *mut libc::c_void,
)
} == -1
{
return Err(std::io::Error::last_os_error());
}
let mut finfo = FbFixScreeninfo::default();
if unsafe {
libc::ioctl(
fd,
FBIOGET_FSCREENINFO as _,
&mut finfo as *mut _ as *mut libc::c_void,
)
} == -1
{
return Err(std::io::Error::last_os_error());
}
let width = vinfo.xres;
let height = vinfo.yres;
let stride = finfo.line_length as usize;
// The whole render path treats the mmap as four bytes per pixel. A
// different depth would silently produce garbled output, so reject it
// with a clear error instead.
if vinfo.bits_per_pixel != 32 {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!(
"unsupported framebuffer depth: {} bpp (expected 8-bit grayscale)",
vinfo.bits_per_pixel
),
));
}
if width == 0 || height == 0 || stride < width as usize {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("invalid framebuffer geometry: {width}x{height}, stride={stride}"),
));
}
let len = stride * height as usize;
let map = unsafe {
libc::mmap(
std::ptr::null_mut(),
len,
libc::PROT_READ | libc::PROT_WRITE,
libc::MAP_SHARED,
fd,
0,
)
};
if map == libc::MAP_FAILED {
return Err(std::io::Error::last_os_error());
}
Ok(Self {
map: map as *mut u8,
len,
width,
height,
})
}
pub(crate) fn write_buffer(
&mut self,
pixels: &[slint::platform::software_renderer::Rgb565Pixel],
) {
let dst = unsafe { std::slice::from_raw_parts_mut(self.map as *mut u32, pixels.len()) };
for (dst, src) in dst.iter_mut().zip(pixels) {
let c = src.0; // assuming Rgb565Pixel(pub u16)
let r5 = ((c >> 11) & 0x1f) as u8;
let g6 = ((c >> 5) & 0x3f) as u8;
let b5 = (c & 0x1f) as u8;
// Expand to 8 bits.
let r = (r5 << 3) | (r5 >> 2);
let g = (g6 << 2) | (g6 >> 4);
let b = (b5 << 3) | (b5 >> 2);
// BGRA8888
*dst = u32::from_le_bytes([b, g, r, 0xff]);
}
}
}
impl Drop for Framebuffer {
fn drop(&mut self) {
unsafe { libc::munmap(self.map.cast::<libc::c_void>(), self.len) };
}
}

101
trekstor/src/input.rs Normal file
View File

@@ -0,0 +1,101 @@
use std::collections::HashSet;
use evdev::{AbsoluteAxisCode, Device, EventSummary, KeyCode};
pub struct InputState {
device: Option<Device>,
pub x: i32,
pub y: i32,
width: i32,
height: i32,
keys_down: HashSet<KeyCode>,
keys_pressed_this_update: HashSet<KeyCode>,
keys_released_this_update: HashSet<KeyCode>,
}
impl InputState {
pub fn new(w: u32, h: u32) -> Self {
let device = evdev::enumerate()
.find(|(_, device)| device.name() == Some("silead_ts"))
.map(|(_, d)| {
d.set_nonblocking(true).ok();
d
});
Self {
device,
x: 0,
y: 0,
width: w.cast_signed(),
height: h.cast_signed(),
keys_down: HashSet::new(),
keys_pressed_this_update: HashSet::new(),
keys_released_this_update: HashSet::new(),
}
}
pub(super) fn finish(&mut self) {
self.keys_pressed_this_update.clear();
self.keys_released_this_update.clear();
}
pub(super) fn handle_event(&mut self) {
let Some(device) = &mut self.device else {
return;
};
let Ok(events) = device.fetch_events() else {
return;
};
for event in events {
match event.destructure() {
EventSummary::AbsoluteAxis(_, AbsoluteAxisCode::ABS_X, value) => {
self.x = ((((value - 13) as f32 / 882.) * (self.width as f32)) as i32)
.max(0)
.min(self.width);
}
EventSummary::AbsoluteAxis(_, AbsoluteAxisCode::ABS_Y, value) => {
self.y = ((((value - 13) as f32 / 624.) * (self.height as f32)) as i32)
.max(0)
.min(self.width);
}
EventSummary::Key(_, key, value) => match value {
1 => {
if !self.keys_down.contains(&key) {
self.keys_pressed_this_update.insert(key);
}
self.keys_down.insert(key);
}
0 => {
if self.keys_down.contains(&key) {
self.keys_released_this_update.insert(key);
}
self.keys_down.remove(&key);
}
_ => {}
},
_ => {}
}
}
}
}
#[allow(unused)]
impl InputState {
pub fn is_just_pressed(&self, key: KeyCode) -> bool {
self.keys_pressed_this_update.contains(&key)
}
pub fn is_down(&self, key: KeyCode) -> bool {
self.keys_down.contains(&key)
}
pub fn is_just_released(&self, key: KeyCode) -> bool {
self.keys_released_this_update.contains(&key)
}
pub fn is_up(&self, key: KeyCode) -> bool {
!self.is_down(key)
}
}

153
trekstor/src/lib.rs Normal file
View File

@@ -0,0 +1,153 @@
#![allow(clippy::borrow_as_ptr)]
mod framebuffer;
mod input;
use std::rc::Rc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use evdev::KeyCode;
use slint::platform::software_renderer::{MinimalSoftwareWindow, RepaintBufferType, Rgb565Pixel};
use slint::platform::{EventLoopProxy, Platform, WindowEvent};
use slint::{EventLoopError, LogicalPosition, PhysicalSize, PlatformError, WindowSize};
use self::framebuffer::Framebuffer;
use self::input::InputState;
pub struct Trekstor {
window: Rc<MinimalSoftwareWindow>,
queue: Queue,
quit_flag: Arc<AtomicBool>,
}
impl Trekstor {
pub fn new() -> Self {
Self::default()
}
}
impl Default for Trekstor {
fn default() -> Self {
let window = MinimalSoftwareWindow::new(RepaintBufferType::NewBuffer);
Self {
window,
queue: Arc::new(Mutex::new(Vec::new())),
quit_flag: Arc::new(AtomicBool::new(false)),
}
}
}
type Closure = Box<dyn FnOnce() + Send>;
type Queue = Arc<Mutex<Vec<Closure>>>;
struct TrekstorEventProxy {
queue: Queue,
quit_flag: Arc<AtomicBool>,
}
impl EventLoopProxy for TrekstorEventProxy {
fn quit_event_loop(&self) -> Result<(), EventLoopError> {
self.quit_flag.store(true, Ordering::SeqCst);
Ok(())
}
fn invoke_from_event_loop(&self, event: Closure) -> Result<(), EventLoopError> {
// Don't queue if the loop is shutting down, the closure would never
// run, and any caller blocking on a channel send inside it would hang.
if self.quit_flag.load(Ordering::SeqCst) {
return Err(EventLoopError::EventLoopTerminated);
}
self.queue
.lock()
.expect("event loop closure queue poisoned")
.push(event);
Ok(())
}
}
impl Platform for Trekstor {
fn create_window_adapter(
&self,
) -> Result<Rc<dyn slint::platform::WindowAdapter>, slint::PlatformError> {
Ok(self.window.clone())
}
fn run_event_loop(&self) -> Result<(), slint::PlatformError> {
let mut frame_buffer = Framebuffer::open()
.map_err(|e| PlatformError::Other(format!("failed to open /dev/fb0: {e}")))?;
let mut input = InputState::new(frame_buffer.width, frame_buffer.height);
#[allow(clippy::large_stack_arrays)] // causes stack overflow if rgba8
let mut buffer1 = [Rgb565Pixel(0); 614_400];
self.window.set_size(WindowSize::Physical(PhysicalSize::new(
frame_buffer.width,
frame_buffer.height,
)));
loop {
input.handle_event();
slint::platform::update_timers_and_animations();
if input.is_just_pressed(KeyCode::BTN_TOUCH) {
self.window
.try_dispatch_event(WindowEvent::PointerPressed {
position: LogicalPosition::new(input.x as f32, input.y as f32),
button: slint::platform::PointerEventButton::Left,
})
.unwrap();
}
if input.is_just_released(KeyCode::BTN_TOUCH) {
self.window
.try_dispatch_event(WindowEvent::PointerReleased {
position: LogicalPosition::new(input.x as f32, input.y as f32),
button: slint::platform::PointerEventButton::Left,
})
.unwrap();
}
if input.is_down(KeyCode::BTN_TOUCH) {
self.window
.try_dispatch_event(WindowEvent::PointerMoved {
position: LogicalPosition::new(input.x as f32, input.y as f32),
})
.unwrap();
}
if self.quit_flag.load(Ordering::SeqCst) {
break;
}
self.window.draw_if_needed(|renderer| {
renderer.render(&mut buffer1, frame_buffer.width as usize);
frame_buffer.write_buffer(&buffer1);
});
input.finish();
let pending: Vec<_> = self
.queue
.lock()
.expect("event loop closure queue poisoned")
.drain(..)
.collect();
for c in pending {
c();
}
}
Ok(())
}
fn new_event_loop_proxy(&self) -> Option<Box<dyn EventLoopProxy>> {
Some(Box::new(TrekstorEventProxy {
queue: self.queue.clone(),
quit_flag: self.quit_flag.clone(),
}))
}
}