Skip to main content

spvirit_server/
group.rs

1//! Group PV configuration: parse JSON definitions that compose multiple
2//! individual PVs into a single structured PVA channel.
3//!
4//! Corresponds to the C++ QSRV `group` info-tag / JSON config format.
5//! A group PV is a composite PVA channel whose top-level fields each map
6//! to a different backing PV ("member").
7//!
8//! # JSON format
9//!
10//! ```json
11//! {
12//!     "GRP:name": {
13//!         "+id": "epics:nt/NTTable:1.0",
14//!         "+atomic": true,
15//!         "fieldA": {
16//!             "+channel": "RECORD:A",
17//!             "+type": "scalar",
18//!             "+trigger": "*",
19//!             "+putorder": 0
20//!         },
21//!         "fieldB": { "+channel": "RECORD:B" }
22//!     }
23//! }
24//! ```
25
26use std::collections::HashMap;
27use std::fmt;
28
29use serde::Deserialize;
30
31// ---------------------------------------------------------------------------
32// Public types
33// ---------------------------------------------------------------------------
34
35/// How a member PV's value is mapped into the group structure.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum FieldMapping {
38    /// NTScalar/NTScalarArray with full metadata (alarm, timestamp, display, control).
39    Scalar,
40    /// Value only, no metadata.
41    Plain,
42    /// Alarm + timestamp only, no value transfer.
43    Meta,
44    /// Variant-union wrapping (pass-through).
45    Any,
46    /// Process-only: put triggers record processing, no value transfer.
47    Proc,
48}
49
50/// When a member's value changes, which group fields should be re-published.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub enum TriggerDef {
53    /// Trigger all fields (`"*"`).
54    All,
55    /// Trigger only the named fields.
56    Fields(Vec<String>),
57    /// Never trigger (dead member).
58    None,
59}
60
61/// Definition of one field inside a group PV.
62#[derive(Debug, Clone)]
63pub struct GroupMember {
64    /// Field name in the group structure.
65    pub field_name: String,
66    /// Backing channel / PV name.
67    pub channel: String,
68    /// How the PV value is mapped.
69    pub mapping: FieldMapping,
70    /// Which fields a change to this member triggers.
71    pub triggers: TriggerDef,
72    /// Ordering for put operations (lower = earlier).
73    pub put_order: i32,
74    /// Optional struct-id override for this field.
75    pub struct_id: Option<String>,
76}
77
78/// A group PV definition: a structured channel composed of several member PVs.
79#[derive(Debug, Clone)]
80pub struct GroupPvDef {
81    /// Channel name for the group.
82    pub name: String,
83    /// Optional struct-id (e.g. `"epics:nt/NTTable:1.0"`).
84    pub struct_id: Option<String>,
85    /// Whether GET/PUT/MONITOR operate atomically across all members.
86    pub atomic: bool,
87    /// Ordered list of member field definitions.
88    pub members: Vec<GroupMember>,
89}
90
91// ---------------------------------------------------------------------------
92// Error
93// ---------------------------------------------------------------------------
94
95#[derive(Debug)]
96pub struct GroupConfigError(String);
97
98impl fmt::Display for GroupConfigError {
99    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
100        write!(f, "group config: {}", self.0)
101    }
102}
103
104impl std::error::Error for GroupConfigError {}
105
106type Result<T> = std::result::Result<T, GroupConfigError>;
107
108fn err(msg: impl Into<String>) -> GroupConfigError {
109    GroupConfigError(msg.into())
110}
111
112// ---------------------------------------------------------------------------
113// Serde helpers (intermediate JSON representation)
114// ---------------------------------------------------------------------------
115
116/// Top-level JSON: `{ "GROUP:name": { ...members... } }`
117type RawConfig = HashMap<String, RawGroupDef>;
118
119/// One group definition: meta keys (`+id`, `+atomic`) mixed with member defs.
120#[derive(Deserialize)]
121struct RawGroupDef {
122    #[serde(rename = "+id", default)]
123    id: Option<String>,
124    #[serde(rename = "+atomic", default)]
125    atomic: Option<bool>,
126    #[serde(flatten)]
127    members: HashMap<String, RawMember>,
128}
129
130#[derive(Deserialize)]
131struct RawMember {
132    #[serde(rename = "+channel", default)]
133    channel: Option<String>,
134    #[serde(rename = "+type", default)]
135    mapping: Option<String>,
136    #[serde(rename = "+trigger", default)]
137    trigger: Option<String>,
138    #[serde(rename = "+putorder", default)]
139    putorder: Option<i32>,
140    #[serde(rename = "+id", default)]
141    id: Option<String>,
142}
143
144// ---------------------------------------------------------------------------
145// Public API
146// ---------------------------------------------------------------------------
147
148/// Parse a JSON string describing one or more group PVs.
149///
150/// Returns a `Vec<GroupPvDef>` — one entry per top-level key.
151pub fn parse_group_config(json: &str) -> Result<Vec<GroupPvDef>> {
152    let raw: RawConfig =
153        serde_json::from_str(json).map_err(|e| err(format!("invalid JSON: {e}")))?;
154
155    let mut groups: Vec<GroupPvDef> = Vec::with_capacity(raw.len());
156    for (name, raw_group) in raw {
157        groups.push(raw_to_group_def(name, raw_group)?);
158    }
159    // Sort for deterministic order.
160    groups.sort_by(|a, b| a.name.cmp(&b.name));
161    Ok(groups)
162}
163
164/// Parse a record's `info(Q:group, ...)` JSON tag.
165///
166/// Bare channel names (no `:`) are prefixed with `"{record_name}."`.
167pub fn parse_info_group(record_name: &str, json: &str) -> Result<Vec<GroupPvDef>> {
168    let raw: RawConfig =
169        serde_json::from_str(json).map_err(|e| err(format!("invalid JSON: {e}")))?;
170
171    let mut groups: Vec<GroupPvDef> = Vec::with_capacity(raw.len());
172    for (name, mut raw_group) in raw {
173        // Prefix bare channel names.
174        for member in raw_group.members.values_mut() {
175            if let Some(ref mut ch) = member.channel {
176                if !ch.contains(':') {
177                    *ch = format!("{record_name}.{ch}");
178                }
179            }
180        }
181        groups.push(raw_to_group_def(name, raw_group)?);
182    }
183    groups.sort_by(|a, b| a.name.cmp(&b.name));
184    Ok(groups)
185}
186
187/// Merge newly-parsed group defs into an existing map (accumulates members).
188///
189/// This supports the C++ pattern where multiple records each contribute
190/// members to the same group name.
191pub fn merge_group_defs(existing: &mut HashMap<String, GroupPvDef>, new_defs: Vec<GroupPvDef>) {
192    for def in new_defs {
193        existing
194            .entry(def.name.clone())
195            .and_modify(|e| {
196                // Merge struct_id — last one wins (like C++ QSRV).
197                if def.struct_id.is_some() {
198                    e.struct_id.clone_from(&def.struct_id);
199                }
200                e.atomic |= def.atomic;
201                e.members.extend(def.members.iter().cloned());
202            })
203            .or_insert(def);
204    }
205}
206
207// ---------------------------------------------------------------------------
208// Internal helpers
209// ---------------------------------------------------------------------------
210
211fn raw_to_group_def(name: String, raw: RawGroupDef) -> Result<GroupPvDef> {
212    let mut members = Vec::with_capacity(raw.members.len());
213
214    // Collect all field names first (for trigger validation).
215    let field_names: Vec<&str> = raw.members.keys().map(|s| s.as_str()).collect();
216
217    for (field_name, raw_member) in &raw.members {
218        members.push(parse_member(field_name, raw_member, &field_names)?);
219    }
220
221    // Sort members by field name for deterministic ordering.
222    members.sort_by(|a, b| a.field_name.cmp(&b.field_name));
223
224    Ok(GroupPvDef {
225        name,
226        struct_id: raw.id,
227        atomic: raw.atomic.unwrap_or(false),
228        members,
229    })
230}
231
232fn parse_member(field_name: &str, raw: &RawMember, all_fields: &[&str]) -> Result<GroupMember> {
233    let channel = raw
234        .channel
235        .clone()
236        .ok_or_else(|| err(format!("member '{field_name}' missing +channel")))?;
237
238    let mapping = match raw.mapping.as_deref() {
239        None | Some("plain") => FieldMapping::Plain,
240        Some("scalar") => FieldMapping::Scalar,
241        Some("meta") => FieldMapping::Meta,
242        Some("any") => FieldMapping::Any,
243        Some("proc") => FieldMapping::Proc,
244        Some(other) => {
245            return Err(err(format!(
246                "member '{field_name}': unknown +type '{other}'"
247            )));
248        }
249    };
250
251    let triggers = match raw.trigger.as_deref() {
252        None => TriggerDef::None,
253        Some("*") => TriggerDef::All,
254        Some("") => TriggerDef::None,
255        Some(spec) => {
256            let names: Vec<String> = spec.split(',').map(|s| s.trim().to_owned()).collect();
257            // Validate that every named trigger refers to a known field.
258            for n in &names {
259                if !all_fields.contains(&n.as_str()) {
260                    return Err(err(format!(
261                        "member '{field_name}': trigger references unknown field '{n}'"
262                    )));
263                }
264            }
265            TriggerDef::Fields(names)
266        }
267    };
268
269    Ok(GroupMember {
270        field_name: field_name.to_owned(),
271        channel,
272        mapping,
273        triggers,
274        put_order: raw.putorder.unwrap_or(0),
275        struct_id: raw.id.clone(),
276    })
277}
278
279// ---------------------------------------------------------------------------
280// GroupSource — Source implementation that serves group PVs
281// ---------------------------------------------------------------------------
282
283use std::future::Future;
284use std::pin::Pin;
285use std::sync::Arc;
286
287use tokio::sync::mpsc;
288
289use spvirit_codec::spvd_decode::{DecodedValue, FieldDesc, FieldType, StructureDesc, TypeCode};
290use spvirit_types::{NtPayload, PvValue, ScalarArrayValue, ScalarValue};
291
292use crate::pvstore::{PvInfo, Source, SourceRegistry};
293use crate::simple_store::descriptor_for_payload;
294
295/// A [`Source`] that serves group PVs composed from multiple member PVs.
296///
297/// Group PV names are resolved by fetching each member PV from the
298/// underlying [`SourceRegistry`] and composing them into an
299/// [`NtPayload::Generic`]. Non-group names are not claimed.
300pub struct GroupSource {
301    sources: Arc<SourceRegistry>,
302    groups: HashMap<String, GroupPvDef>,
303}
304
305impl GroupSource {
306    /// Create a new group source backed by `sources` with the given group definitions.
307    pub fn new(sources: Arc<SourceRegistry>, groups: HashMap<String, GroupPvDef>) -> Self {
308        Self { sources, groups }
309    }
310
311    /// Build a composite snapshot for a group PV.
312    async fn group_snapshot(&self, def: &GroupPvDef) -> NtPayload {
313        let mut fields = Vec::with_capacity(def.members.len());
314        for member in &def.members {
315            if member.mapping == FieldMapping::Proc {
316                continue; // Proc members don't contribute a value field.
317            }
318            let pv_val = match self.sources.get(&member.channel).await {
319                Some(snap) => payload_to_pv_value(&snap, member.mapping),
320                None => PvValue::Scalar(ScalarValue::I32(0)), // disconnected fallback
321            };
322            fields.push((member.field_name.clone(), pv_val));
323        }
324        NtPayload::Generic {
325            struct_id: def
326                .struct_id
327                .clone()
328                .unwrap_or_else(|| "structure".to_string()),
329            fields,
330        }
331    }
332
333    /// Build a structure descriptor for a group PV.
334    async fn group_descriptor(&self, def: &GroupPvDef) -> StructureDesc {
335        let mut field_descs = Vec::with_capacity(def.members.len());
336        for member in &def.members {
337            if member.mapping == FieldMapping::Proc {
338                continue;
339            }
340            let field_type = match self.sources.get(&member.channel).await {
341                Some(snap) => payload_field_type(&snap, member.mapping),
342                None => FieldType::Scalar(TypeCode::Int32), // disconnected fallback
343            };
344            field_descs.push(FieldDesc {
345                name: member.field_name.clone(),
346                field_type,
347            });
348        }
349        StructureDesc {
350            struct_id: def.struct_id.clone(),
351            fields: field_descs,
352        }
353    }
354}
355
356impl Source for GroupSource {
357    fn claim(&self, name: &str) -> Pin<Box<dyn Future<Output = Option<PvInfo>> + Send + '_>> {
358        let name = name.to_string();
359        Box::pin(async move {
360            let def = self.groups.get(&name)?;
361            let descriptor = self.group_descriptor(def).await;
362            // A group PV is writable if any non-proc non-meta member is writable.
363            let mut writable = false;
364            for member in &def.members {
365                if member.mapping == FieldMapping::Proc || member.mapping == FieldMapping::Meta {
366                    continue;
367                }
368                if self.sources.is_writable(&member.channel).await {
369                    writable = true;
370                    break;
371                }
372            }
373            Some(PvInfo {
374                descriptor,
375                writable,
376            })
377        })
378    }
379
380    fn get(&self, name: &str) -> Pin<Box<dyn Future<Output = Option<NtPayload>> + Send + '_>> {
381        let name = name.to_string();
382        Box::pin(async move {
383            let def = self.groups.get(&name)?;
384            Some(self.group_snapshot(def).await)
385        })
386    }
387
388    fn put(
389        &self,
390        name: &str,
391        value: &DecodedValue,
392    ) -> Pin<
393        Box<dyn Future<Output = std::result::Result<Vec<(String, NtPayload)>, String>> + Send + '_>,
394    > {
395        let name = name.to_string();
396        let value = value.clone();
397        Box::pin(async move {
398            if let Some(def) = self.groups.get(&name) {
399                // Dispatch sub-field puts to member channels.
400                let fields = match &value {
401                    DecodedValue::Structure(f) => f,
402                    _ => return Err("group PUT requires a structure".to_string()),
403                };
404
405                // Sort members by put_order.
406                let mut ordered: Vec<&GroupMember> = def.members.iter().collect();
407                ordered.sort_by_key(|m| m.put_order);
408
409                let mut results = Vec::new();
410                for member in ordered {
411                    if member.mapping == FieldMapping::Proc || member.mapping == FieldMapping::Meta
412                    {
413                        continue;
414                    }
415                    // Find the sub-field matching this member.
416                    if let Some((_, sub_val)) = fields.iter().find(|(n, _)| n == &member.field_name)
417                    {
418                        match self.sources.put(&member.channel, sub_val).await {
419                            Ok(mut r) => results.append(&mut r),
420                            Err(e) => {
421                                tracing::warn!(
422                                    "group PUT {}: member {} failed: {e}",
423                                    name,
424                                    member.field_name
425                                );
426                            }
427                        }
428                    }
429                }
430                Ok(results)
431            } else {
432                Err(format!("group PV '{}' not found", name))
433            }
434        })
435    }
436
437    fn subscribe(
438        &self,
439        name: &str,
440    ) -> Pin<Box<dyn Future<Output = Option<mpsc::Receiver<NtPayload>>> + Send + '_>> {
441        let name = name.to_string();
442        Box::pin(async move {
443            let def = self.groups.get(&name)?;
444            self.subscribe_group(def).await
445        })
446    }
447
448    fn names(&self) -> Pin<Box<dyn Future<Output = Vec<String>> + Send + '_>> {
449        Box::pin(async move { self.groups.keys().cloned().collect() })
450    }
451}
452
453impl GroupSource {
454    /// Subscribe to a group PV by fanning-in member subscriptions.
455    ///
456    /// When any member PV updates, the trigger rules are evaluated and if
457    /// triggered, a full group snapshot is composed and sent to the subscriber.
458    async fn subscribe_group(&self, def: &GroupPvDef) -> Option<mpsc::Receiver<NtPayload>> {
459        let (tx, rx) = mpsc::channel(64);
460        let sources = self.sources.clone();
461        let def = def.clone();
462
463        // Collect member subscriptions.
464        let mut member_rxs: Vec<(String, mpsc::Receiver<NtPayload>)> = Vec::new();
465        for member in &def.members {
466            if let Some(member_rx) = sources.subscribe(&member.channel).await {
467                member_rxs.push((member.field_name.clone(), member_rx));
468            }
469        }
470        if member_rxs.is_empty() {
471            return None;
472        }
473
474        // Build trigger map: field_name → set of field names that get triggered.
475        let trigger_map = build_trigger_map(&def);
476
477        tokio::spawn(async move {
478            // Use a select!-like loop: poll all member receivers.
479            // We use tokio::select! with a macro-generated match is complex for
480            // dynamic member sets, so use a simpler polling approach.
481            loop {
482                // Wait for any member to produce a value.
483                let src_field = match poll_any_member(&mut member_rxs).await {
484                    Some(field_name) => field_name,
485                    None => break, // All members closed.
486                };
487
488                {
489                    // Check trigger rules.
490                    let should_send = match trigger_map.get(&src_field) {
491                        Some(targets) => !targets.is_empty(),
492                        None => false,
493                    };
494
495                    if should_send {
496                        // Compose a full group snapshot.
497                        let mut fields = Vec::with_capacity(def.members.len());
498                        for member in &def.members {
499                            if member.mapping == FieldMapping::Proc {
500                                continue;
501                            }
502                            let pv_val = match sources.get(&member.channel).await {
503                                Some(snap) => payload_to_pv_value(&snap, member.mapping),
504                                None => PvValue::Scalar(ScalarValue::I32(0)),
505                            };
506                            fields.push((member.field_name.clone(), pv_val));
507                        }
508                        let payload = NtPayload::Generic {
509                            struct_id: def
510                                .struct_id
511                                .clone()
512                                .unwrap_or_else(|| "structure".to_string()),
513                            fields,
514                        };
515                        if tx.send(payload).await.is_err() {
516                            break; // Subscriber dropped.
517                        }
518                    }
519                }
520            }
521        });
522
523        Some(rx)
524    }
525}
526
527/// Build a map from source field_name → set of triggered field names.
528fn build_trigger_map(def: &GroupPvDef) -> HashMap<String, Vec<String>> {
529    let all_fields: Vec<String> = def.members.iter().map(|m| m.field_name.clone()).collect();
530    let mut map: HashMap<String, Vec<String>> = HashMap::new();
531
532    for member in &def.members {
533        let targets = match &member.triggers {
534            TriggerDef::All => all_fields.clone(),
535            TriggerDef::Fields(names) => names.clone(),
536            TriggerDef::None => Vec::new(),
537        };
538        map.insert(member.field_name.clone(), targets);
539    }
540    map
541}
542
543/// Wait for any member receiver to produce a value, returning the field name
544/// of the member that updated. Returns `None` when all channels are closed.
545async fn poll_any_member(members: &mut Vec<(String, mpsc::Receiver<NtPayload>)>) -> Option<String> {
546    if members.is_empty() {
547        return None;
548    }
549
550    // Create pinned futures for each member.
551    let futs: Vec<_> = members
552        .iter_mut()
553        .map(|(name, rx)| {
554            let name = name.clone();
555            Box::pin(async move { (name, rx.recv().await) })
556                as std::pin::Pin<Box<dyn Future<Output = (String, Option<NtPayload>)> + Send + '_>>
557        })
558        .collect();
559
560    let (field_name, payload) = race_all(futs).await;
561
562    if payload.is_none() {
563        // This member channel is closed; remove it.
564        members.retain(|(n, _)| n != &field_name);
565        if members.is_empty() {
566            return None;
567        }
568        return Box::pin(poll_any_member(members)).await;
569    }
570
571    Some(field_name)
572}
573
574/// Race a vec of pinned futures, returning the result of the first to complete.
575async fn race_all<T>(futs: Vec<std::pin::Pin<Box<dyn Future<Output = T> + Send + '_>>>) -> T {
576    use std::pin::Pin;
577    use std::task::{Context, Poll};
578
579    assert!(!futs.is_empty());
580
581    struct RaceAll<'a, T> {
582        futs: Vec<Pin<Box<dyn Future<Output = T> + Send + 'a>>>,
583    }
584
585    impl<T> Future for RaceAll<'_, T> {
586        type Output = T;
587        fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
588            for fut in &mut self.futs {
589                if let Poll::Ready(val) = fut.as_mut().poll(cx) {
590                    return Poll::Ready(val);
591                }
592            }
593            Poll::Pending
594        }
595    }
596
597    RaceAll { futs }.await
598}
599
600// ---------------------------------------------------------------------------
601// NtPayload → PvValue conversion helpers
602// ---------------------------------------------------------------------------
603
604/// Convert an NtPayload (member snapshot) to a PvValue for embedding in a
605/// group structure, respecting the FieldMapping.
606fn payload_to_pv_value(payload: &NtPayload, mapping: FieldMapping) -> PvValue {
607    match mapping {
608        FieldMapping::Scalar => payload_to_full_structure(payload),
609        FieldMapping::Plain => payload_to_value_only(payload),
610        FieldMapping::Meta => payload_to_meta_only(payload),
611        FieldMapping::Any => payload_to_full_structure(payload),
612        FieldMapping::Proc => PvValue::Scalar(ScalarValue::I32(0)), // shouldn't be called
613    }
614}
615
616/// Full NT structure as PvValue (Scalar mapping).
617fn payload_to_full_structure(payload: &NtPayload) -> PvValue {
618    match payload {
619        NtPayload::Scalar(nt) => {
620            let mut fields = vec![
621                ("value".to_string(), PvValue::Scalar(nt.value.clone())),
622                (
623                    "alarm".to_string(),
624                    alarm_to_pv_value(nt.alarm_severity, nt.alarm_status, &nt.alarm_message),
625                ),
626                ("timeStamp".to_string(), timestamp_to_pv_value_default()),
627            ];
628            fields.push((
629                "display".to_string(),
630                PvValue::Structure {
631                    struct_id: "display_t".to_string(),
632                    fields: vec![
633                        (
634                            "limitLow".to_string(),
635                            PvValue::Scalar(ScalarValue::F64(nt.display_low)),
636                        ),
637                        (
638                            "limitHigh".to_string(),
639                            PvValue::Scalar(ScalarValue::F64(nt.display_high)),
640                        ),
641                        (
642                            "description".to_string(),
643                            PvValue::Scalar(ScalarValue::Str(nt.display_description.clone())),
644                        ),
645                        (
646                            "units".to_string(),
647                            PvValue::Scalar(ScalarValue::Str(nt.units.clone())),
648                        ),
649                        (
650                            "precision".to_string(),
651                            PvValue::Scalar(ScalarValue::I32(nt.display_precision)),
652                        ),
653                    ],
654                },
655            ));
656            fields.push((
657                "control".to_string(),
658                PvValue::Structure {
659                    struct_id: "control_t".to_string(),
660                    fields: vec![
661                        (
662                            "limitLow".to_string(),
663                            PvValue::Scalar(ScalarValue::F64(nt.control_low)),
664                        ),
665                        (
666                            "limitHigh".to_string(),
667                            PvValue::Scalar(ScalarValue::F64(nt.control_high)),
668                        ),
669                        (
670                            "minStep".to_string(),
671                            PvValue::Scalar(ScalarValue::F64(nt.control_min_step)),
672                        ),
673                    ],
674                },
675            ));
676            PvValue::Structure {
677                struct_id: "epics:nt/NTScalar:1.0".to_string(),
678                fields,
679            }
680        }
681        NtPayload::ScalarArray(nt) => {
682            let fields = vec![
683                ("value".to_string(), PvValue::ScalarArray(nt.value.clone())),
684                (
685                    "alarm".to_string(),
686                    alarm_to_pv_value(nt.alarm.severity, nt.alarm.status, &nt.alarm.message),
687                ),
688                ("timeStamp".to_string(), timestamp_pv(&nt.time_stamp)),
689            ];
690            PvValue::Structure {
691                struct_id: "epics:nt/NTScalarArray:1.0".to_string(),
692                fields,
693            }
694        }
695        NtPayload::Enum(nt) => {
696            let fields = vec![
697                (
698                    "value".to_string(),
699                    PvValue::Structure {
700                        struct_id: "enum_t".to_string(),
701                        fields: vec![
702                            (
703                                "index".to_string(),
704                                PvValue::Scalar(ScalarValue::I32(nt.index)),
705                            ),
706                            (
707                                "choices".to_string(),
708                                PvValue::ScalarArray(ScalarArrayValue::Str(nt.choices.clone())),
709                            ),
710                        ],
711                    },
712                ),
713                ("alarm".to_string(), alarm_pv(&nt.alarm)),
714                ("timeStamp".to_string(), timestamp_pv(&nt.time_stamp)),
715            ];
716            PvValue::Structure {
717                struct_id: "epics:nt/NTEnum:1.0".to_string(),
718                fields,
719            }
720        }
721        NtPayload::Generic { struct_id, fields } => PvValue::Structure {
722            struct_id: struct_id.clone(),
723            fields: fields.clone(),
724        },
725        _ => PvValue::Scalar(ScalarValue::I32(0)),
726    }
727}
728
729/// Value-only (Plain mapping) — extract just the primary value.
730fn payload_to_value_only(payload: &NtPayload) -> PvValue {
731    match payload {
732        NtPayload::Scalar(nt) => PvValue::Scalar(nt.value.clone()),
733        NtPayload::ScalarArray(nt) => PvValue::ScalarArray(nt.value.clone()),
734        NtPayload::Enum(nt) => PvValue::Scalar(ScalarValue::I32(nt.index)),
735        NtPayload::Generic { fields, .. } => {
736            // Try to find a "value" field.
737            fields
738                .iter()
739                .find(|(n, _)| n == "value")
740                .map(|(_, v)| v.clone())
741                .unwrap_or(PvValue::Scalar(ScalarValue::I32(0)))
742        }
743        _ => PvValue::Scalar(ScalarValue::I32(0)),
744    }
745}
746
747/// Meta-only (Meta mapping) — alarm + timestamp only.
748fn payload_to_meta_only(payload: &NtPayload) -> PvValue {
749    match payload {
750        NtPayload::Scalar(nt) => PvValue::Structure {
751            struct_id: String::new(),
752            fields: vec![
753                (
754                    "alarm".to_string(),
755                    alarm_to_pv_value(nt.alarm_severity, nt.alarm_status, &nt.alarm_message),
756                ),
757                ("timeStamp".to_string(), timestamp_to_pv_value_default()),
758            ],
759        },
760        NtPayload::ScalarArray(nt) => PvValue::Structure {
761            struct_id: String::new(),
762            fields: vec![
763                ("alarm".to_string(), alarm_pv(&nt.alarm)),
764                ("timeStamp".to_string(), timestamp_pv(&nt.time_stamp)),
765            ],
766        },
767        NtPayload::Enum(nt) => PvValue::Structure {
768            struct_id: String::new(),
769            fields: vec![
770                ("alarm".to_string(), alarm_pv(&nt.alarm)),
771                ("timeStamp".to_string(), timestamp_pv(&nt.time_stamp)),
772            ],
773        },
774        _ => PvValue::Structure {
775            struct_id: String::new(),
776            fields: vec![],
777        },
778    }
779}
780
781/// Build a FieldType for a member's contribution to the group descriptor.
782fn payload_field_type(payload: &NtPayload, mapping: FieldMapping) -> FieldType {
783    match mapping {
784        FieldMapping::Scalar | FieldMapping::Any => {
785            FieldType::Structure(descriptor_for_payload(payload))
786        }
787        FieldMapping::Plain => match payload {
788            NtPayload::Scalar(nt) => value_field_type(&nt.value),
789            NtPayload::ScalarArray(nt) => array_field_type(&nt.value),
790            NtPayload::Enum(_) => FieldType::Scalar(TypeCode::Int32),
791            _ => FieldType::Scalar(TypeCode::Int32),
792        },
793        FieldMapping::Meta => FieldType::Structure(StructureDesc {
794            struct_id: None,
795            fields: vec![
796                FieldDesc {
797                    name: "alarm".to_string(),
798                    field_type: FieldType::Structure(alarm_struct_desc()),
799                },
800                FieldDesc {
801                    name: "timeStamp".to_string(),
802                    field_type: FieldType::Structure(timestamp_struct_desc()),
803                },
804            ],
805        }),
806        FieldMapping::Proc => FieldType::Scalar(TypeCode::Int32),
807    }
808}
809
810fn value_field_type(sv: &ScalarValue) -> FieldType {
811    match sv {
812        ScalarValue::Str(_) => FieldType::String,
813        sv => {
814            let tc = match sv {
815                ScalarValue::Bool(_) => TypeCode::Boolean,
816                ScalarValue::I8(_) => TypeCode::Int8,
817                ScalarValue::I16(_) => TypeCode::Int16,
818                ScalarValue::I32(_) => TypeCode::Int32,
819                ScalarValue::I64(_) => TypeCode::Int64,
820                ScalarValue::U8(_) => TypeCode::UInt8,
821                ScalarValue::U16(_) => TypeCode::UInt16,
822                ScalarValue::U32(_) => TypeCode::UInt32,
823                ScalarValue::U64(_) => TypeCode::UInt64,
824                ScalarValue::F32(_) => TypeCode::Float32,
825                ScalarValue::F64(_) => TypeCode::Float64,
826                ScalarValue::Str(_) => unreachable!(),
827            };
828            FieldType::Scalar(tc)
829        }
830    }
831}
832
833fn array_field_type(sav: &ScalarArrayValue) -> FieldType {
834    match sav {
835        ScalarArrayValue::Str(_) => FieldType::StringArray,
836        sav => {
837            let tc = match sav {
838                ScalarArrayValue::Bool(_) => TypeCode::Boolean,
839                ScalarArrayValue::I8(_) => TypeCode::Int8,
840                ScalarArrayValue::I16(_) => TypeCode::Int16,
841                ScalarArrayValue::I32(_) => TypeCode::Int32,
842                ScalarArrayValue::I64(_) => TypeCode::Int64,
843                ScalarArrayValue::U8(_) => TypeCode::UInt8,
844                ScalarArrayValue::U16(_) => TypeCode::UInt16,
845                ScalarArrayValue::U32(_) => TypeCode::UInt32,
846                ScalarArrayValue::U64(_) => TypeCode::UInt64,
847                ScalarArrayValue::F32(_) => TypeCode::Float32,
848                ScalarArrayValue::F64(_) => TypeCode::Float64,
849                ScalarArrayValue::Str(_) => unreachable!(),
850            };
851            FieldType::ScalarArray(tc)
852        }
853    }
854}
855
856fn alarm_struct_desc() -> StructureDesc {
857    StructureDesc {
858        struct_id: Some("alarm_t".to_string()),
859        fields: vec![
860            FieldDesc {
861                name: "severity".to_string(),
862                field_type: FieldType::Scalar(TypeCode::Int32),
863            },
864            FieldDesc {
865                name: "status".to_string(),
866                field_type: FieldType::Scalar(TypeCode::Int32),
867            },
868            FieldDesc {
869                name: "message".to_string(),
870                field_type: FieldType::String,
871            },
872        ],
873    }
874}
875
876fn timestamp_struct_desc() -> StructureDesc {
877    StructureDesc {
878        struct_id: Some("time_t".to_string()),
879        fields: vec![
880            FieldDesc {
881                name: "secondsPastEpoch".to_string(),
882                field_type: FieldType::Scalar(TypeCode::Int64),
883            },
884            FieldDesc {
885                name: "nanoseconds".to_string(),
886                field_type: FieldType::Scalar(TypeCode::Int32),
887            },
888            FieldDesc {
889                name: "userTag".to_string(),
890                field_type: FieldType::Scalar(TypeCode::Int32),
891            },
892        ],
893    }
894}
895
896fn alarm_to_pv_value(severity: i32, status: i32, message: &str) -> PvValue {
897    PvValue::Structure {
898        struct_id: "alarm_t".to_string(),
899        fields: vec![
900            (
901                "severity".to_string(),
902                PvValue::Scalar(ScalarValue::I32(severity)),
903            ),
904            (
905                "status".to_string(),
906                PvValue::Scalar(ScalarValue::I32(status)),
907            ),
908            (
909                "message".to_string(),
910                PvValue::Scalar(ScalarValue::Str(message.to_string())),
911            ),
912        ],
913    }
914}
915
916fn alarm_pv(alarm: &spvirit_types::NtAlarm) -> PvValue {
917    alarm_to_pv_value(alarm.severity, alarm.status, &alarm.message)
918}
919
920fn timestamp_pv(ts: &spvirit_types::NtTimeStamp) -> PvValue {
921    PvValue::Structure {
922        struct_id: "time_t".to_string(),
923        fields: vec![
924            (
925                "secondsPastEpoch".to_string(),
926                PvValue::Scalar(ScalarValue::I64(ts.seconds_past_epoch)),
927            ),
928            (
929                "nanoseconds".to_string(),
930                PvValue::Scalar(ScalarValue::I32(ts.nanoseconds)),
931            ),
932            (
933                "userTag".to_string(),
934                PvValue::Scalar(ScalarValue::I32(ts.user_tag)),
935            ),
936        ],
937    }
938}
939
940fn timestamp_to_pv_value_default() -> PvValue {
941    timestamp_pv(&spvirit_types::NtTimeStamp::default())
942}
943
944// ---------------------------------------------------------------------------
945// Tests
946// ---------------------------------------------------------------------------
947
948#[cfg(test)]
949mod tests {
950    use super::*;
951
952    #[test]
953    fn parse_basic_group() {
954        let json = r#"{
955            "GRP:test": {
956                "+id": "epics:nt/NTTable:1.0",
957                "+atomic": true,
958                "fieldA": {
959                    "+channel": "REC:A",
960                    "+type": "scalar",
961                    "+trigger": "*"
962                },
963                "fieldB": {
964                    "+channel": "REC:B",
965                    "+type": "plain"
966                }
967            }
968        }"#;
969
970        let groups = parse_group_config(json).unwrap();
971        assert_eq!(groups.len(), 1);
972        let g = &groups[0];
973        assert_eq!(g.name, "GRP:test");
974        assert_eq!(g.struct_id.as_deref(), Some("epics:nt/NTTable:1.0"));
975        assert!(g.atomic);
976        assert_eq!(g.members.len(), 2);
977
978        let a = g.members.iter().find(|m| m.field_name == "fieldA").unwrap();
979        assert_eq!(a.channel, "REC:A");
980        assert_eq!(a.mapping, FieldMapping::Scalar);
981        assert_eq!(a.triggers, TriggerDef::All);
982
983        let b = g.members.iter().find(|m| m.field_name == "fieldB").unwrap();
984        assert_eq!(b.channel, "REC:B");
985        assert_eq!(b.mapping, FieldMapping::Plain);
986    }
987
988    #[test]
989    fn parse_minimal_member() {
990        let json = r#"{
991            "GRP:min": {
992                "x": { "+channel": "R:x" }
993            }
994        }"#;
995
996        let groups = parse_group_config(json).unwrap();
997        let m = &groups[0].members[0];
998        assert_eq!(m.mapping, FieldMapping::Plain); // default
999        assert_eq!(m.triggers, TriggerDef::None); // default
1000        assert_eq!(m.put_order, 0);
1001    }
1002
1003    #[test]
1004    fn parse_proc_mapping() {
1005        let json = r#"{
1006            "GRP:proc": {
1007                "go": {
1008                    "+channel": "REC:PROC",
1009                    "+type": "proc",
1010                    "+trigger": "go",
1011                    "+putorder": 99
1012                }
1013            }
1014        }"#;
1015
1016        let groups = parse_group_config(json).unwrap();
1017        let m = &groups[0].members[0];
1018        assert_eq!(m.mapping, FieldMapping::Proc);
1019        assert_eq!(m.put_order, 99);
1020        assert_eq!(m.triggers, TriggerDef::Fields(vec!["go".into()]));
1021    }
1022
1023    #[test]
1024    fn parse_error_missing_channel() {
1025        let json = r#"{
1026            "GRP:bad": {
1027                "x": { "+type": "scalar" }
1028            }
1029        }"#;
1030
1031        assert!(parse_group_config(json).is_err());
1032    }
1033
1034    #[test]
1035    fn parse_multiple_groups() {
1036        let json = r#"{
1037            "G:a": { "x": { "+channel": "R:x" } },
1038            "G:b": { "y": { "+channel": "R:y" } }
1039        }"#;
1040
1041        let groups = parse_group_config(json).unwrap();
1042        assert_eq!(groups.len(), 2);
1043    }
1044
1045    #[test]
1046    fn parse_member_id() {
1047        let json = r#"{
1048            "GRP:id": {
1049                "val": {
1050                    "+channel": "R:val",
1051                    "+id": "custom_t"
1052                }
1053            }
1054        }"#;
1055
1056        let groups = parse_group_config(json).unwrap();
1057        assert_eq!(groups[0].members[0].struct_id.as_deref(), Some("custom_t"));
1058    }
1059
1060    #[test]
1061    fn parse_member_no_id() {
1062        let json = r#"{
1063            "GRP:noid": {
1064                "v": { "+channel": "R:v" }
1065            }
1066        }"#;
1067
1068        let groups = parse_group_config(json).unwrap();
1069        assert!(groups[0].members[0].struct_id.is_none());
1070    }
1071
1072    #[test]
1073    fn parse_info_group_prefix() {
1074        let json = r#"{
1075            "TEMP:group": {
1076                "VAL": {
1077                    "+channel": "VAL",
1078                    "+type": "plain",
1079                    "+trigger": "*"
1080                }
1081            }
1082        }"#;
1083
1084        let groups = parse_info_group("TEMP:sensor", json).unwrap();
1085        // Bare field "VAL" should become "TEMP:sensor.VAL"
1086        assert_eq!(groups[0].members[0].channel, "TEMP:sensor.VAL");
1087    }
1088
1089    #[test]
1090    fn parse_info_group_absolute_channel() {
1091        let json = r#"{
1092            "TEMP:group": {
1093                "pressure": {
1094                    "+channel": "PRESS:ai",
1095                    "+type": "scalar"
1096                }
1097            }
1098        }"#;
1099
1100        let groups = parse_info_group("TEMP:sensor", json).unwrap();
1101        // Absolute channel (contains ':') should be kept as-is
1102        assert_eq!(groups[0].members[0].channel, "PRESS:ai");
1103    }
1104
1105    #[test]
1106    fn merge_groups() {
1107        let mut existing = HashMap::new();
1108        let defs1 = parse_group_config(r#"{ "GRP:a": { "x": { "+channel": "R1:x" } } }"#).unwrap();
1109        merge_group_defs(&mut existing, defs1);
1110
1111        let defs2 = parse_group_config(r#"{ "GRP:a": { "y": { "+channel": "R2:y" } } }"#).unwrap();
1112        merge_group_defs(&mut existing, defs2);
1113
1114        let grp = existing.get("GRP:a").unwrap();
1115        assert_eq!(grp.members.len(), 2);
1116    }
1117
1118    #[test]
1119    fn trigger_validation_unknown_field() {
1120        let json = r#"{
1121            "GRP:bad": {
1122                "x": {
1123                    "+channel": "R:x",
1124                    "+trigger": "y,z"
1125                },
1126                "y": { "+channel": "R:y" }
1127            }
1128        }"#;
1129
1130        // y exists but z doesn't — should fail.
1131        let result = parse_group_config(json);
1132        assert!(result.is_err());
1133        let e = format!("{}", result.unwrap_err());
1134        assert!(e.contains("'z'"), "expected error about 'z': {e}");
1135    }
1136
1137    #[test]
1138    fn trigger_validation_self_reference() {
1139        let json = r#"{
1140            "GRP:ok": {
1141                "a": { "+channel": "R:a", "+trigger": "a,b" },
1142                "b": { "+channel": "R:b", "+trigger": "a" }
1143            }
1144        }"#;
1145
1146        // Self-reference and cross-reference are both valid.
1147        assert!(parse_group_config(json).is_ok());
1148    }
1149
1150    #[test]
1151    fn trigger_validation_star_passes() {
1152        let json = r#"{
1153            "GRP:ok": {
1154                "a": { "+channel": "R:a", "+trigger": "*" }
1155            }
1156        }"#;
1157
1158        // "*" doesn't go through field validation.
1159        assert!(parse_group_config(json).is_ok());
1160    }
1161}