Skip to Content

What Your Boiler Really Exposes over OpenTherm — a Full Register Scan with the HomeMaster Gateway

Because the OpenTherm Gateway is open and runs ESPHome, nothing on the bus is off-limits. I swept the full 0–255 Data-ID space on a real boiler — including the manufacturer's private registers — entirely locally, with no cloud in the loop. Here's the complete map of what this boiler exposes.

By Dmitry Drezyulya · Updated July 19, 2026 · ~10 min read

A grid of OpenTherm Data-IDs 0 to 255 for a Viessmann Vitodens 100; answered registers sit only in the top half, the whole 128-255 block is empty

The short version. I swept all 256 OpenTherm Data-IDs on my Viessmann Vitodens 100. It answered 25 registers — all standard. Three of them (IDs 93/94/95) are undocumented manufacturer registers the ESPHome component doesn't expose, and none of them are gas. On this boiler, every ID from 128–255 returned Unknown-DataId. The takeaway isn't a hidden gas meter — it's a complete, reproducible map of the bus, a reusable scanner, and a pattern for reading undocumented registers into Home Assistant.


1. Quick Background: How OpenTherm Data Is Organized

OpenTherm is the two-wire protocol most modern gas boilers use to talk to a thermostat or gateway. All the data — flow temperature, pressure, modulation, setpoints — lives in numbered registers called Data-IDs. The ID field is 8 bits, so there are 256 possible registers (0–255):

  • 0–127 is the range the OpenTherm specification covers — but it's a mix. Most are documented standard registers (flow temp, pressure, modulation…), and the ESPHome opentherm component maps nearly all of those. Scattered among them, though, are manufacturer-reserved slots the spec leaves undefined; the component has no entity for those, so it can't show them.
  • 128–255 is outside the spec entirely — an unclaimed upper half that some manufacturers use for their own private registers, and that nothing polls by default.

Every boiler implements only a subset of these Data-IDs, and manufacturers rarely document which ones. Ask an unsupported ID and the boiler answers Unknown-DataId; ask a supported one and it returns the value. A read scan is simply: ask all 256 and write down what comes back. Whether an ID is supported is the message type in the reply — READ_ACK (type 4) means supported, UNKNOWN_DATAID (type 7) means not, no reply means the boiler ignored it.


2. Why I Went Looking

My boiler reports gas consumption in the Viessmann app, which always puzzled me. The published OpenTherm specification defines no standard Data-ID for cumulative gas consumption — nothing in the spec reports cubic metres. And at least on this boiler, I couldn't find any OpenTherm register that exposes it. So whatever the app is showing, that figure simply isn't available over the OpenTherm interface.

That made me wonder what else the boiler keeps to itself. The ESPHome component already maps nearly all the documented registers, so any standard data I wanted would already be in Home Assistant. The only unexplored territory was the manufacturer-specific space the component can't see: the reserved slots tucked inside 0–127, and the whole undocumented 128–255 half that some boilers quietly use — at least one Daikin implementation, for example, uses ID 162 for its DHW setpoint. Nothing polls those for you, so I scanned all 256 myself to see if my boiler hides anything there.


3. Why the Component's Entity List Isn't Enough

The obvious first move is to enable every sensor the ESPHome opentherm component offers and see which populate. That gets you most of the way — the component maps almost the entire documented protocol. But it has a blind spot: it has no entity for manufacturer-reserved IDs, so it never asks for them. If a boiler answers something in that reserved space, the component simply can't surface it. That blind spot — inside 0–127 and across all of 128–255 — is the only reason a raw scan is worth running.


4. The Scanner: Sweeping All 256 IDs

The component is an OpenTherm master, and normally it only asks the boiler for the registers you've configured entities for — there's no built-in "read everything" mode. But it lets you intercept each outgoing request in a before_send lambda and rewrite the Data-ID before it goes out. That's the whole trick: rewrite the ID on every request so it walks through 0, 1, 2 … 255 instead of the handful you configured, and classify each reply in before_process_response.

globals:
  - id: scan_id
    type: int
    restore_value: no
    initial_value: '0'

