Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

M-Bus telegrams

The telegram decoder of the M-Bus module: it finds the frames of EN 13757-2 in a stream of bytes, builds the requests of a master, and decodes the variable data structure of EN 13757-3 (the answer of a meter) to json. C_PROT_MBUS_M is built on it. A gate that reads M-Bus some other way, or a tool that decodes captured telegrams, can use it directly.

Source code:

Nothing here logs, and nothing keeps state. A function that fails returns -1, 0 or NULL, and writes the reason into the why buffer it is given. The caller logs that reason, because only the caller knows which peer sent the bytes. A meter that answers garbage is a warning about that meter, not an error of the decoder.

How it was checked. The decoder was compared, record by record, with libmbus’s decoding of the 78 real telegrams in libmbus’s test collection. 893 of the 909 records are equal. The other 16 differ on purpose:

The module’s test (tests/c/mbus_telegram) keeps six of those telegrams.


mbus_parse_frame()

mbus_parse_frame() finds the frame that starts at bf[0]: an E5, a short frame (10 C A CS 16), a control frame or a long frame (68 L L 68 C A CI data CS 16).

int mbus_parse_frame(
    const uint8_t *bf,
    size_t len,
    mbus_frame_t *frame,
    char *why,
    size_t why_size
);

Parameters

KeyTypeDescription
bf, lenconst uint8_t *, size_tThe bytes received so far.
framembus_frame_t *Filled when a frame is found: type, c, a, ci, and data/data_len, the bytes after CI. data points into bf.
why, why_sizechar *, size_tWhere the reason of a refusal is written.

Returns

Bytes after the frame are not looked at: the caller decides whether they are the next frame or noise.

Example

mbus_frame_t frame;
char why[256];
int r = mbus_parse_frame(priv->rx, priv->rx_len, &frame, why, sizeof(why));
if(r == 0) {
    return 0;   // wait for more bytes
}
if(r < 0) {
    gobj_log_warning(gobj, 0,
        "function",     "%s", __FUNCTION__,
        "msgset",       "%s", MSGSET_PROTOCOL,
        "msg",          "%s", "M-Bus bad frame",
        "why",          "%s", why,
        NULL
    );
    priv->rx_len = 0;
    return -1;
}
// frame.type is MBUS_FRAME_ACK, _SHORT, _CONTROL or _LONG

mbus_build_short_frame()

mbus_build_short_frame() builds a short frame, the shape of every request of a master that carries no data: SND_NKE, REQ_UD2.

size_t mbus_build_short_frame(uint8_t *bf, size_t size, uint8_t c, uint8_t a);

Parameters

KeyTypeDescription
bf, sizeuint8_t *, size_tWhere the frame is written.
cuint8_tThe control field: MBUS_C_SND_NKE, or MBUS_C_REQ_UD2 with or without MBUS_C_FCB.
auint8_tThe address: 1-250, or MBUS_ADDRESS_SELECTED (FD) for the slave selected by its secondary address.

Returns

5, the length written, or 0 when size is less than that.

Example

uint8_t bf[MBUS_FRAME_MAX_LENGTH];
size_t n = mbus_build_short_frame(bf, sizeof(bf), MBUS_C_REQ_UD2|MBUS_C_FCB, 1);
// 10 7B 01 7C 16

mbus_build_long_frame()

mbus_build_long_frame() builds a long frame: the SND_UD of a master (a selection, a write), or the RSP_UD of a meter in a test.

size_t mbus_build_long_frame(
    uint8_t *bf,
    size_t size,
    uint8_t c,
    uint8_t a,
    uint8_t ci,
    const uint8_t *data,
    size_t data_len
);

Parameters

KeyTypeDescription
bf, sizeuint8_t *, size_tWhere the frame is written: data_len + 9 bytes.
c, a, ciuint8_tControl, address and control information fields.
data, data_lenconst uint8_t *, size_tThe bytes after CI, 252 at most.

Returns

The length written, or 0 when data_len is more than a frame carries or size is too small.

Example

uint8_t sel[MBUS_SECONDARY_ADDRESS_SIZE];
mbus_build_secondary_address(sel, "12345678", "KAM", -1, -1, why, sizeof(why));
size_t n = mbus_build_long_frame(bf, sizeof(bf),
    MBUS_C_SND_UD, MBUS_ADDRESS_SELECTED, MBUS_CI_SELECT_SECONDARY,
    sel, sizeof(sel)
);
// 68 0B 0B 68 53 FD 52 78 56 34 12 2D 2C FF FF 0D 16

mbus_build_secondary_address()

mbus_build_secondary_address() encodes the 8 bytes that select a meter by its secondary address: identification (4 BCD bytes, least significant first), manufacturer, version, medium.

int mbus_build_secondary_address(
    uint8_t out[MBUS_SECONDARY_ADDRESS_SIZE],
    const char *id,
    const char *manufacturer,
    int version,
    int medium,
    char *why,
    size_t why_size
);

Parameters

KeyTypeDescription
outuint8_t[8]Where the selection is written.
idconst char *8 digits. An F in a digit is a wildcard ("1234FFFF"), as a search of the bus uses them.
manufacturerconst char *3 letters ("KAM"), or NULL/"" for any.
versionint0-255, or -1 for any.
mediumint0-255, or -1 for any. mbus_medium_code() gives it from a name.
why, why_sizechar *, size_tThe reason of a refusal.

