mirror of
https://github.com/home-assistant/core.git
synced 2026-02-26 23:59:00 +00:00
Compare commits
46 Commits
2026.3.0b0
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8a35ea69d | ||
|
|
28b950c64a | ||
|
|
e7cf6cbe72 | ||
|
|
5ad71453b8 | ||
|
|
ab9c8093c3 | ||
|
|
51acdeb563 | ||
|
|
bf60d57cc2 | ||
|
|
d94f15b985 | ||
|
|
8a621e6570 | ||
|
|
dd44b15b7b | ||
|
|
23ec28bbbf | ||
|
|
7a6a479b53 | ||
|
|
f9ffaad7f1 | ||
|
|
d4aa52ecc3 | ||
|
|
1b5eea5fae | ||
|
|
39dce8eb31 | ||
|
|
b651e62c7f | ||
|
|
1e807dc9da | ||
|
|
cba69e7e69 | ||
|
|
802a7aafec | ||
|
|
db5e7b3e3b | ||
|
|
75798bfb5e | ||
|
|
06a25de0d5 | ||
|
|
892da4a03e | ||
|
|
91e8e3da7a | ||
|
|
144b8768a1 | ||
|
|
cb6d86f86d | ||
|
|
422007577e | ||
|
|
7c2904bf48 | ||
|
|
3240fd7fc8 | ||
|
|
7dc2dff4e7 | ||
|
|
7e8de9bb9c | ||
|
|
9eff12605c | ||
|
|
784ac85759 | ||
|
|
31f7961437 | ||
|
|
eaae64fa12 | ||
|
|
88b276f3a4 | ||
|
|
f5c996e243 | ||
|
|
4863df00a1 | ||
|
|
9fadfecf14 | ||
|
|
dae7f73f53 | ||
|
|
c46d0382c3 | ||
|
|
c21e9cb24c | ||
|
|
928732af40 | ||
|
|
51dc6d7c26 | ||
|
|
02972579aa |
@@ -34,6 +34,7 @@ base_platforms: &base_platforms
|
||||
- homeassistant/components/humidifier/**
|
||||
- homeassistant/components/image/**
|
||||
- homeassistant/components/image_processing/**
|
||||
- homeassistant/components/infrared/**
|
||||
- homeassistant/components/lawn_mower/**
|
||||
- homeassistant/components/light/**
|
||||
- homeassistant/components/lock/**
|
||||
|
||||
2
.github/workflows/ci.yaml
vendored
2
.github/workflows/ci.yaml
vendored
@@ -40,7 +40,7 @@ env:
|
||||
CACHE_VERSION: 3
|
||||
UV_CACHE_VERSION: 1
|
||||
MYPY_CACHE_VERSION: 1
|
||||
HA_SHORT_VERSION: "2026.3"
|
||||
HA_SHORT_VERSION: "2026.4"
|
||||
DEFAULT_PYTHON: "3.14.2"
|
||||
ALL_PYTHON_VERSIONS: "['3.14.2']"
|
||||
# 10.3 is the oldest supported version
|
||||
|
||||
@@ -289,6 +289,7 @@ homeassistant.components.imgw_pib.*
|
||||
homeassistant.components.immich.*
|
||||
homeassistant.components.incomfort.*
|
||||
homeassistant.components.inels.*
|
||||
homeassistant.components.infrared.*
|
||||
homeassistant.components.input_button.*
|
||||
homeassistant.components.input_select.*
|
||||
homeassistant.components.input_text.*
|
||||
|
||||
2
CODEOWNERS
generated
2
CODEOWNERS
generated
@@ -794,6 +794,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/inels/ @epdevlab
|
||||
/homeassistant/components/influxdb/ @mdegat01 @Robbie1221
|
||||
/tests/components/influxdb/ @mdegat01 @Robbie1221
|
||||
/homeassistant/components/infrared/ @home-assistant/core
|
||||
/tests/components/infrared/ @home-assistant/core
|
||||
/homeassistant/components/inkbird/ @bdraco
|
||||
/tests/components/inkbird/ @bdraco
|
||||
/homeassistant/components/input_boolean/ @home-assistant/core
|
||||
|
||||
@@ -10,6 +10,7 @@ coverage:
|
||||
target: auto
|
||||
threshold: 1
|
||||
paths:
|
||||
- homeassistant/components/*/backup.py
|
||||
- homeassistant/components/*/config_flow.py
|
||||
- homeassistant/components/*/device_action.py
|
||||
- homeassistant/components/*/device_condition.py
|
||||
@@ -28,6 +29,7 @@ coverage:
|
||||
target: 100
|
||||
threshold: 0
|
||||
paths:
|
||||
- homeassistant/components/*/backup.py
|
||||
- homeassistant/components/*/config_flow.py
|
||||
- homeassistant/components/*/device_action.py
|
||||
- homeassistant/components/*/device_condition.py
|
||||
|
||||
@@ -12,10 +12,6 @@ from homeassistant.helpers.dispatcher import dispatcher_send
|
||||
|
||||
from .const import DOMAIN, DOMAIN_DATA, LOGGER
|
||||
|
||||
SERVICE_SETTINGS = "change_setting"
|
||||
SERVICE_CAPTURE_IMAGE = "capture_image"
|
||||
SERVICE_TRIGGER_AUTOMATION = "trigger_automation"
|
||||
|
||||
ATTR_SETTING = "setting"
|
||||
ATTR_VALUE = "value"
|
||||
|
||||
@@ -75,16 +71,13 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Home Assistant services."""
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_SETTINGS, _change_setting, schema=CHANGE_SETTING_SCHEMA
|
||||
DOMAIN, "change_setting", _change_setting, schema=CHANGE_SETTING_SCHEMA
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_CAPTURE_IMAGE, _capture_image, schema=CAPTURE_IMAGE_SCHEMA
|
||||
DOMAIN, "capture_image", _capture_image, schema=CAPTURE_IMAGE_SCHEMA
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_TRIGGER_AUTOMATION,
|
||||
_trigger_automation,
|
||||
schema=AUTOMATION_SCHEMA,
|
||||
DOMAIN, "trigger_automation", _trigger_automation, schema=AUTOMATION_SCHEMA
|
||||
)
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["accuweather"],
|
||||
"requirements": ["accuweather==5.0.0"]
|
||||
"requirements": ["accuweather==5.1.0"]
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ async def system_health_info(hass: HomeAssistant) -> dict[str, Any]:
|
||||
)
|
||||
|
||||
return {
|
||||
"can_reach_server": system_health.async_check_can_reach_url(hass, ENDPOINT),
|
||||
"can_reach_server": system_health.async_check_can_reach_url(
|
||||
hass, str(ENDPOINT)
|
||||
),
|
||||
"remaining_requests": remaining_requests,
|
||||
}
|
||||
|
||||
@@ -10,8 +10,6 @@ from homeassistant.helpers import config_validation as cv, service
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
ADVANTAGE_AIR_SERVICE_SET_TIME_TO = "set_time_to"
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
@@ -20,7 +18,7 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
||||
service.async_register_platform_entity_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
ADVANTAGE_AIR_SERVICE_SET_TIME_TO,
|
||||
"set_time_to",
|
||||
entity_domain=SENSOR_DOMAIN,
|
||||
schema={vol.Required("minutes"): cv.positive_int},
|
||||
func="set_time_to",
|
||||
|
||||
@@ -8,18 +8,12 @@ from homeassistant.helpers import service
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_DEV_EN_ALT = "enable_alerts"
|
||||
_DEV_DS_ALT = "disable_alerts"
|
||||
_DEV_EN_REC = "start_recording"
|
||||
_DEV_DS_REC = "stop_recording"
|
||||
_DEV_SNAP = "snapshot"
|
||||
|
||||
CAMERA_SERVICES = {
|
||||
_DEV_EN_ALT: "async_enable_alerts",
|
||||
_DEV_DS_ALT: "async_disable_alerts",
|
||||
_DEV_EN_REC: "async_start_recording",
|
||||
_DEV_DS_REC: "async_stop_recording",
|
||||
_DEV_SNAP: "async_snapshot",
|
||||
"enable_alerts": "async_enable_alerts",
|
||||
"disable_alerts": "async_disable_alerts",
|
||||
"start_recording": "async_start_recording",
|
||||
"stop_recording": "async_stop_recording",
|
||||
"snapshot": "async_snapshot",
|
||||
}
|
||||
|
||||
|
||||
|
||||
32
homeassistant/components/aladdin_connect/diagnostics.py
Normal file
32
homeassistant/components/aladdin_connect/diagnostics.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""Diagnostics support for Aladdin Connect."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import AladdinConnectConfigEntry
|
||||
|
||||
TO_REDACT = {"access_token", "refresh_token"}
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, config_entry: AladdinConnectConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
return {
|
||||
"config_entry": async_redact_data(config_entry.as_dict(), TO_REDACT),
|
||||
"doors": {
|
||||
uid: {
|
||||
"device_id": coordinator.data.device_id,
|
||||
"door_number": coordinator.data.door_number,
|
||||
"name": coordinator.data.name,
|
||||
"status": coordinator.data.status,
|
||||
"link_status": coordinator.data.link_status,
|
||||
"battery_level": coordinator.data.battery_level,
|
||||
}
|
||||
for uid, coordinator in config_entry.runtime_data.items()
|
||||
},
|
||||
}
|
||||
@@ -45,7 +45,7 @@ rules:
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
diagnostics: done
|
||||
discovery: done
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
|
||||
@@ -13,9 +13,6 @@ from homeassistant.helpers import config_validation as cv, service
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
SERVICE_ALARM_TOGGLE_CHIME = "alarm_toggle_chime"
|
||||
|
||||
SERVICE_ALARM_KEYPRESS = "alarm_keypress"
|
||||
ATTR_KEYPRESS = "keypress"
|
||||
|
||||
|
||||
@@ -26,7 +23,7 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
||||
service.async_register_platform_entity_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
SERVICE_ALARM_TOGGLE_CHIME,
|
||||
"alarm_toggle_chime",
|
||||
entity_domain=ALARM_CONTROL_PANEL_DOMAIN,
|
||||
schema={
|
||||
vol.Required(ATTR_CODE): cv.string,
|
||||
@@ -37,7 +34,7 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
||||
service.async_register_platform_entity_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
SERVICE_ALARM_KEYPRESS,
|
||||
"alarm_keypress",
|
||||
entity_domain=ALARM_CONTROL_PANEL_DOMAIN,
|
||||
schema={
|
||||
vol.Required(ATTR_KEYPRESS): cv.string,
|
||||
|
||||
@@ -16,9 +16,6 @@ from .coordinator import AmazonConfigEntry
|
||||
ATTR_TEXT_COMMAND = "text_command"
|
||||
ATTR_SOUND = "sound"
|
||||
ATTR_INFO_SKILL = "info_skill"
|
||||
SERVICE_TEXT_COMMAND = "send_text_command"
|
||||
SERVICE_SOUND_NOTIFICATION = "send_sound"
|
||||
SERVICE_INFO_SKILL = "send_info_skill"
|
||||
|
||||
SCHEMA_SOUND_SERVICE = vol.Schema(
|
||||
{
|
||||
@@ -128,17 +125,17 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Set up the services for the Amazon Devices integration."""
|
||||
for service_name, method, schema in (
|
||||
(
|
||||
SERVICE_SOUND_NOTIFICATION,
|
||||
"send_sound",
|
||||
async_send_sound_notification,
|
||||
SCHEMA_SOUND_SERVICE,
|
||||
),
|
||||
(
|
||||
SERVICE_TEXT_COMMAND,
|
||||
"send_text_command",
|
||||
async_send_text_command,
|
||||
SCHEMA_CUSTOM_COMMAND,
|
||||
),
|
||||
(
|
||||
SERVICE_INFO_SKILL,
|
||||
"send_info_skill",
|
||||
async_send_info_skill,
|
||||
SCHEMA_INFO_SKILL,
|
||||
),
|
||||
|
||||
@@ -16,8 +16,6 @@ ATTRIBUTION = "Data provided by Amber Electric"
|
||||
LOGGER = logging.getLogger(__package__)
|
||||
PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR]
|
||||
|
||||
SERVICE_GET_FORECASTS = "get_forecasts"
|
||||
|
||||
GENERAL_CHANNEL = "general"
|
||||
CONTROLLED_LOAD_CHANNEL = "controlled_load"
|
||||
FEED_IN_CHANNEL = "feed_in"
|
||||
|
||||
@@ -22,7 +22,6 @@ from .const import (
|
||||
DOMAIN,
|
||||
FEED_IN_CHANNEL,
|
||||
GENERAL_CHANNEL,
|
||||
SERVICE_GET_FORECASTS,
|
||||
)
|
||||
from .coordinator import AmberConfigEntry
|
||||
from .helpers import format_cents_to_dollars, normalize_descriptor
|
||||
@@ -101,7 +100,7 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_GET_FORECASTS,
|
||||
"get_forecasts",
|
||||
handle_get_forecasts,
|
||||
GET_FORECASTS_SCHEMA,
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
|
||||
@@ -49,18 +49,6 @@ SCAN_INTERVAL = timedelta(seconds=15)
|
||||
|
||||
STREAM_SOURCE_LIST = ["snapshot", "mjpeg", "rtsp"]
|
||||
|
||||
_SRV_EN_REC = "enable_recording"
|
||||
_SRV_DS_REC = "disable_recording"
|
||||
_SRV_EN_AUD = "enable_audio"
|
||||
_SRV_DS_AUD = "disable_audio"
|
||||
_SRV_EN_MOT_REC = "enable_motion_recording"
|
||||
_SRV_DS_MOT_REC = "disable_motion_recording"
|
||||
_SRV_GOTO = "goto_preset"
|
||||
_SRV_CBW = "set_color_bw"
|
||||
_SRV_TOUR_ON = "start_tour"
|
||||
_SRV_TOUR_OFF = "stop_tour"
|
||||
|
||||
_SRV_PTZ_CTRL = "ptz_control"
|
||||
_ATTR_PTZ_TT = "travel_time"
|
||||
_ATTR_PTZ_MOV = "movement"
|
||||
_MOV = [
|
||||
@@ -103,17 +91,17 @@ _SRV_PTZ_SCHEMA = _SRV_SCHEMA.extend(
|
||||
)
|
||||
|
||||
CAMERA_SERVICES = {
|
||||
_SRV_EN_REC: (_SRV_SCHEMA, "async_enable_recording", ()),
|
||||
_SRV_DS_REC: (_SRV_SCHEMA, "async_disable_recording", ()),
|
||||
_SRV_EN_AUD: (_SRV_SCHEMA, "async_enable_audio", ()),
|
||||
_SRV_DS_AUD: (_SRV_SCHEMA, "async_disable_audio", ()),
|
||||
_SRV_EN_MOT_REC: (_SRV_SCHEMA, "async_enable_motion_recording", ()),
|
||||
_SRV_DS_MOT_REC: (_SRV_SCHEMA, "async_disable_motion_recording", ()),
|
||||
_SRV_GOTO: (_SRV_GOTO_SCHEMA, "async_goto_preset", (_ATTR_PRESET,)),
|
||||
_SRV_CBW: (_SRV_CBW_SCHEMA, "async_set_color_bw", (_ATTR_COLOR_BW,)),
|
||||
_SRV_TOUR_ON: (_SRV_SCHEMA, "async_start_tour", ()),
|
||||
_SRV_TOUR_OFF: (_SRV_SCHEMA, "async_stop_tour", ()),
|
||||
_SRV_PTZ_CTRL: (
|
||||
"enable_recording": (_SRV_SCHEMA, "async_enable_recording", ()),
|
||||
"disable_recording": (_SRV_SCHEMA, "async_disable_recording", ()),
|
||||
"enable_audio": (_SRV_SCHEMA, "async_enable_audio", ()),
|
||||
"disable_audio": (_SRV_SCHEMA, "async_disable_audio", ()),
|
||||
"enable_motion_recording": (_SRV_SCHEMA, "async_enable_motion_recording", ()),
|
||||
"disable_motion_recording": (_SRV_SCHEMA, "async_disable_motion_recording", ()),
|
||||
"goto_preset": (_SRV_GOTO_SCHEMA, "async_goto_preset", (_ATTR_PRESET,)),
|
||||
"set_color_bw": (_SRV_CBW_SCHEMA, "async_set_color_bw", (_ATTR_COLOR_BW,)),
|
||||
"start_tour": (_SRV_SCHEMA, "async_start_tour", ()),
|
||||
"stop_tour": (_SRV_SCHEMA, "async_stop_tour", ()),
|
||||
"ptz_control": (
|
||||
_SRV_PTZ_SCHEMA,
|
||||
"async_ptz_control",
|
||||
(_ATTR_PTZ_MOV, _ATTR_PTZ_TT),
|
||||
|
||||
@@ -36,7 +36,7 @@ from .const import (
|
||||
SIGNAL_CONFIG_ENTITY,
|
||||
)
|
||||
from .entity import AndroidTVEntity, adb_decorator
|
||||
from .services import ATTR_ADB_RESPONSE, ATTR_HDMI_INPUT, SERVICE_LEARN_SENDEVENT
|
||||
from .services import ATTR_ADB_RESPONSE, ATTR_HDMI_INPUT
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -271,7 +271,7 @@ class ADBDevice(AndroidTVEntity, MediaPlayerEntity):
|
||||
self.async_write_ha_state()
|
||||
|
||||
msg = (
|
||||
f"Output from service '{SERVICE_LEARN_SENDEVENT}' from"
|
||||
f"Output from service 'learn_sendevent' from"
|
||||
f" {self.entity_id}: '{output}'"
|
||||
)
|
||||
persistent_notification.async_create(
|
||||
|
||||
@@ -16,11 +16,6 @@ ATTR_DEVICE_PATH = "device_path"
|
||||
ATTR_HDMI_INPUT = "hdmi_input"
|
||||
ATTR_LOCAL_PATH = "local_path"
|
||||
|
||||
SERVICE_ADB_COMMAND = "adb_command"
|
||||
SERVICE_DOWNLOAD = "download"
|
||||
SERVICE_LEARN_SENDEVENT = "learn_sendevent"
|
||||
SERVICE_UPLOAD = "upload"
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
@@ -29,7 +24,7 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
||||
service.async_register_platform_entity_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
SERVICE_ADB_COMMAND,
|
||||
"adb_command",
|
||||
entity_domain=MEDIA_PLAYER_DOMAIN,
|
||||
schema={vol.Required(ATTR_COMMAND): cv.string},
|
||||
func="adb_command",
|
||||
@@ -37,7 +32,7 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
||||
service.async_register_platform_entity_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
SERVICE_LEARN_SENDEVENT,
|
||||
"learn_sendevent",
|
||||
entity_domain=MEDIA_PLAYER_DOMAIN,
|
||||
schema=None,
|
||||
func="learn_sendevent",
|
||||
@@ -45,7 +40,7 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
||||
service.async_register_platform_entity_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
SERVICE_DOWNLOAD,
|
||||
"download",
|
||||
entity_domain=MEDIA_PLAYER_DOMAIN,
|
||||
schema={
|
||||
vol.Required(ATTR_DEVICE_PATH): cv.string,
|
||||
@@ -56,7 +51,7 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
||||
service.async_register_platform_entity_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
SERVICE_UPLOAD,
|
||||
"upload",
|
||||
entity_domain=MEDIA_PLAYER_DOMAIN,
|
||||
schema={
|
||||
vol.Required(ATTR_DEVICE_PATH): cv.string,
|
||||
|
||||
@@ -858,6 +858,11 @@ class AnthropicBaseLLMEntity(Entity):
|
||||
]
|
||||
)
|
||||
messages.extend(new_messages)
|
||||
except anthropic.AuthenticationError as err:
|
||||
self.entry.async_start_reauth(self.hass)
|
||||
raise HomeAssistantError(
|
||||
"Authentication error with Anthropic API, reauthentication required"
|
||||
) from err
|
||||
except anthropic.AnthropicError as err:
|
||||
raise HomeAssistantError(
|
||||
f"Sorry, I had a problem talking to Anthropic: {err}"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"domain": "anthropic",
|
||||
"name": "Anthropic Conversation",
|
||||
"name": "Anthropic",
|
||||
"after_dependencies": ["assist_pipeline", "intent"],
|
||||
"codeowners": ["@Shulyaka"],
|
||||
"config_flow": true,
|
||||
|
||||
@@ -19,7 +19,7 @@ from homeassistant.components.backup import (
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
|
||||
from . import S3ConfigEntry
|
||||
from .const import CONF_BUCKET, DATA_BACKUP_AGENT_LISTENERS, DOMAIN
|
||||
from .const import CONF_BUCKET, CONF_PREFIX, DATA_BACKUP_AGENT_LISTENERS, DOMAIN
|
||||
from .helpers import async_list_backups_from_s3
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -100,6 +100,13 @@ class S3BackupAgent(BackupAgent):
|
||||
self.unique_id = entry.entry_id
|
||||
self._backup_cache: dict[str, AgentBackup] = {}
|
||||
self._cache_expiration = time()
|
||||
self._prefix: str = entry.data.get(CONF_PREFIX, "")
|
||||
|
||||
def _with_prefix(self, key: str) -> str:
|
||||
"""Add prefix to a key if configured."""
|
||||
if not self._prefix:
|
||||
return key
|
||||
return f"{self._prefix}/{key}"
|
||||
|
||||
@handle_boto_errors
|
||||
async def async_download_backup(
|
||||
@@ -115,7 +122,9 @@ class S3BackupAgent(BackupAgent):
|
||||
backup = await self._find_backup_by_id(backup_id)
|
||||
tar_filename, _ = suggested_filenames(backup)
|
||||
|
||||
response = await self._client.get_object(Bucket=self._bucket, Key=tar_filename)
|
||||
response = await self._client.get_object(
|
||||
Bucket=self._bucket, Key=self._with_prefix(tar_filename)
|
||||
)
|
||||
return response["Body"].iter_chunks()
|
||||
|
||||
async def async_upload_backup(
|
||||
@@ -142,7 +151,7 @@ class S3BackupAgent(BackupAgent):
|
||||
metadata_content = json.dumps(backup.as_dict())
|
||||
await self._client.put_object(
|
||||
Bucket=self._bucket,
|
||||
Key=metadata_filename,
|
||||
Key=self._with_prefix(metadata_filename),
|
||||
Body=metadata_content,
|
||||
)
|
||||
except BotoCoreError as err:
|
||||
@@ -169,7 +178,7 @@ class S3BackupAgent(BackupAgent):
|
||||
|
||||
await self._client.put_object(
|
||||
Bucket=self._bucket,
|
||||
Key=tar_filename,
|
||||
Key=self._with_prefix(tar_filename),
|
||||
Body=bytes(file_data),
|
||||
)
|
||||
|
||||
@@ -186,7 +195,7 @@ class S3BackupAgent(BackupAgent):
|
||||
_LOGGER.debug("Starting multipart upload for %s", tar_filename)
|
||||
multipart_upload = await self._client.create_multipart_upload(
|
||||
Bucket=self._bucket,
|
||||
Key=tar_filename,
|
||||
Key=self._with_prefix(tar_filename),
|
||||
)
|
||||
upload_id = multipart_upload["UploadId"]
|
||||
try:
|
||||
@@ -216,7 +225,7 @@ class S3BackupAgent(BackupAgent):
|
||||
)
|
||||
part = await cast(Any, self._client).upload_part(
|
||||
Bucket=self._bucket,
|
||||
Key=tar_filename,
|
||||
Key=self._with_prefix(tar_filename),
|
||||
PartNumber=part_number,
|
||||
UploadId=upload_id,
|
||||
Body=part_data.tobytes(),
|
||||
@@ -244,7 +253,7 @@ class S3BackupAgent(BackupAgent):
|
||||
)
|
||||
part = await cast(Any, self._client).upload_part(
|
||||
Bucket=self._bucket,
|
||||
Key=tar_filename,
|
||||
Key=self._with_prefix(tar_filename),
|
||||
PartNumber=part_number,
|
||||
UploadId=upload_id,
|
||||
Body=remaining_data.tobytes(),
|
||||
@@ -253,7 +262,7 @@ class S3BackupAgent(BackupAgent):
|
||||
|
||||
await cast(Any, self._client).complete_multipart_upload(
|
||||
Bucket=self._bucket,
|
||||
Key=tar_filename,
|
||||
Key=self._with_prefix(tar_filename),
|
||||
UploadId=upload_id,
|
||||
MultipartUpload={"Parts": parts},
|
||||
)
|
||||
@@ -262,7 +271,7 @@ class S3BackupAgent(BackupAgent):
|
||||
try:
|
||||
await self._client.abort_multipart_upload(
|
||||
Bucket=self._bucket,
|
||||
Key=tar_filename,
|
||||
Key=self._with_prefix(tar_filename),
|
||||
UploadId=upload_id,
|
||||
)
|
||||
except BotoCoreError:
|
||||
@@ -283,8 +292,12 @@ class S3BackupAgent(BackupAgent):
|
||||
tar_filename, metadata_filename = suggested_filenames(backup)
|
||||
|
||||
# Delete both the backup file and its metadata file
|
||||
await self._client.delete_object(Bucket=self._bucket, Key=tar_filename)
|
||||
await self._client.delete_object(Bucket=self._bucket, Key=metadata_filename)
|
||||
await self._client.delete_object(
|
||||
Bucket=self._bucket, Key=self._with_prefix(tar_filename)
|
||||
)
|
||||
await self._client.delete_object(
|
||||
Bucket=self._bucket, Key=self._with_prefix(metadata_filename)
|
||||
)
|
||||
|
||||
# Reset cache after successful deletion
|
||||
self._cache_expiration = time()
|
||||
@@ -317,7 +330,9 @@ class S3BackupAgent(BackupAgent):
|
||||
if time() <= self._cache_expiration:
|
||||
return self._backup_cache
|
||||
|
||||
backups_list = await async_list_backups_from_s3(self._client, self._bucket)
|
||||
backups_list = await async_list_backups_from_s3(
|
||||
self._client, self._bucket, self._prefix
|
||||
)
|
||||
self._backup_cache = {b.backup_id: b for b in backups_list}
|
||||
self._cache_expiration = time() + CACHE_TTL
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ from .const import (
|
||||
CONF_ACCESS_KEY_ID,
|
||||
CONF_BUCKET,
|
||||
CONF_ENDPOINT_URL,
|
||||
CONF_PREFIX,
|
||||
CONF_SECRET_ACCESS_KEY,
|
||||
DEFAULT_ENDPOINT_URL,
|
||||
DESCRIPTION_AWS_S3_DOCS_URL,
|
||||
@@ -39,6 +40,7 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
vol.Required(CONF_ENDPOINT_URL, default=DEFAULT_ENDPOINT_URL): TextSelector(
|
||||
config=TextSelectorConfig(type=TextSelectorType.URL)
|
||||
),
|
||||
vol.Optional(CONF_PREFIX, default=""): cv.string,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -53,12 +55,17 @@ class S3ConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
self._async_abort_entries_match(
|
||||
{
|
||||
CONF_BUCKET: user_input[CONF_BUCKET],
|
||||
CONF_ENDPOINT_URL: user_input[CONF_ENDPOINT_URL],
|
||||
}
|
||||
)
|
||||
normalized_prefix = user_input.get(CONF_PREFIX, "").strip("/")
|
||||
# Check for existing entries, treating missing prefix as empty
|
||||
for entry in self._async_current_entries(include_ignore=False):
|
||||
entry_prefix = (entry.data.get(CONF_PREFIX) or "").strip("/")
|
||||
if (
|
||||
entry.data.get(CONF_BUCKET) == user_input[CONF_BUCKET]
|
||||
and entry.data.get(CONF_ENDPOINT_URL)
|
||||
== user_input[CONF_ENDPOINT_URL]
|
||||
and entry_prefix == normalized_prefix
|
||||
):
|
||||
return self.async_abort(reason="already_configured")
|
||||
|
||||
hostname = urlparse(user_input[CONF_ENDPOINT_URL]).hostname
|
||||
if not hostname or not hostname.endswith(AWS_DOMAIN):
|
||||
@@ -83,9 +90,18 @@ class S3ConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
except ConnectionError:
|
||||
errors[CONF_ENDPOINT_URL] = "cannot_connect"
|
||||
else:
|
||||
return self.async_create_entry(
|
||||
title=user_input[CONF_BUCKET], data=user_input
|
||||
)
|
||||
data = dict(user_input)
|
||||
if not normalized_prefix:
|
||||
# Do not persist empty optional values
|
||||
data.pop(CONF_PREFIX, None)
|
||||
else:
|
||||
data[CONF_PREFIX] = normalized_prefix
|
||||
|
||||
title = user_input[CONF_BUCKET]
|
||||
if normalized_prefix:
|
||||
title = f"{title} - {normalized_prefix}"
|
||||
|
||||
return self.async_create_entry(title=title, data=data)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
|
||||
@@ -11,6 +11,7 @@ CONF_ACCESS_KEY_ID = "access_key_id"
|
||||
CONF_SECRET_ACCESS_KEY = "secret_access_key"
|
||||
CONF_ENDPOINT_URL = "endpoint_url"
|
||||
CONF_BUCKET = "bucket"
|
||||
CONF_PREFIX = "prefix"
|
||||
|
||||
AWS_DOMAIN = "amazonaws.com"
|
||||
DEFAULT_ENDPOINT_URL = f"https://s3.eu-central-1.{AWS_DOMAIN}/"
|
||||
|
||||
@@ -13,7 +13,7 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import CONF_BUCKET, DOMAIN
|
||||
from .const import CONF_BUCKET, CONF_PREFIX, DOMAIN
|
||||
from .helpers import async_list_backups_from_s3
|
||||
|
||||
SCAN_INTERVAL = timedelta(hours=6)
|
||||
@@ -53,11 +53,14 @@ class S3DataUpdateCoordinator(DataUpdateCoordinator[SensorData]):
|
||||
)
|
||||
self.client = client
|
||||
self._bucket: str = entry.data[CONF_BUCKET]
|
||||
self._prefix: str = entry.data.get(CONF_PREFIX, "")
|
||||
|
||||
async def _async_update_data(self) -> SensorData:
|
||||
"""Fetch data from AWS S3."""
|
||||
try:
|
||||
backups = await async_list_backups_from_s3(self.client, self._bucket)
|
||||
backups = await async_list_backups_from_s3(
|
||||
self.client, self._bucket, self._prefix
|
||||
)
|
||||
except BotoCoreError as error:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
|
||||
@@ -17,11 +17,17 @@ _LOGGER = logging.getLogger(__name__)
|
||||
async def async_list_backups_from_s3(
|
||||
client: S3Client,
|
||||
bucket: str,
|
||||
prefix: str,
|
||||
) -> list[AgentBackup]:
|
||||
"""List backups from an S3 bucket by reading metadata files."""
|
||||
paginator = client.get_paginator("list_objects_v2")
|
||||
metadata_files: list[dict[str, Any]] = []
|
||||
async for page in paginator.paginate(Bucket=bucket):
|
||||
|
||||
list_kwargs: dict[str, Any] = {"Bucket": bucket}
|
||||
if prefix:
|
||||
list_kwargs["Prefix"] = prefix + "/"
|
||||
|
||||
async for page in paginator.paginate(**list_kwargs):
|
||||
metadata_files.extend(
|
||||
obj
|
||||
for obj in page.get("Contents", [])
|
||||
|
||||
@@ -23,7 +23,9 @@ rules:
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
unique-config-entry:
|
||||
status: exempt
|
||||
comment: Hassfest does not recognize the duplicate prevention logic. Duplicate entries are prevented by checking bucket, endpoint URL, and prefix in the config flow.
|
||||
|
||||
# Silver
|
||||
action-exceptions:
|
||||
@@ -36,7 +38,7 @@ rules:
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: todo
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow: todo
|
||||
test-coverage: done
|
||||
|
||||
@@ -15,12 +15,14 @@
|
||||
"access_key_id": "Access key ID",
|
||||
"bucket": "Bucket name",
|
||||
"endpoint_url": "Endpoint URL",
|
||||
"prefix": "Prefix",
|
||||
"secret_access_key": "Secret access key"
|
||||
},
|
||||
"data_description": {
|
||||
"access_key_id": "Access key ID to connect to AWS S3 API",
|
||||
"bucket": "Bucket must already exist and be writable by the provided credentials.",
|
||||
"endpoint_url": "Endpoint URL provided to [Boto3 Session]({boto3_docs_url}). Region-specific [AWS S3 endpoints]({aws_s3_docs_url}) are available in their docs.",
|
||||
"prefix": "Folder or prefix to store backups in, for example `backups`",
|
||||
"secret_access_key": "Secret access key to connect to AWS S3 API"
|
||||
},
|
||||
"title": "Add AWS S3 bucket"
|
||||
|
||||
@@ -43,11 +43,11 @@
|
||||
"title": "The backup location {agent_id} is unavailable"
|
||||
},
|
||||
"automatic_backup_failed_addons": {
|
||||
"description": "Add-ons {failed_addons} could not be included in automatic backup. Please check the Supervisor logs for more information. Another attempt will be made at the next scheduled time if a backup schedule is configured.",
|
||||
"title": "Not all add-ons could be included in automatic backup"
|
||||
"description": "Apps {failed_addons} could not be included in automatic backup. Please check the Supervisor logs for more information. Another attempt will be made at the next scheduled time if a backup schedule is configured.",
|
||||
"title": "Not all apps could be included in automatic backup"
|
||||
},
|
||||
"automatic_backup_failed_agents_addons_folders": {
|
||||
"description": "The automatic backup was created with errors:\n* Locations which the backup could not be uploaded to: {failed_agents}\n* Add-ons which could not be backed up: {failed_addons}\n* Folders which could not be backed up: {failed_folders}\n\nPlease check the Core and Supervisor logs for more information. Another attempt will be made at the next scheduled time if a backup schedule is configured.",
|
||||
"description": "The automatic backup was created with errors:\n* Locations which the backup could not be uploaded to: {failed_agents}\n* Apps which could not be backed up: {failed_addons}\n* Folders which could not be backed up: {failed_folders}\n\nPlease check the Core and Supervisor logs for more information. Another attempt will be made at the next scheduled time if a backup schedule is configured.",
|
||||
"title": "Automatic backup was created with errors"
|
||||
},
|
||||
"automatic_backup_failed_create": {
|
||||
|
||||
@@ -12,6 +12,7 @@ from .coordinator import CompitConfigEntry, CompitDataUpdateCoordinator
|
||||
PLATFORMS = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.CLIMATE,
|
||||
Platform.FAN,
|
||||
Platform.NUMBER,
|
||||
Platform.SELECT,
|
||||
Platform.SENSOR,
|
||||
|
||||
172
homeassistant/components/compit/fan.py
Normal file
172
homeassistant/components/compit/fan.py
Normal file
@@ -0,0 +1,172 @@
|
||||
"""Fan platform for Compit integration."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from compit_inext_api import PARAM_VALUES
|
||||
from compit_inext_api.consts import CompitParameter
|
||||
|
||||
from homeassistant.components.fan import (
|
||||
FanEntity,
|
||||
FanEntityDescription,
|
||||
FanEntityFeature,
|
||||
)
|
||||
from homeassistant.const import STATE_OFF, STATE_ON
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from homeassistant.util.percentage import (
|
||||
ordered_list_item_to_percentage,
|
||||
percentage_to_ordered_list_item,
|
||||
)
|
||||
|
||||
from .const import DOMAIN, MANUFACTURER_NAME
|
||||
from .coordinator import CompitConfigEntry, CompitDataUpdateCoordinator
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
COMPIT_GEAR_TO_HA = PARAM_VALUES[CompitParameter.VENTILATION_GEAR_TARGET]
|
||||
HA_STATE_TO_COMPIT = {value: key for key, value in COMPIT_GEAR_TO_HA.items()}
|
||||
|
||||
|
||||
DEVICE_DEFINITIONS: dict[int, FanEntityDescription] = {
|
||||
223: FanEntityDescription(
|
||||
key="Nano Color 2",
|
||||
translation_key="ventilation",
|
||||
),
|
||||
12: FanEntityDescription(
|
||||
key="Nano Color",
|
||||
translation_key="ventilation",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: CompitConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Compit fan entities from a config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
async_add_entities(
|
||||
CompitFan(
|
||||
coordinator,
|
||||
device_id,
|
||||
device_definition,
|
||||
)
|
||||
for device_id, device in coordinator.connector.all_devices.items()
|
||||
if (device_definition := DEVICE_DEFINITIONS.get(device.definition.code))
|
||||
)
|
||||
|
||||
|
||||
class CompitFan(CoordinatorEntity[CompitDataUpdateCoordinator], FanEntity):
|
||||
"""Representation of a Compit fan entity."""
|
||||
|
||||
_attr_speed_count = len(COMPIT_GEAR_TO_HA)
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
_attr_supported_features = (
|
||||
FanEntityFeature.TURN_ON
|
||||
| FanEntityFeature.TURN_OFF
|
||||
| FanEntityFeature.SET_SPEED
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: CompitDataUpdateCoordinator,
|
||||
device_id: int,
|
||||
entity_description: FanEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the fan entity."""
|
||||
super().__init__(coordinator)
|
||||
self.device_id = device_id
|
||||
self.entity_description = entity_description
|
||||
self._attr_unique_id = f"{device_id}_{entity_description.key}"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, str(device_id))},
|
||||
name=entity_description.key,
|
||||
manufacturer=MANUFACTURER_NAME,
|
||||
model=entity_description.key,
|
||||
)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is available."""
|
||||
return (
|
||||
super().available
|
||||
and self.coordinator.connector.get_device(self.device_id) is not None
|
||||
)
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return true if the fan is on."""
|
||||
value = self.coordinator.connector.get_current_option(
|
||||
self.device_id, CompitParameter.VENTILATION_ON_OFF
|
||||
)
|
||||
|
||||
return True if value == STATE_ON else False if value == STATE_OFF else None
|
||||
|
||||
async def async_turn_on(
|
||||
self,
|
||||
percentage: int | None = None,
|
||||
preset_mode: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Turn on the fan."""
|
||||
await self.coordinator.connector.select_device_option(
|
||||
self.device_id, CompitParameter.VENTILATION_ON_OFF, STATE_ON
|
||||
)
|
||||
|
||||
if percentage is None:
|
||||
self.async_write_ha_state()
|
||||
return
|
||||
|
||||
await self.async_set_percentage(percentage)
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn off the fan."""
|
||||
await self.coordinator.connector.select_device_option(
|
||||
self.device_id, CompitParameter.VENTILATION_ON_OFF, STATE_OFF
|
||||
)
|
||||
self.async_write_ha_state()
|
||||
|
||||
@property
|
||||
def percentage(self) -> int | None:
|
||||
"""Return the current fan speed as a percentage."""
|
||||
if self.is_on is False:
|
||||
return 0
|
||||
mode = self.coordinator.connector.get_current_option(
|
||||
self.device_id, CompitParameter.VENTILATION_GEAR_TARGET
|
||||
)
|
||||
if mode is None:
|
||||
return None
|
||||
gear = COMPIT_GEAR_TO_HA.get(mode)
|
||||
return (
|
||||
None
|
||||
if gear is None
|
||||
else ordered_list_item_to_percentage(
|
||||
list(COMPIT_GEAR_TO_HA.values()),
|
||||
gear,
|
||||
)
|
||||
)
|
||||
|
||||
async def async_set_percentage(self, percentage: int) -> None:
|
||||
"""Set the fan speed."""
|
||||
if percentage == 0:
|
||||
await self.async_turn_off()
|
||||
return
|
||||
|
||||
gear = int(
|
||||
percentage_to_ordered_list_item(
|
||||
list(COMPIT_GEAR_TO_HA.values()),
|
||||
percentage,
|
||||
)
|
||||
)
|
||||
mode = HA_STATE_TO_COMPIT.get(gear)
|
||||
if mode is None:
|
||||
return
|
||||
|
||||
await self.coordinator.connector.select_device_option(
|
||||
self.device_id, CompitParameter.VENTILATION_GEAR_TARGET, mode
|
||||
)
|
||||
self.async_write_ha_state()
|
||||
@@ -20,6 +20,14 @@
|
||||
"default": "mdi:alert"
|
||||
}
|
||||
},
|
||||
"fan": {
|
||||
"ventilation": {
|
||||
"default": "mdi:fan",
|
||||
"state": {
|
||||
"off": "mdi:fan-off"
|
||||
}
|
||||
}
|
||||
},
|
||||
"number": {
|
||||
"boiler_target_temperature": {
|
||||
"default": "mdi:water-boiler"
|
||||
|
||||
@@ -53,6 +53,11 @@
|
||||
"name": "Temperature alert"
|
||||
}
|
||||
},
|
||||
"fan": {
|
||||
"ventilation": {
|
||||
"name": "[%key:component::fan::title%]"
|
||||
}
|
||||
},
|
||||
"number": {
|
||||
"boiler_target_temperature": {
|
||||
"name": "Boiler target temperature"
|
||||
@@ -324,8 +329,8 @@
|
||||
"nano_nr_3": "Nano 3",
|
||||
"nano_nr_4": "Nano 4",
|
||||
"nano_nr_5": "Nano 5",
|
||||
"off": "Off",
|
||||
"on": "On",
|
||||
"off": "[%key:common::state::off%]",
|
||||
"on": "[%key:common::state::on%]",
|
||||
"summer": "Summer",
|
||||
"winter": "Winter"
|
||||
}
|
||||
@@ -363,8 +368,8 @@
|
||||
"pump_status": {
|
||||
"name": "Pump status",
|
||||
"state": {
|
||||
"off": "Off",
|
||||
"on": "On"
|
||||
"off": "[%key:common::state::off%]",
|
||||
"on": "[%key:common::state::on%]"
|
||||
}
|
||||
},
|
||||
"return_circuit_temperature": {
|
||||
|
||||
@@ -115,7 +115,7 @@ def _zone_temperature_lists(device: Appliance) -> tuple[list[str], list[str]]:
|
||||
try:
|
||||
heating = device.represent(DAIKIN_ZONE_TEMP_HEAT)[1]
|
||||
cooling = device.represent(DAIKIN_ZONE_TEMP_COOL)[1]
|
||||
except AttributeError:
|
||||
except AttributeError, KeyError:
|
||||
return ([], [])
|
||||
return (list(heating or []), list(cooling or []))
|
||||
|
||||
|
||||
@@ -41,14 +41,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import (
|
||||
ATTR_DURATION,
|
||||
ATTR_DURATION_UNTIL,
|
||||
ATTR_PERIOD,
|
||||
ATTR_SETPOINT,
|
||||
EVOHOME_DATA,
|
||||
EvoService,
|
||||
)
|
||||
from .const import ATTR_DURATION, ATTR_PERIOD, ATTR_SETPOINT, EVOHOME_DATA, EvoService
|
||||
from .coordinator import EvoDataUpdateCoordinator
|
||||
from .entity import EvoChild, EvoEntity
|
||||
|
||||
@@ -179,20 +172,20 @@ class EvoZone(EvoChild, EvoClimateEntity):
|
||||
|
||||
async def async_zone_svc_request(self, service: str, data: dict[str, Any]) -> None:
|
||||
"""Process a service request (setpoint override) for a zone."""
|
||||
if service == EvoService.RESET_ZONE_OVERRIDE:
|
||||
if service == EvoService.CLEAR_ZONE_OVERRIDE:
|
||||
await self.coordinator.call_client_api(self._evo_device.reset())
|
||||
return
|
||||
|
||||
# otherwise it is EvoService.SET_ZONE_OVERRIDE
|
||||
temperature = max(min(data[ATTR_SETPOINT], self.max_temp), self.min_temp)
|
||||
|
||||
if ATTR_DURATION_UNTIL in data:
|
||||
duration: timedelta = data[ATTR_DURATION_UNTIL]
|
||||
if ATTR_DURATION in data:
|
||||
duration: timedelta = data[ATTR_DURATION]
|
||||
if duration.total_seconds() == 0:
|
||||
await self._update_schedule()
|
||||
until = self.setpoints.get("next_sp_from")
|
||||
else:
|
||||
until = dt_util.now() + data[ATTR_DURATION_UNTIL]
|
||||
until = dt_util.now() + data[ATTR_DURATION]
|
||||
else:
|
||||
until = None # indefinitely
|
||||
|
||||
|
||||
@@ -28,7 +28,6 @@ ATTR_PERIOD: Final = "period" # number of days
|
||||
ATTR_DURATION: Final = "duration" # number of minutes, <24h
|
||||
|
||||
ATTR_SETPOINT: Final = "setpoint"
|
||||
ATTR_DURATION_UNTIL: Final = "duration"
|
||||
|
||||
|
||||
@unique
|
||||
@@ -39,4 +38,4 @@ class EvoService(StrEnum):
|
||||
SET_SYSTEM_MODE = "set_system_mode"
|
||||
RESET_SYSTEM = "reset_system"
|
||||
SET_ZONE_OVERRIDE = "set_zone_override"
|
||||
RESET_ZONE_OVERRIDE = "clear_zone_override"
|
||||
CLEAR_ZONE_OVERRIDE = "clear_zone_override"
|
||||
|
||||
@@ -49,7 +49,7 @@ class EvoEntity(CoordinatorEntity[EvoDataUpdateCoordinator]):
|
||||
return
|
||||
if payload["service"] in (
|
||||
EvoService.SET_ZONE_OVERRIDE,
|
||||
EvoService.RESET_ZONE_OVERRIDE,
|
||||
EvoService.CLEAR_ZONE_OVERRIDE,
|
||||
):
|
||||
await self.async_zone_svc_request(payload["service"], payload["data"])
|
||||
return
|
||||
|
||||
@@ -19,20 +19,13 @@ from homeassistant.helpers import config_validation as cv, entity_registry as er
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.helpers.service import verify_domain_control
|
||||
|
||||
from .const import (
|
||||
ATTR_DURATION,
|
||||
ATTR_DURATION_UNTIL,
|
||||
ATTR_PERIOD,
|
||||
ATTR_SETPOINT,
|
||||
DOMAIN,
|
||||
EvoService,
|
||||
)
|
||||
from .const import ATTR_DURATION, ATTR_PERIOD, ATTR_SETPOINT, DOMAIN, EvoService
|
||||
from .coordinator import EvoDataUpdateCoordinator
|
||||
|
||||
# system mode schemas are built dynamically when the services are registered
|
||||
# because supported modes can vary for edge-case systems
|
||||
|
||||
RESET_ZONE_OVERRIDE_SCHEMA: Final = vol.Schema(
|
||||
CLEAR_ZONE_OVERRIDE_SCHEMA: Final = vol.Schema(
|
||||
{vol.Required(ATTR_ENTITY_ID): cv.entity_id}
|
||||
)
|
||||
SET_ZONE_OVERRIDE_SCHEMA: Final = vol.Schema(
|
||||
@@ -41,7 +34,7 @@ SET_ZONE_OVERRIDE_SCHEMA: Final = vol.Schema(
|
||||
vol.Required(ATTR_SETPOINT): vol.All(
|
||||
vol.Coerce(float), vol.Range(min=4.0, max=35.0)
|
||||
),
|
||||
vol.Optional(ATTR_DURATION_UNTIL): vol.All(
|
||||
vol.Optional(ATTR_DURATION): vol.All(
|
||||
cv.time_period,
|
||||
vol.Range(min=timedelta(days=0), max=timedelta(days=1)),
|
||||
),
|
||||
@@ -166,9 +159,9 @@ def setup_service_functions(
|
||||
# The zone modes are consistent across all systems and use the same schema
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
EvoService.RESET_ZONE_OVERRIDE,
|
||||
EvoService.CLEAR_ZONE_OVERRIDE,
|
||||
set_zone_override,
|
||||
schema=RESET_ZONE_OVERRIDE_SCHEMA,
|
||||
schema=CLEAR_ZONE_OVERRIDE_SCHEMA,
|
||||
)
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
|
||||
@@ -21,5 +21,5 @@
|
||||
"integration_type": "system",
|
||||
"preview_features": { "winter_mode": {} },
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20260225.0"]
|
||||
"requirements": ["home-assistant-frontend==20260226.0"]
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ from homeassistant.components.homeassistant import async_set_stop_handler
|
||||
from homeassistant.components.http import StaticPathConfig
|
||||
from homeassistant.config_entries import SOURCE_SYSTEM, ConfigEntry
|
||||
from homeassistant.const import (
|
||||
ATTR_DEVICE_ID,
|
||||
ATTR_NAME,
|
||||
EVENT_CORE_CONFIG_UPDATE,
|
||||
HASSIO_USER_NAME,
|
||||
@@ -34,11 +35,13 @@ from homeassistant.core import (
|
||||
async_get_hass_or_none,
|
||||
callback,
|
||||
)
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers import (
|
||||
config_validation as cv,
|
||||
device_registry as dr,
|
||||
discovery_flow,
|
||||
issue_registry as ir,
|
||||
selector,
|
||||
)
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.event import async_call_later
|
||||
@@ -92,6 +95,7 @@ from .const import (
|
||||
DATA_SUPERVISOR_INFO,
|
||||
DOMAIN,
|
||||
HASSIO_UPDATE_INTERVAL,
|
||||
SupervisorEntityModel,
|
||||
)
|
||||
from .coordinator import (
|
||||
HassioDataUpdateCoordinator,
|
||||
@@ -147,6 +151,7 @@ SERVICE_BACKUP_FULL = "backup_full"
|
||||
SERVICE_BACKUP_PARTIAL = "backup_partial"
|
||||
SERVICE_RESTORE_FULL = "restore_full"
|
||||
SERVICE_RESTORE_PARTIAL = "restore_partial"
|
||||
SERVICE_MOUNT_RELOAD = "mount_reload"
|
||||
|
||||
VALID_ADDON_SLUG = vol.Match(re.compile(r"^[-_.A-Za-z0-9]+$"))
|
||||
|
||||
@@ -229,6 +234,19 @@ SCHEMA_RESTORE_PARTIAL = SCHEMA_RESTORE_FULL.extend(
|
||||
}
|
||||
)
|
||||
|
||||
SCHEMA_MOUNT_RELOAD = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_DEVICE_ID): selector.DeviceSelector(
|
||||
selector.DeviceSelectorConfig(
|
||||
filter=selector.DeviceFilterSelectorConfig(
|
||||
integration=DOMAIN,
|
||||
model=SupervisorEntityModel.MOUNT,
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _is_32_bit() -> bool:
|
||||
size = struct.calcsize("P")
|
||||
@@ -444,6 +462,42 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
|
||||
DOMAIN, service, async_service_handler, schema=settings.schema
|
||||
)
|
||||
|
||||
dev_reg = dr.async_get(hass)
|
||||
|
||||
async def async_mount_reload(service: ServiceCall) -> None:
|
||||
"""Handle service calls for Hass.io."""
|
||||
coordinator: HassioDataUpdateCoordinator | None = None
|
||||
|
||||
if (device := dev_reg.async_get(service.data[ATTR_DEVICE_ID])) is None:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="mount_reload_unknown_device_id",
|
||||
)
|
||||
|
||||
if (
|
||||
device.name is None
|
||||
or device.model != SupervisorEntityModel.MOUNT
|
||||
or (coordinator := hass.data.get(ADDONS_COORDINATOR)) is None
|
||||
or coordinator.entry_id not in device.config_entries
|
||||
):
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="mount_reload_invalid_device",
|
||||
)
|
||||
|
||||
try:
|
||||
await supervisor_client.mounts.reload_mount(device.name)
|
||||
except SupervisorError as error:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="mount_reload_error",
|
||||
translation_placeholders={"name": device.name, "error": str(error)},
|
||||
) from error
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_MOUNT_RELOAD, async_mount_reload, SCHEMA_MOUNT_RELOAD
|
||||
)
|
||||
|
||||
async def update_info_data(_: datetime | None = None) -> None:
|
||||
"""Update last available supervisor information."""
|
||||
supervisor_client = get_supervisor_client(hass)
|
||||
|
||||
@@ -46,6 +46,9 @@
|
||||
"host_shutdown": {
|
||||
"service": "mdi:power"
|
||||
},
|
||||
"mount_reload": {
|
||||
"service": "mdi:reload"
|
||||
},
|
||||
"restore_full": {
|
||||
"service": "mdi:backup-restore"
|
||||
},
|
||||
|
||||
@@ -165,3 +165,13 @@ restore_partial:
|
||||
example: "password"
|
||||
selector:
|
||||
text:
|
||||
|
||||
mount_reload:
|
||||
fields:
|
||||
device_id:
|
||||
required: true
|
||||
selector:
|
||||
device:
|
||||
filter:
|
||||
integration: hassio
|
||||
model: Home Assistant Mount
|
||||
|
||||
@@ -43,6 +43,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"mount_reload_error": {
|
||||
"message": "Failed to reload mount {name}: {error}"
|
||||
},
|
||||
"mount_reload_invalid_device": {
|
||||
"message": "Device is not a supervisor mount point"
|
||||
},
|
||||
"mount_reload_unknown_device_id": {
|
||||
"message": "Device ID not found"
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"issue_addon_boot_fail": {
|
||||
"fix_flow": {
|
||||
@@ -456,6 +467,16 @@
|
||||
"description": "Powers off the host system.",
|
||||
"name": "Power off the host system"
|
||||
},
|
||||
"mount_reload": {
|
||||
"description": "Reloads a network storage mount.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"description": "The device ID of the network storage mount to reload.",
|
||||
"name": "Device ID"
|
||||
}
|
||||
},
|
||||
"name": "Reload network storage mount"
|
||||
},
|
||||
"restore_full": {
|
||||
"description": "Restores from full backup.",
|
||||
"fields": {
|
||||
|
||||
@@ -16,7 +16,14 @@ from homeassistant.helpers.helper_integration import (
|
||||
)
|
||||
from homeassistant.helpers.template import Template
|
||||
|
||||
from .const import CONF_DURATION, CONF_END, CONF_START, PLATFORMS
|
||||
from .const import (
|
||||
CONF_DURATION,
|
||||
CONF_END,
|
||||
CONF_MIN_STATE_DURATION,
|
||||
CONF_START,
|
||||
PLATFORMS,
|
||||
SECTION_ADVANCED_SETTINGS,
|
||||
)
|
||||
from .coordinator import HistoryStatsUpdateCoordinator
|
||||
from .data import HistoryStats
|
||||
|
||||
@@ -36,8 +43,14 @@ async def async_setup_entry(
|
||||
end: str | None = entry.options.get(CONF_END)
|
||||
|
||||
duration: timedelta | None = None
|
||||
min_state_duration: timedelta
|
||||
if duration_dict := entry.options.get(CONF_DURATION):
|
||||
duration = timedelta(**duration_dict)
|
||||
advanced_settings = entry.options.get(SECTION_ADVANCED_SETTINGS, {})
|
||||
if min_state_duration_dict := advanced_settings.get(CONF_MIN_STATE_DURATION):
|
||||
min_state_duration = timedelta(**min_state_duration_dict)
|
||||
else:
|
||||
min_state_duration = timedelta(0)
|
||||
|
||||
history_stats = HistoryStats(
|
||||
hass,
|
||||
@@ -46,6 +59,7 @@ async def async_setup_entry(
|
||||
Template(start, hass) if start else None,
|
||||
Template(end, hass) if end else None,
|
||||
duration,
|
||||
min_state_duration,
|
||||
)
|
||||
coordinator = HistoryStatsUpdateCoordinator(hass, history_stats, entry, entry.title)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
@@ -12,6 +12,7 @@ from homeassistant.components import websocket_api
|
||||
from homeassistant.components.sensor import CONF_STATE_CLASS, SensorStateClass
|
||||
from homeassistant.const import CONF_ENTITY_ID, CONF_NAME, CONF_STATE, CONF_TYPE
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.data_entry_flow import section
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.schema_config_entry_flow import (
|
||||
SchemaCommonFlowHandler,
|
||||
@@ -37,6 +38,7 @@ from homeassistant.helpers.template import Template
|
||||
from .const import (
|
||||
CONF_DURATION,
|
||||
CONF_END,
|
||||
CONF_MIN_STATE_DURATION,
|
||||
CONF_PERIOD_KEYS,
|
||||
CONF_START,
|
||||
CONF_TYPE_KEYS,
|
||||
@@ -44,6 +46,7 @@ from .const import (
|
||||
CONF_TYPE_TIME,
|
||||
DEFAULT_NAME,
|
||||
DOMAIN,
|
||||
SECTION_ADVANCED_SETTINGS,
|
||||
)
|
||||
from .coordinator import HistoryStatsUpdateCoordinator
|
||||
from .data import HistoryStats
|
||||
@@ -139,7 +142,7 @@ def _get_options_schema_with_entity_id(entity_id: str, type: str) -> vol.Schema:
|
||||
vol.Optional(CONF_START): TemplateSelector(),
|
||||
vol.Optional(CONF_END): TemplateSelector(),
|
||||
vol.Optional(CONF_DURATION): DurationSelector(
|
||||
DurationSelectorConfig(enable_day=True, allow_negative=False)
|
||||
DurationSelectorConfig(enable_day=True, allow_negative=False),
|
||||
),
|
||||
vol.Optional(CONF_STATE_CLASS): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
@@ -148,6 +151,18 @@ def _get_options_schema_with_entity_id(entity_id: str, type: str) -> vol.Schema:
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
),
|
||||
),
|
||||
vol.Optional(SECTION_ADVANCED_SETTINGS): section(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_MIN_STATE_DURATION): DurationSelector(
|
||||
DurationSelectorConfig(
|
||||
enable_day=True, allow_negative=False
|
||||
)
|
||||
),
|
||||
}
|
||||
),
|
||||
{"collapsed": True},
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -275,6 +290,8 @@ async def ws_start_preview(
|
||||
start = validated_data.get(CONF_START)
|
||||
end = validated_data.get(CONF_END)
|
||||
duration = validated_data.get(CONF_DURATION)
|
||||
advanced_settings = validated_data.get(SECTION_ADVANCED_SETTINGS, {})
|
||||
min_state_duration = advanced_settings.get(CONF_MIN_STATE_DURATION)
|
||||
state_class = validated_data.get(CONF_STATE_CLASS)
|
||||
|
||||
history_stats = HistoryStats(
|
||||
@@ -284,6 +301,7 @@ async def ws_start_preview(
|
||||
Template(start, hass) if start else None,
|
||||
Template(end, hass) if end else None,
|
||||
timedelta(**duration) if duration else None,
|
||||
timedelta(**min_state_duration) if min_state_duration else timedelta(0),
|
||||
True,
|
||||
)
|
||||
coordinator = HistoryStatsUpdateCoordinator(hass, history_stats, None, name, True)
|
||||
|
||||
@@ -8,6 +8,7 @@ PLATFORMS = [Platform.SENSOR]
|
||||
CONF_START = "start"
|
||||
CONF_END = "end"
|
||||
CONF_DURATION = "duration"
|
||||
CONF_MIN_STATE_DURATION = "min_state_duration"
|
||||
CONF_PERIOD_KEYS = [CONF_START, CONF_END, CONF_DURATION]
|
||||
|
||||
CONF_TYPE_TIME = "time"
|
||||
@@ -16,3 +17,5 @@ CONF_TYPE_COUNT = "count"
|
||||
CONF_TYPE_KEYS = [CONF_TYPE_TIME, CONF_TYPE_RATIO, CONF_TYPE_COUNT]
|
||||
|
||||
DEFAULT_NAME = "unnamed statistics"
|
||||
|
||||
SECTION_ADVANCED_SETTINGS = "advanced_settings"
|
||||
|
||||
@@ -47,6 +47,7 @@ class HistoryStats:
|
||||
start: Template | None,
|
||||
end: Template | None,
|
||||
duration: datetime.timedelta | None,
|
||||
min_state_duration: datetime.timedelta,
|
||||
preview: bool = False,
|
||||
) -> None:
|
||||
"""Init the history stats manager."""
|
||||
@@ -58,6 +59,7 @@ class HistoryStats:
|
||||
self._has_recorder_data = False
|
||||
self._entity_states = set(entity_states)
|
||||
self._duration = duration
|
||||
self._min_state_duration = min_state_duration.total_seconds()
|
||||
self._start = start
|
||||
self._end = end
|
||||
self._preview = preview
|
||||
@@ -243,18 +245,38 @@ class HistoryStats:
|
||||
)
|
||||
break
|
||||
|
||||
if previous_state_matches:
|
||||
elapsed += state_change_timestamp - last_state_change_timestamp
|
||||
elif current_state_matches:
|
||||
match_count += 1
|
||||
if not previous_state_matches and current_state_matches:
|
||||
# We are entering a matching state.
|
||||
# This marks the start of a new candidate block that may later
|
||||
# qualify if it lasts at least min_state_duration.
|
||||
last_state_change_timestamp = max(
|
||||
start_timestamp, state_change_timestamp
|
||||
)
|
||||
elif previous_state_matches and not current_state_matches:
|
||||
# We are leaving a matching state.
|
||||
# This closes the current matching block and allows to
|
||||
# evaluate its total duration.
|
||||
block_duration = state_change_timestamp - last_state_change_timestamp
|
||||
if block_duration >= self._min_state_duration:
|
||||
# The block lasted long enough so we increment match count
|
||||
# and accumulate its duration.
|
||||
elapsed += block_duration
|
||||
match_count += 1
|
||||
|
||||
previous_state_matches = current_state_matches
|
||||
last_state_change_timestamp = max(start_timestamp, state_change_timestamp)
|
||||
|
||||
# Count time elapsed between last history state and end of measure
|
||||
if previous_state_matches:
|
||||
# We are still inside a matching block at the end of the
|
||||
# measurement window. This block has not been closed by a
|
||||
# transition, so we evaluate it up to measure_end.
|
||||
measure_end = min(end_timestamp, now_timestamp)
|
||||
elapsed += measure_end - last_state_change_timestamp
|
||||
last_state_duration = max(0, measure_end - last_state_change_timestamp)
|
||||
if last_state_duration >= self._min_state_duration:
|
||||
# The open block lasted long enough so we increment match count
|
||||
# and accumulate its duration.
|
||||
elapsed += last_state_duration
|
||||
match_count += 1
|
||||
|
||||
# Save value in seconds
|
||||
seconds_matched = elapsed
|
||||
|
||||
@@ -42,6 +42,7 @@ from . import HistoryStatsConfigEntry
|
||||
from .const import (
|
||||
CONF_DURATION,
|
||||
CONF_END,
|
||||
CONF_MIN_STATE_DURATION,
|
||||
CONF_PERIOD_KEYS,
|
||||
CONF_START,
|
||||
CONF_TYPE_COUNT,
|
||||
@@ -63,6 +64,8 @@ UNITS: dict[str, str] = {
|
||||
}
|
||||
ICON = "mdi:chart-line"
|
||||
|
||||
DEFAULT_MIN_STATE_DURATION = datetime.timedelta(0)
|
||||
|
||||
|
||||
def exactly_two_period_keys[_T: dict[str, Any]](conf: _T) -> _T:
|
||||
"""Ensure exactly 2 of CONF_PERIOD_KEYS are provided."""
|
||||
@@ -91,6 +94,9 @@ PLATFORM_SCHEMA = vol.All(
|
||||
vol.Optional(CONF_START): cv.template,
|
||||
vol.Optional(CONF_END): cv.template,
|
||||
vol.Optional(CONF_DURATION): cv.time_period,
|
||||
vol.Optional(
|
||||
CONF_MIN_STATE_DURATION, default=DEFAULT_MIN_STATE_DURATION
|
||||
): cv.time_period,
|
||||
vol.Optional(CONF_TYPE, default=CONF_TYPE_TIME): vol.In(CONF_TYPE_KEYS),
|
||||
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
|
||||
vol.Optional(CONF_UNIQUE_ID): cv.string,
|
||||
@@ -120,6 +126,7 @@ async def async_setup_platform(
|
||||
start: Template | None = config.get(CONF_START)
|
||||
end: Template | None = config.get(CONF_END)
|
||||
duration: datetime.timedelta | None = config.get(CONF_DURATION)
|
||||
min_state_duration: datetime.timedelta = config[CONF_MIN_STATE_DURATION]
|
||||
sensor_type: str = config[CONF_TYPE]
|
||||
name: str = config[CONF_NAME]
|
||||
unique_id: str | None = config.get(CONF_UNIQUE_ID)
|
||||
@@ -127,7 +134,9 @@ async def async_setup_platform(
|
||||
CONF_STATE_CLASS, SensorStateClass.MEASUREMENT
|
||||
)
|
||||
|
||||
history_stats = HistoryStats(hass, entity_id, entity_states, start, end, duration)
|
||||
history_stats = HistoryStats(
|
||||
hass, entity_id, entity_states, start, end, duration, min_state_duration
|
||||
)
|
||||
coordinator = HistoryStatsUpdateCoordinator(hass, history_stats, None, name)
|
||||
await coordinator.async_refresh()
|
||||
if not coordinator.last_update_success:
|
||||
|
||||
@@ -19,14 +19,23 @@
|
||||
},
|
||||
"data_description": {
|
||||
"duration": "Duration of the measure.",
|
||||
"end": "When to stop the measure (timestamp or datetime). Can be a template",
|
||||
"end": "When to stop the measure (timestamp or datetime). Can be a template.",
|
||||
"entity_id": "[%key:component::history_stats::config::step::user::data_description::entity_id%]",
|
||||
"start": "When to start the measure (timestamp or datetime). Can be a template.",
|
||||
"state": "[%key:component::history_stats::config::step::user::data_description::state%]",
|
||||
"state_class": "The state class for statistics calculation.",
|
||||
"type": "[%key:component::history_stats::config::step::user::data_description::type%]"
|
||||
},
|
||||
"description": "Read the documentation for further details on how to configure the history stats sensor using these options."
|
||||
"description": "Read the documentation for further details on how to configure the history stats sensor using these options.",
|
||||
"sections": {
|
||||
"advanced_settings": {
|
||||
"data": { "min_state_duration": "Minimum state duration" },
|
||||
"data_description": {
|
||||
"min_state_duration": "The minimum state duration to account for the statistics. Default is 0 seconds."
|
||||
},
|
||||
"name": "Advanced settings"
|
||||
}
|
||||
}
|
||||
},
|
||||
"state": {
|
||||
"data": {
|
||||
@@ -82,7 +91,18 @@
|
||||
"state_class": "The state class for statistics calculation. Changing the state class will require statistics to be reset.",
|
||||
"type": "[%key:component::history_stats::config::step::user::data_description::type%]"
|
||||
},
|
||||
"description": "[%key:component::history_stats::config::step::options::description%]"
|
||||
"description": "[%key:component::history_stats::config::step::options::description%]",
|
||||
"sections": {
|
||||
"advanced_settings": {
|
||||
"data": {
|
||||
"min_state_duration": "[%key:component::history_stats::config::step::options::sections::advanced_settings::data::min_state_duration%]"
|
||||
},
|
||||
"data_description": {
|
||||
"min_state_duration": "[%key:component::history_stats::config::step::options::sections::advanced_settings::data_description::min_state_duration%]"
|
||||
},
|
||||
"name": "[%key:component::history_stats::config::step::options::sections::advanced_settings::name%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
153
homeassistant/components/infrared/__init__.py
Normal file
153
homeassistant/components/infrared/__init__.py
Normal file
@@ -0,0 +1,153 @@
|
||||
"""Provides functionality to interact with infrared devices."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import abstractmethod
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import final
|
||||
|
||||
from infrared_protocols import Command as InfraredCommand
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import STATE_UNAVAILABLE
|
||||
from homeassistant.core import Context, HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv, entity_registry as er
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util import dt as dt_util
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
__all__ = [
|
||||
"DOMAIN",
|
||||
"InfraredEntity",
|
||||
"InfraredEntityDescription",
|
||||
"async_get_emitters",
|
||||
"async_send_command",
|
||||
]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DATA_COMPONENT: HassKey[EntityComponent[InfraredEntity]] = HassKey(DOMAIN)
|
||||
ENTITY_ID_FORMAT = DOMAIN + ".{}"
|
||||
PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA
|
||||
PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE
|
||||
SCAN_INTERVAL = timedelta(seconds=30)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the infrared domain."""
|
||||
component = hass.data[DATA_COMPONENT] = EntityComponent[InfraredEntity](
|
||||
_LOGGER, DOMAIN, hass, SCAN_INTERVAL
|
||||
)
|
||||
await component.async_setup(config)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up a config entry."""
|
||||
return await hass.data[DATA_COMPONENT].async_setup_entry(entry)
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.data[DATA_COMPONENT].async_unload_entry(entry)
|
||||
|
||||
|
||||
@callback
|
||||
def async_get_emitters(hass: HomeAssistant) -> list[InfraredEntity]:
|
||||
"""Get all infrared emitters."""
|
||||
component = hass.data.get(DATA_COMPONENT)
|
||||
if component is None:
|
||||
return []
|
||||
|
||||
return list(component.entities)
|
||||
|
||||
|
||||
async def async_send_command(
|
||||
hass: HomeAssistant,
|
||||
entity_id_or_uuid: str,
|
||||
command: InfraredCommand,
|
||||
context: Context | None = None,
|
||||
) -> None:
|
||||
"""Send an IR command to the specified infrared entity.
|
||||
|
||||
Raises:
|
||||
HomeAssistantError: If the infrared entity is not found.
|
||||
"""
|
||||
component = hass.data.get(DATA_COMPONENT)
|
||||
if component is None:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="component_not_loaded",
|
||||
)
|
||||
|
||||
ent_reg = er.async_get(hass)
|
||||
entity_id = er.async_validate_entity_id(ent_reg, entity_id_or_uuid)
|
||||
entity = component.get_entity(entity_id)
|
||||
if entity is None:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="entity_not_found",
|
||||
translation_placeholders={"entity_id": entity_id},
|
||||
)
|
||||
|
||||
if context is not None:
|
||||
entity.async_set_context(context)
|
||||
|
||||
await entity.async_send_command_internal(command)
|
||||
|
||||
|
||||
class InfraredEntityDescription(EntityDescription, frozen_or_thawed=True):
|
||||
"""Describes infrared entities."""
|
||||
|
||||
|
||||
class InfraredEntity(RestoreEntity):
|
||||
"""Base class for infrared transmitter entities."""
|
||||
|
||||
entity_description: InfraredEntityDescription
|
||||
_attr_should_poll = False
|
||||
_attr_state: None = None
|
||||
|
||||
__last_command_sent: str | None = None
|
||||
|
||||
@property
|
||||
@final
|
||||
def state(self) -> str | None:
|
||||
"""Return the entity state."""
|
||||
return self.__last_command_sent
|
||||
|
||||
@final
|
||||
async def async_send_command_internal(self, command: InfraredCommand) -> None:
|
||||
"""Send an IR command and update state.
|
||||
|
||||
Should not be overridden, handles setting last sent timestamp.
|
||||
"""
|
||||
await self.async_send_command(command)
|
||||
self.__last_command_sent = dt_util.utcnow().isoformat(timespec="milliseconds")
|
||||
self.async_write_ha_state()
|
||||
|
||||
@final
|
||||
async def async_internal_added_to_hass(self) -> None:
|
||||
"""Call when the infrared entity is added to hass."""
|
||||
await super().async_internal_added_to_hass()
|
||||
state = await self.async_get_last_state()
|
||||
if state is not None and state.state not in (STATE_UNAVAILABLE, None):
|
||||
self.__last_command_sent = state.state
|
||||
|
||||
@abstractmethod
|
||||
async def async_send_command(self, command: InfraredCommand) -> None:
|
||||
"""Send an IR command.
|
||||
|
||||
Args:
|
||||
command: The IR command to send.
|
||||
|
||||
Raises:
|
||||
HomeAssistantError: If transmission fails.
|
||||
"""
|
||||
5
homeassistant/components/infrared/const.py
Normal file
5
homeassistant/components/infrared/const.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Constants for the Infrared integration."""
|
||||
|
||||
from typing import Final
|
||||
|
||||
DOMAIN: Final = "infrared"
|
||||
7
homeassistant/components/infrared/icons.json
Normal file
7
homeassistant/components/infrared/icons.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"entity_component": {
|
||||
"_": {
|
||||
"default": "mdi:led-on"
|
||||
}
|
||||
}
|
||||
}
|
||||
9
homeassistant/components/infrared/manifest.json
Normal file
9
homeassistant/components/infrared/manifest.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"domain": "infrared",
|
||||
"name": "Infrared",
|
||||
"codeowners": ["@home-assistant/core"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/infrared",
|
||||
"integration_type": "entity",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["infrared-protocols==1.0.0"]
|
||||
}
|
||||
10
homeassistant/components/infrared/strings.json
Normal file
10
homeassistant/components/infrared/strings.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"exceptions": {
|
||||
"component_not_loaded": {
|
||||
"message": "Infrared component not loaded"
|
||||
},
|
||||
"entity_not_found": {
|
||||
"message": "Infrared entity `{entity_id}` not found"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,7 +56,9 @@ from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN
|
||||
|
||||
COMPONENTS_WITH_DEMO_PLATFORM = [
|
||||
Platform.BUTTON,
|
||||
Platform.FAN,
|
||||
Platform.IMAGE,
|
||||
Platform.INFRARED,
|
||||
Platform.LAWN_MOWER,
|
||||
Platform.LOCK,
|
||||
Platform.NOTIFY,
|
||||
@@ -131,6 +133,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
# Notify backup listeners
|
||||
hass.async_create_task(_notify_backup_listeners(hass), eager_start=False)
|
||||
|
||||
# Reload config entry when subentries are added/removed/updated
|
||||
entry.async_on_unload(entry.add_update_listener(_async_update_listener))
|
||||
|
||||
# Subscribe to labs feature updates for kitchen_sink preview repair
|
||||
entry.async_on_unload(
|
||||
async_subscribe_preview_feature(
|
||||
@@ -147,6 +152,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Reload config entry on update (e.g. subentry added/removed)."""
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload config entry."""
|
||||
# Notify backup listeners
|
||||
|
||||
@@ -8,18 +8,23 @@ from typing import Any
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import data_entry_flow
|
||||
from homeassistant.components.infrared import (
|
||||
DOMAIN as INFRARED_DOMAIN,
|
||||
async_get_emitters,
|
||||
)
|
||||
from homeassistant.config_entries import (
|
||||
ConfigEntry,
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
ConfigSubentryFlow,
|
||||
OptionsFlowWithReload,
|
||||
OptionsFlow,
|
||||
SubentryFlowResult,
|
||||
)
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.selector import EntitySelector, EntitySelectorConfig
|
||||
|
||||
from . import DOMAIN
|
||||
from .const import CONF_INFRARED_ENTITY_ID, DOMAIN
|
||||
|
||||
CONF_BOOLEAN = "bool"
|
||||
CONF_INT = "int"
|
||||
@@ -44,7 +49,10 @@ class KitchenSinkConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
cls, config_entry: ConfigEntry
|
||||
) -> dict[str, type[ConfigSubentryFlow]]:
|
||||
"""Return subentries supported by this handler."""
|
||||
return {"entity": SubentryFlowHandler}
|
||||
return {
|
||||
"entity": SubentryFlowHandler,
|
||||
"infrared_fan": InfraredFanSubentryFlowHandler,
|
||||
}
|
||||
|
||||
async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
|
||||
"""Set the config entry up from yaml."""
|
||||
@@ -65,7 +73,7 @@ class KitchenSinkConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
return self.async_abort(reason="reauth_successful")
|
||||
|
||||
|
||||
class OptionsFlowHandler(OptionsFlowWithReload):
|
||||
class OptionsFlowHandler(OptionsFlow):
|
||||
"""Handle options."""
|
||||
|
||||
async def async_step_init(
|
||||
@@ -146,7 +154,7 @@ class SubentryFlowHandler(ConfigSubentryFlow):
|
||||
"""Reconfigure a sensor."""
|
||||
if user_input is not None:
|
||||
title = user_input.pop("name")
|
||||
return self.async_update_reload_and_abort(
|
||||
return self.async_update_and_abort(
|
||||
self._get_entry(),
|
||||
self._get_reconfigure_subentry(),
|
||||
data=user_input,
|
||||
@@ -162,3 +170,35 @@ class SubentryFlowHandler(ConfigSubentryFlow):
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class InfraredFanSubentryFlowHandler(ConfigSubentryFlow):
|
||||
"""Handle infrared fan subentry flow."""
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> SubentryFlowResult:
|
||||
"""User flow to add an infrared fan."""
|
||||
|
||||
entities = async_get_emitters(self.hass)
|
||||
if not entities:
|
||||
return self.async_abort(reason="no_emitters")
|
||||
|
||||
if user_input is not None:
|
||||
title = user_input.pop("name")
|
||||
return self.async_create_entry(data=user_input, title=title)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required("name"): str,
|
||||
vol.Required(CONF_INFRARED_ENTITY_ID): EntitySelector(
|
||||
EntitySelectorConfig(
|
||||
domain=INFRARED_DOMAIN,
|
||||
include_entities=[entity.entity_id for entity in entities],
|
||||
)
|
||||
),
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
@@ -7,6 +7,7 @@ from collections.abc import Callable
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
DOMAIN = "kitchen_sink"
|
||||
CONF_INFRARED_ENTITY_ID = "infrared_entity_id"
|
||||
DATA_BACKUP_AGENT_LISTENERS: HassKey[list[Callable[[], None]]] = HassKey(
|
||||
f"{DOMAIN}.backup_agent_listeners"
|
||||
)
|
||||
|
||||
150
homeassistant/components/kitchen_sink/fan.py
Normal file
150
homeassistant/components/kitchen_sink/fan.py
Normal file
@@ -0,0 +1,150 @@
|
||||
"""Demo platform that offers a fake infrared fan entity."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import infrared_protocols
|
||||
|
||||
from homeassistant.components.fan import FanEntity, FanEntityFeature
|
||||
from homeassistant.components.infrared import async_send_command
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import STATE_UNAVAILABLE
|
||||
from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.event import async_track_state_change_event
|
||||
|
||||
from .const import CONF_INFRARED_ENTITY_ID, DOMAIN
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
DUMMY_FAN_ADDRESS = 0x1234
|
||||
DUMMY_CMD_POWER_ON = 0x01
|
||||
DUMMY_CMD_POWER_OFF = 0x02
|
||||
DUMMY_CMD_SPEED_LOW = 0x03
|
||||
DUMMY_CMD_SPEED_MEDIUM = 0x04
|
||||
DUMMY_CMD_SPEED_HIGH = 0x05
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the demo infrared fan platform."""
|
||||
for subentry_id, subentry in config_entry.subentries.items():
|
||||
if subentry.subentry_type != "infrared_fan":
|
||||
continue
|
||||
async_add_entities(
|
||||
[
|
||||
DemoInfraredFan(
|
||||
subentry_id=subentry_id,
|
||||
device_name=subentry.title,
|
||||
infrared_entity_id=subentry.data[CONF_INFRARED_ENTITY_ID],
|
||||
)
|
||||
],
|
||||
config_subentry_id=subentry_id,
|
||||
)
|
||||
|
||||
|
||||
class DemoInfraredFan(FanEntity):
|
||||
"""Representation of a demo infrared fan entity."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
_attr_should_poll = False
|
||||
_attr_assumed_state = True
|
||||
_attr_speed_count = 3
|
||||
_attr_supported_features = (
|
||||
FanEntityFeature.SET_SPEED
|
||||
| FanEntityFeature.TURN_OFF
|
||||
| FanEntityFeature.TURN_ON
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
subentry_id: str,
|
||||
device_name: str,
|
||||
infrared_entity_id: str,
|
||||
) -> None:
|
||||
"""Initialize the demo infrared fan entity."""
|
||||
self._infrared_entity_id = infrared_entity_id
|
||||
self._attr_unique_id = subentry_id
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, subentry_id)},
|
||||
name=device_name,
|
||||
)
|
||||
self._attr_percentage = 0
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Subscribe to infrared entity state changes."""
|
||||
await super().async_added_to_hass()
|
||||
|
||||
@callback
|
||||
def _async_ir_state_changed(event: Event[EventStateChangedData]) -> None:
|
||||
"""Handle infrared entity state changes."""
|
||||
new_state = event.data["new_state"]
|
||||
self._attr_available = (
|
||||
new_state is not None and new_state.state != STATE_UNAVAILABLE
|
||||
)
|
||||
self.async_write_ha_state()
|
||||
|
||||
self.async_on_remove(
|
||||
async_track_state_change_event(
|
||||
self.hass, [self._infrared_entity_id], _async_ir_state_changed
|
||||
)
|
||||
)
|
||||
|
||||
# Set initial availability based on current infrared entity state
|
||||
ir_state = self.hass.states.get(self._infrared_entity_id)
|
||||
self._attr_available = (
|
||||
ir_state is not None and ir_state.state != STATE_UNAVAILABLE
|
||||
)
|
||||
|
||||
async def _send_command(self, command_code: int) -> None:
|
||||
"""Send an IR command using the NEC protocol."""
|
||||
command = infrared_protocols.NECCommand(
|
||||
address=DUMMY_FAN_ADDRESS,
|
||||
command=command_code,
|
||||
modulation=38000,
|
||||
)
|
||||
await async_send_command(
|
||||
self.hass, self._infrared_entity_id, command, context=self._context
|
||||
)
|
||||
|
||||
async def async_turn_on(
|
||||
self,
|
||||
percentage: int | None = None,
|
||||
preset_mode: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Turn on the fan."""
|
||||
if percentage is not None:
|
||||
await self.async_set_percentage(percentage)
|
||||
return
|
||||
await self._send_command(DUMMY_CMD_POWER_ON)
|
||||
self._attr_percentage = 33
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn off the fan."""
|
||||
await self._send_command(DUMMY_CMD_POWER_OFF)
|
||||
self._attr_percentage = 0
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_set_percentage(self, percentage: int) -> None:
|
||||
"""Set the speed percentage of the fan."""
|
||||
if percentage == 0:
|
||||
await self.async_turn_off()
|
||||
return
|
||||
|
||||
if percentage <= 33:
|
||||
await self._send_command(DUMMY_CMD_SPEED_LOW)
|
||||
elif percentage <= 66:
|
||||
await self._send_command(DUMMY_CMD_SPEED_MEDIUM)
|
||||
else:
|
||||
await self._send_command(DUMMY_CMD_SPEED_HIGH)
|
||||
|
||||
self._attr_percentage = percentage
|
||||
self.async_write_ha_state()
|
||||
65
homeassistant/components/kitchen_sink/infrared.py
Normal file
65
homeassistant/components/kitchen_sink/infrared.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""Demo platform that offers a fake infrared entity."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import infrared_protocols
|
||||
|
||||
from homeassistant.components import persistent_notification
|
||||
from homeassistant.components.infrared import InfraredEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import DOMAIN
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the demo infrared platform."""
|
||||
async_add_entities(
|
||||
[
|
||||
DemoInfrared(
|
||||
unique_id="ir_transmitter",
|
||||
device_name="IR Blaster",
|
||||
entity_name="Infrared Transmitter",
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class DemoInfrared(InfraredEntity):
|
||||
"""Representation of a demo infrared entity."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_should_poll = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
unique_id: str,
|
||||
device_name: str,
|
||||
entity_name: str,
|
||||
) -> None:
|
||||
"""Initialize the demo infrared entity."""
|
||||
self._attr_unique_id = unique_id
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, unique_id)},
|
||||
name=device_name,
|
||||
)
|
||||
self._attr_name = entity_name
|
||||
|
||||
async def async_send_command(self, command: infrared_protocols.Command) -> None:
|
||||
"""Send an IR command."""
|
||||
timings = [
|
||||
interval
|
||||
for timing in command.get_raw_timings()
|
||||
for interval in (timing.high_us, -timing.low_us)
|
||||
]
|
||||
persistent_notification.async_create(
|
||||
self.hass, str(timings), title="Infrared Command"
|
||||
)
|
||||
@@ -101,6 +101,8 @@ async def async_setup_entry(
|
||||
)
|
||||
|
||||
for subentry_id, subentry in config_entry.subentries.items():
|
||||
if subentry.subentry_type != "entity":
|
||||
continue
|
||||
async_add_entities(
|
||||
[
|
||||
DemoSensor(
|
||||
|
||||
@@ -32,6 +32,24 @@
|
||||
"description": "Reconfigure the sensor"
|
||||
}
|
||||
}
|
||||
},
|
||||
"infrared_fan": {
|
||||
"abort": {
|
||||
"no_emitters": "No infrared transmitter entities found. Please set up an infrared device first."
|
||||
},
|
||||
"entry_type": "Infrared fan",
|
||||
"initiate_flow": {
|
||||
"user": "Add infrared fan"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"infrared_entity_id": "Infrared transmitter",
|
||||
"name": "[%key:common::config_flow::data::name%]"
|
||||
},
|
||||
"description": "Select an infrared transmitter to control the fan."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"device": {
|
||||
|
||||
@@ -206,10 +206,11 @@ class MatterVacuum(MatterEntity, StateVacuumEntity):
|
||||
|
||||
if (
|
||||
response
|
||||
and response.status != clusters.ServiceArea.Enums.SelectAreasStatus.kSuccess
|
||||
and response["status"]
|
||||
!= clusters.ServiceArea.Enums.SelectAreasStatus.kSuccess
|
||||
):
|
||||
raise HomeAssistantError(
|
||||
f"Failed to select areas: {response.statusText or response.status.name}"
|
||||
f"Failed to select areas: {response['statusText'] or response['status']}"
|
||||
)
|
||||
|
||||
await self.send_device_command(
|
||||
|
||||
@@ -512,6 +512,11 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow):
|
||||
options.pop(CONF_WEB_SEARCH_REGION, None)
|
||||
options.pop(CONF_WEB_SEARCH_COUNTRY, None)
|
||||
options.pop(CONF_WEB_SEARCH_TIMEZONE, None)
|
||||
if (
|
||||
user_input.get(CONF_CODE_INTERPRETER)
|
||||
and user_input.get(CONF_REASONING_EFFORT) == "minimal"
|
||||
):
|
||||
errors[CONF_CODE_INTERPRETER] = "code_interpreter_minimal_reasoning"
|
||||
|
||||
options.update(user_input)
|
||||
if not errors:
|
||||
@@ -539,15 +544,15 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow):
|
||||
if not model.startswith(("o", "gpt-5")) or model.startswith("gpt-5-pro"):
|
||||
return []
|
||||
|
||||
MODELS_REASONING_MAP = {
|
||||
models_reasoning_map: dict[str | tuple[str, ...], list[str]] = {
|
||||
"gpt-5.2-pro": ["medium", "high", "xhigh"],
|
||||
"gpt-5.2": ["none", "low", "medium", "high", "xhigh"],
|
||||
("gpt-5.2", "gpt-5.3"): ["none", "low", "medium", "high", "xhigh"],
|
||||
"gpt-5.1": ["none", "low", "medium", "high"],
|
||||
"gpt-5": ["minimal", "low", "medium", "high"],
|
||||
"": ["low", "medium", "high"], # The default case
|
||||
}
|
||||
|
||||
for prefix, options in MODELS_REASONING_MAP.items():
|
||||
for prefix, options in models_reasoning_map.items():
|
||||
if model.startswith(prefix):
|
||||
return options
|
||||
return [] # pragma: no cover
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
},
|
||||
"entry_type": "AI task",
|
||||
"error": {
|
||||
"code_interpreter_minimal_reasoning": "[%key:component::openai_conversation::config_subentries::conversation::error::code_interpreter_minimal_reasoning%]",
|
||||
"model_not_supported": "[%key:component::openai_conversation::config_subentries::conversation::error::model_not_supported%]",
|
||||
"web_search_minimal_reasoning": "[%key:component::openai_conversation::config_subentries::conversation::error::web_search_minimal_reasoning%]"
|
||||
},
|
||||
@@ -93,6 +94,7 @@
|
||||
},
|
||||
"entry_type": "Conversation agent",
|
||||
"error": {
|
||||
"code_interpreter_minimal_reasoning": "Code interpreter is not supported with minimal reasoning effort",
|
||||
"model_not_supported": "This model is not supported, please select a different model",
|
||||
"web_search_minimal_reasoning": "Web search is currently not supported with minimal reasoning effort"
|
||||
},
|
||||
|
||||
@@ -1 +1,51 @@
|
||||
"""The orvibo component."""
|
||||
"""The orvibo integration."""
|
||||
|
||||
import logging
|
||||
|
||||
from orvibo.s20 import S20, S20Exception
|
||||
|
||||
from homeassistant import core
|
||||
from homeassistant.const import CONF_HOST, CONF_MAC, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
|
||||
from .const import DOMAIN
|
||||
from .models import S20ConfigEntry
|
||||
|
||||
PLATFORMS = [Platform.SWITCH]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(hass: core.HomeAssistant, entry: S20ConfigEntry) -> bool:
|
||||
"""Set up platform from a ConfigEntry."""
|
||||
|
||||
try:
|
||||
s20 = await hass.async_add_executor_job(
|
||||
S20,
|
||||
entry.data[CONF_HOST],
|
||||
entry.data[CONF_MAC],
|
||||
)
|
||||
_LOGGER.debug("Initialized S20 at %s", entry.data[CONF_HOST])
|
||||
except S20Exception as err:
|
||||
_LOGGER.debug("S20 at %s couldn't be initialized", entry.data[CONF_HOST])
|
||||
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="init_error",
|
||||
translation_placeholders={
|
||||
"host": entry.data[CONF_HOST],
|
||||
},
|
||||
) from err
|
||||
|
||||
entry.runtime_data = s20
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: S20ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
205
homeassistant/components/orvibo/config_flow.py
Normal file
205
homeassistant/components/orvibo/config_flow.py
Normal file
@@ -0,0 +1,205 @@
|
||||
"""Config flow for the orvibo integration."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from orvibo.s20 import S20, S20Exception, discover
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.device_registry import format_mac
|
||||
|
||||
from .const import CONF_SWITCH_LIST, DEFAULT_NAME, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
FULL_EDIT_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_HOST): cv.string,
|
||||
vol.Optional(CONF_MAC): cv.string,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class S20ConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle the config flow for Orvibo S20 switches."""
|
||||
|
||||
VERSION = 1
|
||||
MINOR_VERSION = 1
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize an instance of the S20 config flow."""
|
||||
self.discovery_task: asyncio.Task | None = None
|
||||
self._discovered_switches: dict[str, dict[str, Any]] = {}
|
||||
self.chosen_switch: dict[str, Any] = {}
|
||||
|
||||
async def _async_discover(self) -> None:
|
||||
def _filter_discovered_switches(
|
||||
switches: dict[str, dict[str, Any]],
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
# Get existing unique_ids from config entries
|
||||
existing_ids = {entry.unique_id for entry in self._async_current_entries()}
|
||||
_LOGGER.debug("Existing unique IDs: %s", existing_ids)
|
||||
# Build a new filtered dict
|
||||
filtered = {}
|
||||
for ip, info in switches.items():
|
||||
mac_bytes = info.get("mac")
|
||||
if not mac_bytes:
|
||||
continue # skip if no MAC
|
||||
|
||||
unique_id = format_mac(mac_bytes.hex()).lower()
|
||||
if unique_id not in existing_ids:
|
||||
filtered[ip] = info
|
||||
_LOGGER.debug("New switches: %s", filtered)
|
||||
return filtered
|
||||
|
||||
# Discover S20 devices.
|
||||
_LOGGER.debug("Discovering S20 switches")
|
||||
|
||||
_unfiltered_switches = await self.hass.async_add_executor_job(discover)
|
||||
_LOGGER.debug("All discovered switches: %s", _unfiltered_switches)
|
||||
|
||||
self._discovered_switches = _filter_discovered_switches(_unfiltered_switches)
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle a flow initialized by the user."""
|
||||
|
||||
return self.async_show_menu(
|
||||
step_id="user", menu_options=["start_discovery", "edit"]
|
||||
)
|
||||
|
||||
async def _validate_input(self, user_input: dict[str, Any]) -> str | None:
|
||||
"""Validate user input and discover MAC if missing."""
|
||||
|
||||
if user_input.get(CONF_MAC):
|
||||
user_input[CONF_MAC] = format_mac(user_input[CONF_MAC]).lower()
|
||||
if len(user_input[CONF_MAC]) != 17 or user_input[CONF_MAC].count(":") != 5:
|
||||
return "invalid_mac"
|
||||
|
||||
try:
|
||||
device = await self.hass.async_add_executor_job(
|
||||
S20,
|
||||
user_input[CONF_HOST],
|
||||
user_input.get(CONF_MAC),
|
||||
)
|
||||
|
||||
if not user_input.get(CONF_MAC):
|
||||
# Using private attribute access here since S20 class doesn't have a public method to get the MAC without repeating discovery
|
||||
if not device._mac: # noqa: SLF001
|
||||
return "cannot_discover"
|
||||
user_input[CONF_MAC] = format_mac(device._mac.hex()).lower() # noqa: SLF001
|
||||
|
||||
except S20Exception:
|
||||
return "cannot_connect"
|
||||
|
||||
return None
|
||||
|
||||
async def async_step_edit(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Edit a discovered or manually configured server."""
|
||||
|
||||
errors = {}
|
||||
if user_input:
|
||||
error = await self._validate_input(user_input)
|
||||
if not error:
|
||||
await self.async_set_unique_id(user_input[CONF_MAC])
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(
|
||||
title=f"{DEFAULT_NAME} ({user_input[CONF_HOST]})", data=user_input
|
||||
)
|
||||
errors["base"] = error
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="edit",
|
||||
data_schema=FULL_EDIT_SCHEMA,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_start_discovery(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle a flow initialized by the user."""
|
||||
if not self.discovery_task:
|
||||
self.discovery_task = self.hass.async_create_task(self._async_discover())
|
||||
return self.async_show_progress(
|
||||
step_id="start_discovery",
|
||||
progress_action="start_discovery",
|
||||
progress_task=self.discovery_task,
|
||||
)
|
||||
if self.discovery_task.done():
|
||||
try:
|
||||
self.discovery_task.result()
|
||||
except (S20Exception, OSError) as err:
|
||||
_LOGGER.debug("Discovery task failed: %s", err)
|
||||
self.discovery_task = None
|
||||
return self.async_show_progress_done(
|
||||
next_step_id=(
|
||||
"choose_switch" if self._discovered_switches else "discovery_failed"
|
||||
)
|
||||
)
|
||||
return self.async_show_progress(
|
||||
step_id="start_discovery",
|
||||
progress_action="start_discovery",
|
||||
progress_task=self.discovery_task,
|
||||
)
|
||||
|
||||
async def async_step_choose_switch(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Choose manual or discover flow."""
|
||||
_chosen_host: str
|
||||
|
||||
if user_input:
|
||||
_chosen_host = user_input[CONF_SWITCH_LIST]
|
||||
for host, data in self._discovered_switches.items():
|
||||
if _chosen_host == host:
|
||||
self.chosen_switch[CONF_HOST] = host
|
||||
self.chosen_switch[CONF_MAC] = format_mac(
|
||||
data[CONF_MAC].hex()
|
||||
).lower()
|
||||
await self.async_set_unique_id(self.chosen_switch[CONF_MAC])
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(
|
||||
title=f"{DEFAULT_NAME} ({host})", data=self.chosen_switch
|
||||
)
|
||||
_LOGGER.debug("discovered switches: %s", self._discovered_switches)
|
||||
|
||||
_options = {
|
||||
host: f"{host} ({format_mac(data[CONF_MAC].hex()).lower()})"
|
||||
for host, data in self._discovered_switches.items()
|
||||
}
|
||||
return self.async_show_form(
|
||||
step_id="choose_switch",
|
||||
data_schema=vol.Schema({vol.Required(CONF_SWITCH_LIST): vol.In(_options)}),
|
||||
)
|
||||
|
||||
async def async_step_discovery_failed(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle a failed discovery."""
|
||||
|
||||
return self.async_show_menu(
|
||||
step_id="discovery_failed", menu_options=["start_discovery", "edit"]
|
||||
)
|
||||
|
||||
async def async_step_import(self, user_input: dict[str, Any]) -> ConfigFlowResult:
|
||||
"""Handle import from configuration.yaml."""
|
||||
_LOGGER.debug("Importing config: %s", user_input)
|
||||
|
||||
error = await self._validate_input(user_input)
|
||||
if error:
|
||||
return self.async_abort(reason=error)
|
||||
|
||||
await self.async_set_unique_id(user_input[CONF_MAC])
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
return self.async_create_entry(
|
||||
title=user_input.get(CONF_NAME, user_input[CONF_HOST]), data=user_input
|
||||
)
|
||||
5
homeassistant/components/orvibo/const.py
Normal file
5
homeassistant/components/orvibo/const.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Constants for the orvibo integration."""
|
||||
|
||||
DOMAIN = "orvibo"
|
||||
DEFAULT_NAME = "S20"
|
||||
CONF_SWITCH_LIST = "switches"
|
||||
@@ -2,6 +2,7 @@
|
||||
"domain": "orvibo",
|
||||
"name": "Orvibo",
|
||||
"codeowners": [],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/orvibo",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["orvibo"],
|
||||
|
||||
7
homeassistant/components/orvibo/models.py
Normal file
7
homeassistant/components/orvibo/models.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""Data models for the Orvibo integration."""
|
||||
|
||||
from orvibo.s20 import S20
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
|
||||
type S20ConfigEntry = ConfigEntry[S20]
|
||||
71
homeassistant/components/orvibo/strings.json
Normal file
71
homeassistant/components/orvibo/strings.json
Normal file
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
|
||||
"cannot_connect": "Unable to connect to the S20 switch",
|
||||
"cannot_discover": "Unable to discover MAC address of S20 switch. Please enter the MAC address.",
|
||||
"invalid_mac": "Invalid MAC address format"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:component::orvibo::config::abort::cannot_connect%]",
|
||||
"cannot_discover": "[%key:component::orvibo::config::abort::cannot_discover%]",
|
||||
"invalid_mac": "Invalid MAC address format"
|
||||
},
|
||||
"progress": {
|
||||
"start_discovery": "Attempting to discover new S20 switches\n\nThis will take about 3 seconds\n\nDiscovery may fail if the switch is asleep. If your switch does not appear, please power toggle your switch before re-running discovery.",
|
||||
"title": "Orvibo S20"
|
||||
},
|
||||
"step": {
|
||||
"choose_switch": {
|
||||
"data": {
|
||||
"switches": "Choose discovered switch to configure"
|
||||
},
|
||||
"title": "Discovered switches"
|
||||
},
|
||||
"discovery_failed": {
|
||||
"description": "No S20 switches were discovered on the network. Discovery may have failed if the switch is asleep. Please power toggle your switch before re-running discovery.",
|
||||
"menu_options": {
|
||||
"edit": "Enter configuration manually",
|
||||
"start_discovery": "Try discovering again"
|
||||
},
|
||||
"title": "Discovery failed"
|
||||
},
|
||||
"edit": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
"mac": "MAC address"
|
||||
},
|
||||
"title": "Configure Orvibo S20 switch"
|
||||
},
|
||||
"user": {
|
||||
"menu_options": {
|
||||
"edit": "Enter configuration manually",
|
||||
"start_discovery": "Discover new S20 switches"
|
||||
},
|
||||
"title": "Orvibo S20 Configuration"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"init_error": {
|
||||
"message": "Error while initializing S20 {host}."
|
||||
},
|
||||
"turn_off_error": {
|
||||
"message": "Error while turning off S20 {name}."
|
||||
},
|
||||
"turn_on_error": {
|
||||
"message": "Error while turning on S20 {name}."
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"yaml_deprecation": {
|
||||
"description": "The device (MAC: {mac}, Host: {host}) is configured in `configuration.yaml`. The Orvibo integration now supports UI-based configuration and this device has been migrated to the new UI. Please remove the YAML block from `configuration.yaml` to avoid future issues.",
|
||||
"title": "Legacy YAML configuration detected {host}"
|
||||
},
|
||||
"yaml_deprecation_import_issue": {
|
||||
"description": "Attempting to import this device (MAC: {mac}, Host: {host}) from YAML has failed for reason {reason}. 1) Remove the YAML block from `configuration.yaml`, 2) Restart Home Assistant, 3) Add the device using the UI configuration flow.",
|
||||
"title": "Legacy YAML configuration import issue for {host}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
"""Support for Orvibo S20 Wifi Smart Switches."""
|
||||
"""Switch platform for the Orvibo integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from orvibo.s20 import S20, S20Exception, discover
|
||||
from orvibo.s20 import S20, S20Exception
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components.switch import (
|
||||
PLATFORM_SCHEMA as SWITCH_PLATFORM_SCHEMA,
|
||||
SwitchEntity,
|
||||
@@ -20,14 +21,25 @@ from homeassistant.const import (
|
||||
CONF_SWITCHES,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv, issue_registry as ir
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import (
|
||||
AddConfigEntryEntitiesCallback,
|
||||
AddEntitiesCallback,
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
|
||||
from .const import DEFAULT_NAME, DOMAIN
|
||||
from .models import S20ConfigEntry
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_NAME = "Orvibo S20 Switch"
|
||||
DEFAULT_DISCOVERY = True
|
||||
DEFAULT_DISCOVERY = False
|
||||
|
||||
# Library is not thread safe and uses global variables, so we limit to 1 update at a time
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
PLATFORM_SCHEMA = SWITCH_PLATFORM_SCHEMA.extend(
|
||||
{
|
||||
@@ -46,65 +58,138 @@ PLATFORM_SCHEMA = SWITCH_PLATFORM_SCHEMA.extend(
|
||||
)
|
||||
|
||||
|
||||
def setup_platform(
|
||||
async def async_setup_platform(
|
||||
hass: HomeAssistant,
|
||||
config: ConfigType,
|
||||
add_entities_callback: AddEntitiesCallback,
|
||||
discovery_info: DiscoveryInfoType | None = None,
|
||||
) -> None:
|
||||
"""Set up S20 switches."""
|
||||
"""Set up the integration from configuration.yaml."""
|
||||
for switch in config.get(CONF_SWITCHES, []):
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": config_entries.SOURCE_IMPORT},
|
||||
data=switch,
|
||||
)
|
||||
|
||||
switch_data = {}
|
||||
switches = []
|
||||
switch_conf = config.get(CONF_SWITCHES, [config])
|
||||
|
||||
if config.get(CONF_DISCOVERY):
|
||||
_LOGGER.debug("Discovering S20 switches")
|
||||
switch_data.update(discover())
|
||||
|
||||
for switch in switch_conf:
|
||||
switch_data[switch.get(CONF_HOST)] = switch
|
||||
|
||||
for host, data in switch_data.items():
|
||||
try:
|
||||
switches.append(
|
||||
S20Switch(data.get(CONF_NAME), S20(host, mac=data.get(CONF_MAC)))
|
||||
if (
|
||||
result.get("type") is FlowResultType.ABORT
|
||||
and result.get("reason") != "already_configured"
|
||||
):
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
f"yaml_deprecation_import_issue_{switch.get('host')}_{(switch.get('mac') or 'unknown_mac').replace(':', '').lower()}",
|
||||
breaks_in_ha_version="2026.9.0",
|
||||
is_fixable=False,
|
||||
is_persistent=False,
|
||||
issue_domain=DOMAIN,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="yaml_deprecation_import_issue",
|
||||
translation_placeholders={
|
||||
"reason": str(result.get("reason")),
|
||||
"host": switch.get("host"),
|
||||
"mac": switch.get("mac", ""),
|
||||
},
|
||||
)
|
||||
_LOGGER.debug("Initialized S20 at %s", host)
|
||||
except S20Exception:
|
||||
_LOGGER.error("S20 at %s couldn't be initialized", host)
|
||||
continue
|
||||
|
||||
add_entities_callback(switches)
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
f"yaml_deprecation_{switch.get('host')}_{(switch.get('mac') or 'unknown_mac').replace(':', '').lower()}",
|
||||
breaks_in_ha_version="2026.9.0",
|
||||
is_fixable=False,
|
||||
is_persistent=False,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="yaml_deprecation",
|
||||
translation_placeholders={
|
||||
"host": switch.get("host"),
|
||||
"mac": switch.get("mac") or "Unknown MAC",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: S20ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up orvibo from a config entry."""
|
||||
async_add_entities(
|
||||
[
|
||||
S20Switch(
|
||||
entry.title,
|
||||
entry.data[CONF_HOST],
|
||||
entry.data[CONF_MAC],
|
||||
entry.runtime_data,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class S20Switch(SwitchEntity):
|
||||
"""Representation of an S20 switch."""
|
||||
|
||||
def __init__(self, name, s20):
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(self, name: str, host: str, mac: str, s20: S20) -> None:
|
||||
"""Initialize the S20 device."""
|
||||
|
||||
self._attr_name = name
|
||||
self._s20 = s20
|
||||
self._attr_is_on = False
|
||||
self._exc = S20Exception
|
||||
|
||||
def update(self) -> None:
|
||||
"""Update device state."""
|
||||
try:
|
||||
self._attr_is_on = self._s20.on
|
||||
except self._exc:
|
||||
_LOGGER.exception("Error while fetching S20 state")
|
||||
self._host = host
|
||||
self._mac = mac
|
||||
self._s20 = s20
|
||||
self._attr_unique_id = self._mac
|
||||
self._name = name
|
||||
self._attr_name = None
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={
|
||||
# MAC addresses are used as unique identifiers within this domain
|
||||
(DOMAIN, self._attr_unique_id)
|
||||
},
|
||||
name=name,
|
||||
manufacturer="Orvibo",
|
||||
model="S20",
|
||||
connections={(CONNECTION_NETWORK_MAC, self._mac)},
|
||||
)
|
||||
|
||||
def turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the device on."""
|
||||
try:
|
||||
self._s20.on = True
|
||||
except self._exc:
|
||||
_LOGGER.exception("Error while turning on S20")
|
||||
except S20Exception as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="turn_on_error",
|
||||
translation_placeholders={"name": self._name},
|
||||
) from err
|
||||
|
||||
def turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the device off."""
|
||||
try:
|
||||
self._s20.on = False
|
||||
except self._exc:
|
||||
_LOGGER.exception("Error while turning off S20")
|
||||
except S20Exception as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="turn_off_error",
|
||||
translation_placeholders={"name": self._name},
|
||||
) from err
|
||||
|
||||
def update(self) -> None:
|
||||
"""Update device state."""
|
||||
try:
|
||||
self._attr_is_on = self._s20.on
|
||||
|
||||
# If the device was previously offline, let the user know it's back!
|
||||
if not self._attr_available:
|
||||
_LOGGER.info("Orvibo switch %s reconnected", self._name)
|
||||
self._attr_available = True
|
||||
|
||||
except S20Exception as err:
|
||||
# Only log the error if this is the FIRST time it failed
|
||||
if self._attr_available:
|
||||
_LOGGER.info(
|
||||
"Error communicating with Orvibo switch %s: %s", self._name, err
|
||||
)
|
||||
self._attr_available = False
|
||||
|
||||
@@ -137,11 +137,10 @@ class PhilipsTVLightEntity(PhilipsJsEntity, LightEntity):
|
||||
|
||||
_attr_effect: str
|
||||
_attr_translation_key = "ambilight"
|
||||
_attr_supported_color_modes = {ColorMode.HS}
|
||||
_attr_supported_features = LightEntityFeature.EFFECT
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: PhilipsTVDataUpdateCoordinator,
|
||||
) -> None:
|
||||
def __init__(self, coordinator: PhilipsTVDataUpdateCoordinator) -> None:
|
||||
"""Initialize light."""
|
||||
self._tv = coordinator.api
|
||||
self._hs = None
|
||||
@@ -150,8 +149,6 @@ class PhilipsTVLightEntity(PhilipsJsEntity, LightEntity):
|
||||
self._last_selected_effect: AmbilightEffect | None = None
|
||||
super().__init__(coordinator)
|
||||
|
||||
self._attr_supported_color_modes = {ColorMode.HS, ColorMode.ONOFF}
|
||||
self._attr_supported_features = LightEntityFeature.EFFECT
|
||||
self._attr_unique_id = coordinator.unique_id
|
||||
|
||||
self._update_from_coordinator()
|
||||
|
||||
@@ -16,7 +16,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import PortainerConfigEntry
|
||||
from .const import CONTAINER_STATE_RUNNING, STACK_STATUS_ACTIVE
|
||||
from .coordinator import PortainerContainerData, PortainerCoordinator
|
||||
from .coordinator import PortainerContainerData
|
||||
from .entity import (
|
||||
PortainerContainerEntity,
|
||||
PortainerCoordinatorData,
|
||||
@@ -165,18 +165,6 @@ class PortainerEndpointSensor(PortainerEndpointEntity, BinarySensorEntity):
|
||||
|
||||
entity_description: PortainerEndpointBinarySensorEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: PortainerCoordinator,
|
||||
entity_description: PortainerEndpointBinarySensorEntityDescription,
|
||||
device_info: PortainerCoordinatorData,
|
||||
) -> None:
|
||||
"""Initialize Portainer endpoint binary sensor entity."""
|
||||
self.entity_description = entity_description
|
||||
super().__init__(device_info, coordinator)
|
||||
|
||||
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{device_info.id}_{entity_description.key}"
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return true if the binary sensor is on."""
|
||||
@@ -188,19 +176,6 @@ class PortainerContainerSensor(PortainerContainerEntity, BinarySensorEntity):
|
||||
|
||||
entity_description: PortainerContainerBinarySensorEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: PortainerCoordinator,
|
||||
entity_description: PortainerContainerBinarySensorEntityDescription,
|
||||
device_info: PortainerContainerData,
|
||||
via_device: PortainerCoordinatorData,
|
||||
) -> None:
|
||||
"""Initialize the Portainer container sensor."""
|
||||
self.entity_description = entity_description
|
||||
super().__init__(device_info, coordinator, via_device)
|
||||
|
||||
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{self.device_name}_{entity_description.key}"
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return true if the binary sensor is on."""
|
||||
@@ -212,19 +187,6 @@ class PortainerStackSensor(PortainerStackEntity, BinarySensorEntity):
|
||||
|
||||
entity_description: PortainerStackBinarySensorEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: PortainerCoordinator,
|
||||
entity_description: PortainerStackBinarySensorEntityDescription,
|
||||
device_info: PortainerStackData,
|
||||
via_device: PortainerCoordinatorData,
|
||||
) -> None:
|
||||
"""Initialize the Portainer stack sensor."""
|
||||
self.entity_description = entity_description
|
||||
super().__init__(device_info, coordinator, via_device)
|
||||
|
||||
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{device_info.stack.id}_{entity_description.key}"
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return true if the binary sensor is on."""
|
||||
|
||||
@@ -167,18 +167,6 @@ class PortainerEndpointButton(PortainerEndpointEntity, PortainerBaseButton):
|
||||
|
||||
entity_description: PortainerButtonDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: PortainerCoordinator,
|
||||
entity_description: PortainerButtonDescription,
|
||||
device_info: PortainerCoordinatorData,
|
||||
) -> None:
|
||||
"""Initialize the Portainer endpoint button entity."""
|
||||
self.entity_description = entity_description
|
||||
super().__init__(device_info, coordinator)
|
||||
|
||||
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{device_info.id}_{entity_description.key}"
|
||||
|
||||
async def _async_press_call(self) -> None:
|
||||
"""Call the endpoint button press action."""
|
||||
await self.entity_description.press_action(
|
||||
@@ -191,19 +179,6 @@ class PortainerContainerButton(PortainerContainerEntity, PortainerBaseButton):
|
||||
|
||||
entity_description: PortainerButtonDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: PortainerCoordinator,
|
||||
entity_description: PortainerButtonDescription,
|
||||
device_info: PortainerContainerData,
|
||||
via_device: PortainerCoordinatorData,
|
||||
) -> None:
|
||||
"""Initialize the Portainer button entity."""
|
||||
self.entity_description = entity_description
|
||||
super().__init__(device_info, coordinator, via_device)
|
||||
|
||||
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{self.device_name}_{entity_description.key}"
|
||||
|
||||
async def _async_press_call(self) -> None:
|
||||
"""Call the container button press action."""
|
||||
await self.entity_description.press_action(
|
||||
|
||||
@@ -170,11 +170,11 @@ class PortainerCoordinator(DataUpdateCoordinator[dict[int, PortainerCoordinatorD
|
||||
docker_system_df,
|
||||
stacks,
|
||||
) = await asyncio.gather(
|
||||
self.portainer.get_containers(endpoint_id=endpoint.id),
|
||||
self.portainer.docker_version(endpoint_id=endpoint.id),
|
||||
self.portainer.docker_info(endpoint_id=endpoint.id),
|
||||
self.portainer.get_containers(endpoint.id),
|
||||
self.portainer.docker_version(endpoint.id),
|
||||
self.portainer.docker_info(endpoint.id),
|
||||
self.portainer.docker_system_df(endpoint.id),
|
||||
self.portainer.get_stacks(endpoint_id=endpoint.id),
|
||||
self.portainer.get_stacks(endpoint.id),
|
||||
)
|
||||
|
||||
prev_endpoint = self.data.get(endpoint.id) if self.data else None
|
||||
|
||||
@@ -4,6 +4,7 @@ from yarl import URL
|
||||
|
||||
from homeassistant.const import CONF_URL
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DEFAULT_NAME, DOMAIN
|
||||
@@ -26,11 +27,13 @@ class PortainerEndpointEntity(PortainerCoordinatorEntity):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device_info: PortainerCoordinatorData,
|
||||
coordinator: PortainerCoordinator,
|
||||
entity_description: EntityDescription,
|
||||
device_info: PortainerCoordinatorData,
|
||||
) -> None:
|
||||
"""Initialize a Portainer endpoint."""
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = entity_description
|
||||
self._device_info = device_info
|
||||
self.device_id = device_info.endpoint.id
|
||||
self._attr_device_info = DeviceInfo(
|
||||
@@ -45,6 +48,7 @@ class PortainerEndpointEntity(PortainerCoordinatorEntity):
|
||||
name=device_info.endpoint.name,
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
)
|
||||
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{device_info.id}_{entity_description.key}"
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
@@ -57,12 +61,14 @@ class PortainerContainerEntity(PortainerCoordinatorEntity):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device_info: PortainerContainerData,
|
||||
coordinator: PortainerCoordinator,
|
||||
entity_description: EntityDescription,
|
||||
device_info: PortainerContainerData,
|
||||
via_device: PortainerCoordinatorData,
|
||||
) -> None:
|
||||
"""Initialize a Portainer container."""
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = entity_description
|
||||
self._device_info = device_info
|
||||
self.device_id = self._device_info.container.id
|
||||
self.endpoint_id = via_device.endpoint.id
|
||||
@@ -91,13 +97,14 @@ class PortainerContainerEntity(PortainerCoordinatorEntity):
|
||||
# else it's the endpoint
|
||||
via_device=(
|
||||
DOMAIN,
|
||||
f"{coordinator.config_entry.entry_id}_{self.endpoint_id}_{device_info.stack.name}"
|
||||
f"{coordinator.config_entry.entry_id}_{self.endpoint_id}_stack_{device_info.stack.id}"
|
||||
if device_info.stack
|
||||
else f"{coordinator.config_entry.entry_id}_{self.endpoint_id}",
|
||||
),
|
||||
translation_key=None if self.device_name else "unknown_container",
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
)
|
||||
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{self.device_name}_{entity_description.key}"
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
@@ -119,12 +126,14 @@ class PortainerStackEntity(PortainerCoordinatorEntity):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device_info: PortainerStackData,
|
||||
coordinator: PortainerCoordinator,
|
||||
entity_description: EntityDescription,
|
||||
device_info: PortainerStackData,
|
||||
via_device: PortainerCoordinatorData,
|
||||
) -> None:
|
||||
"""Initialize a Portainer stack."""
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = entity_description
|
||||
self._device_info = device_info
|
||||
self.stack_id = device_info.stack.id
|
||||
self.device_name = device_info.stack.name
|
||||
@@ -135,7 +144,7 @@ class PortainerStackEntity(PortainerCoordinatorEntity):
|
||||
identifiers={
|
||||
(
|
||||
DOMAIN,
|
||||
f"{coordinator.config_entry.entry_id}_{self.endpoint_id}_{self.device_name}",
|
||||
f"{coordinator.config_entry.entry_id}_{self.endpoint_id}_stack_{self.stack_id}",
|
||||
)
|
||||
},
|
||||
manufacturer=DEFAULT_NAME,
|
||||
@@ -149,6 +158,7 @@ class PortainerStackEntity(PortainerCoordinatorEntity):
|
||||
f"{coordinator.config_entry.entry_id}_{self.endpoint_id}",
|
||||
),
|
||||
)
|
||||
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{self.stack_id}_{entity_description.key}"
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
|
||||
@@ -21,7 +21,6 @@ from .const import STACK_TYPE_COMPOSE, STACK_TYPE_KUBERNETES, STACK_TYPE_SWARM
|
||||
from .coordinator import (
|
||||
PortainerConfigEntry,
|
||||
PortainerContainerData,
|
||||
PortainerCoordinator,
|
||||
PortainerStackData,
|
||||
)
|
||||
from .entity import (
|
||||
@@ -398,19 +397,6 @@ class PortainerContainerSensor(PortainerContainerEntity, SensorEntity):
|
||||
|
||||
entity_description: PortainerContainerSensorEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: PortainerCoordinator,
|
||||
entity_description: PortainerContainerSensorEntityDescription,
|
||||
device_info: PortainerContainerData,
|
||||
via_device: PortainerCoordinatorData,
|
||||
) -> None:
|
||||
"""Initialize the Portainer container sensor."""
|
||||
self.entity_description = entity_description
|
||||
super().__init__(device_info, coordinator, via_device)
|
||||
|
||||
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{self.device_name}_{entity_description.key}"
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
"""Return the state of the sensor."""
|
||||
@@ -422,18 +408,6 @@ class PortainerEndpointSensor(PortainerEndpointEntity, SensorEntity):
|
||||
|
||||
entity_description: PortainerEndpointSensorEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: PortainerCoordinator,
|
||||
entity_description: PortainerEndpointSensorEntityDescription,
|
||||
device_info: PortainerCoordinatorData,
|
||||
) -> None:
|
||||
"""Initialize the Portainer endpoint sensor."""
|
||||
self.entity_description = entity_description
|
||||
super().__init__(device_info, coordinator)
|
||||
|
||||
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{device_info.id}_{entity_description.key}"
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
"""Return the state of the sensor."""
|
||||
@@ -446,19 +420,6 @@ class PortainerStackSensor(PortainerStackEntity, SensorEntity):
|
||||
|
||||
entity_description: PortainerStackSensorEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: PortainerCoordinator,
|
||||
entity_description: PortainerStackSensorEntityDescription,
|
||||
device_info: PortainerStackData,
|
||||
via_device: PortainerCoordinatorData,
|
||||
) -> None:
|
||||
"""Initialize the Portainer stack sensor."""
|
||||
self.entity_description = entity_description
|
||||
super().__init__(device_info, coordinator, via_device)
|
||||
|
||||
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{device_info.stack.id}_{entity_description.key}"
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
"""Return the state of the sensor."""
|
||||
|
||||
@@ -167,19 +167,6 @@ class PortainerContainerSwitch(PortainerContainerEntity, SwitchEntity):
|
||||
|
||||
entity_description: PortainerSwitchEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: PortainerCoordinator,
|
||||
entity_description: PortainerSwitchEntityDescription,
|
||||
device_info: PortainerContainerData,
|
||||
via_device: PortainerCoordinatorData,
|
||||
) -> None:
|
||||
"""Initialize the Portainer container switch."""
|
||||
self.entity_description = entity_description
|
||||
super().__init__(device_info, coordinator, via_device)
|
||||
|
||||
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{self.device_name}_{entity_description.key}"
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return the state of the device."""
|
||||
@@ -209,19 +196,6 @@ class PortainerStackSwitch(PortainerStackEntity, SwitchEntity):
|
||||
|
||||
entity_description: PortainerStackSwitchEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: PortainerCoordinator,
|
||||
entity_description: PortainerStackSwitchEntityDescription,
|
||||
device_info: PortainerStackData,
|
||||
via_device: PortainerCoordinatorData,
|
||||
) -> None:
|
||||
"""Initialize the Portainer stack switch."""
|
||||
self.entity_description = entity_description
|
||||
super().__init__(device_info, coordinator, via_device)
|
||||
|
||||
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{device_info.stack.id}_{entity_description.key}"
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return the state of the device."""
|
||||
|
||||
@@ -17,7 +17,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import NODE_ONLINE, VM_CONTAINER_RUNNING
|
||||
from .coordinator import ProxmoxConfigEntry, ProxmoxCoordinator, ProxmoxNodeData
|
||||
from .coordinator import ProxmoxConfigEntry, ProxmoxNodeData
|
||||
from .entity import ProxmoxContainerEntity, ProxmoxNodeEntity, ProxmoxVMEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -147,18 +147,6 @@ class ProxmoxNodeBinarySensor(ProxmoxNodeEntity, BinarySensorEntity):
|
||||
|
||||
entity_description: ProxmoxNodeBinarySensorEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: ProxmoxCoordinator,
|
||||
entity_description: ProxmoxNodeBinarySensorEntityDescription,
|
||||
node_data: ProxmoxNodeData,
|
||||
) -> None:
|
||||
"""Initialize Proxmox node binary sensor entity."""
|
||||
self.entity_description = entity_description
|
||||
super().__init__(coordinator, node_data)
|
||||
|
||||
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{node_data.node['id']}_{entity_description.key}"
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return true if the binary sensor is on."""
|
||||
@@ -170,19 +158,6 @@ class ProxmoxVMBinarySensor(ProxmoxVMEntity, BinarySensorEntity):
|
||||
|
||||
entity_description: ProxmoxVMBinarySensorEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: ProxmoxCoordinator,
|
||||
entity_description: ProxmoxVMBinarySensorEntityDescription,
|
||||
vm_data: dict[str, Any],
|
||||
node_data: ProxmoxNodeData,
|
||||
) -> None:
|
||||
"""Initialize the Proxmox VM binary sensor."""
|
||||
self.entity_description = entity_description
|
||||
super().__init__(coordinator, vm_data, node_data)
|
||||
|
||||
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{self.device_id}_{entity_description.key}"
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return true if the binary sensor is on."""
|
||||
@@ -194,19 +169,6 @@ class ProxmoxContainerBinarySensor(ProxmoxContainerEntity, BinarySensorEntity):
|
||||
|
||||
entity_description: ProxmoxContainerBinarySensorEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: ProxmoxCoordinator,
|
||||
entity_description: ProxmoxContainerBinarySensorEntityDescription,
|
||||
container_data: dict[str, Any],
|
||||
node_data: ProxmoxNodeData,
|
||||
) -> None:
|
||||
"""Initialize the Proxmox Container binary sensor."""
|
||||
self.entity_description = entity_description
|
||||
super().__init__(coordinator, container_data, node_data)
|
||||
|
||||
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{self.device_id}_{entity_description.key}"
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return true if the binary sensor is on."""
|
||||
|
||||
@@ -262,18 +262,6 @@ class ProxmoxNodeButtonEntity(ProxmoxNodeEntity, ProxmoxBaseButton):
|
||||
|
||||
entity_description: ProxmoxNodeButtonNodeEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: ProxmoxCoordinator,
|
||||
entity_description: ProxmoxNodeButtonNodeEntityDescription,
|
||||
node_data: ProxmoxNodeData,
|
||||
) -> None:
|
||||
"""Initialize the Proxmox Node button entity."""
|
||||
self.entity_description = entity_description
|
||||
super().__init__(coordinator, node_data)
|
||||
|
||||
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{node_data.node['id']}_{entity_description.key}"
|
||||
|
||||
async def _async_press_call(self) -> None:
|
||||
"""Execute the node button action via executor."""
|
||||
await self.hass.async_add_executor_job(
|
||||
@@ -288,19 +276,6 @@ class ProxmoxVMButtonEntity(ProxmoxVMEntity, ProxmoxBaseButton):
|
||||
|
||||
entity_description: ProxmoxVMButtonEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: ProxmoxCoordinator,
|
||||
entity_description: ProxmoxVMButtonEntityDescription,
|
||||
vm_data: dict[str, Any],
|
||||
node_data: ProxmoxNodeData,
|
||||
) -> None:
|
||||
"""Initialize the Proxmox VM button entity."""
|
||||
self.entity_description = entity_description
|
||||
super().__init__(coordinator, vm_data, node_data)
|
||||
|
||||
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{self.device_id}_{entity_description.key}"
|
||||
|
||||
async def _async_press_call(self) -> None:
|
||||
"""Execute the VM button action via executor."""
|
||||
await self.hass.async_add_executor_job(
|
||||
@@ -316,19 +291,6 @@ class ProxmoxContainerButtonEntity(ProxmoxContainerEntity, ProxmoxBaseButton):
|
||||
|
||||
entity_description: ProxmoxContainerButtonEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: ProxmoxCoordinator,
|
||||
entity_description: ProxmoxContainerButtonEntityDescription,
|
||||
container_data: dict[str, Any],
|
||||
node_data: ProxmoxNodeData,
|
||||
) -> None:
|
||||
"""Initialize the Proxmox Container button entity."""
|
||||
self.entity_description = entity_description
|
||||
super().__init__(coordinator, container_data, node_data)
|
||||
|
||||
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{self.device_id}_{entity_description.key}"
|
||||
|
||||
async def _async_press_call(self) -> None:
|
||||
"""Execute the container button action via executor."""
|
||||
await self.hass.async_add_executor_job(
|
||||
|
||||
@@ -8,6 +8,7 @@ from yarl import URL
|
||||
|
||||
from homeassistant.const import CONF_HOST, CONF_PORT
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
@@ -36,6 +37,7 @@ class ProxmoxNodeEntity(ProxmoxCoordinatorEntity):
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: ProxmoxCoordinator,
|
||||
entity_description: EntityDescription,
|
||||
node_data: ProxmoxNodeData,
|
||||
) -> None:
|
||||
"""Initialize the Proxmox node entity."""
|
||||
@@ -43,6 +45,7 @@ class ProxmoxNodeEntity(ProxmoxCoordinatorEntity):
|
||||
self._node_data = node_data
|
||||
self.device_id = node_data.node["id"]
|
||||
self.device_name = node_data.node["node"]
|
||||
self.entity_description = entity_description
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={
|
||||
(DOMAIN, f"{coordinator.config_entry.entry_id}_node_{self.device_id}")
|
||||
@@ -54,6 +57,8 @@ class ProxmoxNodeEntity(ProxmoxCoordinatorEntity):
|
||||
),
|
||||
)
|
||||
|
||||
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{node_data.node['id']}_{entity_description.key}"
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if the device is available."""
|
||||
@@ -66,11 +71,13 @@ class ProxmoxVMEntity(ProxmoxCoordinatorEntity):
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: ProxmoxCoordinator,
|
||||
entity_description: EntityDescription,
|
||||
vm_data: dict[str, Any],
|
||||
node_data: ProxmoxNodeData,
|
||||
) -> None:
|
||||
"""Initialize the Proxmox VM entity."""
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = entity_description
|
||||
self._vm_data = vm_data
|
||||
self._node_name = node_data.node["node"]
|
||||
self.device_id = vm_data["vmid"]
|
||||
@@ -91,6 +98,8 @@ class ProxmoxVMEntity(ProxmoxCoordinatorEntity):
|
||||
),
|
||||
)
|
||||
|
||||
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{self.device_id}_{entity_description.key}"
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if the device is available."""
|
||||
@@ -112,11 +121,13 @@ class ProxmoxContainerEntity(ProxmoxCoordinatorEntity):
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: ProxmoxCoordinator,
|
||||
entity_description: EntityDescription,
|
||||
container_data: dict[str, Any],
|
||||
node_data: ProxmoxNodeData,
|
||||
) -> None:
|
||||
"""Initialize the Proxmox Container entity."""
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = entity_description
|
||||
self._container_data = container_data
|
||||
self._node_name = node_data.node["node"]
|
||||
self.device_id = container_data["vmid"]
|
||||
@@ -140,6 +151,8 @@ class ProxmoxContainerEntity(ProxmoxCoordinatorEntity):
|
||||
),
|
||||
)
|
||||
|
||||
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{self.device_id}_{entity_description.key}"
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if the device is available."""
|
||||
|
||||
@@ -18,7 +18,7 @@ from homeassistant.const import PERCENTAGE, UnitOfInformation
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import ProxmoxConfigEntry, ProxmoxCoordinator, ProxmoxNodeData
|
||||
from .coordinator import ProxmoxConfigEntry, ProxmoxNodeData
|
||||
from .entity import ProxmoxContainerEntity, ProxmoxNodeEntity, ProxmoxVMEntity
|
||||
|
||||
|
||||
@@ -320,18 +320,6 @@ class ProxmoxNodeSensor(ProxmoxNodeEntity, SensorEntity):
|
||||
|
||||
entity_description: ProxmoxNodeSensorEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: ProxmoxCoordinator,
|
||||
entity_description: ProxmoxNodeSensorEntityDescription,
|
||||
node_data: ProxmoxNodeData,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
super().__init__(coordinator, node_data)
|
||||
self.entity_description = entity_description
|
||||
|
||||
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{node_data.node['id']}_{entity_description.key}"
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
"""Return the native value of the sensor."""
|
||||
@@ -343,19 +331,6 @@ class ProxmoxVMSensor(ProxmoxVMEntity, SensorEntity):
|
||||
|
||||
entity_description: ProxmoxVMSensorEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: ProxmoxCoordinator,
|
||||
entity_description: ProxmoxVMSensorEntityDescription,
|
||||
vm_data: dict[str, Any],
|
||||
node_data: ProxmoxNodeData,
|
||||
) -> None:
|
||||
"""Initialize the Proxmox VM sensor."""
|
||||
self.entity_description = entity_description
|
||||
super().__init__(coordinator, vm_data, node_data)
|
||||
|
||||
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{self.device_id}_{entity_description.key}"
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
"""Return the native value of the sensor."""
|
||||
@@ -367,19 +342,6 @@ class ProxmoxContainerSensor(ProxmoxContainerEntity, SensorEntity):
|
||||
|
||||
entity_description: ProxmoxContainerSensorEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: ProxmoxCoordinator,
|
||||
entity_description: ProxmoxContainerSensorEntityDescription,
|
||||
container_data: dict[str, Any],
|
||||
node_data: ProxmoxNodeData,
|
||||
) -> None:
|
||||
"""Initialize the Proxmox container sensor."""
|
||||
self.entity_description = entity_description
|
||||
super().__init__(coordinator, container_data, node_data)
|
||||
|
||||
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{self.device_id}_{entity_description.key}"
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
"""Return the native value of the sensor."""
|
||||
|
||||
@@ -269,7 +269,6 @@ class LeilSaunaClimate(LeilSaunaEntity, ClimateEntity):
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="start_session_failed",
|
||||
translation_placeholders={"error": str(err)},
|
||||
) from err
|
||||
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@@ -47,5 +47,4 @@ class LeilSaunaCoordinator(DataUpdateCoordinator[SaunumData]):
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="communication_error",
|
||||
translation_placeholders={"error": str(err)},
|
||||
) from err
|
||||
|
||||
@@ -88,7 +88,7 @@
|
||||
},
|
||||
"exceptions": {
|
||||
"communication_error": {
|
||||
"message": "Communication error: {error}"
|
||||
"message": "Communication error with sauna control unit"
|
||||
},
|
||||
"door_open": {
|
||||
"message": "Cannot start sauna session when sauna door is open"
|
||||
@@ -130,7 +130,7 @@
|
||||
"message": "Failed to set temperature to {temperature}"
|
||||
},
|
||||
"start_session_failed": {
|
||||
"message": "Failed to start sauna session: {error}"
|
||||
"message": "Failed to start sauna session"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
|
||||
@@ -4,11 +4,16 @@ from __future__ import annotations
|
||||
|
||||
from pycognito.exceptions import WarrantException
|
||||
import pyschlage
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import HomeAssistant, SupportsResponse
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers import config_validation as cv, service
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import DOMAIN, SERVICE_ADD_CODE, SERVICE_DELETE_CODE, SERVICE_GET_CODES
|
||||
from .coordinator import SchlageConfigEntry, SchlageDataUpdateCoordinator
|
||||
|
||||
PLATFORMS: list[Platform] = [
|
||||
@@ -19,6 +24,46 @@ PLATFORMS: list[Platform] = [
|
||||
Platform.SWITCH,
|
||||
]
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the Schlage component."""
|
||||
service.async_register_platform_entity_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
SERVICE_ADD_CODE,
|
||||
entity_domain=LOCK_DOMAIN,
|
||||
schema={
|
||||
vol.Required("name"): cv.string,
|
||||
vol.Required("code"): cv.matches_regex(r"^\d{4,8}$"),
|
||||
},
|
||||
func=SERVICE_ADD_CODE,
|
||||
)
|
||||
|
||||
service.async_register_platform_entity_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
SERVICE_DELETE_CODE,
|
||||
entity_domain=LOCK_DOMAIN,
|
||||
schema={
|
||||
vol.Required("name"): cv.string,
|
||||
},
|
||||
func=SERVICE_DELETE_CODE,
|
||||
)
|
||||
|
||||
service.async_register_platform_entity_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
SERVICE_GET_CODES,
|
||||
entity_domain=LOCK_DOMAIN,
|
||||
schema=None,
|
||||
func=SERVICE_GET_CODES,
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: SchlageConfigEntry) -> bool:
|
||||
"""Set up Schlage from a config entry."""
|
||||
|
||||
@@ -7,3 +7,7 @@ DOMAIN = "schlage"
|
||||
LOGGER = logging.getLogger(__package__)
|
||||
MANUFACTURER = "Schlage"
|
||||
UPDATE_INTERVAL = timedelta(seconds=30)
|
||||
|
||||
SERVICE_ADD_CODE = "add_code"
|
||||
SERVICE_DELETE_CODE = "delete_code"
|
||||
SERVICE_GET_CODES = "get_codes"
|
||||
|
||||
13
homeassistant/components/schlage/icons.json
Normal file
13
homeassistant/components/schlage/icons.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"services": {
|
||||
"add_code": {
|
||||
"service": "mdi:key-plus"
|
||||
},
|
||||
"delete_code": {
|
||||
"service": "mdi:key-minus"
|
||||
},
|
||||
"get_codes": {
|
||||
"service": "mdi:table-key"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,15 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pyschlage.code import AccessCode
|
||||
from pyschlage.exceptions import Error as SchlageError
|
||||
|
||||
from homeassistant.components.lock import LockEntity
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.core import HomeAssistant, ServiceResponse, callback
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import LockData, SchlageConfigEntry, SchlageDataUpdateCoordinator
|
||||
from .entity import SchlageEntity
|
||||
|
||||
@@ -64,3 +69,108 @@ class SchlageLockEntity(SchlageEntity, LockEntity):
|
||||
"""Unlock the device."""
|
||||
await self.hass.async_add_executor_job(self._lock.unlock)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@staticmethod
|
||||
def _normalize_code_name(name: str) -> str:
|
||||
"""Normalize a code name for comparison."""
|
||||
return name.lower().strip()
|
||||
|
||||
def _validate_code_name(
|
||||
self, codes: dict[str, AccessCode] | None, name: str
|
||||
) -> None:
|
||||
"""Validate that the code name doesn't already exist."""
|
||||
normalized = self._normalize_code_name(name)
|
||||
if codes and any(
|
||||
self._normalize_code_name(code.name) == normalized
|
||||
for code in codes.values()
|
||||
):
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="schlage_name_exists",
|
||||
translation_placeholders={"name": name},
|
||||
)
|
||||
|
||||
def _validate_code_value(
|
||||
self, codes: dict[str, AccessCode] | None, code: str
|
||||
) -> None:
|
||||
"""Validate that the code value doesn't already exist."""
|
||||
if codes and any(
|
||||
existing_code.code == code for existing_code in codes.values()
|
||||
):
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="schlage_code_exists",
|
||||
)
|
||||
|
||||
async def _async_fetch_access_codes(self) -> dict[str, AccessCode] | None:
|
||||
"""Fetch access codes from the lock on demand."""
|
||||
try:
|
||||
await self.hass.async_add_executor_job(self._lock.refresh_access_codes)
|
||||
except SchlageError as ex:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="schlage_refresh_failed",
|
||||
) from ex
|
||||
return self._lock.access_codes
|
||||
|
||||
async def add_code(self, name: str, code: str) -> None:
|
||||
"""Add a lock code."""
|
||||
|
||||
codes = await self._async_fetch_access_codes()
|
||||
self._validate_code_name(codes, name)
|
||||
self._validate_code_value(codes, code)
|
||||
|
||||
access_code = AccessCode(name=name, code=code)
|
||||
try:
|
||||
await self.hass.async_add_executor_job(
|
||||
self._lock.add_access_code, access_code
|
||||
)
|
||||
except SchlageError as ex:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="schlage_add_code_failed",
|
||||
) from ex
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def delete_code(self, name: str) -> None:
|
||||
"""Delete a lock code."""
|
||||
codes = await self._async_fetch_access_codes()
|
||||
if not codes:
|
||||
return
|
||||
|
||||
normalized = self._normalize_code_name(name)
|
||||
code_id_to_delete = next(
|
||||
(
|
||||
code_id
|
||||
for code_id, code_data in codes.items()
|
||||
if self._normalize_code_name(code_data.name) == normalized
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
if not code_id_to_delete:
|
||||
# Code not found in defined codes, operation successful
|
||||
return
|
||||
|
||||
try:
|
||||
await self.hass.async_add_executor_job(codes[code_id_to_delete].delete)
|
||||
except SchlageError as ex:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="schlage_delete_code_failed",
|
||||
) from ex
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def get_codes(self) -> ServiceResponse:
|
||||
"""Get lock codes."""
|
||||
await self._async_fetch_access_codes()
|
||||
|
||||
if self._lock.access_codes:
|
||||
return {
|
||||
code: {
|
||||
"name": self._lock.access_codes[code].name,
|
||||
"code": self._lock.access_codes[code].code,
|
||||
}
|
||||
for code in self._lock.access_codes
|
||||
}
|
||||
return {}
|
||||
|
||||
38
homeassistant/components/schlage/services.yaml
Normal file
38
homeassistant/components/schlage/services.yaml
Normal file
@@ -0,0 +1,38 @@
|
||||
get_codes:
|
||||
target:
|
||||
entity:
|
||||
domain: lock
|
||||
integration: schlage
|
||||
|
||||
add_code:
|
||||
target:
|
||||
entity:
|
||||
domain: lock
|
||||
integration: schlage
|
||||
fields:
|
||||
name:
|
||||
required: true
|
||||
example: "Example Person"
|
||||
selector:
|
||||
text:
|
||||
multiline: false
|
||||
code:
|
||||
required: true
|
||||
example: "1111"
|
||||
selector:
|
||||
text:
|
||||
multiline: false
|
||||
type: password
|
||||
|
||||
delete_code:
|
||||
target:
|
||||
entity:
|
||||
domain: lock
|
||||
integration: schlage
|
||||
fields:
|
||||
name:
|
||||
required: true
|
||||
example: "Example Person"
|
||||
selector:
|
||||
text:
|
||||
multiline: false
|
||||
@@ -56,8 +56,50 @@
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"schlage_add_code_failed": {
|
||||
"message": "Failed to add PIN code to the lock."
|
||||
},
|
||||
"schlage_code_exists": {
|
||||
"message": "A PIN code with this value already exists on the lock."
|
||||
},
|
||||
"schlage_delete_code_failed": {
|
||||
"message": "Failed to delete PIN code from the lock."
|
||||
},
|
||||
"schlage_name_exists": {
|
||||
"message": "A PIN code with the name \"{name}\" already exists on the lock."
|
||||
},
|
||||
"schlage_refresh_failed": {
|
||||
"message": "Failed to refresh Schlage data"
|
||||
"message": "Failed to refresh Schlage data."
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"add_code": {
|
||||
"description": "Add a PIN code to a lock.",
|
||||
"fields": {
|
||||
"code": {
|
||||
"description": "The PIN code to add. Must be unique to lock and be between 4 and 8 digits long.",
|
||||
"name": "PIN code"
|
||||
},
|
||||
"name": {
|
||||
"description": "Name for PIN code. Must be case insensitively unique to lock.",
|
||||
"name": "PIN name"
|
||||
}
|
||||
},
|
||||
"name": "Add PIN code"
|
||||
},
|
||||
"delete_code": {
|
||||
"description": "Delete a PIN code from a lock.",
|
||||
"fields": {
|
||||
"name": {
|
||||
"description": "Name of PIN code to delete.",
|
||||
"name": "PIN name"
|
||||
}
|
||||
},
|
||||
"name": "Delete PIN code"
|
||||
},
|
||||
"get_codes": {
|
||||
"description": "Retrieve all PIN codes from the lock.",
|
||||
"name": "Get PIN codes"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import fields
|
||||
|
||||
from aiopyarr.models.host_configuration import PyArrHostConfiguration
|
||||
from aiopyarr.sonarr_client import SonarrClient
|
||||
|
||||
@@ -37,7 +39,6 @@ from .coordinator import (
|
||||
SeriesDataUpdateCoordinator,
|
||||
SonarrConfigEntry,
|
||||
SonarrData,
|
||||
SonarrDataUpdateCoordinator,
|
||||
StatusDataUpdateCoordinator,
|
||||
WantedDataUpdateCoordinator,
|
||||
)
|
||||
@@ -89,16 +90,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: SonarrConfigEntry) -> bo
|
||||
)
|
||||
# Temporary, until we add diagnostic entities
|
||||
_version = None
|
||||
coordinators: list[SonarrDataUpdateCoordinator] = [
|
||||
data.upcoming,
|
||||
data.commands,
|
||||
data.diskspace,
|
||||
data.queue,
|
||||
data.series,
|
||||
data.status,
|
||||
data.wanted,
|
||||
]
|
||||
for coordinator in coordinators:
|
||||
for field in fields(data):
|
||||
coordinator = getattr(data, field.name)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
if isinstance(coordinator, StatusDataUpdateCoordinator):
|
||||
_version = coordinator.data.version
|
||||
|
||||
@@ -128,35 +128,6 @@ def format_queue(
|
||||
return shows
|
||||
|
||||
|
||||
def format_episode_item(
|
||||
series: SonarrSeries, episode_data: dict[str, Any], base_url: str | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""Format a single episode item."""
|
||||
result: dict[str, Any] = {
|
||||
"id": episode_data.get("id"),
|
||||
"episode_number": episode_data.get("episodeNumber"),
|
||||
"season_number": episode_data.get("seasonNumber"),
|
||||
"title": episode_data.get("title"),
|
||||
"air_date": str(episode_data.get("airDate", "")),
|
||||
"overview": episode_data.get("overview"),
|
||||
"has_file": episode_data.get("hasFile", False),
|
||||
"monitored": episode_data.get("monitored", False),
|
||||
}
|
||||
|
||||
# Add episode images if available
|
||||
if images := episode_data.get("images"):
|
||||
result["images"] = {}
|
||||
for image in images:
|
||||
cover_type = image.coverType
|
||||
# Prefer remoteUrl (public TVDB URL) over local path
|
||||
if remote_url := getattr(image, "remoteUrl", None):
|
||||
result["images"][cover_type] = remote_url
|
||||
elif base_url and (url := getattr(image, "url", None)):
|
||||
result["images"][cover_type] = f"{base_url.rstrip('/')}{url}"
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def format_series(
|
||||
series_list: list[SonarrSeries], base_url: str | None = None
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
|
||||
@@ -46,7 +46,7 @@ CONF_SEASON_NUMBER = "season_number"
|
||||
CONF_SPACE_UNIT = "space_unit"
|
||||
|
||||
# Valid space units
|
||||
SPACE_UNITS = ["bytes", "kb", "kib", "mb", "mib", "gb", "gib", "tb", "tib", "pb", "pib"]
|
||||
SPACE_UNITS = ["bytes", "KB", "KiB", "MB", "MiB", "GB", "GiB", "TB", "TiB", "PB", "PiB"]
|
||||
DEFAULT_SPACE_UNIT = "bytes"
|
||||
|
||||
# Default values - 0 means no limit
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
"name": "Sonarr entry"
|
||||
},
|
||||
"space_unit": {
|
||||
"description": "Unit for space values. Use binary units (kib, mib, gib, tib, pib) for 1024-based values or decimal units (kb, mb, gb, tb, pb) for 1000-based values.",
|
||||
"description": "Unit for space values. Use binary units (KiB, MiB, GiB, TiB, PiB) for 1024-based values or decimal units (KB, MB, GB, TB, PB) for 1000-based values. The default is bytes.",
|
||||
"name": "Space unit"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -48,6 +48,7 @@ from .services import async_setup_services
|
||||
PLATFORMS: Final = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.BUTTON,
|
||||
Platform.CALENDAR,
|
||||
Platform.CLIMATE,
|
||||
Platform.COVER,
|
||||
Platform.DEVICE_TRACKER,
|
||||
|
||||
282
homeassistant/components/teslemetry/calendar.py
Normal file
282
homeassistant/components/teslemetry/calendar.py
Normal file
@@ -0,0 +1,282 @@
|
||||
"""Calendar platform for Teslemetry integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.calendar import CalendarEntity, CalendarEvent
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from . import TeslemetryConfigEntry
|
||||
from .entity import TeslemetryEnergyInfoEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: TeslemetryConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Teslemetry Calendar platform from a config entry."""
|
||||
|
||||
entities_to_add: list[CalendarEntity] = []
|
||||
|
||||
entities_to_add.extend(
|
||||
TeslemetryTariffSchedule(energy, "tariff_content_v2")
|
||||
for energy in entry.runtime_data.energysites
|
||||
if energy.info_coordinator.data.get("tariff_content_v2_seasons")
|
||||
)
|
||||
|
||||
entities_to_add.extend(
|
||||
TeslemetryTariffSchedule(energy, "tariff_content_v2_sell_tariff")
|
||||
for energy in entry.runtime_data.energysites
|
||||
if energy.info_coordinator.data.get("tariff_content_v2_sell_tariff_seasons")
|
||||
)
|
||||
|
||||
async_add_entities(entities_to_add)
|
||||
|
||||
|
||||
def _is_day_in_range(day_of_week: int, from_day: int, to_day: int) -> bool:
|
||||
"""Check if a day of week falls within a range, handling week crossing."""
|
||||
if from_day <= to_day:
|
||||
return from_day <= day_of_week <= to_day
|
||||
# Week crossing (e.g., Fri=4 to Mon=0)
|
||||
return day_of_week >= from_day or day_of_week <= to_day
|
||||
|
||||
|
||||
def _parse_period_times(
|
||||
period_def: dict[str, Any],
|
||||
base_day: datetime,
|
||||
) -> tuple[datetime, datetime] | None:
|
||||
"""Parse a TOU period definition into start and end times.
|
||||
|
||||
Returns None if the base_day's weekday doesn't match the period's day range.
|
||||
For periods crossing midnight, end_time will be on the following day.
|
||||
"""
|
||||
# DaysOfWeek are from 0-6 (Monday-Sunday)
|
||||
from_day = period_def.get("fromDayOfWeek", 0)
|
||||
to_day = period_def.get("toDayOfWeek", 6)
|
||||
|
||||
if not _is_day_in_range(base_day.weekday(), from_day, to_day):
|
||||
return None
|
||||
|
||||
# Hours are from 0-23, so 24 hours is 0-0
|
||||
from_hour = period_def.get("fromHour", 0)
|
||||
to_hour = period_def.get("toHour", 0)
|
||||
|
||||
# Minutes are from 0-59, so 60 minutes is 0-0
|
||||
from_minute = period_def.get("fromMinute", 0)
|
||||
to_minute = period_def.get("toMinute", 0)
|
||||
|
||||
start_time = base_day.replace(
|
||||
hour=from_hour, minute=from_minute, second=0, microsecond=0
|
||||
)
|
||||
end_time = base_day.replace(hour=to_hour, minute=to_minute, second=0, microsecond=0)
|
||||
|
||||
if end_time <= start_time:
|
||||
end_time += timedelta(days=1)
|
||||
|
||||
return start_time, end_time
|
||||
|
||||
|
||||
def _build_event(
|
||||
key_base: str,
|
||||
season_name: str,
|
||||
period_name: str,
|
||||
price: float | None,
|
||||
start_time: datetime,
|
||||
end_time: datetime,
|
||||
) -> CalendarEvent:
|
||||
"""Build a CalendarEvent for a tariff period."""
|
||||
price_str = f"{price:.2f}/kWh" if price is not None else "Unknown Price"
|
||||
return CalendarEvent(
|
||||
start=start_time,
|
||||
end=end_time,
|
||||
summary=f"{period_name.capitalize().replace('_', ' ')}: {price_str}",
|
||||
description=(
|
||||
f"Season: {season_name.capitalize()}\n"
|
||||
f"Period: {period_name.capitalize().replace('_', ' ')}\n"
|
||||
f"Price: {price_str}"
|
||||
),
|
||||
uid=f"{key_base}_{season_name}_{period_name}_{start_time.isoformat()}",
|
||||
)
|
||||
|
||||
|
||||
class TeslemetryTariffSchedule(TeslemetryEnergyInfoEntity, CalendarEntity):
|
||||
"""Energy Site Tariff Schedule Calendar."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
data: Any,
|
||||
key_base: str,
|
||||
) -> None:
|
||||
"""Initialize the tariff schedule calendar."""
|
||||
self.key_base: str = key_base
|
||||
self.seasons: dict[str, dict[str, Any]] = {}
|
||||
self.charges: dict[str, dict[str, Any]] = {}
|
||||
super().__init__(data, key_base)
|
||||
|
||||
@property
|
||||
def event(self) -> CalendarEvent | None:
|
||||
"""Return the current active tariff event."""
|
||||
now = dt_util.now()
|
||||
current_season_name = self._get_current_season(now)
|
||||
|
||||
if not current_season_name or not self.seasons.get(current_season_name):
|
||||
return None
|
||||
|
||||
# Time of use (TOU) periods define the tariff schedule within a season
|
||||
tou_periods = self.seasons[current_season_name].get("tou_periods", {})
|
||||
|
||||
for period_name, period_group in tou_periods.items():
|
||||
for period_def in period_group.get("periods", []):
|
||||
result = _parse_period_times(period_def, now)
|
||||
if result is None:
|
||||
continue
|
||||
|
||||
start_time, end_time = result
|
||||
|
||||
# Check if now falls within this period
|
||||
if not (start_time <= now < end_time):
|
||||
# For cross-midnight periods, check yesterday's instance
|
||||
start_time -= timedelta(days=1)
|
||||
end_time -= timedelta(days=1)
|
||||
if not (start_time <= now < end_time):
|
||||
continue
|
||||
|
||||
price = self._get_price_for_period(current_season_name, period_name)
|
||||
return _build_event(
|
||||
self.key_base,
|
||||
current_season_name,
|
||||
period_name,
|
||||
price,
|
||||
start_time,
|
||||
end_time,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
async def async_get_events(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
start_date: datetime,
|
||||
end_date: datetime,
|
||||
) -> list[CalendarEvent]:
|
||||
"""Return calendar events (tariff periods) within a datetime range."""
|
||||
events: list[CalendarEvent] = []
|
||||
|
||||
start_date = dt_util.as_local(start_date)
|
||||
end_date = dt_util.as_local(end_date)
|
||||
|
||||
# Start one day earlier to catch TOU periods that cross midnight
|
||||
# from the previous evening into the query range.
|
||||
current_day = dt_util.start_of_local_day(start_date) - timedelta(days=1)
|
||||
while current_day < end_date:
|
||||
season_name = self._get_current_season(current_day)
|
||||
if not season_name or not self.seasons.get(season_name):
|
||||
current_day += timedelta(days=1)
|
||||
continue
|
||||
|
||||
tou_periods = self.seasons[season_name].get("tou_periods", {})
|
||||
|
||||
for period_name, period_group in tou_periods.items():
|
||||
for period_def in period_group.get("periods", []):
|
||||
result = _parse_period_times(period_def, current_day)
|
||||
if result is None:
|
||||
continue
|
||||
|
||||
start_time, end_time = result
|
||||
|
||||
if start_time < end_date and end_time > start_date:
|
||||
price = self._get_price_for_period(season_name, period_name)
|
||||
events.append(
|
||||
_build_event(
|
||||
self.key_base,
|
||||
season_name,
|
||||
period_name,
|
||||
price,
|
||||
start_time,
|
||||
end_time,
|
||||
)
|
||||
)
|
||||
|
||||
current_day += timedelta(days=1)
|
||||
|
||||
events.sort(key=lambda x: x.start)
|
||||
return events
|
||||
|
||||
def _get_current_season(self, date_to_check: datetime) -> str | None:
|
||||
"""Determine the active season for a given date."""
|
||||
local_date = dt_util.as_local(date_to_check)
|
||||
year = local_date.year
|
||||
|
||||
for season_name, season_data in self.seasons.items():
|
||||
if not season_data:
|
||||
continue
|
||||
|
||||
try:
|
||||
from_month = season_data["fromMonth"]
|
||||
from_day = season_data["fromDay"]
|
||||
to_month = season_data["toMonth"]
|
||||
to_day = season_data["toDay"]
|
||||
|
||||
# Handle seasons that cross year boundaries
|
||||
start_year = year
|
||||
end_year = year
|
||||
|
||||
# Season crosses year boundary (e.g., Oct-Mar)
|
||||
if from_month > to_month or (
|
||||
from_month == to_month and from_day > to_day
|
||||
):
|
||||
if local_date.month > from_month or (
|
||||
local_date.month == from_month and local_date.day >= from_day
|
||||
):
|
||||
end_year = year + 1
|
||||
else:
|
||||
start_year = year - 1
|
||||
|
||||
season_start = local_date.replace(
|
||||
year=start_year,
|
||||
month=from_month,
|
||||
day=from_day,
|
||||
hour=0,
|
||||
minute=0,
|
||||
second=0,
|
||||
microsecond=0,
|
||||
)
|
||||
season_end = local_date.replace(
|
||||
year=end_year,
|
||||
month=to_month,
|
||||
day=to_day,
|
||||
hour=0,
|
||||
minute=0,
|
||||
second=0,
|
||||
microsecond=0,
|
||||
) + timedelta(days=1)
|
||||
|
||||
if season_start <= local_date < season_end:
|
||||
return season_name
|
||||
except KeyError, ValueError:
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
def _get_price_for_period(self, season_name: str, period_name: str) -> float | None:
|
||||
"""Get the price for a specific season and period name."""
|
||||
try:
|
||||
season_charges = self.charges.get(season_name, self.charges.get("ALL", {}))
|
||||
rates = season_charges.get("rates", {})
|
||||
price = rates.get(period_name, rates.get("ALL"))
|
||||
return float(price) if price is not None else None
|
||||
except KeyError, ValueError, TypeError:
|
||||
return None
|
||||
|
||||
def _async_update_attrs(self) -> None:
|
||||
"""Update the Calendar attributes from coordinator data."""
|
||||
self.seasons = self.coordinator.data.get(f"{self.key_base}_seasons", {})
|
||||
self.charges = self.coordinator.data.get(f"{self.key_base}_energy_charges", {})
|
||||
self._attr_available = bool(self.seasons and self.charges)
|
||||
@@ -104,6 +104,7 @@ class TeslemetryVehicleDataCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_failed",
|
||||
) from e
|
||||
|
||||
return flatten(data)
|
||||
|
||||
|
||||
@@ -200,7 +201,11 @@ class TeslemetryEnergySiteInfoCoordinator(DataUpdateCoordinator[dict[str, Any]])
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_failed",
|
||||
) from e
|
||||
return flatten(data)
|
||||
|
||||
return flatten(
|
||||
data,
|
||||
skip_keys=["daily_charges", "demand_charges", "energy_charges", "seasons"],
|
||||
)
|
||||
|
||||
|
||||
class TeslemetryEnergyHistoryCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
|
||||
@@ -11,14 +11,20 @@ from homeassistant.helpers import device_registry as dr
|
||||
from .const import DOMAIN, LOGGER
|
||||
|
||||
|
||||
def flatten(data: dict[str, Any], parent: str | None = None) -> dict[str, Any]:
|
||||
def flatten(
|
||||
data: dict[str, Any],
|
||||
parent: str | None = None,
|
||||
*,
|
||||
skip_keys: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Flatten the data structure."""
|
||||
result = {}
|
||||
for key, value in data.items():
|
||||
skip = skip_keys and key in skip_keys
|
||||
if parent:
|
||||
key = f"{parent}_{key}"
|
||||
if isinstance(value, dict):
|
||||
result.update(flatten(value, key))
|
||||
if isinstance(value, dict) and not skip:
|
||||
result.update(flatten(value, key, skip_keys=skip_keys))
|
||||
else:
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
@@ -272,6 +272,14 @@
|
||||
"name": "Wake"
|
||||
}
|
||||
},
|
||||
"calendar": {
|
||||
"tariff_content_v2": {
|
||||
"name": "Buy tariff"
|
||||
},
|
||||
"tariff_content_v2_sell_tariff": {
|
||||
"name": "Sell tariff"
|
||||
}
|
||||
},
|
||||
"climate": {
|
||||
"climate_state_cabin_overheat_protection": {
|
||||
"name": "Cabin overheat protection"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user