Compare commits

..

1 Commits

Author SHA1 Message Date
Erik
c6a7fd5b79 Add device_tracker triggers left_zone and entered_zone 2026-04-02 13:41:06 +02:00
920 changed files with 21944 additions and 39019 deletions

View File

@@ -11,9 +11,10 @@
This repository contains the core of Home Assistant, a Python 3 based home automation application.
## Git Commit Guidelines
## Code Review Guidelines
- **Do NOT amend, squash, or rebase commits that have already been pushed to the PR branch after the PR is opened** - Reviewers need to follow the commit history, as well as see what changed since their last review
**Git commit practices during review:**
- **Do NOT amend, squash, or rebase commits after review has started** - Reviewers need to see what changed since their last review
## Development Commands

View File

@@ -14,7 +14,7 @@ env:
UV_HTTP_TIMEOUT: 60
UV_SYSTEM_PYTHON: "true"
# Base image version from https://github.com/home-assistant/docker
BASE_IMAGE_VERSION: "2026.02.0"
BASE_IMAGE_VERSION: "2026.01.0"
ARCHITECTURES: '["amd64", "aarch64"]'
permissions: {}
@@ -47,6 +47,10 @@ jobs:
with:
python-version-file: ".python-version"
- name: Get information
id: info
uses: home-assistant/actions/helpers/info@master # zizmor: ignore[unpinned-uses]
- name: Get version
id: version
uses: home-assistant/actions/helpers/version@master # zizmor: ignore[unpinned-uses]
@@ -108,7 +112,7 @@ jobs:
- name: Download nightly wheels of frontend
if: needs.init.outputs.channel == 'dev'
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
uses: dawidd6/action-download-artifact@8a338493df3d275e4a7a63bcff3b8fe97e51a927 # v19
with:
github_token: ${{secrets.GITHUB_TOKEN}}
repo: home-assistant/frontend
@@ -119,7 +123,7 @@ jobs:
- name: Download nightly wheels of intents
if: needs.init.outputs.channel == 'dev'
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
uses: dawidd6/action-download-artifact@8a338493df3d275e4a7a63bcff3b8fe97e51a927 # v19
with:
github_token: ${{secrets.GITHUB_TOKEN}}
repo: OHF-Voice/intents-package
@@ -338,19 +342,19 @@ jobs:
registry: ["ghcr.io/home-assistant", "docker.io/homeassistant"]
steps:
- name: Install Cosign
uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
uses: sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 # v4.1.0
with:
cosign-release: "v2.5.3"
- name: Login to DockerHub
if: matrix.registry == 'docker.io/homeassistant'
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -499,7 +503,7 @@ jobs:
python -m build
- name: Upload package to PyPI
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
with:
skip-existing: true
@@ -523,7 +527,7 @@ jobs:
persist-credentials: false
- name: Login to GitHub Container Registry
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}

View File

@@ -1392,7 +1392,7 @@ jobs:
pattern: coverage-*
- name: Upload coverage to Codecov
if: needs.info.outputs.test_full_suite == 'true'
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
with:
fail_ci_if_error: true
flags: full-suite
@@ -1563,7 +1563,7 @@ jobs:
pattern: coverage-*
- name: Upload coverage to Codecov
if: needs.info.outputs.test_full_suite == 'false'
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
with:
fail_ci_if_error: true
token: ${{ secrets.CODECOV_TOKEN }} # zizmor: ignore[secrets-outside-env]
@@ -1591,7 +1591,7 @@ jobs:
with:
pattern: test-results-*
- name: Upload test results to Codecov
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
with:
report_type: test_results
fail_ci_if_error: true

View File

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

View File

@@ -87,13 +87,6 @@ repos:
language: script
types: [text]
files: ^(homeassistant/.+/manifest\.json|homeassistant/brands/.+\.json|pyproject\.toml|\.pre-commit-config\.yaml|script/gen_requirements_all\.py)$
- id: gen_copilot_instructions
name: gen_copilot_instructions
entry: script/run-in-env.sh python3 -m script.gen_copilot_instructions
pass_filenames: false
language: script
types: [text]
files: ^(AGENTS\.md|\.claude/skills/(?!github-pr-reviewer/).+/SKILL\.md|\.github/copilot-instructions\.md|script/gen_copilot_instructions\.py)$
- id: hassfest
name: hassfest
entry: script/run-in-env.sh python3 -m script.hassfest

View File

@@ -1 +1 @@
3.14.3
3.14.2

View File

@@ -2,9 +2,10 @@
This repository contains the core of Home Assistant, a Python 3 based home automation application.
## Git Commit Guidelines
## Code Review Guidelines
- **Do NOT amend, squash, or rebase commits that have already been pushed to the PR branch after the PR is opened** - Reviewers need to follow the commit history, as well as see what changed since their last review
**Git commit practices during review:**
- **Do NOT amend, squash, or rebase commits after review has started** - Reviewers need to see what changed since their last review
## Development Commands

15
CODEOWNERS generated
View File

@@ -37,13 +37,6 @@ build.json @home-assistant/supervisor
# Other code
/homeassistant/scripts/check_config.py @kellerza
# Agent Configurations
AGENTS.md @home-assistant/core
CLAUDE.md @home-assistant/core
/.agent/ @home-assistant/core
/.claude/ @home-assistant/core
/.gemini/ @home-assistant/core
# Integrations
/homeassistant/components/abode/ @shred86
/tests/components/abode/ @shred86
@@ -418,8 +411,6 @@ CLAUDE.md @home-assistant/core
/tests/components/dsmr_reader/ @sorted-bits @glodenox @erwindouna
/homeassistant/components/duckdns/ @tr4nt0r
/tests/components/duckdns/ @tr4nt0r
/homeassistant/components/duco/ @ronaldvdmeer
/tests/components/duco/ @ronaldvdmeer
/homeassistant/components/duotecno/ @cereal2nd
/tests/components/duotecno/ @cereal2nd
/homeassistant/components/dwd_weather_warnings/ @runningman84 @stephan192
@@ -1265,8 +1256,8 @@ CLAUDE.md @home-assistant/core
/tests/components/openuv/ @bachya
/homeassistant/components/openweathermap/ @fabaff @freekode @nzapponi @wittypluck
/tests/components/openweathermap/ @fabaff @freekode @nzapponi @wittypluck
/homeassistant/components/opnsense/ @HarlemSquirrel @Snuffy2
/tests/components/opnsense/ @HarlemSquirrel @Snuffy2
/homeassistant/components/opnsense/ @mtreinish
/tests/components/opnsense/ @mtreinish
/homeassistant/components/opower/ @tronikos
/tests/components/opower/ @tronikos
/homeassistant/components/oralb/ @bdraco @Lash-L
@@ -1877,8 +1868,6 @@ CLAUDE.md @home-assistant/core
/tests/components/vicare/ @CFenner
/homeassistant/components/victron_ble/ @rajlaud
/tests/components/victron_ble/ @rajlaud
/homeassistant/components/victron_gx/ @tomer-w
/tests/components/victron_gx/ @tomer-w
/homeassistant/components/victron_remote_monitoring/ @AndyTempel
/tests/components/victron_remote_monitoring/ @AndyTempel
/homeassistant/components/vilfo/ @ManneW

View File

@@ -1,5 +1,5 @@
{
"domain": "victron",
"name": "Victron",
"integrations": ["victron_gx", "victron_ble", "victron_remote_monitoring"]
"integrations": ["victron_ble", "victron_remote_monitoring"]
}

View File

@@ -1,7 +1,11 @@
"""The Actron Air integration."""
from actron_neo_api import ActronAirAPI, ActronAirAPIError, ActronAirAuthError
from actron_neo_api.models.system import ActronAirSystemInfo
from actron_neo_api import (
ActronAirACSystem,
ActronAirAPI,
ActronAirAPIError,
ActronAirAuthError,
)
from homeassistant.const import CONF_API_TOKEN, Platform
from homeassistant.core import HomeAssistant
@@ -21,7 +25,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ActronAirConfigEntry) ->
"""Set up Actron Air integration from a config entry."""
api = ActronAirAPI(refresh_token=entry.data[CONF_API_TOKEN])
systems: list[ActronAirSystemInfo] = []
systems: list[ActronAirACSystem] = []
try:
systems = await api.get_ac_systems()
@@ -32,17 +36,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ActronAirConfigEntry) ->
translation_key="auth_error",
) from err
except ActronAirAPIError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="setup_connection_error",
) from err
raise ConfigEntryNotReady from err
system_coordinators: dict[str, ActronAirSystemCoordinator] = {}
for system in systems:
coordinator = ActronAirSystemCoordinator(hass, entry, api, system)
_LOGGER.debug("Setting up coordinator for system: %s", system.serial)
_LOGGER.debug("Setting up coordinator for system: %s", system["serial"])
await coordinator.async_config_entry_first_refresh()
system_coordinators[system.serial] = coordinator
system_coordinators[system["serial"]] = coordinator
entry.runtime_data = ActronAirRuntimeData(
api=api,

View File

@@ -18,7 +18,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import ActronAirConfigEntry, ActronAirSystemCoordinator
from .entity import ActronAirAcEntity, ActronAirZoneEntity, actron_air_command
from .entity import ActronAirAcEntity, ActronAirZoneEntity, handle_actron_api_errors
PARALLEL_UPDATES = 0
@@ -136,19 +136,19 @@ class ActronSystemClimate(ActronAirAcEntity, ActronAirClimateEntity):
"""Return the target temperature."""
return self._status.user_aircon_settings.temperature_setpoint_cool_c
@actron_air_command
@handle_actron_api_errors
async def async_set_fan_mode(self, fan_mode: str) -> None:
"""Set a new fan mode."""
api_fan_mode = FAN_MODE_MAPPING_HA_TO_ACTRONAIR.get(fan_mode)
await self._status.user_aircon_settings.set_fan_mode(api_fan_mode)
@actron_air_command
@handle_actron_api_errors
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set the HVAC mode."""
ac_mode = HVAC_MODE_MAPPING_HA_TO_ACTRONAIR.get(hvac_mode)
await self._status.ac_system.set_system_mode(ac_mode)
@actron_air_command
@handle_actron_api_errors
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set the temperature."""
temp = kwargs.get(ATTR_TEMPERATURE)
@@ -212,13 +212,13 @@ class ActronZoneClimate(ActronAirZoneEntity, ActronAirClimateEntity):
"""Return the target temperature."""
return self._zone.temperature_setpoint_cool_c
@actron_air_command
@handle_actron_api_errors
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set the HVAC mode."""
is_enabled = hvac_mode != HVACMode.OFF
await self._zone.enable(is_enabled)
@actron_air_command
@handle_actron_api_errors
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set the temperature."""
await self._zone.set_temperature(temperature=kwargs.get(ATTR_TEMPERATURE))

View File

@@ -38,10 +38,10 @@ class ActronAirConfigFlow(ConfigFlow, domain=DOMAIN):
_LOGGER.error("OAuth2 flow failed: %s", err)
return self.async_abort(reason="oauth2_error")
self._device_code = device_code_response.device_code
self._user_code = device_code_response.user_code
self._verification_uri = device_code_response.verification_uri_complete
self._expires_minutes = str(device_code_response.expires_in // 60)
self._device_code = device_code_response["device_code"]
self._user_code = device_code_response["user_code"]
self._verification_uri = device_code_response["verification_uri_complete"]
self._expires_minutes = str(device_code_response["expires_in"] // 60)
async def _wait_for_authorization() -> None:
"""Wait for the user to authorize the device."""

View File

@@ -6,12 +6,12 @@ from dataclasses import dataclass
from datetime import timedelta
from actron_neo_api import (
ActronAirACSystem,
ActronAirAPI,
ActronAirAPIError,
ActronAirAuthError,
ActronAirStatus,
)
from actron_neo_api.models.system import ActronAirSystemInfo
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
@@ -38,7 +38,7 @@ class ActronAirRuntimeData:
type ActronAirConfigEntry = ConfigEntry[ActronAirRuntimeData]
class ActronAirSystemCoordinator(DataUpdateCoordinator[ActronAirStatus]):
class ActronAirSystemCoordinator(DataUpdateCoordinator[ActronAirACSystem]):
"""System coordinator for Actron Air integration."""
def __init__(
@@ -46,7 +46,7 @@ class ActronAirSystemCoordinator(DataUpdateCoordinator[ActronAirStatus]):
hass: HomeAssistant,
entry: ActronAirConfigEntry,
api: ActronAirAPI,
system: ActronAirSystemInfo,
system: ActronAirACSystem,
) -> None:
"""Initialize the coordinator."""
super().__init__(
@@ -57,7 +57,7 @@ class ActronAirSystemCoordinator(DataUpdateCoordinator[ActronAirStatus]):
config_entry=entry,
)
self.system = system
self.serial_number = system.serial
self.serial_number = system["serial"]
self.api = api
self.status = self.api.state_manager.get_status(self.serial_number)
self.last_seen = dt_util.utcnow()

View File

@@ -1,35 +0,0 @@
"""Diagnostics support for Actron Air."""
from __future__ import annotations
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_API_TOKEN
from homeassistant.core import HomeAssistant
from .coordinator import ActronAirConfigEntry
TO_REDACT = {CONF_API_TOKEN, "master_serial", "serial_number", "serial"}
async def async_get_config_entry_diagnostics(
hass: HomeAssistant,
entry: ActronAirConfigEntry,
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
coordinators: dict[int, Any] = {}
for idx, coordinator in enumerate(entry.runtime_data.system_coordinators.values()):
coordinators[idx] = {
"system": async_redact_data(
coordinator.system.model_dump(mode="json"), TO_REDACT
),
"status": async_redact_data(
coordinator.data.model_dump(mode="json", exclude={"last_known_state"}),
TO_REDACT,
),
}
return {
"entry_data": async_redact_data(entry.data, TO_REDACT),
"coordinators": coordinators,
}

View File

@@ -14,14 +14,10 @@ from .const import DOMAIN
from .coordinator import ActronAirSystemCoordinator
def actron_air_command[_EntityT: ActronAirEntity, **_P](
def handle_actron_api_errors[_EntityT: ActronAirEntity, **_P](
func: Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, Any]],
) -> Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, None]]:
"""Decorator for Actron Air API calls.
Handles ActronAirAPIError exceptions, and requests a coordinator update
to update the status of the devices as soon as possible.
"""
"""Decorate Actron Air API calls to handle ActronAirAPIError exceptions."""
@wraps(func)
async def wrapper(self: _EntityT, *args: _P.args, **kwargs: _P.kwargs) -> None:
@@ -34,7 +30,6 @@ def actron_air_command[_EntityT: ActronAirEntity, **_P](
translation_key="api_error",
translation_placeholders={"error": str(err)},
) from err
self.coordinator.async_set_updated_data(self.coordinator.data)
return wrapper

View File

@@ -13,5 +13,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"quality_scale": "silver",
"requirements": ["actron-neo-api==0.5.0"]
"requirements": ["actron-neo-api==0.4.1"]
}

View File

@@ -41,7 +41,7 @@ rules:
# Gold
devices: done
diagnostics: done
diagnostics: todo
discovery-update-info:
status: exempt
comment: This integration uses DHCP discovery, however is cloud polling. Therefore there is no information to update.
@@ -54,11 +54,17 @@ rules:
docs-troubleshooting: done
docs-use-cases: done
dynamic-devices: todo
entity-category: done
entity-device-class: todo
entity-disabled-by-default: todo
entity-category:
status: exempt
comment: This integration does not use entity categories.
entity-device-class:
status: exempt
comment: This integration does not use entity device classes.
entity-disabled-by-default:
status: exempt
comment: Not required for this integration at this stage.
entity-translations: todo
exception-translations: done
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
repair-issues:

View File

@@ -55,9 +55,6 @@
"auth_error": {
"message": "Authentication failed, please reauthenticate"
},
"setup_connection_error": {
"message": "Failed to connect to the Actron Air API"
},
"update_error": {
"message": "An error occurred while retrieving data from the Actron Air API: {error}"
}

View File

@@ -10,7 +10,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import ActronAirConfigEntry, ActronAirSystemCoordinator
from .entity import ActronAirAcEntity, actron_air_command
from .entity import ActronAirAcEntity, handle_actron_api_errors
PARALLEL_UPDATES = 0
@@ -105,12 +105,12 @@ class ActronAirSwitch(ActronAirAcEntity, SwitchEntity):
"""Return true if the switch is on."""
return self.entity_description.is_on_fn(self.coordinator)
@actron_air_command
@handle_actron_api_errors
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the switch on."""
await self.entity_description.set_fn(self.coordinator, True)
@actron_air_command
@handle_actron_api_errors
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the switch off."""
await self.entity_description.set_fn(self.coordinator, False)

View File

@@ -74,8 +74,7 @@ async def _resolve_attachments(
resolved_attachments.append(
conversation.Attachment(
media_content_id=media_content_id,
mime_type=attachment.get("media_content_type")
or image_data.content_type,
mime_type=image_data.content_type,
path=temp_filename,
)
)
@@ -90,7 +89,7 @@ async def _resolve_attachments(
resolved_attachments.append(
conversation.Attachment(
media_content_id=media_content_id,
mime_type=attachment.get("media_content_type") or media.mime_type,
mime_type=media.mime_type,
path=media.path,
)
)

View File

@@ -54,16 +54,7 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
entry.data[CONF_PASSWORD],
entry.data[CONF_LOGIN_DATA],
)
device_registry = dr.async_get(hass)
self.previous_devices: set[str] = {
identifier
for device in device_registry.devices.get_devices_for_config_entry_id(
entry.entry_id
)
if device.entry_type != dr.DeviceEntryType.SERVICE
for identifier_domain, identifier in device.identifiers
if identifier_domain == DOMAIN
}
self.previous_devices: set[str] = set()
async def _async_update_data(self) -> dict[str, AmazonDevice]:
"""Update device data."""

View File

@@ -11,12 +11,12 @@
"user": {
"data": {
"tracked_apps": "Apps",
"tracked_custom_integrations": "Community integrations",
"tracked_custom_integrations": "Custom integrations",
"tracked_integrations": "Integrations"
},
"data_description": {
"tracked_apps": "Select the apps you want to track",
"tracked_custom_integrations": "Select the community integrations you want to track",
"tracked_custom_integrations": "Select the custom integrations you want to track",
"tracked_integrations": "Select the integrations you want to track"
}
}
@@ -31,7 +31,7 @@
"unit_of_measurement": "[%key:component::analytics_insights::entity::sensor::apps::unit_of_measurement%]"
},
"custom_integrations": {
"name": "{custom_integration_domain} (community)",
"name": "{custom_integration_domain} (custom)",
"unit_of_measurement": "[%key:component::analytics_insights::entity::sensor::apps::unit_of_measurement%]"
},
"total_active_installations": {

View File

@@ -92,7 +92,6 @@ class AnglianWaterUpdateCoordinator(DataUpdateCoordinator[None]):
_LOGGER.debug("Updating statistics for the first time")
usage_sum = 0.0
last_stats_time = None
allow_update_last_stored_hour = False
else:
if not meter.readings or len(meter.readings) == 0:
_LOGGER.debug("No recent usage statistics found, skipping update")
@@ -108,7 +107,6 @@ class AnglianWaterUpdateCoordinator(DataUpdateCoordinator[None]):
continue
start = dt_util.as_local(parsed_read_at) - timedelta(hours=1)
_LOGGER.debug("Getting statistics at %s", start)
stats: dict[str, list[Any]] = {}
for end in (start + timedelta(seconds=1), None):
stats = await get_instance(self.hass).async_add_executor_job(
statistics_during_period,
@@ -129,28 +127,15 @@ class AnglianWaterUpdateCoordinator(DataUpdateCoordinator[None]):
"Not found, trying to find oldest statistic after %s",
start,
)
assert stats
if not stats or not stats.get(usage_statistic_id):
_LOGGER.debug(
"Could not find existing statistics during period lookup for %s, "
"falling back to last stored statistic",
usage_statistic_id,
)
allow_update_last_stored_hour = True
last_records = last_stat[usage_statistic_id]
usage_sum = float(last_records[0].get("sum") or 0.0)
last_stats_time = last_records[0]["start"]
else:
allow_update_last_stored_hour = False
records = stats[usage_statistic_id]
def _safe_get_sum(records: list[Any]) -> float:
if records and "sum" in records[0]:
return float(records[0]["sum"])
return 0.0
def _safe_get_sum(records: list[Any]) -> float:
if records and "sum" in records[0]:
return float(records[0]["sum"])
return 0.0
usage_sum = _safe_get_sum(records)
last_stats_time = records[0]["start"]
usage_sum = _safe_get_sum(stats.get(usage_statistic_id, []))
last_stats_time = stats[usage_statistic_id][0]["start"]
usage_statistics = []
@@ -163,13 +148,7 @@ class AnglianWaterUpdateCoordinator(DataUpdateCoordinator[None]):
)
continue
start = dt_util.as_local(parsed_read_at) - timedelta(hours=1)
if last_stats_time is not None and (
start.timestamp() < last_stats_time
or (
start.timestamp() == last_stats_time
and not allow_update_last_stored_hour
)
):
if last_stats_time is not None and start.timestamp() <= last_stats_time:
continue
usage_state = max(0, read["consumption"] / 1000)
usage_sum = max(0, read["read"])

View File

