Implemented Query, Telemetry, Weather and RawGPS parsing

This commit is contained in:
2026-03-15 21:13:12 +01:00
parent e0d4844c5b
commit eca757b24f
7 changed files with 714 additions and 19 deletions

View File

@@ -11,9 +11,24 @@ import type {
ObjectPayload,
ItemPayload,
StatusPayload,
QueryPayload,
TelemetryDataPayload,
TelemetryBitSensePayload,
TelemetryCoefficientsPayload,
TelemetryParameterPayload,
TelemetryUnitPayload,
WeatherPayload,
RawGPSPayload,
} from "./frame.types";
import { Position } from "./position";
import { base91ToNumber } from "./parser";
import {
DTM,
GGA,
INmeaSentence,
Decoder as NmeaDecoder,
RMC,
} from "extended-nmea";
export class Timestamp implements ITimestamp {
day?: number;
@@ -385,10 +400,9 @@ export class Frame implements IFrame {
// Parse timestamp if present (7 characters: DDHHMMz or HHMMSSh or MMDDHMMM)
if (hasTimestamp) {
if (this.payload.length < 8) return { payload: null };
const timestampOffset = offset;
const timeStr = this.payload.substring(offset, offset + 7);
const { timestamp: parsedTimestamp, segment: timestampSegment } =
this.parseTimestamp(timeStr, withStructure, timestampOffset);
this.parseTimestamp(timeStr, withStructure);
timestamp = parsedTimestamp;
if (timestampSegment) {
@@ -401,7 +415,6 @@ export class Frame implements IFrame {
if (this.payload.length < offset + 19) return { payload: null };
// Check if compressed format
const positionOffset = offset;
const isCompressed = this.isCompressedPosition(
this.payload.substring(offset),
);
@@ -415,7 +428,6 @@ export class Frame implements IFrame {
this.parseCompressedPosition(
this.payload.substring(offset),
withStructure,
positionOffset,
);
if (!compressed) return { payload: null };
@@ -441,7 +453,6 @@ export class Frame implements IFrame {
this.parseUncompressedPosition(
this.payload.substring(offset),
withStructure,
positionOffset,
);
if (!uncompressed) return { payload: null };
@@ -1438,7 +1449,6 @@ export class Frame implements IFrame {
const { timestamp, segment: tsSegment } = this.parseTimestamp(
timeStr,
withStructure,
offset,
);
if (timestamp) {
offset += 7;
@@ -1472,7 +1482,7 @@ export class Frame implements IFrame {
const timeSegment = segments.find((s) => s.name === "timestamp");
if (timeSegment) {
const tsStr = new TextDecoder().decode(timeSegment.data);
const { timestamp } = this.parseTimestamp(tsStr, false, 0);
const { timestamp } = this.parseTimestamp(tsStr, false);
if (timestamp) payload.timestamp = timestamp;
}
}
@@ -1496,32 +1506,531 @@ export class Frame implements IFrame {
payload: Payload | null;
segment?: Segment[];
} {
// TODO: Implement query decoding with section emission
return { payload: withStructure ? null : null };
try {
if (this.payload.length < 2) return { payload: null };
// Skip data type identifier '?'
const segments: Segment[] = withStructure ? [] : [];
// Remaining payload
const rest = this.payload.substring(1).trim();
if (!rest) return { payload: null };
// Query type is the first token (up to first space)
const firstSpace = rest.indexOf(" ");
let queryType = "";
let target: string | undefined = undefined;
if (firstSpace === -1) {
queryType = rest;
} else {
queryType = rest.substring(0, firstSpace);
target = rest.substring(firstSpace + 1).trim();
if (target === "") target = undefined;
}
if (!queryType) return { payload: null };
if (withStructure) {
// Emit query type section
segments.push({
name: "query type",
data: new TextEncoder().encode(queryType).buffer,
isString: true,
fields: [
{ type: FieldType.STRING, name: "type", length: queryType.length },
],
});
if (target) {
segments.push({
name: "query target",
data: new TextEncoder().encode(target).buffer,
isString: true,
fields: [
{ type: FieldType.STRING, name: "target", length: target.length },
],
});
}
}
const payload: QueryPayload = {
type: "query",
queryType,
...(target ? { target } : {}),
};
if (withStructure) return { payload, segment: segments };
return { payload };
} catch {
return { payload: null };
}
}
private decodeTelemetry(withStructure: boolean = false): {
payload: Payload | null;
segment?: Segment[];
} {
// TODO: Implement telemetry decoding with section emission
return { payload: withStructure ? null : null };
try {
if (this.payload.length < 2) return { payload: null };
const rest = this.payload.substring(1).trim();
if (!rest) return { payload: null };
const segments: Segment[] = withStructure ? [] : [];
// Telemetry data: convention used here: starts with '#' then sequence then analogs and digital
if (rest.startsWith("#")) {
const parts = rest.substring(1).trim().split(/\s+/);
const seq = parseInt(parts[0], 10);
let analog: number[] = [];
let digital = 0;
if (parts.length >= 2) {
// analogs as comma separated
analog = parts[1].split(",").map((v) => parseFloat(v));
}
if (parts.length >= 3) {
digital = parseInt(parts[2], 10);
}
if (withStructure) {
segments.push({
name: "telemetry sequence",
data: new TextEncoder().encode(String(seq)).buffer,
isString: true,
fields: [
{
type: FieldType.STRING,
name: "sequence",
length: String(seq).length,
},
],
});
segments.push({
name: "telemetry analog",
data: new TextEncoder().encode(parts[1] || "").buffer,
isString: true,
fields: [
{
type: FieldType.STRING,
name: "analogs",
length: (parts[1] || "").length,
},
],
});
segments.push({
name: "telemetry digital",
data: new TextEncoder().encode(String(digital)).buffer,
isString: true,
fields: [
{
type: FieldType.STRING,
name: "digital",
length: String(digital).length,
},
],
});
}
const payload: TelemetryDataPayload = {
type: "telemetry-data",
sequence: isNaN(seq) ? 0 : seq,
analog,
digital: isNaN(digital) ? 0 : digital,
};
if (withStructure) return { payload, segment: segments };
return { payload };
}
// Telemetry parameters: 'PARAM' keyword
if (/^PARAM/i.test(rest)) {
const after = rest.replace(/^PARAM\s*/i, "");
const names = after.split(/[,\s]+/).filter(Boolean);
if (withStructure) {
segments.push({
name: "telemetry parameters",
data: new TextEncoder().encode(after).buffer,
isString: true,
fields: [
{ type: FieldType.STRING, name: "names", length: after.length },
],
});
}
const payload: TelemetryParameterPayload = {
type: "telemetry-parameters",
names,
};
if (withStructure) return { payload, segment: segments };
return { payload };
}
// Telemetry units: 'UNIT'
if (/^UNIT/i.test(rest)) {
const after = rest.replace(/^UNIT\s*/i, "");
const units = after.split(/[,\s]+/).filter(Boolean);
if (withStructure) {
segments.push({
name: "telemetry units",
data: new TextEncoder().encode(after).buffer,
isString: true,
fields: [
{ type: FieldType.STRING, name: "units", length: after.length },
],
});
}
const payload: TelemetryUnitPayload = {
type: "telemetry-units",
units,
};
if (withStructure) return { payload, segment: segments };
return { payload };
}
// Telemetry coefficients: 'COEFF' a:,b:,c:
if (/^COEFF/i.test(rest)) {
const after = rest.replace(/^COEFF\s*/i, "");
const aMatch = after.match(/A:([^\s;]+)/i);
const bMatch = after.match(/B:([^\s;]+)/i);
const cMatch = after.match(/C:([^\s;]+)/i);
const parseList = (s?: string) =>
s ? s.split(",").map((v) => parseFloat(v)) : [];
const coefficients = {
a: parseList(aMatch?.[1]),
b: parseList(bMatch?.[1]),
c: parseList(cMatch?.[1]),
};
if (withStructure) {
segments.push({
name: "telemetry coefficients",
data: new TextEncoder().encode(after).buffer,
isString: true,
fields: [
{ type: FieldType.STRING, name: "coeffs", length: after.length },
],
});
}
const payload: TelemetryCoefficientsPayload = {
type: "telemetry-coefficients",
coefficients,
};
if (withStructure) return { payload, segment: segments };
return { payload };
}
// Telemetry bitsense/project: 'BITS' <number> [project]
if (/^BITS?/i.test(rest)) {
const parts = rest.split(/\s+/).slice(1);
const sense = parts.length > 0 ? parseInt(parts[0], 10) : 0;
const projectName =
parts.length > 1 ? parts.slice(1).join(" ") : undefined;
if (withStructure) {
segments.push({
name: "telemetry bitsense",
data: new TextEncoder().encode(rest).buffer,
isString: true,
fields: [
{ type: FieldType.STRING, name: "bitsense", length: rest.length },
],
});
}
const payload: TelemetryBitSensePayload = {
type: "telemetry-bitsense",
sense: isNaN(sense) ? 0 : sense,
...(projectName ? { projectName } : {}),
};
if (withStructure) return { payload, segment: segments };
return { payload };
}
return { payload: null };
} catch {
return { payload: null };
}
}
private decodeWeather(withStructure: boolean = false): {
payload: Payload | null;
segment?: Segment[];
} {
// TODO: Implement weather decoding with section emission
return { payload: withStructure ? null : null };
try {
if (this.payload.length < 2) return { payload: null };
let offset = 1; // skip '_' data type
const segments: Segment[] = withStructure ? [] : [];
// Try optional timestamp (7 chars)
let timestamp;
if (this.payload.length >= offset + 7) {
const timeStr = this.payload.substring(offset, offset + 7);
const parsed = this.parseTimestamp(timeStr, withStructure);
timestamp = parsed.timestamp;
if (parsed.segment) {
segments.push(parsed.segment);
}
if (timestamp) offset += 7;
}
// Try optional position following timestamp
let position: IPosition | undefined;
let consumed = 0;
const tail = this.payload.substring(offset);
if (tail.length > 0) {
// If the tail starts with a wind token like DDD/SSS, treat it as weather data
// and do not attempt to parse it as a position (avoids mis-detecting wind
// values as compressed position fields).
if (/^\s*\d{3}\/\d{1,3}/.test(tail)) {
// no position present; leave consumed = 0
} else if (this.isCompressedPosition(tail)) {
const parsed = this.parseCompressedPosition(tail, withStructure);
if (parsed.position) {
position = {
latitude: parsed.position.latitude,
longitude: parsed.position.longitude,
symbol: parsed.position.symbol,
altitude: parsed.position.altitude,
};
if (parsed.segment) segments.push(parsed.segment);
consumed = 13;
}
} else {
const parsed = this.parseUncompressedPosition(tail, withStructure);
if (parsed.position) {
position = {
latitude: parsed.position.latitude,
longitude: parsed.position.longitude,
symbol: parsed.position.symbol,
ambiguity: parsed.position.ambiguity,
};
if (parsed.segment) segments.push(parsed.segment);
consumed = 19;
}
}
}
offset += consumed;
const rest = this.payload.substring(offset).trim();
const payload: WeatherPayload = { type: "weather" };
if (timestamp) payload.timestamp = timestamp;
if (position) payload.position = position;
if (rest && rest.length > 0) {
// Parse common tokens
// Wind: DDD/SSS [gGGG]
const windMatch = rest.match(/(\d{3})\/(\d{1,3})(?:g(\d{1,3}))?/);
if (windMatch) {
payload.windDirection = parseInt(windMatch[1], 10);
payload.windSpeed = parseInt(windMatch[2], 10);
if (windMatch[3]) payload.windGust = parseInt(windMatch[3], 10);
}
// Temperature: tNNN (F)
const tempMatch = rest.match(/t(-?\d{1,3})/i);
if (tempMatch) payload.temperature = parseInt(tempMatch[1], 10);
// Rain: rNNN (last hour), pNNN (24h), PNNN (since midnight) - values are hundredths of inch
const rMatch = rest.match(/r(\d{3})/);
if (rMatch) payload.rainLastHour = parseInt(rMatch[1], 10);
const pMatch = rest.match(/p(\d{3})/);
if (pMatch) payload.rainLast24Hours = parseInt(pMatch[1], 10);
const PMatch = rest.match(/P(\d{3})/);
if (PMatch) payload.rainSinceMidnight = parseInt(PMatch[1], 10);
// Humidity: hNN
const hMatch = rest.match(/h(\d{1,3})/);
if (hMatch) payload.humidity = parseInt(hMatch[1], 10);
// Pressure: bXXXX or bXXXXX (tenths of millibar)
const bMatch = rest.match(/b(\d{4,5})/);
if (bMatch) payload.pressure = parseInt(bMatch[1], 10);
// Add raw comment
payload.comment = rest;
if (withStructure) {
segments.push({
name: "weather",
data: new TextEncoder().encode(rest).buffer,
isString: true,
fields: [
{ type: FieldType.STRING, name: "text", length: rest.length },
],
});
}
}
if (withStructure) return { payload, segment: segments };
return { payload };
} catch {
return { payload: null };
}
}
private decodeRawGPS(withStructure: boolean = false): {
payload: Payload | null;
segment?: Segment[];
} {
// TODO: Implement raw GPS decoding with section emission
return { payload: withStructure ? null : null };
try {
if (this.payload.length < 2) return { payload: null };
// Raw GPS payloads start with '$' followed by an NMEA sentence
const sentence = this.payload.substring(1).trim();
// Attempt to parse with extended-nmea Decoder to extract position (best-effort)
let parsed: INmeaSentence | null = null;
try {
const full = sentence.startsWith("$") ? sentence : `$${sentence}`;
parsed = NmeaDecoder.decode(full);
} catch {
// ignore parse errors - accept any sentence as raw-gps per APRS
}
const payload: RawGPSPayload = {
type: "raw-gps",
sentence,
};
// If parse produced latitude/longitude, attach structured position.
// Otherwise fallback to a minimal NMEA parser for common sentences (RMC, GGA).
if (
parsed &&
(parsed instanceof RMC ||
parsed instanceof GGA ||
parsed instanceof DTM) &&
parsed.latitude &&
parsed.longitude
) {
// extended-nmea latitude/longitude are GeoCoordinate objects with
// fields { degrees, decimal, quadrant }
const latObj = parsed.latitude;
const lonObj = parsed.longitude;
const lat = latObj.degrees + (Number(latObj.decimal) || 0) / 60.0;
const lon = lonObj.degrees + (Number(lonObj.decimal) || 0) / 60.0;
const latitude = latObj.quadrant === "S" ? -lat : lat;
const longitude = lonObj.quadrant === "W" ? -lon : lon;
const pos: IPosition = {
latitude,
longitude,
};
// altitude
if ("altMean" in parsed && parsed.altMean !== undefined) {
pos.altitude = Number(parsed.altMean);
}
if ("altitude" in parsed && parsed.altitude !== undefined) {
pos.altitude = Number(parsed.altitude);
}
// speed/course (RMC fields)
if (
"speedOverGround" in parsed &&
parsed.speedOverGround !== undefined
) {
pos.speed = Number(parsed.speedOverGround);
}
if (
"courseOverGround" in parsed &&
parsed.courseOverGround !== undefined
) {
pos.course = Number(parsed.courseOverGround);
}
payload.position = pos;
} else {
try {
const full = sentence.startsWith("$") ? sentence : `$${sentence}`;
const withoutChecksum = full.split("*")[0];
const parts = withoutChecksum.split(",");
const header = parts[0].slice(1).toUpperCase();
const parseCoord = (coord: string, hemi: string) => {
if (!coord || coord === "") return undefined;
const degDigits = hemi === "N" || hemi === "S" ? 2 : 3;
if (coord.length <= degDigits) return undefined;
const degPart = coord.slice(0, degDigits);
const minPart = coord.slice(degDigits);
const degrees = parseFloat(degPart);
const mins = parseFloat(minPart);
if (Number.isNaN(degrees) || Number.isNaN(mins)) return undefined;
let dec = degrees + mins / 60.0;
if (hemi === "S" || hemi === "W") dec = -dec;
return dec;
};
if (header.endsWith("RMC")) {
const lat = parseCoord(parts[3], parts[4]);
const lon = parseCoord(parts[5], parts[6]);
if (lat !== undefined && lon !== undefined) {
const pos: IPosition = { latitude: lat, longitude: lon };
if (parts[7]) pos.speed = Number(parts[7]);
if (parts[8]) pos.course = Number(parts[8]);
payload.position = pos;
}
} else if (header.endsWith("GGA")) {
const lat = parseCoord(parts[2], parts[3]);
const lon = parseCoord(parts[4], parts[5]);
if (lat !== undefined && lon !== undefined) {
const pos: IPosition = { latitude: lat, longitude: lon };
if (parts[9]) pos.altitude = Number(parts[9]);
payload.position = pos;
}
}
} catch {
// ignore fallback parse errors
}
}
if (withStructure) {
const segments: Segment[] = [
{
name: "raw-gps",
data: new TextEncoder().encode(sentence).buffer,
isString: true,
fields: [
{
type: FieldType.STRING,
name: "sentence",
length: sentence.length,
},
],
},
];
if (payload.position) {
segments.push({
name: "raw-gps-position",
data: new TextEncoder().encode(JSON.stringify(payload.position))
.buffer,
isString: true,
fields: [
{
type: FieldType.STRING,
name: "latitude",
length: String(payload.position.latitude).length,
},
{
type: FieldType.STRING,
name: "longitude",
length: String(payload.position.longitude).length,
},
],
});
}
return { payload, segment: segments };
}
return { payload };
} catch {
return { payload: null };
}
}
private decodeCapabilities(withStructure: boolean = false): {

View File

@@ -1,4 +1,4 @@
import { PacketSegment, PacketStructure } from "./parser.types";
import { Dissected, Segment } from "@hamradio/packet";
export interface IAddress {
call: string;
@@ -107,7 +107,7 @@ export interface PositionPayload {
messageType?: string;
isStandard?: boolean;
};
sections?: PacketSegment[];
sections?: Segment[];
}
// Compressed Position Format
@@ -250,12 +250,14 @@ export interface WeatherPayload {
rawRain?: number; // Raw rain counter
software?: string; // Weather software type
weatherUnit?: string; // Weather station type
comment?: string; // Additional comment
}
// Raw GPS Payload (NMEA sentences)
export interface RawGPSPayload {
type: "raw-gps";
sentence: string; // Raw NMEA sentence
position?: IPosition; // Optional parsed position if available
}
// Station Capabilities Payload
@@ -323,5 +325,5 @@ export type Payload = BasePayload &
// Extended Frame with decoded payload
export interface DecodedFrame extends IFrame {
decoded?: Payload;
structure?: PacketStructure; // Routing and other frame-level sections
structure?: Dissected; // Routing and other frame-level sections
}