Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add platform sensor to BSBLAN integration #125474

Merged
merged 10 commits into from
Sep 13, 2024
2 changes: 1 addition & 1 deletion homeassistant/components/bsblan/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
from .const import CONF_PASSKEY, DOMAIN
from .coordinator import BSBLanUpdateCoordinator

PLATFORMS = [Platform.CLIMATE]
PLATFORMS = [Platform.CLIMATE, Platform.SENSOR]


@dataclasses.dataclass
Expand Down
6 changes: 4 additions & 2 deletions homeassistant/components/bsblan/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from datetime import timedelta
from random import randint

from bsblan import BSBLAN, BSBLANConnectionError, State
from bsblan import BSBLAN, BSBLANConnectionError, Sensor, State

from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST
Expand All @@ -19,6 +19,7 @@ class BSBLanCoordinatorData:
"""BSBLan data stored in the Home Assistant data object."""

state: State
sensor: Sensor


class BSBLanUpdateCoordinator(DataUpdateCoordinator[BSBLanCoordinatorData]):
Expand Down Expand Up @@ -54,11 +55,12 @@ async def _async_update_data(self) -> BSBLanCoordinatorData:
"""Get state and sensor data from BSB-Lan device."""
try:
state = await self.client.state()
sensor = await self.client.sensor()
except BSBLANConnectionError as err:
host = self.config_entry.data[CONF_HOST] if self.config_entry else "unknown"
raise UpdateFailed(
f"Error while establishing connection with BSB-Lan device at {host}"
) from err

self.update_interval = self._get_update_interval()
return BSBLanCoordinatorData(state=state)
return BSBLanCoordinatorData(state=state, sensor=sensor)
1 change: 1 addition & 0 deletions homeassistant/components/bsblan/diagnostics.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ async def async_get_config_entry_diagnostics(
"device": data.device.to_dict(),
"coordinator_data": {
"state": data.coordinator.data.state.to_dict(),
"sensor": data.coordinator.data.sensor.to_dict(),
},
"static": data.static.to_dict(),
}
84 changes: 84 additions & 0 deletions homeassistant/components/bsblan/sensor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
"""Support for BSB-Lan sensors."""

from __future__ import annotations

from collections.abc import Callable
from dataclasses import dataclass

from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType

from . import BSBLanData
from .const import DOMAIN
from .coordinator import BSBLanCoordinatorData
from .entity import BSBLanEntity


@dataclass(frozen=True, kw_only=True)
class BSBLanSensorEntityDescription(SensorEntityDescription):
"""Describes BSB-Lan sensor entity."""

value_fn: Callable[[BSBLanCoordinatorData], StateType]


SENSOR_TYPES: tuple[BSBLanSensorEntityDescription, ...] = (
BSBLanSensorEntityDescription(
key="current_temperature",
translation_key="current_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda data: data.sensor.current_temperature.value,
),
BSBLanSensorEntityDescription(
key="outside_temperature",
translation_key="outside_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda data: data.sensor.outside_temperature.value,
),
)


