TRB143 + Sontex / NeoVac Supercal 739 – please support REQ_UD2 / Class‑2 data (energy not decoded)

Dear forum,

I am using a Teltonika TRB143 as M‑Bus master for seven NeoVac / Sontex Supercal 739 compact heat meters. These meters were previously connected to a NeoVac M‑Bus relay/display, where heat energy (kWh/MWh) values were correctly available for billing (so the meters have been set up correctly)
With the TRB143 M‑Bus client I can successfully read data from all meters, but output only contains volume values ( Volume (1e-2 m^3) ) and no energy values (kWh/MWh/GJ).

M‑Bus settings: Baud rate: 2400, Parity: Even, Stop bits: 1, M‑Bus client enabled, data collecting group active, “All” parameters selected

Questions:

  1. Does the TRB143 currently support decoding heat energy (kWh/MWh) from Sontex / NeoVac Supercal 739 meters on the M‑Bus?
  2. Is the TRB143 sending REQ_UD2 (Class 2) requests to obtain billing (stored) values, or only REQ_UD1 (instantaneous)?
  3. Is there a way to enable or map energy records for these meters in the current firmware, or is a template/firmware update required?

Any guidance, configuration example, or confirmation whether Supercal 739 is fully supported (including energy values) would be highly appreciated.

Thanking you in advance!

Hello!

Will inquire about this with our developers.

Natively, no, but you can achieve this with a custom script. For custom scripts, we’re unable to provide support or help you write them. You’ll have to utilize your programming knowledge, or any other resources.

Will inquire about this with our developers.

Regards,
M.

1 Like

Hello,

Here are the answers R&D have provided:

There is no specific support for Sontex / NeoVac Supercal 739 or similar device. Heat as a medium and kWh, MWh as units should be supported and readable in generic device, though.

REQ_UD2 is sent for requesting data.

Not much can be changed from configuration of the application.

They also suggested that you should check what is sent to the Mbus device and what’s received from it. Check the data received from the sensor in HEX format. Does it contain all the data needed? It could then be compared with the decoded values (XML) and check what is missing and where. You could also check how the meter itself is configured:

  • Tariff 1 vs Tariff 2
  • Cooling energy instead of heating
  • Monthly billing register only
  • etc.

You could also double-check whether maybe the meter is configured so that some of the registers are hidden for the M-Bus interface? Maybe only billing-relevant values are exposed? Maybe there is no data measured?

Kind regards,
M.

1 Like

Hi Matas

Thank you for your support. I was able to solve it by using the TRB143 as gateway and then made a py script that behaves like a very lightweight M-Bus master that performs a standard Class 2 readout.

For each configured primary M-Bus address, it sends exactly one REQ_UD2 (Class 2) request. This request is a short control frame addressed to the meter and tells the device to transmit its complete Class 2 user data. The script does not issue repeated REQ_UD2 commands, does not manage access numbers, and does not explicitly request individual data blocks.

After snding the REQ_UD2, the script simply listens on the TCP connection. The meter responds with one or more RSP_UD frames, depending on how much data it has to send. If the response is large, the meter transmits several long frames back-to-back. The script reads all incoming frames, assembles them, and considers the readout complete once no further frames arrive within the socket timeout.

In other words, the “fetching” is entirely meter-driven: a single REQ_UD2 triggers the meter to push all available Class 2 data in one session, and the script passively receives and parses whatever the meter sends. It does not reset the meter, does not use Class 1 requests, and does not alter the meter state in any way, making it suitable for periodic, non-intrusive polling.

cheers
Alex


off course I will share that script with you if you’re interessted

2 Likes

Hello,

I’m glad to hear that you were able to resolve it! You are more than welcome to share the script here if you wish, so in the future, users who come upon similar troubles as this could use your script for their own setups.

Regards,
M.

Dear Alex!

Dear Alex! I recently received a similar task to share data from Sontex devices. I’m stuck, I can’t access the read data, but TRB143 reads it, and a connection is established. Unfortunately, I have no experience in writing scripts. After reading your post, it would be great to have access to all the data. Is it possible to share your script, any advice? FW- version TRB1_R_00.07.21. Thank you.

Hi Alex, I’m definitely interested in your script. If you’re still okay with it, thanks a lot for sharing!

Best regards,

