1use std::future::Future;
12use std::pin::Pin;
13use std::sync::Arc;
14
15use tokio::sync::{Mutex, mpsc};
16
17use spvirit_codec::spvd_decode::DecodedValue;
18use spvirit_types::{NtPayload, NtScalar, NtScalarArray, ScalarArrayValue};
19
20use crate::field_provider::{RecordFieldProvider, resolve_field_info, resolve_field_payload};
21use crate::pvstore::{PvInfo, Source};
22use crate::types::{RecordInstance, RecordType, ScalarValue};
23
24#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct FieldRef {
27 pub base: String,
28 pub field: String,
29 pub long_string: bool,
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum FieldKind {
36 Str,
37 Int,
38 Double,
39}
40
41pub fn parse_field_ref(name: &str) -> Option<FieldRef> {
48 let (base, field_part) = name.rsplit_once('.')?;
49 if base.is_empty() {
50 return None;
51 }
52 let (field, long_string) = match field_part.strip_suffix('$') {
53 Some(stripped) => (stripped, true),
54 None => (field_part, false),
55 };
56 if field.is_empty()
57 || !field
58 .bytes()
59 .all(|b| b.is_ascii_uppercase() || b.is_ascii_digit())
60 {
61 return None;
62 }
63 Some(FieldRef {
64 base: base.to_string(),
65 field: field.to_string(),
66 long_string,
67 })
68}
69
70const DBCOMMON_DEFAULTS: &[(&str, FieldKind, &str)] = &[
76 ("DESC", FieldKind::Str, ""),
77 ("SCAN", FieldKind::Str, "Passive"),
78 ("PINI", FieldKind::Str, "NO"),
79 ("PHAS", FieldKind::Int, "0"),
80 ("EVNT", FieldKind::Str, ""),
81 ("PRIO", FieldKind::Str, "LOW"),
82 ("DISV", FieldKind::Int, "1"),
83 ("DISA", FieldKind::Int, "0"),
84 ("SDIS", FieldKind::Str, ""),
85 ("DISS", FieldKind::Str, "NO_ALARM"),
86 ("PROC", FieldKind::Int, "0"),
87 ("STAT", FieldKind::Str, "UDF"),
88 ("SEVR", FieldKind::Str, "INVALID"),
89 ("UDF", FieldKind::Int, "1"),
90 ("TPRO", FieldKind::Int, "0"),
91 ("FLNK", FieldKind::Str, ""),
92 ("ADEL", FieldKind::Double, "0"),
93 ("MDEL", FieldKind::Double, "0"),
94 ("TSE", FieldKind::Int, "0"),
95 ("DISP", FieldKind::Int, "0"),
96 ("ACKS", FieldKind::Str, "NO_ALARM"),
97 ("ACKT", FieldKind::Str, "YES"),
98 ("ASG", FieldKind::Str, ""),
99];
100
101pub fn dbcommon_default(field: &str) -> Option<(FieldKind, &'static str)> {
103 DBCOMMON_DEFAULTS
104 .iter()
105 .find(|(name, _, _)| *name == field)
106 .map(|(_, kind, default)| (*kind, *default))
107}
108
109pub fn dbcommon_default_value(field: &str) -> Option<ScalarValue> {
114 dbcommon_default(field).map(|(kind, default)| typed_value(kind, default))
115}
116
117pub const LINK_FIELDS: &[&str] = &["INP", "OUT", "DOL", "SDIS", "FLNK"];
123
124pub fn is_link_field(field: &str) -> bool {
126 LINK_FIELDS.contains(&field)
127}
128
129pub fn render_link_text(
143 target: &str,
144 field: Option<&str>,
145 process_passive: bool,
146 maximize_severity: bool,
147) -> String {
148 let mut s = target.to_string();
149 if let Some(field) = field {
150 s.push('.');
151 s.push_str(field);
152 }
153 s.push(' ');
154 s.push_str(if process_passive { "PP" } else { "NPP" });
155 s.push(' ');
156 s.push_str(if maximize_severity { "MS" } else { "NMS" });
157 s
158}
159
160pub fn canonical_link_text(raw: &str, forward: bool) -> Option<String> {
173 let raw = raw.trim();
174 if raw.is_empty() || raw.parse::<f64>().is_ok() {
175 return None;
176 }
177 let mut parts = raw.split_whitespace();
178 let target_spec = parts.next()?;
179 let mut process_passive = false;
180 let mut maximize_severity = false;
181 for modifier in parts {
182 match modifier.to_ascii_uppercase().as_str() {
185 "PP" => process_passive = true,
186 "NPP" => process_passive = false,
187 "MS" | "MSS" | "MSI" => maximize_severity = true,
188 "NMS" => maximize_severity = false,
189 _ => return None,
190 }
191 }
192 let (target, field) = match target_spec.split_once('.') {
194 Some((t, f)) if !t.is_empty() && !f.is_empty() => (t, Some(f)),
195 _ => (target_spec, None),
196 };
197 let field = field.filter(|f| !forward && !f.eq_ignore_ascii_case("VAL"));
200 Some(render_link_text(
201 target,
202 field,
203 process_passive,
204 maximize_severity,
205 ))
206}
207
208pub fn record_type_name(rt: &RecordType) -> &'static str {
210 match rt {
211 RecordType::Ai => "ai",
212 RecordType::Ao => "ao",
213 RecordType::Bi => "bi",
214 RecordType::Bo => "bo",
215 RecordType::StringIn => "stringin",
216 RecordType::StringOut => "stringout",
217 RecordType::Waveform => "waveform",
218 RecordType::Aai => "aai",
219 RecordType::Aao => "aao",
220 RecordType::SubArray => "subArray",
221 RecordType::NtTable => "ntTable",
222 RecordType::NtNdArray => "ntNDArray",
223 RecordType::Mbbi => "mbbi",
224 RecordType::Mbbo => "mbbo",
225 RecordType::Generic => "generic",
226 RecordType::LongIn => "longin",
227 RecordType::LongOut => "longout",
228 }
229}
230
231pub fn mdel_of(record: &RecordInstance) -> f64 {
233 record
234 .raw_fields
235 .get("MDEL")
236 .and_then(|s| s.trim().parse::<f64>().ok())
237 .unwrap_or(0.0)
238}
239
240fn typed_value(kind: FieldKind, raw: &str) -> ScalarValue {
242 match kind {
243 FieldKind::Int => raw
244 .trim()
245 .parse::<i32>()
246 .map(ScalarValue::I32)
247 .unwrap_or_else(|_| ScalarValue::Str(raw.to_string())),
248 FieldKind::Double => raw
249 .trim()
250 .parse::<f64>()
251 .map(ScalarValue::F64)
252 .unwrap_or_else(|_| ScalarValue::Str(raw.to_string())),
253 FieldKind::Str => ScalarValue::Str(raw.to_string()),
254 }
255}
256
257pub fn field_value(record: &RecordInstance, field: &str) -> Option<ScalarValue> {
263 match field {
264 "RTYP" => {
265 return Some(ScalarValue::Str(
266 record_type_name(&record.record_type).to_string(),
267 ));
268 }
269 "NAME" => return Some(ScalarValue::Str(record.name.clone())),
270 "VAL" => return Some(record.current_value()),
271 "DTYP" => {
272 let dtyp = record
273 .raw_fields
274 .get("DTYP")
275 .cloned()
276 .unwrap_or_else(|| "Soft Channel".to_string());
277 return Some(ScalarValue::Str(dtyp));
278 }
279 _ => {}
280 }
281
282 let kind = dbcommon_default(field).map(|(kind, _)| kind);
283 if let Some(raw) = record.raw_fields.get(field) {
284 if is_link_field(field)
288 && let Some(rendered) = canonical_link_text(raw, field == "FLNK")
289 {
290 return Some(ScalarValue::Str(rendered));
291 }
292 return Some(typed_value(kind.unwrap_or(FieldKind::Str), raw));
293 }
294 if field == "DESC" {
295 return Some(ScalarValue::Str(record.common.desc.clone()));
296 }
297 dbcommon_default(field).map(|(kind, default)| typed_value(kind, default))
298}
299
300pub fn payload_for_value(value: ScalarValue, desc: &str, long_string: bool) -> Option<NtPayload> {
310 if long_string {
311 let ScalarValue::Str(s) = value else {
312 return None;
313 };
314 let bytes: Vec<i8> = s.into_bytes().into_iter().map(|b| b as i8).collect();
315 return Some(NtPayload::ScalarArray(NtScalarArray::from_value(
316 ScalarArrayValue::I8(bytes),
317 )));
318 }
319 let mut nt = NtScalar::from_value(value);
320 nt.display_description = desc.to_string();
321 Some(NtPayload::Scalar(nt))
322}
323
324pub fn payload_for(record: &RecordInstance, field_ref: &FieldRef) -> Option<NtPayload> {
326 let value = field_value(record, &field_ref.field)?;
327 payload_for_value(value, &record.common.desc, field_ref.long_string)
328}
329
330pub struct RecordFieldSource {
336 provider: Arc<dyn RecordFieldProvider>,
337 open_subs: Mutex<Vec<mpsc::Sender<NtPayload>>>,
342}
343
344impl RecordFieldSource {
345 pub fn new(provider: Arc<dyn RecordFieldProvider>) -> Self {
346 Self {
347 provider,
348 open_subs: Mutex::new(Vec::new()),
349 }
350 }
351}
352
353impl Source for RecordFieldSource {
354 fn claim(&self, name: &str) -> Pin<Box<dyn Future<Output = Option<PvInfo>> + Send + '_>> {
355 let name = name.to_string();
356 Box::pin(async move { resolve_field_info(self.provider.as_ref(), &name).await })
357 }
358
359 fn get(&self, name: &str) -> Pin<Box<dyn Future<Output = Option<NtPayload>> + Send + '_>> {
360 let name = name.to_string();
361 Box::pin(async move { resolve_field_payload(self.provider.as_ref(), &name).await })
362 }
363
364 fn put(
365 &self,
366 name: &str,
367 _value: &DecodedValue,
368 ) -> Pin<Box<dyn Future<Output = Result<Vec<(String, NtPayload)>, String>> + Send + '_>> {
369 let name = name.to_string();
370 Box::pin(async move { Err(format!("field PV '{}' is read-only", name)) })
371 }
372
373 fn subscribe(
374 &self,
375 name: &str,
376 ) -> Pin<Box<dyn Future<Output = Option<mpsc::Receiver<NtPayload>>> + Send + '_>> {
377 let name = name.to_string();
378 Box::pin(async move {
379 let initial = resolve_field_payload(self.provider.as_ref(), &name).await?;
380 let (tx, rx) = mpsc::channel(4);
381 let _ = tx.try_send(initial);
382 self.open_subs.lock().await.push(tx);
383 Some(rx)
384 })
385 }
386
387 fn names(&self) -> Pin<Box<dyn Future<Output = Vec<String>> + Send + '_>> {
388 Box::pin(async move { Vec::new() })
391 }
392}
393
394#[cfg(test)]
395mod tests {
396 use super::*;
397
398 #[test]
399 fn parses_simple_field_ref() {
400 let r = parse_field_ref("SIM:AO.RTYP").unwrap();
401 assert_eq!(
402 (r.base.as_str(), r.field.as_str(), r.long_string),
403 ("SIM:AO", "RTYP", false)
404 );
405 }
406
407 #[test]
408 fn parses_long_string_suffix() {
409 let r = parse_field_ref("SIM:AO.DESC$").unwrap();
410 assert_eq!((r.field.as_str(), r.long_string), ("DESC", true));
411 }
412
413 #[test]
414 fn rejects_non_field_names() {
415 assert!(parse_field_ref("SIM:AO").is_none()); assert!(parse_field_ref("SIM:AO.").is_none()); assert!(parse_field_ref("SIM:AO.rtyp").is_none()); assert!(parse_field_ref("SIM:AO.$").is_none()); assert!(parse_field_ref(".RTYP").is_none()); }
421
422 #[test]
423 fn dbcommon_defaults_cover_key_fields() {
424 assert!(matches!(
425 dbcommon_default("SCAN"),
426 Some((FieldKind::Str, "Passive"))
427 ));
428 assert!(matches!(
429 dbcommon_default("PINI"),
430 Some((FieldKind::Str, "NO"))
431 ));
432 assert!(matches!(
433 dbcommon_default("PHAS"),
434 Some((FieldKind::Int, "0"))
435 ));
436 assert!(matches!(
437 dbcommon_default("MDEL"),
438 Some((FieldKind::Double, "0"))
439 ));
440 assert!(dbcommon_default("NOTAFIELD").is_none());
441 }
442
443 #[test]
444 fn record_type_names_match_db_names() {
445 assert_eq!(record_type_name(&RecordType::Ao), "ao");
446 assert_eq!(record_type_name(&RecordType::StringIn), "stringin");
447 assert_eq!(record_type_name(&RecordType::LongIn), "longin");
448 }
449
450 fn test_record() -> RecordInstance {
451 let recs = crate::db::parse_db(
452 r#"
453record(ao, "SIM:AO") {
454 field(VAL, "2.34")
455 field(DESC, "A test output")
456 field(EGU, "V")
457 field(MDEL, "0.5")
458}"#,
459 )
460 .expect("parse");
461 recs.get("SIM:AO").expect("record present").clone()
462 }
463
464 #[test]
465 fn computed_fields_resolve() {
466 let r = test_record();
467 assert_eq!(field_value(&r, "RTYP"), Some(ScalarValue::Str("ao".into())));
468 assert_eq!(
469 field_value(&r, "NAME"),
470 Some(ScalarValue::Str("SIM:AO".into()))
471 );
472 assert_eq!(
473 field_value(&r, "DTYP"),
474 Some(ScalarValue::Str("Soft Channel".into()))
475 );
476 assert_eq!(field_value(&r, "VAL"), Some(ScalarValue::F64(2.34)));
477 }
478
479 #[test]
480 fn raw_db_fields_take_precedence_over_defaults() {
481 let r = test_record();
482 assert_eq!(
483 field_value(&r, "DESC"),
484 Some(ScalarValue::Str("A test output".into()))
485 );
486 assert_eq!(field_value(&r, "EGU"), Some(ScalarValue::Str("V".into())));
487 assert_eq!(field_value(&r, "MDEL"), Some(ScalarValue::F64(0.5)));
488 }
489
490 #[test]
491 fn dbcommon_defaults_fill_absent_fields() {
492 let r = test_record();
493 assert_eq!(
494 field_value(&r, "SCAN"),
495 Some(ScalarValue::Str("Passive".into()))
496 );
497 assert_eq!(field_value(&r, "PHAS"), Some(ScalarValue::I32(0)));
498 assert_eq!(field_value(&r, "ADEL"), Some(ScalarValue::F64(0.0)));
499 assert_eq!(field_value(&r, "NOTAFIELD"), None);
500 }
501
502 fn test_source() -> RecordFieldSource {
503 let recs = crate::db::parse_db(
504 r#"
505record(ao, "SIM:AO") {
506 field(VAL, "2.34")
507 field(DESC, "A test output")
508 field(MDEL, "0.5")
509}"#,
510 )
511 .expect("parse");
512 let store = crate::simple_store::SimplePvStore::new(
513 recs,
514 std::collections::HashMap::new(),
515 Vec::new(),
516 false,
517 );
518 let provider: Arc<dyn RecordFieldProvider> = Arc::new(store);
519 RecordFieldSource::new(provider)
520 }
521
522 #[tokio::test]
523 async fn claims_field_pvs_read_only() {
524 let src = test_source();
525 let info = src.claim("SIM:AO.RTYP").await.expect("claimed");
526 assert!(!info.writable);
527 match src.get("SIM:AO.RTYP").await.expect("payload") {
528 NtPayload::Scalar(nt) => assert_eq!(nt.value, ScalarValue::Str("ao".into())),
529 other => panic!("expected scalar, got {other:?}"),
530 }
531 }
532
533 #[tokio::test]
534 async fn does_not_claim_non_field_names() {
535 let src = test_source();
536 assert!(src.claim("SIM:AO").await.is_none()); assert!(src.claim("SIM:AO.NOTAFIELD").await.is_none());
538 assert!(src.claim("SIM:MISSING.RTYP").await.is_none());
539 assert!(src.claim("SIM:AO.MDEL$").await.is_none()); }
541
542 #[tokio::test]
543 async fn put_is_rejected() {
544 let src = test_source();
545 let err = src
546 .put("SIM:AO.DESC", &DecodedValue::Int32(1))
547 .await
548 .expect_err("put must fail");
549 assert!(err.contains("read-only"), "unexpected error: {err}");
550 }
551
552 #[tokio::test]
553 async fn long_string_serves_utf8_bytes() {
554 let src = test_source();
555 match src.get("SIM:AO.DESC$").await.expect("payload") {
556 NtPayload::ScalarArray(arr) => {
557 let ScalarArrayValue::I8(bytes) = arr.value else {
558 panic!("expected Int8 array, got {:?}", arr.value);
559 };
560 let expected: Vec<i8> = "A test output".bytes().map(|b| b as i8).collect();
561 assert_eq!(bytes, expected);
562 }
563 other => panic!("expected scalar array, got {other:?}"),
564 }
565 }
566
567 #[tokio::test]
568 async fn subscribe_delivers_initial_snapshot() {
569 let src = test_source();
570 let mut rx = src.subscribe("SIM:AO.RTYP").await.expect("subscribed");
571 match rx.recv().await.expect("initial value") {
572 NtPayload::Scalar(nt) => assert_eq!(nt.value, ScalarValue::Str("ao".into())),
573 other => panic!("expected scalar, got {other:?}"),
574 }
575 assert!(rx.try_recv().is_err());
577 }
578
579 #[test]
580 fn payload_for_value_wraps_a_scalar_with_its_description() {
581 let p = payload_for_value(ScalarValue::F64(2.34), "A test output", false)
582 .expect("scalars always wrap");
583 match p {
584 NtPayload::Scalar(nt) => {
585 assert_eq!(nt.value, ScalarValue::F64(2.34));
586 assert_eq!(nt.display_description, "A test output");
587 }
588 other => panic!("expected scalar, got {other:?}"),
589 }
590 }
591
592 #[test]
593 fn payload_for_value_long_string_needs_a_string() {
594 assert!(payload_for_value(ScalarValue::F64(1.0), "", true).is_none());
595 let p = payload_for_value(ScalarValue::Str("hi".into()), "", true).expect("string wraps");
596 match p {
597 NtPayload::ScalarArray(arr) => {
598 assert_eq!(arr.value, ScalarArrayValue::I8(vec![104, 105]));
599 }
600 other => panic!("expected scalar array, got {other:?}"),
601 }
602 }
603
604 #[test]
605 fn a_terse_link_renders_with_both_modifiers_spelled_out() {
606 assert_eq!(
607 canonical_link_text("PV:B PP", false).as_deref(),
608 Some("PV:B PP NMS")
609 );
610 assert_eq!(
611 canonical_link_text(" PV:B MS ", false).as_deref(),
612 Some("PV:B NPP MS")
613 );
614 assert_eq!(
615 canonical_link_text("PV:B", false).as_deref(),
616 Some("PV:B NPP NMS")
617 );
618 }
619
620 #[test]
624 fn the_implied_val_field_is_never_printed() {
625 assert_eq!(
626 canonical_link_text("PV:B.VAL PP", false).as_deref(),
627 Some("PV:B PP NMS")
628 );
629 assert_eq!(
630 canonical_link_text("PV:B.SEVR", false).as_deref(),
631 Some("PV:B.SEVR NPP NMS")
632 );
633 }
634
635 #[test]
637 fn a_forward_link_drops_its_field() {
638 assert_eq!(
639 canonical_link_text("PV:B.PROC", true).as_deref(),
640 Some("PV:B NPP NMS")
641 );
642 }
643
644 #[test]
647 fn constants_and_unmodelled_modifiers_do_not_canonicalise() {
648 assert_eq!(canonical_link_text("7", false), None);
649 assert_eq!(canonical_link_text("-1.5", false), None);
650 assert_eq!(canonical_link_text(" ", false), None);
651 assert_eq!(canonical_link_text("PV:B CPP", false), None);
652 }
653
654 #[test]
655 fn link_fields_render_canonically_out_of_the_raw_db() {
656 let recs = crate::db::parse_db(
657 r#"
658record(ai, "PV:A") {
659 field(INP, "PV:B PP")
660 field(FLNK, "PV:B")
661 field(EGU, "mm")
662}"#,
663 )
664 .expect("parse");
665 let record = recs.get("PV:A").expect("record present");
666 assert_eq!(
667 field_value(record, "INP"),
668 Some(ScalarValue::Str("PV:B PP NMS".into()))
669 );
670 assert_eq!(
671 field_value(record, "FLNK"),
672 Some(ScalarValue::Str("PV:B NPP NMS".into()))
673 );
674 assert_eq!(
676 field_value(record, "EGU"),
677 Some(ScalarValue::Str("mm".into()))
678 );
679 }
680}