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<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 let fields = match &value {
399 DecodedValue::Structure(f) => f,
400 _ => return Err("group PUT requires a structure".to_string()),
401 };
402
403 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 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 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 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 let trigger_map = build_trigger_map(&def);
474
475 tokio::spawn(async move {
476 loop {
480 let src_field = match poll_any_member(&mut member_rxs).await {
482 Some(field_name) => field_name,
483 None => break, };
485
486 {
487 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 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; }
516 }
517 }
518 }
519 });
520
521 Some(rx)
522 }
523}
524
525fn 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
541async fn poll_any_member(members: &mut Vec<(String, mpsc::Receiver<NtPayload>)>) -> Option<String> {
544 if members.is_empty() {
545 return None;
546 }
547
548 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 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
572async 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
598fn 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)), }
612}
613
614fn 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
727fn 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 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
745fn 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
779fn 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#[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); assert_eq!(m.triggers, TriggerDef::None); 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 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 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 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 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 assert!(parse_group_config(json).is_ok());
1158 }
1159}