Greg

Hi Alex,

Congratulation on a well done task. I am trying to parse mbus data and fill modbus registers on TRB143 so I am also interested for your script if you still want to share it.

Thank you and best regards.

Predrag

Hi Peter

Sorry it took so long. I’m currently in the rainforest in Malaysia, almost no internet… well, here’s the script:

#!/usr/bin/env python3
import socket
import csv
import json
import meterbus
from io import StringIO
from datetime import datetime
from flask import Flask, make_response

HOST = "192.168.1.150"   # TRB143 IP
PORT = 5020              # TRB143 M-Bus TCP Port
SCAN_START = 1
SCAN_END = 10

META_KEYS = ["addr", "manufacturer", "identification", "medium", "version", "access_no"]

app = Flask(__name__)

def build_req_ud2_short(addr: int) -> bytes:
    C = 0x5B
    checksum = (C + addr) & 0xFF
    return bytes([0x10, C, addr, checksum, 0x16])

def build_req_ud1_short(addr: int) -> bytes:
    C = 0x5A  # REQ_UD1 (Class 1 data)
    checksum = (C + addr) & 0xFF
    return bytes([0x10, C, addr, checksum, 0x16])

def recv_mbus_frame(sock: socket.socket) -> bytes:
    first = sock.recv(1)
    if not first:
        raise IOError("No data")

    if first[0] == 0x10:  # Short frame
        rest = sock.recv(4)
        if len(rest) < 4:
            raise IOError("Incomplete short frame")
        return first + rest

    if first[0] != 0x68:
        raise IOError(f"Unexpected start byte: {first[0]:02x}")

    header = sock.recv(3)
    if len(header) < 3:
        raise IOError("Incomplete long frame header")

    length = header[0]
    body = bytearray()
    while len(body) < length + 2:
        chunk = sock.recv(length + 2 - len(body))
        if not chunk:
            raise IOError("Connection closed while reading body")
        body.extend(chunk)

    return bytes(b"\x68" + header + body)

def ping_meter(addr: int, req_builder=build_req_ud2_short, timeout: float = 3.0) -> bytes | None:
    try:
        with socket.create_connection((HOST, PORT), timeout=timeout) as s:
            req = req_builder(addr)
            s.sendall(req)
            raw = recv_mbus_frame(s)
            return raw
    except Exception:
        return None

def normalize_key(r):
    type_str = r.get("type") or ""
    type_short = type_str.split(".")[-1] if "." in type_str else type_str
    return f"{type_short}_S0"

def record_priority(r):
    """
    Kleinere Zahl = bessere Priorität
    """
    if r["storage_number"] == 0:
        return 0
    if "INSTANT" in r["function"] or "CURRENT" in r["function"]:
        return 1
    return 2

def read_meter(addr: int):
    # 1) Class‑2 Daten (REQ_UD2)
    raw_ud2 = ping_meter(addr, req_builder=build_req_ud2_short)
    if not raw_ud2:
        return None

    t2 = meterbus.load(raw_ud2)
    data2 = json.loads(t2.to_JSON())

    header = data2["body"]["header"]
    records_json = list(data2["body"]["records"])

    # 2) Optional: Class‑1 Daten (REQ_UD1, Alarm/Status)
    raw_ud1 = ping_meter(addr, req_builder=build_req_ud1_short)
    if raw_ud1:
        t1 = meterbus.load(raw_ud1)
        data1 = json.loads(t1.to_JSON())
        records_json.extend(data1["body"]["records"])

    selected = {}

    for r in records_json:
        if r["value"] is None:
            continue
        if r["function"] == "FunctionType.MORE_RECORDS_FOLLOW":
            continue

        key = normalize_key(r)
        prio = record_priority(r)

        current = selected.get(key)
        if current is None or prio < current["_prio"]:
            rec = {
                "key": key,
                "value": r["value"],
                "unit": r["unit"],
                "storage_number": r["storage_number"],
                "function": r["function"],
                "_prio": prio,
            }

            if key == "ENERGY_WH_S0":
                rec["derived"] = r["value"] / 1_000_000
                rec["derived_unit"] = "MWh"
            else:
                rec["derived"] = None
                rec["derived_unit"] = None

            selected[key] = rec

    records = list(selected.values())
    for r in records:
        r.pop("_prio", None)

    return {
        "addr": addr,
        "manufacturer": header["manufacturer"],
        "identification": header["identification"],
        "medium": header["medium"],
        "version": header["version"],
        "access_no": header["access_no"],
        "records": records,
    }

