Compare commits

..

1 Commits

Author SHA1 Message Date
Paulus Schoutsen
a209b98d1c Add app panel 2025-11-29 16:54:34 -05:00
165 changed files with 1357 additions and 4661 deletions

View File

@@ -231,7 +231,7 @@ jobs:
- name: Detect duplicates using AI
id: ai_detection
if: steps.extract.outputs.should_continue == 'true' && steps.fetch_similar.outputs.has_similar == 'true'
uses: actions/ai-inference@334892bb203895caaed82ec52d23c1ed9385151e # v2.0.4
uses: actions/ai-inference@02c6cc30ae592ce65ee356387748dfc2fd5f7993 # v2.0.3
with:
model: openai/gpt-4o
system-prompt: |

View File

@@ -57,7 +57,7 @@ jobs:
- name: Detect language using AI
id: ai_language_detection
if: steps.detect_language.outputs.should_continue == 'true'
uses: actions/ai-inference@334892bb203895caaed82ec52d23c1ed9385151e # v2.0.4
uses: actions/ai-inference@02c6cc30ae592ce65ee356387748dfc2fd5f7993 # v2.0.3
with:
model: openai/gpt-4o-mini
system-prompt: |

2
CODEOWNERS generated
View File

@@ -539,8 +539,6 @@ build.json @home-assistant/supervisor
/tests/components/freebox/ @hacf-fr @Quentame
/homeassistant/components/freedompro/ @stefano055415
/tests/components/freedompro/ @stefano055415
/homeassistant/components/fressnapf_tracker/ @eifinger
/tests/components/fressnapf_tracker/ @eifinger
/homeassistant/components/fritz/ @AaronDavidSchneider @chemelli74 @mib1185
/tests/components/fritz/ @AaronDavidSchneider @chemelli74 @mib1185
/homeassistant/components/fritzbox/ @mib1185 @flabbamann

View File

@@ -2,7 +2,6 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
import logging
from typing import Any
@@ -175,56 +174,6 @@ class AirobotConfigFlow(BaseConfigFlow, domain=DOMAIN):
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle reauthentication upon an API authentication error."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm reauthentication dialog."""
errors: dict[str, str] = {}
reauth_entry = self._get_reauth_entry()
if user_input is not None:
# Combine existing data with new password
data = {
CONF_HOST: reauth_entry.data[CONF_HOST],
CONF_USERNAME: reauth_entry.data[CONF_USERNAME],
CONF_PASSWORD: user_input[CONF_PASSWORD],
}
try:
await validate_input(self.hass, data)
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
errors["base"] = "invalid_auth"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return self.async_update_reload_and_abort(
reauth_entry,
data_updates={CONF_PASSWORD: user_input[CONF_PASSWORD]},
)
return self.async_show_form(
step_id="reauth_confirm",
data_schema=vol.Schema(
{
vol.Required(CONF_PASSWORD): str,
}
),
description_placeholders={
"username": reauth_entry.data[CONF_USERNAME],
"host": reauth_entry.data[CONF_HOST],
},
errors=errors,
)
class CannotConnect(HomeAssistantError):
"""Error to indicate we cannot connect."""

View File

@@ -11,7 +11,6 @@ from pyairobotrest.exceptions import AirobotAuthError, AirobotConnectionError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -54,15 +53,7 @@ class AirobotDataUpdateCoordinator(DataUpdateCoordinator[AirobotData]):
try:
status = await self.client.get_statuses()
settings = await self.client.get_settings()
except AirobotAuthError as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="authentication_failed",
) from err
except AirobotConnectionError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="connection_failed",
) from err
except (AirobotAuthError, AirobotConnectionError) as err:
raise UpdateFailed(f"Failed to communicate with device: {err}") from err
return AirobotData(status=status, settings=settings)

View File

@@ -12,6 +12,6 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["pyairobotrest"],
"quality_scale": "silver",
"quality_scale": "bronze",
"requirements": ["pyairobotrest==0.1.0"]
}

View File

@@ -34,7 +34,7 @@ rules:
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: done
reauthentication-flow: todo
test-coverage: done
# Gold

View File

