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?; 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-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/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..19a3c4dd3 --- /dev/null +++ b/crates/hypercolor-types/src/api/displays.rs @@ -0,0 +1,77 @@ +//! 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, +} + +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, skip_serializing_if = "HashMap::is_empty")] + 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..f53f8e715 --- /dev/null +++ b/crates/hypercolor-types/src/api/layers.rs @@ -0,0 +1,180 @@ +//! 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 +/// `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. + #[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/crates/hypercolor-types/tests/api_request_tests.rs b/crates/hypercolor-types/tests/api_request_tests.rs new file mode 100644 index 000000000..303942dc4 --- /dev/null +++ b/crates/hypercolor-types/tests/api_request_tests.rs @@ -0,0 +1,332 @@ +//! 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::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::{ + CreateLayerRequest, PatchLayerControlsRequest, UpdateLayerRequest, +}; +use hypercolor_types::api::library::{ + PlaylistItemRequest, PlaylistTargetRequest, SavePlaylistRequest, SavePresetRequest, +}; +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. +/// +/// 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: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(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")); + 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!( + 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..c6f8d345c 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) @@ -99,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/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 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..75f3445b5 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,12 @@ 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 +134,12 @@ 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..d832ab2aa 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 + `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. @@ -122,7 +123,8 @@ async def asyncio_detailed( Args: id (str): zone_id (str): - body (LayerOrderRequest): + body (LayerOrderRequest): Request body for + `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/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..a19915680 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 + `PATCH /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..872da30d3 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,15 @@ @_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