Wärmemengenzähler WMZ über IR-Schnittstelle M-Bus auslesen

Hallo zusammen,

bei uns wurden im Zuge eines Heizungsabtausches mehrere neue WMZ verbaut. Diese WMZ haben eine integrierte Infrarot-Schnittstelle. Kann man diesen Zähler über einen IR-Lesekopf (z.B. von Hichi) auslesen und die Daten per z.B. Wifi mit HA lesen? Evtl über einen ESP32?

Wärmemengenzähler Hersteller: Molline Typ: Wingstar C3S

Es müsste sich laut Beschreibung um die selbe Bauart handeln, wie die WMZ von Engelmann.

Laut Beschreibung handelt es sich um da M-Bus Protokoll.

Hat das jemand in der Form schon mal umgesetzt? Ich bin über jede Hilfe dankbar.

ja geht mit dem Hitchi kopf ich lese so meinen engelmann aus

Das hört sich ja schon mal gut an.

Darf ich fragen welche Hardware du dafür verwendest?

Ja einen Hitchi Lesekopf fertig mit dem passenden Skript das ist alles was ich noch machen muss ist die lötbrücke zu erstellen das der engelmann sensorstar nicht nur alle 45 min ausgelesen wird, sondern alle 2 min

Nachfrage:

Haben eure WMZ einen externen Stromanschluss ?
Ansonnten sind die ja bald “kaputt”.
Meines Wissens ist ein Tausch der Batterie nicht vorgesehen und zerstört den WMZ.
Stichwort hier : Manipulations-Schutz.

Abstecken anstecken fertig, wurde schon mehrfach bestätigt das der Engelmann dann weiter funktioniert.

Hast du das Skript dafür selber geschrieben? Oder wo hast du das her?

Welchen Hichi-Lesekopf verwendest du? Hast du dafür noch einen ESP32 im Einsatz, oder sendet dein Lesekopf direkt die Daten?

Das hatte ich im Internet irgendwo auf gegabelt, und ja die Daten gehen dann per MQTT an HA und ich habe den Hitchi mit USB C Anschluss funktioniert zuverlässig und einfach

Ich hab eine Config für ESPHome die bei mir funktioniert.

Board ist ein ESP32 WROOM-32

# ============================================================
# Engelmann SensoStar U – Optical IR Readout (Hichi IR Head)
# ============================================================
# Protocol:  M-Bus (EN 13757-3 / EN 1434-3 / EN 62056)
# Interface: Optical infrared via Hichi IR read head on UART
# Board:     ESP32 WROOM-32
#
# The meter's optical interface is activated by sending a
# wake-up preamble (~504 × 0x55) at 2400 Bd, 8E1.
# After wake-up the interface stays open for 4s; each valid
# communication extends it by another 4s.
# The daily number of optical readouts is limited – at one
# readout per day at least 4 communications are possible.
# ============================================================

substitutions:
  name: sensostar-u

esphome:
  name: ${name}

esp32:
  board: esp32dev
  framework:
    type: esp-idf
    advanced:
      minimum_chip_revision: "3.1"
      sram1_as_iram: true

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password
  fast_connect: true
  min_auth_mode: WPA2
  ap:
    ssid: "${name}-AP"
    password: !secret ap_password

captive_portal:

debug:
#   update_interval: 30s

# ---- Globals for tracking communication state ----
globals:
  - id: mbus_frame
    type: std::vector<uint8_t>
    restore_value: no
  - id: last_read_ok
    type: bool
    restore_value: no
    initial_value: "false"
  - id: fcb_bit
    type: bool
    restore_value: no
    initial_value: "false"
  - id: read_count
    type: int
    restore_value: no
    initial_value: "0"
  - id: t_ref_ms          # timestamp of the last TX action, all timing is derived from it
    type: uint32_t
    restore_value: no
    initial_value: "0"

web_server:
  version: 3
  include_internal: true
  sorting_groups:
    - id: grp_meter
      name: "Zähler"
      sorting_weight: 10
    - id: grp_technical
      name: "Technik"
      sorting_weight: 20
    - id: grp_max
      name: "Maximalwerte"
      sorting_weight: 25
    - id: grp_stichtag
      name: "Stichtag"
      sorting_weight: 30
    - id: grp_system
      name: "System & Diagnostics"
      sorting_weight: 50

logger:
  level: DEBUG

button:
  - platform: template
    name: "Manuell Auslesen"
    icon: "mdi:refresh"
    on_press:
      - script.execute: read_sensostar
    web_server:
      sorting_group_id: grp_meter
      sorting_weight: 1

text_sensor:
  - platform: template
    name: "Status"
    id: meter_status
    icon: "mdi:information-outline"
    web_server:
      sorting_group_id: grp_meter
      sorting_weight: 2

  - platform: template
    name: "Zähler-ID"
    id: meter_serial
    icon: "mdi:identifier"
    disabled_by_default: true
    web_server:
      sorting_group_id: grp_meter
      sorting_weight: 3

  - platform: template
    name: "Stichtag"
    id: stichtag_date
    icon: "mdi:calendar-check"
    web_server:
      sorting_group_id: grp_stichtag
      sorting_weight: 5

  - platform: debug
    id: reset_reason
    reset_reason:
      name: "Reset Reason"
      icon: "mdi:information-outline"
      web_server:
        sorting_group_id: grp_system
        sorting_weight: 30

