Compare commits

..

17 Commits

Author SHA1 Message Date
Daniel Hjelseth Høyer
16cca904a0 homevolt tests
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-01-16 13:39:23 +01:00
Daniel Hjelseth Høyer
6cf15bf70c homevolt
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-01-14 19:09:37 +01:00
Daniel Hjelseth Høyer
5a34c31e42 Merge branch 'dev' into homevolt 2026-01-14 18:30:20 +01:00
Daniel Hjelseth Høyer
9dcc86f12e homevolt
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-01-14 18:03:21 +01:00
Daniel Hjelseth Høyer
04429a6eef homevolt
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-01-14 17:40:51 +01:00
Daniel Hjelseth Høyer
51e2506afb homevolt
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-01-14 16:41:08 +01:00
Daniel Hjelseth Høyer
e49e5c7c40 Merge branch 'dev' into homevolt 2026-01-14 14:41:26 +01:00
Daniel Hjelseth Høyer
b8dfc523da homevolt
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-01-14 14:36:43 +01:00
Daniel Hjelseth Høyer
a25fbf57ef Add Homevolt integration
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-01-07 17:20:27 +01:00
Daniel Hjelseth Høyer
dac22002b0 Add Homevolt integration
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-01-07 14:53:07 +01:00
Daniel Hjelseth Høyer
e61f00a3ae Add Homevolt integration
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-01-07 14:15:56 +01:00
Daniel Hjelseth Høyer
14a67c6b5d Add Homevolt integration
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-01-07 10:46:49 +01:00
Daniel Hjelseth Høyer
90ae81f02b Add Homevolt integration
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-01-07 10:39:46 +01:00
Daniel Hjelseth Høyer
a741f214da Add Homevolt integration
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-01-07 10:35:53 +01:00
Daniel Hjelseth Høyer
21d0bd3ce2 Add Homevolt integration
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-01-07 10:22:32 +01:00
Daniel Hjelseth Høyer
d9c1f4850a Add Homevolt integration
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-01-07 10:09:50 +01:00
Daniel Hjelseth Høyer
335994af7e Add Homevolt integration
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-01-07 09:44:06 +01:00
104 changed files with 2019 additions and 2817 deletions

View File

