1use axum::{
21 extract::{Path, Query, State},
22 http::{header, HeaderMap, StatusCode},
23 response::{IntoResponse, Response},
24 routing::get,
25 Json, Router,
26};
27use serde::Deserialize;
28use serde_json::json;
29
30use std::sync::Arc;
31
32use crate::feed::DashboardFeed;
33use crate::protocol::{device_type_str, DeviceListItem, DeviceType, DeviceUpdate};
34use crate::store::{DeviceRecord, DeviceStore};
35
36#[derive(Clone)]
42pub struct HardwareCtx {
43 pub hardware: DeviceStore,
44 pub dashboards: Arc<dyn DashboardFeed>,
45}
46
47pub fn devices_routes(ctx: HardwareCtx) -> Router<()> {
52 Router::new()
53 .route("/", get(list_devices))
54 .route(
55 "/:id",
56 axum::routing::patch(update_device).delete(delete_device),
57 )
58 .route(
59 "/:id/dashboard",
60 get(get_device_dashboard).put(set_device_dashboard),
61 )
62 .with_state(ctx)
63}
64
65pub fn display_routes(ctx: HardwareCtx) -> Router<()> {
71 Router::new()
72 .route("/:device_id", get(display_manifest))
73 .route("/:device_id/image", get(display_image))
74 .with_state(ctx)
75}
76
77pub fn openapi() -> utoipa::openapi::OpenApi {
81 <HardwareApiDoc as utoipa::OpenApi>::openapi()
82}
83
84#[derive(utoipa::OpenApi)]
85#[openapi(paths(
86 list_devices,
87 update_device,
88 delete_device,
89 get_device_dashboard,
90 set_device_dashboard,
91 display_manifest,
92 display_image,
93))]
94struct HardwareApiDoc;
95
96const ONLINE_WINDOW_MS: i64 = 90_000;
99
100fn to_list_item(record: &DeviceRecord) -> DeviceListItem {
102 let now = chrono::Utc::now().timestamp_millis();
103 let online = record
104 .last_seen
105 .map(|ts| now - ts <= ONLINE_WINDOW_MS)
106 .unwrap_or(false);
107 DeviceListItem {
108 device_id: record.device_id.clone(),
109 device_type: record.device_type,
110 name: record.name.clone(),
111 last_seen: record.last_seen,
112 online,
113 battery_pct: record.battery_pct,
114 }
115}
116
117#[utoipa::path(
119 get,
120 path = "/api/hardware/devices",
121 tag = "Hardware",
122 summary = "list paired devices with presence + battery.",
123 responses((status = 200, description = "OK", body = serde_json::Value))
124)]
125pub async fn list_devices(State(ctx): State<HardwareCtx>) -> (StatusCode, Json<serde_json::Value>) {
126 match ctx.hardware.list().await {
127 Ok(records) => {
128 let items: Vec<DeviceListItem> = records.iter().map(to_list_item).collect();
129 (StatusCode::OK, Json(json!({ "devices": items })))
130 }
131 Err(e) => (
132 StatusCode::INTERNAL_SERVER_ERROR,
133 Json(json!({ "devices": [], "error": e.to_string() })),
134 ),
135 }
136}
137
138#[utoipa::path(
140 patch,
141 path = "/api/hardware/devices/{id}",
142 tag = "Hardware",
143 summary = "update a device's name / prefs.",
144 params(("id" = String, Path)),
145 request_body = serde_json::Value,
146 responses((status = 200, description = "OK", body = serde_json::Value))
147)]
148pub async fn update_device(
149 State(ctx): State<HardwareCtx>,
150 Path(id): Path<String>,
151 Json(body): Json<DeviceUpdate>,
152) -> (StatusCode, Json<serde_json::Value>) {
153 match ctx.hardware.update(&id, body.name, body.prefs).await {
154 Ok(true) => match ctx.hardware.get(&id).await {
155 Ok(Some(record)) => (
156 StatusCode::OK,
157 Json(json!({ "device": to_list_item(&record) })),
158 ),
159 _ => (StatusCode::OK, Json(json!({ "ok": true }))),
160 },
161 Ok(false) => (
162 StatusCode::NOT_FOUND,
163 Json(json!({ "error": "device not found" })),
164 ),
165 Err(e) => (
166 StatusCode::INTERNAL_SERVER_ERROR,
167 Json(json!({ "error": e.to_string() })),
168 ),
169 }
170}
171
172#[utoipa::path(
174 delete,
175 path = "/api/hardware/devices/{id}",
176 tag = "Hardware",
177 summary = "revoke a device (delete it + its token).",
178 params(("id" = String, Path)),
179 responses((status = 200, description = "OK", body = serde_json::Value))
180)]
181pub async fn delete_device(
182 State(ctx): State<HardwareCtx>,
183 Path(id): Path<String>,
184) -> (StatusCode, Json<serde_json::Value>) {
185 ctx.dashboards.delete_device(&id).await;
187 match ctx.hardware.revoke(&id).await {
188 Ok(true) => (StatusCode::OK, Json(json!({ "ok": true }))),
189 Ok(false) => (
190 StatusCode::NOT_FOUND,
191 Json(json!({ "error": "device not found" })),
192 ),
193 Err(e) => (
194 StatusCode::INTERNAL_SERVER_ERROR,
195 Json(json!({ "error": e.to_string() })),
196 ),
197 }
198}
199
200fn bearer_token(headers: &HeaderMap) -> Option<String> {
204 headers
205 .get(header::AUTHORIZATION)
206 .and_then(|v| v.to_str().ok())
207 .and_then(|v| v.strip_prefix("Bearer "))
208 .map(str::to_string)
209}
210
211async fn device_authorized(ctx: &HardwareCtx, device_id: &str, headers: &HeaderMap) -> bool {
218 let Some(token) = bearer_token(headers) else {
219 return false;
220 };
221 if let Ok(shared) = std::env::var("RYU_TOKEN") {
223 if !shared.is_empty() && token == shared {
224 return true;
225 }
226 }
227 ctx.hardware
228 .verify_token(device_id, &token)
229 .await
230 .unwrap_or(false)
231}
232
233#[utoipa::path(
237 get,
238 path = "/api/hardware/display/{device_id}",
239 tag = "Hardware",
240 summary = "the display manifest. Returns the",
241 params(("device_id" = String, Path)),
242 responses((status = 200, description = "OK", body = serde_json::Value))
243)]
244pub async fn display_manifest(
245 State(ctx): State<HardwareCtx>,
246 Path(device_id): Path<String>,
247 headers: HeaderMap,
248) -> Response {
249 if !device_authorized(&ctx, &device_id, &headers).await {
250 return (
251 StatusCode::UNAUTHORIZED,
252 Json(json!({ "error": "unauthorized" })),
253 )
254 .into_response();
255 }
256 let record = match ctx.hardware.get(&device_id).await {
257 Ok(Some(r)) => r,
258 Ok(None) => {
259 return (
260 StatusCode::NOT_FOUND,
261 Json(json!({ "error": "device not found" })),
262 )
263 .into_response()
264 }
265 Err(e) => {
266 return (
267 StatusCode::INTERNAL_SERVER_ERROR,
268 Json(json!({ "error": e.to_string() })),
269 )
270 .into_response()
271 }
272 };
273 match ctx
274 .dashboards
275 .device_manifest(
276 &device_id,
277 &record.name,
278 device_type_str(record.device_type),
279 &record.prefs,
280 )
281 .await
282 {
283 Ok(m) => {
284 let s = &m.screen;
285 let rev = m.rev;
286 (
287 StatusCode::OK,
288 Json(json!({
289 "image_url": format!("/api/hardware/display/{device_id}/image?rev={rev}"),
290 "rev": rev,
291 "refresh_rate": m.refresh_rate,
292 "screen": {
293 "w": s.w,
294 "h": s.h,
295 "bit_depth": s.bit_depth,
296 "palette": s.palette,
297 "rotation": s.rotation,
298 },
299 })),
300 )
301 .into_response()
302 }
303 Err(e) => (
304 StatusCode::INTERNAL_SERVER_ERROR,
305 Json(json!({ "error": e })),
306 )
307 .into_response(),
308 }
309}
310
311#[derive(Debug, Deserialize)]
314pub struct ImageQuery {
315 #[serde(default)]
316 pub rev: Option<String>,
317}
318
319#[utoipa::path(
323 get,
324 path = "/api/hardware/display/{device_id}/image",
325 tag = "Hardware",
326 summary = "the rendered image bytes",
327 params(("device_id" = String, Path)),
328 responses((status = 200, description = "OK", body = serde_json::Value))
329)]
330pub async fn display_image(
331 State(ctx): State<HardwareCtx>,
332 Path(device_id): Path<String>,
333 Query(q): Query<ImageQuery>,
334 headers: HeaderMap,
335) -> Response {
336 if !device_authorized(&ctx, &device_id, &headers).await {
337 return (StatusCode::UNAUTHORIZED, "unauthorized").into_response();
338 }
339 let record = match ctx.hardware.get(&device_id).await {
340 Ok(Some(r)) => r,
341 Ok(None) => return (StatusCode::NOT_FOUND, "device not found").into_response(),
342 Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
343 };
344 match ctx
345 .dashboards
346 .device_image(
347 &device_id,
348 &record.name,
349 device_type_str(record.device_type),
350 &record.prefs,
351 q.rev.as_deref(),
352 )
353 .await
354 {
355 Ok(None) => StatusCode::NOT_MODIFIED.into_response(),
357 Ok(Some(image)) => (
358 StatusCode::OK,
359 [
360 (header::CONTENT_TYPE, image.content_type),
361 (header::ETAG, format!("\"{}\"", image.rev)),
362 (header::CACHE_CONTROL, "no-cache".to_string()),
363 ],
364 image.bytes,
365 )
366 .into_response(),
367 Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
368 }
369}
370
371#[utoipa::path(
374 get,
375 path = "/api/hardware/devices/{id}/dashboard",
376 tag = "Hardware",
377 summary = "the device's dashboard config",
378 params(("id" = String, Path)),
379 responses((status = 200, description = "OK", body = serde_json::Value))
380)]
381pub async fn get_device_dashboard(
382 State(ctx): State<HardwareCtx>,
383 Path(id): Path<String>,
384) -> (StatusCode, Json<serde_json::Value>) {
385 let record = match ctx.hardware.get(&id).await {
386 Ok(Some(r)) => r,
387 Ok(None) => {
388 return (
389 StatusCode::NOT_FOUND,
390 Json(json!({ "error": "device not found" })),
391 )
392 }
393 Err(e) => {
394 return (
395 StatusCode::INTERNAL_SERVER_ERROR,
396 Json(json!({ "error": e.to_string() })),
397 )
398 }
399 };
400 match ctx
401 .dashboards
402 .device_config(
403 &id,
404 &record.name,
405 device_type_str(record.device_type),
406 &record.prefs,
407 )
408 .await
409 {
410 Ok(config) => (StatusCode::OK, Json(config)),
411 Err(e) => (
412 StatusCode::INTERNAL_SERVER_ERROR,
413 Json(json!({ "error": e })),
414 ),
415 }
416}
417
418#[derive(Debug, Deserialize)]
422pub struct DeviceDashboardUpdate {
423 #[serde(default)]
424 pub refresh_rate: Option<u32>,
425 #[serde(default)]
426 pub widgets: Option<serde_json::Value>,
427}
428
429#[utoipa::path(
434 put,
435 path = "/api/hardware/devices/{id}/dashboard",
436 tag = "Hardware",
437 summary = "set the device's poll interval and/or",
438 params(("id" = String, Path)),
439 request_body = serde_json::Value,
440 responses((status = 200, description = "OK", body = serde_json::Value))
441)]
442pub async fn set_device_dashboard(
443 State(ctx): State<HardwareCtx>,
444 Path(id): Path<String>,
445 Json(body): Json<DeviceDashboardUpdate>,
446) -> (StatusCode, Json<serde_json::Value>) {
447 let record = match ctx.hardware.get(&id).await {
448 Ok(Some(r)) => r,
449 Ok(None) => {
450 return (
451 StatusCode::NOT_FOUND,
452 Json(json!({ "error": "device not found" })),
453 )
454 }
455 Err(e) => {
456 return (
457 StatusCode::INTERNAL_SERVER_ERROR,
458 Json(json!({ "error": e.to_string() })),
459 )
460 }
461 };
462
463 let result = match ctx
464 .dashboards
465 .set_device_config(&id, &record.name, body.refresh_rate, body.widgets)
466 .await
467 {
468 Ok(r) => r,
469 Err(e) => return (StatusCode::BAD_REQUEST, Json(json!({ "error": e }))),
472 };
473
474 nudge_device_display(&record, "dashboard").await;
476
477 (
478 StatusCode::OK,
479 Json(json!({
480 "ok": true,
481 "dashboard_id": result.dashboard_id,
482 "refresh_rate": result.refresh_rate,
483 })),
484 )
485}
486
487pub async fn nudge_device_display(record: &DeviceRecord, widget: &str) {
492 use crate::protocol::{RhpServerMsg, Surface};
493 let surface = match record.device_type {
494 DeviceType::Watch => Surface::Lcd,
495 _ => Surface::Eink,
496 };
497 crate::session::live::send(
498 &record.device_id,
499 RhpServerMsg::Display {
500 surface,
501 widget: widget.to_string(),
502 payload: json!({ "action": "repoll" }),
503 },
504 )
505 .await;
506}