sensor:
  - platform: template
    name: "Betriebstage"
    id: operating_days
    unit_of_measurement: "d"
    accuracy_decimals: 0
    icon: "mdi:calendar-clock"
    disabled_by_default: true
    web_server:
      sorting_group_id: grp_meter
      sorting_weight: 4

  - platform: template
    name: "Zählerzeit"
    id: meter_time
    unit_of_measurement: "s"
    accuracy_decimals: 0
    icon: "mdi:clock-outline"
    disabled_by_default: true
    web_server:
      sorting_group_id: grp_meter
      sorting_weight: 5

  - platform: template
    name: "Messdatenalter"
    id: meter_time_age
    unit_of_measurement: "s"
    accuracy_decimals: 0
    icon: "mdi:timer-sand"
    update_interval: 5s
    lambda: |-
      if (!id(sntp_time).now().is_valid()) {
        return {};
      }

      if (!id(last_read_ok)) {
        return {};
      }

      double meter_ts = id(meter_time).state;

      if (meter_ts <= 0) {
        return {};
      }

      int64_t age =
        id(sntp_time).now().timestamp - (int64_t)meter_ts;

      if (age < 0) {
        return {};
      }

      return (float)age;
    web_server:
      sorting_group_id: grp_meter
      sorting_weight: 6

  - platform: template
    name: "Abfragen"
    id: read_counter
    accuracy_decimals: 0
    icon: "mdi:counter"
    disabled_by_default: true
    web_server:
      sorting_group_id: grp_meter
      sorting_weight: 7

  - platform: template
    name: "Wärmemenge"
    id: heat_energy
    unit_of_measurement: "kWh"
    device_class: energy
    state_class: total_increasing
    accuracy_decimals: 1
    icon: "mdi:fire"
    web_server:
      sorting_group_id: grp_meter
      sorting_weight: 8

  - platform: template
    name: "Volumen"
    id: total_volume
    unit_of_measurement: "m³"
    device_class: water
    state_class: total_increasing
    accuracy_decimals: 3
    icon: "mdi:water"
    web_server:
      sorting_group_id: grp_meter
      sorting_weight: 9

  # ---- Technical values ----
  - platform: template
    name: "Leistung"
    id: heat_power
    unit_of_measurement: "kW"
    device_class: power
    accuracy_decimals: 2
    icon: "mdi:flash"
    disabled_by_default: true
    web_server:
      sorting_group_id: grp_technical
      sorting_weight: 5

  - platform: template
    name: "Durchfluss"
    id: flow_rate
    unit_of_measurement: "m³/h"
    accuracy_decimals: 3
    icon: "mdi:waves"
    disabled_by_default: true
    web_server:
      sorting_group_id: grp_technical
      sorting_weight: 6

  - platform: template
    name: "Vorlauftemperatur"
    id: temp_flow
    unit_of_measurement: "°C"
    device_class: temperature
    accuracy_decimals: 1
    icon: "mdi:thermometer-chevron-up"
    disabled_by_default: true
    web_server:
      sorting_group_id: grp_technical
      sorting_weight: 7

  - platform: template
    name: "Rücklauftemperatur"
    id: temp_return
    unit_of_measurement: "°C"
    device_class: temperature
    accuracy_decimals: 1
    icon: "mdi:thermometer-chevron-down"
    disabled_by_default: true
    web_server:
      sorting_group_id: grp_technical
      sorting_weight: 8

  - platform: template
    name: "Temperaturdifferenz"
    id: temp_diff
    unit_of_measurement: "K"
    accuracy_decimals: 1
    icon: "mdi:thermometer-lines"
    disabled_by_default: true
    web_server:
      sorting_group_id: grp_technical
      sorting_weight: 9

  - platform: template
    name: "Leistung Max"
    id: heat_power_max
    unit_of_measurement: "kW"
    device_class: power
    state_class: measurement
    accuracy_decimals: 2
    icon: "mdi:flash-alert"
    disabled_by_default: true
    web_server:
      sorting_group_id: grp_max
      sorting_weight: 5

  - platform: template
    name: "Durchfluss Maximum"
    id: flow_rate_max
    unit_of_measurement: "m³/h"
    state_class: measurement
    accuracy_decimals: 3
    icon: "mdi:waves-arrow-up"
    disabled_by_default: true
    web_server:
      sorting_group_id: grp_max
      sorting_weight: 6

  # ---- Values at the meter reading date (Stichtag, storage number 1) ----
  - platform: template
    name: "Wärmemenge Stichtag"
    id: heat_energy_stichtag
    unit_of_measurement: "kWh"
    device_class: energy
    accuracy_decimals: 1
    icon: "mdi:fire-clock"
    web_server:
      sorting_group_id: grp_stichtag
      sorting_weight: 6

  - platform: template
    name: "Volumen Stichtag"
    id: total_volume_stichtag
    unit_of_measurement: "m³"
    device_class: water
    accuracy_decimals: 3
    icon: "mdi:water-clock"
    disabled_by_default: true
    web_server:
      sorting_group_id: grp_stichtag
      sorting_weight: 7

  - platform: uptime
    name: Uptime
    id: up
    update_interval: 5s
    web_server:
      sorting_group_id: grp_system
      sorting_weight: 20

  - platform: wifi_signal
    name: "WiFi Signal"
    id: wifi_rssi
    update_interval: 5s
    web_server:
      sorting_group_id: grp_system
      sorting_weight: 10

