Add Tuya indexed phase sensors as child devices (#182161)

Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
huangrenwei79
2026-09-29 16:50:00 +02:00
committed by GitHub
co-authored by epenet Claude Opus 5.5
parent e1b51fdd01
commit bb82a71866
5 changed files with 8036 additions and 4 deletions
+20
View File
@@ -816,6 +816,26 @@ class DPCode(StrEnum):
PHASE_A = "phase_a"
PHASE_B = "phase_b"
PHASE_C = "phase_c"
PHASE_S1 = "phase_s1"
PHASE_S2 = "phase_s2"
PHASE_S3 = "phase_s3"
PHASE_S4 = "phase_s4"
PHASE_S5 = "phase_s5"
PHASE_S6 = "phase_s6"
PHASE_S7 = "phase_s7"
PHASE_S8 = "phase_s8"
PHASE_S9 = "phase_s9"
PHASE_S10 = "phase_s10"
PHASE_S11 = "phase_s11"
PHASE_S12 = "phase_s12"
PHASE_S13 = "phase_s13"
PHASE_S14 = "phase_s14"
PHASE_S15 = "phase_s15"
PHASE_S16 = "phase_s16"
PHASE_S17 = "phase_s17"
PHASE_S18 = "phase_s18"
PHASE_S19 = "phase_s19"
PHASE_S20 = "phase_s20"
PH_CURRENT = "ph_current"
PIR = "pir" # Motion sensor
PM1 = "pm1"
+101 -3
View File
@@ -13,16 +13,22 @@ from tuya_device_handlers.device_wrapper.common import (
)
from tuya_device_handlers.device_wrapper.sensor import (
DeltaIntegerWrapper,
ElectricityApparentPowerHexStringWrapper,
ElectricityApparentPowerJsonWrapper,
ElectricityApparentPowerRawWrapper,
ElectricityCurrentHexStringWrapper,
ElectricityCurrentJsonWrapper,
ElectricityCurrentRawWrapper,
ElectricityPowerFactorHexStringWrapper,
ElectricityPowerFactorJsonWrapper,
ElectricityPowerFactorRawWrapper,
ElectricityPowerHexStringWrapper,
ElectricityPowerJsonWrapper,
ElectricityPowerRawWrapper,
ElectricityReactivePowerHexStringWrapper,
ElectricityReactivePowerJsonWrapper,
ElectricityReactivePowerRawWrapper,
ElectricityVoltageHexStringWrapper,
ElectricityVoltageJsonWrapper,
ElectricityVoltageRawWrapper,
WindDirectionEnumWrapper,
@@ -47,19 +53,22 @@ from homeassistant.const import (
UnitOfTime,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import ChildDeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .const import (
DEVICE_CLASS_UNITS,
DOMAIN,
LOGGER,
TUYA_DISCOVERY_NEW,
DeviceCategory,
DPCode,
)
from .coordinator import TuyaConfigEntry
from .entity import TuyaEntity, TuyaEntityDescription
from .entity import TuyaEntity, TuyaEntityDescription, get_child_device_info
from .util import get_device_temp_unit_convert
@@ -137,6 +146,73 @@ def _electricity_data(dpcode: DPCode) -> tuple[TuyaSensorEntityDescription, ...]
)
def _has_multiple_phase_channels(device: CustomerDevice) -> bool:
"""Return if the device reports more than one indexed phase channel."""
return DPCode.PHASE_S2 in device.status_range
def _indexed_electricity_data(
dpcode: DPCode, index: int
) -> tuple[TuyaSensorEntityDescription, ...]:
"""Build sensors extracted from an indexed hex-string electricity DPCode."""
return (
TuyaSensorEntityDescription(
key=f"{dpcode}electriccurrent",
dpcode=dpcode,
device_class=SensorDeviceClass.CURRENT,
state_class=SensorStateClass.MEASUREMENT,
wrapper_class=(ElectricityCurrentHexStringWrapper,),
channel_index=index,
channel_condition=_has_multiple_phase_channels,
),
TuyaSensorEntityDescription(
key=f"{dpcode}power",
dpcode=dpcode,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
wrapper_class=(ElectricityPowerHexStringWrapper,),
channel_index=index,
channel_condition=_has_multiple_phase_channels,
),
TuyaSensorEntityDescription(
key=f"{dpcode}voltage",
dpcode=dpcode,
device_class=SensorDeviceClass.VOLTAGE,
state_class=SensorStateClass.MEASUREMENT,
wrapper_class=(ElectricityVoltageHexStringWrapper,),
channel_index=index,
channel_condition=_has_multiple_phase_channels,
),
TuyaSensorEntityDescription(
key=f"{dpcode}reactivepower",
dpcode=dpcode,
device_class=SensorDeviceClass.REACTIVE_POWER,
state_class=SensorStateClass.MEASUREMENT,
wrapper_class=(ElectricityReactivePowerHexStringWrapper,),
channel_index=index,
channel_condition=_has_multiple_phase_channels,
),
TuyaSensorEntityDescription(
key=f"{dpcode}apparentpower",
dpcode=dpcode,
device_class=SensorDeviceClass.APPARENT_POWER,
state_class=SensorStateClass.MEASUREMENT,
wrapper_class=(ElectricityApparentPowerHexStringWrapper,),
channel_index=index,
channel_condition=_has_multiple_phase_channels,
),
TuyaSensorEntityDescription(
key=f"{dpcode}powerfactor",
dpcode=dpcode,
device_class=SensorDeviceClass.POWER_FACTOR,
state_class=SensorStateClass.MEASUREMENT,
wrapper_class=(ElectricityPowerFactorHexStringWrapper,),
channel_index=index,
channel_condition=_has_multiple_phase_channels,
),
)
# Commonly used battery sensors, that are reused in the sensors down below.
BATTERY_SENSORS: tuple[TuyaSensorEntityDescription, ...] = (
TuyaSensorEntityDescription(
@@ -1705,6 +1781,13 @@ SENSORS: dict[DeviceCategory, tuple[TuyaSensorEntityDescription, ...]] = {
*_electricity_data(DPCode.PHASE_A),
*_electricity_data(DPCode.PHASE_B),
*_electricity_data(DPCode.PHASE_C),
*(
description
for index in range(1, 21)
for description in _indexed_electricity_data(
DPCode(f"phase_s{index}"), index
)
),
),
DeviceCategory.ZNJDQ: (
TuyaSensorEntityDescription(
@@ -1836,8 +1919,21 @@ async def async_setup_entry(
for device_id in device_ids:
device = manager.device_map[device_id]
if descriptions := SENSORS.get(device.category):
parent_device_id = dr.async_get_device_id_by_identifier(
hass,
(DOMAIN, device.id),
config_entry_id=entry.entry_id,
)
entities.extend(
TuyaSensorEntity(device, manager, description, definition)
TuyaSensorEntity(
device,
manager,
description,
definition,
device_info=get_child_device_info(
device, parent_device_id, description
),
)
for description in descriptions
if (
definition := get_default_definition(
@@ -1868,9 +1964,11 @@ class TuyaSensorEntity(TuyaEntity, SensorEntity):
device_manager: Manager,
description: TuyaSensorEntityDescription,
definition: SensorDefinition,
*,
device_info: ChildDeviceInfo | None = None,
) -> None:
"""Init Tuya sensor."""
super().__init__(device, device_manager, description)
super().__init__(device, device_manager, description, device_info=device_info)
self._dpcode_wrapper = definition.sensor_wrapper
if description.suggested_unit_of_measurement is None:
@@ -9009,6 +9009,406 @@
'via_device_id': None,
})
# ---
# name: test_device_registry[wrr5fva8vgffwlxqbdnz_channel_10]
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'disabled_by': None,
'id': <ANY>,
'identifiers': set({
tuple(
'tuya',
'wrr5fva8vgffwlxqbdnz_channel_10',
),
}),
'labels': set({
}),
'name': 'Channel 10',
'name_by_user': None,
'parent_device_id': <ANY>,
})
# ---
# name: test_device_registry[wrr5fva8vgffwlxqbdnz_channel_11]
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'disabled_by': None,
'id': <ANY>,
'identifiers': set({
tuple(
'tuya',
'wrr5fva8vgffwlxqbdnz_channel_11',
),
}),
'labels': set({
}),
'name': 'Channel 11',
'name_by_user': None,
'parent_device_id': <ANY>,
})
# ---
# name: test_device_registry[wrr5fva8vgffwlxqbdnz_channel_12]
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'disabled_by': None,
'id': <ANY>,
'identifiers': set({
tuple(
'tuya',
'wrr5fva8vgffwlxqbdnz_channel_12',
),
}),
'labels': set({
}),
'name': 'Channel 12',
'name_by_user': None,
'parent_device_id': <ANY>,
})
# ---
# name: test_device_registry[wrr5fva8vgffwlxqbdnz_channel_13]
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'disabled_by': None,
'id': <ANY>,
'identifiers': set({
tuple(
'tuya',
'wrr5fva8vgffwlxqbdnz_channel_13',
),
}),
'labels': set({
}),
'name': 'Channel 13',
'name_by_user': None,
'parent_device_id': <ANY>,
})
# ---
# name: test_device_registry[wrr5fva8vgffwlxqbdnz_channel_14]
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'disabled_by': None,
'id': <ANY>,
'identifiers': set({
tuple(
'tuya',
'wrr5fva8vgffwlxqbdnz_channel_14',
),
}),
'labels': set({
}),
'name': 'Channel 14',
'name_by_user': None,
'parent_device_id': <ANY>,
})
# ---
# name: test_device_registry[wrr5fva8vgffwlxqbdnz_channel_15]
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'disabled_by': None,
'id': <ANY>,
'identifiers': set({
tuple(
'tuya',
'wrr5fva8vgffwlxqbdnz_channel_15',
),
}),
'labels': set({
}),
'name': 'Channel 15',
'name_by_user': None,
'parent_device_id': <ANY>,
})
# ---
# name: test_device_registry[wrr5fva8vgffwlxqbdnz_channel_16]
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'disabled_by': None,
'id': <ANY>,
'identifiers': set({
tuple(
'tuya',
'wrr5fva8vgffwlxqbdnz_channel_16',
),
}),
'labels': set({
}),
'name': 'Channel 16',
'name_by_user': None,
'parent_device_id': <ANY>,
})
# ---
# name: test_device_registry[wrr5fva8vgffwlxqbdnz_channel_17]
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'disabled_by': None,
'id': <ANY>,
'identifiers': set({
tuple(
'tuya',
'wrr5fva8vgffwlxqbdnz_channel_17',
),
}),
'labels': set({
}),
'name': 'Channel 17',
'name_by_user': None,
'parent_device_id': <ANY>,
})
# ---
# name: test_device_registry[wrr5fva8vgffwlxqbdnz_channel_18]
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'disabled_by': None,
'id': <ANY>,
'identifiers': set({
tuple(
'tuya',
'wrr5fva8vgffwlxqbdnz_channel_18',
),
}),
'labels': set({
}),
'name': 'Channel 18',
'name_by_user': None,
'parent_device_id': <ANY>,
})
# ---
# name: test_device_registry[wrr5fva8vgffwlxqbdnz_channel_19]
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'disabled_by': None,
'id': <ANY>,
'identifiers': set({
tuple(
'tuya',
'wrr5fva8vgffwlxqbdnz_channel_19',
),
}),
'labels': set({
}),
'name': 'Channel 19',
'name_by_user': None,
'parent_device_id': <ANY>,
})
# ---
# name: test_device_registry[wrr5fva8vgffwlxqbdnz_channel_1]
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'disabled_by': None,
'id': <ANY>,
'identifiers': set({
tuple(
'tuya',
'wrr5fva8vgffwlxqbdnz_channel_1',
),
}),
'labels': set({
}),
'name': 'Channel 1',
'name_by_user': None,
'parent_device_id': <ANY>,
})
# ---
# name: test_device_registry[wrr5fva8vgffwlxqbdnz_channel_20]
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'disabled_by': None,
'id': <ANY>,
'identifiers': set({
tuple(
'tuya',
'wrr5fva8vgffwlxqbdnz_channel_20',
),
}),
'labels': set({
}),
'name': 'Channel 20',
'name_by_user': None,
'parent_device_id': <ANY>,
})
# ---
# name: test_device_registry[wrr5fva8vgffwlxqbdnz_channel_2]
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'disabled_by': None,
'id': <ANY>,
'identifiers': set({
tuple(
'tuya',
'wrr5fva8vgffwlxqbdnz_channel_2',
),
}),
'labels': set({
}),
'name': 'Channel 2',
'name_by_user': None,
'parent_device_id': <ANY>,
})
# ---
# name: test_device_registry[wrr5fva8vgffwlxqbdnz_channel_3]
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'disabled_by': None,
'id': <ANY>,
'identifiers': set({
tuple(
'tuya',
'wrr5fva8vgffwlxqbdnz_channel_3',
),
}),
'labels': set({
}),
'name': 'Channel 3',
'name_by_user': None,
'parent_device_id': <ANY>,
})
# ---
# name: test_device_registry[wrr5fva8vgffwlxqbdnz_channel_4]
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'disabled_by': None,
'id': <ANY>,
'identifiers': set({
tuple(
'tuya',
'wrr5fva8vgffwlxqbdnz_channel_4',
),
}),
'labels': set({
}),
'name': 'Channel 4',
'name_by_user': None,
'parent_device_id': <ANY>,
})
# ---
# name: test_device_registry[wrr5fva8vgffwlxqbdnz_channel_5]
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'disabled_by': None,
'id': <ANY>,
'identifiers': set({
tuple(
'tuya',
'wrr5fva8vgffwlxqbdnz_channel_5',
),
}),
'labels': set({
}),
'name': 'Channel 5',
'name_by_user': None,
'parent_device_id': <ANY>,
})
# ---
# name: test_device_registry[wrr5fva8vgffwlxqbdnz_channel_6]
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'disabled_by': None,
'id': <ANY>,
'identifiers': set({
tuple(
'tuya',
'wrr5fva8vgffwlxqbdnz_channel_6',
),
}),
'labels': set({
}),
'name': 'Channel 6',
'name_by_user': None,
'parent_device_id': <ANY>,
})
# ---
# name: test_device_registry[wrr5fva8vgffwlxqbdnz_channel_7]
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'disabled_by': None,
'id': <ANY>,
'identifiers': set({
tuple(
'tuya',
'wrr5fva8vgffwlxqbdnz_channel_7',
),
}),
'labels': set({
}),
'name': 'Channel 7',
'name_by_user': None,
'parent_device_id': <ANY>,
})
# ---
# name: test_device_registry[wrr5fva8vgffwlxqbdnz_channel_8]
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'disabled_by': None,
'id': <ANY>,
'identifiers': set({
tuple(
'tuya',
'wrr5fva8vgffwlxqbdnz_channel_8',
),
}),
'labels': set({
}),
'name': 'Channel 8',
'name_by_user': None,
'parent_device_id': <ANY>,
})
# ---
# name: test_device_registry[wrr5fva8vgffwlxqbdnz_channel_9]
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'disabled_by': None,
'id': <ANY>,
'identifiers': set({
tuple(
'tuya',
'wrr5fva8vgffwlxqbdnz_channel_9',
),
}),
'labels': set({
}),
'name': 'Channel 9',
'name_by_user': None,
'parent_device_id': <ANY>,
})
# ---
# name: test_device_registry[x4nogasbi8ggpb3lcd]
DeviceRegistryEntrySnapshot({
'area_id': None,
File diff suppressed because it is too large Load Diff
+39 -1
View File
@@ -9,9 +9,10 @@ from syrupy.assertion import SnapshotAssertion
from tuya_sharing import CustomerDevice, Manager
from homeassistant.components.sensor import SensorStateClass
from homeassistant.components.tuya.const import DOMAIN
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er, json
from homeassistant.helpers import device_registry as dr, entity_registry as er, json
from homeassistant.util import json as json_util
from homeassistant.util.unit_system import (
METRIC_SYSTEM,
@@ -46,6 +47,43 @@ async def test_platform_setup_and_discovery(
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
@pytest.mark.parametrize(
("mock_device_code", "expected_children", "expected_parent_phase_entities"),
[
pytest.param("zndb_qxlwffgv8avf5rrw", 20, 0, id="multi_channel"),
pytest.param("zndb_uqzhc4bx5zqwpg2m", 0, 6, id="single_channel"),
],
)
async def test_indexed_phase_child_devices(
hass: HomeAssistant,
mock_manager: Manager,
mock_config_entry: MockConfigEntry,
mock_device: CustomerDevice,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
expected_children: int,
expected_parent_phase_entities: int,
) -> None:
"""Test indexed phase sensors only move to child devices for multi-channel meters."""
await initialize_entry(hass, mock_manager, mock_config_entry, mock_device)
parent = device_registry.async_get_device_by_identifier(
(DOMAIN, mock_device.id), mock_config_entry.entry_id
)
assert parent is not None
assert (
len(dr.async_entries_for_parent_device(device_registry, parent.id))
== expected_children
)
assert (
sum(
"phase_s" in entry.unique_id
for entry in er.async_entries_for_device(entity_registry, parent.id)
)
== expected_parent_phase_entities
)
@pytest.mark.parametrize(
"mock_device_code",
["mcs_8yhypbo7"],