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(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/// `PATCH /api/hardware/devices/:id` — update a device's name / prefs.
139#[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/// `DELETE /api/hardware/devices/:id` — revoke a device (delete it + its token).
173#[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    // Also drop the device's dashboard binding so a re-paired id starts clean.
186    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
200// ── Dashboard display surface (TRMNL model, apps/hardware/DASHBOARD.md) ───────
201
202/// Extract a `Bearer` device token from the upgrade/request headers.
203fn 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
211/// Verify the device's own Bearer token against the registry. The display routes
212/// are on the **public** router (a device presents a per-device token, which the
213/// global-`RYU_TOKEN` `require_auth` would reject), so each handler authenticates
214/// the device token here — the same model the WS upgrade uses (PROTOCOL.md §2).
215/// Loopback self-calls (the desktop preview, the nudge loop's own render) present
216/// the shared `RYU_TOKEN` instead and are allowed through.
217async fn device_authorized(ctx: &HardwareCtx, device_id: &str, headers: &HeaderMap) -> bool {
218    let Some(token) = bearer_token(headers) else {
219        return false;
220    };
221    // A management caller (desktop) may present the node's shared token.
222    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/// `GET /api/hardware/display/:device_id` — the display manifest. Returns the
234/// content hash (`rev`), the poll interval, the screen geometry, and the image URL
235/// the device should fetch. The device skips re-downloading when `rev` is unchanged.
236#[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/// Query for the image endpoint: the `rev` the device already holds (so an
312/// unchanged image returns `304 Not Modified` and saves the download).
313#[derive(Debug, Deserialize)]
314pub struct ImageQuery {
315    #[serde(default)]
316    pub rev: Option<String>,
317}
318
319/// `GET /api/hardware/display/:device_id/image?rev=` — the rendered image bytes
320/// (packed 1-bit for e-ink, RGB565, or PNG). Returns `304` when the device's `rev`
321/// matches the freshly-rendered content hash.
322#[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        // `None` ⇒ the device's `rev` still matches the freshly-rendered content.
356        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/// `GET /api/hardware/devices/:id/dashboard` — the device's dashboard config
372/// (the binding + the bound dashboard's widgets).
373#[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/// Request body for `PUT /api/hardware/devices/:id/dashboard`. Any field may be
419/// omitted; only the present ones are applied. `widgets` (when present) **replaces**
420/// the bound dashboard's widget set (the device-scoped analog of the desktop grid).
421#[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/// `PUT /api/hardware/devices/:id/dashboard` — set the device's poll interval and/or
430/// replace its widget selection + layout. Reuses the dashboard store so the same
431/// widgets the desktop builder authors render on the device. Pushes a `display`
432/// nudge so a connected device re-polls immediately.
433#[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        // A bad widget batch is a client error (the feed validates the source
470        // allowlist); everything else is a store failure.
471        Err(e) => return (StatusCode::BAD_REQUEST, Json(json!({ "error": e }))),
472    };
473
474    // Nudge: tell a connected device its dashboard changed so it re-polls now.
475    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
487/// Send the RHP `display` re-poll signal to a connected device over its live WS.
488/// Best-effort: a no-op when the device is offline (it will poll on its own cadence).
489/// The surface (`eink`/`lcd`) is derived from the device class so the firmware knows
490/// which panel to refresh.
491pub 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}