use askama::Template;
use axum::{
extract::{Form, State},
http::{header, StatusCode},
response::{
sse::{Event, KeepAlive, Sse},
Html, IntoResponse, Response,
},
routing::{get, post},
Router,
};
use serde::Deserialize;
use std::convert::Infallible;
use std::sync::Arc;
use tokio::sync::broadcast;
use tokio_stream::wrappers::BroadcastStream;
use tokio_stream::{Stream, StreamExt};
use crate::dbus::DaemonSignals;
use crate::faces::{
available_faces, available_themes, complication_names, complication_options,
ComplicationOptionType,
};
use crate::state::AppState;
#[derive(Clone)]
struct FaceOption {
id: String,
display_name: String,
}
#[derive(Clone)]
struct ThemeOption {
id: String,
display_name: String,
}
use ht32_panel_hw::Orientation;
#[derive(Template)]
#[template(path = "index.html")]
struct IndexTemplate;
#[derive(Template)]
#[template(path = "partials/status.html")]
struct StatusTemplate {
connected: bool,
}
#[derive(Template)]
#[template(path = "partials/orientation.html")]
struct OrientationTemplate {
current: String,
}
#[derive(Template)]
#[template(path = "partials/face.html")]
struct FaceTemplate {
current: String,
faces: Vec<FaceOption>,
}
#[derive(Template)]
#[template(path = "partials/led.html")]
struct LedTemplate {
theme: u8,
intensity: u8,
speed: u8,
error: Option<String>,
}
#[derive(Template)]
#[template(path = "partials/theme.html")]
struct ThemeTemplate {
current: String,
themes: Vec<ThemeOption>,
}
#[derive(Template)]
#[template(path = "partials/preview.html")]
struct PreviewTemplate {
timestamp: u128,
}
struct ComplicationOptionChoice {
value: String,
label: String,
}
struct ComplicationOptionRange {
min: f32,
max: f32,
step: f32,
}
struct ComplicationOptionItem {
id: String,
name: String,
current_value: String,
is_range: bool,
choices: Vec<ComplicationOptionChoice>,
range: Option<ComplicationOptionRange>,
}
struct ComplicationItem {
id: String,
name: String,
description: String,
enabled: bool,
options: Vec<ComplicationOptionItem>,
}
#[derive(Template)]
#[template(path = "partials/complications.html")]
struct ComplicationsTemplate {
face_name: String,
complications: Vec<ComplicationItem>,
}
#[derive(Clone)]
pub struct WebState {
pub app: Arc<AppState>,
pub signal_tx: broadcast::Sender<DaemonSignals>,
}
pub fn create_router(state: Arc<AppState>, signal_tx: broadcast::Sender<DaemonSignals>) -> Router {
let web_state = WebState {
app: state,
signal_tx,
};
Router::new()
.route("/", get(index))
.route("/lcd.png", get(lcd_png))
.route("/events", get(events_stream))
.route("/status", get(status))
.route("/orientation", get(orientation_get).post(orientation_set))
.route("/face", get(face_get).post(face_set))
.route("/led", get(led_get).post(led_set))
.route("/theme", get(theme_get).post(theme_set))
.route(
"/complications",
get(complications_get).post(complications_set),
)
.route("/complication-option", post(complication_option_set))
.route("/preview", get(preview_get))
.with_state(web_state)
}
async fn index() -> impl IntoResponse {
Html(IndexTemplate.render().unwrap())
}
async fn events_stream(
State(state): State<WebState>,
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
let rx = state.signal_tx.subscribe();
let stream = BroadcastStream::new(rx).filter_map(|result| {
match result {
Ok(signal) => {
let event_type = match signal {
DaemonSignals::OrientationChanged => "orientation",
DaemonSignals::LedChanged => "led",
DaemonSignals::DisplaySettingsChanged => "display",
DaemonSignals::ComplicationOptionChanged => "complication",
};
Some(Ok(Event::default().event(event_type).data("reload")))
}
Err(_) => None, }
});
Sse::new(stream).keep_alive(KeepAlive::default())
}
async fn lcd_png(State(state): State<WebState>) -> Response {
match state.app.get_screen_png() {
Ok(png_data) => (
StatusCode::OK,
[
(header::CONTENT_TYPE, "image/png"),
(header::CACHE_CONTROL, "no-cache"),
],
png_data,
)
.into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to generate PNG: {}", e),
)
.into_response(),
}
}
async fn status(State(state): State<WebState>) -> impl IntoResponse {
let connected = state.app.is_lcd_connected();
Html(StatusTemplate { connected }.render().unwrap())
}
async fn orientation_get(State(state): State<WebState>) -> impl IntoResponse {
let current = state.app.orientation().to_string();
Html(OrientationTemplate { current }.render().unwrap())
}
#[derive(Deserialize)]
struct OrientationForm {
orientation: String,
}
async fn orientation_set(
State(state): State<WebState>,
Form(form): Form<OrientationForm>,
) -> impl IntoResponse {
if let Ok(orientation) = form.orientation.parse::<Orientation>() {
let _ = state.app.set_orientation(orientation);
}
let current = state.app.orientation().to_string();
Html(OrientationTemplate { current }.render().unwrap())
}
async fn face_get(State(state): State<WebState>) -> impl IntoResponse {
let current = state.app.face_name();
let faces: Vec<FaceOption> = available_faces()
.iter()
.map(|f| FaceOption {
id: f.id.to_string(),
display_name: f.display_name.to_string(),
})
.collect();
Html(FaceTemplate { current, faces }.render().unwrap())
}
#[derive(Deserialize)]
struct FaceForm {
face: String,
}
async fn face_set(State(state): State<WebState>, Form(form): Form<FaceForm>) -> impl IntoResponse {
let _ = state.app.set_face(&form.face);
let current = state.app.face_name();
let faces: Vec<FaceOption> = available_faces()
.iter()
.map(|f| FaceOption {
id: f.id.to_string(),
display_name: f.display_name.to_string(),
})
.collect();
Html(FaceTemplate { current, faces }.render().unwrap())
}
async fn led_get(State(state): State<WebState>) -> impl IntoResponse {
let (theme, intensity, speed) = state.app.led_settings();
Html(
LedTemplate {
theme,
intensity,
speed,
error: None,
}
.render()
.unwrap(),
)
}
#[derive(Deserialize)]
struct LedForm {
theme: u8,
#[serde(default = "default_led")]
intensity: u8,
#[serde(default = "default_led")]
speed: u8,
}
fn default_led() -> u8 {
3
}
async fn led_set(State(state): State<WebState>, Form(form): Form<LedForm>) -> impl IntoResponse {
let theme = form.theme.clamp(1, 5);
let intensity = form.intensity.clamp(1, 5);
let speed = form.speed.clamp(1, 5);
let error = match state.app.set_led(theme, intensity, speed).await {
Ok(()) => None,
Err(e) => {
tracing::error!("Failed to set LED: {}", e);
Some(e.to_string())
}
};
let (theme, intensity, speed) = state.app.led_settings();
Html(
LedTemplate {
theme,
intensity,
speed,
error,
}
.render()
.unwrap(),
)
}
async fn theme_get(State(state): State<WebState>) -> impl IntoResponse {
let current = state.app.theme_name();
let themes: Vec<ThemeOption> = available_themes()
.iter()
.map(|t| ThemeOption {
id: t.id.to_string(),
display_name: t.display_name.to_string(),
})
.collect();
Html(ThemeTemplate { current, themes }.render().unwrap())
}
#[derive(Deserialize)]
struct ThemeForm {
theme: String,
}
async fn theme_set(
State(state): State<WebState>,
Form(form): Form<ThemeForm>,
) -> impl IntoResponse {
let _ = state.app.set_theme(&form.theme);
let current = state.app.theme_name();
let themes: Vec<ThemeOption> = available_themes()
.iter()
.map(|t| ThemeOption {
id: t.id.to_string(),
display_name: t.display_name.to_string(),
})
.collect();
Html(ThemeTemplate { current, themes }.render().unwrap())
}
async fn preview_get() -> impl IntoResponse {
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis();
Html(PreviewTemplate { timestamp }.render().unwrap())
}
async fn complications_get(State(state): State<WebState>) -> impl IntoResponse {
let face_name = state.app.face_name();
let available = state.app.available_complications();
let enabled = state.app.enabled_complications();
let interfaces = state.app.list_network_interfaces();
let complications: Vec<ComplicationItem> = available
.into_iter()
.map(|c| {
let options: Vec<ComplicationOptionItem> = c
.options
.iter()
.map(|opt| {
let current_value = state
.app
.get_complication_option(&c.id, &opt.id)
.unwrap_or_else(|| opt.default_value.clone());
match &opt.option_type {
ComplicationOptionType::Choice(choices) => {
let choice_list = if c.id == complication_names::NETWORK
&& opt.id == complication_options::INTERFACE
{
let mut iface_choices = vec![ComplicationOptionChoice {
value: "auto".to_string(),
label: "Auto-detect".to_string(),
}];
for iface in &interfaces {
iface_choices.push(ComplicationOptionChoice {
value: iface.clone(),
label: iface.clone(),
});
}
iface_choices
} else {
choices
.iter()
.map(|ch| ComplicationOptionChoice {
value: ch.value.clone(),
label: ch.label.clone(),
})
.collect()
};
ComplicationOptionItem {
id: opt.id.clone(),
name: opt.name.clone(),
current_value,
is_range: false,
choices: choice_list,
range: None,
}
}
ComplicationOptionType::Boolean => ComplicationOptionItem {
id: opt.id.clone(),
name: opt.name.clone(),
current_value,
is_range: false,
choices: vec![
ComplicationOptionChoice {
value: "true".to_string(),
label: "Yes".to_string(),
},
ComplicationOptionChoice {
value: "false".to_string(),
label: "No".to_string(),
},
],
range: None,
},
ComplicationOptionType::Range { min, max, step } => {
ComplicationOptionItem {
id: opt.id.clone(),
name: opt.name.clone(),
current_value,
is_range: true,
choices: Vec::new(),
range: Some(ComplicationOptionRange {
min: *min,
max: *max,
step: *step,
}),
}
}
}
})
.collect();
ComplicationItem {
enabled: enabled.contains(&c.id),
id: c.id,
name: c.name,
description: c.description,
options,
}
})
.collect();
Html(
ComplicationsTemplate {
face_name,
complications,
}
.render()
.unwrap(),
)
}
#[derive(Deserialize)]
struct ComplicationForm {
complication: String,
enabled: Option<String>,
}
async fn complications_set(
State(state): State<WebState>,
Form(form): Form<ComplicationForm>,
) -> impl IntoResponse {
let enabled = form.enabled.as_deref() == Some("on");
let _ = state
.app
.set_complication_enabled(&form.complication, enabled);
render_complications(&state.app)
}
#[derive(Deserialize)]
struct ComplicationOptionForm {
complication: String,
option: String,
value: String,
}
async fn complication_option_set(
State(state): State<WebState>,
Form(form): Form<ComplicationOptionForm>,
) -> impl IntoResponse {
let _ = state
.app
.set_complication_option(&form.complication, &form.option, &form.value);
render_complications(&state.app)
}
fn render_complications(state: &Arc<AppState>) -> Html<String> {
let face_name = state.face_name();
let available = state.available_complications();
let enabled_set = state.enabled_complications();
let interfaces = state.list_network_interfaces();
let complications: Vec<ComplicationItem> = available
.into_iter()
.map(|c| {
let options: Vec<ComplicationOptionItem> = c
.options
.iter()
.map(|opt| {
let current_value = state
.get_complication_option(&c.id, &opt.id)
.unwrap_or_else(|| opt.default_value.clone());
match &opt.option_type {
ComplicationOptionType::Choice(choices) => {
let choice_list = if c.id == complication_names::NETWORK
&& opt.id == complication_options::INTERFACE
{
let mut iface_choices = vec![ComplicationOptionChoice {
value: "auto".to_string(),
label: "Auto-detect".to_string(),
}];
for iface in &interfaces {
iface_choices.push(ComplicationOptionChoice {
value: iface.clone(),
label: iface.clone(),
});
}
iface_choices
} else {
choices
.iter()
.map(|ch| ComplicationOptionChoice {
value: ch.value.clone(),
label: ch.label.clone(),
})
.collect()
};
ComplicationOptionItem {
id: opt.id.clone(),
name: opt.name.clone(),
current_value,
is_range: false,
choices: choice_list,
range: None,
}
}
ComplicationOptionType::Boolean => ComplicationOptionItem {
id: opt.id.clone(),
name: opt.name.clone(),
current_value,
is_range: false,
choices: vec![
ComplicationOptionChoice {
value: "true".to_string(),
label: "Yes".to_string(),
},
ComplicationOptionChoice {
value: "false".to_string(),
label: "No".to_string(),
},
],
range: None,
},
ComplicationOptionType::Range { min, max, step } => {
ComplicationOptionItem {
id: opt.id.clone(),
name: opt.name.clone(),
current_value,
is_range: true,
choices: Vec::new(),
range: Some(ComplicationOptionRange {
min: *min,
max: *max,
step: *step,
}),
}
}
}
})
.collect();
ComplicationItem {
enabled: enabled_set.contains(&c.id),
id: c.id,
name: c.name,
description: c.description,
options,
}
})
.collect();
Html(
ComplicationsTemplate {
face_name,
complications,
}
.render()
.unwrap(),
)
}