Skip to content

Commit 5e0ebdd

Browse files
authored
Add temperature sensor to SENZ integration (home-assistant#156181)
1 parent c0f61f6 commit 5e0ebdd

File tree

12 files changed

+421
-5
lines changed

12 files changed

+421
-5
lines changed

homeassistant/components/senz/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828

2929
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
3030

31-
PLATFORMS = [Platform.CLIMATE]
31+
PLATFORMS = [Platform.CLIMATE, Platform.SENSOR]
3232

3333
type SENZDataUpdateCoordinator = DataUpdateCoordinator[dict[str, Thermostat]]
3434

homeassistant/components/senz/climate.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ async def async_setup_entry(
3535
)
3636

3737

38-
class SENZClimate(CoordinatorEntity, ClimateEntity):
38+
class SENZClimate(CoordinatorEntity[SENZDataUpdateCoordinator], ClimateEntity):
3939
"""Representation of a SENZ climate entity."""
4040

4141
_attr_temperature_unit = UnitOfTemperature.CELSIUS
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
"""nVent RAYCHEM SENZ sensor platform."""
2+
3+
from __future__ import annotations
4+
5+
from collections.abc import Callable
6+
from dataclasses import dataclass
7+
8+
from aiosenz import Thermostat
9+
10+
from homeassistant.components.sensor import (
11+
SensorDeviceClass,
12+
SensorEntity,
13+
SensorEntityDescription,
14+
SensorStateClass,
15+
)
16+
from homeassistant.config_entries import ConfigEntry
17+
from homeassistant.const import UnitOfTemperature
18+
from homeassistant.core import HomeAssistant
19+
from homeassistant.helpers.device_registry import DeviceInfo
20+
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
21+
from homeassistant.helpers.update_coordinator import CoordinatorEntity
22+
23+
from . import SENZDataUpdateCoordinator
24+
from .const import DOMAIN
25+
26+
27+
@dataclass(kw_only=True, frozen=True)
28+
class SenzSensorDescription(SensorEntityDescription):
29+
"""Describes SENZ sensor entity."""
30+
31+
value_fn: Callable[[Thermostat], str | int | float | None]
32+
33+
34+
SENSORS: tuple[SenzSensorDescription, ...] = (
35+
SenzSensorDescription(
36+
key="temperature",
37+
device_class=SensorDeviceClass.TEMPERATURE,
38+
value_fn=lambda data: data.current_temperatue,
39+
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
40+
state_class=SensorStateClass.MEASUREMENT,
41+
suggested_display_precision=1,
42+
),
43+
)
44+
45+
46+
async def async_setup_entry(
47+
hass: HomeAssistant,
48+
entry: ConfigEntry,
49+
async_add_entities: AddConfigEntryEntitiesCallback,
50+
) -> None:
51+
"""Set up the SENZ sensor entities from a config entry."""
52+
coordinator: SENZDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
53+
async_add_entities(
54+
SENZSensor(thermostat, coordinator, description)
55+
for description in SENSORS
56+
for thermostat in coordinator.data.values()
57+
)
58+
59+
60+
class SENZSensor(CoordinatorEntity[SENZDataUpdateCoordinator], SensorEntity):
61+
"""Representation of a SENZ sensor entity."""
62+
63+
entity_description: SenzSensorDescription
64+
_attr_has_entity_name = True
65+
66+
def __init__(
67+
self,
68+
thermostat: Thermostat,
69+
coordinator: SENZDataUpdateCoordinator,
70+
description: SenzSensorDescription,
71+
) -> None:
72+
"""Init SENZ sensor."""
73+
super().__init__(coordinator)
74+
self.entity_description = description
75+
self._thermostat = thermostat
76+
self._attr_unique_id = f"{thermostat.serial_number}_{description.key}"
77+
self._attr_device_info = DeviceInfo(
78+
identifiers={(DOMAIN, thermostat.serial_number)},
79+
manufacturer="nVent Raychem",
80+
model="SENZ WIFI",
81+
name=thermostat.name,
82+
serial_number=thermostat.serial_number,
83+
)
84+
85+
@property
86+
def available(self) -> bool:
87+
"""Return True if the thermostat is available."""
88+
return super().available and self._thermostat.online
89+
90+
@property
91+
def native_value(self) -> str | float | int | None:
92+
"""Return the state of the sensor."""
93+
return self.entity_description.value_fn(self._thermostat)

tests/components/senz/__init__.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,12 @@
11
"""Tests for the SENZ integration."""
2+
3+
from homeassistant.core import HomeAssistant
4+
5+
from tests.common import MockConfigEntry
6+
7+
8+
async def setup_integration(hass: HomeAssistant, entry: MockConfigEntry) -> None:
9+
"""Set up the Senz integration in Home Assistant."""
10+
entry.add_to_hass(hass)
11+
await hass.config_entries.async_setup(entry.entry_id)
12+
await hass.async_block_till_done()

tests/components/senz/conftest.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
"""Fixtures for Senz testing."""
2+
3+
from collections.abc import Generator
4+
import time
5+
from typing import Any
6+
from unittest.mock import MagicMock, patch
7+
8+
from aiosenz import Account, Thermostat
9+
import pytest
10+
11+
from homeassistant.components.application_credentials import (
12+
ClientCredential,
13+
async_import_client_credential,
14+
)
15+
from homeassistant.components.senz.const import DOMAIN
16+
from homeassistant.core import HomeAssistant
17+
from homeassistant.setup import async_setup_component
18+
19+
from .const import CLIENT_ID, CLIENT_SECRET
20+
21+
from tests.common import (
22+
MockConfigEntry,
23+
async_load_json_array_fixture,
24+
async_load_json_object_fixture,
25+
)
26+
27+
28+
@pytest.fixture(scope="package")
29+
def load_device_file() -> str:
30+
"""Fixture for loading device file."""
31+
return "thermostats.json"
32+
33+
34+
@pytest.fixture
35+
async def device_fixture(
36+
hass: HomeAssistant, load_device_file: str
37+
) -> list[dict[str, Any]]:
38+
"""Fixture for device."""
39+
return await async_load_json_array_fixture(hass, load_device_file, DOMAIN)
40+
41+
42+
@pytest.fixture(scope="package")
43+
def load_account_file() -> str:
44+
"""Fixture for loading account file."""
45+
return "account.json"
46+
47+
48+
@pytest.fixture
49+
async def account_fixture(
50+
hass: HomeAssistant, load_account_file: str
51+
) -> dict[str, Any]:
52+
"""Fixture for device."""
53+
return await async_load_json_object_fixture(hass, load_account_file, DOMAIN)
54+
55+
56+
@pytest.fixture(name="expires_at")
57+
def mock_expires_at() -> float:
58+
"""Fixture to set the oauth token expiration time."""
59+
return time.time() + 3600
60+
61+
62+
@pytest.fixture
63+
def mock_config_entry(hass: HomeAssistant, expires_at: float) -> MockConfigEntry:
64+
"""Return the default mocked config entry."""
65+
config_entry = MockConfigEntry(
66+
minor_version=1,
67+
domain=DOMAIN,
68+
title="Senz test",
69+
data={
70+
"auth_implementation": DOMAIN,
71+
"token": {
72+
"access_token": "Fake_token",
73+
"expires_in": 86399,
74+
"refresh_token": "3012bc9f-7a65-4240-b817-9154ffdcc30f",
75+
"token_type": "Bearer",
76+
"expires_at": expires_at,
77+
},
78+
},
79+
entry_id="senz_test",
80+
)
81+
config_entry.add_to_hass(hass)
82+
return config_entry
83+
84+
85+
@pytest.fixture
86+
def mock_senz_client(account_fixture, device_fixture) -> Generator[MagicMock]:
87+
"""Mock thermostat data."""
88+
with patch("homeassistant.components.senz.SENZAPI", autospec=True) as mock_senz:
89+
client = mock_senz.return_value
90+
91+
client.get_account.return_value = Account(account_fixture)
92+
client.get_thermostats.return_value = [
93+
Thermostat(device, None) for device in device_fixture
94+
]
95+
96+
yield client
97+
98+
99+
@pytest.fixture(autouse=True)
100+
async def setup_credentials(hass: HomeAssistant) -> None:
101+
"""Fixture to setup credentials."""
102+
assert await async_setup_component(hass, "application_credentials", {})
103+
await async_import_client_credential(
104+
hass,
105+
DOMAIN,
106+
ClientCredential(
107+
CLIENT_ID,
108+
CLIENT_SECRET,
109+
),
110+
DOMAIN,
111+
)

tests/components/senz/const.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
"""Constants for the senz component tests."""
2+
3+
CLIENT_ID = "test_client_id"
4+
CLIENT_SECRET = "test_client_secret"
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"userName": "test_user",
3+
"temperatureScale": "celsius",
4+
"language": "en"
5+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
[
2+
{
3+
"serialNumber": "1001",
4+
"name": "Test room 1",
5+
"currentTemperature": 1845,
6+
"online": true,
7+
"isHeating": true,
8+
"setPointTemperature": 1900,
9+
"holdUntil": null,
10+
"mode": 5,
11+
"errorState": null
12+
},
13+
{
14+
"serialNumber": "1002",
15+
"name": "Test room 2",
16+
"currentTemperature": 930,
17+
"online": true,
18+
"isHeating": false,
19+
"setPointTemperature": 600,
20+
"holdUntil": null,
21+
"mode": 1,
22+
"errorState": null
23+
}
24+
]
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
# serializer version: 1
2+
# name: test_sensor_snapshot[sensor.test_room_1_temperature-entry]
3+
EntityRegistryEntrySnapshot({
4+
'aliases': set({
5+
}),
6+
'area_id': None,
7+
'capabilities': dict({
8+
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
9+
}),
10+
'config_entry_id': <ANY>,
11+
'config_subentry_id': <ANY>,
12+
'device_class': None,
13+
'device_id': <ANY>,
14+
'disabled_by': None,
15+
'domain': 'sensor',
16+
'entity_category': None,
17+
'entity_id': 'sensor.test_room_1_temperature',
18+
'has_entity_name': True,
19+
'hidden_by': None,
20+
'icon': None,
21+
'id': <ANY>,
22+
'labels': set({
23+
}),
24+
'name': None,
25+
'options': dict({
26+
'sensor': dict({
27+
'suggested_display_precision': 1,
28+
}),
29+
}),
30+
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
31+
'original_icon': None,
32+
'original_name': 'Temperature',
33+
'platform': 'senz',
34+
'previous_unique_id': None,
35+
'suggested_object_id': None,
36+
'supported_features': 0,
37+
'translation_key': None,
38+
'unique_id': '1001_temperature',
39+
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
40+
})
41+
# ---
42+
# name: test_sensor_snapshot[sensor.test_room_1_temperature-state]
43+
StateSnapshot({
44+
'attributes': ReadOnlyDict({
45+
'device_class': 'temperature',
46+
'friendly_name': 'Test room 1 Temperature',
47+
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
48+
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
49+
}),
50+
'context': <ANY>,
51+
'entity_id': 'sensor.test_room_1_temperature',
52+
'last_changed': <ANY>,
53+
'last_reported': <ANY>,
54+
'last_updated': <ANY>,
55+
'state': '18.45',
56+
})
57+
# ---
58+
# name: test_sensor_snapshot[sensor.test_room_2_temperature-entry]
59+
EntityRegistryEntrySnapshot({
60+
'aliases': set({
61+
}),
62+
'area_id': None,
63+
'capabilities': dict({
64+
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
65+
}),
66+
'config_entry_id': <ANY>,
67+
'config_subentry_id': <ANY>,
68+
'device_class': None,
69+
'device_id': <ANY>,
70+
'disabled_by': None,
71+
'domain': 'sensor',
72+
'entity_category': None,
73+
'entity_id': 'sensor.test_room_2_temperature',
74+
'has_entity_name': True,
75+
'hidden_by': None,
76+
'icon': None,
77+
'id': <ANY>,
78+
'labels': set({
79+
}),
80+
'name': None,
81+
'options': dict({
82+
'sensor': dict({
83+
'suggested_display_precision': 1,
84+
}),
85+
}),
86+
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
87+
'original_icon': None,
88+
'original_name': 'Temperature',
89+
'platform': 'senz',
90+
'previous_unique_id': None,
91+
'suggested_object_id': None,
92+
'supported_features': 0,
93+
'translation_key': None,
94+
'unique_id': '1002_temperature',
95+
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
96+
})
97+
# ---
98+
# name: test_sensor_snapshot[sensor.test_room_2_temperature-state]
99+
StateSnapshot({
100+
'attributes': ReadOnlyDict({
101+
'device_class': 'temperature',
102+
'friendly_name': 'Test room 2 Temperature',
103+
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
104+
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
105+
}),
106+
'context': <ANY>,
107+
'entity_id': 'sensor.test_room_2_temperature',
108+
'last_changed': <ANY>,
109+
'last_reported': <ANY>,
110+
'last_updated': <ANY>,
111+
'state': '9.3',
112+
})
113+
# ---

0 commit comments

Comments
 (0)