Skip to main content

Topic Mapper & Ecosystem Profiles

The topic mapper (hc-topic-map) lets non-standard MQTT devices — Tasmota, Shelly, Zigbee2MQTT, ESPHome, and any homebrew firmware — integrate with HomeCore without writing a dedicated plugin. It works by translating their native topic schema to the HomeCore canonical schema using a profile file.


How it works​

When the MQTT client receives a message on any topic, the EcosystemRouter checks whether any loaded profile matches:

  1. Match — the topic matches a state_topics, availability_topics, or cmd_topics pattern in a profile.
  2. Extract — the {device} wildcard in the pattern captures the device name.
  3. Field map — JSON keys are renamed from the device's native names to HomeCore canonical attribute names.
  4. Coerce — type conversions are applied ("ON" → true, 100 → percentage float, etc.).
  5. Publish — the translated payload is forwarded to homecore/devices/{prefix}{device}/state as if a plugin had published it.

The device is registered automatically on first state message. No manual registration is needed.


Activating a profile​

Profiles live in config/profiles/. Only profiles in this directory are loaded — files in config/profiles/examples/ are reference copies and are not auto-loaded.

To activate a profile, copy it from examples/:

cp config/profiles/examples/tasmota.toml config/profiles/tasmota.toml
cp config/profiles/examples/zigbee2mqtt.toml config/profiles/zigbee2mqtt.toml

HomeCore loads all .toml files in config/profiles/ at startup. Multiple profiles can be active simultaneously.


Profile structure​

A profile is a TOML file with three sections: [ecosystem] metadata, [[ecosystem.state_topics]] (inbound state), [[ecosystem.availability_topics]] (inbound availability), and [[ecosystem.cmd_topics]] (outbound commands).

Minimal example​

[ecosystem]
name = "my-firmware"
description = "Custom firmware devices"
prefix = "myfirmware_"

[[ecosystem.state_topics]]
pattern = "myfirmware/{device}/status"

[ecosystem.state_topics.field_map]
relay = "on"
temp = "temperature"

[ecosystem.state_topics.coerce]
on = "onoff_to_bool" # "ON"/"OFF" → true/false

This single stanza maps any myfirmware/+/status topic to homecore/devices/myfirmware_{device}/state with the attribute rename and type coercion applied.


Pattern syntax​

SyntaxMeaning
{device}Captures one or more path segments as the device name
+MQTT single-level wildcard (not captured)
Literal textMust match exactly

The {device} capture becomes the second part of the HomeCore device ID: {prefix}{device}.

Examples:

PatternTopicCaptured {device}HomeCore device ID (prefix=t_)
stat/{device}/POWERstat/kitchen/POWERkitchent_kitchen
zigbee2mqtt/{device}zigbee2mqtt/living_room_lightliving_room_lightt_living_room_light
devices/{device}/sensors/datadevices/esp32-01/sensors/dataesp32-01t_esp32-01

Field map​

field_map renames JSON keys from the device's schema to HomeCore canonical attribute names — the shared device vocabulary (on, brightness_pct 0–100, color_temp in kelvin, open, occupied, battery_pct, temperatures in °C, …). Convert the device's scale with coerce (below) so the published value is in the shared unit.

[ecosystem.state_topics.field_map]
state = "on" # Z2M "state" → HomeCore "on"
brightness = "brightness_pct" # renamed; scale converted by coerce below
temperature = "temperature" # pass-through
POWER = "on" # Tasmota "POWER" → HomeCore "on"
"ENERGY.Power" = "power_w" # nested path using dot notation

Keys not listed in field_map pass through with their original names. Keys listed in field_map are renamed; their original names do not appear in the published state.

Nested paths​

Use dot notation to extract nested JSON values:

"ENERGY.Power"   = "power_w"    # { "ENERGY": { "Power": 45.2 } } → { "power_w": 45.2 }
"AM2301.Temperature" = "temperature"

Coerce (type conversions)​

coerce applies a named conversion function to an attribute after field renaming.

[ecosystem.state_topics.coerce]
on = "onoff_to_bool" # "ON"/"OFF" → true/false
brightness_pct = "pct255_to_100" # 0–255 integer → 0–100 percentage
color_temp = "mired_to_kelvin" # mired → Kelvin
battery_pct = "scalar_int" # ensure integer type
Coerce nameInputOutput
onoff_to_bool"ON" / "OFF"true / false
bool_to_onofftrue / false"ON" / "OFF"
scalar_boolAny non-zero / non-nulltrue / false
scalar_intNumberInteger
scalar_floatNumberFloat
pct255_to_1000–2550–100 percentage
pct100_to_2550–1000–255
pct254_to_100Zigbee's 0–254 level0–100 percentage (on at the lowest level never reads 0)
pct100_to_2540–1000–254 (above 0 never sends 0)
mired_to_kelvinMired (integer)Kelvin
kelvin_to_miredKelvinMired
invert_booltrue / falsefalse / true (a contact that reports true when closed → open)
lock_to_bool"LOCK" / "locked"true, anything else false
bool_to_locktrue / false"LOCK" / "UNLOCK"
hvac_action_words"heat" / "cool" / "fan_only""heating" / "cooling" / "fan"
uppercase"toggle""TOGGLE"
ms_to_secs / secs_to_msMilliseconds / secondsSeconds / milliseconds

Map to homeCore's shared names and units — brightness_pct 0–100, color_temp in kelvin, open, occupied, battery_pct, temperatures in °C — and convert the device's own scale here, so automations and the app treat a mapped device like any plugin's.

In cmd_topics, a coercion is looked up by homeCore's attribute name first, then by the device's key. That lets two attributes that go to the same key convert differently — Zigbee2MQTT's state takes both on (bool_to_onoff) and locked (bool_to_lock).


