diff --git a/package.json b/package.json index 23fb7b2..7cdf9ae 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "vitest": "^4.0.18" }, "dependencies": { - "@hamradio/packet": "^1.1.0" + "@hamradio/packet": "^1.1.0", + "extended-nmea": "^2.1.3" } } diff --git a/src/frame.ts b/src/frame.ts index bce0e6c..0e81a34 100644 --- a/src/frame.ts +++ b/src/frame.ts @@ -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' [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): { diff --git a/src/frame.types.ts b/src/frame.types.ts index 1fddbff..c3a8eb7 100644 --- a/src/frame.types.ts +++ b/src/frame.types.ts @@ -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 } diff --git a/test/frame.query.test.ts b/test/frame.query.test.ts new file mode 100644 index 0000000..56da05c --- /dev/null +++ b/test/frame.query.test.ts @@ -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"); + }); +}); diff --git a/test/frame.rawgps.test.ts b/test/frame.rawgps.test.ts new file mode 100644 index 0000000..a812a99 --- /dev/null +++ b/test/frame.rawgps.test.ts @@ -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(); + }); +}); diff --git a/test/frame.telemetry.test.ts b/test/frame.telemetry.test.ts new file mode 100644 index 0000000..46074e9 --- /dev/null +++ b/test/frame.telemetry.test.ts @@ -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"); + }); +}); diff --git a/test/frame.weather.test.ts b/test/frame.weather.test.ts new file mode 100644 index 0000000..a58ef0d --- /dev/null +++ b/test/frame.weather.test.ts @@ -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"); + }); +});