Device Mapping Library¶
The rtl_433 integration turns the JSON fields emitted by rtl_433 into Home Assistant entities using a data-driven device library: a set of YAML files that map each rtl_433 field name to a Home Assistant entity descriptor.
Use device mappings to add support for fields your rtl_433 hardware already reports, without editing the integration code or waiting for a new release. You can add mappings from the Home Assistant UI; contributors can add mappings to pyrtl_433.
The YAML files and their loader are part of
pyrtl_433, the integration's runtime dependency — not this repository. The authoritative YAML schema reference is the pyrtl_433 device-library reference.
Adding device mappings¶
You can extend or correct device mappings directly from the Home Assistant UI.
Settings → Devices & Services → rtl_433 → Configure (the gear icon) → Device mappings
The Device mappings step opens Home Assistant's built-in YAML editor pre-filled
with that hub's current mappings. You edit mappings as YAML, using the
same schema as pyrtl_433: top-level keys are rtl_433
field names, values are entry mappings. They may optionally include a skip_keys: list to add extra
skip entries, and an optional models: block to
add or override model-scoped descriptors.
Mappings are stored per hub in that hub's config entry — each hub has its own independent set. The editor:
- Blocks invalid YAML syntax before you can submit.
- Validates the mapping schema on save and rejects bad input with a per-field error naming the offending field and the reason.
- Reloads the hub automatically.