Availability topics​

[[ecosystem.availability_topics]]
pattern = "tele/{device}/LWT"
payload = "raw_string" # treat payload as a plain string (not JSON)

[ecosystem.availability_topics.value_map]
Online = true
Offline = false

For JSON availability payloads:

[[ecosystem.availability_topics]]
pattern = "zigbee2mqtt/{device}/availability"
json_field = "state" # extract .state from JSON payload

[ecosystem.availability_topics.value_map]
online = true
offline = false

Command topics (outbound)​

cmd_topics define how HomeCore commands are translated back to the device's native topic/format.

[[ecosystem.cmd_topics]]
source = "homecore/devices/tasmota_{device}/cmd"
target = "cmnd/{device}/POWER"
attribute = "on" # extract only "on" from the cmd JSON

[ecosystem.cmd_topics.coerce]
on = "bool_to_onoff" # true → "ON", false → "OFF"

The source pattern uses the same {device} wildcard as state_topics. When a SetDeviceState action publishes to homecore/devices/tasmota_kitchen/cmd, the mapper captures device=kitchen, extracts the on attribute, coerces it, and publishes "ON" to cmnd/kitchen/POWER.


Rhai transforms​

When field_map is not expressive enough, use a transform — a Rhai function that receives the raw payload and returns a Rhai map of attribute key-value pairs.

[[ecosystem.state_topics]]
pattern = "tele/{device}/SENSOR"
transform = "parse_my_sensor"

The Rhai function is defined in a .rhai file in config/profiles/:

// config/profiles/my-transforms.rhai

fn parse_my_sensor(payload) {
let p = parse_json(payload);
#{
"temperature": p.SHT30.Temperature,
"humidity": p.SHT30.Humidity,
"battery_pct": (p.battery / 255.0 * 100.0).to_int(),
}
}

.rhai files in config/profiles/ are loaded automatically alongside profile TOML files. Function names must be unique across all loaded transform files.

Rhai functions available in transforms​

FunctionDescription
parse_json(str)Parse a JSON string into a Rhai map
to_json(val)Serialize a Rhai value to JSON string
All standard Rhai arithmetic and string operations

Reference profiles​

Four reference profiles ship in config/profiles/examples/:

tasmota.toml​

Covers single relay, dual relay, power monitoring (Sonoff Pow, Gosund SP111), temperature/humidity sensors (AM2301, DHT22, SHT3x, DS18B20), and dimmer control. Availability via LWT (tele/{device}/LWT).

Device IDs: tasmota_{topic_name} (topic name set via Tasmota console: Topic mydevice)

shelly-gen1.toml​

Gen1 Shelly devices (Shelly1, Shelly2.5, ShellyPlug-S). Relay state, power monitoring, availability.

Device IDs: shelly_{device_id} (device ID is the Shelly's mDNS name, e.g. shelly1-AABBCC)

shelly-gen2.toml​

Gen2+ Shelly devices (Plus, Pro, Mini series). Uses the Gen2 RPC-based MQTT topic schema which differs significantly from Gen1.

Device IDs: shelly2_{device_id}

zigbee2mqtt.toml​

Full attribute mapping for Zigbee2MQTT in homeCore's shared names: lights (on, brightness_pct from Zigbee's 0–254, color_temp in kelvin, color_xy; commands also take color_rgb, transition_ms and {"action": "toggle"}), sensors (temperature, humidity, pressure, illuminance, occupied, open, water_detected, battery_pct, tampered, co2), power monitoring, covers (position, tilt_pct), locks (locked), thermostats (temperature, heat_setpoint, cool_setpoint, hvac_mode, hvac_action, fan_mode), signal quality. Availability via {device}/availability JSON payload.

Device IDs: zigbee_{z2m_friendly_name} (use underscored names in Z2M for clean IDs)


Writing a new profile​

  1. Create config/profiles/examples/my-ecosystem.toml.

  2. Define the [ecosystem] block with name, description, and prefix.

  3. Add one [[ecosystem.state_topics]] entry per distinct topic pattern the device publishes. Use field_map to rename keys to HomeCore canonical names. Use coerce for type conversions.

  4. Add [[ecosystem.availability_topics]] if the device publishes an availability/LWT topic.

  5. Add [[ecosystem.cmd_topics]] for each command the device accepts. Map from homecore/devices/{prefix}{device}/cmd to the device's command topic.

  6. Test by copying to config/profiles/ and watching the server logs:

    RUST_LOG=info,hc_topic_map=debug cargo run -p homecore

    Debug log shows every topic match attempt, the captured {device} value, the pre- and post-transform payload, and the resulting HomeCore device ID.

  7. Commit the file to config/profiles/examples/ for reference. Users copy it to config/profiles/ to activate.


Debugging topic mapper issues​

# Show all profile match attempts and transforms
RUST_LOG=info,hc_topic_map=debug cargo run -p homecore

# Run topic mapper tests
cargo test -p hc-topic-map
cargo test -p hc-topic-map -- --nocapture

Common problems:

SymptomCauseFix
Device never appearsProfile pattern doesn't match the device's topicEnable hc_topic_map=debug and look for "no match" lines
Attributes missingKey not in field_map and auto-passthrough dropped itAdd the key to field_map (or remove field_map entirely for full passthrough)
on attribute is a string not boolMissing coerceAdd on = "onoff_to_bool" to [coerce]
Commands not reaching devicecmd_topics pattern wrong or attribute name mismatchCheck source matches the HomeCore device ID exactly
Rhai transform error at startupSyntax error in .rhai fileCheck server startup log for script error with line number