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

@@ -49,6 +49,7 @@
"vitest": "^4.0.18" "vitest": "^4.0.18"
}, },
"dependencies": { "dependencies": {
"@hamradio/packet": "^1.1.0" "@hamradio/packet": "^1.1.0",
"extended-nmea": "^2.1.3"
} }
} }

View File

@@ -11,9 +11,24 @@ import type {
ObjectPayload, ObjectPayload,
ItemPayload, ItemPayload,
StatusPayload, StatusPayload,
QueryPayload,
TelemetryDataPayload,
TelemetryBitSensePayload,
TelemetryCoefficientsPayload,
TelemetryParameterPayload,
TelemetryUnitPayload,
WeatherPayload,
RawGPSPayload,
} from "./frame.types"; } from "./frame.types";
import { Position } from "./position"; import { Position } from "./position";
import { base91ToNumber } from "./parser"; import { base91ToNumber } from "./parser";
import {
DTM,
GGA,
INmeaSentence,
Decoder as NmeaDecoder,
RMC,
} from "extended-nmea";
export class Timestamp implements ITimestamp { export class Timestamp implements ITimestamp {
day?: number; day?: number;
@@ -385,10 +400,9 @@ export class Frame implements IFrame {
// Parse timestamp if present (7 characters: DDHHMMz or HHMMSSh or MMDDHMMM) // Parse timestamp if present (7 characters: DDHHMMz or HHMMSSh or MMDDHMMM)
if (hasTimestamp) { if (hasTimestamp) {
if (this.payload.length < 8) return { payload: null }; if (this.payload.length < 8) return { payload: null };
const timestampOffset = offset;
const timeStr = this.payload.substring(offset, offset + 7); const timeStr = this.payload.substring(offset, offset + 7);
const { timestamp: parsedTimestamp, segment: timestampSegment } = const { timestamp: parsedTimestamp, segment: timestampSegment } =
this.parseTimestamp(timeStr, withStructure, timestampOffset); this.parseTimestamp(timeStr, withStructure);
timestamp = parsedTimestamp; timestamp = parsedTimestamp;
if (timestampSegment) { if (timestampSegment) {
@@ -401,7 +415,6 @@ export class Frame implements IFrame {
if (this.payload.length < offset + 19) return { payload: null }; if (this.payload.length < offset + 19) return { payload: null };
// Check if compressed format // Check if compressed format
const positionOffset = offset;
const isCompressed = this.isCompressedPosition( const isCompressed = this.isCompressedPosition(
this.payload.substring(offset), this.payload.substring(offset),
); );
@@ -415,7 +428,6 @@ export class Frame implements IFrame {
this.parseCompressedPosition( this.parseCompressedPosition(
this.payload.substring(offset), this.payload.substring(offset),
withStructure, withStructure,
positionOffset,
); );
if (!compressed) return { payload: null }; if (!compressed) return { payload: null };
@@ -441,7 +453,6 @@ export class Frame implements IFrame {
this.parseUncompressedPosition( this.parseUncompressedPosition(
this.payload.substring(offset), this.payload.substring(offset),
withStructure, withStructure,
positionOffset,
); );
if (!uncompressed) return { payload: null }; if (!uncompressed) return { payload: null };
@@ -1438,7 +1449,6 @@ export class Frame implements IFrame {
const { timestamp, segment: tsSegment } = this.parseTimestamp( const { timestamp, segment: tsSegment } = this.parseTimestamp(
timeStr, timeStr,
withStructure, withStructure,
offset,
); );
if (timestamp) { if (timestamp) {
offset += 7; offset += 7;
@@ -1472,7 +1482,7 @@ export class Frame implements IFrame {
const timeSegment = segments.find((s) => s.name === "timestamp"); const timeSegment = segments.find((s) => s.name === "timestamp");
if (timeSegment) { if (timeSegment) {
const tsStr = new TextDecoder().decode(timeSegment.data); 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; if (timestamp) payload.timestamp = timestamp;
} }
} }
@@ -1496,32 +1506,531 @@ export class Frame implements IFrame {
payload: Payload | null; payload: Payload | null;
segment?: Segment[]; segment?: Segment[];
} { } {
// TODO: Implement query decoding with section emission try {
return { payload: withStructure ? null : null }; 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): { private decodeTelemetry(withStructure: boolean = false): {
payload: Payload | null; payload: Payload | null;
segment?: Segment[]; segment?: Segment[];
} { } {
// TODO: Implement telemetry decoding with section emission try {
return { payload: withStructure ? null : null }; 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): { private decodeWeather(withStructure: boolean = false): {
payload: Payload | null; payload: Payload | null;
segment?: Segment[]; segment?: Segment[];
} { } {
// TODO: Implement weather decoding with section emission try {
return { payload: withStructure ? null : null }; 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): { private decodeRawGPS(withStructure: boolean = false): {
payload: Payload | null; payload: Payload | null;
segment?: Segment[]; segment?: Segment[];
} { } {
// TODO: Implement raw GPS decoding with section emission try {
return { payload: withStructure ? null : null }; 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): { 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 { export interface IAddress {
call: string; call: string;
@@ -107,7 +107,7 @@ export interface PositionPayload {
messageType?: string; messageType?: string;
isStandard?: boolean; isStandard?: boolean;
}; };
sections?: PacketSegment[]; sections?: Segment[];
} }
// Compressed Position Format // Compressed Position Format
@@ -250,12 +250,14 @@ export interface WeatherPayload {
rawRain?: number; // Raw rain counter rawRain?: number; // Raw rain counter
software?: string; // Weather software type software?: string; // Weather software type
weatherUnit?: string; // Weather station type weatherUnit?: string; // Weather station type
comment?: string; // Additional comment
} }
// Raw GPS Payload (NMEA sentences) // Raw GPS Payload (NMEA sentences)
export interface RawGPSPayload { export interface RawGPSPayload {
type: "raw-gps"; type: "raw-gps";
sentence: string; // Raw NMEA sentence sentence: string; // Raw NMEA sentence
position?: IPosition; // Optional parsed position if available
} }
// Station Capabilities Payload // Station Capabilities Payload
@@ -323,5 +325,5 @@ export type Payload = BasePayload &
// Extended Frame with decoded payload // Extended Frame with decoded payload
export interface DecodedFrame extends IFrame { export interface DecodedFrame extends IFrame {
decoded?: Payload; decoded?: Payload;
structure?: PacketStructure; // Routing and other frame-level sections structure?: Dissected; // Routing and other frame-level sections
} }

39
test/frame.query.test.ts Normal file
View File

@@ -0,0 +1,39 @@
import { expect } from "vitest";
import { describe, it } from "vitest";
import { Dissected } from "@hamradio/packet";
import { Frame } from "../src/frame";
import { QueryPayload } from "../src/frame.types";
describe("Frame decode - Query", () => {
it("decodes simple query without target", () => {
const frame = Frame.fromString("SRC>DEST:?APRS");
const payload = frame.decode() as QueryPayload;
expect(payload).not.toBeNull();
expect(payload.type).toBe("query");
expect(payload.queryType).toBe("APRS");
expect(payload.target).toBeUndefined();
});
it("decodes query with target", () => {
const frame = Frame.fromString("SRC>DEST:?PING N0CALL");
const payload = frame.decode() as QueryPayload;
expect(payload).not.toBeNull();
expect(payload.type).toBe("query");
expect(payload.queryType).toBe("PING");
expect(payload.target).toBe("N0CALL");
});
it("returns structure sections when requested", () => {
const frame = Frame.fromString("SRC>DEST:?PING N0CALL");
const result = frame.decode(true) as {
payload: QueryPayload;
structure: Dissected;
};
expect(result).toHaveProperty("payload");
expect(result.payload.type).toBe("query");
expect(Array.isArray(result.structure)).toBe(true);
const names = result.structure.map((s) => s.name);
expect(names).toContain("query type");
expect(names).toContain("query target");
});
});

48
test/frame.rawgps.test.ts Normal file
View File

@@ -0,0 +1,48 @@
import { describe, it, expect } from "vitest";
import { Frame } from "../src/frame";
import type { RawGPSPayload } from "../src/frame.types";
import { Dissected } from "@hamradio/packet";
describe("Raw GPS decoding", () => {
it("decodes simple NMEA sentence as raw-gps payload", () => {
const sentence =
"GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A";
const frameStr = `SRC>DEST:$${sentence}`;
const f = Frame.parse(frameStr);
const payload = f.decode(false) as RawGPSPayload | null;
expect(payload).not.toBeNull();
expect(payload?.type).toBe("raw-gps");
expect(payload?.sentence).toBe(sentence);
expect(payload?.position).toBeDefined();
expect(typeof payload?.position?.latitude).toBe("number");
expect(typeof payload?.position?.longitude).toBe("number");
});
it("returns structure when requested", () => {
const sentence =
"GPGGA,092750.000,5321.6802,N,00630.3372,W,1,08,1.0,73.0,M,0.0,M,,*6A";
const frameStr = `SRC>DEST:$${sentence}`;
const f = Frame.parse(frameStr);
const result = f.decode(true) as {
payload: RawGPSPayload | null;
structure: Dissected;
};
expect(result.payload).not.toBeNull();
expect(result.payload?.type).toBe("raw-gps");
expect(result.payload?.sentence).toBe(sentence);
expect(result.payload?.position).toBeDefined();
expect(typeof result.payload?.position?.latitude).toBe("number");
expect(typeof result.payload?.position?.longitude).toBe("number");
expect(result.structure).toBeDefined();
const rawSection = result.structure.find((s) => s.name === "raw-gps");
expect(rawSection).toBeDefined();
const posSection = result.structure.find(
(s) => s.name === "raw-gps-position",
);
expect(posSection).toBeDefined();
});
});

View File

@@ -0,0 +1,59 @@
import { describe, it } from "vitest";
import {
TelemetryDataPayload,
TelemetryParameterPayload,
TelemetryUnitPayload,
TelemetryCoefficientsPayload,
TelemetryBitSensePayload,
} from "../src/frame.types";
import { Frame } from "../src/frame";
import { expect } from "vitest";
describe("Frame decode - Telemetry", () => {
it("decodes telemetry data payload", () => {
const frame = Frame.fromString("SRC>DEST:T#1 10,20,30,40,50 7");
const payload = frame.decode() as TelemetryDataPayload;
expect(payload).not.toBeNull();
expect(payload.type).toBe("telemetry-data");
expect(payload.sequence).toBe(1);
expect(Array.isArray(payload.analog)).toBe(true);
expect(payload.analog.length).toBe(5);
expect(payload.digital).toBe(7);
});
it("decodes telemetry parameters list", () => {
const frame = Frame.fromString("SRC>DEST:TPARAM Temp,Hum,Wind");
const payload = frame.decode() as TelemetryParameterPayload;
expect(payload).not.toBeNull();
expect(payload.type).toBe("telemetry-parameters");
expect(Array.isArray(payload.names)).toBe(true);
expect(payload.names).toEqual(["Temp", "Hum", "Wind"]);
});
it("decodes telemetry units list", () => {
const frame = Frame.fromString("SRC>DEST:TUNIT C,% ,mph");
const payload = frame.decode() as TelemetryUnitPayload;
expect(payload).not.toBeNull();
expect(payload.type).toBe("telemetry-units");
expect(payload.units).toEqual(["C", "%", "mph"]);
});
it("decodes telemetry coefficients", () => {
const frame = Frame.fromString("SRC>DEST:TCOEFF A:1,2 B:3,4 C:5,6");
const payload = frame.decode() as TelemetryCoefficientsPayload;
expect(payload).not.toBeNull();
expect(payload.type).toBe("telemetry-coefficients");
expect(payload.coefficients.a).toEqual([1, 2]);
expect(payload.coefficients.b).toEqual([3, 4]);
expect(payload.coefficients.c).toEqual([5, 6]);
});
it("decodes telemetry bitsense with project", () => {
const frame = Frame.fromString("SRC>DEST:TBITS 255 ProjectX");
const payload = frame.decode() as TelemetryBitSensePayload;
expect(payload).not.toBeNull();
expect(payload.type).toBe("telemetry-bitsense");
expect(payload.sense).toBe(255);
expect(payload.projectName).toBe("ProjectX");
});
});

View File

@@ -0,0 +1,37 @@
import { describe, it, expect } from "vitest";
import { Frame } from "../src/frame";
import { WeatherPayload } from "../src/frame.types";
import { Dissected } from "@hamradio/packet";
describe("Frame decode - Weather", () => {
it("parses weather with timestamp, wind, temp, rain, humidity and pressure", () => {
const data = "SRC>DEST:_120345z180/10g15t072r000p025P050h50b10132";
const frame = Frame.fromString(data);
const payload = frame.decode() as WeatherPayload;
expect(payload).not.toBeNull();
expect(payload.type).toBe("weather");
expect(payload.timestamp).toBeDefined();
expect(payload.windDirection).toBe(180);
expect(payload.windSpeed).toBe(10);
expect(payload.windGust).toBe(15);
expect(payload.temperature).toBe(72);
expect(payload.rainLast24Hours).toBe(25);
expect(payload.rainSinceMidnight).toBe(50);
expect(payload.humidity).toBe(50);
expect(payload.pressure).toBe(10132);
});
it("emits structure when requested", () => {
const data = "SRC>DEST:_120345z180/10g15t072r000p025P050h50b10132";
const frame = Frame.fromString(data);
const res = frame.decode(true) as {
payload: WeatherPayload;
structure: Dissected;
};
expect(res.payload).not.toBeNull();
expect(Array.isArray(res.structure)).toBe(true);
const names = res.structure.map((s) => s.name);
expect(names).toContain("timestamp");
expect(names).toContain("weather");
});
});