Returns

0, or -1 when a field is not valid.

Example

uint8_t sel[MBUS_SECONDARY_ADDRESS_SIZE];
char why[256];
if(mbus_build_secondary_address(sel, "1234ffff", NULL, -1, mbus_medium_code("water"),
        why, sizeof(why)) < 0) {
    // why: "secondary address id must be digits or F: '...'"
}
// FF FF 34 12 FF FF FF 07

mbus_decode_user_data()

mbus_decode_user_data() decodes the user data of a meter’s long frame (RSP_UD) to json.

json_t *mbus_decode_user_data(
    const mbus_frame_t *frame,
    BOOL *more_follows,
    char *why,
    size_t why_size
);

Parameters

KeyTypeDescription
frameconst mbus_frame_t *A long frame found by mbus_parse_frame().
more_followsBOOL *Set when the telegram ends with DIF 1F: the meter has more records, asked for with the FCB toggled. May be NULL.
why, why_sizechar *, size_tThe reason of a refusal.

Returns

The json, yours to decref, or NULL with the reason in why.

CIDecoded to
72header (12 bytes: id, manufacturer, version, medium, medium_code, access_no, status) and records
7Aheader (access_no, status) and records
78records only
70{"application_error": <code>}
71{"alarm_status": "<hex>"}

It refuses the fixed data structure (CI 73/77), the other CIs, records encrypted (security modes 5, 7, 8, 9, 10 and 13 of the configuration field), and a record that does not fit in the telegram.

The id of a record says what the record is, and stays the same from one reading to the next:

<quantity>[:max|:min|:error][@s<n>][@t<n>][@u<n>][~<vife>...][#<n>]
PartFromExample
quantitythe VIF (and a VIFE that changes it)volume, energy, flow_temperature, volume_neg
:max, :min, :errorthe function of the DIF (instantaneous has none)power:max
@s<n>the storage number, when not 0volume@s1, often the value at the due date
@t<n>, @u<n>the tariff, the subunit, when not 0energy@t2, volume@u1
~<vife>a VIFE not interpreted, in hexvolume~22, flow_temperature:max@t1~6f (the date of the maximum)
#<n>the n-th record with the same id in the readingpower#2

Values come in one unit per quantity, already scaled:

QuantityUnitAlso from
energyWhJ, MWh, GJ
powerWJ/h, MW, GJ/h
volumem3
volume_flowm3/hm3/min, m3/s
mass, mass_flowkg, kg/ht
flow_temperature, return_temperature, external_temperatureC°F
temperature_differenceK°F
pressurebar
on_time, operating_time, averaging_duration, storage_interval, tariff_period, since_last_cumulation, battery_operating_time, ...smin, h, days
storage_interval_months, tariff_period_months, since_last_cumulation_months, battery_operating_time_monthsmonthyears
voltage, currentV, A

So a consumer that reads energy never has to ask whether the meter counted it in GJ. A month or a year is no fixed number of seconds, so a duration the meter counts in months or years is another quantity, with _months in its name (a year is 12 months):

{"storage_interval":        {"value": 900, "unit": "s"},
 "storage_interval_months": {"value": 24,  "unit": "month"}}

The one quantity with no fixed unit is custom, a plain text VIF: its unit is the text the meter sends.

Dates come as text: "2026-10-23" (type G), "2026-10-23T14:05" (type F), "2026-10-23T14:05:30" (type I) or "14:05:30" (type J). Identity numbers come as text too (fabrication_number). These have "value": null: a real that is NaN or +-Inf (json has no number for them; the other records of the telegram are decoded), a BCD with a digit in error, a date marked invalid, never set, or with a field out of its range (year 0-99, month 1-12, day 1-31, hour 0-23, minute and second 0-59), and a record that carries a record-error VIFE (it also has "error": "no data available", or whatever the code says). Manufacturer data (after DIF 0F) comes as mfr_data in hex.

Example

BOOL more;
char why[256];
json_t *jn = mbus_decode_user_data(&frame, &more, why, sizeof(why));
if(!jn) {
    // why: "records encrypted (security mode 5): not decoded"
    return -1;
}
json_t *jn_volume = json_object_get(json_object_get(jn, "records"), "volume");
double m3 = json_number_value(json_object_get(jn_volume, "value"));
JSON_DECREF(jn)

A record id is a key: read it with json_object_get(), never as a kw path.


mbus_medium_name()

mbus_medium_name() gives the name of a medium code, the one a header carries as medium.

const char *mbus_medium_name(int medium);

Parameters

KeyTypeDescription
mediumintThe medium byte of a header or of a secondary address.

Returns

A static name, or NULL for a code that has none:

CodeNameCodeName
01oil07water
02electricity08heat_cost_allocator
03gas0Cheat_inlet
04heat15hot_water
06warm_water16cold_water

and the rest of the codes in EN 13757-3 (steam, compressed_air, cooling_outlet, pressure, smoke_detector, valve, waste_water, ...).

Example

const char *name = mbus_medium_name(0x07);  // "water"

mbus_medium_code()

mbus_medium_code() gives the code of a medium name: the reverse of mbus_medium_name().

int mbus_medium_code(const char *name);

Parameters

KeyTypeDescription
nameconst char *A name of the table above.

Returns

The code, or -1 for a name it does not know (or an empty one).

Example

int medium = mbus_medium_code("gas");   // 3