Skip to main content

sidereon_core/nmea/
sentence.rs

1use crate::format::{Diagnostics, Parsed, RecordRef, Warning, WarningKind};
2use crate::validate::{self, FieldError};
3use crate::GnssSystem;
4
5use super::{
6    Gga, GgaQuality, Gll, Gsa, GsaFixMode, GsaSelectionMode, Gst, Gsv, GsvSatellite,
7    NmeaCoordinate, NmeaDate, NmeaError, NmeaSatNumber, NmeaSignalId, NmeaTalker, NmeaTime, Rmc,
8    RmcStatus, Vtg, Zda,
9};
10
11#[derive(Debug, Clone, PartialEq)]
12pub struct NmeaSentence {
13    pub talker: NmeaTalker,
14    pub body: NmeaBody,
15}
16
17#[derive(Debug, Clone, PartialEq)]
18pub enum NmeaBody {
19    Gga(Gga),
20    Rmc(Rmc),
21    Gsa(Gsa),
22    Gsv(Gsv),
23    Gst(Gst),
24    Vtg(Vtg),
25    Gll(Gll),
26    Zda(Zda),
27}
28
29pub(crate) struct FramedSentence<'a> {
30    pub delimiter: u8,
31    pub body: &'a str,
32    pub diagnostics: Diagnostics,
33}
34
35pub(crate) fn checksum_body(body: &str) -> u8 {
36    body.bytes().fold(0, |acc, byte| acc ^ byte)
37}
38
39pub(crate) fn frame_sentence(line: &str) -> Result<FramedSentence<'_>, NmeaError> {
40    let mut diagnostics = Diagnostics::new();
41    let trimmed = line.trim_end_matches(['\r', '\n']);
42    let start =
43        trimmed
44            .bytes()
45            .position(|b| b == b'$' || b == b'!')
46            .ok_or(NmeaError::NotFramed {
47                reason: "no NMEA start delimiter",
48            })?;
49    if start > 0 {
50        diagnostics.push_warning(Warning {
51            at: RecordRef::default(),
52            kind: WarningKind::Mismatch,
53        });
54    }
55    let sentence = &trimmed[start..];
56    if sentence.len() > 1024 {
57        return Err(NmeaError::NotFramed {
58            reason: "sentence over length cap",
59        });
60    }
61    if !sentence.is_ascii() {
62        return Err(NmeaError::NotFramed {
63            reason: "non-ASCII byte",
64        });
65    }
66    let delimiter = sentence.as_bytes()[0];
67    let rest = &sentence[1..];
68    let (body, checksum) = if let Some(star) = rest.rfind('*') {
69        let checksum_token = rest.get(star + 1..star + 3).unwrap_or("");
70        if checksum_token.len() != 2 || !checksum_token.bytes().all(|b| b.is_ascii_hexdigit()) {
71            return Err(NmeaError::NotFramed {
72                reason: "malformed checksum",
73            });
74        }
75        let trailing = &rest[star + 3..];
76        if !trailing
77            .bytes()
78            .all(|b| b == b' ' || b == b'\r' || b == b'\n')
79        {
80            diagnostics.push_warning(Warning {
81                at: RecordRef::default(),
82                kind: WarningKind::Mismatch,
83            });
84        }
85        let stated = u8::from_str_radix(checksum_token, 16).map_err(|_| NmeaError::NotFramed {
86            reason: "malformed checksum",
87        })?;
88        (&rest[..star], Some(stated))
89    } else {
90        diagnostics.push_warning(Warning {
91            at: RecordRef::default(),
92            kind: WarningKind::MissingMetadata,
93        });
94        (rest, None)
95    };
96    if let Some(stated) = checksum {
97        let computed = checksum_body(body);
98        if computed != stated {
99            return Err(NmeaError::ChecksumMismatch { computed, stated });
100        }
101    }
102    Ok(FramedSentence {
103        delimiter,
104        body,
105        diagnostics,
106    })
107}
108
109pub(crate) fn parse_framed(framed: FramedSentence<'_>) -> Result<Parsed<NmeaSentence>, NmeaError> {
110    if framed.delimiter == b'!' {
111        return Err(NmeaError::UnsupportedType {
112            address: "encapsulated sentence".to_string(),
113        });
114    }
115    let mut diagnostics = framed.diagnostics;
116    let mut parts = framed.body.split(',');
117    let address = parts.next().unwrap_or_default();
118    if address.starts_with('P') {
119        return Err(NmeaError::Proprietary {
120            address: address.to_string(),
121        });
122    }
123    if address.len() != 5 {
124        return Err(NmeaError::UnsupportedType {
125            address: address.to_string(),
126        });
127    }
128    let talker = NmeaTalker::parse(&address[..2]);
129    let sentence_type = &address[2..];
130    let fields: Vec<&str> = parts.collect();
131    let body = match sentence_type {
132        "GGA" => NmeaBody::Gga(parse_gga(&fields)?),
133        "RMC" => NmeaBody::Rmc(parse_rmc(&fields)?),
134        "GSA" => NmeaBody::Gsa(parse_gsa(talker, &fields, &mut diagnostics)?),
135        "GSV" => NmeaBody::Gsv(parse_gsv(talker, &fields, &mut diagnostics)?),
136        "GST" => NmeaBody::Gst(parse_gst(&fields)?),
137        "VTG" => NmeaBody::Vtg(parse_vtg(&fields)?),
138        "GLL" => NmeaBody::Gll(parse_gll(&fields)?),
139        "ZDA" => NmeaBody::Zda(parse_zda(&fields)?),
140        _ => {
141            return Err(NmeaError::UnsupportedType {
142                address: address.to_string(),
143            })
144        }
145    };
146    Ok(Parsed::new(NmeaSentence { talker, body }, diagnostics))
147}
148
149fn parse_gga(fields: &[&str]) -> Result<Gga, FieldError> {
150    Ok(Gga {
151        time: parse_opt_time(fields.first().copied())?,
152        latitude: parse_opt_coordinate(fields.get(1).copied(), fields.get(2).copied(), true)?,
153        longitude: parse_opt_coordinate(fields.get(3).copied(), fields.get(4).copied(), false)?,
154        quality: parse_opt_quality(fields.get(5).copied())?,
155        satellites_used: parse_opt_u8_range(fields.get(6).copied(), "satellites used", 0, 99)?,
156        hdop: parse_opt_f64(fields.get(7).copied(), "hdop")?,
157        altitude_msl_m: parse_opt_unit_f64(
158            fields.get(8).copied(),
159            fields.get(9).copied(),
160            "altitude msl",
161        )?,
162        geoid_separation_m: parse_opt_unit_f64(
163            fields.get(10).copied(),
164            fields.get(11).copied(),
165            "geoid separation",
166        )?,
167        differential_age_s: parse_opt_f64(fields.get(12).copied(), "differential age")?,
168        differential_station_id: parse_opt_u16_range(
169            fields.get(13).copied(),
170            "differential station id",
171            0,
172            9999,
173        )?,
174    })
175}
176
177fn parse_rmc(fields: &[&str]) -> Result<Rmc, FieldError> {
178    let magnetic = parse_opt_signed_pair(
179        fields.get(9).copied(),
180        fields.get(10).copied(),
181        "magnetic variation",
182        "EW",
183    )?;
184    Ok(Rmc {
185        time: parse_opt_time(fields.first().copied())?,
186        status: parse_opt_rmc_status(fields.get(1).copied())?,
187        latitude: parse_opt_coordinate(fields.get(2).copied(), fields.get(3).copied(), true)?,
188        longitude: parse_opt_coordinate(fields.get(4).copied(), fields.get(5).copied(), false)?,
189        speed_over_ground_kn: parse_opt_f64(fields.get(6).copied(), "speed over ground")?,
190        course_over_ground_deg: parse_opt_f64(fields.get(7).copied(), "course over ground")?,
191        date: parse_opt_date_rmc(fields.get(8).copied())?,
192        magnetic_variation_deg: magnetic,
193        faa_mode: parse_opt_char(fields.get(11).copied(), "faa mode")?,
194        navigational_status: parse_opt_char(fields.get(12).copied(), "navigation status")?,
195    })
196}
197
198fn parse_gsa(
199    talker: NmeaTalker,
200    fields: &[&str],
201    diagnostics: &mut Diagnostics,
202) -> Result<Gsa, FieldError> {
203    let system_id = parse_opt_u8_range(fields.get(17).copied(), "system id", 0, 9)?;
204    let system = match system_id {
205        Some(1) => Some(GnssSystem::Gps),
206        Some(2) => Some(GnssSystem::Glonass),
207        Some(3) => Some(GnssSystem::Galileo),
208        Some(4) => Some(GnssSystem::BeiDou),
209        Some(5) => Some(GnssSystem::Qzss),
210        Some(6) => Some(GnssSystem::Navic),
211        Some(_) => {
212            diagnostics.push_warning(Warning {
213                at: RecordRef::default(),
214                kind: WarningKind::Degraded,
215            });
216            None
217        }
218        None => talker.system(),
219    };
220    let mut satellites = Vec::new();
221    for field in fields.iter().skip(2).take(12) {
222        if let Some(sat) = parse_opt_sat_number(Some(*field), system, diagnostics)? {
223            satellites.push(sat);
224        }
225    }
226    Ok(Gsa {
227        selection_mode: parse_opt_gsa_selection(fields.first().copied())?,
228        fix_mode: parse_opt_gsa_fix(fields.get(1).copied())?,
229        satellites,
230        pdop: parse_opt_f64(fields.get(14).copied(), "pdop")?,
231        hdop: parse_opt_f64(fields.get(15).copied(), "hdop")?,
232        vdop: parse_opt_f64(fields.get(16).copied(), "vdop")?,
233        system_id,
234        system,
235    })
236}
237
238fn parse_gsv(
239    talker: NmeaTalker,
240    fields: &[&str],
241    diagnostics: &mut Diagnostics,
242) -> Result<Gsv, FieldError> {
243    let total_messages = parse_required_u8_range(fields.first().copied(), "gsv total", 1, 15)?;
244    let message_number = parse_required_u8_range(
245        fields.get(1).copied(),
246        "gsv message number",
247        1,
248        total_messages,
249    )?;
250    let satellites_in_view =
251        parse_opt_u16_range(fields.get(2).copied(), "satellites in view", 0, 999)?;
252    let tail = if fields.len() > 3 { &fields[3..] } else { &[] };
253    let rem = tail.len() % 4;
254    let (sat_fields, signal) = if rem == 1 {
255        let signal = parse_opt_signal_id(tail.last().copied(), talker.system(), diagnostics)?;
256        (&tail[..tail.len() - 1], signal)
257    } else {
258        if rem == 2 || rem == 3 {
259            diagnostics.push_warning(Warning {
260                at: RecordRef::default(),
261                kind: WarningKind::Mismatch,
262            });
263        }
264        (tail, None)
265    };
266    let mut satellites = Vec::new();
267    for chunk in sat_fields.chunks(4) {
268        let sat_number =
269            parse_opt_sat_number(chunk.first().copied(), talker.system(), diagnostics)?;
270        let elevation_deg = parse_opt_i16_range(chunk.get(1).copied(), "elevation", -90, 90)?;
271        let azimuth_deg = parse_opt_u16_range(chunk.get(2).copied(), "azimuth", 0, 359)?;
272        let cn0_db_hz = parse_opt_u8_range(chunk.get(3).copied(), "cn0", 0, 99)?;
273        satellites.push(GsvSatellite {
274            sat_number,
275            elevation_deg,
276            azimuth_deg,
277            cn0_db_hz,
278        });
279    }
280    Ok(Gsv {
281        total_messages,
282        message_number,
283        satellites_in_view,
284        satellites,
285        signal,
286    })
287}
288
289fn parse_gst(fields: &[&str]) -> Result<Gst, FieldError> {
290    Ok(Gst {
291        time: parse_opt_time(fields.first().copied())?,
292        rms_range_residual_m: parse_opt_f64(fields.get(1).copied(), "range residual rms")?,
293        semi_major_error_m: parse_opt_f64(fields.get(2).copied(), "semi major error")?,
294        semi_minor_error_m: parse_opt_f64(fields.get(3).copied(), "semi minor error")?,
295        orientation_deg: parse_opt_f64(fields.get(4).copied(), "error orientation")?,
296        latitude_sigma_m: parse_opt_f64(fields.get(5).copied(), "latitude sigma")?,
297        longitude_sigma_m: parse_opt_f64(fields.get(6).copied(), "longitude sigma")?,
298        altitude_sigma_m: parse_opt_f64(fields.get(7).copied(), "altitude sigma")?,
299    })
300}
301
302fn parse_vtg(fields: &[&str]) -> Result<Vtg, FieldError> {
303    Ok(Vtg {
304        course_true_deg: parse_opt_tagged_f64(
305            fields.first().copied(),
306            fields.get(1).copied(),
307            "course true",
308            "T",
309        )?,
310        course_magnetic_deg: parse_opt_tagged_f64(
311            fields.get(2).copied(),
312            fields.get(3).copied(),
313            "course magnetic",
314            "M",
315        )?,
316        speed_kn: parse_opt_tagged_f64(
317            fields.get(4).copied(),
318            fields.get(5).copied(),
319            "speed knots",
320            "N",
321        )?,
322        speed_kmh: parse_opt_tagged_f64(
323            fields.get(6).copied(),
324            fields.get(7).copied(),
325            "speed kmh",
326            "K",
327        )?,
328        faa_mode: parse_opt_char(fields.get(8).copied(), "faa mode")?,
329    })
330}
331
332fn parse_gll(fields: &[&str]) -> Result<Gll, FieldError> {
333    Ok(Gll {
334        latitude: parse_opt_coordinate(fields.first().copied(), fields.get(1).copied(), true)?,
335        longitude: parse_opt_coordinate(fields.get(2).copied(), fields.get(3).copied(), false)?,
336        time: parse_opt_time(fields.get(4).copied())?,
337        status: parse_opt_rmc_status(fields.get(5).copied())?,
338        faa_mode: parse_opt_char(fields.get(6).copied(), "faa mode")?,
339    })
340}
341
342fn parse_zda(fields: &[&str]) -> Result<Zda, FieldError> {
343    let day = parse_opt_u8_range(fields.get(1).copied(), "zda day", 1, 31)?;
344    let month = parse_opt_u8_range(fields.get(2).copied(), "zda month", 1, 12)?;
345    let year = parse_opt_u16_range(fields.get(3).copied(), "zda year", 0, 9999)?;
346    let date = match (year, month, day) {
347        (Some(year), Some(month), Some(day)) => Some(NmeaDate::new(year, month, day)?),
348        (None, None, None) => None,
349        _ => return Err(FieldError::Missing { field: "zda date" }),
350    };
351    Ok(Zda {
352        time: parse_opt_time(fields.first().copied())?,
353        date,
354        local_zone_hours: parse_opt_i8_range(fields.get(4).copied(), "zone hours", -13, 13)?,
355        local_zone_minutes: parse_opt_u8_range(fields.get(5).copied(), "zone minutes", 0, 59)?,
356    })
357}
358
359fn parse_opt_time(token: Option<&str>) -> Result<Option<NmeaTime>, FieldError> {
360    match token.unwrap_or("").trim() {
361        "" => Ok(None),
362        token => NmeaTime::parse(token).map(Some),
363    }
364}
365
366fn parse_opt_date_rmc(token: Option<&str>) -> Result<Option<NmeaDate>, FieldError> {
367    match token.unwrap_or("").trim() {
368        "" => Ok(None),
369        token => NmeaDate::parse_rmc(token).map(Some),
370    }
371}
372
373fn parse_opt_coordinate(
374    value: Option<&str>,
375    hemisphere: Option<&str>,
376    is_latitude: bool,
377) -> Result<Option<NmeaCoordinate>, FieldError> {
378    let value = value.unwrap_or("").trim();
379    let hemisphere = hemisphere.unwrap_or("").trim();
380    match (value.is_empty(), hemisphere.is_empty()) {
381        (true, true) => Ok(None),
382        (false, false) => NmeaCoordinate::parse(value, hemisphere, is_latitude).map(Some),
383        _ => Err(FieldError::Missing {
384            field: if is_latitude {
385                "latitude pair"
386            } else {
387                "longitude pair"
388            },
389        }),
390    }
391}
392
393fn parse_opt_quality(token: Option<&str>) -> Result<Option<GgaQuality>, FieldError> {
394    match token.unwrap_or("").trim() {
395        "" => Ok(None),
396        token => GgaQuality::parse(token).map(Some),
397    }
398}
399
400fn parse_opt_rmc_status(token: Option<&str>) -> Result<Option<RmcStatus>, FieldError> {
401    match parse_opt_char(token, "rmc status")? {
402        None => Ok(None),
403        Some('A') => Ok(Some(RmcStatus::Valid)),
404        Some('V') => Ok(Some(RmcStatus::Warning)),
405        Some(ch) => Ok(Some(RmcStatus::Other(ch))),
406    }
407}
408
409fn parse_opt_gsa_selection(token: Option<&str>) -> Result<Option<GsaSelectionMode>, FieldError> {
410    match parse_opt_char(token, "gsa selection")? {
411        None => Ok(None),
412        Some('M') => Ok(Some(GsaSelectionMode::Manual)),
413        Some('A') => Ok(Some(GsaSelectionMode::Automatic)),
414        Some(ch) => Ok(Some(GsaSelectionMode::Other(ch))),
415    }
416}
417
418fn parse_opt_gsa_fix(token: Option<&str>) -> Result<Option<GsaFixMode>, FieldError> {
419    match token.unwrap_or("").trim() {
420        "" => Ok(None),
421        token => {
422            let value = validate::strict_int::<u8>(token, "gsa fix mode")?;
423            Ok(Some(match value {
424                1 => GsaFixMode::None,
425                2 => GsaFixMode::TwoD,
426                3 => GsaFixMode::ThreeD,
427                other => GsaFixMode::Other(other),
428            }))
429        }
430    }
431}
432
433fn parse_opt_char(token: Option<&str>, field: &'static str) -> Result<Option<char>, FieldError> {
434    let token = token.unwrap_or("").trim();
435    if token.is_empty() {
436        return Ok(None);
437    }
438    let mut chars = token.chars();
439    let Some(ch) = chars.next() else {
440        return Ok(None);
441    };
442    if chars.next().is_some() {
443        return Err(FieldError::IntParse {
444            field,
445            value: token.to_string(),
446        });
447    }
448    Ok(Some(ch))
449}
450
451fn parse_opt_f64(token: Option<&str>, field: &'static str) -> Result<Option<f64>, FieldError> {
452    match token.unwrap_or("").trim() {
453        "" => Ok(None),
454        token => validate::strict_f64(token, field).map(Some),
455    }
456}
457
458fn parse_opt_tagged_f64(
459    value: Option<&str>,
460    unit: Option<&str>,
461    field: &'static str,
462    expected_unit: &str,
463) -> Result<Option<f64>, FieldError> {
464    let value = value.unwrap_or("").trim();
465    let unit = unit.unwrap_or("").trim();
466    if value.is_empty() {
467        return Ok(None);
468    }
469    if unit != expected_unit {
470        return Err(FieldError::OutOfRange {
471            field: "unit",
472            min: 0.0,
473            max: 0.0,
474            upper_inclusive: true,
475        });
476    }
477    validate::strict_f64(value, field).map(Some)
478}
479
480fn parse_opt_signed_pair(
481    value: Option<&str>,
482    direction: Option<&str>,
483    field: &'static str,
484    valid_dirs: &str,
485) -> Result<Option<f64>, FieldError> {
486    let Some(mut value) = parse_opt_f64(value, field)? else {
487        return Ok(None);
488    };
489    let direction = direction.unwrap_or("").trim();
490    if direction.len() != 1 || !valid_dirs.contains(direction) {
491        return Err(FieldError::OutOfRange {
492            field: "direction",
493            min: 0.0,
494            max: 0.0,
495            upper_inclusive: true,
496        });
497    }
498    if direction == "W" || direction == "S" {
499        value = -value;
500    }
501    Ok(Some(value))
502}
503
504fn parse_opt_unit_f64(
505    value: Option<&str>,
506    unit: Option<&str>,
507    field: &'static str,
508) -> Result<Option<f64>, FieldError> {
509    let value = value.unwrap_or("").trim();
510    let unit = unit.unwrap_or("").trim();
511    if value.is_empty() {
512        if unit.is_empty() {
513            return Ok(None);
514        }
515        return Err(FieldError::Missing { field });
516    }
517    if unit != "M" {
518        return Err(FieldError::OutOfRange {
519            field: "unit",
520            min: 0.0,
521            max: 0.0,
522            upper_inclusive: true,
523        });
524    }
525    validate::strict_f64(value, field).map(Some)
526}
527
528fn parse_required_u8_range(
529    token: Option<&str>,
530    field: &'static str,
531    min: u8,
532    max: u8,
533) -> Result<u8, FieldError> {
534    parse_opt_u8_range(token, field, min, max)?.ok_or(FieldError::Missing { field })
535}
536
537fn parse_opt_u8_range(
538    token: Option<&str>,
539    field: &'static str,
540    min: u8,
541    max: u8,
542) -> Result<Option<u8>, FieldError> {
543    match token.unwrap_or("").trim() {
544        "" => Ok(None),
545        token => {
546            let value = validate::strict_int::<u8>(token, field)?;
547            if value < min || value > max {
548                Err(FieldError::OutOfRange {
549                    field,
550                    min: f64::from(min),
551                    max: f64::from(max),
552                    upper_inclusive: true,
553                })
554            } else {
555                Ok(Some(value))
556            }
557        }
558    }
559}
560
561fn parse_opt_i8_range(
562    token: Option<&str>,
563    field: &'static str,
564    min: i8,
565    max: i8,
566) -> Result<Option<i8>, FieldError> {
567    match token.unwrap_or("").trim() {
568        "" => Ok(None),
569        token => {
570            let value = validate::strict_int::<i8>(token, field)?;
571            if value < min || value > max {
572                Err(FieldError::OutOfRange {
573                    field,
574                    min: f64::from(min),
575                    max: f64::from(max),
576                    upper_inclusive: true,
577                })
578            } else {
579                Ok(Some(value))
580            }
581        }
582    }
583}
584
585fn parse_opt_i16_range(
586    token: Option<&str>,
587    field: &'static str,
588    min: i16,
589    max: i16,
590) -> Result<Option<i16>, FieldError> {
591    match token.unwrap_or("").trim() {
592        "" => Ok(None),
593        token => {
594            let value = validate::strict_int::<i16>(token, field)?;
595            if value < min || value > max {
596                Err(FieldError::OutOfRange {
597                    field,
598                    min: f64::from(min),
599                    max: f64::from(max),
600                    upper_inclusive: true,
601                })
602            } else {
603                Ok(Some(value))
604            }
605        }
606    }
607}
608
609fn parse_opt_u16_range(
610    token: Option<&str>,
611    field: &'static str,
612    min: u16,
613    max: u16,
614) -> Result<Option<u16>, FieldError> {
615    match token.unwrap_or("").trim() {
616        "" => Ok(None),
617        token => {
618            let value = validate::strict_int::<u16>(token, field)?;
619            if value < min || value > max {
620                Err(FieldError::OutOfRange {
621                    field,
622                    min: f64::from(min),
623                    max: f64::from(max),
624                    upper_inclusive: true,
625                })
626            } else {
627                Ok(Some(value))
628            }
629        }
630    }
631}
632
633fn parse_opt_sat_number(
634    token: Option<&str>,
635    context: Option<GnssSystem>,
636    diagnostics: &mut Diagnostics,
637) -> Result<Option<NmeaSatNumber>, FieldError> {
638    let token = token.unwrap_or("").trim();
639    if token.is_empty() {
640        return Ok(None);
641    }
642    let raw = validate::strict_int::<u16>(token, "satellite number")?;
643    let resolved = super::fields::resolve_sat_number(context, raw);
644    if resolved.is_none() {
645        diagnostics.push_warning(Warning {
646            at: RecordRef::default(),
647            kind: WarningKind::Degraded,
648        });
649    }
650    Ok(Some(NmeaSatNumber { raw, resolved }))
651}
652
653fn parse_opt_signal_id(
654    token: Option<&str>,
655    system: Option<GnssSystem>,
656    diagnostics: &mut Diagnostics,
657) -> Result<Option<NmeaSignalId>, FieldError> {
658    let token = token.unwrap_or("").trim();
659    if token.is_empty() {
660        return Ok(None);
661    }
662    let id = if token.len() == 1 {
663        u8::from_str_radix(token, 16).map_err(|_| FieldError::IntParse {
664            field: "signal id",
665            value: token.to_string(),
666        })?
667    } else {
668        validate::strict_int::<u8>(token, "signal id")?
669    };
670    let signal = NmeaSignalId { system, id };
671    if id > 15 || signal.carrier_band().is_none() {
672        diagnostics.push_warning(Warning {
673            at: RecordRef::default(),
674            kind: WarningKind::Degraded,
675        });
676    }
677    Ok(Some(signal))
678}