Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
4669783b67
|
|||
|
94c96ebf15
|
|||
|
121aa9d1ad
|
|||
|
ebe4670c08
|
|||
|
08177f4e6f
|
@@ -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
115
scripts/release.js
Executable 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);
|
||||
}
|
||||
})();
|
||||
281
src/frame.ts
281
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 };
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user