• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

KD0NKS / js-aprs-fap / 20597491052

30 Dec 2025 01:21PM UTC coverage: 87.02% (-0.3%) from 87.294%
20597491052

push

github

web-flow
Bump the dependencies group across 1 directory with 2 updates (#373)

Bumps the dependencies group with 2 updates in the / directory: [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) and [chai](https://github.com/chaijs/chai).


Updates `@types/node` from 24.10.1 to 25.0.3
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `chai` from 6.2.1 to 6.2.2
- [Release notes](https://github.com/chaijs/chai/releases)
- [Changelog](https://github.com/chaijs/chai/blob/main/History.md)
- [Commits](https://github.com/chaijs/chai/compare/v6.2.1...v6.2.2)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 25.0.3
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: dependencies
- dependency-name: chai
  dependency-version: 6.2.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

752 of 907 branches covered (82.91%)

Branch coverage included in aggregate %.

1152 of 1281 relevant lines covered (89.93%)

64.2 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

85.53
/src/parser.ts
1
import aprsPacket from './aprsPacket'
2,361✔
2
import { ConversionConstantEnum } from './ConversionConstantEnum'
3✔
3
import { ConversionUtil } from './ConversionUtil'
3✔
4
import digipeater from './digipeater'
3✔
5
import { DST_SYMBOLS } from './DSTSymbols'
3✔
6
import { RESULT_MESSAGES } from './ResultMessages'
3✔
7
import telemetry from './telemetry'
3✔
8
import wx from './wx'
3✔
9
import { PacketTypeEnum } from './PacketTypeEnum'
3✔
10

11
export default class aprsParser {
3✔
12
    constructor() { }
13

14
    /**
15
     * Used to add error messages to a packet.
16
     *
17
     * @param {json} rethash Parsed values from packet.
18
     * @param {string} errcode Error code, this should be able to be found in the result_messages object/map.
19
     * @param {string} val Value that caused the error.
20
     * @return {void}
21
     */
22
    addError(packet: aprsPacket, errorCode: string, value?: any): aprsPacket {
23
        packet.resultCode = errorCode;
180✔
24

25
        packet.resultMessage = ((RESULT_MESSAGES[errorCode] !== undefined) ? RESULT_MESSAGES[errorCode] : errorCode)
180✔
26
                + `: ${value}`;
27
                //+ ((value !== undefined && value) ? value : value);
28

29
        return packet;
180✔
30
    }
31

32
    /**
33
     * Used to add warning messages to a packet.
34
     *
35
     * @param {json} rethash Parsed values from packet.
36
     * @param {string} errcode Error code, this should be able to be found in the result_messages object/map.
37
     * @param {string} val Value that caused the warning.
38
     * @return {void}
39
     */
40
    addWarning(packet: aprsPacket, errorCode: string, value?: string): aprsPacket {
41
        if(packet.warningCodes == undefined || !packet.warningCodes) {
18✔
42
            packet.warningCodes = [];
15✔
43
        }
44

45
        packet.warningCodes.push(errorCode);
18✔
46

47
        packet.resultMessage = ((RESULT_MESSAGES[errorCode] !== undefined && RESULT_MESSAGES[errorCode]) ? RESULT_MESSAGES[errorCode] : errorCode)
18✔
48
                + ((value !== undefined && value) ? `: ${value}` : '');
42✔
49

50
        return packet;
18✔
51
    }
52

53
    /**
54
     * =item checkAX25Call()
55
     * Check the callsign for a valid AX.25 callsign format and
56
     * return cleaned up (OH2XYZ-0) callsign or undef if the callsign
57
     * is not a valid AX.25 address.
58
     * Please note that it's very common to use invalid callsigns on the APRS-IS.
59
     */
60
    checkAX25Call(callsign: string) {
61
        let tempCallsign: string[];
62

63
        if((tempCallsign = callsign.match(/^([A-Z0-9]{1,6})(-\d{1,2}|)$/))) {
498✔
64
            if(!tempCallsign[2]) {
474✔
65
                return tempCallsign[1];
429✔
66
            } else {
67
                // convert SSID to positive and numeric
68
                let $ssid = 0 - parseInt(tempCallsign[2]);
45✔
69

70
                if($ssid < 16) {
45✔
71
                    // 15 is maximum in AX.25
72
                    return tempCallsign[1] + '-' + $ssid;
39✔
73
                }
74
            }
75
        }
76

77
        // no successfull return yet, so error
78
        return null;
30✔
79
    }
80

81
    /**
82
     * =item parseaprs($packet, $hashref, %options)
83
     * Parse an APRS packet given as a string, e.g.
84
     * "OH2XYZ>APRS,RELAY*,WIDE:!2345.56N/12345.67E-PHG0123 hi there"
85
     * Second parameter has to be a reference to a hash. That hash will
86
     * be filled with as much data as possible based on the packet
87
     * given as parameter.
88
     * Returns 1 if the decoding was successfull,
89
     * returns 0 if not. In case zero is returned, the contents of
90
     * the parameter hash should be discarded, except for the error cause
91
     * as reported via hash elements resultcode and resultmsg.
92
     * The third parameter is an optional hash containing any of the following
93
     * options:
94
     * B<isax25> - the packet should be examined in a form
95
     * that can exist on an AX.25 network (1) or whether the frame is
96
     * from the Internet (0 - default).
97
     * B<accept_broken_mice> - if the packet contains corrupted
98
     * mic-e fields, but some of the data is still recovable, decode
99
     * the packet instead of reporting an error. At least aprsd produces
100
     * these packets. 1: try to decode, 0: report an error (default).
101
     * Packets which have been successfully demangled will contain the
102
     * B<mice_mangled> flag.
103
     * B<raw_timestamp> - Timestamps within the packets are not decoded
104
     * to an UNIX timestamp, but are returned as raw strings.
105
     * Example:
106
     * my %hash;
107
     * my ret = parseaprs("OH2XYZ>APRS,RELAY*,WIDE:!2345.56N/12345.67E-PHG0123 hi",
108
     * \%hash, 'isax25' => 0, 'accept_broken_mice' => 0);
109
     */
110
    parseaprs(packet: string, options?: any): aprsPacket | null | undefined {
111
        let retVal: aprsPacket = new aprsPacket();
456✔
112
        let isax25 = (options && options['isax25'] != undefined) ? options['isax25'] : false;
456✔
113

114
        // save the original packet
115
        retVal.origpacket = packet;
456✔
116

117
        if(packet === undefined) {
456✔
118
            return this.addError(retVal, 'packet_no');;
3✔
119
        }
120

121
        if(!packet || packet.length < 1) {
453✔
122
            return this.addError(retVal, 'packet_short');
3✔
123
        }
124

125
        // Separate the header and packet body on the first colon.
126
        let [ header, body ] = packet.split(/:(.*)/);
450✔
127

128
        // If no body, skip
129
        if(!body) {
450✔
130
            return this.addError(retVal, 'packet_nobody');
3✔
131
        }
132

133
        // Save all the parts of the packet
134
        retVal.header = header;
447✔
135
        retVal.body = body;
447✔
136

137
        // Source callsign, put the rest in $rest
138
        let srcCallsign;
139
        let rest;
140
        let $header;
141

142
        if(($header = header.match(/^([A-Z0-9-]{1,9})>(.*)$/i))) {
447✔
143
            rest = $header[2];
441✔
144

145
            if(isax25 == false) {
441✔
146
                srcCallsign = $header[1];
417✔
147
            } else {
148
                srcCallsign = this.checkAX25Call($header[1].toUpperCase());
24✔
149

150
                if(!srcCallsign) {
24✔
151
                    return this.addError(retVal, 'srccall_noax25');
6✔
152
                }
153
            }
154
        } else {
155
            // can't be a valid amateur radio callsign, even
156
            // in the extended sense of APRS-IS callsigns
157
            return this.addError(retVal, 'srccall_badchars');
6✔
158
        }
159

160
        retVal.sourceCallsign = srcCallsign;
435✔
161

162
        // Get the destination callsign and digipeaters.
163
        // Only TNC-2 format is supported, AEA (with digipeaters) is not.
164
        let pathcomponents = rest.split(',');
435✔
165

166
        // More than 9 (dst callsign + 8 digipeaters) path components
167
        // from AX.25 or less than 1 from anywhere is invalid.
168
        if(isax25 == true) {
435✔
169
            if(pathcomponents.length > 9) {
18✔
170
                // too many fields to be from AX.25
171
                return this.addError(retVal, 'dstpath_toomany');
3✔
172
            }
173
        }
174

175
        if(pathcomponents.length === 1 && pathcomponents[0] === '') {
432✔
176
            // no destination field
177
            return this.addError(retVal, 'dstcall_none');
3✔
178
        }
179

180

181
        // Destination callsign. We are strict here, there
182
        // should be no need to use a non-AX.25 compatible
183
        //# destination callsigns in the APRS-IS.
184
        let dstcallsign = this.checkAX25Call(pathcomponents.shift());
429✔
185

186
        if(!dstcallsign) {
429✔
187
            return this.addError(retVal, 'dstcall_noax25');
6✔
188
        }
189

190
        retVal.destCallsign = dstcallsign;
423✔
191

192
        // digipeaters
193
        let digipeaters = [];
423✔
194

195
        if(isax25 == true) {
423✔
196
            for(let digi of pathcomponents) {
15✔
197
                let d;
198

199
                if((d = digi.match(/^([A-Z0-9-]+)(\*|)$/i))) {
30✔
200
                    let digitested = this.checkAX25Call(d[1].toUpperCase());
27✔
201

202
                    if(!digitested) {
27✔
203
                        return this.addError(retVal, `${digi} digicall_noax25`);
9✔
204
                    }
205

206
                    // add it to the digipeater array
207
                    digipeaters.push(new digipeater(
18✔
208
                        digitested
209
                        , (d[2] == '*')
210
                    ));
211
                } else {
212
                    return this.addError(retVal, 'digicall_badchars');
3✔
213
                }
214
            }
215
        } else {
216
            let seen_qconstr = false;
408✔
217
            let tmp = null;
408✔
218

219
            for(let digi of pathcomponents) {
408✔
220
                // From the internet. Apply the same checks as for
221
                // APRS-IS packet originator. Allow long hexadecimal IPv6
222
                // address after the Q construct.
223
                if((tmp = digi.match(/^([A-Z0-9a-z-]{1,9})(\*|)$/))) {
1,401✔
224
                    digipeaters.push(new digipeater(tmp[1], (tmp[2] == '*')));
1,392✔
225

226
                    seen_qconstr = /^q..$/.test(tmp[1]) || seen_qconstr; // if it's already true, don't reset it to false.
1,392✔
227
                } else {
228
                    //if ($seen_qconstr && $digi =~ /^([0-9A-F]{32})$/) { // This doesn't even make sense.  Unless perl does something special
229
                    // this condition should never be true.  Lets remove the first condition for fun.
230
                    if(seen_qconstr == true && (tmp = digi.match(/^([0-9A-F]{32})$/))) {
9✔
231
                        digipeaters.push(new digipeater(tmp[1], false));
3✔
232
                    } else {
233
                        return this.addError(retVal, 'digicall_badchars');
6✔
234
                    }
235
                }
236
            }
237
        }
238

239
        retVal.digipeaters = digipeaters;
405✔
240

241
        // So now we have source and destination callsigns and
242
        // digipeaters parsed and ok. Move on to the body.
243

244
        // Check the first character of the packet
245
        // and determine the packet type
246
        //let $retval = -1;
247
        let packettype = body.charAt(0);
405✔
248
        let paclen = body.length;
405✔
249

250
        // Check the packet type and proceed depending on it
251
        // Mic-encoder packet
252
        if(packettype == '\'' || packettype == '`') {
405✔
253
            // the following are obsolete mic-e types: 0x1c 0x1d
254
            // mic-encoder data
255
            // minimum body length 9 chars
256
            if(paclen >= 9) {
54✔
257
                retVal.type = PacketTypeEnum.LOCATION
54✔
258

259
                retVal = this.miceToDecimal(body.substring(1), dstcallsign, srcCallsign, retVal, options);
54✔
260
                //return $rethash;
261
            }
262
        // Normal or compressed location packet, with or without
263
        // timestamp, with or without messaging capability
264
        } else if(packettype == '!' || packettype == '=' ||
351✔
265
                packettype == '/' || packettype == '@') {
266
            // with or without messaging
267
            retVal.messaging = !(packettype == '!' || packettype == '/');
105✔
268

269
            if(paclen >= 14) {
105✔
270
                retVal.type = PacketTypeEnum.LOCATION;
102✔
271

272
                if(packettype == '/' || packettype == '@') {
102✔
273
                    // With a prepended timestamp, check it and jump over.
274
                    // If the timestamp is invalid, it will be set to zero.
275
                    retVal.timestamp = this.parseTimestamp(options, body.substring(1, 8));
45✔
276

277
                    // TODO: this can be hit if this condition is not met: /^(\d{2})(\d{2})(\d{2})(z|h|\/)$/
278
                    if(retVal.timestamp == 0) {
45✔
279
                        this.addWarning(retVal, 'timestamp_inv_loc');
3✔
280
                    }
281

282
                    body = body.substring(7);
45✔
283
                }
284

285
                // remove the first character
286
                body = body.substring(1);
102✔
287

288
                // grab the ascii value of the first byte of body
289
                let poschar = body.charCodeAt(0);
102✔
290

291
                if(poschar >= 48 && poschar <= 57) {
102✔
292
                    // poschar is a digit... normal uncompressed position
293
                    if(body.length >= 19) {
75✔
294
                        retVal = this._normalpos_to_decimal(body, srcCallsign, retVal);
72✔
295

296
                        // continue parsing with possible comments, but only
297
                        // if this is not a weather report (course/speed mixup,
298
                        // weather as comment)
299
                        // if the comments don't parse, don't raise an error
300
                        if((retVal.resultCode === undefined && !retVal.resultCode)  && retVal.symbolcode != '_') {
72✔
301
                            retVal = this._comments_to_decimal(body.substring(19), srcCallsign, retVal);
51✔
302
                        } else {
303
                            // warn "maybe a weather report?\n" . substring($body, 19) . "\n";
304
                            retVal = this._wx_parse(body.substring(19), retVal);
21✔
305
                        }
306
                    }
307

308
                    // TODO: Should an error be added here since there's no location data on the packet?
309
                } else if(poschar == 47 || poschar == 92
27✔
310
                        || (poschar >= 65 && poschar <= 90)
311
                        || (poschar >= 97 && poschar <= 106)) {
312
                    // compressed position
313
                    if(body.length >= 13) {
18✔
314
                        retVal = this._compressed_to_decimal(body.substring(0, 13), srcCallsign, retVal);
15✔
315

316
                        // continue parsing with possible comments, but only
317
                        // if this is not a weather report (course/speed mixup,
318
                        // weather as comment)
319
                        // if the comments don't parse, don't raise an error
320
                        if((retVal.resultCode === undefined && !retVal.resultCode) && retVal.symbolcode != '_') {
15✔
321
                            retVal = this._comments_to_decimal(body.substring(13), srcCallsign, retVal);
9✔
322
                        } else {
323
                            // warn "maybe a weather report?\n" . substring($body, 13) . "\n";
324
                            retVal = this._wx_parse(body.substring(13), retVal);
6✔
325
                        }
326
                    } else {
327
                        return this.addError(retVal, 'packet_invalid', 'Body is too short.');
3✔
328
                    }
329
                } else if(poschar == 33) { // '!'
9✔
330
                    // Weather report from Ultimeter 2000
331
                    retVal.type = PacketTypeEnum.WEATHER
3✔
332

333
                    retVal = this._wx_parse_peet_logging(body.substring(1), srcCallsign, retVal);
3✔
334
                } else {
335
                    return this.addError(retVal, 'packet_invalid');
6✔
336
                }
337
            } else {
338
                return this.addError(retVal, 'packet_short', 'location');
3✔
339
            }
340
        // Weather report
341
        } else if(packettype == '_') {
246✔
342
            if(/_(\d{8})c[\- \.\d]{1,3}s[\- \.\d]{1,3}/.test(body)) {
6✔
343
                retVal.type = PacketTypeEnum.WEATHER
3✔
344

345
                retVal = this._wx_parse(body.substring(9), retVal);
3✔
346
            } else {
347
                return this.addError(retVal, 'wx_unsupp', 'Positionless');
3✔
348
            }
349
        // Object
350
        } else if (packettype == ';') {
240✔
351
            // if(paclen >= 31) { is there a case where this couldn't be
352
            retVal.type = PacketTypeEnum.OBJECT
27✔
353

354
            retVal = this.objectToDecimal(options, body, srcCallsign, retVal);
27✔
355
        // NMEA data
356
        } else if(packettype == '$') {
213✔
357
            // don't try to parse the weather stations, require "$GP" start
358
            if(body.substring(0, 3) == '$GP') {
48✔
359
                // dstcallsign can contain the APRS symbol to use,
360
                // so read that one too
361
                retVal.type = PacketTypeEnum.LOCATION
42✔
362

363
                retVal = this._nmea_to_decimal(options, body.substring(1), srcCallsign, dstcallsign, retVal);
42✔
364
            } else if(body.substring(0, 5) == '$ULTW') {
6✔
365
                retVal.type = PacketTypeEnum.WEATHER
6✔
366
                retVal = this._wx_parse_peet_packet(body.substring(5), srcCallsign, retVal);
6✔
367
            }
368
            /*
369
            else {
370
                throw new Error(`test 1 - ${retVal.origpacket}`);
371
            }
372
            */
373
        // Item
374
        } else if (packettype == ')') {
165✔
375
            retVal.type = PacketTypeEnum.ITEM
18✔
376
            retVal = this._item_to_decimal(body, srcCallsign, retVal);
18✔
377
        // Message, bulletin or an announcement
378
        } else if(packettype === ':') {
147✔
379
            if(paclen >= 11) {
102✔
380
                // all are labeled as messages for the time being
381
                retVal.type = PacketTypeEnum.MESSAGE
102✔
382

383
                retVal = this.messageParse(body, retVal);
102✔
384
            }
385
            /*
386
            else {
387
                throw new Error(`test 2 - ${retVal.origpacket}`);
388
            }
389
            */
390
        // Station capabilities
391
        } else if(packettype == '<') {
45✔
392
            // at least one other character besides '<' required
393
            if(paclen >= 2) {
6✔
394
                retVal.type = PacketTypeEnum.CAPABILITIES
3✔
395

396
                retVal = this._capabilities_parse(body.substring(1), srcCallsign, retVal);
3✔
397
            }
398

399
            // TODO: add an error to the packet?
400
        // Status reports
401
        } else if(packettype == '>') {
39✔
402
            // we can live with empty status reports
403
            // if($paclen >= 1) { NOTE: this cannot ever hit the else case, because the body will be empty and return an error
404
                retVal.type = PacketTypeEnum.STATUS
12✔
405

406
                retVal = this._status_parse(options, body.substring(1), srcCallsign, retVal)
12✔
407
            //}
408
        // Telemetry
409
        } else if(/^T#(.*?),(.*)$/.test(body)) {
27✔
410
            retVal.type = PacketTypeEnum.TELEMETRY
18✔
411

412
            retVal = this._telemetry_parse(body.substring(2), retVal);
18✔
413
        // DX spot
414
        }
9✔
415
        /*
416
        else if (/^DX\s+de\s+(.*?)\s*[:>]\s*(.*)$/i.test(body)) {
417
            var tmp: string[];
418
            tmp = body.match(/^DX\s+de\s+(.*?)\s*[:>]\s*(.*)$/i);
419

420
            retVal.type = PacketTypeEnum.DX
421

422
            retVal = this._dx_parse(tmp[1], tmp[2], retVal);
423
        //# Experimental
424
        } */
425
        else if(/^\{\{/i.test(body)) {
426
            return this.addError(retVal, 'exp_unsupp');
3✔
427
        // When all else fails, try to look for a !-position that can
428
        // occur anywhere within the 40 first characters according
429
        // to the spec.
430
        } else {
431
            let pos = body.indexOf('!');
6✔
432

433
            if(pos >= 0 && pos <= 39) {
6✔
434
                retVal.type = PacketTypeEnum.LOCATION
3✔
435
                retVal.messaging = false;
3✔
436

437
                let pChar = body.substring(pos + 1, pos + 2);
3✔
438

439
                if(/^[\/\\A-Za-j]$/.test(pChar)) {
3!
440
                    // compressed position
441
                    if(body.length >= (pos + 1 + 13)) {
×
442
                        retVal = this._compressed_to_decimal(body.substring(pos + 1, pos + 13), srcCallsign, retVal);
×
443

444
                        // check the APRS data extension and comment,
445
                        // if not weather data
446
                        if(retVal.resultCode === undefined && !retVal.resultCode && retVal.symbolcode != '_') {
×
447
                            retVal = this._comments_to_decimal(body.substring(pos + 14), srcCallsign, retVal);
×
448
                        }
449
                    }
450
                } else if(/^\d$/i.test(pChar)) {
3✔
451
                    // normal uncompressed position
452
                    if(body.length >= (pos + 1 + 19)) {
3✔
453
                        retVal = this._normalpos_to_decimal(body.substring(pos + 1), srcCallsign, retVal);
3✔
454

455
                        // check the APRS data extension and comment,
456
                        // if not weather data
457
                        if(!retVal.resultMessage && retVal.symbolcode != '_') {
3✔
458
                            retVal =  this._comments_to_decimal(body.substring(pos + 20), srcCallsign, retVal);
3✔
459
                        }
460
                    }
461
                }
462
            }
463
        }
464

465
        // Return packet regardless of if there were errors or not
466
        return retVal;
387✔
467
    }
468

469
    /**
470
     * Parse a status report. Only timestamps
471
     * and text report are supported. Maidenhead,
472
     * beam headings and symbols are not.
473
     */
474
    private _status_parse(options: any, packet: string, srccallsign: string, rethash: aprsPacket): aprsPacket {
475
        let tmp;
476

477
        // Remove CRs, LFs and trailing spaces
478
        packet = packet.trim();
12✔
479

480
        // Check for a timestamp
481
        if((tmp = packet.match(/^(\d{6}z)/))) {
12✔
482
            rethash.timestamp = this.parseTimestamp({}, tmp[1]);
6✔
483

484
            if(rethash.timestamp == 0) {
6✔
485
                rethash = this.addWarning(rethash, 'timestamp_inv_sta') ;
3✔
486
            }
487

488
            packet = packet.substring(7);
6✔
489
        }
490

491
        // TODO: handle beam heading and maidenhead grid locator status reports
492

493
        // Save the rest as the report
494
        rethash.status = packet;
12✔
495

496
        return rethash;
12✔
497
    }
498

499
    /**
500
     * Creates a unix timestamp based on an APRS six (+ one char for type) character timestamp or 0 if it's an invalid timestamp
501
     *
502
     * @param {json} options Looking for a raw_timestamp value
503
     * @param {string} stamp 6 digit number followed by z, h, or /
504
     * @returns {number} A unix timestamp
505
     */
506
    private parseTimestamp = function(options: any, stamp: any): number {
81✔
507
        // Check initial format
508
        if(!(stamp = stamp.match(/^(\d{2})(\d{2})(\d{2})(z|h|\/)$/))) {
72✔
509
            return 0;
3✔
510
        }
511

512
        if(options && options['raw_timestamp']) {
69✔
513
            return stamp[1] + stamp[2] + stamp[3];
9✔
514
        }
515

516
        let stamptype = stamp[4];
60✔
517

518
        if(stamptype == 'h') {
60✔
519
            // HMS format
520
            let hour = stamp[1];
12✔
521
            let minute = stamp[2];
12✔
522
            let second = stamp[3];
12✔
523

524
            // Check for invalid time
525
            if(hour > 23 || minute > 59 || second > 59) {
12✔
526
                return 0;
3✔
527
            }
528

529
            // All calculations here are in UTC, but if this is run under old MacOS (pre-OSX), then
530
            // Date_to_Time could be in local time.
531
            let ts = new Date();
9✔
532
            let currentTime: number = Math.floor(ts.getTime() / 1000);
9✔
533
            let cYear = ts.getUTCFullYear();
9✔
534
            let cMonth = ts.getUTCMonth();
9✔
535
            let cDay = ts.getUTCDate();
9✔
536
            let tStamp = Math.floor(new Date(Date.UTC(cYear, cMonth, cDay, hour, minute, second, 0)).getTime() / 1000);
9✔
537

538
            // If the time is more than about one hour
539
            // into the future, roll the timestamp
540
            // one day backwards.
541
            if(currentTime + 3900 < tStamp) {
9!
542
                tStamp -= 86400;
×
543
                // If the time is more than about 23 hours
544
                // into the past, roll the timestamp one
545
                // day forwards.
546
            } else if(currentTime - 82500 > tStamp) {
9!
547
                tStamp += 86400;
×
548
            }
549

550
            return tStamp;
9✔
551
        } else if(stamptype == 'z' || stamptype == '/') {
48✔
552
            // Timestamp is DHM, UTC (z) or local (/).
553
            // Always intepret local to mean local
554
            // to this computer.
555
            let day = parseInt(stamp[1]);
48✔
556
            let hour = parseInt(stamp[2]);
48✔
557
            let minute = parseInt(stamp[3]);
48✔
558

559
            if(day < 1 || day > 31 || hour > 23 || minute > 59) {
48✔
560
                return 0;
3✔
561
            }
562

563
            // If time is under about 12 hours into the future, go there.
564
            // Otherwise get the first matching time in the past.
565
            let ts = new Date();
45✔
566
            let currentTime = Math.floor(ts.getTime() / 1000);
45✔
567
            let cYear;
568
            let cMonth;
569
            let cDay;
570

571
            if (stamptype === 'z') {
45✔
572
                cYear = ts.getUTCFullYear();
42✔
573
                cMonth = ts.getUTCMonth();
42✔
574
                cDay = ts.getUTCDate();
42✔
575
            } else {
576
                cYear = ts.getFullYear();
3✔
577
                cMonth = ts.getMonth()
3✔
578
                cDay = ts.getDate();
3✔
579
            }
580

581
            // Form the possible timestamps in
582
            // this, the next and the previous month
583
            let tmpDate = new Date(cYear, cMonth, cDay, 0, 0, 0, 0);
45✔
584
            tmpDate.setDate(tmpDate.getMonth() + 1);
45✔
585

586
            let fwdYear = tmpDate.getFullYear();
45✔
587
            let fwdMonth = tmpDate.getMonth();
45✔
588

589
            // Calculate back date.
590
            //tmpDate = new Date($cyear, $cmonth - 1, $cday, 0, 0, 0, 0);
591
            tmpDate = new Date(cYear, cMonth, cDay, 0, 0, 0, 0);
45✔
592
            tmpDate.setDate(tmpDate.getMonth() - 1);
45✔
593

594
            let backYear = tmpDate.getFullYear();
45✔
595
            let backMonth = tmpDate.getMonth();
45✔
596

597
            let fwdtstamp = null;
45✔
598
            let currtstamp = null;
45✔
599
            let backtstamp = null;
45✔
600

601
            if(ConversionUtil.CheckDate(cYear, cMonth, day)) {
45✔
602
                if(stamptype === 'z') {
45✔
603
                    //$currtstamp = Date_to_Time($cyear, $cmonth, $day, $hour, $minute, 0);
604
                    currtstamp = Math.floor(new Date(Date.UTC(cYear, cMonth, cDay, hour, minute, 0, 0)).getTime() / 1000);
42✔
605
                } else {
606
                    currtstamp = Math.floor(new Date(cYear, cMonth, day, hour, minute, 0, 0).getTime() / 1000);
3✔
607
                }
608
            }
609

610
            if(ConversionUtil.CheckDate(fwdYear, fwdMonth, day)) {
45✔
611
                if(stamptype === 'z') {
45✔
612
                    fwdtstamp = Math.floor(new Date(Date.UTC(fwdYear, fwdMonth, day, hour, minute, 0, 0)).getTime() / 1000);
42✔
613
                } else {
614
                    fwdtstamp = Math.floor(new Date(cYear, cMonth, day, hour, minute, 0, 0).getTime() / 1000);
3✔
615
                }
616
            }
617

618
            if(ConversionUtil.CheckDate(backYear, backMonth, day)) {
45✔
619
                if(stamptype === 'z') {
45✔
620
                    backtstamp = Math.floor(new Date(Date.UTC(backYear, backMonth, day, hour, minute, 0, 0)).getTime() / 1000);
42✔
621
                } else {
622
                    backtstamp = Math.floor(new Date(cYear, cMonth, day, hour, minute, 0, 0).getTime() / 1000);
3✔
623
                }
624
            }
625

626
            // Select the timestamp to use. Pick the timestamp
627
            // that is largest, but under about 12 hours from
628
            // current time.
629
            if(fwdtstamp && (fwdtstamp - currentTime) < 43400) {
45!
630
                return fwdtstamp;
45✔
631
            } else if(currtstamp && (currtstamp - currentTime) < 43400) {
×
632
                return currtstamp;
×
633
            } else if(backtstamp) {
×
634
                return backtstamp;
×
635
            }
636
        }
637

638
        // return failure if we haven't returned with a success earlier
639
        return 0;
×
640
    }
641

642
    /**
643
     * Parse a message
644
     * possible TODO: ack piggybacking
645
     */
646
    private messageParse(packet: string, retVal: aprsPacket) {
647
        let tmp;
648

649
        // Check format
650
        // x20 - x7e, x80 - xfe
651
        if((tmp = packet.match(/^:([A-Za-z0-9_ -]{9}):([ -~]+)$/))) { // match all ascii printable characters for now
102✔
652
            const message = tmp[2];
99✔
653
            retVal.destination = tmp[1].trim();
99✔
654

655
            // check whether this is an ack
656
            if((tmp = message.match(/^ack([A-Za-z0-9}]{1,5})\s*$/))) {
99✔
657
                // trailing spaces are allowed because some
658
                // broken software insert them..
659
                retVal.messageAck = tmp[1];
18✔
660
                return retVal;
18✔
661
            } else if((tmp = message.match(/^rej([A-Za-z0-9}]{1,5})\s*$/))) {  // check whether this is a message reject
81✔
662
                retVal.messageReject = tmp[1];
18✔
663
                return retVal;
18✔
664
            } else if((tmp = message.match(/^([^{]*)\{([A-Za-z0-9]{1,5})(}[A-Za-z0-9]{1,5}|\}|)\s*$/))) {  // separate message-id from the body, if present
63✔
665
                retVal.message = tmp[1];
54✔
666
                retVal.messageId = tmp[2];
54✔
667

668
                if(tmp.length > 2 && tmp[3] != null && tmp[3].length > 1) {
54✔
669
                    retVal.messageAck = tmp[3].substring(1)
18✔
670
                }
671
            } else {
672
                retVal.message = message;
9✔
673
            }
674

675
            // catch telemetry messages
676
            if(/^(BITS|PARM|UNIT|EQNS)\./i.test(message)) {
63✔
677
                retVal.type = PacketTypeEnum.TELEMETRY_MESSAGE
3✔
678
            }
679

680
            // messages cannot contain |, ~, or {
681
            if(/[|~{]+/.test(retVal.message)) {
63✔
682
                return this.addError(retVal, 'msg_inv');
3✔
683
            }
684

685
            return retVal;
60✔
686
        }
687

688
        return this.addError(retVal, 'msg_inv');
3✔
689
    }
690

691
    /**
692
     * Parse an object
693
     */
694
    private objectToDecimal(options: any, packet: string, srcCallsign: string, retVal: aprsPacket): aprsPacket {
695
        let tmp;
696

697
        // Minimum length for an object is 31 characters
698
        // (or 46 characters for non-compressed)
699
        if(packet.length < 31) {
27✔
700
            return this.addError(retVal, 'obj_short');
3✔
701
        }
702

703
        // Parse the object up to the location
704
        let timeStamp;
705

706
        if((tmp = packet.match(/^;([\x20-\x7e]{9})(\*|_)(\d{6})(z|h|\/)/))) {
24✔
707
            // hash member 'objectname' signals an object
708
            retVal.objectname = tmp[1];
21✔
709
            retVal.alive = (tmp[2] == '*');
21✔
710

711
            timeStamp = tmp[3] + tmp[4];
21✔
712
        } else {
713
            return this.addError(retVal, 'obj_inv');
3✔
714
        }
715

716
        // Check the timestamp for validity and convert
717
        // to UNIX epoch. If the timestamp is invalid, set it
718
        // to zero.
719
        retVal.timestamp = this.parseTimestamp(options, timeStamp);
21✔
720

721
        if(retVal.timestamp == 0) {
21✔
722
            retVal = this.addWarning(retVal, 'timestamp_inv_obj');
3✔
723
        }
724

725
        // Forward the location parsing onwards
726
        let locationOffset = 18; // object location always starts here
21✔
727
        let locationChar = packet.charAt(18);
21✔
728

729
        if(/^[\/\\A-Za-j]$/.test(locationChar)) {
21✔
730
            // compressed
731
            retVal = this._compressed_to_decimal(packet.substring(18, 31), srcCallsign, retVal);
3✔
732
            locationOffset = 31; // now points to APRS data extension/comment
3✔
733
        } else if(/^\d$/i.test(locationChar)) {
18✔
734
            // normal
735
            retVal = this._normalpos_to_decimal(packet.substring(18), srcCallsign, retVal);
12✔
736
            locationOffset = 37; // now points to APRS data extension/comment
12✔
737
        } else {
738
            // error
739
            return this.addError(retVal, 'obj_dec_err');
6✔
740
        }
741

742
        // check to see if another function returned an error... explicit error throwing might cut out a lot of manual work here...
743
        if(retVal.resultCode != undefined && retVal.resultCode) {
15✔
744
            return retVal;
6✔
745
        }
746

747
        // Check the APRS data extension and possible comments,
748
        // unless it is a weather report (we don't want erroneus
749
        // course/speed figures and weather in the comments..)
750
        if(retVal.symbolcode != '_') {
9!
751
            retVal = this._comments_to_decimal(packet.substring(locationOffset), srcCallsign, retVal);
9✔
752
        } else {
753
            // possibly a weather object, try to parse
754
            retVal = this._wx_parse(packet.substring(locationOffset), retVal);
×
755
        }
756

757
        return retVal;
9✔
758
    }
759

760
    /**
761
     * Returns position resolution in meters based on the number
762
     * of minute decimal digits.
763
     *
764
     * Also accepts negative numbers,
765
     * i.e. -1 for 10 minute resolution and -2 for 1 degree resolution.
766
     * Calculation is based on latitude so it is worst case
767
     * (resolution in longitude gets better as you get closer to the poles).
768
     *
769
     * @param {Number} dec Minute decimal digits.
770
     * @returns {Number} Position resolution in meters based on the number of minute decimal digits.
771
     */
772
    private get_posresolution(dec: number): number {
773
        return parseFloat((ConversionConstantEnum.KNOT_TO_KMH * (dec <= -2 ? 600 : 1000) * Math.pow(10, (-1 * dec))).toFixed(4));
162✔
774
    }
775

776
    /**
777
     * Return an NMEA latitude or longitude.
778
     *
779
     * @param {string} value Latitude or Longitude value to convert. (dd)dmm.m(mmm..)
780
     * @param {string} sign North/South or East/West indicator.
781
     * @param {json} retHash JSON object containing the parsed values of the packet.
782
     * @returns {float} The returned value is decimal degrees, North and East are positive.  Value is null if there's an error.
783
     * TODO: should this return the packet instead?
784
     */
785
    private _nmea_getlatlon(value: string, sign: string, rethash: aprsPacket): [ aprsPacket, number ] {
786
        let tmp;
787
        let retVal: number;
788

789
        // upcase the sign for compatibility
790
        sign = sign.toUpperCase();
27✔
791

792
        // Be leninent on what to accept, anything
793
        // goes as long as degrees has 1-3 digits,
794
        // minutes has 2 digits and there is at least
795
        // one decimal minute.
796
        if((tmp = value.match(/^\s*(\d{1,3})([0-5][0-9])\.(\d+)\s*$/))) {
27✔
797
            let minutes = `${tmp[2]}.${tmp[3]}`;
24✔
798

799
            // javascript engines aren't smart enough to convert these to numeric form
800
            retVal = parseFloat(tmp[1]) + (parseFloat(minutes) / 60);
24✔
801

802
            // capture position resolution in meters based
803
            // on the amount of minute decimals present
804
            rethash.posresolution = this.get_posresolution(tmp[3].length);
24✔
805
        } else {
806
            return [ this.addError(rethash, 'nmea_inv_cval', value), null ];
3✔
807
        }
808

809
        if(/^\s*[EW]\s*$/.test(sign)) {
24✔
810
            // make sure the value is ok
811
            if(retVal > 179.999999) {
9✔
812
                return [ this.addError(rethash, 'nmea_large_ew', value), null ];
3✔
813
            }
814

815
            // west negative
816
            if(/^\s*W\s*$/.test(sign)) {
6✔
817
                retVal *= -1;
3✔
818
            }
819
        } else if(/^\s*[NS]\s*$/.test(sign)) {
15✔
820
            // make sure the value is ok
821
            if(retVal > 89.999999) {
12✔
822
                return [ this.addError(rethash, 'nmea_large_ns', value), null ];
3✔
823
            }
824

825
            // south negative
826
            if(/^\s*S\s*$/.test(sign)) {
9✔
827
                retVal *= -1;
3✔
828
            }
829
        } else {
830
            // incorrect sign
831
            return [ this.addError(rethash, 'nmea_inv_sign', sign), null ];
3✔
832
        }
833

834
        // all ok
835
        return [ rethash, retVal ];
15✔
836
    }
837

838
    /**
839
     * return a two element array, first containing
840
     * the symbol table id (or overlay) and second
841
     * containing symbol id. return undef in error
842
     */
843
    private _get_symbol_fromdst(dstCallsign: string): [ string, string] {
844
        let table;
845
        let code;
846
        let tmp;
847

848
        if(tmp = dstCallsign.match(/^(GPS|SPC)([A-Z0-9]{2,3})/)) {
39✔
849
            let leftoverstring = tmp[2];
21✔
850
            let type = leftoverstring.substring(0, 1);
21✔
851
            let sublength = leftoverstring.length;
21✔
852

853
            if(sublength === 3) {
21!
854
                if(type === 'C' || type === 'E') {
×
855
                    let numberid = leftoverstring.substring(1, 2);
×
856

857
                    if(/^(\d{2})$/.test(numberid) && parseInt(numberid) > 0 && parseInt(numberid) < 95) {
×
858
                        code = String.fromCharCode(parseInt(tmp[1]) + 32);
×
859

860
                        if(type === 'C') {
×
861
                            table = '/';
×
862
                        } else {
863
                            table = "\\";
×
864
                        }
865

866
                        return [ table, code ];
×
867
                    } else {
868
                        return [ null, null ];
×
869
                    }
870
                } else {
871
                    // secondary symbol table, with overlay
872
                    // Check first that we really are in the
873
                    // secondary symbol table
874
                    let dsttype = leftoverstring.substring(0, 2);
×
875
                    let overlay = leftoverstring.substring(2, 3);
×
876

877
                    if((type === 'O' || type === 'A' || type === 'N'
×
878
                            || type === 'D' || type === 'S' || type === 'Q')
879
                            && (/^[A-Z0-9]$/).test(overlay)) {
880
                        if(dsttype in DST_SYMBOLS) {
×
881
                            code = DST_SYMBOLS[dsttype].substring(1, 2);
×
882
                            return [ overlay, code ];
×
883
                        } else {
884
                            return [ null, null ];
×
885
                        }
886
                    } else {
887
                        return [ null, null ];
×
888
                    }
889
                }
890
            } else {
891
                // primary or secondary symbol table, no overlay
892
                if(leftoverstring in DST_SYMBOLS) {
21!
893
                    let dstsymbol = DST_SYMBOLS[leftoverstring];
21✔
894
                    table = dstsymbol.substring(0, 1);
21✔
895
                    code = dstsymbol.substring(1, 2);
21✔
896
                    return [ table, code ];
21✔
897
                } else {
898
                    return [ null, null ];
×
899
                }
900
            }
901
        } else {
902
            return [ null, null ];
18✔
903
        }
904
    }
905

906
    /**
907
     * Parse an NMEA location
908
     */
909
    private _nmea_to_decimal(options: any, body: string, srccallsign: string, dstcallsign: string, rethash: aprsPacket): aprsPacket {
910
        let tmp;
911
        /*
912
        if ($debug > 1) {
913
            # print packet, after stripping control chars
914
            my $printbody = $body;
915
            $printbody =~ tr/[\x00-\x1f]//d;
916
            warn "NMEA: from $srccallsign to $dstcallsign: $printbody\n";
917
        }
918
        */
919

920
        // verify checksum first, if it is provided
921
        // trimRight would be preferred, but not supported in all browser engines.
922
        body = body.trimRight() // NOTE: Perl version only trims spaces, not all whitespace
42✔
923

924
        if ((tmp = body.match(/^([\x20-\x7e]+)\*([0-9A-F]{2})$/i))) {
42✔
925
            const checksumarea = tmp[1];
27✔
926

927
            // hex(): Interprets EXPR as a hex string and returns the corresponding numeric value.
928
            let checksumgiven: any = parseInt(tmp[2], 16).toString(10);
27✔
929
            let checksumcalculated = 0;
27✔
930

931
            for (var i = 0; i < checksumarea.length; i++) {
27✔
932
                checksumcalculated ^= checksumarea.charCodeAt(i);
1,590✔
933
            }
934

935
            if(checksumgiven != checksumcalculated.toString()) {
27✔
936
                // invalid checksum
937
                return this.addError(rethash, 'nmea_inv_cksum');
3✔
938
            }
939

940
            // make a note of the existance of a checksum
941
            rethash.checksumok = true;
24✔
942
        }
943

944
        // checksum ok or not provided
945
        rethash.format = 'nmea';
39✔
946

947
        // use a dot as a default symbol if one is not defined in
948
        // the destination callsign
949
        let [ symtable, symcode ] = this._get_symbol_fromdst(dstcallsign);
39✔
950

951
        if(!symtable || !symcode) {
39✔
952
            rethash.symboltable = '/';
18✔
953
            rethash.symbolcode = '/';
18✔
954
        } else {
955
            rethash.symboltable = symtable;
21✔
956
            rethash.symbolcode = symcode;
21✔
957
        }
958

959
        // Split to NMEA fields
960
        body = body.replace(/\*[0-9A-F]{2}$/, '');    // remove checksum from body first
39✔
961
        let nmeafields = body.split(',');
39✔
962

963
        // Now check the sentence type and get as much info
964
        // as we can (want).
965
        if(nmeafields[0] == 'GPRMC') {
39✔
966
            // we want at least 10 fields
967
            if(nmeafields.length < 10) {
36✔
968
                return this.addError(rethash, 'gprmc_fewfields', nmeafields);
3✔
969
            }
970

971
            if(nmeafields[2] != 'A') {
33✔
972
                // invalid position
973
                return this.addError(rethash, 'gprmc_nofix');
3✔
974
            }
975

976
            // check and save the timestamp
977
            let hour;
978
            let minute;
979
            let second;
980

981
            if((tmp = nmeafields[1].match(/^\s*(\d{2})(\d{2})(\d{2})(|\.\d+)\s*$/))) {
30✔
982
                // if seconds has a decimal part, ignore it
983
                // leap seconds are not taken into account...
984
                if(parseInt(tmp[1]) > 23 || parseInt(tmp[2]) > 59 || parseInt(tmp[3]) > 59) {
24✔
985
                    return this.addError(rethash, 'gprmc_inv_time', nmeafields[1]);
3✔
986
                }
987

988
                hour = parseInt(tmp[1]);
21✔
989
                minute = parseInt(tmp[2]);
21✔
990
                second = parseInt(tmp[3]);
21✔
991
            } else {
992
                return this.addError(rethash, 'gprmc_inv_time');
6✔
993
            }
994

995
            let year: number;
996
            let month: number;
997
            let day: number;
998

999
            if((tmp = nmeafields[9].match(/^\s*(\d{2})(\d{2})(\d{2})\s*$/))) {
21✔
1000
                // check the date for validity. Assume
1001
                // years 0-69 are 21st century and years
1002
                // 70-99 are 20th century
1003
                year = 2000 + parseInt(tmp[3]);
18✔
1004

1005
                if(parseInt(tmp[3]) >= 70) {
18!
1006
                    year = 1900 + parseInt(tmp[3]);
×
1007
                }
1008

1009
                // check for invalid date
1010
                // javascript months are 0 based
1011
                if(!(ConversionUtil.CheckDate(year, parseInt(tmp[2]) - 1, parseInt(tmp[1])))) {
18!
1012
                    return this.addError(rethash, 'gprmc_inv_date', `${year} ${parseInt(tmp[2]) - 1} ${tmp[1]}`);
×
1013
                }
1014

1015
                // javascript months are 0 based
1016
                month = parseInt(tmp[2]) - 1; // force numeric
18✔
1017
                day = parseInt(tmp[1]);
18✔
1018
            } else {
1019
                return this.addError(rethash, 'gprmc_inv_date');
3✔
1020
            }
1021

1022
            // TODO: This isn't true for javascript - https://stackoverflow.com/questions/11526504/minimum-and-maximum-date
1023
            // Date_to_Time() can only handle 32-bit unix timestamps,
1024
            // so make sure it is not used for those years that
1025
            // are outside that range.
1026
            if(year >= 2038 || year < 1970) {
18!
1027
                rethash.timestamp = 0;
×
1028
                return this.addError(rethash, 'gprmc_date_out', year);
×
1029
            } else {
1030
                let d = new Date(Date.UTC(year, month, day, hour, minute, second, 0));
18✔
1031

1032
                rethash.timestamp = d.getTime() / 1000;
18✔
1033
            }
1034

1035
            // speed (knots) and course, make these optional
1036
            // in the parsing sense (don't fail if speed/course
1037
            // can't be decoded).
1038
            if((tmp = nmeafields[7].match(/^\s*(\d+(|\.\d+))\s*$/))) {
18✔
1039
                // convert to km/h
1040
                rethash.speed = parseFloat(tmp[1]) * ConversionConstantEnum.KNOT_TO_KMH;
18✔
1041
            }
1042

1043
            if((tmp = nmeafields[8].match(/^\s*(\d+(|\.\d+))\s*$/))) {
18!
1044
                // round to nearest integer
1045
                let course = Math.round((parseFloat(tmp[1]) + 0.5));
18✔
1046

1047
                // if zero, set to 360 because in APRS
1048
                // zero means invalid course...
1049
                if(course == 0) {
18!
1050
                    course = 360;
×
1051
                } else if(course > 360) {
18!
1052
                    course = 0; // invalid
×
1053
                }
1054

1055
                rethash.course = course;
18✔
1056
            } else {
1057
                rethash.course = 0; // unknown
×
1058
            }
1059

1060
            // latitude and longitude
1061
            let latitude: number;
1062
            [ rethash, latitude ] = this._nmea_getlatlon(nmeafields[3], nmeafields[4], rethash);
18✔
1063

1064
            if(latitude === undefined || !latitude) {
18✔
1065
                return rethash;
9✔
1066
            }
1067

1068
            rethash.latitude = latitude;
9✔
1069

1070
            let longitude: number;
1071
            [ rethash, longitude ] = this._nmea_getlatlon(nmeafields[5], nmeafields[6], rethash);
9✔
1072

1073
            if(longitude === undefined || !longitude) {
9✔
1074
                return rethash;
3✔
1075
            }
1076

1077
            rethash.longitude = longitude;
6✔
1078

1079
            // we have everything we want, return
1080
            return rethash;
6✔
1081
        }
1082
        /*
1083
            else if(nmeafields[0] == 'GPGGA') {
1084

1085
            # we want at least 11 fields
1086
            if (@nmeafields < 11) {
1087
                addError($rethash, 'gpgga_fewfields', scalar(@nmeafields));
1088
                return 0;
1089
            }
1090

1091
            # check for position validity
1092
            if ($nmeafields[6] =~ /^\s*(\d+)\s*$/o) {
1093
                if ($1 < 1) {
1094
                    addError($rethash, 'gpgga_nofix', $1);
1095
                    return 0;
1096
                }
1097
            } else {
1098
                addError($rethash, 'gpgga_nofix');
1099
                return 0;
1100
            }
1101

1102
            # Use the APRS time parsing routines to check
1103
            # the time and convert it to timestamp.
1104
            # But before that, remove a possible decimal part
1105
            $nmeafields[1] =~ s/\.\d+$//;
1106
            $rethash->{'timestamp'} = _parse_timestamp($options, $nmeafields[1] . 'h');
1107
            if ($rethash->{'timestamp'} == 0) {
1108
                addError($rethash, 'timestamp_inv_gpgga');
1109
                return 0;
1110
            }
1111

1112
            # latitude and longitude
1113
            my $latitude = _nmea_getlatlon($nmeafields[2], $nmeafields[3], $rethash);
1114
            if (not(defined($latitude))) {
1115
                return 0;
1116
            }
1117
            $rethash->{'latitude'} = $latitude;
1118
            my $longitude = _nmea_getlatlon($nmeafields[4], $nmeafields[5], $rethash);
1119
            if (not(defined($longitude))) {
1120
                return 0;
1121
            }
1122
            $rethash->{'longitude'} = $longitude;
1123

1124
            # altitude, only meters are accepted
1125
            if ($nmeafields[10] eq 'M' &&
1126
                $nmeafields[9] =~ /^(-?\d+(|\.\d+))$/o) {
1127
                # force numeric interpretation
1128
                $rethash->{'altitude'} = $1 + 0;
1129
            }
1130

1131
            # ok
1132
            return 1;
1133
            *
1134
        } else if(nmeafields[0] == 'GPGLL') {
1135
            /*
1136
            # we want at least 5 fields
1137
            if (@nmeafields < 5) {
1138
                addError($rethash, 'gpgll_fewfields', scalar(@nmeafields));
1139
                return 0;
1140
            }
1141

1142
            # latitude and longitude
1143
            my $latitude = _nmea_getlatlon($nmeafields[1], $nmeafields[2], $rethash);
1144
            if (not(defined($latitude))) {
1145
                return 0;
1146
            }
1147
            $rethash->{'latitude'} = $latitude;
1148
            my $longitude = _nmea_getlatlon($nmeafields[3], $nmeafields[4], $rethash);
1149
            if (not(defined($longitude))) {
1150
                return 0;
1151
            }
1152
            $rethash->{'longitude'} = $longitude;
1153

1154
            # Use the APRS time parsing routines to check
1155
            # the time and convert it to timestamp.
1156
            # But before that, remove a possible decimal part
1157
            if (@nmeafields >= 6) {
1158
                $nmeafields[5] =~ s/\.\d+$//;
1159
                $rethash->{'timestamp'} = _parse_timestamp($options, $nmeafields[5] . 'h');
1160
                if ($rethash->{'timestamp'} == 0) {
1161
                    addError($rethash, 'timestamp_inv_gpgll');
1162
                    return 0;
1163
                }
1164
            }
1165

1166
            if (@nmeafields >= 7) {
1167
                # GPS fix validity supplied
1168
                if ($nmeafields[6] ne 'A') {
1169
                    addError($rethash, 'gpgll_nofix');
1170
                    return 0;
1171
                }
1172
            }
1173

1174
            # ok
1175
            return 1;
1176
            *
1177
        //} elsif ($nmeafields[0] eq 'GPVTG') {
1178
        //} elsif ($nmeafields[0] eq 'GPWPT') {
1179
        }
1180
        */
1181
        else {
1182
            return this.addError(
3✔
1183
                rethash
1184
                , 'nmea_unsupp'
1185
                , nmeafields[0].replace(/[\x00-\x1f]/, (x) => { return parseInt(x, 16).toString(16) })
×
1186
            )
1187
        }
1188
    }
1189

1190
    /**
1191
     * Parse the possible APRS data extension
1192
     * as well as comment
1193
     */
1194
    private _comments_to_decimal(rest: string, srccallsign: string, rethash: aprsPacket): aprsPacket {
1195
        let tmprest;
1196

1197
        // First check the possible APRS data extension,
1198
        // immediately following the packet
1199
        if(rest.length >= 7) {
81✔
1200
            if(/^([0-9. ]{3})\/([0-9. ]{3})/.test(rest)) {
72✔
1201
                let [ , course, speed ] = rest.match(/^([0-9. ]{3})\/([0-9. ]{3})/);
39✔
1202
                let match;
1203

1204
                if(/^\d{3}$/.test(course) &&
39✔
1205
                        parseInt(course) <= 360 &&
1206
                        parseInt(course) >= 1) {
1207
                    // force numeric interpretation
1208
                    rethash.course = parseInt(course);
36✔
1209
                } else {
1210
                    // course is invalid, set it to zero
1211
                    rethash.course = 0;
3✔
1212
                }
1213

1214
                // If speed is invalid, don't set it
1215
                // (zero speed is a valid speed).
1216
                if(/^\d{3}$/.test(speed)) {
39✔
1217
                    // force numeric interpretation
1218
                    // and convert to km/h
1219
                    rethash.speed = parseInt(speed) * ConversionConstantEnum.KNOT_TO_KMH;
36✔
1220
                }
1221

1222
                rest = rest.substring(7);
39✔
1223
            } else if((tmprest = rest.match(/^PHG(\d[\x30-\x7e]\d\d[0-9A-Z])\//))) {
33✔
1224
                // PHGR
1225
                rethash.phg = tmprest[1];
3✔
1226
                rest = rest.substring(8);
3✔
1227
            } else if((tmprest = rest.match(/^PHG(\d[\x30-\x7e]\d\d)/))) {
30✔
1228
                // don't do anything fancy with PHG, just store it
1229
                rethash.phg = tmprest[1];
15✔
1230
                rest = rest.substring(7);
15✔
1231
            } else if((tmprest = rest.match(/^RNG(\d{4})/))) {
15!
1232
                // radio range, in miles, so convert to km
1233
                rethash.radiorange = parseInt(tmprest[1]) * ConversionConstantEnum.MPH_TO_KMH;
×
1234
                rest = rest.substring(7);
×
1235
            }
1236
        }
1237

1238
        // Check for optional altitude anywhere in the comment,
1239
        // take the first occurrence
1240
        if((tmprest = rest.match(/^(.*?)\/A=(-\d{5}|\d{6})(.*)$/))) {
81✔
1241
            // convert to meters as well
1242
            rethash.altitude = parseFloat(tmprest[2]) * ConversionConstantEnum.FEET_TO_METERS;
33✔
1243
            rest = tmprest[1] + tmprest[3];
33✔
1244
        }
1245

1246
        // Check for new-style base-91 comment telemetry - ISSUE HERE
1247
        [ rest, rethash ] = this._comment_telemetry(rethash, rest);
81✔
1248

1249
        // Check for !DAO!, take the last occurrence (per recommendation)
1250
        if((tmprest = rest.match(/^(.*)\!([\x21-\x7b][\x20-\x7b]{2})\!(.*?)$/))) {
81✔
1251
            let found = false;
18✔
1252
            [ rethash, found ] = this._dao_parse(tmprest[2], srccallsign, rethash);
18✔
1253

1254
            if(found === true) {
18✔
1255
                rest = tmprest[1] + tmprest[3];
18✔
1256
            }
1257
        }
1258

1259
        // Strip a / or a ' ' from the beginning of a comment
1260
        // (delimiter after PHG or other data stuffed within the comment)
1261
        rest = rest.replace(/^[\/\s]/, '');
81✔
1262

1263
        // Save the rest as a separate comment, if
1264
        // anything is left (trim unprintable chars
1265
        // out first and white space from both ends)
1266
        if(rest.length > 0) {
81✔
1267
            rethash.comment = rest.trim();
60✔
1268
        }
1269

1270
        // Always succeed as these are optional
1271
        return rethash;
81✔
1272
    }
1273

1274
    /**
1275
     * Parse a station capabilities packet
1276
     */
1277
    private _capabilities_parse(packet: string, srccallsign: string, rethash: aprsPacket): aprsPacket {
1278
        /*
1279
        # Remove CRs, LFs and trailing spaces
1280
        $packet =~ tr/\r\n//d;
1281
        $packet =~ s/\s+$//;
1282
        # Then just split the packet, we aren't too picky about the format here.
1283
        # Also duplicates and case changes are not handled in any way,
1284
        # so the last part will override an earlier part and different
1285
        # cases can be present. Just remove trailing/leading spaces.
1286
        my @caps = split(/,/, $packet);
1287
        my %caphash = ();
1288
        foreach my $cap (@caps) {
1289
            if ($cap =~ /^\s*([^=]+?)\s*=\s*(.*?)\s*$/o) {
1290
                # TOKEN=VALUE
1291
                $caphash{$1} = $2;
1292
            } elsif ($cap =~ /^\s*([^=]+?)\s*$/o) {
1293
                # just TOKEN
1294
                $caphash{$1} = undef;
1295
            }
1296
        }
1297

1298
        my $keycount = keys(%caphash);
1299
        if ($keycount > 0) {
1300
            # store the capabilities in the return hash
1301
            $rethash->{'capabilities'} = \%caphash;
1302
            return 1;
1303
        }
1304
        */
1305

1306
        // at least one capability has to be defined for a capability
1307
        // packet to be counted as valid
1308
        // return 0;
1309
        return rethash;
3✔
1310
    }
1311

1312
    private _comment_telemetry(rethash: aprsPacket, rest: string): [ string, aprsPacket ] {
1313
        rest = rest.replace(/^(.*)\|([!-{]{2})([!-{]{2})([!-{]{2}|)([!-{]{2}|)([!-{]{2}|)([!-{]{2}|)([!-{]{2}|)\|(.*)$/, function(a, b, c, d, e, f, g, h, i, j) {
117✔
1314
            rethash.telemetry = new telemetry(
21✔
1315
                ((c.charCodeAt(0) - 33) * 91) + ((c.charCodeAt(1) - 33))
1316
                , [
1317
                    ((d.charCodeAt(0) - 33) * 91) + (d.charCodeAt(1) - 33)
1318
                    , e != '' ? ((e.charCodeAt(0) - 33) * 91) + ((e.charCodeAt(1) - 33)) : null
21✔
1319
                    , f != '' ? ((f.charCodeAt(0) - 33) * 91) + ((f.charCodeAt(1) - 33)) : null
21✔
1320
                    , g != '' ? ((g.charCodeAt(0) - 33) * 91) + ((g.charCodeAt(1) - 33)) : null
21✔
1321
                    , h != '' ? ((h.charCodeAt(0) - 33) * 91) + ((h.charCodeAt(1) - 33)) : null
21✔
1322
                ]
1323
            );
1324

1325
            if(i != '') {
21✔
1326
                // bits: first, decode the base-91 integer
1327
                let bitint = (((i.charCodeAt(0) - 33) * 91) + ((i.charCodeAt(1) - 33)));
15✔
1328

1329
                // then, decode the 8 bits of telemetry
1330
                let bitstr = (bitint << 7).toString(2)
15✔
1331

1332
                rethash.telemetry.bits = '00000000'.substring(0, 8 - bitstr.length) + bitstr; //unpack('b8', pack('C', $bitint));
15✔
1333
            }
1334

1335
            return b + j;
21✔
1336
        });
1337

1338
        return [ rest, rethash ];
117✔
1339
    }
1340

1341
    /**
1342
     * Parse an item
1343
     */
1344
    private _item_to_decimal(packet: string, srccallsign: string, rethash: aprsPacket): aprsPacket {
1345
        let tmp;
1346

1347
        // Minimum length for an item is 18 characters
1348
        // (or 24 characters for non-compressed)
1349
        if(packet.length < 18) {
18✔
1350
            return this.addError(rethash, 'item_short');
3✔
1351
        }
1352

1353
        // Parse the item up to the location
1354
        if((tmp = packet.match(/^\)([\x20\x22-\x5e\x60-\x7e]{3,9})(!|_)/))) {
15!
1355
            // hash member 'itemname' signals an item
1356
            rethash.itemname = tmp[1];
15✔
1357

1358
            if(tmp[2] == '!') {
15✔
1359
                rethash.alive = true;
12✔
1360
            } else {
1361
                rethash.alive = false;
3✔
1362
            }
1363
        } else {
1364
            return this.addError(rethash, 'item_inv');
×
1365
        }
1366

1367
        // Forward the location parsing onwards
1368
        let locationoffset = 2 + rethash.itemname.length;
15✔
1369
        let locationchar = packet.charAt(locationoffset);
15✔
1370

1371
        if(/^[\/\\A-Za-j]$/.test(locationchar)) {
15✔
1372
            // compressed
1373
            rethash = this._compressed_to_decimal(packet.substring(locationoffset, locationoffset + 13), srccallsign, rethash);
3✔
1374
            locationoffset += 13;
3✔
1375
        } else if(/^\d$/i.test(locationchar)) {
12✔
1376
            // normal
1377
            rethash = this._normalpos_to_decimal(packet.substring(locationoffset), srccallsign, rethash);
9✔
1378
            locationoffset += 19;
9✔
1379
        } else {
1380
            // error
1381
            return this.addError(rethash, 'item_dec_err');
3✔
1382
        }
1383

1384
        // check to see if another function returned an error... explicit error throwing might cut out a lot of manual work here...
1385
        if(rethash.resultCode !== undefined && rethash.resultCode) {
12✔
1386
            return rethash;
3✔
1387
        }
1388

1389
        // Check the APRS data extension and possible comments,
1390
        // unless it is a weather report (we don't want erroneus
1391
        // course/speed figures and weather in the comments..)
1392
        if(rethash.symbolcode != '_') {
9✔
1393
            rethash = this._comments_to_decimal(packet.substring(locationoffset), srccallsign, rethash);
9✔
1394
        }
1395

1396
        return rethash;
9✔
1397
    }
1398

1399
    /**
1400
     * Parse a normal uncompressed location
1401
     */
1402
    private _normalpos_to_decimal(packet: string, srccallsign: string, rethash: aprsPacket): aprsPacket {
1403
        // Check the length
1404
        if(packet.length < 19) {
96✔
1405
            return this.addError(rethash, 'loc_short');
6✔
1406
        }
1407

1408
        rethash.format = 'uncompressed';
90✔
1409

1410
        // Make a more detailed check on the format, but do the
1411
        // actual value checks later
1412
        let lon_deg;
1413
        let lat_deg;
1414
        let lon_min;
1415
        let lat_min;
1416
        let issouth = 0;
90✔
1417
        let iswest = 0;
90✔
1418
        let symboltable;
1419
        let matches;
1420

1421
        if((matches = packet.match(/^(\d{2})([0-7 ][0-9 ]\.[0-9 ]{2})([NnSs])(.)(\d{3})([0-7 ][0-9 ]\.[0-9 ]{2})([EeWw])([\x21-\x7b\x7d])/))) {
90✔
1422
            let sind = matches[3].toUpperCase();
87✔
1423
            let wind = matches[7].toUpperCase();
87✔
1424

1425
            symboltable = matches[4];
87✔
1426

1427
            rethash.symbolcode = matches[8];
87✔
1428

1429
            if(sind == 'S') {
87✔
1430
                issouth = 1;
21✔
1431
            }
1432

1433
            if(wind == 'W') {
87✔
1434
                iswest = 1;
48✔
1435
            }
1436

1437
            lat_deg = matches[1];
87✔
1438
            lat_min = matches[2];
87✔
1439
            lon_deg = matches[5];
87✔
1440
            lon_min = matches[6];
87✔
1441
        } else {
1442
            return this.addError(rethash, 'loc_inv');
3✔
1443
        }
1444

1445
        if(!symboltable.match(/^[\/\\A-Z0-9]$/)) {
87✔
1446
            return this.addError(rethash, 'sym_inv_table');
3✔
1447
        }
1448

1449
        rethash.symboltable = symboltable;
84✔
1450

1451
        // Check the degree values
1452
        if(parseInt(lat_deg) > 89 || parseInt(lon_deg) > 179) {
84✔
1453
            return this.addError(rethash, 'loc_large');
3✔
1454
        }
1455

1456
        // Find out the amount of position ambiguity
1457
        let tmplat = lat_min.replace(/\./, '');
81✔
1458

1459
        // Count the amount of spaces at the end
1460
        if((matches = tmplat.match(/^(\d{0,4})( {0,4})$/i))) {
81!
1461
            rethash.posambiguity = matches[2].length;
81✔
1462
        } else {
1463
            return this.addError(rethash, 'loc_amb_inv');
×
1464
        }
1465

1466
        let latitude: number;
1467
        let longitude: number;
1468

1469
        if(rethash.posambiguity == 0) {
81✔
1470
            // No position ambiguity. Check longitude for invalid spaces
1471
            if(lon_min.match(/ /)) {
72!
1472
                return this.addError(rethash, 'loc_amb_inv', 'longitude 0');
×
1473
            }
1474

1475
            latitude = parseFloat(lat_deg) + (parseFloat(lat_min) / 60);
72✔
1476
            longitude = parseFloat(lon_deg) + (parseFloat(lon_min) / 60);
72✔
1477
        } else if(rethash.posambiguity == 4) {
9✔
1478
            // disregard the minutes and add 0.5 to the degree values
1479
            latitude = parseFloat(lat_deg) + 0.5;
3✔
1480
            longitude = parseFloat(lon_deg) + 0.5;
3✔
1481
        } else if(rethash.posambiguity == 1) {
6!
1482
            // the last digit is not used
1483
            lat_min = lat_min.substring(0, 4);
×
1484
            lon_min = lon_min.substring(0, 4);
×
1485

1486
            if(lat_min.match(/ /i) || lon_min.match(/ /i)) {
×
1487
                return this.addError(rethash, 'loc_amb_inv', 'lat/lon 1');
×
1488
            }
1489

1490
            latitude = parseFloat(lat_deg) + ((parseFloat(lat_min) + 0.05) / 60);
×
1491
            longitude = parseFloat(lon_deg) + ((parseFloat(lon_min) + 0.05) / 60);
×
1492
        } else if(rethash.posambiguity == 2) {
6✔
1493
            // the minute decimals are not used
1494
            lat_min = lat_min.substring(0, 2);
3✔
1495
            lon_min = lon_min.substring(0, 2);
3✔
1496

1497
            if(lat_min.match(/ /i) || lon_min.match(/ /i)) {
3✔
1498
                return this.addError(rethash, 'loc_amb_inv', 'lat/lon 2');
3✔
1499
            }
1500

1501
            latitude = parseFloat(lat_deg) + ((parseFloat(lat_min) + 0.5) / 60);
×
1502
            longitude = parseFloat(lon_deg) + ((parseFloat(lon_min) + 0.5) / 60);
×
1503
        } else if(rethash.posambiguity == 3) {
3!
1504
            // the single minutes are not used
1505
            lat_min = lat_min.charAt(0) + '5';
3✔
1506
            lon_min = lon_min.charAt(0) + '5';
3✔
1507

1508
            if(lat_min.match(/ /i) || lon_min.match(/ /i)) {
3!
1509
                return this.addError(rethash, 'loc_amb_inv', 'lat/lon 3');
×
1510
            }
1511

1512
            latitude = parseFloat(lat_deg) + (parseFloat(lat_min) / 60);
3✔
1513
            longitude = parseFloat(lon_deg) + (parseFloat(lon_min) / 60);
3✔
1514
        } else {
1515
            return this.addError(rethash, 'loc_amb_inv');
×
1516
        }
1517

1518
        // Finally apply south/west indicators
1519
        if(issouth == 1) {
78✔
1520
            latitude = 0 - latitude;
21✔
1521
        }
1522

1523
        if(iswest == 1) {
78✔
1524
            longitude = 0 - longitude;
48✔
1525
        }
1526

1527
        // Store the locations
1528
        // TODO: Are these supposed to be fixed to 4 decimal places?
1529
        rethash.latitude = latitude;
78✔
1530
        rethash.longitude = longitude;
78✔
1531

1532
        // Calculate position resolution based on position ambiguity
1533
        // calculated above.
1534
        rethash.posresolution = this.get_posresolution(2 - rethash.posambiguity);
78✔
1535

1536
        // Parse possible APRS data extension
1537
        // afterwards along with comments
1538
        return rethash;
78✔
1539
    }
1540

1541
    /**
1542
     * convert a mic-encoder packet
1543
     */
1544
    private miceToDecimal(packet: string, dstcallsign: string, srccallsign: string, rethash: aprsPacket, options: any): aprsPacket {
1545
        let tmp: any;
1546
        rethash.format = 'mice';
54✔
1547

1548
        // We only want the base callsign
1549
        dstcallsign = dstcallsign.replace(/-\d+$/, '');
54✔
1550

1551
        // Check the format
1552
        if(packet.length < 8 || dstcallsign.length != 6) {
54✔
1553
            // too short packet to be mic-e
1554
            return this.addError(rethash, 'mice_short');
3✔
1555
        }
1556

1557
        if(!(/^[0-9A-LP-Z]{3}[0-9LP-Z]{3}$/i.test(dstcallsign))) {
51✔
1558
            // A-K characters are not used in the last 3 characters
1559
            // and MNO are never used
1560
            return this.addError(rethash, 'mice_inv');
3✔
1561
        }
1562

1563
        // check the information field (longitude, course, speed and
1564
        // symbol table and code are checked). Not bullet proof..
1565
        let mice_fixed: boolean = false;
48✔
1566
        let symboltable = packet.charAt(7);
48✔
1567

1568
        if(!(tmp = packet.match(/^[\x26-\x7f][\x26-\x61][\x1c-\x7f]{2}[\x1c-\x7d][\x1c-\x7f][\x21-\x7b\x7d][\/\\A-Z0-9]/))) {
48✔
1569
            // If the accept_broken_mice option is given, check for a known
1570
            // corruption in the packets and try to fix it - aprsd is
1571
            // replacing some valid but non-printable mic-e packet
1572
            // characters with spaces, and some other software is replacing
1573
            // the multiple spaces with a single space. This regexp
1574
            // replaces the single space with two spaces, so that the rest
1575
            // of the code can still parse the position data.
1576

1577
            if(options && options['accept_broken_mice']
12✔
1578
                    && (packet = packet.replace(/^([\x26-\x7f][\x26-\x61][\x1c-\x7f]{2})\x20([\x21-\x7b\x7d][\/\\A-Z0-9])(.*)/, '$1\x20\x20$2$3'))) {
1579
                mice_fixed = true;
6✔
1580
                // Now the symbol table identifier is again in the correct spot...
1581
                symboltable = packet.charAt(7);
6✔
1582

1583
                if(!/^[\/\\A-Z0-9]$/.test(symboltable)) {
6✔
1584
                    return this.addError(rethash, 'sym_inv_table');
3✔
1585
                }
1586
            } else {
1587
                // Get a more precise error message for invalid symbol table
1588
                if(!(/^[\/\\A-Z0-9]$/.test(symboltable))) {
6✔
1589
                    return this.addError(rethash, 'sym_inv_table');
3✔
1590
                } else {
1591
                    return this.addError(rethash, 'mice_inv_info');
3✔
1592
                }
1593
            }
1594
        }
1595

1596
        // First do the destination callsign
1597
        // (latitude, message bits, N/S and W/E indicators and long. offset)
1598

1599
        // Translate the characters to get the latitude
1600
        let tmplat: any = dstcallsign.toUpperCase();
39✔
1601
        tmplat = tmplat.split('');
39✔
1602
        tmp = '';
39✔
1603

1604
        // /A-JP-YKLZ/0-90-9___/ <- Unfortunately, JavaScript isn't as cool as Perl
1605
        // Lets discrace Perl's awesomeness and use a loop instead.
1606
        tmplat.forEach(function(c: string) {
39✔
1607
            if(/[A-J]/.test(c)) {
234!
1608
                tmp += (c.charCodeAt(0) - 65);
×
1609
            } else if(/[P-Y]/.test(c)) {
234✔
1610
                tmp += (c.charCodeAt(0) - 80);
138✔
1611
            } else if(/[KLZ]/.test(c)) {
96✔
1612
                tmp += '_';
3✔
1613
            } else {
1614
                tmp += c;
93✔
1615
            }
1616
        });
1617

1618
        tmplat = tmp;
39✔
1619

1620
        // Find out the amount of position ambiguity
1621
        if((tmp = tmplat.match(/^(\d+)(_*)$/i))) {
39✔
1622
            let amount = 6 - tmp[1].length;
36✔
1623

1624
            if(amount > 4) {
36!
1625
                // only minutes and decimal minutes can
1626
                // be masked out
1627
                return this.addError(rethash, 'mice_amb_large');
×
1628
            }
1629

1630
            rethash.posambiguity = amount;
36✔
1631

1632
            // Calculate position resolution based on position ambiguity
1633
            // calculated above.
1634
            rethash.posresolution = this.get_posresolution(2 - amount);
36✔
1635
        } else {
1636
            // no digits in the beginning, baaad..
1637
            // or the ambiguity digits weren't continuous
1638
            return this.addError(rethash, 'mice_amb_inv');
3✔
1639
        }
1640

1641
        // convert the latitude to the midvalue if position ambiguity
1642
        // is used
1643
        if(rethash.posambiguity >= 4) {
36!
1644
            // the minute is between 0 and 60, so
1645
            // the middle point is 30
1646
            tmplat = tmplat.replace('_', '3');
×
1647
        } else {
1648
            tmplat = tmplat.replace('_', '5');  // the first is changed to digit 5
36✔
1649
        }
1650

1651
        tmplat = tmplat.replace(/_/g, '0'); // the rest are changed to digit 0
36✔
1652

1653
        // get the degrees
1654
        let latitude = tmplat.substring(0, 2);
36✔
1655

1656
        // the minutes
1657
        let latminutes = tmplat.substring(2, 4) + '.' + tmplat.substring(4, 6);
36✔
1658

1659
        // convert the minutes to decimal degrees and combine
1660
        latitude = parseFloat(latitude) + (parseFloat(latminutes) / 60);
36✔
1661

1662
        // check the north/south direction and correct the latitude
1663
        // if necessary
1664
        let nschar = dstcallsign.charCodeAt(3);
36✔
1665

1666
        if(nschar <= 0x4c) {
36✔
1667
            latitude = (0 - parseFloat(latitude));
12✔
1668
        }
1669

1670
        // Latitude is finally complete, so store it
1671
        rethash.latitude = latitude;
36✔
1672

1673
        // Get the message bits. 1 is standard one-bit and
1674
        // 2 is custom one-bit. mice_messagetypes provides
1675
        // the mappings to message names
1676
        let mbitstring = dstcallsign.substring(0, 3);
36✔
1677

1678
        mbitstring = mbitstring.replace(/[0-9L]/g, '0');
36✔
1679
        mbitstring = mbitstring.replace(/[P-Z]/g, '1');
36✔
1680
        mbitstring = mbitstring.replace(/[A-K]/g, '2');
36✔
1681

1682
        rethash.mbits = mbitstring;
36✔
1683

1684
        // Decode the longitude, the first three bytes of the
1685
        // body after the data type indicator.
1686
        // First longitude degrees, remember the longitude offset
1687
        let longitude = packet.charCodeAt(0) - 28;
36✔
1688
        let longoffsetchar = dstcallsign.charCodeAt(4);
36✔
1689

1690
        if(longoffsetchar >= 80) {
36✔
1691
            longitude = longitude + 100;
15✔
1692
        }
1693

1694
        if(longitude >= 180 && longitude <= 189) {
36!
1695
            longitude = longitude - 80;
×
1696
        } else if(longitude >= 190 && longitude <= 199) {
36!
1697
            longitude = longitude - 190;
×
1698
        }
1699

1700
        // Decode the longitude minutes
1701
        let longminutes: any = packet.charCodeAt(1) - 28;
36✔
1702

1703
        if(longminutes >= 60) {
36✔
1704
            longminutes -= 60;
3✔
1705
        }
1706

1707
        // ... and minute decimals
1708
        longminutes = longminutes + '.' + (packet.charCodeAt(2) - 28).toString().padStart(2, '0');
36✔
1709

1710
        // apply position ambiguity to longitude
1711
        if(rethash.posambiguity == 4) {
36!
1712
            // minute is unused -> add 0.5 degrees to longitude
1713
            longitude += 0.5;
×
1714
        } else if(rethash.posambiguity == 3) {
36!
1715
            let $lontmp = longminutes.charAt(0) + '5';
×
1716
            longitude = longitude + (parseFloat($lontmp) / 60);
×
1717
        } else if(rethash.posambiguity == 2) {
36!
1718
            let $lontmp = longminutes.substring(0, 2) + '.5';
×
1719
            longitude = longitude + (parseFloat($lontmp) / 60);
×
1720
        } else if(rethash.posambiguity == 1) {
36!
1721
            let $lontmp = longminutes.substring(0, 4) + '5';
×
1722
            longitude = (longitude + (parseFloat($lontmp) / 60));
×
1723
        } else if(rethash.posambiguity == 0) {
36!
1724
            longitude = longitude + (parseFloat(longminutes) / 60);
36✔
1725
        } else {
1726
            return this.addError(rethash, 'mice_amb_odd', rethash.posambiguity.toString());
×
1727
        }
1728

1729
        // check the longitude E/W sign
1730
        if(dstcallsign.charCodeAt(5) >= 80) {
36✔
1731
            longitude = longitude * -1;
15✔
1732
        }
1733

1734
        // Longitude is finally complete, so store it
1735
        rethash.longitude = longitude;
36✔
1736

1737
        // Now onto speed and course.
1738
        // If the packet has had a mic-e fix applied, course and speed are likely to be off.
1739
        if(mice_fixed == false) {
36✔
1740
            let speed = ((packet.charCodeAt(3)) - 28) * 10;
33✔
1741
            let coursespeed = (packet.charCodeAt(4)) - 28;
33✔
1742
            let coursespeedtmp = Math.floor(coursespeed / 10);  // had been parseint... changed to math.floor because tests started failing.
33✔
1743

1744
            speed += coursespeedtmp;
33✔
1745
            coursespeed -= coursespeedtmp * 10;
33✔
1746

1747
            let course = (100 * coursespeed) + (packet.charCodeAt(5) - 28);
33✔
1748

1749
            if(course >= 400) {
33✔
1750
                course -= 400;
33✔
1751
            }
1752

1753
            // also zero course is saved, which means unknown
1754
            if(course >= 0) {
33✔
1755
                rethash.course = course;
33✔
1756
            }
1757

1758
            // do some important adjustements
1759
            if(speed >= 800) {
33✔
1760
                speed -= 800;
21✔
1761
            }
1762

1763
            // convert speed to km/h and store
1764
            rethash.speed = speed * ConversionConstantEnum.KNOT_TO_KMH;
33✔
1765
        }
1766

1767
        // save the symbol table and code
1768
        rethash.symbolcode = packet.charAt(6);
36✔
1769
        rethash.symboltable = symboltable;
36✔
1770

1771
        // Check for possible altitude and comment data.
1772
        // It is base-91 coded and in format 'xxx}' where
1773
        // x are the base-91 digits in meters, origin is 10000 meters
1774
        // below sea.
1775
        if(packet.length > 8) {
36✔
1776
            let rest = packet.substring(8);
36✔
1777

1778
            // check for Mic-E Telemetry Data
1779
            if((tmp = rest.match(/^'([0-9a-f]{2})([0-9a-f]{2})(.*)$/i))) {
36✔
1780
                // two hexadecimal values: channels 1 and 3
1781
                rest = tmp[3];
3✔
1782

1783
                rethash.telemetry = new telemetry(null, [ parseInt(tmp[1], 16), 0, parseInt(tmp[2], 16) ]);
3✔
1784
            }
1785

1786
            if((tmp = rest.match(/^‘([0-9a-f]{10})(.*)$/i))) {
36✔
1787
                // five channels:
1788
                rest = tmp[2];
3✔
1789

1790
                // less elegant version of pack/unpack... gets the job done. deal with it or fix it
1791
                tmp[1] = tmp[1].match(/.{2}/g);
3✔
1792
                // don't know what item is, don't care, but don't remove it
1793
                tmp[1].forEach(function(item: any, index: number) { tmp[1][index] = parseInt(tmp[1][index], 16); });
15✔
1794

1795
                rethash.telemetry = new telemetry(null, tmp[1]);
3✔
1796
            }
1797

1798

1799
            // check for altitude
1800
            if((tmp = rest.match(/^(.*?)([\x21-\x7b])([\x21-\x7b])([\x21-\x7b])\}(.*)$/))) {
36✔
1801
                rethash.altitude = (
15✔
1802
                        ((tmp[2].charCodeAt(0) - 33) * Math.pow(91, 2))
1803
                        + ((tmp[3].charCodeAt(0) - 33) * 91)
1804
                        + (tmp[4].charCodeAt(0) - 33))
1805
                    - 10000;
1806

1807
                rest = tmp[1] + tmp[5];
15✔
1808
            }
1809

1810
            // Check for new-style base-91 comment telemetry
1811
            [ rest, rethash ] = this._comment_telemetry(rethash, rest);
36✔
1812

1813
            // Check for !DAO!, take the last occurrence (per recommendation)
1814
            if((tmp = rest.match(/^(.*)\!([\x21-\x7b][\x20-\x7b]{2})\!(.*?)$/))) {
36✔
1815
                let daofound = false;
6✔
1816
                [ rethash, daofound ] = this._dao_parse(tmp[2], srccallsign, rethash);
6✔
1817

1818
                if(daofound === true) {
6✔
1819
                    rest = tmp[1] + tmp[3];
6✔
1820
                }
1821
            }
1822

1823
            // If anything is left, store it as a comment
1824
            // after removing non-printable ASCII
1825
            // characters
1826
            if(rest.length > 0) {
36✔
1827
                rethash.comment = rest.trim();
36✔
1828
            }
1829
        }
1830

1831
        if(mice_fixed == true) {
36✔
1832
            rethash.mice_mangled = true;
3✔
1833
            // TODO: warn "$srccallsign: fixed packet was parsed\n";
1834
        }
1835

1836
        return rethash;
36✔
1837
    }
1838

1839
    /**
1840
     * convert a compressed position to decimal degrees
1841
     *
1842
     * TODO: p39.  Parse NMEA Source and Compression Origin
1843
     */
1844
    private _compressed_to_decimal(packet: string, srccallsign: string, rethash: aprsPacket): aprsPacket {
1845
        // A compressed position is always 13 characters long.
1846
        // Make sure we get at least 13 characters and that they are ok.
1847
        // Also check the allowed base-91 characters at the same time.
1848
        if(!(/^[\/\\A-Za-j]{1}[\x21-\x7b]{8}[\x21-\x7b\x7d]{1}[\x20-\x7b]{3}/.test(packet))) {
21✔
1849
            return this.addError(rethash, 'comp_inv');
3✔
1850
        }
1851

1852
        rethash.format = 'compressed';
18✔
1853

1854
        let lat1 = packet.charCodeAt(1) - 33;
18✔
1855
        let lat2 = packet.charCodeAt(2) - 33;
18✔
1856
        let lat3 = packet.charCodeAt(3) - 33;
18✔
1857
        let lat4 = packet.charCodeAt(4) - 33;
18✔
1858
        let long1 = packet.charCodeAt(5) - 33;
18✔
1859
        let long2 = packet.charCodeAt(6) - 33;
18✔
1860
        let long3 = packet.charCodeAt(7) - 33;
18✔
1861
        let long4 = packet.charCodeAt(8) - 33;
18✔
1862
        let symbolcode = packet.charAt(9);
18✔
1863
        let c1 = packet.charCodeAt(10) - 33;
18✔
1864
        let s1 = packet.charCodeAt(11) - 33;
18✔
1865
        let comptype = packet.charCodeAt(12) - 33;
18✔
1866

1867
        // save the symbol table and code
1868
        rethash.symbolcode = symbolcode;
18✔
1869

1870
        // the symbol table values a..j are really 0..9
1871
        if(/a-j/.test(packet.charAt(0))) {
18!
1872
            rethash.symboltable =  (packet.charCodeAt(0) - 97).toString();
×
1873
        } else {
1874
            rethash.symboltable =  packet.charAt(0);
18✔
1875
        }
1876

1877
        // calculate latitude and longitude
1878
        rethash.latitude = 90 - ((
18✔
1879
                lat1 * Math.pow(91, 3)
1880
                + lat2 * Math.pow(91, 2)
1881
                + lat3 * 91
1882
                + lat4
1883
                ) / 380926);
1884

1885
        rethash.longitude = -180 + ((
18✔
1886
                long1 * Math.pow(91, 3)
1887
                + long2 * Math.pow(91, 2)
1888
                + long3 * 91
1889
                + long4
1890
                ) / 190463);
1891

1892
        // save best-case position resolution in meters
1893
        // 1852 meters * 60 minutes in a degree * 180 degrees
1894
        // / 91 ** 4
1895
        rethash.posresolution = 0.291;
18✔
1896

1897
        // GPS fix status, only if csT is used
1898
        if(c1 != -1) {
18✔
1899
            if((comptype & 0x20) == 0x20) {
12✔
1900
                rethash.gpsfixstatus = true;
9✔
1901
            } else {
1902
                rethash.gpsfixstatus = false;
3✔
1903
            }
1904
        }
1905

1906
        // check the compression type, if GPGGA, then
1907
        // the cs bytes are altitude. Otherwise try
1908
        // to decode it as course and speed. And
1909
        // finally as radio range
1910
        // if c is space, then csT is not used.
1911
        // Also require that s is not a space.
1912
        if(c1 == -1 || s1 == -1) {
18✔
1913
            // csT not used
1914
        } else if((comptype & 0x18) == 0x10) {
12!
1915
            // cs is altitude
1916
            let cs = c1 * 91 + s1;
×
1917
            // convert directly to meters
1918
            rethash.altitude = Math.pow(1.002, cs) * ConversionConstantEnum.FEET_TO_METERS;
×
1919
        } else if(c1 >= 0 && c1 <= 89) {
12✔
1920
            if(c1 == 0) {
6✔
1921
                // special case of north, APRS spec
1922
                // uses zero for unknown and 360 for north.
1923
                // so remember to convert north here.
1924
                rethash.course = 360;
3✔
1925
            } else {
1926
                rethash.course = c1 * 4;
3✔
1927
            }
1928

1929
            // convert directly to km/h
1930
            rethash.speed = (Math.pow(1.08, s1) - 1) * ConversionConstantEnum.KNOT_TO_KMH;
6✔
1931
        } else if(c1 == 90) {
6✔
1932
            // convert directly to km
1933
            rethash.radiorange = (2 * Math.pow(1.08, s1)) * ConversionConstantEnum.MPH_TO_KMH;
6✔
1934
        }
1935

1936
        return rethash;
18✔
1937
    }
1938

1939
    /**
1940
     * Parse a possible !DAO! extension (datum and extra
1941
     * lat/lon digits). Returns 1 if a valid !DAO! extension was
1942
     * detected in the test subject (and stored in $rethash), 0 if not.
1943
     * Only the "DAO" should be passed as the candidate parameter,
1944
     * not the delimiting exclamation marks.
1945
     */
1946
    private _dao_parse(daocandidate: string, srccallsign: string, rethash: aprsPacket): [ aprsPacket, boolean ] {
1947
        // datum character is the first character and also
1948
        // defines how the rest is interpreted
1949
        let latoff;
1950
        let lonoff;
1951
        let tmp;
1952

1953
        if((tmp = daocandidate.match(/^([A-Z])(\d)(\d)$/))) {
24✔
1954
            // human readable (datum byte A...Z)
1955
            rethash.posresolution = this.get_posresolution(3);
15✔
1956
            rethash.daodatumbyte = tmp[1];
15✔
1957

1958
            latoff = parseInt(tmp[2]) * 0.001 / 60;
15✔
1959
            lonoff = parseInt(tmp[3]) * 0.001 / 60;
15✔
1960
        } else if((tmp = daocandidate.match(/^([a-z])([\x21-\x7b])([\x21-\x7b])$/))) {
9!
1961
            // base-91 (datum byte a...z)
1962
            // store the datum in upper case, still
1963
            rethash.daodatumbyte = tmp[1].toUpperCase();
9✔
1964

1965
            // close enough.. not exact:
1966
            rethash.posresolution = this.get_posresolution(4);
9✔
1967

1968
            // do proper scaling of base-91 values
1969
            latoff = (tmp[2].charCodeAt(0) - 33) / 91 * 0.01 / 60;
9✔
1970
            lonoff = (tmp[3].charCodeAt(0) - 33) / 91 * 0.01 / 60;
9✔
1971
        } else if((tmp = daocandidate.match(/^([\x21-\x7b])\s\s$/))) {
×
1972
            // only datum information, no lat/lon digits
1973
            rethash.daodatumbyte = tmp[1].toUpperCase();
×
1974

1975
            return [ rethash, true ];
×
1976
        } else {
1977
            return [ rethash, false ];
×
1978
        }
1979

1980
        // check N/S and E/W
1981
        if(rethash.latitude < 0) {
24!
1982
            rethash.latitude -= latoff;
×
1983
        } else {
1984
            rethash.latitude += latoff;
24✔
1985
        }
1986

1987
        if(rethash.longitude < 0) {
24✔
1988
            rethash.longitude -= lonoff;
18✔
1989
        } else {
1990
            rethash.longitude += lonoff;
6✔
1991
        }
1992

1993
        return [ rethash, true ];
24✔
1994
    }
1995

1996
    /**
1997
     * _dx_parse($sourcecall, $info, $rethash)
1998
     *
1999
     * Parses the body of a DX spot packet. Returns the following
2000
     * hash elements: dxsource (source of the info), dxfreq (frequency),
2001
     * dxcall (DX callsign) and dxinfo (info string).
2002
     *
2003
    private _dx_parse($sourcecall: string, $info: string, $rethash: aprsPacket): aprsPacket {
2004
        if(!this.checkAX25Call($sourcecall)) {
2005
            return this.addError($rethash, 'dx_inv_src', $sourcecall);
2006
        }
2007

2008
        $rethash['dxsource'] = $sourcecall;
2009

2010
        $info = $info.replace(/^\s*(.*?)\s*$/, $1); // strip whitespace
2011

2012
        if(($info = $info.match(/\s*(\d{3,4}Z/))) {
2013
            $rethash['dxtime'] = $info[1];
2014
        }
2015

2016
        if(!($info = $info.match(/^(\d+\.\d+)\s* /))) {
2017
            this.addError($rethash, 'dx_inv_freq')  //); // TODO: remove space between * and /
2018
            return 0;
2019
        }
2020

2021
        $rethash['dxfreq'] = $info[1];
2022

2023
        if(!($info = $info.match(/^([a-zA-Z0-9-\/]+)\s* /))) {
2024
            this.addError($rethash, 'dx_no_dx'); // TODO: remove space between * and /
2025
            return 0;
2026
        }
2027

2028
        $rethash['dxcall'] = $info[1];
2029

2030
        $info = $info.match(/\s+/ /g);
2031
        $rethash['dxinfo'] = $info;
2032

2033
        return 1;
2034

2035
        return $rethash;
2036
    }
2037
    */
2038

2039
    /**
2040
     * _wx_parse($s, $rethash)
2041
     *
2042
     * Parses a normal uncompressed weather report packet.
2043
     */
2044
    private _wx_parse(s: string, rethash: aprsPacket): aprsPacket {
2045
        // 257/007g013t055r000P000p000h56b10160v31
2046
        // 045/000t064r000p000h35b10203.open2300v1.10
2047
        // 175/007g007p...P000r000t062h32b10224wRSW
2048
        let w = new wx();
30✔
2049
        let wind_dir;
2050
        let wind_speed;
2051
        let temp;
2052
        let wind_gust;
2053
        let tmp;
2054

2055
        if((tmp = s.match(/^_{0,1}([\d \.\-]{3})\/([\d \.]{3})g([\d \.]+)t(-{0,1}[\d \.]+)/))
30✔
2056
                || (tmp = s.match(/^_{0,1}c([\d \.\-]{3})s([\d \.]{3})g([\d \.]+)t(-{0,1}[\d \.]+)/))) {
2057
            // TODO: warn "wind $1 / $2 gust $3 temp $4\n";
2058
            wind_dir = tmp[1];
12✔
2059
            wind_speed = tmp[2];
12✔
2060
            wind_gust = tmp[3];
12✔
2061

2062
            if(tmp[0]) {
12✔
2063
                s = s.replace(tmp[0], '');
12✔
2064
            }
2065

2066
            temp = tmp[4];
12✔
2067
        } else if((tmp = s.match(/^_{0,1}([\d \.\-]{3})\/([\d \.]{3})t(-{0,1}[\d \.]+)/))) {
18!
2068
            // TODO: warn "$initial\nwind $1 / $2 temp $3\n";
2069
            wind_dir = tmp[1];
×
2070
            wind_speed = tmp[2];
×
2071

2072
            if(tmp[0]) {
×
2073
                s = s.replace(tmp[0], '');
×
2074
            }
2075

2076
            temp = tmp[3];
×
2077
        } else if((tmp = s.match(/^_{0,1}([\d \.\-]{3})\/([\d \.]{3})g([\d \.]+)/))) {
18!
2078
            // TODO: warn "$initial\nwind $1 / $2 gust $3\n";
2079
            wind_dir = tmp[1];
×
2080
            wind_speed = tmp[2];
×
2081
            wind_gust = tmp[3];
×
2082

2083
            if(tmp[0]) {
×
2084
                s = s.replace(tmp[0], '');
×
2085
            }
2086
        } else if((tmp = s.match(/^g(\d+)t(-{0,1}[\d \.]+)/))) {
18✔
2087
            // TODO: ($s =~ s/^g([\d .]+)t(-{0,1}[\d \.]+)//)
2088
            // g000t054r000p010P010h65b10073WS 2300 {UIV32N}
2089
            wind_gust = tmp[1];
3✔
2090

2091
            if(tmp[0]) {
3✔
2092
                s = s.replace(tmp[0], '');
3✔
2093
            }
2094

2095
            temp = tmp[2];
3✔
2096
        } else {
2097
            // TODO: warn "wx_parse: no initial match: $s\n";
2098
            return rethash;
15✔
2099
        }
2100

2101
        if(!temp) {
15!
2102
            s = s.replace(/t(-{0,1}\d{1,3})/, function(a, b) {
×
2103
                if(b) {
×
2104
                    temp = b;
×
2105
                }
2106

2107
                return '';
×
2108
            });
2109
        }
2110

2111
        if(/^\d+$/.test(wind_gust)) {
15✔
2112
            w.wind_gust = (parseFloat(wind_gust) * ConversionConstantEnum.MPH_TO_MS).toFixed(1);
15✔
2113
        }
2114

2115
        if(/^\d+$/.test(wind_dir)) {
15✔
2116
            w.wind_direction = parseFloat(wind_dir).toFixed(0);
12✔
2117
        }
2118

2119
        if(/^\d+$/.test(wind_speed)) {
15✔
2120
            w.wind_speed = (parseFloat(wind_speed) * ConversionConstantEnum.MPH_TO_MS).toFixed(1);
12✔
2121
        }
2122

2123
        if(/^-{0,1}\d+$/.test(temp)) {
15✔
2124
            w.temp = ConversionUtil.FahrenheitToCelsius(parseInt(temp)).toFixed(1) ;
15✔
2125
        }
2126

2127
        s = s.replace(/r(\d{1,3})/, function($a, b) {
15✔
2128
            if(b) {
15✔
2129
                w.rain_1h = (parseFloat(b) * ConversionConstantEnum.HINCH_TO_MM).toFixed(1); // during last 1h
15✔
2130
            }
2131

2132
            return '';
15✔
2133
        });
2134

2135
        s = s.replace(/p(\d{1,3})/, function(a, b) {
15✔
2136
            if(b) {
15✔
2137
                w.rain_24h = (parseFloat(b) * ConversionConstantEnum.HINCH_TO_MM).toFixed(1); // during last 24h
15✔
2138
            }
2139

2140
            return '';
15✔
2141
        });
2142

2143
        s = s.replace(/P(\d{1,3})/, function(a, b) {
15✔
2144
            if(b) {
15✔
2145
                w.rain_midnight = (parseFloat(b) * ConversionConstantEnum.HINCH_TO_MM).toFixed(1); // since midnight
15✔
2146
            }
2147

2148
            return '';
15✔
2149
        });
2150

2151
        s = s.replace(/h(\d{1,3})/, function(a, b) {
15✔
2152
            if(b) {
15✔
2153
                w.humidity = parseInt(b); // percentage
15✔
2154

2155
                if(w.humidity == 0) {
15✔
2156
                    w.humidity = 100;
3✔
2157
                }
2158

2159
                if(w.humidity > 100 || w.humidity < 1) {
15!
2160
                    w.humidity = null;
×
2161
                }
2162
            }
2163

2164
            return '';
15✔
2165
        });
2166

2167
        s = s.replace(/b(\d{4,5})/, function(a, b) {
15✔
2168
            if(b) {
15✔
2169
                w.pressure = (b / 10).toFixed(1); // results in millibars
15✔
2170
            }
2171

2172
            return '';
15✔
2173
        });
2174

2175
        s = s.replace(/([lL])(\d{1,3})/, function(a, b, c) {
15✔
2176
            if(c) {
3✔
2177
                w.luminosity = parseFloat(c).toFixed(0); // watts / m2
3✔
2178
            }
2179

2180
            if(b && b == 'l') {
3!
2181
                w.luminosity += 1000;
×
2182
            }
2183

2184
            return '';
3✔
2185
        });
2186

2187
        /*
2188
        if ($s =~ s/v([\-\+]{0,1}\d+)//) {
2189
            # what ?
2190
        }
2191
        */
2192

2193
        s = s.replace(/s(\d{1,3})/, function(a, b) {
15✔
2194
            // snowfall
2195
            if(b) {
3✔
2196
                w.snow_24h = (b * ConversionConstantEnum.HINCH_TO_MM).toFixed(1);
3✔
2197
            }
2198

2199
            return '';
3✔
2200
        });
2201

2202
        /*
2203
        if ($s =~ s/#(\d+)//) {
2204
            # raw rain counter
2205
        }
2206
        */
2207

2208
        tmp = s.match(/^([rPphblLs#][\. ]{1,5})+/);
15✔
2209

2210
        //$s =~ s/^\s+//;
2211
        //$s =~ s/\s+/ /;
2212

2213
        if(/^[a-zA-Z0-9\-_]{3,5}$/.test(s)) {
15✔
2214
            if(s != '') {
3✔
2215
                w.soft = s.substring(0, 16);
3✔
2216
            }
2217
        } else {
2218
            rethash.comment = s.trim();
12✔
2219
        }
2220

2221
        if(w.temp || (w.wind_speed && w.wind_direction)) {
15!
2222
            // warn "ok: $initial\n$s\n";
2223
            rethash.wx = w;
15✔
2224
        }
2225

2226
        return rethash;
15✔
2227
    }
2228

2229
    /**
2230
     * _wx_parse_peet_packet($s, $sourcecall, $rethash)
2231
     *
2232
     * Parses a Peet bros Ultimeter weather packet ($ULTW header).
2233
     */
2234
    private _wx_parse_peet_packet(s: string, sourcecall: string, rethash: aprsPacket): aprsPacket {
2235
        // warn "\$ULTW: $s\n";
2236
        // 0000000001FF000427C70002CCD30001026E003A050F00040000
2237
        let w = new wx();
6✔
2238
        let t;
2239
        let vals: number[] = [];
6✔
2240

2241
        while(/^([0-9a-f]{4}|----)/i.test(s)) {
6✔
2242
            s = s.replace(/^([0-9a-f]{4}|----)/i, function(a, b) {
72✔
2243
                if(b == '----') {
72!
2244
                    vals.push(null);
×
2245
                } else {
2246
                    // Signed 16-bit integers in network (big-endian) order
2247
                    // encoded in hex, high nybble first.
2248
                    // Perl 5.10 unpack supports n! for signed ints, 5.8
2249
                    // requires tricks like this:
2250
                    let v = 0; //= unpack('n', pack('H*', $1));
72✔
2251

2252
                    for(var i = 0; i < 4; i++) {
72✔
2253
                        var c = b.charAt(i);
288✔
2254

2255
                        v += parseInt(c, 16) << 12 - (4 * i); // 12 = 16(bits) - 4  shortcut to reduce mathmatical operations
288✔
2256
                    }
2257

2258
                    if(v >= 32768) {
72✔
2259
                        v = v - 65536;
9✔
2260
                    }
2261

2262
                    vals.push(v);
72✔
2263
                }
2264

2265
                return '';
72✔
2266
            });
2267
        }
2268

2269
        if(!vals || vals.length == 0) {
6!
2270
            return null;
×
2271
        }
2272

2273
        t = vals.shift();
6✔
2274
        if(t != null) {
6✔
2275
            w.wind_gust = (t * ConversionConstantEnum.KMH_TO_MS / 10).toFixed(1);
6✔
2276
        }
2277

2278
        t = vals.shift();
6✔
2279
        if(t != null) {
6✔
2280
            w.wind_direction = ((t & 0xff) * 1.41176).toFixed(0);  // 1/255 => 1/360
6✔
2281
        }
2282

2283
        t = vals.shift();
6✔
2284
        if(t != null) {
6✔
2285
            w.temp = ConversionUtil.FahrenheitToCelsius(t / 10).toFixed(1);   // 1/255 => 1/360
6✔
2286
        }
2287

2288
        t = vals.shift();
6✔
2289
        if(t != null) {
6✔
2290
            w.rain_midnight = (t * ConversionConstantEnum.HINCH_TO_MM).toFixed(1);
6✔
2291
        }
2292

2293
        t = vals.shift();
6✔
2294
        if(t && t >= 10) {
6✔
2295
            w.pressure = (t / 10).toFixed(1);
6✔
2296
        }
2297

2298
        // Do we care about these?
2299
        vals.shift(); // Barometer Delta
6✔
2300
        vals.shift(); // Barometer Corr. Factor (LSW)
6✔
2301
        vals.shift(); // Barometer Corr. Factor (MSW)
6✔
2302

2303
        t = vals.shift();
6✔
2304
        if(t) {
6✔
2305
            w.humidity = Math.floor(t / 10);    // .toFixed(0) percentage
6✔
2306

2307
            if(w.humidity > 100 || w.humidity < 1) {
6!
2308
                delete w.humidity;
×
2309
            }
2310
        }
2311

2312
        // Do we care about these?
2313
        vals.shift(); // date
6✔
2314
        vals.shift(); // time
6✔
2315

2316
        t = vals.shift();
6✔
2317
        if(t) {
6✔
2318
            w.rain_midnight = (t * ConversionConstantEnum.HINCH_TO_MM).toFixed(1);
3✔
2319
        }
2320

2321
        t = vals.shift();
6✔
2322
        if(t) {
6✔
2323
            w.wind_speed = (t * ConversionConstantEnum.KMH_TO_MS / 10).toFixed(1);
3✔
2324
        }
2325

2326
        if(w.temp
6!
2327
                || (w.wind_speed && w.wind_direction)
2328
                || w.pressure
2329
                || w.humidity
2330
                ) {
2331
            rethash.wx = w;
6✔
2332

2333
            //return $rethash;
2334
        }
2335

2336
        //return 0;
2337
        return rethash; // do we need to notify somehow the parsing failed?
6✔
2338
    }
2339

2340
    /**
2341
     * _wx_parse_peet_logging($s, $sourcecall, $rethash)
2342
     *
2343
     * Parses a Peet bros Ultimeter weather logging frame (!! header).
2344
     */
2345
    private _wx_parse_peet_logging(s: string, sourcecall: string, rethash: aprsPacket): aprsPacket {
2346
        // warn "\!!: $s\n";
2347
        // 0000000001FF000427C70002CCD30001026E003A050F00040000
2348
        let w = new wx();
3✔
2349
        let t;
2350
        let vals: number[] = [];
3✔
2351

2352
        while(/^([0-9a-f]{4}|----)/i.test(s)) {
3✔
2353
            s = s.replace(/^([0-9a-f]{4}|----)/i, function(a, b) {
36✔
2354
                if(b == '----') {
36✔
2355
                    vals.push(null);
6✔
2356
                } else {
2357
                    // Signed 16-bit integers in network (big-endian) order
2358
                    // encoded in hex, high nybble first.
2359
                    // Perl 5.10 unpack supports n! for signed ints, 5.8
2360
                    // requires tricks like this:
2361
                    let v = 0; //= unpack('n', pack('H*', $1));
30✔
2362

2363
                    for(var i = 0; i < 4; i++) {
30✔
2364
                        var c = b.charAt(i);
120✔
2365

2366
                        v += parseInt(c, 16) << 12 - (4 * i); // 12 = 16(bits) - 4  shortcut to reduce mathmatical operations
120✔
2367
                    }
2368

2369
                    if(v >= 32768) {
30!
2370
                        v = v - 65536;
×
2371
                    }
2372

2373
                    vals.push(v);
30✔
2374
                }
2375

2376
                return '';
36✔
2377
            });
2378
        }
2379

2380
        if(!vals || vals.length == 0) {
3!
2381
            return rethash; // TODO: do we need to signal an error?
×
2382
        }
2383

2384
        //0000 0066 013D 0000 2871 0166 ---- ---- 0158 0532 0120 0210
2385

2386
        t = vals.shift(); // instant wind speed
3✔
2387
        if(t != null) {
3✔
2388
            w.wind_speed = (t * ConversionConstantEnum.KMH_TO_MS / 10).toFixed(1);
3✔
2389
        }
2390

2391
        t = vals.shift();
3✔
2392
        if(t != null) {
3✔
2393
            w.wind_direction = ((t & 0xff) * 1.41176).toFixed(0); // 1/255 => 1/360
3✔
2394
        }
2395

2396
        t = vals.shift();
3✔
2397
        if(t) {
3✔
2398
            w.temp = ConversionUtil.FahrenheitToCelsius(t / 10).toFixed(1); // 1/255 => 1/360
3✔
2399
        }
2400

2401
        t = vals.shift();
3✔
2402
        if(t) {
3!
2403
            w.rain_midnight = (t * ConversionConstantEnum.HINCH_TO_MM).toFixed(1);
×
2404
        }
2405

2406
        t = vals.shift();
3✔
2407
        if(t && t >= 10) {
3✔
2408
            w.pressure = (t / 10).toFixed(1);
3✔
2409
        }
2410

2411
        t = vals.shift();
3✔
2412
        if(t) {
3✔
2413
            w.temp_in = parseFloat(ConversionUtil.FahrenheitToCelsius(t / 10).toFixed(1));   // 1/255 => 1/360
3✔
2414
        }
2415

2416
        t = vals.shift();
3✔
2417
        if(t) {
3!
2418
            w.humidity = Math.floor(t / 10);    // .toFixed(0) percentage
×
2419

2420
            if(w.humidity > 100 || w.humidity < 1) {
×
2421
                delete w.humidity;
×
2422
            }
2423
        }
2424

2425
        t = vals.shift();
3✔
2426
        if(t) {
3!
2427
            w.humidity_in = Math.floor(t / 10); // .toFixed(0) percentage
×
2428

2429
            if(w.humidity > 100 || w.humidity < 1) {
×
2430
                delete w.humidity_in;
×
2431
            }
2432
        }
2433

2434
        vals.shift(); // date
3✔
2435
        vals.shift(); // time
3✔
2436

2437
        t = vals.shift();
3✔
2438
        if(t) {
3✔
2439
            w.rain_midnight = (t * ConversionConstantEnum.HINCH_TO_MM).toFixed(1);
3✔
2440
        }
2441

2442
        // avg wind speed
2443
        t = vals.shift();
3✔
2444
        if(t) {
3✔
2445
            w.wind_speed = (t * ConversionConstantEnum.KMH_TO_MS / 10).toFixed(1);
3✔
2446
        }
2447

2448
        // if inside temperature exists but no outside, use inside
2449
        if(w.temp_in && !w.temp) {
3!
2450
            w.temp = w.temp_in.toString();
×
2451
        }
2452

2453
        if(w.humidity_in && !w.humidity) {
3!
2454
            w.humidity = w.humidity_in;
×
2455
        }
2456

2457
        if(w.temp || w.pressure || w.humidity
3!
2458
                || (w.wind_speed && w.wind_direction)) {
2459
            rethash.wx = w;
3✔
2460

2461
            //return 1;
2462
        }
2463

2464
        return rethash; // this originally returned 0, TODO: signal error?
3✔
2465
    }
2466

2467
    /**
2468
     * _telemetry_parse($s, $rethash)
2469
     *
2470
     * Parses a telemetry packet.
2471
     */
2472
    private _telemetry_parse(s: string, rh: aprsPacket): aprsPacket {
2473
        // warn "did match\n";
2474
                let t: telemetry = new telemetry()
18✔
2475
        let tmp: string[]
2476

2477
        if((tmp = s.match(/^(\d+),([\-\d\,\.]+)/))) {
18✔
2478
            t.seq = parseInt(tmp[0])
15✔
2479

2480
            //let $vals: string[] = [ (tmp[2] + tmp[3]), (tmp[4] + tmp[5]), (tmp[6] + tmp[7])
2481
            //        , (tmp[8] + tmp[9]), (tmp[10] + tmp[11]) ];
2482
            let vals: string[] = tmp[2].split(',')
15✔
2483
            let vout: number[] = []
15✔
2484

2485
            for(let i = 0; i <= 4; i++) {
15✔
2486
                let v: number
2487

2488
                if(i < vals.length && vals[i] != null && vals[i] != undefined && vals[i] != '') {
51✔
2489
                    if(vals[i].match(/^-{0,1}(\d+|\d*\.\d+)$/)) {
36!
2490
                        v = parseFloat(vals[i])
36✔
2491

2492
                        // NOTE: http://blog.aprs.fi/2020/03/aprsfi-supports-kenneths-proposed.html
2493
                        if(parseFloat(vals[i]) < -2147483648 || parseFloat(vals[i]) > 2147483647) {
36✔
2494
                            return this.addError(rh, 'tlm_large')
6✔
2495
                        }
2496
                    } else {
2497
                        // TODO: Can this scenario even happen?  The parent if statement should prevent this from happening.
2498
                        return this.addError(rh, 'tlm_inv')
×
2499
                    }
2500
                }
2501

2502
                vout.push(v)
45✔
2503
            }
2504

2505
            t.vals = vout
9✔
2506

2507
            // TODO: validate bits
2508
            if(vals[5] && vals[5] != '') {
9✔
2509
                t.bits = vals[5]
9✔
2510

2511
                // expand bits to 8 bits if some are missing
2512
                if(t.bits.length < 8) {
9✔
2513
                    t.bits = t.bits.padEnd(8, '0')
3✔
2514
                }
2515
                // TODO: What happens if there's more than 8 bits?
2516
            }
2517
        } else {
2518
            return this.addError(rh, 'tlm_inv');
3✔
2519
        }
2520

2521
        rh.telemetry = t;
9✔
2522

2523
        //warn 'ok: ' . Dumper(\%t);
2524
        return rh;
9✔
2525
    }
2526
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc