Skip to content
Merged
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
4 changes: 4 additions & 0 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,10 @@ jobs:
sudo apt-get update
sudo apt-get install -y libgtk-4-dev libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libwebkitgtk-6.0-dev libssl-dev xvfb xauth dbus-x11
- run: cargo test --manifest-path client/Cargo.toml --workspace --locked
- name: Preserved display metadata and fullscreen refresh
run: xvfb-run -a dbus-run-session -- cargo test --manifest-path client/Cargo.toml display_refresh_updates -- --ignored --test-threads=1
- name: Layout animation cleanup without frame ticks
run: xvfb-run -a dbus-run-session -- cargo test --manifest-path client/Cargo.toml layout_animation_restores_tiles -- --ignored --test-threads=1
- name: Linux sleep screen and activity regression
run: xvfb-run -a dbus-run-session -- cargo test --manifest-path client/Cargo.toml sleep_integration -- --ignored --test-threads=1
- name: Confirm healthy pairing UI before enrollment
Expand Down
135 changes: 134 additions & 1 deletion client/core/src/layout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ use std::collections::HashMap;
use serde_json::Value;
use url::Url;

use super::bundle::{BundleCell, BundleDisplayWithLayouts, BundleLayout, KioskBundle};
use super::bundle::{BundleCell, BundleDisplayWithLayouts, BundleLayout, KioskBundle, BundleCamera};

pub const NO_LAYOUTS_ASSIGNED_MESSAGE: &str =
"go into BetterFrame and assign layouts to this display";
Expand Down Expand Up @@ -51,6 +51,72 @@ pub fn resolve_display<'a>(
.or_else(|| bundle.displays.get(native_index))
}

/// Only fields consumed by stream selection and pipeline construction.
/// Password fields must already be normalized by the platform's comparator.
pub fn camera_pipeline_inputs(camera: &BundleCamera) -> Value {
let streams: Vec<_> = camera.streams.iter()
.map(|stream| serde_json::json!({"role": stream.role, "uri": stream.rtsp_uri}))
.collect();
serde_json::json!({
"enabled": camera.enabled,
"url": camera.rtsp_url,
"streams": streams,
"username": camera.playback_username,
"password": camera.playback_password_encrypted,
})
}

/// A rename updates the visible tile label but need not reconnect its decoder.
pub fn camera_render_inputs(camera: &BundleCamera) -> Value {
serde_json::json!({"name": camera.name, "pipeline": camera_pipeline_inputs(camera)})
}

/// Whether a bundle refresh can keep a display's existing widgets and overrides.
/// Inactive layouts still enter the cached bundle for the next layout switch.
pub fn display_render_unchanged(
previous: &KioskBundle,
next: &KioskBundle,
display_id: &str,
active_id: Option<&str>,
) -> bool {
fn inputs(bundle: &KioskBundle, display_id: &str, active_id: Option<&str>) -> Option<Value> {
let displays = bundle.normalized_displays();
let display = displays.iter().find(|d| d.id == display_id)?;
let active_id = active_id?;
let layout = display.layouts.iter().find(|l| l.id == active_id)?;
let cameras: std::collections::BTreeMap<_, _> = bundle.cameras.iter()
.filter(|camera| layout.preload_camera_ids.contains(&camera.id)
|| layout.cells.iter().any(|cell| cell.camera_id.as_deref() == Some(&camera.id)))
.map(|camera| {
let visible = layout.cells.iter().any(|cell| cell.camera_id.as_deref() == Some(&camera.id));
(camera.id.as_str(), if visible { camera_render_inputs(camera) } else { camera_pipeline_inputs(camera) })
})
.collect();
Some(serde_json::json!({
"width": display.width_px,
"height": display.height_px,
"layout": layout,
Comment thread
bcbetterninja marked this conversation as resolved.
"cameras": cameras,
"operator_console": bundle.operator_console,
"tenant": bundle.tenant_slug,
Comment thread
bcbetterninja marked this conversation as resolved.
}))
}
let old = inputs(previous, display_id, active_id);
old.is_some() && old == inputs(next, display_id, active_id)
Comment thread
bcbetterninja marked this conversation as resolved.
}

/// Pool entries are keyed by camera ID and stream role, so their configuration
/// must also match before an old decoder/RTSP connection can be reused.
pub fn unchanged_camera_ids(previous: Option<&KioskBundle>, next: &KioskBundle) -> std::collections::HashSet<String> {
let Some(previous) = previous else { return Default::default() };
let old: HashMap<_, _> = previous.cameras.iter().map(|camera| (&camera.id, camera)).collect();
next.cameras.iter().filter(|camera| {
old.get(&camera.id).is_some_and(|prior| {
camera_pipeline_inputs(prior) == camera_pipeline_inputs(camera)
})
}).map(|camera| camera.id.clone()).collect()
}

