Skip to main content

viva_zenoh_api/
lib.rs

1//! The message payloads and topic names the viva GenICam camera services
2//! publish, and their clients subscribe to.
3//!
4//! This is the *contract*, not the transport. Despite the crate name, nothing
5//! here depends on the `zenoh` crate: these are plain serde types, so a client
6//! can agree with the service on the message format without linking a message
7//! broker — and the crate compiles everywhere, including `wasm32`.
8
9use serde::{Deserialize, Serialize};
10
11pub mod frame_header;
12pub use frame_header::{FRAME_MAGIC, FrameHeader, FrameHeaderError, HEADER_SIZE};
13
14// ── Discovery ────────────────────────────────────────────────────────────────
15
16/// Current GenICam Zenoh API version.
17///
18/// Increment this constant when making breaking changes to the Zenoh wire
19/// protocol.  The service publishes this value; clients check it on discovery
20/// and emit warnings when versions differ.
21///
22/// ## Version history
23/// - `1` — initial `NodeValueUpdate` contract with optional `min`/`max`/`inc`.
24/// - `2` — adds [`FeatureState`] / [`NumericRange`] / [`CommandResult`] for
25///   live introspection. `NodeValueUpdate` stays wire-compatible; readers that
26///   understand the new types can consume the new queryables and payloads.
27pub const API_VERSION: u32 = 2;
28
29/// Periodic announcement published by the camera service.
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct DeviceAnnounce {
32    pub id: String,
33    pub name: String,
34    pub model: String,
35    pub serial: String,
36    /// Zenoh API version supported by this service.
37    ///
38    /// `None` when deserializing from older services that do not include the
39    /// field — handled gracefully by the app (warns but still discovers).
40    #[serde(default)]
41    pub api_version: Option<u32>,
42}
43
44// ── Connection Lifecycle ─────────────────────────────────────────────────────
45
46/// Device connection status pushed by the service on change.
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct DeviceStatus {
49    pub connected: bool,
50    pub error: Option<String>,
51}
52
53/// Response to `genicam/devices/{id}/xml` queryable.
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct DeviceXmlResponse {
56    pub xml: String,
57}
58
59// ── Node Values ──────────────────────────────────────────────────────────────
60
61/// Live node value update published by the service on change.
62///
63/// `min`, `max`, and `inc` are optional runtime constraint hints.
64/// When present, the UI can tighten slider ranges without re-parsing XML.
65/// Services that do not implement constraint propagation may omit them.
66///
67/// **Legacy payload.** New code should publish [`FeatureState`] instead — it
68/// carries the same information plus runtime introspection (access mode, kind,
69/// implementation/availability, available enum entries). `NodeValueUpdate`
70/// stays in the wire contract so older services/clients keep working.
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct NodeValueUpdate {
73    pub value: serde_json::Value,
74    pub access_mode: String,
75    /// Optional minimum allowed value for this node at the current camera state.
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub min: Option<f64>,
78    /// Optional maximum allowed value for this node at the current camera state.
79    #[serde(skip_serializing_if = "Option::is_none")]
80    pub max: Option<f64>,
81    /// Optional increment (step) for this node's value.
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub inc: Option<f64>,
84}
85
86// ── FeatureState (live introspection) ────────────────────────────────────────
87
88/// Numeric range for an Integer or Float feature at the current camera state.
89///
90/// `min`/`max` are resolved from the XML's static declaration, from `pMin`/`pMax`
91/// references when present, or from selector-dependent rules. Services that
92/// cannot resolve the range must omit [`FeatureState::numeric`] entirely rather
93/// than invent defaults like `i64::MIN..=i64::MAX` (the UI renders "range
94/// unknown" in that case).
95#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
96pub struct NumericRange {
97    pub min: f64,
98    pub max: f64,
99    /// Optional increment (step). Omitted when the feature has no grid.
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub inc: Option<f64>,
102}
103
104/// Live state of a GenICam feature at a single point in time.
105///
106/// Replaces [`NodeValueUpdate`] as the authoritative snapshot consumed by the
107/// UI. Every field reflects what the device reports *now*, not what the XML
108/// declares statically.
109///
110/// # Design notes
111///
112/// - `kind` is a plain string matching `viva_genapi::Node::kind_name` values
113///   ("Integer", "Float", "Enumeration", "Boolean", "Command", "Category",
114///   "SwissKnife", "Converter", "IntConverter", "StringReg", "Register").
115///   Clients should
116///   tolerate unknown kinds.
117/// - `access_mode` uses GenICam spelling: `"RO"`, `"RW"`, `"WO"`, `"NA"`.
118/// - `is_implemented` and `is_available` default to `true` for deserialization
119///   from services that do not evaluate these predicates yet.
120/// - `numeric` is `Some` only for Integer/Float nodes with a resolvable range.
121/// - `enum_available` is `Some` only for Enumeration nodes. When the service
122///   cannot filter by IsAvailable it SHOULD still populate this with the full
123///   entry list so the UI stops falling back to static XML.
124#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
125pub struct FeatureState {
126    /// Current feature value, typed according to `kind`.
127    pub value: serde_json::Value,
128    /// Live access mode.
129    pub access_mode: String,
130    /// GenICam node kind.
131    pub kind: String,
132    /// Whether the node is implemented by the device.
133    #[serde(default = "default_true")]
134    pub is_implemented: bool,
135    /// Whether the node is currently accessible (selector gating etc).
136    #[serde(default = "default_true")]
137    pub is_available: bool,
138    /// Range for Integer/Float nodes, when resolvable.
139    #[serde(default, skip_serializing_if = "Option::is_none")]
140    pub numeric: Option<NumericRange>,
141    /// Available enum entries for Enumeration nodes.
142    #[serde(default, skip_serializing_if = "Option::is_none")]
143    pub enum_available: Option<Vec<String>>,
144    /// Engineering unit copied from node metadata (e.g. `"us"`).
145    #[serde(default, skip_serializing_if = "Option::is_none")]
146    pub unit: Option<String>,
147}
148
149fn default_true() -> bool {
150    true
151}
152
153impl FeatureState {
154    /// Project this state into the legacy [`NodeValueUpdate`] shape so callers
155    /// that still speak the old contract keep working during migration.
156    pub fn to_node_value_update(&self) -> NodeValueUpdate {
157        let (min, max, inc) = match &self.numeric {
158            Some(r) => (Some(r.min), Some(r.max), r.inc),
159            None => (None, None, None),
160        };
161        NodeValueUpdate {
162            value: self.value.clone(),
163            access_mode: self.access_mode.clone(),
164            min,
165            max,
166            inc,
167        }
168    }
169}
170
171impl From<&FeatureState> for NodeValueUpdate {
172    fn from(s: &FeatureState) -> Self {
173        s.to_node_value_update()
174    }
175}
176
177// ── CommandResult ────────────────────────────────────────────────────────────
178
179/// Response from an `execute` queryable.
180///
181/// Like [`NodeOpResponse`] but also returns the post-execution state of any
182/// nodes whose value is likely to have changed. For example, executing
183/// `AcquisitionStart` populates `affected_states` with the refreshed state of
184/// `AcquisitionStatus`, etc. The UI uses these to update badges / form inputs
185/// without a second round-trip.
186#[derive(Debug, Clone, Serialize, Deserialize)]
187pub struct CommandResult {
188    pub ok: bool,
189    #[serde(default, skip_serializing_if = "Option::is_none")]
190    pub error: Option<String>,
191    /// Map of node name -> refreshed state for nodes affected by this command.
192    /// Empty when the service cannot determine side effects.
193    #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
194    pub affected_states: std::collections::HashMap<String, FeatureState>,
195}
196
197/// Request payload for the `nodes/{name}/set` queryable.
198#[derive(Debug, Clone, Serialize, Deserialize)]
199pub struct NodeSetRequest {
200    pub value: serde_json::Value,
201}
202
203/// Generic response for node write, execute, and acquisition control.
204#[derive(Debug, Clone, Serialize, Deserialize)]
205pub struct NodeOpResponse {
206    pub ok: bool,
207    pub error: Option<String>,
208}
209
210// ── Bulk Node Read ────────────────────────────────────────────────────────────
211
212/// Request payload for the `nodes/bulk/read` queryable.
213///
214/// An empty `names` list is valid and returns an empty map.
215#[derive(Debug, Clone, Serialize, Deserialize)]
216pub struct BulkReadRequest {
217    pub names: Vec<String>,
218}
219
220/// Response to a `nodes/bulk/read` query.
221///
222/// `values` maps each requested node name to its current value + access_mode.
223/// Node names not found in the store are omitted (not an error).
224#[derive(Debug, Clone, Serialize, Deserialize)]
225pub struct BulkReadResponse {
226    pub values: std::collections::HashMap<String, NodeValueUpdate>,
227}
228
229// ── Acquisition ──────────────────────────────────────────────────────────────
230
231/// Request payload for the `acquisition/control` queryable.
232#[derive(Debug, Clone, Serialize, Deserialize)]
233pub struct AcquisitionControlRequest {
234    pub command: AcquisitionCommand,
235}
236
237#[derive(Debug, Clone, Serialize, Deserialize)]
238#[serde(rename_all = "lowercase")]
239pub enum AcquisitionCommand {
240    Start,
241    Stop,
242}
243
244/// Acquisition status pushed by the service on change.
245#[derive(Debug, Clone, Serialize, Deserialize)]
246pub struct AcquisitionStatus {
247    pub active: bool,
248    pub fps: Option<f32>,
249    pub dropped: u64,
250}
251
252// ── Image ────────────────────────────────────────────────────────────────────
253
254/// SFNC pixel format identifiers.
255#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
256pub enum PixelFormat {
257    Mono8,
258    Mono10,
259    Mono12,
260    Mono16,
261    BayerRG8,
262    BayerGR8,
263    BayerBG8,
264    BayerGB8,
265    BayerRG10,
266    BayerGR10,
267    BayerBG10,
268    BayerGB10,
269    BayerRG12,
270    BayerGR12,
271    BayerBG12,
272    BayerGB12,
273    BayerRG16,
274    BayerGR16,
275    BayerBG16,
276    BayerGB16,
277    RGB8,
278    BGR8,
279    RGBa8,
280    YCbCr422_8,
281    YCbCr8,
282    #[serde(rename = "Coord3D_C16")]
283    Coord3dC16,
284    #[serde(other)]
285    Unknown,
286}
287
288impl PixelFormat {
289    /// Bytes per pixel (or fractional for packed/subsampled formats).
290    pub fn bytes_per_pixel(&self) -> f32 {
291        match self {
292            Self::Mono8 | Self::BayerRG8 | Self::BayerGR8 | Self::BayerBG8 | Self::BayerGB8 => 1.0,
293            Self::Mono10
294            | Self::Mono12
295            | Self::Mono16
296            | Self::BayerRG10
297            | Self::BayerGR10
298            | Self::BayerBG10
299            | Self::BayerGB10
300            | Self::BayerRG12
301            | Self::BayerGR12
302            | Self::BayerBG12
303            | Self::BayerGB12
304            | Self::BayerRG16
305            | Self::BayerGR16
306            | Self::BayerBG16
307            | Self::BayerGB16
308            | Self::Coord3dC16 => 2.0,
309            Self::RGB8 | Self::BGR8 | Self::YCbCr8 => 3.0,
310            Self::RGBa8 => 4.0,
311            Self::YCbCr422_8 => 2.0,
312            Self::Unknown => 1.0,
313        }
314    }
315}
316
317/// Image stream metadata published at acquisition start and on format change.
318#[derive(Debug, Clone, Serialize, Deserialize)]
319pub struct ImageMeta {
320    pub pixel_format: PixelFormat,
321    pub width: u32,
322    pub height: u32,
323    pub payload_size: u64,
324}
325
326// ── Key Expressions ──────────────────────────────────────────────────────────
327
328/// Key expression constants and helpers for the GenICam Zenoh API.
329pub mod keys {
330    /// Wildcard subscription for all device announcements.
331    pub const ANNOUNCE_ALL: &str = "genicam/devices/*/announce";
332
333    pub fn announce(device_id: &str) -> String {
334        format!("genicam/devices/{device_id}/announce")
335    }
336
337    pub fn xml(device_id: &str) -> String {
338        format!("genicam/devices/{device_id}/xml")
339    }
340
341    pub fn status(device_id: &str) -> String {
342        format!("genicam/devices/{device_id}/status")
343    }
344
345    pub fn node_value(device_id: &str, node_name: &str) -> String {
346        format!("genicam/devices/{device_id}/nodes/{node_name}/value")
347    }
348
349    pub fn node_value_wildcard(device_id: &str) -> String {
350        format!("genicam/devices/{device_id}/nodes/*/value")
351    }
352
353    pub fn node_set(device_id: &str, node_name: &str) -> String {
354        format!("genicam/devices/{device_id}/nodes/{node_name}/set")
355    }
356
357    pub fn node_execute(device_id: &str, node_name: &str) -> String {
358        format!("genicam/devices/{device_id}/nodes/{node_name}/execute")
359    }
360
361    /// Key expression for a single-node [`super::FeatureState`] queryable.
362    /// Direction: App -> Service (queryable GET). Reply payload is a
363    /// [`super::FeatureState`] JSON object.
364    pub fn node_introspect(device_id: &str, node_name: &str) -> String {
365        format!("genicam/devices/{device_id}/nodes/{node_name}/state")
366    }
367
368    /// Key expression for the bulk node read queryable.
369    /// Direction: App -> Service (queryable GET).
370    pub fn nodes_bulk_read(device_id: &str) -> String {
371        format!("genicam/devices/{device_id}/nodes/bulk/read")
372    }
373
374    /// Key expression for the bulk [`super::FeatureState`] queryable.
375    /// Direction: App -> Service (queryable GET). Reply payload is a
376    /// `HashMap<String, FeatureState>` JSON object.
377    pub fn nodes_bulk_state(device_id: &str) -> String {
378        format!("genicam/devices/{device_id}/nodes/bulk/state")
379    }
380
381    pub fn acquisition_control(device_id: &str) -> String {
382        format!("genicam/devices/{device_id}/acquisition/control")
383    }
384
385    pub fn acquisition_status(device_id: &str) -> String {
386        format!("genicam/devices/{device_id}/acquisition/status")
387    }
388
389    pub fn image(device_id: &str) -> String {
390        format!("genicam/devices/{device_id}/image")
391    }
392
393    pub fn image_meta(device_id: &str) -> String {
394        format!("genicam/devices/{device_id}/image/meta")
395    }
396
397    /// Extract node name from `genicam/devices/{id}/nodes/{name}/{suffix}`
398    /// where suffix is `"value"`, `"set"`, or `"execute"`.
399    pub fn extract_node_name(key: &str) -> Option<&str> {
400        let parts: Vec<&str> = key.split('/').collect();
401        if parts.len() >= 6 && parts[parts.len() - 3] == "nodes" {
402            Some(parts[parts.len() - 2])
403        } else {
404            None
405        }
406    }
407}
408
409#[cfg(test)]
410mod tests {
411    use super::*;
412
413    #[test]
414    fn test_device_announce_deserializes_without_api_version() {
415        let legacy = r#"{"id":"cam0","name":"Test Cam","model":"M1","serial":"S1"}"#;
416        let a: DeviceAnnounce = serde_json::from_str(legacy).expect("should deserialize");
417        assert!(
418            a.api_version.is_none(),
419            "api_version should be None for legacy JSON"
420        );
421    }
422
423    #[test]
424    fn test_device_announce_deserializes_with_api_version() {
425        let json = r#"{"id":"cam0","name":"Test","model":"M","serial":"S","api_version":1}"#;
426        let a: DeviceAnnounce = serde_json::from_str(json).expect("should deserialize");
427        assert_eq!(a.api_version, Some(1));
428    }
429
430    #[test]
431    fn test_node_value_update_without_constraints() {
432        let u = NodeValueUpdate {
433            value: serde_json::json!(42),
434            access_mode: "RW".to_string(),
435            min: None,
436            max: None,
437            inc: None,
438        };
439        let s = serde_json::to_string(&u).expect("serialization failed");
440        assert!(!s.contains("\"min\""), "min should be absent: {s}");
441        assert!(!s.contains("\"max\""), "max should be absent: {s}");
442        assert!(!s.contains("\"inc\""), "inc should be absent: {s}");
443        assert!(s.contains("\"value\""));
444        assert!(s.contains("\"access_mode\""));
445    }
446
447    #[test]
448    fn test_node_value_update_with_constraints() {
449        let u = NodeValueUpdate {
450            value: serde_json::json!(1024),
451            access_mode: "RW".to_string(),
452            min: Some(1.0),
453            max: Some(4096.0),
454            inc: Some(1.0),
455        };
456        let s = serde_json::to_string(&u).expect("serialization failed");
457        let d: NodeValueUpdate = serde_json::from_str(&s).expect("deserialization failed");
458        assert_eq!(d.min, Some(1.0));
459        assert_eq!(d.max, Some(4096.0));
460        assert_eq!(d.inc, Some(1.0));
461        assert_eq!(d.access_mode, "RW");
462    }
463
464    #[test]
465    fn test_extract_node_name_value_key() {
466        assert_eq!(
467            keys::extract_node_name("genicam/devices/cam0/nodes/Width/value"),
468            Some("Width")
469        );
470    }
471
472    #[test]
473    fn test_extract_node_name_set_key() {
474        assert_eq!(
475            keys::extract_node_name("genicam/devices/cam0/nodes/Width/set"),
476            Some("Width")
477        );
478    }
479
480    #[test]
481    fn test_extract_node_name_execute_key() {
482        assert_eq!(
483            keys::extract_node_name("genicam/devices/cam0/nodes/AcquisitionStart/execute"),
484            Some("AcquisitionStart")
485        );
486    }
487
488    #[test]
489    fn test_extract_node_name_too_short() {
490        assert_eq!(keys::extract_node_name("genicam/devices/cam0"), None);
491    }
492
493    #[test]
494    fn test_extract_node_name_non_node_key() {
495        assert_eq!(
496            keys::extract_node_name("genicam/devices/cam0/acquisition/control/something"),
497            None
498        );
499    }
500
501    #[test]
502    fn test_node_value_update_deserializes_legacy() {
503        let legacy = r#"{"value": 1024, "access_mode": "RW"}"#;
504        let d: NodeValueUpdate = serde_json::from_str(legacy).expect("deserialization failed");
505        assert!(d.min.is_none());
506        assert!(d.max.is_none());
507        assert!(d.inc.is_none());
508        assert_eq!(d.access_mode, "RW");
509    }
510
511    // ── FeatureState ────────────────────────────────────────────────────────
512
513    #[test]
514    fn test_feature_state_minimal_roundtrip() {
515        let s = FeatureState {
516            value: serde_json::json!(1920),
517            access_mode: "RW".to_string(),
518            kind: "Integer".to_string(),
519            is_implemented: true,
520            is_available: true,
521            numeric: None,
522            enum_available: None,
523            unit: None,
524        };
525        let j = serde_json::to_string(&s).expect("serialize");
526        // Optional fields should be skipped.
527        assert!(!j.contains("\"numeric\""), "numeric should be absent: {j}");
528        assert!(
529            !j.contains("\"enum_available\""),
530            "enum_available absent: {j}"
531        );
532        assert!(!j.contains("\"unit\""), "unit absent: {j}");
533        let d: FeatureState = serde_json::from_str(&j).expect("deserialize");
534        assert_eq!(d, s);
535    }
536
537    #[test]
538    fn test_feature_state_defaults_when_fields_missing() {
539        // A service that does not yet populate is_implemented/is_available
540        // must still deserialize into a usable state.
541        let legacy = r#"{"value":1920,"access_mode":"RW","kind":"Integer"}"#;
542        let d: FeatureState = serde_json::from_str(legacy).expect("deserialize");
543        assert!(d.is_implemented, "is_implemented defaults to true");
544        assert!(d.is_available, "is_available defaults to true");
545    }
546
547    #[test]
548    fn test_feature_state_integer_with_range() {
549        let s = FeatureState {
550            value: serde_json::json!(512),
551            access_mode: "RW".to_string(),
552            kind: "Integer".to_string(),
553            is_implemented: true,
554            is_available: true,
555            numeric: Some(NumericRange {
556                min: 16.0,
557                max: 4096.0,
558                inc: Some(8.0),
559            }),
560            enum_available: None,
561            unit: Some("px".to_string()),
562        };
563        let j = serde_json::to_string(&s).expect("serialize");
564        let d: FeatureState = serde_json::from_str(&j).expect("deserialize");
565        assert_eq!(d, s);
566    }
567
568    #[test]
569    fn test_feature_state_to_node_value_update() {
570        let s = FeatureState {
571            value: serde_json::json!(512),
572            access_mode: "RW".to_string(),
573            kind: "Integer".to_string(),
574            is_implemented: true,
575            is_available: true,
576            numeric: Some(NumericRange {
577                min: 16.0,
578                max: 4096.0,
579                inc: Some(8.0),
580            }),
581            enum_available: None,
582            unit: None,
583        };
584        let u: NodeValueUpdate = (&s).into();
585        assert_eq!(u.value, serde_json::json!(512));
586        assert_eq!(u.access_mode, "RW");
587        assert_eq!(u.min, Some(16.0));
588        assert_eq!(u.max, Some(4096.0));
589        assert_eq!(u.inc, Some(8.0));
590    }
591
592    #[test]
593    fn test_feature_state_enumeration() {
594        let s = FeatureState {
595            value: serde_json::json!("Once"),
596            access_mode: "RW".to_string(),
597            kind: "Enumeration".to_string(),
598            is_implemented: true,
599            is_available: true,
600            numeric: None,
601            enum_available: Some(vec!["Off".to_string(), "Once".to_string()]),
602            unit: None,
603        };
604        let j = serde_json::to_string(&s).expect("serialize");
605        assert!(j.contains("\"enum_available\""));
606        let d: FeatureState = serde_json::from_str(&j).expect("deserialize");
607        assert_eq!(
608            d.enum_available.as_deref(),
609            Some(&["Off".to_string(), "Once".to_string()][..])
610        );
611    }
612
613    #[test]
614    fn test_command_result_ok_empty() {
615        let r = CommandResult {
616            ok: true,
617            error: None,
618            affected_states: std::collections::HashMap::new(),
619        };
620        let j = serde_json::to_string(&r).expect("serialize");
621        assert!(
622            !j.contains("\"affected_states\""),
623            "empty affected_states should be skipped: {j}"
624        );
625        assert!(
626            !j.contains("\"error\""),
627            "none error should be skipped: {j}"
628        );
629    }
630
631    #[test]
632    fn test_command_result_with_affected_states() {
633        let mut affected = std::collections::HashMap::new();
634        affected.insert(
635            "AcquisitionStatus".to_string(),
636            FeatureState {
637                value: serde_json::json!(true),
638                access_mode: "RO".to_string(),
639                kind: "Boolean".to_string(),
640                is_implemented: true,
641                is_available: true,
642                numeric: None,
643                enum_available: None,
644                unit: None,
645            },
646        );
647        let r = CommandResult {
648            ok: true,
649            error: None,
650            affected_states: affected,
651        };
652        let j = serde_json::to_string(&r).expect("serialize");
653        let d: CommandResult = serde_json::from_str(&j).expect("deserialize");
654        assert!(d.ok);
655        assert_eq!(d.affected_states.len(), 1);
656        assert!(d.affected_states.contains_key("AcquisitionStatus"));
657    }
658}