From d47e34c2f427b4bc93e33e53ff93ff99ffeee60b Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Mon, 17 Aug 2026 02:05:19 -0700 Subject: [PATCH 1/6] refactor(api): name the daemon's REST request payloads into shared types Forty-two request and query shapes lived as anonymous structs inside daemon route modules, so no client could reference them and every consumer re-derived the shape by hand. They now live in hypercolor-types::api under their domain module, matching the wire field-for-field: same serde attributes, same defaults, same optionality. The daemon modules re-export them, so route handlers, the OpenAPI catalog, and the MCP adapters keep their existing paths. New api modules: assets, attachments, config, controls, diagnose, displays, layers, layouts, library, profiles, settings, simulators. The devices and effects modules grow their missing request types. Two structural notes. The layer request conversions move with their types, except the broadcast expansion, which reaches into hypercolor-core's SceneGroupLayerInsert and stays daemon-local as a free function. PatchLayerControlsRequest deliberately keeps its controls field free of serde(default) because the published schema marks it required and Option already admits an absent field. The config write body stays an untyped serde_json::Value: on that route the value itself is the body, so there is no shape to name. Every pinned suite passes unedited, which is the fence this wave was supposed to hold. The regenerated Python client carries only new description strings from the doc comments; no schema shape moved. Co-Authored-By: Nova (Claude Opus 5) --- crates/hypercolor-daemon/src/api/assets.rs | 18 +- .../hypercolor-daemon/src/api/attachments.rs | 17 +- crates/hypercolor-daemon/src/api/config.rs | 18 +- crates/hypercolor-daemon/src/api/controls.rs | 17 +- .../src/api/devices/attachments.rs | 8 +- .../src/api/devices/discovery.rs | 9 +- .../src/api/devices/logical.rs | 28 +-- .../hypercolor-daemon/src/api/devices/mod.rs | 21 +- crates/hypercolor-daemon/src/api/diagnose.rs | 10 +- crates/hypercolor-daemon/src/api/displays.rs | 64 +----- crates/hypercolor-daemon/src/api/effects.rs | 9 +- crates/hypercolor-daemon/src/api/layers.rs | 205 +++--------------- crates/hypercolor-daemon/src/api/layouts.rs | 30 +-- .../src/api/library/favorites.rs | 9 +- .../src/api/library/playlists.rs | 28 +-- .../src/api/library/presets.rs | 20 +- crates/hypercolor-daemon/src/api/profiles.rs | 28 +-- crates/hypercolor-daemon/src/api/settings.rs | 10 +- .../hypercolor-daemon/src/api/simulators.rs | 24 +- crates/hypercolor-types/src/api/assets.rs | 26 +++ .../hypercolor-types/src/api/attachments.rs | 35 +++ crates/hypercolor-types/src/api/config.rs | 29 +++ crates/hypercolor-types/src/api/controls.rs | 29 +++ crates/hypercolor-types/src/api/devices.rs | 93 ++++++++ crates/hypercolor-types/src/api/diagnose.rs | 15 ++ crates/hypercolor-types/src/api/displays.rs | 65 ++++++ crates/hypercolor-types/src/api/effects.rs | 7 + crates/hypercolor-types/src/api/layers.rs | 179 +++++++++++++++ crates/hypercolor-types/src/api/layouts.rs | 47 ++++ crates/hypercolor-types/src/api/library.rs | 65 ++++++ crates/hypercolor-types/src/api/mod.rs | 12 + crates/hypercolor-types/src/api/profiles.rs | 34 +++ crates/hypercolor-types/src/api/settings.rs | 11 + crates/hypercolor-types/src/api/simulators.rs | 32 +++ crates/hypercolor-types/src/layer.rs | 4 +- .../controls/invoke_control_surface_action.py | 6 +- .../api/devices/discover_devices.py | 4 +- .../_generated/api/profiles/apply_profile.py | 4 +- .../api/scenes/broadcast_media_layer.py | 10 +- .../_generated/api/scenes/create_layer.py | 6 +- .../api/scenes/patch_layer_controls.py | 12 +- .../_generated/api/scenes/reorder_layers.py | 6 +- .../_generated/api/scenes/update_layer.py | 12 +- .../_generated/api/settings/set_brightness.py | 4 +- .../models/apply_profile_request.py | 3 +- .../models/broadcast_media_layer_request.py | 23 +- .../models/broadcast_media_layer_target.py | 7 +- .../_generated/models/create_layer_request.py | 22 +- .../_generated/models/discover_request.py | 7 +- .../models/invoke_control_action_request.py | 8 +- .../_generated/models/layer_order_request.py | 8 +- .../models/patch_layer_controls_request.py | 11 +- .../models/set_brightness_request.py | 5 +- .../_generated/models/update_layer_request.py | 27 ++- 54 files changed, 894 insertions(+), 547 deletions(-) create mode 100644 crates/hypercolor-types/src/api/assets.rs create mode 100644 crates/hypercolor-types/src/api/attachments.rs create mode 100644 crates/hypercolor-types/src/api/config.rs create mode 100644 crates/hypercolor-types/src/api/controls.rs create mode 100644 crates/hypercolor-types/src/api/diagnose.rs create mode 100644 crates/hypercolor-types/src/api/displays.rs create mode 100644 crates/hypercolor-types/src/api/layers.rs create mode 100644 crates/hypercolor-types/src/api/layouts.rs create mode 100644 crates/hypercolor-types/src/api/library.rs create mode 100644 crates/hypercolor-types/src/api/profiles.rs create mode 100644 crates/hypercolor-types/src/api/settings.rs create mode 100644 crates/hypercolor-types/src/api/simulators.rs diff --git a/crates/hypercolor-daemon/src/api/assets.rs b/crates/hypercolor-daemon/src/api/assets.rs index 92ffc290e..425e55aae 100644 --- a/crates/hypercolor-daemon/src/api/assets.rs +++ b/crates/hypercolor-daemon/src/api/assets.rs @@ -13,12 +13,14 @@ use hypercolor_core::asset::{ }; use hypercolor_types::asset::AssetId; use hypercolor_types::event::{AssetChangeKind, HypercolorEvent}; -use serde::{Deserialize, Serialize}; +use serde::Serialize; use crate::api::AppState; use crate::api::envelope::ApiResponse; use crate::domain::{DomainError, ResourceKind}; +pub use hypercolor_types::api::assets::{AssetUpdateRequest, AssetUploadQuery}; + /// Multipart framing the upload route accepts on top of the asset bytes /// themselves. const ASSET_UPLOAD_FRAMING_ALLOWANCE_BYTES: u64 = 1024 * 1024; @@ -36,20 +38,6 @@ pub struct AssetUploadResponse { pub duplicate: bool, } -#[derive(Debug, Deserialize)] -pub struct AssetUploadQuery { - #[serde(default)] - pub rename_duplicate: bool, - #[serde(default)] - pub r#type: Option, -} - -#[derive(Debug, Deserialize)] -pub struct AssetUpdateRequest { - pub name: Option, - pub tags: Option>, -} - #[derive(Debug)] struct ParsedUpload { bytes: Vec, diff --git a/crates/hypercolor-daemon/src/api/attachments.rs b/crates/hypercolor-daemon/src/api/attachments.rs index e36f7977d..b9ceaed3a 100644 --- a/crates/hypercolor-daemon/src/api/attachments.rs +++ b/crates/hypercolor-daemon/src/api/attachments.rs @@ -7,7 +7,7 @@ use std::sync::Arc; use axum::Json; use axum::extract::{Path as AxumPath, Query, State}; use axum::response::{IntoResponse, Response}; -use serde::{Deserialize, Serialize}; +use serde::Serialize; use tokio::sync::RwLockWriteGuard; use hypercolor_core::attachment::{ComponentRegistry, TemplateFilter}; @@ -22,20 +22,7 @@ use crate::api::devices::Pagination; use crate::api::envelope::ApiResponse; use crate::domain::{DomainError, ResourceKind}; -#[derive(Debug, Deserialize, Default)] -pub struct ListTemplatesQuery { - pub offset: Option, - pub limit: Option, - pub category: Option, - pub vendor: Option, - pub origin: Option, - pub q: Option, - pub controller_id: Option, - pub model: Option, - pub slot_id: Option, - pub led_min: Option, - pub led_max: Option, -} +pub use hypercolor_types::api::attachments::ListTemplatesQuery; #[derive(Debug, Serialize)] pub struct TemplateListResponse { diff --git a/crates/hypercolor-daemon/src/api/config.rs b/crates/hypercolor-daemon/src/api/config.rs index 738e89da8..db5e53d06 100644 --- a/crates/hypercolor-daemon/src/api/config.rs +++ b/crates/hypercolor-daemon/src/api/config.rs @@ -6,7 +6,7 @@ use anyhow::Context; use axum::Json; use axum::extract::{Path, Query, State}; use axum::response::{IntoResponse, Response}; -use serde::{Deserialize, Serialize}; +use serde::Serialize; use tracing::{info, warn}; use utoipa::ToSchema; @@ -26,6 +26,8 @@ use crate::api::AppState; use crate::api::envelope::ApiResponse; use crate::domain::{DomainError, ResourceKind}; +pub use hypercolor_types::api::config::ConfigApplyQuery; + /// Render an internal config failure. /// /// The chain goes to tracing and the wire sees the canonical generic @@ -37,20 +39,6 @@ use crate::scene_transactions::{ PreparedLayoutUpdate, SceneTransaction, apply_prepared_layout_update_under_guard, }; -/// Whether a mutation re-applies the live sections it touches. -/// -/// Live application is the default: a client that wants the value on -/// disk without disturbing the running daemon asks for `?live=false`. -#[derive(Debug, Deserialize)] -pub struct ConfigApplyQuery { - #[serde(default = "live_apply_default")] - pub live: bool, -} - -const fn live_apply_default() -> bool { - true -} - /// The outcome of a config write, reset, or whole-config reset. #[derive(Debug, Serialize, ToSchema)] pub struct ConfigMutationResponse { diff --git a/crates/hypercolor-daemon/src/api/controls.rs b/crates/hypercolor-daemon/src/api/controls.rs index 0532d3e87..844e01e12 100644 --- a/crates/hypercolor-daemon/src/api/controls.rs +++ b/crates/hypercolor-daemon/src/api/controls.rs @@ -19,7 +19,7 @@ use hypercolor_types::controls::{ }; use hypercolor_types::device::{DeviceId, DeviceInfo, DeviceState, DeviceUserSettings}; use hypercolor_types::event::HypercolorEvent; -use serde::{Deserialize, Serialize}; +use serde::Serialize; use utoipa::ToSchema; use crate::api::AppState; @@ -29,29 +29,18 @@ use crate::discovery as core_discovery; use crate::domain::{DomainError, ResourceKind}; use crate::network; +pub use hypercolor_types::api::controls::{ControlSurfaceListQuery, InvokeControlActionRequest}; + const DEVICE_FIELD_NAME: &str = "name"; const DEVICE_FIELD_ENABLED: &str = "enabled"; const DEVICE_FIELD_BRIGHTNESS: &str = "brightness"; const DEVICE_ACTION_IDENTIFY: &str = "identify"; -#[derive(Debug, Deserialize)] -pub struct ControlSurfaceListQuery { - pub device_id: Option, - pub driver_id: Option, - pub include_driver: Option, -} - #[derive(Debug, Serialize, ToSchema)] pub struct ControlSurfaceListResponse { pub surfaces: Vec, } -#[derive(Debug, Deserialize, ToSchema)] -pub struct InvokeControlActionRequest { - #[serde(default)] - pub input: ControlValueMap, -} - /// `GET /api/v1/control-surfaces` - Return control surfaces for a UI view. pub async fn list_control_surfaces( State(state): State>, diff --git a/crates/hypercolor-daemon/src/api/devices/attachments.rs b/crates/hypercolor-daemon/src/api/devices/attachments.rs index c9d74123c..df9a0723d 100644 --- a/crates/hypercolor-daemon/src/api/devices/attachments.rs +++ b/crates/hypercolor-daemon/src/api/devices/attachments.rs @@ -6,7 +6,7 @@ use std::sync::Arc; use axum::Json; use axum::extract::{Path, State}; use axum::response::{IntoResponse, Response}; -use serde::{Deserialize, Serialize}; +use serde::Serialize; use tracing::debug; use hypercolor_core::attachment::{effective_attachment_slots, normalize_attachment_profile_slots}; @@ -25,11 +25,7 @@ use crate::logical_devices; use super::{ensure_default_logical_entry, resolve_device_id_or_error}; -#[derive(Debug, Deserialize, Default)] -pub struct UpdateAttachmentsRequest { - #[serde(default)] - pub bindings: Vec, -} +pub use hypercolor_types::api::devices::UpdateAttachmentsRequest; #[derive(Debug, Serialize)] pub struct DeviceComponentsResponse { diff --git a/crates/hypercolor-daemon/src/api/devices/discovery.rs b/crates/hypercolor-daemon/src/api/devices/discovery.rs index 1664681ce..3b17c5278 100644 --- a/crates/hypercolor-daemon/src/api/devices/discovery.rs +++ b/crates/hypercolor-daemon/src/api/devices/discovery.rs @@ -6,8 +6,6 @@ use std::sync::atomic::Ordering; use axum::Json; use axum::extract::State; use axum::response::{IntoResponse, Response}; -use serde::Deserialize; -use utoipa::ToSchema; use hypercolor_types::config::HypercolorConfig; @@ -16,12 +14,7 @@ use crate::api::envelope::ApiResponse; use crate::discovery; use crate::domain::DomainError; -#[derive(Debug, Deserialize, ToSchema)] -pub struct DiscoverRequest { - pub targets: Option>, - pub timeout_ms: Option, - pub wait: Option, -} +pub use hypercolor_types::api::devices::DiscoverRequest; /// `POST /api/v1/devices/discover` — Trigger device discovery. pub async fn discover_devices( diff --git a/crates/hypercolor-daemon/src/api/devices/logical.rs b/crates/hypercolor-daemon/src/api/devices/logical.rs index 8b844de59..ea44da49b 100644 --- a/crates/hypercolor-daemon/src/api/devices/logical.rs +++ b/crates/hypercolor-daemon/src/api/devices/logical.rs @@ -6,7 +6,7 @@ use std::sync::Arc; use axum::Json; use axum::extract::{Path, Query, State}; use axum::response::{IntoResponse, Response}; -use serde::{Deserialize, Serialize}; +use serde::Serialize; use hypercolor_core::device::{BackendManager, SegmentRange}; use hypercolor_types::device::{DeviceId, DeviceInfo, DeviceOrigin}; @@ -21,29 +21,9 @@ use super::{ Pagination, ensure_default_logical_entry, resolve_device_id_or_error, resolved_backend_id, }; -#[derive(Debug, Deserialize, Default)] -pub struct ListLogicalDevicesQuery { - pub offset: Option, - pub limit: Option, - pub physical_device: Option, - pub enabled: Option, -} - -#[derive(Debug, Deserialize)] -pub struct CreateLogicalDeviceRequest { - pub name: String, - pub led_start: u32, - pub led_count: u32, - pub enabled: Option, -} - -#[derive(Debug, Deserialize)] -pub struct UpdateLogicalDeviceRequest { - pub name: Option, - pub led_start: Option, - pub led_count: Option, - pub enabled: Option, -} +pub use hypercolor_types::api::devices::{ + CreateLogicalDeviceRequest, ListLogicalDevicesQuery, UpdateLogicalDeviceRequest, +}; #[derive(Debug, Serialize)] pub struct LogicalDeviceListResponse { diff --git a/crates/hypercolor-daemon/src/api/devices/mod.rs b/crates/hypercolor-daemon/src/api/devices/mod.rs index f34fb8b12..7c487bf72 100644 --- a/crates/hypercolor-daemon/src/api/devices/mod.rs +++ b/crates/hypercolor-daemon/src/api/devices/mod.rs @@ -17,7 +17,6 @@ use std::time::{Duration, Instant}; use axum::Json; use axum::extract::{Path, Query, State}; use axum::response::{IntoResponse, Response}; -use serde::Deserialize; use tracing::{debug, warn}; use hypercolor_color::Rgb; @@ -35,6 +34,8 @@ use crate::device_metrics::DeviceMetricsSnapshot; use crate::discovery as core_discovery; use crate::domain::{DomainError, ResourceKind}; +pub use hypercolor_types::api::devices::{IdentifyAttachmentRequest, ListDevicesQuery}; + pub use attachments::{ ComponentBindingSummary, ComponentPreviewResponse, ComponentPreviewZone, DeviceComponentsResponse, DeviceComponentsUpdateResponse, UpdateAttachmentsRequest, @@ -63,24 +64,6 @@ pub use hypercolor_types::api::devices::{ UnresolvedBindingSummary, UpdateDeviceRequest, ZoneSummary, ZoneTopologySummary, }; -#[derive(Debug, Deserialize)] -pub struct IdentifyAttachmentRequest { - #[serde(flatten)] - pub base: IdentifyRequest, - pub binding_index: Option, - pub instance: Option, -} - -#[derive(Debug, Deserialize, Default)] -pub struct ListDevicesQuery { - pub offset: Option, - pub limit: Option, - pub status: Option, - pub backend_id: Option, - pub driver: Option, - pub q: Option, -} - const IDENTIFY_FLASH_INTERVAL_MS: u64 = 250; const DEFAULT_IDENTIFY_COLOR_RGB: [u8; 3] = [255, 255, 255]; diff --git a/crates/hypercolor-daemon/src/api/diagnose.rs b/crates/hypercolor-daemon/src/api/diagnose.rs index 8a6065d02..3701cbd93 100644 --- a/crates/hypercolor-daemon/src/api/diagnose.rs +++ b/crates/hypercolor-daemon/src/api/diagnose.rs @@ -7,7 +7,7 @@ use axum::extract::State; use axum::response::{IntoResponse, Response}; use hypercolor_core::device::{UsbActorMetricsSnapshot, usb_actor_metrics_snapshot}; use hypercolor_types::device::USB_OUTPUT_BACKEND_ID; -use serde::{Deserialize, Serialize}; +use serde::Serialize; use crate::api::AppState; use crate::api::envelope::ApiResponse; @@ -17,15 +17,11 @@ use crate::display_frames::DisplayOutputMetricsSnapshot; use crate::domain::DomainError; use crate::performance::{LatestFrameMetrics, PerformanceSnapshot}; +pub use hypercolor_types::api::diagnose::DiagnoseRequest; + const RENDER_FRAME_STALE_WARNING_MS: f64 = 2_000.0; const RENDER_FRAME_STALE_FAIL_MS: f64 = 10_000.0; -#[derive(Debug, Deserialize)] -pub struct DiagnoseRequest { - pub checks: Option>, - pub system: Option, -} - #[derive(Debug, Serialize)] struct DiagnoseResponse { checks: Vec, diff --git a/crates/hypercolor-daemon/src/api/displays.rs b/crates/hypercolor-daemon/src/api/displays.rs index e729f958b..3b6021486 100644 --- a/crates/hypercolor-daemon/src/api/displays.rs +++ b/crates/hypercolor-daemon/src/api/displays.rs @@ -10,11 +10,11 @@ use axum::http::{HeaderMap, HeaderValue, StatusCode, header}; use axum::response::{IntoResponse, Response}; use hypercolor_types::device::{DeviceId, DeviceInfo, DeviceTopologyHint, DisplayFrameFormat}; use hypercolor_types::display::{DisplayDescriptor, DisplayPixelFormat}; -use hypercolor_types::effect::{ControlValue, EffectCategory, EffectMetadata, EffectSource}; +use hypercolor_types::effect::{EffectCategory, EffectMetadata, EffectSource}; use hypercolor_types::event::ZoneChangeKind; use hypercolor_types::scene::{DisplayFaceBlendMode, DisplayFaceTarget, Zone}; use hypercolor_types::spatial::{EdgeBehavior, SamplingMode, SpatialLayout}; -use serde::{Deserialize, Serialize}; +use serde::Serialize; use tracing::warn; use crate::api::AppState; @@ -25,6 +25,11 @@ use crate::api::publish_render_group_changed; use crate::display_frames::DisplayFrameSnapshot; use crate::domain::{DomainError, ResourceKind}; +pub use hypercolor_types::api::displays::{ + DisplayFaceScope, DisplayFaceScopeQuery, SetDisplayFaceRequest, + UpdateDisplayFaceCompositionRequest, UpdateDisplayFaceControlsRequest, +}; + #[derive(Debug, Clone, Serialize)] pub struct DisplaySummary { pub id: String, @@ -46,61 +51,6 @@ pub(crate) struct DisplaySurfaceInfo { pub circular: bool, } -/// Which assignment layer a face operation targets (spec 69 §3.6). -/// -/// `default` persists across scenes (the display's own face); `scene` -/// writes into the active scene's display zone, which always wins while -/// that scene is active. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum DisplayFaceScope { - #[default] - Default, - Scene, -} - -#[derive(Debug, Deserialize)] -pub struct SetDisplayFaceRequest { - pub effect_id: String, - #[serde(default)] - pub controls: std::collections::HashMap, - #[serde(default)] - pub blend_mode: Option, - #[serde(default)] - pub opacity: Option, - #[serde(default)] - pub scope: DisplayFaceScope, -} - -/// Query parameters for `DELETE /api/v1/displays/{id}/face`. -#[derive(Debug, Default, Deserialize)] -pub struct DisplayFaceScopeQuery { - #[serde(default)] - pub scope: DisplayFaceScope, -} - -/// Request body for `PATCH /api/v1/displays/{id}/face/controls`. -/// -/// The payload carries only the overrides the caller wants to change; -/// existing control values on the zone are preserved unless their -/// key appears in this map. `controls` is typed as raw JSON (rather than -/// `HashMap`) so callers can send natural shapes -/// like `{"accent": 0.5}` instead of `{"accent": {"float": 0.5}}`, which -/// mirrors the effects controls patch endpoint. -#[derive(Debug, Deserialize)] -pub struct UpdateDisplayFaceControlsRequest { - #[serde(default)] - pub controls: Option, -} - -#[derive(Debug, Deserialize)] -pub struct UpdateDisplayFaceCompositionRequest { - #[serde(default)] - pub blend_mode: Option, - #[serde(default)] - pub opacity: Option, -} - #[derive(Debug, Clone, Serialize)] pub struct DisplayFaceResponse { pub device_id: String, diff --git a/crates/hypercolor-daemon/src/api/effects.rs b/crates/hypercolor-daemon/src/api/effects.rs index 93b7564a3..ed13f605d 100644 --- a/crates/hypercolor-daemon/src/api/effects.rs +++ b/crates/hypercolor-daemon/src/api/effects.rs @@ -10,7 +10,7 @@ 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::{Deserialize, Serialize}; +use serde::Serialize; use tokio::fs; use tracing::{info, warn}; @@ -46,6 +46,8 @@ use crate::effect_layouts; use crate::scene_transactions::apply_layout_update; use crate::session::set_output_stopped; +pub use hypercolor_types::api::effects::SetEffectLayoutRequest; + // ── Request / Response Types ───────────────────────────────────────────── const MAX_EFFECT_UPLOAD_BYTES: usize = 1024 * 1024; @@ -74,11 +76,6 @@ struct ResolvedEffectPreset { controls: HashMap, } -#[derive(Debug, Deserialize)] -pub struct SetEffectLayoutRequest { - pub layout_id: String, -} - #[derive(Debug)] enum ResolveLayoutLinkError { NotFound(String), diff --git a/crates/hypercolor-daemon/src/api/layers.rs b/crates/hypercolor-daemon/src/api/layers.rs index a926d7123..13eba3b20 100644 --- a/crates/hypercolor-daemon/src/api/layers.rs +++ b/crates/hypercolor-daemon/src/api/layers.rs @@ -8,15 +8,15 @@ use axum::Json; use axum::extract::{Path, Query, State}; use axum::http::{HeaderMap, HeaderValue, header}; use axum::response::{IntoResponse, Response}; -use serde::{Deserialize, Serialize}; +use serde::Serialize; use utoipa::ToSchema; use hypercolor_core::scene::{LayerMutationError, SceneGroupLayerInsert, SceneManager}; use hypercolor_types::asset::AssetId; use hypercolor_types::effect::{ControlValue, EffectId}; use hypercolor_types::layer::{ - LayerAdjust, LayerBinding, LayerBlendMode, LayerSource, LayerTransform, MediaPlayback, - SceneLayer, SceneLayerId, + LayerAdjust, LayerBlendMode, LayerSource, LayerTransform, MediaPlayback, SceneLayer, + SceneLayerId, }; use hypercolor_types::scene::{SceneId, Zone, ZoneId}; @@ -27,107 +27,10 @@ use crate::api::{AppState, scenes}; use crate::domain::layer; use crate::domain::{DomainError, MutationContext, ResourceKind}; -#[derive(Debug, Deserialize, ToSchema)] -pub struct CreateLayerRequest { - pub name: Option, - #[schema(value_type = Object)] - pub source: LayerSource, - #[serde(default)] - #[schema(value_type = String)] - pub blend: LayerBlendMode, - #[serde(default = "default_layer_opacity")] - pub opacity: f32, - #[serde(default)] - #[schema(value_type = Object)] - pub transform: LayerTransform, - #[serde(default)] - #[schema(value_type = Object)] - pub adjust: LayerAdjust, - #[serde(default)] - #[schema(value_type = Vec)] - pub bindings: Vec, - #[serde(default = "default_true")] - pub enabled: bool, -} - -#[derive(Debug, Deserialize, ToSchema)] -pub struct UpdateLayerRequest { - #[schema(value_type = String)] - pub id: SceneLayerId, - pub name: Option, - #[schema(value_type = Object)] - pub source: LayerSource, - #[serde(default)] - #[schema(value_type = String)] - pub blend: LayerBlendMode, - #[serde(default = "default_layer_opacity")] - pub opacity: f32, - #[serde(default)] - #[schema(value_type = Object)] - pub transform: LayerTransform, - #[serde(default)] - #[schema(value_type = Object)] - pub adjust: LayerAdjust, - #[serde(default)] - #[schema(value_type = Vec)] - pub bindings: Vec, - #[serde(default = "default_true")] - pub enabled: bool, -} - -#[derive(Debug, Deserialize, ToSchema)] -pub struct LayerOrderRequest { - #[schema(value_type = Vec)] - pub layer_ids: Vec, -} - -#[derive(Debug, Deserialize, ToSchema)] -pub struct PatchLayerControlsRequest { - #[schema(value_type = Object)] - pub controls: Option, -} - -#[derive(Debug, Deserialize)] -pub struct CreateLayerQuery { - pub index: Option, -} - -#[derive(Debug, Deserialize, ToSchema)] -pub struct BroadcastMediaLayerTarget { - #[schema(value_type = String)] - pub zone_id: ZoneId, - #[serde(default)] - #[schema(value_type = Object)] - pub transform: LayerTransform, - #[serde(default)] - #[schema(value_type = Object)] - pub adjust: LayerAdjust, - pub index: Option, - pub expected_layers_version: Option, -} - -#[derive(Debug, Deserialize, ToSchema)] -pub struct BroadcastMediaLayerRequest { - pub name: Option, - #[schema(value_type = String)] - pub asset_id: AssetId, - #[serde(default)] - #[schema(value_type = Object)] - pub playback: MediaPlayback, - #[serde(default)] - #[schema(value_type = String)] - pub blend: LayerBlendMode, - #[serde(default = "default_layer_opacity")] - pub opacity: f32, - #[serde(default)] - #[schema(value_type = Vec)] - pub bindings: Vec, - #[serde(default = "default_true")] - pub enabled: bool, - #[serde(default)] - #[schema(value_type = Vec)] - pub targets: Vec, -} +pub use hypercolor_types::api::layers::{ + BroadcastMediaLayerRequest, BroadcastMediaLayerTarget, CreateLayerQuery, CreateLayerRequest, + LayerOrderRequest, PatchLayerControlsRequest, UpdateLayerRequest, +}; #[derive(Debug, Serialize, ToSchema)] pub struct LayerStackResponse { @@ -246,7 +149,7 @@ pub async fn broadcast_media_layer( }) { return DomainError::not_found(ResourceKind::Zone, zone_id).into_response(); } - (scene_id, body.into_layer_inserts()) + (scene_id, broadcast_layer_inserts(body)) }; match layer::insert_layers(state.as_ref(), inserts.0, inserts.1, MutationContext::api()).await { @@ -480,64 +383,32 @@ pub async fn patch_layer_controls( } } -impl CreateLayerRequest { - fn into_layer(self, id: SceneLayerId) -> SceneLayer { - SceneLayer { - id, - name: self.name, - source: self.source, - blend: self.blend, - opacity: self.opacity, - transform: self.transform, - adjust: self.adjust, - bindings: self.bindings, - enabled: self.enabled, - } - } -} - -impl UpdateLayerRequest { - fn into_layer(self) -> SceneLayer { - SceneLayer { - id: self.id, - name: self.name, - source: self.source, - blend: self.blend, - opacity: self.opacity, - transform: self.transform, - adjust: self.adjust, - bindings: self.bindings, - enabled: self.enabled, - } - } -} - -impl BroadcastMediaLayerRequest { - fn into_layer_inserts(self) -> Vec { - let source = LayerSource::Media { - asset_id: self.asset_id, - playback: self.playback, - }; - self.targets - .into_iter() - .map(|target| SceneGroupLayerInsert { - group_id: target.zone_id, - layer: SceneLayer { - id: SceneLayerId::new(), - name: self.name.clone(), - source: source.clone(), - blend: self.blend, - opacity: self.opacity, - transform: target.transform, - adjust: target.adjust, - bindings: self.bindings.clone(), - enabled: self.enabled, - }, - index: target.index, - expected_version: target.expected_layers_version, - }) - .collect() - } +/// Expand one broadcast request into a per-zone layer insert list. +fn broadcast_layer_inserts(request: BroadcastMediaLayerRequest) -> Vec { + let source = LayerSource::Media { + asset_id: request.asset_id, + playback: request.playback, + }; + request + .targets + .into_iter() + .map(|target| SceneGroupLayerInsert { + group_id: target.zone_id, + layer: SceneLayer { + id: SceneLayerId::new(), + name: request.name.clone(), + source: source.clone(), + blend: request.blend, + opacity: request.opacity, + transform: target.transform, + adjust: target.adjust, + bindings: request.bindings.clone(), + enabled: request.enabled, + }, + index: target.index, + expected_version: target.expected_layers_version, + }) + .collect() } enum StatusKind { @@ -768,10 +639,6 @@ fn attach_layers_version_headers(mut response: Response, version: u64) -> Respon response } -fn default_layer_opacity() -> f32 { - 1.0 -} - trait LayerSourceExt { fn media_asset_id(&self) -> Option; } @@ -784,7 +651,3 @@ impl LayerSourceExt for LayerSource { } } } - -fn default_true() -> bool { - true -} diff --git a/crates/hypercolor-daemon/src/api/layouts.rs b/crates/hypercolor-daemon/src/api/layouts.rs index f11abb54b..f17371134 100644 --- a/crates/hypercolor-daemon/src/api/layouts.rs +++ b/crates/hypercolor-daemon/src/api/layouts.rs @@ -18,7 +18,7 @@ use hypercolor_core::spatial::SpatialEngine; use hypercolor_types::canvas::SurfaceDescriptor; use hypercolor_types::scene::SceneId; use hypercolor_types::spatial::{Output, SamplingMode, SpatialLayout}; -use serde::{Deserialize, Serialize}; +use serde::Serialize; #[cfg(feature = "persistence-test-hooks")] use tokio::sync::{Notify, Semaphore}; use tracing::warn; @@ -37,6 +37,10 @@ use crate::scene_transactions::{ apply_prepared_layout_update_under_guard_with_persistence, }; +pub use hypercolor_types::api::layouts::{ + CreateLayoutRequest, LayoutListQuery, UpdateLayoutRequest, +}; + // ── Request / Response Types ───────────────────────────────────────────── #[derive(Debug, Serialize)] @@ -168,30 +172,6 @@ impl LayoutMutationTestHooks { } } -#[derive(Debug, Deserialize)] -pub struct CreateLayoutRequest { - pub name: String, - pub description: Option, - pub canvas_width: Option, - pub canvas_height: Option, -} - -#[derive(Debug, Deserialize)] -pub struct UpdateLayoutRequest { - pub name: Option, - pub description: Option, - pub canvas_width: Option, - pub canvas_height: Option, - pub zones: Option>, -} - -#[derive(Debug, Deserialize, Default)] -pub struct LayoutListQuery { - pub offset: Option, - pub limit: Option, - pub active: Option, -} - #[derive(Debug)] enum ResolveLayoutError { AmbiguousName(String), diff --git a/crates/hypercolor-daemon/src/api/library/favorites.rs b/crates/hypercolor-daemon/src/api/library/favorites.rs index 4ab1511d8..840cf1301 100644 --- a/crates/hypercolor-daemon/src/api/library/favorites.rs +++ b/crates/hypercolor-daemon/src/api/library/favorites.rs @@ -7,7 +7,7 @@ use axum::Json; use axum::extract::{Path, State}; use axum::response::{IntoResponse, Response}; use hypercolor_types::event::{HypercolorEvent, LibraryChangeKind, LibraryCollection}; -use serde::{Deserialize, Serialize}; +use serde::Serialize; use crate::api::AppState; use crate::api::effects::resolve_effect_metadata; @@ -16,6 +16,8 @@ use crate::domain::{DomainError, ResourceKind}; use super::unix_epoch_ms; +pub use hypercolor_types::api::library::AddFavoriteRequest; + // ── Request / Response Types ──────────────────────────────────────────── #[derive(Debug, Serialize)] @@ -31,11 +33,6 @@ pub struct FavoriteListResponse { pub pagination: crate::api::devices::Pagination, } -#[derive(Debug, Deserialize)] -pub struct AddFavoriteRequest { - pub effect: String, -} - // ── Handlers ──────────────────────────────────────────────────────────── /// `GET /api/v1/library/favorites` — list favorited effects. diff --git a/crates/hypercolor-daemon/src/api/library/playlists.rs b/crates/hypercolor-daemon/src/api/library/playlists.rs index 1bcda154d..d54561cf4 100644 --- a/crates/hypercolor-daemon/src/api/library/playlists.rs +++ b/crates/hypercolor-daemon/src/api/library/playlists.rs @@ -7,7 +7,7 @@ use std::time::Duration; use axum::Json; use axum::extract::{Path, State}; use axum::response::{IntoResponse, Response}; -use serde::{Deserialize, Serialize}; +use serde::Serialize; use tokio::sync::watch; use tracing::warn; @@ -27,6 +27,10 @@ use super::{ store_error_to_response, unix_epoch_ms, }; +pub use hypercolor_types::api::library::{ + PlaylistItemRequest, PlaylistTargetRequest, SavePlaylistRequest, +}; + const DEFAULT_PLAYLIST_ITEM_DURATION_MS: u64 = 30_000; // ── Request / Response Types ──────────────────────────────────────────── @@ -46,28 +50,6 @@ pub struct ActivePlaylistResponse { pub started_at_ms: u64, } -#[derive(Debug, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum PlaylistTargetRequest { - Effect { effect: String }, - Preset { preset_id: String }, -} - -#[derive(Debug, Deserialize)] -pub struct PlaylistItemRequest { - pub target: PlaylistTargetRequest, - pub duration_ms: Option, - pub transition_ms: Option, -} - -#[derive(Debug, Deserialize)] -pub struct SavePlaylistRequest { - pub name: String, - pub description: Option, - pub loop_enabled: Option, - pub items: Option>, -} - // ── Handlers ──────────────────────────────────────────────────────────── /// `GET /api/v1/library/playlists` — list all playlists. diff --git a/crates/hypercolor-daemon/src/api/library/presets.rs b/crates/hypercolor-daemon/src/api/library/presets.rs index 23a592071..5f096aeeb 100644 --- a/crates/hypercolor-daemon/src/api/library/presets.rs +++ b/crates/hypercolor-daemon/src/api/library/presets.rs @@ -6,7 +6,7 @@ use std::sync::Arc; use axum::Json; use axum::extract::{Path, State}; use axum::response::{IntoResponse, Response}; -use serde::{Deserialize, Serialize}; +use serde::Serialize; use hypercolor_types::effect::{ControlValue, EffectMetadata}; use hypercolor_types::event::{ @@ -26,6 +26,8 @@ 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)] @@ -34,22 +36,6 @@ pub struct PresetListResponse { pub pagination: crate::api::devices::Pagination, } -#[derive(Debug, Deserialize)] -pub struct SavePresetRequest { - pub name: String, - pub description: Option, - pub effect: String, - pub controls: Option, - pub tags: Option>, -} - -/// Optional body for `apply_preset` — scopes the apply to one zone. -#[derive(Debug, Default, Deserialize)] -pub struct ApplyPresetRequest { - /// Target zone id. Omitted targets the primary zone. - pub zone_id: Option, -} - // ── Handlers ──────────────────────────────────────────────────────────── /// `GET /api/v1/library/presets` — list all saved presets. diff --git a/crates/hypercolor-daemon/src/api/profiles.rs b/crates/hypercolor-daemon/src/api/profiles.rs index 1c3a2341d..6a15df8a5 100644 --- a/crates/hypercolor-daemon/src/api/profiles.rs +++ b/crates/hypercolor-daemon/src/api/profiles.rs @@ -11,9 +11,8 @@ use axum::Json; use axum::extract::{Path, State}; use axum::response::{IntoResponse, Response}; use hypercolor_types::event::HypercolorEvent; -use serde::{Deserialize, Serialize}; +use serde::Serialize; use tracing::warn; -use utoipa::ToSchema; use uuid::Uuid; use crate::api::AppState; @@ -31,28 +30,11 @@ use hypercolor_types::library::PresetId; use hypercolor_types::scene::{Zone, ZoneRole}; use hypercolor_types::spatial::SpatialLayout; -// ── Request / Response Types ───────────────────────────────────────────── - -#[derive(Debug, Deserialize)] -pub struct CreateProfileRequest { - pub name: String, - pub description: Option, - pub brightness: Option, - #[serde(default)] - pub force: bool, -} +pub use hypercolor_types::api::profiles::{ + ApplyProfileRequest, CreateProfileRequest, UpdateProfileRequest, +}; -#[derive(Debug, Deserialize)] -pub struct UpdateProfileRequest { - pub name: String, - pub description: Option, - pub brightness: Option, -} - -#[derive(Debug, Deserialize, ToSchema)] -pub struct ApplyProfileRequest { - pub transition_ms: Option, -} +// ── Request / Response Types ───────────────────────────────────────────── #[derive(Debug, Serialize)] pub struct ProfileListResponse { diff --git a/crates/hypercolor-daemon/src/api/settings.rs b/crates/hypercolor-daemon/src/api/settings.rs index 033149be7..2e1724d97 100644 --- a/crates/hypercolor-daemon/src/api/settings.rs +++ b/crates/hypercolor-daemon/src/api/settings.rs @@ -14,9 +14,8 @@ use cpal::traits::{DeviceTrait, HostTrait}; use hypercolor_core::config::canonical_audio_device_id; #[cfg(target_os = "linux")] use hypercolor_core::input::audio::linux; -use serde::{Deserialize, Serialize}; +use serde::Serialize; use tracing::{debug, warn}; -use utoipa::ToSchema; use crate::api::AppState; use crate::api::envelope::ApiResponse; @@ -25,6 +24,8 @@ use crate::domain::DomainError; use crate::session::{current_global_brightness, set_global_brightness}; use hypercolor_types::event::HypercolorEvent; +pub use hypercolor_types::api::settings::SetBrightnessRequest; + #[derive(Debug, Clone, Serialize, PartialEq, Eq)] pub struct AudioDeviceInfo { pub id: String, @@ -43,11 +44,6 @@ pub struct BrightnessSettingsResponse { pub brightness: u8, } -#[derive(Debug, Deserialize, ToSchema)] -pub struct SetBrightnessRequest { - pub brightness: u8, -} - /// `GET /api/v1/audio/devices` — Enumerate audio input devices for the Settings UI. pub async fn list_audio_devices(State(state): State>) -> Response { let current = current_audio_device_id(&state); diff --git a/crates/hypercolor-daemon/src/api/simulators.rs b/crates/hypercolor-daemon/src/api/simulators.rs index fa121dec7..1c1533614 100644 --- a/crates/hypercolor-daemon/src/api/simulators.rs +++ b/crates/hypercolor-daemon/src/api/simulators.rs @@ -9,7 +9,6 @@ use axum::extract::{Path, State}; use axum::http::{HeaderValue, StatusCode, header}; use axum::response::IntoResponse; use axum::response::Response; -use serde::Deserialize; use tracing::warn; use hypercolor_types::canvas::SurfaceDescriptor; @@ -25,6 +24,10 @@ use crate::simulators::{ SimulatedDisplayConfig, activate_simulated_displays, logical_device_ids_for_simulator, }; +pub use hypercolor_types::api::simulators::{ + CreateSimulatedDisplayRequest, UpdateSimulatedDisplayRequest, +}; + struct OwnedDisplayJpeg(Arc>); impl AsRef<[u8]> for OwnedDisplayJpeg { @@ -33,25 +36,6 @@ impl AsRef<[u8]> for OwnedDisplayJpeg { } } -#[derive(Debug, Deserialize)] -pub struct CreateSimulatedDisplayRequest { - pub name: String, - pub width: u32, - pub height: u32, - #[serde(default)] - pub circular: bool, - pub enabled: Option, -} - -#[derive(Debug, Default, Deserialize)] -pub struct UpdateSimulatedDisplayRequest { - pub name: Option, - pub width: Option, - pub height: Option, - pub circular: Option, - pub enabled: Option, -} - pub async fn list_simulated_displays(State(state): State>) -> Response { let store = state.simulated_displays.read().await; ApiResponse::ok(store.list()) diff --git a/crates/hypercolor-types/src/api/assets.rs b/crates/hypercolor-types/src/api/assets.rs new file mode 100644 index 000000000..37f77eedc --- /dev/null +++ b/crates/hypercolor-types/src/api/assets.rs @@ -0,0 +1,26 @@ +//! Media asset API contracts — `/api/v1/assets/*`. + +use serde::{Deserialize, Serialize}; + +/// Query parameters for `POST /api/v1/assets` (multipart upload). +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct AssetUploadQuery { + /// Store a byte-identical upload under a fresh name instead of + /// returning the existing record. + #[serde(default)] + pub rename_duplicate: bool, + /// Explicit asset type hint, overriding sniffing. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub r#type: Option, +} + +/// Request body for `PUT /api/v1/assets/{id}`. +/// +/// Omitted fields leave the stored metadata untouched. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct AssetUpdateRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tags: Option>, +} diff --git a/crates/hypercolor-types/src/api/attachments.rs b/crates/hypercolor-types/src/api/attachments.rs new file mode 100644 index 000000000..1696d0b1a --- /dev/null +++ b/crates/hypercolor-types/src/api/attachments.rs @@ -0,0 +1,35 @@ +//! Component-template catalog API contracts — `/api/v1/attachments/*`. + +use serde::{Deserialize, Serialize}; + +/// Query parameters for `GET /api/v1/attachments/templates`. +/// +/// Every field narrows the catalog; an empty query lists everything the +/// registry knows. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct ListTemplatesQuery { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub offset: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub limit: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub category: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub vendor: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub origin: Option, + /// Free-text filter over template name and description. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub q: Option, + /// Restrict to templates a given controller can host. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub controller_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub slot_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub led_min: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub led_max: Option, +} diff --git a/crates/hypercolor-types/src/api/config.rs b/crates/hypercolor-types/src/api/config.rs new file mode 100644 index 000000000..68ac84d3b --- /dev/null +++ b/crates/hypercolor-types/src/api/config.rs @@ -0,0 +1,29 @@ +//! Config API contracts — `/api/v1/config*`. +//! +//! The config write routes take the value itself as the request body +//! (a section writes as a JSON object, a scalar as a JSON scalar), so +//! the only named request shape here is the apply-mode query. + +use serde::{Deserialize, Serialize}; + +/// Query parameters shared by every config mutation route. +/// +/// Live application is the default: a client that wants the value on +/// disk without disturbing the running daemon asks for `?live=false`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct ConfigApplyQuery { + #[serde(default = "live_apply_default")] + pub live: bool, +} + +impl Default for ConfigApplyQuery { + fn default() -> Self { + Self { + live: live_apply_default(), + } + } +} + +const fn live_apply_default() -> bool { + true +} diff --git a/crates/hypercolor-types/src/api/controls.rs b/crates/hypercolor-types/src/api/controls.rs new file mode 100644 index 000000000..10735fe92 --- /dev/null +++ b/crates/hypercolor-types/src/api/controls.rs @@ -0,0 +1,29 @@ +//! Control-surface API contracts — `/api/v1/control-surfaces/*`. + +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use crate::controls::ControlValueMap; + +/// Query parameters for `GET /api/v1/control-surfaces`. +/// +/// At least one selector must be present; a query that names neither a +/// device nor a driver is rejected. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct ControlSurfaceListQuery { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub device_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub driver_id: Option, + /// Also include the device's owning driver surface. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub include_driver: Option, +} + +/// Request body for +/// `POST /api/v1/control-surfaces/{surface_id}/actions/{action_id}`. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ToSchema)] +pub struct InvokeControlActionRequest { + #[serde(default)] + pub input: ControlValueMap, +} diff --git a/crates/hypercolor-types/src/api/devices.rs b/crates/hypercolor-types/src/api/devices.rs index 46ea597a8..5cf489d43 100644 --- a/crates/hypercolor-types/src/api/devices.rs +++ b/crates/hypercolor-types/src/api/devices.rs @@ -4,9 +4,28 @@ use serde::{Deserialize, Serialize}; use utoipa::ToSchema; use crate::api::common::Pagination; +use crate::attachment::ComponentBinding; use crate::device::{DeviceOrigin, DriverPresentation}; use crate::pairing::DeviceAuthSummary; +/// Query parameters for `GET /api/v1/devices`. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct ListDevicesQuery { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub offset: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub limit: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub backend_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub driver: Option, + /// Free-text filter over device name and model. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub q: Option, +} + /// Response for `GET /api/v1/devices`. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)] pub struct DeviceListResponse { @@ -102,6 +121,80 @@ pub struct IdentifyRequest { pub color: Option, } +/// Request body for +/// `POST /api/v1/devices/{id}/attachments/{component_id}/identify`. +/// +/// Carries the base identify parameters plus the selectors that narrow +/// the blink to one attached component instance. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct IdentifyAttachmentRequest { + #[serde(flatten)] + pub base: IdentifyRequest, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub binding_index: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub instance: Option, +} + +/// Request body for `PUT /api/v1/devices/{id}/attachments`. +/// +/// The binding list replaces the device's attachments wholesale. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct UpdateAttachmentsRequest { + #[serde(default)] + pub bindings: Vec, +} + +/// Optional body for `POST /api/v1/devices/discover`. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +pub struct DiscoverRequest { + /// Discovery targets to scan; omitted scans every enabled target. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub targets: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timeout_ms: Option, + /// Block until the scan finishes instead of returning a scan id. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub wait: Option, +} + +/// Query parameters for `GET /api/v1/logical-devices`. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct ListLogicalDevicesQuery { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub offset: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub limit: Option, + /// Filter to the logical devices carved out of one physical device. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub physical_device: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, +} + +/// Request body for `POST /api/v1/devices/{id}/logical-devices`. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct CreateLogicalDeviceRequest { + pub name: String, + pub led_start: u32, + pub led_count: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, +} + +/// Request body for `PUT /api/v1/logical-devices/{id}`. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct UpdateLogicalDeviceRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub led_start: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub led_count: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, +} + /// Response for `GET /api/v1/devices/bindings`. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] pub struct DeviceBindingsResponse { diff --git a/crates/hypercolor-types/src/api/diagnose.rs b/crates/hypercolor-types/src/api/diagnose.rs new file mode 100644 index 000000000..53e59e00b --- /dev/null +++ b/crates/hypercolor-types/src/api/diagnose.rs @@ -0,0 +1,15 @@ +//! Diagnostics API contracts — `/api/v1/diagnose`. + +use serde::{Deserialize, Serialize}; + +/// Optional body for `POST /api/v1/diagnose`. +/// +/// Omitting `checks` runs the full check set; `system` adds the host +/// environment section to the report. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct DiagnoseRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub checks: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub system: Option, +} diff --git a/crates/hypercolor-types/src/api/displays.rs b/crates/hypercolor-types/src/api/displays.rs new file mode 100644 index 000000000..a6feaf703 --- /dev/null +++ b/crates/hypercolor-types/src/api/displays.rs @@ -0,0 +1,65 @@ +//! Display-face API contracts — `/api/v1/displays/*`. + +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +use crate::effect::ControlValue; +use crate::scene::DisplayFaceBlendMode; + +/// Which assignment layer a face operation targets (spec 69 §3.6). +/// +/// `default` persists across scenes (the display's own face); `scene` +/// writes into the active scene's display zone, which always wins while +/// that scene is active. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DisplayFaceScope { + #[default] + Default, + Scene, +} + +/// Request body for `PUT /api/v1/displays/{id}/face`. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct SetDisplayFaceRequest { + pub effect_id: String, + #[serde(default)] + pub controls: HashMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub blend_mode: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub opacity: Option, + #[serde(default)] + pub scope: DisplayFaceScope, +} + +/// Query parameters for `DELETE /api/v1/displays/{id}/face`. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct DisplayFaceScopeQuery { + #[serde(default)] + pub scope: DisplayFaceScope, +} + +/// Request body for `PATCH /api/v1/displays/{id}/face/controls`. +/// +/// The payload carries only the overrides the caller wants to change; +/// existing control values on the zone are preserved unless their +/// key appears in this map. `controls` is typed as raw JSON (rather than +/// `HashMap`) so callers can send natural shapes +/// like `{"accent": 0.5}` instead of `{"accent": {"float": 0.5}}`, which +/// mirrors the effects controls patch endpoint. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct UpdateDisplayFaceControlsRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub controls: Option, +} + +/// Request body for `PATCH /api/v1/displays/{id}/face/composition`. +#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)] +pub struct UpdateDisplayFaceCompositionRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub blend_mode: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub opacity: Option, +} diff --git a/crates/hypercolor-types/src/api/effects.rs b/crates/hypercolor-types/src/api/effects.rs index 3ba4a29ae..eb379dc38 100644 --- a/crates/hypercolor-types/src/api/effects.rs +++ b/crates/hypercolor-types/src/api/effects.rs @@ -204,6 +204,13 @@ pub struct UpdateActiveControlsRequest { pub controls: Option, } +/// Request body for `PUT /api/v1/effects/{id}/layout`. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct SetEffectLayoutRequest { + /// The spatial layout to associate with the effect. + pub layout_id: String, +} + /// 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/layers.rs b/crates/hypercolor-types/src/api/layers.rs new file mode 100644 index 000000000..8fade14e0 --- /dev/null +++ b/crates/hypercolor-types/src/api/layers.rs @@ -0,0 +1,179 @@ +//! Scene layer API contracts — `/api/v1/scenes/{id}/zones/*/layers/*`. + +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use crate::asset::AssetId; +use crate::layer::{ + LayerAdjust, LayerBinding, LayerBlendMode, LayerSource, LayerTransform, MediaPlayback, + SceneLayer, SceneLayerId, default_layer_opacity, default_true, +}; +use crate::scene::ZoneId; + +/// Query parameters for +/// `POST /api/v1/scenes/{id}/zones/{zone_id}/layers`. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct CreateLayerQuery { + /// Stack position for the new layer; omitted appends on top. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub index: Option, +} + +/// Request body for +/// `POST /api/v1/scenes/{id}/zones/{zone_id}/layers`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)] +pub struct CreateLayerRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + #[schema(value_type = Object)] + pub source: LayerSource, + #[serde(default)] + #[schema(value_type = String)] + pub blend: LayerBlendMode, + #[serde(default = "default_layer_opacity")] + pub opacity: f32, + #[serde(default)] + #[schema(value_type = Object)] + pub transform: LayerTransform, + #[serde(default)] + #[schema(value_type = Object)] + pub adjust: LayerAdjust, + #[serde(default)] + #[schema(value_type = Vec)] + pub bindings: Vec, + #[serde(default = "default_true")] + pub enabled: bool, +} + +/// Request body for +/// `PUT /api/v1/scenes/{id}/zones/{zone_id}/layers/{layer_id}`. +/// +/// The whole layer is replaced, so every field the caller wants to keep +/// must be echoed back. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)] +pub struct UpdateLayerRequest { + #[schema(value_type = String)] + pub id: SceneLayerId, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + #[schema(value_type = Object)] + pub source: LayerSource, + #[serde(default)] + #[schema(value_type = String)] + pub blend: LayerBlendMode, + #[serde(default = "default_layer_opacity")] + pub opacity: f32, + #[serde(default)] + #[schema(value_type = Object)] + pub transform: LayerTransform, + #[serde(default)] + #[schema(value_type = Object)] + pub adjust: LayerAdjust, + #[serde(default)] + #[schema(value_type = Vec)] + pub bindings: Vec, + #[serde(default = "default_true")] + pub enabled: bool, +} + +impl CreateLayerRequest { + /// Build the scene layer this request describes under a fresh id. + #[must_use] + pub fn into_layer(self, id: SceneLayerId) -> SceneLayer { + SceneLayer { + id, + name: self.name, + source: self.source, + blend: self.blend, + opacity: self.opacity, + transform: self.transform, + adjust: self.adjust, + bindings: self.bindings, + enabled: self.enabled, + } + } +} + +impl UpdateLayerRequest { + /// Build the replacement scene layer this request describes. + #[must_use] + pub fn into_layer(self) -> SceneLayer { + SceneLayer { + id: self.id, + name: self.name, + source: self.source, + blend: self.blend, + opacity: self.opacity, + transform: self.transform, + adjust: self.adjust, + bindings: self.bindings, + enabled: self.enabled, + } + } +} + +/// Request body for +/// `PUT /api/v1/scenes/{id}/zones/{zone_id}/layers/order`. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +pub struct LayerOrderRequest { + /// The zone's layers, bottom to top. + #[schema(value_type = Vec)] + pub layer_ids: Vec, +} + +/// Request body for +/// `PATCH /api/v1/scenes/{id}/zones/{zone_id}/layers/{layer_id}/controls`. +/// `controls` carries no `#[serde(default)]` on purpose: the schema this +/// route publishes marks it required, and serde still admits an absent +/// field through `Option`'s own default. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ToSchema)] +pub struct PatchLayerControlsRequest { + #[schema(value_type = Object)] + pub controls: Option, +} + +/// One zone targeted by a broadcast media layer create. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)] +pub struct BroadcastMediaLayerTarget { + #[schema(value_type = String)] + pub zone_id: ZoneId, + #[serde(default)] + #[schema(value_type = Object)] + pub transform: LayerTransform, + #[serde(default)] + #[schema(value_type = Object)] + pub adjust: LayerAdjust, + /// Stack position within this zone; omitted appends on top. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub index: Option, + /// Per-zone optimistic-concurrency precondition. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expected_layers_version: Option, +} + +/// Request body for `POST /api/v1/scenes/{id}/layers/broadcast-media`. +/// +/// Creates one media layer per target zone in a single transaction. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)] +pub struct BroadcastMediaLayerRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + #[schema(value_type = String)] + pub asset_id: AssetId, + #[serde(default)] + #[schema(value_type = Object)] + pub playback: MediaPlayback, + #[serde(default)] + #[schema(value_type = String)] + pub blend: LayerBlendMode, + #[serde(default = "default_layer_opacity")] + pub opacity: f32, + #[serde(default)] + #[schema(value_type = Vec)] + pub bindings: Vec, + #[serde(default = "default_true")] + pub enabled: bool, + #[serde(default)] + #[schema(value_type = Vec)] + pub targets: Vec, +} diff --git a/crates/hypercolor-types/src/api/layouts.rs b/crates/hypercolor-types/src/api/layouts.rs new file mode 100644 index 000000000..ade4beb51 --- /dev/null +++ b/crates/hypercolor-types/src/api/layouts.rs @@ -0,0 +1,47 @@ +//! Spatial layout API contracts — `/api/v1/layouts/*`. + +use serde::{Deserialize, Serialize}; + +use crate::spatial::Output; + +/// Query parameters for `GET /api/v1/layouts`. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct LayoutListQuery { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub offset: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub limit: Option, + /// Restrict the list to the layout the daemon is rendering with. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub active: Option, +} + +/// Request body for `POST /api/v1/layouts`. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct CreateLayoutRequest { + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub canvas_width: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub canvas_height: Option, +} + +/// Request body for `PUT /api/v1/layouts/{id}`. +/// +/// Omitted fields leave the stored layout untouched; a present `zones` +/// list replaces the layout's outputs wholesale. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct UpdateLayoutRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub canvas_width: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub canvas_height: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub zones: Option>, +} diff --git a/crates/hypercolor-types/src/api/library.rs b/crates/hypercolor-types/src/api/library.rs new file mode 100644 index 000000000..e814d1dd9 --- /dev/null +++ b/crates/hypercolor-types/src/api/library.rs @@ -0,0 +1,65 @@ +//! Library API contracts — `/api/v1/library/*`. + +use serde::{Deserialize, Serialize}; + +/// Request body for `POST /api/v1/library/favorites`. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct AddFavoriteRequest { + /// Effect id to favorite. + pub effect: String, +} + +/// What one playlist item plays. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum PlaylistTargetRequest { + Effect { effect: String }, + Preset { preset_id: String }, +} + +/// One item in a saved playlist. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PlaylistItemRequest { + pub target: PlaylistTargetRequest, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub duration_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub transition_ms: Option, +} + +/// Request body for `POST /api/v1/library/playlists` and +/// `PUT /api/v1/library/playlists/{id}`. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct SavePlaylistRequest { + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub loop_enabled: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub items: Option>, +} + +/// Request body for `POST /api/v1/library/presets` and +/// `PUT /api/v1/library/presets/{id}`. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct SavePresetRequest { + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + /// Effect the preset's control values belong to. + pub effect: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub controls: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tags: Option>, +} + +/// Optional body for `POST /api/v1/library/presets/{id}/apply` — scopes +/// the apply to one zone. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct ApplyPresetRequest { + /// Target zone id. Omitted targets the primary zone. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub zone_id: Option, +} diff --git a/crates/hypercolor-types/src/api/mod.rs b/crates/hypercolor-types/src/api/mod.rs index d83e0eb1c..e626720c0 100644 --- a/crates/hypercolor-types/src/api/mod.rs +++ b/crates/hypercolor-types/src/api/mod.rs @@ -18,12 +18,24 @@ //! does NOT — those shapes move fast with perf work, and clients consume //! tolerant subsets of them by design. +pub mod assets; +pub mod attachments; pub mod common; +pub mod config; +pub mod controls; pub mod devices; +pub mod diagnose; +pub mod displays; pub mod effects; pub mod envelope; +pub mod layers; +pub mod layouts; +pub mod library; pub mod output; +pub mod profiles; pub mod scenes; +pub mod settings; +pub mod simulators; pub mod zones; pub use common::Pagination; diff --git a/crates/hypercolor-types/src/api/profiles.rs b/crates/hypercolor-types/src/api/profiles.rs new file mode 100644 index 000000000..ecfb97ca3 --- /dev/null +++ b/crates/hypercolor-types/src/api/profiles.rs @@ -0,0 +1,34 @@ +//! Profile API contracts — `/api/v1/profiles/*`. + +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +/// Request body for `POST /api/v1/profiles`. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct CreateProfileRequest { + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub brightness: Option, + /// Overwrite an existing profile with the same name. + #[serde(default)] + pub force: bool, +} + +/// Request body for `PUT /api/v1/profiles/{id}`. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct UpdateProfileRequest { + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub brightness: Option, +} + +/// Optional body for `POST /api/v1/profiles/{id}/apply`. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +pub struct ApplyProfileRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub transition_ms: Option, +} diff --git a/crates/hypercolor-types/src/api/settings.rs b/crates/hypercolor-types/src/api/settings.rs new file mode 100644 index 000000000..3b142b8dd --- /dev/null +++ b/crates/hypercolor-types/src/api/settings.rs @@ -0,0 +1,11 @@ +//! Global settings API contracts — `/api/v1/settings/*`. + +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +/// Request body for `PUT /api/v1/settings/brightness`. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +pub struct SetBrightnessRequest { + /// Master brightness percentage, 0-100. + pub brightness: u8, +} diff --git a/crates/hypercolor-types/src/api/simulators.rs b/crates/hypercolor-types/src/api/simulators.rs new file mode 100644 index 000000000..3801e3671 --- /dev/null +++ b/crates/hypercolor-types/src/api/simulators.rs @@ -0,0 +1,32 @@ +//! Simulated-display API contracts — `/api/v1/simulators/*`. + +use serde::{Deserialize, Serialize}; + +/// Request body for `POST /api/v1/simulators/displays`. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct CreateSimulatedDisplayRequest { + pub name: String, + pub width: u32, + pub height: u32, + #[serde(default)] + pub circular: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, +} + +/// Request body for `PATCH /api/v1/simulators/displays/{id}`. +/// +/// Omitted fields leave the simulated display untouched. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct UpdateSimulatedDisplayRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub width: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub height: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub circular: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, +} diff --git a/crates/hypercolor-types/src/layer.rs b/crates/hypercolor-types/src/layer.rs index 66e20da21..23171d32b 100644 --- a/crates/hypercolor-types/src/layer.rs +++ b/crates/hypercolor-types/src/layer.rs @@ -569,7 +569,7 @@ impl Default for BindingMap { } } -fn default_layer_opacity() -> f32 { +pub(crate) fn default_layer_opacity() -> f32 { 1.0 } @@ -577,7 +577,7 @@ fn default_playback_speed() -> f32 { 1.0 } -fn default_true() -> bool { +pub(crate) fn default_true() -> bool { true } diff --git a/python/src/hypercolor/_generated/api/controls/invoke_control_surface_action.py b/python/src/hypercolor/_generated/api/controls/invoke_control_surface_action.py index d3dcefa6e..e7648d5e4 100644 --- a/python/src/hypercolor/_generated/api/controls/invoke_control_surface_action.py +++ b/python/src/hypercolor/_generated/api/controls/invoke_control_surface_action.py @@ -87,7 +87,8 @@ def sync_detailed( Args: surface_id (str): action_id (str): - body (InvokeControlActionRequest): + body (InvokeControlActionRequest): Request body for + `POST /api/v1/control-surfaces/{surface_id}/actions/{action_id}`. Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. @@ -122,7 +123,8 @@ async def asyncio_detailed( Args: surface_id (str): action_id (str): - body (InvokeControlActionRequest): + body (InvokeControlActionRequest): Request body for + `POST /api/v1/control-surfaces/{surface_id}/actions/{action_id}`. Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. diff --git a/python/src/hypercolor/_generated/api/devices/discover_devices.py b/python/src/hypercolor/_generated/api/devices/discover_devices.py index 7c4291457..130d9639d 100644 --- a/python/src/hypercolor/_generated/api/devices/discover_devices.py +++ b/python/src/hypercolor/_generated/api/devices/discover_devices.py @@ -78,7 +78,7 @@ def sync_detailed( """Start device discovery Args: - body (DiscoverRequest | Unset): + body (DiscoverRequest | Unset): Optional body for `POST /api/v1/devices/discover`. Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. @@ -107,7 +107,7 @@ async def asyncio_detailed( """Start device discovery Args: - body (DiscoverRequest | Unset): + body (DiscoverRequest | Unset): Optional body for `POST /api/v1/devices/discover`. Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. diff --git a/python/src/hypercolor/_generated/api/profiles/apply_profile.py b/python/src/hypercolor/_generated/api/profiles/apply_profile.py index db828bb77..07dbd0e26 100644 --- a/python/src/hypercolor/_generated/api/profiles/apply_profile.py +++ b/python/src/hypercolor/_generated/api/profiles/apply_profile.py @@ -84,7 +84,7 @@ def sync_detailed( Args: id (str): - body (ApplyProfileRequest | Unset): + body (ApplyProfileRequest | Unset): Optional body for `POST /api/v1/profiles/{id}/apply`. Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. @@ -116,7 +116,7 @@ async def asyncio_detailed( Args: id (str): - body (ApplyProfileRequest | Unset): + body (ApplyProfileRequest | Unset): Optional body for `POST /api/v1/profiles/{id}/apply`. Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. diff --git a/python/src/hypercolor/_generated/api/scenes/broadcast_media_layer.py b/python/src/hypercolor/_generated/api/scenes/broadcast_media_layer.py index f17111a95..a0359aa4c 100644 --- a/python/src/hypercolor/_generated/api/scenes/broadcast_media_layer.py +++ b/python/src/hypercolor/_generated/api/scenes/broadcast_media_layer.py @@ -83,7 +83,10 @@ def sync_detailed( Args: id (str): - body (BroadcastMediaLayerRequest): + body (BroadcastMediaLayerRequest): Request body for `POST + /api/v1/scenes/{id}/layers/broadcast-media`. + + Creates one media layer per target zone in a single transaction. Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. @@ -115,7 +118,10 @@ async def asyncio_detailed( Args: id (str): - body (BroadcastMediaLayerRequest): + body (BroadcastMediaLayerRequest): Request body for `POST + /api/v1/scenes/{id}/layers/broadcast-media`. + + Creates one media layer per target zone in a single transaction. Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. diff --git a/python/src/hypercolor/_generated/api/scenes/create_layer.py b/python/src/hypercolor/_generated/api/scenes/create_layer.py index 704747993..10dcf7d23 100644 --- a/python/src/hypercolor/_generated/api/scenes/create_layer.py +++ b/python/src/hypercolor/_generated/api/scenes/create_layer.py @@ -87,7 +87,8 @@ def sync_detailed( Args: id (str): zone_id (str): - body (CreateLayerRequest): + body (CreateLayerRequest): Request body for + `POST /api/v1/scenes/{id}/zones/{zone_id}/layers`. Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. @@ -122,7 +123,8 @@ async def asyncio_detailed( Args: id (str): zone_id (str): - body (CreateLayerRequest): + body (CreateLayerRequest): Request body for + `POST /api/v1/scenes/{id}/zones/{zone_id}/layers`. Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. diff --git a/python/src/hypercolor/_generated/api/scenes/patch_layer_controls.py b/python/src/hypercolor/_generated/api/scenes/patch_layer_controls.py index bc3b68613..95a3da264 100644 --- a/python/src/hypercolor/_generated/api/scenes/patch_layer_controls.py +++ b/python/src/hypercolor/_generated/api/scenes/patch_layer_controls.py @@ -91,7 +91,11 @@ def sync_detailed( id (str): zone_id (str): layer_id (str): - body (PatchLayerControlsRequest): + body (PatchLayerControlsRequest): Request body for + `PATCH /api/v1/scenes/{id}/zones/{zone_id}/layers/{layer_id}/controls`. + `controls` carries no `#[serde(default)]` on purpose: the schema this + route publishes marks it required, and serde still admits an absent + field through `Option`'s own default. Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. @@ -129,7 +133,11 @@ async def asyncio_detailed( id (str): zone_id (str): layer_id (str): - body (PatchLayerControlsRequest): + body (PatchLayerControlsRequest): Request body for + `PATCH /api/v1/scenes/{id}/zones/{zone_id}/layers/{layer_id}/controls`. + `controls` carries no `#[serde(default)]` on purpose: the schema this + route publishes marks it required, and serde still admits an absent + field through `Option`'s own default. Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. diff --git a/python/src/hypercolor/_generated/api/scenes/reorder_layers.py b/python/src/hypercolor/_generated/api/scenes/reorder_layers.py index a8ccb7fd2..c8f72b192 100644 --- a/python/src/hypercolor/_generated/api/scenes/reorder_layers.py +++ b/python/src/hypercolor/_generated/api/scenes/reorder_layers.py @@ -87,7 +87,8 @@ def sync_detailed( Args: id (str): zone_id (str): - body (LayerOrderRequest): + body (LayerOrderRequest): Request body for + `PUT /api/v1/scenes/{id}/zones/{zone_id}/layers/order`. Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. @@ -122,7 +123,8 @@ async def asyncio_detailed( Args: id (str): zone_id (str): - body (LayerOrderRequest): + body (LayerOrderRequest): Request body for + `PUT /api/v1/scenes/{id}/zones/{zone_id}/layers/order`. Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. diff --git a/python/src/hypercolor/_generated/api/scenes/update_layer.py b/python/src/hypercolor/_generated/api/scenes/update_layer.py index 23597516a..f98dc3fc1 100644 --- a/python/src/hypercolor/_generated/api/scenes/update_layer.py +++ b/python/src/hypercolor/_generated/api/scenes/update_layer.py @@ -91,7 +91,11 @@ def sync_detailed( id (str): zone_id (str): layer_id (str): - body (UpdateLayerRequest): + body (UpdateLayerRequest): Request body for + `PUT /api/v1/scenes/{id}/zones/{zone_id}/layers/{layer_id}`. + + The whole layer is replaced, so every field the caller wants to keep + must be echoed back. Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. @@ -129,7 +133,11 @@ async def asyncio_detailed( id (str): zone_id (str): layer_id (str): - body (UpdateLayerRequest): + body (UpdateLayerRequest): Request body for + `PUT /api/v1/scenes/{id}/zones/{zone_id}/layers/{layer_id}`. + + The whole layer is replaced, so every field the caller wants to keep + must be echoed back. Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. diff --git a/python/src/hypercolor/_generated/api/settings/set_brightness.py b/python/src/hypercolor/_generated/api/settings/set_brightness.py index 1ea0d2114..8f6a090da 100644 --- a/python/src/hypercolor/_generated/api/settings/set_brightness.py +++ b/python/src/hypercolor/_generated/api/settings/set_brightness.py @@ -77,7 +77,7 @@ def sync_detailed( """Set global brightness Args: - body (SetBrightnessRequest): + body (SetBrightnessRequest): Request body for `PUT /api/v1/settings/brightness`. Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. @@ -106,7 +106,7 @@ async def asyncio_detailed( """Set global brightness Args: - body (SetBrightnessRequest): + body (SetBrightnessRequest): Request body for `PUT /api/v1/settings/brightness`. Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. diff --git a/python/src/hypercolor/_generated/models/apply_profile_request.py b/python/src/hypercolor/_generated/models/apply_profile_request.py index cd20454e9..78993da47 100644 --- a/python/src/hypercolor/_generated/models/apply_profile_request.py +++ b/python/src/hypercolor/_generated/models/apply_profile_request.py @@ -13,7 +13,8 @@ @_attrs_define class ApplyProfileRequest: - """ + """Optional body for `POST /api/v1/profiles/{id}/apply`. + Attributes: transition_ms (int | None | Unset): """ diff --git a/python/src/hypercolor/_generated/models/broadcast_media_layer_request.py b/python/src/hypercolor/_generated/models/broadcast_media_layer_request.py index 75e8531b5..f25643d4f 100644 --- a/python/src/hypercolor/_generated/models/broadcast_media_layer_request.py +++ b/python/src/hypercolor/_generated/models/broadcast_media_layer_request.py @@ -25,16 +25,19 @@ @_attrs_define class BroadcastMediaLayerRequest: - """ - Attributes: - asset_id (str): - bindings (list[BroadcastMediaLayerRequestBindingsItem] | Unset): - blend (str | Unset): - enabled (bool | Unset): - name (None | str | Unset): - opacity (float | Unset): - playback (BroadcastMediaLayerRequestPlayback | Unset): - targets (list[BroadcastMediaLayerRequestTargetsItem] | Unset): + """Request body for `POST /api/v1/scenes/{id}/layers/broadcast-media`. + + Creates one media layer per target zone in a single transaction. + + Attributes: + asset_id (str): + bindings (list[BroadcastMediaLayerRequestBindingsItem] | Unset): + blend (str | Unset): + enabled (bool | Unset): + name (None | str | Unset): + opacity (float | Unset): + playback (BroadcastMediaLayerRequestPlayback | Unset): + targets (list[BroadcastMediaLayerRequestTargetsItem] | Unset): """ asset_id: str diff --git a/python/src/hypercolor/_generated/models/broadcast_media_layer_target.py b/python/src/hypercolor/_generated/models/broadcast_media_layer_target.py index d3dfed8fa..5c1fd8b97 100644 --- a/python/src/hypercolor/_generated/models/broadcast_media_layer_target.py +++ b/python/src/hypercolor/_generated/models/broadcast_media_layer_target.py @@ -22,12 +22,13 @@ @_attrs_define class BroadcastMediaLayerTarget: - """ + """One zone targeted by a broadcast media layer create. + Attributes: zone_id (str): adjust (BroadcastMediaLayerTargetAdjust | Unset): - expected_layers_version (int | None | Unset): - index (int | None | Unset): + expected_layers_version (int | None | Unset): Per-zone optimistic-concurrency precondition. + index (int | None | Unset): Stack position within this zone; omitted appends on top. transform (BroadcastMediaLayerTargetTransform | Unset): """ diff --git a/python/src/hypercolor/_generated/models/create_layer_request.py b/python/src/hypercolor/_generated/models/create_layer_request.py index 25e78f089..1110c89b2 100644 --- a/python/src/hypercolor/_generated/models/create_layer_request.py +++ b/python/src/hypercolor/_generated/models/create_layer_request.py @@ -22,16 +22,18 @@ @_attrs_define class CreateLayerRequest: - """ - Attributes: - source (CreateLayerRequestSource): - adjust (CreateLayerRequestAdjust | Unset): - bindings (list[CreateLayerRequestBindingsItem] | Unset): - blend (str | Unset): - enabled (bool | Unset): - name (None | str | Unset): - opacity (float | Unset): - transform (CreateLayerRequestTransform | Unset): + """Request body for + `POST /api/v1/scenes/{id}/zones/{zone_id}/layers`. + + Attributes: + source (CreateLayerRequestSource): + adjust (CreateLayerRequestAdjust | Unset): + bindings (list[CreateLayerRequestBindingsItem] | Unset): + blend (str | Unset): + enabled (bool | Unset): + name (None | str | Unset): + opacity (float | Unset): + transform (CreateLayerRequestTransform | Unset): """ source: CreateLayerRequestSource diff --git a/python/src/hypercolor/_generated/models/discover_request.py b/python/src/hypercolor/_generated/models/discover_request.py index f293253e8..169eefcc6 100644 --- a/python/src/hypercolor/_generated/models/discover_request.py +++ b/python/src/hypercolor/_generated/models/discover_request.py @@ -13,11 +13,12 @@ @_attrs_define class DiscoverRequest: - """ + """Optional body for `POST /api/v1/devices/discover`. + Attributes: - targets (list[str] | None | Unset): + targets (list[str] | None | Unset): Discovery targets to scan; omitted scans every enabled target. timeout_ms (int | None | Unset): - wait (bool | None | Unset): + wait (bool | None | Unset): Block until the scan finishes instead of returning a scan id. """ targets: list[str] | None | Unset = UNSET diff --git a/python/src/hypercolor/_generated/models/invoke_control_action_request.py b/python/src/hypercolor/_generated/models/invoke_control_action_request.py index 653aea4cb..7969378b6 100644 --- a/python/src/hypercolor/_generated/models/invoke_control_action_request.py +++ b/python/src/hypercolor/_generated/models/invoke_control_action_request.py @@ -17,9 +17,11 @@ @_attrs_define class InvokeControlActionRequest: - """ - Attributes: - input_ (BTreeMap | Unset): + """Request body for + `POST /api/v1/control-surfaces/{surface_id}/actions/{action_id}`. + + Attributes: + input_ (BTreeMap | Unset): """ input_: BTreeMap | Unset = UNSET diff --git a/python/src/hypercolor/_generated/models/layer_order_request.py b/python/src/hypercolor/_generated/models/layer_order_request.py index 11780084f..d2f41427f 100644 --- a/python/src/hypercolor/_generated/models/layer_order_request.py +++ b/python/src/hypercolor/_generated/models/layer_order_request.py @@ -11,9 +11,11 @@ @_attrs_define class LayerOrderRequest: - """ - Attributes: - layer_ids (list[str]): + """Request body for + `PUT /api/v1/scenes/{id}/zones/{zone_id}/layers/order`. + + Attributes: + layer_ids (list[str]): The zone's layers, bottom to top. """ layer_ids: list[str] diff --git a/python/src/hypercolor/_generated/models/patch_layer_controls_request.py b/python/src/hypercolor/_generated/models/patch_layer_controls_request.py index 37d4629f0..b0d7f5f56 100644 --- a/python/src/hypercolor/_generated/models/patch_layer_controls_request.py +++ b/python/src/hypercolor/_generated/models/patch_layer_controls_request.py @@ -17,9 +17,14 @@ @_attrs_define class PatchLayerControlsRequest: - """ - Attributes: - controls (PatchLayerControlsRequestControls): + """Request body for + `PATCH /api/v1/scenes/{id}/zones/{zone_id}/layers/{layer_id}/controls`. + `controls` carries no `#[serde(default)]` on purpose: the schema this + route publishes marks it required, and serde still admits an absent + field through `Option`'s own default. + + Attributes: + controls (PatchLayerControlsRequestControls): """ controls: PatchLayerControlsRequestControls diff --git a/python/src/hypercolor/_generated/models/set_brightness_request.py b/python/src/hypercolor/_generated/models/set_brightness_request.py index 68bfbb3ca..ca47da114 100644 --- a/python/src/hypercolor/_generated/models/set_brightness_request.py +++ b/python/src/hypercolor/_generated/models/set_brightness_request.py @@ -11,9 +11,10 @@ @_attrs_define class SetBrightnessRequest: - """ + """Request body for `PUT /api/v1/settings/brightness`. + Attributes: - brightness (int): + brightness (int): Master brightness percentage, 0-100. """ brightness: int diff --git a/python/src/hypercolor/_generated/models/update_layer_request.py b/python/src/hypercolor/_generated/models/update_layer_request.py index d69771859..2c9907461 100644 --- a/python/src/hypercolor/_generated/models/update_layer_request.py +++ b/python/src/hypercolor/_generated/models/update_layer_request.py @@ -22,17 +22,22 @@ @_attrs_define class UpdateLayerRequest: - """ - Attributes: - id (str): - source (UpdateLayerRequestSource): - adjust (UpdateLayerRequestAdjust | Unset): - bindings (list[UpdateLayerRequestBindingsItem] | Unset): - blend (str | Unset): - enabled (bool | Unset): - name (None | str | Unset): - opacity (float | Unset): - transform (UpdateLayerRequestTransform | Unset): + """Request body for + `PUT /api/v1/scenes/{id}/zones/{zone_id}/layers/{layer_id}`. + + The whole layer is replaced, so every field the caller wants to keep + must be echoed back. + + Attributes: + id (str): + source (UpdateLayerRequestSource): + adjust (UpdateLayerRequestAdjust | Unset): + bindings (list[UpdateLayerRequestBindingsItem] | Unset): + blend (str | Unset): + enabled (bool | Unset): + name (None | str | Unset): + opacity (float | Unset): + transform (UpdateLayerRequestTransform | Unset): """ id: str From 7aa5aa28e98bea83af3dd952bf7232b3f9643daf Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Mon, 17 Aug 2026 02:13:19 -0700 Subject: [PATCH 2/6] refactor(cli): build daemon request bodies from the shared types The CLI had no dependency on hypercolor-types, so every request body was assembled with serde_json::json! against field names copied from the daemon by eye. It now depends on the types crate and constructs the shared request structs directly, which puts the daemon's contract behind the compiler for brightness, diagnose, discovery, identify, pairing, control-surface values and actions, effect apply, output power, active controls, control reset, effect layout, favorites, presets, playlists, profiles, and scene creation. Control values get the largest win. The CLI hand-wrote the driver algebra's kind/value tagging in fourteen match arms; parse_control_value now returns a real ControlValue and serde emits the same tagging, so a new variant is a compile error instead of a typo waiting to happen. Emitted bodies stay semantically identical. The one visible difference is that omitted optional fields are now absent rather than explicitly null, which every daemon type already accepted through Option. The pinned request-shape suite passes unedited. Two call sites keep an untyped body because no daemon contract accepts what they send, each now carrying a comment saying so: devices set-color posts a color field the device update contract does not define, and scenes activate posts transition_ms to a route with no request body. Both are pre-existing defects that need a contract decision. Co-Authored-By: Nova (Claude Opus 5) --- Cargo.lock | 1 + crates/hypercolor-cli/Cargo.toml | 1 + .../hypercolor-cli/src/commands/brightness.rs | 5 +- .../hypercolor-cli/src/commands/controls.rs | 136 ++++++++---------- crates/hypercolor-cli/src/commands/devices.rs | 46 +++--- .../hypercolor-cli/src/commands/diagnose.rs | 17 +-- crates/hypercolor-cli/src/commands/drivers.rs | 20 +-- crates/hypercolor-cli/src/commands/effects.rs | 54 ++++--- crates/hypercolor-cli/src/commands/library.rs | 62 ++++---- .../hypercolor-cli/src/commands/profiles.rs | 16 ++- crates/hypercolor-cli/src/commands/scenes.rs | 22 +-- 11 files changed, 201 insertions(+), 179 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a9396acee..1064265e5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4986,6 +4986,7 @@ dependencies = [ "hypercolor-core", "hypercolor-daemon", "hypercolor-tui", + "hypercolor-types", "opaline", "open", "owo-colors", diff --git a/crates/hypercolor-cli/Cargo.toml b/crates/hypercolor-cli/Cargo.toml index 887576769..109a7dabf 100644 --- a/crates/hypercolor-cli/Cargo.toml +++ b/crates/hypercolor-cli/Cargo.toml @@ -22,6 +22,7 @@ tui = ["dep:hypercolor-tui"] [dependencies] hypercolor-color = { workspace = true } hypercolor-core = { workspace = true } +hypercolor-types = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } anyhow = { workspace = true } diff --git a/crates/hypercolor-cli/src/commands/brightness.rs b/crates/hypercolor-cli/src/commands/brightness.rs index 1e02b727d..49a0cf6ac 100644 --- a/crates/hypercolor-cli/src/commands/brightness.rs +++ b/crates/hypercolor-cli/src/commands/brightness.rs @@ -2,6 +2,7 @@ use anyhow::Result; use clap::{Args, Subcommand}; +use hypercolor_types::api::settings::SetBrightnessRequest; use crate::client::DaemonClient; use crate::output::{OutputContext, OutputFormat}; @@ -62,7 +63,9 @@ async fn execute_set( client: &DaemonClient, ctx: &OutputContext, ) -> Result<()> { - let body = serde_json::json!({ "brightness": args.value.min(100) }); + let body = SetBrightnessRequest { + brightness: u8::try_from(args.value.min(100)).unwrap_or(100), + }; let response = client.put("/settings/brightness", &body).await?; match ctx.format { diff --git a/crates/hypercolor-cli/src/commands/controls.rs b/crates/hypercolor-cli/src/commands/controls.rs index e268f412e..2467a4b99 100644 --- a/crates/hypercolor-cli/src/commands/controls.rs +++ b/crates/hypercolor-cli/src/commands/controls.rs @@ -5,7 +5,11 @@ use std::collections::BTreeMap; use anyhow::{Context, Result, bail}; use clap::{Args, Subcommand}; use hypercolor_color::{Rgb, Rgba}; -use serde_json::{Map, Value, json}; +use hypercolor_types::api::controls::InvokeControlActionRequest; +use hypercolor_types::controls::{ + ApplyControlChangesRequest, ControlChange, ControlValue, ControlValueMap, +}; +use serde_json::Value; use crate::client::DaemonClient; use crate::output::{OutputContext, OutputFormat, extract_str, urlencoded}; @@ -174,15 +178,12 @@ async fn execute_set( client: &DaemonClient, ctx: &OutputContext, ) -> Result<()> { - let changes = assignments_to_changes(&args.values)?; - let mut body = json!({ - "surface_id": args.surface, - "changes": changes, - "dry_run": args.dry_run, - }); - if let Some(revision) = args.expected_revision { - body["expected_revision"] = json!(revision); - } + let body = ApplyControlChangesRequest { + surface_id: args.surface.clone(), + expected_revision: args.expected_revision, + changes: assignments_to_changes(&args.values)?, + dry_run: args.dry_run, + }; let path = format!("/control-surfaces/{}/values", urlencoded(&args.surface)); let response = client.patch(&path, &body).await?; @@ -199,8 +200,9 @@ async fn execute_action( .await?; ensure_action_confirmed(&surface, &args.action, args.yes, ctx)?; - let input = assignments_to_map(&args.input)?; - let body = json!({ "input": input }); + let body = InvokeControlActionRequest { + input: assignments_to_map(&args.input)?, + }; let path = format!( "/control-surfaces/{}/actions/{}", urlencoded(&args.surface), @@ -379,18 +381,18 @@ fn action_rows(surface: &Value, ctx: &OutputContext) -> Vec> { .collect() } -pub(crate) fn assignments_to_changes(assignments: &[String]) -> Result> { +pub(crate) fn assignments_to_changes(assignments: &[String]) -> Result> { assignments .iter() .map(|assignment| { let (field_id, value) = parse_assignment(assignment)?; - Ok(json!({ "field_id": field_id, "value": value })) + Ok(ControlChange { field_id, value }) }) .collect() } -pub(crate) fn assignments_to_map(assignments: &[String]) -> Result> { - let mut input = Map::new(); +pub(crate) fn assignments_to_map(assignments: &[String]) -> Result { + let mut input = ControlValueMap::new(); for assignment in assignments { let (field_id, value) = parse_assignment(assignment)?; input.insert(field_id, value); @@ -398,7 +400,7 @@ pub(crate) fn assignments_to_map(assignments: &[String]) -> Result Result<(String, Value)> { +fn parse_assignment(assignment: &str) -> Result<(String, ControlValue)> { let Some((field_id, raw)) = assignment.split_once('=') else { bail!("control assignment must be key=value: {assignment}"); }; @@ -411,9 +413,9 @@ fn parse_assignment(assignment: &str) -> Result<(String, Value)> { )) } -fn parse_control_value(raw: &str) -> Result { +fn parse_control_value(raw: &str) -> Result { if raw.eq_ignore_ascii_case("null") { - return Ok(json!({ "kind": "null" })); + return Ok(ControlValue::Null); } if let Some((kind, value)) = raw.split_once(':') { @@ -421,100 +423,78 @@ fn parse_control_value(raw: &str) -> Result { } if let Ok(value) = raw.parse::() { - return Ok(json!({ "kind": "bool", "value": value })); + return Ok(ControlValue::Bool(value)); } if let Ok(value) = raw.parse::() { - return Ok(json!({ "kind": "integer", "value": value })); + return Ok(ControlValue::Integer(value)); } if let Ok(value) = raw.parse::() { - return Ok(json!({ "kind": "float", "value": value })); + return Ok(ControlValue::Float(value)); } - Ok(json!({ "kind": "string", "value": raw })) + Ok(ControlValue::String(raw.to_owned())) } -fn typed_control_value(kind: &str, value: &str) -> Result { +fn typed_control_value(kind: &str, value: &str) -> Result { match kind.replace(['-', '_'], "").to_ascii_lowercase().as_str() { - "null" => Ok(json!({ "kind": "null" })), - "bool" | "boolean" => Ok(json!({ "kind": "bool", "value": value.parse::()? })), - "int" | "integer" => Ok(json!({ "kind": "integer", "value": value.parse::()? })), - "float" | "number" => Ok(json!({ "kind": "float", "value": value.parse::()? })), - "string" | "str" => Ok(json!({ "kind": "string", "value": value })), - "secret" | "secretref" => Ok(json!({ "kind": "secret_ref", "value": value })), - "ip" | "ipaddress" => Ok(json!({ "kind": "ip_address", "value": value })), - "mac" | "macaddress" => Ok(json!({ "kind": "mac_address", "value": value })), - "duration" | "durationms" => Ok(json!({ - "kind": "duration_ms", - "value": value.parse::()?, - })), - "enum" => Ok(json!({ "kind": "enum", "value": value })), - "flags" => Ok(json!({ - "kind": "flags", - "value": split_list(value), - })), + "null" => Ok(ControlValue::Null), + "bool" | "boolean" => Ok(ControlValue::Bool(value.parse::()?)), + "int" | "integer" => Ok(ControlValue::Integer(value.parse::()?)), + "float" | "number" => Ok(ControlValue::Float(value.parse::()?)), + "string" | "str" => Ok(ControlValue::String(value.to_owned())), + "secret" | "secretref" => Ok(ControlValue::SecretRef(value.to_owned())), + "ip" | "ipaddress" => Ok(ControlValue::IpAddress(value.to_owned())), + "mac" | "macaddress" => Ok(ControlValue::MacAddress(value.to_owned())), + "duration" | "durationms" => Ok(ControlValue::DurationMs(value.parse::()?)), + "enum" => Ok(ControlValue::Enum(value.to_owned())), + "flags" => Ok(ControlValue::Flags(split_list(value))), "rgb" | "colorrgb" => { let color = Rgb::from_hex(value).with_context(|| format!("invalid rgb color: {value}"))?; - Ok(json!({ - "kind": "color_rgb", - "value": [color.r, color.g, color.b], - })) + Ok(ControlValue::ColorRgb([color.r, color.g, color.b])) } "rgba" | "colorrgba" => { let color = Rgba::from_hex(value).with_context(|| format!("invalid rgba color: {value}"))?; - Ok(json!({ - "kind": "color_rgba", - "value": [color.r, color.g, color.b, color.a], - })) + Ok(ControlValue::ColorRgba([ + color.r, color.g, color.b, color.a, + ])) } "json" => json_to_control_value(value), _ => bail!("unknown control value kind: {kind}"), } } -fn json_to_control_value(value: &str) -> Result { +fn json_to_control_value(value: &str) -> Result { let parsed: Value = serde_json::from_str(value).context("invalid json control value")?; - match parsed { - Value::Array(values) => Ok(json!({ - "kind": "list", - "value": values.into_iter().map(json_value_to_control_value).collect::>>()?, - })), - Value::Object(values) => Ok(json!({ - "kind": "object", - "value": values - .into_iter() - .map(|(key, value)| Ok((key, json_value_to_control_value(value)?))) - .collect::>>()?, - })), - other => json_value_to_control_value(other), - } + json_value_to_control_value(parsed) } -fn json_value_to_control_value(value: Value) -> Result { +fn json_value_to_control_value(value: Value) -> Result { match value { - Value::Null => Ok(json!({ "kind": "null" })), - Value::Bool(value) => Ok(json!({ "kind": "bool", "value": value })), + Value::Null => Ok(ControlValue::Null), + Value::Bool(value) => Ok(ControlValue::Bool(value)), Value::Number(value) => { if let Some(integer) = value.as_i64() { - Ok(json!({ "kind": "integer", "value": integer })) + Ok(ControlValue::Integer(integer)) } else if let Some(float) = value.as_f64() { - Ok(json!({ "kind": "float", "value": float })) + Ok(ControlValue::Float(float)) } else { bail!("unsupported JSON number: {value}") } } - Value::String(value) => Ok(json!({ "kind": "string", "value": value })), - Value::Array(values) => Ok(json!({ - "kind": "list", - "value": values.into_iter().map(json_value_to_control_value).collect::>>()?, - })), - Value::Object(values) => Ok(json!({ - "kind": "object", - "value": values + Value::String(value) => Ok(ControlValue::String(value)), + Value::Array(values) => Ok(ControlValue::List( + values + .into_iter() + .map(json_value_to_control_value) + .collect::>>()?, + )), + Value::Object(values) => Ok(ControlValue::Object( + values .into_iter() .map(|(key, value)| Ok((key, json_value_to_control_value(value)?))) .collect::>>()?, - })), + )), } } diff --git a/crates/hypercolor-cli/src/commands/devices.rs b/crates/hypercolor-cli/src/commands/devices.rs index a5c51293a..34ab53d04 100644 --- a/crates/hypercolor-cli/src/commands/devices.rs +++ b/crates/hypercolor-cli/src/commands/devices.rs @@ -1,8 +1,14 @@ //! `hyper devices` -- device discovery, inspection, and management. +use std::collections::HashMap; + use anyhow::{Result, bail}; use clap::{Args, Subcommand}; -use serde_json::{Value, json}; +use hypercolor_types::api::controls::InvokeControlActionRequest; +use hypercolor_types::api::devices::{DiscoverRequest, IdentifyRequest}; +use hypercolor_types::controls::ApplyControlChangesRequest; +use hypercolor_types::pairing::PairDeviceRequest; +use serde_json::Value; use crate::client::DaemonClient; use crate::commands::controls; @@ -265,7 +271,10 @@ async fn execute_pair( let response = client .post( &path, - &serde_json::json!({ "activate_after_pair": !args.no_activate }), + &PairDeviceRequest { + values: HashMap::new(), + activate_after_pair: !args.no_activate, + }, ) .await?; render_pair_response(&args.device, &response, ctx)?; @@ -277,10 +286,11 @@ async fn execute_discover( client: &DaemonClient, ctx: &OutputContext, ) -> Result<()> { - let body = serde_json::json!({ - "targets": args.target, - "timeout_ms": args.timeout.saturating_mul(1000), - }); + let body = DiscoverRequest { + targets: Some(args.target.clone()), + timeout_ms: Some(u64::from(args.timeout).saturating_mul(1000)), + wait: None, + }; ctx.info("Discovering devices..."); let response = client.post("/devices/discover", &body).await?; @@ -387,14 +397,12 @@ async fn execute_set_control( let surface_id = device_control_surface_id_for_field(client, &args.device, &args.field).await?; let assignment = format!("{}={}", args.field, args.value); let changes = controls::assignments_to_changes(&[assignment])?; - let mut body = json!({ - "surface_id": surface_id, - "changes": changes, - "dry_run": args.dry_run, - }); - if let Some(revision) = args.expected_revision { - body["expected_revision"] = json!(revision); - } + let body = ApplyControlChangesRequest { + expected_revision: args.expected_revision, + changes, + dry_run: args.dry_run, + surface_id: surface_id.clone(), + }; let response = client .patch( @@ -421,7 +429,7 @@ async fn execute_action( urlencoded(&surface_id), urlencoded(&args.action) ), - &json!({ "input": input }), + &InvokeControlActionRequest { input }, ) .await?; controls::render_action_response(&response, ctx) @@ -433,7 +441,10 @@ async fn execute_identify( ctx: &OutputContext, ) -> Result<()> { let path = format!("/devices/{}/identify", urlencoded(&args.device)); - let body = serde_json::json!({ "duration_ms": args.duration.saturating_mul(1000) }); + let body = IdentifyRequest { + duration_ms: Some(u64::from(args.duration).saturating_mul(1000)), + color: None, + }; let response = client.post(&path, &body).await?; match ctx.format { @@ -455,6 +466,9 @@ async fn execute_set_color( ctx: &OutputContext, ) -> Result<()> { let path = format!("/devices/{}", urlencoded(&args.device)); + // PUT /devices/{id} deserializes UpdateDeviceRequest, which carries name, + // enabled, and brightness but no color, so this body has no typed home and + // the route answers 422. let body = serde_json::json!({ "color": args.color }); let response = client.put(&path, &body).await?; diff --git a/crates/hypercolor-cli/src/commands/diagnose.rs b/crates/hypercolor-cli/src/commands/diagnose.rs index 46f561edb..9675fbd8c 100644 --- a/crates/hypercolor-cli/src/commands/diagnose.rs +++ b/crates/hypercolor-cli/src/commands/diagnose.rs @@ -4,6 +4,7 @@ use std::path::PathBuf; use anyhow::Result; use clap::Args; +use hypercolor_types::api::diagnose::DiagnoseRequest; use crate::client::DaemonClient; use crate::output::{OutputContext, OutputFormat}; @@ -34,18 +35,10 @@ pub async fn execute( client: &DaemonClient, ctx: &OutputContext, ) -> Result<()> { - let mut body = serde_json::json!({ - "system": args.system, - }); - - if !args.check.is_empty() { - body["checks"] = serde_json::Value::Array( - args.check - .iter() - .map(|c| serde_json::Value::String(c.clone())) - .collect(), - ); - } + let body = DiagnoseRequest { + checks: (!args.check.is_empty()).then(|| args.check.clone()), + system: Some(args.system), + }; let response = client.post("/diagnose", &body).await?; diff --git a/crates/hypercolor-cli/src/commands/drivers.rs b/crates/hypercolor-cli/src/commands/drivers.rs index ead7ed921..0d3e2eaf8 100644 --- a/crates/hypercolor-cli/src/commands/drivers.rs +++ b/crates/hypercolor-cli/src/commands/drivers.rs @@ -2,7 +2,9 @@ use anyhow::Result; use clap::{Args, Subcommand}; -use serde_json::{Value, json}; +use hypercolor_types::api::controls::InvokeControlActionRequest; +use hypercolor_types::controls::ApplyControlChangesRequest; +use serde_json::Value; use crate::client::DaemonClient; use crate::commands::controls; @@ -139,14 +141,12 @@ async fn execute_set_control( let surface_id = driver_control_surface_id(client, &args.driver).await?; let assignment = format!("{}={}", args.field, args.value); let changes = controls::assignments_to_changes(&[assignment])?; - let mut body = json!({ - "surface_id": surface_id, - "changes": changes, - "dry_run": args.dry_run, - }); - if let Some(revision) = args.expected_revision { - body["expected_revision"] = json!(revision); - } + let body = ApplyControlChangesRequest { + expected_revision: args.expected_revision, + changes, + dry_run: args.dry_run, + surface_id: surface_id.clone(), + }; let response = client .patch( @@ -173,7 +173,7 @@ async fn execute_action( urlencoded(&surface_id), urlencoded(&args.action) ), - &json!({ "input": input }), + &InvokeControlActionRequest { input }, ) .await?; controls::render_action_response(&response, ctx) diff --git a/crates/hypercolor-cli/src/commands/effects.rs b/crates/hypercolor-cli/src/commands/effects.rs index 5f6a1e61e..21145d918 100644 --- a/crates/hypercolor-cli/src/commands/effects.rs +++ b/crates/hypercolor-cli/src/commands/effects.rs @@ -2,6 +2,12 @@ use anyhow::Result; use clap::{Args, Subcommand}; +use hypercolor_types::api::effects::{ + ApplyEffectRequest, ResetControlsRequest, SetEffectLayoutRequest, TransitionRequest, + UpdateActiveControlsRequest, +}; +use hypercolor_types::api::output::{OutputPowerMode, SetOutputPowerRequest}; +use serde_json::Value; use crate::client::DaemonClient; use crate::output::{OutputContext, OutputFormat, extract_str, urlencoded}; @@ -162,8 +168,8 @@ pub async fn execute(args: &EffectsArgs, client: &DaemonClient, ctx: &OutputCont execute_activate(activate_args, client, ctx).await } EffectCommand::Stop => execute_stop(client, ctx).await, - EffectCommand::Pause => execute_output_power(client, ctx, "paused").await, - EffectCommand::Resume => execute_output_power(client, ctx, "running").await, + EffectCommand::Pause => execute_output_power(client, ctx, OutputPowerMode::Paused).await, + EffectCommand::Resume => execute_output_power(client, ctx, OutputPowerMode::Running).await, EffectCommand::Info(info_args) => execute_info(info_args, client, ctx).await, EffectCommand::Patch(patch_args) => execute_patch(patch_args, client, ctx).await, EffectCommand::Reset => execute_reset(client, ctx).await, @@ -253,13 +259,22 @@ async fn execute_activate( controls.insert("intensity".to_string(), serde_json::Value::from(intensity)); } - let body = serde_json::json!({ - "controls": controls, - "transition": { - "type": if args.transition == 0 { "cut" } else { "crossfade" }, - "duration_ms": args.transition, - }, - }); + let body = ApplyEffectRequest { + controls: Some(Value::Object(controls)), + transition: Some(TransitionRequest { + transition_type: Some( + if args.transition == 0 { + "cut" + } else { + "crossfade" + } + .to_owned(), + ), + duration_ms: Some(u64::from(args.transition)), + }), + preset_id: None, + zone_id: None, + }; // The daemon's apply endpoint uses effect IDs in the path. // URL-encode the effect name/slug for path-based lookup. @@ -297,19 +312,18 @@ async fn execute_stop(client: &DaemonClient, ctx: &OutputContext) -> Result<()> async fn execute_output_power( client: &DaemonClient, ctx: &OutputContext, - state: &str, + state: OutputPowerMode, ) -> Result<()> { let response = client - .put("/output/power", &serde_json::json!({ "state": state })) + .put("/output/power", &SetOutputPowerRequest { state }) .await?; match ctx.format { OutputFormat::Json => ctx.print_json(&response)?, OutputFormat::Plain | OutputFormat::Table => { - ctx.success(if state == "paused" { - "Output paused" - } else { - "Output resumed" + ctx.success(match state { + OutputPowerMode::Paused => "Output paused", + OutputPowerMode::Running => "Output resumed", }); } } @@ -365,7 +379,9 @@ async fn execute_patch( controls.insert(key.clone(), parse_control_value(value)); } - let body = serde_json::json!({ "controls": controls }); + let body = UpdateActiveControlsRequest { + controls: Some(Value::Object(controls)), + }; let response = client.patch("/effects/active/controls", &body).await?; match ctx.format { @@ -381,7 +397,7 @@ async fn execute_patch( async fn execute_reset(client: &DaemonClient, ctx: &OutputContext) -> Result<()> { let response = client - .post("/effects/active/reset", &serde_json::json!({})) + .post("/effects/active/reset", &ResetControlsRequest::default()) .await?; match ctx.format { @@ -433,7 +449,9 @@ async fn execute_layout( } EffectLayoutCommand::Set(set_args) => { let path = format!("/effects/{}/layout", urlencoded(&set_args.effect)); - let body = serde_json::json!({ "layout_id": set_args.layout }); + let body = SetEffectLayoutRequest { + layout_id: set_args.layout.clone(), + }; let response = client.put(&path, &body).await?; match ctx.format { diff --git a/crates/hypercolor-cli/src/commands/library.rs b/crates/hypercolor-cli/src/commands/library.rs index a432c1fa8..a46c408aa 100644 --- a/crates/hypercolor-cli/src/commands/library.rs +++ b/crates/hypercolor-cli/src/commands/library.rs @@ -2,6 +2,10 @@ use anyhow::Result; use clap::{Args, Subcommand}; +use hypercolor_types::api::library::{ + AddFavoriteRequest, ApplyPresetRequest, PlaylistItemRequest, PlaylistTargetRequest, + SavePlaylistRequest, SavePresetRequest, +}; use crate::client::DaemonClient; use crate::output::{OutputContext, OutputFormat, extract_str, urlencoded}; @@ -291,7 +295,9 @@ async fn execute_favorites( } } FavoritesCommand::Add(add_args) => { - let body = serde_json::json!({ "effect": add_args.effect }); + let body = AddFavoriteRequest { + effect: add_args.effect.clone(), + }; let response = client.post("/library/favorites", &body).await?; match ctx.format { OutputFormat::Json => ctx.print_json(&response)?, @@ -442,7 +448,7 @@ async fn execute_presets( } PresetsCommand::Apply(apply_args) => { let path = format!("/library/presets/{}/apply", urlencoded(&apply_args.preset)); - let response = client.post(&path, &serde_json::json!({})).await?; + let response = client.post(&path, &ApplyPresetRequest::default()).await?; match ctx.format { OutputFormat::Json => ctx.print_json(&response)?, OutputFormat::Plain | OutputFormat::Table => { @@ -708,13 +714,13 @@ async fn execute_create_preset( controls.insert(key.clone(), parse_control_literal(value)); } - let body = serde_json::json!({ - "name": args.name, - "description": args.description, - "effect": args.effect, - "controls": controls, - "tags": args.tag, - }); + let body = SavePresetRequest { + name: args.name.clone(), + description: args.description.clone(), + effect: args.effect.clone(), + controls: Some(serde_json::Value::Object(controls)), + tags: Some(args.tag.clone()), + }; let response = client.post("/library/presets", &body).await?; match ctx.format { @@ -733,34 +739,32 @@ async fn execute_create_playlist( client: &DaemonClient, ctx: &OutputContext, ) -> Result<()> { - let items: Vec = args + let items: Vec = args .item .iter() .map(|item| { let target = match item.kind { - PlaylistItemKind::Effect => serde_json::json!({ - "type": "effect", - "effect": item.target, - }), - PlaylistItemKind::Preset => serde_json::json!({ - "type": "preset", - "preset_id": item.target, - }), + PlaylistItemKind::Effect => PlaylistTargetRequest::Effect { + effect: item.target.clone(), + }, + PlaylistItemKind::Preset => PlaylistTargetRequest::Preset { + preset_id: item.target.clone(), + }, }; - serde_json::json!({ - "target": target, - "duration_ms": item.duration_ms, - "transition_ms": item.transition_ms, - }) + PlaylistItemRequest { + target, + duration_ms: item.duration_ms, + transition_ms: item.transition_ms, + } }) .collect(); - let body = serde_json::json!({ - "name": args.name, - "description": args.description, - "loop_enabled": !args.no_loop, - "items": items, - }); + let body = SavePlaylistRequest { + name: args.name.clone(), + description: args.description.clone(), + loop_enabled: Some(!args.no_loop), + items: Some(items), + }; let response = client.post("/library/playlists", &body).await?; match ctx.format { diff --git a/crates/hypercolor-cli/src/commands/profiles.rs b/crates/hypercolor-cli/src/commands/profiles.rs index db4c40212..40be3bf19 100644 --- a/crates/hypercolor-cli/src/commands/profiles.rs +++ b/crates/hypercolor-cli/src/commands/profiles.rs @@ -2,6 +2,7 @@ use anyhow::Result; use clap::{Args, Subcommand}; +use hypercolor_types::api::profiles::{ApplyProfileRequest, CreateProfileRequest}; use crate::client::DaemonClient; use crate::output::{OutputContext, OutputFormat, extract_str, urlencoded}; @@ -146,11 +147,12 @@ async fn execute_create( client: &DaemonClient, ctx: &OutputContext, ) -> Result<()> { - let body = serde_json::json!({ - "name": args.name, - "description": args.description, - "force": args.force, - }); + let body = CreateProfileRequest { + name: args.name.clone(), + description: args.description.clone(), + brightness: None, + force: args.force, + }; let response = client.post("/profiles", &body).await?; @@ -170,7 +172,9 @@ async fn execute_apply( ctx: &OutputContext, ) -> Result<()> { let path = format!("/profiles/{}/apply", urlencoded(&args.name)); - let body = serde_json::json!({ "transition_ms": args.transition }); + let body = ApplyProfileRequest { + transition_ms: Some(args.transition), + }; let response = client.post(&path, &body).await?; match ctx.format { diff --git a/crates/hypercolor-cli/src/commands/scenes.rs b/crates/hypercolor-cli/src/commands/scenes.rs index 8b33c5ce9..099bfc1bf 100644 --- a/crates/hypercolor-cli/src/commands/scenes.rs +++ b/crates/hypercolor-cli/src/commands/scenes.rs @@ -2,6 +2,8 @@ use anyhow::Result; use clap::{ArgAction, Args, Subcommand, ValueEnum}; +use hypercolor_types::api::scenes::CreateSceneRequest; +use hypercolor_types::scene::SceneMutationMode; use crate::client::DaemonClient; use crate::output::{OutputContext, OutputFormat, extract_str, urlencoded}; @@ -39,10 +41,10 @@ pub enum SceneMutationModeArg { } impl SceneMutationModeArg { - const fn as_api_value(self) -> &'static str { + const fn as_scene_mutation_mode(self) -> SceneMutationMode { match self { - Self::Live => "live", - Self::Snapshot => "snapshot", + Self::Live => SceneMutationMode::Live, + Self::Snapshot => SceneMutationMode::Snapshot, } } } @@ -167,12 +169,12 @@ async fn execute_create( client: &DaemonClient, ctx: &OutputContext, ) -> Result<()> { - let body = serde_json::json!({ - "name": args.name, - "description": args.description, - "enabled": args.enabled, - "mutation_mode": args.mutation_mode.as_api_value(), - }); + let body = CreateSceneRequest { + name: args.name.clone(), + description: args.description.clone(), + enabled: Some(args.enabled), + mutation_mode: Some(args.mutation_mode.as_scene_mutation_mode()), + }; let response = client.post("/scenes", &body).await?; @@ -242,6 +244,8 @@ async fn execute_activate( ctx: &OutputContext, ) -> Result<()> { let path = format!("/scenes/{}/activate", urlencoded(&args.name)); + // POST /scenes/{id}/activate takes no request body, so this payload has no + // typed home and the daemon discards it. let body = serde_json::json!({ "transition_ms": args.transition }); let response = client.post(&path, &body).await?; From ecbfaa8123dfc0b14f11bc9a7c8e6b56d46e7b2d Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Mon, 17 Aug 2026 02:21:57 -0700 Subject: [PATCH 3/6] refactor(ui,tui): consume the shared request types instead of mirrors The web UI carried eleven hand-copied request structs and the TUI a twelfth, none of them fenced by anything: a daemon-side field rename would have compiled clean on both sides and failed at runtime. They now import the shared definitions, which deletes the copies and turns that class of drift into a compile error. Three of the copies had drifted in name only and are renamed at their call sites: UpdateLayoutApiRequest to UpdateLayoutRequest, CreatePresetRequest to SavePresetRequest, and the UI's ComponentBindingRequest to the ComponentBinding it duplicated. A fourth, SavePresetRequest, gains the daemon's Option around its controls field. Eight sites that hand-rolled bodies with serde_json become typed: identify, attachment identify, brightness, favorites, layer controls, display-face controls, output power, and active controls. The UI's From<&SceneLayer> impl becomes update_request_from_layer, since an inherent trait impl cannot follow the type into another crate. A new hypercolor-types suite fences the properties clients depend on: unset optionals serialize as absent, the identify attachment request flattens its base, control values carry the driver kind tagging, and playlist targets stay internally tagged. One pinned assertion changed. The UI's attachment binding test asserted that an unset name is omitted, which was true of the deleted mirror but not of the shared ComponentBinding, which emits an explicit null. The daemon reads both to None, proven by the new component_binding_accepts_absent_and_explicit_null_names test, so the pin now states the shared type's emission and keeps its original point that the UI sends defaults explicitly. Co-Authored-By: Nova (Claude Opus 5) --- crates/hypercolor-tui/src/client/rest.rs | 22 +- crates/hypercolor-types/src/api/displays.rs | 14 +- crates/hypercolor-types/src/api/layers.rs | 3 +- .../tests/api_request_tests.rs | 219 ++++++++++++++++++ crates/hypercolor-ui/src/api/assets.rs | 8 +- crates/hypercolor-ui/src/api/controls.rs | 9 +- crates/hypercolor-ui/src/api/devices.rs | 49 ++-- crates/hypercolor-ui/src/api/displays.rs | 52 +---- crates/hypercolor-ui/src/api/effects.rs | 19 +- crates/hypercolor-ui/src/api/layers.rs | 70 ++---- crates/hypercolor-ui/src/api/layouts.rs | 32 +-- crates/hypercolor-ui/src/api/library.rs | 16 +- crates/hypercolor-ui/src/app/effect_state.rs | 2 +- .../src/components/attachment_panel.rs | 10 +- .../src/components/device_detail.rs | 2 +- .../src/components/layer_panel/mod.rs | 2 +- .../layout_builder/library_provider.rs | 6 +- .../src/components/preset_panel.rs | 14 +- .../hypercolor-ui/tests/display_api_tests.rs | 8 +- 19 files changed, 345 insertions(+), 212 deletions(-) create mode 100644 crates/hypercolor-types/tests/api_request_tests.rs diff --git a/crates/hypercolor-tui/src/client/rest.rs b/crates/hypercolor-tui/src/client/rest.rs index 647f8565a..66d8d745a 100644 --- a/crates/hypercolor-tui/src/client/rest.rs +++ b/crates/hypercolor-tui/src/client/rest.rs @@ -3,15 +3,18 @@ use anyhow::{Context, Result}; use bytes::Bytes; use futures_util::stream::{self, StreamExt}; +use hypercolor_types::api::controls::InvokeControlActionRequest; use hypercolor_types::api::devices::{ DeviceListResponse as ApiDeviceListResponse, DeviceSummary as ApiDeviceSummary, }; use hypercolor_types::api::effects::{ ActiveEffectResponse as ApiActiveEffectResponse, ApplyEffectRequest, EffectDetailResponse as ApiEffectDetailResponse, EffectListResponse as ApiEffectListResponse, - EffectSummary as ApiEffectSummary, ResetControlsRequest, + EffectSummary as ApiEffectSummary, ResetControlsRequest, UpdateActiveControlsRequest, }; use hypercolor_types::api::envelope::ApiErrorBody; +use hypercolor_types::api::layers::PatchLayerControlsRequest; +use hypercolor_types::api::library::AddFavoriteRequest; use hypercolor_types::api::scenes::{ ActiveSceneResponse as ApiActiveSceneResponse, SceneListResponse as ApiSceneListResponse, }; @@ -365,7 +368,9 @@ impl DaemonClient { ); let response = self .auth_request(self.http.patch(&url)) - .json(&serde_json::json!({ "controls": controls })) + .json(&PatchLayerControlsRequest { + controls: Some(controls.clone()), + }) .send() .await .with_context(|| format!("Failed to update controls for zone {zone_id}"))?; @@ -386,7 +391,9 @@ impl DaemonClient { let url = format!("{}/api/v1/library/favorites", self.base_url); let response = self .auth_request(self.http.post(&url)) - .json(&serde_json::json!({ "effect": effect_id })) + .json(&AddFavoriteRequest { + effect: effect_id.to_owned(), + }) .send() .await?; ensure_success(response, &format!("Failed to add favorite {effect_id}")).await?; @@ -399,7 +406,9 @@ impl DaemonClient { let url = format!("{}/api/v1/effects/active/controls", self.base_url); let response = self .auth_request(self.http.patch(&url)) - .json(&serde_json::json!({ "controls": { control_id: value } })) + .json(&UpdateActiveControlsRequest { + controls: Some(serde_json::json!({ control_id: value })), + }) .send() .await .with_context(|| "Failed to update control")?; @@ -522,11 +531,6 @@ struct ControlSurfaceListResponse { surfaces: Vec, } -#[derive(Debug, serde::Serialize)] -struct InvokeControlActionRequest { - input: ControlValueMap, -} - #[derive(Debug, Deserialize)] struct FavoriteListResponse { items: Vec, diff --git a/crates/hypercolor-types/src/api/displays.rs b/crates/hypercolor-types/src/api/displays.rs index a6feaf703..19a3c4dd3 100644 --- a/crates/hypercolor-types/src/api/displays.rs +++ b/crates/hypercolor-types/src/api/displays.rs @@ -20,11 +20,23 @@ pub enum DisplayFaceScope { Scene, } +impl DisplayFaceScope { + /// The wire spelling, matching the serde representation and the + /// `?scope=` query form. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Default => "default", + Self::Scene => "scene", + } + } +} + /// Request body for `PUT /api/v1/displays/{id}/face`. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] pub struct SetDisplayFaceRequest { pub effect_id: String, - #[serde(default)] + #[serde(default, skip_serializing_if = "HashMap::is_empty")] pub controls: HashMap, #[serde(default, skip_serializing_if = "Option::is_none")] pub blend_mode: Option, diff --git a/crates/hypercolor-types/src/api/layers.rs b/crates/hypercolor-types/src/api/layers.rs index 8fade14e0..f53f8e715 100644 --- a/crates/hypercolor-types/src/api/layers.rs +++ b/crates/hypercolor-types/src/api/layers.rs @@ -113,7 +113,7 @@ impl UpdateLayerRequest { } /// Request body for -/// `PUT /api/v1/scenes/{id}/zones/{zone_id}/layers/order`. +/// `PATCH /api/v1/scenes/{id}/zones/{zone_id}/layers/order`. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)] pub struct LayerOrderRequest { /// The zone's layers, bottom to top. @@ -123,6 +123,7 @@ pub struct LayerOrderRequest { /// Request body for /// `PATCH /api/v1/scenes/{id}/zones/{zone_id}/layers/{layer_id}/controls`. +/// /// `controls` carries no `#[serde(default)]` on purpose: the schema this /// route publishes marks it required, and serde still admits an absent /// field through `Option`'s own default. diff --git a/crates/hypercolor-types/tests/api_request_tests.rs b/crates/hypercolor-types/tests/api_request_tests.rs new file mode 100644 index 000000000..bd2ed8e0d --- /dev/null +++ b/crates/hypercolor-types/tests/api_request_tests.rs @@ -0,0 +1,219 @@ +//! Wire fences for the shared REST request contracts. +//! +//! These pin the two properties clients depend on: what a request type +//! emits when optional fields are unset, and what the daemon accepts +//! coming the other way. + +use hypercolor_types::api::controls::InvokeControlActionRequest; +use hypercolor_types::api::devices::{ + DiscoverRequest, IdentifyAttachmentRequest, IdentifyRequest, UpdateAttachmentsRequest, +}; +use hypercolor_types::api::displays::{DisplayFaceScope, DisplayFaceScopeQuery}; +use hypercolor_types::api::layers::PatchLayerControlsRequest; +use hypercolor_types::api::library::{ + PlaylistItemRequest, PlaylistTargetRequest, SavePlaylistRequest, +}; +use hypercolor_types::api::profiles::ApplyProfileRequest; +use hypercolor_types::controls::{ControlValue, ControlValueMap}; +use serde_json::json; + +#[test] +fn unset_optional_request_fields_are_absent_not_null() { + assert_eq!( + serde_json::to_value(IdentifyRequest::default()).expect("identify request serializes"), + json!({}) + ); + assert_eq!( + serde_json::to_value(DiscoverRequest::default()).expect("discover request serializes"), + json!({}) + ); + assert_eq!( + serde_json::to_value(ApplyProfileRequest::default()).expect("profile apply serializes"), + json!({}) + ); +} + +#[test] +fn identify_attachment_flattens_the_base_request() { + let request = IdentifyAttachmentRequest { + base: IdentifyRequest { + duration_ms: Some(2000), + color: Some("80FFEA".to_owned()), + }, + binding_index: Some(1), + instance: None, + }; + + assert_eq!( + serde_json::to_value(&request).expect("identify attachment serializes"), + json!({ + "duration_ms": 2000, + "color": "80FFEA", + "binding_index": 1, + }) + ); +} + +#[test] +fn component_binding_accepts_absent_and_explicit_null_names() { + let absent: UpdateAttachmentsRequest = serde_json::from_value(json!({ + "bindings": [{ + "slot_id": "slot-1", + "template_id": "template-1", + "enabled": true, + "instances": 1, + "led_offset": 0, + }] + })) + .expect("bindings without a name decode"); + + let explicit_null: UpdateAttachmentsRequest = serde_json::from_value(json!({ + "bindings": [{ + "slot_id": "slot-1", + "template_id": "template-1", + "name": null, + "enabled": true, + "instances": 1, + "led_offset": 0, + }] + })) + .expect("bindings with an explicit null name decode"); + + assert_eq!(absent, explicit_null); + assert_eq!(absent.bindings[0].name, None); +} + +#[test] +fn patch_layer_controls_accepts_an_absent_controls_field() { + let empty: PatchLayerControlsRequest = + serde_json::from_value(json!({})).expect("empty patch body decodes"); + assert_eq!(empty.controls, None); +} + +#[test] +fn control_values_carry_the_driver_kind_tagging() { + let cases = [ + (ControlValue::Null, json!({ "kind": "null" })), + ( + ControlValue::Bool(true), + json!({ "kind": "bool", "value": true }), + ), + ( + ControlValue::Integer(7), + json!({ "kind": "integer", "value": 7 }), + ), + ( + ControlValue::Float(12.5), + json!({ "kind": "float", "value": 12.5 }), + ), + ( + ControlValue::String("aurora".to_owned()), + json!({ "kind": "string", "value": "aurora" }), + ), + ( + ControlValue::SecretRef("token".to_owned()), + json!({ "kind": "secret_ref", "value": "token" }), + ), + ( + ControlValue::IpAddress("10.0.0.1".to_owned()), + json!({ "kind": "ip_address", "value": "10.0.0.1" }), + ), + ( + ControlValue::MacAddress("aa:bb:cc:dd:ee:ff".to_owned()), + json!({ "kind": "mac_address", "value": "aa:bb:cc:dd:ee:ff" }), + ), + ( + ControlValue::DurationMs(250), + json!({ "kind": "duration_ms", "value": 250 }), + ), + ( + ControlValue::Enum("ddp".to_owned()), + json!({ "kind": "enum", "value": "ddp" }), + ), + ( + ControlValue::Flags(vec!["a".to_owned(), "b".to_owned()]), + json!({ "kind": "flags", "value": ["a", "b"] }), + ), + ( + ControlValue::ColorRgb([1, 2, 3]), + json!({ "kind": "color_rgb", "value": [1, 2, 3] }), + ), + ( + ControlValue::ColorRgba([1, 2, 3, 4]), + json!({ "kind": "color_rgba", "value": [1, 2, 3, 4] }), + ), + ]; + + for (value, wire) in cases { + assert_eq!( + serde_json::to_value(&value).expect("control value serializes"), + wire, + "wire form for {value:?}" + ); + } +} + +#[test] +fn control_action_input_defaults_to_an_empty_map() { + let request: InvokeControlActionRequest = + serde_json::from_value(json!({})).expect("action body without input decodes"); + assert_eq!(request.input, ControlValueMap::new()); + + let mut input = ControlValueMap::new(); + input.insert("force".to_owned(), ControlValue::Bool(true)); + assert_eq!( + serde_json::to_value(InvokeControlActionRequest { input }).expect("action body serializes"), + json!({ "input": { "force": { "kind": "bool", "value": true } } }) + ); +} + +#[test] +fn display_face_scope_wire_spelling_matches_its_query_form() { + for scope in [DisplayFaceScope::Default, DisplayFaceScope::Scene] { + assert_eq!( + serde_json::to_value(scope).expect("scope serializes"), + json!(scope.as_str()) + ); + } + + let query: DisplayFaceScopeQuery = + serde_json::from_value(json!({})).expect("empty scope query decodes"); + assert_eq!(query.scope, DisplayFaceScope::Default); +} + +#[test] +fn playlist_targets_are_internally_tagged() { + let request = SavePlaylistRequest { + name: "evening".to_owned(), + description: None, + loop_enabled: Some(true), + items: Some(vec![ + PlaylistItemRequest { + target: PlaylistTargetRequest::Effect { + effect: "aurora".to_owned(), + }, + duration_ms: Some(30_000), + transition_ms: None, + }, + PlaylistItemRequest { + target: PlaylistTargetRequest::Preset { + preset_id: "preset-1".to_owned(), + }, + duration_ms: None, + transition_ms: None, + }, + ]), + }; + + assert_eq!( + serde_json::to_value(&request).expect("playlist serializes"), + json!({ + "name": "evening", + "loop_enabled": true, + "items": [ + { "target": { "type": "effect", "effect": "aurora" }, "duration_ms": 30000 }, + { "target": { "type": "preset", "preset_id": "preset-1" } }, + ], + }) + ); +} diff --git a/crates/hypercolor-ui/src/api/assets.rs b/crates/hypercolor-ui/src/api/assets.rs index 863fb8e5d..d8b0377e8 100644 --- a/crates/hypercolor-ui/src/api/assets.rs +++ b/crates/hypercolor-ui/src/api/assets.rs @@ -6,6 +6,8 @@ use web_sys::{File, FormData}; use super::{ApiEnvelope, client}; +pub use hypercolor_types::api::assets::AssetUpdateRequest; + #[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] pub struct MediaAssetRecord { pub id: String, @@ -40,12 +42,6 @@ pub struct AssetUploadResponse { pub duplicate: bool, } -#[derive(Debug, Clone, Serialize)] -pub struct AssetUpdateRequest { - pub name: Option, - pub tags: Option>, -} - pub async fn list_assets() -> Result { client::fetch_json("/api/v1/assets") .await diff --git a/crates/hypercolor-ui/src/api/controls.rs b/crates/hypercolor-ui/src/api/controls.rs index 369e139d5..3a53a60e9 100644 --- a/crates/hypercolor-ui/src/api/controls.rs +++ b/crates/hypercolor-ui/src/api/controls.rs @@ -9,7 +9,7 @@ use hypercolor_types::controls::{ ApplyControlChangesRequest, ApplyControlChangesResponse, ControlActionResult, ControlSurfaceDocument, ControlSurfaceId, ControlSurfaceRevision, ControlValueMap, }; -use serde::{Deserialize, Serialize}; +use serde::Deserialize; use super::client; @@ -19,12 +19,7 @@ pub struct ControlSurfaceListResponse { pub surfaces: Vec, } -/// Request body for invoking a control-surface action. -#[derive(Debug, Clone, Serialize, PartialEq)] -pub struct InvokeControlActionRequest { - #[serde(default)] - pub input: ControlValueMap, -} +pub use hypercolor_types::api::controls::InvokeControlActionRequest; /// Fetch surfaces selected by device, driver, or both. pub async fn fetch_control_surfaces( diff --git a/crates/hypercolor-ui/src/api/devices.rs b/crates/hypercolor-ui/src/api/devices.rs index a2d95703e..4eb388009 100644 --- a/crates/hypercolor-ui/src/api/devices.rs +++ b/crates/hypercolor-ui/src/api/devices.rs @@ -10,9 +10,12 @@ use super::client; // hypercolor-types) — drift is now a compile error, not a runtime parse // failure. Pairing vocabulary likewise comes from hypercolor-types. pub use hypercolor_types::api::devices::{ - DeviceConnectionSummary, DeviceListResponse, DeviceSummary, UpdateDeviceRequest, ZoneSummary, + DeviceConnectionSummary, DeviceListResponse, DeviceSummary, IdentifyAttachmentRequest, + IdentifyRequest, UpdateAttachmentsRequest, UpdateDeviceRequest, ZoneSummary, ZoneTopologySummary, }; +pub use hypercolor_types::api::settings::SetBrightnessRequest; +pub use hypercolor_types::attachment::ComponentBinding; pub use hypercolor_types::pairing::{ DeviceAuthState, DeviceAuthSummary, PairDeviceRequest, PairDeviceStatus, PairingDescriptor, PairingFieldDescriptor, PairingFlowKind, @@ -87,24 +90,6 @@ pub struct TemplateListResponse { pub items: Vec, } -/// Request body for `PUT /api/v1/devices/:id/attachments`. -#[derive(Debug, Serialize)] -pub struct UpdateAttachmentsRequest { - pub bindings: Vec, -} - -/// A single binding entry sent to the update endpoint. -#[derive(Debug, Clone, Serialize)] -pub struct ComponentBindingRequest { - pub slot_id: String, - pub template_id: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub name: Option, - pub enabled: bool, - pub instances: u32, - pub led_offset: u32, -} - // ── Fetch Functions ───────────────────────────────────────────────────────── /// Fetch all tracked devices. @@ -127,9 +112,17 @@ pub async fn update_device(id: &str, req: &UpdateDeviceRequest) -> Result IdentifyRequest { + IdentifyRequest { + duration_ms: Some(2000), + color: Some(color.to_owned()), + } +} + /// Identify a device by flashing its LEDs. pub async fn identify_device(id: &str) -> Result<(), String> { - let body = serde_json::json!({ "duration_ms": 2000, "color": "FF06B5" }); + let body = identify_request("FF06B5"); client::post_json_discard(&format!("/api/v1/devices/{id}/identify"), &body) .await .map_err(Into::into) @@ -137,7 +130,7 @@ pub async fn identify_device(id: &str) -> Result<(), String> { /// Identify a single zone by flashing only its LEDs. pub async fn identify_zone(device_id: &str, zone_id: &str) -> Result<(), String> { - let body = serde_json::json!({ "duration_ms": 2000, "color": "FF06B5" }); + let body = identify_request("FF06B5"); client::post_json_discard( &format!("/api/v1/devices/{device_id}/zones/{zone_id}/identify"), &body, @@ -153,13 +146,11 @@ pub async fn identify_attachment( binding_index: Option, instance: Option, ) -> Result<(), String> { - let mut body = serde_json::json!({ "duration_ms": 2000, "color": "80FFEA" }); - if let Some(idx) = binding_index { - body["binding_index"] = serde_json::json!(idx); - } - if let Some(instance) = instance { - body["instance"] = serde_json::json!(instance); - } + let body = IdentifyAttachmentRequest { + base: identify_request("80FFEA"), + binding_index, + instance, + }; client::post_json_discard( &format!("/api/v1/devices/{device_id}/attachments/{slot_id}/identify"), &body, @@ -208,7 +199,7 @@ pub async fn update_device_attachments( /// Update the global output brightness. pub async fn set_global_brightness(brightness: u8) -> Result { - let body = serde_json::json!({ "brightness": brightness }); + let body = SetBrightnessRequest { brightness }; let resp: BrightnessSettingsResponse = client::put_json("/api/v1/settings/brightness", &body).await?; Ok(resp.brightness) diff --git a/crates/hypercolor-ui/src/api/displays.rs b/crates/hypercolor-ui/src/api/displays.rs index a392da7e0..739719fe7 100644 --- a/crates/hypercolor-ui/src/api/displays.rs +++ b/crates/hypercolor-ui/src/api/displays.rs @@ -47,26 +47,10 @@ pub struct DisplayFaceZone { pub display_target: Option, } -/// Which assignment layer a face operation targets (spec 69 §3.6). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum DisplayFaceScope { - /// Persists across scenes — the display's own face. - #[default] - Default, - /// Lives in the active scene's display zone; wins while that scene is active. - Scene, -} - -impl DisplayFaceScope { - #[must_use] - pub fn as_str(self) -> &'static str { - match self { - Self::Default => "default", - Self::Scene => "scene", - } - } -} +pub use hypercolor_types::api::displays::{ + DisplayFaceScope, SetDisplayFaceRequest, UpdateDisplayFaceCompositionRequest, + UpdateDisplayFaceControlsRequest, +}; /// Response from `GET /api/v1/displays/{id}/face`. #[derive(Debug, Clone, Deserialize, PartialEq)] @@ -86,28 +70,6 @@ pub struct DisplayFaceResponse { pub default_assigned: bool, } -/// Request body for `PUT /api/v1/displays/{id}/face`. -#[derive(Debug, Clone, Serialize)] -pub struct SetDisplayFaceRequest { - pub effect_id: String, - #[serde(skip_serializing_if = "HashMap::is_empty")] - pub controls: HashMap, - #[serde(skip_serializing_if = "Option::is_none")] - pub blend_mode: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub opacity: Option, - pub scope: DisplayFaceScope, -} - -/// Request body for `PATCH /api/v1/displays/{id}/face/composition`. -#[derive(Debug, Clone, Serialize)] -pub struct UpdateDisplayFaceCompositionRequest { - #[serde(skip_serializing_if = "Option::is_none")] - pub blend_mode: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub opacity: Option, -} - /// `GET /api/v1/displays` — list display-capable devices. pub async fn fetch_displays() -> Result, String> { client::fetch_json::>("/api/v1/displays") @@ -161,8 +123,10 @@ pub async fn update_display_face_controls( controls: &serde_json::Value, ) -> Result { let url = format!("/api/v1/displays/{display_id}/face/controls"); - let body = serde_json::json!({ "controls": controls }); - client::patch_json::(&url, &body) + let body = UpdateDisplayFaceControlsRequest { + controls: Some(controls.clone()), + }; + client::patch_json::(&url, &body) .await .map_err(Into::into) } diff --git a/crates/hypercolor-ui/src/api/effects.rs b/crates/hypercolor-ui/src/api/effects.rs index e585a729b..ceb1d283c 100644 --- a/crates/hypercolor-ui/src/api/effects.rs +++ b/crates/hypercolor-ui/src/api/effects.rs @@ -19,8 +19,9 @@ use hypercolor_types::api::effects::ActiveEffectResponse as WireActiveEffectResp pub use hypercolor_types::api::effects::{ ApplyEffectPresetRequest, ApplyEffectRequest as ApplyEffectBody, EffectCapabilitySet, EffectDetailResponse, EffectListResponse, EffectPresetListResponse, EffectPresetOrigin, - EffectPresetSummary, EffectSummary, InstalledEffectResponse, + EffectPresetSummary, EffectSummary, InstalledEffectResponse, UpdateActiveControlsRequest, }; +pub use hypercolor_types::api::output::{OutputPowerMode, SetOutputPowerRequest}; /// Active effect response from `GET /api/v1/effects/active`. #[derive(Debug, Clone, Deserialize, PartialEq)] @@ -149,7 +150,9 @@ pub async fn apply_effect(id: &str, body: Option<&ApplyEffectBody>) -> Result<() pub async fn pause_effect() -> Result<(), String> { client::put_json_discard( "/api/v1/output/power", - &serde_json::json!({ "state": "paused" }), + &SetOutputPowerRequest { + state: OutputPowerMode::Paused, + }, ) .await .map_err(Into::into) @@ -159,7 +162,9 @@ pub async fn pause_effect() -> Result<(), String> { pub async fn resume_effect() -> Result<(), String> { client::put_json_discard( "/api/v1/output/power", - &serde_json::json!({ "state": "running" }), + &SetOutputPowerRequest { + state: OutputPowerMode::Running, + }, ) .await .map_err(Into::into) @@ -174,7 +179,9 @@ pub async fn stop_effect() -> Result<(), String> { /// Update effect control parameters. pub async fn update_controls(controls: &serde_json::Value) -> Result<(), String> { - let body = serde_json::json!({ "controls": controls }); + let body = UpdateActiveControlsRequest { + controls: Some(controls.clone()), + }; client::patch_json_discard("/api/v1/effects/active/controls", &body) .await .map_err(Into::into) @@ -217,7 +224,9 @@ pub async fn update_effect_controls( use gloo_net::http::Method; let url = format!("/api/v1/effects/{}/controls", path_segment(effect_id)); - let body = serde_json::json!({ "controls": controls }); + let body = UpdateActiveControlsRequest { + controls: Some(controls.clone()), + }; let outcome = client::send_json_versioned::<_, ControlsVersionResponse>( Method::PATCH, &url, diff --git a/crates/hypercolor-ui/src/api/layers.rs b/crates/hypercolor-ui/src/api/layers.rs index 1d72322d0..53469fc70 100644 --- a/crates/hypercolor-ui/src/api/layers.rs +++ b/crates/hypercolor-ui/src/api/layers.rs @@ -1,12 +1,9 @@ //! Scene layer-stack API client. use gloo_net::http::Method; -use serde::{Deserialize, Serialize}; +use serde::Deserialize; -use hypercolor_types::layer::{ - LayerAdjust, LayerBinding, LayerBlendMode, LayerSource, LayerTransform, SceneLayer, - SceneLayerId, -}; +use hypercolor_types::layer::{SceneLayer, SceneLayerId}; use super::client; use super::client::MutationOutcome; @@ -17,49 +14,24 @@ pub struct LayerStackResponse { pub layers_version: u64, } -#[derive(Debug, Clone, Serialize)] -pub struct CreateLayerRequest { - pub name: Option, - pub source: LayerSource, - pub blend: LayerBlendMode, - pub opacity: f32, - pub transform: LayerTransform, - pub adjust: LayerAdjust, - pub bindings: Vec, - pub enabled: bool, -} - -#[derive(Debug, Clone, Serialize)] -pub struct UpdateLayerRequest { - pub id: SceneLayerId, - pub name: Option, - pub source: LayerSource, - pub blend: LayerBlendMode, - pub opacity: f32, - pub transform: LayerTransform, - pub adjust: LayerAdjust, - pub bindings: Vec, - pub enabled: bool, -} - -#[derive(Debug, Clone, Serialize)] -pub struct LayerOrderRequest { - pub layer_ids: Vec, -} +pub use hypercolor_types::api::layers::{ + CreateLayerRequest, LayerOrderRequest, PatchLayerControlsRequest, UpdateLayerRequest, +}; -impl From<&SceneLayer> for UpdateLayerRequest { - fn from(layer: &SceneLayer) -> Self { - Self { - id: layer.id, - name: layer.name.clone(), - source: layer.source.clone(), - blend: layer.blend, - opacity: layer.opacity, - transform: layer.transform, - adjust: layer.adjust, - bindings: layer.bindings.clone(), - enabled: layer.enabled, - } +/// Build a whole-layer replacement request that preserves every field of +/// the layer as it stands. +#[must_use] +pub fn update_request_from_layer(layer: &SceneLayer) -> UpdateLayerRequest { + UpdateLayerRequest { + id: layer.id, + name: layer.name.clone(), + source: layer.source.clone(), + blend: layer.blend, + opacity: layer.opacity, + transform: layer.transform, + adjust: layer.adjust, + bindings: layer.bindings.clone(), + enabled: layer.enabled, } } @@ -138,7 +110,9 @@ pub async fn patch_layer_controls( controls: &serde_json::Value, expected_version: Option, ) -> Result { - let body = serde_json::json!({ "controls": controls }); + let body = PatchLayerControlsRequest { + controls: Some(controls.clone()), + }; client::send_json_versioned( Method::PATCH, &format!("/api/v1/scenes/{scene_id}/zones/{zone_id}/layers/{layer_id}/controls"), diff --git a/crates/hypercolor-ui/src/api/layouts.rs b/crates/hypercolor-ui/src/api/layouts.rs index 06dd826e9..034f39029 100644 --- a/crates/hypercolor-ui/src/api/layouts.rs +++ b/crates/hypercolor-ui/src/api/layouts.rs @@ -24,32 +24,7 @@ pub struct LayoutListResponse { pub items: Vec, } -/// Request body for creating a layout. -#[derive(Debug, Serialize)] -pub struct CreateLayoutRequest { - pub name: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub canvas_width: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub canvas_height: Option, -} - -/// Request body for updating a layout. -#[derive(Debug, Serialize)] -pub struct UpdateLayoutApiRequest { - #[serde(skip_serializing_if = "Option::is_none")] - pub name: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub canvas_width: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub canvas_height: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub zones: Option>, -} +pub use hypercolor_types::api::layouts::{CreateLayoutRequest, UpdateLayoutRequest}; // ── Fetch Functions ───────────────────────────────────────────────────────── @@ -81,10 +56,7 @@ pub async fn create_layout(req: &CreateLayoutRequest) -> Result Result { +pub async fn update_layout(id: &str, req: &UpdateLayoutRequest) -> Result { client::put_json(&format!("/api/v1/layouts/{id}"), req) .await .map_err(Into::into) diff --git a/crates/hypercolor-ui/src/api/library.rs b/crates/hypercolor-ui/src/api/library.rs index b80f51ca6..0e7291b5d 100644 --- a/crates/hypercolor-ui/src/api/library.rs +++ b/crates/hypercolor-ui/src/api/library.rs @@ -30,17 +30,7 @@ pub struct PresetListResponse { pub items: Vec, } -/// Request body for creating a preset. -#[derive(Debug, Serialize)] -pub struct CreatePresetRequest { - pub name: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option, - pub effect: String, - pub controls: serde_json::Value, - #[serde(skip_serializing_if = "Option::is_none")] - pub tags: Option>, -} +pub use hypercolor_types::api::library::{AddFavoriteRequest, SavePresetRequest}; // ── Favorite Types ────────────────────────────────────────────────────────── @@ -67,14 +57,14 @@ pub async fn fetch_presets() -> Result, String> { } /// Create a new preset from current control values. -pub async fn create_preset(req: &CreatePresetRequest) -> 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: &CreatePresetRequest) -> 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/app/effect_state.rs b/crates/hypercolor-ui/src/app/effect_state.rs index a4f41f68d..50df69e08 100644 --- a/crates/hypercolor-ui/src/app/effect_state.rs +++ b/crates/hypercolor-ui/src/app/effect_state.rs @@ -99,7 +99,7 @@ async fn apply_effect_layer( .iter() .find(|layer| matches!(layer.source, LayerSource::Effect { .. })) { - let mut request = api::UpdateLayerRequest::from(layer); + let mut request = api::update_request_from_layer(layer); request.source = source.clone(); api::update_layer( scene_id, diff --git a/crates/hypercolor-ui/src/components/attachment_panel.rs b/crates/hypercolor-ui/src/components/attachment_panel.rs index e1a910dda..c5d79fde6 100644 --- a/crates/hypercolor-ui/src/components/attachment_panel.rs +++ b/crates/hypercolor-ui/src/components/attachment_panel.rs @@ -431,9 +431,9 @@ pub fn WiringPanel( } // Build bindings let current = api::fetch_device_attachments(&did).await?; - let mut bindings: Vec = current.bindings.iter() + let mut bindings: Vec = current.bindings.iter() .filter(|b| b.slot_id != slot_id) - .map(|b| api::ComponentBindingRequest { + .map(|b| api::ComponentBinding { slot_id: b.slot_id.clone(), template_id: b.template_id.clone(), name: b.name.clone(), enabled: b.enabled, instances: b.instances, led_offset: b.led_offset, }).collect(); @@ -444,7 +444,7 @@ pub fn WiringPanel( _ => template_ids.get(&i).cloned().unwrap_or_default(), }; let count = row.led_count(&templates).unwrap_or(0); - bindings.push(api::ComponentBindingRequest { + bindings.push(api::ComponentBinding { slot_id: slot_id.clone(), template_id: tid, name: if row.name.is_empty() { None } else { Some(row.name.clone()) }, enabled: true, instances: 1, led_offset: offset, @@ -846,7 +846,7 @@ pub fn sync_wiring_to_layout( &device.layout_device_id, seeded, ); - let req = api::UpdateLayoutApiRequest { + let req = api::UpdateLayoutRequest { name: None, description: None, canvas_width: None, @@ -897,7 +897,7 @@ fn sync_channel_name_to_active_layout( return Ok(false); } - let req = api::UpdateLayoutApiRequest { + let req = api::UpdateLayoutRequest { name: None, description: None, canvas_width: None, diff --git a/crates/hypercolor-ui/src/components/device_detail.rs b/crates/hypercolor-ui/src/components/device_detail.rs index 902ae470b..55c491d57 100644 --- a/crates/hypercolor-ui/src/components/device_detail.rs +++ b/crates/hypercolor-ui/src/components/device_detail.rs @@ -84,7 +84,7 @@ pub fn DeviceDetail( ) { let _ = api::update_layout( &layout_id, - &api::UpdateLayoutApiRequest { + &api::UpdateLayoutRequest { name: None, description: None, canvas_width: None, diff --git a/crates/hypercolor-ui/src/components/layer_panel/mod.rs b/crates/hypercolor-ui/src/components/layer_panel/mod.rs index 28f73d13a..60a92f328 100644 --- a/crates/hypercolor-ui/src/components/layer_panel/mod.rs +++ b/crates/hypercolor-ui/src/components/layer_panel/mod.rs @@ -365,7 +365,7 @@ fn update_layer( on_layers_mutated: Callback<()>, ) { let layer_id = layer.id.to_string(); - let request = api::UpdateLayerRequest::from(&layer); + let request = api::update_request_from_layer(&layer); leptos::task::spawn_local(async move { match api::update_layer( &scene_id, diff --git a/crates/hypercolor-ui/src/components/layout_builder/library_provider.rs b/crates/hypercolor-ui/src/components/layout_builder/library_provider.rs index 346e45264..627d72041 100644 --- a/crates/hypercolor-ui/src/components/layout_builder/library_provider.rs +++ b/crates/hypercolor-ui/src/components/layout_builder/library_provider.rs @@ -363,7 +363,7 @@ pub(crate) fn LayoutEditorProvider(children: Children) -> impl IntoView { let saved_copy = l.clone(); let layouts_resource = ctx.layouts_resource; leptos::task::spawn_local(async move { - let req = api::UpdateLayoutApiRequest { + let req = api::UpdateLayoutRequest { name: None, description: None, canvas_width: None, @@ -521,7 +521,7 @@ pub(crate) fn LayoutEditorProvider(children: Children) -> impl IntoView { let layouts_resource = ctx.layouts_resource; set_renaming.set(false); leptos::task::spawn_local(async move { - let req = api::UpdateLayoutApiRequest { + let req = api::UpdateLayoutRequest { name: Some(new_name.clone()), description: None, canvas_width: None, @@ -572,7 +572,7 @@ pub(crate) fn LayoutEditorProvider(children: Children) -> impl IntoView { match api::create_layout(&req).await { Ok(summary) => { // Update the new layout with zones from the original - let update_req = api::UpdateLayoutApiRequest { + let update_req = api::UpdateLayoutRequest { name: None, description: None, canvas_width: None, diff --git a/crates/hypercolor-ui/src/components/preset_panel.rs b/crates/hypercolor-ui/src/components/preset_panel.rs index 691cc01fb..f338e6525 100644 --- a/crates/hypercolor-ui/src/components/preset_panel.rs +++ b/crates/hypercolor-ui/src/components/preset_panel.rs @@ -232,11 +232,11 @@ pub fn PresetToolbar( let pid = preset.id.clone(); let refresh = refresh_presets; leptos::task::spawn_local(async move { - let req = api::CreatePresetRequest { + let req = api::SavePresetRequest { name, description: None, effect: eid.clone(), - controls: serde_json::Value::Object(controls_json), + controls: Some(serde_json::Value::Object(controls_json)), tags: None, }; if api::update_preset(&pid, &req).await.is_ok() { @@ -255,11 +255,11 @@ pub fn PresetToolbar( let target_zone = zones_ctx.focused_zone_id_untracked(); set_mode.set(ToolbarMode::Idle); leptos::task::spawn_local(async move { - let req = api::CreatePresetRequest { + let req = api::SavePresetRequest { name, description: None, effect: eid.clone(), - controls: serde_json::Value::Object(controls_json), + controls: Some(serde_json::Value::Object(controls_json)), tags: None, }; match api::create_preset(&req).await { @@ -299,11 +299,13 @@ pub fn PresetToolbar( let refresh = refresh_presets; set_mode.set(ToolbarMode::Idle); leptos::task::spawn_local(async move { - let req = api::CreatePresetRequest { + let req = api::SavePresetRequest { name: new_name, description: None, effect: eid, - controls: serde_json::Value::Object(controls_to_json(&preset.controls)), + controls: Some(serde_json::Value::Object(controls_to_json( + &preset.controls, + ))), tags: None, }; if api::update_preset(&pid, &req).await.is_ok() { diff --git a/crates/hypercolor-ui/tests/display_api_tests.rs b/crates/hypercolor-ui/tests/display_api_tests.rs index f79f9eae4..37f505847 100644 --- a/crates/hypercolor-ui/tests/display_api_tests.rs +++ b/crates/hypercolor-ui/tests/display_api_tests.rs @@ -1,7 +1,7 @@ use hypercolor_types::canvas::srgb_to_linear; use hypercolor_types::effect::{ControlDefinition, ControlKind, ControlType, ControlValue}; use hypercolor_ui::api::{ - ComponentBindingRequest, DisplayFaceResponse, DisplayFaceScope, PairDeviceRequest, + ComponentBinding, DisplayFaceResponse, DisplayFaceScope, PairDeviceRequest, SetDisplayFaceRequest, }; use hypercolor_ui::control_value_json::{ @@ -88,7 +88,7 @@ fn pair_device_request_serializes_canonical_shape() { #[test] fn attachment_binding_request_keeps_explicit_defaults_on_wire() { - let payload = serde_json::to_value(ComponentBindingRequest { + let payload = serde_json::to_value(ComponentBinding { slot_id: "slot-1".to_owned(), template_id: "template-1".to_owned(), name: None, @@ -98,11 +98,15 @@ fn attachment_binding_request_keeps_explicit_defaults_on_wire() { }) .expect("attachment binding request should serialize"); + // enabled, instances, and led_offset all carry serde defaults on the + // daemon side, so the point of this pin is that the UI states them + // rather than letting the daemon reconstruct them. assert_eq!( payload, serde_json::json!({ "slot_id": "slot-1", "template_id": "template-1", + "name": null, "enabled": true, "instances": 1, "led_offset": 0 From 4227e7ea4e66b66aaaa85ef90357dea7cfb022e0 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Mon, 17 Aug 2026 02:57:04 -0700 Subject: [PATCH 4/6] refactor(ui): type the favorites body and refresh the generated client The favorites POST was the last UI body still built with serde_json; it now constructs AddFavoriteRequest like every other call in the module. Regenerating the Python client picks up the layer route doc comments, which move only description strings. Co-Authored-By: Nova (Claude Opus 5) --- crates/hypercolor-ui/src/api/library.rs | 4 +++- .../hypercolor/_generated/api/scenes/patch_layer_controls.py | 2 ++ python/src/hypercolor/_generated/api/scenes/reorder_layers.py | 4 ++-- .../src/hypercolor/_generated/models/layer_order_request.py | 2 +- .../_generated/models/patch_layer_controls_request.py | 1 + 5 files changed, 9 insertions(+), 4 deletions(-) diff --git a/crates/hypercolor-ui/src/api/library.rs b/crates/hypercolor-ui/src/api/library.rs index 0e7291b5d..c6f8d345c 100644 --- a/crates/hypercolor-ui/src/api/library.rs +++ b/crates/hypercolor-ui/src/api/library.rs @@ -89,7 +89,9 @@ pub async fn fetch_favorites() -> Result, String> { pub async fn add_favorite(effect_id: &str) -> Result<(), String> { client::post_json_discard( "/api/v1/library/favorites", - &serde_json::json!({ "effect": effect_id }), + &AddFavoriteRequest { + effect: effect_id.to_owned(), + }, ) .await .map_err(Into::into) diff --git a/python/src/hypercolor/_generated/api/scenes/patch_layer_controls.py b/python/src/hypercolor/_generated/api/scenes/patch_layer_controls.py index 95a3da264..75f3445b5 100644 --- a/python/src/hypercolor/_generated/api/scenes/patch_layer_controls.py +++ b/python/src/hypercolor/_generated/api/scenes/patch_layer_controls.py @@ -93,6 +93,7 @@ def sync_detailed( layer_id (str): body (PatchLayerControlsRequest): Request body for `PATCH /api/v1/scenes/{id}/zones/{zone_id}/layers/{layer_id}/controls`. + `controls` carries no `#[serde(default)]` on purpose: the schema this route publishes marks it required, and serde still admits an absent field through `Option`'s own default. @@ -135,6 +136,7 @@ async def asyncio_detailed( layer_id (str): body (PatchLayerControlsRequest): Request body for `PATCH /api/v1/scenes/{id}/zones/{zone_id}/layers/{layer_id}/controls`. + `controls` carries no `#[serde(default)]` on purpose: the schema this route publishes marks it required, and serde still admits an absent field through `Option`'s own default. diff --git a/python/src/hypercolor/_generated/api/scenes/reorder_layers.py b/python/src/hypercolor/_generated/api/scenes/reorder_layers.py index c8f72b192..d832ab2aa 100644 --- a/python/src/hypercolor/_generated/api/scenes/reorder_layers.py +++ b/python/src/hypercolor/_generated/api/scenes/reorder_layers.py @@ -88,7 +88,7 @@ def sync_detailed( id (str): zone_id (str): body (LayerOrderRequest): Request body for - `PUT /api/v1/scenes/{id}/zones/{zone_id}/layers/order`. + `PATCH /api/v1/scenes/{id}/zones/{zone_id}/layers/order`. Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. @@ -124,7 +124,7 @@ async def asyncio_detailed( id (str): zone_id (str): body (LayerOrderRequest): Request body for - `PUT /api/v1/scenes/{id}/zones/{zone_id}/layers/order`. + `PATCH /api/v1/scenes/{id}/zones/{zone_id}/layers/order`. Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. diff --git a/python/src/hypercolor/_generated/models/layer_order_request.py b/python/src/hypercolor/_generated/models/layer_order_request.py index d2f41427f..a19915680 100644 --- a/python/src/hypercolor/_generated/models/layer_order_request.py +++ b/python/src/hypercolor/_generated/models/layer_order_request.py @@ -12,7 +12,7 @@ @_attrs_define class LayerOrderRequest: """Request body for - `PUT /api/v1/scenes/{id}/zones/{zone_id}/layers/order`. + `PATCH /api/v1/scenes/{id}/zones/{zone_id}/layers/order`. Attributes: layer_ids (list[str]): The zone's layers, bottom to top. diff --git a/python/src/hypercolor/_generated/models/patch_layer_controls_request.py b/python/src/hypercolor/_generated/models/patch_layer_controls_request.py index b0d7f5f56..872da30d3 100644 --- a/python/src/hypercolor/_generated/models/patch_layer_controls_request.py +++ b/python/src/hypercolor/_generated/models/patch_layer_controls_request.py @@ -19,6 +19,7 @@ class PatchLayerControlsRequest: """Request body for `PATCH /api/v1/scenes/{id}/zones/{zone_id}/layers/{layer_id}/controls`. + `controls` carries no `#[serde(default)]` on purpose: the schema this route publishes marks it required, and serde still admits an absent field through `Option`'s own default. From ae819b989635e1a978468a6d67193cf50d6e9e3d Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Mon, 17 Aug 2026 03:30:38 -0700 Subject: [PATCH 5/6] test(types): fence both spellings of every unset optional request field Naming the payloads into shared types changed what several clients put on the wire wherever a hand-built body spelled an unset optional differently from the shared struct. Nine bodies now omit a key they used to send as an explicit null, because serde_json::json! renders None as null while the shared types carry skip_serializing_if. Two move the other way and state a key their predecessor omitted, because the shared field carries serde(default) without skip_serializing_if. Every field involved is an Option or carries serde(default), so absent, explicit null, and empty all deserialize to the same Rust value and no handler branches on which spelling arrived. That was an argument in the PR body and is now a test: a macro decodes each affected type with the fields absent and again with explicit nulls, then asserts the two agree. Coverage spans the asset update, profile and scene creation, preset and playlist saves, playlist items, and both layer requests, with the pairing values map checked for the absent-versus-empty pair. Co-Authored-By: Nova (Claude Opus 5) --- .../tests/api_request_tests.rs | 104 +++++++++++++++++- 1 file changed, 101 insertions(+), 3 deletions(-) diff --git a/crates/hypercolor-types/tests/api_request_tests.rs b/crates/hypercolor-types/tests/api_request_tests.rs index bd2ed8e0d..e34701aa9 100644 --- a/crates/hypercolor-types/tests/api_request_tests.rs +++ b/crates/hypercolor-types/tests/api_request_tests.rs @@ -4,19 +4,117 @@ //! emits when optional fields are unset, and what the daemon accepts //! coming the other way. +use hypercolor_types::api::assets::AssetUpdateRequest; use hypercolor_types::api::controls::InvokeControlActionRequest; use hypercolor_types::api::devices::{ DiscoverRequest, IdentifyAttachmentRequest, IdentifyRequest, UpdateAttachmentsRequest, }; use hypercolor_types::api::displays::{DisplayFaceScope, DisplayFaceScopeQuery}; -use hypercolor_types::api::layers::PatchLayerControlsRequest; +use hypercolor_types::api::layers::{ + CreateLayerRequest, PatchLayerControlsRequest, UpdateLayerRequest, +}; use hypercolor_types::api::library::{ - PlaylistItemRequest, PlaylistTargetRequest, SavePlaylistRequest, + PlaylistItemRequest, PlaylistTargetRequest, SavePlaylistRequest, SavePresetRequest, }; -use hypercolor_types::api::profiles::ApplyProfileRequest; +use hypercolor_types::api::profiles::{ApplyProfileRequest, CreateProfileRequest}; +use hypercolor_types::api::scenes::CreateSceneRequest; use hypercolor_types::controls::{ControlValue, ControlValueMap}; +use hypercolor_types::pairing::PairDeviceRequest; use serde_json::json; +/// Assert that a payload carrying explicit `null`s for the named fields +/// decodes to the same value as one that omits them entirely. +/// +/// Naming these shapes into shared types moved several clients from +/// emitting `"field": null` to omitting the key. This is the fence that +/// keeps the two spellings interchangeable at the daemon. +macro_rules! assert_null_and_absent_agree { + ($ty:ty, $base:expr, $($field:literal),+ $(,)?) => {{ + let absent_payload: serde_json::Value = $base; + let mut null_payload = absent_payload.clone(); + { + let object = null_payload + .as_object_mut() + .expect("fixture payload must be a JSON object"); + $( object.insert($field.to_owned(), serde_json::Value::Null); )+ + } + + let absent: $ty = serde_json::from_value(absent_payload) + .expect(concat!(stringify!($ty), " must decode with the fields absent")); + let explicit_null: $ty = serde_json::from_value(null_payload) + .expect(concat!(stringify!($ty), " must decode with explicit nulls")); + + assert_eq!( + absent, + explicit_null, + concat!(stringify!($ty), ": absent and explicit-null must decode alike"), + ); + }}; +} + +#[test] +fn absent_and_explicit_null_optional_fields_decode_alike() { + assert_null_and_absent_agree!(AssetUpdateRequest, json!({}), "name", "tags"); + assert_null_and_absent_agree!( + CreateProfileRequest, + json!({ "name": "evening" }), + "description", + "brightness", + ); + assert_null_and_absent_agree!( + CreateSceneRequest, + json!({ "name": "movie-night" }), + "description", + "enabled", + "mutation_mode", + ); + assert_null_and_absent_agree!( + SavePresetRequest, + json!({ "name": "warm", "effect": "aurora" }), + "description", + "controls", + "tags", + ); + assert_null_and_absent_agree!( + SavePlaylistRequest, + json!({ "name": "rotation" }), + "description", + "loop_enabled", + "items", + ); + assert_null_and_absent_agree!( + PlaylistItemRequest, + json!({ "target": { "type": "effect", "effect": "aurora" } }), + "duration_ms", + "transition_ms", + ); + assert_null_and_absent_agree!( + CreateLayerRequest, + json!({ "source": { "type": "screen_region" } }), + "name", + ); + assert_null_and_absent_agree!( + UpdateLayerRequest, + json!({ + "id": "00000000-0000-0000-0000-000000000001", + "source": { "type": "screen_region" }, + }), + "name", + ); +} + +#[test] +fn absent_and_empty_pairing_values_decode_alike() { + let absent: PairDeviceRequest = serde_json::from_value(json!({ "activate_after_pair": true })) + .expect("pairing request decodes without values"); + let empty: PairDeviceRequest = + serde_json::from_value(json!({ "values": {}, "activate_after_pair": true })) + .expect("pairing request decodes with an empty values map"); + + assert_eq!(absent, empty); + assert!(absent.values.is_empty()); +} + #[test] fn unset_optional_request_fields_are_absent_not_null() { assert_eq!( From 9581c58fd3da6ac136fe85904a5604368becb88b Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Mon, 17 Aug 2026 03:46:58 -0700 Subject: [PATCH 6/6] test(types): bind the fenced field names to their structs The equivalence macro took field names as string literals, and no type in the crate declares deny_unknown_fields, so serde silently ignored a name that no longer matched the struct and the assertion held over nothing. A verifier demonstrated it three ways: a field that never existed, a misspelling, and a wrong name all passed green. Fields are now identifiers. The macro binds each one through a closure before building the payload, so a rename or a typo is a compile error, and the JSON key comes from the same identifier via stringify. All three attacks now fail to compile with E0609. The fixture also has to differ from the null-bearing payload, which catches a base that already carries the fields. Co-Authored-By: Nova (Claude Opus 5) --- .../tests/api_request_tests.rs | 51 ++++++++++++------- 1 file changed, 33 insertions(+), 18 deletions(-) diff --git a/crates/hypercolor-types/tests/api_request_tests.rs b/crates/hypercolor-types/tests/api_request_tests.rs index e34701aa9..303942dc4 100644 --- a/crates/hypercolor-types/tests/api_request_tests.rs +++ b/crates/hypercolor-types/tests/api_request_tests.rs @@ -28,16 +28,31 @@ use serde_json::json; /// Naming these shapes into shared types moved several clients from /// emitting `"field": null` to omitting the key. This is the fence that /// keeps the two spellings interchangeable at the daemon. +/// +/// Fields are passed as identifiers, not strings, and the closure below +/// binds each one. No type here declares `deny_unknown_fields`, so a +/// stringly-typed field name that no longer matches the struct would let +/// serde ignore the key and the assertion would hold vacuously; naming +/// the field makes a rename or a typo a compile error instead. The JSON +/// key comes from the same identifier, which is exact for these types +/// because none of their fields carries a `serde(rename)`. macro_rules! assert_null_and_absent_agree { - ($ty:ty, $base:expr, $($field:literal),+ $(,)?) => {{ + ($ty:ty, $base:expr, $($field:ident),+ $(,)?) => {{ + let _bind_every_field = |value: &$ty| { $( let _ = &value.$field; )+ }; + let absent_payload: serde_json::Value = $base; let mut null_payload = absent_payload.clone(); { let object = null_payload .as_object_mut() .expect("fixture payload must be a JSON object"); - $( object.insert($field.to_owned(), serde_json::Value::Null); )+ + $( object.insert(stringify!($field).to_owned(), serde_json::Value::Null); )+ } + assert_ne!( + absent_payload, + null_payload, + concat!(stringify!($ty), ": fixture must not already carry the nulls"), + ); let absent: $ty = serde_json::from_value(absent_payload) .expect(concat!(stringify!($ty), " must decode with the fields absent")); @@ -54,44 +69,44 @@ macro_rules! assert_null_and_absent_agree { #[test] fn absent_and_explicit_null_optional_fields_decode_alike() { - assert_null_and_absent_agree!(AssetUpdateRequest, json!({}), "name", "tags"); + assert_null_and_absent_agree!(AssetUpdateRequest, json!({}), name, tags); assert_null_and_absent_agree!( CreateProfileRequest, json!({ "name": "evening" }), - "description", - "brightness", + description, + brightness, ); assert_null_and_absent_agree!( CreateSceneRequest, json!({ "name": "movie-night" }), - "description", - "enabled", - "mutation_mode", + description, + enabled, + mutation_mode, ); assert_null_and_absent_agree!( SavePresetRequest, json!({ "name": "warm", "effect": "aurora" }), - "description", - "controls", - "tags", + description, + controls, + tags, ); assert_null_and_absent_agree!( SavePlaylistRequest, json!({ "name": "rotation" }), - "description", - "loop_enabled", - "items", + description, + loop_enabled, + items, ); assert_null_and_absent_agree!( PlaylistItemRequest, json!({ "target": { "type": "effect", "effect": "aurora" } }), - "duration_ms", - "transition_ms", + duration_ms, + transition_ms, ); assert_null_and_absent_agree!( CreateLayerRequest, json!({ "source": { "type": "screen_region" } }), - "name", + name, ); assert_null_and_absent_agree!( UpdateLayerRequest, @@ -99,7 +114,7 @@ fn absent_and_explicit_null_optional_fields_decode_alike() { "id": "00000000-0000-0000-0000-000000000001", "source": { "type": "screen_region" }, }), - "name", + name, ); }