pub fn configured_cell_action(cell: &BundleCell, kind: &str) -> Option<(String, Value)> {
let event = cell.input_options.as_ref()?.get("events")?.get(kind)?;
Some((
Expand Down Expand Up @@ -86,6 +152,73 @@ mod tests {
use super::*;
use crate::bundle::BundleDisplayWithLayouts;

#[test]
fn refresh_only_invalidates_the_active_display_content() {
let previous: KioskBundle = serde_json::from_value(serde_json::json!({
"kiosk_id": "k", "kiosk_name": "Kiosk", "version": "1", "cameras": [],
"displays": [{
"id": "d", "name": "Display", "width_px": 1920, "height_px": 1080,
"idle_timeout_seconds": 0, "sleep_timeout_seconds": 0,
"layouts": [
{"id": "active", "name": "Active", "grid_cols": 1, "grid_rows": 1,
"priority": "normal", "is_default": true, "resets_idle_timer": true, "cells": []},
{"id": "other", "name": "Other", "grid_cols": 1, "grid_rows": 1,
"priority": "normal", "is_default": false, "resets_idle_timer": true, "cells": []}
]
}]
})).unwrap();
let unchanged = |next: &KioskBundle| display_render_unchanged(&previous, next, "d", Some("active"));
let mut next = previous.clone();
next.version = "2".into();
assert!(unchanged(&next), "unassigned edits / repeated notifications keep the display");
next.displays[0].layouts[1].grid_cols = 2;
assert!(unchanged(&next), "inactive assigned layout edits keep the display");
next.displays[0].layouts.remove(1);
assert!(unchanged(&next), "removing an inactive assignment keeps the display");
next.displays[0].layouts[0].grid_cols = 2;
assert!(!unchanged(&next), "active layout edits must render");
next = previous.clone();
next.displays[0].layouts.remove(0);
assert!(!unchanged(&next), "removing the active assignment must render a fallback");
next = previous.clone();
next.displays[0].width_px = 1280;
assert!(!unchanged(&next));
next.displays.clear();
assert!(!unchanged(&next));

let mut preloaded = previous.clone();
preloaded.cameras.push(serde_json::from_value(serde_json::json!({
"id": "preload", "name": "Preload", "type": "onvif", "stream_policy": "auto",
"streams": [{"id": "s", "name": "Sub", "role": "sub", "rtsp_uri": "rtsp://old/sub"}]
})).unwrap());
preloaded.displays[0].layouts[0].preload_camera_ids.push("preload".into());
assert!(unchanged_camera_ids(Some(&preloaded), &preloaded).contains("preload"));
let mut metadata = preloaded.clone();
metadata.cameras[0].last_seen_at = Some("2026-09-23T22:00:00Z".into());
metadata.cameras[0].labels.push("new-label".into());
metadata.cameras[0].capabilities.push("ptz".into());
metadata.cameras[0].recording_config = serde_json::json!({"changed": true});
metadata.cameras[0].onvif_password_encrypted = Some("new-event-credential".into());
metadata.cameras[0].streams[0].name = "Renamed stream".into();
assert!(display_render_unchanged(&preloaded, &metadata, "d", Some("active")));
assert!(unchanged_camera_ids(Some(&preloaded), &metadata).contains("preload"));
metadata.cameras[0].name = "Renamed camera".into();
assert!(unchanged_camera_ids(Some(&preloaded), &metadata).contains("preload"));
assert_ne!(camera_render_inputs(&preloaded.cameras[0]), camera_render_inputs(&metadata.cameras[0]));
let mut changed = preloaded.clone();
changed.cameras[0].streams[0].rtsp_uri = "rtsp://new/sub".into();
assert!(!display_render_unchanged(&preloaded, &changed, "d", Some("active")));
assert!(!unchanged_camera_ids(Some(&preloaded), &changed).contains("preload"));
changed = preloaded.clone();
changed.cameras[0].playback_password_encrypted = Some("new-encrypted-value".into());
assert!(!display_render_unchanged(&preloaded, &changed, "d", Some("active")));
assert!(!unchanged_camera_ids(Some(&preloaded), &changed).contains("preload"));
changed.cameras.clear();
assert!(!display_render_unchanged(&preloaded, &changed, "d", Some("active")));
assert!(unchanged_camera_ids(Some(&preloaded), &changed).is_empty());
assert!(unchanged_camera_ids(None, &preloaded).is_empty());
}

#[test]
fn selects_default_then_first_layout() {
let mut display: BundleDisplayWithLayouts = serde_json::from_value(serde_json::json!({
Expand Down
Loading
Loading