Compare commits

..

1 Commits

Author SHA1 Message Date
Paulus Schoutsen
84e4a0f22e Allow finding relevant blueprints 2025-11-15 14:01:37 -05:00
153 changed files with 1529 additions and 5636 deletions

View File

@@ -87,7 +87,7 @@ repos:
pass_filenames: false
language: script
types: [text]
files: ^(homeassistant/.+/(icons|manifest|strings)\.json|homeassistant/.+/(conditions|quality_scale|services|triggers)\.yaml|homeassistant/brands/.*\.json|script/hassfest/(?!metadata|mypy_config).+\.py|requirements.+\.txt)$
files: ^(homeassistant/.+/(icons|manifest|strings)\.json|homeassistant/.+/(quality_scale)\.yaml|homeassistant/brands/.*\.json|homeassistant/.+/services\.yaml|script/hassfest/(?!metadata|mypy_config).+\.py|requirements.+\.txt)$
- id: hassfest-metadata
name: hassfest-metadata
entry: script/run-in-env.sh python3 -m script.hassfest -p metadata,docker

View File

@@ -579,7 +579,6 @@ homeassistant.components.wiz.*
homeassistant.components.wled.*
homeassistant.components.workday.*
homeassistant.components.worldclock.*
homeassistant.components.xbox.*
homeassistant.components.xiaomi_ble.*
homeassistant.components.yale_smart_alarm.*
homeassistant.components.yalexs_ble.*

View File

@@ -45,7 +45,7 @@ SERVICE_REFRESH_SCHEMA = vol.Schema(
{vol.Optional(CONF_FORCE, default=False): cv.boolean}
)
PLATFORMS = [Platform.SENSOR, Platform.SWITCH, Platform.UPDATE]
PLATFORMS = [Platform.SENSOR, Platform.SWITCH]
type AdGuardConfigEntry = ConfigEntry[AdGuardData]

View File

@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "local_polling",
"loggers": ["adguardhome"],
"requirements": ["adguardhome==0.8.1"]
"requirements": ["adguardhome==0.8.0"]
}

View File

@@ -1,71 +0,0 @@
"""AdGuard Home Update platform."""
from __future__ import annotations
from datetime import timedelta
from typing import Any
from adguardhome import AdGuardHomeError
from homeassistant.components.update import UpdateEntity, UpdateEntityFeature
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AdGuardConfigEntry, AdGuardData
from .const import DOMAIN
from .entity import AdGuardHomeEntity
SCAN_INTERVAL = timedelta(seconds=300)
PARALLEL_UPDATES = 1
async def async_setup_entry(
hass: HomeAssistant,
entry: AdGuardConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up AdGuard Home update entity based on a config entry."""
data = entry.runtime_data
if (await data.client.update.update_available()).disabled:
return
async_add_entities([AdGuardHomeUpdate(data, entry)], True)
class AdGuardHomeUpdate(AdGuardHomeEntity, UpdateEntity):
"""Defines an AdGuard Home update."""
_attr_supported_features = UpdateEntityFeature.INSTALL
_attr_name = None
def __init__(
self,
data: AdGuardData,
entry: AdGuardConfigEntry,
) -> None:
"""Initialize AdGuard Home update."""
super().__init__(data, entry)
self._attr_unique_id = "_".join(
[DOMAIN, self.adguard.host, str(self.adguard.port), "update"]
)
async def _adguard_update(self) -> None:
"""Update AdGuard Home entity."""
value = await self.adguard.update.update_available()
self._attr_installed_version = self.data.version
self._attr_latest_version = value.new_version
self._attr_release_summary = value.announcement
self._attr_release_url = value.announcement_url
async def async_install(
self, version: str | None, backup: bool, **kwargs: Any
) -> None:
"""Install latest update."""
try:
await self.adguard.update.begin_update()
except AdGuardHomeError as err:
raise HomeAssistantError(f"Failed to install update: {err}") from err
self.hass.config_entries.async_schedule_reload(self._entry.entry_id)

View File

@@ -392,7 +392,7 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
type="tool_use",
id=response.content_block.id,
name=response.content_block.name,
input={},
input="",
)
current_tool_args = ""
if response.content_block.name == output_tool:
@@ -459,7 +459,7 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
type="server_tool_use",
id=response.content_block.id,
name=response.content_block.name,
input={},
input="",
)
current_tool_args = ""
elif isinstance(response.content_block, WebSearchToolResultBlock):

View File

@@ -8,5 +8,5 @@
"documentation": "https://www.home-assistant.io/integrations/anthropic",
"integration_type": "service",
"iot_class": "cloud_polling",
"requirements": ["anthropic==0.73.0"]
"requirements": ["anthropic==0.69.0"]
}

View File

@@ -1,7 +1,13 @@
"""The blueprint integration."""
from typing import Any
import voluptuous as vol
from homeassistant.const import CONF_NAME, CONF_SELECTOR
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.selector import selector as create_selector
from homeassistant.helpers.typing import ConfigType
from . import websocket_api
@@ -29,3 +35,61 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the blueprint integration."""
websocket_api.async_setup(hass)
return True
async def async_find_relevant_blueprints(
hass: HomeAssistant, device_id: str
) -> dict[str, list[dict[str, Any]]]:
"""Find all blueprints relevant to a specific device."""
results = {}
entities = [
entry
for entry in er.async_entries_for_device(er.async_get(hass), device_id)
if not entry.entity_category
]
async def all_blueprints_generator(hass: HomeAssistant):
"""Yield all blueprints from all domains."""
blueprint_domains: dict[str, DomainBlueprints] = hass.data[DOMAIN]
for blueprint_domain in blueprint_domains.values():
blueprints = await blueprint_domain.async_get_blueprints()
for blueprint in blueprints.values():
yield blueprint
async for blueprint in all_blueprints_generator(hass):
blueprint_input_matches: dict[str, list[str]] = {}
for info in blueprint.inputs.values():
if (
not info
or not (selector_conf := info.get(CONF_SELECTOR))
or "entity" not in selector_conf
):
continue
selector = create_selector(selector_conf)
matched = []
for entity in entities:
try:
entity.entity_id, selector(entity.entity_id)
except vol.Invalid:
continue
matched.append(entity.entity_id)
if matched:
blueprint_input_matches[info[CONF_NAME]] = matched
if not blueprint_input_matches:
continue
results.setdefault(blueprint.domain, []).append(
{
"blueprint": blueprint,
"matched_input": blueprint_input_matches,
}
)
return results

View File

@@ -20,7 +20,7 @@
"bluetooth-adapters==2.1.0",
"bluetooth-auto-recovery==1.5.3",
"bluetooth-data-tools==1.28.4",
"dbus-fast==3.0.0",
"dbus-fast==2.45.0",
"habluetooth==5.7.0"
]
}

View File

@@ -7,7 +7,6 @@
"documentation": "https://www.home-assistant.io/integrations/google_assistant_sdk",
"integration_type": "service",
"iot_class": "cloud_polling",
"quality_scale": "gold",
"requirements": ["gassist-text==0.0.14"],
"single_config_entry": true
}

View File

@@ -1,98 +0,0 @@
rules:
# Bronze
action-setup: done
appropriate-polling:
status: exempt
comment: No polling.
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions: done
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: No entities.
entity-unique-id:
status: exempt
comment: No entities.
has-entity-name:
status: exempt
comment: No entities.
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions: done
config-entry-unloading: done
docs-configuration-parameters: done
docs-installation-parameters: done
entity-unavailable:
status: exempt
comment: No entities.
integration-owner: done
log-when-unavailable:
status: exempt
comment: No entities.
parallel-updates:
status: exempt
comment: No entities to update.
reauthentication-flow: done
test-coverage: done
# Gold
devices:
status: exempt
comment: This integration acts as a service and does not represent physical devices.
diagnostics: done
discovery-update-info:
status: exempt
comment: No discovery.
discovery:
status: exempt
comment: This is a cloud service integration that cannot be discovered locally.
docs-data-update:
status: exempt
comment: No entities to update.
docs-examples: done
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: done
dynamic-devices:
status: exempt
comment: No devices.
entity-category:
status: exempt
comment: No entities.
entity-device-class:
status: exempt
comment: No entities.
entity-disabled-by-default:
status: exempt
comment: No entities.
entity-translations:
status: exempt
comment: No entities.
exception-translations: done
icon-translations: done
reconfiguration-flow: done
repair-issues:
status: exempt
comment: No repairs.
stale-devices:
status: exempt
comment: No devices.
# Platinum
async-dependency: todo
inject-websession:
status: exempt
comment: The underlying library uses gRPC, not aiohttp/httpx, for communication.
strict-typing: done

View File

@@ -56,9 +56,6 @@
"init": {
"data": {
"language_code": "Language code"
},
"data_description": {
"language_code": "Language for the Google Assistant SDK requests and responses."
}
}
}

View File

@@ -31,7 +31,6 @@ from .const import DOMAIN
if TYPE_CHECKING:
from . import GoogleSheetsConfigEntry
ADD_CREATED_COLUMN = "add_created_column"
DATA = "data"
DATA_CONFIG_ENTRY = "config_entry"
ROWS = "rows"
@@ -44,7 +43,6 @@ SHEET_SERVICE_SCHEMA = vol.All(
{
vol.Required(DATA_CONFIG_ENTRY): ConfigEntrySelector({"integration": DOMAIN}),
vol.Optional(WORKSHEET): cv.string,
vol.Optional(ADD_CREATED_COLUMN, default=True): cv.boolean,
vol.Required(DATA): vol.Any(cv.ensure_list, [dict]),
},
)
@@ -71,11 +69,10 @@ def _append_to_sheet(call: ServiceCall, entry: GoogleSheetsConfigEntry) -> None:
worksheet = sheet.worksheet(call.data.get(WORKSHEET, sheet.sheet1.title))
columns: list[str] = next(iter(worksheet.get_values("A1:ZZ1")), [])
add_created_column = call.data[ADD_CREATED_COLUMN]
now = str(datetime.now())
rows = []
for d in call.data[DATA]:
row_data = ({"created": now} | d) if add_created_column else d
row_data = {"created": now} | d
row = [row_data.get(column, "") for column in columns]
for key, value in row_data.items():
if key not in columns:

View File

@@ -9,11 +9,6 @@ append_sheet:
example: "Sheet1"
selector:
text:
add_created_column:
required: false
default: true
selector:
boolean:
data:
required: true
example: '{"hello": world, "cool": True, "count": 5}'

View File

@@ -45,10 +45,6 @@
"append_sheet": {
"description": "Appends data to a worksheet in Google Sheets.",
"fields": {
"add_created_column": {
"description": "Add a \"created\" column with the current date-time to the appended data.",
"name": "Add created column"
},
"config_entry": {
"description": "The sheet to add data to.",
"name": "Sheet"

View File

@@ -12,7 +12,6 @@ from pyicloud.exceptions import (
PyiCloudFailedLoginException,
PyiCloudNoDevicesException,
PyiCloudServiceNotActivatedException,
PyiCloudServiceUnavailable,
)
from pyicloud.services.findmyiphone import AppleDevice
@@ -131,21 +130,15 @@ class IcloudAccount:
except (
PyiCloudServiceNotActivatedException,
PyiCloudNoDevicesException,
PyiCloudServiceUnavailable,
) as err:
_LOGGER.error("No iCloud device found")
raise ConfigEntryNotReady from err
if user_info is None:
raise ConfigEntryNotReady("No user info found in iCloud devices response")
self._owner_fullname = (
f"{user_info.get('firstName')} {user_info.get('lastName')}"
)
self._owner_fullname = f"{user_info['firstName']} {user_info['lastName']}"
self._family_members_fullname = {}
if user_info.get("membersInfo") is not None:
for prs_id, member in user_info.get("membersInfo").items():
for prs_id, member in user_info["membersInfo"].items():
self._family_members_fullname[prs_id] = (
f"{member['firstName']} {member['lastName']}"
)

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/icloud",
"iot_class": "cloud_polling",
"loggers": ["keyrings.alt", "pyicloud"],
"requirements": ["pyicloud==2.2.0"]
"requirements": ["pyicloud==2.1.0"]
}

View File

@@ -237,23 +237,14 @@ class SettingDataUpdateCoordinator(
"""Implementation of PlenticoreUpdateCoordinator for settings data."""
async def _async_update_data(self) -> Mapping[str, Mapping[str, str]]:
if (client := self._plenticore.client) is None:
client = self._plenticore.client
if not self._fetch or client is None:
return {}
fetch = defaultdict(set)
_LOGGER.debug("Fetching %s for %s", self.name, self._fetch)
for module_id, data_ids in self._fetch.items():
fetch[module_id].update(data_ids)
for module_id, data_id in self.async_contexts():
fetch[module_id].add(data_id)
if not fetch:
return {}
_LOGGER.debug("Fetching %s for %s", self.name, fetch)
return await client.get_setting_values(fetch)
return await client.get_setting_values(self._fetch)
class PlenticoreSelectUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]):

View File

@@ -34,29 +34,6 @@ async def async_get_config_entry_diagnostics(
},
}
# Add important information how the inverter is configured
string_count_setting = await plenticore.client.get_setting_values(
"devices:local", "Properties:StringCnt"
)
try:
string_count = int(
string_count_setting["devices:local"]["Properties:StringCnt"]
)
except ValueError:
string_count = 0
configuration_settings = await plenticore.client.get_setting_values(
"devices:local",
(
"Properties:StringCnt",
*(f"Properties:String{idx}Features" for idx in range(string_count)),
),
)
data["configuration"] = {
**configuration_settings,
}
device_info = {**plenticore.device_info}
device_info[ATTR_IDENTIFIERS] = REDACTED # contains serial number
data["device"] = device_info

View File

@@ -5,13 +5,12 @@ from __future__ import annotations
from dataclasses import dataclass
from datetime import timedelta
import logging
from typing import Any, Final
from typing import Any
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -67,7 +66,7 @@ async def async_setup_entry(
"""Add kostal plenticore Switch."""
plenticore = entry.runtime_data
entities: list[Entity] = []
entities = []
available_settings_data = await plenticore.client.get_settings()
settings_data_update_coordinator = SettingDataUpdateCoordinator(
@@ -104,57 +103,6 @@ async def async_setup_entry(
)
)
# add shadow management switches for strings which support it
string_count_setting = await plenticore.client.get_setting_values(
"devices:local", "Properties:StringCnt"
)
try:
string_count = int(
string_count_setting["devices:local"]["Properties:StringCnt"]
)
except ValueError:
string_count = 0
dc_strings = tuple(range(string_count))
dc_string_feature_ids = tuple(
PlenticoreShadowMgmtSwitch.DC_STRING_FEATURE_DATA_ID % dc_string
for dc_string in dc_strings
)
dc_string_features = await plenticore.client.get_setting_values(
PlenticoreShadowMgmtSwitch.MODULE_ID,
dc_string_feature_ids,
)
for dc_string, dc_string_feature_id in zip(
dc_strings, dc_string_feature_ids, strict=True
):
try:
dc_string_feature = int(
dc_string_features[PlenticoreShadowMgmtSwitch.MODULE_ID][
dc_string_feature_id
]
)
except ValueError:
dc_string_feature = 0
if dc_string_feature == PlenticoreShadowMgmtSwitch.SHADOW_MANAGEMENT_SUPPORT:
entities.append(
PlenticoreShadowMgmtSwitch(
settings_data_update_coordinator,
dc_string,
entry.entry_id,
entry.title,
plenticore.device_info,
)
)
else:
_LOGGER.debug(
"Skipping shadow management for DC string %d, not supported (Feature: %d)",
dc_string + 1,
dc_string_feature,
)
async_add_entities(entities)
@@ -188,6 +136,7 @@ class PlenticoreDataSwitch(
self.off_value = description.off_value
self.off_label = description.off_label
self._attr_unique_id = f"{entry_id}_{description.module_id}_{description.key}"
self._attr_device_info = device_info
@property
@@ -240,98 +189,3 @@ class PlenticoreDataSwitch(
f"{self.platform_name} {self._name} {self.off_label}"
)
return bool(self.coordinator.data[self.module_id][self.data_id] == self._is_on)
class PlenticoreShadowMgmtSwitch(
CoordinatorEntity[SettingDataUpdateCoordinator], SwitchEntity
):
"""Representation of a Plenticore Switch for shadow management.
The shadow management switch can be controlled for each DC string separately. The DC string is
coded as bit in a single settings value, bit 0 for DC string 1, bit 1 for DC string 2, etc.
Not all DC strings are available for shadown management, for example if one of them is used
for a battery.
"""
_attr_entity_category = EntityCategory.CONFIG
entity_description: SwitchEntityDescription
MODULE_ID: Final = "devices:local"
SHADOW_DATA_ID: Final = "Generator:ShadowMgmt:Enable"
"""Settings id for the bit coded shadow management."""
DC_STRING_FEATURE_DATA_ID: Final = "Properties:String%dFeatures"
"""Settings id pattern for the DC string features."""
SHADOW_MANAGEMENT_SUPPORT: Final = 1
"""Feature value for shadow management support in the DC string features."""
def __init__(
self,
coordinator: SettingDataUpdateCoordinator,
dc_string: int,
entry_id: str,
platform_name: str,
device_info: DeviceInfo,
) -> None:
"""Create a new Switch Entity for Plenticore shadow management."""
super().__init__(coordinator, context=(self.MODULE_ID, self.SHADOW_DATA_ID))
self._mask: Final = 1 << dc_string
self.entity_description = SwitchEntityDescription(
key=f"ShadowMgmt{dc_string}",
name=f"Shadow Management DC string {dc_string + 1}",
entity_registry_enabled_default=False,
)
self.platform_name = platform_name
self._attr_name = f"{platform_name} {self.entity_description.name}"
self._attr_unique_id = (
f"{entry_id}_{self.MODULE_ID}_{self.SHADOW_DATA_ID}_{dc_string}"
)
self._attr_device_info = device_info
@property
def available(self) -> bool:
"""Return if entity is available."""
return (
super().available
and self.coordinator.data is not None
and self.MODULE_ID in self.coordinator.data
and self.SHADOW_DATA_ID in self.coordinator.data[self.MODULE_ID]
)
def _get_shadow_mgmt_value(self) -> int:
"""Return the current shadow management value for all strings as integer."""
try:
return int(self.coordinator.data[self.MODULE_ID][self.SHADOW_DATA_ID])
except ValueError:
return 0
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn shadow management on."""
shadow_mgmt_value = self._get_shadow_mgmt_value()
shadow_mgmt_value |= self._mask
if await self.coordinator.async_write_data(
self.MODULE_ID, {self.SHADOW_DATA_ID: str(shadow_mgmt_value)}
):
await self.coordinator.async_request_refresh()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn shadow management off."""
shadow_mgmt_value = self._get_shadow_mgmt_value()
shadow_mgmt_value &= ~self._mask
if await self.coordinator.async_write_data(
self.MODULE_ID, {self.SHADOW_DATA_ID: str(shadow_mgmt_value)}
):
await self.coordinator.async_request_refresh()
@property
def is_on(self) -> bool:
"""Return true if shadow management is on."""
return (self._get_shadow_mgmt_value() & self._mask) != 0

View File

@@ -1,7 +1,6 @@
"""Support for LCN binary sensors."""
from collections.abc import Iterable
from datetime import timedelta
from functools import partial
import pypck
@@ -20,7 +19,6 @@ from .entity import LcnEntity
from .helpers import InputType, LcnConfigEntry
PARALLEL_UPDATES = 0
SCAN_INTERVAL = timedelta(minutes=1)
def add_lcn_entities(
@@ -71,11 +69,21 @@ class LcnBinarySensor(LcnEntity, BinarySensorEntity):
config[CONF_DOMAIN_DATA][CONF_SOURCE]
]
async def async_update(self) -> None:
"""Update the state of the entity."""
await self.device_connection.request_status_binary_sensors(
SCAN_INTERVAL.seconds
)
async def async_added_to_hass(self) -> None:
"""Run when entity about to be added to hass."""
await super().async_added_to_hass()
if not self.device_connection.is_group:
await self.device_connection.activate_status_request_handler(
self.bin_sensor_port
)
async def async_will_remove_from_hass(self) -> None:
"""Run when entity will be removed from hass."""
await super().async_will_remove_from_hass()
if not self.device_connection.is_group:
await self.device_connection.cancel_status_request_handler(
self.bin_sensor_port
)
def input_received(self, input_obj: InputType) -> None:
"""Set sensor value when LCN input object (command) is received."""

View File

@@ -1,8 +1,6 @@
"""Support for LCN climate control."""
import asyncio
from collections.abc import Iterable
from datetime import timedelta
from functools import partial
from typing import Any, cast
@@ -38,7 +36,6 @@ from .entity import LcnEntity
from .helpers import InputType, LcnConfigEntry
PARALLEL_UPDATES = 0
SCAN_INTERVAL = timedelta(minutes=1)
def add_lcn_entities(
@@ -113,6 +110,20 @@ class LcnClimate(LcnEntity, ClimateEntity):
ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON
)
async def async_added_to_hass(self) -> None:
"""Run when entity about to be added to hass."""
await super().async_added_to_hass()
if not self.device_connection.is_group:
await self.device_connection.activate_status_request_handler(self.variable)
await self.device_connection.activate_status_request_handler(self.setpoint)
async def async_will_remove_from_hass(self) -> None:
"""Run when entity will be removed from hass."""
await super().async_will_remove_from_hass()
if not self.device_connection.is_group:
await self.device_connection.cancel_status_request_handler(self.variable)
await self.device_connection.cancel_status_request_handler(self.setpoint)
@property
def temperature_unit(self) -> str:
"""Return the unit of measurement."""
@@ -181,17 +192,6 @@ class LcnClimate(LcnEntity, ClimateEntity):
self._target_temperature = temperature
self.async_write_ha_state()
async def async_update(self) -> None:
"""Update the state of the entity."""
await asyncio.gather(
self.device_connection.request_status_variable(
self.variable, SCAN_INTERVAL.seconds
),
self.device_connection.request_status_variable(
self.setpoint, SCAN_INTERVAL.seconds
),
)
def input_received(self, input_obj: InputType) -> None:
"""Set temperature value when LCN input object is received."""
if not isinstance(input_obj, pypck.inputs.ModStatusVar):

View File

@@ -1,8 +1,6 @@
"""Support for LCN covers."""
import asyncio
from collections.abc import Iterable
from datetime import timedelta
from functools import partial
from typing import Any
@@ -29,7 +27,6 @@ from .entity import LcnEntity
from .helpers import InputType, LcnConfigEntry
PARALLEL_UPDATES = 0
SCAN_INTERVAL = timedelta(minutes=1)
def add_lcn_entities(
@@ -76,7 +73,7 @@ async def async_setup_entry(
class LcnOutputsCover(LcnEntity, CoverEntity):
"""Representation of a LCN cover connected to output ports."""
_attr_is_closed = True
_attr_is_closed = False
_attr_is_closing = False
_attr_is_opening = False
_attr_assumed_state = True
@@ -96,6 +93,28 @@ class LcnOutputsCover(LcnEntity, CoverEntity):
else:
self.reverse_time = None
async def async_added_to_hass(self) -> None:
"""Run when entity about to be added to hass."""
await super().async_added_to_hass()
if not self.device_connection.is_group:
await self.device_connection.activate_status_request_handler(
pypck.lcn_defs.OutputPort["OUTPUTUP"]
)
await self.device_connection.activate_status_request_handler(
pypck.lcn_defs.OutputPort["OUTPUTDOWN"]
)
async def async_will_remove_from_hass(self) -> None:
"""Run when entity will be removed from hass."""
await super().async_will_remove_from_hass()
if not self.device_connection.is_group:
await self.device_connection.cancel_status_request_handler(
pypck.lcn_defs.OutputPort["OUTPUTUP"]
)
await self.device_connection.cancel_status_request_handler(
pypck.lcn_defs.OutputPort["OUTPUTDOWN"]
)
async def async_close_cover(self, **kwargs: Any) -> None:
"""Close the cover."""
state = pypck.lcn_defs.MotorStateModifier.DOWN
@@ -128,18 +147,6 @@ class LcnOutputsCover(LcnEntity, CoverEntity):
self._attr_is_opening = False
self.async_write_ha_state()
async def async_update(self) -> None:
"""Update the state of the entity."""
if not self.device_connection.is_group:
await asyncio.gather(
self.device_connection.request_status_output(
pypck.lcn_defs.OutputPort["OUTPUTUP"], SCAN_INTERVAL.seconds
),
self.device_connection.request_status_output(
pypck.lcn_defs.OutputPort["OUTPUTDOWN"], SCAN_INTERVAL.seconds
),
)
def input_received(self, input_obj: InputType) -> None:
"""Set cover states when LCN input object (command) is received."""
if (
@@ -168,7 +175,7 @@ class LcnOutputsCover(LcnEntity, CoverEntity):
class LcnRelayCover(LcnEntity, CoverEntity):
"""Representation of a LCN cover connected to relays."""
_attr_is_closed = True
_attr_is_closed = False
_attr_is_closing = False
_attr_is_opening = False
_attr_assumed_state = True
@@ -199,6 +206,20 @@ class LcnRelayCover(LcnEntity, CoverEntity):
self._is_closing = False
self._is_opening = False
async def async_added_to_hass(self) -> None:
"""Run when entity about to be added to hass."""
await super().async_added_to_hass()
if not self.device_connection.is_group:
await self.device_connection.activate_status_request_handler(
self.motor, self.positioning_mode
)
async def async_will_remove_from_hass(self) -> None:
"""Run when entity will be removed from hass."""
await super().async_will_remove_from_hass()
if not self.device_connection.is_group:
await self.device_connection.cancel_status_request_handler(self.motor)
async def async_close_cover(self, **kwargs: Any) -> None:
"""Close the cover."""
if not await self.device_connection.control_motor_relays(
@@ -253,17 +274,6 @@ class LcnRelayCover(LcnEntity, CoverEntity):
self.async_write_ha_state()
async def async_update(self) -> None:
"""Update the state of the entity."""
coros = [self.device_connection.request_status_relays(SCAN_INTERVAL.seconds)]
if self.positioning_mode == pypck.lcn_defs.MotorPositioningMode.BS4:
coros.append(
self.device_connection.request_status_motor_position(
self.motor, self.positioning_mode, SCAN_INTERVAL.seconds
)
)
await asyncio.gather(*coros)
def input_received(self, input_obj: InputType) -> None:
"""Set cover states when LCN input object (command) is received."""
if isinstance(input_obj, pypck.inputs.ModStatusRelays):

View File

@@ -22,6 +22,7 @@ from .helpers import (
class LcnEntity(Entity):
"""Parent class for all entities associated with the LCN component."""
_attr_should_poll = False
_attr_has_entity_name = True
device_connection: DeviceConnectionType
@@ -56,24 +57,15 @@ class LcnEntity(Entity):
).lower(),
)
@property
def should_poll(self) -> bool:
"""Groups may not poll for a status."""
return not self.device_connection.is_group
async def async_added_to_hass(self) -> None:
"""Run when entity about to be added to hass."""
self.device_connection = get_device_connection(
self.hass, self.config[CONF_ADDRESS], self.config_entry
)
if self.device_connection.is_group:
return
self._unregister_for_inputs = self.device_connection.register_for_inputs(
self.input_received
)
self.schedule_update_ha_state(force_refresh=True)
if not self.device_connection.is_group:
self._unregister_for_inputs = self.device_connection.register_for_inputs(
self.input_received
)
async def async_will_remove_from_hass(self) -> None:
"""Run when entity will be removed from hass."""

View File

@@ -251,19 +251,13 @@ async def async_update_device_config(
"""Fill missing values in device_config with infos from LCN bus."""
# fetch serial info if device is module
if not (is_group := device_config[CONF_ADDRESS][2]): # is module
await device_connection.serials_known()
await device_connection.serial_known
if device_config[CONF_HARDWARE_SERIAL] == -1:
device_config[CONF_HARDWARE_SERIAL] = (
device_connection.serials.hardware_serial
)
device_config[CONF_HARDWARE_SERIAL] = device_connection.hardware_serial
if device_config[CONF_SOFTWARE_SERIAL] == -1:
device_config[CONF_SOFTWARE_SERIAL] = (
device_connection.serials.software_serial
)
device_config[CONF_SOFTWARE_SERIAL] = device_connection.software_serial
if device_config[CONF_HARDWARE_TYPE] == -1:
device_config[CONF_HARDWARE_TYPE] = (
device_connection.serials.hardware_type.value
)
device_config[CONF_HARDWARE_TYPE] = device_connection.hardware_type.value
# fetch name if device is module
if device_config[CONF_NAME] != "":

View File

@@ -1,7 +1,6 @@
"""Support for LCN lights."""
from collections.abc import Iterable
from datetime import timedelta
from functools import partial
from typing import Any
@@ -34,7 +33,6 @@ from .helpers import InputType, LcnConfigEntry
BRIGHTNESS_SCALE = (1, 100)
PARALLEL_UPDATES = 0
SCAN_INTERVAL = timedelta(minutes=1)
def add_lcn_entities(
@@ -102,6 +100,18 @@ class LcnOutputLight(LcnEntity, LightEntity):
self._attr_color_mode = ColorMode.ONOFF
self._attr_supported_color_modes = {self._attr_color_mode}
async def async_added_to_hass(self) -> None:
"""Run when entity about to be added to hass."""
await super().async_added_to_hass()
if not self.device_connection.is_group:
await self.device_connection.activate_status_request_handler(self.output)
async def async_will_remove_from_hass(self) -> None:
"""Run when entity will be removed from hass."""
await super().async_will_remove_from_hass()
if not self.device_connection.is_group:
await self.device_connection.cancel_status_request_handler(self.output)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the entity on."""
if ATTR_TRANSITION in kwargs:
@@ -147,12 +157,6 @@ class LcnOutputLight(LcnEntity, LightEntity):
self._attr_is_on = False
self.async_write_ha_state()
async def async_update(self) -> None:
"""Update the state of the entity."""
await self.device_connection.request_status_output(
self.output, SCAN_INTERVAL.seconds
)
def input_received(self, input_obj: InputType) -> None:
"""Set light state when LCN input object (command) is received."""
if (
@@ -180,6 +184,18 @@ class LcnRelayLight(LcnEntity, LightEntity):
self.output = pypck.lcn_defs.RelayPort[config[CONF_DOMAIN_DATA][CONF_OUTPUT]]
async def async_added_to_hass(self) -> None:
"""Run when entity about to be added to hass."""
await super().async_added_to_hass()
if not self.device_connection.is_group:
await self.device_connection.activate_status_request_handler(self.output)
async def async_will_remove_from_hass(self) -> None:
"""Run when entity will be removed from hass."""
await super().async_will_remove_from_hass()
if not self.device_connection.is_group:
await self.device_connection.cancel_status_request_handler(self.output)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the entity on."""
states = [pypck.lcn_defs.RelayStateModifier.NOCHANGE] * 8
@@ -198,10 +214,6 @@ class LcnRelayLight(LcnEntity, LightEntity):
self._attr_is_on = False
self.async_write_ha_state()
async def async_update(self) -> None:
"""Update the state of the entity."""
await self.device_connection.request_status_relays(SCAN_INTERVAL.seconds)
def input_received(self, input_obj: InputType) -> None:
"""Set light state when LCN input object (command) is received."""
if not isinstance(input_obj, pypck.inputs.ModStatusRelays):

View File

@@ -6,8 +6,8 @@
"config_flow": true,
"dependencies": ["http", "websocket_api"],
"documentation": "https://www.home-assistant.io/integrations/lcn",
"iot_class": "local_polling",
"iot_class": "local_push",
"loggers": ["pypck"],
"quality_scale": "bronze",
"requirements": ["pypck==0.9.2", "lcn-frontend==0.2.7"]
"requirements": ["pypck==0.8.12", "lcn-frontend==0.2.7"]
}

View File

@@ -1,7 +1,6 @@
"""Support for LCN sensors."""
from collections.abc import Iterable
from datetime import timedelta
from functools import partial
from itertools import chain
@@ -41,8 +40,6 @@ from .entity import LcnEntity
from .helpers import InputType, LcnConfigEntry
PARALLEL_UPDATES = 0
SCAN_INTERVAL = timedelta(minutes=1)
DEVICE_CLASS_MAPPING = {
pypck.lcn_defs.VarUnit.CELSIUS: SensorDeviceClass.TEMPERATURE,
@@ -131,11 +128,17 @@ class LcnVariableSensor(LcnEntity, SensorEntity):
)
self._attr_device_class = DEVICE_CLASS_MAPPING.get(self.unit)
async def async_update(self) -> None:
"""Update the state of the entity."""
await self.device_connection.request_status_variable(
self.variable, SCAN_INTERVAL.seconds
)
async def async_added_to_hass(self) -> None:
"""Run when entity about to be added to hass."""
await super().async_added_to_hass()
if not self.device_connection.is_group:
await self.device_connection.activate_status_request_handler(self.variable)
async def async_will_remove_from_hass(self) -> None:
"""Run when entity will be removed from hass."""
await super().async_will_remove_from_hass()
if not self.device_connection.is_group:
await self.device_connection.cancel_status_request_handler(self.variable)
def input_received(self, input_obj: InputType) -> None:
"""Set sensor value when LCN input object (command) is received."""
@@ -167,11 +170,17 @@ class LcnLedLogicSensor(LcnEntity, SensorEntity):
config[CONF_DOMAIN_DATA][CONF_SOURCE]
]
async def async_update(self) -> None:
"""Update the state of the entity."""
await self.device_connection.request_status_led_and_logic_ops(
SCAN_INTERVAL.seconds
)
async def async_added_to_hass(self) -> None:
"""Run when entity about to be added to hass."""
await super().async_added_to_hass()
if not self.device_connection.is_group:
await self.device_connection.activate_status_request_handler(self.source)
async def async_will_remove_from_hass(self) -> None:
"""Run when entity will be removed from hass."""
await super().async_will_remove_from_hass()
if not self.device_connection.is_group:
await self.device_connection.cancel_status_request_handler(self.source)
def input_received(self, input_obj: InputType) -> None:
"""Set sensor value when LCN input object (command) is received."""

View File

@@ -380,6 +380,9 @@ class LockKeys(LcnServiceCall):
else:
await device_connection.lock_keys(table_id, states)
handler = device_connection.status_requests_handler
await handler.request_status_locked_keys_timeout()
class DynText(LcnServiceCall):
"""Send dynamic text to LCN-GTxD displays."""

View File

@@ -1,7 +1,6 @@
"""Support for LCN switches."""
from collections.abc import Iterable
from datetime import timedelta
from functools import partial
from typing import Any
@@ -18,7 +17,6 @@ from .entity import LcnEntity
from .helpers import InputType, LcnConfigEntry
PARALLEL_UPDATES = 0
SCAN_INTERVAL = timedelta(minutes=1)
def add_lcn_switch_entities(
@@ -79,6 +77,18 @@ class LcnOutputSwitch(LcnEntity, SwitchEntity):
self.output = pypck.lcn_defs.OutputPort[config[CONF_DOMAIN_DATA][CONF_OUTPUT]]
async def async_added_to_hass(self) -> None:
"""Run when entity about to be added to hass."""
await super().async_added_to_hass()
if not self.device_connection.is_group:
await self.device_connection.activate_status_request_handler(self.output)
async def async_will_remove_from_hass(self) -> None:
"""Run when entity will be removed from hass."""
await super().async_will_remove_from_hass()
if not self.device_connection.is_group:
await self.device_connection.cancel_status_request_handler(self.output)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the entity on."""
if not await self.device_connection.dim_output(self.output.value, 100, 0):
@@ -93,12 +103,6 @@ class LcnOutputSwitch(LcnEntity, SwitchEntity):
self._attr_is_on = False
self.async_write_ha_state()
async def async_update(self) -> None:
"""Update the state of the entity."""
await self.device_connection.request_status_output(
self.output, SCAN_INTERVAL.seconds
)
def input_received(self, input_obj: InputType) -> None:
"""Set switch state when LCN input object (command) is received."""
if (
@@ -122,6 +126,18 @@ class LcnRelaySwitch(LcnEntity, SwitchEntity):
self.output = pypck.lcn_defs.RelayPort[config[CONF_DOMAIN_DATA][CONF_OUTPUT]]
async def async_added_to_hass(self) -> None:
"""Run when entity about to be added to hass."""
await super().async_added_to_hass()
if not self.device_connection.is_group:
await self.device_connection.activate_status_request_handler(self.output)
async def async_will_remove_from_hass(self) -> None:
"""Run when entity will be removed from hass."""
await super().async_will_remove_from_hass()
if not self.device_connection.is_group:
await self.device_connection.cancel_status_request_handler(self.output)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the entity on."""
states = [pypck.lcn_defs.RelayStateModifier.NOCHANGE] * 8
@@ -140,10 +156,6 @@ class LcnRelaySwitch(LcnEntity, SwitchEntity):
self._attr_is_on = False
self.async_write_ha_state()
async def async_update(self) -> None:
"""Update the state of the entity."""
await self.device_connection.request_status_relays(SCAN_INTERVAL.seconds)
def input_received(self, input_obj: InputType) -> None:
"""Set switch state when LCN input object (command) is received."""
if not isinstance(input_obj, pypck.inputs.ModStatusRelays):
@@ -167,6 +179,22 @@ class LcnRegulatorLockSwitch(LcnEntity, SwitchEntity):
]
self.reg_id = pypck.lcn_defs.Var.to_set_point_id(self.setpoint_variable)
async def async_added_to_hass(self) -> None:
"""Run when entity about to be added to hass."""
await super().async_added_to_hass()
if not self.device_connection.is_group:
await self.device_connection.activate_status_request_handler(
self.setpoint_variable
)
async def async_will_remove_from_hass(self) -> None:
"""Run when entity will be removed from hass."""
await super().async_will_remove_from_hass()
if not self.device_connection.is_group:
await self.device_connection.cancel_status_request_handler(
self.setpoint_variable
)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the entity on."""
if not await self.device_connection.lock_regulator(self.reg_id, True):
@@ -181,12 +209,6 @@ class LcnRegulatorLockSwitch(LcnEntity, SwitchEntity):
self._attr_is_on = False
self.async_write_ha_state()
async def async_update(self) -> None:
"""Update the state of the entity."""
await self.device_connection.request_status_variable(
self.setpoint_variable, SCAN_INTERVAL.seconds
)
def input_received(self, input_obj: InputType) -> None:
"""Set switch state when LCN input object (command) is received."""
if (
@@ -212,6 +234,18 @@ class LcnKeyLockSwitch(LcnEntity, SwitchEntity):
self.table_id = ord(self.key.name[0]) - 65
self.key_id = int(self.key.name[1]) - 1
async def async_added_to_hass(self) -> None:
"""Run when entity about to be added to hass."""
await super().async_added_to_hass()
if not self.device_connection.is_group:
await self.device_connection.activate_status_request_handler(self.key)
async def async_will_remove_from_hass(self) -> None:
"""Run when entity will be removed from hass."""
await super().async_will_remove_from_hass()
if not self.device_connection.is_group:
await self.device_connection.cancel_status_request_handler(self.key)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the entity on."""
states = [pypck.lcn_defs.KeyLockStateModifier.NOCHANGE] * 8
@@ -234,10 +268,6 @@ class LcnKeyLockSwitch(LcnEntity, SwitchEntity):
self._attr_is_on = False
self.async_write_ha_state()
async def async_update(self) -> None:
"""Update the state of the entity."""
await self.device_connection.request_status_locked_keys(SCAN_INTERVAL.seconds)
def input_received(self, input_obj: InputType) -> None:
"""Set switch state when LCN input object (command) is received."""
if (

View File

@@ -4237,8 +4237,7 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow):
return self.async_show_form(
step_id="entity",
data_schema=data_schema,
description_placeholders=TRANSLATION_DESCRIPTION_PLACEHOLDERS
| {
description_placeholders={
"mqtt_device": device_name,
"entity_name_label": entity_name_label,
"platform_label": platform_label,

View File

@@ -1312,7 +1312,6 @@
"speed": "[%key:component::sensor::entity_component::speed::name%]",
"sulphur_dioxide": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]",
"temperature": "[%key:component::sensor::entity_component::temperature::name%]",
"temperature_delta": "[%key:component::sensor::entity_component::temperature_delta::name%]",
"timestamp": "[%key:component::sensor::entity_component::timestamp::name%]",
"volatile_organic_compounds": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]",
"volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds_parts::name%]",

View File

@@ -19,5 +19,5 @@
"documentation": "https://www.home-assistant.io/integrations/nest",
"iot_class": "cloud_push",
"loggers": ["google_nest_sdm"],
"requirements": ["google-nest-sdm==9.0.1"]
"requirements": ["google-nest-sdm==7.1.4"]
}

View File

@@ -1,6 +1,8 @@
rules:
# Bronze
config-flow: done
config-flow:
status: todo
comment: Some fields are missing a data_description
brands: done
dependency-transparency: done
common-modules:

View File

@@ -34,9 +34,6 @@
"data": {
"cloud_project_id": "Google Cloud Project ID"
},
"data_description": {
"cloud_project_id": "The Google Cloud Project ID which can be obtained from the Cloud Console"
},
"description": "Enter the Cloud Project ID below e.g. *example-project-12345*. See the [Google Cloud Console]({cloud_console_url}) or the documentation for [more info]({more_info_url}).",
"title": "Nest: Enter Cloud Project ID"
},
@@ -48,9 +45,6 @@
"data": {
"project_id": "Device Access Project ID"
},
"data_description": {
"project_id": "The Device Access Project ID which can be obtained from the Device Access Console"
},
"description": "Create a Nest Device Access project which **requires paying Google a US $5 fee** to set up.\n1. Go to the [Device Access Console]({device_access_console_url}), and through the payment flow.\n1. Select on **Create project**\n1. Give your Device Access project a name and select **Next**.\n1. Enter your OAuth Client ID\n1. Skip enabling events for now and select **Create project**.\n\nEnter your Device Access Project ID below ([more info]({more_info_url})).",
"title": "Nest: Create a Device Access Project"
},
@@ -70,9 +64,6 @@
"data": {
"subscription_name": "Pub/Sub subscription name"
},
"data_description": {
"subscription_name": "The Pub/Sub subscription name to receive Nest device updates"
},
"description": "Home Assistant receives realtime Nest device updates with a Cloud Pub/Sub subscription for topic `{topic}`.\n\nSelect an existing subscription below if one already exists, or the next step will create a new one for you. See the integration documentation for [more info]({more_info_url}).",
"title": "Configure Cloud Pub/Sub subscription"
},
@@ -80,9 +71,6 @@
"data": {
"topic_name": "Pub/Sub topic name"
},
"data_description": {
"topic_name": "The Pub/Sub topic name configured in the Device Access Console"
},
"description": "Nest devices publish updates on a Cloud Pub/Sub topic. You can select an existing topic if one exists, or choose to create a new topic and the next step will create it for you with the necessary permissions. See the integration documentation for [more info]({more_info_url}).",
"title": "Configure Cloud Pub/Sub topic"
},

View File

@@ -37,7 +37,6 @@ SELECT_TYPES = (
PlugwiseSelectEntityDescription(
key=SELECT_SCHEDULE,
translation_key=SELECT_SCHEDULE,
entity_category=EntityCategory.CONFIG,
options_key="available_schedules",
),
PlugwiseSelectEntityDescription(

View File

@@ -48,6 +48,7 @@ SENSORS: tuple[PlugwiseSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
),
PlugwiseSensorEntityDescription(
key="setpoint_high",
@@ -55,6 +56,7 @@ SENSORS: tuple[PlugwiseSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
),
PlugwiseSensorEntityDescription(
key="setpoint_low",
@@ -62,11 +64,13 @@ SENSORS: tuple[PlugwiseSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
),
PlugwiseSensorEntityDescription(
key="temperature",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
),
PlugwiseSensorEntityDescription(
@@ -90,7 +94,6 @@ SENSORS: tuple[PlugwiseSensorEntityDescription, ...] = (
translation_key="outdoor_temperature",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
),
PlugwiseSensorEntityDescription(
@@ -349,8 +352,8 @@ SENSORS: tuple[PlugwiseSensorEntityDescription, ...] = (
key="illuminance",
native_unit_of_measurement=LIGHT_LUX,
device_class=SensorDeviceClass.ILLUMINANCE,
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
),
PlugwiseSensorEntityDescription(
key="modulation_level",
@@ -362,8 +365,8 @@ SENSORS: tuple[PlugwiseSensorEntityDescription, ...] = (
PlugwiseSensorEntityDescription(
key="valve_position",
translation_key="valve_position",
native_unit_of_measurement=PERCENTAGE,
entity_category=EntityCategory.DIAGNOSTIC,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
),
PlugwiseSensorEntityDescription(

View File

@@ -2,17 +2,5 @@
from __future__ import annotations
from homeassistant.const import UnitOfTemperature, UnitOfVolumeFlowRate
DOMAIN = "pooldose"
MANUFACTURER = "SEKO"
# Mapping of device units to Home Assistant units
UNIT_MAPPING: dict[str, str] = {
# Temperature units
"°C": UnitOfTemperature.CELSIUS,
"°F": UnitOfTemperature.FAHRENHEIT,
# Volume flow rate units
"m3/h": UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR,
"L/s": UnitOfVolumeFlowRate.LITERS_PER_SECOND,
}

View File

@@ -1,15 +1,6 @@
{
"entity": {
"sensor": {
"cl": {
"default": "mdi:pool"
},
"cl_type_dosing": {
"default": "mdi:flask"
},
"flow_rate": {
"default": "mdi:pipe-valve"
},
"ofa_orp_time": {
"default": "mdi:clock"
},
@@ -31,9 +22,6 @@
"orp_type_dosing": {
"default": "mdi:flask"
},
"peristaltic_cl_dosing": {
"default": "mdi:pump"
},
"peristaltic_orp_dosing": {
"default": "mdi:pump"
},

View File

@@ -2,7 +2,6 @@
from __future__ import annotations
from dataclasses import dataclass
import logging
from typing import TYPE_CHECKING
@@ -11,61 +10,36 @@ from homeassistant.components.sensor import (
SensorEntity,
SensorEntityDescription,
)
from homeassistant.const import (
CONCENTRATION_PARTS_PER_MILLION,
EntityCategory,
UnitOfElectricPotential,
UnitOfTime,
)
from homeassistant.const import EntityCategory, UnitOfElectricPotential, UnitOfTime
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import PooldoseConfigEntry
from .const import UNIT_MAPPING
from .entity import PooldoseEntity
_LOGGER = logging.getLogger(__name__)
@dataclass(frozen=True, kw_only=True)
class PooldoseSensorEntityDescription(SensorEntityDescription):
"""Describes PoolDose sensor entity."""
use_dynamic_unit: bool = False
SENSOR_DESCRIPTIONS: tuple[PooldoseSensorEntityDescription, ...] = (
PooldoseSensorEntityDescription(
SENSOR_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(
key="temperature",
device_class=SensorDeviceClass.TEMPERATURE,
use_dynamic_unit=True,
# Unit dynamically determined via API
),
PooldoseSensorEntityDescription(key="ph", device_class=SensorDeviceClass.PH),
PooldoseSensorEntityDescription(
SensorEntityDescription(key="ph", device_class=SensorDeviceClass.PH),
SensorEntityDescription(
key="orp",
translation_key="orp",
device_class=SensorDeviceClass.VOLTAGE,
native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT,
),
PooldoseSensorEntityDescription(
key="cl",
translation_key="cl",
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
),
PooldoseSensorEntityDescription(
key="flow_rate",
translation_key="flow_rate",
device_class=SensorDeviceClass.VOLUME_FLOW_RATE,
use_dynamic_unit=True,
),
PooldoseSensorEntityDescription(
SensorEntityDescription(
key="ph_type_dosing",
translation_key="ph_type_dosing",
entity_category=EntityCategory.DIAGNOSTIC,
device_class=SensorDeviceClass.ENUM,
options=["alcalyne", "acid"],
),
PooldoseSensorEntityDescription(
SensorEntityDescription(
key="peristaltic_ph_dosing",
translation_key="peristaltic_ph_dosing",
entity_category=EntityCategory.DIAGNOSTIC,
@@ -73,7 +47,7 @@ SENSOR_DESCRIPTIONS: tuple[PooldoseSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.ENUM,
options=["proportional", "on_off", "timed"],
),
PooldoseSensorEntityDescription(
SensorEntityDescription(
key="ofa_ph_time",
translation_key="ofa_ph_time",
entity_category=EntityCategory.DIAGNOSTIC,
@@ -81,7 +55,7 @@ SENSOR_DESCRIPTIONS: tuple[PooldoseSensorEntityDescription, ...] = (
entity_registry_enabled_default=False,
native_unit_of_measurement=UnitOfTime.MINUTES,
),
PooldoseSensorEntityDescription(
SensorEntityDescription(
key="orp_type_dosing",
translation_key="orp_type_dosing",
entity_category=EntityCategory.DIAGNOSTIC,
@@ -89,7 +63,7 @@ SENSOR_DESCRIPTIONS: tuple[PooldoseSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.ENUM,
options=["low", "high"],
),
PooldoseSensorEntityDescription(
SensorEntityDescription(
key="peristaltic_orp_dosing",
translation_key="peristaltic_orp_dosing",
entity_category=EntityCategory.DIAGNOSTIC,
@@ -97,23 +71,7 @@ SENSOR_DESCRIPTIONS: tuple[PooldoseSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.ENUM,
options=["off", "proportional", "on_off", "timed"],
),
PooldoseSensorEntityDescription(
key="cl_type_dosing",
translation_key="cl_type_dosing",
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
device_class=SensorDeviceClass.ENUM,
options=["low", "high"],
),
PooldoseSensorEntityDescription(
key="peristaltic_cl_dosing",
translation_key="peristaltic_cl_dosing",
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
device_class=SensorDeviceClass.ENUM,
options=["off", "proportional", "on_off", "timed"],
),
PooldoseSensorEntityDescription(
SensorEntityDescription(
key="ofa_orp_time",
translation_key="ofa_orp_time",
device_class=SensorDeviceClass.DURATION,
@@ -121,7 +79,7 @@ SENSOR_DESCRIPTIONS: tuple[PooldoseSensorEntityDescription, ...] = (
entity_registry_enabled_default=False,
native_unit_of_measurement=UnitOfTime.MINUTES,
),
PooldoseSensorEntityDescription(
SensorEntityDescription(
key="ph_calibration_type",
translation_key="ph_calibration_type",
entity_category=EntityCategory.DIAGNOSTIC,
@@ -129,7 +87,7 @@ SENSOR_DESCRIPTIONS: tuple[PooldoseSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.ENUM,
options=["off", "reference", "1_point", "2_points"],
),
PooldoseSensorEntityDescription(
SensorEntityDescription(
key="ph_calibration_offset",
translation_key="ph_calibration_offset",
entity_category=EntityCategory.DIAGNOSTIC,
@@ -138,7 +96,7 @@ SENSOR_DESCRIPTIONS: tuple[PooldoseSensorEntityDescription, ...] = (
entity_registry_enabled_default=False,
native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT,
),
PooldoseSensorEntityDescription(
SensorEntityDescription(
key="ph_calibration_slope",
translation_key="ph_calibration_slope",
entity_category=EntityCategory.DIAGNOSTIC,
@@ -147,7 +105,7 @@ SENSOR_DESCRIPTIONS: tuple[PooldoseSensorEntityDescription, ...] = (
entity_registry_enabled_default=False,
native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT,
),
PooldoseSensorEntityDescription(
SensorEntityDescription(
key="orp_calibration_type",
translation_key="orp_calibration_type",
entity_category=EntityCategory.DIAGNOSTIC,
@@ -155,7 +113,7 @@ SENSOR_DESCRIPTIONS: tuple[PooldoseSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.ENUM,
options=["off", "reference", "1_point"],
),
PooldoseSensorEntityDescription(
SensorEntityDescription(
key="orp_calibration_offset",
translation_key="orp_calibration_offset",
entity_category=EntityCategory.DIAGNOSTIC,
@@ -164,7 +122,7 @@ SENSOR_DESCRIPTIONS: tuple[PooldoseSensorEntityDescription, ...] = (
entity_registry_enabled_default=False,
native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT,
),
PooldoseSensorEntityDescription(
SensorEntityDescription(
key="orp_calibration_slope",
translation_key="orp_calibration_slope",
entity_category=EntityCategory.DIAGNOSTIC,
@@ -205,8 +163,6 @@ async def async_setup_entry(
class PooldoseSensor(PooldoseEntity, SensorEntity):
"""Sensor entity for the Seko PoolDose Python API."""
entity_description: PooldoseSensorEntityDescription
@property
def native_value(self) -> float | int | str | None:
"""Return the current value of the sensor."""
@@ -219,12 +175,9 @@ class PooldoseSensor(PooldoseEntity, SensorEntity):
def native_unit_of_measurement(self) -> str | None:
"""Return the unit of measurement."""
if (
self.entity_description.use_dynamic_unit
self.entity_description.key == "temperature"
and (data := self.get_data()) is not None
and (device_unit := data.get("unit"))
):
# Map device unit to Home Assistant unit, return None if unknown
return UNIT_MAPPING.get(device_unit)
return data["unit"] # °C or °F
# Fall back to static unit from entity description
return super().native_unit_of_measurement

View File

@@ -34,19 +34,6 @@
},
"entity": {
"sensor": {
"cl": {
"name": "Chlorine"
},
"cl_type_dosing": {
"name": "Chlorine dosing type",
"state": {
"high": "[%key:common::state::high%]",
"low": "[%key:common::state::low%]"
}
},
"flow_rate": {
"name": "Flow rate"
},
"ofa_orp_time": {
"name": "ORP overfeed alert time"
},
@@ -77,15 +64,6 @@
"low": "[%key:common::state::low%]"
}
},
"peristaltic_cl_dosing": {
"name": "Chlorine peristaltic dosing",
"state": {
"off": "[%key:common::state::off%]",
"on_off": "[%key:component::pooldose::entity::sensor::peristaltic_ph_dosing::state::on_off%]",
"proportional": "[%key:component::pooldose::entity::sensor::peristaltic_ph_dosing::state::proportional%]",
"timed": "[%key:component::pooldose::entity::sensor::peristaltic_ph_dosing::state::timed%]"
}
},
"peristaltic_orp_dosing": {
"name": "ORP peristaltic dosing",
"state": {

View File

@@ -128,7 +128,6 @@
"speed": "[%key:component::sensor::entity_component::speed::name%]",
"sulphur_dioxide": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]",
"temperature": "[%key:component::sensor::entity_component::temperature::name%]",
"temperature_delta": "[%key:component::sensor::entity_component::temperature_delta::name%]",
"timestamp": "[%key:component::sensor::entity_component::timestamp::name%]",
"volatile_organic_compounds": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]",
"volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds_parts::name%]",

View File

@@ -222,13 +222,17 @@ class ReolinkHost:
enable_onvif = None
enable_rtmp = None
if not self._api.rtsp_enabled and self._api.supported(None, "RTSP"):
if not self._api.rtsp_enabled and not self._api.baichuan_only:
_LOGGER.debug(
"RTSP is disabled on %s, trying to enable it", self._api.nvr_name
)
enable_rtsp = True
if not self._api.onvif_enabled and onvif_supported:
if (
not self._api.onvif_enabled
and onvif_supported
and not self._api.baichuan_only
):
_LOGGER.debug(
"ONVIF is disabled on %s, trying to enable it", self._api.nvr_name
)

View File

@@ -10,7 +10,6 @@ from typing import Any
import aiohttp
from aiohttp import hdrs
import voluptuous as vol
from yarl import URL
from homeassistant.const import (
CONF_AUTHENTICATION,
@@ -52,7 +51,6 @@ SUPPORT_REST_METHODS = ["get", "patch", "post", "put", "delete"]
CONF_CONTENT_TYPE = "content_type"
CONF_INSECURE_CIPHER = "insecure_cipher"
CONF_SKIP_URL_ENCODING = "skip_url_encoding"
COMMAND_SCHEMA = vol.Schema(
{
@@ -71,7 +69,6 @@ COMMAND_SCHEMA = vol.Schema(
vol.Optional(CONF_CONTENT_TYPE): cv.string,
vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean,
vol.Optional(CONF_INSECURE_CIPHER, default=False): cv.boolean,
vol.Optional(CONF_SKIP_URL_ENCODING, default=False): cv.boolean,
}
)
@@ -116,7 +113,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
method = command_config[CONF_METHOD]
template_url = command_config[CONF_URL]
skip_url_encoding = command_config[CONF_SKIP_URL_ENCODING]
auth = None
digest_middleware = None
@@ -183,7 +179,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
request_kwargs["middlewares"] = (digest_middleware,)
async with getattr(websession, method)(
URL(request_url, encoded=skip_url_encoding),
request_url,
**request_kwargs,
) as response:
if response.status < HTTPStatus.BAD_REQUEST:

View File

@@ -7,23 +7,23 @@ rules:
status: exempt
comment: This integration does not poll.
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
common-modules: todo
config-flow-test-coverage: todo
config-flow: todo
dependency-transparency: todo
docs-actions:
status: exempt
comment: This integration does not provide any service actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup: done
entity-unique-id: done
has-entity-name: done
docs-high-level-description: todo
docs-installation-instructions: todo
docs-removal-instructions: todo
entity-event-setup: todo
entity-unique-id: todo
has-entity-name: todo
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
unique-config-entry: todo
# Silver
action-exceptions: todo

View File

@@ -1,49 +0,0 @@
"""Diagnostics support for Saunum Leil Sauna Control Unit integration."""
from __future__ import annotations
from dataclasses import asdict
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from . import LeilSaunaConfigEntry
REDACT_CONFIG = {CONF_HOST}
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: LeilSaunaConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
coordinator = entry.runtime_data
# Build diagnostics data
diagnostics_data: dict[str, Any] = {
"config": async_redact_data(entry.data, REDACT_CONFIG),
"client_info": {"connected": coordinator.client.is_connected},
"coordinator_info": {
"last_update_success": coordinator.last_update_success,
"update_interval": str(coordinator.update_interval),
"last_exception": str(coordinator.last_exception)
if coordinator.last_exception
else None,
},
}
# Add coordinator data if available
if coordinator.data:
data_dict = asdict(coordinator.data)
diagnostics_data["coordinator_data"] = data_dict
# Add alarm summary
alarm_fields = [
key
for key, value in data_dict.items()
if key.startswith("alarm_") and value is True
]
diagnostics_data["active_alarms"] = alarm_fields
return diagnostics_data

View File

@@ -41,7 +41,7 @@ rules:
# Gold tier
devices: done
diagnostics: done
diagnostics: todo
discovery:
status: exempt
comment: Device uses generic Espressif hardware with no unique identifying information (MAC OUI or hostname) that would distinguish it from other Espressif-based devices on the network.
@@ -49,7 +49,7 @@ rules:
docs-data-update: done
docs-examples: todo
docs-known-limitations: done
docs-supported-devices: done
docs-supported-devices: todo
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: done

View File

@@ -186,7 +186,6 @@
"speed": "[%key:component::sensor::entity_component::speed::name%]",
"sulphur_dioxide": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]",
"temperature": "[%key:component::sensor::entity_component::temperature::name%]",
"temperature_delta": "[%key:component::sensor::entity_component::temperature_delta::name%]",
"timestamp": "[%key:component::sensor::entity_component::timestamp::name%]",
"volatile_organic_compounds": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]",
"volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds_parts::name%]",

View File

@@ -5,7 +5,6 @@ from __future__ import annotations
from dataclasses import dataclass
from typing import Final, cast
from aioshelly.block_device import Block
from aioshelly.const import RPC_GENERATIONS
from homeassistant.components.binary_sensor import (
@@ -17,11 +16,10 @@ from homeassistant.components.binary_sensor import (
from homeassistant.const import STATE_ON, EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.entity_registry import RegistryEntry
from homeassistant.helpers.restore_state import RestoreEntity
from .const import CONF_SLEEP_PERIOD, MODEL_FRANKEVER_WATER_VALVE, ROLE_GENERIC
from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator
from .coordinator import ShellyConfigEntry, ShellyRpcCoordinator
from .entity import (
BlockEntityDescription,
RestEntityDescription,
@@ -39,10 +37,6 @@ from .utils import (
async_remove_orphaned_entities,
get_blu_trv_device_info,
get_device_entry_gen,
get_entity_translation_attributes,
get_rpc_channel_name,
get_rpc_custom_name,
get_rpc_key,
is_block_momentary_input,
is_rpc_momentary_input,
is_view_for_platform,
@@ -73,44 +67,6 @@ class RpcBinarySensor(ShellyRpcAttributeEntity, BinarySensorEntity):
entity_description: RpcBinarySensorDescription
def __init__(
self,
coordinator: ShellyRpcCoordinator,
key: str,
attribute: str,
description: RpcBinarySensorDescription,
) -> None:
"""Initialize sensor."""
super().__init__(coordinator, key, attribute, description)
if hasattr(self, "_attr_name") and description.role != ROLE_GENERIC:
if not description.role and description.key == "input":
_, component, component_id = get_rpc_key(key)
if not get_rpc_custom_name(coordinator.device, key) and (
component.lower() == "input" and component_id.isnumeric()
):
self._attr_translation_placeholders = {"input_number": component_id}
self._attr_translation_key = "input_with_number"
else:
return
delattr(self, "_attr_name")
if not description.role and description.key != "input":
translation_placeholders, translation_key = (
get_entity_translation_attributes(
get_rpc_channel_name(coordinator.device, key),
description.translation_key,
description.device_class,
self._default_to_device_class_name(),
)
)
if translation_placeholders:
self._attr_translation_placeholders = translation_placeholders
if translation_key:
self._attr_translation_key = translation_key
@property
def is_on(self) -> bool:
"""Return true if RPC sensor state is on."""
@@ -151,84 +107,85 @@ class RpcBluTrvBinarySensor(RpcBinarySensor):
SENSORS: dict[tuple[str, str], BlockBinarySensorDescription] = {
("device", "overtemp"): BlockBinarySensorDescription(
key="device|overtemp",
translation_key="overheating",
name="Overheating",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
),
("device", "overpower"): BlockBinarySensorDescription(
key="device|overpower",
translation_key="overpowering",
name="Overpowering",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
),
("light", "overpower"): BlockBinarySensorDescription(
key="light|overpower",
translation_key="overpowering",
name="Overpowering",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
),
("relay", "overpower"): BlockBinarySensorDescription(
key="relay|overpower",
translation_key="overpowering",
name="Overpowering",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
),
("sensor", "dwIsOpened"): BlockBinarySensorDescription(
key="sensor|dwIsOpened",
translation_key="door",
name="Door",
device_class=BinarySensorDeviceClass.OPENING,
available=lambda block: cast(int, block.dwIsOpened) != -1,
),
("sensor", "flood"): BlockBinarySensorDescription(
key="sensor|flood",
translation_key="flood",
device_class=BinarySensorDeviceClass.MOISTURE,
key="sensor|flood", name="Flood", device_class=BinarySensorDeviceClass.MOISTURE
),
("sensor", "gas"): BlockBinarySensorDescription(
key="sensor|gas",
name="Gas",
device_class=BinarySensorDeviceClass.GAS,
translation_key="gas",
value=lambda value: value in ["mild", "heavy"],
),
("sensor", "smoke"): BlockBinarySensorDescription(
key="sensor|smoke", device_class=BinarySensorDeviceClass.SMOKE
key="sensor|smoke", name="Smoke", device_class=BinarySensorDeviceClass.SMOKE
),
("sensor", "vibration"): BlockBinarySensorDescription(
key="sensor|vibration",
name="Vibration",
device_class=BinarySensorDeviceClass.VIBRATION,
),
("input", "input"): BlockBinarySensorDescription(
key="input|input",
translation_key="input",
name="Input",
device_class=BinarySensorDeviceClass.POWER,
removal_condition=is_block_momentary_input,
),
("relay", "input"): BlockBinarySensorDescription(
key="relay|input",
translation_key="input",
name="Input",
device_class=BinarySensorDeviceClass.POWER,
removal_condition=is_block_momentary_input,
),
("device", "input"): BlockBinarySensorDescription(
key="device|input",
translation_key="input",
name="Input",
device_class=BinarySensorDeviceClass.POWER,
removal_condition=is_block_momentary_input,
),
("sensor", "extInput"): BlockBinarySensorDescription(
key="sensor|extInput",
translation_key="external_input",
name="External input",
device_class=BinarySensorDeviceClass.POWER,
entity_registry_enabled_default=False,
),
("sensor", "motion"): BlockBinarySensorDescription(
key="sensor|motion", device_class=BinarySensorDeviceClass.MOTION
key="sensor|motion", name="Motion", device_class=BinarySensorDeviceClass.MOTION
),
}
REST_SENSORS: Final = {
"cloud": RestBinarySensorDescription(
key="cloud",
translation_key="cloud",
name="Cloud",
value=lambda status, _: status["cloud"]["connected"],
device_class=BinarySensorDeviceClass.CONNECTIVITY,
entity_registry_enabled_default=False,
@@ -240,14 +197,13 @@ RPC_SENSORS: Final = {
"input": RpcBinarySensorDescription(
key="input",
sub_key="state",
translation_key="input",
device_class=BinarySensorDeviceClass.POWER,
removal_condition=is_rpc_momentary_input,
),
"cloud": RpcBinarySensorDescription(
key="cloud",
sub_key="connected",
translation_key="cloud",
name="Cloud",
device_class=BinarySensorDeviceClass.CONNECTIVITY,
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
@@ -255,7 +211,7 @@ RPC_SENSORS: Final = {
"external_power": RpcBinarySensorDescription(
key="devicepower",
sub_key="external",
translation_key="external_power",
name="External power",
value=lambda status, _: status["present"],
device_class=BinarySensorDeviceClass.POWER,
entity_category=EntityCategory.DIAGNOSTIC,
@@ -263,7 +219,7 @@ RPC_SENSORS: Final = {
"overtemp": RpcBinarySensorDescription(
key="switch",
sub_key="errors",
translation_key="overheating",
name="Overheating",
device_class=BinarySensorDeviceClass.PROBLEM,
value=lambda status, _: False if status is None else "overtemp" in status,
entity_category=EntityCategory.DIAGNOSTIC,
@@ -272,7 +228,7 @@ RPC_SENSORS: Final = {
"overpower": RpcBinarySensorDescription(
key="switch",
sub_key="errors",
translation_key="overpowering",
name="Overpowering",
device_class=BinarySensorDeviceClass.PROBLEM,
value=lambda status, _: False if status is None else "overpower" in status,
entity_category=EntityCategory.DIAGNOSTIC,
@@ -281,7 +237,7 @@ RPC_SENSORS: Final = {
"overvoltage": RpcBinarySensorDescription(
key="switch",
sub_key="errors",
translation_key="overvoltage",
name="Overvoltage",
device_class=BinarySensorDeviceClass.PROBLEM,
value=lambda status, _: False if status is None else "overvoltage" in status,
entity_category=EntityCategory.DIAGNOSTIC,
@@ -290,7 +246,7 @@ RPC_SENSORS: Final = {
"overcurrent": RpcBinarySensorDescription(
key="switch",
sub_key="errors",
translation_key="overcurrent",
name="Overcurrent",
device_class=BinarySensorDeviceClass.PROBLEM,
value=lambda status, _: False if status is None else "overcurrent" in status,
entity_category=EntityCategory.DIAGNOSTIC,
@@ -299,12 +255,13 @@ RPC_SENSORS: Final = {
"smoke": RpcBinarySensorDescription(
key="smoke",
sub_key="alarm",
name="Smoke",
device_class=BinarySensorDeviceClass.SMOKE,
),
"restart": RpcBinarySensorDescription(
key="sys",
sub_key="restart_required",
translation_key="restart_required",
name="Restart required",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
@@ -312,7 +269,7 @@ RPC_SENSORS: Final = {
"boolean_generic": RpcBinarySensorDescription(
key="boolean",
sub_key="value",
removal_condition=lambda config, _, key: not is_view_for_platform(
removal_condition=lambda config, _status, key: not is_view_for_platform(
config, key, BINARY_SENSOR_PLATFORM
),
role=ROLE_GENERIC,
@@ -328,7 +285,7 @@ RPC_SENSORS: Final = {
"calibration": RpcBinarySensorDescription(
key="blutrv",
sub_key="errors",
translation_key="calibration",
name="Calibration",
device_class=BinarySensorDeviceClass.PROBLEM,
value=lambda status, _: False if status is None else "not_calibrated" in status,
entity_category=EntityCategory.DIAGNOSTIC,
@@ -337,13 +294,13 @@ RPC_SENSORS: Final = {
"flood": RpcBinarySensorDescription(
key="flood",
sub_key="alarm",
translation_key="flood",
name="Flood",
device_class=BinarySensorDeviceClass.MOISTURE,
),
"mute": RpcBinarySensorDescription(
key="flood",
sub_key="mute",
translation_key="mute",
name="Mute",
entity_category=EntityCategory.DIAGNOSTIC,
),
"flood_cable_unplugged": RpcBinarySensorDescription(
@@ -352,7 +309,7 @@ RPC_SENSORS: Final = {
value=lambda status, _: False
if status is None
else "cable_unplugged" in status,
translation_key="cable_unplugged",
name="Cable unplugged",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
supported=lambda status: status.get("alarm") is not None,
@@ -361,12 +318,14 @@ RPC_SENSORS: Final = {
key="presence",
sub_key="num_objects",
value=lambda status, _: bool(status),
name="Occupancy",
device_class=BinarySensorDeviceClass.OCCUPANCY,
entity_class=RpcPresenceBinarySensor,
),
"presencezone_state": RpcBinarySensorDescription(
key="presencezone",
sub_key="value",
name="Occupancy",
device_class=BinarySensorDeviceClass.OCCUPANCY,
entity_class=RpcPresenceBinarySensor,
),
@@ -454,19 +413,6 @@ class BlockBinarySensor(ShellyBlockAttributeEntity, BinarySensorEntity):
entity_description: BlockBinarySensorDescription
def __init__(
self,
coordinator: ShellyBlockCoordinator,
block: Block,
attribute: str,
description: BlockBinarySensorDescription,
) -> None:
"""Initialize sensor."""
super().__init__(coordinator, block, attribute, description)
if hasattr(self, "_attr_name"):
delattr(self, "_attr_name")
@property
def is_on(self) -> bool:
"""Return true if sensor state is on."""
@@ -478,18 +424,6 @@ class RestBinarySensor(ShellyRestAttributeEntity, BinarySensorEntity):
entity_description: RestBinarySensorDescription
def __init__(
self,
coordinator: ShellyBlockCoordinator,
attribute: str,
description: RestBinarySensorDescription,
) -> None:
"""Initialize sensor."""
super().__init__(coordinator, attribute, description)
if hasattr(self, "_attr_name"):
delattr(self, "_attr_name")
@property
def is_on(self) -> bool:
"""Return true if REST sensor state is on."""
@@ -503,20 +437,6 @@ class BlockSleepingBinarySensor(
entity_description: BlockBinarySensorDescription
def __init__(
self,
coordinator: ShellyBlockCoordinator,
block: Block | None,
attribute: str,
description: BlockBinarySensorDescription,
entry: RegistryEntry | None = None,
) -> None:
"""Initialize the sleeping sensor."""
super().__init__(coordinator, block, attribute, description, entry)
if hasattr(self, "_attr_name"):
delattr(self, "_attr_name")
async def async_added_to_hass(self) -> None:
"""Handle entity which will be added."""
await super().async_added_to_hass()
@@ -541,35 +461,6 @@ class RpcSleepingBinarySensor(
entity_description: RpcBinarySensorDescription
def __init__(
self,
coordinator: ShellyRpcCoordinator,
key: str,
attribute: str,
description: RpcBinarySensorDescription,
entry: RegistryEntry | None = None,
) -> None:
"""Initialize the sleeping sensor."""
super().__init__(coordinator, key, attribute, description, entry)
if coordinator.device.initialized:
if hasattr(self, "_attr_name"):
delattr(self, "_attr_name")
translation_placeholders, translation_key = (
get_entity_translation_attributes(
get_rpc_channel_name(coordinator.device, key),
description.translation_key,
description.device_class,
self._default_to_device_class_name(),
)
)
if translation_placeholders:
self._attr_translation_placeholders = translation_placeholders
if translation_key:
self._attr_translation_key = translation_key
async def async_added_to_hass(self) -> None:
"""Handle entity which will be added."""
await super().async_added_to_hass()

View File

@@ -129,80 +129,6 @@
}
},
"entity": {
"binary_sensor": {
"cable_unplugged": {
"name": "Cable unplugged"
},
"cable_unplugged_with_channel_name": {
"name": "{channel_name} cable unplugged"
},
"calibration": {
"name": "Calibration"
},
"cloud": {
"name": "Cloud"
},
"door": {
"name": "Door"
},
"external_input": {
"name": "External input"
},
"external_power": {
"name": "External power"
},
"flood": {
"name": "Flood"
},
"flood_with_channel_name": {
"name": "{channel_name} flood"
},
"input": {
"name": "Input"
},
"input_with_number": {
"name": "Input {input_number}"
},
"mute": {
"name": "Mute"
},
"mute_with_channel_name": {
"name": "{channel_name} mute"
},
"occupancy_with_channel_name": {
"name": "{channel_name} occupancy"
},
"overcurrent": {
"name": "Overcurrent"
},
"overcurrent_with_channel_name": {
"name": "{channel_name} overcurrent"
},
"overheating": {
"name": "Overheating"
},
"overheating_with_channel_name": {
"name": "{channel_name} overheating"
},
"overpowering": {
"name": "Overpowering"
},
"overpowering_with_channel_name": {
"name": "{channel_name} overpowering"
},
"overvoltage": {
"name": "Overvoltage"
},
"overvoltage_with_channel_name": {
"name": "{channel_name} overvoltage"
},
"restart_required": {
"name": "Restart required"
},
"smoke_with_channel_name": {
"name": "{channel_name} smoke"
}
},
"button": {
"calibrate": {
"name": "Calibrate"

View File

@@ -391,13 +391,7 @@ def get_shelly_model_name(
return cast(str, MODEL_NAMES.get(model))
def get_rpc_key(value: str) -> tuple[bool, str, str]:
"""Get split device key."""
parts = value.split(":")
return len(parts) > 1, parts[0], parts[-1]
def get_rpc_custom_name(device: RpcDevice, key: str) -> str | None:
def get_rpc_component_name(device: RpcDevice, key: str) -> str | None:
"""Get component name from device config."""
if (
key in device.config
@@ -409,11 +403,6 @@ def get_rpc_custom_name(device: RpcDevice, key: str) -> str | None:
return None
def get_rpc_component_name(device: RpcDevice, key: str) -> str | None:
"""Get component name from device config."""
return get_rpc_custom_name(device, key)
def get_rpc_channel_name(device: RpcDevice, key: str) -> str | None:
"""Get name based on device and channel name."""
if BLU_TRV_IDENTIFIER in key:
@@ -425,11 +414,11 @@ def get_rpc_channel_name(device: RpcDevice, key: str) -> str | None:
component = key.split(":")[0]
component_id = key.split(":")[-1]
if custom_name := get_rpc_custom_name(device, key):
if component_name := get_rpc_component_name(device, key):
if component in (*VIRTUAL_COMPONENTS, "input", "presencezone", "script"):
return custom_name
return component_name
return custom_name if instances == 1 else None
return component_name if instances == 1 else None
if component in (*VIRTUAL_COMPONENTS, "input"):
return f"{component.title()} {component_id}"

View File

@@ -9,11 +9,7 @@
"iot_class": "local_push",
"loggers": ["soco", "sonos_websocket"],
"quality_scale": "bronze",
"requirements": [
"defusedxml==0.7.1",
"soco==0.30.12",
"sonos-websocket==0.1.3"
],
"requirements": ["soco==0.30.12", "sonos-websocket==0.1.3"],
"ssdp": [
{
"st": "urn:schemas-upnp-org:device:ZonePlayer:1"

View File

@@ -161,7 +161,6 @@
"speed": "[%key:component::sensor::entity_component::speed::name%]",
"sulphur_dioxide": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]",
"temperature": "[%key:component::sensor::entity_component::temperature::name%]",
"temperature_delta": "[%key:component::sensor::entity_component::temperature_delta::name%]",
"timestamp": "[%key:component::sensor::entity_component::timestamp::name%]",
"volatile_organic_compounds": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]",
"volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds_parts::name%]",

View File

@@ -1111,7 +1111,6 @@
"speed": "[%key:component::sensor::entity_component::speed::name%]",
"sulphur_dioxide": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]",
"temperature": "[%key:component::sensor::entity_component::temperature::name%]",
"temperature_delta": "[%key:component::sensor::entity_component::temperature_delta::name%]",
"timestamp": "[%key:component::sensor::entity_component::timestamp::name%]",
"volatile_organic_compounds": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]",
"volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds_parts::name%]",

View File

@@ -24,5 +24,5 @@
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/thermopro",
"iot_class": "local_push",
"requirements": ["thermopro-ble==1.1.2"]
"requirements": ["thermopro-ble==0.13.1"]
}

View File

@@ -1,83 +1,33 @@
"""Support for Tibber."""
from __future__ import annotations
from dataclasses import dataclass
import logging
import aiohttp
from aiohttp.client_exceptions import ClientError, ClientResponseError
import tibber
from tibber import data_api as tibber_data_api
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ACCESS_TOKEN, EVENT_HOMEASSISTANT_STOP, Platform
from homeassistant.core import Event, HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.config_entry_oauth2_flow import (
ImplementationUnavailableError,
OAuth2Session,
async_get_config_entry_implementation,
)
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import dt as dt_util, ssl as ssl_util
from .const import (
API_TYPE_DATA_API,
API_TYPE_GRAPHQL,
CONF_API_TYPE,
DATA_HASS_CONFIG,
DOMAIN,
)
from .const import DATA_HASS_CONFIG, DOMAIN
from .services import async_setup_services
GRAPHQL_PLATFORMS = [Platform.NOTIFY, Platform.SENSOR]
DATA_API_PLATFORMS = [Platform.SENSOR]
PLATFORMS = [Platform.NOTIFY, Platform.SENSOR]
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
_LOGGER = logging.getLogger(__name__)
@dataclass(slots=True)
class TibberGraphQLRuntimeData:
"""Runtime data for GraphQL-based Tibber entries."""
tibber: tibber.Tibber
@dataclass(slots=True)
class TibberDataAPIRuntimeData:
"""Runtime data for Tibber Data API entries."""
session: OAuth2Session
_client: tibber_data_api.TibberDataAPI | None = None
async def async_get_client(
self, hass: HomeAssistant
) -> tibber_data_api.TibberDataAPI:
"""Return an authenticated Tibber Data API client."""
await self.session.async_ensure_token_valid()
token = self.session.token
access_token = token.get(CONF_ACCESS_TOKEN)
if not access_token:
raise ConfigEntryAuthFailed("Access token missing from OAuth session")
if self._client is None:
self._client = tibber_data_api.TibberDataAPI(
access_token,
websession=async_get_clientsession(hass),
)
self._client.set_access_token(access_token)
return self._client
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Tibber component."""
hass.data[DATA_HASS_CONFIG] = config
hass.data.setdefault(DOMAIN, {})
async_setup_services(hass)
@@ -87,100 +37,45 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up a config entry."""
hass.data.setdefault(DOMAIN, {})
api_type = entry.data.get(CONF_API_TYPE, API_TYPE_GRAPHQL)
if api_type == API_TYPE_DATA_API:
return await _async_setup_data_api_entry(hass, entry)
return await _async_setup_graphql_entry(hass, entry)
async def _async_setup_graphql_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up the legacy GraphQL Tibber entry."""
tibber_connection = tibber.Tibber(
access_token=entry.data[CONF_ACCESS_TOKEN],
websession=async_get_clientsession(hass),
time_zone=dt_util.get_default_time_zone(),
ssl=ssl_util.get_default_context(),
)
hass.data[DOMAIN] = tibber_connection
runtime = TibberGraphQLRuntimeData(tibber_connection)
entry.runtime_data = runtime
hass.data[DOMAIN][API_TYPE_GRAPHQL] = runtime
async def _close(_event: Event) -> None:
async def _close(event: Event) -> None:
await tibber_connection.rt_disconnect()
entry.async_on_unload(hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _close))
try:
await tibber_connection.update_info()
except (
TimeoutError,
aiohttp.ClientError,
tibber.RetryableHttpExceptionError,
) as err:
raise ConfigEntryNotReady("Unable to connect") from err
except tibber.InvalidLoginError as err:
_LOGGER.error("Failed to login to Tibber GraphQL API: %s", err)
except tibber.InvalidLoginError as exp:
_LOGGER.error("Failed to login. %s", exp)
return False
except tibber.FatalHttpExceptionError as err:
_LOGGER.error("Fatal error communicating with Tibber GraphQL API: %s", err)
except tibber.FatalHttpExceptionError:
return False
await hass.config_entries.async_forward_entry_setups(entry, GRAPHQL_PLATFORMS)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def _async_setup_data_api_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up a Tibber Data API entry."""
try:
implementation = await async_get_config_entry_implementation(hass, entry)
except ImplementationUnavailableError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="oauth2_implementation_unavailable",
) from err
session = OAuth2Session(hass, entry, implementation)
try:
await session.async_ensure_token_valid()
except ClientResponseError as err:
if 400 <= err.status < 500:
raise ConfigEntryAuthFailed(
"OAuth session is not valid, reauthentication required"
) from err
raise ConfigEntryNotReady from err
except ClientError as err:
raise ConfigEntryNotReady from err
runtime = TibberDataAPIRuntimeData(session=session)
entry.runtime_data = runtime
hass.data[DOMAIN][API_TYPE_DATA_API] = runtime
await hass.config_entries.async_forward_entry_setups(entry, DATA_API_PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Unload a config entry."""
api_type = config_entry.data.get(CONF_API_TYPE, API_TYPE_GRAPHQL)
unload_ok = await hass.config_entries.async_unload_platforms(
config_entry,
GRAPHQL_PLATFORMS if api_type == API_TYPE_GRAPHQL else DATA_API_PLATFORMS,
config_entry, PLATFORMS
)
if unload_ok:
if api_type == API_TYPE_GRAPHQL:
runtime = hass.data[DOMAIN].get(api_type)
if runtime:
tibber_connection = runtime.tibber
await tibber_connection.rt_disconnect()
hass.data[DOMAIN].pop(api_type, None)
tibber_connection = hass.data[DOMAIN]
await tibber_connection.rt_disconnect()
return unload_ok

View File

@@ -1,15 +0,0 @@
"""Application credentials platform for Tibber."""
from homeassistant.components.application_credentials import AuthorizationServer
from homeassistant.core import HomeAssistant
AUTHORIZE_URL = "https://thewall.tibber.com/connect/authorize"
TOKEN_URL = "https://thewall.tibber.com/connect/token"
async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer:
"""Return authorization server for Tibber Data API."""
return AuthorizationServer(
authorize_url=AUTHORIZE_URL,
token_url=TOKEN_URL,
)

View File

@@ -2,118 +2,36 @@
from __future__ import annotations
from collections.abc import Mapping
import logging
from typing import Any
import aiohttp
import tibber
from tibber.data_api import TibberDataAPI
import voluptuous as vol
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.config_entry_oauth2_flow import (
AbstractOAuth2FlowHandler,
async_get_config_entry_implementation,
async_get_implementations,
)
from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig
from .const import (
API_TYPE_DATA_API,
API_TYPE_GRAPHQL,
CONF_API_TYPE,
DATA_API_DEFAULT_SCOPES,
DOMAIN,
)
TYPE_SELECTOR = vol.Schema(
{
vol.Required(CONF_API_TYPE, default=API_TYPE_GRAPHQL): SelectSelector(
SelectSelectorConfig(
options=[API_TYPE_GRAPHQL, API_TYPE_DATA_API],
translation_key="api_type",
)
)
}
)
GRAPHQL_SCHEMA = vol.Schema({vol.Required(CONF_ACCESS_TOKEN): str})
from .const import DOMAIN
DATA_SCHEMA = vol.Schema({vol.Required(CONF_ACCESS_TOKEN): str})
ERR_TIMEOUT = "timeout"
ERR_CLIENT = "cannot_connect"
ERR_TOKEN = "invalid_access_token"
TOKEN_URL = "https://developer.tibber.com/settings/access-token"
DATA_API_DOC_URL = "https://data-api.tibber.com/docs/auth/"
APPLICATION_CREDENTIALS_DOC_URL = (
"https://www.home-assistant.io/integrations/application_credentials/"
)
_LOGGER = logging.getLogger(__name__)
class TibberConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
class TibberConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Tibber integration."""
DOMAIN = DOMAIN
VERSION = 1
MINOR_VERSION = 1
def __init__(self) -> None:
"""Initialize the config flow."""
super().__init__()
self._api_type: str | None = None
self._data_api_home_ids: list[str] = []
self._data_api_user_sub: str | None = None
self._reauth_confirmed: bool = False
@property
def logger(self) -> logging.Logger:
"""Return the logger."""
return _LOGGER
@property
def extra_authorize_data(self) -> dict:
"""Extra data appended to the authorize URL."""
if self._api_type != API_TYPE_DATA_API:
return super().extra_authorize_data
return {
**super().extra_authorize_data,
"scope": " ".join(DATA_API_DEFAULT_SCOPES),
}
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
if user_input is None:
return self.async_show_form(
step_id="user",
data_schema=TYPE_SELECTOR,
description_placeholders={"url": DATA_API_DOC_URL},
)
self._api_type = user_input[CONF_API_TYPE]
if self._api_type == API_TYPE_GRAPHQL:
return await self.async_step_graphql()
return await self.async_step_data_api()
async def async_step_graphql(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle GraphQL token based configuration."""
if self.source != SOURCE_REAUTH:
for entry in self._async_current_entries(include_ignore=False):
if entry.entry_id == self.context.get("entry_id"):
continue
if entry.data.get(CONF_API_TYPE, API_TYPE_GRAPHQL) == API_TYPE_GRAPHQL:
return self.async_abort(reason="already_configured")
self._async_abort_entries_match()
if user_input is not None:
access_token = user_input[CONF_ACCESS_TOKEN].replace(" ", "")
@@ -140,146 +58,24 @@ class TibberConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
if errors:
return self.async_show_form(
step_id="graphql",
data_schema=GRAPHQL_SCHEMA,
step_id="user",
data_schema=DATA_SCHEMA,
description_placeholders={"url": TOKEN_URL},
errors=errors,
)
unique_id = tibber_connection.user_id
await self.async_set_unique_id(unique_id)
if self.source == SOURCE_REAUTH:
self._abort_if_unique_id_mismatch(reason="wrong_account")
return self.async_update_reload_and_abort(
self._get_reauth_entry(),
data_updates={
CONF_API_TYPE: API_TYPE_GRAPHQL,
CONF_ACCESS_TOKEN: access_token,
},
title=tibber_connection.name,
)
self._abort_if_unique_id_configured()
data = {
CONF_API_TYPE: API_TYPE_GRAPHQL,
CONF_ACCESS_TOKEN: access_token,
}
return self.async_create_entry(
title=tibber_connection.name,
data=data,
data={CONF_ACCESS_TOKEN: access_token},
)
return self.async_show_form(
step_id="graphql",
data_schema=GRAPHQL_SCHEMA,
step_id="user",
data_schema=DATA_SCHEMA,
description_placeholders={"url": TOKEN_URL},
errors={},
)
async def async_step_data_api(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the Data API OAuth configuration."""
implementations = await async_get_implementations(self.hass, self.DOMAIN)
if not implementations:
return self.async_abort(
reason="missing_credentials",
description_placeholders={
"application_credentials_url": APPLICATION_CREDENTIALS_DOC_URL,
"data_api_url": DATA_API_DOC_URL,
},
)
if self.source != SOURCE_REAUTH:
for entry in self._async_current_entries(include_ignore=False):
if entry.entry_id == self.context.get("entry_id"):
continue
if entry.data.get(CONF_API_TYPE, API_TYPE_GRAPHQL) == API_TYPE_DATA_API:
return self.async_abort(reason="already_configured")
return await self.async_step_pick_implementation(user_input)
async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult:
"""Finalize the OAuth flow and create the config entry."""
assert self._api_type == API_TYPE_DATA_API
token: dict[str, Any] = data["token"]
client = TibberDataAPI(
token[CONF_ACCESS_TOKEN],
websession=async_get_clientsession(self.hass),
)
try:
userinfo = await client.get_userinfo()
except (
tibber.InvalidLoginError,
tibber.FatalHttpExceptionError,
) as err:
self.logger.error("Authentication failed against Data API: %s", err)
return self.async_abort(reason="oauth_invalid_token")
except (aiohttp.ClientError, TimeoutError) as err:
self.logger.error("Error retrieving homes via Data API: %s", err)
return self.async_abort(reason="cannot_connect")
unique_id = userinfo["email"]
title = userinfo["email"]
await self.async_set_unique_id(unique_id)
if self.source == SOURCE_REAUTH:
reauth_entry = self._get_reauth_entry()
self._abort_if_unique_id_mismatch(
reason="wrong_account",
description_placeholders={"email": reauth_entry.unique_id or ""},
)
return self.async_update_reload_and_abort(
reauth_entry,
data_updates={
CONF_API_TYPE: API_TYPE_DATA_API,
"auth_implementation": data["auth_implementation"],
CONF_TOKEN: token,
},
title=title,
)
self._abort_if_unique_id_configured()
entry_data: dict[str, Any] = {
CONF_API_TYPE: API_TYPE_DATA_API,
"auth_implementation": data["auth_implementation"],
CONF_TOKEN: token,
}
return self.async_create_entry(
title=title,
data=entry_data,
)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle reauthentication."""
api_type = entry_data.get(CONF_API_TYPE, API_TYPE_GRAPHQL)
self._api_type = api_type
if api_type == API_TYPE_DATA_API:
self.flow_impl = await async_get_config_entry_implementation(
self.hass, self._get_reauth_entry()
)
return await self.async_step_auth()
self.context["title_placeholders"] = {"name": self._get_reauth_entry().title}
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm the reauth dialog for GraphQL entries."""
if user_input is None and not self._reauth_confirmed:
self._reauth_confirmed = True
return self.async_show_form(step_id="reauth_confirm")
return await self.async_step_graphql()

View File

@@ -3,19 +3,3 @@
DATA_HASS_CONFIG = "tibber_hass_config"
DOMAIN = "tibber"
MANUFACTURER = "Tibber"
CONF_API_TYPE = "api_type"
API_TYPE_GRAPHQL = "graphql"
API_TYPE_DATA_API = "data_api"
DATA_API_DEFAULT_SCOPES = [
"openid",
"profile",
"email",
"offline_access",
"data-api-user-read",
"data-api-chargers-read",
"data-api-energy-systems-read",
"data-api-homes-read",
"data-api-thermostats-read",
"data-api-vehicles-read",
"data-api-inverters-read",
]

View File

@@ -4,11 +4,9 @@ from __future__ import annotations
from datetime import timedelta
import logging
from typing import Any, cast
from typing import cast
from aiohttp.client_exceptions import ClientError
import tibber
from tibber.data_api import TibberDataAPI, TibberDevice
from homeassistant.components.recorder import get_instance
from homeassistant.components.recorder.models import (
@@ -24,7 +22,6 @@ from homeassistant.components.recorder.statistics import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfEnergy
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util
from homeassistant.util.unit_conversion import EnergyConverter
@@ -190,48 +187,3 @@ class TibberDataCoordinator(DataUpdateCoordinator[None]):
unit_of_measurement=unit,
)
async_add_external_statistics(self.hass, metadata, statistics)
class TibberDataAPICoordinator(DataUpdateCoordinator[dict[str, TibberDevice]]):
"""Fetch and cache Tibber Data API device capabilities."""
def __init__(
self,
hass: HomeAssistant,
entry: ConfigEntry,
runtime_data: Any,
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
name=f"{DOMAIN} Data API",
update_interval=timedelta(minutes=1),
config_entry=entry,
)
self._runtime_data = runtime_data
async def _async_get_client(self) -> TibberDataAPI:
"""Get the Tibber Data API client with error handling."""
try:
return cast(
TibberDataAPI,
await self._runtime_data.async_get_client(self.hass),
)
except ConfigEntryAuthFailed:
raise
except (ClientError, TimeoutError, tibber.UserAgentMissingError) as err:
raise UpdateFailed(
f"Unable to create Tibber Data API client: {err}"
) from err
async def _async_setup(self) -> None:
"""Initial load of Tibber Data API devices."""
client = await self._async_get_client()
self.data = await client.get_all_devices()
async def _async_update_data(self) -> dict[str, TibberDevice]:
"""Fetch the latest device capabilities from the Tibber Data API."""
client = await self._async_get_client()
devices: dict[str, TibberDevice] = await client.update_devices()
return devices

View File

@@ -4,80 +4,29 @@ from __future__ import annotations
from typing import Any
import aiohttp
import tibber
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from .const import API_TYPE_DATA_API, API_TYPE_GRAPHQL, CONF_API_TYPE, DOMAIN
from .const import DOMAIN
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, config_entry: ConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
api_type = config_entry.data.get(CONF_API_TYPE, API_TYPE_GRAPHQL)
domain_data = hass.data.get(DOMAIN, {})
if api_type == API_TYPE_GRAPHQL:
tibber_connection: tibber.Tibber = domain_data[API_TYPE_GRAPHQL].tibber
return {
"api_type": API_TYPE_GRAPHQL,
"homes": [
{
"last_data_timestamp": home.last_data_timestamp,
"has_active_subscription": home.has_active_subscription,
"has_real_time_consumption": home.has_real_time_consumption,
"last_cons_data_timestamp": home.last_cons_data_timestamp,
"country": home.country,
}
for home in tibber_connection.get_homes(only_active=False)
],
}
runtime = domain_data.get(API_TYPE_DATA_API)
if runtime is None:
return {
"api_type": API_TYPE_DATA_API,
"devices": [],
}
devices: dict[str, Any] = {}
error: str | None = None
try:
devices = await (await runtime.async_get_client(hass)).get_all_devices()
except ConfigEntryAuthFailed:
devices = {}
error = "Authentication failed"
except TimeoutError:
devices = {}
error = "Timeout error"
except aiohttp.ClientError:
devices = {}
error = "Client error"
except tibber.InvalidLoginError:
devices = {}
error = "Invalid login"
except tibber.RetryableHttpExceptionError as err:
devices = {}
error = f"Retryable HTTP error ({err.status})"
except tibber.FatalHttpExceptionError as err:
devices = {}
error = f"Fatal HTTP error ({err.status})"
tibber_connection: tibber.Tibber = hass.data[DOMAIN]
return {
"api_type": API_TYPE_DATA_API,
"error": error,
"devices": [
"homes": [
{
"id": device.id,
"name": device.name,
"brand": device.brand,
"model": device.model,
"last_data_timestamp": home.last_data_timestamp,
"has_active_subscription": home.has_active_subscription,
"has_real_time_consumption": home.has_real_time_consumption,
"last_cons_data_timestamp": home.last_cons_data_timestamp,
"country": home.country,
}
for device in devices.values()
],
for home in tibber_connection.get_homes(only_active=False)
]
}

View File

@@ -3,9 +3,9 @@
"name": "Tibber",
"codeowners": ["@danielhiversen"],
"config_flow": true,
"dependencies": ["application_credentials", "recorder"],
"dependencies": ["recorder"],
"documentation": "https://www.home-assistant.io/integrations/tibber",
"iot_class": "cloud_polling",
"loggers": ["tibber"],
"requirements": ["pyTibber==0.33.1"]
"requirements": ["pyTibber==0.32.2"]
}

View File

@@ -14,7 +14,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import API_TYPE_GRAPHQL, DOMAIN
from . import DOMAIN
async def async_setup_entry(
@@ -39,7 +39,7 @@ class TibberNotificationEntity(NotifyEntity):
async def async_send_message(self, message: str, title: str | None = None) -> None:
"""Send a message to Tibber devices."""
tibber_connection: Tibber = self.hass.data[DOMAIN][API_TYPE_GRAPHQL].tibber
tibber_connection: Tibber = self.hass.data[DOMAIN]
try:
await tibber_connection.send_notification(
title or ATTR_TITLE_DEFAULT, message

View File

@@ -10,8 +10,7 @@ from random import randrange
from typing import Any
import aiohttp
from tibber import FatalHttpExceptionError, RetryableHttpExceptionError, TibberHome
from tibber.data_api import TibberDevice
import tibber
from homeassistant.components.sensor import (
SensorDeviceClass,
@@ -28,7 +27,6 @@ from homeassistant.const import (
UnitOfElectricCurrent,
UnitOfElectricPotential,
UnitOfEnergy,
UnitOfLength,
UnitOfPower,
)
from homeassistant.core import Event, HomeAssistant, callback
@@ -43,14 +41,8 @@ from homeassistant.helpers.update_coordinator import (
)
from homeassistant.util import Throttle, dt as dt_util
from .const import (
API_TYPE_DATA_API,
API_TYPE_GRAPHQL,
CONF_API_TYPE,
DOMAIN,
MANUFACTURER,
)
from .coordinator import TibberDataAPICoordinator, TibberDataCoordinator
from .const import DOMAIN, MANUFACTURER
from .coordinator import TibberDataCoordinator
_LOGGER = logging.getLogger(__name__)
@@ -268,58 +260,6 @@ SENSORS: tuple[SensorEntityDescription, ...] = (
)
DATA_API_SENSORS: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(
key="storage.stateOfCharge",
translation_key="storage_state_of_charge",
device_class=SensorDeviceClass.BATTERY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="storage.targetStateOfCharge",
translation_key="storage_target_state_of_charge",
device_class=SensorDeviceClass.BATTERY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="connector.status",
translation_key="connector_status",
device_class=SensorDeviceClass.ENUM,
options=["connected", "disconnected", "unknown"],
),
SensorEntityDescription(
key="charging.status",
translation_key="charging_status",
device_class=SensorDeviceClass.ENUM,
options=["charging", "idle", "unknown"],
),
SensorEntityDescription(
key="range.remaining",
translation_key="range_remaining",
device_class=SensorDeviceClass.DISTANCE,
native_unit_of_measurement=UnitOfLength.KILOMETERS,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
),
SensorEntityDescription(
key="charging.current.max",
translation_key="charging_current_max",
device_class=SensorDeviceClass.CURRENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="charging.current.offlineFallback",
translation_key="charging_current_offline_fallback",
device_class=SensorDeviceClass.CURRENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
state_class=SensorStateClass.MEASUREMENT,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
@@ -327,11 +267,7 @@ async def async_setup_entry(
) -> None:
"""Set up the Tibber sensor."""
if entry.data.get(CONF_API_TYPE, API_TYPE_GRAPHQL) == API_TYPE_DATA_API:
await _async_setup_data_api_sensors(hass, entry, async_add_entities)
return
tibber_connection = hass.data[DOMAIN][API_TYPE_GRAPHQL].tibber
tibber_connection = hass.data[DOMAIN]
entity_registry = er.async_get(hass)
device_registry = dr.async_get(hass)
@@ -344,11 +280,7 @@ async def async_setup_entry(
except TimeoutError as err:
_LOGGER.error("Timeout connecting to Tibber home: %s ", err)
raise PlatformNotReady from err
except (
RetryableHttpExceptionError,
FatalHttpExceptionError,
aiohttp.ClientError,
) as err:
except (tibber.RetryableHttpExceptionError, aiohttp.ClientError) as err:
_LOGGER.error("Error connecting to Tibber home: %s ", err)
raise PlatformNotReady from err
@@ -396,94 +328,14 @@ async def async_setup_entry(
async_add_entities(entities, True)
async def _async_setup_data_api_sensors(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up sensors backed by the Tibber Data API."""
domain_data = hass.data.get(DOMAIN, {})
runtime = domain_data[API_TYPE_DATA_API]
coordinator = TibberDataAPICoordinator(hass, entry, runtime)
await coordinator.async_config_entry_first_refresh()
entities: list[TibberDataAPISensor] = []
api_sensors = {sensor.key: sensor for sensor in DATA_API_SENSORS}
for device in coordinator.data.values():
for sensor in device.sensors:
description: SensorEntityDescription | None = api_sensors.get(sensor.id)
if description is None:
_LOGGER.error("Sensor %s not found", sensor)
continue
entities.append(
TibberDataAPISensor(
coordinator, device, description, sensor.description
)
)
async_add_entities(entities)
class TibberDataAPISensor(CoordinatorEntity[TibberDataAPICoordinator], SensorEntity):
"""Representation of a Tibber Data API capability sensor."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: TibberDataAPICoordinator,
device: TibberDevice,
entity_description: SensorEntityDescription,
name: str,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self._device_id: str = device.id
self.entity_description = entity_description
self._attr_name = name
self._attr_unique_id = f"{device.external_id}_{self.entity_description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, device.external_id)},
name=device.name,
manufacturer=device.brand,
model=device.model,
)
@property
def native_value(
self,
) -> StateType:
"""Return the value reported by the device."""
device = self.coordinator.data.get(self._device_id)
if device is None:
return None
for sensor in device.sensors:
if sensor.id == self.entity_description.key:
return sensor.value
return None
@property
def available(self) -> bool:
"""Return whether the sensor is available."""
device = self.coordinator.data.get(self._device_id)
if device is None:
return False
return self.native_value is not None
class TibberSensor(SensorEntity):
"""Representation of a generic Tibber sensor."""
_attr_has_entity_name = True
def __init__(self, *args: Any, tibber_home: TibberHome, **kwargs: Any) -> None:
def __init__(
self, *args: Any, tibber_home: tibber.TibberHome, **kwargs: Any
) -> None:
"""Initialize the sensor."""
super().__init__(*args, **kwargs)
self._tibber_home = tibber_home
@@ -514,7 +366,7 @@ class TibberSensorElPrice(TibberSensor):
_attr_state_class = SensorStateClass.MEASUREMENT
_attr_translation_key = "electricity_price"
def __init__(self, tibber_home: TibberHome) -> None:
def __init__(self, tibber_home: tibber.TibberHome) -> None:
"""Initialize the sensor."""
super().__init__(tibber_home=tibber_home)
self._last_updated: datetime.datetime | None = None
@@ -591,7 +443,7 @@ class TibberDataSensor(TibberSensor, CoordinatorEntity[TibberDataCoordinator]):
def __init__(
self,
tibber_home: TibberHome,
tibber_home: tibber.TibberHome,
coordinator: TibberDataCoordinator,
entity_description: SensorEntityDescription,
) -> None:
@@ -618,7 +470,7 @@ class TibberSensorRT(TibberSensor, CoordinatorEntity["TibberRtDataCoordinator"])
def __init__(
self,
tibber_home: TibberHome,
tibber_home: tibber.TibberHome,
description: SensorEntityDescription,
initial_state: float,
coordinator: TibberRtDataCoordinator,
@@ -680,7 +532,7 @@ class TibberRtEntityCreator:
def __init__(
self,
async_add_entities: AddConfigEntryEntitiesCallback,
tibber_home: TibberHome,
tibber_home: tibber.TibberHome,
entity_registry: er.EntityRegistry,
) -> None:
"""Initialize the data handler."""
@@ -766,7 +618,7 @@ class TibberRtDataCoordinator(DataUpdateCoordinator): # pylint: disable=hass-en
hass: HomeAssistant,
config_entry: ConfigEntry,
add_sensor_callback: Callable[[TibberRtDataCoordinator, Any], None],
tibber_home: TibberHome,
tibber_home: tibber.TibberHome,
) -> None:
"""Initialize the data handler."""
self._add_sensor_callback = add_sensor_callback

View File

@@ -18,7 +18,7 @@ from homeassistant.core import (
from homeassistant.exceptions import ServiceValidationError
from homeassistant.util import dt as dt_util
from .const import API_TYPE_GRAPHQL, DOMAIN
from .const import DOMAIN
PRICE_SERVICE_NAME = "get_prices"
ATTR_START: Final = "start"
@@ -33,15 +33,7 @@ SERVICE_SCHEMA: Final = vol.Schema(
async def __get_prices(call: ServiceCall) -> ServiceResponse:
domain_data = call.hass.data.get(DOMAIN, {})
runtime = domain_data.get(API_TYPE_GRAPHQL)
if runtime is None:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="graphql_required",
)
tibber_connection = runtime.tibber
tibber_connection = call.hass.data[DOMAIN]
start = __get_date(call.data.get(ATTR_START), "start")
end = __get_date(call.data.get(ATTR_END), "end")

View File

@@ -1,13 +1,7 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
"missing_credentials": "Add Tibber Data API application credentials under application credentials before continuing. See {application_credentials_url} for guidance and {data_api_url} for API documentation.",
"oauth_invalid_token": "[%key:common::config_flow::abort::oauth2_error%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"wrong_account": "The connected account does not match {email}. Sign in with the same Tibber account and try again."
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
@@ -15,21 +9,11 @@
"timeout": "[%key:common::config_flow::error::timeout_connect%]"
},
"step": {
"graphql": {
"user": {
"data": {
"access_token": "[%key:common::config_flow::data::access_token%]"
},
"description": "Enter your access token from {url}"
},
"reauth_confirm": {
"description": "Reconnect your Tibber account to refresh access.",
"title": "[%key:common::config_flow::title::reauth%]"
},
"user": {
"data": {
"api_type": "API type"
},
"description": "Select which Tibber API you want to configure. See {url} for documentation."
}
}
},
@@ -56,37 +40,6 @@
"average_power": {
"name": "Average power"
},
"battery_battery_power": {
"name": "Battery power"
},
"battery_battery_state_of_charge": {
"name": "Battery state of charge"
},
"battery_stored_energy": {
"name": "Stored energy"
},
"charging_current_max": {
"name": "Maximum charging current"
},
"charging_current_offline_fallback": {
"name": "Offline fallback charging current"
},
"charging_status": {
"name": "Charging status",
"state": {
"charging": "Charging",
"idle": "Idle",
"unknown": "Unknown"
}
},
"connector_status": {
"name": "Connector status",
"state": {
"connected": "Connected",
"disconnected": "Disconnected",
"unknown": "Unknown"
}
},
"current_l1": {
"name": "Current L1"
},
@@ -102,30 +55,6 @@
"estimated_hour_consumption": {
"name": "Estimated consumption current hour"
},
"ev_charger_charge_current": {
"name": "Charge current"
},
"ev_charger_charging_state": {
"name": "Charging state"
},
"ev_charger_power": {
"name": "Charging power"
},
"ev_charger_session_energy": {
"name": "Session energy"
},
"ev_charger_total_energy": {
"name": "Total energy"
},
"heat_pump_measured_temperature": {
"name": "Measured temperature"
},
"heat_pump_operation_mode": {
"name": "Operation mode"
},
"heat_pump_target_temperature": {
"name": "Target temperature"
},
"last_meter_consumption": {
"name": "Last meter consumption"
},
@@ -159,33 +88,9 @@
"power_production": {
"name": "Power production"
},
"range_remaining": {
"name": "Remaining range"
},
"signal_strength": {
"name": "Signal strength"
},
"solar_power": {
"name": "Solar power"
},
"solar_power_production": {
"name": "Power production"
},
"storage_state_of_charge": {
"name": "Storage state of charge"
},
"storage_target_state_of_charge": {
"name": "Storage target state of charge"
},
"thermostat_measured_temperature": {
"name": "Measured temperature"
},
"thermostat_operation_mode": {
"name": "Operation mode"
},
"thermostat_target_temperature": {
"name": "Target temperature"
},
"voltage_phase1": {
"name": "Voltage phase1"
},
@@ -198,27 +103,13 @@
}
},
"exceptions": {
"graphql_required": {
"message": "Configure the Tibber GraphQL API before calling this service."
},
"invalid_date": {
"message": "Invalid datetime provided {date}"
},
"oauth2_implementation_unavailable": {
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
},
"send_message_timeout": {
"message": "Timeout sending message with Tibber"
}
},
"selector": {
"api_type": {
"options": {
"data_api": "Data API (OAuth2)",
"graphql": "GraphQL API (access token)"
}
}
},
"services": {
"get_prices": {
"description": "Fetches hourly energy prices including price level.",

View File

@@ -607,7 +607,6 @@ class DPCode(StrEnum):
ALARM_DELAY_TIME = "alarm_delay_time"
ALARM_MESSAGE = "alarm_message"
ALARM_MSG = "alarm_msg"
ALARM_STATE = "alarm_state"
ALARM_SWITCH = "alarm_switch" # Alarm switch
ALARM_TIME = "alarm_time" # Alarm time
ALARM_VOLUME = "alarm_volume" # Alarm volume

View File

@@ -32,82 +32,12 @@ from .entity import TuyaEntity
from .models import (
DPCodeBooleanWrapper,
DPCodeEnumWrapper,
DPCodeIntegerWrapper,
IntegerTypeData,
find_dpcode,
)
from .util import get_dpcode, get_dptype, remap_value
class _BrightnessWrapper(DPCodeIntegerWrapper):
"""Wrapper for brightness DP code.
Handles brightness value conversion between device scale and Home Assistant's
0-255 scale. Supports optional dynamic brightness_min and brightness_max
wrappers that allow the device to specify runtime brightness range limits.
"""
brightness_min: DPCodeIntegerWrapper | None = None
brightness_max: DPCodeIntegerWrapper | None = None
def read_device_status(self, device: CustomerDevice) -> Any | None:
"""Return the brightness of this light between 0..255."""
if (brightness := self._read_device_status_raw(device)) is None:
return None
# Remap value to our scale
brightness = self.type_information.remap_value_to(brightness)
# If there is a min/max value, the brightness is actually limited.
# Meaning it is actually not on a 0-255 scale.
if (
self.brightness_max is not None
and self.brightness_min is not None
and (brightness_max := device.status.get(self.brightness_max.dpcode))
is not None
and (brightness_min := device.status.get(self.brightness_min.dpcode))
is not None
):
# Remap values onto our scale
brightness_max = self.brightness_max.type_information.remap_value_to(
brightness_max
)
brightness_min = self.brightness_min.type_information.remap_value_to(
brightness_min
)
# Remap the brightness value from their min-max to our 0-255 scale
brightness = remap_value(
brightness, from_min=brightness_min, from_max=brightness_max
)
return round(brightness)
def _convert_value_to_raw_value(self, device: CustomerDevice, value: Any) -> Any:
"""Convert a Home Assistant value (0..255) back to a raw device value."""
# If there is a min/max value, the brightness is actually limited.
# Meaning it is actually not on a 0-255 scale.
if (
self.brightness_max is not None
and self.brightness_min is not None
and (brightness_max := device.status.get(self.brightness_max.dpcode))
is not None
and (brightness_min := device.status.get(self.brightness_min.dpcode))
is not None
):
# Remap values onto our scale
brightness_max = self.brightness_max.type_information.remap_value_to(
brightness_max
)
brightness_min = self.brightness_min.type_information.remap_value_to(
brightness_min
)
# Remap the brightness value from our 0-255 scale to their min-max
value = remap_value(value, to_min=brightness_min, to_max=brightness_max)
return round(self.type_information.remap_value_from(value))
@dataclass
class ColorTypeData:
"""Color Type Data."""
@@ -487,24 +417,6 @@ class ColorData:
return round(self.type_data.v_type.remap_value_to(self.v_value, 0, 255))
def _get_brightness_wrapper(
device: CustomerDevice, description: TuyaLightEntityDescription
) -> _BrightnessWrapper | None:
if (
brightness_wrapper := _BrightnessWrapper.find_dpcode(
device, description.brightness, prefer_function=True
)
) is None:
return None
brightness_wrapper.brightness_max = DPCodeIntegerWrapper.find_dpcode(
device, description.brightness_max, prefer_function=True
)
brightness_wrapper.brightness_min = DPCodeIntegerWrapper.find_dpcode(
device, description.brightness_min, prefer_function=True
)
return brightness_wrapper
async def async_setup_entry(
hass: HomeAssistant,
entry: TuyaConfigEntry,
@@ -525,7 +437,6 @@ async def async_setup_entry(
device,
manager,
description,
brightness_wrapper=_get_brightness_wrapper(device, description),
color_mode_wrapper=DPCodeEnumWrapper.find_dpcode(
device, description.color_mode, prefer_function=True
),
@@ -553,6 +464,9 @@ class TuyaLightEntity(TuyaEntity, LightEntity):
entity_description: TuyaLightEntityDescription
_brightness_max: IntegerTypeData | None = None
_brightness_min: IntegerTypeData | None = None
_brightness: IntegerTypeData | None = None
_color_data_dpcode: DPCode | None = None
_color_data_type: ColorTypeData | None = None
_color_temp: IntegerTypeData | None = None
@@ -567,7 +481,6 @@ class TuyaLightEntity(TuyaEntity, LightEntity):
device_manager: Manager,
description: TuyaLightEntityDescription,
*,
brightness_wrapper: DPCodeIntegerWrapper | None,
color_mode_wrapper: DPCodeEnumWrapper | None,
switch_wrapper: DPCodeBooleanWrapper,
) -> None:
@@ -575,14 +488,25 @@ class TuyaLightEntity(TuyaEntity, LightEntity):
super().__init__(device, device_manager)
self.entity_description = description
self._attr_unique_id = f"{super().unique_id}{description.key}"
self._brightness_wrapper = brightness_wrapper
self._color_mode_wrapper = color_mode_wrapper
self._switch_wrapper = switch_wrapper
color_modes: set[ColorMode] = {ColorMode.ONOFF}
if brightness_wrapper:
if int_type := find_dpcode(
self.device,
description.brightness,
dptype=DPType.INTEGER,
prefer_function=True,
):
self._brightness = int_type
color_modes.add(ColorMode.BRIGHTNESS)
self._brightness_max = find_dpcode(
self.device, description.brightness_max, dptype=DPType.INTEGER
)
self._brightness_min = find_dpcode(
self.device, description.brightness_min, dptype=DPType.INTEGER
)
if (dpcode := get_dpcode(self.device, description.color_data)) and (
get_dptype(self.device, dpcode, prefer_function=True) == DPType.JSON
@@ -605,8 +529,7 @@ class TuyaLightEntity(TuyaEntity, LightEntity):
# If no type is found, use a default one
self._color_data_type = self.entity_description.default_color_type
if self._color_data_dpcode == DPCode.COLOUR_DATA_V2 or (
self._brightness_wrapper
and self._brightness_wrapper.type_information.max > 255
self._brightness and self._brightness.max > 255
):
self._color_data_type = DEFAULT_COLOR_TYPE_DATA_V2
@@ -718,16 +641,46 @@ class TuyaLightEntity(TuyaEntity, LightEntity):
},
]
elif self._brightness_wrapper and (
ATTR_BRIGHTNESS in kwargs or ATTR_WHITE in kwargs
):
elif self._brightness and (ATTR_BRIGHTNESS in kwargs or ATTR_WHITE in kwargs):
if ATTR_BRIGHTNESS in kwargs:
brightness = kwargs[ATTR_BRIGHTNESS]
else:
brightness = kwargs[ATTR_WHITE]
# If there is a min/max value, the brightness is actually limited.
# Meaning it is actually not on a 0-255 scale.
if (
self._brightness_max is not None
and self._brightness_min is not None
and (
brightness_max := self.device.status.get(
self._brightness_max.dpcode
)
)
is not None
and (
brightness_min := self.device.status.get(
self._brightness_min.dpcode
)
)
is not None
):
# Remap values onto our scale
brightness_max = self._brightness_max.remap_value_to(brightness_max)
brightness_min = self._brightness_min.remap_value_to(brightness_min)
# Remap the brightness value from their min-max to our 0-255 scale
brightness = remap_value(
brightness,
to_min=brightness_min,
to_max=brightness_max,
)
commands += [
self._brightness_wrapper.get_update_command(self.device, brightness),
{
"code": self._brightness.dpcode,
"value": round(self._brightness.remap_value_from(brightness)),
},
]
self._send_command(commands)
@@ -738,12 +691,43 @@ class TuyaLightEntity(TuyaEntity, LightEntity):
@property
def brightness(self) -> int | None:
"""Return the brightness of this light between 0..255."""
"""Return the brightness of the light."""
# If the light is currently in color mode, extract the brightness from the color data
if self.color_mode == ColorMode.HS and (color_data := self._get_color_data()):
return color_data.brightness
return self._read_wrapper(self._brightness_wrapper)
if not self._brightness:
return None
brightness = self.device.status.get(self._brightness.dpcode)
if brightness is None:
return None
# Remap value to our scale
brightness = self._brightness.remap_value_to(brightness)
# If there is a min/max value, the brightness is actually limited.
# Meaning it is actually not on a 0-255 scale.
if (
self._brightness_max is not None
and self._brightness_min is not None
and (brightness_max := self.device.status.get(self._brightness_max.dpcode))
is not None
and (brightness_min := self.device.status.get(self._brightness_min.dpcode))
is not None
):
# Remap values onto our scale
brightness_max = self._brightness_max.remap_value_to(brightness_max)
brightness_min = self._brightness_min.remap_value_to(brightness_min)
# Remap the brightness value from their min-max to our 0-255 scale
brightness = remap_value(
brightness,
from_min=brightness_min,
from_max=brightness_max,
)
return round(brightness)
@property
def color_temp_kelvin(self) -> int | None:

View File

@@ -207,11 +207,6 @@ SELECTS: dict[DeviceCategory, tuple[SelectEntityDescription, ...]] = {
),
),
DeviceCategory.SGBJ: (
SelectEntityDescription(
key=DPCode.ALARM_STATE,
translation_key="siren_mode",
entity_category=EntityCategory.CONFIG,
),
SelectEntityDescription(
key=DPCode.ALARM_VOLUME,
translation_key="volume",

View File

@@ -494,15 +494,6 @@
"power_on": "[%key:common::state::on%]"
}
},
"siren_mode": {
"name": "Siren mode",
"state": {
"alarm_light": "Light",
"alarm_sound": "Sound",
"alarm_sound_light": "Sound & light",
"normal": "[%key:common::state::normal%]"
}
},
"target_humidity": {
"name": "Target humidity"
},

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
from typing import NamedTuple
from pythonxbox.api.client import XboxLiveClient
from pythonxbox.api.provider.catalog.const import HOME_APP_IDS
from pythonxbox.api.provider.catalog.const import HOME_APP_IDS, SYSTEM_PFN_ID_MAP
from pythonxbox.api.provider.catalog.models import (
AlternateIdType,
CatalogResponse,
@@ -42,6 +42,7 @@ TYPE_MAP = {
async def build_item_response(
client: XboxLiveClient,
device_id: str,
tv_configured: bool,
media_content_type: str,
media_content_id: str,
) -> BrowseMedia | None:
@@ -82,6 +83,29 @@ async def build_item_response(
)
)
# Add TV if configured
if tv_configured:
tv_catalog: CatalogResponse = (
await client.catalog.get_product_from_alternate_id(
SYSTEM_PFN_ID_MAP["Microsoft.Xbox.LiveTV_8wekyb3d8bbwe"][id_type],
id_type,
)
)
tv_thumb = _find_media_image(
tv_catalog.products[0].localized_properties[0].images
)
children.append(
BrowseMedia(
media_class=MediaClass.APP,
media_content_id="TV",
media_content_type=MediaType.APP,
title="Live TV",
can_play=True,
can_expand=False,
thumbnail=None if tv_thumb is None else tv_thumb.uri,
)
)
content_types = sorted(
{app.content_type for app in apps.result if app.content_type in TYPE_MAP}
)
@@ -130,7 +154,7 @@ async def build_item_response(
)
def item_payload(item: InstalledPackage, images: dict[str, list[Image]]) -> BrowseMedia:
def item_payload(item: InstalledPackage, images: dict[str, list[Image]]):
"""Create response payload for a single media item."""
thumbnail = None
image = _find_media_image(images.get(item.one_store_product_id, [])) # type: ignore[arg-type]

View File

@@ -176,6 +176,7 @@ class XboxMediaPlayer(XboxConsoleBaseEntity, MediaPlayerEntity):
return await build_item_response(
self.client,
self._console.id,
self.data.status.is_tv_configured,
media_content_type or "",
media_content_id or "",
) # type: ignore[return-value]
@@ -186,8 +187,10 @@ class XboxMediaPlayer(XboxConsoleBaseEntity, MediaPlayerEntity):
"""Launch an app on the Xbox."""
if media_id == "Home":
await self.client.smartglass.go_home(self._console.id)
await self.client.smartglass.launch_app(self._console.id, media_id)
elif media_id == "TV":
await self.client.smartglass.show_tv_guide(self._console.id)
else:
await self.client.smartglass.launch_app(self._console.id, media_id)
def _find_media_image(images: list[Image]) -> Image | None:

View File

@@ -335,7 +335,7 @@ class XboxStorageDeviceSensorEntity(
)
@property
def data(self) -> StorageDevice | None:
def data(self):
"""Storage device data."""
consoles = self.coordinator.data.result
console = next((c for c in consoles if c.id == self._console.id), None)

View File

@@ -37,7 +37,6 @@ APPLICATION_CREDENTIALS = [
"smartthings",
"spotify",
"tesla_fleet",
"tibber",
"twitch",
"volvo",
"weheat",

View File

@@ -3418,7 +3418,7 @@
"name": "LCN",
"integration_type": "hub",
"config_flow": true,
"iot_class": "local_polling"
"iot_class": "local_push"
},
"ld2410_ble": {
"name": "LD2410 BLE",

View File

@@ -30,7 +30,7 @@ certifi>=2021.5.30
ciso8601==2.3.3
cronsim==2.7
cryptography==46.0.2
dbus-fast==3.0.0
dbus-fast==2.45.0
file-read-backwards==2.0.0
fnv-hash-fast==1.6.0
go2rtc-client==0.2.1

10
mypy.ini generated
View File

@@ -5549,16 +5549,6 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.xbox.*]
check_untyped_defs = true
disallow_incomplete_defs = true
disallow_subclassing_any = true
disallow_untyped_calls = true
disallow_untyped_decorators = true
disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.xiaomi_ble.*]
check_untyped_defs = true
disallow_incomplete_defs = true

17
requirements_all.txt generated
View File

@@ -145,7 +145,7 @@ adb-shell[async]==0.4.4
adext==0.4.4
# homeassistant.components.adguard
adguardhome==0.8.1
adguardhome==0.8.0
# homeassistant.components.advantage_air
advantage-air==0.4.4
@@ -500,7 +500,7 @@ anova-wifi==0.17.0
anthemav==1.4.1
# homeassistant.components.anthropic
anthropic==0.73.0
anthropic==0.69.0
# homeassistant.components.mcp_server
anyio==4.10.0
@@ -772,7 +772,7 @@ datadog==0.52.0
datapoint==0.12.1
# homeassistant.components.bluetooth
dbus-fast==3.0.0
dbus-fast==2.45.0
# homeassistant.components.debugpy
debugpy==1.8.16
@@ -786,7 +786,6 @@ deebot-client==16.3.0
# homeassistant.components.ihc
# homeassistant.components.namecheapdns
# homeassistant.components.ohmconnect
# homeassistant.components.sonos
defusedxml==0.7.1
# homeassistant.components.deluge
@@ -1077,7 +1076,7 @@ google-genai==1.38.0
google-maps-routing==0.6.15
# homeassistant.components.nest
google-nest-sdm==9.0.1
google-nest-sdm==7.1.4
# homeassistant.components.google_photos
google-photos-library-api==0.12.1
@@ -1839,7 +1838,7 @@ pyRFXtrx==0.31.1
pySDCP==1
# homeassistant.components.tibber
pyTibber==0.33.1
pyTibber==0.32.2
# homeassistant.components.dlink
pyW215==0.8.0
@@ -2063,7 +2062,7 @@ pyhomeworks==1.1.2
pyialarm==2.2.0
# homeassistant.components.icloud
pyicloud==2.2.0
pyicloud==2.1.0
# homeassistant.components.insteon
pyinsteon==1.6.3
@@ -2269,7 +2268,7 @@ pypaperless==4.1.1
pypca==0.0.7
# homeassistant.components.lcn
pypck==0.9.2
pypck==0.8.12
# homeassistant.components.pglab
pypglab==0.0.5
@@ -2967,7 +2966,7 @@ tessie-api==0.1.1
thermobeacon-ble==0.10.0
# homeassistant.components.thermopro
thermopro-ble==1.1.2
thermopro-ble==0.13.1
# homeassistant.components.thingspeak
thingspeak==1.0.0

View File

@@ -133,7 +133,7 @@ adb-shell[async]==0.4.4
adext==0.4.4
# homeassistant.components.adguard
adguardhome==0.8.1
adguardhome==0.8.0
# homeassistant.components.advantage_air
advantage-air==0.4.4
@@ -473,7 +473,7 @@ anova-wifi==0.17.0
anthemav==1.4.1
# homeassistant.components.anthropic
anthropic==0.73.0
anthropic==0.69.0
# homeassistant.components.mcp_server
anyio==4.10.0
@@ -675,7 +675,7 @@ datadog==0.52.0
datapoint==0.12.1
# homeassistant.components.bluetooth
dbus-fast==3.0.0
dbus-fast==2.45.0
# homeassistant.components.debugpy
debugpy==1.8.16
@@ -686,7 +686,6 @@ deebot-client==16.3.0
# homeassistant.components.ihc
# homeassistant.components.namecheapdns
# homeassistant.components.ohmconnect
# homeassistant.components.sonos
defusedxml==0.7.1
# homeassistant.components.deluge
@@ -944,7 +943,7 @@ google-genai==1.38.0
google-maps-routing==0.6.15
# homeassistant.components.nest
google-nest-sdm==9.0.1
google-nest-sdm==7.1.4
# homeassistant.components.google_photos
google-photos-library-api==0.12.1
@@ -1549,7 +1548,7 @@ pyHomee==1.3.8
pyRFXtrx==0.31.1
# homeassistant.components.tibber
pyTibber==0.33.1
pyTibber==0.32.2
# homeassistant.components.dlink
pyW215==0.8.0
@@ -1722,7 +1721,7 @@ pyhomeworks==1.1.2
pyialarm==2.2.0
# homeassistant.components.icloud
pyicloud==2.2.0
pyicloud==2.1.0
# homeassistant.components.insteon
pyinsteon==1.6.3
@@ -1892,7 +1891,7 @@ pypalazzetti==0.1.20
pypaperless==4.1.1
# homeassistant.components.lcn
pypck==0.9.2
pypck==0.8.12
# homeassistant.components.pglab
pypglab==0.0.5
@@ -2452,7 +2451,7 @@ tessie-api==0.1.1
thermobeacon-ble==0.10.0
# homeassistant.components.thermopro
thermopro-ble==1.1.2
thermopro-ble==0.13.1
# homeassistant.components.lg_thinq
thinqconnect==1.0.8

View File

@@ -428,6 +428,7 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [
"gogogate2",
"goodwe",
"google_assistant",
"google_assistant_sdk",
"google_cloud",
"google_domains",
"google_generative_ai_conversation",
@@ -1442,6 +1443,7 @@ INTEGRATIONS_WITHOUT_SCALE = [
"goodwe",
"google",
"google_assistant",
"google_assistant_sdk",
"google_cloud",
"google_domains",
"google_generative_ai_conversation",

View File

@@ -1,25 +1 @@
"""Tests for the AdGuard Home integration."""
from homeassistant.const import CONTENT_TYPE_JSON
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
from tests.test_util.aiohttp import AiohttpClientMocker
async def setup_integration(
hass: HomeAssistant,
config_entry: MockConfigEntry,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Fixture for setting up the component."""
config_entry.add_to_hass(hass)
aioclient_mock.get(
"https://127.0.0.1:3000/control/status",
json={"version": "v0.107.50"},
headers={"Content-Type": CONTENT_TYPE_JSON},
)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()

View File

@@ -1,32 +0,0 @@
"""Common fixtures for the adguard tests."""
import pytest
from homeassistant.components.adguard import DOMAIN
from homeassistant.const import (
CONF_HOST,
CONF_PASSWORD,
CONF_PORT,
CONF_SSL,
CONF_USERNAME,
CONF_VERIFY_SSL,
)
from tests.common import MockConfigEntry
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Mock a config entry."""
return MockConfigEntry(
domain=DOMAIN,
data={
CONF_HOST: "127.0.0.1",
CONF_PORT: 3000,
CONF_USERNAME: "user",
CONF_PASSWORD: "pass",
CONF_SSL: True,
CONF_VERIFY_SSL: True,
},
title="AdGuard Home",
)

View File

@@ -1,61 +0,0 @@
# serializer version: 1
# name: test_update[update.adguard_home-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'update',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'update.adguard_home',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': None,
'platform': 'adguard',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <UpdateEntityFeature: 1>,
'translation_key': None,
'unique_id': 'adguard_127.0.0.1_3000_update',
'unit_of_measurement': None,
})
# ---
# name: test_update[update.adguard_home-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'auto_update': False,
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/adguard/icon.png',
'friendly_name': 'AdGuard Home',
'in_progress': False,
'installed_version': 'v0.107.50',
'latest_version': 'v0.107.59',
'release_summary': 'AdGuard Home v0.107.59 is now available!',
'release_url': 'https://github.com/AdguardTeam/AdGuardHome/releases/tag/v0.107.59',
'skipped_version': None,
'supported_features': <UpdateEntityFeature: 1>,
'title': None,
'update_percentage': None,
}),
'context': <ANY>,
'entity_id': 'update.adguard_home',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---

View File

@@ -1,138 +0,0 @@
"""Tests for the AdGuard Home update entity."""
from unittest.mock import patch
from adguardhome import AdGuardHomeError
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.const import CONTENT_TYPE_JSON, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from . import setup_integration
from tests.common import MockConfigEntry, snapshot_platform
from tests.test_util.aiohttp import AiohttpClientMocker
async def test_update(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
aioclient_mock: AiohttpClientMocker,
snapshot: SnapshotAssertion,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test the adguard update platform."""
aioclient_mock.post(
"https://127.0.0.1:3000/control/version.json",
json={
"new_version": "v0.107.59",
"announcement": "AdGuard Home v0.107.59 is now available!",
"announcement_url": "https://github.com/AdguardTeam/AdGuardHome/releases/tag/v0.107.59",
"can_autoupdate": True,
"disabled": False,
},
headers={"Content-Type": CONTENT_TYPE_JSON},
)
with patch("homeassistant.components.adguard.PLATFORMS", [Platform.UPDATE]):
await setup_integration(hass, mock_config_entry, aioclient_mock)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
async def test_update_disabled(
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test the adguard update is disabled."""
aioclient_mock.post(
"https://127.0.0.1:3000/control/version.json",
json={"disabled": True},
headers={"Content-Type": CONTENT_TYPE_JSON},
)
with patch("homeassistant.components.adguard.PLATFORMS", [Platform.UPDATE]):
await setup_integration(hass, mock_config_entry, aioclient_mock)
assert not hass.states.async_all()
async def test_update_install(
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test the adguard update installation."""
aioclient_mock.post(
"https://127.0.0.1:3000/control/version.json",
json={
"new_version": "v0.107.59",
"announcement": "AdGuard Home v0.107.59 is now available!",
"announcement_url": "https://github.com/AdguardTeam/AdGuardHome/releases/tag/v0.107.59",
"can_autoupdate": True,
"disabled": False,
},
headers={"Content-Type": CONTENT_TYPE_JSON},
)
aioclient_mock.post("https://127.0.0.1:3000/control/update")
with patch("homeassistant.components.adguard.PLATFORMS", [Platform.UPDATE]):
await setup_integration(hass, mock_config_entry, aioclient_mock)
aioclient_mock.mock_calls.clear()
await hass.services.async_call(
"update",
"install",
{"entity_id": "update.adguard_home"},
blocking=True,
)
assert aioclient_mock.mock_calls[0][0] == "POST"
assert (
str(aioclient_mock.mock_calls[0][1]) == "https://127.0.0.1:3000/control/update"
)
async def test_update_install_failed(
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test the adguard update install failed."""
aioclient_mock.post(
"https://127.0.0.1:3000/control/version.json",
json={
"new_version": "v0.107.59",
"announcement": "AdGuard Home v0.107.59 is now available!",
"announcement_url": "https://github.com/AdguardTeam/AdGuardHome/releases/tag/v0.107.59",
"can_autoupdate": True,
"disabled": False,
},
headers={"Content-Type": CONTENT_TYPE_JSON},
)
aioclient_mock.post(
"https://127.0.0.1:3000/control/update", exc=AdGuardHomeError("boom")
)
with patch("homeassistant.components.adguard.PLATFORMS", [Platform.UPDATE]):
await setup_integration(hass, mock_config_entry, aioclient_mock)
aioclient_mock.mock_calls.clear()
with pytest.raises(HomeAssistantError):
await hass.services.async_call(
"update",
"install",
{"entity_id": "update.adguard_home"},
blocking=True,
)
assert aioclient_mock.mock_calls[0][0] == "POST"
assert (
str(aioclient_mock.mock_calls[0][1]) == "https://127.0.0.1:3000/control/update"
)

View File

@@ -1 +1,58 @@
"""Tests for the blueprint init."""
from pathlib import Path
from unittest.mock import patch
from homeassistant.components import automation, blueprint
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
from tests.common import MockConfigEntry
async def test_find_relevant_blueprints(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
device_registry: dr.DeviceRegistry,
) -> None:
"""Test finding relevant blueprints."""
config_entry = MockConfigEntry()
config_entry.add_to_hass(hass)
device = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
identifiers={("test_domain", "test_device")},
name="Test Device",
)
entity_registry.async_get_or_create(
"person",
"test_domain",
"test_entity",
device_id=device.id,
original_name="Test Person",
)
with patch.object(
hass.config,
"path",
return_value=Path(automation.__file__).parent / "blueprints",
):
automation.async_get_blueprints(hass)
results = await blueprint.async_find_relevant_blueprints(hass, device.id)
for matches in results.values():
for match in matches:
match["blueprint"] = match["blueprint"].name
assert results == {
"automation": [
{
"blueprint": "Motion-activated Light",
"matched_input": {
"Person": [
"person.test_domain_test_entity",
]
},
}
]
}

View File

@@ -1,16 +1,74 @@
"""Tests for GIOS."""
from unittest.mock import patch
from homeassistant.components.gios.const import DOMAIN
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
from tests.common import (
MockConfigEntry,
async_load_json_array_fixture,
async_load_json_object_fixture,
)
STATIONS = [
{
"Identyfikator stacji": 123,
"Nazwa stacji": "Test Name 1",
"WGS84 φ N": "99.99",
"WGS84 λ E": "88.88",
},
{
"Identyfikator stacji": 321,
"Nazwa stacji": "Test Name 2",
"WGS84 φ N": "77.77",
"WGS84 λ E": "66.66",
},
]
async def setup_integration(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""Set up the GIOS integration for testing."""
mock_config_entry.add_to_hass(hass)
async def init_integration(
hass: HomeAssistant, incomplete_data=False, invalid_indexes=False
) -> MockConfigEntry:
"""Set up the GIOS integration in Home Assistant."""
entry = MockConfigEntry(
domain=DOMAIN,
title="Home",
unique_id="123",
data={"station_id": 123, "name": "Home"},
entry_id="86129426118ae32020417a53712d6eef",
)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
indexes = await async_load_json_object_fixture(hass, "indexes.json", DOMAIN)
station = await async_load_json_array_fixture(hass, "station.json", DOMAIN)
sensors = await async_load_json_object_fixture(hass, "sensors.json", DOMAIN)
if incomplete_data:
indexes["AqIndex"] = "foo"
sensors["pm10"]["Lista danych pomiarowych"][0]["Wartość"] = None
sensors["pm10"]["Lista danych pomiarowych"][1]["Wartość"] = None
if invalid_indexes:
indexes = {}
with (
patch(
"homeassistant.components.gios.coordinator.Gios._get_stations",
return_value=STATIONS,
),
patch(
"homeassistant.components.gios.coordinator.Gios._get_station",
return_value=station,
),
patch(
"homeassistant.components.gios.coordinator.Gios._get_all_sensors",
return_value=sensors,
),
patch(
"homeassistant.components.gios.coordinator.Gios._get_indexes",
return_value=indexes,
),
):
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
return entry

View File

@@ -1,84 +0,0 @@
"""Fixtures for GIOS integration tests."""
from collections.abc import AsyncGenerator
from unittest.mock import AsyncMock, MagicMock, patch
from gios.model import GiosSensors, GiosStation, Sensor as GiosSensor
import pytest
from homeassistant.components.gios.const import DOMAIN
from homeassistant.core import HomeAssistant
from . import setup_integration
from tests.common import MockConfigEntry
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Return the default mocked config entry."""
return MockConfigEntry(
domain=DOMAIN,
title="Home",
unique_id="123",
data={"station_id": 123, "name": "Home"},
entry_id="86129426118ae32020417a53712d6eef",
)
@pytest.fixture
def mock_gios_sensors() -> GiosSensors:
"""Return the default mocked gios sensors."""
return GiosSensors(
aqi=GiosSensor(name="AQI", id=None, index=None, value="good"),
c6h6=GiosSensor(name="benzene", id=658, index="very_good", value=0.23789),
co=GiosSensor(name="carbon monoxide", id=660, index="good", value=251.874),
no=GiosSensor(name="nitrogen monoxide", id=664, index=None, value=5.1),
no2=GiosSensor(name="nitrogen dioxide", id=665, index="good", value=7.13411),
nox=GiosSensor(name="nitrogen oxides", id=666, index=None, value=5.5),
o3=GiosSensor(name="ozone", id=667, index="good", value=95.7768),
pm10=GiosSensor(
name="particulate matter 10", id=14395, index="good", value=16.8344
),
pm25=GiosSensor(name="particulate matter 2.5", id=670, index="good", value=4),
so2=GiosSensor(name="sulfur dioxide", id=672, index="very_good", value=4.35478),
)
@pytest.fixture
def mock_gios_stations() -> dict[int, GiosStation]:
"""Return the default mocked gios stations."""
return {
123: GiosStation(id=123, name="Test Name 1", latitude=99.99, longitude=88.88),
321: GiosStation(id=321, name="Test Name 2", latitude=77.77, longitude=66.66),
}
@pytest.fixture
async def mock_gios(
hass: HomeAssistant,
mock_gios_stations: dict[int, GiosStation],
mock_gios_sensors: GiosSensors,
) -> AsyncGenerator[MagicMock]:
"""Yield a mocked GIOS client."""
with (
patch("homeassistant.components.gios.Gios", autospec=True) as mock_gios,
patch("homeassistant.components.gios.config_flow.Gios", new=mock_gios),
):
mock_gios.create = AsyncMock(return_value=mock_gios)
mock_gios.async_update = AsyncMock(return_value=mock_gios_sensors)
mock_gios.measurement_stations = mock_gios_stations
mock_gios.station_id = 123
mock_gios.station_name = mock_gios_stations[mock_gios.station_id].name
yield mock_gios
@pytest.fixture
async def init_integration(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_gios: MagicMock,
) -> None:
"""Set up the GIOS integration for testing."""
await setup_integration(hass, mock_config_entry)

View File

@@ -0,0 +1,38 @@
{
"AqIndex": {
"Identyfikator stacji pomiarowej": 123,
"Data wykonania obliczeń indeksu": "2020-07-31 15:10:17",
"Nazwa kategorii indeksu": "Dobry",
"Data danych źródłowych, z których policzono wartość indeksu dla wskaźnika st": "2020-07-31 14:00:00",
"Data wykonania obliczeń indeksu dla wskaźnika SO2": "2020-07-31 15:10:17",
"Wartość indeksu dla wskaźnika SO2": 0,
"Nazwa kategorii indeksu dla wskażnika SO2": "Bardzo dobry",
"Data danych źródłowych, z których policzono wartość indeksu dla wskaźnika SO2": "2020-07-31 14:00:00",
"Data wykonania obliczeń indeksu dla wskaźnika NO2": "2020-07-31 14:00:00",
"Wartość indeksu dla wskaźnika NO2": 0,
"Nazwa kategorii indeksu dla wskażnika NO2": "Dobry",
"Data danych źródłowych, z których policzono wartość indeksu dla wskaźnika NO2": "2020-07-31 14:00:00",
"Data danych źródłowych, z których policzono wartość indeksu dla wskaźnika CO": "2020-07-31 15:10:17",
"Wartość indeksu dla wskaźnika CO": 0,
"Nazwa kategorii indeksu dla wskażnika CO": "Dobry",
"Data wykonania obliczeń indeksu dla wskaźnika CO": "2020-07-31 14:00:00",
"Data danych źródłowych, z których policzono wartość indeksu dla wskaźnika PM10": "2020-07-31 15:10:17",
"Wartość indeksu dla wskaźnika PM10": 0,
"Nazwa kategorii indeksu dla wskażnika PM10": "Dobry",
"Data wykonania obliczeń indeksu dla wskaźnika PM10": "2020-07-31 14:00:00",
"Data danych źródłowych, z których policzono wartość indeksu dla wskaźnika PM2.5": "2020-07-31 15:10:17",
"Wartość indeksu dla wskaźnika PM2.5": 0,
"Nazwa kategorii indeksu dla wskażnika PM2.5": "Dobry",
"Data wykonania obliczeń indeksu dla wskaźnika PM2.5": "2020-07-31 14:00:00",
"Data danych źródłowych, z których policzono wartość indeksu dla wskaźnika O3": "2020-07-31 15:10:17",
"Wartość indeksu dla wskaźnika O3": 1,
"Nazwa kategorii indeksu dla wskażnika O3": "Dobry",
"Data wykonania obliczeń indeksu dla wskaźnika O3": "2020-07-31 14:00:00",
"Data danych źródłowych, z których policzono wartość indeksu dla wskaźnika C6H6": "2020-07-31 15:10:17",
"Wartość indeksu dla wskaźnika C6H6": 0,
"Nazwa kategorii indeksu dla wskażnika C6H6": "Bardzo dobry",
"Data wykonania obliczeń indeksu dla wskaźnika C6H6": "2020-07-31 14:00:00",
"Status indeksu ogólnego dla stacji pomiarowej": true,
"Kod zanieczyszczenia krytycznego": "OZON"
}
}

View File

@@ -0,0 +1,65 @@
{
"so2": {
"Lista danych pomiarowych": [
{ "Data": "2020-07-31 15:00:00", "Wartość": 4.35478 },
{ "Data": "2020-07-31 14:00:00", "Wartość": 4.25478 },
{ "Data": "2020-07-31 13:00:00", "Wartość": 4.34309 }
]
},
"c6h6": {
"Lista danych pomiarowych": [
{ "Data": "2020-07-31 15:00:00", "Wartość": 0.23789 },
{ "Data": "2020-07-31 14:00:00", "Wartość": 0.22789 },
{ "Data": "2020-07-31 13:00:00", "Wartość": 0.21315 }
]
},
"co": {
"Lista danych pomiarowych": [
{ "Data": "2020-07-31 15:00:00", "Wartość": 251.874 },
{ "Data": "2020-07-31 14:00:00", "Wartość": 250.874 },
{ "Data": "2020-07-31 13:00:00", "Wartość": 251.097 }
]
},
"no": {
"Lista danych pomiarowych": [
{ "Data": "2020-07-31 15:00:00", "Wartość": 5.1 },
{ "Data": "2020-07-31 14:00:00", "Wartość": 4.0 },
{ "Data": "2020-07-31 13:00:00", "Wartość": 5.2 }
]
},
"no2": {
"Lista danych pomiarowych": [
{ "Data": "2020-07-31 15:00:00", "Wartość": 7.13411 },
{ "Data": "2020-07-31 14:00:00", "Wartość": 7.33411 },
{ "Data": "2020-07-31 13:00:00", "Wartość": 9.32578 }
]
},
"nox": {
"Lista danych pomiarowych": [
{ "Data": "2020-07-31 15:00:00", "Wartość": 5.5 },
{ "Data": "2020-07-31 14:00:00", "Wartość": 6.3 },
{ "Data": "2020-07-31 13:00:00", "Wartość": 4.9 }
]
},
"o3": {
"Lista danych pomiarowych": [
{ "Data": "2020-07-31 15:00:00", "Wartość": 95.7768 },
{ "Data": "2020-07-31 14:00:00", "Wartość": 93.7768 },
{ "Data": "2020-07-31 13:00:00", "Wartość": 89.4232 }
]
},
"pm2.5": {
"Lista danych pomiarowych": [
{ "Data": "2020-07-31 15:00:00", "Wartość": 4 },
{ "Data": "2020-07-31 14:00:00", "Wartość": 4 },
{ "Data": "2020-07-31 13:00:00", "Wartość": 5 }
]
},
"pm10": {
"Lista danych pomiarowych": [
{ "Data": "2020-07-31 15:00:00", "Wartość": 16.8344 },
{ "Data": "2020-07-31 14:00:00", "Wartość": 17.8344 },
{ "Data": "2020-07-31 13:00:00", "Wartość": 20.8094 }
]
}
}

View File

@@ -0,0 +1,74 @@
[
{
"Identyfikator stanowiska": 672,
"Identyfikator stacji": 117,
"Wskaźnik": "dwutlenek siarki",
"Wskaźnik - wzór": "SO2",
"Wskaźnik - kod": "SO2",
"Id wskaźnika": 1
},
{
"Identyfikator stanowiska": 658,
"Identyfikator stacji": 117,
"Wskaźnik": "benzen",
"Wskaźnik - wzór": "C6H6",
"Wskaźnik - kod": "C6H6",
"Id wskaźnika": 10
},
{
"Identyfikator stanowiska": 660,
"Identyfikator stacji": 117,
"Wskaźnik": "tlenek węgla",
"Wskaźnik - wzór": "CO",
"Wskaźnik - kod": "CO",
"Id wskaźnika": 8
},
{
"Identyfikator stanowiska": 664,
"Identyfikator stacji": 117,
"Wskaźnik": "tlenek azotu",
"Wskaźnik - wzór": "NO",
"Wskaźnik - kod": "NO",
"Id wskaźnika": 16
},
{
"Identyfikator stanowiska": 665,
"Identyfikator stacji": 117,
"Wskaźnik": "dwutlenek azotu",
"Wskaźnik - wzór": "NO2",
"Wskaźnik - kod": "NO2",
"Id wskaźnika": 6
},
{
"Identyfikator stanowiska": 666,
"Identyfikator stacji": 117,
"Wskaźnik": "tlenki azotu",
"Wskaźnik - wzór": "NOx",
"Wskaźnik - kod": "NOx",
"Id wskaźnika": 7
},
{
"Identyfikator stanowiska": 667,
"Identyfikator stacji": 117,
"Wskaźnik": "ozon",
"Wskaźnik - wzór": "O3",
"Wskaźnik - kod": "O3",
"Id wskaźnika": 5
},
{
"Identyfikator stanowiska": 670,
"Identyfikator stacji": 117,
"Wskaźnik": "pył zawieszony PM2.5",
"Wskaźnik - wzór": "PM2.5",
"Wskaźnik - kod": "PM2.5",
"Id wskaźnika": 69
},
{
"Identyfikator stanowiska": 14395,
"Identyfikator stacji": 117,
"Wskaźnik": "pył zawieszony PM10",
"Wskaźnik - wzór": "PM10",
"Wskaźnik - kod": "PM10",
"Id wskaźnika": 3
}
]

View File

@@ -1,93 +1,138 @@
"""Define tests for the GIOS config flow."""
from unittest.mock import MagicMock
import json
from unittest.mock import patch
from gios import ApiError, InvalidSensorsDataError
import pytest
from gios import ApiError
from homeassistant.components.gios import config_flow
from homeassistant.components.gios.const import CONF_STATION_ID, DOMAIN
from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from . import STATIONS
from tests.common import async_load_fixture
CONFIG = {
CONF_NAME: "Foo",
CONF_STATION_ID: "123",
}
pytestmark = pytest.mark.usefixtures("mock_gios")
async def test_show_form(hass: HomeAssistant) -> None:
"""Test that the form is served with no input."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
with patch(
"homeassistant.components.gios.coordinator.Gios._get_stations",
return_value=STATIONS,
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert len(result["data_schema"].schema[CONF_STATION_ID].config["options"]) == 2
async def test_form_with_api_error(hass: HomeAssistant, mock_gios: MagicMock) -> None:
async def test_form_with_api_error(hass: HomeAssistant) -> None:
"""Test the form is aborted because of API error."""
mock_gios.create.side_effect = ApiError("error")
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
with patch(
"homeassistant.components.gios.coordinator.Gios._get_stations",
side_effect=ApiError("error"),
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "cannot_connect"
@pytest.mark.parametrize(
("exception", "errors"),
[
(
InvalidSensorsDataError("Invalid data"),
{CONF_STATION_ID: "invalid_sensors_data"},
async def test_invalid_sensor_data(hass: HomeAssistant) -> None:
"""Test that errors are shown when sensor data is invalid."""
with (
patch(
"homeassistant.components.gios.coordinator.Gios._get_stations",
return_value=STATIONS,
),
(ApiError("error"), {"base": "cannot_connect"}),
],
)
async def test_form_submission_errors(
hass: HomeAssistant, mock_gios: MagicMock, exception, errors
) -> None:
"""Test errors during form submission."""
mock_gios.async_update.side_effect = exception
patch(
"homeassistant.components.gios.coordinator.Gios._get_station",
return_value=json.loads(
await async_load_fixture(hass, "station.json", DOMAIN)
),
),
patch(
"homeassistant.components.gios.coordinator.Gios._get_sensor",
return_value={},
),
):
flow = config_flow.GiosFlowHandler()
flow.hass = hass
flow.context = {}
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=CONFIG
)
result = await flow.async_step_user(user_input=CONFIG)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == errors
mock_gios.async_update.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=CONFIG
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "Test Name 1"
assert result["errors"] == {CONF_STATION_ID: "invalid_sensors_data"}
async def test_cannot_connect(hass: HomeAssistant) -> None:
"""Test that errors are shown when cannot connect to GIOS server."""
with (
patch(
"homeassistant.components.gios.coordinator.Gios._get_stations",
return_value=STATIONS,
),
patch(
"homeassistant.components.gios.coordinator.Gios._async_get",
side_effect=ApiError("error"),
),
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], CONFIG
)
await hass.async_block_till_done()
assert result["errors"] == {"base": "cannot_connect"}
async def test_create_entry(hass: HomeAssistant) -> None:
"""Test that the user step works."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=CONFIG
)
with (
patch(
"homeassistant.components.gios.coordinator.Gios._get_stations",
return_value=STATIONS,
),
patch(
"homeassistant.components.gios.coordinator.Gios._get_station",
return_value=json.loads(
await async_load_fixture(hass, "station.json", DOMAIN)
),
),
patch(
"homeassistant.components.gios.coordinator.Gios._get_all_sensors",
return_value=json.loads(
await async_load_fixture(hass, "sensors.json", DOMAIN)
),
),
patch(
"homeassistant.components.gios.coordinator.Gios._get_indexes",
return_value=json.loads(
await async_load_fixture(hass, "indexes.json", DOMAIN)
),
),
):
flow = config_flow.GiosFlowHandler()
flow.hass = hass
flow.context = {}
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "Test Name 1"
assert result["data"][CONF_STATION_ID] == 123
result = await flow.async_step_user(user_input=CONFIG)
assert result["result"].unique_id == "123"
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "Test Name 1"
assert result["data"][CONF_STATION_ID] == CONFIG[CONF_STATION_ID]
assert flow.context["unique_id"] == "123"

View File

@@ -1,24 +1,24 @@
"""Test GIOS diagnostics."""
import pytest
from syrupy.assertion import SnapshotAssertion
from syrupy.filters import props
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
from . import init_integration
from tests.components.diagnostics import get_diagnostics_for_config_entry
from tests.typing import ClientSessionGenerator
@pytest.mark.usefixtures("init_integration")
async def test_entry_diagnostics(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
snapshot: SnapshotAssertion,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test config entry diagnostics."""
assert await get_diagnostics_for_config_entry(
hass, hass_client, mock_config_entry
) == snapshot(exclude=props("created_at", "modified_at"))
entry = await init_integration(hass)
assert await get_diagnostics_for_config_entry(hass, hass_client, entry) == snapshot(
exclude=props("created_at", "modified_at")
)

View File

@@ -1,8 +1,7 @@
"""Test init of GIOS integration."""
from unittest.mock import MagicMock
import pytest
import json
from unittest.mock import patch
from homeassistant.components.air_quality import DOMAIN as AIR_QUALITY_PLATFORM
from homeassistant.components.gios.const import DOMAIN
@@ -11,98 +10,108 @@ from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
from . import setup_integration
from . import STATIONS, init_integration
from tests.common import MockConfigEntry
from tests.common import MockConfigEntry, async_load_fixture
@pytest.mark.usefixtures("init_integration")
async def test_async_setup_entry(
hass: HomeAssistant,
) -> None:
async def test_async_setup_entry(hass: HomeAssistant) -> None:
"""Test a successful setup entry."""
await init_integration(hass)
state = hass.states.get("sensor.home_pm2_5")
assert state is not None
assert state.state != STATE_UNAVAILABLE
assert state.state == "4"
async def test_config_not_ready(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_gios: MagicMock,
) -> None:
async def test_config_not_ready(hass: HomeAssistant) -> None:
"""Test for setup failure if connection to GIOS is missing."""
mock_gios.create.side_effect = ConnectionError()
entry = MockConfigEntry(
domain=DOMAIN,
title="Home",
unique_id=123,
data={"station_id": 123, "name": "Home"},
)
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
with patch(
"homeassistant.components.gios.coordinator.Gios._get_stations",
side_effect=ConnectionError(),
):
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
assert entry.state is ConfigEntryState.SETUP_RETRY
@pytest.mark.usefixtures("init_integration")
async def test_unload_entry(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
async def test_unload_entry(hass: HomeAssistant) -> None:
"""Test successful unload of entry."""
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
assert mock_config_entry.state is ConfigEntryState.LOADED
entry = await init_integration(hass)
assert await hass.config_entries.async_unload(mock_config_entry.entry_id)
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
assert entry.state is ConfigEntryState.LOADED
assert await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
assert entry.state is ConfigEntryState.NOT_LOADED
assert not hass.data.get(DOMAIN)
async def test_migrate_device_and_config_entry(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
device_registry: dr.DeviceRegistry,
mock_gios: MagicMock,
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None:
"""Test device_info identifiers and config entry migration."""
mock_config_entry.add_to_hass(hass)
device_entry = device_registry.async_get_or_create(
config_entry_id=mock_config_entry.entry_id, identifiers={(DOMAIN, 123)}
config_entry = MockConfigEntry(
domain=DOMAIN,
title="Home",
unique_id=123,
data={
"station_id": 123,
"name": "Home",
},
)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
indexes = json.loads(await async_load_fixture(hass, "indexes.json", DOMAIN))
station = json.loads(await async_load_fixture(hass, "station.json", DOMAIN))
sensors = json.loads(await async_load_fixture(hass, "sensors.json", DOMAIN))
migrated_device_entry = device_registry.async_get_or_create(
config_entry_id=mock_config_entry.entry_id, identifiers={(DOMAIN, "123")}
)
assert device_entry.id == migrated_device_entry.id
with (
patch(
"homeassistant.components.gios.coordinator.Gios._get_stations",
return_value=STATIONS,
),
patch(
"homeassistant.components.gios.coordinator.Gios._get_station",
return_value=station,
),
patch(
"homeassistant.components.gios.coordinator.Gios._get_all_sensors",
return_value=sensors,
),
patch(
"homeassistant.components.gios.coordinator.Gios._get_indexes",
return_value=indexes,
),
):
config_entry.add_to_hass(hass)
device_entry = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id, identifiers={(DOMAIN, 123)}
)
async def test_migrate_unique_id_to_str(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_gios: MagicMock,
) -> None:
"""Test device_info identifiers and config entry migration."""
mock_config_entry.add_to_hass(hass)
hass.config_entries.async_update_entry(
mock_config_entry,
unique_id=int(mock_config_entry.unique_id), # type: ignore[misc]
)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
await setup_integration(hass, mock_config_entry)
assert mock_config_entry.unique_id == "123"
migrated_device_entry = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id, identifiers={(DOMAIN, "123")}
)
assert device_entry.id == migrated_device_entry.id
async def test_remove_air_quality_entities(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
mock_config_entry: MockConfigEntry,
mock_gios: MagicMock,
hass: HomeAssistant, entity_registry: er.EntityRegistry
) -> None:
"""Test remove air_quality entities from registry."""
mock_config_entry.add_to_hass(hass)
entity_registry.async_get_or_create(
AIR_QUALITY_PLATFORM,
DOMAIN,
@@ -111,8 +120,7 @@ async def test_remove_air_quality_entities(
disabled_by=None,
)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
await init_integration(hass)
entry = entity_registry.async_get("air_quality.home")
assert entry is None

View File

@@ -1,45 +1,42 @@
"""Test sensor of GIOS integration."""
from collections.abc import Generator
from unittest.mock import MagicMock, patch
from copy import deepcopy
from datetime import timedelta
import json
from unittest.mock import patch
from freezegun.api import FrozenDateTimeFactory
from gios import ApiError
from gios.model import GiosSensors
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.gios.const import DOMAIN, SCAN_INTERVAL
from homeassistant.components.gios.const import DOMAIN
from homeassistant.components.sensor import DOMAIN as PLATFORM
from homeassistant.const import STATE_UNAVAILABLE, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.util.dt import utcnow
from . import setup_integration
from . import init_integration
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
from tests.common import async_fire_time_changed, async_load_fixture, snapshot_platform
@pytest.fixture(autouse=True)
def override_platforms() -> Generator[None]:
"""Override PLATFORMS."""
with patch("homeassistant.components.gios.PLATFORMS", [Platform.SENSOR]):
yield
@pytest.mark.usefixtures("init_integration")
async def test_sensor(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
mock_config_entry: MockConfigEntry,
hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion
) -> None:
"""Test states of the sensor."""
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
with patch("homeassistant.components.gios.PLATFORMS", [Platform.SENSOR]):
entry = await init_integration(hass)
await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id)
@pytest.mark.usefixtures("init_integration")
async def test_availability(hass: HomeAssistant) -> None:
"""Ensure that we mark the entities unavailable correctly when service causes an error."""
indexes = json.loads(await async_load_fixture(hass, "indexes.json", DOMAIN))
sensors = json.loads(await async_load_fixture(hass, "sensors.json", DOMAIN))
await init_integration(hass)
state = hass.states.get("sensor.home_pm2_5")
assert state
assert state.state == "4"
@@ -52,22 +49,13 @@ async def test_availability(hass: HomeAssistant) -> None:
assert state
assert state.state == "good"
@pytest.mark.usefixtures("init_integration")
async def test_availability_api_error(
hass: HomeAssistant,
mock_gios: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Ensure that we mark the entities unavailable correctly when service causes an error."""
state = hass.states.get("sensor.home_pm2_5")
assert state
assert state.state == "4"
mock_gios.async_update.side_effect = ApiError("Unexpected error")
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
future = utcnow() + timedelta(minutes=60)
with patch(
"homeassistant.components.gios.coordinator.Gios._get_all_sensors",
side_effect=ApiError("Unexpected error"),
):
async_fire_time_changed(hass, future)
await hass.async_block_till_done()
state = hass.states.get("sensor.home_pm2_5")
assert state
@@ -81,16 +69,21 @@ async def test_availability_api_error(
assert state
assert state.state == STATE_UNAVAILABLE
mock_gios.async_update.side_effect = None
gios_sensors: GiosSensors = mock_gios.async_update.return_value
old_pm25 = gios_sensors.pm25
old_aqi = gios_sensors.aqi
gios_sensors.pm25 = None
gios_sensors.aqi = None
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
incomplete_sensors = deepcopy(sensors)
incomplete_sensors["pm2.5"] = {}
future = utcnow() + timedelta(minutes=120)
with (
patch(
"homeassistant.components.gios.coordinator.Gios._get_all_sensors",
return_value=incomplete_sensors,
),
patch(
"homeassistant.components.gios.coordinator.Gios._get_indexes",
return_value={},
),
):
async_fire_time_changed(hass, future)
await hass.async_block_till_done()
# There is no PM2.5 data so the state should be unavailable
state = hass.states.get("sensor.home_pm2_5")
@@ -107,12 +100,19 @@ async def test_availability_api_error(
assert state
assert state.state == STATE_UNAVAILABLE
gios_sensors.pm25 = old_pm25
gios_sensors.aqi = old_aqi
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
future = utcnow() + timedelta(minutes=180)
with (
patch(
"homeassistant.components.gios.coordinator.Gios._get_all_sensors",
return_value=sensors,
),
patch(
"homeassistant.components.gios.coordinator.Gios._get_indexes",
return_value=indexes,
),
):
async_fire_time_changed(hass, future)
await hass.async_block_till_done()
state = hass.states.get("sensor.home_pm2_5")
assert state
@@ -127,46 +127,9 @@ async def test_availability_api_error(
assert state.state == "good"
async def test_dont_create_entities_when_data_missing_for_station(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_gios: MagicMock,
freezer: FrozenDateTimeFactory,
entity_registry: er.EntityRegistry,
mock_gios_sensors: GiosSensors,
) -> None:
"""Test that no entities are created when data is missing for the station."""
mock_gios_sensors.co = None
mock_gios_sensors.no = None
mock_gios_sensors.no2 = None
mock_gios_sensors.nox = None
mock_gios_sensors.o3 = None
mock_gios_sensors.pm10 = None
mock_gios_sensors.pm25 = None
mock_gios_sensors.so2 = None
mock_gios_sensors.aqi = None
mock_gios_sensors.c6h6 = None
await setup_integration(hass, mock_config_entry)
assert hass.states.async_entity_ids() == []
async def test_missing_index_data(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_gios: MagicMock,
mock_gios_sensors: GiosSensors,
) -> None:
async def test_invalid_indexes(hass: HomeAssistant) -> None:
"""Test states of the sensor when API returns invalid indexes."""
mock_gios_sensors.no2.index = None
mock_gios_sensors.o3.index = None
mock_gios_sensors.pm10.index = None
mock_gios_sensors.pm25.index = None
mock_gios_sensors.so2.index = None
mock_gios_sensors.aqi = None
await setup_integration(hass, mock_config_entry)
await init_integration(hass, invalid_indexes=True)
state = hass.states.get("sensor.home_nitrogen_dioxide_index")
assert state
@@ -193,21 +156,18 @@ async def test_missing_index_data(
async def test_unique_id_migration(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
mock_gios: MagicMock,
mock_config_entry: MockConfigEntry,
hass: HomeAssistant, entity_registry: er.EntityRegistry
) -> None:
"""Test states of the unique_id migration."""
entity_registry.async_get_or_create(
Platform.SENSOR,
PLATFORM,
DOMAIN,
"123-pm2.5",
suggested_object_id="home_pm2_5",
disabled_by=None,
)
await setup_integration(hass, mock_config_entry)
await init_integration(hass)
entry = entity_registry.async_get("sensor.home_pm2_5")
assert entry

View File

@@ -6,7 +6,6 @@ import time
from typing import Any
from unittest.mock import patch
from freezegun import freeze_time
from gspread.exceptions import APIError
import pytest
from requests.models import Response
@@ -18,11 +17,8 @@ from homeassistant.components.application_credentials import (
)
from homeassistant.components.google_sheets.const import DOMAIN
from homeassistant.components.google_sheets.services import (
ADD_CREATED_COLUMN,
DATA,
DATA_CONFIG_ENTRY,
ROWS,
SERVICE_APPEND_SHEET,
SERVICE_GET_SHEET,
WORKSHEET,
)
@@ -198,24 +194,12 @@ async def test_expired_token_refresh_failure(
assert entries[0].state is expected_state
@pytest.mark.parametrize(
("add_created_column_param", "expected_row"),
[
({ADD_CREATED_COLUMN: True}, ["bar", "2024-01-15 12:30:45.123456"]),
({ADD_CREATED_COLUMN: False}, ["bar", ""]),
({}, ["bar", "2024-01-15 12:30:45.123456"]),
],
ids=["created_column_true", "created_column_false", "created_column_default"],
)
@freeze_time("2024-01-15 12:30:45.123456")
async def test_append_sheet(
hass: HomeAssistant,
setup_integration: ComponentSetup,
config_entry: MockConfigEntry,
add_created_column_param: dict[str, bool],
expected_row: list[str],
) -> None:
"""Test created column behavior based on add_created_column parameter."""
"""Test service call appending to a sheet."""
await setup_integration()
entries = hass.config_entries.async_entries(DOMAIN)
@@ -223,26 +207,17 @@ async def test_append_sheet(
assert entries[0].state is ConfigEntryState.LOADED
with patch("homeassistant.components.google_sheets.services.Client") as mock_client:
mock_worksheet = (
mock_client.return_value.open_by_key.return_value.worksheet.return_value
)
mock_worksheet.get_values.return_value = [["foo", "created"]]
await hass.services.async_call(
DOMAIN,
SERVICE_APPEND_SHEET,
"append_sheet",
{
DATA_CONFIG_ENTRY: config_entry.entry_id,
WORKSHEET: "Sheet1",
DATA: {"foo": "bar"},
**add_created_column_param,
"config_entry": config_entry.entry_id,
"worksheet": "Sheet1",
"data": {"foo": "bar"},
},
blocking=True,
)
mock_worksheet.append_rows.assert_called_once()
rows_data = mock_worksheet.append_rows.call_args[0][0]
assert rows_data[0] == expected_row
assert len(mock_client.mock_calls) == 8
async def test_get_sheet(

View File

@@ -10,8 +10,6 @@ from homeassistant.components.icloud.const import (
)
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
FIRST_NAME = "user"
LAST_NAME = "name"
USERNAME = "username@me.com"
USERNAME_2 = "second_username@icloud.com"
PASSWORD = "password"
@@ -20,30 +18,6 @@ WITH_FAMILY = True
MAX_INTERVAL = 15
GPS_ACCURACY_THRESHOLD = 250
MEMBER_1_FIRST_NAME = "John"
MEMBER_1_LAST_NAME = "TRAVOLTA"
MEMBER_1_FULL_NAME = MEMBER_1_FIRST_NAME + " " + MEMBER_1_LAST_NAME
MEMBER_1_PERSON_ID = (MEMBER_1_FIRST_NAME + MEMBER_1_LAST_NAME).lower()
MEMBER_1_APPLE_ID = MEMBER_1_PERSON_ID + "@icloud.com"
USER_INFO = {
"accountFormatter": 0,
"firstName": FIRST_NAME,
"lastName": LAST_NAME,
"membersInfo": {
MEMBER_1_PERSON_ID: {
"accountFormatter": 0,
"firstName": MEMBER_1_FIRST_NAME,
"lastName": MEMBER_1_LAST_NAME,
"deviceFetchStatus": "DONE",
"useAuthWidget": True,
"isHSA": True,
"appleId": MEMBER_1_APPLE_ID,
}
},
"hasMembers": True,
}
MOCK_CONFIG = {
CONF_USERNAME: USERNAME,
CONF_PASSWORD: PASSWORD,
@@ -55,17 +29,3 @@ MOCK_CONFIG = {
TRUSTED_DEVICES = [
{"deviceType": "SMS", "areaCode": "", "phoneNumber": "*******58", "deviceId": "1"}
]
DEVICE = {
"id": "device1",
"name": "iPhone",
"deviceStatus": "200",
"batteryStatus": "NotCharging",
"batteryLevel": 0.8,
"rawDeviceModel": "iPhone14,2",
"deviceClass": "iPhone",
"deviceDisplayName": "iPhone",
"prsId": None,
"lowPowerMode": False,
"location": None,
}

View File

@@ -1,167 +0,0 @@
"""Tests for the iCloud account."""
from unittest.mock import MagicMock, Mock, patch
import pytest
from homeassistant.components.icloud.account import IcloudAccount
from homeassistant.components.icloud.const import (
CONF_GPS_ACCURACY_THRESHOLD,
CONF_MAX_INTERVAL,
CONF_WITH_FAMILY,
DOMAIN,
)
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.storage import Store
from .const import DEVICE, MOCK_CONFIG, USER_INFO, USERNAME
from tests.common import MockConfigEntry
@pytest.fixture(name="mock_store")
def mock_store_fixture():
"""Mock the storage."""
with patch("homeassistant.components.icloud.account.Store") as store_mock:
store_instance = Mock(spec=Store)
store_instance.path = "/mock/path"
store_mock.return_value = store_instance
yield store_instance
@pytest.fixture(name="mock_icloud_service_no_userinfo")
def mock_icloud_service_no_userinfo_fixture():
"""Mock PyiCloudService with devices as dict but no userInfo."""
with patch(
"homeassistant.components.icloud.account.PyiCloudService"
) as service_mock:
service_instance = MagicMock()
service_instance.requires_2fa = False
mock_device = MagicMock()
mock_device.status = iter(DEVICE)
mock_device.user_info = None
service_instance.devices = mock_device
service_mock.return_value = service_instance
yield service_instance
async def test_setup_fails_when_userinfo_missing(
hass: HomeAssistant,
mock_store: Mock,
mock_icloud_service_no_userinfo: MagicMock,
) -> None:
"""Test setup fails when userInfo is missing from devices dict."""
assert mock_icloud_service_no_userinfo is not None
config_entry = MockConfigEntry(
domain=DOMAIN, data=MOCK_CONFIG, entry_id="test", unique_id=USERNAME
)
config_entry.add_to_hass(hass)
account = IcloudAccount(
hass,
MOCK_CONFIG[CONF_USERNAME],
MOCK_CONFIG[CONF_PASSWORD],
mock_store,
MOCK_CONFIG[CONF_WITH_FAMILY],
MOCK_CONFIG[CONF_MAX_INTERVAL],
MOCK_CONFIG[CONF_GPS_ACCURACY_THRESHOLD],
config_entry,
)
with pytest.raises(ConfigEntryNotReady, match="No user info found"):
account.setup()
class MockAppleDevice:
"""Mock "Apple device" which implements the .status(...) method used by the account."""
def __init__(self, status_dict) -> None:
"""Set status."""
self._status = status_dict
def status(self, key):
"""Return current status."""
return self._status
def __getitem__(self, key):
"""Allow indexing the device itself (device[KEY]) to proxy into the raw status dict."""
return self._status.get(key)
class MockDevicesContainer:
"""Mock devices container which is iterable and indexable returning device status dicts."""
def __init__(self, userinfo, devices) -> None:
"""Initialize with userinfo and list of device objects."""
self.user_info = userinfo
self._devices = devices
def __iter__(self):
"""Iterate returns device objects (each must have .status(...))."""
return iter(self._devices)
def __len__(self):
"""Return number of devices."""
return len(self._devices)
def __getitem__(self, idx):
"""Indexing returns device object (which must have .status(...))."""
dev = self._devices[idx]
if hasattr(dev, "status"):
return dev.status(None)
return dev
@pytest.fixture(name="mock_icloud_service")
def mock_icloud_service_fixture():
"""Mock PyiCloudService with devices container that is iterable and indexable returning status dict."""
with patch(
"homeassistant.components.icloud.account.PyiCloudService",
) as service_mock:
service_instance = MagicMock()
device_obj = MockAppleDevice(DEVICE)
devices_container = MockDevicesContainer(USER_INFO, [device_obj])
service_instance.devices = devices_container
service_instance.requires_2fa = False
service_mock.return_value = service_instance
yield service_instance
async def test_setup_success_with_devices(
hass: HomeAssistant,
mock_store: Mock,
mock_icloud_service: MagicMock,
) -> None:
"""Test successful setup with devices."""
assert mock_icloud_service is not None
config_entry = MockConfigEntry(
domain=DOMAIN, data=MOCK_CONFIG, entry_id="test", unique_id=USERNAME
)
config_entry.add_to_hass(hass)
account = IcloudAccount(
hass,
MOCK_CONFIG[CONF_USERNAME],
MOCK_CONFIG[CONF_PASSWORD],
mock_store,
MOCK_CONFIG[CONF_WITH_FAMILY],
MOCK_CONFIG[CONF_MAX_INTERVAL],
MOCK_CONFIG[CONF_GPS_ACCURACY_THRESHOLD],
config_entry,
)
with patch.object(account, "_schedule_next_fetch"):
account.setup()
assert account.api is not None
assert account.owner_fullname == "user name"
assert "johntravolta" in account.family_members_fullname
assert account.family_members_fullname["johntravolta"] == "John TRAVOLTA"

View File

@@ -2,67 +2,18 @@
from __future__ import annotations
from collections.abc import Generator, Iterable
import copy
from unittest.mock import patch
from collections.abc import Generator
from unittest.mock import AsyncMock, MagicMock, patch
from pykoplenti import ExtendedApiClient, MeData, SettingsData, VersionData
from pykoplenti import MeData, VersionData
import pytest
from homeassistant.components.kostal_plenticore.coordinator import Plenticore
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from tests.common import MockConfigEntry
DEFAULT_SETTING_VALUES = {
"devices:local": {
"Properties:StringCnt": "2",
"Properties:String0Features": "1",
"Properties:String1Features": "1",
"Properties:SerialNo": "42",
"Branding:ProductName1": "PLENTICORE",
"Branding:ProductName2": "plus 10",
"Properties:VersionIOC": "01.45",
"Properties:VersionMC": "01.46",
"Battery:MinSoc": "5",
"Battery:MinHomeComsumption": "50",
},
"scb:network": {"Hostname": "scb"},
}
DEFAULT_SETTINGS = {
"devices:local": [
SettingsData(
min="5",
max="100",
default=None,
access="readwrite",
unit="%",
id="Battery:MinSoc",
type="byte",
),
SettingsData(
min="50",
max="38000",
default=None,
access="readwrite",
unit="W",
id="Battery:MinHomeComsumption",
type="byte",
),
],
"scb:network": [
SettingsData(
min="1",
max="63",
default=None,
access="readwrite",
unit=None,
id="Hostname",
type="string",
)
],
}
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
@@ -91,67 +42,37 @@ def mock_installer_config_entry() -> MockConfigEntry:
@pytest.fixture
def mock_get_settings() -> dict[str, list[SettingsData]]:
"""Add setting data to mock_plenticore_client.
Returns a dictionary with setting data which can be mutated by test cases.
"""
return copy.deepcopy(DEFAULT_SETTINGS)
@pytest.fixture
def mock_get_setting_values() -> dict[str, dict[str, str]]:
"""Add setting values to mock_plenticore_client.
Returns a dictionary with setting values which can be mutated by test cases.
"""
# Add default settings values - this values are always retrieved by the integration on startup
return copy.deepcopy(DEFAULT_SETTING_VALUES)
@pytest.fixture
def mock_plenticore_client(
mock_get_settings: dict[str, list[SettingsData]],
mock_get_setting_values: dict[str, dict[str, str]],
) -> Generator[ExtendedApiClient]:
"""Return a patched ExtendedApiClient."""
def mock_plenticore() -> Generator[Plenticore]:
"""Set up a Plenticore mock with some default values."""
with patch(
"homeassistant.components.kostal_plenticore.coordinator.ExtendedApiClient",
autospec=True,
) as plenticore_client_class:
"homeassistant.components.kostal_plenticore.Plenticore", autospec=True
) as mock_api_class:
# setup
plenticore = mock_api_class.return_value
plenticore.async_setup = AsyncMock()
plenticore.async_setup.return_value = True
def default_settings_data(*args):
# the get_setting_values method can be called with different argument types and numbers
match args:
case (str() as module_id, str() as data_id):
request = {module_id: [data_id]}
case (str() as module_id, Iterable() as data_ids):
request = {module_id: data_ids}
case ({},):
request = args[0]
case _:
raise NotImplementedError
plenticore.device_info = DeviceInfo(
configuration_url="http://192.168.1.2",
identifiers={("kostal_plenticore", "12345")},
manufacturer="Kostal",
model="PLENTICORE plus 10",
name="scb",
sw_version="IOC: 01.45 MC: 01.46",
)
result = {}
for module_id, data_ids in request.items():
if (values := mock_get_setting_values.get(module_id)) is not None:
result[module_id] = {}
for data_id in data_ids:
if data_id in values:
result[module_id][data_id] = values[data_id]
else:
raise ValueError(
f"Missing data_id {data_id} in module {module_id}"
)
else:
raise ValueError(f"Missing module_id {module_id}")
plenticore.client = MagicMock()
return result
plenticore.client.get_version = AsyncMock()
plenticore.client.get_version.return_value = VersionData(
api_version="0.2.0",
hostname="scb",
name="PUCK RESTful API",
sw_version="01.16.05025",
)
client = plenticore_client_class.return_value
client.get_setting_values.side_effect = default_settings_data
client.get_settings.return_value = mock_get_settings
client.get_me.return_value = MeData(
plenticore.client.get_me = AsyncMock()
plenticore.client.get_me.return_value = MeData(
locked=False,
active=True,
authenticated=True,
@@ -159,14 +80,11 @@ def mock_plenticore_client(
anonymous=False,
role="USER",
)
client.get_version.return_value = VersionData(
api_version="0.2.0",
hostname="scb",
name="PUCK RESTful API",
sw_version="01.16.05025",
)
yield client
plenticore.client.get_process_data = AsyncMock()
plenticore.client.get_settings = AsyncMock()
yield plenticore
@pytest.fixture

View File

@@ -1,8 +1,9 @@
"""Test Kostal Plenticore diagnostics."""
from unittest.mock import Mock
from pykoplenti import SettingsData
from homeassistant.components.diagnostics import REDACTED
from homeassistant.components.kostal_plenticore.coordinator import Plenticore
from homeassistant.core import HomeAssistant
from tests.common import ANY, MockConfigEntry
@@ -13,16 +14,30 @@ from tests.typing import ClientSessionGenerator
async def test_entry_diagnostics(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
mock_plenticore_client: Mock,
mock_plenticore: Plenticore,
init_integration: MockConfigEntry,
) -> None:
"""Test config entry diagnostics."""
# set some test process data for the diagnostics output
mock_plenticore_client.get_process_data.return_value = {
# set some test process and settings data for the diagnostics output
mock_plenticore.client.get_process_data.return_value = {
"devices:local": ["HomeGrid_P", "HomePv_P"]
}
mock_plenticore.client.get_settings.return_value = {
"devices:local": [
SettingsData(
min="5",
max="100",
default=None,
access="readwrite",
unit="%",
id="Battery:MinSoc",
type="byte",
)
]
}
assert await get_diagnostics_for_config_entry(
hass, hass_client, init_integration
) == {
@@ -50,19 +65,8 @@ async def test_entry_diagnostics(
"available_process_data": {"devices:local": ["HomeGrid_P", "HomePv_P"]},
"available_settings_data": {
"devices:local": [
"min='5' max='100' default=None access='readwrite' unit='%' id='Battery:MinSoc' type='byte'",
"min='50' max='38000' default=None access='readwrite' unit='W' id='Battery:MinHomeComsumption' type='byte'",
],
"scb:network": [
"min='1' max='63' default=None access='readwrite' unit=None id='Hostname' type='string'"
],
},
},
"configuration": {
"devices:local": {
"Properties:StringCnt": "2",
"Properties:String0Features": "1",
"Properties:String1Features": "1",
"min='5' max='100' default=None access='readwrite' unit='%' id='Battery:MinSoc' type='byte'"
]
},
},
"device": {
@@ -74,28 +78,3 @@ async def test_entry_diagnostics(
"sw_version": "IOC: 01.45 MC: 01.46",
},
}
async def test_entry_diagnostics_invalid_string_count(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
mock_plenticore_client: Mock,
mock_get_setting_values: Mock,
init_integration: MockConfigEntry,
) -> None:
"""Test config entry diagnostics if string count is invalid."""
# set some test process data for the diagnostics output
mock_plenticore_client.get_process_data.return_value = {
"devices:local": ["HomeGrid_P", "HomePv_P"]
}
mock_get_setting_values["devices:local"]["Properties:StringCnt"] = "invalid"
diagnostic_data = await get_diagnostics_for_config_entry(
hass, hass_client, init_integration
)
assert diagnostic_data["configuration"] == {
"devices:local": {"Properties:StringCnt": "invalid"}
}

View File

@@ -1,6 +1,8 @@
"""Test Kostal Plenticore number."""
from collections.abc import Generator
from datetime import timedelta
from unittest.mock import patch
from pykoplenti import ApiClient, SettingsData
import pytest
@@ -19,9 +21,75 @@ from homeassistant.util import dt as dt_util
from tests.common import MockConfigEntry, async_fire_time_changed
pytestmark = [
pytest.mark.usefixtures("mock_plenticore_client"),
]
@pytest.fixture
def mock_plenticore_client() -> Generator[ApiClient]:
"""Return a patched ExtendedApiClient."""
with patch(
"homeassistant.components.kostal_plenticore.coordinator.ExtendedApiClient",
autospec=True,
) as plenticore_client_class:
yield plenticore_client_class.return_value
@pytest.fixture
def mock_get_setting_values(mock_plenticore_client: ApiClient) -> list:
"""Add a setting value to the given Plenticore client.
Returns a list with setting values which can be extended by test cases.
"""
mock_plenticore_client.get_settings.return_value = {
"devices:local": [
SettingsData(
min="5",
max="100",
default=None,
access="readwrite",
unit="%",
id="Battery:MinSoc",
type="byte",
),
SettingsData(
min="50",
max="38000",
default=None,
access="readwrite",
unit="W",
id="Battery:MinHomeComsumption",
type="byte",
),
],
"scb:network": [
SettingsData(
min="1",
max="63",
default=None,
access="readwrite",
unit=None,
id="Hostname",
type="string",
)
],
}
# this values are always retrieved by the integration on startup
setting_values = [
{
"devices:local": {
"Properties:SerialNo": "42",
"Branding:ProductName1": "PLENTICORE",
"Branding:ProductName2": "plus 10",
"Properties:VersionIOC": "01.45",
"Properties:VersionMC": " 01.46",
},
"scb:network": {"Hostname": "scb"},
}
]
mock_plenticore_client.get_setting_values.side_effect = setting_values
return setting_values
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
@@ -29,6 +97,8 @@ async def test_setup_all_entries(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
mock_config_entry: MockConfigEntry,
mock_plenticore_client: ApiClient,
mock_get_setting_values: list,
) -> None:
"""Test if all available entries are setup."""
@@ -48,27 +118,25 @@ async def test_setup_no_entries(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
mock_config_entry: MockConfigEntry,
mock_get_settings: dict[str, list[SettingsData]],
mock_plenticore_client: ApiClient,
mock_get_setting_values: list,
) -> None:
"""Test that no entries are setup if Plenticore does not provide data."""
# remove all settings except hostname which is used during setup
mock_get_settings.clear()
mock_get_settings.update(
{
"scb:network": [
SettingsData(
min="1",
max="63",
default=None,
access="readwrite",
unit=None,
id="Hostname",
type="string",
)
]
}
)
mock_plenticore_client.get_settings.return_value = {
"scb:network": [
SettingsData(
min="1",
max="63",
default=None,
access="readwrite",
unit=None,
id="Hostname",
type="string",
)
],
}
mock_config_entry.add_to_hass(hass)
@@ -83,11 +151,12 @@ async def test_setup_no_entries(
async def test_number_has_value(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_get_setting_values: dict[str, dict[str, str]],
mock_plenticore_client: ApiClient,
mock_get_setting_values: list,
) -> None:
"""Test if number has a value if data is provided on update."""
mock_get_setting_values["devices:local"]["Battery:MinSoc"] = "42"
mock_get_setting_values.append({"devices:local": {"Battery:MinSoc": "42"}})
mock_config_entry.add_to_hass(hass)
@@ -107,12 +176,11 @@ async def test_number_has_value(
async def test_number_is_unavailable(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_get_setting_values: dict[str, dict[str, str]],
mock_plenticore_client: ApiClient,
mock_get_setting_values: list,
) -> None:
"""Test if number is unavailable if no data is provided on update."""
del mock_get_setting_values["devices:local"]["Battery:MinSoc"]
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
@@ -130,11 +198,11 @@ async def test_set_value(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_plenticore_client: ApiClient,
mock_get_setting_values: dict[str, dict[str, str]],
mock_get_setting_values: list,
) -> None:
"""Test if a new value could be set."""
mock_get_setting_values["devices:local"]["Battery:MinSoc"] = "42"
mock_get_setting_values.append({"devices:local": {"Battery:MinSoc": "42"}})
mock_config_entry.add_to_hass(hass)

Some files were not shown because too many files have changed in this diff Show More