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.