async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up BSB-Lan sensor based on a config entry."""
data: BSBLanData = hass.data[DOMAIN][entry.entry_id]
async_add_entities(BSBLanSensor(data, description) for description in SENSOR_TYPES)


class BSBLanSensor(BSBLanEntity, SensorEntity):
"""Defines a BSB-Lan sensor."""

entity_description: BSBLanSensorEntityDescription

def __init__(
self,
data: BSBLanData,
description: BSBLanSensorEntityDescription,
) -> None:
"""Initialize BSB-Lan sensor."""
super().__init__(data.coordinator, data)
self.entity_description = description
self._attr_unique_id = f"{data.device.MAC}-{description.key}"

@property
def native_value(self) -> StateType:
"""Return the state of the sensor."""
value = self.entity_description.value_fn(self.coordinator.data)
if value == "---":
return None
return value
10 changes: 10 additions & 0 deletions homeassistant/components/bsblan/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,5 +32,15 @@
"set_data_error": {
"message": "An error occurred while sending the data to the BSBLAN device"
}
},
"entity": {
"sensor": {
"current_temperature": {
"name": "Current Temperature"
},
"outside_temperature": {
"name": "Outside Temperature"
}
}
}
}
5 changes: 4 additions & 1 deletion tests/components/bsblan/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from collections.abc import Generator
from unittest.mock import AsyncMock, MagicMock, patch

from bsblan import Device, Info, State, StaticState
from bsblan import Device, Info, Sensor, State, StaticState
import pytest

from homeassistant.components.bsblan.const import CONF_PASSKEY, DOMAIN
Expand Down Expand Up @@ -55,6 +55,9 @@ def mock_bsblan() -> Generator[MagicMock, None, None]:
bsblan.static_values.return_value = StaticState.from_json(
load_fixture("static.json", DOMAIN)
)
bsblan.sensor.return_value = Sensor.from_json(
load_fixture("sensor.json", DOMAIN)
)

yield bsblan

Expand Down
20 changes: 20 additions & 0 deletions tests/components/bsblan/fixtures/sensor.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"outside_temperature": {
"name": "Outside temp sensor local",
"error": 0,
"value": "6.1",
"desc": "",
"dataType": 0,
"readonly": 0,
"unit": "°C"
},
"current_temperature": {
"name": "Room temp 1 actual value",
"error": 0,
"value": "18.6",
"desc": "",
"dataType": 0,
"readonly": 1,
"unit": "°C"
}
}
16 changes: 16 additions & 0 deletions tests/components/bsblan/snapshots/test_diagnostics.ambr
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,22 @@
# name: test_diagnostics
dict({
'coordinator_data': dict({
'sensor': dict({
'current_temperature': dict({
'data_type': 0,
'desc': '',
'name': 'Room temp 1 actual value',
'unit': '°C',
'value': '18.6',
}),
'outside_temperature': dict({
'data_type': 0,
'desc': '',
'name': 'Outside temp sensor local',
'unit': '°C',
'value': '6.1',
}),
}),
'state': dict({
'current_temperature': dict({
'data_type': 0,
Expand Down
103 changes: 103 additions & 0 deletions tests/components/bsblan/snapshots/test_sensor.ambr
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
# serializer version: 1
# name: test_sensor_entity_properties[sensor.bsb_lan_current_temperature-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.bsb_lan_current_temperature',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
'original_icon': None,
'original_name': 'Current Temperature',
'platform': 'bsblan',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'current_temperature',
'unique_id': '00:80:41:19:69:90-current_temperature',
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_sensor_entity_properties[sensor.bsb_lan_current_temperature-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'temperature',
'friendly_name': 'BSB-LAN Current Temperature',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'sensor.bsb_lan_current_temperature',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '18.6',
})
# ---
# name: test_sensor_entity_properties[sensor.bsb_lan_outside_temperature-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.bsb_lan_outside_temperature',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
'original_icon': None,
'original_name': 'Outside Temperature',
'platform': 'bsblan',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'outside_temperature',
'unique_id': '00:80:41:19:69:90-outside_temperature',
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_sensor_entity_properties[sensor.bsb_lan_outside_temperature-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'temperature',
'friendly_name': 'BSB-LAN Outside Temperature',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'sensor.bsb_lan_outside_temperature',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '6.1',
})
# ---
66 changes: 66 additions & 0 deletions tests/components/bsblan/test_sensor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"""Tests for the BSB-Lan sensor platform."""

from datetime import timedelta
from unittest.mock import AsyncMock, MagicMock

from freezegun.api import FrozenDateTimeFactory
import pytest
from syrupy.assertion import SnapshotAssertion

from homeassistant.const import STATE_UNKNOWN, Platform
from homeassistant.core import HomeAssistant
import homeassistant.helpers.entity_registry as er

from . import setup_with_selected_platforms

from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform

ENTITY_CURRENT_TEMP = "sensor.bsb_lan_current_temperature"
ENTITY_OUTSIDE_TEMP = "sensor.bsb_lan_outside_temperature"


async def test_sensor_entity_properties(
hass: HomeAssistant,
mock_bsblan: AsyncMock,
mock_config_entry: MockConfigEntry,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test the sensor entity properties."""
await setup_with_selected_platforms(hass, mock_config_entry, [Platform.SENSOR])
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)


@pytest.mark.parametrize(
("value", "expected_state"),
[
(18.6, "18.6"),
(None, STATE_UNKNOWN),
("---", STATE_UNKNOWN),
],
)
async def test_current_temperature_scenarios(
hass: HomeAssistant,
mock_bsblan: AsyncMock,
mock_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
value,
expected_state,
) -> None:
"""Test various scenarios for current temperature sensor."""
await setup_with_selected_platforms(hass, mock_config_entry, [Platform.SENSOR])

# Set up the mock value
mock_current_temp = MagicMock()
mock_current_temp.value = value
mock_bsblan.sensor.return_value.current_temperature = mock_current_temp

# Trigger an update
freezer.tick(timedelta(minutes=1))
async_fire_time_changed(hass)
await hass.async_block_till_done()

# Check the state
state = hass.states.get(ENTITY_CURRENT_TEMP)
assert state.state == expected_state