A parser for wireless M-Bus telegrams: it turns the bytes a receiver hands you into the values a meter sent - each with its unit, description, storage number and tariff - along with the meter which sent them and, on request, every layer the telegram is built from.
It follows EN 13757 and OMS as far as the meters in the field need it. What is missing is either not relevant anymore or has not been necessary yet - most of all the large number of VIFs the OMS standard introduced. "Wired" M-Bus telegrams are supported to a limited extent, a few proprietary protocols partially.
A legacy result format which mostly matches the output of the parser that used to be part of iobroker.wireless-mbus is available as well.
- every layer of a telegram: link layer, extended link layer, authentication and fragmentation layer and application layer
- encryption modes 5 and 7, the encryption of the extended link layer and the MAC of the authentication and fragmentation layer
- automatic CRC detection - receivers differ in whether they strip it
- compact frames, with a cache of data record headers which can be kept across restarts
- manufacturer specific data records: a handler per manufacturer turns them into named values, and can be described instead of written
- proprietary telegrams: Diehl PRIOS, Techem heat, water and HCA meters (partially) and Itron smoke detectors
- ESM and CommonJS with TypeScript types, one dependency
npm install wireless-mbus-parser
# or
pnpm add wireless-mbus-parserNode 22 or newer is required. The package ships as ESM and CommonJS with TypeScript types.
import { WirelessMbusParser } from "wireless-mbus-parser";
const data =
"2E44931578563412330333637A2A0020255923C95AAA26D1B2E7493BC2AD013EC4A6F6D3529B520EDFF0EA6DEFC955B29D6D69EBF3EC8A";
const key = "0102030405060708090A0B0C0D0E0F11";
const parser = new WirelessMbusParser();
const result = await parser.parse(Buffer.from(data, "hex"), {
key: Buffer.from(key, "hex"),
});
const fullResult = await parser.parse(Buffer.from(data, "hex"), {
verbose: true,
containsCrc: undefined,
key: Buffer.from(key, "hex"),
});
const legacyResult = WirelessMbusParser.toLegacyResult(fullResult);Notes:
If containsCrc is undefined, the parser tries to guess whether
the data contains CRC or not. Trailing data is ignored. Only a frame
without CRC which happens to carry exactly as many trailing bytes as
its CRCs would occupy is mistaken for a frame with CRC - in that case
the CRC check fails and containsCrc has to be set explicitly.
The legacy result can only be generated from the "verbose" result.
Everything which prevents a telegram from being parsed is thrown as a
ParserError. Its name is one of:
| Name | Meaning |
|---|---|
CRC_ERROR |
CRC check failed or the telegram is too short |
NO_AES_KEY |
the telegram is encrypted, but no key was provided |
WRONG_AES_KEY |
decryption or the MAC check failed |
DECRYPTION_ERROR |
decryption could not be performed |
DATA_RECORD_CACHE_MISSING |
compact frame without the matching data record headers |
UNIMPLEMENTED_FEATURE |
valid, but unsupported telegram content |
UNEXPECTED_STATE |
the telegram does not match the expected structure |
import { ParserError } from "wireless-mbus-parser";
try {
await parser.parse(data, { key });
} catch (error) {
if (error instanceof ParserError && error.name === "NO_AES_KEY") {
// ...
}
}Telegram data is arbitrary radio data, so anything unexpected which is
not caught while parsing is wrapped in a ParserError as well - the
original error is available as its cause.
Every value carries a stable name in info.name, which identifies the quantity
it is regardless of its unit: an energy is energy whether the meter reports
it in Wh or in GJ, a flow temperature is flow_temperature in °C and in °F.
info.extensionNames lists the VIF extensions which change what the value
means, in the order they were sent - e.g. ["negative"] for an energy which
only accumulates negative contributions. Extensions which only scale the value
or report an error have no name.
A meter can mark a value as broken with a record error, e.g. "data error" or
"no data available". The value then keeps its name - it is still the same
quantity - and info.recordError states the error, e.g. data_error; it is
not set for a sound value. A consumer should not take such a value for a real
reading: the ioBroker adapter, for example, can skip it or set the quality of
the state.
const { name, extensionNames, storageNo, tariff, functionField } = value.info;
const id = [name, ...extensionNames].join("_"); // e.g. "energy_negative"The names are meant to be used as identifiers, so they are part of the API: a name only changes with a new major version. They follow these rules, which a test checks for every entry of the VIF tables:
- snake_case of
a-z,0-9and_, a digit always follows an underscore (storage_1, neverstorage1) - no unit
- no prefix
max_,min_orerror_state_and no suffix_u<N>or_t<N>- function field, subunit and tariff are reported separately (functionField,deviceUnit,tariff)
A VIF or extension the parser does not know is named after its code, e.g.
unknown_vif_fd_25 or manufacturer_specific_vife_7, so that two of them stay
apart. Such a name changes when the parser learns the code.
Compact frames (CI 0x79) contain values without the data record headers which describe them. The headers have to be taken from a previously received full telegram of the same meter, which the parser keeps in a cache. Both frames reference the headers by a CRC over them, so the parser knows which cache entry belongs to a compact frame.
The cache is only filled for meters which actually send compact frames:
if a compact frame cannot be decoded, a ParserError with the name
DATA_RECORD_CACHE_MISSING is thrown and the next full telegram of that
meter populates the cache. Therefore at least one compact frame is lost
whenever a parser is created without a cache.
To keep the cache across restarts, read it from the parser and pass it to the constructor later on:
import { WirelessMbusParser } from "wireless-mbus-parser";
const parser = new WirelessMbusParser({
cachedDataRecordHeaders: JSON.parse(storedCache),
});
// ... parse telegrams ...
storedCache = JSON.stringify(parser.cache);A single entry can also be created from a "verbose" result, e.g. to fill the cache without waiting for a compact frame to be lost:
const entry = WirelessMbusParser.getDataRecordHeadersCacheEntry(fullResult);
const parser = new WirelessMbusParser({ cachedDataRecordHeaders: [entry] });Meters put data the standard does not describe into manufacturer specific data records, often several values packed into a few bytes. Such a record is always kept as it is - and its content is decoded additionally, if a handler for the manufacturer exists.
A handler is called with the raw bytes of the record and returns one entry per value it extracts. Writing one needs no knowledge about telegram structures:
function decodeAcmeData(data: Buffer): ManufacturerSpecificValue[] {
return [
{ description: "Backflow detected", value: data[0] & 0b1 },
{ description: "Battery", value: data[1], unit: "%" },
];
}Only description and value are required. unit, legacyName, storageNo
and tariff are optional: storage number and tariff are taken from the record
the data came from, as is its function field, so a value of a "maximum value"
record is described as one as well.
The legacy result carries the legacyName of a value as its type, which
consumers use as an identifier - the ioBroker adapter builds the id of its
objects from it. A value which states none is named after its description:
"Warning: smoke alarm" becomes VIF_WARNING_SMOKE_ALARM, accents are folded
and everything else that is not a letter or a digit becomes an underscore.
The description of a handler is therefore part of what the legacy result
promises: rewording it renames the objects of everyone who receives that meter.
Use legacyName for a name which should not follow the wording - or for one
the description does not make a good identifier of.
The same goes for the name of a value: name states it, otherwise it
is derived from the legacy name - VIF_WARNING_SMOKE_ALARM becomes
warning_smoke_alarm. A derived name is not checked against the naming rules,
so a handler whose description starts with "Max", for example, should state
one.
Handlers are registered per manufacturer, either when the parser is created:
const parser = new WirelessMbusParser({
manufacturerSpecificHandlers: { ACM: decodeAcmeData },
});or in src/manufacturerSpecificData/handler.ts, which is the place for
handlers to be shipped with the parser:
export const manufacturerSpecificHandlers = {
ACM: decodeAcmeData,
};A handler of the configuration takes precedence over the one of the parser for the same manufacturer, so a meter can be decoded differently without changing the parser itself.
There is one handler per manufacturer and not one per device, because manufacturers do not agree on how their devices are told apart: the version field is the device version for Techem, but part of the identification number for Itron. A handler decides for itself which meters it can decode, everything it does not recognize yields no values:
function decodeAcmeData(data: Buffer, meterData: MeterData) {
return meterData.type === 0x1b ? decodeWaterMeter(data) : [];
}The decoded values are appended to the data of the result, after the records of the telegram itself, so adding a handler does not move them. The data records are not touched at all - they describe the telegram. A handler which throws only costs its own values.
Most blobs are a fixed sequence of numbers and flags, which can be described
instead of decoded. createManufacturerSpecificHandler() turns such a
description into a handler. The description is plain data, so it can come from
a configuration file and does not have to be code at all:
import { createManufacturerSpecificHandler } from "wireless-mbus-parser";
const decodeAcmeData = createManufacturerSpecificHandler([
{ byte: 0, bit: 0, description: "Backflow detected" },
{ byte: 1, description: "Battery", unit: "%" },
{ byte: 2, bytes: 3, description: "Volume", unit: "l" },
{ byte: 5, flags: ["Leakage", "Burst", null, "Removal"] },
]);A field starts at byte and is bytes wide - one byte by default, at most
six, read little endian. The whole field is reported unless bit picks a
single bit or bits an inclusive range of them, which may span the bytes of
the field: { byte: 11, bytes: 2, bits: [7, 11] } are the five bits starting
at bit 7. flags names one bit each and yields one value per name, the
reserved ones are named null and are not reported. values names the
possible values of a field: a list names the values 0, 1, 2 and so on, an
object only the ones which have a name ({ 4: "Heat", 13: "Cooling" }), and a
value without a name stays the number it is. unit, legacyName, name,
storageNo and tariff are the same as for a handler written by hand - name
is checked against the naming rules and cannot be given to a group of flags. A flag is named after the name of its bit, so a
flag whose legacy name should not follow that name is described as a bit
field with a legacyName of its own.
Several kinds of blob are described as a list of layouts. The first layout whose conditions the blob meets decodes it, one without conditions matches everything:
const decodeAcmeData = createManufacturerSpecificHandler([
{ deviceType: 0x07, fields: [...] },
{ deviceType: [0x04, 0x0c], vif: 0x11, fields: [...] },
{ length: 4, index: 1, fields: [...] },
]);A layout applies to the device types of deviceType, to records with the
primary VIF vif, to blobs of length bytes and to the blob at index among
the manufacturer specific records of the telegram, counted from 0. Meters which
send several blobs of the same size and VIF - Itron does - can only be told
apart by that order.
A blob which is too short for all the fields of a layout does not match it and falls through to the next one; a blob no layout matches yields no values. A description which is not sound - a bit outside of its field, a condition which is not a number - throws where it is created, because that is a mistake of its author and not of a telegram.
Everything which is not a fixed layout of numbers and flags still needs code: a
checksum, a field whose meaning depends on another one or a date. The Itron
smoke detector shipped with the parser is described declaratively,
src/manufacturerSpecificData/itron.ts is a complete example.
The descriptions shipped with the parser are available as data as well. A configured handler replaces the internal one scoped by manufacturer. The built-in description can be used as a starting point or extended if something is missing:
import {
WirelessMbusParser,
createManufacturerSpecificHandler,
getManufacturerSpecificDescriptions,
} from "wireless-mbus-parser";
const { ITW } = getManufacturerSpecificDescriptions();
// e.g. decode a further device type with the same layout
ITW[0].deviceType = [0x1a, 0x1b];
const parser = new WirelessMbusParser({
manufacturerSpecificHandlers: { ITW: createManufacturerSpecificHandler(ITW) },
});The result is a copy, which survives JSON - it can be written to a configuration file as it is.
- TCH smoke detector?
- Every value carries a stable name in
info.name. The names are meant as identifiers and are part of the API from now on - see Names. info.extensionNameslists the VIF extensions which change what a value means, e.g.negative- A manufacturer specific value and a field of a declarative handler can state
a
nameof their own - A value the meter marks as broken states the error in
info.recordError - expose the DIB function field as
info.functionField - The description of the record error "data overflow" was "undefined"
getManufacturerSpecificDescriptions()returns the descriptions of the built-in manufacturer specific handlers
- Manufacturer specific handlers can be passed to the parser as
manufacturerSpecificHandlersof the configuration, so they no longer have to be part of it - Such a handler can be described instead of written:
createManufacturerSpecificHandler()builds one from a list of bytes, bits and named flags -- plain data, which can come from a configuration file. One description can hold several layouts, told apart by device type, VIF, the length of the blob and its position among the manufacturer specific records of the telegram, which is passed to every handler now. - Values of a manufacturer specific handler are named after their description
in the legacy result:
VIF_WARNING_SMOKE_ALARMinstead ofVIF_MANUFACTURER_SPECIFICfor every one of them. The Itron descriptions are shorter and say which part of the meter reports a flag.
- Decode manufacturer specific data records: a handler per manufacturer turns
the raw bytes of such a record into named values, which are appended to the
data of the result. The data records themselves are not touched, so
datacan contain more entries thandataRecordsnow. - Decode the configuration and error codes of the Itron smoke detector
Both Techem fixes change the dates a telegram decodes to.
- Techem: the year of the current date is taken from the last period date of the same telegram instead of the wall clock, so a telegram no longer decodes differently depending on when it is parsed. Only a telegram without a usable last period date still falls back to the current year.
- Techem heat meters: fix the day of the current date, which is a 16 bit field and was read as a single byte -- the day could only ever be 0 or 1, and a 0 silently turned into the last day of the previous month
- Use the object returned by applying a VIFE: every descriptor of the shipped tables modifies the evaluated data in place, so one which returns a new object instead was silently doing nothing
- VIFs which only differ in their power of ten are generated from a range
instead of being written out, and all VIF tables are covered by a snapshot
listing every entry. The three range entries which scaled by
multiply(x, 1)now use the identity like the rest, so they no longer throw for values which are not numbers.
Breaking changes:
- Require node 22 -- node 20 reached its end of life in April 2026
EvaluatedData.typenow describes the value which is actually returned: scaling a 64 bit value yields aNumberand is no longer reported asBigInt-- in the legacy result such a value changes from string to number- Malformed telegrams always throw a
ParserError-- reading beyond the end of a telegram surfaced as aRangeErrorbefore, and the manufacturer specific decoders threw plainErrors
Fixes:
- Fix Techem and PRIOS telegrams: the first data record was skipped, which shifted all decoded values
- Fix truncation of the current period energy of TCH heat meters
- CRC auto detection: ignore trailing data instead of failing
- Fix the ELL encryption flag, which was reported as a negative number if its most significant bit was set
- Checking the AFL MAC without the required AFL fields now throws a
ParserErrorinstead of aTypeError - Fix the invalid date warning for type F date/times, which never triggered
- Fix the error message for unknown data record header cache versions
Other changes:
ParserErroris exported as a class, so errors can be checked withinstanceofinstead of comparingname- Document error handling and compact frames
- Ship unminified code with source maps
- Mark the package as side effect free
- Enable the "strict" tsc option
- Update dependencies: eslint 10, vitest 5 and pnpm 11
- Provide access to data record header cache
- Data record header cache can be populated when parser object is constructed
- Enable "erasableSyntaxOnly" tsc option -- output of "type" (EvaluatedDataType) and "table" (VifTable) changes from numeric to readable string
- Do not throw on DIF_SPECIAL_FUNCTIONS
- Fix duplicate VIF in 0xFB table (thanks @mathis92)
- First release