Skip to content

Commit 5fa91db

Browse files
authored
Merge branch 'master' into fix/h1-crc-buffer-web-apis
2 parents 78dda0e + f3dd9e2 commit 5fa91db

27 files changed

Lines changed: 961 additions & 145 deletions

License.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 Airframes contributors
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

README.md

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,7 @@
1-
# Node.js Support Policy
2-
3-
- Runtime support: Node.js >= 18
4-
- Development and CI: validated on Node 18.x, 20.x, 22.x, and 24.x
5-
- TypeScript development uses the latest stable Node types (currently v24) without forcing consumers to use a specific Node types version, since `@types/node` is a devDependency.
6-
- The published builds are compiled targeting Node 18 runtime via `tsup` so they remain compatible across supported Node versions.
7-
8-
# @airframes/acars-decoder
9-
10-
This is a no-op documentation tweak to verify repository access, build/lint, tests, and PR workflow. No functional code changes are included.
11-
121
# acars-decoder-typescript
132

143
[![NPM Version](https://badge.fury.io/js/@airframes%2Facars-decoder.svg)](https://badge.fury.io/js/@airframes%2Facars-decoder)
4+
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./License.md)
155
[![GitHub Actions Workflow Status](https://github.com/airframesio/acars-decoder-typescript/actions/workflows/yarn-test.yml/badge.svg)
166
](https://github.com/airframesio/acars-decoder-typescript/actions/workflows/yarn-test.yml)
177
![CodeRabbit Pull Request Reviews](https://img.shields.io/coderabbit/prs/github/airframesio/acars-decoder-typescript?utm_source=oss&utm_medium=github&utm_campaign=airframesio%2Facars-decoder-typescript&labelColor=171717&color=FF570A&link=https%3A%2F%2Fcoderabbit.ai&label=CodeRabbit+Reviews)
@@ -27,6 +17,13 @@ It has been written in TypeScript (which compiles to Javascript) and is publishe
2717

2818
You are welcome to contribute (please see https://github.com/airframesio/acars-message-documentation where we collaborate to research and document the various types of messages), and while it was primarily developed to power [Airframes](https://app.airframes.io) and [AcarsHub](https://sdr-e.com/docker-acarshub), you may use this library in your own applications freely.
2919

20+
# Node.js Support Policy
21+
22+
- Runtime support: Node.js >= 18
23+
- Development and CI: validated on Node 18.x, 20.x, 22.x, and 24.x
24+
- TypeScript development uses the latest stable Node types (currently v24) without forcing consumers to use a specific Node types version, since `@types/node` is a devDependency.
25+
- The published builds are compiled targeting Node 18 runtime via `tsup` so they remain compatible across supported Node versions.
26+
3027
# Installation
3128

3229
Add the `@airframes/acars-decoder` library to your JavaScript or TypeScript project.

lib/DateTimeUtils.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { DateTimeUtils } from './DateTimeUtils';
2+
3+
describe('DateTimeUtils.UTCDateTimeToString', () => {
4+
it('decodes a DDMMYY date with the ACARS 1-12 month convention', () => {
5+
// ACARS "150226" = day 15, month 02 (Feb), year 26 -> 15 Feb 2026
6+
const out = DateTimeUtils.UTCDateTimeToString('150226', '1230');
7+
expect(out).toContain('Feb');
8+
expect(out).toContain('15');
9+
expect(out).toContain('2026');
10+
});
11+
12+
it('parses DDMMYY = 280226 as 28 Feb 2026, not a later month', () => {
13+
// ACARS month 02 must map to JS month 1 (February). Before the fix,
14+
// passing the raw digit 2 to setUTCMonth produced March. Assert the
15+
// returned UTC string matches the canonical Date.UTC value directly.
16+
const out = DateTimeUtils.UTCDateTimeToString('280226', '1230');
17+
const expected = new Date(Date.UTC(2026, 1, 28, 12, 30, 0)).toUTCString();
18+
expect(out).toBe(expected);
19+
});
20+
21+
it('handles a six-digit time with seconds', () => {
22+
const out = DateTimeUtils.UTCDateTimeToString('150226', '123045');
23+
expect(out).toContain('12:30:45');
24+
});
25+
});

lib/DateTimeUtils.ts

Lines changed: 14 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -9,25 +9,20 @@ export class DateTimeUtils {
99
// Expects a six digit date string and a four digit UTC time string
1010
// (DDMMYY) (HHMM)
1111
public static UTCDateTimeToString(dateString: string, timeString: string) {
12-
let utcDate = new Date();
13-
utcDate.setUTCDate(+dateString.substr(0, 2));
14-
utcDate.setUTCMonth(+dateString.substr(2, 2));
15-
if (dateString.length === 6) {
16-
utcDate.setUTCFullYear(2000 + +dateString.substr(4, 2));
17-
}
18-
if (timeString.length === 6) {
19-
utcDate.setUTCHours(
20-
+timeString.substr(0, 2),
21-
+timeString.substr(2, 2),
22-
+timeString.substr(4, 2),
23-
);
24-
} else {
25-
utcDate.setUTCHours(
26-
+timeString.substr(0, 2),
27-
+timeString.substr(2, 2),
28-
0,
29-
);
30-
}
12+
const day = +dateString.substr(0, 2);
13+
// ACARS month is 1-12; JS Date months are 0-11, so subtract one.
14+
const month = +dateString.substr(2, 2) - 1;
15+
const year =
16+
dateString.length === 6
17+
? 2000 + +dateString.substr(4, 2)
18+
: new Date().getUTCFullYear();
19+
const hours = +timeString.substr(0, 2);
20+
const minutes = +timeString.substr(2, 2);
21+
const seconds = timeString.length === 6 ? +timeString.substr(4, 2) : 0;
22+
// Build the date in one call so an incremental setUTC* on a Date that
23+
// was initialised to "now" can't roll the month forward when the
24+
// current real-world day is later than the last day of the target.
25+
const utcDate = new Date(Date.UTC(year, month, day, hours, minutes, seconds));
3126
return utcDate.toUTCString();
3227
}
3328

lib/MessageDecoder.labelindex.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,41 @@ describe('MessageDecoder label index', () => {
109109
expect(result.decoder.name).toBe('catch-all');
110110
});
111111

112+
test('wildcard registration order is preserved across existing label buckets', () => {
113+
// Regression test: when a wildcard plugin is registered AFTER a label
114+
// bucket has already been created, it must be inserted at the end of
115+
// the wildcard section, not at the front. Otherwise later-registered
116+
// wildcards would incorrectly take precedence over earlier ones for
117+
// labels that already had buckets, and wildcard order would diverge
118+
// from buckets created later (which seed from wildcardEntries.slice()).
119+
const decoder = new MessageDecoder();
120+
121+
// 1. Register a label-specific plugin for '99' — this creates a
122+
// bucket with no wildcards yet (existing wildcards from the
123+
// constructor are in, but that's fine; we only care about the
124+
// relative order of stubs registered here).
125+
const labelSpecific = new StubPlugin(decoder, 'label-99-specific', ['99']);
126+
decoder.registerPlugin(labelSpecific);
127+
128+
// 2. Register two new wildcard stubs in order.
129+
const firstWildcard = new StubPlugin(decoder, 'wild-first', ['*']);
130+
decoder.registerPlugin(firstWildcard);
131+
const secondWildcard = new StubPlugin(decoder, 'wild-second', ['*']);
132+
decoder.registerPlugin(secondWildcard);
133+
134+
// 3. For a label-99 message, the first-registered wildcard must run
135+
// before the second-registered wildcard. Since both succeed, the
136+
// decoder.name tells us which one won the race.
137+
const result = decoder.decode({ label: '99', text: 'anything' });
138+
expect(result.decoded).toBe(true);
139+
expect(result.decoder.name).toBe('wild-first');
140+
141+
// 4. The same ordering must hold for a brand-new label that didn't
142+
// have a bucket yet (it gets seeded from wildcardEntries.slice()).
143+
const newLabel = decoder.decode({ label: 'ZZ', text: 'anything' });
144+
expect(newLabel.decoder.name).toBe('wild-first');
145+
});
146+
112147
test('preamble-based plugins only match correct preambles', () => {
113148
const decoder = new MessageDecoder();
114149
const stub = new StubPlugin(

lib/MessageDecoder.ts

Lines changed: 76 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -80,15 +80,27 @@ const pluginClasses = [
8080
Plugins.Label_QS,
8181
];
8282

83+
/**
84+
* Per-plugin metadata captured at registration time so that decode() can
85+
* avoid re-invoking plugin.qualifiers() (and re-allocating its arrays) on
86+
* every message.
87+
*/
88+
interface PluginEntry {
89+
plugin: DecoderPluginInterface;
90+
preambles: string[] | undefined;
91+
}
92+
8393
export class MessageDecoder {
8494
name: string;
8595
plugins: Array<DecoderPluginInterface>;
8696
debug: boolean;
8797

88-
/** Maps a label string to the plugins registered for it, preserving registration order. */
89-
private labelIndex: Map<string, DecoderPluginInterface[]> = new Map();
90-
/** Plugins that match all labels (qualifier label '*'). */
91-
private wildcardPlugins: DecoderPluginInterface[] = [];
98+
/** Maps a label string to the candidate entries (wildcard + label-specific) in registration order. */
99+
private candidatesByLabel: Map<string, PluginEntry[]> = new Map();
100+
/** Wildcard entries (plugins that register the '*' label). */
101+
private wildcardEntries: PluginEntry[] = [];
102+
/** Membership set for wildcard entries to dedupe when a plugin also registers a specific label. */
103+
private wildcardSet: Set<DecoderPluginInterface> = new Set();
92104

93105
constructor() {
94106
this.name = 'acars-decoder-typescript';
@@ -104,47 +116,59 @@ export class MessageDecoder {
104116
this.plugins.push(plugin);
105117

106118
const qualifiers = plugin.qualifiers();
119+
const entry: PluginEntry = {
120+
plugin,
121+
preambles:
122+
qualifiers.preambles && qualifiers.preambles.length > 0
123+
? qualifiers.preambles
124+
: undefined,
125+
};
126+
107127
for (const label of qualifiers.labels) {
108128
if (label === '*') {
109-
this.wildcardPlugins.push(plugin);
129+
if (!this.wildcardSet.has(plugin)) {
130+
this.wildcardEntries.push(entry);
131+
this.wildcardSet.add(plugin);
132+
// Insert the new wildcard at the end of the wildcard section
133+
// in every existing label bucket so that wildcard plugins are
134+
// still tried before label-specific ones while preserving
135+
// registration order among wildcard plugins (matching how new
136+
// buckets are seeded via wildcardEntries.slice() below).
137+
const wildcardInsertIndex = this.wildcardEntries.length - 1;
138+
for (const bucket of this.candidatesByLabel.values()) {
139+
bucket.splice(wildcardInsertIndex, 0, entry);
140+
}
141+
}
110142
} else {
111-
let bucket = this.labelIndex.get(label);
143+
let bucket = this.candidatesByLabel.get(label);
112144
if (!bucket) {
113-
bucket = [];
114-
this.labelIndex.set(label, bucket);
145+
// Seed new bucket with all wildcard entries (in registration order)
146+
// so they remain ahead of label-specific plugins.
147+
bucket = this.wildcardEntries.slice();
148+
this.candidatesByLabel.set(label, bucket);
149+
}
150+
// Skip if this plugin is a wildcard plugin already in the bucket.
151+
if (!this.wildcardSet.has(plugin)) {
152+
bucket.push(entry);
115153
}
116-
bucket.push(plugin);
117154
}
118155
}
119156

120157
return true;
121158
}
122159

123160
decode(message: Message, options: Options = {}): DecodeResult {
124-
// Build candidate list: wildcard plugins first (e.g. CBand wrapper),
125-
// then label-specific plugins, preserving registration order.
126-
// Use a Set to prevent duplicate execution if a plugin registers both '*' and a specific label.
127-
const labelPlugins = this.labelIndex.get(message.label) ?? [];
128-
const seen = new Set<DecoderPluginInterface>();
129-
const candidates: DecoderPluginInterface[] = [];
130-
for (const plugin of [...this.wildcardPlugins, ...labelPlugins]) {
131-
if (!seen.has(plugin)) {
132-
seen.add(plugin);
133-
candidates.push(plugin);
134-
}
135-
}
136-
137-
const usablePlugins = candidates.filter((plugin) => {
138-
const preambles = plugin.qualifiers().preambles;
139-
if (!preambles || preambles.length === 0) {
140-
return true;
141-
}
142-
return preambles.some((p: string) => message.text.startsWith(p));
143-
});
161+
const text = message.text;
162+
const candidates =
163+
this.candidatesByLabel.get(message.label) ?? this.wildcardEntries;
144164

145165
if (options.debug) {
146166
console.log('Usable plugins');
147-
console.log(usablePlugins);
167+
console.log(
168+
candidates
169+
.filter((e) => this.matchesPreambles(text, e.preambles))
170+
.map((e) => e.plugin),
171+
);
148172
}
149173

150174
let result: DecodeResult = {
@@ -157,7 +181,7 @@ export class MessageDecoder {
157181
},
158182
message: message,
159183
remaining: {
160-
text: message.text,
184+
text: text,
161185
},
162186
raw: {},
163187
formatted: {
@@ -166,9 +190,12 @@ export class MessageDecoder {
166190
},
167191
};
168192

169-
for (let i = 0; i < usablePlugins.length; i++) {
170-
const plugin = usablePlugins[i];
171-
result = plugin.decode(message, options);
193+
for (let i = 0; i < candidates.length; i++) {
194+
const entry = candidates[i];
195+
if (!this.matchesPreambles(text, entry.preambles)) {
196+
continue;
197+
}
198+
result = entry.plugin.decode(message, options);
172199
if (result.decoded) {
173200
break;
174201
}
@@ -181,4 +208,19 @@ export class MessageDecoder {
181208

182209
return result;
183210
}
211+
212+
private matchesPreambles(
213+
text: string,
214+
preambles: string[] | undefined,
215+
): boolean {
216+
if (!preambles) {
217+
return true;
218+
}
219+
for (let i = 0; i < preambles.length; i++) {
220+
if (text.startsWith(preambles[i])) {
221+
return true;
222+
}
223+
}
224+
return false;
225+
}
184226
}

lib/plugins/ARINC_702.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { MessageDecoder } from '../MessageDecoder';
2+
import { Arinc702 } from './ARINC_702';
3+
4+
describe('ARINC_702 wildcard wrapper', () => {
5+
let plugin: Arinc702;
6+
const message = { label: 'H1', text: '' };
7+
8+
beforeEach(() => {
9+
const decoder = new MessageDecoder();
10+
plugin = new Arinc702(decoder);
11+
});
12+
13+
test('matches wildcard qualifier', () => {
14+
expect(plugin.qualifiers()).toEqual({ labels: ['*'] });
15+
expect(plugin.name).toBe('arinc-702');
16+
});
17+
18+
test('strips embedded CR/LF before delegating to the H1 helper', () => {
19+
// Inserting newlines into a known-good H1 REQ POS payload should not
20+
// prevent it from being decoded.
21+
message.text = 'REQ\nPOS\r037B';
22+
const result = plugin.decode(message);
23+
24+
expect(result.decoded).toBe(true);
25+
expect(result.decoder.decodeLevel).toBe('full');
26+
expect(result.raw.checksum).toBe(0x037b);
27+
expect(result.formatted.description).toBe('Request for Position Report');
28+
});
29+
30+
test('peels a leading / header before delegating', () => {
31+
// /HDQDLUA.<H1 message body>
32+
message.text = '/HDQDLUA.REQPOS037B';
33+
const result = plugin.decode(message);
34+
35+
expect(result.decoded).toBe(true);
36+
// The delegated H1 body must actually be decoded — assert the
37+
// REQ POS fields landed in the result, proving the '/HDQDLUA.'
38+
// prefix was peeled before delegation.
39+
expect(result.formatted.description).toBe('Request for Position Report');
40+
expect(result.raw.checksum).toBe(0x037b);
41+
// The unparsed header also ends up in remaining text.
42+
expect(result.remaining.text).toContain('/HDQDLUA');
43+
});
44+
45+
test('returns not-decoded when nothing matches', () => {
46+
message.text = 'totally bogus payload that no H1 rule matches';
47+
const result = plugin.decode(message);
48+
49+
expect(result.decoded).toBe(false);
50+
expect(result.decoder.decodeLevel).toBe('none');
51+
// The full original text should be present in the remaining text the
52+
// wrapper accumulates (the wrapper may also emit prefix fragments).
53+
expect(result.remaining.text).toContain(message.text);
54+
});
55+
56+
test('end-to-end via MessageDecoder routes ARINC 702 wildcard for H1', () => {
57+
const decoder = new MessageDecoder();
58+
const result = decoder.decode({ label: 'H1', text: 'REQPOS037B' });
59+
expect(result.decoded).toBe(true);
60+
expect(result.formatted.description).toBe('Request for Position Report');
61+
});
62+
});

0 commit comments

Comments
 (0)