Add Besen sensor platform (#180585)

This commit is contained in:
moryoav
2026-08-29 14:26:41 +02:00
committed by GitHub
parent 304788935a
commit 5be245735f
9 changed files with 1474 additions and 20 deletions
+1 -1
View File
@@ -7,4 +7,4 @@ from homeassistant.const import Platform
DOMAIN: Final = "besen"
NAME: Final = "Besen"
PLATFORMS: Final = [Platform.SWITCH]
PLATFORMS: Final = [Platform.SENSOR, Platform.SWITCH]
+1 -1
View File
@@ -14,5 +14,5 @@
"integration_type": "device",
"iot_class": "local_push",
"quality_scale": "bronze",
"requirements": ["besen==0.3.4"]
"requirements": ["besen==0.4.0"]
}
+197
View File
@@ -0,0 +1,197 @@
"""Sensor platform for Besen."""
from collections.abc import Callable
from dataclasses import dataclass
from typing import override
from besen.models import BesenData
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
StateType,
)
from homeassistant.const import (
EntityCategory,
UnitOfElectricCurrent,
UnitOfElectricPotential,
UnitOfEnergy,
UnitOfPower,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BesenConfigEntry
from .coordinator import BesenCoordinator
from .entity import BesenEntity
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class BesenSensorEntityDescription(SensorEntityDescription):
"""Describe a Besen sensor entity."""
value_fn: Callable[[BesenData], float | int | None]
three_phase_only: bool = False
SENSOR_DESCRIPTIONS: tuple[BesenSensorEntityDescription, ...] = (
BesenSensorEntityDescription(
key="charging_power",
translation_key="charging_power",
device_class=SensorDeviceClass.POWER,
native_unit_of_measurement=UnitOfPower.WATT,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda data: data.charge.power,
),
BesenSensorEntityDescription(
key="total_energy",
translation_key="total_energy",
device_class=SensorDeviceClass.ENERGY,
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
state_class=SensorStateClass.TOTAL_INCREASING,
suggested_display_precision=2,
value_fn=lambda data: data.charge.total_energy,
),
BesenSensorEntityDescription(
key="session_energy",
translation_key="session_energy",
device_class=SensorDeviceClass.ENERGY,
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
state_class=SensorStateClass.TOTAL_INCREASING,
suggested_display_precision=2,
value_fn=lambda data: data.charge.session_energy,
),
BesenSensorEntityDescription(
key="internal_temperature",
translation_key="internal_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
value_fn=lambda data: data.charge.inner_temp_c,
),
BesenSensorEntityDescription(
key="external_temperature",
translation_key="external_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
value_fn=lambda data: data.charge.outer_temp,
),
BesenSensorEntityDescription(
key="l1_voltage",
translation_key="l1_voltage",
device_class=SensorDeviceClass.VOLTAGE,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
value_fn=lambda data: data.charge.l1_voltage,
),
BesenSensorEntityDescription(
key="l1_current",
translation_key="l1_current",
device_class=SensorDeviceClass.CURRENT,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
value_fn=lambda data: data.charge.l1_amperage,
),
BesenSensorEntityDescription(
key="l2_voltage",
translation_key="l2_voltage",
device_class=SensorDeviceClass.VOLTAGE,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
three_phase_only=True,
value_fn=lambda data: data.charge.l2_voltage,
),
BesenSensorEntityDescription(
key="l2_current",
translation_key="l2_current",
device_class=SensorDeviceClass.CURRENT,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
three_phase_only=True,
value_fn=lambda data: data.charge.l2_amperage,
),
BesenSensorEntityDescription(
key="l3_voltage",
translation_key="l3_voltage",
device_class=SensorDeviceClass.VOLTAGE,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
three_phase_only=True,
value_fn=lambda data: data.charge.l3_voltage,
),
BesenSensorEntityDescription(
key="l3_current",
translation_key="l3_current",
device_class=SensorDeviceClass.CURRENT,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
three_phase_only=True,
value_fn=lambda data: data.charge.l3_amperage,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: BesenConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Besen sensors."""
coordinator = entry.runtime_data
async_add_entities(
BesenSensor(coordinator, description)
for description in SENSOR_DESCRIPTIONS
if not description.three_phase_only or coordinator.data.info.phases == 3
)
class BesenSensor(BesenEntity, SensorEntity):
"""Representation of a Besen sensor."""
entity_description: BesenSensorEntityDescription
def __init__(
self,
coordinator: BesenCoordinator,
description: BesenSensorEntityDescription,
) -> None:
"""Initialize a Besen sensor."""
super().__init__(coordinator, description.key)
self.entity_description = description
@property
@override
def native_value(self) -> StateType:
"""Return the sensor value."""
return self.entity_description.value_fn(self.coordinator.data)
@@ -38,6 +38,19 @@
}
},
"entity": {
"sensor": {
"charging_power": { "name": "Charging power" },
"external_temperature": { "name": "External temperature" },
"internal_temperature": { "name": "Internal temperature" },
"l1_current": { "name": "L1 current" },
"l1_voltage": { "name": "L1 voltage" },
"l2_current": { "name": "L2 current" },
"l2_voltage": { "name": "L2 voltage" },
"l3_current": { "name": "L3 current" },
"l3_voltage": { "name": "L3 voltage" },
"session_energy": { "name": "Session energy" },
"total_energy": { "name": "Total energy" }
},
"switch": {
"charging": { "name": "Charge" }
}
+1 -1
View File
@@ -661,7 +661,7 @@ batinfo==0.4.2
beautifulsoup4==4.13.3
# homeassistant.components.besen
besen==0.3.4
besen==0.4.0
# homeassistant.components.bizkaibus
bizkaibus==0.1.1
+29 -11
View File
@@ -6,9 +6,9 @@ from unittest.mock import AsyncMock, Mock, patch
from besen.models import BesenData, ChargerConfig, ChargerInfo, ChargeStatus
import pytest
from homeassistant.components.besen.const import DOMAIN
from homeassistant.components.besen.const import DOMAIN, PLATFORMS
from homeassistant.components.bluetooth import BluetoothServiceInfoBleak
from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_PIN
from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_PIN, Platform
from homeassistant.core import HomeAssistant
from . import publish_besen_state
@@ -52,6 +52,8 @@ def charger_state(
charger_status: bool | None = True,
available: bool = True,
authenticated: bool = True,
phases: int = 1,
charge: ChargeStatus | None = None,
) -> BesenData:
"""Return a populated charger state."""
@@ -59,19 +61,30 @@ def charger_state(
info=ChargerInfo(
address=FIXTURE_ADDRESS,
serial="SERIAL",
phases=1,
phases=phases,
manufacturer="Besen",
model="BS20",
hardware_version="HW1",
software_version="SW1",
),
config=ChargerConfig(device_name="Garage", rssi=-55),
charge=ChargeStatus(
charger_status=charger_status,
current_energy=3500,
total_energy=1.2,
current_amount=12.3,
inner_temp_c=24.5,
charge=(
charge
if charge is not None
else ChargeStatus(
charger_status=charger_status,
power=3500,
total_energy=12.3,
session_energy=1.2,
inner_temp_c=24.5,
outer_temp=22.5,
l1_voltage=230.0,
l1_amperage=15.2,
l2_voltage=231.0,
l2_amperage=15.1,
l3_voltage=232.0,
l3_amperage=15.0,
)
),
available=available,
authenticated=authenticated,
@@ -179,9 +192,14 @@ def mock_setup_entry() -> Generator[AsyncMock]:
async def setup_integration(
hass: HomeAssistant,
entry: MockConfigEntry,
platforms: list[Platform] | None = None,
) -> None:
"""Set up the Besen integration."""
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
with patch(
"homeassistant.components.besen.PLATFORMS",
platforms if platforms is not None else PLATFORMS,
):
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
File diff suppressed because it is too large Load Diff
+180
View File
@@ -0,0 +1,180 @@
"""Tests for the Besen sensor platform."""
from unittest.mock import Mock
from besen.models import ChargeStatus
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.const import (
STATE_UNAVAILABLE,
STATE_UNKNOWN,
EntityCategory,
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import publish_besen_state
from .conftest import charger_state, setup_integration
from tests.common import MockConfigEntry, snapshot_platform
POWER_ENTITY_ID = "sensor.garage_charging_power"
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
@pytest.mark.parametrize("phases", [1, 3], ids=["single_phase", "three_phase"])
async def test_sensor_state(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
mock_config_entry: MockConfigEntry,
mock_besen_client: Mock,
phases: int,
) -> None:
"""Test sensor states and registry data."""
mock_besen_client.state = charger_state(phases=phases)
await setup_integration(hass, mock_config_entry, [Platform.SENSOR])
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
mock_besen_client.async_start.assert_awaited_once()
async def test_sensor_updates_from_client(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_besen_client: Mock,
) -> None:
"""Test sensor states update from client push data."""
await setup_integration(hass, mock_config_entry, [Platform.SENSOR])
publish_besen_state(
mock_besen_client,
charger_state(
charge=ChargeStatus(
power=7200,
total_energy=123.45,
session_energy=4.56,
inner_temp_c=26.5,
)
),
)
await hass.async_block_till_done()
assert (state := hass.states.get(POWER_ENTITY_ID)) is not None
assert state.state == "7200"
assert (state := hass.states.get("sensor.garage_total_energy")) is not None
assert state.state == "123.45"
assert (state := hass.states.get("sensor.garage_session_energy")) is not None
assert state.state == "4.56"
assert (state := hass.states.get("sensor.garage_internal_temperature")) is not None
assert state.state == "26.5"
async def test_sensor_unknown_value(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_besen_client: Mock,
) -> None:
"""Test a missing measurement is unknown while the charger is available."""
await setup_integration(hass, mock_config_entry, [Platform.SENSOR])
publish_besen_state(mock_besen_client, charger_state(charge=ChargeStatus()))
await hass.async_block_till_done()
assert (state := hass.states.get(POWER_ENTITY_ID)) is not None
assert state.state == STATE_UNKNOWN
@pytest.mark.parametrize(
("available", "authenticated"),
[
(False, True),
(True, False),
],
)
async def test_sensor_unavailable_from_client_state(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_besen_client: Mock,
available: bool,
authenticated: bool,
) -> None:
"""Test sensor availability follows client connection and authentication."""
await setup_integration(hass, mock_config_entry, [Platform.SENSOR])
publish_besen_state(
mock_besen_client,
charger_state(available=available, authenticated=authenticated),
)
await hass.async_block_till_done()
assert (state := hass.states.get(POWER_ENTITY_ID)) is not None
assert state.state == STATE_UNAVAILABLE
async def test_diagnostic_sensors_disabled_by_default(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
mock_config_entry: MockConfigEntry,
mock_besen_client: Mock,
) -> None:
"""Test diagnostic sensors are disabled by default."""
mock_besen_client.state = charger_state(phases=3)
await setup_integration(hass, mock_config_entry, [Platform.SENSOR])
entries = er.async_entries_for_config_entry(
entity_registry, mock_config_entry.entry_id
)
diagnostic_entries = {
entry.unique_id: entry
for entry in entries
if entry.entity_category is EntityCategory.DIAGNOSTIC
}
assert set(diagnostic_entries) == {
f"{mock_besen_client.address}_external_temperature",
f"{mock_besen_client.address}_l1_current",
f"{mock_besen_client.address}_l1_voltage",
f"{mock_besen_client.address}_l2_current",
f"{mock_besen_client.address}_l2_voltage",
f"{mock_besen_client.address}_l3_current",
f"{mock_besen_client.address}_l3_voltage",
}
for entry in diagnostic_entries.values():
assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION
assert hass.states.get(entry.entity_id) is None
@pytest.mark.parametrize(("phases", "expected"), [(1, False), (3, True)])
async def test_three_phase_sensor_filtering(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
mock_config_entry: MockConfigEntry,
mock_besen_client: Mock,
phases: int,
expected: bool,
) -> None:
"""Test L2 and L3 sensors are added only for three-phase chargers."""
mock_besen_client.state = charger_state(phases=phases)
await setup_integration(hass, mock_config_entry, [Platform.SENSOR])
unique_ids = {
entry.unique_id
for entry in er.async_entries_for_config_entry(
entity_registry, mock_config_entry.entry_id
)
}
three_phase_unique_ids = {
f"{mock_besen_client.address}_l2_voltage",
f"{mock_besen_client.address}_l2_current",
f"{mock_besen_client.address}_l3_voltage",
f"{mock_besen_client.address}_l3_current",
}
assert three_phase_unique_ids.issubset(unique_ids) is expected
+7 -6
View File
@@ -15,6 +15,7 @@ from homeassistant.const import (
STATE_OFF,
STATE_ON,
STATE_UNAVAILABLE,
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
@@ -38,7 +39,7 @@ async def test_switch_state(
) -> None:
"""Test switch entity state and registry data."""
await setup_integration(hass, mock_config_entry)
await setup_integration(hass, mock_config_entry, [Platform.SWITCH])
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
mock_besen_client.async_start.assert_awaited_once()
@@ -51,7 +52,7 @@ async def test_switch_updates_from_client(
) -> None:
"""Test switch state updates from client push data."""
await setup_integration(hass, mock_config_entry)
await setup_integration(hass, mock_config_entry, [Platform.SWITCH])
publish_besen_state(mock_besen_client, charger_state(charger_status=False))
await hass.async_block_till_done()
@@ -68,7 +69,7 @@ async def test_switch_updates_on_refresh(
) -> None:
"""Test switch state updates when the coordinator refreshes."""
await setup_integration(hass, mock_config_entry)
await setup_integration(hass, mock_config_entry, [Platform.SWITCH])
mock_besen_client.state = charger_state(charger_status=False)
await async_update_entity(hass, ENTITY_ID)
@@ -95,7 +96,7 @@ async def test_switch_unavailable_from_client_state(
) -> None:
"""Test switch availability follows client availability and authentication."""
await setup_integration(hass, mock_config_entry)
await setup_integration(hass, mock_config_entry, [Platform.SWITCH])
publish_besen_state(
mock_besen_client,
@@ -115,7 +116,7 @@ async def test_switch_services(
) -> None:
"""Test switch turn on and turn off services."""
await setup_integration(hass, mock_config_entry)
await setup_integration(hass, mock_config_entry, [Platform.SWITCH])
await hass.services.async_call(
SWITCH_DOMAIN,
@@ -155,7 +156,7 @@ async def test_switch_command_failure(
side_effect=CommandFailed("failed")
)
await setup_integration(hass, mock_config_entry)
await setup_integration(hass, mock_config_entry, [Platform.SWITCH])
with pytest.raises(HomeAssistantError) as err:
await hass.services.async_call(