Compare commits

..

1 Commits

Author SHA1 Message Date
Mike Degatano
47781b9aa6 Remove code notary related unsupported reasons 2026-03-12 20:59:08 +00:00
227 changed files with 1069 additions and 10732 deletions

View File

@@ -208,7 +208,7 @@ jobs:
cosign-release: "v2.5.3"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Build variables
id: vars
@@ -242,7 +242,7 @@ jobs:
- name: Build base image
id: build
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
context: .
file: ./Dockerfile
@@ -442,7 +442,7 @@ jobs:
# 2025.12.0.dev202511250240 -> tags: 2025.12.0.dev202511250240, dev
- name: Generate Docker metadata
id: meta
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with:
images: ${{ matrix.registry }}/home-assistant
sep-tags: ","
@@ -456,7 +456,7 @@ jobs:
type=semver,pattern={{major}}.{{minor}},value=${{ needs.init.outputs.version }},enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v3.7.1
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.7.1
- name: Copy architecture images to DockerHub
if: matrix.registry == 'docker.io/homeassistant'
@@ -592,7 +592,7 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Docker image
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
context: . # So action will not pull the repository again
file: ./script/hassfest/docker/Dockerfile
@@ -605,7 +605,7 @@ jobs:
- name: Push Docker image
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
id: push
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
context: . # So action will not pull the repository again
file: ./script/hassfest/docker/Dockerfile

View File

@@ -28,11 +28,11 @@ jobs:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
uses: github/codeql-action/init@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
with:
languages: python
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
uses: github/codeql-action/analyze@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
with:
category: "/language:python"

8
CODEOWNERS generated
View File

@@ -186,8 +186,6 @@ build.json @home-assistant/supervisor
/tests/components/auth/ @home-assistant/core
/homeassistant/components/automation/ @home-assistant/core
/tests/components/automation/ @home-assistant/core
/homeassistant/components/autoskope/ @mcisk
/tests/components/autoskope/ @mcisk
/homeassistant/components/avea/ @pattyland
/homeassistant/components/awair/ @ahayworth @ricohageman
/tests/components/awair/ @ahayworth @ricohageman
@@ -1073,8 +1071,6 @@ build.json @home-assistant/supervisor
/tests/components/moon/ @fabaff @frenck
/homeassistant/components/mopeka/ @bdraco
/tests/components/mopeka/ @bdraco
/homeassistant/components/motion/ @home-assistant/core
/tests/components/motion/ @home-assistant/core
/homeassistant/components/motion_blinds/ @starkillerOG
/tests/components/motion_blinds/ @starkillerOG
/homeassistant/components/motionblinds_ble/ @LennP @jerrybboy
@@ -1188,8 +1184,6 @@ build.json @home-assistant/supervisor
/tests/components/nzbget/ @chriscla
/homeassistant/components/obihai/ @dshokouhi @ejpenney
/tests/components/obihai/ @dshokouhi @ejpenney
/homeassistant/components/occupancy/ @home-assistant/core
/tests/components/occupancy/ @home-assistant/core
/homeassistant/components/octoprint/ @rfleming71
/tests/components/octoprint/ @rfleming71
/homeassistant/components/ohmconnect/ @robbiet480
@@ -1786,8 +1780,6 @@ build.json @home-assistant/supervisor
/tests/components/ukraine_alarm/ @PaulAnnekov
/homeassistant/components/unifi/ @Kane610
/tests/components/unifi/ @Kane610
/homeassistant/components/unifi_access/ @imhotep @RaHehl
/tests/components/unifi_access/ @imhotep @RaHehl
/homeassistant/components/unifi_direct/ @tofuSCHNITZEL
/homeassistant/components/unifiled/ @florisvdk
/homeassistant/components/unifiprotect/ @RaHehl

View File

