Add Label C1 Loadsheet decoder plugin#416
Conversation
New plugin registered in official.ts and MessageDecoder.ts.
|
Warning Rate limit exceeded
Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 55 minutes and 17 seconds. ⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout. Please see our FAQ for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (3)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
kevinelliott
left a comment
There was a problem hiding this comment.
Summary
Adds Label_C1_Loadsheet for label C1 electronic loadsheet uplinks. Tolerant line-by-line parser with a single-line fallback that splits on known keywords. Registered last in MessageDecoder.ts/official.ts. No companion test file.
Verdict
Comment-only review — biggest single issue is the missing test coverage given how many fields this plugin tries to handle. The parsing logic is generally well-structured, but a few mismatches with the project's conventions and a couple of small bugs should be addressed.
Must Fix
- Add a
Label_C1_Loadsheet.test.ts. This plugin recognizes ~13 distinct field types — please cover them. At minimum:qualifiers(), the multi-line happy path from the doc-comment example, the single-line fallback (splitIntoFieldskeyword path),PRELIMINARYvsFINALvariants, theSIroute-vs-free-text branch, theNOTOC: NOnormalization, and a non-matching message. - Use
ResultFormatterfor shared concerns. Today the plugin setsraw.tailand pushes a customcode: 'TAIL'item with label'Aircraft Registration', instead of callingResultFormatter.tail(decodeResult, m[1]). Same for therouteSI branch —ResultFormatter.departureAirport/arrivalAirportalready exist and produce the canonical IATA/ICAO items. Directraw.departure_iata = ...followed by a customcode: 'ROUTE'item bypasses the canonical "Origin/Destination" rows that consumers (and the test fixtures) expect.
Should Fix
- Add a
preamblesqualifier if at all possible. Every C1 observation in your doc-comment begins with.(the FRAGDLH-style sender token). If that's reliable,preambles: ['.']would prevent this plugin from running its full keyword scan against unrelated C1 traffic. - DDHHMM has no bounds validation.
190925is fine, but999999is happily decoded as "Day 99 @ 99:99Z". Either validatedd <= 31,hh < 24,mm < 60, or document the looseness. Same applies to the loadsheetreference_timecapture (free-form pass-through is fine; just be aware). PAXregex captures wrong totals when classes are swapped. For inputPAX C/Y 6/84you bindclasses = ['C','Y']andcounts = [6, 84]— perfect. ForPAX Y/C 84/6you'd produce{Y: 84, C: 6}which is correct, but theclassLabelmapping has a duplicate entry: bothCandJmap to'Business'. That's acceptable airline-data shorthand, but worth a comment explaining you're treating them as equivalent intentionally.splitIntoFieldsis destructive across the input. It collapses\s{2,}to a single space before line-splitting. That's deliberate, but if a free-textSIline contains intentional double spaces, they're lost. Move the whitespace collapse inside per-line handling rather than at the top.raw.day = Number(dd)clobbers the typed field.RawFields.dayisnumber | string, so this is OK, but be aware the sameraw.dayslot is used byResultFormatter.day()— if a future C1 message also arrives with a UTC timestamp elsewhere you may write twice. Considerraw.dtg_dayto disambiguate.const [_, dd, hh, mm] = m;— under stricter eslint configs the unused leading_triggers@typescript-eslint/no-unused-vars. The codebase tends to usem[1]/m[2]/m[3]indexed access (seeLabel_15.ts). Optional cleanup.
Nits
- The keyword-splitter list mixes
'AN ','PAX ','PAX/'etc. — some have trailing space, some have a slash. That's fine but awkward to maintain. Consider a[{name, regex}]table. - Doc-comment is excellent, very useful for future maintainers.
- Saving
decodeResult.raw.is_agm = trueas a typed boolean is fine; just consistent with how other plugins represent markers (most use aformatted.itemsentry only, norawfield). partialdecode level whenremaining.length === 0but a section was unrecognized — small thing, butsetDecodeLevel(decodeResult, anyMatched, remaining.length ? 'partial' : 'full')returnsdecoded: falseifanyMatchedwas false. Confirm that's intended (it is, per the protocol).
Tests
None added. Please add Label_C1_Loadsheet.test.ts. See "Must Fix" above for coverage list.
Notes — Registration & Coexistence
- Registered at the tail of
pluginClassesand exported fromofficial.ts. No other plugin claims label C1, so order is irrelevant. - No coexistence concerns.
Thanks @thepacket!
There was a problem hiding this comment.
Pull request overview
Adds a new decoder plugin for ACARS label C1 ground-to-air loadsheet / load-info uplinks (preliminary and final), registers it in official.ts and the MessageDecoder default plugin list, and parses common fields (sender, DTG, registration, weights, PAX, MACTOW, route, NOTOC, cabin config, fuel, preparer, reference, end marker) with a single-line fallback splitter.
Changes:
- New
Label_C1_Loadsheetplugin with multi-field regex parser and single-line keyword splitter. - Plugin exported from
lib/plugins/official.ts. - Plugin registered in
lib/MessageDecoder.tsdefault plugin list.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 6 comments.
| File | Description |
|---|---|
| lib/plugins/Label_C1_Loadsheet.ts | New decoder for label C1 loadsheet uplinks. |
| lib/plugins/official.ts | Re-exports the new plugin. |
| lib/MessageDecoder.ts | Registers the new plugin in the default list. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Normalize: split on newlines AND on known field-boundary markers so | ||
| // the decoder also works against single-line messages. | ||
| const normalized = text | ||
| .replace(/\r/g, '') | ||
| .replace(/\s{2,}/g, ' ') |
| export class Label_C1_Loadsheet extends DecoderPlugin { | ||
| name = 'label-c1-loadsheet'; | ||
|
|
||
| qualifiers() { | ||
| return { | ||
| labels: ['C1'], | ||
| }; | ||
| } |
| // Aircraft registration + address qualifier + seq: "AN HB-JVA/MA 277A" | ||
| m = line.match(/^AN\s+([A-Z0-9-]+)\/([A-Z]{1,3})\s+(\S+)$/); | ||
| if (m) { | ||
| decodeResult.raw.tail = m[1]; | ||
| decodeResult.raw.address_qualifier = m[2]; | ||
| decodeResult.raw.message_sequence = m[3]; | ||
| decodeResult.formatted.items.push({ | ||
| type: 'tail', | ||
| code: 'TAIL', | ||
| label: 'Aircraft Registration', | ||
| value: m[1], | ||
| }); | ||
| decodeResult.formatted.items.push({ | ||
| type: 'address_qualifier', | ||
| code: 'ADDRQ', | ||
| label: 'Address Qualifier', | ||
| value: m[2], | ||
| }); | ||
| decodeResult.formatted.items.push({ | ||
| type: 'sequence', | ||
| code: 'SEQ', | ||
| label: 'Message Sequence', | ||
| value: m[3], | ||
| }); | ||
| anyMatched = true; | ||
| continue; | ||
| } | ||
|
|
||
| // Variant: PRELIMINARY LOAD INFO / LOADSHEET FINAL / LOADSHEET PRELIMINARY etc | ||
| m = line.match( | ||
| /^(?:(PRELIMINARY|FINAL)\s+LOAD(?:\s+INFO|\s*SHEET)?|LOAD\s*SHEET\s+(PRELIMINARY|FINAL))(?:\s+(\S.*))?$/, | ||
| ); | ||
| if (m) { | ||
| const variant = (m[1] || m[2]).toUpperCase(); | ||
| decodeResult.raw.loadsheet_variant = variant; | ||
| if (m[3]) decodeResult.raw.loadsheet_reference_time = m[3]; | ||
| decodeResult.formatted.items.push({ | ||
| type: 'loadsheet_variant', | ||
| code: 'VARIANT', | ||
| label: 'Loadsheet Type', | ||
| value: | ||
| variant === 'PRELIMINARY' | ||
| ? 'Preliminary (pre-departure estimate)' | ||
| : 'Final (authoritative for takeoff)', | ||
| }); | ||
| if (m[3]) { | ||
| decodeResult.formatted.items.push({ | ||
| type: 'reference_time', | ||
| code: 'REFTIME', | ||
| label: 'Reference Time', | ||
| value: m[3], | ||
| }); | ||
| } | ||
| anyMatched = true; | ||
| continue; | ||
| } | ||
|
|
||
| // ZFW / TOW / LAW / TOF etc — weights in kilograms | ||
| m = line.match(/^(ZFW|TOW|LAW|TOF|DOW|MZFW|MTOW|MLAW)\s+([\d.]+)$/); | ||
| if (m) { | ||
| const code = m[1]; | ||
| const val = Number(m[2]); | ||
| const keyMap: Record<string, string> = { | ||
| ZFW: 'zero_fuel_weight_kg', | ||
| TOW: 'takeoff_weight_kg', | ||
| LAW: 'landing_weight_kg', | ||
| TOF: 'takeoff_fuel_kg', | ||
| DOW: 'dry_operating_weight_kg', | ||
| MZFW: 'max_zero_fuel_weight_kg', | ||
| MTOW: 'max_takeoff_weight_kg', | ||
| MLAW: 'max_landing_weight_kg', | ||
| }; | ||
| const labelMap: Record<string, string> = { | ||
| ZFW: 'Zero Fuel Weight', | ||
| TOW: 'Takeoff Weight', | ||
| LAW: 'Landing Weight', | ||
| TOF: 'Takeoff Fuel', | ||
| DOW: 'Dry Operating Weight', | ||
| MZFW: 'Max Zero Fuel Weight', | ||
| MTOW: 'Max Takeoff Weight', | ||
| MLAW: 'Max Landing Weight', | ||
| }; | ||
| decodeResult.raw[keyMap[code]] = val; | ||
| decodeResult.formatted.items.push({ | ||
| type: 'weight', | ||
| code, | ||
| label: labelMap[code], | ||
| value: `${val.toLocaleString()} kg`, | ||
| }); | ||
| anyMatched = true; | ||
| continue; | ||
| } | ||
|
|
||
| // Passenger count: "PAX C/Y 6/84" or "PAX/18/204" (no class split) | ||
| m = line.match(/^PAX(?:\s+([A-Z]+)\/([A-Z]+))?\s*\/?\s*(\d+)\/(\d+)(?:\/(\d+))?$/); | ||
| if (m) { | ||
| const classes = m[1] && m[2] ? [m[1], m[2]] : null; | ||
| const counts = [Number(m[3]), Number(m[4])]; | ||
| if (m[5]) counts.push(Number(m[5])); | ||
| const total = counts.reduce((s, n) => s + n, 0); | ||
| decodeResult.raw.passenger_counts = classes | ||
| ? Object.fromEntries(classes.map((c, i) => [c, counts[i]])) | ||
| : counts; | ||
| decodeResult.raw.passenger_total = total; | ||
| const valueStr = classes | ||
| ? classes.map((c, i) => `${counts[i]} ${this.classLabel(c)}`).join(', ') + | ||
| ` (${total} total)` | ||
| : counts.join('/') + ` (${total} total)`; | ||
| decodeResult.formatted.items.push({ | ||
| type: 'passengers', | ||
| code: 'PAX', | ||
| label: 'Passengers', | ||
| value: valueStr, | ||
| }); | ||
| anyMatched = true; | ||
| continue; | ||
| } | ||
|
|
||
| // MAC at TOW: "MACTOW 16.8" | ||
| m = line.match(/^MACTOW\s+([\d.]+)$/); | ||
| if (m) { | ||
| decodeResult.raw.mac_tow_percent = Number(m[1]); | ||
| decodeResult.formatted.items.push({ | ||
| type: 'mac', | ||
| code: 'MACTOW', | ||
| label: 'CG at Takeoff (% MAC)', | ||
| value: `${m[1]}%`, | ||
| }); | ||
| anyMatched = true; | ||
| continue; | ||
| } |
| // Sender routing token: .FRAGDLH — leading dot + alpha station ID | ||
| let m = line.match(/^\.([A-Z0-9]+)$/); | ||
| if (m) { | ||
| decodeResult.raw.sender_token = m[1]; | ||
| // Conventional first 3 chars = originating IATA/station code | ||
| const station = m[1].substring(0, 3); | ||
| decodeResult.raw.sender_station = station; | ||
| decodeResult.formatted.items.push({ | ||
| type: 'sender', | ||
| code: 'SENDER', | ||
| label: 'Sender', | ||
| value: `${m[1]} (station ${station})`, | ||
| }); | ||
| anyMatched = true; | ||
| continue; | ||
| } |
| // Passenger count: "PAX C/Y 6/84" or "PAX/18/204" (no class split) | ||
| m = line.match(/^PAX(?:\s+([A-Z]+)\/([A-Z]+))?\s*\/?\s*(\d+)\/(\d+)(?:\/(\d+))?$/); | ||
| if (m) { | ||
| const classes = m[1] && m[2] ? [m[1], m[2]] : null; | ||
| const counts = [Number(m[3]), Number(m[4])]; | ||
| if (m[5]) counts.push(Number(m[5])); | ||
| const total = counts.reduce((s, n) => s + n, 0); | ||
| decodeResult.raw.passenger_counts = classes | ||
| ? Object.fromEntries(classes.map((c, i) => [c, counts[i]])) | ||
| : counts; | ||
| decodeResult.raw.passenger_total = total; | ||
| const valueStr = classes | ||
| ? classes.map((c, i) => `${counts[i]} ${this.classLabel(c)}`).join(', ') + | ||
| ` (${total} total)` | ||
| : counts.join('/') + ` (${total} total)`; | ||
| decodeResult.formatted.items.push({ | ||
| type: 'passengers', | ||
| code: 'PAX', | ||
| label: 'Passengers', | ||
| value: valueStr, | ||
| }); | ||
| anyMatched = true; | ||
| continue; |
| // DTG: DDHHMM — 6 digits: day + hour + minute UTC | ||
| m = line.match(/^(\d{2})(\d{2})(\d{2})$/); | ||
| if (m) { | ||
| const [_, dd, hh, mm] = m; | ||
| decodeResult.raw.day = Number(dd); | ||
| decodeResult.raw.message_time_utc = `${hh}:${mm}`; | ||
| decodeResult.formatted.items.push({ | ||
| type: 'timestamp', | ||
| code: 'DTG', | ||
| label: 'Date/Time (UTC)', | ||
| value: `Day ${dd} @ ${hh}:${mm}Z`, | ||
| }); | ||
| anyMatched = true; | ||
| continue; | ||
| } |
Adds a decoder for label C1 electronic loadsheet uplinks.
npm run buildpasses.