Skip to main content

canboat/
json_input.rs

1// (C) 2009-2026, Kees Verruijt, Harlingen, The Netherlands.
2
3//! Analyzer JSON records → wire frames: the driver behind `canboat
4//! convert --from json`, the gateway bridges' stdin TX, and the wasm
5//! bindings.
6//!
7//! The input is line-delimited analyzer JSON, one record per line, in
8//! either shape the analyzer emits: bare `-json` (unwrapped, fields
9//! keyed by human name) or `-camel` (wrapped `{"<pgnId>":{…}}`, fields
10//! keyed by camelCase id). The camel wrapper id names the exact PGN
11//! variant and is the recommended form — bare records resolve by PGN
12//! number alone and are rejected when the number is ambiguous (e.g. the
13//! 126208 group functions).
14//!
15//! The `-nv` flavour (`{"value":N,"name":"…"}` per lookup/measured
16//! field) is the lossless interchange form: the raw `value` is written
17//! back verbatim. Plain scalars are accepted for numeric and lookup
18//! fields, and strings for string / lookup-label / hex-binary fields;
19//! date/time/duration display strings are NOT parsed back — feed `-nv`
20//! JSON for those.
21//!
22//! Repeating sets arrive exactly as the analyzer emits them: `"list"`
23//! (set 1) and `"list2"` (set 2) arrays inside `fields`, one object per
24//! iteration.
25//!
26//! Bare physical values are read against the unit system the stream's
27//! `{"version":…,"units":…}` banner declares — degrees in a `"std"`
28//! stream, radians in an `"si"` one. Without a banner the caller's
29//! assumption stands (`--units`, SI by default). `-nv` raw values are
30//! unit-agnostic and unaffected.
31//!
32//! This lives in the `canboat` crate behind the `json-input` feature
33//! rather than in the engine because it is the one place a real
34//! JSON parser is warranted — nested repeating lists and `-nv` objects
35//! are beyond `analyzer_json`'s deliberate substring-scan minimalism —
36//! and `serde_json` has no business in the core's dependency closure.
37//! The `cli` feature folds it in, so the binary always has it.
38
39use std::io::BufRead;
40
41use crate::engine::source::FrameSource;
42use crate::engine::types::{FieldInfo, FieldType};
43use crate::engine::{BusProtocol, EncodeValue, PgnBuilder, PgnDatabase, RawFrame};
44use anyhow::{Context, Result, anyhow, bail};
45use serde_json::{Map, Value};
46
47/// Top-level keys of a bare (non-camel) analyzer record. A single-key
48/// object whose key is none of these is a `-camel` envelope and the key
49/// is the PGN variant id.
50const BARE_TOP_LEVEL: [&str; 7] = [
51    "timestamp",
52    "prio",
53    "src",
54    "dst",
55    "pgn",
56    "description",
57    "fields",
58];
59
60/// A [`FrameSource`] that reads analyzer-JSON lines from `src` and
61/// yields the re-encoded frames. Lines that fail to encode are skipped
62/// with a warning (mirroring the analyzer's tolerance of undecodable
63/// input).
64///
65/// The stream's `{"version":…,"units":…}` banner is not merely skipped:
66/// its `units` decides how bare physical values in the following
67/// records are read back. A `43.0` in a `"units":"std"` stream is
68/// degrees; the same number in an SI stream is radians, and encoding it
69/// against the wrong schema puts the wrong bits on the wire. The
70/// constructor's `db` is only the assumption for a bannerless stream —
71/// the banner always wins.
72///
73/// Its `protocol` is checked rather than adopted: the same PGN number
74/// means different things on NMEA 2000 and J1939, so a J1939 stream
75/// read against the NMEA 2000 table (or the other way round) would
76/// encode garbage. A mismatch is an error naming the `--protocol` to
77/// use. A banner without `protocol` (canboat C, older Rust) is NMEA
78/// 2000.
79pub struct JsonFrameReader<R> {
80    src: R,
81    db: &'static PgnDatabase,
82    buf: String,
83    line_no: usize,
84}
85
86impl<R: BufRead> JsonFrameReader<R> {
87    /// Read analyzer-JSON lines from `src`, encoding against `db` —
88    /// which supplies the unit system to assume until (and unless) the
89    /// stream declares its own in a banner.
90    pub fn new(src: R, db: &'static PgnDatabase) -> Self {
91        Self {
92            src,
93            db,
94            buf: String::with_capacity(512),
95            line_no: 0,
96        }
97    }
98}
99
100/// What an analyzer banner declares.
101#[derive(Debug, PartialEq, Eq)]
102struct Banner {
103    /// `"si"` (strict SI) or `"std"` (canboat's practical Metric);
104    /// `None` when the banner doesn't say.
105    units: Option<crate::engine::Units>,
106    /// The table the records were decoded against: `nmea2000` when the
107    /// banner predates the key.
108    protocol: std::result::Result<BusProtocol, String>,
109}
110
111/// Parse `line` as an analyzer banner; `None` when it isn't one.
112fn banner(line: &str) -> Option<Banner> {
113    if !line.starts_with("{\"version\"") {
114        return None;
115    }
116    let root: Value = serde_json::from_str(line).ok()?;
117    let root = root.as_object()?;
118    let units = match root.get("units").and_then(Value::as_str) {
119        Some("si") => Some(crate::engine::Units::Si),
120        Some("std") => Some(crate::engine::Units::Metric),
121        _ => None,
122    };
123    let protocol = match root.get("protocol").and_then(Value::as_str) {
124        None => Ok(BusProtocol::Nmea2000),
125        Some(p) => p.parse::<BusProtocol>().map_err(|_| p.to_string()),
126    };
127    Some(Banner { units, protocol })
128}
129
130impl<R: BufRead> FrameSource for JsonFrameReader<R> {
131    fn read_frame(&mut self) -> std::io::Result<Option<RawFrame>> {
132        loop {
133            self.buf.clear();
134            if self.src.read_line(&mut self.buf)? == 0 {
135                return Ok(None);
136            }
137            self.line_no += 1;
138            let line = self.buf.trim();
139            if line.is_empty() {
140                continue;
141            }
142            // Adopt the producer's declared unit system for the rest of
143            // the stream. The banner itself is not a record, so either
144            // way this line yields no frame.
145            if let Some(banner) = banner(line) {
146                let ours = self.db.protocol();
147                match banner.protocol {
148                    Ok(theirs) if theirs == ours => {}
149                    Ok(theirs) => {
150                        return Err(std::io::Error::new(
151                            std::io::ErrorKind::InvalidData,
152                            format!(
153                                "line {}: the input was decoded as {theirs}, but is read as \
154                                 {ours}; pass --protocol {theirs}",
155                                self.line_no
156                            ),
157                        ));
158                    }
159                    Err(theirs) => {
160                        return Err(std::io::Error::new(
161                            std::io::ErrorKind::InvalidData,
162                            format!(
163                                "line {}: the input was decoded as protocol '{theirs}', \
164                                 which this version cannot read",
165                                self.line_no
166                            ),
167                        ));
168                    }
169                }
170                if let Some(units) = banner.units
171                    && units != self.db.units()
172                {
173                    log::info!(
174                        "input declares {} units; reading values against that schema",
175                        if units == crate::engine::Units::Si {
176                            "SI (rad/K/Pa)"
177                        } else {
178                            "Metric (deg/°C/bar)"
179                        }
180                    );
181                    self.db = ours.database(units);
182                }
183                continue;
184            }
185            match frame_from_json(self.db, line) {
186                Ok(Some(frame)) => return Ok(Some(frame)),
187                Ok(None) => continue,
188                Err(e) => {
189                    log::warn!("line {}: {e:#}", self.line_no);
190                    continue;
191                }
192            }
193        }
194    }
195}
196
197/// Encode one analyzer-JSON line. `Ok(None)` for records that are not
198/// messages (the `{"version":…}` startup header).
199pub fn frame_from_json(db: &'static PgnDatabase, line: &str) -> Result<Option<RawFrame>> {
200    let root: Value = serde_json::from_str(line).context("parsing JSON")?;
201    let root = root
202        .as_object()
203        .ok_or_else(|| anyhow!("not a JSON object"))?;
204
205    // `-camel` wraps the record under the PGN variant id.
206    let (variant_id, record) = match root.iter().next() {
207        Some((key, Value::Object(inner)))
208            if root.len() == 1 && !BARE_TOP_LEVEL.contains(&key.as_str()) =>
209        {
210            (Some(key.as_str()), inner)
211        }
212        _ => (None, root),
213    };
214
215    let Some(pgn) = record.get("pgn").and_then(Value::as_u64) else {
216        if record.contains_key("version") {
217            return Ok(None); // analyzer startup header
218        }
219        bail!("record has no 'pgn'");
220    };
221    // Synthetic canboat-internal PGNs (gateway status et al.) never
222    // exist on the wire; skip them silently on the way back.
223    if pgn >= 0x40000 {
224        return Ok(None);
225    }
226
227    let mut builder = match variant_id {
228        Some(id) => db
229            .encode(id)
230            .or_else(|_| db.encode_by_pgn(pgn as u32))
231            .map_err(|e| anyhow!("{e}"))?,
232        None => {
233            // A bare record may still name its variant: `description`
234            // carries either the human description ("System Time") or a
235            // camel id — enough to disambiguate multi-variant PGNs
236            // (canboatjs-style objects always include it).
237            let by_desc = record
238                .get("description")
239                .and_then(Value::as_str)
240                .and_then(|d| {
241                    db.pgn_variants(pgn as u32)
242                        .find(|p| p.description == d || p.id == d)
243                });
244            let by_match = || variant_by_match_fields(db, pgn as u32, record.get("fields"));
245            match by_desc.or_else(by_match) {
246                Some(info) => db.encode_for(info),
247                None => db.encode_by_pgn(pgn as u32).map_err(|e| {
248                    anyhow!("{e}; use the -camel wrapped form to select the exact variant")
249                })?,
250            }
251        }
252    };
253
254    if let Some(p) = record.get("prio").and_then(Value::as_u64) {
255        builder = builder.priority(p as u8);
256    }
257    if let Some(s) = record.get("src").and_then(Value::as_u64) {
258        builder = builder.source(s as u8);
259    }
260    if let Some(d) = record.get("dst").and_then(Value::as_u64) {
261        builder = builder.destination(d as u8);
262    }
263    if let Some(ts) = record.get("timestamp").and_then(Value::as_str) {
264        builder = builder.timestamp(ts);
265    }
266
267    if let Some(Value::Object(fields)) = record.get("fields") {
268        stage_fields(db, &mut builder, fields)?;
269    }
270
271    builder.build().map(Some).map_err(|e| anyhow!("{e}"))
272}
273
274/// Stage every member of a `fields` object onto the builder —
275/// top-level fields directly, `"list"` / `"list2"` arrays as repeating
276/// set 1 / 2 instances.
277fn stage_fields(
278    db: &'static PgnDatabase,
279    builder: &mut PgnBuilder,
280    fields: &Map<String, Value>,
281) -> Result<()> {
282    for (key, value) in fields {
283        let set = match key.as_str() {
284            "list" => Some(1),
285            "list2" => Some(2),
286            _ => None,
287        };
288        if let Some(set) = set {
289            let Value::Array(instances) = value else {
290                bail!("'{key}' is not an array");
291            };
292            for inst in instances {
293                let Value::Object(inst_fields) = inst else {
294                    bail!("'{key}' entry is not an object");
295                };
296                let idx = builder.add_set_instance(set).map_err(|e| anyhow!("{e}"))?;
297                for (fkey, fval) in inst_fields {
298                    let Some(info) = find_field(builder, fkey) else {
299                        log::warn!("{key}[{idx}]: unknown field '{fkey}' ignored");
300                        continue;
301                    };
302                    if let Some(ev) = to_encode_value(db, info, fval)
303                        .with_context(|| format!("{key}[{idx}].{fkey}"))?
304                    {
305                        builder
306                            .push_in_set(set, idx, info.name, ev)
307                            .map_err(|e| anyhow!("{key}[{idx}].{fkey}: {e}"))?;
308                    }
309                }
310            }
311            continue;
312        }
313        let Some(info) = find_field(builder, key) else {
314            log::warn!("unknown field '{key}' ignored");
315            continue;
316        };
317        if let Some(ev) = to_encode_value(db, info, value).with_context(|| key.clone())? {
318            // `push_by_name` matches schema names; `info` was resolved
319            // from either the name or the camel id, so push by its name.
320            builder
321                .push_by_name(info.name, ev)
322                .map_err(|e| anyhow!("{key}: {e}"))?;
323        }
324    }
325    // A record carrying neither the count field nor its list encodes
326    // a count of zero — the builder's auto-fill. canboat#812 settled
327    // this: zero over the unavailable sentinel (canboatjs writes
328    // unavailable; that difference is canboatjs-side now).
329    Ok(())
330}
331
332fn find_field(builder: &PgnBuilder, key: &str) -> Option<&'static FieldInfo> {
333    builder
334        .pgn_info()
335        .fields
336        .iter()
337        .find(|f| f.id == key || f.name == key)
338}
339
340const LOOKUP_TYPES: [FieldType; 4] = [
341    FieldType::Lookup,
342    FieldType::IndirectLookup,
343    FieldType::BitLookup,
344    FieldType::DynamicFieldKey,
345];
346
347fn is_lookup(f: &FieldInfo) -> bool {
348    f.field_type.is_some_and(|t| LOOKUP_TYPES.contains(&t))
349}
350
351/// Translate one JSON field value into an [`EncodeValue`], or `None` to
352/// leave the field at its default. The `-nv` object form carries the
353/// raw wire value and is written back verbatim — the lossless path.
354fn to_encode_value(
355    db: &'static PgnDatabase,
356    f: &'static FieldInfo,
357    v: &Value,
358) -> Result<Option<EncodeValue>> {
359    Ok(match v {
360        Value::Null => None,
361        // `-nv` form: {"value":N,"name":"…"} — take the raw value.
362        // Some field types render their value as a string even in `-nv`
363        // (MMSIs, hex binaries); those route through the same per-type
364        // string handling as bare string values.
365        // A TIME / DURATION's `name` is its clock form, which reads the
366        // same whatever `value` held: seconds now, the wire count in
367        // captures from before canboat gave seconds.
368        Value::Object(o)
369            if matches!(
370                f.field_type,
371                Some(FieldType::Time) | Some(FieldType::Duration)
372            ) && let Some(seconds) = o
373                .get("name")
374                .and_then(Value::as_str)
375                .and_then(crate::engine::output::parse_time) =>
376        {
377            Some(EncodeValue::Number(seconds))
378        }
379        Value::Object(o) => match o.get("value") {
380            Some(Value::Number(n)) => Some(raw_number(f, n)?),
381            Some(Value::String(s)) => Some(string_value(f, s)?),
382            _ => None,
383        },
384        Value::Number(n) => {
385            if is_lookup(f) {
386                // A bare number on a lookup field is the raw enum value.
387                Some(EncodeValue::Int(n.as_i64().ok_or_else(|| {
388                    anyhow!("lookup value {n} is not an integer")
389                })?))
390            } else if matches!(f.field_type, Some(FieldType::Binary)) {
391                // canboatjs renders short BINARY fields as numbers (the
392                // raw bits); rebuild the little-endian bytes.
393                let raw = n
394                    .as_u64()
395                    .ok_or_else(|| anyhow!("binary value {n} is not an integer"))?;
396                let width = f.bit_length.map(|bl| bl.div_ceil(8) as usize).unwrap_or(8);
397                Some(EncodeValue::Bytes(
398                    raw.to_le_bytes()[..width.min(8)].to_vec(),
399                ))
400            } else {
401                // A physical value in the field's display unit.
402                Some(EncodeValue::Number(
403                    n.as_f64().ok_or_else(|| anyhow!("bad number {n}"))?,
404                ))
405            }
406        }
407        Value::String(s) => Some(string_value(f, s)?),
408        // A bit lookup: the set bits, as `-nv` objects (mask values) or
409        // bit names. OR them into one raw value.
410        Value::Array(entries) => {
411            let mut raw: u64 = 0;
412            for e in entries {
413                match e {
414                    Value::Object(o) => {
415                        let mask = o
416                            .get("value")
417                            .and_then(Value::as_u64)
418                            .ok_or_else(|| anyhow!("bit entry without numeric 'value'"))?;
419                        raw |= mask;
420                    }
421                    Value::String(name) => {
422                        raw |= 1u64 << bit_by_name(db, f, name)?;
423                    }
424                    Value::Number(n) => {
425                        raw |= n.as_u64().ok_or_else(|| anyhow!("bad bit mask {n}"))?;
426                    }
427                    other => bail!("unsupported bit entry {other}"),
428                }
429            }
430            Some(EncodeValue::Int(raw as i64))
431        }
432        Value::Bool(_) => bail!("boolean values are not part of analyzer JSON"),
433    })
434}
435
436/// The `-nv` raw value: written back verbatim as field bits. Fractional
437/// raws do not exist on the wire, so a float here means the input was
438/// not `-nv` after all — treat it as a physical value. That includes a
439/// FLOAT under `-debug`, which wraps every field and gives a FLOAT's
440/// value in the schema's unit (156.27 deg for 2.7274 rad in Metric), so
441/// the encoder must take its resolution off, not write it as bits —
442/// even when that value happens to be whole (180 deg). `-nv` writes a
443/// FLOAT bare, so an object-form FLOAT is never a raw value.
444fn raw_number(f: &FieldInfo, n: &serde_json::Number) -> Result<EncodeValue> {
445    // A FLOAT, TIME or DURATION is never the wire integer, so even a
446    // whole number is scaled: a FLOAT is in the schema's unit, a TIME or
447    // DURATION is seconds. Nor is a group-function VARIABLE: its value is
448    // the target field's, written like that field's own.
449    let physical = matches!(
450        f.field_type,
451        Some(FieldType::Float)
452            | Some(FieldType::Time)
453            | Some(FieldType::Duration)
454            | Some(FieldType::Variable)
455    );
456    // The wire bits: a signed field's negative raw two's-complements back
457    // into its width, and a 64-bit one (an ISO NAME) can exceed i64.
458    let raw = n.as_u64().or_else(|| n.as_i64().map(|i| i as u64));
459    if let Some(raw) = raw.filter(|_| !physical) {
460        Ok(EncodeValue::Raw(raw))
461    } else if let Some(i) = n.as_i64() {
462        Ok(EncodeValue::Int(i)) // the same value as a Number, kept exact
463    } else {
464        Ok(EncodeValue::Number(n.as_f64().ok_or_else(|| {
465            anyhow!("field '{}': bad number {n}", f.name)
466        })?))
467    }
468}
469
470fn string_value(f: &'static FieldInfo, s: &str) -> Result<EncodeValue> {
471    match f.field_type {
472        Some(FieldType::StringFix) | Some(FieldType::StringLz) | Some(FieldType::StringLau) => {
473            Ok(EncodeValue::Text(s.to_string()))
474        }
475        Some(FieldType::Binary) | Some(FieldType::DynamicFieldValue) => {
476            Ok(EncodeValue::Bytes(parse_hex(s)?))
477        }
478        // A group-function VARIABLE value: its real type lives in the
479        // referenced PGN's field and is resolved at build time — pass
480        // the text through, the builder retypes labels for lookups.
481        Some(FieldType::Variable) => Ok(EncodeValue::Text(s.to_string())),
482        // The display renderings parse back: "2017.04.15" → days since
483        // epoch, "14:57:57(.1234)" → seconds. Both are the physical
484        // value in the field's unit (d / s), so Number staging scales
485        // them through the schema resolution.
486        Some(FieldType::Date) => parse_date_days(s)
487            .map(EncodeValue::Number)
488            .ok_or_else(|| anyhow!("field '{}': bad date '{s}'", f.name)),
489        Some(FieldType::Time) | Some(FieldType::Duration) => crate::engine::output::parse_time(s)
490            .map(EncodeValue::Number)
491            .ok_or_else(|| anyhow!("field '{}': bad time '{s}'", f.name)),
492        // A digit string on a lookup is the raw value beyond the
493        // enumeration (canboatjs renders unknown entries that way);
494        // anything else is a label.
495        _ if is_lookup(f) => Ok(match s.parse::<i64>() {
496            Ok(raw) => EncodeValue::Int(raw),
497            Err(_) => EncodeValue::Lookup(s.to_string()),
498        }),
499        // Digit strings on numeric fields (e.g. an MMSI rendered as a
500        // string) parse back.
501        _ => s.parse::<f64>().map(EncodeValue::Number).map_err(|_| {
502            anyhow!(
503                "field '{}': cannot re-encode display string '{s}'; use -nv JSON input",
504                f.name
505            )
506        }),
507    }
508}
509
510/// `"YYYY.MM.DD"` (the analyzer/canboatjs date rendering) → days since
511/// the epoch, the DATE field's physical value.
512fn parse_date_days(s: &str) -> Option<f64> {
513    let mut it = s.split('.');
514    let (y, m, d) = (
515        it.next()?.parse::<i32>().ok()?,
516        it.next()?.parse::<u32>().ok()?,
517        it.next()?.parse::<u32>().ok()?,
518    );
519    if it.next().is_some() {
520        return None;
521    }
522    let date = chrono::NaiveDate::from_ymd_opt(y, m, d)?;
523    let epoch = chrono::NaiveDate::from_ymd_opt(1970, 1, 1)?;
524    Some((date - epoch).num_days() as f64)
525}
526
527/// Select a PGN variant from a bare record's field values, the way
528/// canboatjs's encoder does: a variant qualifies when every one of its
529/// match-valued fields that the record provides agrees with the
530/// declared match value; among qualifiers the one agreeing on the most
531/// provided fields wins (a strict winner — a tie stays ambiguous).
532/// This is what resolves e.g. a 126208 with `"Function Code":"Command"`
533/// to the command variant without a camel wrapper or description.
534fn variant_by_match_fields(
535    db: &'static PgnDatabase,
536    pgn: u32,
537    fields: Option<&Value>,
538) -> Option<&'static crate::engine::types::PgnInfo> {
539    let fields = fields?.as_object()?;
540    let mut best: Option<(usize, &'static crate::engine::types::PgnInfo)> = None;
541    let mut tied = false;
542    'variants: for info in db.pgn_variants(pgn) {
543        let mut agreed = 0usize;
544        for f in info.fields {
545            let Some(mv) = f.match_value else { continue };
546            let Some(v) = fields.get(f.id).or_else(|| fields.get(f.name)) else {
547                continue;
548            };
549            match match_field_agrees(db, f, v, mv) {
550                Some(true) => agreed += 1,
551                Some(false) => continue 'variants,
552                None => continue,
553            }
554        }
555        if agreed == 0 {
556            continue;
557        }
558        match &best {
559            Some((n, _)) if *n == agreed => tied = true,
560            Some((n, _)) if *n > agreed => {}
561            _ => {
562                best = Some((agreed, info));
563                tied = false;
564            }
565        }
566    }
567    if tied { None } else { best.map(|(_, i)| i) }
568}
569
570/// Does a provided JSON value agree with a field's declared match
571/// value? `None` when the value cannot be interpreted for comparison.
572fn match_field_agrees(
573    db: &'static PgnDatabase,
574    f: &'static FieldInfo,
575    v: &Value,
576    mv: i64,
577) -> Option<bool> {
578    match v {
579        Value::Number(n) => Some(n.as_i64()? == mv),
580        Value::String(s) => {
581            let table = f.lookup_enumeration.and_then(|t| db.lookup(t))?;
582            let val = table
583                .values
584                .iter()
585                .find(|lv| lv.name == *s || lv.id == Some(s.as_str()))?
586                .value;
587            Some(val as i64 == mv)
588        }
589        Value::Object(o) => o.get("value").and_then(Value::as_i64).map(|x| x == mv),
590        _ => None,
591    }
592}
593
594/// Resolve a bit name (`"Low Oil Pressure"`) to its bit position via
595/// the field's BITLOOKUP table.
596fn bit_by_name(db: &'static PgnDatabase, f: &'static FieldInfo, name: &str) -> Result<u8> {
597    let table = f
598        .lookup_bit_enumeration
599        .and_then(|t| db.bit_lookup(t))
600        .ok_or_else(|| anyhow!("field '{}' has no bit lookup table", f.name))?;
601    table
602        .values
603        .iter()
604        .find(|bv| bv.name == name || bv.id == Some(name))
605        .map(|bv| bv.bit)
606        .ok_or_else(|| anyhow!("field '{}': unknown bit '{name}'", f.name))
607}
608
609/// Parse analyzer hex-byte strings: `"d1 08 00"`, `"D108"`, with or
610/// without spaces.
611fn parse_hex(s: &str) -> Result<Vec<u8>> {
612    let compact: String = s.chars().filter(|c| !c.is_whitespace()).collect();
613    if !compact.len().is_multiple_of(2) || compact.is_empty() {
614        bail!("bad hex string '{s}'");
615    }
616    (0..compact.len())
617        .step_by(2)
618        .map(|i| u8::from_str_radix(&compact[i..i + 2], 16).map_err(|e| anyhow!("{e}")))
619        .collect()
620}
621
622#[cfg(test)]
623mod tests {
624    use super::*;
625    use crate::engine::Units;
626
627    fn db() -> &'static PgnDatabase {
628        PgnDatabase::embedded(Units::Metric)
629    }
630
631    #[test]
632    fn si_energy_and_charge_encode_from_joules_and_coulombs() {
633        // The database keeps kWh and Ah; the SI schema reads J and C
634        // back, so both unit systems put the same bits on the wire.
635        let si = PgnDatabase::embedded(Units::Si);
636        for (si_line, metric_line) in [
637            (
638                r#"{"pgn":65005,"fields":{"totalEnergyExport":14806800000,"totalEnergyImport":1080000000}}"#,
639                r#"{"pgn":65005,"fields":{"totalEnergyExport":4113,"totalEnergyImport":300}}"#,
640            ),
641            (
642                r#"{"pgn":127506,"fields":{"instance":1,"remainingCapacity":360000}}"#,
643                r#"{"pgn":127506,"fields":{"instance":1,"remainingCapacity":100}}"#,
644            ),
645        ] {
646            let from_si = frame_from_json(si, si_line).unwrap().unwrap();
647            let from_metric = frame_from_json(db(), metric_line).unwrap().unwrap();
648            assert_eq!(from_si.data, from_metric.data, "{si_line}");
649        }
650        let f = frame_from_json(
651            si,
652            r#"{"pgn":65005,"fields":{"totalEnergyExport":14806800000}}"#,
653        )
654        .unwrap()
655        .unwrap();
656        assert_eq!(f.data[..4], [0x11, 0x10, 0x00, 0x00]);
657    }
658
659    #[test]
660    fn si_percentages_encode_from_ratios() {
661        // The database keeps %; the SI schema reads a ratio, signed ones
662        // included, so both unit systems put the same bits on the wire.
663        let si = PgnDatabase::embedded(Units::Si);
664        for (si_line, metric_line) in [
665            (
666                r#"{"pgn":127505,"fields":{"instance":0,"type":"Fuel","level":0.97536}}"#,
667                r#"{"pgn":127505,"fields":{"instance":0,"type":"Fuel","level":97.536}}"#,
668            ),
669            (
670                r#"{"pgn":130576,"fields":{"portTrimTab":-0.5,"starboardTrimTab":0.25}}"#,
671                r#"{"pgn":130576,"fields":{"portTrimTab":-50,"starboardTrimTab":25}}"#,
672            ),
673        ] {
674            let from_si = frame_from_json(si, si_line).unwrap().unwrap();
675            let from_metric = frame_from_json(db(), metric_line).unwrap().unwrap();
676            assert_eq!(from_si.data, from_metric.data, "{si_line}");
677        }
678        let f = frame_from_json(
679            si,
680            r#"{"pgn":130576,"fields":{"portTrimTab":-0.5,"starboardTrimTab":0.25}}"#,
681        )
682        .unwrap()
683        .unwrap();
684        assert_eq!(f.data[..2], [0xce, 0x19]);
685    }
686
687    #[test]
688    fn time_encodes_from_seconds_or_any_clock_form() {
689        // 126992 System Time 09:10:20.2240 = 33020.2240 s = raw 330202240
690        // (0.0001 s). Seconds, bare or -nv, are what canboat writes now;
691        // the clock string, a number of seconds as text, and an older
692        // -nv object whose value was the raw count (its name still the
693        // clock) are all read too.
694        let want = [0x80u8, 0x7c, 0xae, 0x13];
695        for time in [
696            "33020.224",
697            r#"{"value":33020.2240,"name":"09:10:20.2240"}"#,
698            r#""09:10:20.2240""#,
699            r#""33020.224""#,
700            r#"{"value":330202240,"name":"09:10:20.2240"}"#,
701        ] {
702            let line = format!(
703                r#"{{"pgn":126992,"fields":{{"source":"GPS","date":"2024.07.30","time":{time}}}}}"#
704            );
705            let frame = frame_from_json(db(), &line).unwrap().unwrap();
706            assert_eq!(frame.data[frame.data.len() - 4..], want, "{time}");
707        }
708    }
709
710    #[test]
711    fn negative_duration_clock_encodes_negative() {
712        // "-00:05:00.000" is minus five minutes, not minus zero hours
713        // plus five minutes.
714        assert_eq!(
715            crate::engine::output::parse_time("-00:05:00.000"),
716            Some(-300.0)
717        );
718        assert_eq!(crate::engine::output::parse_time("-300"), Some(-300.0));
719        assert_eq!(crate::engine::output::parse_time("01:10:10"), Some(4210.0));
720        assert_eq!(crate::engine::output::parse_time("later"), None);
721        assert_eq!(crate::engine::output::parse_time("--00:05:00"), None);
722        assert_eq!(crate::engine::output::parse_time("00:-05:00"), None);
723        assert_eq!(crate::engine::output::parse_time("inf:00"), None);
724        assert_eq!(crate::engine::output::parse_time("1e308:00"), None);
725    }
726
727    #[test]
728    fn si_volumes_rotations_and_angles_encode_from_si_values() {
729        // The database keeps rpm, L, L/h and degree offsets; the SI schema
730        // reads Hz, m3, m3/s and rad, so both unit systems put the same
731        // bits on the wire.
732        let si = PgnDatabase::embedded(Units::Si);
733        for (si_line, metric_line) in [
734            (
735                r#"{"pgn":127488,"fields":{"instance":0,"speed":30.0}}"#,
736                r#"{"pgn":127488,"fields":{"instance":0,"speed":1800.0}}"#,
737            ),
738            (
739                r#"{"pgn":127497,"fields":{"instance":0,"tripFuelUsed":0.045,"fuelRateAverage":0.0000029166666666666666}}"#,
740                r#"{"pgn":127497,"fields":{"instance":0,"tripFuelUsed":45.0,"fuelRateAverage":10.5}}"#,
741            ),
742            (
743                r#"{"pgn":130818,"fields":{"manufacturerCode":"Furuno","industryCode":"Marine Industry","headingOffset":0.21816615649929116,"pitchOffset":-0.04363323129985824}}"#,
744                r#"{"pgn":130818,"fields":{"manufacturerCode":"Furuno","industryCode":"Marine Industry","headingOffset":12.5,"pitchOffset":-2.5}}"#,
745            ),
746        ] {
747            let from_si = frame_from_json(si, si_line).unwrap().unwrap();
748            let from_metric = frame_from_json(db(), metric_line).unwrap().unwrap();
749            assert_eq!(from_si.data, from_metric.data, "{si_line}");
750        }
751    }
752
753    #[test]
754    fn debug_float_encodes_from_its_presented_value() {
755        // -debug wraps a FLOAT as {"value":…} in the schema's unit: deg in
756        // Metric, rad in SI. Both put Heading to Steer's own bits back.
757        let si = PgnDatabase::embedded(Units::Si);
758        for (db, value) in [(db(), "156.26844443585534"), (si, "2.7273988723754883")] {
759            let line = format!(
760                r#"{{"pgn":126720,"fields":{{"manufacturerCode":"Garmin","industryCode":"Marine Industry","subProtocolId":"Autopilot transport","field":11,"headingToSteer":{{"value":{value},"bytes":"B4 8D 2E 40"}}}}}}"#
761            );
762            let frame = frame_from_json(db, &line).unwrap().unwrap();
763            assert_eq!(
764                frame.data[frame.data.len() - 4..],
765                [0xb4, 0x8d, 0x2e, 0x40],
766                "{value}"
767            );
768        }
769    }
770
771    #[test]
772    fn debug_float_with_a_whole_value_is_still_scaled() {
773        // 180 deg in Metric is π rad on the wire, not 180.0f32.
774        let line = r#"{"pgn":126720,"fields":{"manufacturerCode":"Garmin","industryCode":"Marine Industry","subProtocolId":"Autopilot transport","field":11,"headingToSteer":{"value":180}}}"#;
775        let frame = frame_from_json(db(), line).unwrap().unwrap();
776        let wire = f32::from_le_bytes(frame.data[frame.data.len() - 4..].try_into().unwrap());
777        assert!((wire - std::f32::consts::PI).abs() < 1e-5, "{wire}");
778    }
779
780    #[test]
781    fn camel_envelope_encodes_byte_exact() {
782        // Integer-only PGN → wire-exact round trip. The envelope id
783        // selects the variant; -nv objects carry raw values verbatim.
784        let line = r#"{"isoAddressClaim":{"prio":6,"src":3,"dst":255,"pgn":60928,
785            "fields":{"uniqueNumber":1631699,"manufacturerCode":{"value":1580},
786            "deviceInstanceLower":0,"deviceInstanceUpper":0,
787            "deviceFunction":{"value":130,"name":"PC Gateway"},
788            "deviceClass":{"value":25,"name":"Internetwork device"},
789            "systemInstance":0,"industryGroup":{"value":4,"name":"Marine Industry"},
790            "arbitraryAddressCapable":{"value":1,"name":"Yes"}}}}"#
791            .replace('\n', " ");
792        let frame = frame_from_json(db(), &line).unwrap().unwrap();
793        assert_eq!(frame.pgn, 60928);
794        assert_eq!(frame.src, 3);
795        assert_eq!(
796            frame.data.as_slice(),
797            &[0xd3, 0xe5, 0x98, 0xc5, 0x00, 0x82, 0x32, 0xc0]
798        );
799    }
800
801    #[test]
802    fn repeating_list_encodes_instances() {
803        let line = r#"{"gnssSatsInView":{"pgn":129540,"src":3,"dst":255,"prio":6,
804            "fields":{"sid":9,"list":[
805              {"prn":7,"snr":30.0},
806              {"prn":11,"snr":42.5}]}}}"#
807            .replace('\n', " ");
808        let frame = frame_from_json(db(), &line).unwrap().unwrap();
809        let decoded = db().decode(&frame).unwrap();
810        assert!(decoded.has_repeating_set[0]);
811        let prns: Vec<i64> = decoded
812            .fields
813            .iter()
814            .filter(|f| f.info.id == "prn" && f.repeat_set == 1)
815            .map(|f| match &f.value {
816                crate::engine::FieldValue::Integer(n) => *n,
817                crate::engine::FieldValue::Number(x) => *x as i64,
818                other => panic!("prn: {other:?}"),
819            })
820            .collect();
821        assert_eq!(prns, vec![7, 11]);
822    }
823
824    #[test]
825    fn startup_header_and_synthetic_pgns_are_skipped() {
826        assert!(
827            frame_from_json(db(), r#"{"version":"8.0.0","units":"si"}"#)
828                .unwrap()
829                .is_none()
830        );
831        assert!(
832            frame_from_json(db(), r#"{"canboatStartup":{"pgn":262656,"fields":{}}}"#)
833                .unwrap()
834                .is_none()
835        );
836    }
837
838    #[test]
839    fn sub_byte_binary_hex_round_trips() {
840        // AIS Communication State is a 19-bit BINARY rendered as three
841        // hex bytes; it must stage as masked bits, not whole bytes.
842        let line = r#"{"positionReportClassB":{"pgn":129039,"src":43,"dst":255,"prio":2,
843            "fields":{"userId":{"value":"338654321"},"communicationState":"2A 4C 01"}}}"#
844            .replace('\n', " ");
845        let frame = frame_from_json(db(), &line).unwrap().unwrap();
846        let decoded = db().decode(&frame).unwrap();
847        match &decoded.field_by_name("Communication State").unwrap().value {
848            crate::engine::FieldValue::Binary(b) => {
849                assert_eq!(&b[..], &[0x2a, 0x4c, 0x01]);
850            }
851            other => panic!("communicationState: {other:?}"),
852        }
853    }
854
855    #[test]
856    fn bare_record_selects_variant_by_match_fields() {
857        // The exact shape signalk-server's device manager emits for an
858        // NMEA instance change: bare (no wrapper, no description), the
859        // variant named only by the Function Code match field. Must
860        // encode byte-identical to canboatjs.
861        let line = r#"{"pgn":126208,"prio":3,"dst":42,"fields":{
862            "Function Code":"Command","PGN":60928,"priority":8,
863            "numberOfParameters":2,
864            "list":[{"parameter":3,"value":5},{"parameter":4,"value":0}]}}"#
865            .replace('\n', " ");
866        let frame = frame_from_json(db(), &line).unwrap().unwrap();
867        assert_eq!(frame.pgn, 126208);
868        assert_eq!(frame.dst, 42);
869        assert_eq!(
870            frame.data.as_slice(),
871            &[0x01, 0x00, 0xee, 0x00, 0xf8, 0x02, 0x03, 0x05, 0x04, 0x00]
872        );
873    }
874
875    #[test]
876    fn request_names_a_126720_variant_behind_the_catch_all() {
877        // An Airmar DST console reading the sensor's Simulate Mode:
878        // a Request for 126720 naming Manufacturer = Airmar, Industry =
879        // Marine and Proprietary ID 35. 126720's catch-all is listed
880        // first; like the C analyzer, the parameters are matched against
881        // the manufacturer variants behind it, so parameter 4 takes
882        // airmarSimulateMode's 8-bit Proprietary ID instead of the
883        // catch-all's raw Data. Byte-identical to canboatjs.
884        let line = r#"{"pgn":126208,"prio":3,"dst":35,"fields":{
885            "functionCode":"Request","pgn":126720,"numberOfParameters":3,
886            "list":[{"parameter":1,"value":135},{"parameter":3,"value":4},
887                    {"parameter":4,"value":35}]}}"#
888            .replace('\n', " ");
889        let frame = frame_from_json(db(), &line).unwrap().unwrap();
890        assert_eq!(
891            frame.data.as_slice(),
892            &[
893                0x00, 0x00, 0xef, 0x01, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x03, 0x01, 0x87, 0x00,
894                0x03, 0x04, 0x04, 0x23
895            ]
896        );
897
898        // The decoder resolves the same variant from the wire bytes.
899        let decoded = db().decode(&frame).unwrap();
900        let id = decoded
901            .fields
902            .iter()
903            .rfind(|f| f.repeat_set == 1)
904            .expect("parameter 4's value");
905        assert!(
906            matches!(
907                id.value,
908                crate::engine::FieldValue::Lookup { value: 35, .. }
909            ),
910            "got {:?}",
911            id.value
912        );
913    }
914
915    #[test]
916    fn variable_time_target_encodes_from_seconds_or_clock() {
917        // A 126208 Command setting 126992 System Time's Time (field 5)
918        // through a VARIABLE: seconds, whole or not, bare or -nv, and the
919        // clock string all reach the same wire value (0.0001 s units).
920        let encode = |value: &str| {
921            let line = format!(
922                r#"{{"pgn":126208,"prio":3,"dst":42,"fields":{{"Function Code":"Command","PGN":126992,"priority":8,"numberOfParameters":1,"list":[{{"parameter":5,"value":{value}}}]}}}}"#
923            );
924            frame_from_json(db(), &line).unwrap().unwrap().data.to_vec()
925        };
926        let want = encode("33020.5");
927        for value in [
928            r#""09:10:20.5""#,
929            r#"{"value":33020.5,"name":"09:10:20.5000"}"#,
930        ] {
931            assert_eq!(encode(value), want, "{value}");
932        }
933        // A whole number of seconds is seconds, not the wire count.
934        assert_eq!(encode("600"), encode("600.0"));
935        assert_eq!(
936            encode(r#"{"value":600,"name":"00:10:00"}"#),
937            encode("600.0")
938        );
939    }
940
941    #[test]
942    fn ambiguous_bare_pgn_is_a_clear_error() {
943        let err = frame_from_json(db(), r#"{"pgn":126208,"fields":{}}"#).unwrap_err();
944        assert!(err.to_string().contains("-camel"), "got: {err:#}");
945    }
946
947    #[test]
948    fn banner_reads_the_producers_declaration() {
949        let si = r#"{"version":"8.0.0","commit":"abc","units":"si","showLookupValues":true}"#;
950        let std = r#"{"version":"7.1.0","units":"std","showLookupValues":true}"#;
951        let j1939 =
952            r#"{"version":"8.4.0","units":"si","protocol":"j1939","showLookupValues":true}"#;
953        assert_eq!(banner(si).unwrap().units, Some(Units::Si));
954        assert_eq!(banner(std).unwrap().units, Some(Units::Metric));
955        // A banner from before the key, canboat C's included, is NMEA 2000.
956        assert_eq!(banner(si).unwrap().protocol, Ok(BusProtocol::Nmea2000));
957        assert_eq!(banner(j1939).unwrap().protocol, Ok(BusProtocol::J1939));
958        assert_eq!(
959            banner(r#"{"version":"8.4.0","protocol":"quick"}"#)
960                .unwrap()
961                .protocol,
962            Ok(BusProtocol::Quick)
963        );
964        assert_eq!(
965            banner(r#"{"version":"9.0.0","protocol":"canopen"}"#)
966                .unwrap()
967                .protocol,
968            Err("canopen".to_string())
969        );
970        // Not a banner, or a banner that doesn't say — the caller's
971        // assumption stands rather than being silently overridden.
972        assert_eq!(banner(r#"{"version":"7.1.0"}"#).unwrap().units, None);
973        assert_eq!(banner(r#"{"pgn":127250,"fields":{}}"#), None);
974    }
975
976    /// A banner's units are adopted, but in the reader's own protocol: a
977    /// J1939 reader stays J1939 when the banner switches it to Metric.
978    #[test]
979    fn banner_units_keep_the_protocol() {
980        let input = "{\"version\":\"8.4.0\",\"units\":\"std\",\"protocol\":\"j1939\",\"showLookupValues\":true}\n";
981        let mut reader =
982            JsonFrameReader::new(input.as_bytes(), BusProtocol::J1939.database(Units::Si));
983        assert!(reader.read_frame().unwrap().is_none());
984        assert!(reader.db.is_j1939());
985        assert_eq!(reader.db.units(), Units::Metric);
986    }
987
988    /// A J1939 stream read against the NMEA 2000 table is refused,
989    /// naming the option that fixes it.
990    #[test]
991    fn a_protocol_mismatch_is_an_error() {
992        let input = "{\"version\":\"8.4.0\",\"units\":\"si\",\"protocol\":\"j1939\",\"showLookupValues\":true}\n";
993        let mut reader = JsonFrameReader::new(input.as_bytes(), db());
994        let err = reader.read_frame().unwrap_err();
995        assert!(err.to_string().contains("--protocol j1939"), "{err}");
996    }
997
998    /// The whole point of tracking the banner: the same decimal means
999    /// different bits depending on the stream's units.
1000    #[test]
1001    fn banner_units_decide_how_bare_values_encode() {
1002        let heading = |units, value: f64| {
1003            frame_from_json(
1004                PgnDatabase::embedded(units),
1005                &format!(
1006                    r#"{{"pgn":127250,"src":27,"description":"Vessel Heading","fields":{{"heading":{value}}}}}"#
1007                ),
1008            )
1009        };
1010        let deg = heading(Units::Metric, 3.5).unwrap().unwrap();
1011        let rad = heading(Units::Si, 3.5).unwrap().unwrap();
1012        assert_ne!(
1013            deg.data.as_slice(),
1014            rad.data.as_slice(),
1015            "3.5 deg and 3.5 rad must not encode alike"
1016        );
1017        // 3.5 deg = 0.0610865 rad, i.e. 611 in 0.0001 rad units.
1018        assert_eq!(&deg.data[1..3], &[0x63, 0x02]);
1019        // 210.9 deg is a heading; 210.9 rad is outside the field's range
1020        // and is refused rather than wrapped (#970).
1021        assert_eq!(
1022            &heading(Units::Metric, 210.9).unwrap().unwrap().data[1..3],
1023            &[0xc9, 0x8f]
1024        );
1025        assert!(heading(Units::Si, 210.9).is_err());
1026    }
1027}