# ---- UART to Hichi IR read head ----
# Hichi IR head wiring:
#   VCC  → 3.3 V
#   GND  → GND
#   TX   → ESP32 RX (GPIO16)
#   RX   → ESP32 TX (GPIO17)
uart:
  - id: uart_hichi
    rx_pin: GPIO16
    tx_pin: GPIO17
    baud_rate: 2400
    data_bits: 8
    parity: EVEN
    stop_bits: 1
    rx_buffer_size: 1024
    debug:
      direction: RX
      dummy_receiver: false
      sequence:
        - lambda: |-
            // Log raw bytes for debugging
            std::string hex_str;
            char buf[4];
            for (uint8_t b : bytes) {
              snprintf(buf, sizeof(buf), "%02X ", b);
              hex_str += buf;
            }
            if (direction == uart::UART_DIRECTION_RX) {
              ESP_LOGD("uart_rx", "<<< %s", hex_str.c_str());
            } else {
              if (bytes.size() > 0 && bytes[0] == 0x55 && bytes.size() >= 10) {
                ESP_LOGD("uart_tx", ">>> [Preamble %d × 0x55]", bytes.size());
              } else {
                ESP_LOGD("uart_tx", ">>> %s", hex_str.c_str());
              }
            }

prometheus:
  include_internal: false

ota:
  - platform: esphome
    password: !secret ota_password

# interval:
#   - interval: 60min
#     then:
#       - script.execute: read_sensostar

time:
  - platform: sntp
    id: sntp_time
    timezone: "Europe/Berlin"
    on_time:
      - cron: '00 00 * * * *'
        then:
          - script.execute: read_sensostar

