diff --git a/src/frame.ts b/src/frame.ts index 8a3893b..d297fe5 100644 --- a/src/frame.ts +++ b/src/frame.ts @@ -861,10 +861,92 @@ export class Frame implements IFrame { }; } - private decodeMessage(_withStructure: boolean = false): { payload: Payload | null; segment?: PacketSegment[] } { - // TODO: Implement message decoding with section emission - // When implemented, build structure during parsing like decodePosition does - return { payload: null }; + private decodeMessage(withStructure: boolean = false): { payload: Payload | null; segment?: PacketSegment[] } { + // Message format: :AAAAAAAAA[ ]:message text + // where AAAAAAAAA is a 9-character recipient field (padded with spaces) + if (this.payload.length < 2) return { payload: null }; + + let offset = 1; // skip ':' data type + const segments: PacketSegment[] = withStructure ? [] : []; + + // Attempt to read a 9-char recipient field if present + let recipient = ''; + if (this.payload.length >= offset + 1) { + // Try to read up to 9 chars for recipient, but stop early if a ':' separator appears + const look = this.payload.substring(offset, Math.min(offset + 9, this.payload.length)); + const sepIdx = look.indexOf(':'); + let raw = look; + if (sepIdx !== -1) { + raw = look.substring(0, sepIdx); + } else if (look.length < 9 && this.payload.length >= offset + 9) { + // pad to full 9 chars if possible + raw = this.payload.substring(offset, offset + 9); + } else if (look.length === 9) { + raw = look; + } + + recipient = raw.trimEnd(); + if (withStructure) { + segments.push({ + name: 'recipient', + data: new TextEncoder().encode(raw), + fields: [ + { type: FieldType.STRING, name: 'to', size: 9 }, + ], + }); + } + + // Advance offset past the raw we consumed + offset += raw.length; + // If there was a ':' immediately after the consumed raw, skip it as separator + if (this.payload.charAt(offset) === ':') { + offset += 1; + } else if (sepIdx !== -1) { + // Shouldn't normally happen, but ensure we advance past separator + offset += 1; + } + } + + // After recipient there is typically a space and a colon separator before the text + // Find the first ':' after the recipient (it separates the address field from the text) + let textStart = this.payload.indexOf(':', offset); + if (textStart === -1) { + // No explicit separator; skip any spaces and take remainder as text + while (this.payload.charAt(offset) === ' ' && offset < this.payload.length) offset += 1; + textStart = offset - 1; + } + + let text = ''; + if (textStart >= 0 && textStart + 1 <= this.payload.length) { + text = this.payload.substring(textStart + 1); + } + + if (withStructure) { + // Emit text section + segments.push({ + name: 'text', + data: new TextEncoder().encode(text), + fields: [ + { type: FieldType.STRING, name: 'text', size: text.length }, + ], + }); + + const payload: any = { + type: 'message', + to: recipient || undefined, + text, + }; + + return { payload, segment: segments }; + } + + const payload: any = { + type: 'message', + to: recipient || undefined, + text, + }; + + return { payload }; } private decodeObject(withStructure: boolean = false): { payload: Payload | null; segment?: PacketSegment[] } { @@ -989,47 +1071,212 @@ export class Frame implements IFrame { } } - private decodeItem(_withStructure: boolean = false): { payload: Payload | null; segment?: PacketSegment[] } { - // TODO: Implement item decoding with section emission - return { payload: null }; + private decodeItem(withStructure: boolean = false): { payload: Payload | null; segment?: PacketSegment[] } { + // Item format is similar to Object but name may be 3-9 chars (stored in a 9-char field) + // Example: )NNN... where ) is data type, next 9 chars are name, then state char, then timestamp, then position + if (this.payload.length < 12) return { payload: null }; // minimal: 1 + 3 + 1 + 7 + + let offset = 1; // skip data type identifier ')' + const segment: PacketSegment[] = withStructure ? [] : []; + + // Read 9-char name field (pad/truncate as present) + const rawName = this.payload.substring(offset, offset + 9); + const name = rawName.trimEnd(); + if (withStructure) { + segment.push({ + name: 'item name', + data: new TextEncoder().encode(rawName), + fields: [ + { type: FieldType.STRING, name: 'name', size: 9 }, + ], + }); + } + offset += 9; + + // State character: '*' = alive, '_' = killed + const stateChar = this.payload.charAt(offset); + if (stateChar !== '*' && stateChar !== '_') { + return { payload: null }; + } + const alive = stateChar === '*'; + if (withStructure) { + segment.push({ + name: 'item state', + data: new TextEncoder().encode(stateChar), + fields: [ + { type: FieldType.CHAR, name: 'State (* alive, _ killed)', size: 1 }, + ], + }); + } + offset += 1; + + // Timestamp (7 chars) + const timeStr = this.payload.substring(offset, offset + 7); + const { timestamp, segment: timestampSection } = this.parseTimestamp(timeStr, withStructure, offset); + if (!timestamp) return { payload: null }; + if (timestampSection) segment.push(timestampSection); + offset += 7; + + const positionOffset = offset; + const isCompressed = this.isCompressedPosition(this.payload.substring(offset)); + + let position: { latitude: number; longitude: number; symbol: any; ambiguity?: number; altitude?: number; comment?: string } | null = null; + let consumed = 0; + + if (isCompressed) { + const { position: compressed, segment: compressedSection } = this.parseCompressedPosition(this.payload.substring(offset), withStructure, positionOffset); + if (!compressed) return { payload: null }; + + position = { + latitude: compressed.latitude, + longitude: compressed.longitude, + symbol: compressed.symbol, + altitude: compressed.altitude, + }; + consumed = 13; + + if (compressedSection) segment.push(compressedSection); + } else { + const { position: uncompressed, segment: uncompressedSection } = this.parseUncompressedPosition(this.payload.substring(offset), withStructure, positionOffset); + if (!uncompressed) return { payload: null }; + + position = { + latitude: uncompressed.latitude, + longitude: uncompressed.longitude, + symbol: uncompressed.symbol, + ambiguity: uncompressed.ambiguity, + }; + consumed = 19; + + if (uncompressedSection) segment.push(uncompressedSection); + } + + offset += consumed; + const comment = this.payload.substring(offset); + if (comment) { + position.comment = comment; + if (withStructure) { + segment.push({ + name: 'Comment', + data: new TextEncoder().encode(comment), + fields: [ + { type: FieldType.STRING, name: 'text', size: comment.length }, + ], + }); + } + } + + const payload: any = { + type: 'item', + name, + alive, + position, + }; + + if (withStructure) { + return { payload, segment }; + } + + return { payload }; } - private decodeStatus(_withStructure: boolean = false): { payload: Payload | null; segment?: PacketSegment[] } { - // TODO: Implement status decoding with section emission - return { payload: null }; + private decodeStatus(withStructure: boolean = false): { payload: Payload | null; segment?: PacketSegment[] } { + // Status payload: optional 7-char timestamp followed by free text. + // We'll also detect a trailing Maidenhead locator (4 or 6 chars) and expose it. + const offsetBase = 1; // skip data type identifier '>' + if (this.payload.length <= offsetBase) return { payload: null }; + + let offset = offsetBase; + const segments: PacketSegment[] = withStructure ? [] : []; + + // Try parse optional timestamp (7 chars) + if (this.payload.length >= offset + 7) { + const timeStr = this.payload.substring(offset, offset + 7); + const { timestamp, segment: tsSegment } = this.parseTimestamp(timeStr, withStructure, offset); + if (timestamp) { + offset += 7; + if (tsSegment) segments.push(tsSegment); + } + } + + // Remaining text is status text + const text = this.payload.substring(offset); + if (!text) return { payload: null }; + + // Detect trailing Maidenhead locator (4 or 6 chars) at end of text separated by space + let maidenhead: string | undefined; + const mhMatch = text.match(/\s([A-Ra-r]{2}\d{2}(?:[A-Ra-r]{2})?)$/); + let statusText = text; + if (mhMatch) { + maidenhead = mhMatch[1].toUpperCase(); + statusText = text.slice(0, mhMatch.index).trimEnd(); + } + + const payload: any = { + type: 'status', + timestamp: undefined, + text: statusText, + }; + + // If timestamp was parsed, attach it + if (segments.length > 0) { + // The first segment may be timestamp; parseTimestamp returns the Timestamp object + // Re-parse to obtain timestamp object (cheap) - alternate would be to capture earlier + const timeSegment = segments.find(s => s.name === 'timestamp'); + if (timeSegment) { + const tsStr = new TextDecoder().decode(timeSegment.data); + const { timestamp } = this.parseTimestamp(tsStr, false, 0); + if (timestamp) payload.timestamp = timestamp; + } + } + + if (maidenhead) payload.maidenhead = maidenhead; + + if (withStructure) { + segments.push({ + name: 'status', + data: new TextEncoder().encode(text), + fields: [ + { type: FieldType.STRING, name: 'text', size: text.length }, + ], + }); + return { payload, segment: segments }; + } + + return { payload }; } - private decodeQuery(_withStructure: boolean = false): { payload: Payload | null; segment?: PacketSegment[] } { + private decodeQuery(withStructure: boolean = false): { payload: Payload | null; segment?: PacketSegment[] } { // TODO: Implement query decoding with section emission return { payload: null }; } - private decodeTelemetry(_withStructure: boolean = false): { payload: Payload | null; segment?: PacketSegment[] } { + private decodeTelemetry(withStructure: boolean = false): { payload: Payload | null; segment?: PacketSegment[] } { // TODO: Implement telemetry decoding with section emission return { payload: null }; } - private decodeWeather(_withStructure: boolean = false): { payload: Payload | null; segment?: PacketSegment[] } { + private decodeWeather(withStructure: boolean = false): { payload: Payload | null; segment?: PacketSegment[] } { // TODO: Implement weather decoding with section emission return { payload: null }; } - private decodeRawGPS(_withStructure: boolean = false): { payload: Payload | null; segment?: PacketSegment[] } { + private decodeRawGPS(withStructure: boolean = false): { payload: Payload | null; segment?: PacketSegment[] } { // TODO: Implement raw GPS decoding with section emission return { payload: null }; } - private decodeCapabilities(_withStructure: boolean = false): { payload: Payload | null; segment?: PacketSegment[] } { + private decodeCapabilities(withStructure: boolean = false): { payload: Payload | null; segment?: PacketSegment[] } { // TODO: Implement capabilities decoding with section emission return { payload: null }; } - private decodeUserDefined(_withStructure: boolean = false): { payload: Payload | null; segment?: PacketSegment[] } { + private decodeUserDefined(withStructure: boolean = false): { payload: Payload | null; segment?: PacketSegment[] } { // TODO: Implement user-defined decoding with section emission return { payload: null }; } - private decodeThirdParty(_withStructure: boolean = false): { payload: Payload | null; segment?: PacketSegment[] } { + private decodeThirdParty(withStructure: boolean = false): { payload: Payload | null; segment?: PacketSegment[] } { // TODO: Implement third-party decoding with section emission return { payload: null }; }