To find fields your device reports that do not yet have entities, download
diagnostics for the hub from Settings → Devices & Services → the rtl_433
integration → ⋮ → Download diagnostics and inspect unmatched_field_keys. Each
key is either a candidate for a mapping or, if it is genuinely noise/identity
data, an entry for skip_keys:.
Comments and formatting are not preserved. The editor returns parsed YAML, so any comments or hand-formatting in what you paste are dropped once the mappings are stored. The mapping content is preserved exactly.
Mappings you add in the UI layer on top of pyrtl_433:
- A field present in both the UI mapping and pyrtl_433: the UI mapping wins (full entry replacement, not a deep merge), so you can correct a unit, device class, or transform.
- A field present only in the UI mapping: it is added as a new mapping.
skip_keysentries in the UI mapping are unioned with the upstream skip list.- A
models:block in the UI mapping is merged per(model, field_key): a UI model-scoped entry replaces the upstream one for the same model and field, while other upstream model fields are preserved. Per the precedence rules, a model-scoped entry (from either source) always beats a global one — so an upstreammodels:entry outranks a UI global entry for a matching model.
Paste a mapping like the following into the Device mappings editor. This
example adds an unmapped field and re-classifies battery_ok as a low-battery
binary problem sensor:
custom_field_C:
platform: sensor
device_class: temperature
unit_of_measurement: "°C"
state_class: measurement
name: Custom Probe
value_transform: { round: 1 }
object_suffix: TC
battery_ok:
platform: binary_sensor
device_class: battery # HA "battery": on == problem (low)
unit_of_measurement: null
state_class: null
name: Battery
payload: { on: "0", off: "1" } # battery_ok == 0 means low -> problem
entity_category: diagnostic
object_suffix: B
skip_keys: entries work in the editor exactly as in pyrtl_433, and so do
model-scoped mappings — the way to correct a mapping for one specific device
model rather than every device that emits the field. Nest the per-model
descriptors under a models: block keyed by the
exact rtl_433 model string; a model-scoped entry beats any global one for that
model (see precedence). For example, to rename
one model's temperature sensor and round it more finely than the global default,
leaving every other model untouched:
models:
Acurite-Tower: # exact rtl_433 model string
temperature_C:
platform: sensor
device_class: temperature
unit_of_measurement: "°C"
state_class: measurement
name: Outdoor temperature
value_transform: { round: 2 }
object_suffix: T
Mapping overrides are global or model-scoped only — they apply to every device of a model, not a single physical unit. To change settings for one specific unit (its availability timeout, meter calibration, or motion clear delay), use the Device settings page instead.
Mapping entry schema (summary)¶
The full schema is documented in the pyrtl_433 device-library reference.
Top-level keys are rtl_433 field names exactly as they appear in the JSON
event (temperature_C, wind_avg_km_h, battery_ok). Names are matched
case-sensitively, and not every decoder uses snake_case — SCMplus emits
Consumption, ERT-SCM emits consumption_data. A key that differs only in case
silently never matches and will not show up in Home Assistant.
temperature_C:
platform: sensor # sensor | binary_sensor | event
device_class: temperature # an HA device class, or null
unit_of_measurement: "°C" # or null
state_class: measurement # measurement | total | total_increasing | null
name: null # null/omitted => HA names it from device_class
value_transform: { round: 1 } # numeric transform (sensor only)
object_suffix: T # short, STABLE unique-id token
platform and object_suffix are the only truly required attributes;
device_class, unit_of_measurement and state_class are required but may be
null. The rest are optional: value_transform (float / int / scale /
offset / round, applied in that order) for sensors, payload:
{ on: <raw>, off: <raw> } for binary sensors, event_map for event entities,
plus clear_delay, event_driven, force_update, entity_category,
enabled_by_default and icon. A separate _skip_keys.yaml in the library
lists identity and transport fields (model, id, channel, mic, mod,
protocol, …) that must never become entities; a UI mapping can add to that
list with a top-level skip_keys: sequence.
object_suffix is part of every entity's unique ID, so changing it orphans
existing entities.
Two attributes change how an entity behaves in Home Assistant rather than just
how it looks: clear_delay (Motion / occupancy) and
event_driven (Availability classification).
Motion / occupancy¶
PIR / occupancy decoders (Interlogix, Risco Agility, Kerui, …) emit motion
only on detection (raw value 1) and never send an off — the hardware is
detect-only. So motion is a binary_sensor (device class occupancy) whose
payload declares only an on token; the off state is synthesized by a
timer rather than received:
motion:
platform: binary_sensor
device_class: occupancy
name: Motion
payload: { on: "1" } # detect-only: no off token
clear_delay: 90 # synthesize off 90 s after the last detection
object_suffix: motion
The clear_delay attribute (seconds) drives the synthesized off: the sensor
turns on on each detection and is auto-cleared to off after the delay elapses
with no re-detection. Every fresh detection reschedules the timer, so the
off window restarts on each retrigger. The upstream default is 90 seconds.
When Home Assistant restarts, a motion sensor comes back off rather than on, since no timer would be left to clear a restored on. A device that has never reported stays unknown until its first detection.
Per-device override. The delay can be tuned per device in Device settings — Settings → Devices & Services → rtl_433 → Configure (the gear icon) → (device step) exposes a Motion clear delay (seconds) field. Leave it blank to use the 90-second default.
Availability classification¶
A device is marked unavailable when it is not received past its availability timeout. RF devices signal availability only by transmitting, so the timeout is resolved per device, in this order:
- A per-device override.
- An explicit hub default.
- A device-class default derived from the device's known fields.
The known fields are both its adopted (persisted) fields and its latest payload, so an event-driven device that has been silent since a restart is still classified correctly before it next transmits (rather than briefly expiring its battery at the periodic default).
The class default has two outcomes:
- Event-driven → never-expire (the device, and all its entities including
battery, stay available once seen). These devices transmit only on a state
change — a door opening, motion, a button press — so any finite silence
timeout would eventually misfire and wrongly hide a healthy device. A field is
event-driven when it uses
platform: eventor setsevent_driven: true(e.g.motion,contact_open,reed_open,closed,alarm). The set is derived from the active library (upstream descriptors plus user mappings). - Periodic → a finite default (10 min). Everything else — temperature, humidity, power, etc. — which reports on a regular cadence.
Diagnostic fields such as battery_ok do not decide the class on their own. If a
device also has an event-driven field, the whole device uses the event-driven
default, so its battery and other entities stay available between events. An
explicit per-device or hub timeout always overrides the class default.
Because an event-driven device's availability no longer signals freshness, its per-device Last seen timestamp sensor is enabled by default, and is disabled for periodic devices. It stays available once seen, so "no signal for N minutes" automations keep working.
Event entities¶
platform: event is for momentary, fire-and-forget RF fields — a remote
button, a doorbell press — that have no steady "on" / "off"
state to track. Each genuine transmission fires one Home Assistant
event, and the entity
stays available between presses (no faked "off"). Event entries live in their
own library file, events.yaml:
button:
platform: event
device_class: button # an EventDeviceClass
name: Button
object_suffix: button
How event entries differ from sensor / binary_sensor:
- By default the fired
event_typeis the stringified field value (str(value)). There is nopayloadand novalue_transform— the raw value is stringified directly. - By default
event_typesare auto-populated, not declared. Each newly observed value is recorded as a valid type the first time it is seen and persisted per device, so after a restart the entity rebuilds knowing the types it has seen before. You never list them in the YAML. - A field whose value varies (a remote that reports which button was pressed) auto-populates several types; a field that only ever emits one distinct value fires that one type on every transmission.
- The fired event carries no extra attributes — the type is the only payload.
device_classis anEventDeviceClass(button,doorbell).
event_map: naming raw values¶
The optional event_map attribute overrides the default stringified behavior:
it maps a stringified raw value → named event_type. When present:
- A transmission whose raw value is in the map fires the mapped type;
values not in the map still pass through as
str(value). - The mapped types are declared up front in
event_types(in map order), rather than only appearing once observed — so adevice_triggerlists them even before the first press.
The doorbell is the upstream example. secret_knock is emitted on every
press: raw 0 is a regular single press and raw 1 is a "secret knock" (the
button pressed three times rapidly). It maps both onto Home Assistant's doorbell
standard:
secret_knock:
platform: event
device_class: doorbell
name: Doorbell
object_suffix: secret_knock
event_map:
"0": ring # DoorbellEventType.RING — the HA standard type
"1": secret_knock # custom type for the 3x-rapid "secret knock"
The upstream events.yaml has two examples:
| Field | device_class |
Notes |
|---|---|---|
button |
button |
Remote / key-fob button code; the value is the pressed code, so distinct presses auto-populate several types. |
secret_knock |
doorbell |
Honeywell ActivLink doorbell press; emitted on every press (0 regular → ring, 1 secret knock → secret_knock) via event_map. |
Model-scoped mappings (models:)¶
The top-level keys above are the global defaults: a temperature_C entry
applies to every device that emits temperature_C. Some fields, though, need
a different descriptor depending on the device model — most notably the
utility-meter consumption counters (Consumption, consumption_data), whose
unit and scale are not carried in the RF signal and differ between meter
models.
For those, a library file (or a UI mapping) may carry an optional top-level
models: block keyed by the exact rtl_433 model string, each model mapping
to a table of field_key → descriptor using the same per-field attributes as a
global entry:
models:
Some-Model-Name: # an exact rtl_433 `model` string
consumption_data:
platform: sensor
device_class: energy
unit_of_measurement: kWh
state_class: total_increasing
name: Consumption
value_transform: { scale: 1 }
object_suffix: consumption
models: is additive and optional, and models is a reserved top-level key —
you cannot have a field literally named models. See the
schema reference for the full rules.
Lookup resolution order¶
When the integration builds an entity for a field on a device, it resolves the descriptor most-specific first:
- The model-scoped entry for
(model, field_key), if the device's model has amodels:block with that field. - Otherwise the global flat entry for
field_key. - Otherwise the field is unmapped → no entity.
So a models: entry only affects the model it names; every other model keeps the
global descriptor for that same field.
Precedence (specificity-first)¶
Combined with UI mappings and the per-device meter calibration (the Device settings page), the full precedence for a single field on a single device is, highest to lowest:
- Per-device calibration (commodity + base unit + scale, set in the options flow) — applies only to the consumption field(s) of the one calibrated device.
- Model-scoped entry — UI
models:entry, else upstreammodels:entry. - Global flat entry — UI flat key, else upstream flat key.
- Unmapped → no entity.
The rule is specificity-first: a model-scoped entry always beats a global one
regardless of source. In particular an upstream models: entry outranks a
UI global entry for a matching model. Within each tier the UI mapping beats
pyrtl_433. (This falls out naturally from the merge: the UI mapping replaces
the upstream entry within a tier, and the lookup checks the model tier before
the global tier.)
No speculative real-meter mappings ship. Because a meter's consumption unit/scale is not knowable from the signal, pyrtl_433 does not carry a guessed
models:consumption mapping for any real model — a wrong scale would silently corrupt real Energy data. The example below is purely illustrative; for a real meter use the per-device calibration step in the Device settings page (see Utility-meter calibration) until a model's unit/scale is authoritatively known.
Contributing device mappings¶
The easiest way to add support for your own installation is Adding device mappings — no code, no release wait.
If you want the mapping to ship for everyone, contribute it to
pyrtl_433, not to this
repository: the YAML library moved there in pyrtl_433 0.3.0 so every rtl_433
consumer shares one curated set of mappings. The files live under
pyrtl_433/library/data/, one themed file per domain (temperature.yaml,
humidity_moisture.yaml, power_electrical.yaml, events.yaml, misc.yaml, …,
plus _skip_keys.yaml). A merged mapping reaches Home Assistant users on the
next pyrtl_433 release and the integration's requirement bump.
Add-a-mapping workflow¶
- Find the field name. Watch your rtl_433 stream, or download diagnostics
for the hub from Settings → Devices & Services → the rtl_433 integration →
⋮ → Download diagnostics. The export lists unmapped fields your hardware has
sent in
unmatched_field_keys. Each key is either a candidate for a mapping or, if it is genuinely noise/identity data, an entry for_skip_keys.yaml. rtl_433 field names are case-sensitive and unit-suffixed (temperature_C, nottemperature). - Add the entry in pyrtl_433. Pick the themed file that matches the field's
domain (or
misc.yaml), key the entry by the exact field name, and fill in the attributes following the schema reference. Copy a similar existing entry as a template. That repository's own contributing guide covers validation and its test suite. - Add a fixture here too. Once the mapping is merged and released, add a
fixture under
tests/fixtures/in this repository with a real event from your device.tests/test_fixture_coverage.pysweeps every fixture against the installed library and fails if any field in it has no descriptor and no skip-key entry — that sweep is what proves the mapping actually matches the devices this integration builds entities for.
Field names are matched exactly, and a mismatch is silent
A key that differs from the wire name by so much as its case produces no
entity, no warning, and no error — the sensor simply never appears. SCMplus
emits Consumption (CamelCase) while ERT-SCM emits consumption_data
(snake_case); both decoders are in the same protocol family. Copy the name
from the decoder's data_make() call, or better, from a real event.
For the SCM family and Acurite this is checked against rtl_433's actual
output: tests/fixtures/generated/ holds events decoded from real .cu8
captures by scripts/regen_capture_fixtures.py, and CI re-decodes and diffs
them. See tests/fixtures/generated/README.md if you want to extend that
to another protocol.
Notes on fields that cannot be expressed declaratively¶
The upstream mappings table includes two device_automation entries —
channel and button — that publish MQTT device triggers (e.g.
button_short_release) rather than entities. These have no sensor /
binary_sensor equivalent in this schema:
channelis already a device-identity key and lives in_skip_keys.yaml.buttonis modelled as an event entity instead of an MQTT device trigger — see the library'sevents.yaml.
Everything else from the upstream table is ported faithfully.