[selec_meter] Add EM4M (3-phase) support via model option (#17782)

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
Sourabh Jaiswal
2026-10-13 02:58:25 +00:00
committed by GitHub
co-authored by Claude Sonnet 5 pre-commit-ci-lite[bot] J. Nick Koston J. Nick Koston
parent 8acf885241
commit 5017165800
13 changed files with 1105 additions and 82 deletions
@@ -0,0 +1,129 @@
sensor:
- platform: selec_meter
modbus_id: modbus_bus
model: em4m
byte_order: little_endian
phase_a:
voltage:
name: SelecEM4M Voltage L1
current:
name: SelecEM4M Current L1
active_power:
name: SelecEM4M Active Power L1
reactive_power:
name: SelecEM4M Reactive Power L1
apparent_power:
name: SelecEM4M Apparent Power L1
power_factor:
name: SelecEM4M Power Factor L1
import_active_energy:
name: SelecEM4M Import Active Energy L1
export_active_energy:
name: SelecEM4M Export Active Energy L1
import_reactive_energy:
name: SelecEM4M Import Reactive Energy L1
export_reactive_energy:
name: SelecEM4M Export Reactive Energy L1
apparent_energy:
name: SelecEM4M Apparent Energy L1
phase_b:
voltage:
name: SelecEM4M Voltage L2
current:
name: SelecEM4M Current L2
active_power:
name: SelecEM4M Active Power L2
reactive_power:
name: SelecEM4M Reactive Power L2
apparent_power:
name: SelecEM4M Apparent Power L2
power_factor:
name: SelecEM4M Power Factor L2
import_active_energy:
name: SelecEM4M Import Active Energy L2
export_active_energy:
name: SelecEM4M Export Active Energy L2
import_reactive_energy:
name: SelecEM4M Import Reactive Energy L2
export_reactive_energy:
name: SelecEM4M Export Reactive Energy L2
apparent_energy:
name: SelecEM4M Apparent Energy L2
phase_c:
voltage:
name: SelecEM4M Voltage L3
current:
name: SelecEM4M Current L3
active_power:
name: SelecEM4M Active Power L3
reactive_power:
name: SelecEM4M Reactive Power L3
apparent_power:
name: SelecEM4M Apparent Power L3
power_factor:
name: SelecEM4M Power Factor L3
import_active_energy:
name: SelecEM4M Import Active Energy L3
export_active_energy:
name: SelecEM4M Export Active Energy L3
import_reactive_energy:
name: SelecEM4M Import Reactive Energy L3
export_reactive_energy:
name: SelecEM4M Export Reactive Energy L3
apparent_energy:
name: SelecEM4M Apparent Energy L3
voltage_l12:
name: SelecEM4M Voltage L12
voltage_l23:
name: SelecEM4M Voltage L23
voltage_l31:
name: SelecEM4M Voltage L31
average_voltage_ll:
name: SelecEM4M Average Voltage LL
active_power:
name: SelecEM4M Active Power
reactive_power:
name: SelecEM4M Reactive Power
apparent_power:
name: SelecEM4M Apparent Power
voltage:
name: SelecEM4M Average Voltage
current:
name: SelecEM4M Average Current
power_factor:
name: SelecEM4M Average Power Factor
frequency:
name: SelecEM4M Frequency
import_active_energy:
name: SelecEM4M Total Import Active Energy
export_active_energy:
name: SelecEM4M Total Export Active Energy
import_reactive_energy:
name: SelecEM4M Total Import Reactive Energy
export_reactive_energy:
name: SelecEM4M Total Export Reactive Energy
net_active_energy_mains:
name: SelecEM4M Net Active Energy Mains
net_reactive_energy_mains:
name: SelecEM4M Net Reactive Energy Mains
net_apparent_energy_mains:
name: SelecEM4M Net Apparent Energy Mains
net_active_energy_dg:
name: SelecEM4M Net Active Energy DG
net_reactive_energy_dg:
name: SelecEM4M Net Reactive Energy DG
net_apparent_energy_dg:
name: SelecEM4M Net Apparent Energy DG
maximum_demand_active_power:
name: SelecEM4M Maximum Demand Active Power
disabled_by_default: true
maximum_demand_reactive_power:
name: SelecEM4M Maximum Demand Reactive Power
disabled_by_default: true
maximum_demand_apparent_power:
name: SelecEM4M Maximum Demand Apparent Power
disabled_by_default: true
serial_number:
name: SelecEM4M Serial Number
dg_sensing:
name: SelecEM4M DG Sensing
@@ -0,0 +1,8 @@
substitutions:
tx_pin: GPIO4
rx_pin: GPIO5
flow_control_pin: GPIO26
packages:
modbus: !include ../../test_build_components/common/modbus/esp32-idf.yaml
selec_meter: !include common-em4m.yaml
@@ -0,0 +1,8 @@
substitutions:
tx_pin: GPIO0
rx_pin: GPIO2
flow_control_pin: GPIO15
packages:
modbus: !include ../../test_build_components/common/modbus/esp8266-ard.yaml
selec_meter: !include common-em4m.yaml
@@ -4,6 +4,5 @@ substitutions:
flow_control_pin: GPIO26
packages:
selec_meter: !include common.yaml
modbus: !include ../../test_build_components/common/modbus/esp32-idf.yaml
<<: !include common.yaml
@@ -4,6 +4,5 @@ substitutions:
flow_control_pin: GPIO15
packages:
selec_meter: !include common.yaml
modbus: !include ../../test_build_components/common/modbus/esp8266-ard.yaml
<<: !include common.yaml
@@ -4,6 +4,5 @@ substitutions:
flow_control_pin: GPIO6
packages:
selec_meter: !include common.yaml
modbus: !include ../../test_build_components/common/modbus/rp2040-ard.yaml
<<: !include common.yaml
@@ -0,0 +1,103 @@
"""Tests for the selec_meter configuration schema."""
from __future__ import annotations
from typing import Any
import pytest
from esphome.components.selec_meter import sensor as selec_meter
import esphome.config_validation as cv
from esphome.types import ConfigType
def _validate(config: dict[str, Any]) -> ConfigType:
return selec_meter.CONFIG_SCHEMA(config)
def test_model_defaults_to_em2m() -> None:
config = _validate({"voltage": {"name": "Voltage"}})
assert config["model"] == selec_meter.MODEL_EM2M
def test_model_is_case_insensitive() -> None:
config = _validate({"model": "EM4M"})
assert config["model"] == selec_meter.MODEL_EM4M
def test_model_rejects_unknown_value() -> None:
with pytest.raises(cv.Invalid, match="Unknown value 'em9m'"):
_validate({"model": "em9m"})
@pytest.mark.parametrize(
("model", "key", "value"),
[
("em2m", "voltage_l12", {"name": "Test"}),
("em2m", "serial_number", {"name": "Test"}),
("em2m", "dg_sensing", {"name": "Test"}),
("em2m", "phase_a", {"voltage": {"name": "Voltage"}}),
("em2m", "phase_b", {"voltage": {"name": "Voltage"}}),
("em2m", "phase_c", {"voltage": {"name": "Voltage"}}),
("em4m", "total_active_energy", {"name": "Test"}),
("em4m", "total_reactive_energy", {"name": "Test"}),
("em4m", "apparent_energy", {"name": "Test"}),
],
)
def test_key_rejected_on_wrong_model(model: str, key: str, value: Any) -> None:
with pytest.raises(cv.Invalid, match="extra keys not allowed") as err:
_validate({"model": model, key: value})
assert err.value.path == [key]
def test_em4m_only_key_rejected_on_default_model() -> None:
with pytest.raises(cv.Invalid, match="extra keys not allowed"):
_validate({"voltage_l12": {"name": "Test"}})
def test_em4m_with_phases_accepted() -> None:
config = _validate(
{
"model": "em4m",
"phase_a": {"voltage": {"name": "Voltage L1"}},
"phase_c": {"power_factor": {"name": "PF L3"}},
}
)
assert "voltage" in config["phase_a"]
assert "power_factor" in config["phase_c"]
@pytest.mark.parametrize(
("model", "byte_order", "word_swap"),
[
("em2m", "little_endian", True),
("em4m", "big_endian", False),
],
)
def test_byte_order_default_per_model(
model: str, byte_order: str, word_swap: bool
) -> None:
config = _validate({"model": model})
assert config["byte_order"] == byte_order
assert config["byte_order"].enum_value is word_swap
@pytest.mark.parametrize(
("model", "byte_order", "word_swap"),
[
("em2m", "big_endian", False),
("em4m", "LITTLE_ENDIAN", True),
("em4m", "lsrf", True),
("em2m", "msrf", False),
],
)
def test_byte_order_explicit_override(
model: str, byte_order: str, word_swap: bool
) -> None:
config = _validate({"model": model, "byte_order": byte_order})
assert config["byte_order"].enum_value is word_swap
def test_byte_order_rejects_unknown_value() -> None:
with pytest.raises(cv.Invalid, match="Unknown value"):
_validate({"byte_order": "middle_endian"})