[teleinfo] Add predefined properties for main tags of TIC standard and historical (#9167)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
Frédéric Metrich
2026-10-10 08:31:06 -04:00
committed by GitHub
co-authored by Claude Opus 4.6 J. Nick Koston
parent f9a81869ef
commit 721228c1d2
6 changed files with 372 additions and 11 deletions
+1 -1
View File
@@ -570,7 +570,7 @@ esphome/components/tca9555/* @mobrembski
esphome/components/tcl112/* @glmnet
esphome/components/tcp_uart/* @Bascht74
esphome/components/tee501/* @Stock-M
esphome/components/teleinfo/* @0hax
esphome/components/teleinfo/* @0hax @FredM67
esphome/components/tem3200/* @bakerkj
esphome/components/template/alarm_control_panel/* @grahambrown11 @hwstar
esphome/components/template/datetime/* @rfdarter
+1 -1
View File
@@ -4,7 +4,7 @@ import esphome.config_validation as cv
from esphome.const import CONF_ID
from esphome.types import ConfigType
CODEOWNERS = ["@0hax"]
CODEOWNERS = ["@0hax", "@FredM67"]
DOMAIN = "teleinfo"
MULTI_CONF = True
+136 -8
View File
@@ -1,10 +1,32 @@
import esphome.codegen as cg
from esphome.components import sensor
import esphome.config_validation as cv
from esphome.const import (
CONF_DEVICE_CLASS,
CONF_ICON,
CONF_ID,
CONF_STATE_CLASS,
CONF_UNIT_OF_MEASUREMENT,
DEVICE_CLASS_APPARENT_POWER,
DEVICE_CLASS_CURRENT,
DEVICE_CLASS_ENERGY,
DEVICE_CLASS_POWER,
DEVICE_CLASS_REACTIVE_ENERGY,
DEVICE_CLASS_VOLTAGE,
ICON_FLASH,
STATE_CLASS_MEASUREMENT,
STATE_CLASS_TOTAL_INCREASING,
UNIT_AMPERE,
UNIT_KILOVOLT_AMPS,
UNIT_KILOVOLT_AMPS_REACTIVE_HOURS,
UNIT_KILOWATT,
UNIT_KILOWATT_HOURS,
UNIT_MILLIAMP,
UNIT_MILLIVOLT,
UNIT_VOLT,
UNIT_VOLT_AMPS,
UNIT_VOLT_AMPS_REACTIVE_HOURS,
UNIT_WATT,
UNIT_WATT_HOURS,
)
from esphome.types import ConfigType
@@ -13,19 +35,125 @@ from .. import CONF_TAG_NAME, CONF_TELEINFO_ID, TELEINFO_LISTENER_SCHEMA, telein
TeleInfoSensor = teleinfo_ns.class_("TeleInfoSensor", sensor.Sensor, cg.Component)
CONFIG_SCHEMA = sensor.sensor_schema(
TeleInfoSensor,
unit_of_measurement=UNIT_WATT_HOURS,
icon=ICON_FLASH,
accuracy_decimals=0,
device_class=DEVICE_CLASS_ENERGY,
state_class=STATE_CLASS_TOTAL_INCREASING,
).extend(TELEINFO_LISTENER_SCHEMA)
def _preset(unit: str, device_class: str, state_class: str) -> dict[str, str]:
return {
CONF_UNIT_OF_MEASUREMENT: unit,
CONF_DEVICE_CLASS: device_class,
CONF_STATE_CLASS: state_class,
}
_ENERGY = _preset(UNIT_WATT_HOURS, DEVICE_CLASS_ENERGY, STATE_CLASS_TOTAL_INCREASING)
_REACTIVE_ENERGY = _preset(
UNIT_VOLT_AMPS_REACTIVE_HOURS,
DEVICE_CLASS_REACTIVE_ENERGY,
STATE_CLASS_TOTAL_INCREASING,
)
_CURRENT = _preset(UNIT_AMPERE, DEVICE_CLASS_CURRENT, STATE_CLASS_MEASUREMENT)
_VOLTAGE = _preset(UNIT_VOLT, DEVICE_CLASS_VOLTAGE, STATE_CLASS_MEASUREMENT)
_APPARENT_POWER_VA = _preset(
UNIT_VOLT_AMPS, DEVICE_CLASS_APPARENT_POWER, STATE_CLASS_MEASUREMENT
)
_APPARENT_POWER_KVA = _preset(
UNIT_KILOVOLT_AMPS, DEVICE_CLASS_APPARENT_POWER, STATE_CLASS_MEASUREMENT
)
_POWER = _preset(UNIT_WATT, DEVICE_CLASS_POWER, STATE_CLASS_MEASUREMENT)
# Legacy defaults, kept for tags without a known preset
_LEGACY_DEFAULT = {**_ENERGY, CONF_ICON: ICON_FLASH}
# Units that are valid for each device class, used to decide whether a preset's
# device class can be kept when the user overrides the unit (and vice versa)
_COMPATIBLE_UNITS = {
DEVICE_CLASS_ENERGY: {UNIT_WATT_HOURS, UNIT_KILOWATT_HOURS},
DEVICE_CLASS_REACTIVE_ENERGY: {
UNIT_VOLT_AMPS_REACTIVE_HOURS,
UNIT_KILOVOLT_AMPS_REACTIVE_HOURS,
},
DEVICE_CLASS_CURRENT: {UNIT_AMPERE, UNIT_MILLIAMP},
DEVICE_CLASS_VOLTAGE: {UNIT_VOLT, UNIT_MILLIVOLT},
DEVICE_CLASS_APPARENT_POWER: {UNIT_VOLT_AMPS, UNIT_KILOVOLT_AMPS},
DEVICE_CLASS_POWER: {UNIT_WATT, UNIT_KILOWATT},
}
# Presets by tag prefix. No prefix is a prefix of another, so order is irrelevant.
TIC_TAG_CONFIGS = {
# Standard mode
"EA": _ENERGY,
"ER": _REACTIVE_ENERGY,
"IRMS": _CURRENT,
"URMS": _VOLTAGE,
"UMOY": _VOLTAGE,
"SINST": _APPARENT_POWER_VA,
"SMAX": _APPARENT_POWER_VA,
"CC": _POWER,
"PREF": _APPARENT_POWER_KVA,
"PCOUP": _APPARENT_POWER_KVA,
# Historical mode
"BASE": _ENERGY,
"HCH": _ENERGY,
"EJP": _ENERGY,
"BBRH": _ENERGY,
"IINST": _CURRENT,
"IMAX": _CURRENT,
"ISOUSC": _CURRENT,
"ADPS": _CURRENT,
"ADIR": _CURRENT,
"PAPP": _APPARENT_POWER_VA,
"PMAX": _POWER,
}
def apply_tag_config(config: ConfigType) -> ConfigType:
"""Apply preset configurations based on the tag name.
Only keys not set by the user are filled in. For known tags, a preset unit or
device class is dropped if it is incompatible with the one set by the user.
Unknown tags get the legacy defaults, merged key by key as before.
"""
if CONF_TAG_NAME not in config:
return config
# Runs before cv.string, so the raw value may not be a string yet
tag_name = str(config[CONF_TAG_NAME])
preset = next(
(p for prefix, p in TIC_TAG_CONFIGS.items() if tag_name.startswith(prefix)),
None,
)
config = dict(config)
if preset is None:
for key, value in _LEGACY_DEFAULT.items():
config.setdefault(key, value)
return config
unit = config.get(CONF_UNIT_OF_MEASUREMENT, preset[CONF_UNIT_OF_MEASUREMENT])
device_class = config.get(CONF_DEVICE_CLASS, preset[CONF_DEVICE_CLASS])
compatible = unit in _COMPATIBLE_UNITS.get(device_class, ())
for key, value in preset.items():
if key in config:
continue
if key in (CONF_UNIT_OF_MEASUREMENT, CONF_DEVICE_CLASS) and not compatible:
continue
config[key] = value
return config
CONFIG_SCHEMA = cv.All(
apply_tag_config,
sensor.sensor_schema(
TeleInfoSensor,
accuracy_decimals=0,
).extend(TELEINFO_LISTENER_SCHEMA),
)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID], config[CONF_TAG_NAME])
await cg.register_component(var, config)
await sensor.register_sensor(var, config)
teleinfo = await cg.get_variable(config[CONF_TELEINFO_ID])
cg.add(teleinfo.register_teleinfo_listener(var))
@@ -0,0 +1,149 @@
"""Tests for teleinfo sensor tag presets."""
import pytest
from esphome.components.teleinfo import CONF_TAG_NAME
from esphome.components.teleinfo.sensor import apply_tag_config
from esphome.const import (
CONF_DEVICE_CLASS,
CONF_ICON,
CONF_STATE_CLASS,
CONF_UNIT_OF_MEASUREMENT,
DEVICE_CLASS_APPARENT_POWER,
DEVICE_CLASS_CURRENT,
DEVICE_CLASS_ENERGY,
DEVICE_CLASS_POWER,
DEVICE_CLASS_REACTIVE_ENERGY,
DEVICE_CLASS_VOLTAGE,
ICON_FLASH,
STATE_CLASS_MEASUREMENT,
STATE_CLASS_TOTAL_INCREASING,
UNIT_AMPERE,
UNIT_KILOVOLT_AMPS,
UNIT_KILOWATT_HOURS,
UNIT_MILLIAMP,
UNIT_VOLT,
UNIT_VOLT_AMPS,
UNIT_VOLT_AMPS_REACTIVE_HOURS,
UNIT_WATT,
UNIT_WATT_HOURS,
)
@pytest.mark.parametrize(
("tag", "unit", "device_class", "state_class"),
[
("EAST", UNIT_WATT_HOURS, DEVICE_CLASS_ENERGY, STATE_CLASS_TOTAL_INCREASING),
(
"ERQ1",
UNIT_VOLT_AMPS_REACTIVE_HOURS,
DEVICE_CLASS_REACTIVE_ENERGY,
STATE_CLASS_TOTAL_INCREASING,
),
("IRMS1", UNIT_AMPERE, DEVICE_CLASS_CURRENT, STATE_CLASS_MEASUREMENT),
("URMS1", UNIT_VOLT, DEVICE_CLASS_VOLTAGE, STATE_CLASS_MEASUREMENT),
(
"SINSTS",
UNIT_VOLT_AMPS,
DEVICE_CLASS_APPARENT_POWER,
STATE_CLASS_MEASUREMENT,
),
(
"PREF",
UNIT_KILOVOLT_AMPS,
DEVICE_CLASS_APPARENT_POWER,
STATE_CLASS_MEASUREMENT,
),
("CCASN", UNIT_WATT, DEVICE_CLASS_POWER, STATE_CLASS_MEASUREMENT),
("HCHC", UNIT_WATT_HOURS, DEVICE_CLASS_ENERGY, STATE_CLASS_TOTAL_INCREASING),
("BBRHCJB", UNIT_WATT_HOURS, DEVICE_CLASS_ENERGY, STATE_CLASS_TOTAL_INCREASING),
("IINST1", UNIT_AMPERE, DEVICE_CLASS_CURRENT, STATE_CLASS_MEASUREMENT),
("ADIR2", UNIT_AMPERE, DEVICE_CLASS_CURRENT, STATE_CLASS_MEASUREMENT),
("PAPP", UNIT_VOLT_AMPS, DEVICE_CLASS_APPARENT_POWER, STATE_CLASS_MEASUREMENT),
("PMAX", UNIT_WATT, DEVICE_CLASS_POWER, STATE_CLASS_MEASUREMENT),
],
)
def test_known_tag_preset(
tag: str, unit: str, device_class: str, state_class: str
) -> None:
config = apply_tag_config({CONF_TAG_NAME: tag})
assert config[CONF_UNIT_OF_MEASUREMENT] == unit
assert config[CONF_DEVICE_CLASS] == device_class
assert config[CONF_STATE_CLASS] == state_class
assert CONF_ICON not in config
def test_unknown_tag_keeps_legacy_defaults() -> None:
config = apply_tag_config({CONF_TAG_NAME: "MYTAG"})
assert config[CONF_UNIT_OF_MEASUREMENT] == UNIT_WATT_HOURS
assert config[CONF_DEVICE_CLASS] == DEVICE_CLASS_ENERGY
assert config[CONF_STATE_CLASS] == STATE_CLASS_TOTAL_INCREASING
assert config[CONF_ICON] == ICON_FLASH
def test_unknown_tag_user_unit_keeps_legacy_device_class() -> None:
config = apply_tag_config(
{CONF_TAG_NAME: "MYTAG", CONF_UNIT_OF_MEASUREMENT: UNIT_KILOWATT_HOURS}
)
assert config[CONF_UNIT_OF_MEASUREMENT] == UNIT_KILOWATT_HOURS
assert config[CONF_DEVICE_CLASS] == DEVICE_CLASS_ENERGY
assert config[CONF_STATE_CLASS] == STATE_CLASS_TOTAL_INCREASING
assert config[CONF_ICON] == ICON_FLASH
def test_unknown_tag_starting_with_u_is_not_voltage() -> None:
config = apply_tag_config({CONF_TAG_NAME: "UNKNOWN"})
assert config[CONF_DEVICE_CLASS] == DEVICE_CLASS_ENERGY
def test_numeric_tag_name_does_not_crash() -> None:
config = apply_tag_config({CONF_TAG_NAME: 1234})
assert config[CONF_TAG_NAME] == 1234
assert config[CONF_DEVICE_CLASS] == DEVICE_CLASS_ENERGY
@pytest.mark.parametrize(
("tag", "unit", "device_class"),
[
("HCHP", UNIT_KILOWATT_HOURS, DEVICE_CLASS_ENERGY),
("EAST", UNIT_KILOWATT_HOURS, DEVICE_CLASS_ENERGY),
("PREF", UNIT_VOLT_AMPS, DEVICE_CLASS_APPARENT_POWER),
("IINST1", UNIT_MILLIAMP, DEVICE_CLASS_CURRENT),
],
)
def test_user_compatible_unit_keeps_preset_device_class(
tag: str, unit: str, device_class: str
) -> None:
config = apply_tag_config({CONF_TAG_NAME: tag, CONF_UNIT_OF_MEASUREMENT: unit})
assert config[CONF_UNIT_OF_MEASUREMENT] == unit
assert config[CONF_DEVICE_CLASS] == device_class
def test_user_incompatible_unit_skips_preset_device_class() -> None:
config = apply_tag_config({CONF_TAG_NAME: "PAPP", CONF_UNIT_OF_MEASUREMENT: "W"})
assert config[CONF_UNIT_OF_MEASUREMENT] == "W"
assert CONF_DEVICE_CLASS not in config
assert config[CONF_STATE_CLASS] == STATE_CLASS_MEASUREMENT
def test_user_incompatible_device_class_skips_preset_unit() -> None:
config = apply_tag_config(
{CONF_TAG_NAME: "PAPP", CONF_DEVICE_CLASS: DEVICE_CLASS_POWER}
)
assert config[CONF_DEVICE_CLASS] == DEVICE_CLASS_POWER
assert CONF_UNIT_OF_MEASUREMENT not in config
def test_user_same_device_class_keeps_preset_unit() -> None:
config = apply_tag_config(
{CONF_TAG_NAME: "PAPP", CONF_DEVICE_CLASS: DEVICE_CLASS_APPARENT_POWER}
)
assert config[CONF_UNIT_OF_MEASUREMENT] == UNIT_VOLT_AMPS
def test_user_state_class_is_kept() -> None:
config = apply_tag_config(
{CONF_TAG_NAME: "HCHP", CONF_STATE_CLASS: STATE_CLASS_MEASUREMENT}
)
assert config[CONF_STATE_CLASS] == STATE_CLASS_MEASUREMENT
assert config[CONF_UNIT_OF_MEASUREMENT] == UNIT_WATT_HOURS
+85 -1
View File
@@ -22,11 +22,95 @@ teleinfo:
update_interval: 60s
sensor:
# Historical mode tags - auto-configured via apply_tag_config
- platform: teleinfo
name: hchc
tag_name: HCHC
teleinfo_id: test_teleinfo
unit_of_measurement: Wh
- platform: teleinfo
name: base
tag_name: BASE
teleinfo_id: test_teleinfo
- platform: teleinfo
name: iinst1
tag_name: IINST1
teleinfo_id: test_teleinfo
- platform: teleinfo
name: papp
tag_name: PAPP
teleinfo_id: test_teleinfo
- platform: teleinfo
name: bbrhcjb
tag_name: BBRHCJB
teleinfo_id: test_teleinfo
- platform: teleinfo
name: ejphn
tag_name: EJPHN
teleinfo_id: test_teleinfo
- platform: teleinfo
name: pmax
tag_name: PMAX
teleinfo_id: test_teleinfo
- platform: teleinfo
name: imax1
tag_name: IMAX1
teleinfo_id: test_teleinfo
- platform: teleinfo
name: isousc
tag_name: ISOUSC
teleinfo_id: test_teleinfo
- platform: teleinfo
name: adps
tag_name: ADPS
teleinfo_id: test_teleinfo
# Standard mode tags - auto-configured via apply_tag_config
- platform: teleinfo
name: east
tag_name: EAST
teleinfo_id: test_teleinfo
- platform: teleinfo
name: erqt
tag_name: ERQT
teleinfo_id: test_teleinfo
- platform: teleinfo
name: irms1
tag_name: IRMS1
teleinfo_id: test_teleinfo
- platform: teleinfo
name: urms1
tag_name: URMS1
teleinfo_id: test_teleinfo
- platform: teleinfo
name: sinsts
tag_name: SINSTS
teleinfo_id: test_teleinfo
- platform: teleinfo
name: smaxsn
tag_name: SMAXSN
teleinfo_id: test_teleinfo
- platform: teleinfo
name: ccasn
tag_name: CCASN
teleinfo_id: test_teleinfo
- platform: teleinfo
name: pref
tag_name: PREF
teleinfo_id: test_teleinfo
- platform: teleinfo
name: pcoup
tag_name: PCOUP
teleinfo_id: test_teleinfo
# Explicit override - user-specified unit should not be overwritten
- platform: teleinfo
name: hchp_override
tag_name: HCHP
teleinfo_id: test_teleinfo
unit_of_measurement: kWh
# Unknown tag - no auto-config match, should still compile
- platform: teleinfo
name: custom_tag
tag_name: MYTAG
teleinfo_id: test_teleinfo
text_sensor:
- platform: teleinfo