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 let now = Utc::now().timestamp();
102 let epics_unix = secs + 631_152_000; 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}