# ---- Main readout script ----
script:
  - id: read_sensostar
    mode: single
    then:
      - lambda: |-
          ESP_LOGI("sensostar", "Query started.");
          // Clear any stale data in the receive buffer
          while (id(uart_hichi).available()) {
            uint8_t dummy;
            id(uart_hichi).read_byte(&dummy);
          }
          id(mbus_frame).clear();
          id(last_read_ok) = false;

      - lambda: |-
          // ── Step 1: Send optical wake-up preamble ──
          // A strictly alternating bit stream (1200 Hz square wave at 2400 Bd) is only
          // produced by 0x55 in 8N1 (start 0, 1010 1010, stop 1). With 8E1 the parity
          // and stop bits break the pattern once per byte, so the preamble is sent as
          // 8N1 and the UART is switched back to 8E1 afterwards.
          // 552 bytes x 10 bit / 2400 Bd = 2300 ms on the wire.
          uint8_t preamble[552];
          memset(preamble, 0x55, sizeof(preamble));
          id(uart_hichi).set_parity(uart::UART_CONFIG_PARITY_NONE);
          id(uart_hichi).load_settings(false);
          ESP_LOGD("sensostar", "Sending wake-up preamble: 552 x 0x55, 8N1 (2300 ms on the wire).");
          id(t_ref_ms) = millis();   // reference for the preamble timing logs
          id(uart_hichi).write_array(preamble, sizeof(preamble));

      # Wait until the preamble has really left the wire (2300 ms) + 100 ms
      - wait_until:
          condition:
            lambda: 'return (millis() - id(t_ref_ms)) >= 2400;'

      - lambda: |-
          // ── Step 2: back to 8E1, send SND_NKE ──
          unsigned int t_sent = (unsigned int)(millis() - id(t_ref_ms));
          // Reinstalling the UART driver also discards any preamble echo in the RX buffer.
          id(uart_hichi).set_parity(uart::UART_CONFIG_PARITY_EVEN);
          id(uart_hichi).load_settings(false);
          ESP_LOGD("sensostar", "Preamble sent (t=%u ms), UART switched back to 8E1 (t=%u ms).",
                   t_sent, (unsigned int)(millis() - id(t_ref_ms)));
          id(mbus_frame).clear();

          // Short frame: 10h C=40h A=FEh CS=3Eh 16h (SND_NKE, resets FCB in the meter)
          uint8_t snd_nke[] = {0x10, 0x40, 0xFE, 0x3E, 0x16};
          id(t_ref_ms) = millis();   // from now on: reference for ACK latency
          id(uart_hichi).write_array(snd_nke, sizeof(snd_nke));
          id(fcb_bit) = false;
          ESP_LOGD("sensostar", "SND_NKE sent (10 40 FE 3E 16).");

      # Poll for the ACK (0xE5) instead of a blind 1000 ms delay; give up after 500 ms
      - wait_until:
          condition:
            lambda: |-
              auto &f = id(mbus_frame);
              while (id(uart_hichi).available()) {
                uint8_t b;
                id(uart_hichi).read_byte(&b);
                f.push_back(b);
              }
              for (uint8_t b : f) {
                if (b == 0xE5) return true;
              }
              return false;
          timeout: 500ms

      - lambda: |-
          bool ack_received = false;
          std::string hex_str;
          char buf[4];
          for (uint8_t b : id(mbus_frame)) {
            snprintf(buf, sizeof(buf), "%02X ", b);
            hex_str += buf;
            if (b == 0xE5) ack_received = true;
          }
          unsigned int dt = (unsigned int)(millis() - id(t_ref_ms));
          if (ack_received) {
            ESP_LOGI("sensostar", "ACK (0xE5) for SND_NKE after ~%u ms. RX: %s", dt, hex_str.c_str());
          } else {
            ESP_LOGW("sensostar", "No ACK for SND_NKE within %u ms. RX (%d bytes): %s",
                     dt, (int)id(mbus_frame).size(), hex_str.c_str());
          }

      # M-Bus needs >= 33 bit times (~14 ms) between frames
      - delay: 50ms

      - lambda: |-
          // ── Step 3: Send REQ_UD2 (request user data) ──
          id(mbus_frame).clear();
          uint8_t c_field = id(fcb_bit) ? 0x7B : 0x5B;
          uint8_t a_field = 0xFE;
          uint8_t checksum = (c_field + a_field) & 0xFF;
          uint8_t req_frame[] = {0x10, c_field, a_field, checksum, 0x16};
          id(t_ref_ms) = millis();
          id(uart_hichi).write_array(req_frame, sizeof(req_frame));
          id(fcb_bit) = !id(fcb_bit);
          ESP_LOGD("sensostar", "REQ_UD2 sent (C=0x%02X, CS=0x%02X).", c_field, checksum);

      # Wait until a COMPLETE long frame (68 L L 68 ... CS 16) is in the buffer.
      # At 2400 Bd 8E1 a byte takes 4.6 ms -> a 250 byte telegram needs ~1.2 s,
      # so a fixed 1000 ms delay can cut long telegrams.
      - wait_until:
          condition:
            lambda: |-
              auto &f = id(mbus_frame);
              while (id(uart_hichi).available()) {
                uint8_t b;
                id(uart_hichi).read_byte(&b);
                f.push_back(b);
              }
              for (size_t k = 0; k + 3 < f.size(); k++) {
                if (f[k] == 0x68 && f[k + 3] == 0x68 && f[k + 1] == f[k + 2]) {
                  return f.size() >= k + (size_t) f[k + 1] + 6;
                }
              }
              return false;
          timeout: 2500ms

      - lambda: |-
          // ── Step 4: Receive and parse M-Bus response ──
          auto &frame = id(mbus_frame);   // already filled by the wait_until above

          while (id(uart_hichi).available()) {
            uint8_t b;
            id(uart_hichi).read_byte(&b);
            frame.push_back(b);
          }

          std::string hex_str;
          char buf[4];
          for (uint8_t b : frame) {
            snprintf(buf, sizeof(buf), "%02X ", b);
            hex_str += buf;
          }

          if (frame.empty()) {
            ESP_LOGW("sensostar", "No response received from meter (0 bytes in buffer).");
            id(meter_status).publish_state("Keine Antwort");
            return;
          }

          ESP_LOGD("sensostar", "Received %d bytes: %s", frame.size(), hex_str.c_str());

          // Diagnose if only optical TX echo was received
          if (frame.size() == 5 && frame[0] == 0x10 && frame[4] == 0x16) {
            ESP_LOGW("sensostar", "DIAGNOSIS: Received ONLY the 5-byte optical TX echo of REQ_UD2 (10 %02X %02X %02X 16).",
                     frame[1], frame[2], frame[3]);
            ESP_LOGW("sensostar", "The meter did not answer. Causes: optical head misaligned, meter asleep, or daily optical credit limit reached.");
            id(meter_status).publish_state("Nur Echo (Keine Zählerantwort)");
            return;
          }

          // Synchronize to M-Bus long frame: 68h L L 68h
          size_t header_idx = 0;
          bool found_header = false;
          for (size_t k = 0; k + 3 < frame.size(); k++) {
            if (frame[k] == 0x68 && frame[k + 3] == 0x68 && frame[k + 1] == frame[k + 2]) {
              header_idx = k;
              found_header = true;
              break;
            }
          }

          if (!found_header) {
            ESP_LOGW("sensostar", "No valid M-Bus long frame header (68 L L 68) found in %d received bytes: %s",
                     frame.size(), hex_str.c_str());
            id(meter_status).publish_state("Ungültiger Frame");
            return;
          }

          if (header_idx > 0) {
            ESP_LOGD("sensostar", "Skipped %d echo/prefix bytes before M-Bus frame start.", header_idx);
            frame.erase(frame.begin(), frame.begin() + header_idx);
          }

          // ── Validate M-Bus long frame ──
          bool frame_ok = true;
          if (frame.size() < 9) {
            ESP_LOGW("sensostar", "Frame too short (%d bytes).", frame.size());
            id(meter_status).publish_state("Frame zu kurz");
            frame_ok = false;
          }

          uint8_t L = frame[1];
          size_t expected_size = L + 6;  // 68 L L 68 ... CS 16
          if (frame.size() < expected_size) {
            ESP_LOGW("sensostar", "Frame incomplete: got %d, expected %d (L=%d)", frame.size(), expected_size, L);
            id(meter_status).publish_state("Frame unvollständig");
            frame_ok = false;
          }

          if (frame_ok && frame[expected_size - 1] != 0x16) {
            ESP_LOGW("sensostar", "Missing stop byte (0x16) at position %d (got 0x%02X)",
                     expected_size - 1, frame[expected_size - 1]);
            id(meter_status).publish_state("Stoppbyte fehlt");
            frame_ok = false;
          }

          // Verify checksum (sum of bytes from position 4 to 4+L-1, mod 256)
          if (frame_ok) {
            uint8_t cs = 0;
            for (size_t k = 4; k < 4 + L; k++) {
              cs += frame[k];
            }
            if (cs != frame[4 + L]) {
              ESP_LOGW("sensostar", "Checksum mismatch: calc=0x%02X, recv=0x%02X", cs, frame[4 + L]);
              id(meter_status).publish_state("Prüfsumme falsch");
              frame_ok = false;
            }
          }

          if (frame.size() > expected_size) {
            frame.resize(expected_size);
          }

          if (frame_ok) {
            ESP_LOGD("sensostar", "Frame valid (L=%d, total=%d bytes).", L, frame.size());
          } else {
            ESP_LOGW("sensostar", "Attempting best-effort decoding of received data despite frame errors...");
          }

          // Print meter header info if available
          if (frame.size() >= 19) {
            uint32_t meter_id = frame[7] | (frame[8] << 8) | (frame[9] << 16) | (frame[10] << 24);
            uint16_t manuf = frame[11] | (frame[12] << 8);
            char manuf_code[4] = {
              (char)(((manuf >> 10) & 0x1F) + 'A' - 1),
              (char)(((manuf >> 5) & 0x1F) + 'A' - 1),
              (char)((manuf & 0x1F) + 'A' - 1),
              0
            };
            ESP_LOGI("sensostar", "Meter Header: ID=%08X (%u), Manuf=%s (0x%04X), Med=0x%02X, AccNr=%d, Status=0x%02X",
                     meter_id, meter_id, manuf_code, manuf, frame[14], frame[15], frame[16]);
            // The ID is BCD-coded, so its hex representation is the printed serial number
            char id_str[9];
            snprintf(id_str, sizeof(id_str), "%08X", meter_id);
            id(meter_serial).publish_state(id_str);
          }

          // ── Parse M-Bus data records ──
          // Long frame: 68 L L 68 C A CI [fixed header] [data records] CS 16
          // Positions: 0-3=header, 4=C, 5=A, 6=CI,
          //   7..10=ID, 11..12=Manuf, 13=Ver, 14=Med,
          //   15=AccNr, 16=Status, 17..18=Signature
          // Data records start at position 19
          size_t data_start = 19;
          size_t data_end = 4 + L;  // position of checksum byte

          ESP_LOGD("sensostar", "Parsing data records from byte %d to %d.", (int) data_start, (int) data_end - 1);

          // Helper: decode a numeric field (signed LE integer, BCD or 32-bit float)
          auto decode_num = [&frame](size_t idx, int len, bool bcd, bool is_real) -> double {
            if (len <= 0 || len > 8) return 0;
            if (is_real && len == 4) {
              float fv;
              memcpy(&fv, &frame[idx], 4);
              return fv;
            }
            if (bcd) {
              double v = 0, mult = 1;
              for (int k = 0; k < len; k++) {
                v += (frame[idx + k] & 0x0F) * mult;
                mult *= 10;
                v += ((frame[idx + k] >> 4) & 0x0F) * mult;
                mult *= 10;
              }
              return v;
            }
            int64_t v = 0;
            for (int k = len - 1; k >= 0; k--) v = (v << 8) | frame[idx + k];
            if (len < 8 && (frame[idx + len - 1] & 0x80)) v -= ((int64_t) 1 << (8 * len));
            return (double) v;
          };

          // Helper: hex dump of a data field
          auto hex_of = [&frame](size_t idx, int len) -> std::string {
            std::string out;
            char b3[4];
            for (int k = 0; k < len; k++) {
              snprintf(b3, sizeof(b3), "%02X ", frame[idx + k]);
              out += b3;
            }
            return out;
          };

          // Helper: date type G (2 bytes) -> "DD.MM.YYYY"
          auto fmt_date = [](const uint8_t *d) -> std::string {
            int day = d[0] & 0x1F;
            int month = d[1] & 0x0F;
            int y = ((d[0] & 0xE0) >> 5) | ((d[1] & 0xF0) >> 1);
            y += (y < 81) ? 2000 : 1900;
            char out[16];
            snprintf(out, sizeof(out), "%02d.%02d.%04d", day, month, y);
            return std::string(out);
          };

          // Helper: date+time type F (4 bytes) -> "DD.MM.YYYY HH:MM"
          auto fmt_datetime = [](const uint8_t *d) -> std::string {
            int minute = d[0] & 0x3F;
            bool invalid = d[0] & 0x80;
            int hour = d[1] & 0x1F;
            int day = d[2] & 0x1F;
            int month = d[3] & 0x0F;
            int y = ((d[2] & 0xE0) >> 5) | ((d[3] & 0xF0) >> 1);
            y += (y < 81) ? 2000 : 1900;
            char out[32];
            snprintf(out, sizeof(out), "%02d.%02d.%04d %02d:%02d%s", day, month, y, hour, minute,
                     invalid ? " (ungültig)" : "");
            return std::string(out);
          };

          // Helper: date+time type F (4 bytes) -> unix timestamp (UTC seconds).
          // The meter clock is assumed to run on CET (UTC+1), or CEST (UTC+2) if the
          // summer-time flag (bit 7 of byte 1) is set.
          auto meter_to_unix = [](const uint8_t *d) -> int64_t {
            int minute = d[0] & 0x3F;
            int hour = d[1] & 0x1F;
            bool summer = d[1] & 0x80;
            int day = d[2] & 0x1F;
            int month = d[3] & 0x0F;
            int y = ((d[2] & 0xE0) >> 5) | ((d[3] & 0xF0) >> 1);
            y += (y < 81) ? 2000 : 1900;
            // days since 1970-01-01 (civil-from-days algorithm)
            int yy = y - (month <= 2 ? 1 : 0);
            int era = (yy >= 0 ? yy : yy - 399) / 400;
            int yoe = yy - era * 400;
            int mp = (month > 2) ? month - 3 : month + 9;
            int doy = (153 * mp + 2) / 5 + day - 1;
            int doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
            int64_t days = (int64_t) era * 146097 + doe - 719468;
            return days * 86400 + hour * 3600 + minute * 60 - (summer ? 7200 : 3600);
          };

          // Iterate through data records
          size_t i = data_start;
          while (i + 1 < data_end) {
            uint8_t dif = frame[i];

            if (dif == 0x2F) {  // idle filler
              i++;
              continue;
            }
            if (dif == 0x0F || dif == 0x1F) {  // manufacturer specific data / more records follow
              ESP_LOGD("sensostar", "Special DIF=0x%02X at %d, stopping record parsing.", dif, (int) i);
              break;
            }

            // ── DIF / DIFE: function, storage number, tariff, subunit ──
            uint8_t function = (dif >> 4) & 0x03;   // 0 = instantaneous, 1 = maximum, 2 = minimum, 3 = error state
            uint64_t storage = (dif >> 6) & 0x01;   // 0 = current value, 1 = value at Stichtag, ...
            uint32_t tariff = 0;
            uint32_t subunit = 0;
            size_t pos = i + 1;
            bool dife_follows = dif & 0x80;
            int n_dife = 0;
            while (dife_follows && pos < data_end && n_dife < 10) {
              uint8_t dife = frame[pos++];
              storage |= (uint64_t)(dife & 0x0F) << (1 + 4 * n_dife);
              tariff  |= (uint32_t)((dife >> 4) & 0x03) << (2 * n_dife);
              subunit |= (uint32_t)((dife >> 6) & 0x01) << n_dife;
              dife_follows = dife & 0x80;
              n_dife++;
            }

            if (pos >= data_end) break;
            uint8_t vif = frame[pos++];

            // ── VIF / VIFE: remember the first VIFE (needed for the 0xFD/0xFB extension tables) ──
            uint8_t vife0 = 0;
            bool vife_follows = vif & 0x80;
            int n_vife = 0;
            while (vife_follows && pos < data_end) {
              uint8_t vife = frame[pos++];
              if (n_vife == 0) vife0 = vife;
              vife_follows = vife & 0x80;
              n_vife++;
            }

            // ── Data field length from DIF ──
            uint8_t dif_data = dif & 0x0F;
            int data_len = 0;
            bool is_bcd = false;
            bool is_real = false;
            switch (dif_data) {
              case 0x01: data_len = 1; break;
              case 0x02: data_len = 2; break;
              case 0x03: data_len = 3; break;
              case 0x04: data_len = 4; break;
              case 0x05: data_len = 4; is_real = true; break;
              case 0x06: data_len = 6; break;
              case 0x07: data_len = 8; break;
              case 0x09: data_len = 1; is_bcd = true; break;
              case 0x0A: data_len = 2; is_bcd = true; break;
              case 0x0B: data_len = 3; is_bcd = true; break;
              case 0x0C: data_len = 4; is_bcd = true; break;
              case 0x0E: data_len = 6; is_bcd = true; break;
              default:   data_len = 0; break;   // 0x00, 0x08, 0x0D (variable, handled below), 0x0F
            }

            size_t data_pos = pos;
            if (dif_data == 0x0D && data_pos < data_end) {  // variable length
              data_len = frame[data_pos];
              data_pos++;
            }

            if (data_pos + data_len > data_end) {
              ESP_LOGW("sensostar", "Data field overruns frame at pos %d (need %d bytes).", (int) data_pos, data_len);
              break;
            }

            // ── Classify the record ──
            const bool base = (tariff == 0 && subunit == 0);
            const bool is_current  = base && function == 0 && storage == 0;
            const bool is_max      = base && function == 1 && storage == 0;
            const bool is_stichtag = base && function == 0 && storage == 1;
            char where[64];
            snprintf(where, sizeof(where), "fn=%u stor=%u tar=%u sub=%u",
                     (unsigned) function, (unsigned) storage, (unsigned) tariff, (unsigned) subunit);

            const uint8_t vif_no_ext = vif & 0x7F;
            const double value = (dif_data == 0x0D) ? 0 : decode_num(data_pos, data_len, is_bcd, is_real);

            const char *what = nullptr;        // quantity name (set for numeric quantities)
            const char *unit = "";
            double phys = 0;
            sensor::Sensor *target = nullptr;  // sensor to publish to (nullptr = only log)

            // Energy in Wh: VIF 0x00-0x07 -> 10^(nnn-3) Wh
            if (vif_no_ext <= 0x07) {
              what = "Energie"; unit = "kWh";
              phys = value * pow(10.0, (vif_no_ext & 0x07) - 3) / 1000.0;
              if (is_current) target = id(heat_energy);
              else if (is_stichtag) target = id(heat_energy_stichtag);
            }
            // Energy in J: VIF 0x08-0x0F -> 10^(nnn) J
            else if (vif_no_ext <= 0x0F) {
              what = "Energie"; unit = "kWh";
              phys = value * pow(10.0, vif_no_ext & 0x07) / 3600000.0;
              if (is_current) target = id(heat_energy);
              else if (is_stichtag) target = id(heat_energy_stichtag);
            }
            // Volume: VIF 0x10-0x17 -> 10^(nnn-6) m³
            else if (vif_no_ext <= 0x17) {
              what = "Volumen"; unit = "m³";
              phys = value * pow(10.0, (vif_no_ext & 0x07) - 6);
              if (is_current) target = id(total_volume);
              else if (is_stichtag) target = id(total_volume_stichtag);
            }
            // Power: VIF 0x28-0x2F -> 10^(nnn-3) W
            else if (vif_no_ext >= 0x28 && vif_no_ext <= 0x2F) {
              what = "Leistung"; unit = "kW";
              phys = value * pow(10.0, (vif_no_ext & 0x07) - 3) / 1000.0;
              if (is_current) target = id(heat_power);
              else if (is_max) target = id(heat_power_max);
            }
            // Volume flow: VIF 0x38-0x3F -> 10^(nnn-6) m³/h
            else if (vif_no_ext >= 0x38 && vif_no_ext <= 0x3F) {
              what = "Durchfluss"; unit = "m³/h";
              phys = value * pow(10.0, (vif_no_ext & 0x07) - 6);
              if (is_current) target = id(flow_rate);
              else if (is_max) target = id(flow_rate_max);
            }
            // Flow temperature: VIF 0x58-0x5B -> 10^(nn-3) °C
            else if (vif_no_ext >= 0x58 && vif_no_ext <= 0x5B) {
              what = "Vorlauftemperatur"; unit = "°C";
              phys = value * pow(10.0, (vif_no_ext & 0x03) - 3);
              if (is_current) target = id(temp_flow);
            }
            // Return temperature: VIF 0x5C-0x5F -> 10^(nn-3) °C
            else if (vif_no_ext >= 0x5C && vif_no_ext <= 0x5F) {
              what = "Rücklauftemperatur"; unit = "°C";
              phys = value * pow(10.0, (vif_no_ext & 0x03) - 3);
              if (is_current) target = id(temp_return);
            }
            // Temperature difference: VIF 0x60-0x63 -> 10^(nn-3) K
            else if (vif_no_ext >= 0x60 && vif_no_ext <= 0x63) {
              what = "Temperaturdifferenz"; unit = "K";
              phys = value * pow(10.0, (vif_no_ext & 0x03) - 3);
              if (is_current) target = id(temp_diff);
            }

            if (what != nullptr) {
              if (target != nullptr) {
                target->publish_state(phys);
                ESP_LOGI("sensostar", "%s: %.3f %s (%s)", what, phys, unit, where);
              } else {
                ESP_LOGD("sensostar", "%s: %.3f %s (%s) - not exposed", what, phys, unit, where);
              }
            }
            // On time (VIF 0x20-0x23) / operating time (VIF 0x24-0x27) in s, min, h or d
            else if (vif_no_ext >= 0x20 && vif_no_ext <= 0x27) {
              static const char *units[] = {"s", "min", "h", "d"};
              static const double to_days[] = {1.0 / 86400.0, 1.0 / 1440.0, 1.0 / 24.0, 1.0};
              const bool is_on_time = !(vif_no_ext & 0x04);
              if (is_on_time && is_current) {
                // "Betriebstage" = the meter's on-time counter, converted to days
                id(operating_days).publish_state(value * to_days[vif_no_ext & 0x03]);
                ESP_LOGI("sensostar", "Betriebstage: %.0f d (on-time %.0f %s, %s)",
                         value * to_days[vif_no_ext & 0x03], value, units[vif_no_ext & 0x03], where);
              } else {
                ESP_LOGD("sensostar", "%s: %.0f %s (%s) - not exposed", is_on_time ? "On-Zeit" : "Betriebszeit",
                         value, units[vif_no_ext & 0x03], where);
              }
            }
            // Date type G (2 bytes): VIF 0x6C, e.g. the Stichtag
            else if (vif_no_ext == 0x6C && data_len == 2) {
              std::string d = fmt_date(&frame[data_pos]);
              if (is_stichtag) {
                id(stichtag_date).publish_state(d);
                ESP_LOGI("sensostar", "Stichtag: %s (%s)", d.c_str(), where);
              } else {
                ESP_LOGD("sensostar", "Date: %s (%s) - not exposed", d.c_str(), where);
              }
            }
            // Date + time type F (4 bytes): VIF 0x6D
            else if (vif_no_ext == 0x6D && data_len == 4) {
              const uint8_t *d = &frame[data_pos];
              std::string txt = fmt_datetime(d);
              if (is_current && !(d[0] & 0x80)) {
                int64_t ts = meter_to_unix(d);
                id(meter_time).publish_state((double) ts);
                ESP_LOGI("sensostar", "Zählerzeit: %s -> unix %lld (%s)", txt.c_str(), (long long) ts, where);
              } else {
                ESP_LOGD("sensostar", "Date/time: %s (%s) - not exposed", txt.c_str(), where);
              }
            }
            // Fabrication number: VIF 0x78
            else if (vif_no_ext == 0x78) {
              ESP_LOGI("sensostar", "Fabrikationsnummer: %.0f (%s)", value, where);
            }
            // Extension table 0xFD: 0x17 = error flags, 0x0C = model/version
            else if (vif == 0xFD && vife0 == 0x17) {
              ESP_LOGI("sensostar", "Fehlerflags: %s(%s)", hex_of(data_pos, data_len).c_str(), where);
            }
            else if (vif == 0xFD && vife0 == 0x0C) {
              ESP_LOGI("sensostar", "Modell/Version: %s(%s)", hex_of(data_pos, data_len).c_str(), where);
            }
            else {
              ESP_LOGD("sensostar", "Unknown DIF=0x%02X VIF=0x%02X VIFE=0x%02X (%s) data: %s",
                       dif, vif, vife0, where, hex_of(data_pos, data_len).c_str());
            }

            // Advance to next record
            i = data_pos + data_len;
          }

          id(last_read_ok) = true;
          id(read_count) += 1;
          id(read_counter).publish_state(id(read_count));
          id(meter_status).publish_state("OK");
          ESP_LOGI("sensostar", "Readout #%d complete.", id(read_count));