def scan_meters():
    meters = {}
    for addr in range(SCAN_START, SCAN_END + 1):
        m = read_meter(addr)
        if m:
            print(f"Found meter at {addr}: {m['manufacturer']} {m['identification']}")
            meters[addr] = m
    return meters

def build_pivot(meters: dict):
    meter_addrs = sorted(meters.keys())

    # 1) Metadaten-Zeilen
    meta_rows = []
    for meta_key in META_KEYS:
        row = {"param": meta_key.upper(), "values": {}}
        for addr in meter_addrs:
            m = meters[addr]
            row["values"][addr] = m.get(meta_key, "")
        meta_rows.append(row)

    # 2) Normale Werte
    param_keys = sorted({
        rec["key"]
        for m in meters.values()
        for rec in m["records"]
    })
    data_rows = []
    for key in param_keys:
        row = {"param": key, "values": {}}
        for addr in meter_addrs:
            m = meters[addr]
            rec = next((r for r in m["records"] if r["key"] == key), None)
            row["values"][addr] = rec["value"] if rec else ""
        data_rows.append(row)

    # 3) Abgeleitete Werte (z.B. *_DERIVED)
    derived_rows = []
    derived_keys = sorted({
        rec["key"] + "_DERIVED"
        for m in meters.values()
        for rec in m["records"]
        if rec["derived"] is not None
    })
    for key in derived_keys:
        base_key = key.replace("_DERIVED", "")
        row = {"param": key, "values": {}}
        for addr in meter_addrs:
            m = meters[addr]
            rec = next(
                (r for r in m["records"]
                 if r["key"] == base_key and r["derived"] is not None),
                None
            )
            row["values"][addr] = rec["derived"] if rec else ""
        derived_rows.append(row)

    # 4) Gesamtliste flach zurückgeben
    all_rows = meta_rows + data_rows + derived_rows
    return meter_addrs, all_rows

@app.route("/")
def index():
    meters = scan_meters()
    if not meters:
        return "<h1>No meters found</h1>"

    meter_addrs, rows = build_pivot(meters)

    html = []
    html.append("<!doctype html><html lang='de'><head>")
    html.append("<meta charset='utf-8'><title>M-Bus Meter</title>")
    html.append("<style>table{border-collapse:collapse;margin-bottom:1rem}"
                "th,td{border:1px solid #ccc;padding:4px 8px}th{background:#eee}</style>")
    html.append("</head><body>")
    html.append("<h1>M-Bus Meter</h1>")
    html.append("<p><a href='/export'>CSV exportieren</a></p>")

    html.append("<h2>Parameter-Tabelle</h2>")
    html.append("<table><tr><th>param</th>")
    for addr in meter_addrs:
        html.append(f"<th>meter_{addr}</th>")
    html.append("</tr>")
    for row in rows:
        html.append(f"<tr><td>{row['param']}</td>")
        for addr in meter_addrs:
            html.append(f"<td>{row['values'][addr]}</td>")
        html.append("</tr>")
    html.append("</table>")

    html.append("</body></html>")
    return "".join(html)

@app.route("/export")
def export_csv():
    meters = scan_meters()
    if not meters:
        resp = make_response("No meters found.")
        resp.headers["Content-Type"] = "text/plain"
        return resp

    meter_addrs, rows = build_pivot(meters)

    si = StringIO()
    writer = csv.writer(si)

    header = ["param"] + [f"meter_{addr}" for addr in meter_addrs]
    writer.writerow(header)

    for row in rows:
        writer.writerow(
            [row["param"]] + [row["values"][addr] for addr in meter_addrs]
        )

    csv_data = si.getvalue()
    ts = datetime.now().strftime("%Y%m%d-%H%M%S")
    resp = make_response(csv_data)
    resp.headers["Content-Disposition"] = f"attachment; filename=meters-{ts}.csv"
    resp.headers["Content-Type"] = "text/csv"
    return resp

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=5021, debug=True)

2 Likes

Hello,

thank you for the provided code.

Best regards.

Predrag