Skip to main content

spvirit_tools/spvirit_client/
format.rs

1use chrono::{TimeZone, Utc};
2use serde_json::json;
3
4use spvirit_codec::spvd_decode::{extract_nt_scalar_value, format_compact_value, DecodedValue};
5
6#[derive(Clone, Copy, Debug, PartialEq, Eq)]
7pub enum OutputFormat {
8    Text,
9    Json,
10}
11
12#[derive(Clone, Debug)]
13pub struct RenderOptions {
14    pub format: OutputFormat,
15    pub include_timestamp: bool,
16    pub include_units: bool,
17    pub include_alarm: bool,
18    pub multiline: bool,
19}
20
21impl Default for RenderOptions {
22    fn default() -> Self {
23        Self {
24            format: OutputFormat::Text,
25            include_timestamp: true,
26            include_units: false,
27            include_alarm: true,
28            multiline: true,
29        }
30    }
31}
32
33#[derive(Clone, Debug)]
34pub struct AlarmInfo {
35    pub severity: i32,
36    pub status: i32,
37    pub message: String,
38}
39
40pub fn extract_ts_units(value: &DecodedValue) -> (Option<String>, Option<String>) {
41    let mut ts: Option<String> = None;
42    let mut units: Option<String> = None;
43
44    let fields = match value {
45        DecodedValue::Structure(fields) => fields,
46        _ => return (None, None),
47    };
48
49    if let Some((_, DecodedValue::Structure(ts_fields))) =
50        fields.iter().find(|(n, _)| n == "timeStamp")
51    {
52        let secs = ts_fields.iter().find_map(|(n, v)| {
53            if n == "secondsPastEpoch" {
54                if let DecodedValue::Int64(s) = v {
55                    Some(*s)
56                } else {
57                    None
58                }
59            } else {
60                None
61            }
62        });
63        let nanos = ts_fields.iter().find_map(|(n, v)| {
64            if n == "nanoseconds" {
65                if let DecodedValue::Int32(s) = v {
66                    Some(*s as u32)
67                } else {
68                    None
69                }
70            } else {
71                None
72            }
73        });
74        if let Some(secs) = secs {
75            let unix = choose_unix_epoch_seconds(secs);
76            let ns = nanos.unwrap_or(0);
77            if let Some(dt) = Utc.timestamp_opt(unix, ns).single() {
78                ts = Some(dt.format("%Y-%m-%d %H:%M:%S%.3f").to_string());
79            } else {
80                ts = Some(format!("{}.{:09}", unix, ns));
81            }
82        }
83    }
84
85    if let Some((_, DecodedValue::Structure(d_fields))) =
86        fields.iter().find(|(n, _)| n == "display")
87    {
88        if let Some((_, DecodedValue::String(u))) = d_fields.iter().find(|(n, _)| n == "units") {
89            if !u.is_empty() {
90                units = Some(u.clone());
91            }
92        }
93    }
94
95    (ts, units)
96}
97
98fn choose_unix_epoch_seconds(secs: i64) -> i64 {
99    // EPICS base uses UNIX seconds for secondsPastEpoch. Some sources still use EPICS epoch.
100    // Choose the interpretation closest to "now" to avoid 20-year skew.
101    let now = Utc::now().timestamp();
102    let epics_unix = secs + 631_152_000; // 1990-01-01 -> 1970-01-01
103    let dist_unix = (now - secs).abs();
104    let dist_epics = (now - epics_unix).abs();
105    if dist_epics < dist_unix {
106        epics_unix
107    } else {
108        secs
109    }
110}
111
112pub fn extract_alarm(value: &DecodedValue) -> Option<AlarmInfo> {
113    let fields = match value {
114        DecodedValue::Structure(fields) => fields,
115        _ => return None,
116    };
117
118    let alarm_fields = match fields.iter().find(|(n, _)| n == "alarm") {
119        Some((_, DecodedValue::Structure(a))) => a,
120        _ => return None,
121    };
122
123    let severity = alarm_fields.iter().find_map(|(n, v)| {
124        if n == "severity" {
125            if let DecodedValue::Int32(s) = v {
126                return Some(*s);
127            }
128        }
129        None
130    })?;
131
132    let status = alarm_fields.iter().find_map(|(n, v)| {
133        if n == "status" {
134            if let DecodedValue::Int32(s) = v {
135                return Some(*s);
136            }
137        }
138        None
139    })?;
140
141    let message = alarm_fields
142        .iter()
143        .find_map(|(n, v)| {
144            if n == "message" {
145                if let DecodedValue::String(s) = v {
146                    return Some(s.clone());
147                }
148            }
149            None
150        })
151        .unwrap_or_default();
152
153    Some(AlarmInfo {
154        severity,
155        status,
156        message,
157    })
158}
159
160pub fn severity_label(sev: i32) -> &'static str {
161    match sev {
162        0 => "OK",
163        1 => "MINOR",
164        2 => "MAJOR",
165        3 => "INVALID",
166        _ => "UNKNOWN",
167    }
168}
169
170pub fn format_alarm(alarm: &AlarmInfo) -> String {
171    let sev = severity_label(alarm.severity);
172    let status_name = status_label(alarm.status);
173    if !alarm.message.is_empty() {
174        format!(
175            "alarm={} status={}({}) msg={}",
176            sev, status_name, alarm.status, alarm.message
177        )
178    } else {
179        format!("alarm={} status={}({})", sev, status_name, alarm.status)
180    }
181}
182
183pub fn status_label(code: i32) -> &'static str {
184    match code {
185        0 => "NO_ALARM",
186        1 => "READ",
187        2 => "WRITE",
188        3 => "HIHI",
189        4 => "HIGH",
190        5 => "LOLO",
191        6 => "LOW",
192        7 => "STATE",
193        8 => "COS",
194        9 => "COMM",
195        10 => "CALC",
196        11 => "SCAN",
197        12 => "LINK",
198        13 => "SOFT",
199        14 => "BAD_SUB",
200        15 => "UDF",
201        16 => "DISABLE",
202        17 => "SIMM",
203        18 => "READ_ACCESS",
204        19 => "WRITE_ACCESS",
205        20 => "HWLIMIT",
206        21 => "TIMEOUT",
207        _ => "UNKNOWN",
208    }
209}
210
211fn format_value(value: &DecodedValue) -> String {
212    match value {
213        DecodedValue::Float32(v) => trim_float(format!("{:.6}", v)),
214        DecodedValue::Float64(v) => trim_float(format!("{:.6}", v)),
215        DecodedValue::String(s) => s.clone(),
216        _ => value.to_string(),
217    }
218}
219
220fn trim_float(mut s: String) -> String {
221    if let Some(dot) = s.find('.') {
222        while s.ends_with('0') {
223            s.pop();
224        }
225        if s.ends_with('.') {
226            s.pop();
227        }
228        if s.is_empty() || s == "-" {
229            s = "0".to_string();
230        } else if dot >= s.len() {
231            return s;
232        }
233    }
234    s
235}
236
237fn format_value_with_units(value: &DecodedValue, units: Option<&str>) -> String {
238    let base = format_value(value);
239    if let Some(u) = units {
240        if !u.is_empty() {
241            return format!("{} {}", base, u);
242        }
243    }
244    base
245}
246
247fn alarm_is_normal(alarm: &AlarmInfo) -> bool {
248    alarm.severity == 0 && alarm.status == 0 && alarm.message.is_empty()
249}
250
251fn alarm_tokens(alarm: &AlarmInfo, include_status: bool) -> Vec<String> {
252    let mut tokens = Vec::new();
253    tokens.push(severity_label(alarm.severity).to_string());
254    if include_status && alarm.status != 0 {
255        tokens.push(status_label(alarm.status).to_string());
256    }
257    if !alarm.message.is_empty() && alarm.message != status_label(alarm.status) {
258        tokens.push(alarm.message.clone());
259    }
260    tokens
261}
262
263pub fn format_output(pv: &str, value: &DecodedValue, opts: &RenderOptions) -> String {
264    match opts.format {
265        OutputFormat::Json => format_json_output(pv, value, opts),
266        OutputFormat::Text => format_text_output(pv, value, opts),
267    }
268}
269
270fn format_text_output(pv: &str, value: &DecodedValue, opts: &RenderOptions) -> String {
271    if opts.multiline {
272        if let Some(table) = format_table_output(pv, value) {
273            return table;
274        }
275    }
276
277    let (ts, units) = extract_ts_units(value);
278    let alarm = extract_alarm(value);
279    let scalar = extract_nt_scalar_value(value).unwrap_or(value);
280    let units_ref = if opts.include_units {
281        units.as_deref()
282    } else {
283        None
284    };
285    let val_str = format_value_with_units(scalar, units_ref);
286
287    let mut parts: Vec<String> = Vec::new();
288    parts.push(pv.to_string());
289    if opts.include_timestamp {
290        if let Some(ts) = ts {
291            parts.push(ts);
292        }
293    }
294    parts.push(format!("{:>3}", val_str));
295
296    if opts.include_alarm {
297        if let Some(alarm) = alarm {
298            if !alarm_is_normal(&alarm) {
299                parts.extend(alarm_tokens(&alarm, true));
300            }
301        }
302    }
303
304    parts.join(" ")
305}
306
307fn format_json_output(pv: &str, value: &DecodedValue, opts: &RenderOptions) -> String {
308    let (ts, units) = extract_ts_units(value);
309    let alarm = if opts.include_alarm {
310        extract_alarm(value).map(|a| format_alarm(&a))
311    } else {
312        None
313    };
314    let obj = json!({
315        "pv": pv,
316        "value": format_compact_value(value),
317        "timestamp": if opts.include_timestamp { ts } else { None },
318        "units": if opts.include_units { units } else { None },
319        "alarm": alarm,
320    });
321    obj.to_string()
322}
323
324fn format_table_output(pv: &str, value: &DecodedValue) -> Option<String> {
325    let fields = match value {
326        DecodedValue::Structure(fields) => fields,
327        _ => return None,
328    };
329
330    let value_fields = fields.iter().find_map(|(name, val)| {
331        if name == "value" {
332            if let DecodedValue::Structure(cols) = val {
333                return Some(cols);
334            }
335        }
336        None
337    })?;
338
339    let name_col = value_fields.iter().find_map(|(name, val)| {
340        if name == "name" {
341            return array_to_strings(val);
342        }
343        None
344    })?;
345
346    if name_col.is_empty() {
347        return None;
348    }
349
350    let mut columns: Vec<(String, Vec<String>)> = Vec::new();
351    for (name, val) in value_fields {
352        if name == "name" {
353            continue;
354        }
355        if let Some(col) = array_to_strings(val) {
356            columns.push((name.clone(), col));
357        }
358    }
359
360    if columns.is_empty() {
361        return None;
362    }
363
364    let row_count = name_col.len();
365    for (_, col) in &columns {
366        if col.len() < row_count {
367            return None;
368        }
369    }
370
371    let descriptor = fields
372        .iter()
373        .find_map(|(name, val)| {
374            if name == "descriptor" {
375                if let DecodedValue::String(s) = val {
376                    return Some(s.clone());
377                }
378            }
379            None
380        })
381        .or_else(|| {
382            fields.iter().find_map(|(name, val)| {
383                if name == "display" {
384                    if let DecodedValue::Structure(d_fields) = val {
385                        return d_fields.iter().find_map(|(n, v)| {
386                            if n == "description" {
387                                if let DecodedValue::String(s) = v {
388                                    return Some(s.clone());
389                                }
390                            }
391                            None
392                        });
393                    }
394                }
395                None
396            })
397        });
398
399    let labels = fields.iter().find_map(|(name, val)| {
400        if name == "labels" {
401            return array_to_strings(val);
402        }
403        None
404    });
405
406    let (ts, _units) = extract_ts_units(value);
407    let mut lines: Vec<String> = Vec::new();
408    let header = if let Some(ts) = ts {
409        format!("{} {}", pv, ts)
410    } else {
411        pv.to_string()
412    };
413    lines.push(header);
414    if let Some(desc) = descriptor {
415        if !desc.is_empty() {
416            lines.push(format!("     PV \"{}\"", desc));
417        }
418    }
419
420    let name_width = std::cmp::max(16, name_col.iter().map(|s| s.len()).max().unwrap_or(0));
421
422    let (name_label, col_labels): (String, Vec<String>) = match labels {
423        Some(l) if l.len() == columns.len() + 1 => (l[0].clone(), l[1..].to_vec()),
424        Some(l) if l.len() == columns.len() => ("PV".to_string(), l),
425        _ => (
426            "PV".to_string(),
427            columns.iter().map(|(n, _)| n.clone()).collect(),
428        ),
429    };
430
431    let mut header = format!("{:<width$}", name_label, width = name_width);
432    for (idx, _) in columns.iter().enumerate() {
433        header.push(' ');
434        if let Some(label) = col_labels.get(idx) {
435            header.push_str(label);
436        }
437    }
438    lines.push(header);
439    for idx in 0..row_count {
440        let mut line = format!("{:<width$}", name_col[idx], width = name_width);
441        for (_, col) in &columns {
442            line.push(' ');
443            line.push_str(&col[idx]);
444        }
445        lines.push(line);
446    }
447
448    Some(lines.join("\n"))
449}
450
451fn array_to_strings(val: &DecodedValue) -> Option<Vec<String>> {
452    match val {
453        DecodedValue::Array(items) => {
454            let mut out = Vec::with_capacity(items.len());
455            for item in items {
456                out.push(format_value(item));
457            }
458            Some(out)
459        }
460        _ => None,
461    }
462}
463
464#[cfg(test)]
465mod tests {
466    use super::*;
467    use spvirit_codec::spvd_decode::DecodedValue;
468
469    #[test]
470    fn test_extract_ts_units() {
471        let value = DecodedValue::Structure(vec![
472            (
473                "timeStamp".to_string(),
474                DecodedValue::Structure(vec![
475                    ("secondsPastEpoch".to_string(), DecodedValue::Int64(0)),
476                    ("nanoseconds".to_string(), DecodedValue::Int32(0)),
477                ]),
478            ),
479            (
480                "display".to_string(),
481                DecodedValue::Structure(vec![(
482                    "units".to_string(),
483                    DecodedValue::String("counts".to_string()),
484                )]),
485            ),
486        ]);
487
488        let (ts, units) = extract_ts_units(&value);
489        assert!(ts.is_some());
490        assert_eq!(units.as_deref(), Some("counts"));
491    }
492
493    #[test]
494    fn test_extract_alarm() {
495        let value = DecodedValue::Structure(vec![(
496            "alarm".to_string(),
497            DecodedValue::Structure(vec![
498                ("severity".to_string(), DecodedValue::Int32(2)),
499                ("status".to_string(), DecodedValue::Int32(7)),
500                (
501                    "message".to_string(),
502                    DecodedValue::String("HIHI".to_string()),
503                ),
504            ]),
505        )]);
506
507        let alarm = extract_alarm(&value).expect("alarm");
508        assert_eq!(alarm.severity, 2);
509        assert_eq!(alarm.status, 7);
510        assert_eq!(alarm.message, "HIHI");
511        assert!(format_alarm(&alarm).contains("MAJOR"));
512        assert!(format_alarm(&alarm).contains("STATE(7)"));
513    }
514
515    #[test]
516    fn test_status_label() {
517        assert_eq!(status_label(0), "NO_ALARM");
518        assert_eq!(status_label(3), "HIHI");
519        assert_eq!(status_label(99), "UNKNOWN");
520    }
521}