@@ -1,8 +1,7 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
@@ -15,24 +14,15 @@
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"password": "[%key:component::airobot::config::step::user::data_description::password%]"
"password": "The thermostat password."
},
"description": "Airobot thermostat {device_id} discovered at {host}. Enter the password to complete setup. Find the password in the thermostat settings menu under Connectivity → Mobile app."
},
"reauth_confirm": {
"data": {
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"password": "[%key:component::airobot::config::step::user::data_description::password%]"
},
"description": "The authentication for Airobot thermostat at {host} (Device ID: {username}) has expired. Please enter the password to reauthenticate. Find the password in the thermostat settings menu under Connectivity → Mobile app."
},
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]",
"password": "[%key:common::config_flow::data::password%]",
"username": "Device ID"
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"host": "The hostname or IP address of your Airobot thermostat.",
@@ -44,12 +34,6 @@
}
},
"exceptions": {
"authentication_failed": {
"message": "Authentication failed, please reauthenticate."
},
"connection_failed": {
"message": "Failed to communicate with device."
},
"set_preset_mode_failed": {
"message": "Failed to set preset mode to {preset_mode}."
},

View File

@@ -160,6 +160,7 @@
"triggers": {
"armed": {
"description": "Triggers when an alarm is armed.",
"description_configured": "[%key:component::alarm_control_panel::triggers::armed::description%]",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",
@@ -170,6 +171,7 @@
},
"armed_away": {
"description": "Triggers when an alarm is armed away.",
"description_configured": "[%key:component::alarm_control_panel::triggers::armed_away::description%]",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",
@@ -180,6 +182,7 @@
},
"armed_home": {
"description": "Triggers when an alarm is armed home.",
"description_configured": "[%key:component::alarm_control_panel::triggers::armed_home::description%]",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",
@@ -190,6 +193,7 @@
},
"armed_night": {
"description": "Triggers when an alarm is armed night.",
"description_configured": "[%key:component::alarm_control_panel::triggers::armed_night::description%]",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",
@@ -200,6 +204,7 @@
},
"armed_vacation": {
"description": "Triggers when an alarm is armed vacation.",
"description_configured": "[%key:component::alarm_control_panel::triggers::armed_vacation::description%]",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",
@@ -210,6 +215,7 @@
},
"disarmed": {
"description": "Triggers when an alarm is disarmed.",
"description_configured": "[%key:component::alarm_control_panel::triggers::disarmed::description%]",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",
@@ -220,6 +226,7 @@
},
"triggered": {
"description": "Triggers when an alarm is triggered.",
"description_configured": "[%key:component::alarm_control_panel::triggers::triggered::description%]",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",

View File

@@ -19,7 +19,7 @@
"data_description": {
"account_number": "Your account number found on your latest bill.",
"password": "Your password",
"username": "Username or email used to log in to the Anglian Water website."
"username": "Username or email used to login to the Anglian Water website."
},
"description": "Enter your Anglian Water account credentials to connect to Home Assistant."
}

View File

@@ -113,6 +113,7 @@
"triggers": {
"idle": {
"description": "Triggers when an Assist satellite becomes idle.",
"description_configured": "[%key:component::assist_satellite::triggers::idle::description%]",
"fields": {
"behavior": {
"description": "[%key:component::assist_satellite::common::trigger_behavior_description%]",
@@ -123,6 +124,7 @@
},
"listening": {
"description": "Triggers when an Assist satellite starts listening.",
"description_configured": "[%key:component::assist_satellite::triggers::listening::description%]",
"fields": {
"behavior": {
"description": "[%key:component::assist_satellite::common::trigger_behavior_description%]",
@@ -133,6 +135,7 @@
},
"processing": {
"description": "Triggers when an Assist satellite is processing.",
"description_configured": "[%key:component::assist_satellite::triggers::processing::description%]",
"fields": {
"behavior": {
"description": "[%key:component::assist_satellite::common::trigger_behavior_description%]",
@@ -143,6 +146,7 @@
},
"responding": {
"description": "Triggers when an Assist satellite is responding.",
"description_configured": "[%key:component::assist_satellite::triggers::responding::description%]",
"fields": {
"behavior": {
"description": "[%key:component::assist_satellite::common::trigger_behavior_description%]",

View File

@@ -68,9 +68,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: BoschAlarmConfigEntry) -
config_entry_id=entry.entry_id,
connections={(CONNECTION_NETWORK_MAC, mac)} if mac else set(),
identifiers={(DOMAIN, entry.unique_id or entry.entry_id)},
name=f"Bosch {panel.model.name}",
name=f"Bosch {panel.model}",
manufacturer="Bosch Security Systems",
model=panel.model.name,
model=panel.model,
sw_version=panel.firmware_version,
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)

View File

@@ -83,7 +83,7 @@ async def try_connect(
finally:
await panel.disconnect()
return (panel.model.name, panel.serial_number)
return (panel.model, panel.serial_number)
class BoschAlarmConfigFlow(ConfigFlow, domain=DOMAIN):

View File

@@ -20,8 +20,7 @@ async def async_get_config_entry_diagnostics(
return {
"entry_data": async_redact_data(entry.data, TO_REDACT),
"data": {
"model": entry.runtime_data.model.name,
"family": entry.runtime_data.model.family.name,
"model": entry.runtime_data.model,
"serial_number": entry.runtime_data.serial_number,
"protocol_version": entry.runtime_data.protocol_version,
"firmware_version": entry.runtime_data.firmware_version,

View File

@@ -26,7 +26,7 @@ class BoschAlarmEntity(Entity):
self._attr_should_poll = False
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, unique_id)},
name=f"Bosch {panel.model.name}",
name=f"Bosch {panel.model}",
manufacturer="Bosch Security Systems",
)

View File

@@ -12,5 +12,5 @@
"integration_type": "device",
"iot_class": "local_push",
"quality_scale": "platinum",
"requirements": ["bosch-alarm-mode2==0.4.10"]
"requirements": ["bosch-alarm-mode2==0.4.6"]
}

View File

@@ -300,6 +300,7 @@
"triggers": {
"started_cooling": {
"description": "Triggers when a climate started cooling.",
"description_configured": "[%key:component::climate::triggers::started_cooling::description%]",
"fields": {
"behavior": {
"description": "[%key:component::climate::common::trigger_behavior_description%]",
@@ -310,6 +311,7 @@
},
"started_drying": {
"description": "Triggers when a climate started drying.",
"description_configured": "[%key:component::climate::triggers::started_drying::description%]",
"fields": {
"behavior": {
"description": "[%key:component::climate::common::trigger_behavior_description%]",
@@ -320,6 +322,7 @@
},
"started_heating": {
"description": "Triggers when a climate starts to heat.",
"description_configured": "[%key:component::climate::triggers::started_heating::description%]",
"fields": {
"behavior": {
"description": "[%key:component::climate::common::trigger_behavior_description%]",
@@ -330,6 +333,7 @@
},
"turned_off": {
"description": "Triggers when a climate is turned off.",
"description_configured": "[%key:component::climate::triggers::turned_off::description%]",
"fields": {
"behavior": {
"description": "[%key:component::climate::common::trigger_behavior_description%]",
@@ -340,6 +344,7 @@
},
"turned_on": {
"description": "Triggers when a climate is turned on.",
"description_configured": "[%key:component::climate::triggers::turned_on::description%]",
"fields": {
"behavior": {
"description": "[%key:component::climate::common::trigger_behavior_description%]",

View File

@@ -108,5 +108,34 @@
"toggle_cover_tilt": {
"service": "mdi:arrow-top-right-bottom-left"
}
},
"triggers": {
"awning_opened": {
"trigger": "mdi:awning-outline"
},
"blind_opened": {
"trigger": "mdi:blinds-horizontal"
},
"curtain_opened": {
"trigger": "mdi:curtains"
},
"door_opened": {
"trigger": "mdi:door-open"
},
"garage_opened": {
"trigger": "mdi:garage-open"
},
"gate_opened": {
"trigger": "mdi:gate-open"
},
"shade_opened": {
"trigger": "mdi:roller-shade"
},
"shutter_opened": {
"trigger": "mdi:window-shutter-open"
},
"window_opened": {
"trigger": "mdi:window-open"
}
}
}

View File

@@ -1,4 +1,16 @@
{
"common": {
"trigger_behavior_description_awning": "The behavior of the targeted awnings to trigger on.",
"trigger_behavior_description_blind": "The behavior of the targeted blinds to trigger on.",
"trigger_behavior_description_curtain": "The behavior of the targeted curtains to trigger on.",
"trigger_behavior_description_door": "The behavior of the targeted doors to trigger on.",
"trigger_behavior_description_garage": "The behavior of the targeted garage doors to trigger on.",
"trigger_behavior_description_gate": "The behavior of the targeted gates to trigger on.",
"trigger_behavior_description_shade": "The behavior of the targeted shades to trigger on.",
"trigger_behavior_description_shutter": "The behavior of the targeted shutters to trigger on.",
"trigger_behavior_description_window": "The behavior of the targeted windows to trigger on.",
"trigger_behavior_name": "Behavior"
},
"device_automation": {
"action_type": {
"close": "Close {entity_name}",
@@ -82,6 +94,15 @@
"name": "Window"
}
},
"selector": {
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
}
}
},
"services": {
"close_cover": {
"description": "Closes a cover.",
@@ -136,5 +157,142 @@
"name": "Toggle tilt"
}
},
"title": "Cover"
"title": "Cover",
"triggers": {
"awning_opened": {
"description": "Triggers when an awning opens.",
"description_configured": "[%key:component::cover::triggers::awning_opened::description%]",
"fields": {
"behavior": {
"description": "[%key:component::cover::common::trigger_behavior_description_awning%]",
"name": "[%key:component::cover::common::trigger_behavior_name%]"
},
"fully_opened": {
"description": "Require the awnings to be fully opened before triggering.",
"name": "Fully opened"
}
},
"name": "When an awning opens"
},
"blind_opened": {
"description": "Triggers when a blind opens.",
"description_configured": "[%key:component::cover::triggers::blind_opened::description%]",
"fields": {
"behavior": {
"description": "[%key:component::cover::common::trigger_behavior_description_blind%]",
"name": "[%key:component::cover::common::trigger_behavior_name%]"
},
"fully_opened": {
"description": "Require the blinds to be fully opened before triggering.",
"name": "Fully opened"
}
},
"name": "When a blind opens"
},
"curtain_opened": {
"description": "Triggers when a curtain opens.",
"description_configured": "[%key:component::cover::triggers::curtain_opened::description%]",
"fields": {
"behavior": {
"description": "[%key:component::cover::common::trigger_behavior_description_curtain%]",
"name": "[%key:component::cover::common::trigger_behavior_name%]"
},
"fully_opened": {
"description": "Require the curtains to be fully opened before triggering.",
"name": "Fully opened"
}
},
"name": "When a curtain opens"
},
"door_opened": {
"description": "Triggers when a door opens.",
"description_configured": "[%key:component::cover::triggers::door_opened::description%]",
"fields": {
"behavior": {
"description": "[%key:component::cover::common::trigger_behavior_description_door%]",
"name": "[%key:component::cover::common::trigger_behavior_name%]"
},
"fully_opened": {
"description": "Require the doors to be fully opened before triggering.",
"name": "Fully opened"
}
},
"name": "When a door opens"
},
"garage_opened": {
"description": "Triggers when a garage door opens.",
"description_configured": "[%key:component::cover::triggers::garage_opened::description%]",
"fields": {
"behavior": {
"description": "[%key:component::cover::common::trigger_behavior_description_garage%]",
"name": "[%key:component::cover::common::trigger_behavior_name%]"
},
"fully_opened": {
"description": "Require the garage doors to be fully opened before triggering.",
"name": "Fully opened"
}
},
"name": "When a garage door opens"
},
"gate_opened": {
"description": "Triggers when a gate opens.",
"description_configured": "[%key:component::cover::triggers::gate_opened::description%]",
"fields": {
"behavior": {
"description": "[%key:component::cover::common::trigger_behavior_description_gate%]",
"name": "[%key:component::cover::common::trigger_behavior_name%]"
},
"fully_opened": {
"description": "Require the gates to be fully opened before triggering.",
"name": "Fully opened"
}
},
"name": "When a gate opens"
},
"shade_opened": {
"description": "Triggers when a shade opens.",
"description_configured": "[%key:component::cover::triggers::shade_opened::description%]",
"fields": {
"behavior": {
"description": "[%key:component::cover::common::trigger_behavior_description_shade%]",
"name": "[%key:component::cover::common::trigger_behavior_name%]"
},
"fully_opened": {
"description": "Require the shades to be fully opened before triggering.",
"name": "Fully opened"
}
},
"name": "When a shade opens"
},
"shutter_opened": {
"description": "Triggers when a shutter opens.",
"description_configured": "[%key:component::cover::triggers::shutter_opened::description%]",
"fields": {
"behavior": {
"description": "[%key:component::cover::common::trigger_behavior_description_shutter%]",
"name": "[%key:component::cover::common::trigger_behavior_name%]"
},
"fully_opened": {
"description": "Require the shutters to be fully opened before triggering.",
"name": "Fully opened"
}
},
"name": "When a shutter opens"
},
"window_opened": {
"description": "Triggers when a window opens.",
"description_configured": "[%key:component::cover::triggers::window_opened::description%]",
"fields": {
"behavior": {
"description": "[%key:component::cover::common::trigger_behavior_description_window%]",
"name": "[%key:component::cover::common::trigger_behavior_name%]"
},
"fully_opened": {
"description": "Require the windows to be fully opened before triggering.",
"name": "Fully opened"
}
},
"name": "When a window opens"
}
}
}

View File

@@ -0,0 +1,116 @@
"""Provides triggers for covers."""
from typing import Final
import voluptuous as vol
from homeassistant.const import CONF_OPTIONS
from homeassistant.core import HomeAssistant, State
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity import get_device_class
from homeassistant.helpers.trigger import (
ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST,
EntityTriggerBase,
Trigger,
TriggerConfig,
)
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
from . import ATTR_CURRENT_POSITION, CoverDeviceClass, CoverState
from .const import DOMAIN
ATTR_FULLY_OPENED: Final = "fully_opened"
COVER_OPENED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST.extend(
{
vol.Required(CONF_OPTIONS): {
vol.Required(ATTR_FULLY_OPENED, default=False): bool,
},
}
)
def get_device_class_or_undefined(
hass: HomeAssistant, entity_id: str
) -> str | None | UndefinedType:
"""Get the device class of an entity or UNDEFINED if not found."""
try:
return get_device_class(hass, entity_id)
except HomeAssistantError:
return UNDEFINED
class CoverOpenedClosedTrigger(EntityTriggerBase):
"""Class for cover opened and closed triggers."""
_attribute: str = ATTR_CURRENT_POSITION
_attribute_value: int | None = None
_device_class: CoverDeviceClass | None
_domain: str = DOMAIN
_to_states: set[str]
def is_to_state(self, state: State) -> bool:
"""Check if the state matches the target state."""
if state.state not in self._to_states:
return False
if (
self._attribute_value is not None
and (value := state.attributes.get(self._attribute)) is not None
and value != self._attribute_value
):
return False
return True
def entity_filter(self, entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
entities = super().entity_filter(entities)
return {
entity_id
for entity_id in entities
if get_device_class_or_undefined(self._hass, entity_id)
== self._device_class
}
class CoverOpenedTrigger(CoverOpenedClosedTrigger):
"""Class for cover opened triggers."""
_schema = COVER_OPENED_TRIGGER_SCHEMA
_to_states = {CoverState.OPEN, CoverState.OPENING}
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the state trigger."""
super().__init__(hass, config)
if self._options.get(ATTR_FULLY_OPENED):
self._attribute_value = 100
def make_cover_opened_trigger(
device_class: CoverDeviceClass | None,
) -> type[CoverOpenedTrigger]:
"""Create an entity state attribute trigger class."""
class CustomTrigger(CoverOpenedTrigger):
"""Trigger for entity state changes."""
_device_class = device_class
return CustomTrigger
TRIGGERS: dict[str, type[Trigger]] = {
"awning_opened": make_cover_opened_trigger(CoverDeviceClass.AWNING),
"blind_opened": make_cover_opened_trigger(CoverDeviceClass.BLIND),
"curtain_opened": make_cover_opened_trigger(CoverDeviceClass.CURTAIN),
"door_opened": make_cover_opened_trigger(CoverDeviceClass.DOOR),
"garage_opened": make_cover_opened_trigger(CoverDeviceClass.GARAGE),
"gate_opened": make_cover_opened_trigger(CoverDeviceClass.GATE),
"shade_opened": make_cover_opened_trigger(CoverDeviceClass.SHADE),
"shutter_opened": make_cover_opened_trigger(CoverDeviceClass.SHUTTER),
"window_opened": make_cover_opened_trigger(CoverDeviceClass.WINDOW),
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for covers."""
return TRIGGERS

View File

@@ -0,0 +1,79 @@
.trigger_common_fields: &trigger_common_fields
behavior:
required: true
default: any
selector:
select:
translation_key: trigger_behavior
options:
- first
- last
- any
fully_opened:
required: true
default: false
selector:
boolean:
awning_opened:
fields: *trigger_common_fields
target:
entity:
domain: cover
device_class: awning
blind_opened:
fields: *trigger_common_fields
target:
entity:
domain: cover
device_class: blind
curtain_opened:
fields: *trigger_common_fields
target:
entity:
domain: cover
device_class: curtain
door_opened:
fields: *trigger_common_fields
target:
entity:
domain: cover
device_class: door
garage_opened:
fields: *trigger_common_fields
target:
entity:
domain: cover
device_class: garage
gate_opened:
fields: *trigger_common_fields
target:
entity:
domain: cover
device_class: gate
shade_opened:
fields: *trigger_common_fields
target:
entity:
domain: cover
device_class: shade
shutter_opened:
fields: *trigger_common_fields
target:
entity:
domain: cover
device_class: shutter
window_opened:
fields: *trigger_common_fields
target:
entity:
domain: cover
device_class: window

View File

@@ -17,7 +17,7 @@
"mqtt": ["esphome/discover/#"],
"quality_scale": "platinum",
"requirements": [
"aioesphomeapi==42.9.0",
"aioesphomeapi==42.8.0",
"esphome-dashboard-api==1.3.0",
"bleak-esphome==3.4.0"
],

View File

@@ -167,6 +167,7 @@
"triggers": {
"turned_off": {
"description": "Triggers when a fan is turned off.",
"description_configured": "[%key:component::fan::triggers::turned_off::description%]",
"fields": {
"behavior": {
"description": "[%key:component::fan::common::trigger_behavior_description%]",
@@ -177,6 +178,7 @@
},
"turned_on": {
"description": "Triggers when a fan is turned on.",
"description_configured": "[%key:component::fan::triggers::turned_on::description%]",
"fields": {
"behavior": {
"description": "[%key:component::fan::common::trigger_behavior_description%]",

View File

@@ -1,49 +0,0 @@
"""The Fressnapf Tracker integration."""
from fressnapftracker import AuthClient
from homeassistant.const import CONF_ACCESS_TOKEN, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.httpx_client import get_async_client
from .const import CONF_USER_ID
from .coordinator import (
FressnapfTrackerConfigEntry,
FressnapfTrackerDataUpdateCoordinator,
)
PLATFORMS: list[Platform] = [Platform.DEVICE_TRACKER]
async def async_setup_entry(
hass: HomeAssistant, entry: FressnapfTrackerConfigEntry
) -> bool:
"""Set up Fressnapf Tracker from a config entry."""
auth_client = AuthClient(client=get_async_client(hass))
devices = await auth_client.get_devices(
user_id=entry.data[CONF_USER_ID],
user_access_token=entry.data[CONF_ACCESS_TOKEN],
)
coordinators: list[FressnapfTrackerDataUpdateCoordinator] = []
for device in devices:
coordinator = FressnapfTrackerDataUpdateCoordinator(
hass,
entry,
device,
)
await coordinator.async_config_entry_first_refresh()
coordinators.append(coordinator)
entry.runtime_data = coordinators
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(
hass: HomeAssistant, entry: FressnapfTrackerConfigEntry
) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -1,193 +0,0 @@
"""Config flow for the Fressnapf Tracker integration."""
import logging
from typing import Any
from fressnapftracker import (
AuthClient,
FressnapfTrackerInvalidPhoneNumberError,
FressnapfTrackerInvalidTokenError,
)
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.helpers.httpx_client import get_async_client
from .const import CONF_PHONE_NUMBER, CONF_SMS_CODE, CONF_USER_ID, DOMAIN
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_PHONE_NUMBER): str,
}
)
STEP_SMS_CODE_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_SMS_CODE): int,
}
)
class FressnapfTrackerConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Fressnapf Tracker."""
VERSION = 1
def __init__(self) -> None:
"""Init Config Flow."""
self._context: dict[str, Any] = {}
self._auth_client: AuthClient | None = None
@property
def auth_client(self) -> AuthClient:
"""Return the auth client, creating it if needed."""
if self._auth_client is None:
self._auth_client = AuthClient(client=get_async_client(self.hass))
return self._auth_client
async def _async_request_sms_code(
self, phone_number: str
) -> tuple[dict[str, str], bool]:
"""Request SMS code and return errors dict and success flag."""
errors: dict[str, str] = {}
try:
response = await self.auth_client.request_sms_code(
phone_number=phone_number
)
except FressnapfTrackerInvalidPhoneNumberError:
errors["base"] = "invalid_phone_number"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
_LOGGER.debug("SMS code request response: %s", response)
self._context[CONF_USER_ID] = response.id
self._context[CONF_PHONE_NUMBER] = phone_number
return errors, True
return errors, False
async def _async_verify_sms_code(
self, sms_code: int
) -> tuple[dict[str, str], str | None]:
"""Verify SMS code and return errors and access_token."""
errors: dict[str, str] = {}
try:
verification_response = await self.auth_client.verify_phone_number(
user_id=self._context[CONF_USER_ID],
sms_code=sms_code,
)
except FressnapfTrackerInvalidTokenError:
errors["base"] = "invalid_sms_code"
except Exception:
_LOGGER.exception("Unexpected exception during SMS code verification")
errors["base"] = "unknown"
else:
_LOGGER.debug(
"Phone number verification response: %s", verification_response
)
return errors, verification_response.user_token.access_token
return errors, None
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
self._async_abort_entries_match(
{CONF_PHONE_NUMBER: user_input[CONF_PHONE_NUMBER]}
)
errors, success = await self._async_request_sms_code(
user_input[CONF_PHONE_NUMBER]
)
if success:
await self.async_set_unique_id(str(self._context[CONF_USER_ID]))
self._abort_if_unique_id_configured()
return await self.async_step_sms_code()
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)
async def async_step_sms_code(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the SMS code step."""
errors: dict[str, str] = {}
if user_input is not None:
errors, access_token = await self._async_verify_sms_code(
user_input[CONF_SMS_CODE]
)
if access_token:
return self.async_create_entry(
title=self._context[CONF_PHONE_NUMBER],
data={
CONF_PHONE_NUMBER: self._context[CONF_PHONE_NUMBER],
CONF_USER_ID: self._context[CONF_USER_ID],
CONF_ACCESS_TOKEN: access_token,
},
)
return self.async_show_form(
step_id="sms_code",
data_schema=STEP_SMS_CODE_DATA_SCHEMA,
errors=errors,
)
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reconfiguration of the integration."""
errors: dict[str, str] = {}
reconfigure_entry = self._get_reconfigure_entry()
if user_input is not None:
errors, success = await self._async_request_sms_code(
user_input[CONF_PHONE_NUMBER]
)
if success:
if reconfigure_entry.data[CONF_USER_ID] != self._context[CONF_USER_ID]:
errors["base"] = "account_change_not_allowed"
else:
return await self.async_step_reconfigure_sms_code()
return self.async_show_form(
step_id="reconfigure",
data_schema=vol.Schema(
{
vol.Required(
CONF_PHONE_NUMBER,
default=reconfigure_entry.data.get(CONF_PHONE_NUMBER),
): str,
}
),
errors=errors,
)
async def async_step_reconfigure_sms_code(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the SMS code step during reconfiguration."""
errors: dict[str, str] = {}
if user_input is not None:
errors, access_token = await self._async_verify_sms_code(
user_input[CONF_SMS_CODE]
)
if access_token:
return self.async_update_reload_and_abort(
self._get_reconfigure_entry(),
data={
CONF_PHONE_NUMBER: self._context[CONF_PHONE_NUMBER],
CONF_USER_ID: self._context[CONF_USER_ID],
CONF_ACCESS_TOKEN: access_token,
},
)
return self.async_show_form(
step_id="reconfigure_sms_code",
data_schema=STEP_SMS_CODE_DATA_SCHEMA,
errors=errors,
)

View File

@@ -1,6 +0,0 @@
"""Constants for the Fressnapf Tracker integration."""
DOMAIN = "fressnapf_tracker"
CONF_PHONE_NUMBER = "phone_number"
CONF_SMS_CODE = "sms_code"
CONF_USER_ID = "user_id"

View File

@@ -1,50 +0,0 @@
"""Data update coordinator for Fressnapf Tracker integration."""
from datetime import timedelta
import logging
from fressnapftracker import ApiClient, Device, FressnapfTrackerError, Tracker
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.httpx_client import get_async_client
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
type FressnapfTrackerConfigEntry = ConfigEntry[
list[FressnapfTrackerDataUpdateCoordinator]
]
class FressnapfTrackerDataUpdateCoordinator(DataUpdateCoordinator[Tracker]):
"""Class to manage fetching data from the API."""
def __init__(
self,
hass: HomeAssistant,
config_entry: FressnapfTrackerConfigEntry,
device: Device,
) -> None:
"""Initialize."""
super().__init__(
hass,
_LOGGER,
name=DOMAIN,
update_interval=timedelta(minutes=15),
config_entry=config_entry,
)
self.device = device
self.client = ApiClient(
serial_number=device.serialnumber,
device_token=device.token,
client=get_async_client(hass),
)
async def _async_update_data(self) -> Tracker:
try:
return await self.client.get_tracker()
except FressnapfTrackerError as exception:
raise UpdateFailed(exception) from exception

View File

@@ -1,69 +0,0 @@
"""Device tracker platform for fressnapf_tracker."""
from homeassistant.components.device_tracker import SourceType
from homeassistant.components.device_tracker.config_entry import TrackerEntity
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import FressnapfTrackerConfigEntry, FressnapfTrackerDataUpdateCoordinator
from .entity import FressnapfTrackerBaseEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: FressnapfTrackerConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the fressnapf_tracker device_trackers."""
async_add_entities(
FressnapfTrackerDeviceTracker(coordinator) for coordinator in entry.runtime_data
)
class FressnapfTrackerDeviceTracker(FressnapfTrackerBaseEntity, TrackerEntity):
"""fressnapf_tracker device tracker."""
_attr_name = None
_attr_translation_key = "pet"
def __init__(
self,
coordinator: FressnapfTrackerDataUpdateCoordinator,
) -> None:
"""Initialize the device tracker."""
super().__init__(coordinator)
self._attr_unique_id = coordinator.device.serialnumber
@property
def available(self) -> bool:
"""Return if entity is available."""
return super().available and self.coordinator.data.position is not None
@property
def latitude(self) -> float | None:
"""Return latitude value of the device."""
if self.coordinator.data.position is not None:
return self.coordinator.data.position.lat
return None
@property
def longitude(self) -> float | None:
"""Return longitude value of the device."""
if self.coordinator.data.position is not None:
return self.coordinator.data.position.lng
return None
@property
def source_type(self) -> SourceType:
"""Return the source type, eg gps or router, of the device."""
return SourceType.GPS
@property
def location_accuracy(self) -> float:
"""Return the location accuracy of the device.
Value in meters.
"""
if self.coordinator.data.position is not None:
return float(self.coordinator.data.position.accuracy)
return 0

View File

@@ -1,27 +0,0 @@
"""fressnapf_tracker class."""
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import FressnapfTrackerDataUpdateCoordinator
from .const import DOMAIN
class FressnapfTrackerBaseEntity(
CoordinatorEntity[FressnapfTrackerDataUpdateCoordinator]
):
"""Base entity for Fressnapf Tracker."""
_attr_has_entity_name = True
def __init__(self, coordinator: FressnapfTrackerDataUpdateCoordinator) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
self.id = coordinator.device.serialnumber
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, str(self.id))},
name=str(self.coordinator.data.name),
model=str(self.coordinator.data.tracker_settings.generation),
manufacturer="Fressnapf",
serial_number=str(self.id),
)

View File

@@ -1,9 +0,0 @@
{
"entity": {
"device_tracker": {
"pet": {
"default": "mdi:paw"
}
}
}
}

View File

@@ -1,11 +0,0 @@
{
"domain": "fressnapf_tracker",
"name": "Fressnapf Tracker",
"codeowners": ["@eifinger"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/fressnapf_tracker",
"integration_type": "hub",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"requirements": ["fressnapftracker==0.1.2"]
}

View File

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

View File

@@ -1,49 +0,0 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
},
"error": {
"account_change_not_allowed": "Reconfiguring to a different account is not allowed. Please create a new entry instead.",
"invalid_phone_number": "Please enter a valid phone number.",
"invalid_sms_code": "The SMS code you entered is invalid.",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"reconfigure": {
"data": {
"phone_number": "[%key:component::fressnapf_tracker::config::step::user::data::phone_number%]"
},
"data_description": {
"phone_number": "[%key:component::fressnapf_tracker::config::step::user::data_description::phone_number%]"
},
"description": "Re-authenticate with your Fressnapf Tracker account to refresh your credentials."
},
"reconfigure_sms_code": {
"data": {
"sms_code": "[%key:component::fressnapf_tracker::config::step::sms_code::data::sms_code%]"
},
"data_description": {
"sms_code": "[%key:component::fressnapf_tracker::config::step::sms_code::data_description::sms_code%]"
}
},
"sms_code": {
"data": {
"sms_code": "SMS code"
},
"data_description": {
"sms_code": "Enter the SMS code you received on your phone."
}
},
"user": {
"data": {
"phone_number": "Phone number"
},
"data_description": {
"phone_number": "Enter your phone number in international format (e.g., +4917612345678)."
}
}
}
}
}

View File

@@ -23,5 +23,5 @@
"winter_mode": {}
},
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20251201.0"]
"requirements": ["home-assistant-frontend==20251127.0"]
}

View File

@@ -2,7 +2,6 @@
from __future__ import annotations
import asyncio
from collections.abc import Callable, Coroutine
from functools import wraps
from typing import Any
@@ -16,9 +15,7 @@ from homeassistant.helpers import singleton
from homeassistant.helpers.storage import Store
from homeassistant.util.hass_dict import HassKey
DATA_STORAGE: HassKey[dict[str, asyncio.Future[UserStore]]] = HassKey(
"frontend_storage"
)
DATA_STORAGE: HassKey[dict[str, UserStore]] = HassKey("frontend_storage")
DATA_SYSTEM_STORAGE: HassKey[SystemStore] = HassKey("frontend_system_storage")
STORAGE_VERSION_USER_DATA = 1
STORAGE_VERSION_SYSTEM_DATA = 1
@@ -37,18 +34,11 @@ async def async_setup_frontend_storage(hass: HomeAssistant) -> None:
async def async_user_store(hass: HomeAssistant, user_id: str) -> UserStore:
"""Access a user store."""
stores = hass.data.setdefault(DATA_STORAGE, {})
if (future := stores.get(user_id)) is None:
future = stores[user_id] = hass.loop.create_future()
store = UserStore(hass, user_id)
try:
await store.async_load()
except BaseException as ex:
del stores[user_id]
future.set_exception(ex)
raise
future.set_result(store)
if (store := stores.get(user_id)) is None:
store = stores[user_id] = UserStore(hass, user_id)
await store.async_load()
return await future
return store
class UserStore:

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["google_air_quality_api"],
"quality_scale": "bronze",
"requirements": ["google_air_quality_api==1.1.3"]
"requirements": ["google_air_quality_api==1.1.2"]
}

View File

@@ -17,7 +17,7 @@ from aiohasupervisor.models import GreenOptions, YellowOptions # noqa: F401
import voluptuous as vol
from homeassistant.auth.const import GROUP_ID_ADMIN
from homeassistant.components import panel_custom
from homeassistant.components import frontend, panel_custom
from homeassistant.components.homeassistant import async_set_stop_handler
from homeassistant.components.http import StaticPathConfig
from homeassistant.config_entries import SOURCE_SYSTEM, ConfigEntry
@@ -329,6 +329,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
return False
async_load_websocket_api(hass)
frontend.async_register_built_in_panel(hass, "app")
host = os.environ["SUPERVISOR"]
websession = async_get_clientsession(hass)

View File

@@ -33,14 +33,13 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.hassio import is_hassio
from .const import DOMAIN, OTBR_DOMAIN, Z2M_EMBER_DOCS_URL, ZHA_DOMAIN
from .const import OTBR_DOMAIN, Z2M_EMBER_DOCS_URL, ZHA_DOMAIN
from .util import (
ApplicationType,
FirmwareInfo,
OwningAddon,
OwningIntegration,
ResetTarget,
async_firmware_flashing_context,
async_flash_silabs_firmware,
get_otbr_addon_manager,
guess_firmware_info,
@@ -229,95 +228,83 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
# Keep track of the firmware we're working with, for error messages
self.installing_firmware_name = firmware_name
# For the duration of firmware flashing, hint to other integrations (i.e. ZHA)
# that the hardware is in use and should not be accessed. This is separate from
# locking the serial port itself, since a momentary release of the port may
# still allow for ZHA to reclaim the device.
async with async_firmware_flashing_context(self.hass, self._device, DOMAIN):
# Installing new firmware is only truly required if the wrong type is
# installed: upgrading to the latest release of the current firmware type
# isn't strictly necessary for functionality.
self._probed_firmware_info = await probe_silabs_firmware_info(
self._device,
bootloader_reset_methods=self.BOOTLOADER_RESET_METHODS,
application_probe_methods=self.APPLICATION_PROBE_METHODS,
# Installing new firmware is only truly required if the wrong type is
# installed: upgrading to the latest release of the current firmware type
# isn't strictly necessary for functionality.
self._probed_firmware_info = await probe_silabs_firmware_info(
self._device,
bootloader_reset_methods=self.BOOTLOADER_RESET_METHODS,
application_probe_methods=self.APPLICATION_PROBE_METHODS,
)
firmware_install_required = self._probed_firmware_info is None or (
self._probed_firmware_info.firmware_type != expected_installed_firmware_type
)
session = async_get_clientsession(self.hass)
client = FirmwareUpdateClient(fw_update_url, session)
try:
manifest = await client.async_update_data()
fw_manifest = next(
fw for fw in manifest.firmwares if fw.filename.startswith(fw_type)
)
except (StopIteration, TimeoutError, ClientError, ManifestMissing) as err:
_LOGGER.warning("Failed to fetch firmware update manifest", exc_info=True)
firmware_install_required = self._probed_firmware_info is None or (
self._probed_firmware_info.firmware_type
!= expected_installed_firmware_type
)
session = async_get_clientsession(self.hass)
client = FirmwareUpdateClient(fw_update_url, session)
try:
manifest = await client.async_update_data()
fw_manifest = next(
fw for fw in manifest.firmwares if fw.filename.startswith(fw_type)
)
except (StopIteration, TimeoutError, ClientError, ManifestMissing) as err:
_LOGGER.warning(
"Failed to fetch firmware update manifest", exc_info=True
)
# Not having internet access should not prevent setup
if not firmware_install_required:
_LOGGER.debug(
"Skipping firmware upgrade due to index download failure"
)
return
raise AbortFlow(
reason="fw_download_failed",
description_placeholders=self._get_translation_placeholders(),
) from err
# Not having internet access should not prevent setup
if not firmware_install_required:
assert self._probed_firmware_info is not None
_LOGGER.debug("Skipping firmware upgrade due to index download failure")
return
# Make sure we do not downgrade the firmware
fw_metadata = NabuCasaMetadata.from_json(fw_manifest.metadata)
fw_version = fw_metadata.get_public_version()
probed_fw_version = Version(self._probed_firmware_info.firmware_version)
raise AbortFlow(
reason="fw_download_failed",
description_placeholders=self._get_translation_placeholders(),
) from err
if probed_fw_version >= fw_version:
_LOGGER.debug(
"Not downgrading firmware, installed %s is newer than available %s",
probed_fw_version,
fw_version,
)
return
if not firmware_install_required:
assert self._probed_firmware_info is not None
try:
fw_data = await client.async_fetch_firmware(fw_manifest)
except (TimeoutError, ClientError, ValueError) as err:
_LOGGER.warning("Failed to fetch firmware update", exc_info=True)
# Make sure we do not downgrade the firmware
fw_metadata = NabuCasaMetadata.from_json(fw_manifest.metadata)
fw_version = fw_metadata.get_public_version()
probed_fw_version = Version(self._probed_firmware_info.firmware_version)
# If we cannot download new firmware, we shouldn't block setup
if not firmware_install_required:
_LOGGER.debug(
"Skipping firmware upgrade due to image download failure"
)
return
if probed_fw_version >= fw_version:
_LOGGER.debug(
"Not downgrading firmware, installed %s is newer than available %s",
probed_fw_version,
fw_version,
)
return
# Otherwise, fail
raise AbortFlow(
reason="fw_download_failed",
description_placeholders=self._get_translation_placeholders(),
) from err
try:
fw_data = await client.async_fetch_firmware(fw_manifest)
except (TimeoutError, ClientError, ValueError) as err:
_LOGGER.warning("Failed to fetch firmware update", exc_info=True)
self._probed_firmware_info = await async_flash_silabs_firmware(
hass=self.hass,
device=self._device,
fw_data=fw_data,
expected_installed_firmware_type=expected_installed_firmware_type,
bootloader_reset_methods=self.BOOTLOADER_RESET_METHODS,
application_probe_methods=self.APPLICATION_PROBE_METHODS,
progress_callback=lambda offset, total: self.async_update_progress(
offset / total
),
)
# If we cannot download new firmware, we shouldn't block setup
if not firmware_install_required:
_LOGGER.debug("Skipping firmware upgrade due to image download failure")
return
# Otherwise, fail
raise AbortFlow(
reason="fw_download_failed",
description_placeholders=self._get_translation_placeholders(),
) from err
self._probed_firmware_info = await async_flash_silabs_firmware(
hass=self.hass,
device=self._device,
fw_data=fw_data,
expected_installed_firmware_type=expected_installed_firmware_type,
bootloader_reset_methods=self.BOOTLOADER_RESET_METHODS,
application_probe_methods=self.APPLICATION_PROBE_METHODS,
progress_callback=lambda offset, total: self.async_update_progress(
offset / total
),
)
async def _configure_and_start_otbr_addon(self) -> None:
"""Configure and start the OTBR addon."""

View File

@@ -26,7 +26,6 @@ from .util import (
ApplicationType,
FirmwareInfo,
ResetTarget,
async_firmware_flashing_context,
async_flash_silabs_firmware,
)
@@ -275,18 +274,16 @@ class BaseFirmwareUpdateEntity(
)
try:
async with async_firmware_flashing_context(
self.hass, self._current_device, self._config_entry.domain
):
firmware_info = await async_flash_silabs_firmware(
hass=self.hass,
device=self._current_device,
fw_data=fw_data,
expected_installed_firmware_type=self.entity_description.expected_firmware_type,
bootloader_reset_methods=self.BOOTLOADER_RESET_METHODS,
application_probe_methods=self.APPLICATION_PROBE_METHODS,
progress_callback=self._update_progress,
)
firmware_info = await async_flash_silabs_firmware(
hass=self.hass,
device=self._current_device,
fw_data=fw_data,
expected_installed_firmware_type=self.entity_description.expected_firmware_type,
bootloader_reset_methods=self.BOOTLOADER_RESET_METHODS,
application_probe_methods=self.APPLICATION_PROBE_METHODS,
progress_callback=self._update_progress,
domain=self._config_entry.domain,
)
finally:
self._attr_in_progress = False
self.async_write_ha_state()

View File

@@ -26,6 +26,7 @@ from homeassistant.helpers.singleton import singleton
from . import DATA_COMPONENT
from .const import (
DOMAIN,
OTBR_ADDON_MANAGER_DATA,
OTBR_ADDON_NAME,
OTBR_ADDON_SLUG,
@@ -365,22 +366,6 @@ async def probe_silabs_firmware_type(
return fw_info.firmware_type
@asynccontextmanager
async def async_firmware_flashing_context(
hass: HomeAssistant, device: str, source_domain: str
) -> AsyncIterator[None]:
"""Register a device as having its firmware being actively interacted with."""
async with async_firmware_update_context(hass, device, source_domain):
firmware_info = await guess_firmware_info(hass, device)
_LOGGER.debug("Guessed firmware info before update: %s", firmware_info)
async with AsyncExitStack() as stack:
for owner in firmware_info.owners:
await stack.enter_async_context(owner.temporarily_stop(hass))
yield
async def async_flash_silabs_firmware(
hass: HomeAssistant,
device: str,
@@ -389,11 +374,10 @@ async def async_flash_silabs_firmware(
bootloader_reset_methods: Sequence[ResetTarget],
application_probe_methods: Sequence[tuple[ApplicationType, int]],
progress_callback: Callable[[int, int], None] | None = None,
*,
domain: str = DOMAIN,
) -> FirmwareInfo:
"""Flash firmware to the SiLabs device.
This function is meant to be used within a firmware update context.
"""
"""Flash firmware to the SiLabs device."""
if not any(
method == expected_installed_firmware_type
for method, _ in application_probe_methods
@@ -403,44 +387,54 @@ async def async_flash_silabs_firmware(
f" not in application probe methods {application_probe_methods!r}"
)
fw_image = await hass.async_add_executor_job(parse_firmware_image, fw_data)
async with async_firmware_update_context(hass, device, domain):
firmware_info = await guess_firmware_info(hass, device)
_LOGGER.debug("Identified firmware info: %s", firmware_info)
flasher = Flasher(
device=device,
probe_methods=tuple(
(m.as_flasher_application_type(), baudrate)
for m, baudrate in application_probe_methods
),
bootloader_reset=tuple(
m.as_flasher_reset_target() for m in bootloader_reset_methods
),
)
fw_image = await hass.async_add_executor_job(parse_firmware_image, fw_data)
try:
# Enter the bootloader with indeterminate progress
await flasher.enter_bootloader()
flasher = Flasher(
device=device,
probe_methods=tuple(
(m.as_flasher_application_type(), baudrate)
for m, baudrate in application_probe_methods
),
bootloader_reset=tuple(
m.as_flasher_reset_target() for m in bootloader_reset_methods
),
)
# Flash the firmware, with progress
await flasher.flash_firmware(fw_image, progress_callback=progress_callback)
except PermissionError as err:
raise HomeAssistantError(
"Failed to flash firmware: Device is used by another application"
) from err
except Exception as err:
raise HomeAssistantError("Failed to flash firmware") from err
async with AsyncExitStack() as stack:
for owner in firmware_info.owners:
await stack.enter_async_context(owner.temporarily_stop(hass))
probed_firmware_info = await probe_silabs_firmware_info(
device,
bootloader_reset_methods=bootloader_reset_methods,
# Only probe for the expected installed firmware type
application_probe_methods=[
(method, baudrate)
for method, baudrate in application_probe_methods
if method == expected_installed_firmware_type
],
)
try:
# Enter the bootloader with indeterminate progress
await flasher.enter_bootloader()
if probed_firmware_info is None:
raise HomeAssistantError("Failed to probe the firmware after flashing")
# Flash the firmware, with progress
await flasher.flash_firmware(
fw_image, progress_callback=progress_callback
)
except PermissionError as err:
raise HomeAssistantError(
"Failed to flash firmware: Device is used by another application"
) from err
except Exception as err:
raise HomeAssistantError("Failed to flash firmware") from err
return probed_firmware_info
probed_firmware_info = await probe_silabs_firmware_info(
device,
bootloader_reset_methods=bootloader_reset_methods,
# Only probe for the expected installed firmware type
application_probe_methods=[
(method, baudrate)
for method, baudrate in application_probe_methods
if method == expected_installed_firmware_type
],
)
if probed_firmware_info is None:
raise HomeAssistantError("Failed to probe the firmware after flashing")
return probed_firmware_info

View File

@@ -2,7 +2,7 @@
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"not_implemented": "This integration can only be set up via discovery."
"not_implemented": "This integration can only be setup via discovery."
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",

View File

@@ -5,7 +5,6 @@
"config_flow": true,
"dependencies": ["http"],
"documentation": "https://www.home-assistant.io/integrations/konnected",
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["konnected"],
"requirements": ["konnected==1.2.0"],

View File

@@ -42,6 +42,7 @@
"triggers": {
"docked": {
"description": "Triggers when a lawn mower has docked.",
"description_configured": "[%key:component::lawn_mower::triggers::docked::description%]",
"fields": {
"behavior": {
"description": "[%key:component::lawn_mower::common::trigger_behavior_description%]",
@@ -52,6 +53,7 @@
},
"errored": {
"description": "Triggers when a lawn mower has errored.",
"description_configured": "[%key:component::lawn_mower::triggers::errored::description%]",
"fields": {
"behavior": {
"description": "[%key:component::lawn_mower::common::trigger_behavior_description%]",
@@ -62,6 +64,7 @@
},
"paused_mowing": {
"description": "Triggers when a lawn mower has paused mowing.",
"description_configured": "[%key:component::lawn_mower::triggers::paused_mowing::description%]",
"fields": {
"behavior": {
"description": "[%key:component::lawn_mower::common::trigger_behavior_description%]",
@@ -72,6 +75,7 @@
},
"started_mowing": {
"description": "Triggers when a lawn mower has started mowing.",
"description_configured": "[%key:component::lawn_mower::triggers::started_mowing::description%]",
"fields": {
"behavior": {
"description": "[%key:component::lawn_mower::common::trigger_behavior_description%]",

View File

@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/lg_thinq",
"iot_class": "cloud_push",
"loggers": ["thinqconnect"],
"requirements": ["thinqconnect==1.0.9"]
"requirements": ["thinqconnect==1.0.8"]
}

View File

@@ -43,6 +43,7 @@
"conditions": {
"is_off": {
"description": "Test if a light is off.",
"description_configured": "[%key:component::light::conditions::is_off::description%]",
"fields": {
"behavior": {
"description": "[%key:component::light::common::condition_behavior_description%]",
@@ -53,6 +54,7 @@
},
"is_on": {
"description": "Test if a light is on.",
"description_configured": "[%key:component::light::conditions::is_on::description%]",
"fields": {
"behavior": {
"description": "[%key:component::light::common::condition_behavior_description%]",
@@ -511,6 +513,7 @@
"triggers": {
"turned_off": {
"description": "Triggers when a light is turned off.",
"description_configured": "[%key:component::light::triggers::turned_off::description%]",
"fields": {
"behavior": {
"description": "[%key:component::light::common::trigger_behavior_description%]",
@@ -521,6 +524,7 @@
},
"turned_on": {
"description": "Triggers when a light is turned on.",
"description_configured": "[%key:component::light::triggers::turned_on::description%]",
"fields": {
"behavior": {
"description": "[%key:component::light::common::trigger_behavior_description%]",

View File

@@ -9,7 +9,6 @@ post:
required: true
selector:
text:
multiline: true
visibility:
selector:
select:

View File

@@ -6,7 +6,6 @@
"config_flow": true,
"dependencies": ["websocket_api"],
"documentation": "https://www.home-assistant.io/integrations/matter",
"integration_type": "hub",
"iot_class": "local_push",
"requirements": ["python-matter-server==8.1.0"],
"zeroconf": ["_matter._tcp.local.", "_matterc._udp.local."]

View File

@@ -8,6 +8,6 @@
"iot_class": "calculated",
"loggers": ["yt_dlp"],
"quality_scale": "internal",
"requirements": ["yt-dlp[default]==2025.11.12"],
"requirements": ["yt-dlp[default]==2025.10.22"],
"single_config_entry": true
}

View File

@@ -382,6 +382,7 @@
"triggers": {
"stopped_playing": {
"description": "Triggers when a media player stops playing.",
"description_configured": "[%key:component::media_player::triggers::stopped_playing::description%]",
"fields": {
"behavior": {
"description": "[%key:component::media_player::common::trigger_behavior_description%]",

View File

@@ -174,14 +174,14 @@ class ProgramPhaseWashingMachine(MieleEnum, missing_to_none=True):
not_running = 0, 256, 65535
pre_wash = 257, 259
soak = 258
main_wash = 260, 11004
rinse = 261, 11005
main_wash = 260
rinse = 261
rinse_hold = 262
cleaning = 263
cooling_down = 264
drain = 265
spin = 266, 11010
anti_crease = 267, 11029
spin = 266
anti_crease = 267
finished = 268
venting = 269
starch_stop = 270
@@ -483,7 +483,6 @@ class WashingMachineProgramId(MieleEnum, missing_to_none=True):
cottons_eco = 133
quick_power_wash = 146
eco_40_60 = 190
normal = 10001
class DishWasherProgramId(MieleEnum, missing_to_none=True):

View File

@@ -1563,6 +1563,7 @@
"triggers": {
"_": {
"description": "When a specific message is received on a given MQTT topic.",
"description_configured": "When an MQTT message has been received",
"fields": {
"payload": {
"description": "The payload to trigger on.",

View File

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

View File

@@ -1,16 +1,5 @@
"""Constants for the onboarding component."""
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class DefaultArea:
"""Default area definition."""
key: str
icon: str
DOMAIN = "onboarding"
STEP_USER = "user"
STEP_CORE_CONFIG = "core_config"
@@ -19,8 +8,4 @@ STEP_ANALYTICS = "analytics"
STEPS = [STEP_USER, STEP_CORE_CONFIG, STEP_ANALYTICS, STEP_INTEGRATION]
DEFAULT_AREAS = (
DefaultArea(key="living_room", icon="mdi:sofa"),
DefaultArea(key="kitchen", icon="mdi:stove"),
DefaultArea(key="bedroom", icon="mdi:bed"),
)
DEFAULT_AREAS = ("living_room", "kitchen", "bedroom")

View File

@@ -208,11 +208,11 @@ class UserOnboardingView(_BaseOnboardingStepView):
area_registry = ar.async_get(hass)
for area in DEFAULT_AREAS:
name = translations[f"component.onboarding.area.{area.key}"]
name = translations[f"component.onboarding.area.{area}"]
# Guard because area might have been created by an automatically
# set up integration.
if not area_registry.async_get_area_by_name(name):
area_registry.async_create(name, icon=area.icon)
area_registry.async_create(name)
await self._async_mark_done(hass)

View File

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

View File

@@ -119,9 +119,6 @@
},
"ph_type_dosing": {
"default": "mdi:beaker"
},
"water_meter_total_permanent": {
"default": "mdi:counter"
}
},
"switch": {

View File

@@ -11,5 +11,5 @@
"documentation": "https://www.home-assistant.io/integrations/pooldose",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["python-pooldose==0.8.1"]
"requirements": ["python-pooldose==0.8.0"]
}

View File

@@ -10,7 +10,6 @@ from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import (
CONCENTRATION_PARTS_PER_MILLION,
@@ -59,13 +58,6 @@ SENSOR_DESCRIPTIONS: tuple[PooldoseSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.VOLUME_FLOW_RATE,
use_dynamic_unit=True,
),
PooldoseSensorEntityDescription(
key="water_meter_total_permanent",
translation_key="water_meter_total_permanent",
device_class=SensorDeviceClass.VOLUME,
state_class=SensorStateClass.TOTAL_INCREASING,
use_dynamic_unit=True,
),
PooldoseSensorEntityDescription(
key="ph_type_dosing",
translation_key="ph_type_dosing",
@@ -231,8 +223,8 @@ class PooldoseSensor(PooldoseEntity, SensorEntity):
and (data := self.get_data()) is not None
and (device_unit := data.get("unit"))
):
# Map device unit (upper case) to Home Assistant unit, return None if unknown
return UNIT_MAPPING.get(device_unit.upper())
# Map device unit to Home Assistant unit, return None if unknown
return UNIT_MAPPING.get(device_unit)
# Fall back to static unit from entity description
return super().native_unit_of_measurement

View File

@@ -160,9 +160,6 @@
"acid": "pH-",
"alcalyne": "pH+"
}
},
"water_meter_total_permanent": {
"name": "Totalizer"
}
},
"switch": {

View File

@@ -14,7 +14,6 @@ from .coordinator import LeilSaunaCoordinator
PLATFORMS: list[Platform] = [
Platform.CLIMATE,
Platform.LIGHT,
Platform.SENSOR,
]
type LeilSaunaConfigEntry = ConfigEntry[LeilSaunaCoordinator]

View File

@@ -1,9 +0,0 @@
{
"entity": {
"sensor": {
"heater_elements_active": {
"default": "mdi:radiator"
}
}
}
}

View File

@@ -60,10 +60,10 @@ rules:
comment: Integration controls a single device; no dynamic device discovery needed.
entity-category: done
entity-device-class: done
entity-disabled-by-default: done
entity-disabled-by-default: todo
entity-translations: done
exception-translations: done
icon-translations: done
icon-translations: todo
reconfiguration-flow: done
repair-issues:
status: exempt
@@ -74,7 +74,5 @@ rules:
# Platinum
async-dependency: todo
inject-websession:
status: exempt
comment: Integration uses Modbus TCP protocol and does not make HTTP requests.
inject-websession: todo
strict-typing: todo

View File

@@ -1,99 +0,0 @@
"""Sensor platform for Saunum Leil Sauna Control Unit integration."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING
from pysaunum import SaunumData
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import EntityCategory, UnitOfTemperature, UnitOfTime
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import LeilSaunaConfigEntry
from .entity import LeilSaunaEntity
if TYPE_CHECKING:
from .coordinator import LeilSaunaCoordinator
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class LeilSaunaSensorEntityDescription(SensorEntityDescription):
"""Describes Leil Sauna sensor entity."""
value_fn: Callable[[SaunumData], float | int | None]
SENSORS: tuple[LeilSaunaSensorEntityDescription, ...] = (
LeilSaunaSensorEntityDescription(
key="current_temperature",
translation_key="current_temperature",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda data: data.current_temperature,
),
LeilSaunaSensorEntityDescription(
key="heater_elements_active",
translation_key="heater_elements_active",
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda data: data.heater_elements_active,
),
LeilSaunaSensorEntityDescription(
key="on_time",
translation_key="on_time",
native_unit_of_measurement=UnitOfTime.SECONDS,
suggested_unit_of_measurement=UnitOfTime.HOURS,
device_class=SensorDeviceClass.DURATION,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
state_class=SensorStateClass.TOTAL_INCREASING,
value_fn=lambda data: data.on_time,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: LeilSaunaConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Saunum Leil Sauna sensors from a config entry."""
coordinator = entry.runtime_data
async_add_entities(
LeilSaunaSensorEntity(coordinator, description)
for description in SENSORS
if description.value_fn(coordinator.data) is not None
)
class LeilSaunaSensorEntity(LeilSaunaEntity, SensorEntity):
"""Representation of a Saunum Leil Sauna sensor."""
entity_description: LeilSaunaSensorEntityDescription
def __init__(
self,
coordinator: LeilSaunaCoordinator,
description: LeilSaunaSensorEntityDescription,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self._attr_unique_id = f"{coordinator.config_entry.entry_id}-{description.key}"
self.entity_description = description
@property
def native_value(self) -> float | int | None:
"""Return the value reported by the sensor."""
return self.entity_description.value_fn(self.coordinator.data)

View File

@@ -34,15 +34,6 @@
"light": {
"name": "[%key:component::light::title%]"
}
},
"sensor": {
"heater_elements_active": {
"name": "Heater elements active",
"unit_of_measurement": "heater elements"
},
"on_time": {
"name": "Total time turned on"
}
}
},
"exceptions": {

View File

@@ -127,7 +127,7 @@ class RpcBluTrvBinarySensor(RpcBinarySensor):
)
BLOCK_SENSORS: dict[tuple[str, str], BlockBinarySensorDescription] = {
SENSORS: dict[tuple[str, str], BlockBinarySensorDescription] = {
("device", "overtemp"): BlockBinarySensorDescription(
key="device|overtemp",
translation_key="overheating",
@@ -376,7 +376,7 @@ def _async_setup_block_entry(
hass,
config_entry,
async_add_entities,
BLOCK_SENSORS,
SENSORS,
BlockSleepingBinarySensor,
)
else:
@@ -384,7 +384,7 @@ def _async_setup_block_entry(
hass,
config_entry,
async_add_entities,
BLOCK_SENSORS,
SENSORS,
BlockBinarySensor,
)
async_setup_entry_rest(

View File

@@ -341,5 +341,3 @@ MODEL_TOP_EV_CHARGER_EVE01 = "EVE01"
MODEL_FRANKEVER_IRRIGATION_CONTROLLER = "Irrigation"
ROLE_GENERIC = "generic"
TRV_CHANNEL = 0

View File

@@ -17,7 +17,7 @@
"iot_class": "local_push",
"loggers": ["aioshelly"],
"quality_scale": "platinum",
"requirements": ["aioshelly==13.22.0"],
"requirements": ["aioshelly==13.21.0"],
"zeroconf": [
{
"name": "shelly*",

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING, Final, cast
from typing import TYPE_CHECKING, Any, Final, cast
from aioshelly.block_device import Block
from aioshelly.const import RPC_GENERATIONS
@@ -34,7 +34,6 @@ from .const import (
MODEL_LINKEDGO_ST1820_THERMOSTAT,
MODEL_TOP_EV_CHARGER_EVE01,
ROLE_GENERIC,
TRV_CHANNEL,
VIRTUAL_NUMBER_MODE_MAP,
)
from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator
@@ -63,6 +62,9 @@ PARALLEL_UPDATES = 0
class BlockNumberDescription(BlockEntityDescription, NumberEntityDescription):
"""Class to describe a BLOCK sensor."""
rest_path: str = ""
rest_arg: str = ""
@dataclass(frozen=True, kw_only=True)
class RpcNumberDescription(RpcEntityDescription, NumberEntityDescription):
@@ -176,7 +178,7 @@ class RpcBluTrvExtTempNumber(RpcBluTrvNumber):
self.async_write_ha_state()
BLOCK_NUMBERS: dict[tuple[str, str], BlockNumberDescription] = {
NUMBERS: dict[tuple[str, str], BlockNumberDescription] = {
("device", "valvePos"): BlockNumberDescription(
key="device|valvepos",
translation_key="valve_position",
@@ -187,6 +189,8 @@ BLOCK_NUMBERS: dict[tuple[str, str], BlockNumberDescription] = {
native_max_value=100,
native_step=1,
mode=NumberMode.SLIDER,
rest_path="thermostat/0",
rest_arg="pos",
),
}
@@ -353,7 +357,7 @@ def _async_setup_block_entry(
hass,
config_entry,
async_add_entities,
BLOCK_NUMBERS,
NUMBERS,
BlockSleepingNumber,
)
@@ -422,11 +426,18 @@ class BlockSleepingNumber(ShellySleepingBlockAttributeEntity, RestoreNumber):
async def async_set_native_value(self, value: float) -> None:
"""Set value."""
LOGGER.debug(
"Setting thermostat position for entity %s to %s", self.name, value
# Example for Shelly Valve: http://192.168.188.187/thermostat/0?pos=13.0
await self._set_state_full_path(
self.entity_description.rest_path,
{self.entity_description.rest_arg: value},
)
self.async_write_ha_state()
async def _set_state_full_path(self, path: str, params: Any) -> Any:
"""Set block state (HTTP request)."""
LOGGER.debug("Setting state for entity %s, state: %s", self.name, params)
try:
await self.coordinator.device.set_thermostat_state(TRV_CHANNEL, pos=value)
return await self.coordinator.device.http_request("get", path, params)
except DeviceConnectionError as err:
self.coordinator.last_update_success = False
raise HomeAssistantError(
@@ -439,4 +450,3 @@ class BlockSleepingNumber(ShellySleepingBlockAttributeEntity, RestoreNumber):
) from err
except InvalidAuthError:
await self.coordinator.async_shutdown_device_and_start_reauth()
self.async_write_ha_state()

View File

@@ -198,7 +198,7 @@ class RpcBluTrvSensor(RpcSensor):
)
BLOCK_SENSORS: dict[tuple[str, str], BlockSensorDescription] = {
SENSORS: dict[tuple[str, str], BlockSensorDescription] = {
("device", "battery"): BlockSensorDescription(
key="device|battery",
native_unit_of_measurement=PERCENTAGE,
@@ -525,6 +525,7 @@ RPC_SENSORS: Final = {
"power_rgbcct": RpcSensorDescription(
key="rgbcct",
sub_key="apower",
name="Power",
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
@@ -962,6 +963,7 @@ RPC_SENSORS: Final = {
"energy_rgbcct": RpcSensorDescription(
key="rgbcct",
sub_key="aenergy",
name="Energy",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
value=lambda status, _: status["total"],
@@ -1738,7 +1740,7 @@ def _async_setup_block_entry(
hass,
config_entry,
async_add_entities,
BLOCK_SENSORS,
SENSORS,
BlockSleepingSensor,
)
else:
@@ -1746,7 +1748,7 @@ def _async_setup_block_entry(
hass,
config_entry,
async_add_entities,
BLOCK_SENSORS,
SENSORS,
BlockSensor,
)
async_setup_entry_rest(

View File

@@ -33,6 +33,9 @@
},
"solar_elevation": {
"default": "mdi:theme-light-dark"
},
"solar_rising": {
"default": "mdi:sun-clock"
}
}
}

View File

@@ -18,6 +18,11 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.issue_registry import (
IssueSeverity,
async_create_issue,
async_delete_issue,
)
from homeassistant.helpers.typing import StateType
from .const import DOMAIN, SIGNAL_EVENTS_CHANGED, SIGNAL_POSITION_CHANGED
@@ -95,6 +100,13 @@ SENSOR_TYPES: tuple[SunSensorEntityDescription, ...] = (
native_unit_of_measurement=DEGREE,
signal=SIGNAL_POSITION_CHANGED,
),
SunSensorEntityDescription(
key="solar_rising",
translation_key="solar_rising",
value_fn=lambda data: data.rising,
entity_registry_enabled_default=False,
signal=SIGNAL_EVENTS_CHANGED,
),
)
@@ -143,6 +155,20 @@ class SunSensor(SensorEntity):
"""Register signal listener when added to hass."""
await super().async_added_to_hass()
if self.entity_description.key == "solar_rising":
async_create_issue(
self.hass,
DOMAIN,
"deprecated_sun_solar_rising",
breaks_in_ha_version="2026.1.0",
is_fixable=False,
severity=IssueSeverity.WARNING,
translation_key="deprecated_sun_solar_rising",
translation_placeholders={
"entity": self.entity_id,
},
)
self.async_on_remove(
async_dispatcher_connect(
self.hass,
@@ -150,3 +176,9 @@ class SunSensor(SensorEntity):
self.async_write_ha_state,
)
)
async def async_will_remove_from_hass(self) -> None:
"""Call when entity will be removed from hass."""
await super().async_will_remove_from_hass()
if self.entity_description.key == "solar_rising":
async_delete_issue(self.hass, DOMAIN, "deprecated_sun_solar_rising")

View File

@@ -24,7 +24,8 @@
"next_rising": { "name": "Next rising" },
"next_setting": { "name": "Next setting" },
"solar_azimuth": { "name": "Solar azimuth" },
"solar_elevation": { "name": "Solar elevation" }
"solar_elevation": { "name": "Solar elevation" },
"solar_rising": { "name": "Solar rising" }
}
},
"entity_component": {
@@ -36,5 +37,11 @@
}
}
},
"issues": {
"deprecated_sun_solar_rising": {
"description": "The 'Solar rising' sensor of the Sun integration is being deprecated; an equivalent 'Solar rising' binary sensor has been made available as a replacement. To resolve this issue, disable {entity}.",
"title": "Deprecated 'Solar rising' sensor"
}
},
"title": "Sun"
}

View File

@@ -4,7 +4,6 @@ from __future__ import annotations
import asyncio
from collections.abc import Coroutine
from functools import partial
import logging
from typing import Any
@@ -134,16 +133,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await hass.config_entries.async_forward_entry_setups(
entry, (entry.options["template_type"],)
)
entry.async_on_unload(
async_labs_listen(
hass,
AUTOMATION_DOMAIN,
NEW_TRIGGERS_CONDITIONS_FEATURE_FLAG,
partial(hass.config_entries.async_schedule_reload, entry.entry_id),
)
)
return True

View File

@@ -17,7 +17,6 @@ from homeassistant.const import (
CONF_ICON,
CONF_ICON_TEMPLATE,
CONF_NAME,
CONF_PLATFORM,
CONF_STATE,
CONF_UNIQUE_ID,
CONF_VALUE_TEMPLATE,
@@ -258,7 +257,6 @@ def create_legacy_template_issue(
deprecation_list.append(issue_id)
try:
config.pop(CONF_PLATFORM, None)
modified_yaml = format_migration_config(config)
yaml_config = yaml_util.dump({DOMAIN: [{domain: [modified_yaml]}]})
# Format to show up properly in a numbered bullet on the repair.

View File

@@ -46,6 +46,7 @@
"triggers": {
"changed": {
"description": "Triggers when the text changes.",
"description_configured": "[%key:component::text::triggers::changed::description%]",
"name": "When the text changes"
}
}

View File

@@ -184,20 +184,20 @@ class TuyaAlarmEntity(TuyaEntity, AlarmControlPanelEntity):
"""Last change triggered by."""
if self._changed_by_wrapper is None:
return None
return self._read_wrapper(self._changed_by_wrapper)
return self._changed_by_wrapper.read_device_status(self.device)
async def async_alarm_disarm(self, code: str | None = None) -> None:
"""Send Disarm command."""
await self._async_send_wrapper_updates(self._mode_wrapper, "disarm")
await self._async_send_dpcode_update(self._mode_wrapper, "disarm")
async def async_alarm_arm_home(self, code: str | None = None) -> None:
"""Send Home command."""
await self._async_send_wrapper_updates(self._mode_wrapper, "arm_home")
await self._async_send_dpcode_update(self._mode_wrapper, "arm_home")
async def async_alarm_arm_away(self, code: str | None = None) -> None:
"""Send Arm command."""
await self._async_send_wrapper_updates(self._mode_wrapper, "arm_away")
await self._async_send_dpcode_update(self._mode_wrapper, "arm_away")
async def async_alarm_trigger(self, code: str | None = None) -> None:
"""Send SOS command."""
await self._async_send_wrapper_updates(self._mode_wrapper, "trigger")
await self._async_send_dpcode_update(self._mode_wrapper, "trigger")

View File

@@ -372,7 +372,7 @@ class _CustomDPCodeWrapper(DPCodeWrapper):
_valid_values: set[bool | float | int | str]
def __init__(
self, dpcode: str, valid_values: set[bool | float | int | str]
self, dpcode: DPCode, valid_values: set[bool | float | int | str]
) -> None:
"""Init CustomDPCodeBooleanWrapper."""
super().__init__(dpcode)
@@ -390,7 +390,7 @@ def _get_dpcode_wrapper(
description: TuyaBinarySensorEntityDescription,
) -> DPCodeWrapper | None:
"""Get DPCode wrapper for an entity description."""
dpcode = description.dpcode or description.key
dpcode = description.dpcode or DPCode(description.key)
if description.bitmap_key is not None:
return DPCodeBitmapBitWrapper.find_dpcode(
device, dpcode, bitmap_key=description.bitmap_key
@@ -461,4 +461,4 @@ class TuyaBinarySensorEntity(TuyaEntity, BinarySensorEntity):
@property
def is_on(self) -> bool | None:
"""Return true if sensor is on."""
return self._read_wrapper(self._dpcode_wrapper)
return self._dpcode_wrapper.read_device_status(self.device)

View File

@@ -117,4 +117,4 @@ class TuyaButtonEntity(TuyaEntity, ButtonEntity):
async def async_press(self) -> None:
"""Press the button."""
await self._async_send_wrapper_updates(self._dpcode_wrapper, True)
await self._async_send_dpcode_update(self._dpcode_wrapper, True)

View File

@@ -118,8 +118,8 @@ class TuyaCameraEntity(TuyaEntity, CameraEntity):
async def async_enable_motion_detection(self) -> None:
"""Enable motion detection in the camera."""
await self._async_send_wrapper_updates(self._motion_detection_switch, True)
await self._async_send_dpcode_update(self._motion_detection_switch, True)
async def async_disable_motion_detection(self) -> None:
"""Disable motion detection in camera."""
await self._async_send_wrapper_updates(self._motion_detection_switch, False)
await self._async_send_dpcode_update(self._motion_detection_switch, False)

View File

@@ -345,14 +345,14 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
"""Set new target hvac mode."""
commands = []
if self._switch_wrapper:
commands.extend(
self._switch_wrapper.get_update_commands(
commands.append(
self._switch_wrapper.get_update_command(
self.device, hvac_mode != HVACMode.OFF
)
)
if self._hvac_mode_wrapper and hvac_mode in self._hvac_to_tuya:
commands.extend(
self._hvac_mode_wrapper.get_update_commands(
commands.append(
self._hvac_mode_wrapper.get_update_command(
self.device, self._hvac_to_tuya[hvac_mode]
)
)
@@ -360,34 +360,34 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set new target preset mode."""
await self._async_send_wrapper_updates(self._hvac_mode_wrapper, preset_mode)
await self._async_send_dpcode_update(self._hvac_mode_wrapper, preset_mode)
async def async_set_fan_mode(self, fan_mode: str) -> None:
"""Set new target fan mode."""
await self._async_send_wrapper_updates(self._fan_mode_wrapper, fan_mode)
await self._async_send_dpcode_update(self._fan_mode_wrapper, fan_mode)
async def async_set_humidity(self, humidity: int) -> None:
"""Set new target humidity."""
await self._async_send_wrapper_updates(self._target_humidity_wrapper, humidity)
await self._async_send_dpcode_update(self._target_humidity_wrapper, humidity)
async def async_set_swing_mode(self, swing_mode: str) -> None:
"""Set new target swing operation."""
commands = []
if self._swing_wrapper:
commands.extend(
self._swing_wrapper.get_update_commands(
commands.append(
self._swing_wrapper.get_update_command(
self.device, swing_mode == SWING_ON
)
)
if self._swing_v_wrapper:
commands.extend(
self._swing_v_wrapper.get_update_commands(
commands.append(
self._swing_v_wrapper.get_update_command(
self.device, swing_mode in (SWING_BOTH, SWING_VERTICAL)
)
)
if self._swing_h_wrapper:
commands.extend(
self._swing_h_wrapper.get_update_commands(
commands.append(
self._swing_h_wrapper.get_update_command(
self.device, swing_mode in (SWING_BOTH, SWING_HORIZONTAL)
)
)
@@ -396,7 +396,7 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
await self._async_send_wrapper_updates(
await self._async_send_dpcode_update(
self._set_temperature, kwargs[ATTR_TEMPERATURE]
)
@@ -475,8 +475,8 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
async def async_turn_on(self) -> None:
"""Turn the device on, retaining current HVAC (if supported)."""
await self._async_send_wrapper_updates(self._switch_wrapper, True)
await self._async_send_dpcode_update(self._switch_wrapper, True)
async def async_turn_off(self) -> None:
"""Turn the device on, retaining current HVAC (if supported)."""
await self._async_send_wrapper_updates(self._switch_wrapper, False)
await self._async_send_dpcode_update(self._switch_wrapper, False)

View File

@@ -421,7 +421,7 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity):
if self._set_position is not None:
await self._async_send_commands(
self._set_position.get_update_commands(self.device, 100)
[self._set_position.get_update_command(self.device, 100)]
)
async def async_close_cover(self, **kwargs: Any) -> None:
@@ -434,14 +434,12 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity):
if self._set_position is not None:
await self._async_send_commands(
self._set_position.get_update_commands(self.device, 0)
[self._set_position.get_update_command(self.device, 0)]
)
async def async_set_cover_position(self, **kwargs: Any) -> None:
"""Move the cover to a specific position."""
await self._async_send_wrapper_updates(
self._set_position, kwargs[ATTR_POSITION]
)
await self._async_send_dpcode_update(self._set_position, kwargs[ATTR_POSITION])
async def async_stop_cover(self, **kwargs: Any) -> None:
"""Stop the cover."""
@@ -452,6 +450,6 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity):
async def async_set_cover_tilt_position(self, **kwargs: Any) -> None:
"""Move the cover tilt to a specific position."""
await self._async_send_wrapper_updates(
await self._async_send_dpcode_update(
self._tilt_position, kwargs[ATTR_TILT_POSITION]
)

View File

@@ -76,7 +76,7 @@ class TuyaEntity(Entity):
return None
return dpcode_wrapper.read_device_status(self.device)
async def _async_send_wrapper_updates(
async def _async_send_dpcode_update(
self, dpcode_wrapper: DPCodeWrapper | None, value: Any
) -> None:
"""Send command to the device."""
@@ -84,5 +84,5 @@ class TuyaEntity(Entity):
return
await self.hass.async_add_executor_job(
self._send_command,
dpcode_wrapper.get_update_commands(self.device, value),
[dpcode_wrapper.get_update_command(self.device, value)],
)

View File

@@ -209,19 +209,19 @@ class TuyaFanEntity(TuyaEntity, FanEntity):
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set the preset mode of the fan."""
await self._async_send_wrapper_updates(self._mode_wrapper, preset_mode)
await self._async_send_dpcode_update(self._mode_wrapper, preset_mode)
async def async_set_direction(self, direction: str) -> None:
"""Set the direction of the fan."""
await self._async_send_wrapper_updates(self._direction_wrapper, direction)
await self._async_send_dpcode_update(self._direction_wrapper, direction)
async def async_set_percentage(self, percentage: int) -> None:
"""Set the speed of the fan, as a percentage."""
await self._async_send_wrapper_updates(self._speed_wrapper, percentage)
await self._async_send_dpcode_update(self._speed_wrapper, percentage)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the fan off."""
await self._async_send_wrapper_updates(self._switch_wrapper, False)
await self._async_send_dpcode_update(self._switch_wrapper, False)
async def async_turn_on(
self,
@@ -233,22 +233,24 @@ class TuyaFanEntity(TuyaEntity, FanEntity):
if self._switch_wrapper is None:
return
commands = self._switch_wrapper.get_update_commands(self.device, True)
commands: list[dict[str, str | bool | int]] = [
self._switch_wrapper.get_update_command(self.device, True)
]
if percentage is not None and self._speed_wrapper is not None:
commands.extend(
self._speed_wrapper.get_update_commands(self.device, percentage)
commands.append(
self._speed_wrapper.get_update_command(self.device, percentage)
)
if preset_mode is not None and self._mode_wrapper:
commands.extend(
self._mode_wrapper.get_update_commands(self.device, preset_mode)
commands.append(
self._mode_wrapper.get_update_command(self.device, preset_mode)
)
await self._async_send_commands(commands)
async def async_oscillate(self, oscillating: bool) -> None:
"""Oscillate the fan."""
await self._async_send_wrapper_updates(self._oscillate_wrapper, oscillating)
await self._async_send_dpcode_update(self._oscillate_wrapper, oscillating)
@property
def is_on(self) -> bool | None:

View File

@@ -49,9 +49,9 @@ def _has_a_valid_dpcode(
device: CustomerDevice, description: TuyaHumidifierEntityDescription
) -> bool:
"""Check if the device has at least one valid DP code."""
properties_to_check: list[str | tuple[str, ...] | None] = [
properties_to_check: list[DPCode | tuple[DPCode, ...] | None] = [
# Main control switch
description.dpcode or description.key,
description.dpcode or DPCode(description.key),
# Other humidity properties
description.current_humidity,
description.humidity,
@@ -107,7 +107,7 @@ async def async_setup_entry(
),
switch_wrapper=DPCodeBooleanWrapper.find_dpcode(
device,
description.dpcode or description.key,
description.dpcode or DPCode(description.key),
prefer_function=True,
),
target_humidity_wrapper=_RoundedIntegerWrapper.find_dpcode(
@@ -192,7 +192,7 @@ class TuyaHumidifierEntity(TuyaEntity, HumidifierEntity):
self.device,
self.entity_description.dpcode or self.entity_description.key,
)
await self._async_send_wrapper_updates(self._switch_wrapper, True)
await self._async_send_dpcode_update(self._switch_wrapper, True)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the device off."""
@@ -201,7 +201,7 @@ class TuyaHumidifierEntity(TuyaEntity, HumidifierEntity):
self.device,
self.entity_description.dpcode or self.entity_description.key,
)
await self._async_send_wrapper_updates(self._switch_wrapper, False)
await self._async_send_dpcode_update(self._switch_wrapper, False)
async def async_set_humidity(self, humidity: int) -> None:
"""Set new target humidity."""
@@ -210,8 +210,8 @@ class TuyaHumidifierEntity(TuyaEntity, HumidifierEntity):
self.device,
self.entity_description.humidity,
)
await self._async_send_wrapper_updates(self._target_humidity_wrapper, humidity)
await self._async_send_dpcode_update(self._target_humidity_wrapper, humidity)
async def async_set_mode(self, mode: str) -> None:
"""Set new target preset mode."""
await self._async_send_wrapper_updates(self._mode_wrapper, mode)
await self._async_send_dpcode_update(self._mode_wrapper, mode)

View File

@@ -720,23 +720,25 @@ class TuyaLightEntity(TuyaEntity, LightEntity):
def turn_on(self, **kwargs: Any) -> None:
"""Turn on or control the light."""
commands = self._switch_wrapper.get_update_commands(self.device, True)
commands = [
self._switch_wrapper.get_update_command(self.device, True),
]
if self._color_mode_wrapper and (
ATTR_WHITE in kwargs or ATTR_COLOR_TEMP_KELVIN in kwargs
):
commands.extend(
self._color_mode_wrapper.get_update_commands(
commands += [
self._color_mode_wrapper.get_update_command(
self.device, WorkMode.WHITE
),
)
]
if self._color_temp_wrapper and ATTR_COLOR_TEMP_KELVIN in kwargs:
commands.extend(
self._color_temp_wrapper.get_update_commands(
commands += [
self._color_temp_wrapper.get_update_command(
self.device, kwargs[ATTR_COLOR_TEMP_KELVIN]
)
)
]
if self._color_data_wrapper and (
ATTR_HS_COLOR in kwargs
@@ -748,11 +750,11 @@ class TuyaLightEntity(TuyaEntity, LightEntity):
)
):
if self._color_mode_wrapper:
commands.extend(
self._color_mode_wrapper.get_update_commands(
commands += [
self._color_mode_wrapper.get_update_command(
self.device, WorkMode.COLOUR
),
)
]
if not (brightness := kwargs.get(ATTR_BRIGHTNESS)):
brightness = self.brightness or 0
@@ -760,11 +762,11 @@ class TuyaLightEntity(TuyaEntity, LightEntity):
if not (color := kwargs.get(ATTR_HS_COLOR)):
color = self.hs_color or (0, 0)
commands.extend(
self._color_data_wrapper.get_update_commands(
commands += [
self._color_data_wrapper.get_update_command(
self.device, (color, brightness)
),
)
]
elif self._brightness_wrapper and (
ATTR_BRIGHTNESS in kwargs or ATTR_WHITE in kwargs
@@ -774,15 +776,15 @@ class TuyaLightEntity(TuyaEntity, LightEntity):
else:
brightness = kwargs[ATTR_WHITE]
commands.extend(
self._brightness_wrapper.get_update_commands(self.device, brightness),
)
commands += [
self._brightness_wrapper.get_update_command(self.device, brightness),
]
self._send_command(commands)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Instruct the light to turn off."""
await self._async_send_wrapper_updates(self._switch_wrapper, False)
await self._async_send_dpcode_update(self._switch_wrapper, False)
@property
def brightness(self) -> int | None:

View File

@@ -10,7 +10,7 @@ from tuya_sharing import CustomerDevice
from homeassistant.util.json import json_loads, json_loads_object
from .const import LOGGER, DPType
from .const import LOGGER, DPCode, DPType
from .util import parse_dptype, remap_value
# Dictionary to track logged warnings to avoid spamming logs
@@ -39,11 +39,11 @@ class TypeInformation:
As provided by the SDK, from `device.function` / `device.status_range`.
"""
dpcode: str
dpcode: DPCode
type_data: str | None = None
@classmethod
def from_json(cls, dpcode: str, type_data: str) -> Self | None:
def from_json(cls, dpcode: DPCode, type_data: str) -> Self | None:
"""Load JSON string and return a TypeInformation object."""
return cls(dpcode=dpcode, type_data=type_data)
@@ -102,7 +102,7 @@ class IntegerTypeData(TypeInformation):
return remap_value(value, from_min, from_max, self.min, self.max, reverse)
@classmethod
def from_json(cls, dpcode: str, type_data: str) -> Self | None:
def from_json(cls, dpcode: DPCode, type_data: str) -> Self | None:
"""Load JSON string and return a IntegerTypeData object."""
if not (parsed := cast(dict[str, Any] | None, json_loads_object(type_data))):
return None
@@ -125,7 +125,7 @@ class BitmapTypeInformation(TypeInformation):
label: list[str]
@classmethod
def from_json(cls, dpcode: str, type_data: str) -> Self | None:
def from_json(cls, dpcode: DPCode, type_data: str) -> Self | None:
"""Load JSON string and return a BitmapTypeInformation object."""
if not (parsed := json_loads_object(type_data)):
return None
@@ -143,7 +143,7 @@ class EnumTypeData(TypeInformation):
range: list[str]
@classmethod
def from_json(cls, dpcode: str, type_data: str) -> Self | None:
def from_json(cls, dpcode: DPCode, type_data: str) -> Self | None:
"""Load JSON string and return a EnumTypeData object."""
if not (parsed := json_loads_object(type_data)):
return None
@@ -175,7 +175,7 @@ class DPCodeWrapper:
native_unit: str | None = None
suggested_unit: str | None = None
def __init__(self, dpcode: str) -> None:
def __init__(self, dpcode: DPCode) -> None:
"""Init DPCodeWrapper."""
self.dpcode = dpcode
@@ -196,24 +196,20 @@ class DPCodeWrapper:
def _convert_value_to_raw_value(self, device: CustomerDevice, value: Any) -> Any:
"""Convert a Home Assistant value back to a raw device value.
This is called by `get_update_commands` to prepare the value for sending
This is called by `get_update_command` to prepare the value for sending
back to the device, and should be implemented in concrete classes if needed.
"""
raise NotImplementedError
def get_update_commands(
self, device: CustomerDevice, value: Any
) -> list[dict[str, Any]]:
"""Get the update commands for the dpcode.
def get_update_command(self, device: CustomerDevice, value: Any) -> dict[str, Any]:
"""Get the update command for the dpcode.
The Home Assistant value is converted back to a raw device value.
"""
return [
{
"code": self.dpcode,
"value": self._convert_value_to_raw_value(device, value),
}
]
return {
"code": self.dpcode,
"value": self._convert_value_to_raw_value(device, value),
}
class DPCodeTypeInformationWrapper[T: TypeInformation](DPCodeWrapper):
@@ -222,7 +218,7 @@ class DPCodeTypeInformationWrapper[T: TypeInformation](DPCodeWrapper):
DPTYPE: DPType
type_information: T
def __init__(self, dpcode: str, type_information: T) -> None:
def __init__(self, dpcode: DPCode, type_information: T) -> None:
"""Init DPCodeWrapper."""
super().__init__(dpcode)
self.type_information = type_information
@@ -231,7 +227,7 @@ class DPCodeTypeInformationWrapper[T: TypeInformation](DPCodeWrapper):
def find_dpcode(
cls,
device: CustomerDevice,
dpcodes: str | tuple[str, ...] | None,
dpcodes: str | DPCode | tuple[DPCode, ...] | None,
*,
prefer_function: bool = False,
) -> Self | None:
@@ -340,7 +336,7 @@ class DPCodeIntegerWrapper(DPCodeTypeInformationWrapper[IntegerTypeData]):
DPTYPE = DPType.INTEGER
def __init__(self, dpcode: str, type_information: IntegerTypeData) -> None:
def __init__(self, dpcode: DPCode, type_information: IntegerTypeData) -> None:
"""Init DPCodeIntegerWrapper."""
super().__init__(dpcode, type_information)
self.native_unit = type_information.unit
@@ -380,7 +376,7 @@ class DPCodeStringWrapper(DPCodeTypeInformationWrapper[TypeInformation]):
class DPCodeBitmapBitWrapper(DPCodeWrapper):
"""Simple wrapper for a specific bit in bitmap values."""
def __init__(self, dpcode: str, mask: int) -> None:
def __init__(self, dpcode: DPCode, mask: int) -> None:
"""Init DPCodeBitmapWrapper."""
super().__init__(dpcode)
self._mask = mask
@@ -395,7 +391,7 @@ class DPCodeBitmapBitWrapper(DPCodeWrapper):
def find_dpcode(
cls,
device: CustomerDevice,
dpcodes: str | tuple[str, ...],
dpcodes: str | DPCode | tuple[DPCode, ...],
*,
bitmap_key: str,
) -> Self | None:
@@ -412,7 +408,7 @@ class DPCodeBitmapBitWrapper(DPCodeWrapper):
@overload
def find_dpcode(
device: CustomerDevice,
dpcodes: str | tuple[str, ...] | None,
dpcodes: str | DPCode | tuple[DPCode, ...] | None,
*,
prefer_function: bool = False,
dptype: Literal[DPType.BITMAP],
@@ -422,7 +418,7 @@ def find_dpcode(
@overload
def find_dpcode(
device: CustomerDevice,
dpcodes: str | tuple[str, ...] | None,
dpcodes: str | DPCode | tuple[DPCode, ...] | None,
*,
prefer_function: bool = False,
dptype: Literal[DPType.ENUM],
@@ -432,7 +428,7 @@ def find_dpcode(
@overload
def find_dpcode(
device: CustomerDevice,
dpcodes: str | tuple[str, ...] | None,
dpcodes: str | DPCode | tuple[DPCode, ...] | None,
*,
prefer_function: bool = False,
dptype: Literal[DPType.INTEGER],
@@ -442,7 +438,7 @@ def find_dpcode(
@overload
def find_dpcode(
device: CustomerDevice,
dpcodes: str | tuple[str, ...] | None,
dpcodes: str | DPCode | tuple[DPCode, ...] | None,
*,
prefer_function: bool = False,
dptype: Literal[DPType.BOOLEAN, DPType.JSON, DPType.RAW],
@@ -451,7 +447,7 @@ def find_dpcode(
def find_dpcode(
device: CustomerDevice,
dpcodes: str | tuple[str, ...] | None,
dpcodes: str | DPCode | tuple[DPCode, ...] | None,
*,
prefer_function: bool = False,
dptype: DPType,
@@ -463,7 +459,9 @@ def find_dpcode(
if dpcodes is None:
return None
if not isinstance(dpcodes, tuple):
if isinstance(dpcodes, str):
dpcodes = (DPCode(dpcodes),)
elif not isinstance(dpcodes, tuple):
dpcodes = (dpcodes,)
lookup_tuple = (

View File

@@ -551,8 +551,8 @@ class TuyaNumberEntity(TuyaEntity, NumberEntity):
@property
def native_value(self) -> float | None:
"""Return the entity value to represent the entity state."""
return self._read_wrapper(self._dpcode_wrapper)
return self._dpcode_wrapper.read_device_status(self.device)
async def async_set_native_value(self, value: float) -> None:
"""Set new value."""
await self._async_send_wrapper_updates(self._dpcode_wrapper, value)
await self._async_send_dpcode_update(self._dpcode_wrapper, value)

View File

@@ -405,8 +405,8 @@ class TuyaSelectEntity(TuyaEntity, SelectEntity):
@property
def current_option(self) -> str | None:
"""Return the selected entity option to represent the entity state."""
return self._read_wrapper(self._dpcode_wrapper)
return self._dpcode_wrapper.read_device_status(self.device)
async def async_select_option(self, option: str) -> None:
"""Change the selected option."""
await self._async_send_wrapper_updates(self._dpcode_wrapper, option)
await self._async_send_dpcode_update(self._dpcode_wrapper, option)

View File

@@ -1851,4 +1851,4 @@ class TuyaSensorEntity(TuyaEntity, SensorEntity):
@property
def native_value(self) -> StateType:
"""Return the value reported by the sensor."""
return self._read_wrapper(self._dpcode_wrapper)
return self._dpcode_wrapper.read_device_status(self.device)

View File

@@ -105,12 +105,12 @@ class TuyaSirenEntity(TuyaEntity, SirenEntity):
@property
def is_on(self) -> bool | None:
"""Return true if siren is on."""
return self._read_wrapper(self._dpcode_wrapper)
return self._dpcode_wrapper.read_device_status(self.device)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the siren on."""
await self._async_send_wrapper_updates(self._dpcode_wrapper, True)
await self._async_send_dpcode_update(self._dpcode_wrapper, True)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the siren off."""
await self._async_send_wrapper_updates(self._dpcode_wrapper, False)
await self._async_send_dpcode_update(self._dpcode_wrapper, False)

View File

@@ -1038,12 +1038,12 @@ class TuyaSwitchEntity(TuyaEntity, SwitchEntity):
@property
def is_on(self) -> bool | None:
"""Return true if switch is on."""
return self._read_wrapper(self._dpcode_wrapper)
return self._dpcode_wrapper.read_device_status(self.device)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the switch on."""
await self._async_send_wrapper_updates(self._dpcode_wrapper, True)
await self._async_send_dpcode_update(self._dpcode_wrapper, True)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the switch off."""
await self._async_send_wrapper_updates(self._dpcode_wrapper, False)
await self._async_send_dpcode_update(self._dpcode_wrapper, False)

View File

@@ -20,14 +20,16 @@ _DPTYPE_MAPPING: dict[str, DPType] = {
def get_dpcode(
device: CustomerDevice, dpcodes: str | tuple[str, ...] | None
) -> str | None:
device: CustomerDevice, dpcodes: str | DPCode | tuple[DPCode, ...] | None
) -> DPCode | None:
"""Get the first matching DPCode from the device or return None."""
if dpcodes is None:
return None
if not isinstance(dpcodes, tuple):
if isinstance(dpcodes, DPCode):
dpcodes = (dpcodes,)
elif isinstance(dpcodes, str):
dpcodes = (DPCode(dpcodes),)
for dpcode in dpcodes:
if (
@@ -68,23 +70,19 @@ class ActionDPCodeNotFoundError(ServiceValidationError):
"""Custom exception for action DP code not found errors."""
def __init__(
self, device: CustomerDevice, expected: str | tuple[str, ...] | None
self, device: CustomerDevice, expected: str | DPCode | tuple[DPCode, ...] | None
) -> None:
"""Initialize the error with device and expected DP codes."""
if expected is None:
expected = () # empty tuple for no expected codes
elif not isinstance(expected, tuple):
expected = (expected,)
elif isinstance(expected, str):
expected = (DPCode(expected),)
super().__init__(
translation_domain=DOMAIN,
translation_key="action_dpcode_not_found",
translation_placeholders={
"expected": str(
sorted(
[dp.value if isinstance(dp, DPCode) else dp for dp in expected]
)
),
"expected": str(sorted([dp.value for dp in expected])),
"available": str(sorted(device.function.keys())),
},
)

View File

@@ -169,11 +169,11 @@ class TuyaVacuumEntity(TuyaEntity, StateVacuumEntity):
async def async_start(self, **kwargs: Any) -> None:
"""Start the device."""
await self._async_send_wrapper_updates(self._switch_wrapper, True)
await self._async_send_dpcode_update(self._switch_wrapper, True)
async def async_stop(self, **kwargs: Any) -> None:
"""Stop the device."""
await self._async_send_wrapper_updates(self._switch_wrapper, False)
await self._async_send_dpcode_update(self._switch_wrapper, False)
async def async_pause(self, **kwargs: Any) -> None:
"""Pause the device."""
@@ -182,19 +182,19 @@ class TuyaVacuumEntity(TuyaEntity, StateVacuumEntity):
async def async_return_to_base(self, **kwargs: Any) -> None:
"""Return device to dock."""
if self._charge_wrapper:
await self._async_send_wrapper_updates(self._charge_wrapper, True)
await self._async_send_dpcode_update(self._charge_wrapper, True)
else:
await self._async_send_wrapper_updates(
await self._async_send_dpcode_update(
self._mode_wrapper, TUYA_MODE_RETURN_HOME
)
async def async_locate(self, **kwargs: Any) -> None:
"""Locate the device."""
await self._async_send_wrapper_updates(self._locate_wrapper, True)
await self._async_send_dpcode_update(self._locate_wrapper, True)
async def async_set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None:
"""Set fan speed."""
await self._async_send_wrapper_updates(self._fan_speed_wrapper, fan_speed)
await self._async_send_dpcode_update(self._fan_speed_wrapper, fan_speed)
def send_command(
self,

View File

@@ -133,14 +133,14 @@ class TuyaValveEntity(TuyaEntity, ValveEntity):
@property
def is_closed(self) -> bool | None:
"""Return if the valve is closed."""
if (is_open := self._read_wrapper(self._dpcode_wrapper)) is None:
if (is_open := self._dpcode_wrapper.read_device_status(self.device)) is None:
return None
return not is_open
async def async_open_valve(self) -> None:
"""Open the valve."""
await self._async_send_wrapper_updates(self._dpcode_wrapper, True)
await self._async_send_dpcode_update(self._dpcode_wrapper, True)
async def async_close_valve(self) -> None:
"""Close the valve."""
await self._async_send_wrapper_updates(self._dpcode_wrapper, False)
await self._async_send_dpcode_update(self._dpcode_wrapper, False)

View File

@@ -15,7 +15,7 @@ from uiprotect.exceptions import BadRequest, ClientError, NotAuthorized
# diagnostics module will not be imported in the executor.
from uiprotect.test_util.anonymize import anonymize_data # noqa: F401
from homeassistant.config_entries import ConfigEntryState
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.const import CONF_API_KEY, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import (
@@ -208,7 +208,7 @@ async def async_remove_config_entry_device(
return True
async def async_migrate_entry(hass: HomeAssistant, entry: UFPConfigEntry) -> bool:
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Migrate entry."""
_LOGGER.debug("Migrating configuration from version %s", entry.version)

View File

@@ -16,6 +16,7 @@ import voluptuous as vol
from homeassistant.config_entries import (
SOURCE_IGNORE,
ConfigEntry,
ConfigEntryState,
ConfigFlow,
ConfigFlowResult,
@@ -54,7 +55,7 @@ from .const import (
MIN_REQUIRED_PROTECT_V,
OUTDATED_LOG_MESSAGE,
)
from .data import UFPConfigEntry, async_last_update_was_successful
from .data import async_last_update_was_successful
from .discovery import async_start_discovery
from .utils import _async_resolve, _async_short_mac, _async_unifi_mac_from_hass
@@ -79,7 +80,7 @@ def _host_is_direct_connect(host: str) -> bool:
async def _async_console_is_offline(
hass: HomeAssistant,
entry: UFPConfigEntry,
entry: ConfigEntry,
) -> bool:
"""Check if a console is offline.
@@ -223,7 +224,7 @@ class ProtectFlowHandler(ConfigFlow, domain=DOMAIN):
@staticmethod
@callback
def async_get_options_flow(
config_entry: UFPConfigEntry,
config_entry: ConfigEntry,
) -> OptionsFlowHandler:
"""Get the options flow for this handler."""
return OptionsFlowHandler()

View File

@@ -6,9 +6,8 @@ import logging
from typing import Any
from uiprotect.data import Light, ModelType, ProtectAdoptableDeviceModel
from uiprotect.data.devices import LightDeviceSettings
from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity
from homeassistant.components.light import ColorMode, LightEntity
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -73,36 +72,10 @@ class ProtectLight(ProtectDeviceEntity, LightEntity):
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the light on."""
brightness = kwargs.get(ATTR_BRIGHTNESS)
led_level: int | None = None
if brightness is not None:
led_level = hass_to_unifi_brightness(brightness)
_LOGGER.debug(
"Turning on light with brightness %s (led_level=%s)",
brightness,
led_level,
)
else:
_LOGGER.debug("Turning on light")
await self.device.api.update_light_public(
self.device.id,
is_light_force_enabled=True,
light_device_settings=(
LightDeviceSettings(
is_indicator_enabled=self.device.light_device_settings.is_indicator_enabled,
led_level=led_level,
pir_duration=self.device.light_device_settings.pir_duration,
pir_sensitivity=self.device.light_device_settings.pir_sensitivity,
)
if led_level is not None
else None
),
)
_LOGGER.debug("Turning on light")
await self.device.api.set_light_is_led_force_on(self.device.id, True)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the light off."""
_LOGGER.debug("Turning off light")
await self.device.api.update_light_public(
self.device.id, is_light_force_enabled=False
)
await self.device.api.set_light_is_led_force_on(self.device.id, False)

View File

@@ -23,7 +23,6 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .data import ProtectDeviceType, UFPConfigEntry
from .entity import ProtectDeviceEntity
@@ -79,13 +78,7 @@ class ProtectMediaPlayer(ProtectDeviceEntity, MediaPlayerEntity):
def _async_update_device_from_protect(self, device: ProtectDeviceType) -> None:
super()._async_update_device_from_protect(device)
updated_device = self.device
speaker_settings = updated_device.speaker_settings
volume = (
speaker_settings.speaker_volume
if speaker_settings.speaker_volume is not None
else speaker_settings.volume
)
self._attr_volume_level = float(volume / 100)
self._attr_volume_level = float(updated_device.speaker_settings.volume / 100)
if (
updated_device.talkback_stream is not None
@@ -130,10 +123,7 @@ class ProtectMediaPlayer(ProtectDeviceEntity, MediaPlayerEntity):
media_id = async_process_play_media_url(self.hass, play_item.url)
if media_type != MediaType.MUSIC:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="only_music_supported",
)
raise HomeAssistantError("Only music media type is supported")
_LOGGER.debug(
"Playing Media %s for %s Speaker", media_id, self.device.display_name
@@ -142,11 +132,7 @@ class ProtectMediaPlayer(ProtectDeviceEntity, MediaPlayerEntity):
try:
await self.device.play_audio(media_id, blocking=False)
except StreamError as err:
_LOGGER.debug("Error playing audio: %s", err)
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="stream_error",
) from err
raise HomeAssistantError(err) from err
# update state after starting player
self._async_updated_event(self.device)

View File

@@ -92,36 +92,6 @@ CAMERA_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = (
ufp_set_method="set_mic_volume",
ufp_perm=PermRequired.WRITE,
),
ProtectNumberEntityDescription(
key="system_sounds_volume",
translation_key="system_sounds_volume",
icon="mdi:volume-high",
entity_category=EntityCategory.CONFIG,
native_unit_of_measurement=PERCENTAGE,
ufp_min=0,
ufp_max=100,
ufp_step=1,
ufp_required_field="feature_flags.has_speaker",
ufp_value="speaker_settings.volume",
ufp_enabled="feature_flags.has_speaker",
ufp_set_method="set_volume",
ufp_perm=PermRequired.WRITE,
),
ProtectNumberEntityDescription(
key="doorbell_ring_volume",
translation_key="doorbell_ring_volume",
icon="mdi:bell-ring",
entity_category=EntityCategory.CONFIG,
native_unit_of_measurement=PERCENTAGE,
ufp_min=0,
ufp_max=100,
ufp_step=1,
ufp_required_field="feature_flags.is_doorbell",
ufp_value="speaker_settings.ring_volume",
ufp_enabled="feature_flags.is_doorbell",
ufp_set_method="set_ring_volume",
ufp_perm=PermRequired.WRITE,
),
ProtectNumberEntityDescription(
key="zoom_position",
translation_key="zoom_level",

View File

@@ -10,6 +10,7 @@ import voluptuous as vol
from homeassistant import data_entry_flow
from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import issue_registry as ir
@@ -164,7 +165,7 @@ class RTSPRepair(ProtectRepair):
@callback
def _async_get_or_create_api_client(
hass: HomeAssistant, entry: UFPConfigEntry
hass: HomeAssistant, entry: ConfigEntry
) -> ProtectApiClient:
"""Get or create an API client."""
if data := async_get_data_for_entry_id(hass, entry.entry_id):

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