opentherm:
  id: ot_bus
  in_pin: GPIO21
  out_pin: GPIO26
  sync_mode: true

  before_send:
    then:
      - lambda: |-
          // hijack every outgoing frame -> probe the next id as a READ
          x.id = (uint8_t) id(scan_id);
          id(scan_id) = (id(scan_id) + 1) % 256;   // full 8-bit sweep

  before_process_response:
    then:
      - lambda: |-
          int t = (int) x.type;                     // 4 = READ_ACK, 7 = UNKNOWN_DATAID
          if (t == 7)
            ESP_LOGW("otscan", "id=%d NOT supported (Unknown-DataId)", (int) x.id);
          else if (t == 4)
            ESP_LOGI("otscan", "id=%d supported u16=%u", (int) x.id, x.u16());

A couple of extra details in the published config: it advances the counter inside before_send so a timed-out ID can't stall the sweep, and it mirror-checks each upper-half answer against its lower-half twin (id − 128) to catch stacks that secretly mask the ID to 7 bits. On my setup there was no masking — the component genuinely sent all 256 IDs. The whole thing is read-only: it never writes to the boiler and never fires heating, so it's safe to flash, log, and then flash your normal config back.

Each frame takes about a second, so a full 0–255 sweep is ~4–5 minutes; run it 10–15 minutes to catch registers that only answer intermittently. It works on any OpenTherm interface the ESPHome opentherm component supports — I ran it on a HomeMaster OpenTherm Gateway (an ESP32 board running ESPHome), but the technique isn't tied to that hardware. Full config: opentherm-SCAN.yaml.


5. Reading the Log

With the logger at DEBUG, the component prints every request and decoded reply, and the otscan lines tag the verdict. A slice showing a supported register, an unsupported one, an OEM hit, and the empty upper half:

id= 18  supported  u16=435  f88=1.70      <- CH pressure, 1.70 bar
id= 25  supported  u16=15411 f88=60.20    <- flow temp
id= 28  NOT supported (Unknown-DataId)    <- return temp: this boiler doesn't have it
id= 93  supported  u16=2390  f88=9.34      <- OEM, no component entity (a "find")
id=116  NOT supported (Unknown-DataId)    <- burner-starts counter: not exposed
id=162  NOT supported (Unknown-DataId)    <- Daikin's DHW id; Unknown here

The full raw log and a per-ID results file are published alongside the scanner: articles/opentherm-scan.


6. What This Boiler Actually Answered

Across the full 0–255 space, my Viessmann Vitodens 100 (BHC 0122) answered 25 IDs — all of them in the documented 0–127 half. On this boiler, every ID from 128–255 returned Unknown-DataId or timed out, with no aliasing; even ID 162 (the register at least one Daikin uses for its DHW setpoint) returns Unknown here. So there was nothing to find in the upper half of this particular unit.

