Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions config/default.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ freq_hz = 1000000
latch_time_micros = 500
crossfade_ms = 300

[transitions]
default_fade_ms = 300
default_curve = "ease_in_out"

[server]
ip_address = "127.0.0.1"
port = 5000
Expand Down
58 changes: 50 additions & 8 deletions crates/api/src/active_scene.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use serde::Deserialize;
use store::{NewLayer, Store};

use crate::error::ApiError;
use crate::transition::TransitionQuery;
use crate::types::{LayerResponse, SceneResponse};

#[get("/scenes/active")]
Expand All @@ -31,6 +32,10 @@ pub async fn clear_active_scene(
Ok(HttpResponse::NoContent().finish())
}

fn default_opacity() -> f32 {
1.0
}

#[derive(Deserialize)]
struct AddLayerRequest {
effect_id: String,
Expand All @@ -39,6 +44,8 @@ struct AddLayerRequest {
blend_mode: BlendMode,
#[serde(default)]
params: HashMap<String, ParamValue>,
#[serde(default = "default_opacity")]
opacity: f32,
}

impl From<AddLayerRequest> for NewLayer {
Expand All @@ -48,6 +55,7 @@ impl From<AddLayerRequest> for NewLayer {
zone_id: r.zone_id,
blend_mode: r.blend_mode,
params: r.params,
opacity: r.opacity,
}
}
}
Expand All @@ -56,11 +64,12 @@ impl From<AddLayerRequest> for NewLayer {
pub async fn set_active_scene(
store: web::Data<Arc<Store>>,
runtime: web::Data<dyn SceneRuntime>,
query: web::Query<TransitionQuery>,
body: web::Json<Vec<AddLayerRequest>>,
) -> Result<impl Responder, ApiError> {
let layers: Vec<NewLayer> = body.into_inner().into_iter().map(Into::into).collect();
store.replace_active_layers(&layers).await?;
runtime.reload_active();
runtime.reload_active_with(query.into_inner().resolve(runtime.as_ref()));
let layers = store.get_active_layers().await?;
Ok(HttpResponse::Ok().json(
layers
Expand All @@ -74,6 +83,7 @@ pub async fn set_active_scene(
pub async fn add_layer(
store: web::Data<Arc<Store>>,
runtime: web::Data<dyn SceneRuntime>,
query: web::Query<TransitionQuery>,
body: web::Json<AddLayerRequest>,
) -> Result<impl Responder, ApiError> {
let layer = store
Expand All @@ -84,7 +94,7 @@ pub async fn add_layer(
&body.params,
)
.await?;
runtime.reload_active();
runtime.reload_active_with(query.into_inner().resolve(runtime.as_ref()));
Ok(HttpResponse::Created().json(LayerResponse::from(layer)))
}

Expand All @@ -94,12 +104,14 @@ struct PatchLayerRequest {
enabled: Option<bool>,
blend_mode: Option<BlendMode>,
params: Option<HashMap<String, ParamValue>>,
opacity: Option<f32>,
}

#[patch("/scenes/active/layers/{id}")]
pub async fn patch_layer(
store: web::Data<Arc<Store>>,
runtime: web::Data<dyn SceneRuntime>,
query: web::Query<TransitionQuery>,
path: web::Path<String>,
body: web::Json<PatchLayerRequest>,
) -> Result<impl Responder, ApiError> {
Expand All @@ -109,21 +121,49 @@ pub async fn patch_layer(
let enabled = body.enabled.unwrap_or(current.enabled);
let blend_mode = body.blend_mode.unwrap_or(current.blend_mode);
let params = body.params.clone().unwrap_or(current.params);
let layer = store
store
.update_active_layer(&id, zone_id, enabled, blend_mode, &params)
.await?;
runtime.reload_active();
if let Some(opacity) = body.opacity {
store.update_active_layer_opacity(&id, opacity).await?;
}
let layer = store.get_active_layer(&id).await?;
runtime.reload_active_with(query.into_inner().resolve(runtime.as_ref()));
Ok(HttpResponse::Ok().json(LayerResponse::from(layer)))
}

#[delete("/scenes/active/layers/{id}")]
pub async fn remove_layer(
store: web::Data<Arc<Store>>,
runtime: web::Data<dyn SceneRuntime>,
query: web::Query<TransitionQuery>,
path: web::Path<String>,
) -> Result<impl Responder, ApiError> {
store.remove_active_layer(&path.into_inner()).await?;
runtime.reload_active();
let id = path.into_inner();
let spec = query.into_inner().resolve(runtime.as_ref());

if spec.is_instant() {
store.remove_active_layer(&id).await?;
runtime.reload_active_with(spec);
return Ok(HttpResponse::NoContent().finish());
}

// Fade-out: tween opacity to 0 in-place, then drop the row once the
// visible fade is done. Reload runs immediately so the LiveParam picks
// up the new target; the cleanup reload uses INSTANT to avoid stacking
// another scene crossfade on top of a layer that has already faded out.
store.update_active_layer_opacity(&id, 0.0).await?;
runtime.reload_active_with(spec);

let store = store.into_inner();
let runtime: Arc<dyn SceneRuntime> = web::Data::clone(&runtime).into_inner();
let fade_ms = spec.duration_ms as u64;
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(fade_ms)).await;
let _ = store.remove_active_layer(&id).await;
runtime.reload_active_with(domain::TransitionSpec::INSTANT);
});

Ok(HttpResponse::NoContent().finish())
}

Expand All @@ -136,10 +176,11 @@ struct ReorderRequest {
pub async fn reorder_layers(
store: web::Data<Arc<Store>>,
runtime: web::Data<dyn SceneRuntime>,
query: web::Query<TransitionQuery>,
body: web::Json<ReorderRequest>,
) -> Result<impl Responder, ApiError> {
store.reorder_active_layers(&body.ordered_ids).await?;
runtime.reload_active();
runtime.reload_active_with(query.into_inner().resolve(runtime.as_ref()));
Ok(HttpResponse::NoContent().finish())
}

Expand All @@ -161,10 +202,11 @@ pub async fn save_active_scene(
pub async fn load_scene_into_active(
store: web::Data<Arc<Store>>,
runtime: web::Data<dyn SceneRuntime>,
query: web::Query<TransitionQuery>,
path: web::Path<String>,
) -> Result<impl Responder, ApiError> {
store.load_scene_into_active(&path.into_inner()).await?;
runtime.reload_active();
runtime.reload_active_with(query.into_inner().resolve(runtime.as_ref()));
Ok(HttpResponse::NoContent().finish())
}

Expand Down
10 changes: 7 additions & 3 deletions crates/api/src/device.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ use application::{SceneRuntime, SceneSnapshot};
use domain::Rgb;
use serde::{Deserialize, Serialize};

use crate::transition::TransitionQuery;

#[derive(Serialize)]
struct DeviceStateResponse {
on: bool,
Expand Down Expand Up @@ -37,16 +39,18 @@ struct PatchStateRequest {
#[patch("/device/state")]
pub async fn patch_state(
runtime: web::Data<dyn SceneRuntime>,
query: web::Query<TransitionQuery>,
body: web::Json<PatchStateRequest>,
) -> impl Responder {
let spec = query.into_inner().resolve(runtime.as_ref());
if let Some(on) = body.on {
runtime.set_on_off(on);
runtime.set_on_off_with(on, spec);
}
if let Some(brightness) = body.brightness {
runtime.set_brightness(brightness);
runtime.set_brightness_with(brightness, spec);
}
if let Some(color) = body.color {
runtime.set_color(color);
runtime.set_color_with(color, spec);
}
HttpResponse::Ok().json(DeviceStateResponse::from(runtime.snapshot()))
}
Expand Down
2 changes: 2 additions & 0 deletions crates/api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,7 @@ pub mod effects;
pub mod error;
pub mod events;
pub mod scenes;
pub mod state_stack;
pub mod transition;
pub mod types;
pub mod zones;
91 changes: 91 additions & 0 deletions crates/api/src/state_stack.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
use std::sync::Arc;

use actix_web::{HttpResponse, Responder, delete, get, post, web};
use application::SceneRuntime;
use serde::Serialize;
use store::{StackDevice, StackEntry, StackLayer, Store};

use crate::error::ApiError;
use crate::transition::TransitionQuery;

#[derive(Serialize)]
struct StackSummary {
position: i64,
pushed_at: i64,
layer_count: usize,
}

impl From<&StackEntry> for StackSummary {
fn from(e: &StackEntry) -> Self {
Self {
position: e.position,
pushed_at: e.pushed_at,
layer_count: e.layers.len(),
}
}
}

#[post("/scenes/active/push")]
pub async fn push_state(
store: web::Data<Arc<Store>>,
runtime: web::Data<dyn SceneRuntime>,
) -> Result<impl Responder, ApiError> {
let layers: Vec<StackLayer> = store
.get_active_layers()
.await?
.into_iter()
.map(|l| StackLayer {
effect_id: l.effect_id,
zone_id: l.zone_id,
blend_mode: l.blend_mode,
enabled: l.enabled,
params: l.params,
opacity: l.opacity,
})
.collect();

let snapshot = runtime.snapshot();
let device = StackDevice {
on: snapshot.on,
brightness: snapshot.brightness,
color: snapshot.color,
};
store.push_state(&layers, &device).await?;
Ok(HttpResponse::NoContent().finish())
}

#[post("/scenes/active/pop")]
pub async fn pop_state(
store: web::Data<Arc<Store>>,
runtime: web::Data<dyn SceneRuntime>,
query: web::Query<TransitionQuery>,
) -> Result<impl Responder, ApiError> {
let entry = store.pop_state().await?.ok_or(ApiError::NotFound)?;
store.restore_active_from_stack(&entry.layers).await?;
let spec = query.into_inner().resolve(runtime.as_ref());
runtime.set_on_off_with(entry.device.on, spec);
runtime.set_brightness_with(entry.device.brightness, spec);
runtime.set_color_with(entry.device.color, spec);
runtime.reload_active_with(spec);
Ok(HttpResponse::NoContent().finish())
}

#[get("/scenes/active/stack")]
pub async fn get_stack(store: web::Data<Arc<Store>>) -> Result<impl Responder, ApiError> {
let entries = store.list_state_stack().await?;
let summaries: Vec<StackSummary> = entries.iter().map(StackSummary::from).collect();
Ok(HttpResponse::Ok().json(summaries))
}

#[delete("/scenes/active/stack")]
pub async fn clear_stack(store: web::Data<Arc<Store>>) -> Result<impl Responder, ApiError> {
store.clear_state_stack().await?;
Ok(HttpResponse::NoContent().finish())
}

pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(push_state)
.service(pop_state)
.service(get_stack)
.service(clear_stack);
}
19 changes: 19 additions & 0 deletions crates/api/src/transition.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
use application::SceneRuntime;
use domain::{TransitionCurve, TransitionSpec};
use serde::Deserialize;

#[derive(Debug, Default, Clone, Copy, Deserialize)]
pub struct TransitionQuery {
pub fade_ms: Option<u32>,
pub curve: Option<TransitionCurve>,
}

impl TransitionQuery {
pub fn resolve(self, runtime: &dyn SceneRuntime) -> TransitionSpec {
let default = runtime.default_spec();
TransitionSpec::new(
self.fade_ms.unwrap_or(default.duration_ms),
self.curve.unwrap_or(default.curve),
)
}
}
2 changes: 2 additions & 0 deletions crates/api/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ pub struct LayerResponse {
pub params: HashMap<String, ParamValue>,
pub enabled: bool,
pub position: u32,
pub opacity: f32,
}

impl From<LayerRecord> for LayerResponse {
Expand All @@ -25,6 +26,7 @@ impl From<LayerRecord> for LayerResponse {
params: r.params,
enabled: r.enabled,
position: r.position,
opacity: r.opacity,
}
}
}
Expand Down
Loading
Loading