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(
126 State(ctx): State<HardwareCtx>,
127) -> (StatusCode, Json<serde_json::Value>) {
128 match ctx.hardware.list().await {
129 Ok(records) => {
130 let items: Vec<DeviceListItem> = records.iter().map(to_list_item).collect();
131 (StatusCode::OK, Json(json!({ "devices": items })))
132 }
133 Err(e) => (
134 StatusCode::INTERNAL_SERVER_ERROR,
135 Json(json!({ "devices": [], "error": e.to_string() })),
136 ),
137 }
138}
139
140#[utoipa::path(
142 patch,
143 path = "/api/hardware/devices/{id}",
144 tag = "Hardware",
145 summary = "update a device's name / prefs.",
146 params(("id" = String, Path)),
147 request_body = serde_json::Value,
148 responses((status = 200, description = "OK", body = serde_json::Value))
149)]
150pub async fn update_device(
151 State(ctx): State<HardwareCtx>,
152 Path(id): Path<String>,
153 Json(body): Json<DeviceUpdate>,
154) -> (StatusCode, Json<serde_json::Value>) {
155 match ctx.hardware.update(&id, body.name, body.prefs).await {
156 Ok(true) => match ctx.hardware.get(&id).await {
157 Ok(Some(record)) => (
158 StatusCode::OK,
159 Json(json!({ "device": to_list_item(&record) })),
160 ),
161 _ => (StatusCode::OK, Json(json!({ "ok": true }))),
162 },
163 Ok(false) => (
164 StatusCode::NOT_FOUND,
165 Json(json!({ "error": "device not found" })),
166 ),
167 Err(e) => (
168 StatusCode::INTERNAL_SERVER_ERROR,
169 Json(json!({ "error": e.to_string() })),
170 ),
171 }
172}
173
174#[utoipa::path(
176 delete,
177 path = "/api/hardware/devices/{id}",
178 tag = "Hardware",
179 summary = "revoke a device (delete it + its token).",
180 params(("id" = String, Path)),
181 responses((status = 200, description = "OK", body = serde_json::Value))
182)]
183pub async fn delete_device(
184 State(ctx): State<HardwareCtx>,
185 Path(id): Path<String>,
186) -> (StatusCode, Json<serde_json::Value>) {
187 ctx.dashboards.delete_device(&id).await;
189 match ctx.hardware.revoke(&id).await {
190 Ok(true) => (StatusCode::OK, Json(json!({ "ok": true }))),
191 Ok(false) => (
192 StatusCode::NOT_FOUND,
193 Json(json!({ "error": "device not found" })),
194 ),
195 Err(e) => (
196 StatusCode::INTERNAL_SERVER_ERROR,
197 Json(json!({ "error": e.to_string() })),
198 ),
199 }
200}
201
202fn bearer_token(headers: &HeaderMap) -> Option<String> {
206 headers
207 .get(header::AUTHORIZATION)
208 .and_then(|v| v.to_str().ok())
209 .and_then(|v| v.strip_prefix("Bearer "))
210 .map(str::to_string)
211}
212
213async fn device_authorized(ctx: &HardwareCtx, device_id: &str, headers: &HeaderMap) -> bool {
220 let Some(token) = bearer_token(headers) else {
221 return false;
222 };
223 if let Ok(shared) = std::env::var("RYU_TOKEN") {
225 if !shared.is_empty() && token == shared {
226 return true;
227 }
228 }
229 ctx
230 .hardware
231 .verify_token(device_id, &token)
232 .await
233 .unwrap_or(false)
234}
235
236#[utoipa::path(
240 get,
241 path = "/api/hardware/display/{device_id}",
242 tag = "Hardware",
243 summary = "the display manifest. Returns the",
244 params(("device_id" = String, Path)),
245 responses((status = 200, description = "OK", body = serde_json::Value))
246)]
247pub async fn display_manifest(
248 State(ctx): State<HardwareCtx>,
249 Path(device_id): Path<String>,
250 headers: HeaderMap,
251) -> Response {
252 if !device_authorized(&ctx, &device_id, &headers).await {
253 return (
254 StatusCode::UNAUTHORIZED,
255 Json(json!({ "error": "unauthorized" })),
256 )
257 .into_response();
258 }
259 let record = match ctx.hardware.get(&device_id).await {
260 Ok(Some(r)) => r,
261 Ok(None) => {
262 return (
263 StatusCode::NOT_FOUND,
264 Json(json!({ "error": "device not found" })),
265 )
266 .into_response()
267 }
268 Err(e) => {
269 return (
270 StatusCode::INTERNAL_SERVER_ERROR,
271 Json(json!({ "error": e.to_string() })),
272 )
273 .into_response()
274 }
275 };
276 match ctx
277 .dashboards
278 .device_manifest(
279 &device_id,
280 &record.name,
281 device_type_str(record.device_type),
282 &record.prefs,
283 )
284 .await
285 {
286 Ok(m) => {
287 let s = &m.screen;
288 let rev = m.rev;
289 (
290 StatusCode::OK,
291 Json(json!({
292 "image_url": format!("/api/hardware/display/{device_id}/image?rev={rev}"),
293 "rev": rev,
294 "refresh_rate": m.refresh_rate,
295 "screen": {
296 "w": s.w,
297 "h": s.h,
298 "bit_depth": s.bit_depth,
299 "palette": s.palette,
300 "rotation": s.rotation,
301 },
302 })),
303 )
304 .into_response()
305 }
306 Err(e) => (
307 StatusCode::INTERNAL_SERVER_ERROR,
308 Json(json!({ "error": e })),
309 )
310 .into_response(),
311 }
312}
313
314#[derive(Debug, Deserialize)]
317pub struct ImageQuery {
318 #[serde(default)]
319 pub rev: Option<String>,
320}
321
322#[utoipa::path(
326 get,
327 path = "/api/hardware/display/{device_id}/image",
328 tag = "Hardware",
329 summary = "the rendered image bytes",
330 params(("device_id" = String, Path)),
331 responses((status = 200, description = "OK", body = serde_json::Value))
332)]
333pub async fn display_image(
334 State(ctx): State<HardwareCtx>,
335 Path(device_id): Path<String>,
336 Query(q): Query<ImageQuery>,
337 headers: HeaderMap,
338) -> Response {
339 if !device_authorized(&ctx, &device_id, &headers).await {
340 return (StatusCode::UNAUTHORIZED, "unauthorized").into_response();
341 }
342 let record = match ctx.hardware.get(&device_id).await {
343 Ok(Some(r)) => r,
344 Ok(None) => return (StatusCode::NOT_FOUND, "device not found").into_response(),
345 Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
346 };
347 match ctx
348 .dashboards
349 .device_image(
350 &device_id,
351 &record.name,
352 device_type_str(record.device_type),
353 &record.prefs,
354 q.rev.as_deref(),
355 )
356 .await
357 {
358 Ok(None) => StatusCode::NOT_MODIFIED.into_response(),
360 Ok(Some(image)) => (
361 StatusCode::OK,
362 [
363 (header::CONTENT_TYPE, image.content_type),
364 (header::ETAG, format!("\"{}\"", image.rev)),
365 (header::CACHE_CONTROL, "no-cache".to_string()),
366 ],
367 image.bytes,
368 )
369 .into_response(),
370 Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
371 }
372}
373
374#[utoipa::path(
377 get,
378 path = "/api/hardware/devices/{id}/dashboard",
379 tag = "Hardware",
380 summary = "the device's dashboard config",
381 params(("id" = String, Path)),
382 responses((status = 200, description = "OK", body = serde_json::Value))
383)]
384pub async fn get_device_dashboard(
385 State(ctx): State<HardwareCtx>,
386 Path(id): Path<String>,
387) -> (StatusCode, Json<serde_json::Value>) {
388 let record = match ctx.hardware.get(&id).await {
389 Ok(Some(r)) => r,
390 Ok(None) => {
391 return (
392 StatusCode::NOT_FOUND,
393 Json(json!({ "error": "device not found" })),
394 )
395 }
396 Err(e) => {
397 return (
398 StatusCode::INTERNAL_SERVER_ERROR,
399 Json(json!({ "error": e.to_string() })),
400 )
401 }
402 };
403 match ctx
404 .dashboards
405 .device_config(
406 &id,
407 &record.name,
408 device_type_str(record.device_type),
409 &record.prefs,
410 )
411 .await
412 {
413 Ok(config) => (StatusCode::OK, Json(config)),
414 Err(e) => (
415 StatusCode::INTERNAL_SERVER_ERROR,
416 Json(json!({ "error": e })),
417 ),
418 }
419}
420
421#[derive(Debug, Deserialize)]
425pub struct DeviceDashboardUpdate {
426 #[serde(default)]
427 pub refresh_rate: Option<u32>,
428 #[serde(default)]
429 pub widgets: Option<serde_json::Value>,
430}
431
432#[utoipa::path(
437 put,
438 path = "/api/hardware/devices/{id}/dashboard",
439 tag = "Hardware",
440 summary = "set the device's poll interval and/or",
441 params(("id" = String, Path)),
442 request_body = serde_json::Value,
443 responses((status = 200, description = "OK", body = serde_json::Value))
444)]
445pub async fn set_device_dashboard(
446 State(ctx): State<HardwareCtx>,
447 Path(id): Path<String>,
448 Json(body): Json<DeviceDashboardUpdate>,
449) -> (StatusCode, Json<serde_json::Value>) {
450 let record = match ctx.hardware.get(&id).await {
451 Ok(Some(r)) => r,
452 Ok(None) => {
453 return (
454 StatusCode::NOT_FOUND,
455 Json(json!({ "error": "device not found" })),
456 )
457 }
458 Err(e) => {
459 return (
460 StatusCode::INTERNAL_SERVER_ERROR,
461 Json(json!({ "error": e.to_string() })),
462 )
463 }
464 };
465
466 let result = match ctx
467 .dashboards
468 .set_device_config(&id, &record.name, body.refresh_rate, body.widgets)
469 .await
470 {
471 Ok(r) => r,
472 Err(e) => {
475 return (
476 StatusCode::BAD_REQUEST,
477 Json(json!({ "error": e })),
478 )
479 }
480 };
481
482 nudge_device_display(&record, "dashboard").await;
484
485 (
486 StatusCode::OK,
487 Json(json!({
488 "ok": true,
489 "dashboard_id": result.dashboard_id,
490 "refresh_rate": result.refresh_rate,
491 })),
492 )
493}
494
495pub async fn nudge_device_display(record: &DeviceRecord, widget: &str) {
500 use crate::protocol::{RhpServerMsg, Surface};
501 let surface = match record.device_type {
502 DeviceType::Watch => Surface::Lcd,
503 _ => Surface::Eink,
504 };
505 crate::session::live::send(
506 &record.device_id,
507 RhpServerMsg::Display {
508 surface,
509 widget: widget.to_string(),
510 payload: json!({ "action": "repoll" }),
511 },
512 )
513 .await;
514}