Legacy TULIP2 Guide
This page explains the historical parser variants, how the JavaScript implementations were structured, and the recommended path to migrate them onto the modern TypeScript-based TULIP2 codec stack.
Legacy variants at a glance
Generation | Version label | Example device(s) | Notes |
---|---|---|---|
Legacy TULIP2 (JavaScript) | 2.x.x | F98W6 | Handwritten JavaScript formatters published with firmware 2.x.x |
Transitional TypeScript port | 2.x.x | GD20W | First attempt at a literal TypeScript port; uses types but keeps legacy control flow. |
Legacy TULIP2 (JavaScript) | 3.x.x | NETRIS2 | Single device family using the evolved 3.x message map. Structure mirrors 2.x.x but with additional message codes and metadata. |
All three variants share the same conceptual protocol: the first byte selects a message type and subsequent bytes contain payload-specific fields. The differences are in code organisation, naming, and how much metadata is captured in lookups.
Anatomy of the legacy JavaScript parsers (2.x.x and 3.x.x)
The classical 2.x.x formatter (packages/parsers/src/devices/F98W6/index.js
) illustrates the patterns you will find across other devices:
- Global measurement ranges:
At the top of the file the developer must setFORCE_RANGE_START
,DEVICE_TEMPERATURE_RANGE_END
, etc. The decoder assumes these globals are configured beforedecodeUplink
runs. - Single entry point:
decodeUplink(input)
dispatches oninput.bytes[0]
with a giantswitch
orif
chain. Companion helpers (decodeHexString
,decodeBase64String
) normalise the payload before forwarding todecodeUplink
. - Lookup dictionaries:
Arrays or objects likeCHANNEL_NAMES_DICTIONARY
,ALARM_EVENT_NAMES_DICTIONARY
,DEVICE_ALARMS_DICTIONARY
convert numeric codes into human readable text. These are loosely typed and duplicated across devices. - Imperative parsing:
Each message type manually slices byte ranges, applies scaling factors, and assembles the response object. Errors/warnings are pushed into arrays instead of thrown.
The 3.x.x format was a first approach to make the parsers modular. It tried to capture reusable logic in shared utilities. Handlers were not yet a simple input parameter so the structure still mirrors the 2.x.x style while having basic modular utilities. The measurement ranges were moved into the input parameters to avoid globals.
Transitional TypeScript port (GD20W
)
packages/parsers/src/devices/GD20W/index.ts
shows the first attempt at bringing a legacy parser into TypeScript without yet adopting the modern codec API. Key observations:
- The file translates the legacy dictionaries into literal TypeScript constants (
MEASURAND_DICTIONARY
,PROCESS_ALARM_TYPE_NAMES_DICTIONARY
, etc.) and declares verbose TypeScript interfaces for each message type. - Control flow still mirrors the JavaScript formatter: it switches on message IDs inside a single decoding function.
- Strong typing helps describe the payloads but does not leverage shared helpers like
checkChannelsValidity
or the schema tooling. It is still effectively a 1:1 port of the legacy logic.
While this approach keeps behaviour identical, it inherits the drawbacks of mutable globals, duplicated lookups, and difficulty composing codecs.
Migration path to the modern TULIP2 codec
Use the migrated PEW
parser as the reference implementation (packages/parsers/src/devices/PEW/parser/tulip2
). The high-level plan is:
- Inventory the legacy behaviour:
- List message types (first byte values) and the payload shapes they produce.
- Capture scaling formulas, error sentinels (
0xFFFF
), and warning conditions. - Record measurement ranges and channel naming conventions.
- Create typed channel definitions:
- Translate global range constants into an
as const
array ofTULIP2Channel
objects. - Assign stable
channelId
values that match the legacy payload positions.
- Translate global range constants into an
- Port message handlers:
- For each message code (0x00–0x09) author a
Handler
function. Reuse helper utilities (roundValue
,TULIPValueToValue
) where available or extract them from legacy code. - Keep warnings and error checks explicit; return them in the
GenericUplinkOutput
structure.
- For each message code (0x00–0x09) author a
- Assemble the codec:
- Call
defineTULIP2Codec({ deviceName, channels, handlers, encodeHandler? })
in a factory (e.g.createTULIP2DeviceCodec
). It is important that a factory function is used to return fresh channel arrays on each invocation. This way no state leaks between parser instances are possible. - Export a device parser that combines the TULIP2 codec with any TULIP3 codec via
defineParser()
.
- Call
- Add fixtures and tests:
- Move or recreate the legacy example payloads into
examples.json
. - Write or update Vitest suites (see
packages/parsers/src/devices/PEW/driver-examples.test.ts
) to assert decoded payloads and schema validation.
- Move or recreate the legacy example payloads into
- Align schemas:
- Update the device’s Valibot factories to reflect the migrated output.
- Run
pnpm schema
to regenerateuplink.schema.json
(anddownlink.schema.json
if you added an encoder).
- Verify end-to-end:
- Run
pnpm test
, to confirm typechecks pass and runpnpm lint
orpnpm lint:fix
to ensure code style compliance. - Compare outputs against the legacy formatter to ensure regressions were not introduced.
- Run
The PEW migration demonstrates these steps cleanly: it extracted channel definitions into createTULIP2PEWChannels()
, moved each legacy decode branch into a typed handler, and wired the codec through the shared parser factory.
Migration considerations by legacy variant
- 2.x.x JavaScript formatters:
Focus on replacing globals with typed channel definitions and lifting lookup dictionaries into constant maps. Most of the logic slots directly intoHandler
functions. - 3.x.x (
NETRIS2
) Has a different philosophy around how the parser is handled. Though the functions interpreting the messages can be similarly migrated toHandler
functions, the overall structure may need to adapt to the new codec design. - Transitional TypeScript (
GD20W
):
Although typed, it still needs to be refactored into discrete handlers. Use the existing TypeScript types to seed Valibot schemas and unit tests during the migration.
Pitfalls and tips
- Channel range mutations:
The modern codecs mutate channel objects whenadjustMeasuringRange()
runs. Always return fresh channel arrays from codec factories to avoid cross-parser pollution. - Error sentinels:
Legacy formats often use0xFFFF
or0x7FFF
to mark invalid data. Ensure handlers detect these values and surface warnings so downstream systems behave the same. - Downlink parity:
Most legacy parsers never implemented downlink encoding. Only add anencodeHandler
when the device spec defines a stable command set; otherwise leave it undefined. - Schema drift:
The moment you change output structure, update Valibot factories and regenerate JSON schemas to keep published artifacts in sync. - Testing depth:
Legacy code relied on manual QA. The migration is an opportunity to codify fixtures and edge cases so future refactors are safer.