Wurde in mehreren Vibecoding Sessions mittels KI Modellen zusammegestückelt.

@towolf Das Setup hört sich fast genau so an, wie ich das vor habe. Ich möchte ein ESP32-C6 Board mit dem Hichi IR TTL Lesekopf verwenden. Mein WMZ von Molline sollte zum Engelmann identisch sein. Ich möchte/muss es aber mit einer Batterie betreiben. Deswegen soll das Board in den Deep-Sleep-Modus gehen und nach festgelegten Zeiten aufwachen und mir die Werte liefern. Ich hoffe, ich bekomme das, natürlich mit Unterstützung der KI, auch hin :wink:

Offenbar hat man eine Art “Konto” an optischen Abfragen, die irgendwie an die Betriebstage gekoppelt sind und sich um Mitternacht erweitern. Ob sich das Konto um 4, 5, oder 6 Abfragen pro Tag auffüllt muss ich noch herausfinden.

Das heisst, wenn man im Sommer nichts abfragt, kann man immer Winter um so öfter abfragen. Vielleicht sogar alle zwei Stunden?

Ich hatte mich schon gewundert, warum ich nicht gedrosselt wurde, aber das lag daran, dass meine WMZ schon mit so vielen “Betriebstagen” gelagert hatten, dass ich das ungenutzte Konto verbrauchen konnte.