@@ -37,7 +37,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: AnthropicConfigEntry) ->
coordinator = AnthropicCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
LOGGER.debug("Available models: %s", coordinator.data)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
from collections.abc import Mapping
import json
import logging
import re
from typing import TYPE_CHECKING, Any, cast
import anthropic
@@ -47,12 +48,10 @@ from .const import (
CONF_CODE_EXECUTION,
CONF_MAX_TOKENS,
CONF_PROMPT,
CONF_PROMPT_CACHING,
CONF_RECOMMENDED,
CONF_TEMPERATURE,
CONF_THINKING_BUDGET,
CONF_THINKING_EFFORT,
CONF_TOOL_SEARCH,
CONF_WEB_SEARCH,
CONF_WEB_SEARCH_CITY,
CONF_WEB_SEARCH_COUNTRY,
@@ -66,11 +65,8 @@ from .const import (
DOMAIN,
NON_ADAPTIVE_THINKING_MODELS,
NON_THINKING_MODELS,
TOOL_SEARCH_UNSUPPORTED_MODELS,
WEB_SEARCH_UNSUPPORTED_MODELS,
PromptCaching,
)
from .coordinator import model_alias
if TYPE_CHECKING:
from . import AnthropicConfigEntry
@@ -112,13 +108,25 @@ async def get_model_list(client: anthropic.AsyncAnthropic) -> list[SelectOptionD
except anthropic.AnthropicError:
models = []
_LOGGER.debug("Available models: %s", models)
return [
SelectOptionDict(
label=model_info.display_name,
value=model_alias(model_info.id),
model_options: list[SelectOptionDict] = []
short_form = re.compile(r"[^\d]-\d$")
for model_info in models:
# Resolve alias from versioned model name:
model_alias = (
model_info.id[:-9]
if model_info.id != "claude-3-haiku-20240307"
and model_info.id[-2:-1] != "-"
else model_info.id
)
for model_info in models
]
if short_form.search(model_alias):
model_alias += "-0"
model_options.append(
SelectOptionDict(
label=model_info.display_name,
value=model_alias,
)
)
return model_options
class AnthropicConfigFlow(ConfigFlow, domain=DOMAIN):
@@ -348,16 +356,6 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
CONF_TEMPERATURE,
default=DEFAULT[CONF_TEMPERATURE],
): NumberSelector(NumberSelectorConfig(min=0, max=1, step=0.05)),
vol.Optional(
CONF_PROMPT_CACHING,
default=DEFAULT[CONF_PROMPT_CACHING],
): SelectSelector(
SelectSelectorConfig(
options=[x.value for x in PromptCaching],
translation_key=CONF_PROMPT_CACHING,
mode=SelectSelectorMode.DROPDOWN,
)
),
}
if user_input is not None:
@@ -456,16 +454,6 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
self.options.pop(CONF_WEB_SEARCH_COUNTRY, None)
self.options.pop(CONF_WEB_SEARCH_TIMEZONE, None)
if not model.startswith(tuple(TOOL_SEARCH_UNSUPPORTED_MODELS)):
step_schema[
vol.Optional(
CONF_TOOL_SEARCH,
default=DEFAULT[CONF_TOOL_SEARCH],
)
] = bool
else:
self.options.pop(CONF_TOOL_SEARCH, None)
if not step_schema:
user_input = {}

View File

@@ -1,6 +1,5 @@
"""Constants for the Anthropic integration."""
from enum import StrEnum
import logging
DOMAIN = "anthropic"
@@ -14,11 +13,9 @@ CONF_PROMPT = "prompt"
CONF_CHAT_MODEL = "chat_model"
CONF_CODE_EXECUTION = "code_execution"
CONF_MAX_TOKENS = "max_tokens"
CONF_PROMPT_CACHING = "prompt_caching"
CONF_TEMPERATURE = "temperature"
CONF_THINKING_BUDGET = "thinking_budget"
CONF_THINKING_EFFORT = "thinking_effort"
CONF_TOOL_SEARCH = "tool_search"
CONF_WEB_SEARCH = "web_search"
CONF_WEB_SEARCH_USER_LOCATION = "user_location"
CONF_WEB_SEARCH_MAX_USES = "web_search_max_uses"
@@ -27,31 +24,20 @@ CONF_WEB_SEARCH_REGION = "region"
CONF_WEB_SEARCH_COUNTRY = "country"
CONF_WEB_SEARCH_TIMEZONE = "timezone"
class PromptCaching(StrEnum):
"""Prompt caching options."""
OFF = "off"
PROMPT = "prompt"
AUTOMATIC = "automatic"
MIN_THINKING_BUDGET = 1024
DEFAULT = {
CONF_CHAT_MODEL: "claude-haiku-4-5",
CONF_CODE_EXECUTION: False,
CONF_MAX_TOKENS: 3000,
CONF_PROMPT_CACHING: PromptCaching.PROMPT.value,
CONF_TEMPERATURE: 1.0,
CONF_THINKING_BUDGET: MIN_THINKING_BUDGET,
CONF_THINKING_BUDGET: 0,
CONF_THINKING_EFFORT: "low",
CONF_TOOL_SEARCH: False,
CONF_WEB_SEARCH: False,
CONF_WEB_SEARCH_USER_LOCATION: False,
CONF_WEB_SEARCH_MAX_USES: 5,
}
MIN_THINKING_BUDGET = 1024
NON_THINKING_MODELS = [
"claude-3-haiku",
]
@@ -95,11 +81,6 @@ PROGRAMMATIC_TOOL_CALLING_UNSUPPORTED_MODELS = [
"claude-3-haiku",
]
TOOL_SEARCH_UNSUPPORTED_MODELS = [
"claude-3",
"claude-haiku",
]
DEPRECATED_MODELS = [
"claude-3",
]

View File

@@ -2,8 +2,7 @@
from __future__ import annotations
import datetime
import re
from datetime import timedelta
import anthropic
@@ -16,28 +15,13 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
from .const import DOMAIN, LOGGER
UPDATE_INTERVAL_CONNECTED = datetime.timedelta(hours=12)
UPDATE_INTERVAL_DISCONNECTED = datetime.timedelta(minutes=1)
UPDATE_INTERVAL_CONNECTED = timedelta(hours=12)
UPDATE_INTERVAL_DISCONNECTED = timedelta(minutes=1)
type AnthropicConfigEntry = ConfigEntry[AnthropicCoordinator]
_model_short_form = re.compile(r"[^\d]-\d$")
@callback
def model_alias(model_id: str) -> str:
"""Resolve alias from versioned model name."""
if model_id == "claude-3-haiku-20240307" or model_id.endswith("-preview"):
return model_id
if model_id[-2:-1] != "-":
model_id = model_id[:-9]
if _model_short_form.search(model_id):
return model_id + "-0"
return model_id
class AnthropicCoordinator(DataUpdateCoordinator[list[anthropic.types.ModelInfo]]):
class AnthropicCoordinator(DataUpdateCoordinator[None]):
"""DataUpdateCoordinator which uses different intervals after successful and unsuccessful updates."""
client: anthropic.AsyncAnthropic
@@ -58,16 +42,16 @@ class AnthropicCoordinator(DataUpdateCoordinator[list[anthropic.types.ModelInfo]
)
@callback
def async_set_updated_data(self, data: list[anthropic.types.ModelInfo]) -> None:
def async_set_updated_data(self, data: None) -> None:
"""Manually update data, notify listeners and update refresh interval."""
self.update_interval = UPDATE_INTERVAL_CONNECTED
super().async_set_updated_data(data)
async def async_update_data(self) -> list[anthropic.types.ModelInfo]:
async def async_update_data(self) -> None:
"""Fetch data from the API."""
try:
self.update_interval = UPDATE_INTERVAL_DISCONNECTED
result = await self.client.models.list(timeout=10.0)
await self.client.models.list(timeout=10.0)
self.update_interval = UPDATE_INTERVAL_CONNECTED
except anthropic.APITimeoutError as err:
raise TimeoutError(err.message or str(err)) from err
@@ -83,7 +67,6 @@ class AnthropicCoordinator(DataUpdateCoordinator[list[anthropic.types.ModelInfo]
translation_key="api_error",
translation_placeholders={"message": err.message},
) from err
return result.data
def mark_connection_error(self) -> None:
"""Mark the connection as having an error and reschedule background check."""
@@ -93,23 +76,3 @@ class AnthropicCoordinator(DataUpdateCoordinator[list[anthropic.types.ModelInfo]
self.async_update_listeners()
if self._listeners and not self.hass.is_stopping:
self._schedule_refresh()
@callback
def get_model_info(self, model_id: str) -> anthropic.types.ModelInfo:
"""Get model info for a given model ID."""
# First try: exact name match
for model in self.data or []:
if model.id == model_id:
return model
# Second try: match by alias
alias = model_alias(model_id)
for model in self.data or []:
if model_alias(model.id) == alias:
return model
# Model not found, return safe defaults
return anthropic.types.ModelInfo(
type="model",
id=model_id,
created_at=datetime.datetime(1970, 1, 1, tzinfo=datetime.UTC),
display_name=model_id,
)

View File

@@ -58,8 +58,6 @@ from anthropic.types import (
ToolChoiceAutoParam,
ToolChoiceToolParam,
ToolParam,
ToolSearchToolBm25_20251119Param,
ToolSearchToolResultBlock,
ToolUnionParam,
ToolUseBlock,
ToolUseBlockParam,
@@ -76,9 +74,6 @@ from anthropic.types.message_create_params import MessageCreateParamsStreaming
from anthropic.types.text_editor_code_execution_tool_result_block_param import (
Content as TextEditorCodeExecutionToolResultBlockParamContentParam,
)
from anthropic.types.tool_search_tool_result_block_param import (
Content as ToolSearchToolResultBlockParamContentParam,
)
import voluptuous as vol
from voluptuous_openapi import convert
@@ -96,11 +91,9 @@ from .const import (
CONF_CHAT_MODEL,
CONF_CODE_EXECUTION,
CONF_MAX_TOKENS,
CONF_PROMPT_CACHING,
CONF_TEMPERATURE,
CONF_THINKING_BUDGET,
CONF_THINKING_EFFORT,
CONF_TOOL_SEARCH,
CONF_WEB_SEARCH,
CONF_WEB_SEARCH_CITY,
CONF_WEB_SEARCH_COUNTRY,
@@ -116,7 +109,6 @@ from .const import (
NON_THINKING_MODELS,
PROGRAMMATIC_TOOL_CALLING_UNSUPPORTED_MODELS,
UNSUPPORTED_STRUCTURED_OUTPUT_MODELS,
PromptCaching,
)
from .coordinator import AnthropicConfigEntry, AnthropicCoordinator
@@ -210,7 +202,7 @@ class ContentDetails:
]
def _convert_content( # noqa: C901
def _convert_content(
chat_content: Iterable[conversation.Content],
) -> tuple[list[MessageParam], str | None]:
"""Transform HA chat_log content into Anthropic API format."""
@@ -263,15 +255,6 @@ def _convert_content( # noqa: C901
content.tool_result,
),
}
elif content.tool_name == "tool_search":
tool_result_block = {
"type": "tool_search_tool_result",
"tool_use_id": content.tool_call_id,
"content": cast(
ToolSearchToolResultBlockParamContentParam,
content.tool_result,
),
}
else:
tool_result_block = {
"type": "tool_result",
@@ -402,7 +385,6 @@ def _convert_content( # noqa: C901
"code_execution",
"bash_code_execution",
"text_editor_code_execution",
"tool_search_tool_bm25",
],
tool_call.tool_name,
),
@@ -415,7 +397,6 @@ def _convert_content( # noqa: C901
"code_execution",
"bash_code_execution",
"text_editor_code_execution",
"tool_search_tool_bm25",
]
else ToolUseBlockParam(
type="tool_use",
@@ -577,7 +558,6 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
CodeExecutionToolResultBlock,
BashCodeExecutionToolResultBlock,
TextEditorCodeExecutionToolResultBlock,
ToolSearchToolResultBlock,
),
):
if content_details:
@@ -689,21 +669,16 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
super().__init__(entry.runtime_data)
self.entry = entry
self.subentry = subentry
coordinator = entry.runtime_data
self.model_info = coordinator.get_model_info(
subentry.data.get(CONF_CHAT_MODEL, DEFAULT[CONF_CHAT_MODEL])
)
self._attr_unique_id = subentry.subentry_id
self._attr_device_info = dr.DeviceInfo(
identifiers={(DOMAIN, subentry.subentry_id)},
name=subentry.title,
manufacturer="Anthropic",
model=self.model_info.display_name,
model_id=self.model_info.id,
model=subentry.data.get(CONF_CHAT_MODEL, DEFAULT[CONF_CHAT_MODEL]),
entry_type=dr.DeviceEntryType.SERVICE,
)
async def _async_handle_chat_log( # noqa: C901
async def _async_handle_chat_log(
self,
chat_log: conversation.ChatLog,
structure_name: str | None = None,
@@ -713,20 +688,21 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
"""Generate an answer for the chat log."""
options = self.subentry.data
preloaded_tools = [
"HassTurnOn",
"HassTurnOff",
"GetLiveContext",
"code_execution",
"web_search",
]
system = chat_log.content[0]
if not isinstance(system, conversation.SystemContent):
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="system_message_not_found"
)
# System prompt with caching enabled
system_prompt: list[TextBlockParam] = [
TextBlockParam(
type="text",
text=system.content,
cache_control={"type": "ephemeral"},
)
]
messages, container_id = _convert_content(chat_log.content[1:])
model = options.get(CONF_CHAT_MODEL, DEFAULT[CONF_CHAT_MODEL])
@@ -735,28 +711,11 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
model=model,
messages=messages,
max_tokens=options.get(CONF_MAX_TOKENS, DEFAULT[CONF_MAX_TOKENS]),
system=system.content,
system=system_prompt,
stream=True,
container=container_id,
)
if (
options.get(CONF_PROMPT_CACHING, DEFAULT[CONF_PROMPT_CACHING])
== PromptCaching.PROMPT
):
model_args["system"] = [
{
"type": "text",
"text": system.content,
"cache_control": {"type": "ephemeral"},
}
]
elif (
options.get(CONF_PROMPT_CACHING, DEFAULT[CONF_PROMPT_CACHING])
== PromptCaching.AUTOMATIC
):
model_args["cache_control"] = {"type": "ephemeral"}
if not model.startswith(tuple(NON_ADAPTIVE_THINKING_MODELS)):
thinking_effort = options.get(
CONF_THINKING_EFFORT, DEFAULT[CONF_THINKING_EFFORT]
@@ -915,23 +874,8 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
),
)
)
preloaded_tools.append(structure_name)
if tools:
if (
options.get(CONF_TOOL_SEARCH, DEFAULT[CONF_TOOL_SEARCH])
and len(tools) > len(preloaded_tools) + 1
):
for tool in tools:
if not tool["name"].endswith(tuple(preloaded_tools)):
tool["defer_loading"] = True
tools.append(
ToolSearchToolBm25_20251119Param(
type="tool_search_tool_bm25_20251119",
name="tool_search_tool_bm25",
)
)
model_args["tools"] = tools
coordinator = self.entry.runtime_data
@@ -974,8 +918,7 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
) from err
except anthropic.AnthropicError as err:
# Non-connection error, mark connection as healthy
coordinator.async_set_updated_data(coordinator.data)
LOGGER.error("Error while talking to Anthropic: %s", err)
coordinator.async_set_updated_data(None)
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="api_error",
@@ -987,7 +930,7 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
) from err
if not chat_log.unresponded_tool_results:
coordinator.async_set_updated_data(coordinator.data)
coordinator.async_set_updated_data(None)
break

View File

@@ -8,6 +8,6 @@
"documentation": "https://www.home-assistant.io/integrations/anthropic",
"integration_type": "service",
"iot_class": "cloud_polling",
"quality_scale": "silver",
"requirements": ["anthropic==0.92.0"]
"quality_scale": "bronze",
"requirements": ["anthropic==0.83.0"]
}

View File

@@ -81,10 +81,7 @@ rules:
status: exempt
comment: |
No entities disabled by default.
entity-translations:
status: exempt
comment: |
Entities explicitly set `_attr_name` to `None`, so entity name translations are not used.
entity-translations: todo
exception-translations: done
icon-translations: done
reconfiguration-flow: done

View File

