From 17caa223310c3e5f8a54b84f578cae0059c14a68 Mon Sep 17 00:00:00 2001 From: maze Date: Wed, 18 Mar 2026 17:01:46 +0100 Subject: [PATCH] Better parsing for extras; Added deviceID resolution --- package.json | 20 +- src/deviceid.ts | 1088 +++++++++++++++++++++++++++++++++++++ src/frame.ts | 452 +++++++++------ src/frame.types.ts | 56 +- src/index.ts | 3 + test/deviceid.test.ts | 22 + test/frame.extras.test.ts | 31 +- test/frame.test.ts | 85 +-- 8 files changed, 1517 insertions(+), 240 deletions(-) create mode 100644 src/deviceid.ts create mode 100644 test/deviceid.test.ts diff --git a/package.json b/package.json index 0e0f088..75a0d6d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@hamradio/aprs", "type": "module", - "version": "1.2.0", + "version": "1.2.1", "description": "APRS (Automatic Packet Reporting System) protocol support for Typescript", "keywords": [ "APRS", @@ -17,7 +17,7 @@ "license": "MIT", "author": "Wijnand Modderman-Lenstra", "main": "dist/index.js", - "module": "dist/index.mjs", + "module": "dist/index.js", "types": "dist/index.d.ts", "files": [ "dist" @@ -25,7 +25,7 @@ "exports": { ".": { "types": "./dist/index.d.ts", - "import": "./dist/index.mjs", + "import": "./dist/index.js", "require": "./dist/index.js" } }, @@ -39,20 +39,20 @@ "lint": "eslint .", "prepare": "npm run build" }, + "dependencies": { + "@hamradio/packet": "^1.1.0", + "extended-nmea": "^2.1.3" + }, "devDependencies": { "@eslint/js": "^10.0.1", "@trivago/prettier-plugin-sort-imports": "^6.0.2", - "@vitest/coverage-v8": "^4.0.18", + "@vitest/coverage-v8": "^4.1.0", "eslint": "^10.0.3", "globals": "^17.4.0", "prettier": "^3.8.1", "tsup": "^8.5.1", "typescript": "^5.9.3", - "typescript-eslint": "^8.57.0", - "vitest": "^4.0.18" - }, - "dependencies": { - "@hamradio/packet": "^1.1.0", - "extended-nmea": "^2.1.3" + "typescript-eslint": "^8.57.1", + "vitest": "^4.1.0" } } diff --git a/src/deviceid.ts b/src/deviceid.ts new file mode 100644 index 0000000..1f7b101 --- /dev/null +++ b/src/deviceid.ts @@ -0,0 +1,1088 @@ +import { Address } from "./frame"; + +export type DeviceID = { + tocall: string; + vendor?: string; + model?: string; + class?: string; + os?: string; + contact?: string; + features?: string[]; +}; + +export const getDeviceID = (callsign: string | Address): DeviceID | null => { + if (typeof callsign !== "string") { + callsign = callsign.call; + } + const base = callsign.split("-")[0].toUpperCase(); + return knownDeviceIDs.get(base); +}; + +export default getDeviceID; + +type Match = { value: DeviceID | null; prio: number } | null; + +class Node { + children: Map = new Map(); + value: DeviceID | null = null; + isEnd: boolean = false; + prio: number = 0; +} + +class Trie { + private root: Node = new Node(); + + constructor(initial: DeviceID[] = []) { + for (const device of initial) { + this.insert(device); + } + } + + insert(device: DeviceID): void { + let node = this.root; + let prio = 4; + + if (device.tocall.includes("*")) prio = 1; + else if (device.tocall.includes("?")) prio = 2; + else if (device.tocall.includes("n")) prio = 3; + + for (const char of device.tocall) { + if (!node.children.has(char)) { + node.children.set(char, new Node()); + } + node = node.children.get(char)!; + } + node.isEnd = true; + node.value = device; + node.prio = prio; + } + + get(callsign: string): DeviceID | null { + const result = this.search(this.root, callsign, 0); + return result ? result.value : null; + } + + private search(node: Node, callsign: string, index: number): Match { + let best: Match = null; + + if (node.children.has("*")) { + const star = node.children.get("*")!; + best = { value: star.value, prio: star.prio }; + } + + if (index === callsign.length) { + if (node.isEnd) { + if (!best || node.prio > best.prio) { + best = { value: node.value, prio: node.prio }; + } + } + return best; + } + + const char = callsign[index]; + + for (const [key, leaf] of node.children) { + let curr: Match = null; + + if (key === char) { + curr = this.search(leaf, callsign, index + 1); + } else if (key === "?") { + curr = this.search(leaf, callsign, index + 1); + } else if (key === "n" && /\d/.test(char)) { + curr = this.search(leaf, callsign, index + 1); + } + + // Update best if this branch returns a better match + if (curr && (!best || curr.prio > best.prio)) { + best = curr; + } + } + + return best; + } +} + +// Update from https://github.com/aprsorg/aprs-deviceid/blob/main/tocalls.yaml +const knownDeviceIDs = new Trie([ + { tocall: "AP1WWX", vendor: "TAPR", model: "T-238+", class: "wx" }, + { tocall: "AP4R??", vendor: "Open Source", model: "APRS4R", class: "software" }, + { tocall: "APAEP1", vendor: "Paraguay Space Agency (AEP)", model: "EIRUAPRSDIGIS&FV1", class: "satellite" }, + { tocall: "APAF??", model: "AFilter" }, + { tocall: "APAG??", model: "AGate" }, + { tocall: "APAGW", vendor: "SV2AGW", model: "AGWtracker", class: "software", os: "Windows" }, + { tocall: "APAGW?", vendor: "SV2AGW", model: "AGWtracker", class: "software", os: "Windows" }, + { tocall: "APAH??", model: "AHub" }, + { + tocall: "APAIOR", + vendor: "J. Angelo Racoma DU2XXR/N2RAC", + model: "APRSPH net bot based on Ioreth", + class: "service", + os: "Linux", + contact: "info@aprsph.net", + features: ["messaging"] + }, + { tocall: "APALH*", vendor: "Retevis" }, + { tocall: "APALH1", vendor: "Retevis", model: "Ailunce H1", class: "ht" }, + { tocall: "APALHA", vendor: "Retevis", model: "Ailunce HA2", class: "ht" }, + { tocall: "APAM??", vendor: "Altus Metrum", model: "AltOS", class: "tracker" }, + { + tocall: "APANC?", + vendor: "John Rokicki, KC1VMZ", + model: "APRS Net Central", + class: "service", + contact: "kc1vmz@gmail.com", + features: ["messaging"] + }, + { tocall: "APAND?", vendor: "Open Source", model: "APRSdroid", os: "Android", class: "app" }, + { tocall: "APAR??", vendor: "Øyvind, LA7ECA", model: "Arctic Tracker", class: "tracker", os: "embedded" }, + { tocall: "APAT16", vendor: "AnyTone", model: "AT-D168UV", class: "ht" }, + { tocall: "APAT51", vendor: "AnyTone", model: "AT-D578", class: "rig" }, + { tocall: "APAT81", vendor: "AnyTone", model: "AT-D878", class: "ht" }, + { tocall: "APAT89", vendor: "AnyTone", model: "AT-D890UV", class: "ht" }, + { tocall: "APAT??", vendor: "AnyTone" }, + { + tocall: "APATAR", + vendor: "TA7W/OH2UDS Baris Dinc, TA6AD Emre Keles", + model: "ATA-R APRS Digipeater", + class: "digi" + }, + { tocall: "APAVT5", vendor: "SainSonic", model: "AP510", class: "tracker" }, + { tocall: "APAW??", vendor: "SV2AGW", model: "AGWPE", class: "software", os: "Windows" }, + { tocall: "APAX??", model: "AFilterX" }, + { + tocall: "APB2MF", + vendor: "Mike, DL2MF", + model: "MF2APRS Radiosonde tracking tool", + class: "software", + os: "Windows" + }, + { tocall: "APBK??", vendor: "PY5BK", model: "Bravo Tracker", class: "tracker" }, + { tocall: "APBL??", vendor: "BigRedBee", model: "BeeLine GPS", class: "tracker" }, + { tocall: "APBM??", vendor: "R3ABM", model: "BrandMeister DMR" }, + { tocall: "APBPQ?", vendor: "John Wiseman, G8BPQ", model: "BPQ32", class: "software", os: "Windows" }, + { tocall: "APBSD?", vendor: "hambsd.org", model: "HamBSD" }, + { tocall: "APBT*", vendor: "BTECH", contact: "support@baofengtech.com" }, + { tocall: "APBT62", vendor: "BTECH", model: "DMR 6x2", class: "ht", contact: "support@baofengtech.com" }, + { tocall: "APBT72", vendor: "BTECH", model: "DA 7X2", class: "ht", contact: "support@baofengtech.com" }, + { tocall: "APBTUV", vendor: "BTECH", model: "UV-PRO", class: "ht", contact: "support@baofengtech.com" }, + { tocall: "APC???", vendor: "Rob Wittner, KZ5RW", model: "APRS/CE", class: "app" }, + { tocall: "APCDS0", vendor: "ZS6LMG", model: "cell tracker", class: "tracker" }, + { tocall: "APCLEY", vendor: "ZS6EY", model: "EYTraker", class: "tracker" }, + { tocall: "APCLEZ", vendor: "ZS6EY", model: "Telit EZ10 GSM application", class: "tracker" }, + { tocall: "APCLUB", model: "Brazil APRS network" }, + { tocall: "APCLWX", vendor: "ZS6EY", model: "EYWeather", class: "wx" }, + { tocall: "APCN??", vendor: "DG5OAW", model: "carNET" }, + { tocall: "APCSMS", vendor: "USNA", model: "Cosmos" }, + { tocall: "APCSS", vendor: "AMSAT", model: "CubeSatSim CubeSat Simulator" }, + { tocall: "APCTLK", vendor: "Open Source", model: "Codec2Talkie", class: "app" }, + { tocall: "APCWP8", vendor: "GM7HHB", model: "WinphoneAPRS", class: "app" }, + { + tocall: "APD5T?", + vendor: "Geoffrey, F4FXL", + model: "Open Source DStarGateway", + class: "dstar", + contact: "f4fxl@dstargateway.digital" + }, + { + tocall: "APDAGW", + vendor: "Diego Guevara, HJ3DAG", + model: "NodeJS APRS WX", + class: "wx", + contact: "hj3dag@gmail.com" + }, + { tocall: "APDF??", model: "Automatic DF units" }, + { tocall: "APDG??", vendor: "Jonathan, G4KLX", model: "ircDDB Gateway", class: "dstar" }, + { tocall: "APDI??", vendor: "Bela, HA5DI", model: "DIXPRS", class: "software" }, + { tocall: "APDNO?", vendor: "DO3SWW", model: "APRSduino", class: "tracker", os: "embedded" }, + { + tocall: "APDP25", + vendor: "vk44.net", + model: "Project 25 (P25)", + class: "tracker", + os: "embedded", + contact: "support@vk44.net" + }, + { tocall: "APDPRS", vendor: "unknown", model: "D-Star APDPRS", class: "dstar" }, + { tocall: "APDR??", vendor: "Open Source", model: "APRSdroid", os: "Android", class: "app" }, + { tocall: "APDS??", vendor: "SP9UOB", model: "dsDIGI", os: "embedded" }, + { tocall: "APDST?", vendor: "SP9UOB", model: "dsTracker", os: "embedded" }, + { tocall: "APDT??", vendor: "unknown", model: "APRStouch Tone (DTMF)" }, + { tocall: "APDU??", vendor: "JA7UDE", model: "U2APRS", class: "app", os: "Android" }, + { tocall: "APDV??", vendor: "OE6PLD", model: "SSTV with APRS", class: "software" }, + { tocall: "APDW??", vendor: "WB2OSZ", model: "DireWolf" }, + { tocall: "APDnnn", vendor: "Open Source", model: "aprsd", class: "software", os: "Linux/Unix" }, + { tocall: "APE2A?", vendor: "NoseyNick, VA3NNW", model: "Email-2-APRS gateway", class: "software", os: "Linux/Unix" }, + { tocall: "APE???", model: "Telemetry devices" }, + { tocall: "APECAN", vendor: "KT5TK/DL7AD", model: "Pecan Pico APRS Balloon Tracker", class: "tracker" }, + { tocall: "APELK?", vendor: "WB8ELK", model: "Balloon tracker", class: "tracker" }, + { + tocall: "APEML?", + vendor: "Leszek, SP9MLI", + model: "SP9MLI for WX, Telemetry", + class: "software", + contact: "sp9mli@gmail.com" + }, + { + tocall: "APEP??", + vendor: "Patrick EGLOFF, TK5EP", + model: "LoRa WX station", + class: "wx", + os: "embedded", + contact: "pegloff@gmail.com" + }, + { + tocall: "APERRB", + vendor: "KG5JNC", + model: "APRS Backend for Errbot", + class: "service", + contact: "me@kg5jnc.com", + features: ["messaging"] + }, + { tocall: "APERS?", vendor: "Jason, KG7YKZ", model: "Runner tracking", class: "tracker" }, + { tocall: "APERXQ", vendor: "PE1RXQ", model: "PE1RXQ APRS Tracker", class: "tracker" }, + { tocall: "APESP1", vendor: "LY3PH", model: "APRS-ESP", os: "embedded" }, + { tocall: "APESPG", vendor: "OH2TH", model: "ESP SmartBeacon APRS-IS Client", os: "embedded" }, + { tocall: "APESPW", vendor: "OH2TH", model: "ESP Weather Station APRS-IS Client", os: "embedded" }, + { + tocall: "APETBT", + vendor: "PD7R", + model: "TBTracker Balloon Telemetry Tracker", + class: "tracker", + os: "embedded", + contact: "roel@kroes.com" + }, + { tocall: "APFG??", vendor: "KP4DJT", model: "Flood Gage", class: "software" }, + { tocall: "APFI??", vendor: "aprs.fi", class: "app" }, + { tocall: "APFII?", model: "iPhone/iPad app", vendor: "aprs.fi", os: "ios", class: "app" }, + { + tocall: "APFMN?", + vendor: "Thomas Beiderwieden, DL3EL", + model: "FM-Funknetz HS Dashboard", + class: "service", + contact: "dl3el@darc.de" + }, + { + tocall: "APFMO?", + vendor: "BG5ESN", + model: "FMO (NFM Over Internet)", + class: "gadget", + os: "embedded", + contact: "xifengzui@yeah.net" + }, + { + tocall: "APFSYC", + vendor: "David McKenzie, K1FSY", + model: "FSY Packet Console", + class: "software", + contact: "k1fsy@vhfwiki.com" + }, + { tocall: "APGBLN", vendor: "NW5W", model: "GoBalloon", class: "tracker" }, + { tocall: "APGDT?", vendor: "VK4FAST", model: "Graphic Data Terminal" }, + { + tocall: "APGKEY", + vendor: "Mohammad Zaki, 9W2KEY", + model: "9W2KEY iGate", + class: "igate", + contact: "mzakiab@gmail.com" + }, + { tocall: "APGO??", vendor: "AA3NJ", model: "APRS-Go", class: "app" }, + { tocall: "APHAX?", vendor: "PY2UEP", model: "SM2APRS SondeMonitor", class: "software", os: "Windows" }, + { tocall: "APHBL?", vendor: "KF7EEL", model: "HBLink D-APRS Gateway", class: "software" }, + { tocall: "APHH?", vendor: "Steven D. Bragg, KA9MVA", model: "HamHud", class: "tracker" }, + { tocall: "APHK??", vendor: "LA1BR", model: "Digipeater/tracker" }, + { + tocall: "APHMEY", + vendor: "Tapio Heiskanen, OH2TH", + model: "APRS-IS Client for Athom Homey", + contact: "oh2th@iki.fi" + }, + { tocall: "APHPIA", vendor: "HP3ICC", model: "Arduino APRS" }, + { tocall: "APHPIB", vendor: "HP3ICC", model: "Python APRS Beacon" }, + { tocall: "APHPIW", vendor: "HP3ICC", model: "Python APRS WX" }, + { tocall: "APHRM?", vendor: "Giovanni, IW1CGW", model: "Meteo", class: "wx", contact: "iw1cgw@libero.it" }, + { tocall: "APHRT?", vendor: "Giovanni, IW1CGW", model: "Telemetry", contact: "iw1cgw@libero.it" }, + { tocall: "APHT??", vendor: "IU0AAC", model: "HMTracker", class: "tracker" }, + { tocall: "APHW??", vendor: "HamWAN" }, + { tocall: "API282", vendor: "Icom", model: "IC-2820", class: "dstar" }, + { tocall: "API31", vendor: "Icom", model: "IC-31", class: "dstar" }, + { tocall: "API410", vendor: "Icom", model: "IC-4100", class: "dstar" }, + { tocall: "API51", vendor: "Icom", model: "IC-51", class: "dstar" }, + { tocall: "API510", vendor: "Icom", model: "IC-5100", class: "dstar" }, + { tocall: "API710", vendor: "Icom", model: "IC-7100", class: "dstar" }, + { tocall: "API80", vendor: "Icom", model: "IC-80", class: "dstar" }, + { tocall: "API880", vendor: "Icom", model: "IC-880", class: "dstar" }, + { tocall: "API910", vendor: "Icom", model: "IC-9100", class: "dstar" }, + { tocall: "API92", vendor: "Icom", model: "IC-92", class: "dstar" }, + { tocall: "API970", vendor: "Icom", model: "IC-9700", class: "dstar" }, + { tocall: "API???", vendor: "Icom", model: "unknown", class: "dstar" }, + { tocall: "APIC??", vendor: "HA9MCQ", model: "PICiGATE" }, + { tocall: "APIE??", vendor: "W7KMV", model: "PiAPRS" }, + { tocall: "APIN??", vendor: "AB0WV", model: "PinPoint" }, + { + tocall: "APIZCI", + vendor: "TA7W/OH2UDS Baris Dinc, TA6AD Emre Keles", + model: "hymTR IZCI Tracker", + class: "tracker", + os: "embedded" + }, + { tocall: "APJ8??", vendor: "KN4CRD", model: "JS8Call", class: "software" }, + { tocall: "APJA??", vendor: "K4HG & AE5PL", model: "JavAPRS" }, + { tocall: "APJE??", vendor: "Gregg Wonderly, W5GGW", model: "JeAPRS" }, + { tocall: "APJI??", vendor: "Peter Loveall, AE5PL", model: "jAPRSIgate", class: "software" }, + { tocall: "APJID2", vendor: "Peter Loveall, AE5PL", model: "D-Star APJID2", class: "dstar" }, + { tocall: "APJS??", vendor: "Peter Loveall, AE5PL", model: "javAPRSSrvr" }, + { tocall: "APJY??", vendor: "KA2DDO", model: "YAAC", class: "software" }, + { tocall: "APK003", vendor: "Kenwood", model: "TH-D72", class: "ht" }, + { tocall: "APK004", vendor: "Kenwood", model: "TH-D74", class: "ht" }, + { tocall: "APK005", vendor: "Kenwood", model: "TH-D75", class: "ht" }, + { tocall: "APK0??", vendor: "Kenwood", model: "TH-D7", class: "ht" }, + { tocall: "APK1??", vendor: "Kenwood", model: "TM-D700", class: "rig" }, + { + tocall: "APKDXn", + vendor: "KelateDX, 9M2D", + model: "LAHKHUANO APRS", + class: "tracker", + os: "embedded", + contact: "mzakiab@gmail.com" + }, + { + tocall: "APKEYn", + vendor: "9W2KEY", + model: "ATMega328P APRS", + class: "tracker", + os: "embedded", + contact: "mzakiab@gmail.com" + }, + { + tocall: "APKHTW", + vendor: "Kip, W3SN", + model: "Tempest Weather Bridge", + class: "wx", + os: "embedded", + contact: "w3sn@moxracing.33mail.com" + }, + { tocall: "APKRAM", vendor: "kramstuff.com", model: "Ham Tracker", class: "app", os: "ios" }, + { tocall: "APLC??", vendor: "DL3DCW", model: "APRScube" }, + { + tocall: "APLDAG", + vendor: "Inigo, EA2CQ", + model: "DAGA LoRa/APRS SOTA spotting", + class: "service", + contact: "ea2cq@irratia.org", + features: ["messaging"] + }, + { + tocall: "APLDG?", + vendor: "Eddie, 9W2LWK", + model: "LoRAIGate", + class: "igate", + os: "embedded", + contact: "9w2lwk@gmail.com" + }, + { + tocall: "APLDH?", + vendor: "Eddie, 9W2LWK", + model: "LoraTracker", + class: "tracker", + os: "embedded", + contact: "9w2lwk@gmail.com" + }, + { tocall: "APLDI?", vendor: "David, OK2DDS", model: "LoRa IGate/Digipeater", class: "digi" }, + { tocall: "APLDM?", vendor: "David, OK2DDS", model: "LoRa Meteostation", class: "wx" }, + { + tocall: "APLER?", + vendor: "Ercan, TA3OER", + model: "TROY LoRa Tracker/iGate", + os: "embedded", + contact: "ta3oer@gmail.com" + }, + { + tocall: "APLETK", + vendor: "DL5TKL", + model: "T-Echo", + class: "tracker", + os: "embedded", + contact: "cfr34k-git@tkolb.de" + }, + { + tocall: "APLFG?", + vendor: "Gabor, HG3FUG", + model: "LoRa WX station", + class: "wx", + os: "embedded", + contact: "hg3fug@fazi.hu" + }, + { + tocall: "APLFL?", + vendor: "Damian, SQ2CPA", + model: "LoRa/APRS Balloon", + class: "tracker", + os: "embedded", + contact: "sq2cpa@gmail.com" + }, + { tocall: "APLFM?", vendor: "DO1MA", model: "FemtoAPRS", class: "tracker", os: "embedded" }, + { tocall: "APLG??", vendor: "OE5BPA", model: "LoRa Gateway/Digipeater", class: "digi" }, + { + tocall: "APLHB9", + vendor: "SWISS-ARTG", + model: "LoRa iGate RPI", + class: "igate", + os: "Linux/Unix", + contact: "hb9pae@gmail.com" + }, + { + tocall: "APLHI?", + vendor: "Giovanni, IW1CGW", + model: "LoRa IGate/Digipeater/Telemetry", + class: "digi", + contact: "iw1cgw@libero.it" + }, + { + tocall: "APLHM?", + vendor: "Giovanni, IW1CGW", + model: "LoRa Meteostation", + class: "wx", + contact: "iw1cgw@libero.it" + }, + { tocall: "APLIF?", vendor: "TA5Y", model: "TIF LORA APRS I-GATE", class: "igate" }, + { tocall: "APLIG?", vendor: "TA2MUN/TA9OHC", model: "LightAPRS Tracker", class: "tracker" }, + { tocall: "APLLO?", vendor: "HB4LO", model: "HAB BOT", class: "tracker", contact: "david.perrin@hb9hiz.ch" }, + { tocall: "APLM??", vendor: "WA0TQG", class: "software" }, + { tocall: "APLO??", vendor: "SQ9MDD", model: "LoRa KISS TNC/Tracker", class: "tracker" }, + { + tocall: "APLP0?", + vendor: "SQ9P", + model: "fajne digi", + class: "digi", + os: "embedded", + contact: "sq9p.peter@gmail.com" + }, + { + tocall: "APLP1?", + vendor: "SQ9P", + model: "LORA/FSK/AFSK fajny tracker", + class: "tracker", + os: "embedded", + contact: "sq9p.peter@gmail.com" + }, + { tocall: "APLPS?", vendor: "Jose, XE3JAC", model: "ESP-32 LoRa", os: "embedded", contact: "xe3jac@gmail.com" }, + { tocall: "APLRF?", vendor: "Damian, SQ2CPA", model: "LoRa APRS", os: "embedded", contact: "sq2cpa@gmail.com" }, + { + tocall: "APLRG?", + vendor: "Ricardo, CA2RXU", + model: "ESP32 LoRa iGate", + class: "igate", + os: "embedded", + contact: "richonguzman@gmail.com" + }, + { + tocall: "APLRM?", + vendor: "Railab Srl, IU2TZK", + model: "Railab LoRa board", + class: "tracker", + os: "embedded", + contact: "support@railab.com" + }, + { + tocall: "APLRS?", + vendor: "Railab Srl, IU2TZK", + model: "Railab LoRa board", + class: "igate", + os: "embedded", + contact: "support@railab.com" + }, + { + tocall: "APLRT?", + vendor: "Ricardo, CA2RXU", + model: "ESP32 LoRa Tracker", + class: "tracker", + os: "embedded", + contact: "richonguzman@gmail.com" + }, + { tocall: "APLS??", vendor: "SARIMESH", model: "SARIMESH", class: "software" }, + { tocall: "APLT??", vendor: "OE5BPA", model: "LoRa Tracker", class: "tracker" }, + { + tocall: "APLU0?", + vendor: "SP9UP", + model: "ESP32/SX12xx LoRa iGate / Digi", + class: "digi", + os: "embedded", + contact: "wajdzik.m@gmail.com" + }, + { + tocall: "APLU1?", + vendor: "SP9UP", + model: "ESP32/SX12xx LoRa Tracker", + class: "tracker", + os: "embedded", + contact: "wajdzik.m@gmail.com" + }, + { tocall: "APLZA?", vendor: "Huang Xuewu, BD5HTY", model: "LoRa", os: "embedded", contact: "bd5hty@gmail.com" }, + { tocall: "APLZX?", vendor: "N1AF", model: "LoRa-APRS", os: "embedded", contact: "lora-aprs@n1af.org" }, + { tocall: "APMAIL", vendor: "Mike, NA7Q", model: "APRS Mailbox", class: "service", contact: "mike.ph4@gmail.com" }, + { + tocall: "APMBL3", + vendor: "Mobilinkd LLC", + model: "TNC3", + class: "digi", + os: "embedded", + contact: "support@mobilinkd.com" + }, + { + tocall: "APMBL4", + vendor: "Mobilinkd LLC", + model: "TNC4", + class: "digi", + os: "embedded", + contact: "support@mobilinkd.com" + }, + { tocall: "APMBL?", vendor: "Mobilinkd LLC", contact: "support@mobilinkd.com" }, + { + tocall: "APMBLN", + vendor: "Mobilinkd LLC", + model: "NucleoTNC", + class: "digi", + os: "embedded", + contact: "support@mobilinkd.com" + }, + { tocall: "APMG??", vendor: "Alex, AB0TJ", model: "PiCrumbs and MiniGate", class: "software" }, + { tocall: "APMI01", vendor: "Microsat", os: "embedded", model: "WX3in1" }, + { tocall: "APMI02", vendor: "Microsat", os: "embedded", model: "WXEth" }, + { tocall: "APMI03", vendor: "Microsat", os: "embedded", model: "PLXDigi" }, + { tocall: "APMI04", vendor: "Microsat", os: "embedded", model: "WX3in1 Mini" }, + { tocall: "APMI05", vendor: "Microsat", os: "embedded", model: "PLXTracker" }, + { tocall: "APMI06", vendor: "Microsat", os: "embedded", model: "WX3in1 Plus 2.0" }, + { tocall: "APMI??", vendor: "Microsat", os: "embedded" }, + { tocall: "APMON?", vendor: "Amon Schumann, DL9AS", model: "APRS Balloon Tracker", class: "tracker", os: "embedded" }, + { + tocall: "APMPAD", + vendor: "DF1JSL", + model: "Multi-Purpose APRS Daemon", + class: "service", + contact: "joerg.schultze.lutter@gmail.com", + features: ["messaging"] + }, + { tocall: "APMQ??", vendor: "WB2OSZ", model: "Ham Radio of Things" }, + { tocall: "APMT??", vendor: "LZ1PPL", model: "Micro APRS Tracker", class: "tracker" }, + { + tocall: "APN0A?", + vendor: "Jeremy Cooper, KE6JJJ", + model: "N0ARY Full Service Packet BBS", + class: "daemon", + os: "Linux/Unix", + contact: "jeremy.gthb@baymoo.org" + }, + { tocall: "APN102", vendor: "Gregg Wonderly, W5GGW", model: "APRSNow", class: "app", os: "ipad" }, + { tocall: "APN2??", vendor: "VE4KLM", model: "NOSaprs for JNOS 2.0" }, + { tocall: "APN3??", vendor: "Kantronics", model: "KPC-3" }, + { tocall: "APN9??", vendor: "Kantronics", model: "KPC-9612" }, + { + tocall: "APNCM", + vendor: "Keith Kaiser, WA0TJT", + model: "Net Control Manager", + class: "software", + os: "browser", + contact: "wa0tjt@gmail.com" + }, + { tocall: "APND??", vendor: "PE1MEW", model: "DIGI_NED" }, + { + tocall: "APNEO?", + vendor: "chevybowtie", + model: "nesdr-aprs-igate", + class: "daemon", + os: "Linux", + contact: "GitHub - chevybowtie" + }, + { tocall: "APNIC4", vendor: "SQ5EKU", model: "BidaTrak", class: "tracker", os: "embedded" }, + { tocall: "APNIFC", vendor: "Mike, NA7Q", model: "National Interagency Fire Center Alerts", class: "service" }, + { + tocall: "APNJS?", + vendor: "Julien Sansonnens, HB9HRD", + model: "Web messaging service", + class: "service", + contact: "julien.owls@gmail.com", + features: ["messaging"] + }, + { tocall: "APNK01", vendor: "Kenwood", model: "TM-D700", class: "rig", features: ["messaging"] }, + { tocall: "APNK80", vendor: "Kantronics", model: "KAM" }, + { tocall: "APNKMP", vendor: "Kantronics", model: "KAM+" }, + { tocall: "APNKMX", vendor: "Kantronics", model: "KAM-XL" }, + { tocall: "APNL??", vendor: "OE5DXL, OE5HPM", model: "dxlAPRS", class: "daemon", os: "Linux/Unix" }, + { tocall: "APNM??", vendor: "MFJ", model: "TNC" }, + { tocall: "APNP??", vendor: "PacComm", model: "TNC" }, + { tocall: "APNT??", vendor: "SV2AGW", model: "TNT TNC as a digipeater", class: "digi" }, + { tocall: "APNU??", vendor: "IW3FQG", model: "UIdigi", class: "digi" }, + { tocall: "APNV0?", vendor: "SQ8L", model: "VP-Digi", os: "embedded", class: "digi" }, + { tocall: "APNV1?", vendor: "SQ8L", model: "VP-Node", os: "embedded" }, + { tocall: "APNV2?", vendor: "SQ8L", model: "VP-Tracker", class: "tracker" }, + { tocall: "APNV??", vendor: "SQ8L" }, + { tocall: "APNW??", vendor: "SQ3FYK", model: "WX3in1", os: "embedded" }, + { tocall: "APNX??", vendor: "K6DBG", model: "TNC-X" }, + { tocall: "APOA??", vendor: "OpenAPRS", model: "app", class: "app", os: "ios" }, + { tocall: "APOCSG", vendor: "N0AGI", model: "POCSAG" }, + { + tocall: "APODOT", + vendor: "Mike, NA7Q", + model: "Oregon Department of Transportion Traffic Alerts", + class: "service" + }, + { tocall: "APOG7?", vendor: "OpenGD77", model: "OpenGD77", os: "embedded", contact: "Roger VK3KYY/G4KYF" }, + { tocall: "APOLU?", vendor: "AMSAT-LU", model: "Oscar", class: "satellite" }, + { tocall: "APONE?", vendor: "aprs.one" }, + { tocall: "APONEA", vendor: "aprs.one", model: "Mobile app tracker", class: "app" }, + { tocall: "APONET", vendor: "aprs.one", model: "Hardware tracker", class: "tracker" }, + { tocall: "APOPEN", vendor: "David Platt, AE6EO", model: "OpenTNC", os: "embedded", contact: "dplatt@radagast.org" }, + { tocall: "APOPYT", vendor: "Mike, NA7Q", model: "NA7Q Messenger", class: "software", contact: "mike.ph4@gmail.com" }, + { + tocall: "APOSAT", + vendor: "Mike, NA7Q", + model: "Open Source Satellite Gateway", + class: "service", + contact: "mike.ph4@gmail.com" + }, + { + tocall: "APOSB", + vendor: "SharkRF", + model: "openSPOT3", + class: "gadget", + os: "embedded", + contact: "info@sharkrf.com" + }, + { + tocall: "APOSB4", + vendor: "SharkRF", + model: "openSPOT4", + class: "gadget", + os: "embedded", + contact: "info@sharkrf.com" + }, + { tocall: "APOSB?", vendor: "SharkRF", contact: "info@sharkrf.com" }, + { tocall: "APOSBM", vendor: "SharkRF", model: "M1KE", class: "gadget", os: "embedded", contact: "info@sharkrf.com" }, + { + tocall: "APOSMS", + vendor: "Mike, NA7Q", + model: "Open Source SMS Gateway", + class: "service", + contact: "mike.ph4@gmail.com", + features: ["messaging"] + }, + { + tocall: "APOSW?", + vendor: "SharkRF", + model: "openSPOT2", + class: "gadget", + os: "embedded", + contact: "info@sharkrf.com" + }, + { tocall: "APOT??", vendor: "Argent Data Systems", model: "OpenTracker", class: "tracker" }, + { tocall: "APOVU?", vendor: "K J Somaiya Institute", model: "BeliefSat" }, + { tocall: "APOZ??", vendor: "OZ1EKD, OZ7HVO", model: "KissOZ", class: "tracker" }, + { tocall: "APP6??", model: "APRSlib" }, + { + tocall: "APPCO?", + vendor: "RadCommSoft, LLC", + model: "PicoAPRSTracker", + class: "tracker", + os: "embedded", + contact: "ab4mw@radcommsoft.com" + }, + { tocall: "APPIC?", vendor: "DB1NTO", model: "PicoAPRS", class: "tracker" }, + { tocall: "APPM??", vendor: "DL1MX", model: "rtl-sdr Python iGate", class: "software" }, + { + tocall: "APPRIS", + vendor: "DF1JSL", + model: "Apprise APRS plugin", + class: "service", + contact: "joerg.schultze.lutter@gmail.com", + features: ["messaging"] + }, + { + tocall: "APPRJ?", + vendor: "Custom Digital Services, LLC", + model: "Traveler's ptFlex and ptSolar trackers", + class: "tracker", + os: "embedded", + contact: "zack@custom-ds.com" + }, + { + tocall: "APPRO?", + vendor: "KO6IKR", + model: "PyTNC Pro", + class: "software", + os: "Windows", + contact: "KO6IKR@gmail.com" + }, + { + tocall: "APPS??", + vendor: "Øyvind, LA7ECA (for the Norwegian Radio Relay League)", + model: "Polaric Server", + class: "software", + os: "Linux" + }, + { tocall: "APPT??", vendor: "JF6LZE", model: "KetaiTracker", class: "tracker" }, + { + tocall: "APQTH?", + vendor: "Weston Bustraan, W8WJB", + model: "QTH.app", + class: "software", + os: "macOS", + features: ["messaging"] + }, + { + tocall: "APQUIZ", + vendor: "John Rokicki, KC1VMZ", + model: "QUIZME APRS Quizmaster", + class: "service", + contact: "kc1vmz@gmail.com", + features: ["messaging"] + }, + { tocall: "APR2MF", vendor: "Mike, DL2MF", model: "MF2wxAPRS Tinkerforge gateway", class: "wx", os: "Windows" }, + { tocall: "APR8??", vendor: "Bob Bruninga, WB4APR", model: "APRSdos", class: "software" }, + { tocall: "APRARX", vendor: "Open Source", model: "radiosonde_auto_rx", class: "software", os: "Linux/Unix" }, + { + tocall: "APREST", + vendor: "cwop.rest", + model: "HTTP - TCP CWOP Packet Submission", + class: "service", + contact: "leo@herzog.tech" + }, + { tocall: "APRFG?", vendor: "RF.Guru", contact: "info@rf.guru" }, + { tocall: "APRFGB", vendor: "RF.Guru", model: "APRS LoRa Pager", os: "embedded", contact: "info@rf.guru" }, + { + tocall: "APRFGD", + vendor: "RF.Guru", + model: "APRS Digipeater", + class: "digi", + os: "embedded", + contact: "info@rf.guru" + }, + { tocall: "APRFGH", vendor: "RF.Guru", model: "Hotspot", class: "rig", os: "embedded", contact: "info@rf.guru" }, + { + tocall: "APRFGI", + vendor: "RF.Guru", + model: "LoRa APRS iGate", + class: "igate", + os: "embedded", + contact: "info@rf.guru" + }, + { + tocall: "APRFGL", + vendor: "RF.Guru", + model: "Lora APRS Digipeater", + class: "digi", + os: "embedded", + contact: "info@rf.guru" + }, + { tocall: "APRFGM", vendor: "RF.Guru", model: "Mobile Radio", class: "rig", os: "embedded", contact: "info@rf.guru" }, + { + tocall: "APRFGP", + vendor: "RF.Guru", + model: "Portable Radio", + class: "ht", + os: "embedded", + contact: "info@rf.guru" + }, + { tocall: "APRFGR", vendor: "RF.Guru", model: "Repeater", class: "rig", os: "embedded", contact: "info@rf.guru" }, + { + tocall: "APRFGT", + vendor: "RF.Guru", + model: "LoRa APRS Tracker", + class: "tracker", + os: "embedded", + contact: "info@rf.guru" + }, + { + tocall: "APRFGW", + vendor: "RF.Guru", + model: "LoRa APRS Weather Station", + class: "wx", + os: "embedded", + contact: "info@rf.guru" + }, + { + tocall: "APRFTH", + vendor: "Xian Stannard 2E1IPS & Chung Poon M7UNG", + model: "Treasure Hunt Service", + class: "service", + contact: "maintainers@rftrsr.net", + features: ["messaging"] + }, + { tocall: "APRG??", vendor: "OH2GVE", model: "aprsg", class: "software", os: "Linux/Unix" }, + { tocall: "APRHH?", vendor: "Steven D. Bragg, KA9MVA", model: "HamHud", class: "tracker" }, + { + tocall: "APRKEY", + vendor: "Mohammad Zaki, 9W2KEY", + model: "MYANET APRS Bot", + class: "service", + os: "Linux", + contact: "mzakiab@gmail.com" + }, + { + tocall: "APRM20", + vendor: "Open Source", + model: "M20 radiosonde", + class: "tracker", + os: "embedded", + contact: "pawel.sq2ips@gmail.com" + }, + { + tocall: "APRNFW", + vendor: "Franek SP5FRA", + model: "RS41-NFW", + class: "tracker", + os: "embedded", + contact: "franeklada18@gmail.com" + }, + { tocall: "APRNOW", vendor: "Gregg Wonderly, W5GGW", model: "APRSNow", class: "app", os: "ipad" }, + { + tocall: "APRPI?", + vendor: "TA2KVC", + model: "Raspberry Pi APRS / Pico W APRS", + class: "tracker", + contact: "volkancevik@live.com" + }, + { + tocall: "APRPJH", + vendor: "Piju, 9M2PJU", + model: "9M2PJU APRS Heat Bot", + class: "service", + contact: "9m2pju@hamradio.my" + }, + { + tocall: "APRPJM", + vendor: "Piju, 9M2PJU", + model: "APRSMY Net Bot", + class: "service", + contact: "9m2pju@hamradio.my" + }, + { + tocall: "APRPJU", + vendor: "Piju, 9M2PJU", + model: "9M2PJU APRS Bot", + class: "daemon", + contact: "9m2pju@hamradio.my", + features: ["messaging"] + }, + { + tocall: "APRPJW", + vendor: "Piju, 9M2PJU", + model: "9M2PJU APRS WX Bot", + class: "service", + contact: "9m2pju@hamradio.my" + }, + { + tocall: "APRPR?", + vendor: "Robert DM4RW, Peter DL6MAA", + model: "Teensy RPR TNC", + class: "tracker", + os: "embedded", + contact: "dm4rw@skywaves.de" + }, + { tocall: "APRRDZ", model: "rdzTTGOsonde", vendor: "DL9RDZ", class: "tracker" }, + { + tocall: "APRRES", + vendor: "xssfox", + model: "APRS-RepeaterRescue", + class: "network", + os: "embedded", + contact: "repeater-rescue@michaela.lgbt", + features: ["messaging"] + }, + { + tocall: "APRRF?", + vendor: "RRF - Réseau des Répéteurs Francophones", + model: "Tracker for RRF", + class: "tracker", + os: "embedded", + contact: "f1evm@f1evm.fr", + features: ["messaging"] + }, + { tocall: "APRT??", vendor: "Motorola", model: "MotoTRBO" }, + { tocall: "APRS", vendor: "Unknown", model: "Unknown" }, + { tocall: "APRX??", vendor: "Kenneth W. Finnegan, W6KWF", model: "Aprx", class: "igate", os: "Linux/Unix" }, + { tocall: "APS???", vendor: "Brent Hildebrand, KH2Z", model: "APRS+SA", class: "software" }, + { tocall: "APSAR", vendor: "ZL4FOX", model: "SARTrack", class: "software", os: "Windows" }, + { tocall: "APSC??", vendor: "OH2MQK, OH7LZB", model: "aprsc", class: "software" }, + { tocall: "APSDA?", vendor: "APSD Developers", model: "APSD", class: "software", contact: "contact@apsd.app" }, + { + tocall: "APSDR?", + vendor: "Marcus Roskosch, DL8MRE", + model: "sdr-control", + class: "app", + contact: "aprs@ham-radio-apps.com" + }, + { tocall: "APSF??", vendor: "F5OPV, SFCP_LABS", model: "embedded APRS devices", os: "embedded" }, + { tocall: "APSFLG", vendor: "F5OPV, SFCP_LABS", model: "LoRa/APRS Gateway", class: "digi", os: "embedded" }, + { tocall: "APSFRP", vendor: "F5OPV, SFCP_LABS", model: "VHF/UHF Repeater", os: "embedded" }, + { tocall: "APSFTL", vendor: "F5OPV, SFCP_LABS", model: "LoRa/APRS Telemetry Reporter", os: "embedded" }, + { tocall: "APSFWX", vendor: "F5OPV, SFCP_LABS", model: "embedded Weather Station", class: "wx", os: "embedded" }, + { + tocall: "APSIGK", + vendor: "Henri Bergius, DF4HB", + model: "Signal K APRS Plugin", + class: "service", + contact: "henri.bergius@iki.fi" + }, + { tocall: "APSK63", vendor: "Chris Moulding, G4HYG", model: "APRS Messenger", class: "software", os: "Windows" }, + { tocall: "APSMS?", vendor: "Paul Dufresne", model: "SMS gateway", class: "software" }, + { + tocall: "APSN01", + vendor: "CSN Technologies Inc.", + model: "iGateMini", + contact: "info@igatemini.com", + os: "embedded", + features: ["messaging"] + }, + { tocall: "APSN??", vendor: "CSN Technologies Inc." }, + { tocall: "APSRF?", vendor: "SoftRF", model: "Ham Edition", class: "tracker", os: "embedded" }, + { tocall: "APSTAR", vendor: "AllStar Link LLC", model: "Asterisk/app_rpt", class: "daemon", os: "Linux/Unix" }, + { tocall: "APSTM?", vendor: "W7QO", model: "Balloon tracker", class: "tracker" }, + { tocall: "APSTPO", vendor: "N0AGI", model: "Satellite Tracking and Operations", class: "software" }, + { + tocall: "APSVX?", + vendor: "Tobias Blomberg, SM0SVX", + model: "SvxLink", + class: "daemon", + os: "Linux/Unix", + contact: "aprs-deviceid@cyberspejs.net" + }, + { tocall: "APT2??", vendor: "Byonics", model: "TinyTrak2", class: "tracker" }, + { tocall: "APT3??", vendor: "Byonics", model: "TinyTrak3", class: "tracker" }, + { tocall: "APT4??", vendor: "Byonics", model: "TinyTrak4", class: "tracker" }, + { tocall: "APTB??", vendor: "BG5HHP", model: "TinyAPRS" }, + { tocall: "APTCHE", vendor: "PU3IKE", model: "TcheTracker, Tcheduino", class: "tracker" }, + { tocall: "APTCMA", vendor: "Cleber, PU1CMA", model: "CAPI Tracker", class: "tracker" }, + { + tocall: "APTEMP", + vendor: "KL7AF", + model: "APRS-Tempest Weather Gateway", + class: "wx", + os: "Linux/Unix", + contact: "kl7af@foghaven.net" + }, + { + tocall: "APTGIK", + vendor: "Juliet Delta, 9M4GIK", + model: "APRS Melaka", + os: "embedded", + contact: "9m2ikr@gmail.com" + }, + { + tocall: "APTHUR", + model: "APRSThursday weekly event mapbot daemon", + contact: "harihend1973@gmail.com", + vendor: "YD0BCX", + class: "service", + os: "Linux/Unix", + features: ["messaging"] + }, + { tocall: "APTKJ?", vendor: "W9JAJ", model: "ATTiny APRS Tracker", os: "embedded" }, + { + tocall: "APTKVB", + vendor: "IT9KVB", + model: "Python APRS QTH and Weather-Station", + class: "daemon", + contact: "it9kvb@gmail.com" + }, + { tocall: "APTLVC", vendor: "TA5LVC", model: "TR80 APRS Tracker", class: "tracker" }, + { tocall: "APTNG?", vendor: "Filip YU1TTN", model: "Tango Tracker", class: "tracker" }, + { tocall: "APTPN?", vendor: "KN4ORB", model: "TARPN Packet Node Tracker", class: "tracker" }, + { tocall: "APTR??", vendor: "Motorola", model: "MotoTRBO" }, + { tocall: "APTSLA", vendor: "HA2NON", model: "tesla-aprs", class: "daemon", contact: "nonoo@nonoo.hu" }, + { tocall: "APTT*", vendor: "Byonics", model: "TinyTrak", class: "tracker" }, + { + tocall: "APTUR?", + vendor: "aprs.ai, TA7HBK", + model: "Türkiye'nin APRS Uygulaması", + class: "app", + contact: "73@aprs.ai", + features: ["messaging"] + }, + { tocall: "APTW??", vendor: "Byonics", model: "WXTrak", class: "wx" }, + { tocall: "APU1??", vendor: "Roger Barker, G4IDE", model: "UI-View16", class: "software", os: "Windows" }, + { tocall: "APU2*", vendor: "Roger Barker, G4IDE", model: "UI-View32", class: "software", os: "Windows" }, + { tocall: "APUDR?", vendor: "NW Digital Radio", model: "UDR" }, + { tocall: "APVE??", vendor: "unknown", model: "EchoLink" }, + { tocall: "APVM??", vendor: "Digital Radio China Club", model: "DRCC-DVM", class: "igate" }, + { tocall: "APVR??", vendor: "unknown", model: "IRLP" }, + { + tocall: "APW2W?", + vendor: "Joachim Sonnabend, DG3FBL", + model: "WiresX2Web Software", + class: "software", + os: "Windows", + contact: "mail@dg3fbl.de" + }, + { + tocall: "APW9??", + vendor: "Mile Strk, 9A9Y", + model: "WX Katarina", + class: "wx", + os: "embedded", + features: ["messaging"] + }, + { tocall: "APWA??", vendor: "KJ4ERJ", model: "APRSISCE", class: "software", os: "Android" }, + { + tocall: "APWEE?", + vendor: "Tom Keffer and Matthew Wall", + model: "WeeWX Weather Software", + class: "software", + os: "Linux/Unix" + }, + { + tocall: "APWHE?", + vendor: "KF6UFO", + model: "WX-Helios", + class: "wx", + os: "Linux", + contact: "https://github.com/kf6ufo/kf6ufo-wx-helios" + }, + { + tocall: "APWM??", + vendor: "KJ4ERJ", + model: "APRSISCE", + class: "software", + os: "Windows Mobile", + features: ["messaging", "item-in-msg"] + }, + { + tocall: "APWW??", + vendor: "KJ4ERJ", + model: "APRSIS32", + class: "software", + os: "Windows", + features: ["messaging", "item-in-msg"] + }, + { + tocall: "APWXS?", + vendor: "Colin Cogle, W1DNS", + model: "aprs-weather-submit", + class: "daemon", + contact: "https://github.com/rhymeswithmogul/aprs-weather-submit/" + }, + { tocall: "APWnnn", vendor: "Sproul Brothers", model: "WinAPRS", class: "software", os: "Windows" }, + { tocall: "APX???", vendor: "Open Source", model: "Xastir", class: "software", os: "Linux/Unix" }, + { tocall: "APXR??", vendor: "G8PZT", model: "Xrouter" }, + { tocall: "APY01D", vendor: "Yaesu", model: "FT1D", class: "ht" }, + { tocall: "APY02D", vendor: "Yaesu", model: "FT2D", class: "ht" }, + { tocall: "APY05D", vendor: "Yaesu", model: "FT5D", class: "ht" }, + { tocall: "APY200", vendor: "Yaesu", model: "FTM-200D", class: "rig" }, + { tocall: "APY300", vendor: "Yaesu", model: "FTM-300D", class: "rig" }, + { tocall: "APY400", vendor: "Yaesu", model: "FTM-400", class: "rig" }, + { tocall: "APY500", vendor: "Yaesu", model: "FTM-500D", class: "rig" }, + { tocall: "APY510", vendor: "Yaesu", model: "FTM-510D", class: "rig" }, + { tocall: "APYS??", vendor: "W2GMD", model: "Python APRS", class: "software" }, + { tocall: "APZ*", vendor: "Unknown", model: "Experimental" }, + { tocall: "APZ18", vendor: "IW3FQG", model: "UIdigi", class: "digi" }, + { tocall: "APZ186", vendor: "IW3FQG", model: "UIdigi", class: "digi" }, + { tocall: "APZ19", vendor: "IW3FQG", model: "UIdigi", class: "digi" }, + { tocall: "APZ247", model: "UPRS", vendor: "NR0Q" }, + { tocall: "APZG??", vendor: "OH2GVE", model: "aprsg", class: "software", os: "Linux/Unix" }, + { tocall: "APZMAJ", vendor: "M1MAJ", model: "DeLorme inReach Tracker" }, + { tocall: "APZMDR", vendor: "Open Source", model: "HaMDR", class: "tracker", os: "embedded" }, + { tocall: "APZTKP", vendor: "Nick Hanks, N0LP", model: "TrackPoint", class: "tracker", os: "embedded" }, + { tocall: "APZWKR", vendor: "GM1WKR", model: "NetSked", class: "software" }, + { tocall: "APnnnD", vendor: "Painter Engineering", model: "uSmartDigi D-Gate", class: "dstar" }, + { tocall: "APnnnU", vendor: "Painter Engineering", model: "uSmartDigi Digipeater", class: "digi" }, + { tocall: "PSKAPR", vendor: "Open Source", model: "PSKmail", class: "software" } +]); diff --git a/src/frame.ts b/src/frame.ts index f604c8f..826e968 100644 --- a/src/frame.ts +++ b/src/frame.ts @@ -3,7 +3,9 @@ import { FieldType } from "@hamradio/packet"; import { DTM, GGA, INmeaSentence, Decoder as NmeaDecoder, RMC } from "extended-nmea"; import { + DO_NOT_ARCHIVE_MARKER, DataType, + DataTypeNames, type IAddress, IDirectionFinding, type IFrame, @@ -171,6 +173,16 @@ export class Address implements IAddress { } } +interface Extras { + comment: string; + altitude?: number; + range?: number; + phg?: IPowerHeightGain; + dfs?: IDirectionFinding; + cse?: number; + spd?: number; + fields?: Field[]; +} export class Frame implements IFrame { source: Address; destination: Address; @@ -213,11 +225,12 @@ export class Frame implements IFrame { structure.push(routingSection); // Add data type identifier section + const fieldName: string = DataTypeNames[this.getDataTypeIdentifier() as DataType] || "unknown"; structure.push({ name: "Data Type Identifier", data: new TextEncoder().encode(this.payload.charAt(0)).buffer, isString: true, - fields: [{ type: FieldType.CHAR, name: "Identifier", length: 1 }] + fields: [{ type: FieldType.CHAR, name: "identifier", length: 1, value: fieldName }] }); } return { payload: null, structure }; @@ -299,11 +312,12 @@ export class Frame implements IFrame { structure.push(routingSection); } // Add data type identifier section + const fieldName: string = DataTypeNames[dataType as DataType] || "unknown"; structure.push({ name: "data type", data: new TextEncoder().encode(this.payload.charAt(0)).buffer, isString: true, - fields: [{ type: FieldType.CHAR, name: "identifier", length: 1 }] + fields: [{ type: FieldType.CHAR, name: "identifier", length: 1, value: fieldName }] }); if (payloadsegment) { structure.push(...payloadsegment); @@ -402,21 +416,10 @@ export class Frame implements IFrame { comment = this.payload.substring(offset); } - // Parse altitude from comment if present (format: /A=NNNNNN) - const altMatch = comment.match(/\/A=(\d{6})/); - if (altMatch) { - position.altitude = parseInt(altMatch[1], 10) * 0.3048; // feet to meters - // remove altitude token from comment - comment = comment.replace(altMatch[0], "").trim(); - } - - // Extract RNG and PHG tokens and optionally emit sections - const extras = this.parseCommentExtras(comment, withStructure); - if (extras.range !== undefined) position.range = extras.range; - if (extras.phg !== undefined) position.phg = extras.phg; - if (extras.dfs !== undefined) position.dfs = extras.dfs; - if (extras.cse !== undefined && position.course === undefined) position.course = extras.cse; - if (extras.spd !== undefined && position.speed === undefined) position.speed = extras.spd; + // Extract Altitude, CSE/SPD, RNG and PHG tokens and optionally emit sections + const remainder = comment; // Use the remaining comment text for parsing extras + const doNotArchive = remainder.includes(DO_NOT_ARCHIVE_MARKER); + const extras = this.parseCommentExtras(remainder, withStructure); comment = extras.comment; if (comment) { @@ -425,12 +428,11 @@ export class Frame implements IFrame { // Emit comment section as we parse if (withStructure) { const commentFields: Field[] = [{ type: FieldType.STRING, name: "text", length: comment.length }]; - if (extras.fields) commentFields.push(...extras.fields); structure.push({ name: "comment", - data: new TextEncoder().encode(comment).buffer, + data: new TextEncoder().encode(remainder).buffer, isString: true, - fields: commentFields + fields: [...(extras.fields || []), ...commentFields] }); } } else if (withStructure && extras.fields) { @@ -439,7 +441,7 @@ export class Frame implements IFrame { name: "comment", data: new TextEncoder().encode("").buffer, isString: true, - fields: extras.fields + fields: [...(extras.fields || [])] }); } @@ -467,10 +469,12 @@ export class Frame implements IFrame { const payload: PositionPayload = { type: payloadType, + doNotArchive, timestamp, position, messaging }; + this.attachExtras(payload, extras); if (withStructure) { return { payload, segment: structure }; @@ -645,7 +649,7 @@ export class Frame implements IFrame { if (cs === " " && t >= 33 && t <= 124) { // Compressed altitude: altitude = 1.002^(t-33) feet const altFeet = Math.pow(1.002, t - 33); - result.altitude = altFeet * 0.3048; // Convert to meters + result.altitude = feetToMeters(altFeet); // Convert to meters } const section: Segment | undefined = withStructure @@ -755,52 +759,67 @@ export class Frame implements IFrame { return { position: result, segment }; } - private parseCommentExtras( - comment: string, - withStructure: boolean = false - ): { - comment: string; - range?: number; - phg?: IPowerHeightGain; - dfs?: IDirectionFinding; - fields?: Field[]; - cse?: number; - spd?: number; - } { + private parseCommentExtras(comment: string, withStructure: boolean = false): Extras { if (!comment || comment.length === 0) return { comment }; - let cleaned = comment; - let range: number | undefined; - let phg: IPowerHeightGain | undefined; - let dfs: IDirectionFinding | undefined; + const extras: Partial = {}; const fields: Field[] = []; - let cse: number | undefined; - let spd: number | undefined; - // Process successive 7-byte data extensions at the start of the comment. - while (true) { - const trimmed = cleaned.trimStart(); - if (trimmed.length < 7) break; + let ext = comment.trimStart(); + while (ext.length >= 7) { + // /A=NNNNNN -> altitude in feet (6 digits) + // /A=-NNNNN -> altitude in feet with leading minus for negative altitudes (5 digits) + const altMatch = ext.match(/\/A=(-\d{5}|\d{6})/); + if (altMatch) { + const altitude = feetToMeters(parseInt(altMatch[1], 10)); // feet to meters + if (isNaN(altitude)) { + break; // Invalid altitude format, stop parsing extras + } + extras.altitude = altitude; - // Allow a single non-alphanumeric prefix (e.g. '>' or '#') before extension - const prefixLen = /^[^A-Za-z0-9]/.test(trimmed.charAt(0)) ? 1 : 0; - if (trimmed.length < prefixLen + 7) break; - const ext = trimmed.substring(prefixLen, prefixLen + 7); + if (withStructure) { + fields.push({ type: FieldType.STRING, name: "altitude marker", length: 2 }); + fields.push({ + type: FieldType.STRING, + name: "altitude value", + length: altMatch[1].length, + value: altitude.toFixed(3) + "m" + }); + } + + // remove altitude token from comment and advance ext for further parsing + comment = comment.replace(altMatch[0], "").trimStart(); + ext = ext.replace(altMatch[0], "").trimStart(); + + continue; + } // RNGrrrr -> pre-calculated range in miles (4 digits) if (ext.startsWith("RNG")) { const r = ext.substring(3, 7); if (/^\d{4}$/.test(r)) { - range = milesToMeters(parseInt(r, 10)); - cleaned = trimmed.substring(prefixLen + 7).trim(); - if (withStructure) fields.push({ type: FieldType.STRING, name: "RNG", length: 7 }); + extras.range = milesToMeters(parseInt(r, 10)) / 1000.0; // Convert to kilometers + if (withStructure) { + fields.push({ type: FieldType.STRING, name: "RNG marker", length: 3, value: "RNG" }); + fields.push({ + type: FieldType.STRING, + name: "range (rrrr)", + length: 4, + value: extras.range.toString() + "km" + }); + } + + // remove range token from comment and advance ext for further parsing + comment = comment.substring(7).trimStart(); + ext = ext.substring(7).trimStart(); + continue; } } // PHGphgd - if (!phg && ext.startsWith("PHG")) { + if (!extras.phg && ext.startsWith("PHG")) { // PHGphgd: p = power (0-9 or space), h = height (0-9 or space), g = gain (0-9 or space), d = directivity (0-9 or space) const p = ext.charAt(3); const h = ext.charAt(4); @@ -825,14 +844,50 @@ export class Frame implements IFrame { directivity = "unknown"; } - phg = { + extras.phg = { power: powerWatts, height: heightMeters, gain: gainDbi, directivity }; - cleaned = trimmed.substring(prefixLen + 7).trim(); - if (withStructure) fields.push({ type: FieldType.STRING, name: "PHG", length: 7 }); + + if (withStructure) { + fields.push({ type: FieldType.STRING, name: "PHG marker", length: 3, value: "PHG" }); + fields.push({ + type: FieldType.STRING, + name: "power (p)", + length: 1, + value: powerWatts !== undefined ? powerWatts.toString() + "W" : undefined + }); + fields.push({ + type: FieldType.STRING, + name: "height (h)", + length: 1, + value: heightMeters !== undefined ? heightMeters.toString() + "m" : undefined + }); + fields.push({ + type: FieldType.STRING, + name: "gain (g)", + length: 1, + value: gainDbi !== undefined ? gainDbi.toString() + "dBi" : undefined + }); + fields.push({ + type: FieldType.STRING, + name: "directivity (d)", + length: 1, + value: + directivity !== undefined + ? typeof directivity === "number" + ? directivity.toString() + "°" + : directivity + : undefined + }); + } + + // remove PHG token from comment and advance ext for further parsing + comment = comment.substring(7).trimStart(); + ext = ext.substring(7).trimStart(); + continue; } @@ -871,68 +926,149 @@ export class Frame implements IFrame { directivity = "unknown"; } - dfs = { + extras.dfs = { strength, height: heightMeters, gain: gainDbi, directivity }; - cleaned = trimmed.substring(prefixLen + 7).trim(); - if (withStructure) fields.push({ type: FieldType.STRING, name: "DFS", length: 7 }); + if (withStructure) { + fields.push({ type: FieldType.STRING, name: "DFS marker", length: 3, value: "DFS" }); + fields.push({ + type: FieldType.STRING, + name: "strength (s)", + length: 1, + value: strength !== undefined ? strength.toString() : undefined + }); + fields.push({ + type: FieldType.STRING, + name: "height (h)", + length: 1, + value: heightMeters !== undefined ? heightMeters.toString() + "m" : undefined + }); + fields.push({ + type: FieldType.STRING, + name: "gain (g)", + length: 1, + value: gainDbi !== undefined ? gainDbi.toString() + "dBi" : undefined + }); + fields.push({ + type: FieldType.STRING, + name: "directivity (d)", + length: 1, + value: + directivity !== undefined + ? typeof directivity === "number" + ? directivity.toString() + "°" + : directivity + : undefined + }); + } + + // remove DFS token from comment and advance ext for further parsing + comment = comment.substring(7).trimStart(); + ext = ext.substring(7).trimStart(); + continue; } // Course/Speed DDD/SSS (7 bytes: 3 digits / 3 digits) - if (!cse && /^\d{3}\/\d{3}$/.test(ext)) { + if (extras.cse === undefined && /^\d{3}\/\d{3}/.test(ext)) { const courseStr = ext.substring(0, 3); const speedStr = ext.substring(4, 7); - cse = parseInt(courseStr, 10); - spd = knotsToKmh(parseInt(speedStr, 10)); - cleaned = trimmed.substring(prefixLen + 7).trim(); - if (withStructure) fields.push({ type: FieldType.STRING, name: "CSE/SPD", length: 7 }); + extras.cse = parseInt(courseStr, 10); + extras.spd = knotsToKmh(parseInt(speedStr, 10)); + + if (withStructure) { + fields.push({ type: FieldType.STRING, name: "course", length: 3, value: extras.cse.toString() + "°" }); + fields.push({ type: FieldType.CHAR, name: "marker", length: 1, value: "/" }); + fields.push({ type: FieldType.STRING, name: "speed", length: 3, value: extras.spd.toString() + " km/h" }); + } + + // remove course/speed token from comment and advance ext for further parsing + comment = comment.substring(7).trimStart(); + ext = ext.substring(7).trimStart(); // If there is an 8-byte DF/NRQ following (leading '/'), parse that too - if (cleaned.length >= 8 && cleaned.charAt(0) === "/") { - const dfExt = cleaned.substring(0, 8); // e.g. /270/729 + if (ext.length >= 8 && ext.charAt(0) === "/") { + const dfExt = ext.substring(0, 8); // e.g. /270/729 const m = dfExt.match(/\/(\d{3})\/(\d{3})/); if (m) { const dfBearing = parseInt(m[1], 10); const dfStrength = parseInt(m[2], 10); - if (dfs === undefined) { - dfs = {}; + if (extras.dfs === undefined) { + extras.dfs = {}; } - dfs.bearing = dfBearing; - dfs.strength = dfStrength; - if (withStructure) fields.push({ type: FieldType.STRING, name: "DF/NRQ", length: 8 }); - cleaned = cleaned.substring(8).trim(); + extras.dfs.bearing = dfBearing; + extras.dfs.strength = dfStrength; + + if (withStructure) { + fields.push({ type: FieldType.STRING, name: "DF marker", length: 1, value: "/" }); + fields.push({ type: FieldType.STRING, name: "bearing", length: 3, value: dfBearing.toString() + "°" }); + fields.push({ type: FieldType.CHAR, name: "separator", length: 1, value: "/" }); + fields.push({ type: FieldType.STRING, name: "strength", length: 3, value: dfStrength.toString() }); + } + + // remove DF token from comment and advance ext for further parsing + comment = comment.substring(8).trimStart(); + ext = ext.substring(8).trimStart(); + + continue; } } continue; } - // No recognized 7-byte extension at start + // No recognized 7+-byte extension at start break; } - const extrasObj: { - comment: string; - range?: number; - phg?: IPowerHeightGain; - dfs?: IDirectionFinding; - cse?: number; - spd?: number; - dfBearing?: number; - dfStrength?: number; - fields?: Field[]; - } = { comment: cleaned }; - if (range !== undefined) extrasObj.range = range; - if (phg !== undefined) extrasObj.phg = phg; - if (dfs !== undefined) extrasObj.dfs = dfs; - if (cse !== undefined) extrasObj.cse = cse; - if (spd !== undefined) extrasObj.spd = spd; - if (fields.length) extrasObj.fields = fields; - return extrasObj; + extras.comment = comment; + extras.fields = fields.length > 0 ? fields : undefined; + + return extras as Extras; + } + + private attachExtras(payload: Payload, extras: Extras) { + if ("position" in payload && payload.position) { + if (extras.altitude !== undefined) { + payload.position.altitude = extras.altitude; + } + if (extras.range !== undefined) { + payload.position.range = extras.range; + } + if (extras.phg !== undefined) { + payload.position.phg = extras.phg; + } + if (extras.dfs !== undefined) { + payload.position.dfs = extras.dfs; + } + if (extras.cse !== undefined && payload.position.course === undefined) { + payload.position.course = extras.cse; + } + if (extras.spd !== undefined && payload.position.speed === undefined) { + payload.position.speed = extras.spd; + } + } + if ("altitude" in payload && payload.altitude === undefined && extras.altitude !== undefined) { + payload.altitude = extras.altitude; + } + if ("range" in payload && payload.range === undefined && extras.range !== undefined) { + payload.range = extras.range; + } + if ("phg" in payload && payload.phg === undefined && extras.phg !== undefined) { + payload.phg = extras.phg; + } + if ("dfs" in payload && payload.dfs === undefined && extras.dfs !== undefined) { + payload.dfs = extras.dfs; + } + if ("course" in payload && payload.course === undefined && extras.cse !== undefined) { + payload.course = extras.cse; + } + if ("speed" in payload && payload.speed === undefined && extras.spd !== undefined) { + payload.speed = extras.spd; + } } private decodeMicE(withStructure: boolean = false): { @@ -1007,7 +1143,7 @@ export class Frame implements IFrame { if (speed >= 800) speed -= 800; // Convert speed from knots to km/h - const speedKmh = speed * 1.852; + const speedKmh = knotsToKmh(speed); // Symbol code and table if (this.payload.length < offset + 2) return { payload: null }; @@ -1017,39 +1153,30 @@ export class Frame implements IFrame { // Parse remaining data (altitude, comment, telemetry) const remaining = this.payload.substring(offset); + const doNotArchive = remaining.includes(DO_NOT_ARCHIVE_MARKER); let altitude: number | undefined = undefined; let comment = remaining; - // Check for altitude in various formats - const altMatch = remaining.match(/\/A=(\d{6})/); - if (altMatch) { - altitude = parseInt(altMatch[1], 10) * 0.3048; // feet to meters - comment = comment.replace(altMatch[0], "").trim(); - } else if (remaining.startsWith("}")) { - if (remaining.length >= 4) { - try { - const altBase91 = remaining.substring(1, 4); - const altFeet = base91ToNumber(altBase91) - 10000; - altitude = altFeet * 0.3048; // feet to meters - } catch { - // Ignore altitude parsing errors - } + // Check for altitude in old format + if (comment.length >= 4 && comment.charAt(3) === "}") { + try { + const altBase91 = comment.substring(0, 3); + altitude = base91ToNumber(altBase91) - 10000; // Relative to 10km below mean sea level + comment = comment.substring(4); // Remove altitude token from comment + } catch { + // Ignore altitude parsing errors } } // Parse RNG/PHG tokens from comment (defer attaching to result until created) - const extras = this.parseCommentExtras(comment, withStructure); - const extrasRange = extras.range; - const extrasPhg = extras.phg; - const extrasDfs = extras.dfs; - const extrasCse = extras.cse; - const extrasSpd = extras.spd; + const remainder = comment; // Use the remaining comment text for parsing extras + const extras = this.parseCommentExtras(remainder, withStructure); comment = extras.comment; - let payloadType: DataType.MicECurrent | DataType.MicEOld; + let payloadType: DataType.MicE | DataType.MicEOld; switch (this.payload.charAt(0)) { case "`": - payloadType = DataType.MicECurrent; + payloadType = DataType.MicE; break; case "'": payloadType = DataType.MicEOld; @@ -1060,6 +1187,7 @@ export class Frame implements IFrame { const result: MicEPayload = { type: payloadType, + doNotArchive, position: { latitude, longitude, @@ -1088,22 +1216,14 @@ export class Frame implements IFrame { result.position.comment = comment; } - // Attach parsed extras (RNG / PHG / CSE / SPD / DF) if present - if (extrasRange !== undefined) result.position.range = extrasRange; - if (extrasPhg !== undefined) result.position.phg = extrasPhg; - if (extrasDfs !== undefined) result.position.dfs = extrasDfs; - if (extrasCse !== undefined && result.position.course === undefined) result.position.course = extrasCse; - if (extrasSpd !== undefined && result.position.speed === undefined) result.position.speed = extrasSpd; - if (withStructure && extras.fields) { - // merge extras fields into comment field(s) - // if there is an existing comment segment later, we'll include fields there; otherwise add a comment-only segment - } + // Attach parsed extras if present + this.attachExtras(result, extras); if (withStructure) { // Information field section (bytes after data type up to comment) const infoData = this.payload.substring(1, offset); segments.push({ - name: "mic-e info", + name: "mic-E info", data: new TextEncoder().encode(infoData).buffer, isString: true, fields: [ @@ -1119,18 +1239,19 @@ export class Frame implements IFrame { }); if (comment && comment.length > 0) { - const commentFields: Field[] = [{ type: FieldType.STRING, name: "text", length: comment.length }]; - if (extras.fields) commentFields.push(...extras.fields); + const commentFields: Field[] = [ + { type: FieldType.STRING, name: "comment", length: comment.length, value: comment } + ]; segments.push({ name: "comment", - data: new TextEncoder().encode(comment).buffer, + data: new TextEncoder().encode(remainder).buffer, isString: true, - fields: commentFields + fields: [...(extras.fields || []), ...commentFields] }); } else if (extras.fields) { segments.push({ name: "comment", - data: new TextEncoder().encode("").buffer, + data: new TextEncoder().encode(remainder).buffer, isString: true, fields: extras.fields }); @@ -1301,10 +1422,12 @@ export class Frame implements IFrame { if (textStart >= 0 && textStart + 1 <= this.payload.length) { text = this.payload.substring(textStart + 1); } + const doNotArchive = text.includes(DO_NOT_ARCHIVE_MARKER); const payload: MessagePayload = { type: DataType.Message, variant: "message", + doNotArchive, addressee: recipient, text }; @@ -1425,53 +1548,44 @@ export class Frame implements IFrame { } offset += consumed; - let comment = this.payload.substring(offset); - - // Parse altitude token in comment (/A=NNNNNN) - const altMatchObj = comment.match(/\/A=(\d{6})/); - if (altMatchObj) { - position.altitude = parseInt(altMatchObj[1], 10) * 0.3048; - comment = comment.replace(altMatchObj[0], "").trim(); - } + const remainder = this.payload.substring(offset); + const doNotArchive = remainder.includes(DO_NOT_ARCHIVE_MARKER); + let comment = remainder; // Parse RNG/PHG tokens - const extrasObj = this.parseCommentExtras(comment, withStructure); - if (extrasObj.range !== undefined) position.range = extrasObj.range; - if (extrasObj.phg !== undefined) position.phg = extrasObj.phg; - if (extrasObj.dfs !== undefined) position.dfs = extrasObj.dfs; - if (extrasObj.cse !== undefined && position.course === undefined) position.course = extrasObj.cse; - if (extrasObj.spd !== undefined && position.speed === undefined) position.speed = extrasObj.spd; - comment = extrasObj.comment; + const extras = this.parseCommentExtras(comment, withStructure); + comment = extras.comment; if (comment) { position.comment = comment; if (withStructure) { - const commentFields: Field[] = [{ type: FieldType.STRING, name: "text", length: comment.length }]; - if (extrasObj.fields) commentFields.push(...extrasObj.fields); + const commentFields: Field[] = [{ type: FieldType.STRING, name: "comment", length: comment.length }]; segment.push({ name: "Comment", - data: new TextEncoder().encode(comment).buffer, + data: new TextEncoder().encode(remainder).buffer, isString: true, - fields: commentFields + fields: [...(extras.fields || []), ...commentFields] }); } - } else if (withStructure && extrasObj.fields) { + } else if (withStructure && extras.fields) { segment.push({ name: "Comment", - data: new TextEncoder().encode("").buffer, + data: new TextEncoder().encode(remainder).buffer, isString: true, - fields: extrasObj.fields + fields: [...(extras.fields || [])] }); } const payload: ObjectPayload = { type: DataType.Object, + doNotArchive, name, timestamp, alive, position }; + this.attachExtras(payload, extras); if (withStructure) { return { payload, segment }; @@ -1578,54 +1692,44 @@ export class Frame implements IFrame { } offset += consumed; - let comment = this.payload.substring(offset); - - // Parse altitude token in comment (/A=NNNNNN) - const altMatchItem = comment.match(/\/A=(\d{6})/); - if (altMatchItem) { - position.altitude = parseInt(altMatchItem[1], 10) * 0.3048; - comment = comment.replace(altMatchItem[0], "").trim(); - } + const remainder = this.payload.substring(offset); + const doNotArchive = remainder.includes(DO_NOT_ARCHIVE_MARKER); + let comment = remainder; const extrasItem = this.parseCommentExtras(comment, withStructure); - if (extrasItem.range !== undefined) position.range = extrasItem.range; - if (extrasItem.phg !== undefined) position.phg = extrasItem.phg; - if (extrasItem.dfs !== undefined) position.dfs = extrasItem.dfs; - if (extrasItem.cse !== undefined && position.course === undefined) position.course = extrasItem.cse; - if (extrasItem.spd !== undefined && position.speed === undefined) position.speed = extrasItem.spd; comment = extrasItem.comment; if (comment) { position.comment = comment; if (withStructure) { + const commentFields: Field[] = [ + { type: FieldType.STRING, name: "comment", length: comment.length, value: comment } + ]; segment.push({ name: "Comment", - data: new TextEncoder().encode(comment).buffer, + data: new TextEncoder().encode(remainder).buffer, isString: true, - fields: [{ type: FieldType.STRING, name: "text", length: comment.length }] + fields: [...(extrasItem.fields || []), ...commentFields] }); - if (extrasItem.fields) { - // merge extras fields into the last comment segment - const last = segment[segment.length - 1]; - if (last && last.fields) last.fields.push(...extrasItem.fields); - } } } else if (withStructure && extrasItem.fields) { // No free-text comment, but extras fields exist: emit comment-only segment segment.push({ name: "Comment", - data: new TextEncoder().encode("").buffer, + data: new TextEncoder().encode(remainder).buffer, isString: true, - fields: extrasItem.fields + fields: [...(extrasItem.fields || [])] }); } const payload: ItemPayload = { type: DataType.Item, + doNotArchive, name, alive, position }; + this.attachExtras(payload, extrasItem); if (withStructure) { return { payload, segment }; @@ -1659,6 +1763,7 @@ export class Frame implements IFrame { // Remaining text is status text const text = this.payload.substring(offset); if (!text) return { payload: null }; + const doNotArchive = text.includes(DO_NOT_ARCHIVE_MARKER); // Detect trailing Maidenhead locator (4 or 6 chars) at end of text separated by space let maidenhead: string | undefined; @@ -1671,6 +1776,7 @@ export class Frame implements IFrame { const payload: StatusPayload = { type: DataType.Status, + doNotArchive, timestamp: undefined, text: statusText }; diff --git a/src/frame.types.ts b/src/frame.types.ts index fac9b71..1657c49 100644 --- a/src/frame.types.ts +++ b/src/frame.types.ts @@ -1,5 +1,12 @@ import { Dissected, Segment } from "@hamradio/packet"; +// Any comment that contains this marker will set the doNotArchive flag on the +// decoded payload, which can be used by applications to skip archiving or +// logging frames that are meant to be transient or test data. This allows users +// to include the marker in their APRS comments when they want to indicate that +// a particular frame should not be stored long-term. +export const DO_NOT_ARCHIVE_MARKER = "!x!"; + export interface IAddress { call: string; ssid: string; @@ -22,7 +29,7 @@ export enum DataType { PositionWithTimestampWithMessaging = "@", // Mic-E - MicECurrent = "`", + MicE = "`", MicEOld = "'", // Messages and Bulletins @@ -60,6 +67,27 @@ export enum DataType { InvalidOrTest = "," } +export const DataTypeNames: { [key in DataType]: string } = { + [DataType.PositionNoTimestampNoMessaging]: "position", + [DataType.PositionNoTimestampWithMessaging]: "position with messaging", + [DataType.PositionWithTimestampNoMessaging]: "position with timestamp", + [DataType.PositionWithTimestampWithMessaging]: "position with timestamp and messaging", + [DataType.MicE]: "Mic-E", + [DataType.MicEOld]: "Mic-E (old)", + [DataType.Message]: "message/bulletin", + [DataType.Object]: "object", + [DataType.Item]: "item", + [DataType.Status]: "status", + [DataType.Query]: "query", + [DataType.TelemetryData]: "telemetry data", + [DataType.WeatherReportNoPosition]: "weather report", + [DataType.RawGPS]: "raw GPS data", + [DataType.StationCapabilities]: "station capabilities", + [DataType.UserDefined]: "user defined", + [DataType.ThirdParty]: "third-party traffic", + [DataType.InvalidOrTest]: "invalid/test" +}; + export interface ISymbol { table: string; // Symbol table identifier code: string; // Symbol code @@ -73,21 +101,13 @@ export interface IPosition { longitude: number; // Decimal degrees ambiguity?: number; // Position ambiguity (0-4) altitude?: number; // Meters - speed?: number; // Speed in knots/kmh depending on source + speed?: number; // Speed in km/h course?: number; // Course in degrees + range?: number; // Kilometers + phg?: IPowerHeightGain; + dfs?: IDirectionFinding; symbol?: ISymbol; comment?: string; - /** - * Optional reported radio range in miles (from RNG token in comment) - */ - range?: number; - /** - * Optional power/height/gain information from PHG token - * PHG format: PHGpphhgg (pp=power, hh=height, gg=gain) as numeric values - */ - phg?: IPowerHeightGain; - /** Direction-finding / DF information parsed from comment tokens */ - dfs?: IDirectionFinding; toString(): string; // Return combined position representation (e.g., "lat,lon,alt") toCompressed?(): CompressedPosition; // Optional method to convert to compressed format @@ -128,6 +148,7 @@ export interface PositionPayload { | DataType.PositionNoTimestampWithMessaging | DataType.PositionWithTimestampNoMessaging | DataType.PositionWithTimestampWithMessaging; + doNotArchive?: boolean; // Optional flag to indicate frame should not be archived timestamp?: ITimestamp; position: IPosition; messaging: boolean; // Whether APRS messaging is enabled @@ -156,7 +177,8 @@ export interface CompressedPosition { // Mic-E Payload (compressed in destination address) export interface MicEPayload { - type: DataType.MicECurrent | DataType.MicEOld; + type: DataType.MicE | DataType.MicEOld; + doNotArchive?: boolean; // Optional flag to indicate frame should not be archived position: IPosition; messageType?: string; // Standard Mic-E message isStandard?: boolean; // Whether messageType is a standard Mic-E message @@ -170,6 +192,7 @@ export type MessageVariant = "message" | "bulletin"; export interface MessagePayload { type: DataType.Message; variant: "message"; + doNotArchive?: boolean; // Optional flag to indicate frame should not be archived addressee: string; // 9 character padded callsign text: string; // Message text messageNumber?: string; // Message ID for acknowledgment @@ -181,6 +204,7 @@ export interface MessagePayload { export interface BulletinPayload { type: DataType.Message; variant: "bulletin"; + doNotArchive?: boolean; // Optional flag to indicate frame should not be archived bulletinId: string; // Bulletin identifier (BLN#) text: string; group?: string; // Optional group bulletin @@ -189,6 +213,7 @@ export interface BulletinPayload { // Object Payload export interface ObjectPayload { type: DataType.Object; + doNotArchive?: boolean; // Optional flag to indicate frame should not be archived name: string; // 9 character object name timestamp: ITimestamp; alive: boolean; // True if object is active, false if killed @@ -200,6 +225,7 @@ export interface ObjectPayload { // Item Payload export interface ItemPayload { type: DataType.Item; + doNotArchive?: boolean; // Optional flag to indicate frame should not be archived name: string; // 3-9 character item name alive: boolean; // True if item is active, false if killed position: IPosition; @@ -208,6 +234,7 @@ export interface ItemPayload { // Status Payload export interface StatusPayload { type: DataType.Status; + doNotArchive?: boolean; // Optional flag to indicate frame should not be archived timestamp?: ITimestamp; text: string; maidenhead?: string; // Optional Maidenhead grid locator @@ -332,6 +359,7 @@ export interface DFReportPayload { export interface BasePayload { type: DataType; + doNotArchive?: boolean; // Optional flag to indicate frame should not be archived } // Union type for all decoded payload types diff --git a/src/index.ts b/src/index.ts index a3c63c0..f7cd601 100644 --- a/src/index.ts +++ b/src/index.ts @@ -43,3 +43,6 @@ export { celsiusToFahrenheit, fahrenheitToCelsius } from "./parser"; + +export { getDeviceID } from "./deviceid"; +export type { DeviceID } from "./deviceid"; diff --git a/test/deviceid.test.ts b/test/deviceid.test.ts new file mode 100644 index 0000000..76df1a6 --- /dev/null +++ b/test/deviceid.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; + +import { getDeviceID } from "../src/deviceid"; +import { Frame } from "../src/frame"; + +describe("DeviceID parsing", () => { + it("parses known device ID from tocall", () => { + const data = "WB2OSZ-5>APDW17:!4237.14NS07120.83W#PHG7140"; + const frame = Frame.fromString(data); + const deviceID = getDeviceID(frame.destination); + expect(deviceID).not.toBeNull(); + expect(deviceID?.tocall).toBe("APDW??"); + expect(deviceID?.vendor).toBe("WB2OSZ"); + }); + + it("returns null for unknown device ID", () => { + const data = "CALL>WORLD:!4237.14NS07120.83W#PHG7140"; + const frame = Frame.fromString(data); + const deviceID = getDeviceID(frame.destination); + expect(deviceID).toBeNull(); + }); +}); diff --git a/test/frame.extras.test.ts b/test/frame.extras.test.ts index b813942..091d16b 100644 --- a/test/frame.extras.test.ts +++ b/test/frame.extras.test.ts @@ -3,8 +3,9 @@ import { describe, expect, it } from "vitest"; import { Frame } from "../src/frame"; import type { PositionPayload } from "../src/frame.types"; +import { feetToMeters, milesToMeters } from "../src/parser"; -describe("APRS extras test vectors (RNG / PHG / CSE/DDD / SPD / DFS)", () => { +describe("APRS extras test vectors", () => { it("parses PHG from position with messaging (spec vector 1)", () => { const raw = "NOCALL>APZRAZ,qAS,PA2RDK-14:=5154.19N/00627.77E>PHG500073 de NOCALL"; const frame = Frame.fromString(raw); @@ -34,7 +35,7 @@ describe("APRS extras test vectors (RNG / PHG / CSE/DDD / SPD / DFS)", () => { const commentSeg = structure.find((s) => /comment/i.test(String(s.name))) as Segment | undefined; expect(commentSeg).toBeDefined(); const fields = (commentSeg!.fields ?? []) as Field[]; - const hasPHG = fields.some((f) => f.name === "PHG"); + const hasPHG = fields.some((f) => f.name === "PHG marker"); expect(hasPHG).toBe(true); }); @@ -52,7 +53,7 @@ describe("APRS extras test vectors (RNG / PHG / CSE/DDD / SPD / DFS)", () => { const commentSeg = structure.find((s) => /comment/i.test(String(s.name))) as Segment | undefined; expect(commentSeg).toBeDefined(); const fieldsDFS = (commentSeg!.fields ?? []) as Field[]; - const hasDFS = fieldsDFS.some((f) => f.name === "DFS"); + const hasDFS = fieldsDFS.some((f) => f.name === "DFS marker"); expect(hasDFS).toBe(true); }); @@ -72,12 +73,12 @@ describe("APRS extras test vectors (RNG / PHG / CSE/DDD / SPD / DFS)", () => { const commentSeg = structure.find((s) => /comment/i.test(String(s.name))) as Segment | undefined; expect(commentSeg).toBeDefined(); const fieldsCSE = (commentSeg!.fields ?? []) as Field[]; - const hasCSE = fieldsCSE.some((f) => f.name === "CSE/SPD"); + const hasCSE = fieldsCSE.some((f) => f.name === "course"); expect(hasCSE).toBe(true); }); it("parses combined tokens: DDD/SSS PHG and DFS", () => { - const raw = "N0CALL>APRS,WIDE1-1:!4500.00N/07000.00W>090/045PHG5132/DFS2132"; + const raw = "N0CALL>APRS,WIDE1-1:!4500.00N/07000.00W>090/045PHG5132DFS2132"; const frame = Frame.fromString(raw); const res = frame.decode(true) as { payload: PositionPayload | null; structure: Dissected }; const { payload, structure } = res; @@ -92,6 +93,24 @@ describe("APRS extras test vectors (RNG / PHG / CSE/DDD / SPD / DFS)", () => { const commentSeg = structure.find((s) => /comment/i.test(String(s.name))) as Segment | undefined; expect(commentSeg).toBeDefined(); const fieldsCombined = (commentSeg!.fields ?? []) as Field[]; - expect(fieldsCombined.some((f) => ["CSE/SPD", "PHG", "DFS"].includes(String(f.name)))).toBe(true); + expect(fieldsCombined.some((f) => ["course", "PHG marker", "DFS marker"].includes(String(f.name)))).toBe(true); + }); + + it("parses RNG token and emits structure", () => { + const raw = + "NOCALL-S>APDG01,TCPIP*,qAC,NOCALL-GS:;DN9PJF B *181227z5148.38ND00634.32EaRNG0001/A=000010 70cm Voice (D-Star) 439.50000MHz -7.6000MHz"; + const frame = Frame.fromString(raw); + const res = frame.decode(true) as { payload: PositionPayload | null; structure: Dissected }; + const { payload, structure } = res; + + expect(payload).not.toBeNull(); + expect(payload!.position.altitude).toBeCloseTo(feetToMeters(10), 3); + expect(payload!.position.range).toBe(milesToMeters(1) / 1000); + + const commentSeg = structure.find((s) => /comment/i.test(String(s.name))) as Segment | undefined; + expect(commentSeg).toBeDefined(); + const fieldsRNG = (commentSeg!.fields ?? []) as Field[]; + const hasRNG = fieldsRNG.some((f) => f.name === "RNG marker"); + expect(hasRNG).toBe(true); }); }); diff --git a/test/frame.test.ts b/test/frame.test.ts index 9ff696d..fb6b850 100644 --- a/test/frame.test.ts +++ b/test/frame.test.ts @@ -233,7 +233,7 @@ describe("Frame.decodeMicE", () => { const frame = Frame.fromString(data); const decoded = frame.decode() as MicEPayload; expect(decoded).not.toBeNull(); - expect(decoded?.type).toBe(DataType.MicECurrent); + expect(decoded?.type).toBe(DataType.MicE); }); it("decodes a Mic-E packet with old format (single quote)", () => { @@ -322,6 +322,16 @@ describe("Frame.decodePosition", () => { const decoded = frame.decode() as PositionPayload; expect(decoded).not.toBeNull(); }); + + it("should handle UTF-8 characters", () => { + const data = + "WB2OSZ-5>APDW17:!4237.14NS07120.83W#PHG7140 Did you know that APRS comments and messages can contain UTF-8 characters? アマチュア無線"; + const frame = Frame.fromString(data); + const decoded = frame.decode() as PositionPayload; + expect(decoded).not.toBeNull(); + expect(decoded?.type).toBe(DataType.PositionNoTimestampNoMessaging); + expect(decoded?.position.comment).toContain("UTF-8 characters? アマチュア無線"); + }); }); describe("Frame.decodeStatus", () => { @@ -468,9 +478,9 @@ describe("Frame.decodeMicE", () => { const decoded = frame.decode() as MicEPayload; expect(decoded).not.toBeNull(); - expect(decoded?.type).toBe(DataType.MicECurrent); + expect(decoded?.type).toBe(DataType.MicE); - if (decoded && decoded.type === DataType.MicECurrent) { + if (decoded && decoded.type === DataType.MicE) { expect(decoded.position).toBeDefined(); expect(typeof decoded.position.latitude).toBe("number"); expect(typeof decoded.position.longitude).toBe("number"); @@ -497,7 +507,7 @@ describe("Frame.decodeMicE", () => { expect(decoded).not.toBeNull(); - if (decoded && decoded.type === DataType.MicECurrent) { + if (decoded && decoded.type === DataType.MicE) { expect(decoded.position.latitude).toBeCloseTo(12 + 34.56 / 60, 3); } }); @@ -509,7 +519,7 @@ describe("Frame.decodeMicE", () => { expect(decoded).not.toBeNull(); - if (decoded && decoded.type === DataType.MicECurrent) { + if (decoded && decoded.type === DataType.MicE) { expect(decoded.position.latitude).toBeCloseTo(1 + 20.45 / 60, 3); } }); @@ -521,7 +531,7 @@ describe("Frame.decodeMicE", () => { expect(decoded).not.toBeNull(); - if (decoded && decoded.type === DataType.MicECurrent) { + if (decoded && decoded.type === DataType.MicE) { expect(decoded.position.latitude).toBeCloseTo(40 + 12.34 / 60, 3); } }); @@ -533,7 +543,7 @@ describe("Frame.decodeMicE", () => { expect(decoded).not.toBeNull(); - if (decoded && decoded.type === DataType.MicECurrent) { + if (decoded && decoded.type === DataType.MicE) { expect(decoded.position.latitude).toBeLessThan(0); } }); @@ -547,7 +557,7 @@ describe("Frame.decodeMicE", () => { expect(decoded).not.toBeNull(); - if (decoded && decoded.type === DataType.MicECurrent) { + if (decoded && decoded.type === DataType.MicE) { expect(typeof decoded.position.longitude).toBe("number"); expect(decoded.position.longitude).toBeGreaterThanOrEqual(-180); expect(decoded.position.longitude).toBeLessThanOrEqual(180); @@ -561,7 +571,7 @@ describe("Frame.decodeMicE", () => { expect(decoded).not.toBeNull(); - if (decoded && decoded.type === DataType.MicECurrent) { + if (decoded && decoded.type === DataType.MicE) { expect(typeof decoded.position.longitude).toBe("number"); } }); @@ -573,7 +583,7 @@ describe("Frame.decodeMicE", () => { expect(decoded).not.toBeNull(); - if (decoded && decoded.type === DataType.MicECurrent) { + if (decoded && decoded.type === DataType.MicE) { expect(typeof decoded.position.longitude).toBe("number"); expect(Math.abs(decoded.position.longitude)).toBeGreaterThan(90); } @@ -588,7 +598,7 @@ describe("Frame.decodeMicE", () => { expect(decoded).not.toBeNull(); - if (decoded && decoded.type === DataType.MicECurrent) { + if (decoded && decoded.type === DataType.MicE) { if (decoded.position.speed !== undefined) { expect(decoded.position.speed).toBeGreaterThanOrEqual(0); expect(typeof decoded.position.speed).toBe("number"); @@ -603,7 +613,7 @@ describe("Frame.decodeMicE", () => { expect(decoded).not.toBeNull(); - if (decoded && decoded.type === DataType.MicECurrent) { + if (decoded && decoded.type === DataType.MicE) { if (decoded.position.course !== undefined) { expect(decoded.position.course).toBeGreaterThanOrEqual(0); expect(decoded.position.course).toBeLessThan(360); @@ -618,7 +628,7 @@ describe("Frame.decodeMicE", () => { expect(decoded).not.toBeNull(); - if (decoded && decoded.type === DataType.MicECurrent) { + if (decoded && decoded.type === DataType.MicE) { expect(decoded.position.speed).toBeUndefined(); } }); @@ -630,7 +640,7 @@ describe("Frame.decodeMicE", () => { expect(decoded).not.toBeNull(); - if (decoded && decoded.type === DataType.MicECurrent) { + if (decoded && decoded.type === DataType.MicE) { if (decoded.position.course !== undefined) { expect(decoded.position.course).toBeGreaterThan(0); expect(decoded.position.course).toBeLessThan(360); @@ -647,7 +657,7 @@ describe("Frame.decodeMicE", () => { expect(decoded).not.toBeNull(); - if (decoded && decoded.type === DataType.MicECurrent) { + if (decoded && decoded.type === DataType.MicE) { expect(decoded.position.symbol).toBeDefined(); expect(decoded.position.symbol?.table).toBeDefined(); expect(decoded.position.symbol?.code).toBeDefined(); @@ -659,29 +669,30 @@ describe("Frame.decodeMicE", () => { describe("Altitude decoding", () => { it("should decode altitude from /A=NNNNNN format", () => { - const data = "CALL>4ABCDE:`c.l+@&'/'\"G:}/A=001234"; + const data = "CALL>4ABCDE:`c.l+@&'//A=001234"; const frame = Frame.fromString(data); - const decoded = frame.decode() as Payload; + const decoded = frame.decode() as MicEPayload; expect(decoded).not.toBeNull(); + expect(decoded.type).toBe(DataType.MicE); - if (decoded && decoded.type === DataType.MicECurrent) { - expect(decoded.position.altitude).toBeCloseTo(1234 * 0.3048, 1); - } + expect(decoded.position).toBeDefined(); + expect(decoded.position.altitude).toBeDefined(); + expect(decoded.position.altitude).toBeCloseTo(1234 * 0.3048, 1); }); - it("should decode altitude from base-91 format }abc", () => { - const data = "CALL>4AB2DE:`c.l+@&'/'\"G:}}S^X"; + it("should decode altitude from base-91 format abc}", () => { + const data = "N83MZ>T2TQ5U,WA1PLE-4*:`c.l+@&'/\"4T}KJ6TMS"; const frame = Frame.fromString(data); - const decoded = frame.decode() as Payload; + const decoded = frame.decode() as MicEPayload; expect(decoded).not.toBeNull(); + expect(decoded.type).toBe(DataType.MicE); - if (decoded && decoded.type === DataType.MicECurrent) { - if (decoded.position.comment?.startsWith("}")) { - expect(decoded.position.altitude).toBeDefined(); - } - } + expect(decoded.position).toBeDefined(); + expect(decoded.position.altitude).toBeDefined(); + expect(decoded.position.comment).toBe("KJ6TMS"); + expect(decoded.position.altitude).toBeCloseTo(61, 1); }); it("should prefer /A= format over base-91 when both present", () => { @@ -691,7 +702,7 @@ describe("Frame.decodeMicE", () => { expect(decoded).not.toBeNull(); - if (decoded && decoded.type === DataType.MicECurrent) { + if (decoded && decoded.type === DataType.MicE) { expect(decoded.position.altitude).toBeCloseTo(5000 * 0.3048, 1); } }); @@ -703,7 +714,7 @@ describe("Frame.decodeMicE", () => { expect(decoded).not.toBeNull(); - if (decoded && decoded.type === DataType.MicECurrent) { + if (decoded && decoded.type === DataType.MicE) { expect(decoded.position.altitude).toBeUndefined(); expect(decoded.position.comment).toContain("Just a comment"); } @@ -718,7 +729,7 @@ describe("Frame.decodeMicE", () => { expect(decoded).not.toBeNull(); - if (decoded && decoded.type === DataType.MicECurrent) { + if (decoded && decoded.type === DataType.MicE) { expect(decoded.messageType).toBe("M0: Off Duty"); } }); @@ -730,7 +741,7 @@ describe("Frame.decodeMicE", () => { expect(decoded).not.toBeNull(); - if (decoded && decoded.type === DataType.MicECurrent) { + if (decoded && decoded.type === DataType.MicE) { expect(decoded.messageType).toBeDefined(); expect(typeof decoded.messageType).toBe("string"); } @@ -753,7 +764,7 @@ describe("Frame.decodeMicE", () => { expect(decoded).not.toBeNull(); - if (decoded && decoded.type === DataType.MicECurrent) { + if (decoded && decoded.type === DataType.MicE) { expect(decoded.position.comment).toContain("This is a test comment"); } }); @@ -765,7 +776,7 @@ describe("Frame.decodeMicE", () => { expect(decoded).not.toBeNull(); - if (decoded && decoded.type === DataType.MicECurrent) { + if (decoded && decoded.type === DataType.MicE) { expect(decoded.position.comment).toBeDefined(); } }); @@ -802,7 +813,7 @@ describe("Frame.decodeMicE", () => { expect(() => frame.decode()).not.toThrow(); const decoded = frame.decode() as MicEPayload; - expect(decoded === null || decoded?.type === DataType.MicECurrent).toBe(true); + expect(decoded === null || decoded?.type === DataType.MicE).toBe(true); }); }); @@ -813,9 +824,9 @@ describe("Frame.decodeMicE", () => { const decoded = frame.decode() as MicEPayload; expect(decoded).not.toBeNull(); - expect(decoded?.type).toBe(DataType.MicECurrent); + expect(decoded?.type).toBe(DataType.MicE); - if (decoded && decoded.type === DataType.MicECurrent) { + if (decoded && decoded.type === DataType.MicE) { expect(decoded.position.latitude).toBeDefined(); expect(decoded.position.longitude).toBeDefined(); expect(decoded.position.symbol).toBeDefined();