use axum::{
extract::{Path, Query, State},
http::{header, HeaderMap, StatusCode},
response::{IntoResponse, Response},
routing::get,
Json, Router,
};
use serde::Deserialize;
use serde_json::json;
use std::sync::Arc;
use crate::feed::DashboardFeed;
use crate::protocol::{device_type_str, DeviceListItem, DeviceType, DeviceUpdate};
use crate::store::{DeviceRecord, DeviceStore};
#[derive(Clone)]
pub struct HardwareCtx {
pub hardware: DeviceStore,
pub dashboards: Arc<dyn DashboardFeed>,
}
pub fn devices_routes(ctx: HardwareCtx) -> Router<()> {
Router::new()
.route("/", get(list_devices))
.route(
"/:id",
axum::routing::patch(update_device).delete(delete_device),
)
.route(
"/:id/dashboard",
get(get_device_dashboard).put(set_device_dashboard),
)
.with_state(ctx)
}
pub fn display_routes(ctx: HardwareCtx) -> Router<()> {
Router::new()
.route("/:device_id", get(display_manifest))
.route("/:device_id/image", get(display_image))
.with_state(ctx)
}
pub fn openapi() -> utoipa::openapi::OpenApi {
<HardwareApiDoc as utoipa::OpenApi>::openapi()
}
#[derive(utoipa::OpenApi)]
#[openapi(paths(
list_devices,
update_device,
delete_device,
get_device_dashboard,
set_device_dashboard,
display_manifest,
display_image,
))]
struct HardwareApiDoc;
const ONLINE_WINDOW_MS: i64 = 90_000;
fn to_list_item(record: &DeviceRecord) -> DeviceListItem {
let now = chrono::Utc::now().timestamp_millis();
let online = record
.last_seen
.map(|ts| now - ts <= ONLINE_WINDOW_MS)
.unwrap_or(false);
DeviceListItem {
device_id: record.device_id.clone(),
device_type: record.device_type,
name: record.name.clone(),
last_seen: record.last_seen,
online,
battery_pct: record.battery_pct,
}
}
#[utoipa::path(
get,
path = "/api/hardware/devices",
tag = "Hardware",
summary = "list paired devices with presence + battery.",
responses((status = 200, description = "OK", body = serde_json::Value))
)]
pub async fn list_devices(State(ctx): State<HardwareCtx>) -> (StatusCode, Json<serde_json::Value>) {
match ctx.hardware.list().await {
Ok(records) => {
let items: Vec<DeviceListItem> = records.iter().map(to_list_item).collect();
(StatusCode::OK, Json(json!({ "devices": items })))
}
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "devices": [], "error": e.to_string() })),
),
}
}
#[utoipa::path(
patch,
path = "/api/hardware/devices/{id}",
tag = "Hardware",
summary = "update a device's name / prefs.",
params(("id" = String, Path)),
request_body = serde_json::Value,
responses((status = 200, description = "OK", body = serde_json::Value))
)]
pub async fn update_device(
State(ctx): State<HardwareCtx>,
Path(id): Path<String>,
Json(body): Json<DeviceUpdate>,
) -> (StatusCode, Json<serde_json::Value>) {
match ctx.hardware.update(&id, body.name, body.prefs).await {
Ok(true) => match ctx.hardware.get(&id).await {
Ok(Some(record)) => (
StatusCode::OK,
Json(json!({ "device": to_list_item(&record) })),
),
_ => (StatusCode::OK, Json(json!({ "ok": true }))),
},
Ok(false) => (
StatusCode::NOT_FOUND,
Json(json!({ "error": "device not found" })),
),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": e.to_string() })),
),
}
}
#[utoipa::path(
delete,
path = "/api/hardware/devices/{id}",
tag = "Hardware",
summary = "revoke a device (delete it + its token).",
params(("id" = String, Path)),
responses((status = 200, description = "OK", body = serde_json::Value))
)]
pub async fn delete_device(
State(ctx): State<HardwareCtx>,
Path(id): Path<String>,
) -> (StatusCode, Json<serde_json::Value>) {
ctx.dashboards.delete_device(&id).await;
match ctx.hardware.revoke(&id).await {
Ok(true) => (StatusCode::OK, Json(json!({ "ok": true }))),
Ok(false) => (
StatusCode::NOT_FOUND,
Json(json!({ "error": "device not found" })),
),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": e.to_string() })),
),
}
}
fn bearer_token(headers: &HeaderMap) -> Option<String> {
headers
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.map(str::to_string)
}
async fn device_authorized(ctx: &HardwareCtx, device_id: &str, headers: &HeaderMap) -> bool {
let Some(token) = bearer_token(headers) else {
return false;
};
if let Ok(shared) = std::env::var("RYU_TOKEN") {
if !shared.is_empty() && token == shared {
return true;
}
}
ctx.hardware
.verify_token(device_id, &token)
.await
.unwrap_or(false)
}
#[utoipa::path(
get,
path = "/api/hardware/display/{device_id}",
tag = "Hardware",
summary = "the display manifest. Returns the",
params(("device_id" = String, Path)),
responses((status = 200, description = "OK", body = serde_json::Value))
)]
pub async fn display_manifest(
State(ctx): State<HardwareCtx>,
Path(device_id): Path<String>,
headers: HeaderMap,
) -> Response {
if !device_authorized(&ctx, &device_id, &headers).await {
return (
StatusCode::UNAUTHORIZED,
Json(json!({ "error": "unauthorized" })),
)
.into_response();
}
let record = match ctx.hardware.get(&device_id).await {
Ok(Some(r)) => r,
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(json!({ "error": "device not found" })),
)
.into_response()
}
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": e.to_string() })),
)
.into_response()
}
};
match ctx
.dashboards
.device_manifest(
&device_id,
&record.name,
device_type_str(record.device_type),
&record.prefs,
)
.await
{
Ok(m) => {
let s = &m.screen;
let rev = m.rev;
(
StatusCode::OK,
Json(json!({
"image_url": format!("/api/hardware/display/{device_id}/image?rev={rev}"),
"rev": rev,
"refresh_rate": m.refresh_rate,
"screen": {
"w": s.w,
"h": s.h,
"bit_depth": s.bit_depth,
"palette": s.palette,
"rotation": s.rotation,
},
})),
)
.into_response()
}
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": e })),
)
.into_response(),
}
}
#[derive(Debug, Deserialize)]
pub struct ImageQuery {
#[serde(default)]
pub rev: Option<String>,
}
#[utoipa::path(
get,
path = "/api/hardware/display/{device_id}/image",
tag = "Hardware",
summary = "the rendered image bytes",
params(("device_id" = String, Path)),
responses((status = 200, description = "OK", body = serde_json::Value))
)]
pub async fn display_image(
State(ctx): State<HardwareCtx>,
Path(device_id): Path<String>,
Query(q): Query<ImageQuery>,
headers: HeaderMap,
) -> Response {
if !device_authorized(&ctx, &device_id, &headers).await {
return (StatusCode::UNAUTHORIZED, "unauthorized").into_response();
}
let record = match ctx.hardware.get(&device_id).await {
Ok(Some(r)) => r,
Ok(None) => return (StatusCode::NOT_FOUND, "device not found").into_response(),
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
};
match ctx
.dashboards
.device_image(
&device_id,
&record.name,
device_type_str(record.device_type),
&record.prefs,
q.rev.as_deref(),
)
.await
{
Ok(None) => StatusCode::NOT_MODIFIED.into_response(),
Ok(Some(image)) => (
StatusCode::OK,
[
(header::CONTENT_TYPE, image.content_type),
(header::ETAG, format!("\"{}\"", image.rev)),
(header::CACHE_CONTROL, "no-cache".to_string()),
],
image.bytes,
)
.into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
}
}
#[utoipa::path(
get,
path = "/api/hardware/devices/{id}/dashboard",
tag = "Hardware",
summary = "the device's dashboard config",
params(("id" = String, Path)),
responses((status = 200, description = "OK", body = serde_json::Value))
)]
pub async fn get_device_dashboard(
State(ctx): State<HardwareCtx>,
Path(id): Path<String>,
) -> (StatusCode, Json<serde_json::Value>) {
let record = match ctx.hardware.get(&id).await {
Ok(Some(r)) => r,
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(json!({ "error": "device not found" })),
)
}
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": e.to_string() })),
)
}
};
match ctx
.dashboards
.device_config(
&id,
&record.name,
device_type_str(record.device_type),
&record.prefs,
)
.await
{
Ok(config) => (StatusCode::OK, Json(config)),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": e })),
),
}
}
#[derive(Debug, Deserialize)]
pub struct DeviceDashboardUpdate {
#[serde(default)]
pub refresh_rate: Option<u32>,
#[serde(default)]
pub widgets: Option<serde_json::Value>,
}
#[utoipa::path(
put,
path = "/api/hardware/devices/{id}/dashboard",
tag = "Hardware",
summary = "set the device's poll interval and/or",
params(("id" = String, Path)),
request_body = serde_json::Value,
responses((status = 200, description = "OK", body = serde_json::Value))
)]
pub async fn set_device_dashboard(
State(ctx): State<HardwareCtx>,
Path(id): Path<String>,
Json(body): Json<DeviceDashboardUpdate>,
) -> (StatusCode, Json<serde_json::Value>) {
let record = match ctx.hardware.get(&id).await {
Ok(Some(r)) => r,
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(json!({ "error": "device not found" })),
)
}
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": e.to_string() })),
)
}
};
let result = match ctx
.dashboards
.set_device_config(&id, &record.name, body.refresh_rate, body.widgets)
.await
{
Ok(r) => r,
Err(e) => return (StatusCode::BAD_REQUEST, Json(json!({ "error": e }))),
};
nudge_device_display(&record, "dashboard").await;
(
StatusCode::OK,
Json(json!({
"ok": true,
"dashboard_id": result.dashboard_id,
"refresh_rate": result.refresh_rate,
})),
)
}
pub async fn nudge_device_display(record: &DeviceRecord, widget: &str) {
use crate::protocol::{RhpServerMsg, Surface};
let surface = match record.device_type {
DeviceType::Watch => Surface::Lcd,
_ => Surface::Eink,
};
crate::session::live::send(
&record.device_id,
RhpServerMsg::Display {
surface,
widget: widget.to_string(),
payload: json!({ "action": "repoll" }),
},
)
.await;
}