Grid of Data-IDs 0-255 for the Vitodens 100: green supported cells only in the top half, the entire 128-255 half grey
The full 0–255 space for this boiler. Green = answered, blue = write-only (a read isn't expected), yellow = no reply, grey = Unknown-DataId. Everything answered sits in the top half; the whole block below the dashed line is empty.

Here are the 25 registers it answered (the full three-column reference — spec meaning, ESPHome entity name, and this boiler's answer for every ID — is in opentherm-dataid-map.md):

IDWhat it isValue read
0Status flagsCH/DHW/flame bits
3Slave config / MemberIDHB81 / LB33
5Fault flagsno fault
6Remote parameter flagsHB3 / LB3
9Remote override room setpoint0.00
15Max boiler capacity / min modulation25 kW / 10%
17Relative modulation level0%
18CH water pressure1.70 bar
19DHW flow rate0.0 l/min
25Boiler flow temperature60.2 °C
26DHW temperature55.7 °C
33Exhaust temperature (integer)50 °C
35Fan speed (integer)HB28 / LB28
48DHW setpoint bounds60 / 30
49Max CH setpoint bounds82 / 5
56DHW setpoint50 °C
57Max CH setpoint20 °C
93OEM (undocumented)9.34
94OEM (undocumented)23.19
95OEM (undocumented)16.21
99DHW mode0
100Remote override function0
115OEM diagnostic code0
125OpenTherm version (device)4.1
127Product versionHB37 / LB123

The honest takeaway: 22 of the 25 are registers the component already maps, so for the standard data the scan told me almost nothing I couldn't have gotten by enabling those entities by hand. It only earned its keep in one place — the reserved slots the component can't reach.


7. The Three OEM Registers: 93, 94 and 95

Three consecutive IDs in the manufacturer-reserved range answered with real data: 93 ≈ 9.3, 94 ≈ 23.2, 95 ≈ 16.2 (they drift over time). The component labels all three INVALID — no entity, so without the scan I'd never have known they exist. 95 only answered on the longer run, so it's worth sweeping for several minutes rather than one pass.

They are, however, clearly not gas: small, steady values with no public documentation, nothing that looks like a consumption counter. I don't know what they are — that's Viessmann-specific and undocumented. But they're live, and they're a good illustration of the point: the component covers the standard; the reserved space needs a scan.


8. Putting an Undocumented Register on a Dashboard

If your own scan turns up something interesting in the reserved space, here's how to surface it — the pattern I used to watch 93/94/95 in Home Assistant. Since the component has no entity for these IDs, you can't declare them the normal way. Instead, keep your normal config and "borrow" an occasional frame to probe the OEM id, then publish the reply into a template sensor:

# Read the undocumented OEM registers (93, 94, 95) into Home Assistant.
# Normal polling continues; we just borrow ~1 frame in 30 to probe them.
globals:
  - id: probe_n
    type: int
    restore_value: no
    initial_value: '0'

sensor:
  - platform: template
    id: oem_93
    name: "Boiler OEM register 93"
    accuracy_decimals: 2
  - platform: template
    id: oem_94
    name: "Boiler OEM register 94"
    accuracy_decimals: 2
  - platform: template
    id: oem_95
    name: "Boiler OEM register 95"
    accuracy_decimals: 2
  # ... your normal `platform: opentherm` sensors stay here ...

opentherm:
  id: ot_bus
  in_pin: GPIO21
  out_pin: GPIO26

  before_send:
    then:
      - lambda: |-
          // every ~30th frame, borrow it to probe an OEM id (rotating 93/94/95)
          id(probe_n) = (id(probe_n) + 1) % 90;
          if (id(probe_n) == 0)       x.id = 93;
          else if (id(probe_n) == 30) x.id = 94;
          else if (id(probe_n) == 60) x.id = 95;

  before_process_response:
    then:
      - lambda: |-
          if ((int) x.type != 4) return;              // only accept READ_ACK
          if (x.id == 93)      id(oem_93).publish_state(x.f88());
          else if (x.id == 94) id(oem_94).publish_state(x.f88());
          else if (x.id == 95) id(oem_95).publish_state(x.f88());
          // f88() matches the scan (9.3 / 23.2 / 16.2). If a register is really
          // an integer, use x.u16() or x.s16() instead.

Now the three OEM registers graph in Home Assistant like any other sensor — which is the practical way to guess what an unknown register tracks: watch how it moves against boiler state. You don't know a reserved register's data type up front, so try x.f88() (fixed-point) and x.u16()/x.s16() (integers) and keep whichever produces sane numbers.


9. Gotchas That Will Fool You

Write-only IDs read back as "Unknown" — and that's expected. The CH setpoint (ID 1) is a register the master writes. A read scan asks to read it, so the boiler answers Unknown-DataId. It looks unsupported, but control works fine: the gateway writes ID 1 and the boiler obeys. In Home Assistant you'll even see the CH Setpoint show "Unknown" as its current value while every change you make still takes effect.

Some values are integers, not f8.8. Exhaust temperature (33) and fan speed (35) are plain integers. Decode them as f8.8 and the exhaust reads 0.20 instead of 50 °C. The component lets you fix this per sensor with a data_type override (e.g. data_type: "u16").

Vendors do use the upper half. Some boilers move standard functions above 127 — at least one Daikin implementation uses ID 162 instead of 56 for the DHW setpoint. Mine answers Unknown on 162, but that example is exactly why the scan covers the full 0–255 rather than stopping at 127. The same before_send hook is how you'd remap such an ID into a normal entity once you find it.


10. The Takeaway: Full, Local Visibility Into Your Boiler

The useful part isn't this particular boiler — it's the method. Every boiler implements a different subset, and the only way to know what's in your manufacturer's reserved registers is to look. Maybe you find nothing; maybe you find the value you've been missing. Because the gateway is open and ESPHome-native, the scanner and the dashboard pattern above give you a clean, read-only, fully local way to check — on any OpenTherm interface the ESPHome component supports, with no cloud and no vendor black box.