diff --git a/crates/hypercolor-daemon/src/api/attachments.rs b/crates/hypercolor-daemon/src/api/attachments.rs index b9ceaed3a..71102a71f 100644 --- a/crates/hypercolor-daemon/src/api/attachments.rs +++ b/crates/hypercolor-daemon/src/api/attachments.rs @@ -22,47 +22,15 @@ use crate::api::devices::Pagination; use crate::api::envelope::ApiResponse; use crate::domain::{DomainError, ResourceKind}; -pub use hypercolor_types::api::attachments::ListTemplatesQuery; - -#[derive(Debug, Serialize)] -pub struct TemplateListResponse { - pub items: Vec, - pub pagination: Pagination, -} - -#[derive(Debug, Clone, Serialize)] -pub struct TemplateSummary { - pub id: String, - pub name: String, - pub vendor: String, - pub category: ComponentCategory, - pub origin: ComponentOrigin, - pub led_count: u32, - pub description: String, - pub image_url: Option, - pub tags: Vec, -} - -#[derive(Debug, Serialize)] -pub struct TemplateDetail { - pub id: String, - pub name: String, - pub vendor: String, - pub category: ComponentCategory, - pub origin: ComponentOrigin, - pub led_count: u32, - pub description: String, - pub default_size: hypercolor_types::attachment::ComponentCanvasSize, - pub topology: hypercolor_types::spatial::LedTopology, - pub led_positions: Vec, - pub compatible_slots: Vec, - pub tags: Vec, - pub led_names: Option>, - pub led_mapping: Option>, - pub image_url: Option, - pub physical_size_mm: Option<(f32, f32)>, -} +// Wire contracts live in hypercolor-types::api::attachments — shared +// with the web UI and the TUI. +pub use hypercolor_types::api::attachments::{ + ListTemplatesQuery, TemplateDetail, TemplateListResponse, TemplateSummary, +}; +// The category and vendor facets and the per-template item routes are not +// in spec 78's Appendix A, so their shapes stay daemon-local rather than +// entering the shared contract on the way to deletion. #[derive(Debug, Serialize)] pub struct CategoryListResponse { pub items: Vec, diff --git a/crates/hypercolor-daemon/src/api/effects.rs b/crates/hypercolor-daemon/src/api/effects.rs index ed13f605d..be43c2cae 100644 --- a/crates/hypercolor-daemon/src/api/effects.rs +++ b/crates/hypercolor-daemon/src/api/effects.rs @@ -10,7 +10,6 @@ use axum::http::{HeaderMap, HeaderValue, header}; use axum::response::{IntoResponse, Response}; use base64::Engine as _; use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; -use serde::Serialize; use tokio::fs; use tracing::{info, warn}; @@ -64,10 +63,11 @@ pub(crate) async fn invalidate_active_render_groups_after_effect_registry_update // web UI and the TUI. pub use hypercolor_types::api::effects::{ ActiveEffectResponse, ApplyEffectPresetRequest, ApplyEffectRequest, ApplyEffectResponse, - ApplyTransitionResponse, EffectCapabilitySet, EffectDetailResponse, EffectLayoutApplyResult, - EffectListResponse, EffectPresetListResponse, EffectPresetOrigin, EffectPresetSummary, - EffectRefSummary, EffectSummary, InstalledEffectResponse, LayoutLinkSummary, - PauseEffectResponse, ResetControlsRequest, ResumeEffectResponse, TransitionRequest, + ApplyTransitionResponse, DeleteEffectLayoutResponse, EffectCapabilitySet, EffectDetailResponse, + EffectLayoutApplyResult, EffectLayoutResponse, EffectListResponse, EffectPresetListResponse, + EffectPresetOrigin, EffectPresetSummary, EffectRefSummary, EffectSummary, + InstalledEffectResponse, LayoutLinkSummary, PauseEffectResponse, RescanResponse, + ResetControlsRequest, ResumeEffectResponse, SetEffectLayoutResponse, TransitionRequest, UpdateActiveControlsRequest, }; @@ -499,15 +499,15 @@ pub async fn get_effect_layout( }; let summary = layout.as_ref().map(layout_link_summary); - ApiResponse::ok(serde_json::json!({ - "effect": { - "id": effect_id, - "name": effect.name, + ApiResponse::ok(EffectLayoutResponse { + effect: EffectRefSummary { + id: effect_id, + name: effect.name, }, - "layout_id": layout_id, - "resolved": summary.is_some(), - "layout": summary, - })) + layout_id, + resolved: summary.is_some(), + layout: summary, + }) } /// `PUT /api/v1/effects/:id/layout` — Associate an effect with a layout. @@ -572,14 +572,14 @@ pub async fn set_effect_layout( return DomainError::Internal(anyhow::anyhow!(error)).into_response(); } - ApiResponse::ok(serde_json::json!({ - "effect": { - "id": effect_id, - "name": effect.name, + ApiResponse::ok(SetEffectLayoutResponse { + effect: EffectRefSummary { + id: effect_id, + name: effect.name, }, - "layout": layout_link_summary(&layout), - "linked": true, - })) + layout: layout_link_summary(&layout), + linked: true, + }) } /// `DELETE /api/v1/effects/:id/layout` — Remove an effect -> layout association. @@ -623,14 +623,14 @@ pub async fn delete_effect_layout( return DomainError::Internal(anyhow::anyhow!(error)).into_response(); } - ApiResponse::ok(serde_json::json!({ - "effect": { - "id": effect_id, - "name": effect.name, + ApiResponse::ok(DeleteEffectLayoutResponse { + effect: EffectRefSummary { + id: effect_id, + name: effect.name, }, - "layout_id": removed_layout_id, - "deleted": removed_layout_id.is_some(), - })) + deleted: removed_layout_id.is_some(), + layout_id: removed_layout_id, + }) } /// `POST /api/v1/effects/:id/apply` — Start rendering an effect. @@ -1001,6 +1001,10 @@ pub async fn update_active_controls( ); } + // Held back from the wave 3.1c type promotion deliberately: `applied` + // carries f32 control values, and `json!` widens them to f64 while a + // derived struct does not, so naming this shape would reprint every + // non-representable float (0.1 -> 0.10000000149011612 today). ApiResponse::ok(serde_json::json!({ "effect": effect_name, "applied": applied, @@ -1109,6 +1113,7 @@ pub async fn update_effect_controls( ); } + // Held back for the same f32 reprint reason as its `active` sibling. let body = ApiResponse::ok(serde_json::json!({ "effect": effect_name, "applied": applied, @@ -1218,6 +1223,9 @@ pub async fn set_active_control_binding( Err(error) => return error.into_response(), } + // Held back: `ControlBinding` is six f32 fields, which a named struct + // would reprint at f32 precision instead of the widened f64 form the + // literal has always emitted. ApiResponse::ok(serde_json::json!({ "effect": { "id": effect_id, @@ -1427,13 +1435,6 @@ pub async fn install_effect( }) } -#[derive(Debug, Serialize)] -pub struct RescanResponse { - pub added: usize, - pub removed: usize, - pub updated: usize, -} - pub(crate) fn resolve_effect_metadata( registry: &EffectRegistry, id_or_name: &str, diff --git a/crates/hypercolor-daemon/src/api/library/favorites.rs b/crates/hypercolor-daemon/src/api/library/favorites.rs index 840cf1301..5f1ce10a5 100644 --- a/crates/hypercolor-daemon/src/api/library/favorites.rs +++ b/crates/hypercolor-daemon/src/api/library/favorites.rs @@ -7,7 +7,6 @@ use axum::Json; use axum::extract::{Path, State}; use axum::response::{IntoResponse, Response}; use hypercolor_types::event::{HypercolorEvent, LibraryChangeKind, LibraryCollection}; -use serde::Serialize; use crate::api::AppState; use crate::api::effects::resolve_effect_metadata; @@ -16,22 +15,12 @@ use crate::domain::{DomainError, ResourceKind}; use super::unix_epoch_ms; -pub use hypercolor_types::api::library::AddFavoriteRequest; - -// ── Request / Response Types ──────────────────────────────────────────── - -#[derive(Debug, Serialize)] -pub struct FavoriteSummary { - pub effect_id: String, - pub effect_name: String, - pub added_at_ms: u64, -} - -#[derive(Debug, Serialize)] -pub struct FavoriteListResponse { - pub items: Vec, - pub pagination: crate::api::devices::Pagination, -} +// Wire contracts live in hypercolor-types::api::library — shared with +// the web UI and the TUI. +pub use hypercolor_types::api::library::{ + AddFavoriteRequest, AddFavoriteResponse, DeleteFavoriteResponse, FavoriteListResponse, + FavoriteSummary, +}; // ── Handlers ──────────────────────────────────────────────────────────── @@ -105,14 +94,14 @@ pub async fn add_favorite( kind: LibraryChangeKind::Upserted, }); - ApiResponse::ok(serde_json::json!({ - "favorite": FavoriteSummary { + ApiResponse::ok(AddFavoriteResponse { + favorite: FavoriteSummary { effect_id: favorite.effect_id.to_string(), effect_name: effect.name, added_at_ms: favorite.added_at_ms, }, - "created": !existing, - })) + created: !existing, + }) } /// `DELETE /api/v1/library/favorites/:effect` — remove a favorite by effect id/name. @@ -143,8 +132,8 @@ pub async fn remove_favorite( kind: LibraryChangeKind::Removed, }); - ApiResponse::ok(serde_json::json!({ - "effect_id": effect.id.to_string(), - "deleted": true, - })) + ApiResponse::ok(DeleteFavoriteResponse { + effect_id: effect.id.to_string(), + deleted: true, + }) } diff --git a/crates/hypercolor-daemon/src/api/library/playlists.rs b/crates/hypercolor-daemon/src/api/library/playlists.rs index d54561cf4..61cf7e0c3 100644 --- a/crates/hypercolor-daemon/src/api/library/playlists.rs +++ b/crates/hypercolor-daemon/src/api/library/playlists.rs @@ -7,7 +7,6 @@ use std::time::Duration; use axum::Json; use axum::extract::{Path, State}; use axum::response::{IntoResponse, Response}; -use serde::Serialize; use tokio::sync::watch; use tracing::warn; @@ -27,29 +26,16 @@ use super::{ store_error_to_response, unix_epoch_ms, }; +// Wire contracts live in hypercolor-types::api::library — shared with +// the web UI and the TUI. pub use hypercolor_types::api::library::{ - PlaylistItemRequest, PlaylistTargetRequest, SavePlaylistRequest, + ActivatePlaylistResponse, ActivePlaylistResponse, ActivePlaylistStateResponse, + DeletePlaylistResponse, PlaylistItemRequest, PlaylistListResponse, PlaylistTargetRequest, + SavePlaylistRequest, StopPlaylistResponse, }; const DEFAULT_PLAYLIST_ITEM_DURATION_MS: u64 = 30_000; -// ── Request / Response Types ──────────────────────────────────────────── - -#[derive(Debug, Serialize)] -pub struct PlaylistListResponse { - pub items: Vec, - pub pagination: crate::api::devices::Pagination, -} - -#[derive(Debug, Clone, Serialize)] -pub struct ActivePlaylistResponse { - pub id: String, - pub name: String, - pub loop_enabled: bool, - pub item_count: usize, - pub started_at_ms: u64, -} - // ── Handlers ──────────────────────────────────────────────────────────── /// `GET /api/v1/library/playlists` — list all playlists. @@ -216,10 +202,10 @@ pub async fn delete_playlist( }; stop_runtime(active); - ApiResponse::ok(serde_json::json!({ - "id": playlist_id.to_string(), - "deleted": true, - })) + ApiResponse::ok(DeletePlaylistResponse { + id: playlist_id.to_string(), + deleted: true, + }) } /// `POST /api/v1/library/playlists/:id/activate` — start playlist playback. @@ -284,10 +270,10 @@ pub async fn activate_playlist( runtime.active = Some(active); } - ApiResponse::ok(serde_json::json!({ - "playlist": response_payload, - "active": true, - })) + ApiResponse::ok(ActivatePlaylistResponse { + playlist: response_payload, + active: true, + }) } /// `GET /api/v1/library/playlists/active` — inspect the active playlist runtime. @@ -297,10 +283,10 @@ pub async fn get_active_playlist(State(state): State>) -> Response return DomainError::not_found(ResourceKind::Playlist, "active").into_response(); }; - ApiResponse::ok(serde_json::json!({ - "playlist": active_playlist_payload(active), - "state": "running", - })) + ApiResponse::ok(ActivePlaylistStateResponse { + playlist: active_playlist_payload(active), + state: "running".to_owned(), + }) } /// `POST /api/v1/library/playlists/stop` — stop playlist playback if active. @@ -316,10 +302,10 @@ pub async fn stop_playlist(State(state): State>) -> Response { let payload = active_playlist_payload(&active); stop_runtime(Some(active)); - ApiResponse::ok(serde_json::json!({ - "playlist": payload, - "stopped": true, - })) + ApiResponse::ok(StopPlaylistResponse { + playlist: payload, + stopped: true, + }) } // ── Helpers ───────────────────────────────────────────────────────────── diff --git a/crates/hypercolor-daemon/src/api/library/presets.rs b/crates/hypercolor-daemon/src/api/library/presets.rs index aa309e64b..b0d801982 100644 --- a/crates/hypercolor-daemon/src/api/library/presets.rs +++ b/crates/hypercolor-daemon/src/api/library/presets.rs @@ -6,7 +6,6 @@ use std::sync::Arc; use axum::Json; use axum::extract::{Path, State}; use axum::response::{IntoResponse, Response}; -use serde::Serialize; use hypercolor_types::effect::{ControlValue, EffectMetadata}; use hypercolor_types::event::{ @@ -26,15 +25,11 @@ use super::{ resolve_preset_id, store_error_to_response, unix_epoch_ms, }; -pub use hypercolor_types::api::library::{ApplyPresetRequest, SavePresetRequest}; - -// ── Request / Response Types ──────────────────────────────────────────── - -#[derive(Debug, Serialize)] -pub struct PresetListResponse { - pub items: Vec, - pub pagination: crate::api::devices::Pagination, -} +// Wire contracts live in hypercolor-types::api::library — shared with +// the web UI and the TUI. +pub use hypercolor_types::api::library::{ + ApplyPresetRequest, DeletePresetResponse, PresetListResponse, SavePresetRequest, +}; // ── Handlers ──────────────────────────────────────────────────────────── @@ -203,10 +198,10 @@ pub async fn delete_preset(State(state): State>, Path(id): Path, } -#[derive(Debug, Deserialize)] -struct FavoriteListResponse { - items: Vec, -} - -#[derive(Debug, Deserialize)] -struct FavoriteSummaryResponse { - effect_id: String, -} - #[derive(Debug, Deserialize)] struct SystemStatusResponse { running: bool, diff --git a/crates/hypercolor-types/src/api/attachments.rs b/crates/hypercolor-types/src/api/attachments.rs index 1696d0b1a..dfa72d41b 100644 --- a/crates/hypercolor-types/src/api/attachments.rs +++ b/crates/hypercolor-types/src/api/attachments.rs @@ -2,6 +2,27 @@ use serde::{Deserialize, Serialize}; +use crate::api::common::Pagination; +use crate::attachment::{ + ComponentCanvasSize, ComponentCategory, ComponentCompatibility, ComponentOrigin, +}; +use crate::spatial::{LedTopology, NormalizedPosition}; + +/// Accept an absent `origin`, an explicit `null`, or a real value. +/// +/// The daemon always sends a value, so this only widens what clients +/// tolerate. It exists because the hand-rolled web UI mirrors these +/// types replaced declared `origin` as an `Option` and +/// so decoded an explicit `null` happily. `#[serde(default)]` alone +/// covers the absent key but not a present null, which would make the +/// shared type stricter than the mirror it replaced. +fn origin_tolerating_null<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + Ok(Option::::deserialize(deserializer)?.unwrap_or_default()) +} + /// Query parameters for `GET /api/v1/attachments/templates`. /// /// Every field narrows the catalog; an empty query lists everything the @@ -33,3 +54,83 @@ pub struct ListTemplatesQuery { #[serde(default, skip_serializing_if = "Option::is_none")] pub led_max: Option, } + +/// Response for `GET /api/v1/attachments/templates`. +// `Eq` is load-bearing beyond equality: f32 is not `Eq`, so deriving it +// proves transitively that nothing in this response is a float. A float +// here would be a wire hazard, because the shapes these types replace +// were built with `json!`, which widens f32 to f64 and reprints it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TemplateListResponse { + #[serde(default)] + pub items: Vec, + #[serde(default)] + pub pagination: Pagination, +} + +/// One template in the catalog listing. +/// +/// `led_count` is the template's resolved LED total, derived from its +/// topology rather than stored, so it is always present even for +/// templates whose topology is generated. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TemplateSummary { + pub id: String, + pub name: String, + pub vendor: String, + pub category: ComponentCategory, + #[serde(default, deserialize_with = "origin_tolerating_null")] + pub origin: ComponentOrigin, + pub led_count: u32, + pub description: String, + #[serde(default)] + pub image_url: Option, + #[serde(default)] + pub tags: Vec, +} + +/// Response for `POST /api/v1/attachments/templates`, the created +/// template. +/// +/// The summary's fields plus everything needed to place the attachment: +/// `led_positions` is expanded from the topology at request time, so it +/// is present here but never in the listing. +/// +/// The item routes that also return this body today (`GET` and `PUT` on +/// `/attachments/templates/{id}`) are deleted in wave 78.5, which leaves +/// creation as the only caller. The type is chartered on creation for +/// that reason, and the collection listing keeps its own summary shape. +// Unlike its sibling responses this one cannot carry the `Eq` float +// fence: `physical_size_mm` is a genuine `(f32, f32)`. That is safe +// here because the shape was already a struct on the daemon side before +// the promotion, so it never passed through `serde_json::json!` and was +// never exposed to the f32-to-f64 reprint the fence guards against. Its +// serialization path is unchanged. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct TemplateDetail { + pub id: String, + pub name: String, + pub vendor: String, + pub category: ComponentCategory, + #[serde(default, deserialize_with = "origin_tolerating_null")] + pub origin: ComponentOrigin, + pub led_count: u32, + pub description: String, + pub default_size: ComponentCanvasSize, + pub topology: LedTopology, + #[serde(default)] + pub led_positions: Vec, + #[serde(default)] + pub compatible_slots: Vec, + #[serde(default)] + pub tags: Vec, + #[serde(default)] + pub led_names: Option>, + #[serde(default)] + pub led_mapping: Option>, + #[serde(default)] + pub image_url: Option, + /// Physical footprint in millimeters, as `[width, height]`. + #[serde(default)] + pub physical_size_mm: Option<(f32, f32)>, +} diff --git a/crates/hypercolor-types/src/api/common.rs b/crates/hypercolor-types/src/api/common.rs index f2edf2bd6..f102cb2a8 100644 --- a/crates/hypercolor-types/src/api/common.rs +++ b/crates/hypercolor-types/src/api/common.rs @@ -4,7 +4,12 @@ use serde::{Deserialize, Serialize}; use utoipa::ToSchema; /// Pagination envelope attached to every list response. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +// `Default` is the empty page, which lets list responses mark the field +// `#[serde(default)]` and keep parsing a body that omits the envelope. +// Kept out of the doc comment on purpose: utoipa publishes the doc +// comment as the schema description, so editing it moves the generated +// OpenAPI client. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)] pub struct Pagination { pub offset: usize, pub limit: usize, diff --git a/crates/hypercolor-types/src/api/effects.rs b/crates/hypercolor-types/src/api/effects.rs index eb379dc38..7dc9584ac 100644 --- a/crates/hypercolor-types/src/api/effects.rs +++ b/crates/hypercolor-types/src/api/effects.rs @@ -164,6 +164,17 @@ pub struct InstalledEffectResponse { pub presets: usize, } +/// Response for `POST /api/v1/effects/rescan`. +/// +/// Counts describe what the rescan changed in the registry, so an +/// all-zero response means the effect directories were already current. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RescanResponse { + pub added: usize, + pub removed: usize, + pub updated: usize, +} + /// Request body for `POST /api/v1/effects/{id}/apply`. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ToSchema)] pub struct ApplyEffectRequest { @@ -204,6 +215,12 @@ pub struct UpdateActiveControlsRequest { pub controls: Option, } +// The two control PATCH responses and the control-binding response are +// deliberately NOT defined here. Their payloads carry f32 control values, +// and the daemon builds them with `serde_json::json!`, which widens f32 to +// f64 and prints the widened digits. A derived struct writes f32 directly, +// so naming those shapes would change the bytes on the wire. + /// Request body for `PUT /api/v1/effects/{id}/layout`. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct SetEffectLayoutRequest { @@ -211,6 +228,38 @@ pub struct SetEffectLayoutRequest { pub layout_id: String, } +/// Response for `GET /api/v1/effects/{id}/layout`. +/// +/// `resolved` reports whether the linked layout still exists; a stale +/// link answers `resolved: false` with a `null` `layout` rather than a +/// 404, because the association itself is real. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EffectLayoutResponse { + pub effect: EffectRefSummary, + pub layout_id: String, + pub resolved: bool, + pub layout: Option, +} + +/// Response for `PUT /api/v1/effects/{id}/layout`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SetEffectLayoutResponse { + pub effect: EffectRefSummary, + pub layout: LayoutLinkSummary, + pub linked: bool, +} + +/// Response for `DELETE /api/v1/effects/{id}/layout`. +/// +/// `layout_id` is the association that was removed, and `null` with +/// `deleted: false` when the effect had no layout linked. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DeleteEffectLayoutResponse { + pub effect: EffectRefSummary, + pub layout_id: Option, + pub deleted: bool, +} + /// Optional body for `POST /api/v1/effects/active/reset` — scopes the /// reset to one zone (`zone_id`); omitted resets the primary. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)] diff --git a/crates/hypercolor-types/src/api/library.rs b/crates/hypercolor-types/src/api/library.rs index e814d1dd9..4bbcfac0c 100644 --- a/crates/hypercolor-types/src/api/library.rs +++ b/crates/hypercolor-types/src/api/library.rs @@ -2,6 +2,9 @@ use serde::{Deserialize, Serialize}; +use crate::api::common::Pagination; +use crate::library::{EffectPlaylist, EffectPreset}; + /// Request body for `POST /api/v1/library/favorites`. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct AddFavoriteRequest { @@ -9,6 +12,45 @@ pub struct AddFavoriteRequest { pub effect: String, } +/// One favorited effect. +/// +/// `effect_name` is resolved from the registry at request time and falls +/// back to the id when the effect is no longer installed. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FavoriteSummary { + pub effect_id: String, + #[serde(default)] + pub effect_name: String, + #[serde(default)] + pub added_at_ms: u64, +} + +/// Response for `GET /api/v1/library/favorites`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FavoriteListResponse { + #[serde(default)] + pub items: Vec, + #[serde(default)] + pub pagination: Pagination, +} + +/// Response for `POST /api/v1/library/favorites`. +/// +/// `created` is false when the effect was already favorited, which +/// re-stamps `added_at_ms` rather than erroring. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AddFavoriteResponse { + pub favorite: FavoriteSummary, + pub created: bool, +} + +/// Response for `DELETE /api/v1/library/favorites/{effect}`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DeleteFavoriteResponse { + pub effect_id: String, + pub deleted: bool, +} + /// What one playlist item plays. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] @@ -63,3 +105,83 @@ pub struct ApplyPresetRequest { #[serde(default, skip_serializing_if = "Option::is_none")] pub zone_id: Option, } + +/// Response for `GET /api/v1/library/presets`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PresetListResponse { + #[serde(default)] + pub items: Vec, + #[serde(default)] + pub pagination: Pagination, +} + +/// Response for `DELETE /api/v1/library/presets/{id}`. +/// +/// `id` is the resolved preset id, which differs from the path segment +/// when the caller addressed the preset by name. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DeletePresetResponse { + pub id: String, + pub deleted: bool, +} + +// The preset apply response is deliberately NOT defined here: its +// `applied_controls` map carries f32 control values, and the daemon builds +// the body with `serde_json::json!`, which widens f32 to f64 and prints the +// widened digits. Naming the shape would change the bytes on the wire. + +/// Response for `GET /api/v1/library/playlists`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PlaylistListResponse { + #[serde(default)] + pub items: Vec, + #[serde(default)] + pub pagination: Pagination, +} + +/// Response for `DELETE /api/v1/library/playlists/{id}`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DeletePlaylistResponse { + pub id: String, + pub deleted: bool, +} + +/// The playlist the daemon is currently cycling through. +/// +/// This is the live runtime's view, not the stored playlist: the item +/// list is reduced to `item_count`, and `started_at_ms` is when playback +/// began rather than when the playlist was saved. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ActivePlaylistResponse { + pub id: String, + pub name: String, + pub loop_enabled: bool, + pub item_count: usize, + pub started_at_ms: u64, +} + +/// Response for `POST /api/v1/library/playlists/{id}/activate`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ActivatePlaylistResponse { + pub playlist: ActivePlaylistResponse, + pub active: bool, +} + +/// Response for `GET /api/v1/library/playlists/active`. +/// +/// The route answers 404 when nothing is playing, so `state` is always +/// `"running"` on a success. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ActivePlaylistStateResponse { + pub playlist: ActivePlaylistResponse, + #[serde(default)] + pub state: String, +} + +/// Response for `POST /api/v1/library/playlists/stop`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct StopPlaylistResponse { + /// The playlist as it stood when playback stopped. + pub playlist: ActivePlaylistResponse, + pub stopped: bool, +} diff --git a/crates/hypercolor-types/src/library.rs b/crates/hypercolor-types/src/library.rs index 1182b5214..2d1f70c77 100644 --- a/crates/hypercolor-types/src/library.rs +++ b/crates/hypercolor-types/src/library.rs @@ -162,7 +162,9 @@ pub struct EffectPreset { pub controls: HashMap, #[serde(default)] pub tags: Vec, + #[serde(default)] pub created_at_ms: u64, + #[serde(default)] pub updated_at_ms: u64, } diff --git a/crates/hypercolor-types/tests/api_attachments_tests.rs b/crates/hypercolor-types/tests/api_attachments_tests.rs new file mode 100644 index 000000000..f922030f6 --- /dev/null +++ b/crates/hypercolor-types/tests/api_attachments_tests.rs @@ -0,0 +1,153 @@ +//! Attachment-template catalog contract tests. +//! +//! These pin the client-tolerance the shared types inherited from the +//! hand-rolled web UI mirrors they replaced. The mirrors declared +//! `origin` as an `Option`, so an explicit `null` +//! decoded fine; promoting the field to an unconditional +//! `ComponentOrigin` must not make that JSON stop parsing. + +use hypercolor_types::api::attachments::{TemplateDetail, TemplateListResponse, TemplateSummary}; +use hypercolor_types::attachment::{ComponentCategory, ComponentOrigin}; +use serde_json::json; + +fn summary_json(origin: Option) -> serde_json::Value { + let mut value = json!({ + "id": "ll-sl-inf", + "name": "SL Infinity", + "vendor": "Lian Li", + "category": "fan", + "led_count": 16, + "description": "120mm fan", + }); + if let Some(origin) = origin { + value["origin"] = origin; + } + value +} + +fn detail_json(origin: Option) -> serde_json::Value { + let mut value = json!({ + "id": "ll-sl-inf", + "name": "SL Infinity", + "vendor": "Lian Li", + "category": "fan", + "led_count": 2, + "description": "120mm fan", + "default_size": { "width": 0.1, "height": 0.1 }, + "topology": { + "type": "ring", + "count": 2, + "start_angle": 0.0, + "direction": "clockwise" + }, + }); + if let Some(origin) = origin { + value["origin"] = origin; + } + value +} + +// ── Deserialization tolerance ─────────────────────────────────────────── + +#[test] +fn template_summary_decodes_an_explicit_null_origin_as_built_in() { + let summary: TemplateSummary = + serde_json::from_value(summary_json(Some(serde_json::Value::Null))) + .expect("an explicit null origin must still decode"); + + assert_eq!(summary.origin, ComponentOrigin::BuiltIn); +} + +#[test] +fn template_summary_decodes_an_absent_origin_as_built_in() { + let summary: TemplateSummary = + serde_json::from_value(summary_json(None)).expect("an absent origin must still decode"); + + assert_eq!(summary.origin, ComponentOrigin::BuiltIn); +} + +#[test] +fn template_summary_keeps_an_explicit_origin() { + let summary: TemplateSummary = serde_json::from_value(summary_json(Some(json!("user")))) + .expect("an explicit origin must decode"); + + assert_eq!(summary.origin, ComponentOrigin::User); +} + +#[test] +fn template_detail_decodes_an_explicit_null_origin_as_built_in() { + let detail: TemplateDetail = serde_json::from_value(detail_json(Some(serde_json::Value::Null))) + .expect("an explicit null origin must still decode"); + + assert_eq!(detail.origin, ComponentOrigin::BuiltIn); +} + +#[test] +fn template_detail_decodes_an_absent_origin_as_built_in() { + let detail: TemplateDetail = + serde_json::from_value(detail_json(None)).expect("an absent origin must still decode"); + + assert_eq!(detail.origin, ComponentOrigin::BuiltIn); +} + +#[test] +fn template_detail_keeps_an_explicit_origin() { + let detail: TemplateDetail = + serde_json::from_value(detail_json(Some(json!("user")))).expect("origin must decode"); + + assert_eq!(detail.origin, ComponentOrigin::User); +} + +// ── Serialization is unchanged ────────────────────────────────────────── + +#[test] +fn template_summary_always_serializes_origin() { + // The tolerance above is deserialize-only. The daemon still writes + // the key unconditionally, including for the default variant, so a + // `skip_serializing_if` creeping in would be a wire change. + let summary = TemplateSummary { + id: "ll-sl-inf".to_owned(), + name: "SL Infinity".to_owned(), + vendor: "Lian Li".to_owned(), + category: ComponentCategory::Fan, + origin: ComponentOrigin::BuiltIn, + led_count: 16, + description: "120mm fan".to_owned(), + image_url: None, + tags: Vec::new(), + }; + + let value = serde_json::to_value(&summary).expect("summary must serialize"); + + assert_eq!(value["origin"], json!("built_in")); + // `image_url` is likewise emitted as an explicit null rather than + // dropped, which is what the pre-promotion daemon struct did. + assert_eq!(value["image_url"], serde_json::Value::Null); +} + +#[test] +fn template_summary_round_trips_through_a_null_origin() { + let decoded: TemplateSummary = + serde_json::from_value(summary_json(Some(serde_json::Value::Null))) + .expect("null origin decodes"); + let reencoded = serde_json::to_value(&decoded).expect("summary must serialize"); + + // Re-encoding resolves the null to the concrete default, which is + // the shape the daemon would have sent in the first place. + assert_eq!(reencoded["origin"], json!("built_in")); + + let again: TemplateSummary = serde_json::from_value(reencoded).expect("re-decode"); + assert_eq!(again, decoded); +} + +#[test] +fn template_listing_tolerates_a_missing_pagination_envelope() { + // The deleted UI mirror had no `pagination` field at all and simply + // ignored the envelope, so a body without one must still parse. + let listing: TemplateListResponse = + serde_json::from_value(json!({ "items": [summary_json(None)] })) + .expect("a listing without pagination must decode"); + + assert_eq!(listing.items.len(), 1); + assert_eq!(listing.pagination.total, 0); +} diff --git a/crates/hypercolor-ui/src/api/devices.rs b/crates/hypercolor-ui/src/api/devices.rs index b4644c8de..16f97f44d 100644 --- a/crates/hypercolor-ui/src/api/devices.rs +++ b/crates/hypercolor-ui/src/api/devices.rs @@ -30,26 +30,11 @@ pub struct BrightnessSettingsResponse { // ── Attachment Types ──────────────────────────────────────────────────────── -/// Template summary from `GET /api/v1/attachments/templates`. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct TemplateSummary { - pub id: String, - pub name: String, - pub vendor: String, - pub category: hypercolor_types::attachment::ComponentCategory, - #[serde(default)] - pub origin: Option, - pub led_count: u32, - pub description: String, - #[serde(default)] - pub tags: Vec, -} - -/// Paginated template list response. -#[derive(Debug, Deserialize)] -pub struct TemplateListResponse { - pub items: Vec, -} +// The attachment-template catalog contracts are shared with the daemon +// (hypercolor-types::api::attachments). +pub use hypercolor_types::api::attachments::{ + TemplateDetail, TemplateListResponse, TemplateSummary, +}; // ── Fetch Functions ───────────────────────────────────────────────────────── @@ -123,7 +108,7 @@ pub async fn identify_attachment( /// Create a user-authored attachment template (custom strip, matrix, etc.). pub async fn create_attachment_template( template: &hypercolor_types::attachment::ComponentTemplate, -) -> Result { +) -> Result { client::post_json("/api/v1/attachments/templates", template) .await .map_err(Into::into) diff --git a/crates/hypercolor-ui/src/api/effects.rs b/crates/hypercolor-ui/src/api/effects.rs index ceb1d283c..76997a762 100644 --- a/crates/hypercolor-ui/src/api/effects.rs +++ b/crates/hypercolor-ui/src/api/effects.rs @@ -23,7 +23,15 @@ pub use hypercolor_types::api::effects::{ }; pub use hypercolor_types::api::output::{OutputPowerMode, SetOutputPowerRequest}; -/// Active effect response from `GET /api/v1/effects/active`. +/// Active effect response from `GET /api/v1/effects/active`, narrowed to +/// the running case. +/// +/// Not a mirror of the shared wire type but a projection of it: the wire +/// shape types `id` and `name` as `Option` because the idle body carries +/// nulls, and every UI consumer here has already branched on `state` and +/// wants them unwrapped. `fetch_active_effect` decodes the shared +/// `hypercolor_types::api::effects::ActiveEffectResponse` and maps idle to +/// `None`, so this type is only ever built from a decoded wire response. #[derive(Debug, Clone, Deserialize, PartialEq)] pub struct ActiveEffectResponse { pub id: String, @@ -206,6 +214,11 @@ pub enum UpdateControlsOutcome { /// Successful control-PATCH payload — the envelope data carries the new /// `controls_version` (also present in the `ETag` header; the body is /// simpler to extract with `gloo_net`). +/// +/// Stays UI-local rather than moving to hypercolor-types with the rest of +/// the effects contracts: the daemon still builds that body with a +/// `serde_json::json!` literal, because naming the shape would reprint its +/// f32 control values at f32 precision and change the wire. #[derive(Debug, Deserialize)] struct ControlsVersionResponse { controls_version: u64, diff --git a/crates/hypercolor-ui/src/api/library.rs b/crates/hypercolor-ui/src/api/library.rs index c6f8d345c..31fb8e7ff 100644 --- a/crates/hypercolor-ui/src/api/library.rs +++ b/crates/hypercolor-ui/src/api/library.rs @@ -1,70 +1,34 @@ //! Library API — presets and favorites. -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; - use super::client; -// ── Preset Types ──────────────────────────────────────────────────────────── - -/// Preset summary from `GET /api/v1/library/presets`. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct PresetSummary { - pub id: String, - pub name: String, - pub description: Option, - pub effect_id: String, - #[serde(default)] - pub controls: HashMap, - #[serde(default)] - pub tags: Vec, - #[serde(default)] - pub created_at_ms: u64, - #[serde(default)] - pub updated_at_ms: u64, -} - -/// Paginated preset list response. -#[derive(Debug, Deserialize)] -pub struct PresetListResponse { - pub items: Vec, -} - -pub use hypercolor_types::api::library::{AddFavoriteRequest, SavePresetRequest}; - -// ── Favorite Types ────────────────────────────────────────────────────────── - -/// Favorite entry from `GET /api/v1/library/favorites`. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct FavoriteSummary { - pub effect_id: String, - pub effect_name: String, - pub added_at_ms: u64, -} - -/// Paginated favorites list response. -#[derive(Debug, Deserialize)] -pub struct FavoriteListResponse { - pub items: Vec, -} +// Wire contracts are shared with the daemon +// (hypercolor-types::api::library) — drift is a compile error rather +// than a runtime parse failure. `EffectPreset` is the daemon's stored +// preset record, returned verbatim by the preset routes. +pub use hypercolor_types::api::library::{ + AddFavoriteRequest, FavoriteListResponse, FavoriteSummary, PresetListResponse, + SavePresetRequest, +}; +pub use hypercolor_types::library::EffectPreset; // ── Preset Functions ──────────────────────────────────────────────────────── /// Fetch all saved presets. -pub async fn fetch_presets() -> Result, String> { +pub async fn fetch_presets() -> Result, String> { let list: PresetListResponse = client::fetch_json("/api/v1/library/presets").await?; Ok(list.items) } /// Create a new preset from current control values. -pub async fn create_preset(req: &SavePresetRequest) -> Result { +pub async fn create_preset(req: &SavePresetRequest) -> Result { client::post_json("/api/v1/library/presets", req) .await .map_err(Into::into) } /// Update an existing preset (name, controls, etc.). -pub async fn update_preset(id: &str, req: &SavePresetRequest) -> Result { +pub async fn update_preset(id: &str, req: &SavePresetRequest) -> Result { client::put_json(&format!("/api/v1/library/presets/{id}"), req) .await .map_err(Into::into) diff --git a/crates/hypercolor-ui/src/components/attachment_editor.rs b/crates/hypercolor-ui/src/components/attachment_editor.rs index 97ab03cf8..b10c3aca0 100644 --- a/crates/hypercolor-ui/src/components/attachment_editor.rs +++ b/crates/hypercolor-ui/src/components/attachment_editor.rs @@ -176,9 +176,7 @@ pub fn expand_bindings_to_drafts( // Check if this is a user-created strip/matrix template let tmpl = templates.iter().find(|t| t.id == binding.template_id); let is_user = tmpl - .and_then(|t| t.origin.as_ref()) - .map(|o| *o == hypercolor_types::attachment::ComponentOrigin::User) - .unwrap_or(false); + .is_some_and(|t| t.origin == hypercolor_types::attachment::ComponentOrigin::User); if is_user { // Reconstruct as inline strip/matrix based on category diff --git a/crates/hypercolor-ui/src/components/preset_panel.rs b/crates/hypercolor-ui/src/components/preset_panel.rs index f338e6525..810594cc5 100644 --- a/crates/hypercolor-ui/src/components/preset_panel.rs +++ b/crates/hypercolor-ui/src/components/preset_panel.rs @@ -264,10 +264,11 @@ pub fn PresetToolbar( }; match api::create_preset(&req).await { Ok(created) => { - match api::apply_effect_preset(&eid, &created.id, target_zone.as_deref()).await + let created_id = created.id.to_string(); + match api::apply_effect_preset(&eid, &created_id, target_zone.as_deref()).await { Ok(()) => { - set_selected_id.set(Some(created.id)); + set_selected_id.set(Some(created_id)); toasts::toast_success("Preset created"); refresh(); } diff --git a/crates/hypercolor-ui/tests/attachment_editor_tests.rs b/crates/hypercolor-ui/tests/attachment_editor_tests.rs index 95d7b931e..e650da84d 100644 --- a/crates/hypercolor-ui/tests/attachment_editor_tests.rs +++ b/crates/hypercolor-ui/tests/attachment_editor_tests.rs @@ -1,4 +1,4 @@ -use hypercolor_types::attachment::{ComponentCategory, ComponentSlot}; +use hypercolor_types::attachment::{ComponentCategory, ComponentOrigin, ComponentSlot}; use hypercolor_ui::api::{ComponentBindingSummary, TemplateSummary}; use hypercolor_ui::components::attachment_editor::{ DraftRow, expand_bindings_to_drafts, summarize_channel, @@ -22,9 +22,10 @@ fn template(id: &str, name: &str, category: ComponentCategory, led_count: u32) - name: name.to_owned(), vendor: "Lian Li".to_owned(), category, - origin: None, + origin: ComponentOrigin::BuiltIn, led_count, description: String::new(), + image_url: None, tags: Vec::new(), } } diff --git a/crates/hypercolor-ui/tests/component_picker_tests.rs b/crates/hypercolor-ui/tests/component_picker_tests.rs index b90c0f0cc..c9f41f038 100644 --- a/crates/hypercolor-ui/tests/component_picker_tests.rs +++ b/crates/hypercolor-ui/tests/component_picker_tests.rs @@ -1,4 +1,4 @@ -use hypercolor_types::attachment::ComponentCategory; +use hypercolor_types::attachment::{ComponentCategory, ComponentOrigin}; use hypercolor_ui::api::TemplateSummary; use hypercolor_ui::components::component_picker::{filter_components, selected_result_index}; @@ -8,9 +8,10 @@ fn template(id: &str, name: &str, vendor: &str, category: ComponentCategory) -> name: name.to_owned(), vendor: vendor.to_owned(), category, - origin: None, + origin: ComponentOrigin::BuiltIn, led_count: 16, description: String::new(), + image_url: None, tags: Vec::new(), } }