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<Box<dyn Future<Output = std::result::Result<Vec<(String, NtPayload)>, String>> + Send + '_>> {
393        let name = name.to_string();
394        let value = value.clone();
395        Box::pin(async move {
396            if let Some(def) = self.groups.get(&name) {
397                // Dispatch sub-field puts to member channels.
398                let fields = match &value {
399                    DecodedValue::Structure(f) => f,
400                    _ => return Err("group PUT requires a structure".to_string()),
401                };
402
403                // Sort members by put_order.
404                let mut ordered: Vec<&GroupMember> = def.members.iter().collect();
405                ordered.sort_by_key(|m| m.put_order);
406
407                let mut results = Vec::new();
408                for member in ordered {
409                    if member.mapping == FieldMapping::Proc || member.mapping == FieldMapping::Meta
410                    {
411                        continue;
412                    }
413                    // Find the sub-field matching this member.
414                    if let Some((_, sub_val)) = fields.iter().find(|(n, _)| n == &member.field_name)
415                    {
416                        match self.sources.put(&member.channel, sub_val).await {
417                            Ok(mut r) => results.append(&mut r),
418                            Err(e) => {
419                                tracing::warn!(
420                                    "group PUT {}: member {} failed: {e}",
421                                    name,
422                                    member.field_name
423                                );
424                            }
425                        }
426                    }
427                }
428                Ok(results)
429            } else {
430                Err(format!("group PV '{}' not found", name))
431            }
432        })
433    }
434
435    fn subscribe(
436        &self,
437        name: &str,
438    ) -> Pin<Box<dyn Future<Output = Option<mpsc::Receiver<NtPayload>>> + Send + '_>> {
439        let name = name.to_string();
440        Box::pin(async move {
441            let def = self.groups.get(&name)?;
442            self.subscribe_group(def).await
443        })
444    }
445
446    fn names(&self) -> Pin<Box<dyn Future<Output = Vec<String>> + Send + '_>> {
447        Box::pin(async move { self.groups.keys().cloned().collect() })
448    }
449}
450
451impl GroupSource {
452    /// Subscribe to a group PV by fanning-in member subscriptions.
453    ///
454    /// When any member PV updates, the trigger rules are evaluated and if
455    /// triggered, a full group snapshot is composed and sent to the subscriber.
456    async fn subscribe_group(&self, def: &GroupPvDef) -> Option<mpsc::Receiver<NtPayload>> {
457        let (tx, rx) = mpsc::channel(64);
458        let sources = self.sources.clone();
459        let def = def.clone();
460
461        // Collect member subscriptions.
462        let mut member_rxs: Vec<(String, mpsc::Receiver<NtPayload>)> = Vec::new();
463        for member in &def.members {
464            if let Some(member_rx) = sources.subscribe(&member.channel).await {
465                member_rxs.push((member.field_name.clone(), member_rx));
466            }
467        }
468        if member_rxs.is_empty() {
469            return None;
470        }
471
472        // Build trigger map: field_name → set of field names that get triggered.
473        let trigger_map = build_trigger_map(&def);
474
475        tokio::spawn(async move {
476            // Use a select!-like loop: poll all member receivers.
477            // We use tokio::select! with a macro-generated match is complex for
478            // dynamic member sets, so use a simpler polling approach.
479            loop {
480                // Wait for any member to produce a value.
481                let src_field = match poll_any_member(&mut member_rxs).await {
482                    Some(field_name) => field_name,
483                    None => break, // All members closed.
484                };
485
486                {
487                    // Check trigger rules.
488                    let should_send = match trigger_map.get(&src_field) {
489                        Some(targets) => !targets.is_empty(),
490                        None => false,
491                    };
492
493                    if should_send {
494                        // Compose a full group snapshot.
495                        let mut fields = Vec::with_capacity(def.members.len());
496                        for member in &def.members {
497                            if member.mapping == FieldMapping::Proc {
498                                continue;
499                            }
500                            let pv_val = match sources.get(&member.channel).await {
501                                Some(snap) => payload_to_pv_value(&snap, member.mapping),
502                                None => PvValue::Scalar(ScalarValue::I32(0)),
503                            };
504                            fields.push((member.field_name.clone(), pv_val));
505                        }
506                        let payload = NtPayload::Generic {
507                            struct_id: def
508                                .struct_id
509                                .clone()
510                                .unwrap_or_else(|| "structure".to_string()),
511                            fields,
512                        };
513                        if tx.send(payload).await.is_err() {
514                            break; // Subscriber dropped.
515                        }
516                    }
517                }
518            }
519        });
520
521        Some(rx)
522    }
523}
524
525/// Build a map from source field_name → set of triggered field names.
526fn build_trigger_map(def: &GroupPvDef) -> HashMap<String, Vec<String>> {
527    let all_fields: Vec<String> = def.members.iter().map(|m| m.field_name.clone()).collect();
528    let mut map: HashMap<String, Vec<String>> = HashMap::new();
529
530    for member in &def.members {
531        let targets = match &member.triggers {
532            TriggerDef::All => all_fields.clone(),
533            TriggerDef::Fields(names) => names.clone(),
534            TriggerDef::None => Vec::new(),
535        };
536        map.insert(member.field_name.clone(), targets);
537    }
538    map
539}
540
541/// Wait for any member receiver to produce a value, returning the field name
542/// of the member that updated. Returns `None` when all channels are closed.
543async fn poll_any_member(members: &mut Vec<(String, mpsc::Receiver<NtPayload>)>) -> Option<String> {
544    if members.is_empty() {
545        return None;
546    }
547
548    // Create pinned futures for each member.
549    let futs: Vec<_> = members
550        .iter_mut()
551        .map(|(name, rx)| {
552            let name = name.clone();
553            Box::pin(async move { (name, rx.recv().await) })
554                as std::pin::Pin<Box<dyn Future<Output = (String, Option<NtPayload>)> + Send + '_>>
555        })
556        .collect();
557
558    let (field_name, payload) = race_all(futs).await;
559
560    if payload.is_none() {
561        // This member channel is closed; remove it.
562        members.retain(|(n, _)| n != &field_name);
563        if members.is_empty() {
564            return None;
565        }
566        return Box::pin(poll_any_member(members)).await;
567    }
568
569    Some(field_name)
570}
571
572/// Race a vec of pinned futures, returning the result of the first to complete.
573async fn race_all<T>(futs: Vec<std::pin::Pin<Box<dyn Future<Output = T> + Send + '_>>>) -> T {
574    use std::pin::Pin;
575    use std::task::{Context, Poll};
576
577    assert!(!futs.is_empty());
578
579    struct RaceAll<'a, T> {
580        futs: Vec<Pin<Box<dyn Future<Output = T> + Send + 'a>>>,
581    }
582
583    impl<T> Future for RaceAll<'_, T> {
584        type Output = T;
585        fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
586            for fut in &mut self.futs {
587                if let Poll::Ready(val) = fut.as_mut().poll(cx) {
588                    return Poll::Ready(val);
589                }
590            }
591            Poll::Pending
592        }
593    }
594
595    RaceAll { futs }.await
596}
597
598// ---------------------------------------------------------------------------
599// NtPayload → PvValue conversion helpers
600// ---------------------------------------------------------------------------
601
602/// Convert an NtPayload (member snapshot) to a PvValue for embedding in a
603/// group structure, respecting the FieldMapping.
604fn payload_to_pv_value(payload: &NtPayload, mapping: FieldMapping) -> PvValue {
605    match mapping {
606        FieldMapping::Scalar => payload_to_full_structure(payload),
607        FieldMapping::Plain => payload_to_value_only(payload),
608        FieldMapping::Meta => payload_to_meta_only(payload),
609        FieldMapping::Any => payload_to_full_structure(payload),
610        FieldMapping::Proc => PvValue::Scalar(ScalarValue::I32(0)), // shouldn't be called
611    }
612}
613
614/// Full NT structure as PvValue (Scalar mapping).
615fn payload_to_full_structure(payload: &NtPayload) -> PvValue {
616    match payload {
617        NtPayload::Scalar(nt) => {
618            let mut fields = vec![
619                ("value".to_string(), PvValue::Scalar(nt.value.clone())),
620                (
621                    "alarm".to_string(),
622                    alarm_to_pv_value(nt.alarm_severity, nt.alarm_status, &nt.alarm_message),
623                ),
624                ("timeStamp".to_string(), timestamp_to_pv_value_default()),
625            ];
626            fields.push((
627                "display".to_string(),
628                PvValue::Structure {
629                    struct_id: "display_t".to_string(),
630                    fields: vec![
631                        (
632                            "limitLow".to_string(),
633                            PvValue::Scalar(ScalarValue::F64(nt.display_low)),
634                        ),
635                        (
636                            "limitHigh".to_string(),
637                            PvValue::Scalar(ScalarValue::F64(nt.display_high)),
638                        ),
639                        (
640                            "description".to_string(),
641                            PvValue::Scalar(ScalarValue::Str(nt.display_description.clone())),
642                        ),
643                        (
644                            "units".to_string(),
645                            PvValue::Scalar(ScalarValue::Str(nt.units.clone())),
646                        ),
647                        (
648                            "precision".to_string(),
649                            PvValue::Scalar(ScalarValue::I32(nt.display_precision)),
650                        ),
651                    ],
652                },
653            ));
654            fields.push((
655                "control".to_string(),
656                PvValue::Structure {
657                    struct_id: "control_t".to_string(),
658                    fields: vec![
659                        (
660                            "limitLow".to_string(),
661                            PvValue::Scalar(ScalarValue::F64(nt.control_low)),
662                        ),
663                        (
664                            "limitHigh".to_string(),
665                            PvValue::Scalar(ScalarValue::F64(nt.control_high)),
666                        ),
667                        (
668                            "minStep".to_string(),
669                            PvValue::Scalar(ScalarValue::F64(nt.control_min_step)),
670                        ),
671                    ],
672                },
673            ));
674            PvValue::Structure {
675                struct_id: "epics:nt/NTScalar:1.0".to_string(),
676                fields,
677            }
678        }
679        NtPayload::ScalarArray(nt) => {
680            let fields = vec![
681                ("value".to_string(), PvValue::ScalarArray(nt.value.clone())),
682                (
683                    "alarm".to_string(),
684                    alarm_to_pv_value(nt.alarm.severity, nt.alarm.status, &nt.alarm.message),
685                ),
686                ("timeStamp".to_string(), timestamp_pv(&nt.time_stamp)),
687            ];
688            PvValue::Structure {
689                struct_id: "epics:nt/NTScalarArray:1.0".to_string(),
690                fields,
691            }
692        }
693        NtPayload::Enum(nt) => {
694            let fields = vec![
695                (
696                    "value".to_string(),
697                    PvValue::Structure {
698                        struct_id: "enum_t".to_string(),
699                        fields: vec![
700                            (
701                                "index".to_string(),
702                                PvValue::Scalar(ScalarValue::I32(nt.index)),
703                            ),
704                            (
705                                "choices".to_string(),
706                                PvValue::ScalarArray(ScalarArrayValue::Str(nt.choices.clone())),
707                            ),
708                        ],
709                    },
710                ),
711                ("alarm".to_string(), alarm_pv(&nt.alarm)),
712                ("timeStamp".to_string(), timestamp_pv(&nt.time_stamp)),
713            ];
714            PvValue::Structure {
715                struct_id: "epics:nt/NTEnum:1.0".to_string(),
716                fields,
717            }
718        }
719        NtPayload::Generic { struct_id, fields } => PvValue::Structure {
720            struct_id: struct_id.clone(),
721            fields: fields.clone(),
722        },
723        _ => PvValue::Scalar(ScalarValue::I32(0)),
724    }
725}
726
727/// Value-only (Plain mapping) — extract just the primary value.
728fn payload_to_value_only(payload: &NtPayload) -> PvValue {
729    match payload {
730        NtPayload::Scalar(nt) => PvValue::Scalar(nt.value.clone()),
731        NtPayload::ScalarArray(nt) => PvValue::ScalarArray(nt.value.clone()),
732        NtPayload::Enum(nt) => PvValue::Scalar(ScalarValue::I32(nt.index)),
733        NtPayload::Generic { fields, .. } => {
734            // Try to find a "value" field.
735            fields
736                .iter()
737                .find(|(n, _)| n == "value")
738                .map(|(_, v)| v.clone())
739                .unwrap_or(PvValue::Scalar(ScalarValue::I32(0)))
740        }
741        _ => PvValue::Scalar(ScalarValue::I32(0)),
742    }
743}
744
745/// Meta-only (Meta mapping) — alarm + timestamp only.
746fn payload_to_meta_only(payload: &NtPayload) -> PvValue {
747    match payload {
748        NtPayload::Scalar(nt) => PvValue::Structure {
749            struct_id: String::new(),
750            fields: vec![
751                (
752                    "alarm".to_string(),
753                    alarm_to_pv_value(nt.alarm_severity, nt.alarm_status, &nt.alarm_message),
754                ),
755                ("timeStamp".to_string(), timestamp_to_pv_value_default()),
756            ],
757        },
758        NtPayload::ScalarArray(nt) => PvValue::Structure {
759            struct_id: String::new(),
760            fields: vec![
761                ("alarm".to_string(), alarm_pv(&nt.alarm)),
762                ("timeStamp".to_string(), timestamp_pv(&nt.time_stamp)),
763            ],
764        },
765        NtPayload::Enum(nt) => PvValue::Structure {
766            struct_id: String::new(),
767            fields: vec![
768                ("alarm".to_string(), alarm_pv(&nt.alarm)),
769                ("timeStamp".to_string(), timestamp_pv(&nt.time_stamp)),
770            ],
771        },
772        _ => PvValue::Structure {
773            struct_id: String::new(),
774            fields: vec![],
775        },
776    }
777}
778
779/// Build a FieldType for a member's contribution to the group descriptor.
780fn payload_field_type(payload: &NtPayload, mapping: FieldMapping) -> FieldType {
781    match mapping {
782        FieldMapping::Scalar | FieldMapping::Any => {
783            FieldType::Structure(descriptor_for_payload(payload))
784        }
785        FieldMapping::Plain => match payload {
786            NtPayload::Scalar(nt) => value_field_type(&nt.value),
787            NtPayload::ScalarArray(nt) => array_field_type(&nt.value),
788            NtPayload::Enum(_) => FieldType::Scalar(TypeCode::Int32),
789            _ => FieldType::Scalar(TypeCode::Int32),
790        },
791        FieldMapping::Meta => FieldType::Structure(StructureDesc {
792            struct_id: None,
793            fields: vec![
794                FieldDesc {
795                    name: "alarm".to_string(),
796                    field_type: FieldType::Structure(alarm_struct_desc()),
797                },
798                FieldDesc {
799                    name: "timeStamp".to_string(),
800                    field_type: FieldType::Structure(timestamp_struct_desc()),
801                },
802            ],
803        }),
804        FieldMapping::Proc => FieldType::Scalar(TypeCode::Int32),
805    }
806}
807
808fn value_field_type(sv: &ScalarValue) -> FieldType {
809    match sv {
810        ScalarValue::Str(_) => FieldType::String,
811        sv => {
812            let tc = match sv {
813                ScalarValue::Bool(_) => TypeCode::Boolean,
814                ScalarValue::I8(_) => TypeCode::Int8,
815                ScalarValue::I16(_) => TypeCode::Int16,
816                ScalarValue::I32(_) => TypeCode::Int32,
817                ScalarValue::I64(_) => TypeCode::Int64,
818                ScalarValue::U8(_) => TypeCode::UInt8,
819                ScalarValue::U16(_) => TypeCode::UInt16,
820                ScalarValue::U32(_) => TypeCode::UInt32,
821                ScalarValue::U64(_) => TypeCode::UInt64,
822                ScalarValue::F32(_) => TypeCode::Float32,
823                ScalarValue::F64(_) => TypeCode::Float64,
824                ScalarValue::Str(_) => unreachable!(),
825            };
826            FieldType::Scalar(tc)
827        }
828    }
829}
830
831fn array_field_type(sav: &ScalarArrayValue) -> FieldType {
832    match sav {
833        ScalarArrayValue::Str(_) => FieldType::StringArray,
834        sav => {
835            let tc = match sav {
836                ScalarArrayValue::Bool(_) => TypeCode::Boolean,
837                ScalarArrayValue::I8(_) => TypeCode::Int8,
838                ScalarArrayValue::I16(_) => TypeCode::Int16,
839                ScalarArrayValue::I32(_) => TypeCode::Int32,
840                ScalarArrayValue::I64(_) => TypeCode::Int64,
841                ScalarArrayValue::U8(_) => TypeCode::UInt8,
842                ScalarArrayValue::U16(_) => TypeCode::UInt16,
843                ScalarArrayValue::U32(_) => TypeCode::UInt32,
844                ScalarArrayValue::U64(_) => TypeCode::UInt64,
845                ScalarArrayValue::F32(_) => TypeCode::Float32,
846                ScalarArrayValue::F64(_) => TypeCode::Float64,
847                ScalarArrayValue::Str(_) => unreachable!(),
848            };
849            FieldType::ScalarArray(tc)
850        }
851    }
852}
853
854fn alarm_struct_desc() -> StructureDesc {
855    StructureDesc {
856        struct_id: Some("alarm_t".to_string()),
857        fields: vec![
858            FieldDesc {
859                name: "severity".to_string(),
860                field_type: FieldType::Scalar(TypeCode::Int32),
861            },
862            FieldDesc {
863                name: "status".to_string(),
864                field_type: FieldType::Scalar(TypeCode::Int32),
865            },
866            FieldDesc {
867                name: "message".to_string(),
868                field_type: FieldType::String,
869            },
870        ],
871    }
872}
873
874fn timestamp_struct_desc() -> StructureDesc {
875    StructureDesc {
876        struct_id: Some("time_t".to_string()),
877        fields: vec![
878            FieldDesc {
879                name: "secondsPastEpoch".to_string(),
880                field_type: FieldType::Scalar(TypeCode::Int64),
881            },
882            FieldDesc {
883                name: "nanoseconds".to_string(),
884                field_type: FieldType::Scalar(TypeCode::Int32),
885            },
886            FieldDesc {
887                name: "userTag".to_string(),
888                field_type: FieldType::Scalar(TypeCode::Int32),
889            },
890        ],
891    }
892}
893
894fn alarm_to_pv_value(severity: i32, status: i32, message: &str) -> PvValue {
895    PvValue::Structure {
896        struct_id: "alarm_t".to_string(),
897        fields: vec![
898            (
899                "severity".to_string(),
900                PvValue::Scalar(ScalarValue::I32(severity)),
901            ),
902            (
903                "status".to_string(),
904                PvValue::Scalar(ScalarValue::I32(status)),
905            ),
906            (
907                "message".to_string(),
908                PvValue::Scalar(ScalarValue::Str(message.to_string())),
909            ),
910        ],
911    }
912}
913
914fn alarm_pv(alarm: &spvirit_types::NtAlarm) -> PvValue {
915    alarm_to_pv_value(alarm.severity, alarm.status, &alarm.message)
916}
917
918fn timestamp_pv(ts: &spvirit_types::NtTimeStamp) -> PvValue {
919    PvValue::Structure {
920        struct_id: "time_t".to_string(),
921        fields: vec![
922            (
923                "secondsPastEpoch".to_string(),
924                PvValue::Scalar(ScalarValue::I64(ts.seconds_past_epoch)),
925            ),
926            (
927                "nanoseconds".to_string(),
928                PvValue::Scalar(ScalarValue::I32(ts.nanoseconds)),
929            ),
930            (
931                "userTag".to_string(),
932                PvValue::Scalar(ScalarValue::I32(ts.user_tag)),
933            ),
934        ],
935    }
936}
937
938fn timestamp_to_pv_value_default() -> PvValue {
939    timestamp_pv(&spvirit_types::NtTimeStamp::default())
940}
941
942// ---------------------------------------------------------------------------
943// Tests
944// ---------------------------------------------------------------------------
945
946#[cfg(test)]
947mod tests {
948    use super::*;
949
950    #[test]
951    fn parse_basic_group() {
952        let json = r#"{
953            "GRP:test": {
954                "+id": "epics:nt/NTTable:1.0",
955                "+atomic": true,
956                "fieldA": {
957                    "+channel": "REC:A",
958                    "+type": "scalar",
959                    "+trigger": "*"
960                },
961                "fieldB": {
962                    "+channel": "REC:B",
963                    "+type": "plain"
964                }
965            }
966        }"#;
967
968        let groups = parse_group_config(json).unwrap();
969        assert_eq!(groups.len(), 1);
970        let g = &groups[0];
971        assert_eq!(g.name, "GRP:test");
972        assert_eq!(g.struct_id.as_deref(), Some("epics:nt/NTTable:1.0"));
973        assert!(g.atomic);
974        assert_eq!(g.members.len(), 2);
975
976        let a = g.members.iter().find(|m| m.field_name == "fieldA").unwrap();
977        assert_eq!(a.channel, "REC:A");
978        assert_eq!(a.mapping, FieldMapping::Scalar);
979        assert_eq!(a.triggers, TriggerDef::All);
980
981        let b = g.members.iter().find(|m| m.field_name == "fieldB").unwrap();
982        assert_eq!(b.channel, "REC:B");
983        assert_eq!(b.mapping, FieldMapping::Plain);
984    }
985
986    #[test]
987    fn parse_minimal_member() {
988        let json = r#"{
989            "GRP:min": {
990                "x": { "+channel": "R:x" }
991            }
992        }"#;
993
994        let groups = parse_group_config(json).unwrap();
995        let m = &groups[0].members[0];
996        assert_eq!(m.mapping, FieldMapping::Plain); // default
997        assert_eq!(m.triggers, TriggerDef::None); // default
998        assert_eq!(m.put_order, 0);
999    }
1000
1001    #[test]
1002    fn parse_proc_mapping() {
1003        let json = r#"{
1004            "GRP:proc": {
1005                "go": {
1006                    "+channel": "REC:PROC",
1007                    "+type": "proc",
1008                    "+trigger": "go",
1009                    "+putorder": 99
1010                }
1011            }
1012        }"#;
1013
1014        let groups = parse_group_config(json).unwrap();
1015        let m = &groups[0].members[0];
1016        assert_eq!(m.mapping, FieldMapping::Proc);
1017        assert_eq!(m.put_order, 99);
1018        assert_eq!(m.triggers, TriggerDef::Fields(vec!["go".into()]));
1019    }
1020
1021    #[test]
1022    fn parse_error_missing_channel() {
1023        let json = r#"{
1024            "GRP:bad": {
1025                "x": { "+type": "scalar" }
1026            }
1027        }"#;
1028
1029        assert!(parse_group_config(json).is_err());
1030    }
1031
1032    #[test]
1033    fn parse_multiple_groups() {
1034        let json = r#"{
1035            "G:a": { "x": { "+channel": "R:x" } },
1036            "G:b": { "y": { "+channel": "R:y" } }
1037        }"#;
1038
1039        let groups = parse_group_config(json).unwrap();
1040        assert_eq!(groups.len(), 2);
1041    }
1042
1043    #[test]
1044    fn parse_member_id() {
1045        let json = r#"{
1046            "GRP:id": {
1047                "val": {
1048                    "+channel": "R:val",
1049                    "+id": "custom_t"
1050                }
1051            }
1052        }"#;
1053
1054        let groups = parse_group_config(json).unwrap();
1055        assert_eq!(groups[0].members[0].struct_id.as_deref(), Some("custom_t"));
1056    }
1057
1058    #[test]
1059    fn parse_member_no_id() {
1060        let json = r#"{
1061            "GRP:noid": {
1062                "v": { "+channel": "R:v" }
1063            }
1064        }"#;
1065
1066        let groups = parse_group_config(json).unwrap();
1067        assert!(groups[0].members[0].struct_id.is_none());
1068    }
1069
1070    #[test]
1071    fn parse_info_group_prefix() {
1072        let json = r#"{
1073            "TEMP:group": {
1074                "VAL": {
1075                    "+channel": "VAL",
1076                    "+type": "plain",
1077                    "+trigger": "*"
1078                }
1079            }
1080        }"#;
1081
1082        let groups = parse_info_group("TEMP:sensor", json).unwrap();
1083        // Bare field "VAL" should become "TEMP:sensor.VAL"
1084        assert_eq!(groups[0].members[0].channel, "TEMP:sensor.VAL");
1085    }
1086
1087    #[test]
1088    fn parse_info_group_absolute_channel() {
1089        let json = r#"{
1090            "TEMP:group": {
1091                "pressure": {
1092                    "+channel": "PRESS:ai",
1093                    "+type": "scalar"
1094                }
1095            }
1096        }"#;
1097
1098        let groups = parse_info_group("TEMP:sensor", json).unwrap();
1099        // Absolute channel (contains ':') should be kept as-is
1100        assert_eq!(groups[0].members[0].channel, "PRESS:ai");
1101    }
1102
1103    #[test]
1104    fn merge_groups() {
1105        let mut existing = HashMap::new();
1106        let defs1 = parse_group_config(r#"{ "GRP:a": { "x": { "+channel": "R1:x" } } }"#).unwrap();
1107        merge_group_defs(&mut existing, defs1);
1108
1109        let defs2 = parse_group_config(r#"{ "GRP:a": { "y": { "+channel": "R2:y" } } }"#).unwrap();
1110        merge_group_defs(&mut existing, defs2);
1111
1112        let grp = existing.get("GRP:a").unwrap();
1113        assert_eq!(grp.members.len(), 2);
1114    }
1115
1116    #[test]
1117    fn trigger_validation_unknown_field() {
1118        let json = r#"{
1119            "GRP:bad": {
1120                "x": {
1121                    "+channel": "R:x",
1122                    "+trigger": "y,z"
1123                },
1124                "y": { "+channel": "R:y" }
1125            }
1126        }"#;
1127
1128        // y exists but z doesn't — should fail.
1129        let result = parse_group_config(json);
1130        assert!(result.is_err());
1131        let e = format!("{}", result.unwrap_err());
1132        assert!(e.contains("'z'"), "expected error about 'z': {e}");
1133    }
1134
1135    #[test]
1136    fn trigger_validation_self_reference() {
1137        let json = r#"{
1138            "GRP:ok": {
1139                "a": { "+channel": "R:a", "+trigger": "a,b" },
1140                "b": { "+channel": "R:b", "+trigger": "a" }
1141            }
1142        }"#;
1143
1144        // Self-reference and cross-reference are both valid.
1145        assert!(parse_group_config(json).is_ok());
1146    }
1147
1148    #[test]
1149    fn trigger_validation_star_passes() {
1150        let json = r#"{
1151            "GRP:ok": {
1152                "a": { "+channel": "R:a", "+trigger": "*" }
1153            }
1154        }"#;
1155
1156        // "*" doesn't go through field validation.
1157        assert!(parse_group_config(json).is_ok());
1158    }
1159}