1use std::collections::HashMap;
27use std::fmt;
28
29use serde::Deserialize;
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum FieldMapping {
38 Scalar,
40 Plain,
42 Meta,
44 Any,
46 Proc,
48}
49
50#[derive(Debug, Clone, PartialEq, Eq)]
52pub enum TriggerDef {
53 All,
55 Fields(Vec<String>),
57 None,
59}
60
61#[derive(Debug, Clone)]
63pub struct GroupMember {
64 pub field_name: String,
66 pub channel: String,
68 pub mapping: FieldMapping,
70 pub triggers: TriggerDef,
72 pub put_order: i32,
74 pub struct_id: Option<String>,
76}
77
78#[derive(Debug, Clone)]
80pub struct GroupPvDef {
81 pub name: String,
83 pub struct_id: Option<String>,
85 pub atomic: bool,
87 pub members: Vec<GroupMember>,
89}
90
91#[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
112type RawConfig = HashMap<String, RawGroupDef>;
118
119#[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
144pub 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 groups.sort_by(|a, b| a.name.cmp(&b.name));
161 Ok(groups)
162}
163
164pub 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 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
187pub 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 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
207fn raw_to_group_def(name: String, raw: RawGroupDef) -> Result<GroupPvDef> {
212 let mut members = Vec::with_capacity(raw.members.len());
213
214 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 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 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
279use 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
295pub struct GroupSource {
301 sources: Arc<SourceRegistry>,
302 groups: HashMap<String, GroupPvDef>,
303}
304
305impl GroupSource {
306 pub fn new(sources: Arc<SourceRegistry>, groups: HashMap<String, GroupPvDef>) -> Self {
308 Self { sources, groups }
309 }
310
311 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; }
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)), };
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 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), };
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 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 let fields = match &value {
401 DecodedValue::Structure(f) => f,
402 _ => return Err("group PUT requires a structure".to_string()),
403 };
404
405 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 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 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 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 let trigger_map = build_trigger_map(&def);
476
477 tokio::spawn(async move {
478 loop {
482 let src_field = match poll_any_member(&mut member_rxs).await {
484 Some(field_name) => field_name,
485 None => break, };
487
488 {
489 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 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; }
518 }
519 }
520 }
521 });
522
523 Some(rx)
524 }
525}
526
527fn 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
543async fn poll_any_member(members: &mut Vec<(String, mpsc::Receiver<NtPayload>)>) -> Option<String> {
546 if members.is_empty() {
547 return None;
548 }
549
550 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 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
574async 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
600fn 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)), }
614}
615
616fn 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
729fn 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 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
747fn 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
781fn 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#[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); assert_eq!(m.triggers, TriggerDef::None); 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 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 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 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 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 assert!(parse_group_config(json).is_ok());
1160 }
1161}