Codec Development Overview
Codec development is the path you take when a device speaks a protocol variant that none of the shipped codecs can handle yet. This page captures the design rules, folder layout, and validation steps required to add a new codec safely, whether you are extending an existing TULIP generation or preparing for the next one.
When You Need a Codec
Build or extend a codec when any of the following is true:
- The device uses a TULIP generation that is not implemented (
TULIP4
,TULIP5
, …) or a vendor-specific dialect. - Message types, alarm semantics, or register layouts diverge from the assumptions in the shared codec.
- You need new downlink encoders that are currently unavailable.
- Two devices would otherwise fork into wildly different handler logic within the same codec.
If the device already fits an existing codec, skip this page and follow the Parser Development Workflow instead.
Prerequisites
- A full protocol specification (message tables, value scaling, alarm flag meanings).
- Example payload captures that cover each message type, including error paths.
- Agreement on the target directory (
packages/parsers/src/codecs/<family>/
). - Updated TypeScript types or schema definitions when the output payload changes.
Project Layout
Each codec family lives under packages/parsers/src/codecs/
:
tulip2/
: Channel-based handlers with manual message routing.tulip3/
: Device profile factories plus message/encoding helpers.- Future generations (for example
tulip4/
) should mirror this structure to keep adoption predictable.
Inside the family folder place:
codec.ts
: Exports thedefineTULIPxCodec
factory that returns aCodec
object.messages/
: Shared message parsers or encoders.profile.ts
(TULIP3+): Helpers for building device profiles.__tests__/
: Unit tests focused on the codec logic.
Type Generics and Inference Guarantees
The public NPM packages rely heavily on TypeScript generics to surface accurate input/output shapes to downstream consumers. When you author a codec or parser, keep the following contracts in mind, misusing them typically results in any
leaking into the published types.
The Codec
type
packages/parsers/src/codecs/codec.ts
defines the shared Codec<TCodecName, TData, TChannelName, TEncoder>
type. Each type parameter has a specific purpose:
TCodecName extends string
: Literal identifier exposed to consumers. Downstream code uses this in discriminated unions (for example, when selecting a codec forencodeDownlink
). Always build the name with template literals (
${deviceName}TULIP3Codec
) so it stays literal.TData extends GenericUplinkOutput
: Exact result type ofdecode
. In TULIP2 this becomes the union of all handler return types; in TULIP3 it isTULIP3UplinkOutput<TDeviceProfile>
so channel metadata matches the profile.TChannelName extends string
: Compile-time list of valid channel names.defineParser
uses this to gateadjustMeasuringRange
, preventing typos at call sites.TEncoder
: Optional encoder signature. When provided, the resulting codec surface includes a strongly typedencode
function; otherwise the property is omitted entirely. If your codec cannot encode, leave this parameter asundefined
.
defineTULIP2Codec
The TULIP2 factory threads generics through options:
TChannels extends TULIP2Channel[]
: Provide channel arrays asconst
so literal names and IDs survive inference.THandlers extends MessageHandlers<TChannels>
: Each handler can return a different payload type. The utility typeReturnTypeOfHandlers<TChannels, THandlers>
builds a union sodecode
returns an exact discriminated shape.TEncoder extends ((input: object) => number[]) | undefined
: Supply a narrow encoder signature per codec (for exampleencodeHandler: (input: MyDownlink) => number[]
). That flows into both the codec type and parserencodeDownlink
helper.
When you implement defineTULIP2Codec
:
- Declare channels inline and cast with
as const
to keep literal property names. - Return specific handler payload types rather than
GenericUplinkOutput
; this improves editor autocomplete for consumers. - Avoid reusing channel arrays between codecs. Besides the runtime mutation risk, doing so often widens the inferred tuple type to
TULIP2Channel[]
, losing literal channel names.
defineTULIP3Codec
TULIP3 codecs depend on device profiles for their generics:
const TDeviceProfile extends TULIP3DeviceProfile
: Keep the profile factory (defineTULIP3DeviceProfile
) typedas const
; this preserves literal channel names, alarm flags, and message size limits.ChannelNames<TDeviceProfile['sensorChannelConfig']>
: A helper mapped type that extracts channel names from the profile soadjustMeasuringRange
anddefineParser
stay type-safe even when sensors are nested.TULIP3UplinkOutput<TDeviceProfile>
: Ties every decoded message back to the originating profile, ensuring the outputdata
object has precise key types.
Because the current implementation still relies on a default generic in the profile, you will see // @ts-expect-error
annotations inside decode
. These exist to keep the emitted JavaScript lean until we can simplify the type algebra. Do not remove them unless you are also addressing the underlying inference issue.
defineParser
packages/parsers/src/parser.ts
binds everything together with additional generics:
ParserOptions<TCodec extends AnyCodec>
: Passing your codec array lets the parser infer:decodeUplink
/decodeHexUplink
return type: union of all codecdecode
outputs.adjustMeasuringRange(channelName)
signature: union ofTChannelName
values from every codec.encodeDownlink
input: discriminated union keyed bycodec
name, where theinput
shape matches the encoder’s first argument.
To keep inference intact, always instantiate codecs within the same module where you call defineParser
, and avoid post-instantiation mutation of the codec array.
Practical Tips
- Prefer
as const
when declaring channels, device profiles, and handler maps. Literal inference keeps template literal types intact. - Export factory helpers (
createTULIP3<DEVICE>Codec
) instead of raw codec instances; this ensures fresh generic instantiation each time. - If you introduce new message helpers, type their return values explicitly and feed them back into the codec handler signature, this prevents the compiler from falling back to
GenericUplinkOutput
. - When you must broaden a type (for example to satisfy shared utilities), do it as late as possible to preserve inference for consumers.
- Document any
@ts-expect-error
usage directly above the line so future refactors know which generic limitation you are working around.
The generics are intentionally strict today to guarantee type safety in the published packages. We plan to simplify their ergonomics, but until then treat these patterns as part of the public API contract.
Building a TULIP3 Codec
Define the device profile factory
- Start from an existing profile under
packages/parsers/src/devices/<Device>/parser/tulip3/
. - Describe sensor channels, rounding defaults, identification/configuration register limits, and alarm flag selections.
- Return a fresh object (
defineTULIP3DeviceProfile({...})
) to prevent shared mutable state.
- Start from an existing profile under
Create the codec factory
- Import
defineTULIP3Codec
frompackages/parsers/src/codecs/tulip3/codec
. - Call it with the device profile and export a typed helper (for example
createTULIP3PEWCodec
). - Ensure channel names and ranges match the datasheet;
checkChannelsValidity
will enforce this at runtime.
- Import
Implement message handlers
- Reuse helpers in
packages/parsers/src/codecs/tulip3/messages/
for decoding alarms, registers, and spontaneous messages. - Add new helpers when the protocol introduces message types outside the existing enums.
- Keep return shapes aligned with the Valibot schemas (
packages/parsers/src/schemas
).
- Reuse helpers in
Add downlink encoders when needed
- Extend the codec return type with an
encode
function. - Protect the encoder with validation so parser callers receive actionable errors.
- Extend the codec return type with an
Write fixtures and tests
- Place sample payloads under
packages/parsers/__tests__/fixtures/
. - Add Vitest suites that exercise each message type and encoder.
- Assert warnings and error cases, not just happy paths.
- Place sample payloads under
Building a TULIP2 Codec
Describe the channels
- Define the
channels
array withchannelId
,name
, and measuring range. - Use helpers like
roundValue
andTULIPValueToValue
to keep scaling consistent.
- Define the
Provide handler maps
- Implement a
handlers
object keyed by message type byte (0x01
,0x02
, …). - Keep handlers small and delegate lookups or conversions to utilities in
packages/parsers/src/utils
.
- Implement a
Handle encoder support (optional)
- Supply an
encodeHandler
when the device needs downlink support. - Validate inputs aggressively; invalid frames should throw informative errors.
- Supply an
Test thoroughly
- Add focused tests around each handler to guarantee regression coverage.
- Include edge cases such as truncated frames or out-of-range channel data.
Shared Validation & Tooling
- Run
pnpm test
to execute codec unit tests. - Use
pnpm build
to confirm the codec compiles inside the bundle. - Regenerate JSON schemas with
pnpm schema
whenever the decoded payload shape changes. - Double-check the
checkCodecsValidity
errors: they guarantee that channel ranges, channel names, and codec names remain in sync across a device parser.
Handing Off to Device Parsers
Once the codec is stable:
- Export the factory from the codec’s
index.ts
. - Wire it into the target device parser (see the PEW example in Parser Development Workflow).
- Update device-specific documentation under
docs/devices/
so downstream users know which protocol versions are available. - Mention any new configuration hooks or encoder capabilities in the parser guide.
Related Reading
- Parser Development Workflow: Wiring codecs into device parsers.
- Architecture: How codecs plug into the parser abstraction.
- Schemas: Maintaining the Valibot schema definitions alongside codec updates.
- Testing: Expectations for Vitest coverage and regression protection.