Skip to main content

ryu_hardware/
api.rs

1//! HTTP API for the hardware device registry (`/api/hardware/*`, PROTOCOL.md §6).
2//!
3//! - `POST   /api/hardware/pair`         — verify the pairing nonce, register the
4//!   device, return its one-time `device_token` + `node_url`.
5//! - `GET    /api/hardware/devices`      — list paired devices (presence + battery).
6//! - `PATCH  /api/hardware/devices/:id`  — rename / update prefs.
7//! - `DELETE /api/hardware/devices/:id`  — revoke (delete the device + its token).
8//!
9//! ## Auth split (flagged for the router wiring)
10//!
11//! `pair` is **public**: the proof-of-possession is the pairing nonce shown
12//! out-of-band on the device (QR / BLE), and the companion app may hold only a
13//! better-auth session, not the node's `RYU_TOKEN`. `devices` list/patch/delete
14//! are **management** routes and sit behind `require_auth` with the rest of the
15//! protected surface.
16//!
17//! Placement (Core vs Gateway): the registry decides *which device may drive this
18//! node*, so it is Core.
19
20use 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/// Router state for the hardware HTTP surface: the device registry ([`DeviceStore`])
37/// + the [`DashboardFeed`] the TRMNL display render + per-device dashboard binding
38/// reach through. The feed inverts the old direct `ryu_dashboards` coupling, so
39/// this surface has ZERO dependency on the dashboards crate (Core supplies an
40/// in-process or sidecar-backed impl).
41#[derive(Clone)]
42pub struct HardwareCtx {
43    pub hardware: DeviceStore,
44    pub dashboards: Arc<dyn DashboardFeed>,
45}
46
47/// Build the PROTECTED device-registry CRUD router (relative paths, state baked in),
48/// returning a state-less `Router<()>` the host nests at `/api/hardware/devices`
49/// behind the Hardware App gate. These are management routes (desktop +
50/// `dashboard_builder`); the host mounts them INSIDE `require_auth`.
51pub 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
65/// Build the PUBLIC TRMNL display router (relative paths, state baked in), returning
66/// a state-less `Router<()>` the host nests at `/api/hardware/display` on the public
67/// router. A device polls these with its OWN per-device Bearer token (which the
68/// global-`RYU_TOKEN` `require_auth` cannot gate), so each handler authenticates the
69/// device token against the registry itself — hence public, ungated.
70pub 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
77/// The OpenAPI sub-document for the hardware device-registry + display surface,
78/// merged into Core's spec. The public ws/pair ingress keeps its own annotations in
79/// `apps/core` (see `server::hardware_ws` / `server::hardware_public`).
80pub 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
96/// A device is considered "online" if it was seen within this window (ms). The WS
97/// handler `touch`es the row on connect + every telemetry frame.
98const ONLINE_WINDOW_MS: i64 = 90_000;
99
100/// Map a stored [`DeviceRecord`] to the REST [`DeviceListItem`] wire shape.
101fn 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/// `GET /api/hardware/devices` — list paired devices with presence + battery.
118#[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/// `PATCH /api/hardware/devices/:id` — update a device's name / prefs.
141#[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/// `DELETE /api/hardware/devices/:id` — revoke a device (delete it + its token).
175#[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    // Also drop the device's dashboard binding so a re-paired id starts clean.
188    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
202// ── Dashboard display surface (TRMNL model, apps/hardware/DASHBOARD.md) ───────
203
204/// Extract a `Bearer` device token from the upgrade/request headers.
205fn 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
213/// Verify the device's own Bearer token against the registry. The display routes
214/// are on the **public** router (a device presents a per-device token, which the
215/// global-`RYU_TOKEN` `require_auth` would reject), so each handler authenticates
216/// the device token here — the same model the WS upgrade uses (PROTOCOL.md §2).
217/// Loopback self-calls (the desktop preview, the nudge loop's own render) present
218/// the shared `RYU_TOKEN` instead and are allowed through.
219async fn device_authorized(ctx: &HardwareCtx, device_id: &str, headers: &HeaderMap) -> bool {
220    let Some(token) = bearer_token(headers) else {
221        return false;
222    };
223    // A management caller (desktop) may present the node's shared token.
224    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/// `GET /api/hardware/display/:device_id` — the display manifest. Returns the
237/// content hash (`rev`), the poll interval, the screen geometry, and the image URL
238/// the device should fetch. The device skips re-downloading when `rev` is unchanged.
239#[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/// Query for the image endpoint: the `rev` the device already holds (so an
315/// unchanged image returns `304 Not Modified` and saves the download).
316#[derive(Debug, Deserialize)]
317pub struct ImageQuery {
318    #[serde(default)]
319    pub rev: Option<String>,
320}
321
322/// `GET /api/hardware/display/:device_id/image?rev=` — the rendered image bytes
323/// (packed 1-bit for e-ink, RGB565, or PNG). Returns `304` when the device's `rev`
324/// matches the freshly-rendered content hash.
325#[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        // `None` ⇒ the device's `rev` still matches the freshly-rendered content.
359        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/// `GET /api/hardware/devices/:id/dashboard` — the device's dashboard config
375/// (the binding + the bound dashboard's widgets).
376#[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/// Request body for `PUT /api/hardware/devices/:id/dashboard`. Any field may be
422/// omitted; only the present ones are applied. `widgets` (when present) **replaces**
423/// the bound dashboard's widget set (the device-scoped analog of the desktop grid).
424#[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/// `PUT /api/hardware/devices/:id/dashboard` — set the device's poll interval and/or
433/// replace its widget selection + layout. Reuses the dashboard store so the same
434/// widgets the desktop builder authors render on the device. Pushes a `display`
435/// nudge so a connected device re-polls immediately.
436#[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        // A bad widget batch is a client error (the feed validates the source
473        // allowlist); everything else is a store failure.
474        Err(e) => {
475            return (
476                StatusCode::BAD_REQUEST,
477                Json(json!({ "error": e })),
478            )
479        }
480    };
481
482    // Nudge: tell a connected device its dashboard changed so it re-polls now.
483    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
495/// Send the RHP `display` re-poll signal to a connected device over its live WS.
496/// Best-effort: a no-op when the device is offline (it will poll on its own cadence).
497/// The surface (`eink`/`lcd`) is derived from the device class so the firmware knows
498/// which panel to refresh.
499pub 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}