diff --git a/CODEOWNERS b/CODEOWNERS index b5138c0612..3fe111b628 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -568,7 +568,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 diff --git a/esphome/components/teleinfo/__init__.py b/esphome/components/teleinfo/__init__.py index 67aad11d0f..5a6a01f669 100644 --- a/esphome/components/teleinfo/__init__.py +++ b/esphome/components/teleinfo/__init__.py @@ -4,7 +4,8 @@ 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 teleinfo_ns = cg.esphome_ns.namespace("teleinfo") diff --git a/esphome/components/teleinfo/sensor/__init__.py b/esphome/components/teleinfo/sensor/__init__.py index b51d4cb795..6a23836c8a 100644 --- a/esphome/components/teleinfo/sensor/__init__.py +++ b/esphome/components/teleinfo/sensor/__init__.py @@ -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)) diff --git a/tests/component_tests/teleinfo/__init__.py b/tests/component_tests/teleinfo/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/teleinfo/test_sensor.py b/tests/component_tests/teleinfo/test_sensor.py new file mode 100644 index 0000000000..a8f9420dea --- /dev/null +++ b/tests/component_tests/teleinfo/test_sensor.py @@ -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 diff --git a/tests/components/teleinfo/common.yaml b/tests/components/teleinfo/common.yaml index dcb2cb14bd..7f73bca62c 100644 --- a/tests/components/teleinfo/common.yaml +++ b/tests/components/teleinfo/common.yaml @@ -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