@@ -245,8 +245,6 @@ DEFAULT_INTEGRATIONS = {
"garage_door",
"gate",
"humidity",
"motion",
"occupancy",
"window",
}
DEFAULT_INTEGRATIONS_RECOVERY_MODE = {

View File

@@ -2,7 +2,6 @@
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.condition import (
Condition,
EntityStateConditionBase,
@@ -44,7 +43,7 @@ def make_entity_state_required_features_condition(
class CustomCondition(EntityStateRequiredFeaturesCondition):
"""Condition for entity state changes."""
_domain_specs = {domain: DomainSpec()}
_domain = domain
_states = {to_state}
_required_features = required_features

View File

@@ -2,7 +2,6 @@
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.entity import get_supported_features
from homeassistant.helpers.trigger import (
EntityTargetStateTriggerBase,
@@ -45,7 +44,7 @@ def make_entity_state_trigger_required_features(
class CustomTrigger(EntityStateTriggerRequiredFeatures):
"""Trigger for entity state changes."""
_domain_specs = {domain: DomainSpec()}
_domains = {domain}
_to_states = {to_state}
_required_features = required_features

View File

@@ -66,7 +66,6 @@ class ArcamFmjCoordinator(DataUpdateCoordinator[None]):
model="Arcam FMJ AVR",
name=name,
)
self.zone_unique_id = f"{unique_id}-{zone}"
if zone != 1:
self.device_info["via_device"] = (DOMAIN, unique_id)

View File

@@ -1,20 +0,0 @@
"""Base entity for Arcam FMJ integration."""
from __future__ import annotations
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .coordinator import ArcamFmjCoordinator
class ArcamFmjEntity(CoordinatorEntity[ArcamFmjCoordinator]):
"""Base entity for Arcam FMJ."""
_attr_has_entity_name = True
def __init__(self, coordinator: ArcamFmjCoordinator) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
self._attr_device_info = coordinator.device_info
self._attr_entity_registry_enabled_default = coordinator.state.zn == 1
self._attr_unique_id = coordinator.zone_unique_id

View File

@@ -22,10 +22,10 @@ from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import EVENT_TURN_ON
from .coordinator import ArcamFmjConfigEntry, ArcamFmjCoordinator
from .entity import ArcamFmjEntity
_LOGGER = logging.getLogger(__name__)
@@ -39,7 +39,14 @@ async def async_setup_entry(
coordinators = config_entry.runtime_data.coordinators
async_add_entities(
[ArcamFmj(coordinators[zone]) for zone in (1, 2)],
[
ArcamFmj(
config_entry.title,
coordinators[zone],
config_entry.unique_id or config_entry.entry_id,
)
for zone in (1, 2)
],
)
@@ -60,13 +67,21 @@ def convert_exception[**_P, _R](
return _convert_exception
class ArcamFmj(ArcamFmjEntity, MediaPlayerEntity):
class ArcamFmj(CoordinatorEntity[ArcamFmjCoordinator], MediaPlayerEntity):
"""Representation of a media device."""
def __init__(self, coordinator: ArcamFmjCoordinator) -> None:
_attr_has_entity_name = True
def __init__(
self,
device_name: str,
coordinator: ArcamFmjCoordinator,
uuid: str,
) -> None:
"""Initialize device."""
super().__init__(coordinator)
self._state = coordinator.state
self._attr_name = f"Zone {self._state.zn}"
self._attr_supported_features = (
MediaPlayerEntityFeature.SELECT_SOURCE
| MediaPlayerEntityFeature.PLAY_MEDIA
@@ -79,6 +94,9 @@ class ArcamFmj(ArcamFmjEntity, MediaPlayerEntity):
)
if self._state.zn == 1:
self._attr_supported_features |= MediaPlayerEntityFeature.SELECT_SOUND_MODE
self._attr_unique_id = f"{uuid}-{self._state.zn}"
self._attr_entity_registry_enabled_default = self._state.zn == 1
self._attr_device_info = coordinator.device_info
@property
def state(self) -> MediaPlayerState:

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
from pathlib import Path
from typing import cast
from aiohttp import ClientError
from aiohttp import ClientResponseError
from yalexs.exceptions import AugustApiAIOHTTPError
from yalexs.manager.exceptions import CannotConnect, InvalidAuth, RequireValidation
from yalexs.manager.gateway import Config as YaleXSConfig
@@ -13,12 +13,7 @@ from yalexs.manager.gateway import Config as YaleXSConfig
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import (
ConfigEntryAuthFailed,
ConfigEntryNotReady,
OAuth2TokenRequestError,
OAuth2TokenRequestReauthError,
)
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr, issue_registry as ir
from homeassistant.helpers.config_entry_oauth2_flow import (
ImplementationUnavailableError,
@@ -50,18 +45,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: AugustConfigEntry) -> bo
august_gateway = AugustGateway(Path(hass.config.config_dir), session, oauth_session)
try:
await async_setup_august(hass, entry, august_gateway)
except OAuth2TokenRequestReauthError as err:
raise ConfigEntryAuthFailed from err
except (RequireValidation, InvalidAuth) as err:
raise ConfigEntryAuthFailed from err
except TimeoutError as err:
raise ConfigEntryNotReady("Timed out connecting to august api") from err
except (
AugustApiAIOHTTPError,
OAuth2TokenRequestError,
ClientError,
CannotConnect,
) as err:
except (AugustApiAIOHTTPError, ClientResponseError, CannotConnect) as err:
raise ConfigEntryNotReady from err
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True

View File

@@ -152,8 +152,6 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
"light",
"lock",
"media_player",
"motion",
"occupancy",
"person",
"remote",
"scene",

View File

@@ -1,53 +0,0 @@
"""The Autoskope integration."""
from __future__ import annotations
import aiohttp
from autoskope_client.api import AutoskopeApi
from autoskope_client.models import CannotConnect, InvalidAuth
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_create_clientsession
from .const import DEFAULT_HOST
from .coordinator import AutoskopeConfigEntry, AutoskopeDataUpdateCoordinator
PLATFORMS: list[Platform] = [Platform.DEVICE_TRACKER]
async def async_setup_entry(hass: HomeAssistant, entry: AutoskopeConfigEntry) -> bool:
"""Set up Autoskope from a config entry."""
session = async_create_clientsession(hass, cookie_jar=aiohttp.CookieJar())
api = AutoskopeApi(
host=entry.data.get(CONF_HOST, DEFAULT_HOST),
username=entry.data[CONF_USERNAME],
password=entry.data[CONF_PASSWORD],
session=session,
)
try:
await api.connect()
except InvalidAuth as err:
# Raise ConfigEntryError until reauth flow is implemented (then ConfigEntryAuthFailed)
raise ConfigEntryError(
"Authentication failed, please check credentials"
) from err
except CannotConnect as err:
raise ConfigEntryNotReady("Could not connect to Autoskope API") from err
coordinator = AutoskopeDataUpdateCoordinator(hass, api, entry)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: AutoskopeConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -1,89 +0,0 @@
"""Config flow for the Autoskope integration."""
from __future__ import annotations
from typing import Any
from autoskope_client.api import AutoskopeApi
from autoskope_client.models import CannotConnect, InvalidAuth
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from homeassistant.data_entry_flow import section
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.selector import (
TextSelector,
TextSelectorConfig,
TextSelectorType,
)
from .const import DEFAULT_HOST, DOMAIN, SECTION_ADVANCED_SETTINGS
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): TextSelector(
TextSelectorConfig(type=TextSelectorType.PASSWORD)
),
vol.Required(SECTION_ADVANCED_SETTINGS): section(
vol.Schema(
{
vol.Required(CONF_HOST, default=DEFAULT_HOST): TextSelector(
TextSelectorConfig(type=TextSelectorType.URL)
),
}
),
{"collapsed": True},
),
}
)
class AutoskopeConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Autoskope."""
VERSION = 1
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:
username = user_input[CONF_USERNAME].lower()
host = user_input[SECTION_ADVANCED_SETTINGS][CONF_HOST].lower()
try:
cv.url(host)
except vol.Invalid:
errors["base"] = "invalid_url"
if not errors:
await self.async_set_unique_id(f"{username}@{host}")
self._abort_if_unique_id_configured()
try:
async with AutoskopeApi(
host=host,
username=username,
password=user_input[CONF_PASSWORD],
):
pass
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
errors["base"] = "invalid_auth"
else:
return self.async_create_entry(
title=f"Autoskope ({username})",
data={
CONF_USERNAME: username,
CONF_PASSWORD: user_input[CONF_PASSWORD],
CONF_HOST: host,
},
)
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)

View File

@@ -1,9 +0,0 @@
"""Constants for the Autoskope integration."""
from datetime import timedelta
DOMAIN = "autoskope"
DEFAULT_HOST = "https://portal.autoskope.de"
SECTION_ADVANCED_SETTINGS = "advanced_settings"
UPDATE_INTERVAL = timedelta(seconds=60)

View File

@@ -1,60 +0,0 @@
"""Data update coordinator for the Autoskope integration."""
from __future__ import annotations
import logging
from autoskope_client.api import AutoskopeApi
from autoskope_client.models import CannotConnect, InvalidAuth, Vehicle
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN, UPDATE_INTERVAL
_LOGGER = logging.getLogger(__name__)
type AutoskopeConfigEntry = ConfigEntry[AutoskopeDataUpdateCoordinator]
class AutoskopeDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Vehicle]]):
"""Class to manage fetching Autoskope data."""
config_entry: AutoskopeConfigEntry
def __init__(
self, hass: HomeAssistant, api: AutoskopeApi, entry: AutoskopeConfigEntry
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
name=DOMAIN,
update_interval=UPDATE_INTERVAL,
config_entry=entry,
)
self.api = api
async def _async_update_data(self) -> dict[str, Vehicle]:
"""Fetch data from API endpoint."""
try:
vehicles = await self.api.get_vehicles()
return {vehicle.id: vehicle for vehicle in vehicles}
except InvalidAuth:
# Attempt to re-authenticate using stored credentials
try:
await self.api.authenticate()
# Retry the request after successful re-authentication
vehicles = await self.api.get_vehicles()
return {vehicle.id: vehicle for vehicle in vehicles}
except InvalidAuth as reauth_err:
raise ConfigEntryAuthFailed(
f"Authentication failed: {reauth_err}"
) from reauth_err
except CannotConnect as err:
raise UpdateFailed(f"Error communicating with API: {err}") from err

View File

@@ -1,145 +0,0 @@
"""Support for Autoskope device tracking."""
from __future__ import annotations
from autoskope_client.constants import MANUFACTURER
from autoskope_client.models import Vehicle
from homeassistant.components.device_tracker import SourceType, TrackerEntity
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import AutoskopeConfigEntry, AutoskopeDataUpdateCoordinator
PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
entry: AutoskopeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Autoskope device tracker entities."""
coordinator: AutoskopeDataUpdateCoordinator = entry.runtime_data
tracked_vehicles: set[str] = set()
@callback
def update_entities() -> None:
"""Update entities based on coordinator data."""
current_vehicles = set(coordinator.data.keys())
vehicles_to_add = current_vehicles - tracked_vehicles
if vehicles_to_add:
new_entities = [
AutoskopeDeviceTracker(coordinator, vehicle_id)
for vehicle_id in vehicles_to_add
]
tracked_vehicles.update(vehicles_to_add)
async_add_entities(new_entities)
entry.async_on_unload(coordinator.async_add_listener(update_entities))
update_entities()
class AutoskopeDeviceTracker(
CoordinatorEntity[AutoskopeDataUpdateCoordinator], TrackerEntity
):
"""Representation of an Autoskope tracked device."""
_attr_has_entity_name = True
_attr_name: str | None = None
def __init__(
self, coordinator: AutoskopeDataUpdateCoordinator, vehicle_id: str
) -> None:
"""Initialize the TrackerEntity."""
super().__init__(coordinator)
self._vehicle_id = vehicle_id
self._attr_unique_id = vehicle_id
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
if (
self._vehicle_id in self.coordinator.data
and (device_entry := self.device_entry) is not None
and device_entry.name != self._vehicle_data.name
):
device_registry = dr.async_get(self.hass)
device_registry.async_update_device(
device_entry.id, name=self._vehicle_data.name
)
super()._handle_coordinator_update()
@property
def device_info(self) -> DeviceInfo:
"""Return device info for the vehicle."""
vehicle = self.coordinator.data[self._vehicle_id]
return DeviceInfo(
identifiers={(DOMAIN, str(vehicle.id))},
name=vehicle.name,
manufacturer=MANUFACTURER,
model=vehicle.model,
serial_number=vehicle.imei,
)
@property
def available(self) -> bool:
"""Return if entity is available."""
return (
super().available
and self.coordinator.data is not None
and self._vehicle_id in self.coordinator.data
)
@property
def _vehicle_data(self) -> Vehicle:
"""Return the vehicle data for the current entity."""
return self.coordinator.data[self._vehicle_id]
@property
def latitude(self) -> float | None:
"""Return latitude value of the device."""
if (vehicle := self._vehicle_data) and vehicle.position:
return float(vehicle.position.latitude)
return None
@property
def longitude(self) -> float | None:
"""Return longitude value of the device."""
if (vehicle := self._vehicle_data) and vehicle.position:
return float(vehicle.position.longitude)
return None
@property
def source_type(self) -> SourceType:
"""Return the source type of the device."""
return SourceType.GPS
@property
def location_accuracy(self) -> float:
"""Return the location accuracy of the device in meters."""
if (vehicle := self._vehicle_data) and vehicle.gps_quality:
if vehicle.gps_quality > 0:
# HDOP to estimated accuracy in meters
# HDOP of 1-2 = good (5-10m), 2-5 = moderate (10-25m), >5 = poor (>25m)
return float(max(5, int(vehicle.gps_quality * 5.0)))
return 0.0
@property
def icon(self) -> str:
"""Return the icon based on the vehicle's activity."""
if self._vehicle_id not in self.coordinator.data:
return "mdi:car-clock"
vehicle = self._vehicle_data
if vehicle.position:
if vehicle.position.park_mode:
return "mdi:car-brake-parking"
if vehicle.position.speed > 5: # Moving threshold: 5 km/h
return "mdi:car-arrow-right"
return "mdi:car"
return "mdi:car-clock"

View File

@@ -1,11 +0,0 @@
{
"domain": "autoskope",
"name": "Autoskope",
"codeowners": ["@mcisk"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/autoskope",
"integration_type": "hub",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"requirements": ["autoskope_client==1.4.1"]
}

View File

@@ -1,88 +0,0 @@
# + in comment indicates requirement for quality scale
# - in comment indicates issue to be fixed, not impacting quality scale
rules:
# Bronze
action-setup:
status: exempt
comment: |
Integration does not provide custom services.
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: |
Integration does not provide custom services.
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:
status: exempt
comment: |
Integration does not provide custom services.
config-entry-unloading: done
docs-configuration-parameters: done
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: todo
parallel-updates: done
reauthentication-flow:
status: todo
comment: |
Reauthentication flow removed for initial PR, will be added in follow-up.
test-coverage: done
# Gold
devices: done
diagnostics: todo
discovery-update-info:
status: exempt
comment: |
Integration does not use discovery. Autoskope devices use NB-IoT/LTE-M (via IoT SIMs) and LoRaWAN.
discovery:
status: exempt
comment: |
Integration does not use discovery. Autoskope devices use NB-IoT/LTE-M (via IoT SIMs) and LoRaWAN.
docs-data-update: done
docs-examples: done
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: done
dynamic-devices: done
entity-category: done
entity-device-class: done
entity-disabled-by-default:
status: exempt
comment: |
Only one entity type (device_tracker) is created, making this not applicable.
entity-translations: done
exception-translations: done
icon-translations: done
reconfiguration-flow:
status: todo
comment: |
Reconfiguration flow removed for initial PR, will be added in follow-up.
repair-issues: todo
stale-devices: done
# Platinum
async-dependency: done
inject-websession: done
strict-typing:
status: todo
comment: |
Integration needs to be added to .strict-typing file for full compliance.

View File

@@ -1,52 +0,0 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"invalid_url": "Invalid URL",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"user": {
"data": {
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"password": "The password for your Autoskope account.",
"username": "The username for your Autoskope account."
},
"description": "Enter your Autoskope credentials.",
"sections": {
"advanced_settings": {
"data": {
"host": "API endpoint"
},
"data_description": {
"host": "The URL of your Autoskope API endpoint. Only change this if you use a white-label portal."
},
"name": "Advanced settings"
}
},
"title": "Connect to Autoskope"
}
}
},
"issues": {
"cannot_connect": {
"description": "Home Assistant could not connect to the Autoskope API at {host}. Please check the connection details and ensure the API endpoint is reachable.\n\nError: {error}",
"title": "Failed to connect to Autoskope"
},
"invalid_auth": {
"description": "Authentication with Autoskope failed for user {username}. Please re-authenticate the integration with the correct password.",
"title": "Invalid Autoskope authentication"
},
"low_battery": {
"description": "The battery voltage for vehicle {vehicle_name} ({vehicle_id}) is low ({value}V). Consider checking or replacing the battery.",
"title": "Low vehicle battery ({vehicle_name})"
}
}
}

View File

@@ -2,7 +2,6 @@
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
ENTITY_STATE_TRIGGER_SCHEMA,
EntityTriggerBase,
@@ -15,7 +14,7 @@ from . import DOMAIN
class ButtonPressedTrigger(EntityTriggerBase):
"""Trigger for button entity presses."""
_domain_specs = {DOMAIN: DomainSpec()}
_domains = {DOMAIN}
_schema = ENTITY_STATE_TRIGGER_SCHEMA
def is_valid_transition(self, from_state: State, to_state: State) -> bool:

View File

@@ -5,14 +5,13 @@ import voluptuous as vol
from homeassistant.const import ATTR_TEMPERATURE, CONF_OPTIONS
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.automation import DomainSpec, NumericalDomainSpec
from homeassistant.helpers.trigger import (
ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST,
EntityTargetStateTriggerBase,
Trigger,
TriggerConfig,
make_entity_numerical_state_changed_trigger,
make_entity_numerical_state_crossed_threshold_trigger,
make_entity_numerical_state_attribute_changed_trigger,
make_entity_numerical_state_attribute_crossed_threshold_trigger,
make_entity_target_state_attribute_trigger,
make_entity_target_state_trigger,
make_entity_transition_trigger,
@@ -36,7 +35,7 @@ HVAC_MODE_CHANGED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST.extend
class HVACModeChangedTrigger(EntityTargetStateTriggerBase):
"""Trigger for entity state changes."""
_domain_specs = {DOMAIN: DomainSpec()}
_domains = {DOMAIN}
_schema = HVAC_MODE_CHANGED_TRIGGER_SCHEMA
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
@@ -53,17 +52,17 @@ TRIGGERS: dict[str, type[Trigger]] = {
"started_drying": make_entity_target_state_attribute_trigger(
DOMAIN, ATTR_HVAC_ACTION, HVACAction.DRYING
),
"target_humidity_changed": make_entity_numerical_state_changed_trigger(
{DOMAIN: NumericalDomainSpec(value_source=ATTR_HUMIDITY)}
"target_humidity_changed": make_entity_numerical_state_attribute_changed_trigger(
{DOMAIN}, {DOMAIN: ATTR_HUMIDITY}
),
"target_humidity_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
{DOMAIN: NumericalDomainSpec(value_source=ATTR_HUMIDITY)}
"target_humidity_crossed_threshold": make_entity_numerical_state_attribute_crossed_threshold_trigger(
{DOMAIN}, {DOMAIN: ATTR_HUMIDITY}
),
"target_temperature_changed": make_entity_numerical_state_changed_trigger(
{DOMAIN: NumericalDomainSpec(value_source=ATTR_TEMPERATURE)}
"target_temperature_changed": make_entity_numerical_state_attribute_changed_trigger(
{DOMAIN}, {DOMAIN: ATTR_TEMPERATURE}
),
"target_temperature_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
{DOMAIN: NumericalDomainSpec(value_source=ATTR_TEMPERATURE)}
"target_temperature_crossed_threshold": make_entity_numerical_state_attribute_crossed_threshold_trigger(
{DOMAIN}, {DOMAIN: ATTR_TEMPERATURE}
),
"turned_off": make_entity_target_state_trigger(DOMAIN, HVACMode.OFF),
"turned_on": make_entity_transition_trigger(

View File

@@ -15,6 +15,7 @@ async def async_setup_intents(hass: HomeAssistant) -> None:
INTENT_OPEN_COVER,
DOMAIN,
SERVICE_OPEN_COVER,
"Opening {}",
description="Opens a cover",
platforms={DOMAIN},
device_classes={CoverDeviceClass},
@@ -26,6 +27,7 @@ async def async_setup_intents(hass: HomeAssistant) -> None:
INTENT_CLOSE_COVER,
DOMAIN,
SERVICE_CLOSE_COVER,
"Closing {}",
description="Closes a cover",
platforms={DOMAIN},
device_classes={CoverDeviceClass},

View File

@@ -1,82 +1,81 @@
"""Provides triggers for covers."""
from dataclasses import dataclass
from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import HomeAssistant, State, split_entity_id
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import EntityTriggerBase, Trigger
from homeassistant.helpers.trigger import (
EntityTriggerBase,
Trigger,
get_device_class_or_undefined,
)
from .const import ATTR_IS_CLOSED, DOMAIN, CoverDeviceClass
@dataclass(frozen=True, slots=True)
class CoverDomainSpec(DomainSpec):
"""DomainSpec with a target value for comparison."""
target_value: str | bool | None = None
class CoverTriggerBase(EntityTriggerBase[CoverDomainSpec]):
class CoverTriggerBase(EntityTriggerBase):
"""Base trigger for cover state changes."""
def _get_value(self, state: State) -> str | bool | None:
"""Extract the relevant value from state based on domain spec."""
domain_spec = self._domain_specs[split_entity_id(state.entity_id)[0]]
if domain_spec.value_source is not None:
return state.attributes.get(domain_spec.value_source)
return state.state
_binary_sensor_target_state: str
_cover_is_closed_target_value: bool
_device_classes: dict[str, str]
def entity_filter(self, entities: set[str]) -> set[str]:
"""Filter entities by cover device class."""
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_classes[split_entity_id(entity_id)[0]]
}
def is_valid_state(self, state: State) -> bool:
"""Check if the state matches the target cover state."""
domain_spec = self._domain_specs[split_entity_id(state.entity_id)[0]]
return self._get_value(state) == domain_spec.target_value
if split_entity_id(state.entity_id)[0] == DOMAIN:
return (
state.attributes.get(ATTR_IS_CLOSED)
== self._cover_is_closed_target_value
)
return state.state == self._binary_sensor_target_state
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check if the transition is valid for a cover state change."""
if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
return False
if (from_value := self._get_value(from_state)) is None:
return False
return from_value != self._get_value(to_state)
if split_entity_id(from_state.entity_id)[0] == DOMAIN:
if (from_is_closed := from_state.attributes.get(ATTR_IS_CLOSED)) is None:
return False
return from_is_closed != to_state.attributes.get(ATTR_IS_CLOSED) # type: ignore[no-any-return]
return from_state.state != to_state.state
def make_cover_opened_trigger(
*, device_classes: dict[str, str]
*, device_classes: dict[str, str], domains: set[str] | None = None
) -> type[CoverTriggerBase]:
"""Create a trigger cover_opened."""
class CoverOpenedTrigger(CoverTriggerBase):
"""Trigger for cover opened state changes."""
_domain_specs = {
domain: CoverDomainSpec(
device_class=dc,
value_source=ATTR_IS_CLOSED if domain == DOMAIN else None,
target_value=False if domain == DOMAIN else STATE_ON,
)
for domain, dc in device_classes.items()
}
_binary_sensor_target_state = STATE_ON
_cover_is_closed_target_value = False
_domains = domains or {DOMAIN}
_device_classes = device_classes
return CoverOpenedTrigger
def make_cover_closed_trigger(
*, device_classes: dict[str, str]
*, device_classes: dict[str, str], domains: set[str] | None = None
) -> type[CoverTriggerBase]:
"""Create a trigger cover_closed."""
class CoverClosedTrigger(CoverTriggerBase):
"""Trigger for cover closed state changes."""
_domain_specs = {
domain: CoverDomainSpec(
device_class=dc,
value_source=ATTR_IS_CLOSED if domain == DOMAIN else None,
target_value=True if domain == DOMAIN else STATE_OFF,
)
for domain, dc in device_classes.items()
}
_binary_sensor_target_state = STATE_OFF
_cover_is_closed_target_value = True
_domains = domains or {DOMAIN}
_device_classes = device_classes
return CoverClosedTrigger

View File

@@ -20,8 +20,14 @@ DEVICE_CLASSES_DOOR: dict[str, str] = {
TRIGGERS: dict[str, type[Trigger]] = {
"opened": make_cover_opened_trigger(device_classes=DEVICE_CLASSES_DOOR),
"closed": make_cover_closed_trigger(device_classes=DEVICE_CLASSES_DOOR),
"opened": make_cover_opened_trigger(
device_classes=DEVICE_CLASSES_DOOR,
domains={BINARY_SENSOR_DOMAIN, COVER_DOMAIN},
),
"closed": make_cover_closed_trigger(
device_classes=DEVICE_CLASSES_DOOR,
domains={BINARY_SENSOR_DOMAIN, COVER_DOMAIN},
),
}

View File

@@ -5,13 +5,7 @@ from __future__ import annotations
from functools import partial
from typing import Any
from aioesphomeapi import (
EntityInfo,
WaterHeaterFeature,
WaterHeaterInfo,
WaterHeaterMode,
WaterHeaterState,
)
from aioesphomeapi import EntityInfo, WaterHeaterInfo, WaterHeaterMode, WaterHeaterState
from homeassistant.components.water_heater import (
WaterHeaterEntity,
@@ -60,7 +54,6 @@ class EsphomeWaterHeater(
static_info = self._static_info
self._attr_min_temp = static_info.min_temperature
self._attr_max_temp = static_info.max_temperature
self._attr_target_temperature_step = static_info.target_temperature_step
features = WaterHeaterEntityFeature.TARGET_TEMPERATURE
if static_info.supported_modes:
features |= WaterHeaterEntityFeature.OPERATION_MODE
@@ -70,8 +63,6 @@ class EsphomeWaterHeater(
]
else:
self._attr_operation_list = None
if static_info.supported_features & WaterHeaterFeature.SUPPORTS_ON_OFF:
features |= WaterHeaterEntityFeature.ON_OFF
self._attr_supported_features = features
@property
@@ -110,24 +101,6 @@ class EsphomeWaterHeater(
device_id=self._static_info.device_id,
)
@convert_api_error_ha_error
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the water heater on."""
self._client.water_heater_command(
key=self._key,
on=True,
device_id=self._static_info.device_id,
)
@convert_api_error_ha_error
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the water heater off."""
self._client.water_heater_command(
key=self._key,
on=False,
device_id=self._static_info.device_id,
)
async_setup_entry = partial(
platform_async_setup_entry,

View File

@@ -21,5 +21,5 @@
"integration_type": "system",
"preview_features": { "winter_mode": {} },
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20260312.0"]
"requirements": ["home-assistant-frontend==20260304.0"]
}

View File

@@ -20,8 +20,14 @@ DEVICE_CLASSES_GARAGE_DOOR: dict[str, str] = {
TRIGGERS: dict[str, type[Trigger]] = {
"opened": make_cover_opened_trigger(device_classes=DEVICE_CLASSES_GARAGE_DOOR),
"closed": make_cover_closed_trigger(device_classes=DEVICE_CLASSES_GARAGE_DOOR),
"opened": make_cover_opened_trigger(
device_classes=DEVICE_CLASSES_GARAGE_DOOR,
domains={BINARY_SENSOR_DOMAIN, COVER_DOMAIN},
),
"closed": make_cover_closed_trigger(
device_classes=DEVICE_CLASSES_GARAGE_DOOR,
domains={BINARY_SENSOR_DOMAIN, COVER_DOMAIN},
),
}

View File

@@ -12,7 +12,7 @@ from homeassistant.helpers.helper_integration import (
async_remove_helper_config_entry_from_source_device,
)
from .const import CONF_DUR_COOLDOWN, CONF_HEATER, CONF_MIN_DUR, CONF_SENSOR, PLATFORMS
from .const import CONF_HEATER, CONF_SENSOR, PLATFORMS
_LOGGER = logging.getLogger(__name__)
@@ -91,13 +91,8 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
helper_config_entry_id=config_entry.entry_id,
source_device_id=source_device_id,
)
if config_entry.minor_version < 3:
# Set `cycle_cooldown` to `min_cycle_duration` to mimic the old behavior
if CONF_MIN_DUR in options:
options[CONF_DUR_COOLDOWN] = options[CONF_MIN_DUR]
hass.config_entries.async_update_entry(
config_entry, options=options, minor_version=3
config_entry, options=options, minor_version=2
)
_LOGGER.debug(

View File

@@ -5,7 +5,6 @@ from __future__ import annotations
import asyncio
from collections.abc import Mapping
from datetime import datetime, timedelta
from functools import partial
import logging
import math
from typing import Any
@@ -39,9 +38,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import (
CALLBACK_TYPE,
DOMAIN as HOMEASSISTANT_DOMAIN,
Context,
CoreState,
Event,
EventStateChangedData,
@@ -49,30 +46,27 @@ from homeassistant.core import (
State,
callback,
)
from homeassistant.helpers import config_validation as cv
from homeassistant.exceptions import ConditionError
from homeassistant.helpers import condition, config_validation as cv
from homeassistant.helpers.device import async_entity_id_to_device
from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
AddEntitiesCallback,
)
from homeassistant.helpers.event import (
async_call_later,
async_track_state_change_event,
async_track_time_interval,
)
from homeassistant.helpers.reload import async_setup_reload_service
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, VolDictType
from homeassistant.util import dt as dt_util
from .const import (
CONF_AC_MODE,
CONF_COLD_TOLERANCE,
CONF_DUR_COOLDOWN,
CONF_HEATER,
CONF_HOT_TOLERANCE,
CONF_KEEP_ALIVE,
CONF_MAX_DUR,
CONF_MAX_TEMP,
CONF_MIN_DUR,
CONF_MIN_TEMP,
@@ -104,8 +98,6 @@ PLATFORM_SCHEMA_COMMON = vol.Schema(
vol.Optional(CONF_AC_MODE): cv.boolean,
vol.Optional(CONF_MAX_TEMP): vol.Coerce(float),
vol.Optional(CONF_MIN_DUR): cv.positive_time_period,
vol.Optional(CONF_MAX_DUR): cv.positive_time_period,
vol.Optional(CONF_DUR_COOLDOWN): cv.positive_time_period,
vol.Optional(CONF_MIN_TEMP): vol.Coerce(float),
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(CONF_COLD_TOLERANCE, default=DEFAULT_TOLERANCE): vol.Coerce(float),
@@ -175,8 +167,6 @@ async def _async_setup_config(
target_temp: float | None = config.get(CONF_TARGET_TEMP)
ac_mode: bool | None = config.get(CONF_AC_MODE)
min_cycle_duration: timedelta | None = config.get(CONF_MIN_DUR)
max_cycle_duration: timedelta | None = config.get(CONF_MAX_DUR)
cycle_cooldown: timedelta | None = config.get(CONF_DUR_COOLDOWN)
cold_tolerance: float = config[CONF_COLD_TOLERANCE]
hot_tolerance: float = config[CONF_HOT_TOLERANCE]
keep_alive: timedelta | None = config.get(CONF_KEEP_ALIVE)
@@ -200,8 +190,6 @@ async def _async_setup_config(
target_temp=target_temp,
ac_mode=ac_mode,
min_cycle_duration=min_cycle_duration,
max_cycle_duration=max_cycle_duration,
cycle_cooldown=cycle_cooldown,
cold_tolerance=cold_tolerance,
hot_tolerance=hot_tolerance,
keep_alive=keep_alive,
@@ -233,8 +221,6 @@ class GenericThermostat(ClimateEntity, RestoreEntity):
target_temp: float | None,
ac_mode: bool | None,
min_cycle_duration: timedelta | None,
max_cycle_duration: timedelta | None,
cycle_cooldown: timedelta | None,
cold_tolerance: float,
hot_tolerance: float,
keep_alive: timedelta | None,
@@ -254,16 +240,8 @@ class GenericThermostat(ClimateEntity, RestoreEntity):
heater_entity_id,
)
self.ac_mode = ac_mode
self.min_cycle_duration = min_cycle_duration or timedelta()
self.max_cycle_duration = max_cycle_duration
self.cycle_cooldown = cycle_cooldown or timedelta()
self.min_cycle_duration = min_cycle_duration
self._cold_tolerance = cold_tolerance
# Subtract the cooldown so it doesn't impact startup
self._last_toggled_time = dt_util.utcnow() - self.cycle_cooldown
self._cycle_callback: CALLBACK_TYPE | None = None
self._check_callback: CALLBACK_TYPE | None = None
# Context ID used to detect our own toggles
self._last_context_id: str | None = None
self._hot_tolerance = hot_tolerance
self._keep_alive = keep_alive
self._hvac_mode = initial_hvac_mode
@@ -311,7 +289,6 @@ class GenericThermostat(ClimateEntity, RestoreEntity):
self.hass, [self.heater_entity_id], self._async_switch_changed
)
)
self.async_on_remove(self._cancel_timers)
if self._keep_alive:
self.async_on_remove(
@@ -505,18 +482,6 @@ class GenericThermostat(ClimateEntity, RestoreEntity):
self.hass.async_create_task(
self._check_switch_initial_state(), eager_start=True
)
# Update timestamp on toggle
self._last_toggled_time = new_state.last_changed
# If the user toggles the switch, assume they want control and clear the timers.
# Note: If a manual interaction occurs within the 2s context window of a switch
# toggle initiated by us, we may not detect manual control. Users are advised to
# use the climate entity for reliable control, not the switch entity.
if new_state.context.id != self._last_context_id:
_LOGGER.debug("External switch change detected, clearing timers")
self._last_context_id = None
self._cancel_timers()
self.async_write_ha_state()
@callback
@@ -552,69 +517,57 @@ class GenericThermostat(ClimateEntity, RestoreEntity):
if not self._active or self._hvac_mode == HVACMode.OFF:
return
if force and time is not None and self.max_cycle_duration:
# We were invoked due to `max_cycle_duration`, so turn off
_LOGGER.debug(
"Turning off heater %s due to max cycle time of %s",
self.heater_entity_id,
self.max_cycle_duration,
)
self._cancel_cycle_timer()
await self._async_heater_turn_off()
return
# If the `force` argument is True, we
# ignore `min_cycle_duration`.
# If the `time` argument is not none, we were invoked for
# keep-alive purposes, and `min_cycle_duration` is irrelevant.
if not force and time is None and self.min_cycle_duration:
if self._is_device_active:
current_state = STATE_ON
else:
current_state = HVACMode.OFF
try:
long_enough = condition.state(
self.hass,
self.heater_entity_id,
current_state,
self.min_cycle_duration,
)
except ConditionError:
long_enough = False
if not long_enough:
return
assert self._cur_temp is not None and self._target_temp is not None
too_cold = self._target_temp > self._cur_temp + self._cold_tolerance
too_hot = self._target_temp < self._cur_temp - self._hot_tolerance
now = dt_util.utcnow()
min_temp = self._target_temp - self._cold_tolerance
max_temp = self._target_temp + self._hot_tolerance
if self._is_device_active:
if (self.ac_mode and too_cold) or (not self.ac_mode and too_hot):
# Make sure it's past the `min_cycle_duration` before turning off
if (
self._last_toggled_time + self.min_cycle_duration <= now
or force
):
_LOGGER.debug("Turning off heater %s", self.heater_entity_id)
await self._async_heater_turn_off()
elif self._check_callback is None:
_LOGGER.debug(
"Minimum cycle time not reached, check again at %s",
self._last_toggled_time + self.min_cycle_duration,
)
self._check_callback = async_call_later(
self.hass,
now - self._last_toggled_time + self.min_cycle_duration,
self._async_timer_control_heating,
)
if (self.ac_mode and self._cur_temp <= min_temp) or (
not self.ac_mode and self._cur_temp >= max_temp
):
_LOGGER.debug("Turning off heater %s", self.heater_entity_id)
await self._async_heater_turn_off()
elif time is not None:
# This is a keep-alive call, so ensure it's on
# The time argument is passed only in keep-alive case
_LOGGER.debug(
"Keep-alive - Turning on heater %s",
"Keep-alive - Turning on heater heater %s",
self.heater_entity_id,
)
await self._async_heater_turn_on(keepalive=True)
elif (self.ac_mode and too_hot) or (not self.ac_mode and too_cold):
# Make sure it's past the `cycle_cooldown` before turning on
if self._last_toggled_time + self.cycle_cooldown <= now or force:
_LOGGER.debug("Turning on heater %s", self.heater_entity_id)
await self._async_heater_turn_on()
elif self._check_callback is None:
_LOGGER.debug(
"Cooldown time not reached, check again at %s",
self._last_toggled_time + self.cycle_cooldown,
)
self._check_callback = async_call_later(
self.hass,
now - self._last_toggled_time + self.cycle_cooldown,
self._async_timer_control_heating,
)
elif (self.ac_mode and self._cur_temp > max_temp) or (
not self.ac_mode and self._cur_temp < min_temp
):
_LOGGER.debug("Turning on heater %s", self.heater_entity_id)
await self._async_heater_turn_on()
elif time is not None:
# This is a keep-alive call, so ensure it's off
# The time argument is passed only in keep-alive case
_LOGGER.debug(
"Keep-alive - Turning off heater %s", self.heater_entity_id
)
await self._async_heater_turn_off(keepalive=True)
await self._async_heater_turn_off()
@property
def _is_device_active(self) -> bool | None:
@@ -624,48 +577,19 @@ class GenericThermostat(ClimateEntity, RestoreEntity):
return self.hass.states.is_state(self.heater_entity_id, STATE_ON)
async def _async_heater_turn_on(self, keepalive: bool = False) -> None:
async def _async_heater_turn_on(self) -> None:
"""Turn heater toggleable device on."""
data = {ATTR_ENTITY_ID: self.heater_entity_id}
# Create a new context for this service call so we can identify
# the resulting state change event as originating from us
new_context = Context(parent_id=self._context.id if self._context else None)
self.async_set_context(new_context)
self._last_context_id = new_context.id
await self.hass.services.async_call(
HOMEASSISTANT_DOMAIN, SERVICE_TURN_ON, data, context=new_context
HOMEASSISTANT_DOMAIN, SERVICE_TURN_ON, data, context=self._context
)
if not keepalive:
# Update timestamp on turn on
self._last_toggled_time = dt_util.utcnow()
self._cancel_check_timer()
if self.max_cycle_duration:
_LOGGER.debug(
"Scheduling maximum run-time shut-off for %s",
self._last_toggled_time + self.max_cycle_duration,
)
self._cancel_cycle_timer()
self._cycle_callback = async_call_later(
self.hass,
self.max_cycle_duration,
partial(self._async_control_heating, force=True),
)
async def _async_heater_turn_off(self, keepalive: bool = False) -> None:
async def _async_heater_turn_off(self) -> None:
"""Turn heater toggleable device off."""
data = {ATTR_ENTITY_ID: self.heater_entity_id}
# Create a new context for this service call so we can identify
# the resulting state change event as originating from us
new_context = Context(parent_id=self._context.id if self._context else None)
self.async_set_context(new_context)
self._last_context_id = new_context.id
await self.hass.services.async_call(
HOMEASSISTANT_DOMAIN, SERVICE_TURN_OFF, data, context=new_context
HOMEASSISTANT_DOMAIN, SERVICE_TURN_OFF, data, context=self._context
)
if not keepalive:
# Update timestamp on turn off
self._last_toggled_time = dt_util.utcnow()
self._cancel_timers()
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set new preset mode."""
@@ -689,30 +613,3 @@ class GenericThermostat(ClimateEntity, RestoreEntity):
await self._async_control_heating(force=True)
self.async_write_ha_state()
async def _async_timer_control_heating(self, _: datetime | None = None) -> None:
"""Reset check timer and control heating."""
self._check_callback = None
await self._async_control_heating()
@callback
def _cancel_check_timer(self) -> None:
"""Reset check timer."""
if self._check_callback:
_LOGGER.debug("Cancelling scheduled state check")
self._check_callback()
self._check_callback = None
@callback
def _cancel_cycle_timer(self) -> None:
"""Reset cycle timer."""
if self._cycle_callback:
_LOGGER.debug("Cancelling scheduled shut-off")
self._cycle_callback()
self._cycle_callback = None
@callback
def _cancel_timers(self) -> None:
"""Reset timers."""
self._cancel_check_timer()
self._cancel_cycle_timer()

View File

@@ -3,7 +3,6 @@
from __future__ import annotations
from collections.abc import Mapping
from datetime import timedelta
from typing import Any, cast
import voluptuous as vol
@@ -13,20 +12,16 @@ from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDevic
from homeassistant.const import CONF_NAME, DEGREE
from homeassistant.helpers import selector
from homeassistant.helpers.schema_config_entry_flow import (
SchemaCommonFlowHandler,
SchemaConfigFlowHandler,
SchemaFlowError,
SchemaFlowFormStep,
)
from .const import (
CONF_AC_MODE,
CONF_COLD_TOLERANCE,
CONF_DUR_COOLDOWN,
CONF_HEATER,
CONF_HOT_TOLERANCE,
CONF_KEEP_ALIVE,
CONF_MAX_DUR,
CONF_MAX_TEMP,
CONF_MIN_DUR,
CONF_MIN_TEMP,
@@ -68,12 +63,6 @@ OPTIONS_SCHEMA = {
vol.Optional(CONF_KEEP_ALIVE): selector.DurationSelector(
selector.DurationSelectorConfig(allow_negative=False)
),
vol.Optional(CONF_MAX_DUR): selector.DurationSelector(
selector.DurationSelectorConfig(allow_negative=False)
),
vol.Optional(CONF_DUR_COOLDOWN): selector.DurationSelector(
selector.DurationSelectorConfig(allow_negative=False)
),
vol.Optional(CONF_MIN_TEMP): selector.NumberSelector(
selector.NumberSelectorConfig(
mode=selector.NumberSelectorMode.BOX, unit_of_measurement=DEGREE, step=0.1
@@ -101,31 +90,13 @@ CONFIG_SCHEMA = {
}
async def _validate_config(
handler: SchemaCommonFlowHandler, user_input: dict[str, Any]
) -> dict[str, Any]:
"""Validate config."""
if all(x in user_input for x in (CONF_MIN_DUR, CONF_MAX_DUR)):
min_cycle = timedelta(**user_input[CONF_MIN_DUR])
max_cycle = timedelta(**user_input[CONF_MAX_DUR])
if min_cycle >= max_cycle:
raise SchemaFlowError("min_max_runtime")
return user_input
CONFIG_FLOW = {
"user": SchemaFlowFormStep(vol.Schema(CONFIG_SCHEMA), next_step="presets"),
"presets": SchemaFlowFormStep(vol.Schema(PRESETS_SCHEMA)),
}
OPTIONS_FLOW = {
"init": SchemaFlowFormStep(
vol.Schema(OPTIONS_SCHEMA),
validate_user_input=_validate_config,
next_step="presets",
),
"init": SchemaFlowFormStep(vol.Schema(OPTIONS_SCHEMA), next_step="presets"),
"presets": SchemaFlowFormStep(vol.Schema(PRESETS_SCHEMA)),
}
@@ -133,7 +104,7 @@ OPTIONS_FLOW = {
class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
"""Handle a config or options flow."""
MINOR_VERSION = 3
MINOR_VERSION = 2
config_flow = CONFIG_FLOW
options_flow = OPTIONS_FLOW

View File

@@ -20,8 +20,6 @@ CONF_HEATER = "heater"
CONF_HOT_TOLERANCE = "hot_tolerance"
CONF_MAX_TEMP = "max_temp"
CONF_MIN_DUR = "min_cycle_duration"
CONF_MAX_DUR = "max_cycle_duration"
CONF_DUR_COOLDOWN = "cycle_cooldown"
CONF_MIN_TEMP = "min_temp"
CONF_PRESETS = {
p: f"{p}_temp"

View File

@@ -16,13 +16,11 @@
"data": {
"ac_mode": "Cooling mode",
"cold_tolerance": "Cold tolerance",
"cycle_cooldown": "Cooldown period after running",
"heater": "Actuator switch",
"hot_tolerance": "Hot tolerance",
"keep_alive": "Keep-alive interval",
"max_cycle_duration": "Maximum run time",
"max_temp": "Maximum target temperature",
"min_cycle_duration": "Minimum run time",
"min_cycle_duration": "Minimum cycle duration",
"min_temp": "Minimum target temperature",
"name": "[%key:common::config_flow::data::name%]",
"target_sensor": "Temperature sensor"
@@ -30,12 +28,10 @@
"data_description": {
"ac_mode": "Set the actuator specified to be treated as a cooling device instead of a heating device.",
"cold_tolerance": "Minimum amount of difference between the temperature read by the temperature sensor the target temperature that must change prior to being switched on. For example, if the target temperature is 25 and the tolerance is 0.5 the heater will start when the sensor goes below 24.5.",
"cycle_cooldown": "After switching off, the minimum amount of time that must elapse before it can be switched back on.",
"heater": "Switch entity used to cool or heat depending on A/C mode.",
"hot_tolerance": "Minimum amount of difference between the temperature read by the temperature sensor the target temperature that must change prior to being switched off. For example, if the target temperature is 25 and the tolerance is 0.5 the heater will stop when the sensor equals or goes above 25.5.",
"keep_alive": "Trigger the heater periodically to keep devices from losing state.",
"max_cycle_duration": "Once switched on, the maximum amount of time that can elapse before it will be switched off.",
"min_cycle_duration": "Once switched on, the minimum amount of time that must elapse before it may be switched off.",
"keep_alive": "Trigger the heater periodically to keep devices from losing state. When set, min cycle duration is ignored.",
"min_cycle_duration": "Set a minimum amount of time that the switch specified must be in its current state prior to being switched either off or on.",
"target_sensor": "Temperature sensor that reflects the current temperature."
},
"description": "Create a climate entity that controls the temperature via a switch and sensor.",
@@ -44,19 +40,14 @@
}
},
"options": {
"error": {
"min_max_runtime": "Minimum run time must be less than the maximum run time."
},
"step": {
"init": {
"data": {
"ac_mode": "[%key:component::generic_thermostat::config::step::user::data::ac_mode%]",
"cold_tolerance": "[%key:component::generic_thermostat::config::step::user::data::cold_tolerance%]",
"cycle_cooldown": "[%key:component::generic_thermostat::config::step::user::data::cycle_cooldown%]",
"heater": "[%key:component::generic_thermostat::config::step::user::data::heater%]",
"hot_tolerance": "[%key:component::generic_thermostat::config::step::user::data::hot_tolerance%]",
"keep_alive": "[%key:component::generic_thermostat::config::step::user::data::keep_alive%]",
"max_cycle_duration": "[%key:component::generic_thermostat::config::step::user::data::max_cycle_duration%]",
"max_temp": "[%key:component::generic_thermostat::config::step::user::data::max_temp%]",
"min_cycle_duration": "[%key:component::generic_thermostat::config::step::user::data::min_cycle_duration%]",
"min_temp": "[%key:component::generic_thermostat::config::step::user::data::min_temp%]",
@@ -65,11 +56,9 @@
"data_description": {
"ac_mode": "[%key:component::generic_thermostat::config::step::user::data_description::ac_mode%]",
"cold_tolerance": "[%key:component::generic_thermostat::config::step::user::data_description::cold_tolerance%]",
"cycle_cooldown": "[%key:component::generic_thermostat::config::step::user::data_description::cycle_cooldown%]",
"heater": "[%key:component::generic_thermostat::config::step::user::data_description::heater%]",
"hot_tolerance": "[%key:component::generic_thermostat::config::step::user::data_description::hot_tolerance%]",
"keep_alive": "[%key:component::generic_thermostat::config::step::user::data_description::keep_alive%]",
"max_cycle_duration": "[%key:component::generic_thermostat::config::step::user::data_description::max_cycle_duration%]",
"min_cycle_duration": "[%key:component::generic_thermostat::config::step::user::data_description::min_cycle_duration%]",
"target_sensor": "[%key:component::generic_thermostat::config::step::user::data_description::target_sensor%]"
}

View File

@@ -6,5 +6,5 @@
"dependencies": ["network"],
"documentation": "https://www.home-assistant.io/integrations/govee_light_local",
"iot_class": "local_push",
"requirements": ["govee-local-api==2.4.0"]
"requirements": ["govee-local-api==2.3.0"]
}

View File

@@ -5,7 +5,9 @@ rules:
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
config-flow:
status: todo
comment: data-descriptions missing
dependency-transparency: done
docs-actions: done
docs-high-level-description: done
@@ -23,7 +25,7 @@ rules:
action-exceptions: done
config-entry-unloading: done
docs-configuration-parameters: done
docs-installation-parameters: done
docs-installation-parameters: todo
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
@@ -53,7 +55,7 @@ rules:
status: todo
comment: Replace custom precision field with suggested_display_precision to preserve full data granularity.
entity-disabled-by-default: todo
entity-translations: done
entity-translations: todo
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo

View File

@@ -17,20 +17,12 @@
"region": "Server region",
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"password": "The password for your Growatt account.",
"region": "The server region that matches your Growatt account location.",
"username": "The email address or username for your Growatt account."
},
"title": "Enter your Growatt login credentials"
},
"plant": {
"data": {
"plant_id": "Plant"
},
"data_description": {
"plant_id": "The Growatt plant (solar installation) to integrate."
},
"title": "Select your plant"
},
"reauth_confirm": {
@@ -40,12 +32,6 @@
"token": "[%key:component::growatt_server::config::step::token_auth::data::token%]",
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"password": "[%key:component::growatt_server::config::step::password_auth::data_description::password%]",
"region": "[%key:component::growatt_server::config::step::password_auth::data_description::region%]",
"token": "[%key:component::growatt_server::config::step::token_auth::data_description::token%]",
"username": "[%key:component::growatt_server::config::step::password_auth::data_description::username%]"
},
"description": "Re-enter your credentials to continue using this integration.",
"title": "Re-authenticate with Growatt"
},
@@ -54,10 +40,6 @@
"region": "[%key:component::growatt_server::config::step::password_auth::data::region%]",
"token": "API token"
},
"data_description": {
"region": "[%key:component::growatt_server::config::step::password_auth::data_description::region%]",
"token": "The API token for your Growatt account. You can generate one via the Growatt web portal or ShinePhone app."
},
"description": "Token authentication is only supported for MIN/TLX devices. For other device types, please use username/password authentication.",
"title": "Enter your API token"
},

View File

@@ -10,11 +10,7 @@ from functools import partial, wraps
import logging
from typing import Any, Concatenate
from aiohasupervisor import (
AddonNotSupportedError,
SupervisorError,
SupervisorNotFoundError,
)
from aiohasupervisor import SupervisorError
from aiohasupervisor.models import (
AddonsOptions,
AddonState as SupervisorAddonState,
@@ -169,7 +165,15 @@ class AddonManager:
)
addon_info = await self._supervisor_client.addons.addon_info(self.addon_slug)
return self._async_convert_installed_addon_info(addon_info)
addon_state = self.async_get_addon_state(addon_info)
return AddonInfo(
available=addon_info.available,
hostname=addon_info.hostname,
options=addon_info.options,
state=addon_state,
update_available=addon_info.update_available,
version=addon_info.version,
)
@callback
def async_get_addon_state(self, addon_info: InstalledAddonComplete) -> AddonState:
@@ -185,20 +189,6 @@ class AddonManager:
return addon_state
@callback
def _async_convert_installed_addon_info(
self, addon_info: InstalledAddonComplete
) -> AddonInfo:
"""Convert InstalledAddonComplete model to AddonInfo model."""
return AddonInfo(
available=addon_info.available,
hostname=addon_info.hostname,
options=addon_info.options,
state=self.async_get_addon_state(addon_info),
update_available=addon_info.update_available,
version=addon_info.version,
)
@api_error(
"Failed to set the {addon_name} app options",
expected_error_type=SupervisorError,
@@ -209,17 +199,21 @@ class AddonManager:
self.addon_slug, AddonsOptions(config=config)
)
def _check_addon_available(self, addon_info: AddonInfo) -> None:
"""Check if the managed add-on is available."""
if not addon_info.available:
raise AddonError(f"{self.addon_name} app is not available")
@api_error(
"Failed to install the {addon_name} app", expected_error_type=SupervisorError
)
async def async_install_addon(self) -> None:
"""Install the managed add-on."""
try:
await self._supervisor_client.store.install_addon(self.addon_slug)
except AddonNotSupportedError as err:
raise AddonError(
f"{self.addon_name} app is not available: {err!s}"
) from None
addon_info = await self.async_get_addon_info()
self._check_addon_available(addon_info)
await self._supervisor_client.store.install_addon(self.addon_slug)
@api_error(
"Failed to uninstall the {addon_name} app",
@@ -232,29 +226,17 @@ class AddonManager:
@api_error("Failed to update the {addon_name} app")
async def async_update_addon(self) -> None:
"""Update the managed add-on if needed."""
try:
# Not using async_get_addon_info here because it would make an unnecessary
# call to /store/addon/{slug}/info. This will raise if the addon is not
# installed so one call to /addon/{slug}/info is all that is needed
addon_info = await self._supervisor_client.addons.addon_info(
self.addon_slug
)
except SupervisorNotFoundError:
raise AddonError(f"{self.addon_name} app is not installed") from None
addon_info = await self.async_get_addon_info()
self._check_addon_available(addon_info)
if addon_info.state is AddonState.NOT_INSTALLED:
raise AddonError(f"{self.addon_name} app is not installed")
if not addon_info.update_available:
return
try:
await self._supervisor_client.store.addon_availability(self.addon_slug)
except AddonNotSupportedError as err:
raise AddonError(
f"{self.addon_name} app is not available: {err!s}"
) from None
await self.async_create_backup(
addon_info=self._async_convert_installed_addon_info(addon_info)
)
await self.async_create_backup()
await self._supervisor_client.store.update_addon(
self.addon_slug, StoreAddonUpdate(backup=False)
)
@@ -284,14 +266,10 @@ class AddonManager:
"Failed to create a backup of the {addon_name} app",
expected_error_type=SupervisorError,
)
async def async_create_backup(self, *, addon_info: AddonInfo | None = None) -> None:
async def async_create_backup(self) -> None:
"""Create a partial backup of the managed add-on."""
if addon_info:
addon_version = addon_info.version
else:
addon_version = (await self.async_get_addon_info()).version
name = f"addon_{self.addon_slug}_{addon_version}"
addon_info = await self.async_get_addon_info()
name = f"addon_{self.addon_slug}_{addon_info.version}"
self._logger.debug("Creating backup: %s", name)
await self._supervisor_client.backups.partial_backup(

View File

@@ -225,10 +225,6 @@
"description": "System is unsupported because Home Assistant cannot determine when an Internet connection is available. For troubleshooting information, select Learn more.",
"title": "Unsupported system - Connectivity check disabled"
},
"unsupported_content_trust": {
"description": "System is unsupported because Home Assistant cannot verify content being run is trusted and not modified by attackers. For troubleshooting information, select Learn more.",
"title": "Unsupported system - Content-trust check disabled"
},
"unsupported_dbus": {
"description": "System is unsupported because D-Bus is working incorrectly. Many things fail without this as Supervisor cannot communicate with the host. For troubleshooting information, select Learn more.",
"title": "Unsupported system - D-Bus issues"
@@ -281,10 +277,6 @@
"description": "System is unsupported because additional software outside the Home Assistant ecosystem has been detected. For troubleshooting information, select Learn more.",
"title": "Unsupported system - Unsupported software"
},
"unsupported_source_mods": {
"description": "System is unsupported because Supervisor source code has been modified. For troubleshooting information, select Learn more.",
"title": "Unsupported system - Supervisor source modifications"
},
"unsupported_supervisor_version": {
"description": "System is unsupported because an out-of-date version of Supervisor is in use and auto-update has been disabled. For troubleshooting information, select Learn more.",
"title": "Unsupported system - Supervisor version"

View File

@@ -15,43 +15,50 @@ from homeassistant.components.weather import (
ATTR_WEATHER_HUMIDITY,
DOMAIN as WEATHER_DOMAIN,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.automation import NumericalDomainSpec
from homeassistant.core import HomeAssistant, split_entity_id
from homeassistant.helpers.trigger import (
EntityNumericalStateAttributeChangedTriggerBase,
EntityNumericalStateAttributeCrossedThresholdTriggerBase,
EntityTriggerBase,
Trigger,
get_device_class_or_undefined,
)
HUMIDITY_DOMAIN_SPECS: dict[str, NumericalDomainSpec] = {
CLIMATE_DOMAIN: NumericalDomainSpec(
value_source=CLIMATE_ATTR_CURRENT_HUMIDITY,
),
HUMIDIFIER_DOMAIN: NumericalDomainSpec(
value_source=HUMIDIFIER_ATTR_CURRENT_HUMIDITY,
),
SENSOR_DOMAIN: NumericalDomainSpec(
device_class=SensorDeviceClass.HUMIDITY,
),
WEATHER_DOMAIN: NumericalDomainSpec(
value_source=ATTR_WEATHER_HUMIDITY,
),
}
class _HumidityTriggerMixin(EntityTriggerBase):
"""Mixin for humidity triggers providing entity filtering and value extraction."""
_attributes = {
CLIMATE_DOMAIN: CLIMATE_ATTR_CURRENT_HUMIDITY,
HUMIDIFIER_DOMAIN: HUMIDIFIER_ATTR_CURRENT_HUMIDITY,
SENSOR_DOMAIN: None, # Use state.state
WEATHER_DOMAIN: ATTR_WEATHER_HUMIDITY,
}
_domains = {SENSOR_DOMAIN, CLIMATE_DOMAIN, HUMIDIFIER_DOMAIN, WEATHER_DOMAIN}
def entity_filter(self, entities: set[str]) -> set[str]:
"""Filter entities: all climate/humidifier/weather, sensor only with device_class humidity."""
entities = super().entity_filter(entities)
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] != SENSOR_DOMAIN
or get_device_class_or_undefined(self._hass, entity_id)
== SensorDeviceClass.HUMIDITY
}
class HumidityChangedTrigger(EntityNumericalStateAttributeChangedTriggerBase):
class HumidityChangedTrigger(
_HumidityTriggerMixin, EntityNumericalStateAttributeChangedTriggerBase
):
"""Trigger for humidity value changes across multiple domains."""
_domain_specs = HUMIDITY_DOMAIN_SPECS
class HumidityCrossedThresholdTrigger(
EntityNumericalStateAttributeCrossedThresholdTriggerBase
_HumidityTriggerMixin, EntityNumericalStateAttributeCrossedThresholdTriggerBase
):
"""Trigger for humidity value crossing a threshold across multiple domains."""
_domain_specs = HUMIDITY_DOMAIN_SPECS
TRIGGERS: dict[str, type[Trigger]] = {
"changed": HumidityChangedTrigger,

View File

@@ -74,7 +74,7 @@ class IntelliClimaVMCFan(IntelliClimaECOEntity, FanEntity):
"""Return the current speed percentage."""
device_data = self._device_data
if device_data.speed_set == FanSpeed.auto_get:
if device_data.speed_set == FanSpeed.auto:
return None
return ranged_value_to_percentage(self._speed_range, int(device_data.speed_set))
@@ -92,7 +92,7 @@ class IntelliClimaVMCFan(IntelliClimaECOEntity, FanEntity):
if device_data.mode_set == FanMode.off:
return None
if (
device_data.speed_set == FanSpeed.auto_get
device_data.speed_set == FanSpeed.auto
and device_data.mode_set == FanMode.sensor
):
return "auto"
@@ -111,7 +111,7 @@ class IntelliClimaVMCFan(IntelliClimaECOEntity, FanEntity):
infinitely.
"""
percentage = 25 if percentage == 0 else percentage
await self.async_set_mode_speed(preset_mode=preset_mode, percentage=percentage)
await self.async_set_mode_speed(fan_mode=preset_mode, percentage=percentage)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the fan."""
@@ -124,10 +124,10 @@ class IntelliClimaVMCFan(IntelliClimaECOEntity, FanEntity):
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set preset mode."""
await self.async_set_mode_speed(preset_mode=preset_mode)
await self.async_set_mode_speed(fan_mode=preset_mode)
async def async_set_mode_speed(
self, preset_mode: str | None = None, percentage: int | None = None
self, fan_mode: str | None = None, percentage: int | None = None
) -> None:
"""Set mode and speed.
@@ -137,7 +137,7 @@ class IntelliClimaVMCFan(IntelliClimaECOEntity, FanEntity):
percentage = self.percentage if percentage is None else percentage
percentage = 25 if percentage is None else percentage
if preset_mode == "auto":
if fan_mode == "auto":
# auto is a special case with special mode and speed setting
await self.coordinator.api.ecocomfort.set_mode_speed_auto(self._device_sn)
await self.coordinator.async_request_refresh()
@@ -148,20 +148,21 @@ class IntelliClimaVMCFan(IntelliClimaECOEntity, FanEntity):
return
# Determine the fan mode
if not self.is_on:
if fan_mode is not None:
# Set to requested fan_mode
mode = fan_mode
elif not self.is_on:
# Default to alternate fan mode if not turned on
mode = FanMode.alternate
else:
# Maintain current mode
mode = self._device_data.mode_set
speed = FanSpeed(
str(
math.ceil(
percentage_to_ranged_value(
self._speed_range,
percentage,
)
speed = str(
math.ceil(
percentage_to_ranged_value(
self._speed_range,
percentage,
)
)
)

View File

@@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"requirements": ["pyintelliclima==0.3.1"]
"requirements": ["pyintelliclima==0.2.2"]
}

View File

@@ -68,12 +68,12 @@ class IntelliClimaVMCFanModeSelect(IntelliClimaECOEntity, SelectEntity):
# If in auto mode (sensor mode with auto speed), return None (handled by fan entity preset mode)
if (
device_data.speed_set == FanSpeed.auto_get
device_data.speed_set == FanSpeed.auto
and device_data.mode_set == FanMode.sensor
):
return None
return INTELLICLIMA_MODE_TO_FAN_MODE.get(device_data.mode_set)
return INTELLICLIMA_MODE_TO_FAN_MODE.get(FanMode(device_data.mode_set))
async def async_select_option(self, option: str) -> None:
"""Set the fan mode."""
@@ -83,7 +83,7 @@ class IntelliClimaVMCFanModeSelect(IntelliClimaECOEntity, SelectEntity):
# Determine speed: keep current speed if available, otherwise default to sleep
if (
device_data.speed_set == FanSpeed.auto_get
device_data.speed_set == FanSpeed.auto
or device_data.mode_set == FanMode.off
):
speed = FanSpeed.sleep

View File

@@ -6,7 +6,7 @@
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"no_devices": "No supported IntelliClima devices were found in your account",
"no_devices": "No IntelliClima devices found in your account",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {

View File

@@ -4,7 +4,6 @@ from typing import Any
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.automation import NumericalDomainSpec
from homeassistant.helpers.trigger import (
EntityNumericalStateAttributeChangedTriggerBase,
EntityNumericalStateAttributeCrossedThresholdTriggerBase,
@@ -21,18 +20,13 @@ def _convert_uint8_to_percentage(value: Any) -> float:
return (float(value) / 255.0) * 100.0
BRIGHTNESS_DOMAIN_SPECS = {
DOMAIN: NumericalDomainSpec(
value_source=ATTR_BRIGHTNESS,
value_converter=_convert_uint8_to_percentage,
),
}
class BrightnessChangedTrigger(EntityNumericalStateAttributeChangedTriggerBase):
"""Trigger for brightness changed."""
_domain_specs = BRIGHTNESS_DOMAIN_SPECS
_domains = {DOMAIN}
_attributes = {DOMAIN: ATTR_BRIGHTNESS}
_converter = staticmethod(_convert_uint8_to_percentage)
class BrightnessCrossedThresholdTrigger(
@@ -40,7 +34,9 @@ class BrightnessCrossedThresholdTrigger(
):
"""Trigger for brightness crossed threshold."""
_domain_specs = BRIGHTNESS_DOMAIN_SPECS
_domains = {DOMAIN}
_attributes = {DOMAIN: ATTR_BRIGHTNESS}
_converter = staticmethod(_convert_uint8_to_percentage)
TRIGGERS: dict[str, type[Trigger]] = {

View File

@@ -20,8 +20,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import LitterRobotConfigEntry
from .entity import LitterRobotEntity, _WhiskerEntityT
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class RobotBinarySensorEntityDescription(

View File

@@ -14,9 +14,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import LitterRobotConfigEntry
from .entity import LitterRobotEntity, _WhiskerEntityT, whisker_command
PARALLEL_UPDATES = 1
from .entity import LitterRobotEntity, _WhiskerEntityT
@dataclass(frozen=True, kw_only=True)
@@ -73,7 +71,6 @@ class LitterRobotButtonEntity(LitterRobotEntity[_WhiskerEntityT], ButtonEntity):
entity_description: RobotButtonEntityDescription[_WhiskerEntityT]
@whisker_command
async def async_press(self) -> None:
"""Press the button."""
await self.entity_description.press_fn(self.robot)

View File

@@ -46,18 +46,11 @@ class LitterRobotDataUpdateCoordinator(DataUpdateCoordinator[None]):
async def _async_update_data(self) -> None:
"""Update all device states from the Litter-Robot API."""
try:
await self.account.refresh_robots()
await self.account.load_pets()
for pet in self.account.pets:
# Need to fetch weight history for `get_visits_since`
await pet.fetch_weight_history()
except LitterRobotLoginException as ex:
raise ConfigEntryAuthFailed("Invalid credentials") from ex
except LitterRobotException as ex:
raise UpdateFailed(
f"Unable to fetch data from the Whisker API: {ex}"
) from ex
await self.account.refresh_robots()
await self.account.load_pets()
for pet in self.account.pets:
# Need to fetch weight history for `get_visits_since`
await pet.fetch_weight_history()
async def _async_setup(self) -> None:
"""Set up the coordinator."""

View File

@@ -1,24 +0,0 @@
"""Diagnostics support for Litter-Robot."""
from __future__ import annotations
from typing import Any
from pylitterbot.utils import REDACT_FIELDS
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.core import HomeAssistant
from .coordinator import LitterRobotConfigEntry
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: LitterRobotConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
account = entry.runtime_data.account
data = {
"robots": [robot.to_dict() for robot in account.robots],
"pets": [pet.to_dict() for pet in account.pets],
}
return async_redact_data(data, REDACT_FIELDS)

View File

@@ -2,14 +2,11 @@
from __future__ import annotations
from collections.abc import Awaitable, Callable, Coroutine
from typing import Any, Concatenate, Generic, TypeVar
from typing import Generic, TypeVar
from pylitterbot import Pet, Robot
from pylitterbot.exceptions import LitterRobotException
from pylitterbot.robot import EVENT_UPDATE
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -20,26 +17,6 @@ from .coordinator import LitterRobotDataUpdateCoordinator
_WhiskerEntityT = TypeVar("_WhiskerEntityT", bound=Robot | Pet)
def whisker_command[_WhiskerEntityT2: LitterRobotEntity, **_P](
func: Callable[Concatenate[_WhiskerEntityT2, _P], Awaitable[None]],
) -> Callable[Concatenate[_WhiskerEntityT2, _P], Coroutine[Any, Any, None]]:
"""Wrap a Whisker command to handle exceptions."""
async def handler(
self: _WhiskerEntityT2, *args: _P.args, **kwargs: _P.kwargs
) -> None:
try:
await func(self, *args, **kwargs)
except LitterRobotException as ex:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="command_failed",
translation_placeholders={"error": str(ex)},
) from ex
return handler
def get_device_info(whisker_entity: Robot | Pet) -> DeviceInfo:
"""Get device info for a robot or pet."""
if isinstance(whisker_entity, Robot):

View File

@@ -23,16 +23,16 @@ rules:
unique-config-entry: done
# Silver
action-exceptions: done
action-exceptions: todo
config-entry-unloading: done
docs-configuration-parameters:
status: done
comment: No options to configure
docs-installation-parameters: done
entity-unavailable: done
entity-unavailable: todo
integration-owner: done
log-when-unavailable: done
parallel-updates: done
log-when-unavailable: todo
parallel-updates: todo
reauthentication-flow: done
test-coverage:
status: todo
@@ -42,20 +42,20 @@ rules:
# Gold
devices: done
diagnostics: done
diagnostics: todo
discovery-update-info:
status: done
comment: The integration is cloud-based
discovery:
status: todo
comment: Need to validate discovery
docs-data-update: done
docs-examples: done
docs-known-limitations: done
docs-supported-devices: done
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: done
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: todo
entity-category: done
entity-device-class: done

View File

@@ -15,9 +15,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import LitterRobotConfigEntry, LitterRobotDataUpdateCoordinator
from .entity import LitterRobotEntity, _WhiskerEntityT, whisker_command
PARALLEL_UPDATES = 1
from .entity import LitterRobotEntity, _WhiskerEntityT
_CastTypeT = TypeVar("_CastTypeT", int, float, str)
@@ -156,7 +154,6 @@ class LitterRobotSelectEntity(
"""Return the selected entity option to represent the entity state."""
return str(self.entity_description.current_fn(self.robot))
@whisker_command
async def async_select_option(self, option: str) -> None:
"""Change the selected option."""
await self.entity_description.select_fn(self.robot, option)

View File

@@ -23,8 +23,6 @@ from homeassistant.util import dt as dt_util
from .coordinator import LitterRobotConfigEntry
from .entity import LitterRobotEntity, _WhiskerEntityT
PARALLEL_UPDATES = 0
def icon_for_gauge_level(gauge_level: int | None = None, offset: int = 0) -> str:
"""Return a gauge icon valid identifier."""

View File

@@ -195,14 +195,6 @@
}
}
},
"exceptions": {
"command_failed": {
"message": "An error occurred while communicating with the device: {error}"
},
"firmware_update_failed": {
"message": "Unable to start firmware update on {name}"
}
},
"issues": {
"deprecated_entity": {
"description": "The Litter-Robot entity `{entity}` is deprecated and will be removed in a future release.\nPlease update your dashboards, automations and scripts, disable `{entity}` and reload the integration/restart Home Assistant to fix this issue.",

View File

@@ -25,9 +25,7 @@ from homeassistant.helpers.issue_registry import (
from .const import DOMAIN
from .coordinator import LitterRobotConfigEntry
from .entity import LitterRobotEntity, _WhiskerEntityT, whisker_command
PARALLEL_UPDATES = 1
from .entity import LitterRobotEntity, _WhiskerEntityT
@dataclass(frozen=True, kw_only=True)
@@ -137,12 +135,10 @@ class RobotSwitchEntity(LitterRobotEntity[_WhiskerEntityT], SwitchEntity):
"""Return true if switch is on."""
return self.entity_description.value_fn(self.robot)
@whisker_command
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the switch on."""
await self.entity_description.set_fn(self.robot, True)
@whisker_command
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the switch off."""
await self.entity_description.set_fn(self.robot, False)

View File

@@ -16,9 +16,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from .coordinator import LitterRobotConfigEntry
from .entity import LitterRobotEntity, _WhiskerEntityT, whisker_command
PARALLEL_UPDATES = 1
from .entity import LitterRobotEntity, _WhiskerEntityT
@dataclass(frozen=True, kw_only=True)
@@ -76,7 +74,6 @@ class LitterRobotTimeEntity(LitterRobotEntity[_WhiskerEntityT], TimeEntity):
"""Return the value reported by the time."""
return self.entity_description.value_fn(self.robot)
@whisker_command
async def async_set_value(self, value: time) -> None:
"""Update the current value."""
await self.entity_description.set_fn(self.robot, value)

View File

@@ -17,11 +17,8 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import LitterRobotConfigEntry
from .entity import LitterRobotEntity, whisker_command
PARALLEL_UPDATES = 1
from .entity import LitterRobotEntity
SCAN_INTERVAL = timedelta(days=1)
@@ -83,15 +80,11 @@ class RobotUpdateEntity(LitterRobotEntity[LitterRobot4], UpdateEntity):
latest_version = self.robot.firmware
self._attr_latest_version = latest_version
@whisker_command
async def async_install(
self, version: str | None, backup: bool, **kwargs: Any
) -> None:
"""Install an update."""
if await self.robot.has_firmware_update(True):
if not await self.robot.update_firmware():
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="firmware_update_failed",
translation_placeholders={"name": self.robot.name},
)
message = f"Unable to start firmware update on {self.robot.name}"
raise HomeAssistantError(message)

View File

@@ -19,9 +19,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from .coordinator import LitterRobotConfigEntry
from .entity import LitterRobotEntity, whisker_command
PARALLEL_UPDATES = 1
from .entity import LitterRobotEntity
LITTER_BOX_STATUS_STATE_MAP = {
LitterBoxStatus.CLEAN_CYCLE: VacuumActivity.CLEANING,
@@ -68,18 +66,15 @@ class LitterRobotCleaner(LitterRobotEntity[LitterRobot], StateVacuumEntity):
"""Return the state of the cleaner."""
return LITTER_BOX_STATUS_STATE_MAP.get(self.robot.status, VacuumActivity.ERROR)
@whisker_command
async def async_start(self) -> None:
"""Start a clean cycle."""
await self.robot.set_power_status(True)
await self.robot.start_cleaning()
@whisker_command
async def async_stop(self, **kwargs: Any) -> None:
"""Stop the vacuum cleaner."""
await self.robot.set_power_status(False)
@whisker_command
async def async_set_sleep_mode(
self, enabled: bool, start_time: str | None = None
) -> None:

View File

@@ -187,27 +187,6 @@ DISCOVERY_SCHEMAS = [
# allow None value to account for 'default' value
allow_none_value=True,
),
MatterDiscoverySchema(
platform=Platform.NUMBER,
entity_description=MatterNumberEntityDescription(
key="power_on_level",
entity_category=EntityCategory.CONFIG,
translation_key="power_on_level",
native_max_value=255,
native_min_value=0,
mode=NumberMode.BOX,
# use 255 to indicate that the value should revert to the default
device_to_ha=lambda x: 255 if x is None else x,
ha_to_device=lambda x: None if x == 255 else int(x),
native_step=1,
native_unit_of_measurement=None,
),
entity_class=MatterNumber,
required_attributes=(clusters.LevelControl.Attributes.StartUpCurrentLevel,),
not_device_type=(device_types.Speaker,),
# allow None value to account for 'default' value
allow_none_value=True,
),
MatterDiscoverySchema(
platform=Platform.NUMBER,
entity_description=MatterNumberEntityDescription(

View File

@@ -238,9 +238,6 @@
"on_transition_time": {
"name": "On transition time"
},
"power_on_level": {
"name": "Power-on level"
},
"pump_setpoint": {
"name": "Setpoint"
},
@@ -325,11 +322,11 @@
}
},
"startup_on_off": {
"name": "Power-on behavior",
"name": "Power-on behavior on startup",
"state": {
"off": "[%key:common::state::off%]",
"on": "[%key:common::state::on%]",
"previous": "Previous state",
"previous": "Previous",
"toggle": "[%key:common::action::toggle%]"
}
},

View File

@@ -1,17 +0,0 @@
"""Integration for motion triggers."""
from __future__ import annotations
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
DOMAIN = "motion"
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
__all__ = []
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the component."""
return True

View File

@@ -1,10 +0,0 @@
{
"triggers": {
"cleared": {
"trigger": "mdi:motion-sensor-off"
},
"detected": {
"trigger": "mdi:motion-sensor"
}
}
}

View File

@@ -1,8 +0,0 @@
{
"domain": "motion",
"name": "Motion",
"codeowners": ["@home-assistant/core"],
"documentation": "https://www.home-assistant.io/integrations/motion",
"integration_type": "system",
"quality_scale": "internal"
}

View File

@@ -1,38 +0,0 @@
{
"common": {
"trigger_behavior_description": "The behavior of the targeted motion sensors to trigger on.",
"trigger_behavior_name": "Behavior"
},
"selector": {
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
}
}
},
"title": "Motion",
"triggers": {
"cleared": {
"description": "Triggers after one or more motion sensors stop detecting motion.",
"fields": {
"behavior": {
"description": "[%key:component::motion::common::trigger_behavior_description%]",
"name": "[%key:component::motion::common::trigger_behavior_name%]"
}
},
"name": "Motion cleared"
},
"detected": {
"description": "Triggers after one or more motion sensors start detecting motion.",
"fields": {
"behavior": {
"description": "[%key:component::motion::common::trigger_behavior_description%]",
"name": "[%key:component::motion::common::trigger_behavior_name%]"
}
},
"name": "Motion detected"
}
}
}

View File

@@ -1,45 +0,0 @@
"""Provides triggers for motion."""
from homeassistant.components.binary_sensor import (
DOMAIN as BINARY_SENSOR_DOMAIN,
BinarySensorDeviceClass,
)
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
EntityTargetStateTriggerBase,
EntityTriggerBase,
Trigger,
)
class _MotionBinaryTriggerBase(EntityTriggerBase):
"""Base trigger for motion binary sensor state changes."""
_domain_specs = {
BINARY_SENSOR_DOMAIN: DomainSpec(device_class=BinarySensorDeviceClass.MOTION)
}
class MotionDetectedTrigger(_MotionBinaryTriggerBase, EntityTargetStateTriggerBase):
"""Trigger for motion detected (binary sensor ON)."""
_to_states = {STATE_ON}
class MotionClearedTrigger(_MotionBinaryTriggerBase, EntityTargetStateTriggerBase):
"""Trigger for motion cleared (binary sensor OFF)."""
_to_states = {STATE_OFF}
TRIGGERS: dict[str, type[Trigger]] = {
"detected": MotionDetectedTrigger,
"cleared": MotionClearedTrigger,
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for motion."""
return TRIGGERS

View File

@@ -1,25 +0,0 @@
.trigger_common_fields: &trigger_common_fields
behavior:
required: true
default: any
selector:
select:
translation_key: trigger_behavior
options:
- first
- last
- any
detected:
fields: *trigger_common_fields
target:
entity:
- domain: binary_sensor
device_class: motion
cleared:
fields: *trigger_common_fields
target:
entity:
- domain: binary_sensor
device_class: motion

View File

@@ -1,17 +0,0 @@
"""Integration for occupancy triggers."""
from __future__ import annotations
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
DOMAIN = "occupancy"
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
__all__ = []
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the component."""
return True

View File

@@ -1,10 +0,0 @@
{
"triggers": {
"cleared": {
"trigger": "mdi:home-outline"
},
"detected": {
"trigger": "mdi:home-account"
}
}
}

View File

@@ -1,8 +0,0 @@
{
"domain": "occupancy",
"name": "Occupancy",
"codeowners": ["@home-assistant/core"],
"documentation": "https://www.home-assistant.io/integrations/occupancy",
"integration_type": "system",
"quality_scale": "internal"
}

View File

@@ -1,38 +0,0 @@
{
"common": {
"trigger_behavior_description": "The behavior of the targeted occupancy sensors to trigger on.",
"trigger_behavior_name": "Behavior"
},
"selector": {
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
}
}
},
"title": "Occupancy",
"triggers": {
"cleared": {
"description": "Triggers after one or more occupancy sensors stop detecting occupancy.",
"fields": {
"behavior": {
"description": "[%key:component::occupancy::common::trigger_behavior_description%]",
"name": "[%key:component::occupancy::common::trigger_behavior_name%]"
}
},
"name": "Occupancy cleared"
},
"detected": {
"description": "Triggers after one or more occupancy sensors start detecting occupancy.",
"fields": {
"behavior": {
"description": "[%key:component::occupancy::common::trigger_behavior_description%]",
"name": "[%key:component::occupancy::common::trigger_behavior_name%]"
}
},
"name": "Occupancy detected"
}
}
}

View File

@@ -1,49 +0,0 @@
"""Provides triggers for occupancy."""
from homeassistant.components.binary_sensor import (
DOMAIN as BINARY_SENSOR_DOMAIN,
BinarySensorDeviceClass,
)
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
EntityTargetStateTriggerBase,
EntityTriggerBase,
Trigger,
)
class _OccupancyBinaryTriggerBase(EntityTriggerBase):
"""Base trigger for occupancy binary sensor state changes."""
_domain_specs = {
BINARY_SENSOR_DOMAIN: DomainSpec(device_class=BinarySensorDeviceClass.OCCUPANCY)
}
class OccupancyDetectedTrigger(
_OccupancyBinaryTriggerBase, EntityTargetStateTriggerBase
):
"""Trigger for occupancy detected (binary sensor ON)."""
_to_states = {STATE_ON}
class OccupancyClearedTrigger(
_OccupancyBinaryTriggerBase, EntityTargetStateTriggerBase
):
"""Trigger for occupancy cleared (binary sensor OFF)."""
_to_states = {STATE_OFF}
TRIGGERS: dict[str, type[Trigger]] = {
"detected": OccupancyDetectedTrigger,
"cleared": OccupancyClearedTrigger,
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for occupancy."""
return TRIGGERS

View File

@@ -1,25 +0,0 @@
.trigger_common_fields: &trigger_common_fields
behavior:
required: true
default: any
selector:
select:
translation_key: trigger_behavior
options:
- first
- last
- any
detected:
fields: *trigger_common_fields
target:
entity:
- domain: binary_sensor
device_class: occupancy
cleared:
fields: *trigger_common_fields
target:
entity:
- domain: binary_sensor
device_class: occupancy

View File

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

View File

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

View File

@@ -12,5 +12,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "platinum",
"requirements": ["python-pooldose==0.8.5"]
"requirements": ["python-pooldose==0.8.2"]
}

View File

@@ -14,11 +14,13 @@ from .coordinator import PranaConfigEntry, PranaCoordinator
_LOGGER = logging.getLogger(__name__)
PLATFORMS = [Platform.FAN, Platform.SWITCH]
# Keep platforms sorted alphabetically to satisfy lint rule
PLATFORMS = [Platform.SWITCH]
async def async_setup_entry(hass: HomeAssistant, entry: PranaConfigEntry) -> bool:
"""Set up Prana from a config entry."""
coordinator = PranaCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator

View File

@@ -1,186 +0,0 @@
"""Fan platform for Prana integration."""
from collections.abc import Callable
from dataclasses import dataclass
import math
from typing import Any
from prana_local_api_client.models.prana_state import FanState
from homeassistant.components.fan import (
FanEntity,
FanEntityDescription,
FanEntityFeature,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.percentage import (
percentage_to_ranged_value,
ranged_value_to_percentage,
)
from homeassistant.util.scaling import int_states_in_range
from . import PranaConfigEntry
from .entity import PranaBaseEntity, PranaCoordinator, PranaEntityDescription, StrEnum
PARALLEL_UPDATES = 1
# The Prana device API expects fan speed values in scaled units (tenths of a speed step)
# rather than the raw step value used internally by this integration. This factor is
# applied when sending speeds to the API to match its expected units.
PRANA_SPEED_MULTIPLIER = 10
class PranaFanType(StrEnum):
"""Enumerates Prana fan types exposed by the device API."""
SUPPLY = "supply"
EXTRACT = "extract"
BOUNDED = "bounded"
@dataclass(frozen=True, kw_only=True)
class PranaFanEntityDescription(FanEntityDescription, PranaEntityDescription):
"""Description of a Prana fan entity."""
value_fn: Callable[[PranaCoordinator], FanState]
speed_range: Callable[[PranaCoordinator], tuple[int, int]]
ENTITIES: tuple[PranaEntityDescription, ...] = (
PranaFanEntityDescription(
key=PranaFanType.SUPPLY,
translation_key="supply",
value_fn=lambda coord: (
coord.data.supply if not coord.data.bound else coord.data.bounded
),
speed_range=lambda coord: (
1,
coord.data.supply.max_speed
if not coord.data.bound
else coord.data.bounded.max_speed,
),
),
PranaFanEntityDescription(
key=PranaFanType.EXTRACT,
translation_key="extract",
value_fn=lambda coord: (
coord.data.extract if not coord.data.bound else coord.data.bounded
),
speed_range=lambda coord: (
1,
coord.data.extract.max_speed
if not coord.data.bound
else coord.data.bounded.max_speed,
),
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: PranaConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Prana fan entities from a config entry."""
async_add_entities(
PranaFan(entry.runtime_data, entity_description)
for entity_description in ENTITIES
)
class PranaFan(PranaBaseEntity, FanEntity):
"""Representation of a Prana fan entity."""
entity_description: PranaFanEntityDescription
_attr_preset_modes = ["night", "boost"]
_attr_supported_features = (
FanEntityFeature.SET_SPEED
| FanEntityFeature.TURN_ON
| FanEntityFeature.TURN_OFF
| FanEntityFeature.PRESET_MODE
)
@property
def _api_target_key(self) -> str:
"""Return the correct target key for API commands based on bounded state."""
# If the device is in bound mode, both supply and extract fans control the same bounded fan speeds.
if self.coordinator.data.bound:
return PranaFanType.BOUNDED
# Otherwise, return the specific fan type (supply or extract) for API commands.
return self.entity_description.key
@property
def speed_count(self) -> int:
"""Return the number of speeds the fan supports."""
return int_states_in_range(
self.entity_description.speed_range(self.coordinator)
)
@property
def percentage(self) -> int | None:
"""Return the current fan speed percentage."""
current_speed = self.entity_description.value_fn(self.coordinator).speed
return ranged_value_to_percentage(
self.entity_description.speed_range(self.coordinator), current_speed
)
async def async_set_percentage(self, percentage: int) -> None:
"""Set fan speed (0-100%) by converting to device-specific speed steps."""
if percentage == 0:
await self.async_turn_off()
return
await self.coordinator.api_client.set_speed(
math.ceil(
percentage_to_ranged_value(
self.entity_description.speed_range(self.coordinator),
percentage,
)
)
* PRANA_SPEED_MULTIPLIER,
self._api_target_key,
)
await self.coordinator.async_refresh()
@property
def is_on(self) -> bool:
"""Return true if the fan is on."""
return self.entity_description.value_fn(self.coordinator).is_on
async def async_turn_on(
self,
percentage: int | None = None,
preset_mode: str | None = None,
**kwargs: Any,
) -> None:
"""Turn the fan on and optionally set speed or preset mode."""
if percentage == 0:
await self.async_turn_off()
return
await self.coordinator.api_client.set_speed_is_on(True, self._api_target_key)
if percentage is not None:
await self.async_set_percentage(percentage)
if preset_mode is not None:
await self.async_set_preset_mode(preset_mode)
if percentage is None and preset_mode is None:
await self.coordinator.async_refresh()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the fan off."""
await self.coordinator.api_client.set_speed_is_on(False, self._api_target_key)
await self.coordinator.async_refresh()
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set the preset mode (e.g., night or boost)."""
await self.coordinator.api_client.set_switch(preset_mode, True)
await self.coordinator.async_refresh()
@property
def preset_mode(self) -> str | None:
"""Return the current preset mode."""
if self.coordinator.data.night:
return "night"
if self.coordinator.data.boost:
return "boost"
return None

View File

@@ -1,13 +1,5 @@
{
"entity": {
"fan": {
"extract": {
"default": "mdi:arrow-expand-right"
},
"supply": {
"default": "mdi:arrow-expand-left"
}
},
"switch": {
"auto": {
"default": "mdi:fan-auto"

View File

@@ -25,30 +25,6 @@
}
},
"entity": {
"fan": {
"extract": {
"name": "Extract fan",
"state_attributes": {
"preset_mode": {
"state": {
"boost": "Boost",
"night": "Night"
}
}
}
},
"supply": {
"name": "Supply fan",
"state_attributes": {
"preset_mode": {
"state": {
"boost": "[%key:component::prana::entity::fan::extract::state_attributes::preset_mode::state::boost%]",
"night": "[%key:component::prana::entity::fan::extract::state_attributes::preset_mode::state::night%]"
}
}
}
}
},
"switch": {
"auto": {
"name": "Auto"

View File

@@ -2,7 +2,6 @@
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
ENTITY_STATE_TRIGGER_SCHEMA,
EntityTriggerBase,
@@ -15,7 +14,7 @@ from . import DOMAIN
class SceneActivatedTrigger(EntityTriggerBase):
"""Trigger for scene entity activations."""
_domain_specs = {DOMAIN: DomainSpec()}
_domains = {DOMAIN}
_schema = ENTITY_STATE_TRIGGER_SCHEMA
def is_valid_transition(self, from_state: State, to_state: State) -> bool:

View File

@@ -2,7 +2,6 @@
from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
EntityTransitionTriggerBase,
Trigger,
@@ -15,7 +14,7 @@ from .const import ATTR_NEXT_EVENT, DOMAIN
class ScheduleBackToBackTrigger(EntityTransitionTriggerBase):
"""Trigger for back-to-back schedule blocks."""
_domain_specs = {DOMAIN: DomainSpec()}
_domains = {DOMAIN}
_from_states = {STATE_OFF, STATE_ON}
_to_states = {STATE_ON}

View File

@@ -15,9 +15,6 @@
"period": {
"default": "mdi:sine-wave"
},
"spring_status": {
"default": "mdi:feather"
},
"swing_count": {
"default": "mdi:counter"
},

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_push",
"loggers": ["pysmarlaapi", "pysignalr"],
"quality_scale": "silver",
"requirements": ["pysmarlaapi==1.0.2"]
"requirements": ["pysmarlaapi==1.0.1"]
}

View File

@@ -1,18 +1,14 @@
"""Support for the Swing2Sleep Smarla sensor entities."""
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any, Generic, TypeVar
from pysmarlaapi.federwiege.services.classes import Property
from pysmarlaapi.federwiege.services.types import SpringStatus
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
StateType,
)
from homeassistant.const import UnitOfLength, UnitOfTime
from homeassistant.core import HomeAssistant
@@ -23,56 +19,53 @@ from .entity import SmarlaBaseEntity, SmarlaEntityDescription
PARALLEL_UPDATES = 0
_VT = TypeVar("_VT")
@dataclass(frozen=True, kw_only=True)
class SmarlaSensorEntityDescription(
SmarlaEntityDescription, SensorEntityDescription, Generic[_VT]
):
class SmarlaSensorEntityDescription(SmarlaEntityDescription, SensorEntityDescription):
"""Class describing Swing2Sleep Smarla sensor entities."""
value_fn: Callable[[_VT | None], StateType] = lambda value: (
value if isinstance(value, (str, int, float)) else None
)
multiple: bool = False
value_pos: int = 0
SENSORS: list[SmarlaSensorEntityDescription[Any]] = [
SmarlaSensorEntityDescription[list[int]](
SENSORS: list[SmarlaSensorEntityDescription] = [
SmarlaSensorEntityDescription(
key="amplitude",
translation_key="amplitude",
service="analyser",
property="oscillation",
multiple=True,
value_pos=0,
device_class=SensorDeviceClass.DISTANCE,
native_unit_of_measurement=UnitOfLength.MILLIMETERS,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda value: value[0] if value else None,
),
SmarlaSensorEntityDescription[list[int]](
SmarlaSensorEntityDescription(
key="period",
translation_key="period",
service="analyser",
property="oscillation",
multiple=True,
value_pos=1,
device_class=SensorDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.MILLISECONDS,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda value: value[1] if value else None,
),
SmarlaSensorEntityDescription[int](
SmarlaSensorEntityDescription(
key="activity",
translation_key="activity",
service="analyser",
property="activity",
state_class=SensorStateClass.MEASUREMENT,
),
SmarlaSensorEntityDescription[int](
SmarlaSensorEntityDescription(
key="swing_count",
translation_key="swing_count",
service="analyser",
property="swing_count",
state_class=SensorStateClass.TOTAL_INCREASING,
),
SmarlaSensorEntityDescription[int](
SmarlaSensorEntityDescription(
key="total_swing_time",
translation_key="total_swing_time",
service="info",
@@ -82,21 +75,6 @@ SENSORS: list[SmarlaSensorEntityDescription[Any]] = [
suggested_unit_of_measurement=UnitOfTime.HOURS,
state_class=SensorStateClass.TOTAL_INCREASING,
),
SmarlaSensorEntityDescription[SpringStatus](
key="spring_status",
translation_key="spring_status",
service="analyser",
property="spring_status",
device_class=SensorDeviceClass.ENUM,
options=[
status.name.lower()
for status in SpringStatus
if status != SpringStatus.UNKNOWN
],
value_fn=lambda value: (
value.name.lower() if value and value != SpringStatus.UNKNOWN else None
),
),
]
@@ -107,18 +85,38 @@ async def async_setup_entry(
) -> None:
"""Set up the Smarla sensors from config entry."""
federwiege = config_entry.runtime_data
async_add_entities(SmarlaSensor(federwiege, desc) for desc in SENSORS)
async_add_entities(
(
SmarlaSensor(federwiege, desc)
if not desc.multiple
else SmarlaSensorMultiple(federwiege, desc)
)
for desc in SENSORS
)
class SmarlaSensor(SmarlaBaseEntity, SensorEntity, Generic[_VT]):
class SmarlaSensor(SmarlaBaseEntity, SensorEntity):
"""Representation of Smarla sensor."""
entity_description: SmarlaSensorEntityDescription[_VT]
entity_description: SmarlaSensorEntityDescription
_property: Property[_VT]
_property: Property[int]
@property
def native_value(self) -> StateType:
def native_value(self) -> int | None:
"""Return the entity value to represent the entity state."""
value = self._property.get()
return self.entity_description.value_fn(value)
return self._property.get()
class SmarlaSensorMultiple(SmarlaBaseEntity, SensorEntity):
"""Representation of Smarla sensor with multiple values inside property."""
entity_description: SmarlaSensorEntityDescription
_property: Property[list[int]]
@property
def native_value(self) -> int | None:
"""Return the entity value to represent the entity state."""
v = self._property.get()
return v[self.entity_description.value_pos] if v is not None else None

View File

@@ -50,16 +50,6 @@
"period": {
"name": "Period"
},
"spring_status": {
"name": "Spring status",
"state": {
"constellation_critical_too_high": "Critically too strong",
"constellation_critical_too_low": "Critically too weak",
"constellation_too_high": "Too strong",
"constellation_too_low": "Too weak",
"normal": "Normal"
}
},
"swing_count": {
"name": "Swing count",
"unit_of_measurement": "swings"

View File

@@ -74,11 +74,6 @@ from .const import (
_LOGGER = logging.getLogger(__name__)
def format_zigbee_address(address: str) -> str:
"""Format a zigbee address to be more readable."""
return ":".join(address.lower()[i : i + 2] for i in range(0, 16, 2))
@dataclass
class SmartThingsData:
"""Define an object to hold SmartThings data."""
@@ -495,14 +490,6 @@ def create_devices(
kwargs[ATTR_CONNECTIONS] = {
(dr.CONNECTION_NETWORK_MAC, device.device.hub.mac_address)
}
if device.device.hub.hub_eui:
connections = kwargs.setdefault(ATTR_CONNECTIONS, set())
connections.add(
(
dr.CONNECTION_ZIGBEE,
format_zigbee_address(device.device.hub.hub_eui),
)
)
if device.device.parent_device_id and device.device.parent_device_id in devices:
kwargs[ATTR_VIA_DEVICE] = (DOMAIN, device.device.parent_device_id)
if (ocf := device.device.ocf) is not None:
@@ -526,10 +513,6 @@ def create_devices(
ATTR_SW_VERSION: viper.software_version,
}
)
if (zigbee := device.device.zigbee) is not None:
kwargs[ATTR_CONNECTIONS] = {
(dr.CONNECTION_ZIGBEE, format_zigbee_address(zigbee.eui))
}
if (matter := device.device.matter) is not None:
kwargs.update(
{

View File

@@ -200,14 +200,6 @@ CAPABILITY_TO_SENSORS: dict[
supported_states_attributes=Attribute.SUPPORTED_STATUS,
)
},
Capability.CUSTOM_COOKTOP_OPERATING_STATE: {
Attribute.COOKTOP_OPERATING_STATE: SmartThingsBinarySensorEntityDescription(
key=Attribute.COOKTOP_OPERATING_STATE,
translation_key="cooktop_operating_state",
is_on_key="run",
supported_states_attributes=Attribute.SUPPORTED_COOKTOP_OPERATING_STATE,
)
},
}

View File

@@ -34,5 +34,5 @@
"iot_class": "cloud_push",
"loggers": ["pysmartthings"],
"quality_scale": "bronze",
"requirements": ["pysmartthings==3.7.0"]
"requirements": ["pysmartthings==3.6.0"]
}

View File

@@ -49,9 +49,6 @@
"child_lock": {
"name": "Child lock"
},
"cooktop_operating_state": {
"name": "[%key:component::smartthings::entity::sensor::cooktop_operating_state::name%]"
},
"cool_select_plus_door": {
"name": "CoolSelect+ door"
},

View File

@@ -42,7 +42,7 @@
"username": "[%key:common::config_flow::data::username%]"
},
"description": "Please enter your MySubaru credentials\nNOTE: Initial setup may take up to 30 seconds",
"title": "MySubaru Connected Services configuration"
"title": "Subaru Starlink configuration"
}
}
},
@@ -95,7 +95,7 @@
"update_enabled": "Enable vehicle polling"
},
"description": "When enabled, vehicle polling will send a remote command to your vehicle every 2 hours to obtain new sensor data. Without vehicle polling, new sensor data is only received when the vehicle automatically pushes data (normally after engine shutdown).",
"title": "MySubaru Connected Services options"
"title": "Subaru Starlink options"
}
}
},

View File

@@ -7,6 +7,7 @@ import io
import logging
import os
from pathlib import Path
from ssl import SSLContext
from types import MappingProxyType
from typing import Any, cast
@@ -47,8 +48,8 @@ from homeassistant.const import (
from homeassistant.core import Context, HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.httpx_client import get_async_client
from homeassistant.util.json import JsonValueType
from homeassistant.util.ssl import get_default_context, get_default_no_verify_context
from .const import (
ATTR_ARGS,
@@ -565,7 +566,11 @@ class TelegramNotificationService:
username=kwargs.get(ATTR_USERNAME, ""),
password=kwargs.get(ATTR_PASSWORD, ""),
authentication=kwargs.get(ATTR_AUTHENTICATION),
verify_ssl=kwargs.get(ATTR_VERIFY_SSL, False),
verify_ssl=(
get_default_context()
if kwargs.get(ATTR_VERIFY_SSL, False)
else get_default_no_verify_context()
),
)
media: InputMedia
@@ -733,7 +738,11 @@ class TelegramNotificationService:
username=kwargs.get(ATTR_USERNAME, ""),
password=kwargs.get(ATTR_PASSWORD, ""),
authentication=kwargs.get(ATTR_AUTHENTICATION),
verify_ssl=kwargs.get(ATTR_VERIFY_SSL, False),
verify_ssl=(
get_default_context()
if kwargs.get(ATTR_VERIFY_SSL, False)
else get_default_no_verify_context()
),
)
if file_type == SERVICE_SEND_PHOTO:
@@ -1046,7 +1055,7 @@ async def load_data(
username: str,
password: str,
authentication: str | None,
verify_ssl: bool,
verify_ssl: SSLContext,
num_retries: int = 5,
) -> io.BytesIO:
"""Load data into ByteIO/File container from a source."""
@@ -1062,13 +1071,16 @@ async def load_data(
elif authentication == HTTP_BASIC_AUTHENTICATION:
params["auth"] = httpx.BasicAuth(username, password)
if verify_ssl is not None:
params["verify"] = verify_ssl
retry_num = 0
async with get_async_client(hass, verify_ssl) as client:
async with httpx.AsyncClient(
timeout=DEFAULT_TIMEOUT_SECONDS, headers=headers, **params
) as client:
while retry_num < num_retries:
try:
response = await client.get(
url, headers=headers, timeout=DEFAULT_TIMEOUT_SECONDS, **params
)
req = await client.get(url)
except (httpx.HTTPError, httpx.InvalidURL) as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
@@ -1076,15 +1088,15 @@ async def load_data(
translation_placeholders={"error": str(err)},
) from err
if response.status_code != 200:
if req.status_code != 200:
_LOGGER.warning(
"Status code %s (retry #%s) loading %s",
response.status_code,
req.status_code,
retry_num + 1,
url,
)
else:
data = io.BytesIO(response.content)
data = io.BytesIO(req.content)
if data.read():
data.seek(0)
data.name = url
@@ -1099,7 +1111,7 @@ async def load_data(
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="failed_to_load_url",
translation_placeholders={"error": str(response.status_code)},
translation_placeholders={"error": str(req.status_code)},
)
elif filepath is not None:
if hass.config.is_allowed_path(filepath):

View File

@@ -2,7 +2,6 @@
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
ENTITY_STATE_TRIGGER_SCHEMA,
EntityTriggerBase,
@@ -15,7 +14,7 @@ from .const import DOMAIN
class TextChangedTrigger(EntityTriggerBase):
"""Trigger for text entity when its content changes."""
_domain_specs = {DOMAIN: DomainSpec()}
_domains = {DOMAIN}
_schema = ENTITY_STATE_TRIGGER_SCHEMA
def is_valid_state(self, state: State) -> bool:

View File

@@ -5,11 +5,11 @@ from __future__ import annotations
from dataclasses import dataclass
from tuya_device_handlers.device_wrapper.base import DeviceWrapper
from tuya_device_handlers.device_wrapper.binary_sensor import (
DPCodeBitmapBitWrapper,
DPCodeInSetWrapper,
from tuya_device_handlers.device_wrapper.binary_sensor import DPCodeBitmapBitWrapper
from tuya_device_handlers.device_wrapper.common import (
DPCodeBooleanWrapper,
DPCodeWrapper,
)
from tuya_device_handlers.device_wrapper.common import DPCodeBooleanWrapper
from tuya_sharing import CustomerDevice, Manager
from homeassistant.components.binary_sensor import (
@@ -376,10 +376,29 @@ BINARY_SENSORS: dict[DeviceCategory, tuple[TuyaBinarySensorEntityDescription, ..
}
class _CustomDPCodeWrapper(DPCodeWrapper[bool]):
"""Custom DPCode Wrapper to check for values in a set."""
_valid_values: set[bool | float | int | str]
def __init__(
self, dpcode: str, valid_values: set[bool | float | int | str]
) -> None:
"""Init CustomDPCodeBooleanWrapper."""
super().__init__(dpcode)
self._valid_values = valid_values
def read_device_status(self, device: CustomerDevice) -> bool | None:
"""Read the device value for the dpcode."""
if (raw_value := device.status.get(self.dpcode)) is None:
return None
return raw_value in self._valid_values
def _get_dpcode_wrapper(
device: CustomerDevice,
description: TuyaBinarySensorEntityDescription,
) -> DeviceWrapper[bool] | None:
) -> DPCodeWrapper | None:
"""Get DPCode wrapper for an entity description."""
dpcode = description.dpcode or description.key
if description.bitmap_key is not None:
@@ -393,7 +412,7 @@ def _get_dpcode_wrapper(
# Legacy / compatibility
if dpcode not in device.status:
return None
return DPCodeInSetWrapper(
return _CustomDPCodeWrapper(
dpcode,
description.on_value
if isinstance(description.on_value, set)

View File

@@ -205,7 +205,7 @@ class _PresetWrapper(DPCodeEnumWrapper):
def read_device_status(self, device: CustomerDevice) -> str | None:
"""Read the device status."""
if (raw := self._read_dpcode_value(device)) not in self.options:
if (raw := self._read_dpcode_value(device)) in TUYA_HVAC_TO_HA:
return None
return raw

View File

@@ -44,7 +44,7 @@
"iot_class": "cloud_push",
"loggers": ["tuya_sharing"],
"requirements": [
"tuya-device-handlers==0.0.12",
"tuya-device-handlers==0.0.11",
"tuya-device-sharing-sdk==0.2.8"
]
}

View File

@@ -490,9 +490,9 @@
}
},
"relay_status": {
"name": "Power-on behavior",
"name": "Power on behavior",
"state": {
"last": "Previous state",
"last": "Remember last state",
"memory": "[%key:component::tuya::entity::select::relay_status::state::last%]",
"off": "[%key:common::state::off%]",
"on": "[%key:common::state::on%]",

View File

@@ -76,7 +76,9 @@ async def async_remove_config_entry_device(
"""Remove config entry from a device."""
hub = config_entry.runtime_data
return not any(
identifier in hub.api.devices for _, identifier in device_entry.connections
identifier
for _, identifier in device_entry.connections
if identifier in hub.api.clients or identifier in hub.api.devices
)

View File

@@ -1,54 +0,0 @@
"""The UniFi Access integration."""
from __future__ import annotations
from unifi_access_api import ApiAuthError, ApiConnectionError, UnifiAccessApiClient
from homeassistant.const import CONF_API_TOKEN, CONF_HOST, CONF_VERIFY_SSL, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .coordinator import UnifiAccessConfigEntry, UnifiAccessCoordinator
PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.EVENT]
async def async_setup_entry(hass: HomeAssistant, entry: UnifiAccessConfigEntry) -> bool:
"""Set up UniFi Access from a config entry."""
session = async_get_clientsession(hass, verify_ssl=entry.data[CONF_VERIFY_SSL])
client = UnifiAccessApiClient(
host=entry.data[CONF_HOST],
api_token=entry.data[CONF_API_TOKEN],
session=session,
verify_ssl=entry.data[CONF_VERIFY_SSL],
)
try:
await client.authenticate()
except ApiAuthError as err:
raise ConfigEntryNotReady(
f"Authentication failed for UniFi Access at {entry.data[CONF_HOST]}"
) from err
except ApiConnectionError as err:
raise ConfigEntryNotReady(
f"Unable to connect to UniFi Access at {entry.data[CONF_HOST]}"
) from err
coordinator = UnifiAccessCoordinator(hass, entry, client)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
entry.async_on_unload(client.close)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(
hass: HomeAssistant, entry: UnifiAccessConfigEntry
) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -1,52 +0,0 @@
"""Button platform for the UniFi Access integration."""
from __future__ import annotations
from unifi_access_api import ApiError, Door
from homeassistant.components.button import ButtonEntity
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import UnifiAccessConfigEntry, UnifiAccessCoordinator
from .entity import UnifiAccessEntity
PARALLEL_UPDATES = 1
async def async_setup_entry(
hass: HomeAssistant,
entry: UnifiAccessConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up UniFi Access button entities."""
coordinator = entry.runtime_data
async_add_entities(
UnifiAccessUnlockButton(coordinator, door) for door in coordinator.data.values()
)
class UnifiAccessUnlockButton(UnifiAccessEntity, ButtonEntity):
"""Representation of a UniFi Access door unlock button."""
_attr_translation_key = "unlock"
def __init__(
self,
coordinator: UnifiAccessCoordinator,
door: Door,
) -> None:
"""Initialize the button entity."""
super().__init__(coordinator, door, "unlock")
async def async_press(self) -> None:
"""Unlock the door."""
try:
await self.coordinator.client.unlock_door(self._door_id)
except ApiError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="unlock_failed",
) from err

View File

@@ -1,68 +0,0 @@
"""Config flow for UniFi Access integration."""
from __future__ import annotations
import logging
from typing import Any
from unifi_access_api import ApiAuthError, ApiConnectionError, UnifiAccessApiClient
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_API_TOKEN, CONF_HOST, CONF_VERIFY_SSL
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
class UnifiAccessConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for UniFi Access."""
VERSION = 1
MINOR_VERSION = 1
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:
session = async_get_clientsession(
self.hass, verify_ssl=user_input[CONF_VERIFY_SSL]
)
client = UnifiAccessApiClient(
host=user_input[CONF_HOST],
api_token=user_input[CONF_API_TOKEN],
session=session,
verify_ssl=user_input[CONF_VERIFY_SSL],
)
try:
await client.authenticate()
except ApiAuthError:
errors["base"] = "invalid_auth"
except ApiConnectionError:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]})
return self.async_create_entry(
title="UniFi Access",
data=user_input,
)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(CONF_HOST): str,
vol.Required(CONF_API_TOKEN): str,
vol.Required(CONF_VERIFY_SSL, default=False): bool,
}
),
errors=errors,
)

View File

@@ -1,3 +0,0 @@
"""Constants for the UniFi Access integration."""
DOMAIN = "unifi_access"

View File

@@ -1,199 +0,0 @@
"""Data update coordinator for the UniFi Access integration."""
from __future__ import annotations
import asyncio
from collections.abc import Callable
from dataclasses import dataclass
import logging
from typing import Any, cast
from unifi_access_api import (
ApiAuthError,
ApiConnectionError,
ApiError,
Door,
UnifiAccessApiClient,
WsMessageHandler,
)
from unifi_access_api.models.websocket import (
HwDoorbell,
InsightsAdd,
LocationUpdateState,
LocationUpdateV2,
V2LocationState,
V2LocationUpdate,
WebsocketMessage,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
type UnifiAccessConfigEntry = ConfigEntry[UnifiAccessCoordinator]
@dataclass(frozen=True)
class DoorEvent:
"""Represent a door event from WebSocket."""
door_id: str
category: str
event_type: str
event_data: dict[str, Any]
class UnifiAccessCoordinator(DataUpdateCoordinator[dict[str, Door]]):
"""Coordinator for fetching UniFi Access door data."""
config_entry: UnifiAccessConfigEntry
def __init__(
self,
hass: HomeAssistant,
entry: UnifiAccessConfigEntry,
client: UnifiAccessApiClient,
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
config_entry=entry,
name=DOMAIN,
update_interval=None,
)
self.client = client
self._event_listeners: list[Callable[[DoorEvent], None]] = []
@callback
def async_subscribe_door_events(
self,
event_callback: Callable[[DoorEvent], None],
) -> CALLBACK_TYPE:
"""Subscribe to door events (doorbell, access)."""
def _unsubscribe() -> None:
self._event_listeners.remove(event_callback)
self._event_listeners.append(event_callback)
return _unsubscribe
async def _async_setup(self) -> None:
"""Set up the WebSocket connection for push updates."""
handlers: dict[str, WsMessageHandler] = {
"access.data.device.location_update_v2": self._handle_location_update,
"access.data.v2.location.update": self._handle_v2_location_update,
"access.hw.door_bell": self._handle_doorbell,
"access.logs.insights.add": self._handle_insights_add,
}
self.client.start_websocket(
handlers,
on_connect=self._on_ws_connect,
on_disconnect=self._on_ws_disconnect,
)
async def _async_update_data(self) -> dict[str, Door]:
"""Fetch all doors from the API."""
try:
async with asyncio.timeout(10):
doors = await self.client.get_doors()
except ApiAuthError as err:
raise UpdateFailed(f"Authentication failed: {err}") from err
except ApiConnectionError as err:
raise UpdateFailed(f"Error connecting to API: {err}") from err
except ApiError as err:
raise UpdateFailed(f"Error communicating with API: {err}") from err
return {door.id: door for door in doors}
def _on_ws_connect(self) -> None:
"""Handle WebSocket connection established."""
_LOGGER.debug("WebSocket connected to UniFi Access")
if not self.last_update_success:
self.config_entry.async_create_background_task(
self.hass,
self.async_request_refresh(),
"unifi_access_reconnect_refresh",
)
def _on_ws_disconnect(self) -> None:
"""Handle WebSocket disconnection."""
_LOGGER.debug("WebSocket disconnected from UniFi Access")
self.async_set_update_error(
UpdateFailed("WebSocket disconnected from UniFi Access")
)
async def _handle_location_update(self, msg: WebsocketMessage) -> None:
"""Handle location_update_v2 messages."""
update = cast(LocationUpdateV2, msg)
self._process_door_update(update.data.id, update.data.state)
async def _handle_v2_location_update(self, msg: WebsocketMessage) -> None:
"""Handle V2 location update messages."""
update = cast(V2LocationUpdate, msg)
self._process_door_update(update.data.id, update.data.state)
def _process_door_update(
self, door_id: str, ws_state: LocationUpdateState | V2LocationState | None
) -> None:
"""Process a door state update from WebSocket."""
if self.data is None or door_id not in self.data:
return
if ws_state is None:
return
current_door = self.data[door_id]
updates: dict[str, object] = {}
if ws_state.dps is not None:
updates["door_position_status"] = ws_state.dps
if ws_state.lock == "locked":
updates["door_lock_relay_status"] = "lock"
elif ws_state.lock == "unlocked":
updates["door_lock_relay_status"] = "unlock"
updated_door = current_door.with_updates(**updates)
self.async_set_updated_data({**self.data, door_id: updated_door})
async def _handle_doorbell(self, msg: WebsocketMessage) -> None:
"""Handle doorbell press events."""
doorbell = cast(HwDoorbell, msg)
self._dispatch_door_event(
doorbell.data.door_id,
"doorbell",
"ring",
{},
)
async def _handle_insights_add(self, msg: WebsocketMessage) -> None:
"""Handle access insights events (entry/exit)."""
insights = cast(InsightsAdd, msg)
door = insights.data.metadata.door
if not door.id:
return
event_type = (
"access_granted" if insights.data.result == "ACCESS" else "access_denied"
)
attrs: dict[str, Any] = {}
if insights.data.metadata.actor.display_name:
attrs["actor"] = insights.data.metadata.actor.display_name
if insights.data.metadata.authentication.display_name:
attrs["authentication"] = insights.data.metadata.authentication.display_name
if insights.data.result:
attrs["result"] = insights.data.result
self._dispatch_door_event(door.id, "access", event_type, attrs)
@callback
def _dispatch_door_event(
self,
door_id: str,
category: str,
event_type: str,
event_data: dict[str, Any],
) -> None:
"""Dispatch a door event to all subscribed listeners."""
event = DoorEvent(door_id, category, event_type, event_data)
for listener in self._event_listeners:
listener(event)

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