@@ -47,13 +47,11 @@
"data": {
"chat_model": "[%key:common::generic::model%]",
"max_tokens": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data::max_tokens%]",
"prompt_caching": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data::prompt_caching%]",
"temperature": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data::temperature%]"
},
"data_description": {
"chat_model": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data_description::chat_model%]",
"max_tokens": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data_description::max_tokens%]",
"prompt_caching": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data_description::prompt_caching%]",
"temperature": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data_description::temperature%]"
},
"title": "[%key:component::anthropic::config_subentries::conversation::step::advanced::title%]"
@@ -74,7 +72,6 @@
"code_execution": "[%key:component::anthropic::config_subentries::conversation::step::model::data::code_execution%]",
"thinking_budget": "[%key:component::anthropic::config_subentries::conversation::step::model::data::thinking_budget%]",
"thinking_effort": "[%key:component::anthropic::config_subentries::conversation::step::model::data::thinking_effort%]",
"tool_search": "[%key:component::anthropic::config_subentries::conversation::step::model::data::tool_search%]",
"user_location": "[%key:component::anthropic::config_subentries::conversation::step::model::data::user_location%]",
"web_search": "[%key:component::anthropic::config_subentries::conversation::step::model::data::web_search%]",
"web_search_max_uses": "[%key:component::anthropic::config_subentries::conversation::step::model::data::web_search_max_uses%]"
@@ -83,7 +80,6 @@
"code_execution": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::code_execution%]",
"thinking_budget": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::thinking_budget%]",
"thinking_effort": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::thinking_effort%]",
"tool_search": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::tool_search%]",
"user_location": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::user_location%]",
"web_search": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::web_search%]",
"web_search_max_uses": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::web_search_max_uses%]"
@@ -107,13 +103,11 @@
"data": {
"chat_model": "[%key:common::generic::model%]",
"max_tokens": "Maximum tokens to return in response",
"prompt_caching": "Caching strategy",
"temperature": "Temperature"
},
"data_description": {
"chat_model": "The model to serve the responses.",
"max_tokens": "Limit the number of response tokens.",
"prompt_caching": "Optimize your API cost and response times based on your usage.",
"temperature": "Control the randomness of the response, trading off between creativity and coherence."
},
"title": "Advanced settings"
@@ -138,7 +132,6 @@
"code_execution": "Code execution",
"thinking_budget": "Thinking budget",
"thinking_effort": "Thinking effort",
"tool_search": "Enable tool search tool",
"user_location": "Include home location",
"web_search": "Enable web search",
"web_search_max_uses": "Maximum web searches"
@@ -147,7 +140,6 @@
"code_execution": "Allow the model to execute code in a secure sandbox environment, enabling it to analyze data and perform complex calculations.",
"thinking_budget": "The number of tokens the model can use to think about the response out of the total maximum number of tokens. Set to 1024 or greater to enable extended thinking.",
"thinking_effort": "Control how many tokens Claude uses when responding, trading off between response thoroughness and token efficiency",
"tool_search": "Enable dynamic tool discovery instead of preloading all tools into the context",
"user_location": "Localize search results based on home location",
"web_search": "The web search tool gives Claude direct access to real-time web content, allowing it to answer questions with up-to-date information beyond its knowledge cutoff",
"web_search_max_uses": "Limit the number of searches performed per response"
@@ -218,13 +210,6 @@
}
},
"selector": {
"prompt_caching": {
"options": {
"automatic": "Full",
"off": "Disabled",
"prompt": "System prompt"
}
},
"thinking_effort": {
"options": {
"high": "[%key:common::state::high%]",

View File

@@ -7,7 +7,7 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["arcam"],
"requirements": ["arcam-fmj==1.8.3"],
"requirements": ["arcam-fmj==1.8.2"],
"ssdp": [
{
"deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1",

View File

@@ -91,7 +91,6 @@ SENSORS: tuple[ArcamFmjSensorEntityDescription, ...] = (
value_fn=lambda state: (
vp.colorspace.name.lower()
if (vp := state.get_incoming_video_parameters()) is not None
and vp.colorspace is not None
else None
),
),

View File

@@ -75,7 +75,7 @@
},
"services": {
"announce": {
"description": "Lets an Assist satellite announce a message.",
"description": "Lets a satellite announce a message.",
"fields": {
"media_id": {
"description": "The media ID to announce instead of using text-to-speech.",
@@ -94,10 +94,10 @@
"name": "Preannounce media ID"
}
},
"name": "Announce on satellite"
"name": "Announce"
},
"ask_question": {
"description": "Lets an Assist satellite ask a question and get the user's response.",
"description": "Asks a question and gets the user's response.",
"fields": {
"answers": {
"description": "Possible answers to the question.",
@@ -124,10 +124,10 @@
"name": "Question media ID"
}
},
"name": "Ask question on satellite"
"name": "Ask question"
},
"start_conversation": {
"description": "Starts a conversation from an Assist satellite.",
"description": "Starts a conversation from a satellite.",
"fields": {
"extra_system_prompt": {
"description": "Provide background information to the AI about the request.",
@@ -150,13 +150,13 @@
"name": "Message"
}
},
"name": "Start conversation on satellite"
"name": "Start conversation"
}
},
"title": "Assist satellite",
"triggers": {
"idle": {
"description": "Triggers after one or more Assist satellites become idle after having processed a command.",
"description": "Triggers after one or more voice assistant satellites become idle after having processed a command.",
"fields": {
"behavior": {
"name": "[%key:component::assist_satellite::common::trigger_behavior_name%]"
@@ -165,7 +165,7 @@
"name": "Satellite became idle"
},
"listening": {
"description": "Triggers after one or more Assist satellites start listening for a command from someone.",
"description": "Triggers after one or more voice assistant satellites start listening for a command from someone.",
"fields": {
"behavior": {
"name": "[%key:component::assist_satellite::common::trigger_behavior_name%]"
@@ -174,7 +174,7 @@
"name": "Satellite started listening"
},
"processing": {
"description": "Triggers after one or more Assist satellites start processing a command after having heard it.",
"description": "Triggers after one or more voice assistant satellites start processing a command after having heard it.",
"fields": {
"behavior": {
"name": "[%key:component::assist_satellite::common::trigger_behavior_name%]"
@@ -183,7 +183,7 @@
"name": "Satellite started processing"
},
"responding": {
"description": "Triggers after one or more Assist satellites start responding to a command after having processed it, or start announcing something.",
"description": "Triggers after one or more voice assistant satellites start responding to a command after having processed it, or start announcing something.",
"fields": {
"behavior": {
"name": "[%key:component::assist_satellite::common::trigger_behavior_name%]"

View File

@@ -7,9 +7,9 @@ import logging
from typing import TYPE_CHECKING, Any
from aurorapy.client import AuroraError, AuroraSerialClient
import serial.tools.list_ports
import voluptuous as vol
from homeassistant.components import usb
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import ATTR_SERIAL_NUMBER, CONF_ADDRESS, CONF_PORT
from homeassistant.core import HomeAssistant
@@ -57,11 +57,9 @@ def validate_and_connect(
return ret
async def async_scan_comports(
hass: HomeAssistant,
) -> tuple[list[str] | None, str | None]:
def scan_comports() -> tuple[list[str] | None, str | None]:
"""Find and store available com ports for the GUI dropdown."""
com_ports = await usb.async_scan_serial_ports(hass)
com_ports = serial.tools.list_ports.comports(include_links=True)
com_ports_list = []
for port in com_ports:
com_ports_list.append(port.device)
@@ -89,7 +87,7 @@ class AuroraABBConfigFlow(ConfigFlow, domain=DOMAIN):
errors = {}
if self._com_ports_list is None:
result = await async_scan_comports(self.hass)
result = await self.hass.async_add_executor_job(scan_comports)
self._com_ports_list, self._default_com_port = result
if self._default_com_port is None:
return self.async_abort(reason="no_serial_ports")

View File

@@ -3,7 +3,6 @@
"name": "Aurora ABB PowerOne Solar PV",
"codeowners": ["@davet2001"],
"config_flow": true,
"dependencies": ["usb"],
"documentation": "https://www.home-assistant.io/integrations/aurora_abb_powerone",
"integration_type": "device",
"iot_class": "local_polling",

View File

@@ -8,7 +8,7 @@ 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 ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_create_clientsession
from .const import DEFAULT_HOST
@@ -31,7 +31,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: AutoskopeConfigEntry) ->
try:
await api.connect()
except InvalidAuth as err:
raise ConfigEntryAuthFailed(
# Raise ConfigEntryError until reauth flow is implemented (then ConfigEntryAuthFailed)
raise ConfigEntryError(
"Authentication failed, please check credentials"
) from err
except CannotConnect as err:

View File

@@ -2,7 +2,6 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from autoskope_client.api import AutoskopeApi
@@ -40,39 +39,12 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
}
)
STEP_REAUTH_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_PASSWORD): TextSelector(
TextSelectorConfig(type=TextSelectorType.PASSWORD)
),
}
)
class AutoskopeConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Autoskope."""
VERSION = 1
async def _async_validate_credentials(
self, host: str, username: str, password: str, errors: dict[str, str]
) -> bool:
"""Validate credentials against the Autoskope API."""
try:
async with AutoskopeApi(
host=host,
username=username,
password=password,
):
pass
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
errors["base"] = "invalid_auth"
else:
return True
return False
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -91,9 +63,18 @@ class AutoskopeConfigFlow(ConfigFlow, domain=DOMAIN):
await self.async_set_unique_id(f"{username}@{host}")
self._abort_if_unique_id_configured()
if await self._async_validate_credentials(
host, username, user_input[CONF_PASSWORD], errors
):
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={
@@ -106,35 +87,3 @@ class AutoskopeConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle initiation of re-authentication."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle re-authentication with new credentials."""
errors: dict[str, str] = {}
if user_input is not None:
reauth_entry = self._get_reauth_entry()
if await self._async_validate_credentials(
reauth_entry.data[CONF_HOST],
reauth_entry.data[CONF_USERNAME],
user_input[CONF_PASSWORD],
errors,
):
return self.async_update_reload_and_abort(
reauth_entry,
data_updates={CONF_PASSWORD: user_input[CONF_PASSWORD]},
)
return self.async_show_form(
step_id="reauth_confirm",
data_schema=STEP_REAUTH_DATA_SCHEMA,
errors=errors,
)

View File

@@ -39,7 +39,10 @@ rules:
integration-owner: done
log-when-unavailable: todo
parallel-updates: done
reauthentication-flow: done
reauthentication-flow:
status: todo
comment: |
Reauthentication flow removed for initial PR, will be added in follow-up.
test-coverage: done
# Gold
devices: done

View File

@@ -1,8 +1,7 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
@@ -11,15 +10,6 @@
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"reauth_confirm": {
"data": {
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"password": "The new password for your Autoskope account."
},
"description": "Please re-enter your password for your Autoskope account."
},
"user": {
"data": {
"password": "[%key:common::config_flow::data::password%]",

View File

@@ -29,7 +29,7 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["axis"],
"requirements": ["axis==68"],
"requirements": ["axis==67"],
"ssdp": [
{
"manufacturer": "AXIS"

View File

@@ -74,12 +74,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: BackblazeConfigEntry) ->
translation_domain=DOMAIN,
translation_key="invalid_bucket_name",
) from err
except exception.BadRequest as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="bad_request",
translation_placeholders={"error_message": str(err)},
) from err
except (
exception.B2ConnectionError,
exception.B2RequestTimeout,

View File

@@ -174,14 +174,6 @@ class BackblazeConfigFlow(ConfigFlow, domain=DOMAIN):
"Backblaze B2 bucket '%s' does not exist", user_input[CONF_BUCKET]
)
errors[CONF_BUCKET] = "invalid_bucket_name"
except exception.BadRequest as err:
_LOGGER.error(
"Backblaze B2 API rejected the request for Key ID '%s': %s",
user_input[CONF_KEY_ID],
err,
)
errors["base"] = "bad_request"
placeholders["error_message"] = str(err)
except (
exception.B2ConnectionError,
exception.B2RequestTimeout,

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_push",
"loggers": ["b2sdk"],
"quality_scale": "bronze",
"requirements": ["b2sdk==2.10.4"]
"requirements": ["b2sdk==2.10.1"]
}

View File

@@ -6,7 +6,6 @@
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
},
"error": {
"bad_request": "The Backblaze B2 API rejected the request: {error_message}",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_bucket_name": "[%key:component::backblaze_b2::exceptions::invalid_bucket_name::message%]",
"invalid_capability": "[%key:component::backblaze_b2::exceptions::invalid_capability::message%]",
@@ -61,9 +60,6 @@
}
},
"exceptions": {
"bad_request": {
"message": "The Backblaze B2 API rejected the request: {error_message}"
},
"cannot_connect": {
"message": "Cannot connect to endpoint"
},

View File

@@ -23,7 +23,7 @@ from . import util
from .agent import BackupAgent
from .const import DATA_MANAGER
from .manager import BackupManager
from .models import AgentBackup, BackupNotFound, InvalidBackupFilename
from .models import AgentBackup, BackupNotFound
@callback
@@ -195,11 +195,6 @@ class UploadBackupView(HomeAssistantView):
backup_id = await manager.async_receive_backup(
contents=contents, agent_ids=agent_ids
)
except InvalidBackupFilename as err:
return Response(
body=str(err),
status=HTTPStatus.BAD_REQUEST,
)
except OSError as err:
return Response(
body=f"Can't write backup file: {err}",

View File

@@ -68,7 +68,6 @@ from .models import (
BackupReaderWriterError,
BaseBackup,
Folder,
InvalidBackupFilename,
)
from .store import BackupStore
from .util import (
@@ -1007,14 +1006,6 @@ class BackupManager:
) -> str:
"""Receive and store a backup file from upload."""
contents.chunk_size = BUF_SIZE
suggested_filename = contents.filename or "backup.tar"
safe_filename = PureWindowsPath(suggested_filename).name
if (
not safe_filename
or safe_filename != suggested_filename
or safe_filename == ".."
):
raise InvalidBackupFilename(f"Invalid filename: {suggested_filename}")
self.async_on_backup_event(
ReceiveBackupEvent(
reason=None,
@@ -1025,7 +1016,7 @@ class BackupManager:
written_backup = await self._reader_writer.async_receive_backup(
agent_ids=agent_ids,
stream=contents,
suggested_filename=suggested_filename,
suggested_filename=contents.filename or "backup.tar",
)
self.async_on_backup_event(
ReceiveBackupEvent(
@@ -1966,7 +1957,10 @@ class CoreBackupReaderWriter(BackupReaderWriter):
suggested_filename: str,
) -> WrittenBackup:
"""Receive a backup."""
temp_file = Path(self.temp_backup_dir, suggested_filename)
safe_filename = PureWindowsPath(suggested_filename).name
if not safe_filename or safe_filename == "..":
safe_filename = "backup.tar"
temp_file = Path(self.temp_backup_dir, safe_filename)
async_add_executor_job = self._hass.async_add_executor_job
await async_add_executor_job(make_backup_dir, self.temp_backup_dir)

View File

@@ -8,6 +8,6 @@
"integration_type": "service",
"iot_class": "calculated",
"quality_scale": "internal",
"requirements": ["cronsim==2.7", "securetar==2026.4.1"],
"requirements": ["cronsim==2.7", "securetar==2026.2.0"],
"single_config_entry": true
}

View File

@@ -95,12 +95,6 @@ class BackupReaderWriterError(BackupError):
error_code = "backup_reader_writer_error"
class InvalidBackupFilename(BackupManagerError):
"""Raised when a backup filename is invalid."""
error_code = "invalid_backup_filename"
class BackupNotFound(BackupAgentError, BackupManagerError):
"""Raised when a backup is not found."""

View File

@@ -22,7 +22,6 @@ from securetar import (
SecureTarFile,
SecureTarReadError,
SecureTarRootKeyContext,
get_archive_max_ciphertext_size,
)
from homeassistant.core import HomeAssistant
@@ -384,12 +383,9 @@ def _encrypt_backup(
if prefix not in expected_archives:
LOGGER.debug("Unknown inner tar file %s will not be encrypted", obj.name)
continue
if (fileobj := input_tar.extractfile(obj)) is None:
LOGGER.debug(
"Non regular inner tar file %s will not be encrypted", obj.name
)
continue
output_archive.import_tar(fileobj, obj, derived_key_id=inner_tar_idx)
output_archive.import_tar(
input_tar.extractfile(obj), obj, derived_key_id=inner_tar_idx
)
inner_tar_idx += 1
@@ -423,7 +419,7 @@ class _CipherBackupStreamer:
hass: HomeAssistant,
backup: AgentBackup,
open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
password: str,
password: str | None,
) -> None:
"""Initialize."""
self._workers: list[_CipherWorkerStatus] = []
@@ -435,9 +431,7 @@ class _CipherBackupStreamer:
def size(self) -> int:
"""Return the maximum size of the decrypted or encrypted backup."""
return get_archive_max_ciphertext_size(
self._backup.size, SECURETAR_CREATE_VERSION, self._num_tar_files()
)
return self._backup.size + self._num_tar_files() * tarfile.RECORDSIZE
def _num_tar_files(self) -> int:
"""Return the number of inner tar files."""

View File

@@ -20,7 +20,7 @@
"bluetooth-adapters==2.1.0",
"bluetooth-auto-recovery==1.5.3",
"bluetooth-data-tools==1.28.4",
"dbus-fast==4.0.4",
"habluetooth==6.0.0"
"dbus-fast==3.1.2",
"habluetooth==5.11.1"
]
}

View File

@@ -1,7 +1,7 @@
{
"issues": {
"integration_removed": {
"description": "The BMW Connected Drive integration has been removed from Home Assistant.\n\nIn September 2025, BMW blocked third-party access to their servers by adding additional security measures. For EU-registered cars, a [community integration]({custom_component_url}) using BMW's CarData API is available as an alternative.\n\nTo resolve this issue, please remove the (now defunct) integration entries from your Home Assistant setup. [Click here to see your existing BMW Connected Drive integration entries]({entries}).",
"description": "The BMW Connected Drive integration has been removed from Home Assistant.\n\nIn September 2025, BMW blocked third-party access to their servers by adding additional security measures. For EU-registered cars, a community-developed [custom component]({custom_component_url}) using BMW's CarData API is available as an alternative.\n\nTo resolve this issue, please remove the (now defunct) integration entries from your Home Assistant setup. [Click here to see your existing BMW Connected Drive integration entries]({entries}).",
"title": "The BMW Connected Drive integration has been removed"
}
}

View File

@@ -9,7 +9,7 @@
"iot_class": "local_polling",
"loggers": ["brother", "pyasn1", "pysmi", "pysnmp"],
"quality_scale": "platinum",
"requirements": ["brother==6.1.0"],
"requirements": ["brother==6.0.0"],
"zeroconf": [
{
"name": "brother*",

View File

@@ -10,7 +10,6 @@ from bsblan import (
BSBLAN,
BSBLANAuthError,
BSBLANConnectionError,
BSBLANError,
HotWaterConfig,
HotWaterSchedule,
HotWaterState,
@@ -51,7 +50,7 @@ class BSBLanFastData:
state: State
sensor: Sensor
dhw: HotWaterState | None = None
dhw: HotWaterState
@dataclass
@@ -112,6 +111,7 @@ class BSBLanFastCoordinator(BSBLanCoordinator[BSBLanFastData]):
# This reduces response time significantly (~0.2s per parameter)
state = await self.client.state(include=STATE_INCLUDE)
sensor = await self.client.sensor(include=SENSOR_INCLUDE)
dhw = await self.client.hot_water_state(include=DHW_STATE_INCLUDE)
except BSBLANAuthError as err:
raise ConfigEntryAuthFailed(
@@ -126,19 +126,6 @@ class BSBLanFastCoordinator(BSBLanCoordinator[BSBLanFastData]):
translation_placeholders={"host": host},
) from err
# Fetch DHW state separately - device may not support hot water
dhw: HotWaterState | None = None
try:
dhw = await self.client.hot_water_state(include=DHW_STATE_INCLUDE)
except BSBLANError:
# Preserve last known DHW state if available (entity may depend on it)
if self.data:
dhw = self.data.dhw
LOGGER.debug(
"DHW (Domestic Hot Water) state not available on device at %s",
self.config_entry.data[CONF_HOST],
)
return BSBLanFastData(
state=state,
sensor=sensor,
@@ -172,6 +159,13 @@ class BSBLanSlowCoordinator(BSBLanCoordinator[BSBLanSlowData]):
dhw_config = await self.client.hot_water_config(include=DHW_CONFIG_INCLUDE)
dhw_schedule = await self.client.hot_water_schedule()
except AttributeError:
# Device does not support DHW functionality
LOGGER.debug(
"DHW (Domestic Hot Water) not available on device at %s",
self.config_entry.data[CONF_HOST],
)
return BSBLanSlowData()
except (BSBLANConnectionError, BSBLANAuthError) as err:
# If config update fails, keep existing data
LOGGER.debug(
@@ -183,13 +177,6 @@ class BSBLanSlowCoordinator(BSBLanCoordinator[BSBLanSlowData]):
return self.data
# First fetch failed, return empty data
return BSBLanSlowData()
except BSBLANError, AttributeError:
# Device does not support DHW functionality
LOGGER.debug(
"DHW (Domestic Hot Water) not available on device at %s",
self.config_entry.data[CONF_HOST],
)
return BSBLanSlowData()
return BSBLanSlowData(
dhw_config=dhw_config,

View File

@@ -22,9 +22,7 @@ async def async_get_config_entry_diagnostics(
"fast_coordinator_data": {
"state": data.fast_coordinator.data.state.model_dump(),
"sensor": data.fast_coordinator.data.sensor.model_dump(),
"dhw": data.fast_coordinator.data.dhw.model_dump()
if data.fast_coordinator.data.dhw
else None,
"dhw": data.fast_coordinator.data.dhw.model_dump(),
},
"static": data.static.model_dump() if data.static is not None else None,
}

View File

@@ -2,9 +2,6 @@
from __future__ import annotations
from yarl import URL
from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.helpers.device_registry import (
CONNECTION_NETWORK_MAC,
DeviceInfo,
@@ -13,7 +10,7 @@ from homeassistant.helpers.device_registry import (
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import BSBLanData
from .const import DEFAULT_PORT, DOMAIN
from .const import DOMAIN
from .coordinator import BSBLanCoordinator, BSBLanFastCoordinator, BSBLanSlowCoordinator
@@ -25,8 +22,7 @@ class BSBLanEntityBase[_T: BSBLanCoordinator](CoordinatorEntity[_T]):
def __init__(self, coordinator: _T, data: BSBLanData) -> None:
"""Initialize BSBLan entity with device info."""
super().__init__(coordinator)
host = coordinator.config_entry.data[CONF_HOST]
port = coordinator.config_entry.data.get(CONF_PORT, DEFAULT_PORT)
host = coordinator.config_entry.data["host"]
mac = data.device.MAC
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, mac)},
@@ -48,7 +44,7 @@ class BSBLanEntityBase[_T: BSBLanCoordinator](CoordinatorEntity[_T]):
else None
),
sw_version=data.device.version,
configuration_url=str(URL.build(scheme="http", host=host, port=port)),
configuration_url=f"http://{host}",
)

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
from typing import Any
from bsblan import BSBLANError, HotWaterState, SetHotWaterParam
from bsblan import BSBLANError, SetHotWaterParam
from homeassistant.components.water_heater import (
STATE_ECO,
@@ -46,10 +46,8 @@ async def async_setup_entry(
data = entry.runtime_data
# Only create water heater entity if DHW (Domestic Hot Water) is available
# Check if we have any DHW-related data indicating water heater support
dhw_data = data.fast_coordinator.data.dhw
if dhw_data is None:
# Device does not support DHW, skip water heater setup
return
if (
dhw_data.operating_mode is None
and dhw_data.nominal_setpoint is None
@@ -109,21 +107,11 @@ class BSBLANWaterHeater(BSBLanDualCoordinatorEntity, WaterHeaterEntity):
else:
self._attr_max_temp = 65.0 # Default maximum
@property
def _dhw(self) -> HotWaterState:
"""Return DHW state data.
This entity is only created when DHW data is available.
"""
dhw = self.coordinator.data.dhw
assert dhw is not None
return dhw
@property
def current_operation(self) -> str | None:
"""Return current operation."""
if (
operating_mode := self._dhw.operating_mode
operating_mode := self.coordinator.data.dhw.operating_mode
) is None or operating_mode.value is None:
return None
return BSBLAN_TO_HA_OPERATION_MODE.get(operating_mode.value)
@@ -131,14 +119,16 @@ class BSBLANWaterHeater(BSBLanDualCoordinatorEntity, WaterHeaterEntity):
@property
def current_temperature(self) -> float | None:
"""Return the current temperature."""
if (current_temp := self._dhw.dhw_actual_value_top_temperature) is None:
if (
current_temp := self.coordinator.data.dhw.dhw_actual_value_top_temperature
) is None:
return None
return current_temp.value
@property
def target_temperature(self) -> float | None:
"""Return the temperature we try to reach."""
if (target_temp := self._dhw.nominal_setpoint) is None:
if (target_temp := self.coordinator.data.dhw.nominal_setpoint) is None:
return None
return target_temp.value

View File

@@ -17,7 +17,6 @@ import voluptuous as vol
from homeassistant.components import frontend, http, websocket_api
from homeassistant.components.websocket_api import (
ERR_INVALID_FORMAT,
ERR_NOT_FOUND,
ERR_NOT_SUPPORTED,
ActiveConnection,
@@ -34,7 +33,6 @@ from homeassistant.core import (
)
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.entity import Entity, EntityDescription
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.event import async_track_point_in_time
@@ -78,7 +76,6 @@ ENTITY_ID_FORMAT = DOMAIN + ".{}"
PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA
PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE
SCAN_INTERVAL = datetime.timedelta(seconds=60)
EVENT_LISTENER_DEBOUNCE_COOLDOWN = 1.0 # seconds
# Don't support rrules more often than daily
VALID_FREQS = {"DAILY", "WEEKLY", "MONTHLY", "YEARLY"}
@@ -323,7 +320,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
websocket_api.async_register_command(hass, handle_calendar_event_create)
websocket_api.async_register_command(hass, handle_calendar_event_delete)
websocket_api.async_register_command(hass, handle_calendar_event_update)
websocket_api.async_register_command(hass, handle_calendar_event_subscribe)
component.async_register_entity_service(
CREATE_EVENT_SERVICE,
@@ -521,17 +517,6 @@ class CalendarEntity(Entity):
_entity_component_unrecorded_attributes = frozenset({"description"})
_alarm_unsubs: list[CALLBACK_TYPE] | None = None
_event_listeners: (
list[
tuple[
datetime.datetime,
datetime.datetime,
Callable[[list[JsonValueType] | None], None],
]
]
| None
) = None
_event_listener_debouncer: Debouncer[None] | None = None
_attr_initial_color: str | None
@@ -600,10 +585,6 @@ class CalendarEntity(Entity):
the current or upcoming event.
"""
super()._async_write_ha_state()
# Notify websocket subscribers of event changes (debounced)
if self._event_listeners and self._event_listener_debouncer:
self._event_listener_debouncer.async_schedule_call()
if self._alarm_unsubs is None:
self._alarm_unsubs = []
_LOGGER.debug(
@@ -644,13 +625,6 @@ class CalendarEntity(Entity):
event.end_datetime_local,
)
@callback
def _async_cancel_event_listener_debouncer(self) -> None:
"""Cancel and clear the event listener debouncer."""
if self._event_listener_debouncer:
self._event_listener_debouncer.async_cancel()
self._event_listener_debouncer = None
async def async_will_remove_from_hass(self) -> None:
"""Run when entity will be removed from hass.
@@ -659,90 +633,6 @@ class CalendarEntity(Entity):
for unsub in self._alarm_unsubs or ():
unsub()
self._alarm_unsubs = None
self._async_cancel_event_listener_debouncer()
@final
@callback
def async_subscribe_events(
self,
start_date: datetime.datetime,
end_date: datetime.datetime,
event_listener: Callable[[list[JsonValueType] | None], None],
) -> CALLBACK_TYPE:
"""Subscribe to calendar event updates.
Called by websocket API.
"""
if self._event_listeners is None:
self._event_listeners = []
if self._event_listener_debouncer is None:
self._event_listener_debouncer = Debouncer(
self.hass,
_LOGGER,
cooldown=EVENT_LISTENER_DEBOUNCE_COOLDOWN,
immediate=True,
function=self.async_update_event_listeners,
)
listener_data = (start_date, end_date, event_listener)
self._event_listeners.append(listener_data)
@callback
def unsubscribe() -> None:
if self._event_listeners:
self._event_listeners.remove(listener_data)
if not self._event_listeners:
self._async_cancel_event_listener_debouncer()
return unsubscribe
@final
@callback
def async_update_event_listeners(self) -> None:
"""Push updated calendar events to all listeners."""
if not self._event_listeners:
return
for start_date, end_date, listener in self._event_listeners:
self.async_update_single_event_listener(start_date, end_date, listener)
@final
@callback
def async_update_single_event_listener(
self,
start_date: datetime.datetime,
end_date: datetime.datetime,
listener: Callable[[list[JsonValueType] | None], None],
) -> None:
"""Schedule an event fetch and push to a single listener."""
self.hass.async_create_task(
self._async_update_listener(start_date, end_date, listener)
)
async def _async_update_listener(
self,
start_date: datetime.datetime,
end_date: datetime.datetime,
listener: Callable[[list[JsonValueType] | None], None],
) -> None:
"""Fetch events and push to a single listener."""
try:
events = await self.async_get_events(self.hass, start_date, end_date)
except HomeAssistantError as err:
_LOGGER.debug(
"Error fetching calendar events for %s: %s",
self.entity_id,
err,
)
listener(None)
return
event_list: list[JsonValueType] = [
dataclasses.asdict(event, dict_factory=_list_events_dict_factory)
for event in events
]
listener(event_list)
async def async_get_events(
self,
@@ -977,65 +867,6 @@ async def handle_calendar_event_update(
connection.send_result(msg["id"])
@websocket_api.websocket_command(
{
vol.Required("type"): "calendar/event/subscribe",
vol.Required("entity_id"): cv.entity_domain(DOMAIN),
vol.Required("start"): cv.datetime,
vol.Required("end"): cv.datetime,
}
)
@websocket_api.async_response
async def handle_calendar_event_subscribe(
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
) -> None:
"""Subscribe to calendar event updates."""
entity_id: str = msg["entity_id"]
if not (entity := hass.data[DATA_COMPONENT].get_entity(entity_id)):
connection.send_error(
msg["id"],
ERR_NOT_FOUND,
f"Calendar entity not found: {entity_id}",
)
return
start_date = dt_util.as_local(msg["start"])
end_date = dt_util.as_local(msg["end"])
if start_date >= end_date:
connection.send_error(
msg["id"],
ERR_INVALID_FORMAT,
"Start must be before end",
)
return
subscription_id = msg["id"]
@callback
def event_listener(events: list[JsonValueType] | None) -> None:
"""Push updated calendar events to websocket."""
if subscription_id not in connection.subscriptions:
return
connection.send_message(
websocket_api.event_message(
subscription_id,
{
"events": events,
},
)
)
connection.subscriptions[subscription_id] = entity.async_subscribe_events(
start_date, end_date, event_listener
)
connection.send_result(subscription_id)
# Push initial events only to the new subscriber
entity.async_update_single_event_listener(start_date, end_date, event_listener)
def _validate_timespan(
values: dict[str, Any],
) -> tuple[datetime.datetime | datetime.date, datetime.datetime | datetime.date]:

View File

@@ -51,24 +51,18 @@ rules:
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: todo
dynamic-devices:
status: exempt
comment: Each config entry represents a single device.
dynamic-devices: todo
entity-category: done
entity-device-class: done
entity-disabled-by-default: done
entity-disabled-by-default: todo
entity-translations: done
exception-translations: done
icon-translations: done
reconfiguration-flow:
status: exempt
comment: No user-configurable settings in the configuration flow.
reconfiguration-flow: todo
repair-issues:
status: exempt
comment: Integration does not register repair issues.
stale-devices:
status: exempt
comment: Each config entry represents a single device.
stale-devices: todo
# Platinum
async-dependency: done

View File

@@ -2,8 +2,6 @@
from __future__ import annotations
from datetime import datetime, timedelta
from pycasperglow import GlowState
from homeassistant.components.sensor import (
@@ -15,8 +13,6 @@ from homeassistant.const import PERCENTAGE, EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.dt import utcnow
from homeassistant.util.variance import ignore_variance
from .coordinator import CasperGlowConfigEntry, CasperGlowCoordinator
from .entity import CasperGlowEntity
@@ -30,12 +26,7 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the sensor platform for Casper Glow."""
async_add_entities(
[
CasperGlowBatterySensor(entry.runtime_data),
CasperGlowDimmingEndTimeSensor(entry.runtime_data),
]
)
async_add_entities([CasperGlowBatterySensor(entry.runtime_data)])
class CasperGlowBatterySensor(CasperGlowEntity, SensorEntity):
@@ -68,67 +59,3 @@ class CasperGlowBatterySensor(CasperGlowEntity, SensorEntity):
if new_value != self._attr_native_value:
self._attr_native_value = new_value
self.async_write_ha_state()
class CasperGlowDimmingEndTimeSensor(CasperGlowEntity, SensorEntity):
"""Sensor entity for Casper Glow dimming end time."""
_attr_translation_key = "dimming_end_time"
_attr_device_class = SensorDeviceClass.TIMESTAMP
_attr_entity_registry_enabled_default = False
def __init__(self, coordinator: CasperGlowCoordinator) -> None:
"""Initialize the dimming end time sensor."""
super().__init__(coordinator)
self._attr_unique_id = (
f"{format_mac(coordinator.device.address)}_dimming_end_time"
)
self._is_paused = False
self._projected_end_time = ignore_variance(
self._calculate_end_time,
timedelta(minutes=1, seconds=30),
)
self._update_from_state(coordinator.device.state)
@staticmethod
def _calculate_end_time(remaining_ms: int) -> datetime:
"""Calculate projected dimming end time from remaining milliseconds."""
return utcnow() + timedelta(milliseconds=remaining_ms)
async def async_added_to_hass(self) -> None:
"""Register state update callback when entity is added."""
await super().async_added_to_hass()
self.async_on_remove(
self._device.register_callback(self._async_handle_state_update)
)
def _reset_projected_end_time(self) -> None:
"""Clear the projected end time and reset the variance filter."""
self._attr_native_value = None
self._projected_end_time = ignore_variance(
self._calculate_end_time,
timedelta(minutes=1, seconds=30),
)
@callback
def _update_from_state(self, state: GlowState) -> None:
"""Update entity attributes from device state."""
if state.is_paused is not None:
self._is_paused = state.is_paused
if self._is_paused:
self._reset_projected_end_time()
return
remaining_ms = state.dimming_time_remaining_ms
if not remaining_ms:
if remaining_ms == 0 or state.is_on is False:
self._reset_projected_end_time()
return
self._attr_native_value = self._projected_end_time(remaining_ms)
@callback
def _async_handle_state_update(self, state: GlowState) -> None:
"""Handle a state update from the device."""
self._update_from_state(state)
self.async_write_ha_state()

View File

@@ -44,11 +44,6 @@
"dimming_time": {
"name": "Dimming time"
}
},
"sensor": {
"dimming_end_time": {
"name": "Dimming end time"
}
}
},
"exceptions": {

View File

@@ -44,10 +44,10 @@
},
"services": {
"show_lovelace_view": {
"description": "Shows a dashboard view on a Google Cast device.",
"description": "Shows a dashboard view on a Chromecast device.",
"fields": {
"dashboard_path": {
"description": "The URL path of the dashboard to show, defaults to `lovelace` if not specified.",
"description": "The URL path of the dashboard to show, defaults to lovelace if not specified.",
"name": "Dashboard path"
},
"entity_id": {
@@ -59,7 +59,7 @@
"name": "View path"
}
},
"name": "Show dashboard view via Google Cast"
"name": "Show dashboard view"
}
}
}

View File

@@ -239,7 +239,7 @@
"message": "Provided humidity {humidity} is not valid. Accepted range is {min_humidity} to {max_humidity}."
},
"low_temp_higher_than_high_temp": {
"message": "'Lower target temperature' cannot be higher than 'Upper target temperature'."
"message": "'Lower target temperature' can not be higher than 'Upper target temperature'."
},
"missing_target_temperature_entity_feature": {
"message": "Set temperature action was used with the 'Target temperature' parameter but the entity does not support it."

View File

@@ -112,7 +112,7 @@ class ComelitAlarmEntity(
@property
def available(self) -> bool:
"""Return True if alarm is available."""
if self._area.human_status == AlarmAreaState.UNKNOWN:
if self._area.human_status in [AlarmAreaState.ANOMALY, AlarmAreaState.UNKNOWN]:
return False
return super().available
@@ -151,7 +151,7 @@ class ComelitAlarmEntity(
if code != str(self.coordinator.api.device_pin):
return
await self.coordinator.api.set_zone_status(
self._area.index, ALARM_ACTIONS[DISABLE], self._area.anomaly
self._area.index, ALARM_ACTIONS[DISABLE]
)
await self._async_update_state(
AlarmAreaState.DISARMED, ALARM_AREA_ARMED_STATUS[DISABLE]
@@ -160,7 +160,7 @@ class ComelitAlarmEntity(
async def async_alarm_arm_away(self, code: str | None = None) -> None:
"""Send arm away command."""
await self.coordinator.api.set_zone_status(
self._area.index, ALARM_ACTIONS[AWAY], self._area.anomaly
self._area.index, ALARM_ACTIONS[AWAY]
)
await self._async_update_state(
AlarmAreaState.ARMED, ALARM_AREA_ARMED_STATUS[AWAY]
@@ -169,7 +169,7 @@ class ComelitAlarmEntity(
async def async_alarm_arm_home(self, code: str | None = None) -> None:
"""Send arm home command."""
await self.coordinator.api.set_zone_status(
self._area.index, ALARM_ACTIONS[HOME], self._area.anomaly
self._area.index, ALARM_ACTIONS[HOME]
)
await self._async_update_state(
AlarmAreaState.ARMED, ALARM_AREA_ARMED_STATUS[HOME_P1]
@@ -178,7 +178,7 @@ class ComelitAlarmEntity(
async def async_alarm_arm_night(self, code: str | None = None) -> None:
"""Send arm night command."""
await self.coordinator.api.set_zone_status(
self._area.index, ALARM_ACTIONS[NIGHT], self._area.anomaly
self._area.index, ALARM_ACTIONS[NIGHT]
)
await self._async_update_state(
AlarmAreaState.ARMED, ALARM_AREA_ARMED_STATUS[NIGHT]

View File

@@ -65,7 +65,6 @@ class ComelitBaseCoordinator(DataUpdateCoordinator[T]):
)
device_registry = dr.async_get(self.hass)
device_registry.async_get_or_create(
configuration_url=self.api.base_url,
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, entry.entry_id)},
model=device,

View File

@@ -8,5 +8,5 @@
"iot_class": "local_polling",
"loggers": ["aiocomelit"],
"quality_scale": "platinum",
"requirements": ["aiocomelit==2.0.2"]
"requirements": ["aiocomelit==2.0.1"]
}

View File

@@ -10,6 +10,8 @@ from crownstone_cloud.exceptions import (
CrownstoneAuthenticationError,
CrownstoneUnknownError,
)
import serial.tools.list_ports
from serial.tools.list_ports_common import ListPortInfo
import voluptuous as vol
from homeassistant.components import usb
@@ -59,11 +61,9 @@ class BaseCrownstoneFlowHandler(ConfigEntryBaseFlow):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Set up a Crownstone USB dongle."""
list_of_ports = [
p
for p in await usb.async_scan_serial_ports(self.hass)
if isinstance(p, usb.USBDevice)
]
list_of_ports = await self.hass.async_add_executor_job(
serial.tools.list_ports.comports
)
if self.flow_type == CONFIG_FLOW:
ports_as_string = list_ports_as_str(list_of_ports)
else:
@@ -82,8 +82,10 @@ class BaseCrownstoneFlowHandler(ConfigEntryBaseFlow):
else:
index = ports_as_string.index(selection) - 1
selected_port = list_of_ports[index]
self.usb_path = selected_port.device
selected_port: ListPortInfo = list_of_ports[index]
self.usb_path = await self.hass.async_add_executor_job(
usb.get_serial_by_id, selected_port.device
)
return await self.async_step_usb_sphere_config()
return self.async_show_form(

View File

@@ -5,14 +5,15 @@ from __future__ import annotations
from collections.abc import Sequence
import os
from serial.tools.list_ports_common import ListPortInfo
from homeassistant.components import usb
from homeassistant.components.usb import USBDevice
from .const import DONT_USE_USB, MANUAL_PATH, REFRESH_LIST
def list_ports_as_str(
serial_ports: Sequence[USBDevice], no_usb_option: bool = True
serial_ports: Sequence[ListPortInfo], no_usb_option: bool = True
) -> list[str]:
"""Represent currently available serial ports as string.
@@ -30,8 +31,8 @@ def list_ports_as_str(
port.serial_number,
port.manufacturer,
port.description,
port.vid,
port.pid,
f"{hex(port.vid)[2:]:0>4}".upper() if port.vid else None,
f"{hex(port.pid)[2:]:0>4}".upper() if port.pid else None,
)
for port in serial_ports
)

View File

@@ -1,9 +1,9 @@
{
"domain": "crownstone",
"name": "Crownstone",
"after_dependencies": ["usb"],
"codeowners": ["@Crownstone", "@RicArch97"],
"config_flow": true,
"dependencies": ["usb"],
"documentation": "https://www.home-assistant.io/integrations/crownstone",
"iot_class": "cloud_push",
"loggers": [
@@ -15,6 +15,7 @@
"requirements": [
"crownstone-cloud==1.4.11",
"crownstone-sse==2.0.5",
"crownstone-uart==2.1.0"
"crownstone-uart==2.1.0",
"pyserial==3.5"
]
}

View File

@@ -263,7 +263,7 @@ class TrackerEntity(
@callback
def _async_write_ha_state(self) -> None:
"""Calculate active zones."""
if self.available and self.latitude is not None and self.longitude is not None:
if self.latitude is not None and self.longitude is not None:
self.__active_zone, self.__in_zones = zone.async_in_zones(
self.hass, self.latitude, self.longitude, self.location_accuracy
)

View File

@@ -24,8 +24,14 @@
"entered_home": {
"trigger": "mdi:account-arrow-left"
},
"entered_zone": {
"trigger": "mdi:map-marker-plus"
},
"left_home": {
"trigger": "mdi:account-arrow-right"
},
"left_zone": {
"trigger": "mdi:map-marker-minus"
}
}
}

View File

@@ -130,6 +130,19 @@
},
"name": "Entered home"
},
"entered_zone": {
"description": "Triggers when one or more device trackers enter a zone.",
"fields": {
"behavior": {
"name": "[%key:component::device_tracker::common::trigger_behavior_name%]"
},
"zone": {
"description": "The zones to trigger on.",
"name": "Zones"
}
},
"name": "Entered zone"
},
"left_home": {
"description": "Triggers when one or more device trackers leave home.",
"fields": {
@@ -138,6 +151,19 @@
}
},
"name": "Left home"
},
"left_zone": {
"description": "Triggers when one or more device trackers leave a zone.",
"fields": {
"behavior": {
"name": "[%key:component::device_tracker::common::trigger_behavior_name%]"
},
"zone": {
"description": "The zones to trigger on.",
"name": "Zones"
}
},
"name": "Left zone"
}
}
}

View File

@@ -1,18 +1,93 @@
"""Provides triggers for device_trackers."""
from homeassistant.const import STATE_HOME
from homeassistant.core import HomeAssistant
import voluptuous as vol
from homeassistant.const import (
CONF_OPTIONS,
CONF_ZONE,
STATE_HOME,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
)
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST,
EntityTriggerBase,
Trigger,
TriggerConfig,
make_entity_origin_state_trigger,
make_entity_target_state_trigger,
)
from .const import DOMAIN
from .const import ATTR_IN_ZONES, DOMAIN
ZONE_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST.extend(
{
vol.Required(CONF_OPTIONS): {
vol.Required(CONF_ZONE): vol.All(
cv.ensure_list,
vol.Length(min=1),
[cv.entity_domain("zone")],
),
},
}
)
_IN_ZONES_SPEC = {DOMAIN: DomainSpec(value_source=ATTR_IN_ZONES)}
class ZoneTriggerBase(EntityTriggerBase):
"""Base for zone-based device tracker triggers."""
_domain_specs = _IN_ZONES_SPEC
_schema = ZONE_TRIGGER_SCHEMA
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the trigger."""
super().__init__(hass, config)
self._zones: set[str] = set(self._options[CONF_ZONE])
def _is_valid(self, state: State) -> bool:
"""Check if the state is valid (not unavailable/unknown)."""
return state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN)
def _in_target_zones(self, state: State) -> bool:
"""Check if the device is in any of the selected zones."""
in_zones = set(self._get_tracked_value(state) or [])
return bool(in_zones.intersection(self._zones))
class EnteredZoneTrigger(ZoneTriggerBase):
"""Trigger when a device tracker enters one of the selected zones."""
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check that the device was not already in any of the selected zones."""
return self._is_valid(from_state) and not self._in_target_zones(from_state)
def is_valid_state(self, state: State) -> bool:
"""Check that the device is now in at least one of the selected zones."""
return self._is_valid(state) and self._in_target_zones(state)
class LeftZoneTrigger(ZoneTriggerBase):
"""Trigger when a device tracker leaves all of the selected zones."""
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check that the device was previously in at least one of the selected zones."""
return self._is_valid(from_state) and self._in_target_zones(from_state)
def is_valid_state(self, state: State) -> bool:
"""Check that the device is no longer in any of the selected zones."""
return self._is_valid(state) and not self._in_target_zones(state)
TRIGGERS: dict[str, type[Trigger]] = {
"entered_home": make_entity_target_state_trigger(DOMAIN, STATE_HOME),
"entered_zone": EnteredZoneTrigger,
"left_home": make_entity_origin_state_trigger(DOMAIN, from_state=STATE_HOME),
"left_zone": LeftZoneTrigger,
}

View File

@@ -14,5 +14,27 @@
- any
translation_key: trigger_behavior
.trigger_zone: &trigger_zone
<<: *trigger_common
fields:
behavior:
required: true
default: any
selector:
select:
options:
- first
- last
- any
translation_key: trigger_behavior
zone:
required: true
selector:
entity:
domain: zone
multiple: true
entered_home: *trigger_common
entered_zone: *trigger_zone
left_home: *trigger_common
left_zone: *trigger_zone

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio
from functools import partial
import os
from typing import Any
from dsmr_parser import obis_references as obis_ref
@@ -14,9 +15,9 @@ from dsmr_parser.clients.rfxtrx_protocol import (
)
from dsmr_parser.objects import DSMRObject
import serial
import serial.tools.list_ports
import voluptuous as vol
from homeassistant.components import usb
from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
@@ -228,7 +229,9 @@ class DSMRFlowHandler(ConfigFlow, domain=DOMAIN):
self._dsmr_version = user_input[CONF_DSMR_VERSION]
return await self.async_step_setup_serial_manual_path()
dev_path = user_selection
dev_path = await self.hass.async_add_executor_job(
get_serial_by_id, user_selection
)
validate_data = {
CONF_PORT: dev_path,
@@ -239,10 +242,9 @@ class DSMRFlowHandler(ConfigFlow, domain=DOMAIN):
if not errors:
return self.async_create_entry(title=data[CONF_PORT], data=data)
ports = await usb.async_scan_serial_ports(self.hass)
ports = await self.hass.async_add_executor_job(serial.tools.list_ports.comports)
list_of_ports = {
port.device: f"{port.device} - {port.description or 'n/a'}"
f", s/n: {port.serial_number or 'n/a'}"
port.device: f"{port}, s/n: {port.serial_number or 'n/a'}"
+ (f" - {port.manufacturer}" if port.manufacturer else "")
for port in ports
}
@@ -333,6 +335,18 @@ class DSMROptionFlowHandler(OptionsFlow):
)
def get_serial_by_id(dev_path: str) -> str:
"""Return a /dev/serial/by-id match for given device if available."""
by_id = "/dev/serial/by-id"
if not os.path.isdir(by_id):
return dev_path
for path in (entry.path for entry in os.scandir(by_id) if entry.is_symlink()):
if os.path.realpath(path) == dev_path:
return path
return dev_path
class CannotConnect(HomeAssistantError):
"""Error to indicate we cannot connect."""

View File

@@ -3,7 +3,6 @@
"name": "DSMR Smart Meter",
"codeowners": ["@Robbie1221"],
"config_flow": true,
"dependencies": ["usb"],
"documentation": "https://www.home-assistant.io/integrations/dsmr",
"integration_type": "hub",
"iot_class": "local_push",

View File

@@ -1,34 +0,0 @@
"""The Duco integration."""
from __future__ import annotations
from duco import DucoClient
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import PLATFORMS
from .coordinator import DucoConfigEntry, DucoCoordinator
async def async_setup_entry(hass: HomeAssistant, entry: DucoConfigEntry) -> bool:
"""Set up Duco from a config entry."""
client = DucoClient(
session=async_get_clientsession(hass),
host=entry.data[CONF_HOST],
)
coordinator = DucoCoordinator(hass, entry, client)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: DucoConfigEntry) -> bool:
"""Unload a Duco config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -1,74 +0,0 @@
"""Config flow for the Duco integration."""
from __future__ import annotations
import logging
from typing import Any
from duco import DucoClient
from duco.exceptions import DucoConnectionError, DucoError
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import format_mac
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
STEP_USER_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST): str,
}
)
class DucoConfigFlow(ConfigFlow, domain=DOMAIN):
"""Config flow for Duco."""
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:
try:
box_name, mac = await self._validate_input(user_input[CONF_HOST])
except DucoConnectionError:
errors["base"] = "cannot_connect"
except DucoError:
_LOGGER.exception("Unexpected error connecting to Duco box")
errors["base"] = "unknown"
else:
await self.async_set_unique_id(format_mac(mac))
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=box_name,
data={CONF_HOST: user_input[CONF_HOST]},
)
return self.async_show_form(
step_id="user",
data_schema=STEP_USER_SCHEMA,
errors=errors,
)
async def _validate_input(self, host: str) -> tuple[str, str]:
"""Validate the user input by connecting to the Duco box.
Returns a tuple of (box_name, mac_address).
"""
client = DucoClient(
session=async_get_clientsession(self.hass),
host=host,
)
board_info = await client.async_get_board_info()
lan_info = await client.async_get_lan_info()
return board_info.box_name, lan_info.mac

View File

@@ -1,9 +0,0 @@
"""Constants for the Duco integration."""
from datetime import timedelta
from homeassistant.const import Platform
DOMAIN = "duco"
PLATFORMS = [Platform.FAN]
SCAN_INTERVAL = timedelta(seconds=30)

View File

@@ -1,75 +0,0 @@
"""Data update coordinator for the Duco integration."""
from __future__ import annotations
import logging
from duco import DucoClient
from duco.exceptions import DucoConnectionError, DucoError
from duco.models import BoardInfo, Node
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN, SCAN_INTERVAL
_LOGGER = logging.getLogger(__name__)
type DucoConfigEntry = ConfigEntry[DucoCoordinator]
type DucoData = dict[int, Node]
class DucoCoordinator(DataUpdateCoordinator[DucoData]):
"""Coordinator for the Duco integration."""
config_entry: DucoConfigEntry
board_info: BoardInfo
def __init__(
self,
hass: HomeAssistant,
config_entry: DucoConfigEntry,
client: DucoClient,
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=DOMAIN,
update_interval=SCAN_INTERVAL,
)
self.client = client
async def _async_setup(self) -> None:
"""Fetch board info once during initial setup."""
try:
self.board_info = await self.client.async_get_board_info()
except DucoConnectionError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="cannot_connect",
translation_placeholders={"error": repr(err)},
) from err
except DucoError as err:
raise ConfigEntryError(f"Duco API error: {err}") from err
async def _async_update_data(self) -> DucoData:
"""Fetch node data from the Duco box."""
try:
nodes = await self.client.async_get_nodes()
except DucoConnectionError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="cannot_connect",
translation_placeholders={"error": repr(err)},
) from err
except DucoError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="api_error",
translation_placeholders={"error": repr(err)},
) from err
return {node.node_id: node for node in nodes}

View File

@@ -1,52 +0,0 @@
"""Base entity for the Duco integration."""
from __future__ import annotations
from duco.models import Node
from homeassistant.const import ATTR_VIA_DEVICE
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import DucoCoordinator
class DucoEntity(CoordinatorEntity[DucoCoordinator]):
"""Base class for Duco entities."""
_attr_has_entity_name = True
def __init__(self, coordinator: DucoCoordinator, node: Node) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
self._node_id = node.node_id
mac = coordinator.config_entry.unique_id
assert mac is not None
device_info = DeviceInfo(
identifiers={(DOMAIN, f"{mac}_{node.node_id}")},
manufacturer="Duco",
model=coordinator.board_info.box_name
if node.general.node_type == "BOX"
else node.general.node_type,
name=node.general.name or f"Node {node.node_id}",
)
device_info.update(
{
"connections": {(CONNECTION_NETWORK_MAC, mac)},
"serial_number": coordinator.board_info.serial_board_box,
}
if node.general.node_type == "BOX"
else {ATTR_VIA_DEVICE: (DOMAIN, f"{mac}_1")}
)
self._attr_device_info = device_info
@property
def available(self) -> bool:
"""Return True if entity is available."""
return super().available and self._node_id in self.coordinator.data
@property
def _node(self) -> Node:
"""Return the current node data from the coordinator."""
return self.coordinator.data[self._node_id]

View File

@@ -1,127 +0,0 @@
"""Fan platform for the Duco integration."""
from __future__ import annotations
from duco.exceptions import DucoError
from duco.models import Node, VentilationState
from homeassistant.components.fan import FanEntity, FanEntityFeature
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.percentage import percentage_to_ordered_list_item
from .const import DOMAIN
from .coordinator import DucoConfigEntry, DucoCoordinator
from .entity import DucoEntity
PARALLEL_UPDATES = 1
# Permanent speed states ordered low → high.
ORDERED_NAMED_FAN_SPEEDS: list[VentilationState] = [
VentilationState.CNT1,
VentilationState.CNT2,
VentilationState.CNT3,
]
PRESET_AUTO = "auto"
# Upper-bound percentages for 3 speed levels: 33 / 66 / 100.
# Using upper bounds guarantees that reading a percentage back and writing it
# again always round-trips to the same Duco state.
_SPEED_LEVEL_PERCENTAGES: list[int] = [
(i + 1) * 100 // len(ORDERED_NAMED_FAN_SPEEDS)
for i in range(len(ORDERED_NAMED_FAN_SPEEDS))
]
# Maps every active Duco state (including timed MAN variants) to its
# display percentage so externally-set timed modes show the correct level.
_STATE_TO_PERCENTAGE: dict[VentilationState, int] = {
VentilationState.CNT1: _SPEED_LEVEL_PERCENTAGES[0],
VentilationState.MAN1: _SPEED_LEVEL_PERCENTAGES[0],
VentilationState.MAN1x2: _SPEED_LEVEL_PERCENTAGES[0],
VentilationState.MAN1x3: _SPEED_LEVEL_PERCENTAGES[0],
VentilationState.CNT2: _SPEED_LEVEL_PERCENTAGES[1],
VentilationState.MAN2: _SPEED_LEVEL_PERCENTAGES[1],
VentilationState.MAN2x2: _SPEED_LEVEL_PERCENTAGES[1],
VentilationState.MAN2x3: _SPEED_LEVEL_PERCENTAGES[1],
VentilationState.CNT3: _SPEED_LEVEL_PERCENTAGES[2],
VentilationState.MAN3: _SPEED_LEVEL_PERCENTAGES[2],
VentilationState.MAN3x2: _SPEED_LEVEL_PERCENTAGES[2],
VentilationState.MAN3x3: _SPEED_LEVEL_PERCENTAGES[2],
}
async def async_setup_entry(
hass: HomeAssistant,
entry: DucoConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Duco fan entities."""
coordinator = entry.runtime_data
async_add_entities(
DucoVentilationFanEntity(coordinator, node)
for node in coordinator.data.values()
if node.general.node_type == "BOX"
)
class DucoVentilationFanEntity(DucoEntity, FanEntity):
"""Fan entity for the ventilation control of a Duco node."""
_attr_translation_key = "ventilation"
_attr_name = None
_attr_supported_features = FanEntityFeature.SET_SPEED | FanEntityFeature.PRESET_MODE
_attr_preset_modes = [PRESET_AUTO]
_attr_speed_count = len(ORDERED_NAMED_FAN_SPEEDS)
def __init__(self, coordinator: DucoCoordinator, node: Node) -> None:
"""Initialize the fan entity."""
super().__init__(coordinator, node)
self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{node.node_id}"
@property
def percentage(self) -> int | None:
"""Return the current speed as a percentage, or None when in AUTO mode."""
node = self._node
if node.ventilation is None:
return None
return _STATE_TO_PERCENTAGE.get(node.ventilation.state)
@property
def preset_mode(self) -> str | None:
"""Return the current preset mode (auto when Duco controls, else None)."""
node = self._node
if node.ventilation is None:
return None
if node.ventilation.state not in _STATE_TO_PERCENTAGE:
return PRESET_AUTO
return None
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set preset mode: 'auto' hands control back to Duco."""
self._valid_preset_mode_or_raise(preset_mode)
await self._async_set_state(VentilationState.AUTO)
async def async_set_percentage(self, percentage: int) -> None:
"""Set the fan speed as a percentage (maps to low/medium/high)."""
if percentage == 0:
await self._async_set_state(VentilationState.AUTO)
return
state = percentage_to_ordered_list_item(ORDERED_NAMED_FAN_SPEEDS, percentage)
await self._async_set_state(state)
async def _async_set_state(self, state: VentilationState) -> None:
"""Send the ventilation state to the device and refresh coordinator."""
try:
await self.coordinator.client.async_set_ventilation_state(
self._node_id, state
)
except DucoError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="failed_to_set_state",
translation_placeholders={"error": repr(err)},
) from err
await self.coordinator.async_refresh()

View File

@@ -1,12 +0,0 @@
{
"domain": "duco",
"name": "Duco",
"codeowners": ["@ronaldvdmeer"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/duco",
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["duco"],
"quality_scale": "bronze",
"requirements": ["python-duco-client==0.2.0"]
}

View File

@@ -1,89 +0,0 @@
rules:
# Bronze
action-setup:
status: exempt
comment: Integration does not provide service actions.
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: Integration does not provide service actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: Integration uses a coordinator; entities do not subscribe to events directly.
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: done
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: Integration does not provide an option flow.
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable:
status: done
comment: Handled by the DataUpdateCoordinator.
parallel-updates: done
reauthentication-flow:
status: exempt
comment: Integration uses a local API that requires no credentials.
test-coverage: done
# Gold
devices: done
diagnostics: todo
discovery-update-info:
status: todo
comment: >-
DHCP host updating to be implemented in a follow-up PR.
The device hostname follows the pattern duco_<last 6 chars of MAC>
(e.g. duco_061293), which can be used for DHCP hostname matching.
discovery:
status: todo
comment: >-
Device can be discovered via DHCP. The hostname follows the pattern
duco_<last 6 chars of MAC> (e.g. duco_061293). To be implemented
in a follow-up PR together with discovery-update-info.
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:
status: todo
comment: >-
Users can pair new modules (CO2 sensors, humidity sensors, zone valves)
to their Duco box. Dynamic device support to be added in a follow-up PR.
entity-category: todo
entity-device-class: todo
entity-disabled-by-default: todo
entity-translations: done
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
repair-issues: todo
stale-devices:
status: todo
comment: >-
To be implemented together with dynamic device support in a follow-up PR.
# Platinum
async-dependency: done
inject-websession: done
strict-typing: todo

View File

@@ -1,45 +0,0 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]"
},
"data_description": {
"host": "IP address or hostname of your Duco ventilation box."
}
}
}
},
"entity": {
"fan": {
"ventilation": {
"state_attributes": {
"preset_mode": {
"state": {
"auto": "[%key:common::state::auto%]"
}
}
}
}
}
},
"exceptions": {
"api_error": {
"message": "Unexpected error from the Duco API: {error}"
},
"cannot_connect": {
"message": "An error occurred while trying to connect to the Duco instance: {error}"
},
"failed_to_set_state": {
"message": "Failed to set ventilation state: {error}"
}
}
}

View File

@@ -5,7 +5,7 @@
"invalid_identifier": "[%key:component::dwd_weather_warnings::config::error::invalid_identifier%]"
},
"error": {
"ambiguous_identifier": "The region identifier and device tracker cannot be specified together.",
"ambiguous_identifier": "The region identifier and device tracker can not be specified together.",
"attribute_not_found": "The required attributes 'Latitude' and 'Longitude' were not found in the specified device tracker.",
"entity_not_found": "The specified device tracker entity was not found.",
"invalid_identifier": "The specified region identifier / device tracker is invalid.",

View File

@@ -24,10 +24,11 @@ class EcowittEntity(Entity):
self._attr_unique_id = f"{sensor.station.key}-{sensor.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, sensor.station.key)},
identifiers={
(DOMAIN, sensor.station.key),
},
name=sensor.station.model,
model=sensor.station.model,
manufacturer="Ecowitt",
sw_version=sensor.station.version,
)

View File

@@ -29,11 +29,9 @@ VALID_NAME_PATTERN = re.compile(r"^(?![\d\s])[\w\d \.]*[\w\d]$")
class ConfigFlowEkeyApi(ekey_bionyxpy.AbstractAuth):
"""Authentication implementation used during config flow, without refresh.
"""ekey bionyx authentication before a ConfigEntry exists.
This exists to allow the config flow to use the API before it has fully
created a config entry required by OAuth2Session. This does not support
refreshing tokens, which is fine since it should have been just created.
This implementation directly provides the token without supporting refresh.
"""
def __init__(

View File

@@ -6,17 +6,25 @@ rules:
appropriate-polling:
status: exempt
comment: The integration uses a push-based mechanism with a background sync task, not polling.
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
brands:
status: done
common-modules:
status: done
config-flow-test-coverage:
status: done
config-flow:
status: done
dependency-transparency:
status: done
docs-actions:
status: exempt
comment: The integration does not expose any custom service actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
docs-high-level-description:
status: done
docs-installation-instructions:
status: done
docs-removal-instructions:
status: done
entity-event-setup:
status: exempt
comment: This integration does not create its own entities.
@@ -26,30 +34,40 @@ rules:
has-entity-name:
status: exempt
comment: This integration does not create its own entities.
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
runtime-data:
status: done
test-before-configure:
status: done
test-before-setup:
status: done
unique-config-entry:
status: done
# Silver
action-exceptions:
status: exempt
comment: The integration does not expose any custom service actions.
config-entry-unloading: done
docs-configuration-parameters: done
docs-installation-parameters: done
config-entry-unloading:
status: done
docs-configuration-parameters:
status: done
docs-installation-parameters:
status: done
entity-unavailable:
status: exempt
comment: This integration does not create its own entities.
integration-owner: done
integration-owner:
status: done
log-when-unavailable:
status: done
comment: The integration logs a single message when the EnergyID service is unavailable.
parallel-updates:
status: exempt
comment: This integration does not create its own entities.
reauthentication-flow: done
test-coverage: done
reauthentication-flow:
status: done
test-coverage:
status: done
# Gold
devices:
@@ -64,15 +82,21 @@ rules:
discovery-update-info:
status: exempt
comment: No discovery mechanism is used.
docs-data-update: done
docs-examples: done
docs-known-limitations: done
docs-data-update:
status: done
docs-examples:
status: done
docs-known-limitations:
status: done
docs-supported-devices:
status: exempt
comment: This is a service integration not tied to specific device models.
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: done
docs-supported-functions:
status: done
docs-troubleshooting:
status: done
docs-use-cases:
status: done
dynamic-devices:
status: exempt
comment: The integration creates a single device entry for the service connection.
@@ -88,7 +112,8 @@ rules:
entity-translations:
status: exempt
comment: This integration does not create its own entities.
exception-translations: done
exception-translations:
status: done
icon-translations:
status: exempt
comment: This integration does not create its own entities.
@@ -103,8 +128,10 @@ rules:
comment: Creates a single service device entry tied to the config entry.
# Platinum
async-dependency: done
inject-websession: done
async-dependency:
status: done
inject-websession:
status: done
strict-typing:
status: todo
comment: Full strict typing compliance will be addressed in a future update.

View File

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

View File

@@ -20,7 +20,7 @@ from homeassistant.helpers.typing import ConfigType
from homeassistant.util import dt as dt_util
from homeassistant.util.hass_dict import HassKey
from .const import ATTR_EVENT_TYPE, ATTR_EVENT_TYPES, DOMAIN, DoorbellEventType
from .const import ATTR_EVENT_TYPE, ATTR_EVENT_TYPES, DOMAIN
_LOGGER = logging.getLogger(__name__)
DATA_COMPONENT: HassKey[EntityComponent[EventEntity]] = HassKey(DOMAIN)
@@ -44,7 +44,6 @@ __all__ = [
"DOMAIN",
"PLATFORM_SCHEMA",
"PLATFORM_SCHEMA_BASE",
"DoorbellEventType",
"EventDeviceClass",
"EventEntity",
"EventEntityDescription",
@@ -190,21 +189,6 @@ class EventEntity(RestoreEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_)
async def async_internal_added_to_hass(self) -> None:
"""Call when the event entity is added to hass."""
await super().async_internal_added_to_hass()
if (
self.device_class == EventDeviceClass.DOORBELL
and DoorbellEventType.RING not in self.event_types
):
report_issue = self._suggest_report_issue()
_LOGGER.warning(
"Entity %s is a doorbell event entity but does not support "
"the '%s' event type. This will stop working in "
"Home Assistant 2027.4, please %s",
self.entity_id,
DoorbellEventType.RING,
report_issue,
)
if (
(state := await self.async_get_last_state())
and state.state is not None

View File

@@ -1,13 +1,5 @@
"""Provides the constants needed for the component."""
from enum import StrEnum
DOMAIN = "event"
ATTR_EVENT_TYPE = "event_type"
ATTR_EVENT_TYPES = "event_types"
class DoorbellEventType(StrEnum):
"""Standard event types for doorbell device class."""
RING = "ring"

View File

@@ -15,14 +15,7 @@
"name": "Button"
},
"doorbell": {
"name": "Doorbell",
"state_attributes": {
"event_type": {
"state": {
"ring": "Ring"
}
}
}
"name": "Doorbell"
},
"motion": {
"name": "Motion"

View File

@@ -189,25 +189,25 @@ class EvoZone(EvoChild, EvoClimateEntity):
)
async def async_clear_zone_override(self) -> None:
"""Clear the zone override (if any) and return to following its schedule."""
"""Clear the zone's override, if any."""
await self.coordinator.call_client_api(self._evo_device.reset())
async def async_set_zone_override(
self, setpoint: float, duration: timedelta | None = None
) -> None:
"""Override the zone's setpoint, either permanently or for a duration."""
"""Set the zone's override (mode/setpoint)."""
temperature = max(min(setpoint, self.max_temp), self.min_temp)
if duration is None:
until = None # indefinitely
elif duration.total_seconds() == 0:
await self._update_schedule()
until = self.setpoints.get("next_sp_from")
if duration is not None:
if duration.total_seconds() == 0:
await self._update_schedule()
until = self.setpoints.get("next_sp_from")
else:
until = dt_util.now() + duration
else:
until = dt_util.now() + duration
until = None # indefinitely
until = dt_util.as_utc(until) if until else None
await self.coordinator.call_client_api(
self._evo_device.set_temperature(temperature, until=until)
)

View File

@@ -27,7 +27,7 @@ from .coordinator import EvoDataUpdateCoordinator
# System service schemas (registered as domain services)
SET_SYSTEM_MODE_SCHEMA: Final[dict[str | vol.Marker, Any]] = {
# unsupported modes are rejected at runtime with ServiceValidationError
vol.Required(ATTR_MODE): cv.string, # ... so, don't use SystemMode enum here
vol.Required(ATTR_MODE): cv.string, # avoid vol.In(SystemMode)
vol.Exclusive(ATTR_DURATION, "temporary"): vol.All(
cv.time_period,
vol.Range(min=timedelta(hours=0), max=timedelta(hours=24)),

View File

@@ -4,8 +4,6 @@
set_system_mode:
fields:
mode:
required: true
default: Auto
example: Away
selector:
select:
@@ -21,10 +19,9 @@ set_system_mode:
selector:
object:
duration:
example: "18:00"
example: '{"hours": 18}'
selector:
duration:
enable_second: false
object:
reset_system:
@@ -35,8 +32,6 @@ set_zone_override:
entity:
integration: evohome
domain: climate
supported_features:
- climate.ClimateEntityFeature.TARGET_TEMPERATURE
fields:
setpoint:
required: true
@@ -46,15 +41,12 @@ set_zone_override:
max: 35.0
step: 0.1
duration:
example: "02:15"
example: '{"minutes": 135}'
selector:
duration:
enable_second: false
object:
clear_zone_override:
target:
entity:
integration: evohome
domain: climate
supported_features:
- climate.ClimateEntityFeature.TARGET_TEMPERATURE

View File

@@ -4,13 +4,13 @@
"message": "The requested system mode is not supported: {error}"
},
"mode_cant_be_temporary": {
"message": "The mode `{mode}` does not support 'Duration' or 'Period'"
"message": "The mode `{mode}` does not support `duration` or `period`"
},
"mode_cant_have_duration": {
"message": "The mode `{mode}` does not support 'Duration'; use 'Period' instead"
"message": "The mode `{mode}` does not support `duration`; use `period` instead"
},
"mode_cant_have_period": {
"message": "The mode `{mode}` does not support 'Period'; use 'Duration' instead"
"message": "The mode `{mode}` does not support `period`; use `duration` instead"
},
"mode_not_supported": {
"message": "The mode `{mode}` is not supported by this controller"
@@ -29,14 +29,14 @@
"name": "Refresh system"
},
"reset_system": {
"description": "Sets the system to `Auto` mode and resets all the zones to follow their schedules. Not all Evohome systems support this feature (i.e. `AutoWithReset` mode).",
"description": "Sets the system to Auto mode and resets all the zones to follow their schedules. Not all Evohome systems support this feature (i.e. AutoWithReset mode).",
"name": "Reset system"
},
"set_system_mode": {
"description": "Sets the system mode, either indefinitely, or for a specified period of time, after which it will revert to `Auto`. Not all systems support all modes.",
"description": "Sets the system mode, either indefinitely, or for a specified period of time, after which it will revert to Auto. Not all systems support all modes.",
"fields": {
"duration": {
"description": "The duration in hours; used only with `AutoWithEco` mode (up to 24 hours).",
"description": "The duration in hours; used only with AutoWithEco mode (up to 24 hours).",
"name": "Duration"
},
"mode": {
@@ -44,14 +44,14 @@
"name": "[%key:common::config_flow::data::mode%]"
},
"period": {
"description": "A period of time in days; used only with `Away`, `DayOff`, or `Custom` mode. The system will revert to `Auto` mode at midnight (up to 99 days, today is day 1).",
"description": "A period of time in days; used only with Away, DayOff, or Custom mode. The system will revert to Auto mode at midnight (up to 99 days, today is day 1).",
"name": "Period"
}
},
"name": "Set system mode"
},
"set_zone_override": {
"description": "Overrides the zone's setpoint, either indefinitely, or for a specified period of time, after which it will revert to following its schedule.",
"description": "Overrides a zone's setpoint, either indefinitely, or for a specified period of time, after which it will revert to following its schedule.",
"fields": {
"duration": {
"description": "The zone will revert to its schedule after this time. If 0 the change is until the next scheduled setpoint.",

View File

@@ -69,6 +69,7 @@ class EvoDHW(EvoChild, WaterHeaterEntity):
"""Base for any evohome-compatible DHW controller."""
_attr_name = "DHW controller"
_attr_icon = "mdi:thermometer-lines"
_attr_operation_list = list(HA_STATE_TO_EVO)
_attr_supported_features = (
WaterHeaterEntityFeature.AWAY_MODE

View File

@@ -19,7 +19,7 @@ from pyfirefly.models import Account, Bill, Budget, Category, Currency
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_create_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -79,13 +79,13 @@ class FireflyDataUpdateCoordinator(DataUpdateCoordinator[FireflyCoordinatorData]
translation_placeholders={"error": repr(err)},
) from err
except FireflyConnectionError as err:
raise UpdateFailed(
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="cannot_connect",
translation_placeholders={"error": repr(err)},
) from err
except FireflyTimeoutError as err:
raise UpdateFailed(
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="timeout_connect",
translation_placeholders={"error": repr(err)},

View File

@@ -7,14 +7,13 @@ from typing import Any, cast
from fitbit import Fitbit
from fitbit.exceptions import HTTPException, HTTPUnauthorized
from fitbit_web_api import ApiClient, Configuration, DevicesApi, UserApi
from fitbit_web_api import ApiClient, Configuration, DevicesApi
from fitbit_web_api.exceptions import (
ApiException,
OpenApiException,
UnauthorizedException,
)
from fitbit_web_api.models.device import Device
from fitbit_web_api.models.user import User
from requests.exceptions import ConnectionError as RequestsConnectionError
from homeassistant.const import CONF_ACCESS_TOKEN
@@ -25,6 +24,7 @@ from homeassistant.util.unit_system import METRIC_SYSTEM
from .const import FitbitUnitSystem
from .exceptions import FitbitApiException, FitbitAuthException
from .model import FitbitProfile
_LOGGER = logging.getLogger(__name__)
@@ -46,7 +46,7 @@ class FitbitApi(ABC):
) -> None:
"""Initialize Fitbit auth."""
self._hass = hass
self._profile: User | None = None
self._profile: FitbitProfile | None = None
self._unit_system = unit_system
@abstractmethod
@@ -74,16 +74,18 @@ class FitbitApi(ABC):
configuration.access_token = token[CONF_ACCESS_TOKEN]
return await self._hass.async_add_executor_job(ApiClient, configuration)
async def async_get_user_profile(self) -> User:
async def async_get_user_profile(self) -> FitbitProfile:
"""Return the user profile from the API."""
if self._profile is None:
client = await self._async_get_fitbit_web_api()
api = UserApi(client)
api_response = await self._run_async(api.get_profile)
if not api_response.user:
raise FitbitApiException("No user profile returned from fitbit API")
_LOGGER.debug("user_profile_get=%s", api_response.to_dict())
self._profile = api_response.user
client = await self._async_get_client()
response: dict[str, Any] = await self._run(client.user_profile_get)
_LOGGER.debug("user_profile_get=%s", response)
profile = response["user"]
self._profile = FitbitProfile(
encoded_id=profile["encodedId"],
display_name=profile["displayName"],
locale=profile.get("locale"),
)
return self._profile
async def async_get_unit_system(self) -> FitbitUnitSystem:

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