118 lines
2.7 KiB
TypeScript
118 lines
2.7 KiB
TypeScript
import { BaseStream } from './Stream';
|
|
|
|
export interface APRSMessage {
|
|
topic: string;
|
|
receivedAt: Date;
|
|
raw: string;
|
|
radioName?: string;
|
|
}
|
|
|
|
interface APRSJsonEnvelope {
|
|
raw?: string;
|
|
payload?: string;
|
|
payloadBase64?: string;
|
|
rawBase64?: string;
|
|
Raw?: string;
|
|
Payload?: string;
|
|
Time?: string;
|
|
time?: string;
|
|
timestamp?: string;
|
|
}
|
|
|
|
const fromBase64 = (value: string): string => {
|
|
return atob(value);
|
|
};
|
|
|
|
const pickString = (obj: Record<string, unknown>, keys: string[]): string | undefined => {
|
|
for (const key of keys) {
|
|
const value = obj[key];
|
|
if (typeof value === 'string' && value.trim().length > 0) {
|
|
return value;
|
|
}
|
|
}
|
|
return undefined;
|
|
};
|
|
|
|
export class APRSStream extends BaseStream {
|
|
constructor(autoConnect = false) {
|
|
super({}, autoConnect);
|
|
}
|
|
|
|
protected decodeMessage(topic: string, payload: Uint8Array): APRSMessage {
|
|
const text = new TextDecoder().decode(payload).trim();
|
|
const radioName = this.extractRadioNameFromTopic(topic);
|
|
|
|
if (!text.startsWith('{')) {
|
|
return {
|
|
topic,
|
|
receivedAt: new Date(),
|
|
raw: text,
|
|
radioName,
|
|
};
|
|
}
|
|
|
|
const envelope = JSON.parse(text) as APRSJsonEnvelope;
|
|
const record = envelope as unknown as Record<string, unknown>;
|
|
|
|
const raw = this.extractRawFrame(record);
|
|
const receivedAt = this.extractReceivedAt(record);
|
|
|
|
return {
|
|
topic,
|
|
receivedAt,
|
|
raw,
|
|
radioName,
|
|
};
|
|
}
|
|
|
|
private extractRawFrame(envelope: Record<string, unknown>): string {
|
|
const rawFrame = pickString(envelope, ['raw', 'payload']);
|
|
if (rawFrame && rawFrame.includes('>')) {
|
|
return rawFrame;
|
|
}
|
|
|
|
const maybeBase64 = pickString(envelope, ['Raw', 'payloadBase64', 'rawBase64', 'Payload']);
|
|
if (maybeBase64) {
|
|
const decoded = fromBase64(maybeBase64);
|
|
if (decoded.includes('>')) {
|
|
return decoded;
|
|
}
|
|
}
|
|
|
|
if (rawFrame) {
|
|
return rawFrame;
|
|
}
|
|
|
|
throw new Error('APRSStream: invalid payload envelope, no APRS frame found');
|
|
}
|
|
|
|
private extractReceivedAt(envelope: Record<string, unknown>): Date {
|
|
const timeValue = pickString(envelope, ['time', 'Time', 'timestamp']);
|
|
if (!timeValue) {
|
|
return new Date();
|
|
}
|
|
|
|
const parsed = new Date(timeValue);
|
|
if (Number.isNaN(parsed.getTime())) {
|
|
return new Date();
|
|
}
|
|
|
|
return parsed;
|
|
}
|
|
|
|
private extractRadioNameFromTopic(topic: string): string | undefined {
|
|
const parts = topic.split('/');
|
|
if (parts.length >= 3 && parts[0] === 'aprs' && parts[1] === 'packet') {
|
|
try {
|
|
return atob(parts[2]);
|
|
} catch (error) {
|
|
console.warn('Failed to decode radio name from topic:', topic, error);
|
|
}
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
export default APRSStream;
|