@@ -1024,6 +1024,18 @@ class MyCoordinator(DataUpdateCoordinator[MyData]):
)
```
### Entity Performance Optimization
```python
# Use __slots__ for memory efficiency
class MySensor(SensorEntity):
__slots__ = ("_attr_native_value", "_attr_available")
@property
def should_poll(self) -> bool:
"""Disable polling when using coordinator."""
return False # ✅ Let coordinator handle updates
```
## Testing Patterns
### Testing Best Practices
@@ -1169,4 +1181,4 @@ python -m script.hassfest --integration-path homeassistant/components/my_integra
pytest ./tests/components/my_integration \
--cov=homeassistant.components.my_integration \
--cov-report term-missing
```
```

2
CODEOWNERS generated
View File

@@ -711,6 +711,8 @@ build.json @home-assistant/supervisor
/tests/components/homematic/ @pvizeli
/homeassistant/components/homematicip_cloud/ @hahn-th
/tests/components/homematicip_cloud/ @hahn-th
/homeassistant/components/homevolt/ @danielhiversen
/tests/components/homevolt/ @danielhiversen
/homeassistant/components/homewizard/ @DCSBL
/tests/components/homewizard/ @DCSBL
/homeassistant/components/honeywell/ @rdfurman @mkmer

View File

@@ -1,93 +0,0 @@
"""Provides conditions for alarm control panels."""
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.condition import (
Condition,
EntityStateConditionBase,
make_entity_state_condition,
)
from homeassistant.helpers.entity import get_supported_features
from .const import DOMAIN, AlarmControlPanelEntityFeature, AlarmControlPanelState
def supports_feature(hass: HomeAssistant, entity_id: str, features: int) -> bool:
"""Test if an entity supports the specified features."""
try:
return bool(get_supported_features(hass, entity_id) & features)
except HomeAssistantError:
return False
class EntityStateRequiredFeaturesCondition(EntityStateConditionBase):
"""State condition."""
_required_features: int
def entity_filter(self, entities: set[str]) -> set[str]:
"""Filter entities of this domain with the required features."""
entities = super().entity_filter(entities)
return {
entity_id
for entity_id in entities
if supports_feature(self._hass, entity_id, self._required_features)
}
def make_entity_state_required_features_condition(
domain: str, to_state: str, required_features: int
) -> type[EntityStateRequiredFeaturesCondition]:
"""Create an entity state condition class with required feature filtering."""
class CustomCondition(EntityStateRequiredFeaturesCondition):
"""Condition for entity state changes."""
_domain = domain
_states = {to_state}
_required_features = required_features
return CustomCondition
CONDITIONS: dict[str, type[Condition]] = {
"is_armed": make_entity_state_condition(
DOMAIN,
{
AlarmControlPanelState.ARMED_AWAY,
AlarmControlPanelState.ARMED_CUSTOM_BYPASS,
AlarmControlPanelState.ARMED_HOME,
AlarmControlPanelState.ARMED_NIGHT,
AlarmControlPanelState.ARMED_VACATION,
},
),
"is_armed_away": make_entity_state_required_features_condition(
DOMAIN,
AlarmControlPanelState.ARMED_AWAY,
AlarmControlPanelEntityFeature.ARM_AWAY,
),
"is_armed_home": make_entity_state_required_features_condition(
DOMAIN,
AlarmControlPanelState.ARMED_HOME,
AlarmControlPanelEntityFeature.ARM_HOME,
),
"is_armed_night": make_entity_state_required_features_condition(
DOMAIN,
AlarmControlPanelState.ARMED_NIGHT,
AlarmControlPanelEntityFeature.ARM_NIGHT,
),
"is_armed_vacation": make_entity_state_required_features_condition(
DOMAIN,
AlarmControlPanelState.ARMED_VACATION,
AlarmControlPanelEntityFeature.ARM_VACATION,
),
"is_disarmed": make_entity_state_condition(DOMAIN, AlarmControlPanelState.DISARMED),
"is_triggered": make_entity_state_condition(
DOMAIN, AlarmControlPanelState.TRIGGERED
),
}
async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]:
"""Return the alarm control panel conditions."""
return CONDITIONS

View File

@@ -1,52 +0,0 @@
.condition_common: &condition_common
target:
entity:
domain: alarm_control_panel
fields: &condition_common_fields
behavior:
required: true
default: any
selector:
select:
translation_key: condition_behavior
options:
- all
- any
is_armed: *condition_common
is_armed_away:
fields: *condition_common_fields
target:
entity:
domain: alarm_control_panel
supported_features:
- alarm_control_panel.AlarmControlPanelEntityFeature.ARM_AWAY
is_armed_home:
fields: *condition_common_fields
target:
entity:
domain: alarm_control_panel
supported_features:
- alarm_control_panel.AlarmControlPanelEntityFeature.ARM_HOME
is_armed_night:
fields: *condition_common_fields
target:
entity:
domain: alarm_control_panel
supported_features:
- alarm_control_panel.AlarmControlPanelEntityFeature.ARM_NIGHT
is_armed_vacation:
fields: *condition_common_fields
target:
entity:
domain: alarm_control_panel
supported_features:
- alarm_control_panel.AlarmControlPanelEntityFeature.ARM_VACATION
is_disarmed: *condition_common
is_triggered: *condition_common

View File

@@ -1,27 +1,4 @@
{
"conditions": {
"is_armed": {
"condition": "mdi:shield"
},
"is_armed_away": {
"condition": "mdi:shield-lock"
},
"is_armed_home": {
"condition": "mdi:shield-home"
},
"is_armed_night": {
"condition": "mdi:shield-moon"
},
"is_armed_vacation": {
"condition": "mdi:shield-airplane"
},
"is_disarmed": {
"condition": "mdi:shield-off"
},
"is_triggered": {
"condition": "mdi:bell-ring"
}
},
"entity_component": {
"_": {
"default": "mdi:shield",

View File

@@ -1,82 +1,8 @@
{
"common": {
"condition_behavior_description": "How the state should match on the targeted alarms.",
"condition_behavior_name": "Behavior",
"trigger_behavior_description": "The behavior of the targeted alarms to trigger on.",
"trigger_behavior_name": "Behavior"
},
"conditions": {
"is_armed": {
"description": "Tests if one or more alarms are armed.",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
}
},
"name": "If an alarm is armed"
},
"is_armed_away": {
"description": "Tests if one or more alarms are armed in away mode.",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
}
},
"name": "If an alarm is armed away"
},
"is_armed_home": {
"description": "Tests if one or more alarms are armed in home mode.",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
}
},
"name": "If an alarm is armed home"
},
"is_armed_night": {
"description": "Tests if one or more alarms are armed in night mode.",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
}
},
"name": "If an alarm is armed night"
},
"is_armed_vacation": {
"description": "Tests if one or more alarms are armed in vacation mode.",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
}
},
"name": "If an alarm is armed vacation"
},
"is_disarmed": {
"description": "Tests if one or more alarms are disarmed.",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
}
},
"name": "If an alarm is disarmed"
},
"is_triggered": {
"description": "Tests if one or more alarms are triggered.",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
}
},
"name": "If an alarm is triggered"
}
},
"device_automation": {
"action_type": {
"arm_away": "Arm {entity_name} away",
@@ -150,12 +76,6 @@
}
},
"selector": {
"condition_behavior": {
"options": {
"all": "All",
"any": "Any"
}
},
"trigger_behavior": {
"options": {
"any": "Any",

View File

@@ -14,7 +14,7 @@ from .const import DOMAIN, AlarmControlPanelEntityFeature, AlarmControlPanelStat
def supports_feature(hass: HomeAssistant, entity_id: str, features: int) -> bool:
"""Test if an entity supports the specified features."""
"""Get the device class of an entity or UNDEFINED if not found."""
try:
return bool(get_supported_features(hass, entity_id) & features)
except HomeAssistantError:
@@ -39,7 +39,7 @@ class EntityStateTriggerRequiredFeatures(EntityTargetStateTriggerBase):
def make_entity_state_trigger_required_features(
domain: str, to_state: str, required_features: int
) -> type[EntityTargetStateTriggerBase]:
"""Create an entity state trigger class with required feature filtering."""
"""Create an entity state trigger class."""
class CustomTrigger(EntityStateTriggerRequiredFeatures):
"""Trigger for entity state changes."""

View File

@@ -1,23 +0,0 @@
"""Provides conditions for assist satellites."""
from homeassistant.core import HomeAssistant
from homeassistant.helpers.condition import Condition, make_entity_state_condition
from .const import DOMAIN
from .entity import AssistSatelliteState
CONDITIONS: dict[str, type[Condition]] = {
"is_idle": make_entity_state_condition(DOMAIN, AssistSatelliteState.IDLE),
"is_listening": make_entity_state_condition(DOMAIN, AssistSatelliteState.LISTENING),
"is_processing": make_entity_state_condition(
DOMAIN, AssistSatelliteState.PROCESSING
),
"is_responding": make_entity_state_condition(
DOMAIN, AssistSatelliteState.RESPONDING
),
}
async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]:
"""Return the assist satellite conditions."""
return CONDITIONS

View File

@@ -1,19 +0,0 @@
.condition_common: &condition_common
target:
entity:
domain: assist_satellite
fields:
behavior:
required: true
default: any
selector:
select:
translation_key: condition_behavior
options:
- all
- any
is_idle: *condition_common
is_listening: *condition_common
is_processing: *condition_common
is_responding: *condition_common

View File

@@ -1,18 +1,4 @@
{
"conditions": {
"is_idle": {
"condition": "mdi:chat-sleep"
},
"is_listening": {
"condition": "mdi:chat-question"
},
"is_processing": {
"condition": "mdi:chat-processing"
},
"is_responding": {
"condition": "mdi:chat-alert"
}
},
"entity_component": {
"_": {
"default": "mdi:account-voice"

View File

@@ -1,52 +1,8 @@
{
"common": {
"condition_behavior_description": "How the state should match on the targeted Assist satellites.",
"condition_behavior_name": "Behavior",
"trigger_behavior_description": "The behavior of the targeted Assist satellites to trigger on.",
"trigger_behavior_name": "Behavior"
},
"conditions": {
"is_idle": {
"description": "Tests if one or more Assist satellites are idle.",
"fields": {
"behavior": {
"description": "[%key:component::assist_satellite::common::condition_behavior_description%]",
"name": "[%key:component::assist_satellite::common::condition_behavior_name%]"
}
},
"name": "If a satellite is idle"
},
"is_listening": {
"description": "Tests if one or more Assist satellites are listening.",
"fields": {
"behavior": {
"description": "[%key:component::assist_satellite::common::condition_behavior_description%]",
"name": "[%key:component::assist_satellite::common::condition_behavior_name%]"
}
},
"name": "If a satellite is listening"
},
"is_processing": {
"description": "Tests if one or more Assist satellites are processing.",
"fields": {
"behavior": {
"description": "[%key:component::assist_satellite::common::condition_behavior_description%]",
"name": "[%key:component::assist_satellite::common::condition_behavior_name%]"
}
},
"name": "If a satellite is processing"
},
"is_responding": {
"description": "Tests if one or more Assist satellites are responding.",
"fields": {
"behavior": {
"description": "[%key:component::assist_satellite::common::condition_behavior_description%]",
"name": "[%key:component::assist_satellite::common::condition_behavior_name%]"
}
},
"name": "If a satellite is responding"
}
},
"entity_component": {
"_": {
"name": "Assist satellite",
@@ -65,12 +21,6 @@
"sentences": "Sentences"
}
},
"condition_behavior": {
"options": {
"all": "All",
"any": "Any"
}
},
"trigger_behavior": {
"options": {
"any": "Any",

View File

@@ -123,8 +123,6 @@ SERVICE_TRIGGER = "trigger"
NEW_TRIGGERS_CONDITIONS_FEATURE_FLAG = "new_triggers_conditions"
_EXPERIMENTAL_CONDITION_PLATFORMS = {
"alarm_control_panel",
"assist_satellite",
"fan",
"light",
}

View File

@@ -1,7 +1,5 @@
"""BleBox sensor entities."""
from datetime import datetime
import blebox_uniapi.sensor
from homeassistant.components.sensor import (
@@ -148,7 +146,7 @@ class BleBoxSensorEntity(BleBoxEntity[blebox_uniapi.sensor.BaseSensor], SensorEn
return self._feature.native_value
@property
def last_reset(self) -> datetime | None:
def last_reset(self):
"""Return the time when the sensor was last reset, if implemented."""
native_implementation = getattr(self._feature, "last_reset", None)

View File

@@ -64,7 +64,6 @@ def _ws_with_blueprint_domain(
return with_domain_blueprints
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required("type"): "blueprint/list",
@@ -98,7 +97,6 @@ async def ws_list_blueprints(
connection.send_result(msg["id"], results)
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required("type"): "blueprint/import",
@@ -152,7 +150,6 @@ async def ws_import_blueprint(
)
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required("type"): "blueprint/save",
@@ -209,7 +206,6 @@ async def ws_save_blueprint(
)
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required("type"): "blueprint/delete",
@@ -237,7 +233,6 @@ async def ws_delete_blueprint(
)
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required("type"): "blueprint/substitute",

View File

@@ -19,7 +19,6 @@ import attr
from hass_nabucasa import AlreadyConnectedError, Cloud, auth
from hass_nabucasa.const import STATE_DISCONNECTED
from hass_nabucasa.voice_data import TTS_VOICES
import psutil_home_assistant as ha_psutil
import voluptuous as vol
from homeassistant.components import websocket_api
@@ -28,7 +27,6 @@ from homeassistant.components.alexa import (
errors as alexa_errors,
)
from homeassistant.components.google_assistant import helpers as google_helpers
from homeassistant.components.hassio import get_addons_stats, get_supervisor_info
from homeassistant.components.homeassistant import exposed_entities
from homeassistant.components.http import KEY_HASS, HomeAssistantView, require_admin
from homeassistant.components.http.data_validator import RequestDataValidator
@@ -39,7 +37,6 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.hassio import is_hassio
from homeassistant.loader import (
async_get_custom_components,
async_get_loaded_integration,
@@ -574,11 +571,6 @@ class DownloadSupportPackageView(HomeAssistantView):
"</details>\n\n"
)
markdown += await self._get_host_resources_markdown(hass)
if is_hassio(hass):
markdown += await self._get_addon_resources_markdown(hass)
log_handler = hass.data[DATA_CLOUD_LOG_HANDLER]
logs = "\n".join(await log_handler.get_logs(hass))
markdown += (
@@ -592,103 +584,6 @@ class DownloadSupportPackageView(HomeAssistantView):
return markdown
async def _get_host_resources_markdown(self, hass: HomeAssistant) -> str:
"""Get host resource usage markdown using psutil."""
def _collect_system_stats() -> dict[str, Any]:
"""Collect system stats."""
psutil_wrapper = ha_psutil.PsutilWrapper()
psutil_mod = psutil_wrapper.psutil
cpu_percent = psutil_mod.cpu_percent(interval=0.1)
memory = psutil_mod.virtual_memory()
disk = psutil_mod.disk_usage("/")
return {
"cpu_percent": cpu_percent,
"memory_total": memory.total,
"memory_used": memory.used,
"memory_available": memory.available,
"memory_percent": memory.percent,
"disk_total": disk.total,
"disk_used": disk.used,
"disk_free": disk.free,
"disk_percent": disk.percent,
}
markdown = ""
try:
stats = await hass.async_add_executor_job(_collect_system_stats)
markdown += "## Host resource usage\n\n"
markdown += "Resource | Value\n"
markdown += "--- | ---\n"
markdown += f"CPU usage | {stats['cpu_percent']}%\n"
memory_total_gb = round(stats["memory_total"] / (1024**3), 2)
memory_used_gb = round(stats["memory_used"] / (1024**3), 2)
memory_available_gb = round(stats["memory_available"] / (1024**3), 2)
markdown += f"Memory total | {memory_total_gb} GB\n"
markdown += (
f"Memory used | {memory_used_gb} GB ({stats['memory_percent']}%)\n"
)
markdown += f"Memory available | {memory_available_gb} GB\n"
disk_total_gb = round(stats["disk_total"] / (1024**3), 2)
disk_used_gb = round(stats["disk_used"] / (1024**3), 2)
disk_free_gb = round(stats["disk_free"] / (1024**3), 2)
markdown += f"Disk total | {disk_total_gb} GB\n"
markdown += f"Disk used | {disk_used_gb} GB ({stats['disk_percent']}%)\n"
markdown += f"Disk free | {disk_free_gb} GB\n"
markdown += "\n"
except Exception: # noqa: BLE001
# Broad exception catch for robustness in support package generation
markdown += "## Host resource usage\n\n"
markdown += "Unable to collect host resource information\n\n"
return markdown
async def _get_addon_resources_markdown(self, hass: HomeAssistant) -> str:
"""Get add-on resource usage markdown for hassio."""
markdown = ""
try:
supervisor_info = get_supervisor_info(hass) or {}
addons_stats = get_addons_stats(hass)
addons = supervisor_info.get("addons", [])
if addons:
markdown += "## Add-on resource usage\n\n"
markdown += "<details><summary>Add-on resources</summary>\n\n"
markdown += "Add-on | Version | State | CPU | Memory\n"
markdown += "--- | --- | --- | --- | ---\n"
for addon in addons:
slug = addon.get("slug", "unknown")
name = addon.get("name", slug)
version = addon.get("version", "unknown")
state = addon.get("state", "unknown")
addon_stats = addons_stats.get(slug, {})
cpu = addon_stats.get("cpu_percent")
memory = addon_stats.get("memory_percent")
cpu_str = f"{cpu}%" if cpu is not None else "N/A"
memory_str = f"{memory}%" if memory is not None else "N/A"
markdown += (
f"{name} | {version} | {state} | {cpu_str} | {memory_str}\n"
)
markdown += "\n</details>\n\n"
except Exception: # noqa: BLE001
# Broad exception catch for robustness in support package generation
markdown += "## Add-on resource usage\n\n"
markdown += "Unable to collect add-on resource information\n\n"
return markdown
async def get(self, request: web.Request) -> web.Response:
"""Download support package file."""

View File

@@ -5,8 +5,7 @@
"alexa",
"assist_pipeline",
"backup",
"google_assistant",
"hassio"
"google_assistant"
],
"codeowners": ["@home-assistant/cloud"],
"dependencies": ["auth", "http", "repairs", "webhook", "web_rtc"],
@@ -14,6 +13,6 @@
"integration_type": "system",
"iot_class": "cloud_push",
"loggers": ["acme", "hass_nabucasa", "snitun"],
"requirements": ["hass-nabucasa==1.9.0", "psutil-home-assistant==0.0.1"],
"requirements": ["hass-nabucasa==1.9.0"],
"single_config_entry": true
}

View File

@@ -1,7 +1,6 @@
"""Support for Digital Ocean."""
from __future__ import annotations
from datetime import timedelta
import logging
import digitalocean
@@ -13,12 +12,27 @@ from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import Throttle
from .const import DATA_DIGITAL_OCEAN, DOMAIN, MIN_TIME_BETWEEN_UPDATES
_LOGGER = logging.getLogger(__name__)
ATTR_CREATED_AT = "created_at"
ATTR_DROPLET_ID = "droplet_id"
ATTR_DROPLET_NAME = "droplet_name"
ATTR_FEATURES = "features"
ATTR_IPV4_ADDRESS = "ipv4_address"
ATTR_IPV6_ADDRESS = "ipv6_address"
ATTR_MEMORY = "memory"
ATTR_REGION = "region"
ATTR_VCPUS = "vcpus"
ATTRIBUTION = "Data provided by Digital Ocean"
CONF_DROPLETS = "droplets"
DATA_DIGITAL_OCEAN = "data_do"
DIGITAL_OCEAN_PLATFORMS = [Platform.SWITCH, Platform.BINARY_SENSOR]
DOMAIN = "digital_ocean"
MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60)
CONFIG_SCHEMA = vol.Schema(
{DOMAIN: vol.Schema({vol.Required(CONF_ACCESS_TOKEN): cv.string})},

View File

@@ -3,7 +3,6 @@
from __future__ import annotations
import logging
from typing import Any
import voluptuous as vol
@@ -17,7 +16,7 @@ from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .const import (
from . import (
ATTR_CREATED_AT,
ATTR_DROPLET_ID,
ATTR_DROPLET_NAME,
@@ -66,7 +65,6 @@ class DigitalOceanBinarySensor(BinarySensorEntity):
"""Representation of a Digital Ocean droplet sensor."""
_attr_attribution = ATTRIBUTION
_attr_device_class = BinarySensorDeviceClass.MOVING
def __init__(self, do, droplet_id):
"""Initialize a new Digital Ocean sensor."""
@@ -81,12 +79,17 @@ class DigitalOceanBinarySensor(BinarySensorEntity):
return self.data.name
@property
def is_on(self) -> bool:
def is_on(self):
"""Return true if the binary sensor is on."""
return self.data.status == "active"
@property
def extra_state_attributes(self) -> dict[str, Any]:
def device_class(self) -> BinarySensorDeviceClass:
"""Return the class of this sensor."""
return BinarySensorDeviceClass.MOVING
@property
def extra_state_attributes(self):
"""Return the state attributes of the Digital Ocean droplet."""
return {
ATTR_CREATED_AT: self.data.created_at,

View File

@@ -1,30 +0,0 @@
"""Support for Digital Ocean."""
from __future__ import annotations
from datetime import timedelta
from typing import TYPE_CHECKING
from homeassistant.util.hass_dict import HassKey
if TYPE_CHECKING:
from . import DigitalOcean
ATTR_CREATED_AT = "created_at"
ATTR_DROPLET_ID = "droplet_id"
ATTR_DROPLET_NAME = "droplet_name"
ATTR_FEATURES = "features"
ATTR_IPV4_ADDRESS = "ipv4_address"
ATTR_IPV6_ADDRESS = "ipv6_address"
ATTR_MEMORY = "memory"
ATTR_REGION = "region"
ATTR_VCPUS = "vcpus"
ATTRIBUTION = "Data provided by Digital Ocean"
CONF_DROPLETS = "droplets"
DOMAIN = "digital_ocean"
DATA_DIGITAL_OCEAN: HassKey[DigitalOcean] = HassKey(DOMAIN)
MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60)

View File

@@ -16,7 +16,7 @@ from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .const import (
from . import (
ATTR_CREATED_AT,
ATTR_DROPLET_ID,
ATTR_DROPLET_NAME,
@@ -80,12 +80,12 @@ class DigitalOceanSwitch(SwitchEntity):
return self.data.name
@property
def is_on(self) -> bool:
def is_on(self):
"""Return true if switch is on."""
return self.data.status == "active"
@property
def extra_state_attributes(self) -> dict[str, Any]:
def extra_state_attributes(self):
"""Return the state attributes of the Digital Ocean droplet."""
return {
ATTR_CREATED_AT: self.data.created_at,

View File

@@ -1,7 +1,6 @@
"""Support for Ebusd daemon for communication with eBUS heating systems."""
import logging
from typing import Any
import ebusdpy
import voluptuous as vol
@@ -18,7 +17,7 @@ from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.discovery import load_platform
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN, EBUSD_DATA, SENSOR_TYPES
from .const import DOMAIN, SENSOR_TYPES
_LOGGER = logging.getLogger(__name__)
@@ -29,9 +28,9 @@ CACHE_TTL = 900
SERVICE_EBUSD_WRITE = "ebusd_write"
def verify_ebusd_config(config: ConfigType) -> ConfigType:
def verify_ebusd_config(config):
"""Verify eBusd config."""
circuit: str = config[CONF_CIRCUIT]
circuit = config[CONF_CIRCUIT]
for condition in config[CONF_MONITORED_CONDITIONS]:
if condition not in SENSOR_TYPES[circuit]:
raise vol.Invalid(f"Condition '{condition}' not in '{circuit}'.")
@@ -60,17 +59,17 @@ CONFIG_SCHEMA = vol.Schema(
def setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the eBusd component."""
_LOGGER.debug("Integration setup started")
conf: ConfigType = config[DOMAIN]
name: str = conf[CONF_NAME]
circuit: str = conf[CONF_CIRCUIT]
monitored_conditions: list[str] = conf[CONF_MONITORED_CONDITIONS]
server_address: tuple[str, int] = (conf[CONF_HOST], conf[CONF_PORT])
conf = config[DOMAIN]
name = conf[CONF_NAME]
circuit = conf[CONF_CIRCUIT]
monitored_conditions = conf.get(CONF_MONITORED_CONDITIONS)
server_address = (conf.get(CONF_HOST), conf.get(CONF_PORT))
try:
ebusdpy.init(server_address)
except (TimeoutError, OSError):
return False
hass.data[EBUSD_DATA] = EbusdData(server_address, circuit)
hass.data[DOMAIN] = EbusdData(server_address, circuit)
sensor_config = {
CONF_MONITORED_CONDITIONS: monitored_conditions,
"client_name": name,
@@ -78,7 +77,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool:
}
load_platform(hass, Platform.SENSOR, DOMAIN, sensor_config, config)
hass.services.register(DOMAIN, SERVICE_EBUSD_WRITE, hass.data[EBUSD_DATA].write)
hass.services.register(DOMAIN, SERVICE_EBUSD_WRITE, hass.data[DOMAIN].write)
_LOGGER.debug("Ebusd integration setup completed")
return True
@@ -87,13 +86,13 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool:
class EbusdData:
"""Get the latest data from Ebusd."""
def __init__(self, address: tuple[str, int], circuit: str) -> None:
def __init__(self, address, circuit):
"""Initialize the data object."""
self._circuit = circuit
self._address = address
self.value: dict[str, Any] = {}
self.value = {}
def update(self, name: str, stype: int) -> None:
def update(self, name, stype):
"""Call the Ebusd API to update the data."""
try:
_LOGGER.debug("Opening socket to ebusd %s", name)

View File

@@ -1,9 +1,5 @@
"""Constants for ebus component."""
from __future__ import annotations
from typing import TYPE_CHECKING
from homeassistant.components.sensor import SensorDeviceClass
from homeassistant.const import (
PERCENTAGE,
@@ -12,283 +8,277 @@ from homeassistant.const import (
UnitOfTemperature,
UnitOfTime,
)
from homeassistant.util.hass_dict import HassKey
if TYPE_CHECKING:
from . import EbusdData
DOMAIN = "ebusd"
EBUSD_DATA: HassKey[EbusdData] = HassKey(DOMAIN)
# SensorTypes from ebusdpy module :
# 0='decimal', 1='time-schedule', 2='switch', 3='string', 4='value;status'
type SensorSpecs = tuple[str, str | None, str | None, int, SensorDeviceClass | None]
SENSOR_TYPES: dict[str, dict[str, SensorSpecs]] = {
SENSOR_TYPES = {
"700": {
"ActualFlowTemperatureDesired": (
"ActualFlowTemperatureDesired": [
"Hc1ActualFlowTempDesired",
UnitOfTemperature.CELSIUS,
None,
0,
SensorDeviceClass.TEMPERATURE,
),
"MaxFlowTemperatureDesired": (
],
"MaxFlowTemperatureDesired": [
"Hc1MaxFlowTempDesired",
UnitOfTemperature.CELSIUS,
None,
0,
SensorDeviceClass.TEMPERATURE,
),
"MinFlowTemperatureDesired": (
],
"MinFlowTemperatureDesired": [
"Hc1MinFlowTempDesired",
UnitOfTemperature.CELSIUS,
None,
0,
SensorDeviceClass.TEMPERATURE,
),
"PumpStatus": ("Hc1PumpStatus", None, "mdi:toggle-switch", 2, None),
"HCSummerTemperatureLimit": (
],
"PumpStatus": ["Hc1PumpStatus", None, "mdi:toggle-switch", 2, None],
"HCSummerTemperatureLimit": [
"Hc1SummerTempLimit",
UnitOfTemperature.CELSIUS,
"mdi:weather-sunny",
0,
SensorDeviceClass.TEMPERATURE,
),
"HolidayTemperature": (
],
"HolidayTemperature": [
"HolidayTemp",
UnitOfTemperature.CELSIUS,
None,
0,
SensorDeviceClass.TEMPERATURE,
),
"HWTemperatureDesired": (
],
"HWTemperatureDesired": [
"HwcTempDesired",
UnitOfTemperature.CELSIUS,
None,
0,
SensorDeviceClass.TEMPERATURE,
),
"HWActualTemperature": (
],
"HWActualTemperature": [
"HwcStorageTemp",
UnitOfTemperature.CELSIUS,
None,
0,
SensorDeviceClass.TEMPERATURE,
),
"HWTimerMonday": ("hwcTimer.Monday", None, "mdi:timer-outline", 1, None),
"HWTimerTuesday": ("hwcTimer.Tuesday", None, "mdi:timer-outline", 1, None),
"HWTimerWednesday": ("hwcTimer.Wednesday", None, "mdi:timer-outline", 1, None),
"HWTimerThursday": ("hwcTimer.Thursday", None, "mdi:timer-outline", 1, None),
"HWTimerFriday": ("hwcTimer.Friday", None, "mdi:timer-outline", 1, None),
"HWTimerSaturday": ("hwcTimer.Saturday", None, "mdi:timer-outline", 1, None),
"HWTimerSunday": ("hwcTimer.Sunday", None, "mdi:timer-outline", 1, None),
"HWOperativeMode": ("HwcOpMode", None, "mdi:math-compass", 3, None),
"WaterPressure": (
],
"HWTimerMonday": ["hwcTimer.Monday", None, "mdi:timer-outline", 1, None],
"HWTimerTuesday": ["hwcTimer.Tuesday", None, "mdi:timer-outline", 1, None],
"HWTimerWednesday": ["hwcTimer.Wednesday", None, "mdi:timer-outline", 1, None],
"HWTimerThursday": ["hwcTimer.Thursday", None, "mdi:timer-outline", 1, None],
"HWTimerFriday": ["hwcTimer.Friday", None, "mdi:timer-outline", 1, None],
"HWTimerSaturday": ["hwcTimer.Saturday", None, "mdi:timer-outline", 1, None],
"HWTimerSunday": ["hwcTimer.Sunday", None, "mdi:timer-outline", 1, None],
"HWOperativeMode": ["HwcOpMode", None, "mdi:math-compass", 3, None],
"WaterPressure": [
"WaterPressure",
UnitOfPressure.BAR,
"mdi:water-pump",
0,
SensorDeviceClass.PRESSURE,
),
"Zone1RoomZoneMapping": ("z1RoomZoneMapping", None, "mdi:label", 0, None),
"Zone1NightTemperature": (
],
"Zone1RoomZoneMapping": ["z1RoomZoneMapping", None, "mdi:label", 0, None],
"Zone1NightTemperature": [
"z1NightTemp",
UnitOfTemperature.CELSIUS,
"mdi:weather-night",
0,
SensorDeviceClass.TEMPERATURE,
),
"Zone1DayTemperature": (
],
"Zone1DayTemperature": [
"z1DayTemp",
UnitOfTemperature.CELSIUS,
"mdi:weather-sunny",
0,
SensorDeviceClass.TEMPERATURE,
),
"Zone1HolidayTemperature": (
],
"Zone1HolidayTemperature": [
"z1HolidayTemp",
UnitOfTemperature.CELSIUS,
None,
0,
SensorDeviceClass.TEMPERATURE,
),
"Zone1RoomTemperature": (
],
"Zone1RoomTemperature": [
"z1RoomTemp",
UnitOfTemperature.CELSIUS,
None,
0,
SensorDeviceClass.TEMPERATURE,
),
"Zone1ActualRoomTemperatureDesired": (
],
"Zone1ActualRoomTemperatureDesired": [
"z1ActualRoomTempDesired",
UnitOfTemperature.CELSIUS,
None,
0,
SensorDeviceClass.TEMPERATURE,
),
"Zone1TimerMonday": ("z1Timer.Monday", None, "mdi:timer-outline", 1, None),
"Zone1TimerTuesday": ("z1Timer.Tuesday", None, "mdi:timer-outline", 1, None),
"Zone1TimerWednesday": (
],
"Zone1TimerMonday": ["z1Timer.Monday", None, "mdi:timer-outline", 1, None],
"Zone1TimerTuesday": ["z1Timer.Tuesday", None, "mdi:timer-outline", 1, None],
"Zone1TimerWednesday": [
"z1Timer.Wednesday",
None,
"mdi:timer-outline",
1,
None,
),
"Zone1TimerThursday": ("z1Timer.Thursday", None, "mdi:timer-outline", 1, None),
"Zone1TimerFriday": ("z1Timer.Friday", None, "mdi:timer-outline", 1, None),
"Zone1TimerSaturday": ("z1Timer.Saturday", None, "mdi:timer-outline", 1, None),
"Zone1TimerSunday": ("z1Timer.Sunday", None, "mdi:timer-outline", 1, None),
"Zone1OperativeMode": ("z1OpMode", None, "mdi:math-compass", 3, None),
"ContinuosHeating": (
],
"Zone1TimerThursday": ["z1Timer.Thursday", None, "mdi:timer-outline", 1, None],
"Zone1TimerFriday": ["z1Timer.Friday", None, "mdi:timer-outline", 1, None],
"Zone1TimerSaturday": ["z1Timer.Saturday", None, "mdi:timer-outline", 1, None],
"Zone1TimerSunday": ["z1Timer.Sunday", None, "mdi:timer-outline", 1, None],
"Zone1OperativeMode": ["z1OpMode", None, "mdi:math-compass", 3, None],
"ContinuosHeating": [
"ContinuosHeating",
UnitOfTemperature.CELSIUS,
"mdi:weather-snowy",
0,
SensorDeviceClass.TEMPERATURE,
),
"PowerEnergyConsumptionLastMonth": (
],
"PowerEnergyConsumptionLastMonth": [
"PrEnergySumHcLastMonth",
UnitOfEnergy.KILO_WATT_HOUR,
"mdi:flash",
0,
SensorDeviceClass.ENERGY,
),
"PowerEnergyConsumptionThisMonth": (
],
"PowerEnergyConsumptionThisMonth": [
"PrEnergySumHcThisMonth",
UnitOfEnergy.KILO_WATT_HOUR,
"mdi:flash",
0,
SensorDeviceClass.ENERGY,
),
],
},
"ehp": {
"HWTemperature": (
"HWTemperature": [
"HwcTemp",
UnitOfTemperature.CELSIUS,
None,
4,
SensorDeviceClass.TEMPERATURE,
),
"OutsideTemp": (
],
"OutsideTemp": [
"OutsideTemp",
UnitOfTemperature.CELSIUS,
None,
4,
SensorDeviceClass.TEMPERATURE,
),
],
},
"bai": {
"HotWaterTemperature": (
"HotWaterTemperature": [
"HwcTemp",
UnitOfTemperature.CELSIUS,
None,
4,
SensorDeviceClass.TEMPERATURE,
),
"StorageTemperature": (
],
"StorageTemperature": [
"StorageTemp",
UnitOfTemperature.CELSIUS,
None,
4,
SensorDeviceClass.TEMPERATURE,
),
"DesiredStorageTemperature": (
],
"DesiredStorageTemperature": [
"StorageTempDesired",
UnitOfTemperature.CELSIUS,
None,
0,
SensorDeviceClass.TEMPERATURE,
),
"OutdoorsTemperature": (
],
"OutdoorsTemperature": [
"OutdoorstempSensor",
UnitOfTemperature.CELSIUS,
None,
4,
SensorDeviceClass.TEMPERATURE,
),
"WaterPressure": (
],
"WaterPressure": [
"WaterPressure",
UnitOfPressure.BAR,
"mdi:pipe",
4,
SensorDeviceClass.PRESSURE,
),
"AverageIgnitionTime": (
],
"AverageIgnitionTime": [
"averageIgnitiontime",
UnitOfTime.SECONDS,
"mdi:av-timer",
0,
SensorDeviceClass.DURATION,
),
"MaximumIgnitionTime": (
],
"MaximumIgnitionTime": [
"maxIgnitiontime",
UnitOfTime.SECONDS,
"mdi:av-timer",
0,
SensorDeviceClass.DURATION,
),
"MinimumIgnitionTime": (
],
"MinimumIgnitionTime": [
"minIgnitiontime",
UnitOfTime.SECONDS,
"mdi:av-timer",
0,
SensorDeviceClass.DURATION,
),
"ReturnTemperature": (
],
"ReturnTemperature": [
"ReturnTemp",
UnitOfTemperature.CELSIUS,
None,
4,
SensorDeviceClass.TEMPERATURE,
),
"CentralHeatingPump": ("WP", None, "mdi:toggle-switch", 2, None),
"HeatingSwitch": ("HeatingSwitch", None, "mdi:toggle-switch", 2, None),
"DesiredFlowTemperature": (
],
"CentralHeatingPump": ["WP", None, "mdi:toggle-switch", 2, None],
"HeatingSwitch": ["HeatingSwitch", None, "mdi:toggle-switch", 2, None],
"DesiredFlowTemperature": [
"FlowTempDesired",
UnitOfTemperature.CELSIUS,
None,
0,
SensorDeviceClass.TEMPERATURE,
),
"FlowTemperature": (
],
"FlowTemperature": [
"FlowTemp",
UnitOfTemperature.CELSIUS,
None,
4,
SensorDeviceClass.TEMPERATURE,
),
"Flame": ("Flame", None, "mdi:toggle-switch", 2, None),
"PowerEnergyConsumptionHeatingCircuit": (
],
"Flame": ["Flame", None, "mdi:toggle-switch", 2, None],
"PowerEnergyConsumptionHeatingCircuit": [
"PrEnergySumHc1",
UnitOfEnergy.KILO_WATT_HOUR,
"mdi:flash",
0,
SensorDeviceClass.ENERGY,
),
"PowerEnergyConsumptionHotWaterCircuit": (
],
"PowerEnergyConsumptionHotWaterCircuit": [
"PrEnergySumHwc1",
UnitOfEnergy.KILO_WATT_HOUR,
"mdi:flash",
0,
SensorDeviceClass.ENERGY,
),
"RoomThermostat": ("DCRoomthermostat", None, "mdi:toggle-switch", 2, None),
"HeatingPartLoad": (
],
"RoomThermostat": ["DCRoomthermostat", None, "mdi:toggle-switch", 2, None],
"HeatingPartLoad": [
"PartloadHcKW",
UnitOfEnergy.KILO_WATT_HOUR,
"mdi:flash",
0,
SensorDeviceClass.ENERGY,
),
"StateNumber": ("StateNumber", None, "mdi:fire", 3, None),
"ModulationPercentage": (
],
"StateNumber": ["StateNumber", None, "mdi:fire", 3, None],
"ModulationPercentage": [
"ModulationTempDesired",
PERCENTAGE,
"mdi:percent",
0,
None,
),
],
},
}

View File

@@ -4,7 +4,6 @@ from __future__ import annotations
import datetime
import logging
from typing import Any, cast
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
from homeassistant.core import HomeAssistant
@@ -12,8 +11,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import Throttle, dt as dt_util
from . import EbusdData
from .const import EBUSD_DATA, SensorSpecs
from .const import DOMAIN
TIME_FRAME1_BEGIN = "time_frame1_begin"
TIME_FRAME1_END = "time_frame1_end"
@@ -35,9 +33,9 @@ def setup_platform(
"""Set up the Ebus sensor."""
if not discovery_info:
return
ebusd_api = hass.data[EBUSD_DATA]
monitored_conditions: list[str] = discovery_info["monitored_conditions"]
name: str = discovery_info["client_name"]
ebusd_api = hass.data[DOMAIN]
monitored_conditions = discovery_info["monitored_conditions"]
name = discovery_info["client_name"]
add_entities(
(
@@ -51,8 +49,9 @@ def setup_platform(
class EbusdSensor(SensorEntity):
"""Ebusd component sensor methods definition."""
def __init__(self, data: EbusdData, sensor: SensorSpecs, name: str) -> None:
def __init__(self, data, sensor, name):
"""Initialize the sensor."""
self._state = None
self._client_name = name
(
self._name,
@@ -64,15 +63,20 @@ class EbusdSensor(SensorEntity):
self.data = data
@property
def name(self) -> str:
def name(self):
"""Return the name of the sensor."""
return f"{self._client_name} {self._name}"
@property
def extra_state_attributes(self) -> dict[str, Any] | None:
def native_value(self):
"""Return the state of the sensor."""
return self._state
@property
def extra_state_attributes(self):
"""Return the device state attributes."""
if self._type == 1 and (native_value := self.native_value) is not None:
schedule: dict[str, str | None] = {
if self._type == 1 and self._state is not None:
schedule = {
TIME_FRAME1_BEGIN: None,
TIME_FRAME1_END: None,
TIME_FRAME2_BEGIN: None,
@@ -80,7 +84,7 @@ class EbusdSensor(SensorEntity):
TIME_FRAME3_BEGIN: None,
TIME_FRAME3_END: None,
}
time_frame = cast(str, native_value).split(";")
time_frame = self._state.split(";")
for index, item in enumerate(sorted(schedule.items())):
if index < len(time_frame):
parsed = datetime.datetime.strptime(time_frame[index], "%H:%M")
@@ -97,12 +101,12 @@ class EbusdSensor(SensorEntity):
return self._device_class
@property
def icon(self) -> str | None:
def icon(self):
"""Icon to use in the frontend, if any."""
return self._icon
@property
def native_unit_of_measurement(self) -> str | None:
def native_unit_of_measurement(self):
"""Return the unit of measurement."""
return self._unit_of_measurement
@@ -114,6 +118,6 @@ class EbusdSensor(SensorEntity):
if self._name not in self.data.value:
return
self._attr_native_value = self.data.value[self._name]
self._state = self.data.value[self._name]
except RuntimeError:
_LOGGER.debug("EbusdData.update exception")

View File

@@ -18,13 +18,12 @@ from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.discovery import async_load_platform
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.hass_dict import HassKey
_LOGGER = logging.getLogger(__name__)
DOMAIN = "envisalink"
DATA_EVL: HassKey[EnvisalinkAlarmPanel] = HassKey(DOMAIN)
DATA_EVL = "envisalink"
CONF_EVL_KEEPALIVE = "keepalive_interval"
CONF_EVL_PORT = "port"

View File

@@ -3,9 +3,7 @@
from __future__ import annotations
import logging
from typing import Any
from pyenvisalink import EnvisalinkAlarmPanel
import voluptuous as vol
from homeassistant.components.alarm_control_panel import (
@@ -24,7 +22,6 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import (
CONF_PANIC,
CONF_PARTITIONNAME,
CONF_PARTITIONS,
DATA_EVL,
DOMAIN,
PARTITION_SCHEMA,
@@ -54,14 +51,15 @@ async def async_setup_platform(
"""Perform the setup for Envisalink alarm panels."""
if not discovery_info:
return
configured_partitions: dict[int, dict[str, Any]] = discovery_info[CONF_PARTITIONS]
code: str | None = discovery_info[CONF_CODE]
panic_type: str = discovery_info[CONF_PANIC]
configured_partitions = discovery_info["partitions"]
code = discovery_info[CONF_CODE]
panic_type = discovery_info[CONF_PANIC]
entities = []
for part_num, part_config in configured_partitions.items():
entity_config_data = PARTITION_SCHEMA(part_config)
for part_num in configured_partitions:
entity_config_data = PARTITION_SCHEMA(configured_partitions[part_num])
entity = EnvisalinkAlarm(
hass,
part_num,
entity_config_data[CONF_PARTITIONNAME],
code,
@@ -105,14 +103,8 @@ class EnvisalinkAlarm(EnvisalinkEntity, AlarmControlPanelEntity):
)
def __init__(
self,
partition_number: int,
alarm_name: str,
code: str | None,
panic_type: str,
info: dict[str, Any],
controller: EnvisalinkAlarmPanel,
) -> None:
self, hass, partition_number, alarm_name, code, panic_type, info, controller
):
"""Initialize the alarm panel."""
self._partition_number = partition_number
self._panic_type = panic_type

View File

@@ -4,9 +4,6 @@ from __future__ import annotations
import datetime
import logging
from typing import Any
from pyenvisalink import EnvisalinkAlarmPanel
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
@@ -19,14 +16,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import dt as dt_util
from . import (
CONF_ZONENAME,
CONF_ZONES,
CONF_ZONETYPE,
DATA_EVL,
SIGNAL_ZONE_UPDATE,
ZONE_SCHEMA,
)
from . import CONF_ZONENAME, CONF_ZONETYPE, DATA_EVL, SIGNAL_ZONE_UPDATE, ZONE_SCHEMA
from .entity import EnvisalinkEntity
_LOGGER = logging.getLogger(__name__)
@@ -41,12 +31,13 @@ async def async_setup_platform(
"""Set up the Envisalink binary sensor entities."""
if not discovery_info:
return
configured_zones: dict[int, dict[str, Any]] = discovery_info[CONF_ZONES]
configured_zones = discovery_info["zones"]
entities = []
for zone_num, zone_data in configured_zones.items():
entity_config_data = ZONE_SCHEMA(zone_data)
for zone_num in configured_zones:
entity_config_data = ZONE_SCHEMA(configured_zones[zone_num])
entity = EnvisalinkBinarySensor(
hass,
zone_num,
entity_config_data[CONF_ZONENAME],
entity_config_data[CONF_ZONETYPE],
@@ -61,16 +52,9 @@ async def async_setup_platform(
class EnvisalinkBinarySensor(EnvisalinkEntity, BinarySensorEntity):
"""Representation of an Envisalink binary sensor."""
def __init__(
self,
zone_number: int,
zone_name: str,
zone_type: BinarySensorDeviceClass,
info: dict[str, Any],
controller: EnvisalinkAlarmPanel,
) -> None:
def __init__(self, hass, zone_number, zone_name, zone_type, info, controller):
"""Initialize the binary_sensor."""
self._attr_device_class = zone_type
self._zone_type = zone_type
self._zone_number = zone_number
_LOGGER.debug("Setting up zone: %s", zone_name)
@@ -85,9 +69,9 @@ class EnvisalinkBinarySensor(EnvisalinkEntity, BinarySensorEntity):
)
@property
def extra_state_attributes(self) -> dict[str, Any]:
def extra_state_attributes(self):
"""Return the state attributes."""
attr: dict[str, Any] = {}
attr = {}
# The Envisalink library returns a "last_fault" value that's the
# number of seconds since the last fault, up to a maximum of 327680
@@ -120,6 +104,11 @@ class EnvisalinkBinarySensor(EnvisalinkEntity, BinarySensorEntity):
"""Return true if sensor is on."""
return self._info["status"]["open"]
@property
def device_class(self) -> BinarySensorDeviceClass:
"""Return the class of this sensor, from DEVICE_CLASSES."""
return self._zone_type
@callback
def async_update_callback(self, zone):
"""Update the zone's state, if needed."""

View File

@@ -1,9 +1,5 @@
"""Support for Envisalink devices."""
from typing import Any
from pyenvisalink import EnvisalinkAlarmPanel
from homeassistant.helpers.entity import Entity
@@ -12,10 +8,13 @@ class EnvisalinkEntity(Entity):
_attr_should_poll = False
def __init__(
self, name: str, info: dict[str, Any], controller: EnvisalinkAlarmPanel
) -> None:
def __init__(self, name, info, controller):
"""Initialize the device."""
self._controller = controller
self._info = info
self._attr_name = name
self._name = name
@property
def name(self):
"""Return the name of the device."""
return self._name

View File

@@ -3,9 +3,6 @@
from __future__ import annotations
import logging
from typing import Any
from pyenvisalink import EnvisalinkAlarmPanel
from homeassistant.components.sensor import SensorEntity
from homeassistant.core import HomeAssistant, callback
@@ -15,7 +12,6 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import (
CONF_PARTITIONNAME,
CONF_PARTITIONS,
DATA_EVL,
PARTITION_SCHEMA,
SIGNAL_KEYPAD_UPDATE,
@@ -35,12 +31,13 @@ async def async_setup_platform(
"""Perform the setup for Envisalink sensor entities."""
if not discovery_info:
return
configured_partitions: dict[int, dict[str, Any]] = discovery_info[CONF_PARTITIONS]
configured_partitions = discovery_info["partitions"]
entities = []
for part_num, part_config in configured_partitions.items():
entity_config_data = PARTITION_SCHEMA(part_config)
for part_num in configured_partitions:
entity_config_data = PARTITION_SCHEMA(configured_partitions[part_num])
entity = EnvisalinkSensor(
hass,
entity_config_data[CONF_PARTITIONNAME],
part_num,
hass.data[DATA_EVL].alarm_state["partition"][part_num],
@@ -55,16 +52,9 @@ async def async_setup_platform(
class EnvisalinkSensor(EnvisalinkEntity, SensorEntity):
"""Representation of an Envisalink keypad."""
_attr_icon = "mdi:alarm"
def __init__(
self,
partition_name: str,
partition_number: int,
info: dict[str, Any],
controller: EnvisalinkAlarmPanel,
) -> None:
def __init__(self, hass, partition_name, partition_number, info, controller):
"""Initialize the sensor."""
self._icon = "mdi:alarm"
self._partition_number = partition_number
_LOGGER.debug("Setting up sensor for partition: %s", partition_name)
@@ -83,6 +73,11 @@ class EnvisalinkSensor(EnvisalinkEntity, SensorEntity):
)
)
@property
def icon(self):
"""Return the icon if any."""
return self._icon
@property
def native_value(self):
"""Return the overall state."""

View File

@@ -5,21 +5,13 @@ from __future__ import annotations
import logging
from typing import Any
from pyenvisalink import EnvisalinkAlarmPanel
from homeassistant.components.switch import SwitchEntity
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import (
CONF_ZONENAME,
CONF_ZONES,
DATA_EVL,
SIGNAL_ZONE_BYPASS_UPDATE,
ZONE_SCHEMA,
)
from . import CONF_ZONENAME, DATA_EVL, SIGNAL_ZONE_BYPASS_UPDATE, ZONE_SCHEMA
from .entity import EnvisalinkEntity
_LOGGER = logging.getLogger(__name__)
@@ -34,15 +26,16 @@ async def async_setup_platform(
"""Set up the Envisalink switch entities."""
if not discovery_info:
return
configured_zones: dict[int, dict[str, Any]] = discovery_info[CONF_ZONES]
configured_zones = discovery_info["zones"]
entities = []
for zone_num, zone_data in configured_zones.items():
entity_config_data = ZONE_SCHEMA(zone_data)
for zone_num in configured_zones:
entity_config_data = ZONE_SCHEMA(configured_zones[zone_num])
zone_name = f"{entity_config_data[CONF_ZONENAME]}_bypass"
_LOGGER.debug("Setting up zone_bypass switch: %s", zone_name)
entity = EnvisalinkSwitch(
hass,
zone_num,
zone_name,
hass.data[DATA_EVL].alarm_state["zone"][zone_num],
@@ -56,13 +49,7 @@ async def async_setup_platform(
class EnvisalinkSwitch(EnvisalinkEntity, SwitchEntity):
"""Representation of an Envisalink switch."""
def __init__(
self,
zone_number: int,
zone_name: str,
info: dict[str, Any],
controller: EnvisalinkAlarmPanel,
) -> None:
def __init__(self, hass, zone_number, zone_name, info, controller):
"""Initialize the switch."""
self._zone_number = zone_number

View File

@@ -7,7 +7,7 @@ from enum import StrEnum
from typing import Final
DOMAIN: Final = "essent"
UPDATE_INTERVAL: Final = timedelta(hours=1)
UPDATE_INTERVAL: Final = timedelta(hours=12)
ATTRIBUTION: Final = "Data provided by Essent"

View File

@@ -59,7 +59,7 @@ class HMBinarySensor(HMDevice, BinarySensorEntity):
"""Representation of a binary HomeMatic device."""
@property
def is_on(self) -> bool:
def is_on(self):
"""Return true if switch is on."""
if not self.available:
return False
@@ -73,7 +73,7 @@ class HMBinarySensor(HMDevice, BinarySensorEntity):
return BinarySensorDeviceClass.MOTION
return SENSOR_TYPES_CLASS.get(self._hmdevice.__class__.__name__)
def _init_data_struct(self) -> None:
def _init_data_struct(self):
"""Generate the data dictionary (self._data) from metadata."""
# Add state to data struct
if self._state:
@@ -86,11 +86,11 @@ class HMBatterySensor(HMDevice, BinarySensorEntity):
_attr_device_class = BinarySensorDeviceClass.BATTERY
@property
def is_on(self) -> bool:
def is_on(self):
"""Return True if battery is low."""
return bool(self._hm_get_state())
def _init_data_struct(self) -> None:
def _init_data_struct(self):
"""Generate the data dictionary (self._data) from metadata."""
# Add state to data struct
if self._state:

View File

@@ -178,7 +178,7 @@ class HMThermostat(HMDevice, ClimateEntity):
# Homematic
return self._data.get("CONTROL_MODE")
def _init_data_struct(self) -> None:
def _init_data_struct(self):
"""Generate a data dict (self._data) from the Homematic metadata."""
self._state = next(iter(self._hmdevice.WRITENODE.keys()))
self._data[self._state] = None

View File

@@ -78,7 +78,7 @@ class HMCover(HMDevice, CoverEntity):
"""Stop the device if in motion."""
self._hmdevice.stop(self._channel)
def _init_data_struct(self) -> None:
def _init_data_struct(self):
"""Generate a data dictionary (self._data) from metadata."""
self._state = "LEVEL"
self._data.update({self._state: None})
@@ -138,7 +138,7 @@ class HMGarage(HMCover):
"""Return whether the cover is closed."""
return self._hmdevice.is_closed(self._hm_get_state())
def _init_data_struct(self) -> None:
def _init_data_struct(self):
"""Generate a data dictionary (self._data) from metadata."""
self._state = "DOOR_STATE"
self._data.update({self._state: None})

View File

@@ -204,7 +204,7 @@ class HMDevice(Entity):
self._init_data_struct()
@abstractmethod
def _init_data_struct(self) -> None:
def _init_data_struct(self):
"""Generate a data dictionary from the HomeMatic device metadata."""

View File

@@ -51,7 +51,7 @@ class HMLight(HMDevice, LightEntity):
_attr_max_color_temp_kelvin = 6500 # 153 Mireds
@property
def brightness(self) -> int | None:
def brightness(self):
"""Return the brightness of this light between 0..255."""
# Is dimmer?
if self._state == "LEVEL":
@@ -59,7 +59,7 @@ class HMLight(HMDevice, LightEntity):
return None
@property
def is_on(self) -> bool:
def is_on(self):
"""Return true if light is on."""
try:
return self._hm_get_state() > 0
@@ -98,7 +98,7 @@ class HMLight(HMDevice, LightEntity):
return features
@property
def hs_color(self) -> tuple[float, float] | None:
def hs_color(self):
"""Return the hue and saturation color value [float, float]."""
if ColorMode.HS not in self.supported_color_modes:
return None
@@ -116,14 +116,14 @@ class HMLight(HMDevice, LightEntity):
)
@property
def effect_list(self) -> list[str] | None:
def effect_list(self):
"""Return the list of supported effects."""
if not self.supported_features & LightEntityFeature.EFFECT:
return None
return self._hmdevice.get_effect_list()
@property
def effect(self) -> str | None:
def effect(self):
"""Return the current color change program of the light."""
if not self.supported_features & LightEntityFeature.EFFECT:
return None
@@ -166,7 +166,7 @@ class HMLight(HMDevice, LightEntity):
self._hmdevice.off(self._channel)
def _init_data_struct(self) -> None:
def _init_data_struct(self):
"""Generate a data dict (self._data) from the Homematic metadata."""
# Use LEVEL
self._state = "LEVEL"

View File

@@ -48,7 +48,7 @@ class HMLock(HMDevice, LockEntity):
"""Open the door latch."""
self._hmdevice.open()
def _init_data_struct(self) -> None:
def _init_data_struct(self):
"""Generate the data dictionary (self._data) from metadata."""
self._state = "STATE"
self._data.update({self._state: None})

View File

@@ -339,7 +339,7 @@ class HMSensor(HMDevice, SensorEntity):
# No cast, return original value
return self._hm_get_state()
def _init_data_struct(self) -> None:
def _init_data_struct(self):
"""Generate a data dictionary (self._data) from metadata."""
if self._state:
self._data.update({self._state: None})

View File

@@ -35,7 +35,7 @@ class HMSwitch(HMDevice, SwitchEntity):
"""Representation of a HomeMatic switch."""
@property
def is_on(self) -> bool:
def is_on(self):
"""Return True if switch is on."""
try:
return self._hm_get_state() > 0
@@ -43,7 +43,7 @@ class HMSwitch(HMDevice, SwitchEntity):
return False
@property
def today_energy_kwh(self) -> float | None:
def today_energy_kwh(self):
"""Return the current power usage in kWh."""
if "ENERGY_COUNTER" in self._data:
try:
@@ -61,7 +61,7 @@ class HMSwitch(HMDevice, SwitchEntity):
"""Turn the switch off."""
self._hmdevice.off(self._channel)
def _init_data_struct(self) -> None:
def _init_data_struct(self):
"""Generate the data dictionary (self._data) from metadata."""
self._state = "STATE"
self._data.update({self._state: None})

View File

@@ -0,0 +1,41 @@
"""The Homevolt integration."""
from __future__ import annotations
from homevolt import Homevolt
from homeassistant.const import CONF_HOST, CONF_PASSWORD, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .coordinator import HomevoltConfigEntry, HomevoltDataUpdateCoordinator
PLATFORMS: list[Platform] = [
Platform.NUMBER,
Platform.SELECT,
Platform.SENSOR,
Platform.SWITCH,
]
async def async_setup_entry(hass: HomeAssistant, entry: HomevoltConfigEntry) -> bool:
"""Set up Homevolt from a config entry."""
host: str = entry.data[CONF_HOST]
password: str | None = entry.data.get(CONF_PASSWORD)
websession = async_get_clientsession(hass)
client = Homevolt(host, password, websession=websession)
coordinator = HomevoltDataUpdateCoordinator(hass, entry, client)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: HomevoltConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -0,0 +1,152 @@
"""Config flow for the Homevolt integration."""
from __future__ import annotations
import logging
from typing import Any
from homevolt import Homevolt, HomevoltAuthenticationError, HomevoltConnectionError
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PASSWORD
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST): str,
vol.Optional(CONF_PASSWORD): str,
}
)
class HomevoltConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Homevolt."""
VERSION = 1
MINOR_VERSION = 1
_host: str
_device_id: str
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
host = user_input[CONF_HOST]
password = user_input.get(CONF_PASSWORD)
websession = async_get_clientsession(self.hass)
client = Homevolt(host, password, websession=websession)
try:
await client.update_info()
device = client.get_device()
device_id = device.device_id
except HomevoltAuthenticationError:
errors["base"] = "invalid_auth"
except HomevoltConnectionError:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception(
"Error occurred while connecting to the Homevolt battery"
)
errors["base"] = "unknown"
else:
await self.async_set_unique_id(device_id)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title="Homevolt Local",
data={
CONF_HOST: host,
CONF_PASSWORD: password,
},
)
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)
async def async_step_zeroconf(
self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle zeroconf discovery."""
_LOGGER.debug("Zeroconf discovery: %s", discovery_info)
self._host = str(discovery_info.ip_address)
websession = async_get_clientsession(self.hass)
client = Homevolt(self._host, websession=websession)
try:
await client.update_info()
device = client.get_device()
self._device_id = device.device_id
except HomevoltConnectionError:
return self.async_abort(reason="cannot_connect")
except HomevoltAuthenticationError:
# Device requires authentication - proceed to discovery confirm
# where user can enter password
self._device_id = discovery_info.hostname.removesuffix(".local.")
except Exception:
_LOGGER.exception("Error occurred while connecting to the Homevolt battery")
return self.async_abort(reason="cannot_connect")
await self.async_set_unique_id(self._device_id)
self._abort_if_unique_id_configured(updates={CONF_HOST: self._host})
self.context.update(
{
"title_placeholders": {
"name": "Homevolt",
}
}
)
return await self.async_step_discovery_confirm()
async def async_step_discovery_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm discovery and optionally get password."""
errors: dict[str, str] = {}
if user_input is not None:
password = user_input.get(CONF_PASSWORD)
websession = async_get_clientsession(self.hass)
client = Homevolt(self._host, password, websession=websession)
try:
await client.update_info()
device = client.get_device()
self._device_id = device.device_id
except HomevoltAuthenticationError:
errors["base"] = "invalid_auth"
except HomevoltConnectionError:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception(
"Error occurred while connecting to the Homevolt battery"
)
errors["base"] = "unknown"
else:
await self.async_set_unique_id(self._device_id)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title="Homevolt",
data={
CONF_HOST: self._host,
CONF_PASSWORD: password,
},
)
return self.async_show_form(
step_id="discovery_confirm",
data_schema=vol.Schema({vol.Optional(CONF_PASSWORD): str}),
errors=errors,
description_placeholders={
"host": self._host,
},
)

View File

@@ -0,0 +1,9 @@
"""Constants for the Homevolt integration."""
from __future__ import annotations
from datetime import timedelta
DOMAIN = "homevolt"
MANUFACTURER = "Homevolt"
SCAN_INTERVAL = timedelta(seconds=15)

View File

@@ -0,0 +1,56 @@
"""Data update coordinator for Homevolt integration."""
from __future__ import annotations
import logging
from homevolt import (
Device,
Homevolt,
HomevoltAuthenticationError,
HomevoltConnectionError,
HomevoltError,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN, SCAN_INTERVAL
type HomevoltConfigEntry = ConfigEntry[HomevoltDataUpdateCoordinator]
_LOGGER = logging.getLogger(__name__)
class HomevoltDataUpdateCoordinator(DataUpdateCoordinator[Device]):
"""Class to manage fetching Homevolt data."""
config_entry: HomevoltConfigEntry
def __init__(
self,
hass: HomeAssistant,
entry: HomevoltConfigEntry,
client: Homevolt,
) -> None:
"""Initialize the Homevolt coordinator."""
self.client = client
super().__init__(
hass,
_LOGGER,
name=DOMAIN,
update_interval=SCAN_INTERVAL,
config_entry=entry,
)
async def _async_update_data(self) -> Device:
"""Fetch data from the Homevolt API."""
try:
await self.client.update_info()
return self.client.get_device()
except HomevoltAuthenticationError as err:
raise ConfigEntryAuthFailed from err
except (HomevoltConnectionError, HomevoltError) as err:
raise UpdateFailed(f"Error communicating with device: {err}") from err

View File

@@ -0,0 +1,18 @@
{
"domain": "homevolt",
"name": "Homevolt",
"codeowners": ["@danielhiversen"],
"config_flow": true,
"dependencies": [],
"documentation": "https://www.home-assistant.io/integrations/homevolt",
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["homevolt==0.2.4"],
"zeroconf": [
{
"name": "homevolt*",
"type": "_http._tcp.local."
}
]
}

View File

@@ -0,0 +1,70 @@
rules:
# Bronze
action-setup:
status: exempt
comment: Integration does not register custom actions.
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: Integration does not register custom actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: Local_polling without events
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions:
status: exempt
comment: Integration does not register custom actions.
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: Integration does not have an options flow.
docs-installation-parameters: todo
entity-unavailable: done
integration-owner: done
log-when-unavailable: todo
parallel-updates: done
reauthentication-flow: todo
test-coverage: todo
# Gold
devices: done
diagnostics: todo
discovery-update-info: todo
discovery: todo
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: todo
entity-category: todo
entity-device-class: done
entity-disabled-by-default: todo
entity-translations: done
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
repair-issues: todo
stale-devices: todo
# Platinum
async-dependency: done
inject-websession: done
strict-typing: todo

View File

@@ -0,0 +1,148 @@
"""Support for Homevolt sensors."""
from __future__ import annotations
from homevolt.models import SensorType
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import (
PERCENTAGE,
SIGNAL_STRENGTH_DECIBELS,
UnitOfElectricCurrent,
UnitOfElectricPotential,
UnitOfEnergy,
UnitOfFrequency,
UnitOfPower,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .coordinator import HomevoltConfigEntry, HomevoltDataUpdateCoordinator
from .entity import HomevoltEntity
PARALLEL_UPDATES = 0 # Coordinator-based updates
SENSORS: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(
key=SensorType.COUNT,
state_class=SensorStateClass.TOTAL_INCREASING,
),
SensorEntityDescription(
key=SensorType.ENERGY_TOTAL,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL,
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
),
SensorEntityDescription(
key=SensorType.ENERGY_INCREASING,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
),
SensorEntityDescription(
key=SensorType.FREQUENCY,
device_class=SensorDeviceClass.FREQUENCY,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfFrequency.HERTZ,
),
SensorEntityDescription(
key=SensorType.PERCENTAGE,
device_class=SensorDeviceClass.BATTERY,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
),
SensorEntityDescription(
key=SensorType.POWER,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPower.WATT,
),
SensorEntityDescription(
key=SensorType.SCHEDULE_TYPE,
),
SensorEntityDescription(
key=SensorType.SIGNAL_STRENGTH,
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS,
),
SensorEntityDescription(
key=SensorType.TEMPERATURE,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
),
SensorEntityDescription(
key=SensorType.TEXT,
),
SensorEntityDescription(
key=SensorType.VOLTAGE,
device_class=SensorDeviceClass.VOLTAGE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
),
SensorEntityDescription(
key=SensorType.CURRENT,
device_class=SensorDeviceClass.CURRENT,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: HomevoltConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Homevolt sensor."""
coordinator = entry.runtime_data
entities = []
sensors_by_key = {sensor.key: sensor for sensor in SENSORS}
for sensor_key, sensor in coordinator.data.sensors.items():
if (description := sensors_by_key.get(sensor.type)) is None:
continue
entities.append(
HomevoltSensor(
description,
coordinator,
sensor_key,
)
)
async_add_entities(entities)
class HomevoltSensor(HomevoltEntity, SensorEntity):
"""Representation of a Homevolt sensor."""
def __init__(
self,
description: SensorEntityDescription,
coordinator: HomevoltDataUpdateCoordinator,
sensor_key: str,
) -> None:
"""Initialize the sensor."""
self.entity_description = description
device_id = coordinator.data.device_id
self._attr_unique_id = f"{device_id}_{sensor_key}"
sensor_data = coordinator.data.sensors[sensor_key]
self._attr_translation_key = sensor_data.slug
self._sensor_key = sensor_key
super().__init__(coordinator, sensor_data.device_identifier)
@property
def available(self) -> bool:
"""Return if entity is available."""
return super().available and self._sensor_key in self.coordinator.data.sensors
@property
def native_value(self) -> StateType:
"""Return the native value of the sensor."""
return self.coordinator.data.sensors[self._sensor_key].value

View File

@@ -0,0 +1,236 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"cannot_connect": "Failed to connect"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"flow_title": "{name}",
"step": {
"discovery_confirm": {
"data": {
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"password": "The local password configured for your Homevolt battery, if required."
},
"description": "A Homevolt battery was discovered at {host}. Enter the password if required."
},
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]",
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"host": "The IP address or hostname of your Homevolt battery on your local network.",
"password": "The local password configured for your Homevolt battery, if required."
},
"description": "Connect Home Assistant to your Homevolt battery over the local network.",
"title": "Homevolt Local"
}
}
},
"entity": {
"number": {
"battery_max_charge_power": {
"name": "Battery max charge power"
},
"battery_max_discharge_power": {
"name": "Battery max discharge power"
},
"battery_max_soc": {
"name": "Battery maximum state of charge"
},
"battery_min_soc": {
"name": "Battery minimum state of charge"
},
"battery_power_setpoint": {
"name": "Battery power setpoint"
}
},
"select": {
"battery_control_mode": {
"name": "Battery control mode"
}
},
"sensor": {
"available_charging_energy": {
"name": "Available charging energy"
},
"available_charging_power": {
"name": "Available charging power"
},
"available_discharge_energy": {
"name": "Available discharge energy"
},
"available_discharge_power": {
"name": "Available discharge power"
},
"average_rssi_grid": {
"name": "Grid average RSSI"
},
"average_rssi_load": {
"name": "Load average RSSI"
},
"battery_state_of_charge": {
"name": "Battery state of charge"
},
"charge_cycles": {
"name": "Charge cycles"
},
"energy_exported_grid": {
"name": "Grid exported energy"
},
"energy_exported_load": {
"name": "Load exported energy"
},
"energy_imported_grid": {
"name": "Grid imported energy"
},
"energy_imported_load": {
"name": "Load imported energy"
},
"exported_energy": {
"name": "Exported energy"
},
"frequency": {
"name": "Frequency"
},
"imported_energy": {
"name": "Imported energy"
},
"l1_current": {
"name": "L1 current"
},
"l1_current_grid": {
"name": "Grid L1 current"
},
"l1_current_load": {
"name": "Load L1 current"
},
"l1_l2_voltage": {
"name": "L1-L2 voltage"
},
"l1_power_grid": {
"name": "Grid L1 power"
},
"l1_power_load": {
"name": "Load L1 power"
},
"l1_voltage": {
"name": "L1 voltage"
},
"l1_voltage_grid": {
"name": "Grid L1 voltage"
},
"l1_voltage_load": {
"name": "Load L1 voltage"
},
"l2_current": {
"name": "L2 current"
},
"l2_current_grid": {
"name": "Grid L2 current"
},
"l2_current_load": {
"name": "Load L2 current"
},
"l2_l3_voltage": {
"name": "L2-L3 voltage"
},
"l2_power_grid": {
"name": "Grid L2 power"
},
"l2_power_load": {
"name": "Load L2 power"
},
"l2_voltage": {
"name": "L2 voltage"
},
"l2_voltage_grid": {
"name": "Grid L2 voltage"
},
"l2_voltage_load": {
"name": "Load L2 voltage"
},
"l3_current": {
"name": "L3 current"
},
"l3_current_grid": {
"name": "Grid L3 current"
},
"l3_current_load": {
"name": "Load L3 current"
},
"l3_l1_voltage": {
"name": "L3-L1 voltage"
},
"l3_power_grid": {
"name": "Grid L3 power"
},
"l3_power_load": {
"name": "Load L3 power"
},
"l3_voltage": {
"name": "L3 voltage"
},
"l3_voltage_grid": {
"name": "Grid L3 voltage"
},
"l3_voltage_load": {
"name": "Load L3 voltage"
},
"power": {
"name": "Power"
},
"power_grid": {
"name": "Grid power"
},
"power_load": {
"name": "Load power"
},
"rssi_grid": {
"name": "Grid RSSI"
},
"rssi_load": {
"name": "Load RSSI"
},
"schedule_id": {
"name": "Schedule ID"
},
"schedule_max_discharge": {
"name": "Schedule max discharge"
},
"schedule_max_power": {
"name": "Schedule max power"
},
"schedule_power_setpoint": {
"name": "Schedule power setpoint"
},
"schedule_type": {
"name": "Schedule type"
},
"state_of_charge": {
"name": "State of charge"
},
"system_temperature": {
"name": "System temperature"
},
"tmax": {
"name": "Maximum temperature"
},
"tmin": {
"name": "Minimum temperature"
}
},
"switch": {
"local_mode": {
"name": "Local mode"
}
}
}
}

View File

@@ -181,7 +181,7 @@ class GenericHueSensor(GenericHueDevice, entity.Entity): # pylint: disable=hass
)
@property
def state_class(self) -> SensorStateClass:
def state_class(self):
"""Return the state class of this entity, from STATE_CLASSES, if any."""
return SensorStateClass.MEASUREMENT

View File

@@ -1,4 +1,18 @@
.condition_common: &condition_common
is_off:
target:
entity:
domain: light
fields:
behavior:
required: true
default: any
selector:
select:
translation_key: condition_behavior
options:
- all
- any
is_on:
target:
entity:
domain: light
@@ -12,6 +26,3 @@
options:
- all
- any
is_off: *condition_common
is_on: *condition_common

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
import logging
from mficlient.client import FailedToLogin, MFiClient, Port as MFiPort
from mficlient.client import FailedToLogin, MFiClient
import requests
import voluptuous as vol
@@ -12,7 +12,6 @@ from homeassistant.components.sensor import (
PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA,
SensorDeviceClass,
SensorEntity,
StateType,
)
from homeassistant.const import (
CONF_HOST,
@@ -65,29 +64,24 @@ def setup_platform(
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up mFi sensors."""
host: str = config[CONF_HOST]
username: str = config[CONF_USERNAME]
password: str = config[CONF_PASSWORD]
use_tls: bool = config[CONF_SSL]
verify_tls: bool = config[CONF_VERIFY_SSL]
host = config.get(CONF_HOST)
username = config.get(CONF_USERNAME)
password = config.get(CONF_PASSWORD)
use_tls = config.get(CONF_SSL)
verify_tls = config.get(CONF_VERIFY_SSL)
default_port = 6443 if use_tls else 6080
network_port: int = config.get(CONF_PORT, default_port)
port = int(config.get(CONF_PORT, default_port))
try:
client = MFiClient(
host,
username,
password,
port=network_port,
use_tls=use_tls,
verify=verify_tls,
host, username, password, port=port, use_tls=use_tls, verify=verify_tls
)
except (FailedToLogin, requests.exceptions.ConnectionError) as ex:
_LOGGER.error("Unable to connect to mFi: %s", str(ex))
return
add_entities(
MfiSensor(port)
MfiSensor(port, hass)
for device in client.get_devices()
for port in device.ports.values()
if port.model in SENSOR_MODELS
@@ -97,17 +91,18 @@ def setup_platform(
class MfiSensor(SensorEntity):
"""Representation of a mFi sensor."""
def __init__(self, port: MFiPort) -> None:
def __init__(self, port, hass):
"""Initialize the sensor."""
self._port = port
self._hass = hass
@property
def name(self) -> str:
def name(self):
"""Return the name of the sensor."""
return self._port.label
@property
def native_value(self) -> StateType:
def native_value(self):
"""Return the state of the sensor."""
try:
tag = self._port.tag
@@ -134,7 +129,7 @@ class MfiSensor(SensorEntity):
return None
@property
def native_unit_of_measurement(self) -> str | None:
def native_unit_of_measurement(self):
"""Return the unit of measurement of this entity, if any."""
try:
tag = self._port.tag

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
import logging
from typing import Any
from mficlient.client import FailedToLogin, MFiClient, Port as MFiPort
from mficlient.client import FailedToLogin, MFiClient
import requests
import voluptuous as vol
@@ -51,23 +51,18 @@ def setup_platform(
add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up mFi switches."""
host: str = config[CONF_HOST]
username: str = config[CONF_USERNAME]
password: str = config[CONF_PASSWORD]
use_tls: bool = config[CONF_SSL]
verify_tls: bool = config[CONF_VERIFY_SSL]
"""Set up mFi sensors."""
host = config.get(CONF_HOST)
username = config.get(CONF_USERNAME)
password = config.get(CONF_PASSWORD)
use_tls = config[CONF_SSL]
verify_tls = config.get(CONF_VERIFY_SSL)
default_port = 6443 if use_tls else 6080
network_port: int = config.get(CONF_PORT, default_port)
port = int(config.get(CONF_PORT, default_port))
try:
client = MFiClient(
host,
username,
password,
port=network_port,
use_tls=use_tls,
verify=verify_tls,
host, username, password, port=port, use_tls=use_tls, verify=verify_tls
)
except (FailedToLogin, requests.exceptions.ConnectionError) as ex:
_LOGGER.error("Unable to connect to mFi: %s", str(ex))
@@ -84,23 +79,23 @@ def setup_platform(
class MfiSwitch(SwitchEntity):
"""Representation of an mFi switch-able device."""
def __init__(self, port: MFiPort) -> None:
def __init__(self, port):
"""Initialize the mFi device."""
self._port = port
self._target_state: bool | None = None
self._target_state = None
@property
def unique_id(self) -> str:
def unique_id(self):
"""Return the unique ID of the device."""
return self._port.ident
@property
def name(self) -> str:
def name(self):
"""Return the name of the device."""
return self._port.label
@property
def is_on(self) -> bool | None:
def is_on(self):
"""Return true if the device is on."""
return self._port.output

View File

@@ -141,7 +141,7 @@ class NinaConfigFlow(ConfigFlow, domain=DOMAIN):
try:
self._all_region_codes_sorted = swap_key_value(
await nina.get_all_regional_codes()
await nina.getAllRegionalCodes()
)
except ApiError:
return self.async_abort(reason="no_fetch")
@@ -221,7 +221,7 @@ class OptionsFlowHandler(OptionsFlowWithReload):
try:
self._all_region_codes_sorted = swap_key_value(
await nina.get_all_regional_codes()
await nina.getAllRegionalCodes()
)
except ApiError:
return self.async_abort(reason="no_fetch")

View File

@@ -66,7 +66,7 @@ class NINADataUpdateCoordinator(
regions: dict[str, str] = config_entry.data[CONF_REGIONS]
for region in regions:
self._nina.add_region(region)
self._nina.addRegion(region)
super().__init__(
hass,
@@ -151,7 +151,7 @@ class NINADataUpdateCoordinator(
raw_warn.sent or "",
raw_warn.start or "",
raw_warn.expires or "",
raw_warn.is_valid,
raw_warn.isValid(),
)
warnings_for_regions.append(warning_data)

View File

@@ -8,6 +8,6 @@
"iot_class": "cloud_polling",
"loggers": ["pynina"],
"quality_scale": "bronze",
"requirements": ["pynina==1.0.2"],
"requirements": ["pynina==0.3.6"],
"single_config_entry": true
}

View File

@@ -10,5 +10,5 @@
"iot_class": "cloud_polling",
"loggers": ["onedrive_personal_sdk"],
"quality_scale": "platinum",
"requirements": ["onedrive-personal-sdk==0.1.0"]
"requirements": ["onedrive-personal-sdk==0.0.17"]
}

View File

@@ -112,49 +112,45 @@ class OpenAIConfigFlow(ConfigFlow, domain=DOMAIN):
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=STEP_USER_DATA_SCHEMA
)
errors: dict[str, str] = {}
if user_input is not None:
self._async_abort_entries_match(user_input)
try:
await validate_input(self.hass, user_input)
except openai.APIConnectionError:
errors["base"] = "cannot_connect"
except openai.AuthenticationError:
errors["base"] = "invalid_auth"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return self.async_create_entry(
title="ChatGPT",
data=user_input,
subentries=[
{
"subentry_type": "conversation",
"data": RECOMMENDED_CONVERSATION_OPTIONS,
"title": DEFAULT_CONVERSATION_NAME,
"unique_id": None,
},
{
"subentry_type": "ai_task_data",
"data": RECOMMENDED_AI_TASK_OPTIONS,
"title": DEFAULT_AI_TASK_NAME,
"unique_id": None,
},
],
)
self._async_abort_entries_match(user_input)
try:
await validate_input(self.hass, user_input)
except openai.APIConnectionError:
errors["base"] = "cannot_connect"
except openai.AuthenticationError:
errors["base"] = "invalid_auth"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return self.async_create_entry(
title="ChatGPT",
data=user_input,
subentries=[
{
"subentry_type": "conversation",
"data": RECOMMENDED_CONVERSATION_OPTIONS,
"title": DEFAULT_CONVERSATION_NAME,
"unique_id": None,
},
{
"subentry_type": "ai_task_data",
"data": RECOMMENDED_AI_TASK_OPTIONS,
"title": DEFAULT_AI_TASK_NAME,
"unique_id": None,
},
],
)
return self.async_show_form(
step_id="user",
data_schema=self.add_suggested_values_to_schema(
STEP_USER_DATA_SCHEMA, user_input
),
errors=errors,
description_placeholders={
"instructions_url": "https://www.home-assistant.io/integrations/openai_conversation/#generate-an-api-key",
},
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)
@classmethod

View File

@@ -12,11 +12,7 @@
"user": {
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]"
},
"data_description": {
"api_key": "Your OpenAI API key."
},
"description": "Set up OpenAI Conversation integration by providing your OpenAI API key. Instructions to obtain an API key can be found [here]({instructions_url})."
}
}
}
},

View File

@@ -9,5 +9,5 @@
"iot_class": "cloud_polling",
"loggers": ["opower"],
"quality_scale": "bronze",
"requirements": ["opower==0.16.3"]
"requirements": ["opower==0.16.2"]
}

View File

@@ -11,5 +11,5 @@
"documentation": "https://www.home-assistant.io/integrations/sunricher_dali",
"iot_class": "local_push",
"quality_scale": "silver",
"requirements": ["PySrDaliGateway==0.19.3"]
"requirements": ["PySrDaliGateway==0.18.0"]
}

View File

@@ -2,10 +2,10 @@
import asyncio
from collections.abc import Callable
from functools import partial
from typing import Final
from aiohttp import ClientError, ClientResponseError
from aiohttp import ClientResponseError
from aiohttp.client_exceptions import ClientError
from tesla_fleet_api.const import Scope
from tesla_fleet_api.exceptions import (
Forbidden,
@@ -27,7 +27,6 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.config_entry_oauth2_flow import (
ImplementationUnavailableError,
OAuth2Session,
async_get_config_entry_implementation,
)
@@ -76,48 +75,28 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
return True
async def _get_access_token(oauth_session: OAuth2Session) -> str:
"""Get a valid access token, refreshing if necessary."""
LOGGER.debug(
"Token valid: %s, expires_at: %s",
oauth_session.valid_token,
oauth_session.token.get("expires_at"),
)
try:
await oauth_session.async_ensure_token_valid()
except ClientResponseError as err:
if err.status == 401:
raise ConfigEntryAuthFailed from err
raise ConfigEntryNotReady from err
except (KeyError, TypeError) as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="token_data_malformed",
) from err
except ClientError as err:
raise ConfigEntryNotReady from err
return oauth_session.token[CONF_ACCESS_TOKEN]
async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) -> bool:
"""Set up Teslemetry config."""
session = async_get_clientsession(hass)
try:
implementation = await async_get_config_entry_implementation(hass, entry)
except ImplementationUnavailableError as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="oauth_implementation_not_available",
) from err
implementation = await async_get_config_entry_implementation(hass, entry)
oauth_session = OAuth2Session(hass, entry, implementation)
async def _get_access_token() -> str:
try:
await oauth_session.async_ensure_token_valid()
except ClientResponseError as e:
if e.status == 401:
raise ConfigEntryAuthFailed from e
raise ConfigEntryNotReady from e
token: str = oauth_session.token[CONF_ACCESS_TOKEN]
return token
# Create API connection
access_token = partial(_get_access_token, oauth_session)
teslemetry = Teslemetry(
session=session,
access_token=access_token,
access_token=_get_access_token,
)
try:
calls = await asyncio.gather(
@@ -175,7 +154,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) -
if not stream:
stream = TeslemetryStream(
session,
access_token,
_get_access_token,
server=f"{region.lower()}.teslemetry.com",
parse_timestamp=True,
manual=True,

View File

@@ -997,6 +997,7 @@
"total_grid_energy_exported": {
"name": "Grid exported"
},
"total_home_usage": {
"name": "Home usage"
},
@@ -1126,9 +1127,6 @@
"no_vehicle_data_for_device": {
"message": "No vehicle data for device ID: {device_id}"
},
"oauth_implementation_not_available": {
"message": "OAuth implementation not available, try reauthenticating"
},
"set_scheduled_charging_time": {
"message": "Scheduled charging time is required when enabling"
},
@@ -1138,9 +1136,6 @@
"set_scheduled_departure_preconditioning": {
"message": "Preconditioning departure time is required when enabling"
},
"token_data_malformed": {
"message": "Token data malformed, try reauthenticating"
},
"wake_up_failed": {
"message": "Failed to wake up vehicle: {message}"
},

View File

@@ -2,7 +2,6 @@
from __future__ import annotations
import collections
from dataclasses import dataclass
from typing import Any, Self
@@ -141,22 +140,6 @@ class _SwingModeWrapper(DeviceWrapper):
return commands
def _filter_hvac_mode_mappings(tuya_range: list[str]) -> dict[str, HVACMode | None]:
"""Filter TUYA_HVAC_TO_HA modes that are not in the range.
If multiple Tuya modes map to the same HA mode, set the mapping to None to avoid
ambiguity when converting back from HA to Tuya modes.
"""
modes_in_range = {
tuya_mode: TUYA_HVAC_TO_HA.get(tuya_mode) for tuya_mode in tuya_range
}
modes_occurrences = collections.Counter(modes_in_range.values())
for key, value in modes_in_range.items():
if value is not None and modes_occurrences[value] > 1:
modes_in_range[key] = None
return modes_in_range
class _HvacModeWrapper(DPCodeEnumWrapper):
"""Wrapper for managing climate HVACMode."""
@@ -165,9 +148,10 @@ class _HvacModeWrapper(DPCodeEnumWrapper):
def __init__(self, dpcode: str, type_information: EnumTypeInformation) -> None:
"""Init _HvacModeWrapper."""
super().__init__(dpcode, type_information)
self._mappings = _filter_hvac_mode_mappings(type_information.range)
self.options = [
ha_mode for ha_mode in self._mappings.values() if ha_mode is not None
TUYA_HVAC_TO_HA[tuya_mode]
for tuya_mode in type_information.range
if tuya_mode in TUYA_HVAC_TO_HA
]
def read_device_status(self, device: CustomerDevice) -> HVACMode | None:
@@ -182,7 +166,7 @@ class _HvacModeWrapper(DPCodeEnumWrapper):
"""Convert value to raw value."""
return next(
tuya_mode
for tuya_mode, ha_mode in self._mappings.items()
for tuya_mode, ha_mode in TUYA_HVAC_TO_HA.items()
if ha_mode == value
)
@@ -195,9 +179,10 @@ class _PresetWrapper(DPCodeEnumWrapper):
def __init__(self, dpcode: str, type_information: EnumTypeInformation) -> None:
"""Init _PresetWrapper."""
super().__init__(dpcode, type_information)
mappings = _filter_hvac_mode_mappings(type_information.range)
self.options = [
tuya_mode for tuya_mode, ha_mode in mappings.items() if ha_mode is None
tuya_mode
for tuya_mode in type_information.range
if tuya_mode not in TUYA_HVAC_TO_HA
]
def read_device_status(self, device: CustomerDevice) -> str | None:

View File

@@ -5,11 +5,9 @@ from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
from datetime import timedelta
import logging
from uiprotect.data import (
Camera,
Chime,
Doorlock,
Light,
ModelType,
@@ -32,8 +30,6 @@ from .entity import (
)
from .utils import async_ufp_instance_command
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
@@ -249,51 +245,6 @@ _MODEL_DESCRIPTIONS: dict[ModelType, Sequence[ProtectEntityDescription]] = {
}
def _async_chime_ring_volume_entities(
data: ProtectData,
chime: Chime,
) -> list[ChimeRingVolumeNumber]:
"""Generate ring volume entities for each paired camera on a chime."""
entities: list[ChimeRingVolumeNumber] = []
if not chime.is_adopted_by_us:
return entities
auth_user = data.api.bootstrap.auth_user
if not chime.can_write(auth_user):
return entities
for ring_setting in chime.ring_settings:
camera = data.api.bootstrap.cameras.get(ring_setting.camera_id)
if camera is None:
_LOGGER.debug(
"Camera %s not found for chime %s ring volume",
ring_setting.camera_id,
chime.display_name,
)
continue
entities.append(ChimeRingVolumeNumber(data, chime, camera))
return entities
def _async_all_chime_ring_volume_entities(
data: ProtectData,
chime: Chime | None = None,
) -> list[ChimeRingVolumeNumber]:
"""Generate all ring volume entities for chimes."""
entities: list[ChimeRingVolumeNumber] = []
if chime is not None:
return _async_chime_ring_volume_entities(data, chime)
for device in data.get_by_types({ModelType.CHIME}):
if isinstance(device, Chime):
entities.extend(_async_chime_ring_volume_entities(data, device))
return entities
async def async_setup_entry(
hass: HomeAssistant,
entry: UFPConfigEntry,
@@ -304,26 +255,23 @@ async def async_setup_entry(
@callback
def _add_new_device(device: ProtectAdoptableDeviceModel) -> None:
entities = async_all_device_entities(
async_add_entities(
async_all_device_entities(
data,
ProtectNumbers,
model_descriptions=_MODEL_DESCRIPTIONS,
ufp_device=device,
)
)
data.async_subscribe_adopt(_add_new_device)
async_add_entities(
async_all_device_entities(
data,
ProtectNumbers,
model_descriptions=_MODEL_DESCRIPTIONS,
ufp_device=device,
)
# Add ring volume entities for chimes
if isinstance(device, Chime):
entities += _async_all_chime_ring_volume_entities(data, device)
async_add_entities(entities)
data.async_subscribe_adopt(_add_new_device)
entities = async_all_device_entities(
data,
ProtectNumbers,
model_descriptions=_MODEL_DESCRIPTIONS,
)
# Add ring volume entities for all chimes
entities += _async_all_chime_ring_volume_entities(data)
async_add_entities(entities)
class ProtectNumbers(ProtectDeviceEntity, NumberEntity):
@@ -354,62 +302,3 @@ class ProtectNumbers(ProtectDeviceEntity, NumberEntity):
async def async_set_native_value(self, value: float) -> None:
"""Set new value."""
await self.entity_description.ufp_set(self.device, value)
class ChimeRingVolumeNumber(ProtectDeviceEntity, NumberEntity):
"""A UniFi Protect Number Entity for ring volume per camera on a chime."""
device: Chime
_state_attrs = ("_attr_available", "_attr_native_value")
_attr_native_max_value: float = 100
_attr_native_min_value: float = 0
_attr_native_step: float = 1
_attr_native_unit_of_measurement = PERCENTAGE
_attr_entity_category = EntityCategory.CONFIG
def __init__(
self,
data: ProtectData,
chime: Chime,
camera: Camera,
) -> None:
"""Initialize the ring volume number entity."""
self._camera_id = camera.id
# Use chime MAC and camera ID for unique ID
super().__init__(data, chime)
self._attr_unique_id = f"{chime.mac}_ring_volume_{camera.id}"
self._attr_translation_key = "chime_ring_volume"
self._attr_translation_placeholders = {"camera_name": camera.display_name}
# BaseProtectEntity sets _attr_name = None when no description is passed,
# which prevents translation_key from being used. Delete to enable translations.
del self._attr_name
@callback
def _async_update_device_from_protect(self, device: ProtectDeviceType) -> None:
"""Update entity from protect device."""
super()._async_update_device_from_protect(device)
self._attr_native_value = self._get_ring_volume()
def _get_ring_volume(self) -> int | None:
"""Get the ring volume for this camera from the chime's ring settings."""
for ring_setting in self.device.ring_settings:
if ring_setting.camera_id == self._camera_id:
return ring_setting.volume
return None
@property
def available(self) -> bool:
"""Return if entity is available."""
# Entity is unavailable if the camera is no longer paired with the chime
return super().available and self._get_ring_volume() is not None
@async_ufp_instance_command
async def async_set_native_value(self, value: float) -> None:
"""Set new ring volume value."""
camera = self.data.api.bootstrap.cameras.get(self._camera_id)
if camera is None:
_LOGGER.warning(
"Cannot set ring volume: camera %s not found", self._camera_id
)
return
await self.device.set_volume_for_camera_public(camera, int(value))

View File

@@ -323,9 +323,6 @@
"chime_duration": {
"name": "Chime duration"
},
"chime_ring_volume": {
"name": "Ring volume ({camera_name})"
},
"doorbell_ring_volume": {
"name": "Doorbell ring volume"
},

View File

@@ -9,8 +9,8 @@ import voluptuous as vol
from homeassistant.components.select import DOMAIN as SELECT_DOMAIN
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_NAME, CONF_UNIQUE_ID, Platform
from homeassistant.core import HomeAssistant
from homeassistant.const import ATTR_ENTITY_ID, CONF_NAME, CONF_UNIQUE_ID, Platform
from homeassistant.core import HomeAssistant, split_entity_id
from homeassistant.helpers import (
config_validation as cv,
discovery,
@@ -20,6 +20,7 @@ from homeassistant.helpers.device import (
async_entity_id_to_device_id,
async_remove_stale_devices_links_keep_entity_device,
)
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.helper_integration import (
async_handle_source_entity_changes,
async_remove_helper_config_entry_from_source_device,
@@ -43,8 +44,9 @@ from .const import (
DATA_UTILITY,
DOMAIN,
METER_TYPES,
SERVICE_RESET,
SIGNAL_RESET_METER,
)
from .services import async_setup_services
_LOGGER = logging.getLogger(__name__)
@@ -118,7 +120,27 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up an Utility Meter."""
hass.data[DATA_UTILITY] = {}
async_setup_services(hass)
async def async_reset_meters(service_call):
"""Reset all sensors of a meter."""
meters = service_call.data["entity_id"]
for meter in meters:
_LOGGER.debug("resetting meter %s", meter)
domain, entity = split_entity_id(meter)
# backward compatibility up to 2022.07:
if domain == DOMAIN:
async_dispatcher_send(
hass, SIGNAL_RESET_METER, f"{SELECT_DOMAIN}.{entity}"
)
else:
async_dispatcher_send(hass, SIGNAL_RESET_METER, meter)
hass.services.async_register(
DOMAIN,
SERVICE_RESET,
async_reset_meters,
vol.Schema({ATTR_ENTITY_ID: vol.All(cv.ensure_list, [cv.entity_id])}),
)
if DOMAIN not in config:
return True

View File

@@ -700,7 +700,7 @@ class UtilityMeterSensor(RestoreSensor):
return None
@property
def state_class(self) -> SensorStateClass:
def state_class(self):
"""Return the device class of the sensor."""
return (
SensorStateClass.TOTAL

View File

@@ -1,43 +0,0 @@
"""Support for tracking consumption over given periods of time."""
import logging
import voluptuous as vol
from homeassistant.components.select import DOMAIN as SELECT_DOMAIN
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant, ServiceCall, callback, split_entity_id
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.dispatcher import async_dispatcher_send
from .const import DOMAIN, SERVICE_RESET, SIGNAL_RESET_METER
_LOGGER = logging.getLogger(__name__)
async def async_reset_meters(service_call: ServiceCall) -> None:
"""Reset all sensors of a meter."""
meters = service_call.data["entity_id"]
for meter in meters:
_LOGGER.debug("resetting meter %s", meter)
domain, entity = split_entity_id(meter)
# backward compatibility up to 2022.07:
if domain == DOMAIN:
async_dispatcher_send(
service_call.hass, SIGNAL_RESET_METER, f"{SELECT_DOMAIN}.{entity}"
)
else:
async_dispatcher_send(service_call.hass, SIGNAL_RESET_METER, meter)
@callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Set up the services."""
hass.services.async_register(
DOMAIN,
SERVICE_RESET,
async_reset_meters,
vol.Schema({ATTR_ENTITY_ID: vol.All(cv.ensure_list, [cv.entity_id])}),
)

View File

@@ -294,6 +294,7 @@ FLOWS = {
"homekit",
"homekit_controller",
"homematicip_cloud",
"homevolt",
"homewizard",
"homeworks",
"honeywell",

View File

@@ -2836,6 +2836,12 @@
"zwave"
]
},
"homevolt": {
"name": "Homevolt",
"integration_type": "device",
"config_flow": true,
"iot_class": "local_polling"
},
"homewizard": {
"name": "HomeWizard",
"integration_type": "device",

View File

@@ -581,6 +581,10 @@ ZEROCONF = {
"domain": "eheimdigital",
"name": "eheimdigital._http._tcp.local.",
},
{
"domain": "homevolt",
"name": "homevolt*",
},
{
"domain": "lektrico",
"name": "lektrico*",

View File

@@ -679,6 +679,7 @@ class OAuth2Session:
@property
def valid_token(self) -> bool:
"""Return if token is still valid."""
_LOGGER.error("Token: %s", self.token["expires_at"])
return (
cast(float, self.token["expires_at"])
> time.time() + CLOCK_OUT_OF_SYNC_MAX_SEC

View File

@@ -700,7 +700,6 @@ _ENTITY_MATCH: list[TypeHintMatch] = [
TypeHintMatch(
function_name="device_class",
return_type=["str", None],
mandatory=True,
),
TypeHintMatch(
function_name="unit_of_measurement",
@@ -2519,12 +2518,10 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = {
TypeHintMatch(
function_name="state_class",
return_type=["SensorStateClass", "str", None],
mandatory=True,
),
TypeHintMatch(
function_name="last_reset",
return_type=["datetime", None],
mandatory=True,
),
TypeHintMatch(
function_name="native_value",

12
requirements_all.txt generated
View File

@@ -80,7 +80,7 @@ PyQRCode==1.2.1
PyRMVtransport==0.3.3
# homeassistant.components.sunricher_dali
PySrDaliGateway==0.19.3
PySrDaliGateway==0.18.0
# homeassistant.components.switchbot
PySwitchbot==0.76.0
@@ -1226,6 +1226,9 @@ homelink-integration-api==0.0.1
# homeassistant.components.homematicip_cloud
homematicip==2.4.0
# homeassistant.components.homevolt
homevolt==0.2.4
# homeassistant.components.horizon
horimote==0.4.1
@@ -1646,7 +1649,7 @@ omnilogic==0.4.5
ondilo==0.5.0
# homeassistant.components.onedrive
onedrive-personal-sdk==0.1.0
onedrive-personal-sdk==0.0.17
# homeassistant.components.onvif
onvif-zeep-async==4.0.4
@@ -1683,7 +1686,7 @@ openwrt-luci-rpc==1.1.17
openwrt-ubus-rpc==0.0.2
# homeassistant.components.opower
opower==0.16.3
opower==0.16.2
# homeassistant.components.oralb
oralb-ble==1.0.2
@@ -1776,7 +1779,6 @@ prowlpy==1.1.1
# homeassistant.components.proxmoxve
proxmoxer==2.0.1
# homeassistant.components.cloud
# homeassistant.components.hardware
# homeassistant.components.recorder
# homeassistant.components.systemmonitor
@@ -2235,7 +2237,7 @@ pynetgear==0.10.10
pynetio==0.1.9.1
# homeassistant.components.nina
pynina==1.0.2
pynina==0.3.6
# homeassistant.components.nintendo_parental_controls
pynintendoauth==1.0.2

View File

@@ -80,7 +80,7 @@ PyQRCode==1.2.1
PyRMVtransport==0.3.3
# homeassistant.components.sunricher_dali
PySrDaliGateway==0.19.3
PySrDaliGateway==0.18.0
# homeassistant.components.switchbot
PySwitchbot==0.76.0
@@ -1084,6 +1084,9 @@ homelink-integration-api==0.0.1
# homeassistant.components.homematicip_cloud
homematicip==2.4.0
# homeassistant.components.homevolt
homevolt==0.2.4
# homeassistant.components.remember_the_milk
httplib2==0.20.4
@@ -1429,7 +1432,7 @@ omnilogic==0.4.5
ondilo==0.5.0
# homeassistant.components.onedrive
onedrive-personal-sdk==0.1.0
onedrive-personal-sdk==0.0.17
# homeassistant.components.onvif
onvif-zeep-async==4.0.4
@@ -1457,7 +1460,7 @@ openrgb-python==0.3.6
openwebifpy==4.3.1
# homeassistant.components.opower
opower==0.16.3
opower==0.16.2
# homeassistant.components.oralb
oralb-ble==1.0.2
@@ -1522,7 +1525,6 @@ prometheus-client==0.21.0
# homeassistant.components.prowl
prowlpy==1.1.1
# homeassistant.components.cloud
# homeassistant.components.hardware
# homeassistant.components.recorder
# homeassistant.components.systemmonitor
@@ -1888,7 +1890,7 @@ pynecil==4.2.1
pynetgear==0.10.10
# homeassistant.components.nina
pynina==1.0.2
pynina==0.3.6
# homeassistant.components.nintendo_parental_controls
pynintendoauth==1.0.2

View File

@@ -254,21 +254,13 @@ def parametrize_condition_states(
state_with_attributes(other_state, False, True)
for other_state in other_states
),
),
(
state_with_attributes(target_state, True, True)
for target_state in target_states
),
)
),
),
# Test each target state individually to isolate condition_true expectations
*(
(
condition,
condition_options,
[
state_with_attributes(other_states[0], False, True),
state_with_attributes(target_state, True, True),
],
)
for target_state in target_states
),
]

View File

@@ -1,275 +0,0 @@
"""Test alarm_control_panel conditions."""
from typing import Any
import pytest
from homeassistant.components.alarm_control_panel import (
AlarmControlPanelEntityFeature,
AlarmControlPanelState,
)
from homeassistant.const import ATTR_SUPPORTED_FEATURES
from homeassistant.core import HomeAssistant
from tests.components import (
ConditionStateDescription,
assert_condition_gated_by_labs_flag,
create_target_condition,
other_states,
parametrize_condition_states,
parametrize_target_entities,
set_or_remove_state,
target_entities,
)
@pytest.fixture(autouse=True, name="stub_blueprint_populate")
def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None:
"""Stub copying the blueprints to the config folder."""
@pytest.fixture
async def target_alarm_control_panels(hass: HomeAssistant) -> list[str]:
"""Create multiple alarm_control_panel entities associated with different targets."""
return (await target_entities(hass, "alarm_control_panel"))["included"]
@pytest.mark.parametrize(
"condition",
[
"alarm_control_panel.is_armed",
"alarm_control_panel.is_armed_away",
"alarm_control_panel.is_armed_home",
"alarm_control_panel.is_armed_night",
"alarm_control_panel.is_armed_vacation",
"alarm_control_panel.is_disarmed",
"alarm_control_panel.is_triggered",
],
)
async def test_alarm_control_panel_conditions_gated_by_labs_flag(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture, condition: str
) -> None:
"""Test the alarm_control_panel conditions are gated by the labs flag."""
await assert_condition_gated_by_labs_flag(hass, caplog, condition)
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("condition_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("alarm_control_panel"),
)
@pytest.mark.parametrize(
("condition", "condition_options", "states"),
[
*parametrize_condition_states(
condition="alarm_control_panel.is_armed",
target_states=[
AlarmControlPanelState.ARMED_AWAY,
AlarmControlPanelState.ARMED_CUSTOM_BYPASS,
AlarmControlPanelState.ARMED_HOME,
AlarmControlPanelState.ARMED_NIGHT,
AlarmControlPanelState.ARMED_VACATION,
],
other_states=[
AlarmControlPanelState.ARMING,
AlarmControlPanelState.DISARMED,
AlarmControlPanelState.DISARMING,
AlarmControlPanelState.PENDING,
AlarmControlPanelState.TRIGGERED,
],
),
*parametrize_condition_states(
condition="alarm_control_panel.is_armed_away",
target_states=[AlarmControlPanelState.ARMED_AWAY],
other_states=other_states(AlarmControlPanelState.ARMED_AWAY),
additional_attributes={
ATTR_SUPPORTED_FEATURES: AlarmControlPanelEntityFeature.ARM_AWAY
},
),
*parametrize_condition_states(
condition="alarm_control_panel.is_armed_home",
target_states=[AlarmControlPanelState.ARMED_HOME],
other_states=other_states(AlarmControlPanelState.ARMED_HOME),
additional_attributes={
ATTR_SUPPORTED_FEATURES: AlarmControlPanelEntityFeature.ARM_HOME
},
),
*parametrize_condition_states(
condition="alarm_control_panel.is_armed_night",
target_states=[AlarmControlPanelState.ARMED_NIGHT],
other_states=other_states(AlarmControlPanelState.ARMED_NIGHT),
additional_attributes={
ATTR_SUPPORTED_FEATURES: AlarmControlPanelEntityFeature.ARM_NIGHT
},
),
*parametrize_condition_states(
condition="alarm_control_panel.is_armed_vacation",
target_states=[AlarmControlPanelState.ARMED_VACATION],
other_states=other_states(AlarmControlPanelState.ARMED_VACATION),
additional_attributes={
ATTR_SUPPORTED_FEATURES: AlarmControlPanelEntityFeature.ARM_VACATION
},
),
*parametrize_condition_states(
condition="alarm_control_panel.is_disarmed",
target_states=[AlarmControlPanelState.DISARMED],
other_states=other_states(AlarmControlPanelState.DISARMED),
),
*parametrize_condition_states(
condition="alarm_control_panel.is_triggered",
target_states=[AlarmControlPanelState.TRIGGERED],
other_states=other_states(AlarmControlPanelState.TRIGGERED),
),
],
)
async def test_alarm_control_panel_state_condition_behavior_any(
hass: HomeAssistant,
target_alarm_control_panels: list[str],
condition_target_config: dict,
entity_id: str,
entities_in_target: int,
condition: str,
condition_options: dict[str, Any],
states: list[ConditionStateDescription],
) -> None:
"""Test the alarm_control_panel state condition with the 'any' behavior."""
other_entity_ids = set(target_alarm_control_panels) - {entity_id}
# Set all alarm_control_panels, including the tested alarm_control_panel, to the initial state
for eid in target_alarm_control_panels:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
condition = await create_target_condition(
hass,
condition=condition,
target=condition_target_config,
behavior="any",
)
for state in states:
included_state = state["included"]
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true"]
# Check if changing other alarm_control_panels also passes the condition
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true"]
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("condition_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("alarm_control_panel"),
)
@pytest.mark.parametrize(
("condition", "condition_options", "states"),
[
*parametrize_condition_states(
condition="alarm_control_panel.is_armed",
target_states=[
AlarmControlPanelState.ARMED_AWAY,
AlarmControlPanelState.ARMED_CUSTOM_BYPASS,
AlarmControlPanelState.ARMED_HOME,
AlarmControlPanelState.ARMED_NIGHT,
AlarmControlPanelState.ARMED_VACATION,
],
other_states=[
AlarmControlPanelState.ARMING,
AlarmControlPanelState.DISARMED,
AlarmControlPanelState.DISARMING,
AlarmControlPanelState.PENDING,
AlarmControlPanelState.TRIGGERED,
],
),
*parametrize_condition_states(
condition="alarm_control_panel.is_armed_away",
target_states=[AlarmControlPanelState.ARMED_AWAY],
other_states=other_states(AlarmControlPanelState.ARMED_AWAY),
additional_attributes={
ATTR_SUPPORTED_FEATURES: AlarmControlPanelEntityFeature.ARM_AWAY
},
),
*parametrize_condition_states(
condition="alarm_control_panel.is_armed_home",
target_states=[AlarmControlPanelState.ARMED_HOME],
other_states=other_states(AlarmControlPanelState.ARMED_HOME),
additional_attributes={
ATTR_SUPPORTED_FEATURES: AlarmControlPanelEntityFeature.ARM_HOME
},
),
*parametrize_condition_states(
condition="alarm_control_panel.is_armed_night",
target_states=[AlarmControlPanelState.ARMED_NIGHT],
other_states=other_states(AlarmControlPanelState.ARMED_NIGHT),
additional_attributes={
ATTR_SUPPORTED_FEATURES: AlarmControlPanelEntityFeature.ARM_NIGHT
},
),
*parametrize_condition_states(
condition="alarm_control_panel.is_armed_vacation",
target_states=[AlarmControlPanelState.ARMED_VACATION],
other_states=other_states(AlarmControlPanelState.ARMED_VACATION),
additional_attributes={
ATTR_SUPPORTED_FEATURES: AlarmControlPanelEntityFeature.ARM_VACATION
},
),
*parametrize_condition_states(
condition="alarm_control_panel.is_disarmed",
target_states=[AlarmControlPanelState.DISARMED],
other_states=other_states(AlarmControlPanelState.DISARMED),
),
*parametrize_condition_states(
condition="alarm_control_panel.is_triggered",
target_states=[AlarmControlPanelState.TRIGGERED],
other_states=other_states(AlarmControlPanelState.TRIGGERED),
),
],
)
async def test_alarm_control_panel_state_condition_behavior_all(
hass: HomeAssistant,
target_alarm_control_panels: list[str],
condition_target_config: dict,
entity_id: str,
entities_in_target: int,
condition: str,
condition_options: dict[str, Any],
states: list[ConditionStateDescription],
) -> None:
"""Test the alarm_control_panel state condition with the 'all' behavior."""
other_entity_ids = set(target_alarm_control_panels) - {entity_id}
# Set all alarm_control_panels, including the tested alarm_control_panel, to the initial state
for eid in target_alarm_control_panels:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
condition = await create_target_condition(
hass,
condition=condition,
target=condition_target_config,
behavior="all",
)
for state in states:
included_state = state["included"]
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
# The condition passes if all entities are either in a target state or invalid
assert condition(hass) == (
(not state["state_valid"])
or (state["condition_true"] and entities_in_target == 1)
)
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
# The condition passes if all entities are either in a target state or invalid
assert condition(hass) == (
(not state["state_valid"]) or state["condition_true"]
)

View File

@@ -1,190 +0,0 @@
"""Test assist satellite conditions."""
from typing import Any
import pytest
from homeassistant.components.assist_satellite.entity import AssistSatelliteState
from homeassistant.core import HomeAssistant
from tests.components import (
ConditionStateDescription,
assert_condition_gated_by_labs_flag,
create_target_condition,
other_states,
parametrize_condition_states,
parametrize_target_entities,
set_or_remove_state,
target_entities,
)
@pytest.fixture(autouse=True, name="stub_blueprint_populate")
def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None:
"""Stub copying the blueprints to the config folder."""
@pytest.fixture
async def target_assist_satellites(hass: HomeAssistant) -> list[str]:
"""Create multiple assist satellite entities associated with different targets."""
return (await target_entities(hass, "assist_satellite"))["included"]
@pytest.mark.parametrize(
"condition",
[
"assist_satellite.is_idle",
"assist_satellite.is_listening",
"assist_satellite.is_processing",
"assist_satellite.is_responding",
],
)
async def test_assist_satellite_conditions_gated_by_labs_flag(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture, condition: str
) -> None:
"""Test the assist satellite conditions are gated by the labs flag."""
await assert_condition_gated_by_labs_flag(hass, caplog, condition)
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("condition_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("assist_satellite"),
)
@pytest.mark.parametrize(
("condition", "condition_options", "states"),
[
*parametrize_condition_states(
condition="assist_satellite.is_idle",
target_states=[AssistSatelliteState.IDLE],
other_states=other_states(AssistSatelliteState.IDLE),
),
*parametrize_condition_states(
condition="assist_satellite.is_listening",
target_states=[AssistSatelliteState.LISTENING],
other_states=other_states(AssistSatelliteState.LISTENING),
),
*parametrize_condition_states(
condition="assist_satellite.is_processing",
target_states=[AssistSatelliteState.PROCESSING],
other_states=other_states(AssistSatelliteState.PROCESSING),
),
*parametrize_condition_states(
condition="assist_satellite.is_responding",
target_states=[AssistSatelliteState.RESPONDING],
other_states=other_states(AssistSatelliteState.RESPONDING),
),
],
)
async def test_assist_satellite_state_condition_behavior_any(
hass: HomeAssistant,
target_assist_satellites: list[str],
condition_target_config: dict,
entity_id: str,
entities_in_target: int,
condition: str,
condition_options: dict[str, Any],
states: list[ConditionStateDescription],
) -> None:
"""Test the assist satellite state condition with the 'any' behavior."""
other_entity_ids = set(target_assist_satellites) - {entity_id}
# Set all assist satellites, including the tested one, to the initial state
for eid in target_assist_satellites:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
condition = await create_target_condition(
hass,
condition=condition,
target=condition_target_config,
behavior="any",
)
for state in states:
included_state = state["included"]
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true"]
# Check if changing other assist satellites also passes the condition
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true"]
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("condition_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("assist_satellite"),
)
@pytest.mark.parametrize(
("condition", "condition_options", "states"),
[
*parametrize_condition_states(
condition="assist_satellite.is_idle",
target_states=[AssistSatelliteState.IDLE],
other_states=other_states(AssistSatelliteState.IDLE),
),
*parametrize_condition_states(
condition="assist_satellite.is_listening",
target_states=[AssistSatelliteState.LISTENING],
other_states=other_states(AssistSatelliteState.LISTENING),
),
*parametrize_condition_states(
condition="assist_satellite.is_processing",
target_states=[AssistSatelliteState.PROCESSING],
other_states=other_states(AssistSatelliteState.PROCESSING),
),
*parametrize_condition_states(
condition="assist_satellite.is_responding",
target_states=[AssistSatelliteState.RESPONDING],
other_states=other_states(AssistSatelliteState.RESPONDING),
),
],
)
async def test_assist_satellite_state_condition_behavior_all(
hass: HomeAssistant,
target_assist_satellites: list[str],
condition_target_config: dict,
entity_id: str,
entities_in_target: int,
condition: str,
condition_options: dict[str, Any],
states: list[ConditionStateDescription],
) -> None:
"""Test the assist satellite state condition with the 'all' behavior."""
other_entity_ids = set(target_assist_satellites) - {entity_id}
# Set all assist satellites, including the tested one, to the initial state
for eid in target_assist_satellites:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
condition = await create_target_condition(
hass,
condition=condition,
target=condition_target_config,
behavior="all",
)
for state in states:
included_state = state["included"]
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
# The condition passes if all entities are either in a target state or invalid
assert condition(hass) == (
(not state["state_valid"])
or (state["condition_true"] and entities_in_target == 1)
)
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
# The condition passes if all entities are either in a target state or invalid
assert condition(hass) == (
(not state["state_valid"]) or state["condition_true"]
)

View File

@@ -11,7 +11,6 @@ from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from homeassistant.util.yaml import UndefinedSubstitution, parse_yaml
from tests.common import MockUser
from tests.test_util.aiohttp import AiohttpClientMocker
from tests.typing import WebSocketGenerator
@@ -104,51 +103,6 @@ async def test_list_blueprints_non_existing_domain(
assert blueprints == {}
@pytest.mark.parametrize(
"message",
[
{"type": "blueprint/list", "domain": "automation"},
{"type": "blueprint/import", "url": "https://example.com/blueprint.yaml"},
{
"type": "blueprint/save",
"path": "test_save",
"yaml": "raw_data",
"domain": "automation",
},
{
"type": "blueprint/delete",
"path": "test_delete",
"domain": "automation",
},
{
"type": "blueprint/substitute",
"domain": "automation",
"path": "test_event_service.yaml",
"input": {
"trigger_event": "test_event",
"service_to_call": "test.automation",
"a_number": 5,
},
},
],
)
async def test_blueprint_ws_command_requires_admin(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
hass_admin_user: MockUser,
message: dict[str, Any],
) -> None:
"""Test that blueprint websocket commands require admin."""
hass_admin_user.groups = [] # Remove admin privileges
client = await hass_ws_client(hass)
await client.send_json_auto_id(message)
msg = await client.receive_json()
assert not msg["success"]
assert msg["error"]["code"] == "unauthorized"
async def test_import_blueprint(
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,

View File

@@ -87,18 +87,6 @@
</details>
## Host resource usage
Resource | Value
--- | ---
CPU usage | 25.5%
Memory total | 16.0 GB
Memory used | 8.0 GB (50.0%)
Memory available | 8.0 GB
Disk total | 500.0 GB
Disk used | 200.0 GB (40.0%)
Disk free | 300.0 GB
## Full logs
<details><summary>Logs</summary>
@@ -193,18 +181,6 @@
</details>
## Host resource usage
Resource | Value
--- | ---
CPU usage | 25.5%
Memory total | 16.0 GB
Memory used | 8.0 GB (50.0%)
Memory available | 8.0 GB
Disk total | 500.0 GB
Disk used | 200.0 GB (40.0%)
Disk free | 300.0 GB
## Full logs
<details><summary>Logs</summary>
@@ -220,252 +196,6 @@
'''
# ---
# name: test_download_support_package_hassio
'''
## System Information
version | core-2025.2.0
--- | ---
installation_type | Home Assistant OS
dev | False
hassio | True
docker | True
container_arch | aarch64
user | root
virtualenv | False
python_version | 3.13.1
os_name | Linux
os_version | 6.12.9
arch | aarch64
timezone | US/Pacific
config_dir | config
## Active Integrations
Built-in integrations: 23
Custom integrations: 1
<details><summary>Built-in integrations</summary>
Domain | Name
--- | ---
ai_task | AI Task
auth | Auth
binary_sensor | Binary Sensor
cloud | Home Assistant Cloud
cloud.ai_task | Unknown
cloud.binary_sensor | Unknown
cloud.conversation | Unknown
cloud.stt | Unknown
cloud.tts | Unknown
conversation | Conversation
ffmpeg | FFmpeg
hassio | hassio
homeassistant | Home Assistant Core Integration
http | HTTP
intent | Intent
media_source | Media Source
mock_no_info_integration | mock_no_info_integration
repairs | Repairs
stt | Speech-to-text (STT)
system_health | System Health
tts | Text-to-speech (TTS)
web_rtc | WebRTC
webhook | Webhook
</details>
<details><summary>Custom integrations</summary>
Domain | Name | Version | Documentation
--- | --- | --- | ---
test | Test Components | 1.2.3 | http://example.com
</details>
<details><summary>hassio</summary>
host_os | Home Assistant OS 14.0
--- | ---
update_channel | stable
supervisor_version | supervisor-2025.01.0
agent_version | 1.6.0
docker_version | 27.4.1
disk_total | 128.5 GB
disk_used | 45.2 GB
healthy | True
supported | True
host_connectivity | True
supervisor_connectivity | True
board | green
supervisor_api | ok
version_api | ok
installed_addons | Mosquitto broker (6.4.1), Samba share (12.3.2), Visual Studio Code (5.21.2)
</details>
<details><summary>mock_no_info_integration</summary>
No information available
</details>
<details><summary>cloud</summary>
logged_in | True
--- | ---
subscription_expiration | 2025-01-17T11:19:31+00:00
relayer_connected | True
relayer_region | xx-earth-616
remote_enabled | True
remote_connected | False
alexa_enabled | True
google_enabled | False
cloud_ice_servers_enabled | True
remote_server | us-west-1
certificate_status | ready
instance_id | 12345678901234567890
can_reach_cert_server | Exception: Unexpected exception
can_reach_cloud_auth | Failed: unreachable
can_reach_cloud | ok
</details>
## Host resource usage
Resource | Value
--- | ---
CPU usage | 25.5%
Memory total | 16.0 GB
Memory used | 8.0 GB (50.0%)
Memory available | 8.0 GB
Disk total | 500.0 GB
Disk used | 200.0 GB (40.0%)
Disk free | 300.0 GB
## Add-on resource usage
<details><summary>Add-on resources</summary>
Add-on | Version | State | CPU | Memory
--- | --- | --- | --- | ---
Mosquitto broker | 6.4.1 | started | 0.5% | 1.2%
Samba share | 12.3.2 | started | 0.1% | 0.8%
Visual Studio Code | 5.21.2 | stopped | N/A | N/A
</details>
## Full logs
<details><summary>Logs</summary>
```logs
2025-02-10 12:00:00.000 INFO (MainThread) [hass_nabucasa.iot] Hass nabucasa log
2025-02-10 12:00:00.000 WARNING (MainThread) [snitun.utils.aiohttp_client] Snitun log
2025-02-10 12:00:00.000 ERROR (MainThread) [homeassistant.components.cloud.client] Cloud log
```
</details>
'''
# ---
# name: test_download_support_package_host_resources
'''
## System Information
version | core-2025.2.0
--- | ---
installation_type | Home Assistant Container
dev | False
hassio | False
docker | True
container_arch | x86_64
user | root
virtualenv | False
python_version | 3.13.1
os_name | Linux
os_version | 6.12.9
arch | x86_64
timezone | US/Pacific
config_dir | config
## Active Integrations
Built-in integrations: 21
Custom integrations: 0
<details><summary>Built-in integrations</summary>
Domain | Name
--- | ---
ai_task | AI Task
auth | Auth
binary_sensor | Binary Sensor
cloud | Home Assistant Cloud
cloud.ai_task | Unknown
cloud.binary_sensor | Unknown
cloud.conversation | Unknown
cloud.stt | Unknown
cloud.tts | Unknown
conversation | Conversation
ffmpeg | FFmpeg
homeassistant | Home Assistant Core Integration
http | HTTP
intent | Intent
media_source | Media Source
repairs | Repairs
stt | Speech-to-text (STT)
system_health | System Health
tts | Text-to-speech (TTS)
web_rtc | WebRTC
webhook | Webhook
</details>
<details><summary>cloud</summary>
logged_in | True
--- | ---
subscription_expiration | 2025-01-17T11:19:31+00:00
relayer_connected | True
relayer_region | xx-earth-616
remote_enabled | True
remote_connected | False
alexa_enabled | True
google_enabled | False
cloud_ice_servers_enabled | True
remote_server | us-west-1
certificate_status | ready
instance_id | 12345678901234567890
can_reach_cert_server | Exception: Unexpected exception
can_reach_cloud_auth | Failed: unreachable
can_reach_cloud | ok
</details>
## Host resource usage
Resource | Value
--- | ---
CPU usage | 25.5%
Memory total | 16.0 GB
Memory used | 8.0 GB (50.0%)
Memory available | 8.0 GB
Disk total | 500.0 GB
Disk used | 200.0 GB (40.0%)
Disk free | 300.0 GB
## Full logs
<details><summary>Logs</summary>
```logs
2025-02-10 12:00:00.000 INFO (MainThread) [hass_nabucasa.iot] Hass nabucasa log
```
</details>
'''
# ---
# name: test_download_support_package_integration_load_error
'''
## System Information
@@ -516,18 +246,6 @@
</details>
## Host resource usage
Resource | Value
--- | ---
CPU usage | 25.5%
Memory total | 16.0 GB
Memory used | 8.0 GB (50.0%)
Memory available | 8.0 GB
Disk total | 500.0 GB
Disk used | 200.0 GB (40.0%)
Disk free | 300.0 GB
## Full logs
<details><summary>Logs</summary>

View File

@@ -1,6 +1,6 @@
"""Tests for the HTTP API for the cloud component."""
from collections.abc import Callable, Coroutine, Generator
from collections.abc import Callable, Coroutine
from copy import deepcopy
import datetime
from http import HTTPStatus
@@ -114,36 +114,6 @@ PIPELINE_DATA_OTHER = {
SUBSCRIPTION_INFO_URL = "https://api-test.hass.io/payments/subscription_info"
@pytest.fixture
def mock_psutil_wrapper() -> Generator[MagicMock]:
"""Fixture to mock psutil for support package tests."""
mock_memory = MagicMock()
mock_memory.total = 16 * 1024**3 # 16 GB
mock_memory.used = 8 * 1024**3 # 8 GB
mock_memory.available = 8 * 1024**3 # 8 GB
mock_memory.percent = 50.0
mock_disk = MagicMock()
mock_disk.total = 500 * 1024**3 # 500 GB
mock_disk.used = 200 * 1024**3 # 200 GB
mock_disk.free = 300 * 1024**3 # 300 GB
mock_disk.percent = 40.0
mock_psutil = MagicMock()
mock_psutil.cpu_percent = MagicMock(return_value=25.5)
mock_psutil.virtual_memory = MagicMock(return_value=mock_memory)
mock_psutil.disk_usage = MagicMock(return_value=mock_disk)
mock_wrapper = MagicMock()
mock_wrapper.psutil = mock_psutil
with patch(
"homeassistant.components.cloud.http_api.ha_psutil.PsutilWrapper",
return_value=mock_wrapper,
):
yield mock_wrapper
@pytest.fixture(name="setup_cloud")
async def setup_cloud_fixture(hass: HomeAssistant, cloud: MagicMock) -> None:
"""Fixture that sets up cloud."""
@@ -1876,7 +1846,7 @@ async def test_logout_view_dispatch_event(
@patch("homeassistant.components.cloud.helpers.FixedSizeQueueLogHandler.MAX_RECORDS", 3)
@pytest.mark.usefixtures("enable_custom_integrations", "mock_psutil_wrapper")
@pytest.mark.usefixtures("enable_custom_integrations")
async def test_download_support_package(
hass: HomeAssistant,
cloud: MagicMock,
@@ -1989,7 +1959,7 @@ async def test_download_support_package(
assert await req.text() == snapshot
@pytest.mark.usefixtures("enable_custom_integrations", "mock_psutil_wrapper")
@pytest.mark.usefixtures("enable_custom_integrations")
async def test_download_support_package_custom_components_error(
hass: HomeAssistant,
cloud: MagicMock,
@@ -2016,7 +1986,7 @@ async def test_download_support_package_custom_components_error(
async def mock_empty_info(hass: HomeAssistant) -> dict[str, Any]:
return {}
register.async_register_info(mock_empty_info, "/mock_integration")
register.async_register_info(mock_empty_info, "/config/mock_integration")
mock_platform(
hass,
@@ -2101,7 +2071,7 @@ async def test_download_support_package_custom_components_error(
assert await req.text() == snapshot
@pytest.mark.usefixtures("enable_custom_integrations", "mock_psutil_wrapper")
@pytest.mark.usefixtures("enable_custom_integrations")
async def test_download_support_package_integration_load_error(
hass: HomeAssistant,
cloud: MagicMock,
@@ -2128,7 +2098,7 @@ async def test_download_support_package_integration_load_error(
async def mock_empty_info(hass: HomeAssistant) -> dict[str, Any]:
return {}
register.async_register_info(mock_empty_info, "/mock_integration")
register.async_register_info(mock_empty_info, "/config/mock_integration")
mock_platform(
hass,
@@ -2218,277 +2188,6 @@ async def test_download_support_package_integration_load_error(
assert await req.text() == snapshot
@patch("homeassistant.components.cloud.helpers.FixedSizeQueueLogHandler.MAX_RECORDS", 3)
@pytest.mark.usefixtures("enable_custom_integrations", "mock_psutil_wrapper")
async def test_download_support_package_hassio(
hass: HomeAssistant,
cloud: MagicMock,
set_cloud_prefs: Callable[[dict[str, Any]], Coroutine[Any, Any, None]],
hass_client: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
freezer: FrozenDateTimeFactory,
snapshot: SnapshotAssertion,
) -> None:
"""Test downloading a support package file with hassio resources."""
aioclient_mock.get("https://cloud.bla.com/status", text="")
aioclient_mock.get(
"https://cert-server/directory", exc=Exception("Unexpected exception")
)
aioclient_mock.get(
"https://cognito-idp.us-east-1.amazonaws.com/AAAA/.well-known/jwks.json",
exc=aiohttp.ClientError,
)
def async_register_hassio_platform(
hass: HomeAssistant,
register: system_health.SystemHealthRegistration,
) -> None:
async def mock_hassio_info(hass: HomeAssistant) -> dict[str, Any]:
return {
"host_os": "Home Assistant OS 14.0",
"update_channel": "stable",
"supervisor_version": "supervisor-2025.01.0",
"agent_version": "1.6.0",
"docker_version": "27.4.1",
"disk_total": "128.5 GB",
"disk_used": "45.2 GB",
"healthy": True,
"supported": True,
"host_connectivity": True,
"supervisor_connectivity": True,
"board": "green",
"supervisor_api": "ok",
"version_api": "ok",
"installed_addons": "Mosquitto broker (6.4.1), Samba share (12.3.2), Visual Studio Code (5.21.2)",
}
register.async_register_info(mock_hassio_info, "/hassio/system")
mock_platform(
hass,
"hassio.system_health",
MagicMock(async_register=async_register_hassio_platform),
)
hass.config.components.add("hassio")
def async_register_mock_platform(
hass: HomeAssistant,
register: system_health.SystemHealthRegistration,
) -> None:
async def mock_empty_info(hass: HomeAssistant) -> dict[str, Any]:
return {}
register.async_register_info(mock_empty_info, "/config/mock_integration")
mock_platform(
hass,
"mock_no_info_integration.system_health",
MagicMock(async_register=async_register_mock_platform),
)
hass.config.components.add("mock_no_info_integration")
hass.config.components.add("test")
assert await async_setup_component(hass, "system_health", {})
with patch("uuid.UUID.hex", new_callable=PropertyMock) as hexmock:
hexmock.return_value = "12345678901234567890"
assert await async_setup_component(
hass,
DOMAIN,
{
DOMAIN: {
"user_pool_id": "AAAA",
"region": "us-east-1",
"acme_server": "cert-server",
"relayer_server": "cloud.bla.com",
},
},
)
await hass.async_block_till_done()
await cloud.login("test-user", "test-pass")
cloud.remote.snitun_server = "us-west-1"
cloud.remote.certificate_status = CertificateStatus.READY
cloud.expiration_date = dt_util.parse_datetime("2025-01-17T11:19:31.0+00:00")
await cloud.client.async_system_message({"region": "xx-earth-616"})
await set_cloud_prefs(
{
"alexa_enabled": True,
"google_enabled": False,
"remote_enabled": True,
"cloud_ice_servers_enabled": True,
}
)
now = dt_util.utcnow()
tz = now.astimezone().tzinfo
freezer.move_to(datetime.datetime(2025, 2, 10, 12, 0, 0, tzinfo=tz))
logging.getLogger("hass_nabucasa.iot").info(
"This message will be dropped since this test patches MAX_RECORDS"
)
logging.getLogger("hass_nabucasa.iot").info("Hass nabucasa log")
logging.getLogger("snitun.utils.aiohttp_client").warning("Snitun log")
logging.getLogger("homeassistant.components.cloud.client").error("Cloud log")
freezer.move_to(now)
cloud_client = await hass_client()
with (
patch.object(hass.config, "config_dir", new="config"),
patch(
"homeassistant.components.homeassistant.system_health.system_info.async_get_system_info",
return_value={
"installation_type": "Home Assistant OS",
"version": "2025.2.0",
"dev": False,
"hassio": True,
"virtualenv": False,
"python_version": "3.13.1",
"docker": True,
"container_arch": "aarch64",
"arch": "aarch64",
"timezone": "US/Pacific",
"os_name": "Linux",
"os_version": "6.12.9",
"user": "root",
},
),
patch(
"homeassistant.components.cloud.http_api.get_supervisor_info",
return_value={
"addons": [
{
"slug": "core_mosquitto",
"name": "Mosquitto broker",
"version": "6.4.1",
"state": "started",
},
{
"slug": "core_samba",
"name": "Samba share",
"version": "12.3.2",
"state": "started",
},
{
"slug": "a0d7b954_vscode",
"name": "Visual Studio Code",
"version": "5.21.2",
"state": "stopped",
},
],
},
),
patch(
"homeassistant.components.cloud.http_api.get_addons_stats",
return_value={
"core_mosquitto": {
"cpu_percent": 0.5,
"memory_percent": 1.2,
},
"core_samba": {
"cpu_percent": 0.1,
"memory_percent": 0.8,
},
# No stats for vscode (stopped)
},
),
):
req = await cloud_client.get("/api/cloud/support_package")
assert req.status == HTTPStatus.OK
assert await req.text() == snapshot
@patch("homeassistant.components.cloud.helpers.FixedSizeQueueLogHandler.MAX_RECORDS", 3)
@pytest.mark.usefixtures("mock_psutil_wrapper")
async def test_download_support_package_host_resources(
hass: HomeAssistant,
cloud: MagicMock,
set_cloud_prefs: Callable[[dict[str, Any]], Coroutine[Any, Any, None]],
hass_client: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
freezer: FrozenDateTimeFactory,
snapshot: SnapshotAssertion,
) -> None:
"""Test downloading a support package file with psutil host resources (non-hassio)."""
aioclient_mock.get("https://cloud.bla.com/status", text="")
aioclient_mock.get(
"https://cert-server/directory", exc=Exception("Unexpected exception")
)
aioclient_mock.get(
"https://cognito-idp.us-east-1.amazonaws.com/AAAA/.well-known/jwks.json",
exc=aiohttp.ClientError,
)
assert await async_setup_component(hass, "system_health", {})
with patch("uuid.UUID.hex", new_callable=PropertyMock) as hexmock:
hexmock.return_value = "12345678901234567890"
assert await async_setup_component(
hass,
DOMAIN,
{
DOMAIN: {
"user_pool_id": "AAAA",
"region": "us-east-1",
"acme_server": "cert-server",
"relayer_server": "cloud.bla.com",
},
},
)
await hass.async_block_till_done()
await cloud.login("test-user", "test-pass")
cloud.remote.snitun_server = "us-west-1"
cloud.remote.certificate_status = CertificateStatus.READY
cloud.expiration_date = dt_util.parse_datetime("2025-01-17T11:19:31.0+00:00")
await cloud.client.async_system_message({"region": "xx-earth-616"})
await set_cloud_prefs(
{
"alexa_enabled": True,
"google_enabled": False,
"remote_enabled": True,
"cloud_ice_servers_enabled": True,
}
)
now = dt_util.utcnow()
tz = now.astimezone().tzinfo
freezer.move_to(datetime.datetime(2025, 2, 10, 12, 0, 0, tzinfo=tz))
logging.getLogger("hass_nabucasa.iot").info("Hass nabucasa log")
freezer.move_to(now)
cloud_client = await hass_client()
with (
patch.object(hass.config, "config_dir", new="config"),
patch(
"homeassistant.components.homeassistant.system_health.system_info.async_get_system_info",
return_value={
"installation_type": "Home Assistant Container",
"version": "2025.2.0",
"dev": False,
"hassio": False,
"virtualenv": False,
"python_version": "3.13.1",
"docker": True,
"container_arch": "x86_64",
"arch": "x86_64",
"timezone": "US/Pacific",
"os_name": "Linux",
"os_version": "6.12.9",
"user": "root",
},
),
):
req = await cloud_client.get("/api/cloud/support_package")
assert req.status == HTTPStatus.OK
assert await req.text() == snapshot
async def test_websocket_ice_servers(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,

View File

@@ -74,5 +74,5 @@ async def test_sensor_updates_on_hour_tick(
assert (
hass.states.get("sensor.essent_current_electricity_market_price").state
== "0.24535"
== "0.10417"
)

View File

@@ -2,7 +2,6 @@
from unittest.mock import AsyncMock, patch
from freezegun.api import FrozenDateTimeFactory
from pyfirefly.exceptions import (
FireflyAuthenticationError,
FireflyConnectionError,
@@ -11,16 +10,14 @@ from pyfirefly.exceptions import (
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.firefly_iii.coordinator import DEFAULT_SCAN_INTERVAL
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import STATE_UNAVAILABLE, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.util import dt as dt_util
from . import setup_integration
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
from tests.common import MockConfigEntry, snapshot_platform
async def test_all_entities(
@@ -54,7 +51,6 @@ async def test_refresh_exceptions(
mock_firefly_client: AsyncMock,
mock_config_entry: MockConfigEntry,
exception: Exception,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test entities go unavailable after coordinator refresh failures."""
await setup_integration(hass, mock_config_entry)
@@ -62,9 +58,7 @@ async def test_refresh_exceptions(
mock_firefly_client.get_accounts.side_effect = exception
freezer.tick(DEFAULT_SCAN_INTERVAL)
async_fire_time_changed(hass, dt_util.utcnow())
await hass.async_block_till_done(wait_background_tasks=True)
await mock_config_entry.runtime_data.async_refresh()
state = hass.states.get("sensor.credit_card_account_balance")
assert state is not None

View File

@@ -0,0 +1 @@
"""Tests for the Homevolt integration."""

View File

@@ -0,0 +1,110 @@
"""Common fixtures for the Homevolt tests."""
from collections.abc import Generator
from unittest.mock import AsyncMock, MagicMock, patch
from homevolt import Device, DeviceMetadata, Sensor, SensorType
import pytest
from homeassistant.components.homevolt.const import DOMAIN
from homeassistant.const import CONF_HOST, CONF_PASSWORD, Platform
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]:
"""Override async_setup_entry."""
with patch(
"homeassistant.components.homevolt.async_setup_entry", return_value=True
) as mock_setup_entry:
yield mock_setup_entry
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Return the default mocked config entry."""
return MockConfigEntry(
title="Homevolt",
domain=DOMAIN,
data={
CONF_HOST: "127.0.0.1",
CONF_PASSWORD: "test-password",
},
unique_id="40580137858664",
)
@pytest.fixture
def mock_homevolt_client() -> Generator[MagicMock]:
"""Return a mocked Homevolt client."""
with (
patch(
"homeassistant.components.homevolt.Homevolt",
autospec=True,
) as homevolt_mock,
patch(
"homeassistant.components.homevolt.config_flow.Homevolt",
new=homevolt_mock,
),
):
client = homevolt_mock.return_value
client.base_url = "http://127.0.0.1"
client.update_info = AsyncMock()
# Create a mock Device with sensors
device = MagicMock(spec=Device)
device.device_id = "40580137858664"
device.sensors = {
"L1 Voltage": Sensor(
value=234.5,
type=SensorType.VOLTAGE,
device_identifier="ems_40580137858664",
slug="l1_voltage",
),
"Battery State of Charge": Sensor(
value=80.6,
type=SensorType.PERCENTAGE,
device_identifier="ems_40580137858664",
slug="battery_state_of_charge",
),
"Power": Sensor(
value=-12,
type=SensorType.POWER,
device_identifier="ems_40580137858664",
slug="power",
),
}
device.device_metadata = {
"ems_40580137858664": DeviceMetadata(
name="Homevolt EMS",
model="EMS-1000",
),
}
client.get_device.return_value = device
yield client
@pytest.fixture
def platforms() -> list[Platform]:
"""Return the platforms to test."""
return [Platform.SENSOR]
@pytest.fixture
async def init_integration(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_homevolt_client: MagicMock,
platforms: list[Platform],
) -> MockConfigEntry:
"""Set up the Homevolt integration for testing."""
mock_config_entry.add_to_hass(hass)
with patch("homeassistant.components.homevolt.PLATFORMS", platforms):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
return mock_config_entry

View File

@@ -0,0 +1,169 @@
# serializer version: 1
# name: test_entities[sensor.homevolt_ems_battery_state_of_charge-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.homevolt_ems_battery_state_of_charge',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Battery state of charge',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.BATTERY: 'battery'>,
'original_icon': None,
'original_name': 'Battery state of charge',
'platform': 'homevolt',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'battery_state_of_charge',
'unique_id': '40580137858664_Battery State of Charge',
'unit_of_measurement': '%',
})
# ---
# name: test_entities[sensor.homevolt_ems_battery_state_of_charge-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'battery',
'friendly_name': 'Homevolt EMS Battery state of charge',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': '%',
}),
'context': <ANY>,
'entity_id': 'sensor.homevolt_ems_battery_state_of_charge',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '80.6',
})
# ---
# name: test_entities[sensor.homevolt_ems_l1_voltage-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.homevolt_ems_l1_voltage',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'L1 voltage',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
}),
}),
'original_device_class': <SensorDeviceClass.VOLTAGE: 'voltage'>,
'original_icon': None,
'original_name': 'L1 voltage',
'platform': 'homevolt',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'l1_voltage',
'unique_id': '40580137858664_L1 Voltage',
'unit_of_measurement': <UnitOfElectricPotential.VOLT: 'V'>,
})
# ---
# name: test_entities[sensor.homevolt_ems_l1_voltage-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'voltage',
'friendly_name': 'Homevolt EMS L1 voltage',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfElectricPotential.VOLT: 'V'>,
}),
'context': <ANY>,
'entity_id': 'sensor.homevolt_ems_l1_voltage',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '234.5',
})
# ---
# name: test_entities[sensor.homevolt_ems_power-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.homevolt_ems_power',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Power',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
}),
}),
'original_device_class': <SensorDeviceClass.POWER: 'power'>,
'original_icon': None,
'original_name': 'Power',
'platform': 'homevolt',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'power',
'unique_id': '40580137858664_Power',
'unit_of_measurement': <UnitOfPower.WATT: 'W'>,
})
# ---
# name: test_entities[sensor.homevolt_ems_power-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'power',
'friendly_name': 'Homevolt EMS Power',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfPower.WATT: 'W'>,
}),
'context': <ANY>,
'entity_id': 'sensor.homevolt_ems_power',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '-12',
})
# ---

View File

@@ -0,0 +1,404 @@
"""Tests for the Homevolt config flow."""
from __future__ import annotations
from ipaddress import ip_address
from unittest.mock import AsyncMock, MagicMock, patch
from homevolt import HomevoltAuthenticationError, HomevoltConnectionError
import pytest
from homeassistant.components.homevolt.const import DOMAIN
from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF
from homeassistant.const import CONF_HOST, CONF_PASSWORD
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from tests.common import MockConfigEntry
ZEROCONF_DISCOVERY = ZeroconfServiceInfo(
ip_address=ip_address("192.168.68.87"),
ip_addresses=[ip_address("192.168.68.87")],
hostname="homevolt-68b6b34ce824.local.",
name="Homevolt._http._tcp.local.",
port=80,
type="_http._tcp.local.",
properties={},
)
async def test_full_flow_success(
hass: HomeAssistant, mock_setup_entry: AsyncMock
) -> None:
"""Test a complete successful user flow."""
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 result["errors"] == {}
user_input = {
CONF_HOST: "192.168.1.100",
CONF_PASSWORD: "test-password",
}
with (
patch(
"homeassistant.components.homevolt.config_flow.Homevolt.update_info",
new_callable=AsyncMock,
),
patch(
"homeassistant.components.homevolt.config_flow.Homevolt.get_device",
) as mock_get_device,
):
mock_device = MagicMock()
mock_device.device_id = "40580137858664"
mock_get_device.return_value = mock_device
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input,
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "Homevolt Local"
assert result["data"] == user_input
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.parametrize(
("exception", "expected_error"),
[
(HomevoltAuthenticationError, "invalid_auth"),
(HomevoltConnectionError, "cannot_connect"),
(Exception, "unknown"),
],
)
async def test_step_user_errors(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
exception: Exception,
expected_error: str,
) -> None:
"""Test error cases for the user step with recovery."""
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 result["errors"] == {}
user_input = {
CONF_HOST: "192.168.1.100",
CONF_PASSWORD: "test-password",
}
with patch(
"homeassistant.components.homevolt.config_flow.Homevolt.update_info",
new_callable=AsyncMock,
) as mock_update_info:
mock_update_info.side_effect = exception
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input,
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"] == {"base": expected_error}
with (
patch(
"homeassistant.components.homevolt.config_flow.Homevolt.update_info",
new_callable=AsyncMock,
),
patch(
"homeassistant.components.homevolt.config_flow.Homevolt.get_device",
) as mock_get_device,
):
mock_device = MagicMock()
mock_device.device_id = "40580137858664"
mock_get_device.return_value = mock_device
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input,
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "Homevolt Local"
assert result["data"] == user_input
assert len(mock_setup_entry.mock_calls) == 1
async def test_duplicate_entry(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
) -> None:
"""Test that a duplicate device_id aborts the flow."""
existing_entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_HOST: "192.168.1.100", CONF_PASSWORD: "test-password"},
unique_id="40580137858664",
)
existing_entry.add_to_hass(hass)
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 result["errors"] == {}
user_input = {
CONF_HOST: "192.168.1.200",
CONF_PASSWORD: "test-password",
}
with (
patch(
"homeassistant.components.homevolt.config_flow.Homevolt.update_info",
new_callable=AsyncMock,
),
patch(
"homeassistant.components.homevolt.config_flow.Homevolt.get_device",
) as mock_get_device,
):
mock_device = MagicMock()
mock_device.device_id = "40580137858664"
mock_get_device.return_value = mock_device
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input,
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def test_zeroconf_discovery_success(
hass: HomeAssistant, mock_setup_entry: AsyncMock
) -> None:
"""Test successful zeroconf discovery flow."""
with (
patch(
"homeassistant.components.homevolt.config_flow.Homevolt.update_info",
new_callable=AsyncMock,
),
patch(
"homeassistant.components.homevolt.config_flow.Homevolt.get_device",
) as mock_get_device,
):
mock_device = MagicMock()
mock_device.device_id = "40580137858664"
mock_get_device.return_value = mock_device
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_ZEROCONF},
data=ZEROCONF_DISCOVERY,
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "discovery_confirm"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "Homevolt"
assert result["data"] == {
CONF_HOST: "192.168.68.87",
CONF_PASSWORD: None,
}
assert len(mock_setup_entry.mock_calls) == 1
async def test_zeroconf_discovery_with_password(
hass: HomeAssistant, mock_setup_entry: AsyncMock
) -> None:
"""Test zeroconf discovery when device requires password."""
with patch(
"homeassistant.components.homevolt.config_flow.Homevolt.update_info",
new_callable=AsyncMock,
side_effect=HomevoltAuthenticationError,
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_ZEROCONF},
data=ZEROCONF_DISCOVERY,
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "discovery_confirm"
with (
patch(
"homeassistant.components.homevolt.config_flow.Homevolt.update_info",
new_callable=AsyncMock,
),
patch(
"homeassistant.components.homevolt.config_flow.Homevolt.get_device",
) as mock_get_device,
):
mock_device = MagicMock()
mock_device.device_id = "40580137858664"
mock_get_device.return_value = mock_device
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_PASSWORD: "test-password"},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "Homevolt"
assert result["data"] == {
CONF_HOST: "192.168.68.87",
CONF_PASSWORD: "test-password",
}
assert len(mock_setup_entry.mock_calls) == 1
async def test_zeroconf_discovery_connection_error(
hass: HomeAssistant, mock_setup_entry: AsyncMock
) -> None:
"""Test zeroconf discovery when device cannot be reached."""
with patch(
"homeassistant.components.homevolt.config_flow.Homevolt.update_info",
new_callable=AsyncMock,
side_effect=HomevoltConnectionError,
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_ZEROCONF},
data=ZEROCONF_DISCOVERY,
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "cannot_connect"
async def test_zeroconf_discovery_unknown_error(
hass: HomeAssistant, mock_setup_entry: AsyncMock
) -> None:
"""Test zeroconf discovery when an unknown error occurs."""
with patch(
"homeassistant.components.homevolt.config_flow.Homevolt.update_info",
new_callable=AsyncMock,
side_effect=Exception("Unknown error"),
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_ZEROCONF},
data=ZEROCONF_DISCOVERY,
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "cannot_connect"
async def test_zeroconf_discovery_already_configured(
hass: HomeAssistant, mock_setup_entry: AsyncMock
) -> None:
"""Test zeroconf discovery when device is already configured."""
existing_entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_HOST: "192.168.1.100", CONF_PASSWORD: "test-password"},
unique_id="40580137858664",
)
existing_entry.add_to_hass(hass)
with (
patch(
"homeassistant.components.homevolt.config_flow.Homevolt.update_info",
new_callable=AsyncMock,
),
patch(
"homeassistant.components.homevolt.config_flow.Homevolt.get_device",
) as mock_get_device,
):
mock_device = MagicMock()
mock_device.device_id = "40580137858664"
mock_get_device.return_value = mock_device
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_ZEROCONF},
data=ZEROCONF_DISCOVERY,
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
# Verify host was updated
assert existing_entry.data[CONF_HOST] == "192.168.68.87"
async def test_zeroconf_discovery_confirm_errors(
hass: HomeAssistant, mock_setup_entry: AsyncMock
) -> None:
"""Test error handling in discovery confirm step."""
with (
patch(
"homeassistant.components.homevolt.config_flow.Homevolt.update_info",
new_callable=AsyncMock,
),
patch(
"homeassistant.components.homevolt.config_flow.Homevolt.get_device",
) as mock_get_device,
):
mock_device = MagicMock()
mock_device.device_id = "40580137858664"
mock_get_device.return_value = mock_device
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_ZEROCONF},
data=ZEROCONF_DISCOVERY,
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "discovery_confirm"
# Test invalid auth error
with patch(
"homeassistant.components.homevolt.config_flow.Homevolt.update_info",
new_callable=AsyncMock,
side_effect=HomevoltAuthenticationError,
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_PASSWORD: "wrong-password"},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "discovery_confirm"
assert result["errors"] == {"base": "invalid_auth"}
# Recover with correct password
with (
patch(
"homeassistant.components.homevolt.config_flow.Homevolt.update_info",
new_callable=AsyncMock,
),
patch(
"homeassistant.components.homevolt.config_flow.Homevolt.get_device",
) as mock_get_device,
):
mock_device = MagicMock()
mock_device.device_id = "40580137858664"
mock_get_device.return_value = mock_device
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_PASSWORD: "correct-password"},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY

View File

@@ -0,0 +1,65 @@
"""Test the Homevolt init module."""
from __future__ import annotations
from unittest.mock import MagicMock
from homevolt import HomevoltAuthenticationError, HomevoltConnectionError
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
async def test_load_unload_entry(
hass: HomeAssistant,
mock_homevolt_client: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test load and unload entry."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.LOADED
await hass.config_entries.async_remove(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
async def test_config_entry_not_ready(
hass: HomeAssistant,
mock_homevolt_client: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test the Homevolt configuration entry not ready."""
mock_homevolt_client.update_info.side_effect = HomevoltConnectionError(
"Connection failed"
)
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
async def test_config_entry_auth_failed(
hass: HomeAssistant,
mock_homevolt_client: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test the Homevolt configuration entry authentication failed."""
mock_homevolt_client.update_info.side_effect = HomevoltAuthenticationError(
"Authentication failed"
)
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR

View File

@@ -0,0 +1,60 @@
"""Tests for the Homevolt sensor platform."""
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.homevolt.const import DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
from tests.common import MockConfigEntry, snapshot_platform
pytestmark = pytest.mark.usefixtures(
"entity_registry_enabled_by_default", "init_integration"
)
async def test_entities(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
device_registry: dr.DeviceRegistry,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test the sensor entities."""
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
device_entry = device_registry.async_get_device(
identifiers={(DOMAIN, "40580137858664_ems_40580137858664")}
)
assert device_entry
entity_entries = er.async_entries_for_config_entry(
entity_registry, mock_config_entry.entry_id
)
for entity_entry in entity_entries:
assert entity_entry.device_id == device_entry.id
async def test_sensor_exposes_values_from_coordinator(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
mock_config_entry: MockConfigEntry,
mock_homevolt_client,
) -> None:
"""Ensure sensor entities are created and expose values from the coordinator."""
unique_id = "40580137858664_L1 Voltage"
entity_id = entity_registry.async_get_entity_id("sensor", DOMAIN, unique_id)
assert entity_id is not None
state = hass.states.get(entity_id)
assert state is not None
assert float(state.state) == 234.5
mock_homevolt_client.get_device.return_value.sensors["L1 Voltage"].value = 240.1
coordinator = mock_config_entry.runtime_data
await coordinator.async_refresh()
await hass.async_block_till_done()
state = hass.states.get(entity_id)
assert state is not None
assert float(state.state) == 240.1

View File

@@ -5,7 +5,7 @@ from typing import Any
import pytest
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, ServiceCall
from tests.components import (
ConditionStateDescription,
@@ -137,6 +137,7 @@ async def test_light_state_condition_behavior_any(
)
async def test_light_state_condition_behavior_all(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_lights: list[str],
condition_target_config: dict,
entity_id: str,

View File

@@ -129,7 +129,6 @@ async def integration_fixture(
"oven",
"pressure_sensor",
"pump",
"resideo_x2s_thermostat",
"room_airconditioner",
"secuyou_smart_lock",
"silabs_dishwasher",

View File

@@ -1,190 +0,0 @@
{
"node_id": 4,
"date_commissioned": "2026-01-04T01:49:35.244151",
"last_interview": "2026-01-04T03:11:54.520702",
"interview_version": 6,
"available": true,
"is_bridge": false,
"attributes": {
"0/29/0": [
{
"0": 22,
"1": 1
}
],
"0/29/1": [29, 31, 40, 48, 51, 60, 62, 63],
"0/29/2": [],
"0/29/3": [1],
"0/29/65532": 0,
"0/29/65533": 2,
"0/29/65528": [],
"0/29/65529": [],
"0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533],
"0/31/0": [
{
"1": 5,
"2": 2,
"3": [112233],
"4": null,
"254": 1
}
],
"0/31/2": 4,
"0/31/3": 3,
"0/31/4": 4,
"0/31/65532": 0,
"0/31/65533": 1,
"0/31/65528": [],
"0/31/65529": [],
"0/31/65531": [0, 2, 3, 4, 65528, 65529, 65531, 65532, 65533],
"0/40/0": 17,
"0/40/1": "Resideo",
"0/40/2": 4890,
"0/40/3": "X2S Smart Thermostat",
"0/40/4": 4096,
"0/40/5": "",
"0/40/6": "**REDACTED**",
"0/40/7": 2,
"0/40/8": "X2S_STAT_NPS_002",
"0/40/9": 1,
"0/40/10": "2.0.0.0",
"0/40/15": "**REDACTED**",
"0/40/19": {
"0": 3,
"1": 3
},
"0/40/21": 16973824,
"0/40/22": 1,
"0/40/65532": 0,
"0/40/65533": 3,
"0/40/65528": [],
"0/40/65529": [],
"0/40/65531": [
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 15, 19, 21, 22, 65528, 65529, 65531,
65532, 65533
],
"0/48/0": 0,
"0/48/1": {
"0": 60,
"1": 900
},
"0/48/2": 0,
"0/48/3": 2,
"0/48/4": true,
"0/48/65532": 0,
"0/48/65533": 1,
"0/48/65528": [1, 3, 5],
"0/48/65529": [0, 2, 4],
"0/48/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533],
"0/51/0": [
{
"0": "r0",
"1": true,
"2": null,
"3": null,
"4": "XPzhnNpQ",
"5": ["wKgJoQ=="],
"6": ["/oAAAAAAAABe/OH//pzaUA=="],
"7": 1
}
],
"0/51/1": 2,
"0/51/2": 5105,
"0/51/8": false,
"0/51/65532": 0,
"0/51/65533": 2,
"0/51/65528": [2],
"0/51/65529": [0, 1],
"0/51/65531": [0, 1, 2, 8, 65528, 65529, 65531, 65532, 65533],
"0/60/0": 0,
"0/60/1": null,
"0/60/2": null,
"0/60/65532": 0,
"0/60/65533": 1,
"0/60/65528": [],
"0/60/65529": [0, 2],
"0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533],
"0/62/0": [
{
"1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRBBgkBwEkCAEwCUEEKl96Nj13XjcDn1kF4aoFwMWb9leBXP2Urts/tvLi1DF1UZkPEBrfZ5YYqd5tps3ELof6pBX91oACxfbnYF7UyzcKNQEoARgkAgE2AwQCBAEYMAQUY8nv41nGGNtJapsJ0+8/6EAnt9owBRRnrnI3xp/0zhgwIJN0RMbKS99orRgwC0BsruLcJINuIyVVZHD5AlYCuha4XhnLxtIjyYCXIIHGNuu39D6u/j94efSHPrOvVjAHXY56+z5KJguTzlTBOC5tGA==",
"2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEOoay7Kv2Hog5xV7knNJLl+Ywx5Sr/jrp6/PV5XF57NXm4UJfgdb6Ja7rZ+965UjigpYh+JVAVvCRK1xNgkikiDcKNQEpARgkAmAwBBRnrnI3xp/0zhgwIJN0RMbKS99orTAFFEHaAQy9nUPjHiRv7FIwcIp50v+EGDALQF5JHY0EJKgFC63BM4uO0mrkHpeTCSDpUEEz7IsvkdxAgUToWftgJSC3B7gqDelohC4uqReJpmeQ64F5XqYtB3AY",
"254": 1
}
],
"0/62/1": [
{
"1": "BGkTBQSFwwkc5WoOUncXmIahsjWs9bKfHyZRWpArIFMjhyjNKqURWvFS8xbVXTFf+UlFmJF2JnlMX4WgKjXkOLo=",
"2": 4939,
"3": 2,
"4": 4,
"5": "Home",
"254": 1
}
],
"0/62/2": 5,
"0/62/3": 1,
"0/62/4": [
"FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEaRMFBIXDCRzlag5SdxeYhqGyNaz1sp8fJlFakCsgUyOHKM0qpRFa8VLzFtVdMV/5SUWYkXYmeUxfhaAqNeQ4ujcKNQEpARgkAmAwBBRB2gEMvZ1D4x4kb+xSMHCKedL/hDAFFEHaAQy9nUPjHiRv7FIwcIp50v+EGDALQE/fBBea6WzXom6INogGzGdop0w7g8j4dcIo6v8Id2k+sttWqeL5we7dDJonx/m2MgVsQTKCeVhtN/nzT4stvmEY"
],
"0/62/5": 1,
"0/62/65532": 0,
"0/62/65533": 1,
"0/62/65528": [1, 3, 5, 8],
"0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11],
"0/62/65531": [0, 1, 2, 3, 4, 5, 65528, 65529, 65531, 65532, 65533],
"0/63/0": [],
"0/63/1": [],
"0/63/2": 4,
"0/63/3": 3,
"0/63/65532": 0,
"0/63/65533": 2,
"0/63/65528": [2, 5],
"0/63/65529": [0, 1, 3, 4],
"0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533],
"1/3/0": 0,
"1/3/1": 4,
"1/3/65532": 0,
"1/3/65533": 4,
"1/3/65528": [],
"1/3/65529": [0],
"1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533],
"1/4/0": 128,
"1/4/65532": 1,
"1/4/65533": 4,
"1/4/65528": [0, 1, 2, 3],
"1/4/65529": [0, 1, 2, 3, 4, 5],
"1/4/65531": [0, 65528, 65529, 65531, 65532, 65533],
"1/29/0": [
{
"0": 769,
"1": 1
}
],
"1/29/1": [3, 4, 29, 513],
"1/29/2": [],
"1/29/3": [],
"1/29/65532": 0,
"1/29/65533": 2,
"1/29/65528": [],
"1/29/65529": [],
"1/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533],
"1/513/0": 2055,
"1/513/3": 443,
"1/513/4": 3221,
"1/513/5": 1000,
"1/513/6": 3721,
"1/513/17": 2666,
"1/513/18": 2166,
"1/513/25": 0,
"1/513/27": 4,
"1/513/28": 0,
"1/513/65532": 3,
"1/513/65533": 6,
"1/513/65528": [],
"1/513/65529": [0],
"1/513/65531": [
0, 3, 4, 5, 6, 17, 18, 25, 27, 28, 65528, 65529, 65531, 65532, 65533
]
},
"attribute_subscriptions": []
}

View File

@@ -3087,56 +3087,6 @@
'state': 'unknown',
})
# ---
# name: test_buttons[resideo_x2s_thermostat][button.x2s_smart_thermostat_identify-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': 'button',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'button.x2s_smart_thermostat_identify',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Identify',
'options': dict({
}),
'original_device_class': <ButtonDeviceClass.IDENTIFY: 'identify'>,
'original_icon': None,
'original_name': 'Identify',
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-IdentifyButton-3-1',
'unit_of_measurement': None,
})
# ---
# name: test_buttons[resideo_x2s_thermostat][button.x2s_smart_thermostat_identify-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'identify',
'friendly_name': 'X2S Smart Thermostat Identify',
}),
'context': <ANY>,
'entity_id': 'button.x2s_smart_thermostat_identify',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_buttons[secuyou_smart_lock][button.secuyou_smart_lock_identify-entry]
EntityRegistryEntrySnapshot({
'aliases': set({

View File

@@ -467,75 +467,6 @@
'state': 'heat_cool',
})
# ---
# name: test_climates[resideo_x2s_thermostat][climate.x2s_smart_thermostat-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'hvac_modes': list([
<HVACMode.OFF: 'off'>,
<HVACMode.HEAT: 'heat'>,
<HVACMode.COOL: 'cool'>,
<HVACMode.FAN_ONLY: 'fan_only'>,
]),
'max_temp': 32.2,
'min_temp': 4.4,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'climate',
'entity_category': None,
'entity_id': 'climate.x2s_smart_thermostat',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': None,
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <ClimateEntityFeature: 385>,
'translation_key': None,
'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-MatterThermostat-513-0',
'unit_of_measurement': None,
})
# ---
# name: test_climates[resideo_x2s_thermostat][climate.x2s_smart_thermostat-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_temperature': 20.6,
'friendly_name': 'X2S Smart Thermostat',
'hvac_modes': list([
<HVACMode.OFF: 'off'>,
<HVACMode.HEAT: 'heat'>,
<HVACMode.COOL: 'cool'>,
<HVACMode.FAN_ONLY: 'fan_only'>,
]),
'max_temp': 32.2,
'min_temp': 4.4,
'supported_features': <ClimateEntityFeature: 385>,
'temperature': 21.7,
}),
'context': <ANY>,
'entity_id': 'climate.x2s_smart_thermostat',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_climates[room_airconditioner][climate.room_airconditioner-entry]
EntityRegistryEntrySnapshot({
'aliases': set({

View File

@@ -9748,63 +9748,6 @@
'state': '60.0',
})
# ---
# name: test_sensors[resideo_x2s_thermostat][sensor.x2s_smart_thermostat_temperature-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.x2s_smart_thermostat_temperature',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Temperature',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
}),
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
'original_icon': None,
'original_name': 'Temperature',
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-ThermostatLocalTemperature-513-0',
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_sensors[resideo_x2s_thermostat][sensor.x2s_smart_thermostat_temperature-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'temperature',
'friendly_name': 'X2S Smart Thermostat Temperature',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'sensor.x2s_smart_thermostat_temperature',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '20.55',
})
# ---
# name: test_sensors[room_airconditioner][sensor.room_airconditioner_temperature-entry]
EntityRegistryEntrySnapshot({
'aliases': set({

View File

@@ -34,7 +34,7 @@ async def test_setup_missing_config(hass: HomeAssistant) -> None:
"""Test setup with missing configuration."""
with mock.patch("homeassistant.components.mfi.sensor.MFiClient") as mock_client:
config = {"sensor": {"platform": "mfi"}}
assert await async_setup_component(hass, COMPONENT.DOMAIN, config)
assert await async_setup_component(hass, "sensor", config)
assert not mock_client.called
@@ -42,14 +42,14 @@ async def test_setup_failed_login(hass: HomeAssistant) -> None:
"""Test setup with login failure."""
with mock.patch("homeassistant.components.mfi.sensor.MFiClient") as mock_client:
mock_client.side_effect = FailedToLogin
assert not PLATFORM.setup_platform(hass, GOOD_CONFIG[COMPONENT.DOMAIN], None)
assert not PLATFORM.setup_platform(hass, GOOD_CONFIG, None)
async def test_setup_failed_connect(hass: HomeAssistant) -> None:
"""Test setup with connection failure."""
with mock.patch("homeassistant.components.mfi.sensor.MFiClient") as mock_client:
mock_client.side_effect = requests.exceptions.ConnectionError
assert not PLATFORM.setup_platform(hass, GOOD_CONFIG[COMPONENT.DOMAIN], None)
assert not PLATFORM.setup_platform(hass, GOOD_CONFIG, None)
async def test_setup_minimum(hass: HomeAssistant) -> None:
@@ -111,7 +111,7 @@ async def test_setup_adds_proper_devices(hass: HomeAssistant) -> None:
await hass.async_block_till_done()
for ident, port in ports.items():
if ident != "bad":
mock_sensor.assert_any_call(port)
mock_sensor.assert_any_call(port, hass)
assert mock.call(ports["bad"], hass) not in mock_sensor.mock_calls
@@ -124,7 +124,7 @@ def port_fixture() -> mock.MagicMock:
@pytest.fixture(name="sensor")
def sensor_fixture(hass: HomeAssistant, port: mock.MagicMock) -> mfi.MfiSensor:
"""Sensor fixture."""
sensor = mfi.MfiSensor(port)
sensor = mfi.MfiSensor(port, hass)
sensor.hass = hass
return sensor

View File

@@ -17,7 +17,7 @@ from tests.common import (
async def setup_platform(hass: HomeAssistant, config_entry: MockConfigEntry) -> None:
"""Set up the NINA platform."""
with patch(
"pynina.api_client.APIClient.make_request",
"pynina.baseApi.BaseAPI._makeRequest",
wraps=mocked_request_function,
):
await hass.config_entries.async_setup(config_entry.entry_id)

View File

@@ -25,7 +25,7 @@
'id': 'biw.BIWAPP-69634',
'is_valid': False,
'recommended_actions': '',
'sender': None,
'sender': '',
'sent': '1999-08-07T10:59:00+02:00',
'severity': 'Minor',
'start': '',

View File

@@ -49,7 +49,7 @@ def assert_dummy_entry_created(result: dict[str, Any]) -> None:
async def test_step_user_connection_error(hass: HomeAssistant) -> None:
"""Test starting a flow by user but no connection."""
with patch(
"pynina.api_client.APIClient.make_request",
"pynina.baseApi.BaseAPI._makeRequest",
side_effect=ApiError("Could not connect to Api"),
):
result: dict[str, Any] = await hass.config_entries.flow.async_init(
@@ -63,7 +63,7 @@ async def test_step_user_connection_error(hass: HomeAssistant) -> None:
async def test_step_user_unexpected_exception(hass: HomeAssistant) -> None:
"""Test starting a flow by user but with an unexpected exception."""
with patch(
"pynina.api_client.APIClient.make_request",
"pynina.baseApi.BaseAPI._makeRequest",
side_effect=Exception("DUMMY"),
):
result: dict[str, Any] = await hass.config_entries.flow.async_init(
@@ -77,7 +77,7 @@ async def test_step_user_unexpected_exception(hass: HomeAssistant) -> None:
async def test_step_user(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None:
"""Test starting a flow by user with valid values."""
with patch(
"pynina.api_client.APIClient.make_request",
"pynina.baseApi.BaseAPI._makeRequest",
wraps=mocked_request_function,
):
result: dict[str, Any] = await hass.config_entries.flow.async_init(
@@ -95,7 +95,7 @@ async def test_step_user(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> No
async def test_step_user_no_selection(hass: HomeAssistant) -> None:
"""Test starting a flow by user with no selection."""
with patch(
"pynina.api_client.APIClient.make_request",
"pynina.baseApi.BaseAPI._makeRequest",
wraps=mocked_request_function,
):
result: dict[str, Any] = await hass.config_entries.flow.async_init(
@@ -121,7 +121,7 @@ async def test_step_user_already_configured(
) -> None:
"""Test starting a flow by user, but it was already configured."""
with patch(
"pynina.api_client.APIClient.make_request",
"pynina.baseApi.BaseAPI._makeRequest",
wraps=mocked_request_function,
):
result = await hass.config_entries.flow.async_init(
@@ -141,7 +141,7 @@ async def test_options_flow_init(
with (
patch(
"pynina.api_client.APIClient.make_request",
"pynina.baseApi.BaseAPI._makeRequest",
wraps=mocked_request_function,
),
):
@@ -195,7 +195,7 @@ async def test_options_flow_with_no_selection(
with (
patch(
"pynina.api_client.APIClient.make_request",
"pynina.baseApi.BaseAPI._makeRequest",
wraps=mocked_request_function,
),
):
@@ -263,7 +263,7 @@ async def test_options_flow_connection_error(
await setup_platform(hass, mock_config_entry)
with patch(
"pynina.api_client.APIClient.make_request",
"pynina.baseApi.BaseAPI._makeRequest",
side_effect=ApiError("Could not connect to Api"),
):
result = await hass.config_entries.options.async_init(
@@ -283,7 +283,7 @@ async def test_options_flow_unexpected_exception(
with (
patch(
"pynina.api_client.APIClient.make_request",
"pynina.baseApi.BaseAPI._makeRequest",
side_effect=Exception("DUMMY"),
),
):
@@ -312,7 +312,7 @@ async def test_options_flow_entity_removal(
with (
patch(
"pynina.api_client.APIClient.make_request",
"pynina.baseApi.BaseAPI._makeRequest",
wraps=mocked_request_function,
),
):

View File

@@ -32,7 +32,7 @@ async def test_diagnostics(
"""Test diagnostics."""
with patch(
"pynina.api_client.APIClient.make_request",
"pynina.baseApi.BaseAPI._makeRequest",
wraps=mocked_request_function,
):
config_entry: MockConfigEntry = MockConfigEntry(

View File

@@ -28,7 +28,7 @@ async def init_integration(hass: HomeAssistant) -> MockConfigEntry:
"""Set up the NINA integration in Home Assistant."""
with patch(
"pynina.api_client.APIClient.make_request",
"pynina.baseApi.BaseAPI._makeRequest",
wraps=mocked_request_function,
):
entry: MockConfigEntry = MockConfigEntry(
@@ -54,7 +54,7 @@ async def test_config_migration_from1_1(hass: HomeAssistant) -> None:
)
with patch(
"pynina.api_client.APIClient.make_request",
"pynina.baseApi.BaseAPI._makeRequest",
wraps=mocked_request_function,
):
old_conf_entry.add_to_hass(hass)
@@ -82,7 +82,7 @@ async def test_config_migration_from1_2(hass: HomeAssistant) -> None:
)
with patch(
"pynina.api_client.APIClient.make_request",
"pynina.baseApi.BaseAPI._makeRequest",
wraps=mocked_request_function,
):
old_conf_entry.add_to_hass(hass)
@@ -104,7 +104,7 @@ async def test_config_migration_downgrade(hass: HomeAssistant) -> None:
)
with patch(
"pynina.api_client.APIClient.make_request",
"pynina.baseApi.BaseAPI._makeRequest",
wraps=mocked_request_function,
):
conf_entry.add_to_hass(hass)
@@ -126,7 +126,7 @@ async def test_config_entry_not_ready(hass: HomeAssistant) -> None:
async def test_sensors_connection_error(hass: HomeAssistant) -> None:
"""Test the creation and values of the NINA sensors with no connected."""
with patch(
"pynina.api_client.APIClient.make_request",
"pynina.baseApi.BaseAPI._makeRequest",
side_effect=ApiError("Could not connect to Api"),
):
conf_entry: MockConfigEntry = MockConfigEntry(

View File

@@ -58,7 +58,7 @@ async def test_form(hass: HomeAssistant) -> None:
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {}
assert result["errors"] is None
with (
patch(

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