5 Commits

Author SHA1 Message Date
4669783b67 chore(release): v1.0.1 - bump from v1.0.0 2026-03-11 18:00:38 +01:00
94c96ebf15 Added release script 2026-03-11 18:00:10 +01:00
121aa9d1ad More unit tests 2026-03-11 17:59:02 +01:00
ebe4670c08 Added message, object and status decoding 2026-03-11 17:57:07 +01:00
08177f4e6f Use plain ASCII 2026-03-11 17:56:15 +01:00
5 changed files with 690 additions and 495 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "@hamradio/aprs",
"version": "1.0.0",
"version": "1.0.1",
"description": "APRS (Automatic Packet Reporting System) protocol support for Typescript",
"keywords": [
"APRS",

115
scripts/release.js Executable file
View File

@@ -0,0 +1,115 @@
#!/usr/bin/env node
// Minimal safe release script.
// Usage: node scripts/release.js [major|minor|patch|<version>]
const { execSync } = require("child_process");
const fs = require("fs");
const path = require("path");
const root = path.resolve(__dirname, "..");
const pkgPath = path.join(root, "package.json");
function run(cmd, opts = {}) {
return execSync(cmd, { stdio: "inherit", cwd: root, ...opts });
}
function runOutput(cmd) {
return execSync(cmd, { cwd: root }).toString().trim();
}
function bumpSemver(current, spec) {
if (["major","minor","patch"].includes(spec)) {
const [maj, min, patch] = current.split(".").map(n=>parseInt(n,10));
if (spec==="major") return `${maj+1}.0.0`;
if (spec==="minor") return `${maj}.${min+1}.0`;
return `${maj}.${min}.${patch+1}`;
}
if (!/^\d+\.\d+\.\d+$/.test(spec)) throw new Error("Invalid version spec");
return spec;
}
(async () => {
const arg = process.argv[2] || "patch";
const pkgRaw = fs.readFileSync(pkgPath, "utf8");
const pkg = JSON.parse(pkgRaw);
const oldVersion = pkg.version;
const newVersion = bumpSemver(oldVersion, arg);
let committed = false;
let tagged = false;
let pushedTags = false;
try {
// refuse to run if there are unstaged/uncommitted changes
const status = runOutput("git status --porcelain");
if (status) throw new Error("Repository has uncommitted changes; please commit or stash before releasing.");
console.log("Running tests...");
run("npm run test:ci");
console.log("Building...");
run("npm run build");
// write new version
pkg.version = newVersion;
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf8");
console.log(`Bumped version: ${oldVersion} -> ${newVersion}`);
// commit
run(`git add ${pkgPath}`);
run(`git commit -m "chore(release): v${newVersion} - bump from v${oldVersion}"`);
committed = true;
// ensure tag doesn't already exist locally
let localTagExists = false;
try {
runOutput(`git rev-parse --verify refs/tags/v${newVersion}`);
localTagExists = true;
} catch (_) {
localTagExists = false;
}
if (localTagExists) throw new Error(`Tag v${newVersion} already exists locally — aborting to avoid overwrite.`);
// ensure tag doesn't exist on remote
const remoteTagInfo = (() => {
try { return runOutput(`git ls-remote --tags origin v${newVersion}`); } catch (_) { return ""; }
})();
if (remoteTagInfo) throw new Error(`Tag v${newVersion} already exists on remote — aborting to avoid overwrite.`);
// tag
run(`git tag -a v${newVersion} -m "Release v${newVersion}"`);
tagged = true;
// push commit and tags
run("git push");
run("git push --tags");
pushedTags = true;
// publish
console.log("Publishing to npm...");
const publishCmd = pkg.name && pkg.name.startsWith("@") ? "npm publish --access public" : "npm publish";
run(publishCmd);
console.log(`Release v${newVersion} succeeded.`);
process.exit(0);
} catch (err) {
console.error("Release failed:", err.message || err);
try {
// delete local tag
if (tagged) {
try { run(`git tag -d v${newVersion}`); } catch {}
if (pushedTags) {
try { run(`git push origin :refs/tags/v${newVersion}`); } catch {}
}
}
// undo commit if made
if (committed) {
try { run("git reset --hard HEAD~1"); } catch {
// fallback: restore package.json content
fs.writeFileSync(pkgPath, pkgRaw, "utf8");
}
} else {
// restore package.json
fs.writeFileSync(pkgPath, pkgRaw, "utf8");
}
} catch (rbErr) {
console.error("Rollback error:", rbErr.message || rbErr);
}
process.exit(1);
}
})();

View File

@@ -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 };
}

View File

@@ -57,14 +57,14 @@ export class Position implements IPosition {
public distanceTo(other: IPosition): number {
const R = 6371e3; // Earth radius in meters
const φ1 = this.latitude * Math.PI / 180;
const φ2 = other.latitude * Math.PI / 180;
const Δφ = (other.latitude - this.latitude) * Math.PI / 180;
const Δλ = (other.longitude - this.longitude) * Math.PI / 180;
const lat1 = this.latitude * Math.PI / 180;
const lat2 = other.latitude * Math.PI / 180;
const dLat = (other.latitude - this.latitude) * Math.PI / 180;
const dLon = (other.longitude - this.longitude) * Math.PI / 180;
const a = Math.sin(Δφ/2) * Math.sin(Δφ/2) +
Math.cos(φ1) * Math.cos(φ2) *
Math.sin(Δλ/2) * Math.sin(Δλ/2);
const a = Math.sin(dLat/2) * Math.sin(dLat/2) +
Math.cos(lat1) * Math.cos(lat2) *
Math.sin(dLon/2) * Math.sin(dLon/2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
return R * c; // Distance in meters

File diff suppressed because it is too large Load Diff