mirror of
https://github.com/home-assistant/core.git
synced 2026-06-04 02:13:42 +02:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1141ab2887 |
@@ -344,13 +344,13 @@ jobs:
|
||||
|
||||
- name: Login to DockerHub
|
||||
if: matrix.registry == 'docker.io/homeassistant'
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -380,7 +380,7 @@ jobs:
|
||||
# 2025.12.0.dev202511250240 -> tags: 2025.12.0.dev202511250240, dev
|
||||
- name: Generate Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
with:
|
||||
images: ${{ matrix.registry }}/home-assistant
|
||||
sep-tags: ","
|
||||
@@ -394,7 +394,7 @@ jobs:
|
||||
type=semver,pattern={{major}}.{{minor}},value=${{ needs.init.outputs.version }},enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v3.7.1
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v3.7.1
|
||||
|
||||
- name: Copy architecture images to DockerHub
|
||||
if: matrix.registry == 'docker.io/homeassistant'
|
||||
@@ -523,7 +523,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
|
||||
+7
-7
@@ -36,7 +36,7 @@
|
||||
# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
# - actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
# - actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
# - github/gh-aw-actions/setup@029b1de3e913e0604df1ada9da1cec03e282c4db # v0.76.0
|
||||
# - github/gh-aw-actions/setup@318d7f4901f78b85e25b91709cf0109ac9b425f6 # v0.74.9
|
||||
#
|
||||
# Container images used:
|
||||
# - ghcr.io/github/gh-aw-firewall/agent:0.25.46
|
||||
@@ -90,7 +90,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@029b1de3e913e0604df1ada9da1cec03e282c4db # v0.76.0
|
||||
uses: github/gh-aw-actions/setup@318d7f4901f78b85e25b91709cf0109ac9b425f6 # v0.74.9
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@@ -352,7 +352,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@029b1de3e913e0604df1ada9da1cec03e282c4db # v0.76.0
|
||||
uses: github/gh-aw-actions/setup@318d7f4901f78b85e25b91709cf0109ac9b425f6 # v0.74.9
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@@ -961,7 +961,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@029b1de3e913e0604df1ada9da1cec03e282c4db # v0.76.0
|
||||
uses: github/gh-aw-actions/setup@318d7f4901f78b85e25b91709cf0109ac9b425f6 # v0.74.9
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@@ -1100,7 +1100,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@029b1de3e913e0604df1ada9da1cec03e282c4db # v0.76.0
|
||||
uses: github/gh-aw-actions/setup@318d7f4901f78b85e25b91709cf0109ac9b425f6 # v0.74.9
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@@ -1325,7 +1325,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@029b1de3e913e0604df1ada9da1cec03e282c4db # v0.76.0
|
||||
uses: github/gh-aw-actions/setup@318d7f4901f78b85e25b91709cf0109ac9b425f6 # v0.74.9
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@@ -1383,7 +1383,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@029b1de3e913e0604df1ada9da1cec03e282c4db # v0.76.0
|
||||
uses: github/gh-aw-actions/setup@318d7f4901f78b85e25b91709cf0109ac9b425f6 # v0.74.9
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
|
||||
@@ -28,11 +28,11 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
||||
with:
|
||||
languages: python
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
||||
with:
|
||||
category: "/language:python"
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
issues: write # To lock issues
|
||||
pull-requests: write # To lock pull requests
|
||||
steps:
|
||||
- uses: dessant/lock-threads@89ae32b08ed1a541efecbab17912962a5e38981c # v6.0.2
|
||||
- uses: dessant/lock-threads@851cffe46851ddd2051ea7147ebdc995113241c3 # v6.0.1
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
issue-inactive-days: "30"
|
||||
|
||||
+67
-25
@@ -20,36 +20,22 @@ jobs:
|
||||
permissions:
|
||||
issues: write # To label and close stale issues
|
||||
pull-requests: write # To label and close stale PRs
|
||||
actions: write # To delete stalebot state
|
||||
steps:
|
||||
# Generate a token for the GitHub App, we use this method to avoid
|
||||
# hitting API limits for our GitHub actions + have a higher rate limit.
|
||||
- name: Generate app token
|
||||
id: token
|
||||
# Pinned to a specific version of the action for security reasons
|
||||
# v3.2.0
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
|
||||
with:
|
||||
client-id: ${{ secrets.ISSUE_TRIAGE_APP_ID }} # zizmor: ignore[secrets-outside-env]
|
||||
private-key: ${{ secrets.ISSUE_TRIAGE_APP_PEM }} # zizmor: ignore[secrets-outside-env]
|
||||
|
||||
# The 60 day stale policy for PRs
|
||||
# Used for:
|
||||
# - PRs
|
||||
# - No PRs marked as no-stale
|
||||
# The 90 day stale policy for issues
|
||||
# Used for:
|
||||
# - Issues
|
||||
# - No issues marked as no-stale or help-wanted
|
||||
- name: 60 days stale PRs policy and 90 days stale issue policy
|
||||
# - No issues (-1)
|
||||
- name: 60 days stale PRs policy
|
||||
uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||
with:
|
||||
repo-token: ${{ steps.token.outputs.token }}
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
days-before-stale: 60
|
||||
days-before-close: 7
|
||||
days-before-issue-stale: -1
|
||||
days-before-issue-close: -1
|
||||
operations-per-run: 150
|
||||
remove-stale-when-updated: true
|
||||
operations-per-run: 350
|
||||
# pr policy
|
||||
days-before-pr-stale: 60
|
||||
days-before-pr-close: 7
|
||||
stale-pr-label: "stale"
|
||||
exempt-pr-labels: "no-stale"
|
||||
stale-pr-message: >
|
||||
@@ -62,9 +48,65 @@ jobs:
|
||||
branch to ensure that it's up to date with the latest changes.
|
||||
|
||||
Thank you for your contribution!
|
||||
# issue policy
|
||||
days-before-issue-stale: 90
|
||||
days-before-issue-close: 7
|
||||
|
||||
# Generate a token for the GitHub App, we use this method to avoid
|
||||
# hitting API limits for our GitHub actions + have a higher rate limit.
|
||||
# This is only used for issues.
|
||||
- name: Generate app token
|
||||
id: token
|
||||
# Pinned to a specific version of the action for security reasons
|
||||
# v3.2.0
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
|
||||
with:
|
||||
app-id: ${{ secrets.ISSUE_TRIAGE_APP_ID }} # zizmor: ignore[secrets-outside-env]
|
||||
private-key: ${{ secrets.ISSUE_TRIAGE_APP_PEM }} # zizmor: ignore[secrets-outside-env]
|
||||
|
||||
# The 90 day stale policy for issues
|
||||
# Used for:
|
||||
# - Issues
|
||||
# - No issues marked as no-stale or help-wanted
|
||||
# - No PRs (-1)
|
||||
- name: 90 days stale issues
|
||||
uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||
with:
|
||||
repo-token: ${{ steps.token.outputs.token }}
|
||||
days-before-stale: 90
|
||||
days-before-close: 7
|
||||
days-before-pr-stale: -1
|
||||
days-before-pr-close: -1
|
||||
operations-per-run: 250
|
||||
remove-stale-when-updated: true
|
||||
stale-issue-label: "stale"
|
||||
exempt-issue-labels: "no-stale,help-wanted,needs-more-information"
|
||||
stale-issue-message: >
|
||||
There hasn't been any activity on this issue recently. Due to the
|
||||
high number of incoming GitHub notifications, we have to clean some
|
||||
of the old issues, as many of them have already been resolved with
|
||||
the latest updates.
|
||||
|
||||
Please make sure to update to the latest Home Assistant version and
|
||||
check if that solves the issue. Let us know if that works for you by
|
||||
adding a comment 👍
|
||||
|
||||
This issue has now been marked as stale and will be closed if no
|
||||
further activity occurs. Thank you for your contributions.
|
||||
|
||||
# The 30 day stale policy for issues
|
||||
# Used for:
|
||||
# - Issues that are pending more information (incomplete issues)
|
||||
# - No Issues marked as no-stale or help-wanted
|
||||
# - No PRs (-1)
|
||||
- name: Needs more information stale issues policy
|
||||
uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||
with:
|
||||
repo-token: ${{ steps.token.outputs.token }}
|
||||
only-labels: "needs-more-information"
|
||||
days-before-stale: 14
|
||||
days-before-close: 7
|
||||
days-before-pr-stale: -1
|
||||
days-before-pr-close: -1
|
||||
operations-per-run: 250
|
||||
remove-stale-when-updated: true
|
||||
stale-issue-label: "stale"
|
||||
exempt-issue-labels: "no-stale,help-wanted"
|
||||
stale-issue-message: >
|
||||
|
||||
@@ -286,7 +286,6 @@ homeassistant.components.huawei_lte.*
|
||||
homeassistant.components.humidifier.*
|
||||
homeassistant.components.husqvarna_automower.*
|
||||
homeassistant.components.huum.*
|
||||
homeassistant.components.hvv_departures.*
|
||||
homeassistant.components.hydrawise.*
|
||||
homeassistant.components.hyperion.*
|
||||
homeassistant.components.hypontech.*
|
||||
|
||||
Generated
-6
@@ -501,8 +501,6 @@ CLAUDE.md @home-assistant/core
|
||||
/homeassistant/components/enphase_envoy/ @bdraco @cgarwood @catsmanac
|
||||
/tests/components/enphase_envoy/ @bdraco @cgarwood @catsmanac
|
||||
/homeassistant/components/entur_public_transport/ @hfurubotten @SanderBlom
|
||||
/homeassistant/components/envertech_evt800/ @daniel-bergmann-00
|
||||
/tests/components/envertech_evt800/ @daniel-bergmann-00
|
||||
/homeassistant/components/environment_canada/ @gwww @michaeldavie
|
||||
/tests/components/environment_canada/ @gwww @michaeldavie
|
||||
/homeassistant/components/ephember/ @ttroy50 @roberty99
|
||||
@@ -720,8 +718,6 @@ CLAUDE.md @home-assistant/core
|
||||
/homeassistant/components/heatmiser/ @andylockran
|
||||
/homeassistant/components/hegel/ @boazca
|
||||
/tests/components/hegel/ @boazca
|
||||
/homeassistant/components/helty/ @ebaschiera
|
||||
/tests/components/helty/ @ebaschiera
|
||||
/homeassistant/components/heos/ @andrewsayre
|
||||
/tests/components/heos/ @andrewsayre
|
||||
/homeassistant/components/here_travel_time/ @eifinger
|
||||
@@ -840,8 +836,6 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/imgw_pib/ @bieniu
|
||||
/homeassistant/components/immich/ @mib1185
|
||||
/tests/components/immich/ @mib1185
|
||||
/homeassistant/components/imou/ @Imou-OpenPlatform
|
||||
/tests/components/imou/ @Imou-OpenPlatform
|
||||
/homeassistant/components/improv_ble/ @emontnemery
|
||||
/tests/components/improv_ble/ @emontnemery
|
||||
/homeassistant/components/incomfort/ @jbouwh
|
||||
|
||||
@@ -11,6 +11,7 @@ from homeassistant.components.sensor import (
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONCENTRATION_PARTS_PER_CUBIC_METER,
|
||||
PERCENTAGE,
|
||||
UV_INDEX,
|
||||
UnitOfIrradiance,
|
||||
@@ -46,8 +47,6 @@ from .coordinator import (
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
PARTS_PER_CUBIC_METER = "p/m³"
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class AccuWeatherSensorDescription(SensorEntityDescription):
|
||||
@@ -82,7 +81,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
|
||||
AccuWeatherSensorDescription(
|
||||
key="Grass",
|
||||
entity_registry_enabled_default=False,
|
||||
native_unit_of_measurement=PARTS_PER_CUBIC_METER,
|
||||
native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER,
|
||||
value_fn=lambda data: cast(int, data[ATTR_VALUE]),
|
||||
attr_fn=lambda data: {
|
||||
ATTR_LEVEL: POLLEN_CATEGORY_MAP[data[ATTR_CATEGORY_VALUE]]
|
||||
@@ -108,7 +107,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
|
||||
AccuWeatherSensorDescription(
|
||||
key="Mold",
|
||||
entity_registry_enabled_default=False,
|
||||
native_unit_of_measurement=PARTS_PER_CUBIC_METER,
|
||||
native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER,
|
||||
value_fn=lambda data: cast(int, data[ATTR_VALUE]),
|
||||
attr_fn=lambda data: {
|
||||
ATTR_LEVEL: POLLEN_CATEGORY_MAP[data[ATTR_CATEGORY_VALUE]]
|
||||
@@ -117,7 +116,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
|
||||
),
|
||||
AccuWeatherSensorDescription(
|
||||
key="Ragweed",
|
||||
native_unit_of_measurement=PARTS_PER_CUBIC_METER,
|
||||
native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=lambda data: cast(int, data[ATTR_VALUE]),
|
||||
attr_fn=lambda data: {
|
||||
@@ -185,7 +184,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
|
||||
),
|
||||
AccuWeatherSensorDescription(
|
||||
key="Tree",
|
||||
native_unit_of_measurement=PARTS_PER_CUBIC_METER,
|
||||
native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=lambda data: cast(int, data[ATTR_VALUE]),
|
||||
attr_fn=lambda data: {
|
||||
|
||||
@@ -72,7 +72,8 @@ async def _resolve_attachments(
|
||||
resolved_attachments.append(
|
||||
conversation.Attachment(
|
||||
media_content_id=media_content_id,
|
||||
mime_type=image_data.content_type,
|
||||
mime_type=attachment.get("media_content_type")
|
||||
or image_data.content_type,
|
||||
path=temp_filename,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -8,7 +8,6 @@ from bleak.backends.device import BLEDevice
|
||||
from bleak_retry_connector import close_stale_connections_by_address
|
||||
|
||||
from homeassistant.components import bluetooth
|
||||
from homeassistant.components.bluetooth import BluetoothReachabilityIntent
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
@@ -64,16 +63,7 @@ class AirthingsBLEDataUpdateCoordinator(DataUpdateCoordinator[AirthingsDevice]):
|
||||
|
||||
if not ble_device:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="device_not_found",
|
||||
translation_placeholders={
|
||||
"address": address,
|
||||
"reason": bluetooth.async_address_reachability_diagnostics(
|
||||
self.hass,
|
||||
address.upper(),
|
||||
BluetoothReachabilityIntent.CONNECTION,
|
||||
),
|
||||
},
|
||||
f"Could not find Airthings device with address {address}"
|
||||
)
|
||||
self.ble_device = ble_device
|
||||
|
||||
|
||||
@@ -54,10 +54,5 @@
|
||||
"name": "Radon longterm level"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"device_not_found": {
|
||||
"message": "Could not find Airthings device with address {address}: {reason}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
"""Alexa Devices integration."""
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
|
||||
from homeassistant.const import CONF_COUNTRY, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import aiohttp_client, config_validation as cv, httpx_client
|
||||
@@ -43,17 +46,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bo
|
||||
async def _on_http2_reauth_required() -> None:
|
||||
entry.async_start_reauth(hass)
|
||||
|
||||
async def _cancel_http2() -> None:
|
||||
http2_task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await http2_task
|
||||
|
||||
alexa_httpx_client = httpx_client.get_async_client(
|
||||
hass,
|
||||
alpn_protocols=SSL_ALPN_HTTP11_HTTP2,
|
||||
)
|
||||
|
||||
await coordinator.api.start_http2_processing(
|
||||
alexa_httpx_client,
|
||||
on_reauth_required=_on_http2_reauth_required,
|
||||
http2_task = await coordinator.api.start_http2_processing(
|
||||
alexa_httpx_client, on_reauth_required=_on_http2_reauth_required
|
||||
)
|
||||
|
||||
entry.async_on_unload(coordinator.api.stop_http2_processing)
|
||||
entry.async_on_unload(_cancel_http2)
|
||||
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
|
||||
@@ -39,8 +39,11 @@ async def async_setup_entry(
|
||||
class AmazonRoutineButton(AmazonServiceEntity, ButtonEntity):
|
||||
"""Button entity for Alexa routine."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(self, coordinator: AmazonDevicesCoordinator, routine: str) -> None:
|
||||
"""Initialize the routine button entity."""
|
||||
self._coordinator = coordinator
|
||||
self._routine = routine
|
||||
super().__init__(
|
||||
coordinator,
|
||||
@@ -49,4 +52,4 @@ class AmazonRoutineButton(AmazonServiceEntity, ButtonEntity):
|
||||
|
||||
async def async_press(self) -> None:
|
||||
"""Handle button press action."""
|
||||
await self.coordinator.api.call_routine(self._routine)
|
||||
await self._coordinator.api.call_routine(self._routine)
|
||||
|
||||
@@ -204,26 +204,7 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
|
||||
|
||||
async def sync_media_state(self) -> None:
|
||||
"""Sync media state."""
|
||||
try:
|
||||
await self.api.sync_media_state()
|
||||
except CannotAuthenticate as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_auth",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
except (CannotConnect, TimeoutError) as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect_with_error",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
except (CannotRetrieveData, ValueError) as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_retrieve_data_with_error",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
await self.api.sync_media_state()
|
||||
|
||||
async def media_state_event_handler(
|
||||
self, media_state: dict[str, AmazonMediaState]
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioamazondevices"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aioamazondevices==14.0.0"]
|
||||
"requirements": ["aioamazondevices==13.8.1"]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""Media player platform for Alexa Devices."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from typing import Any, Final
|
||||
|
||||
from aioamazondevices.structures import (
|
||||
AmazonMediaControls,
|
||||
@@ -37,6 +38,18 @@ STANDARD_SUPPORTED_FEATURES = (
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class AmazonDevicesMediaPlayerEntityDescription(MediaPlayerEntityDescription):
|
||||
"""Describes an Alexa Devices media player entity."""
|
||||
|
||||
|
||||
MEDIA_PLAYERS: Final = (
|
||||
AmazonDevicesMediaPlayerEntityDescription(
|
||||
key="media",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: AmazonConfigEntry,
|
||||
@@ -56,10 +69,9 @@ async def async_setup_entry(
|
||||
continue
|
||||
|
||||
known_devices.add(serial_num)
|
||||
new_entities.append(
|
||||
AlexaDevicesMediaPlayer(
|
||||
coordinator, serial_num, MediaPlayerEntityDescription(key="media")
|
||||
)
|
||||
new_entities.extend(
|
||||
AlexaDevicesMediaPlayer(coordinator, serial_num, description)
|
||||
for description in MEDIA_PLAYERS
|
||||
)
|
||||
|
||||
if new_entities:
|
||||
@@ -73,6 +85,8 @@ async def async_setup_entry(
|
||||
class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity):
|
||||
"""Representation of an Alexa device media player."""
|
||||
|
||||
entity_description: AmazonDevicesMediaPlayerEntityDescription
|
||||
|
||||
_attr_name = None # Uses the device name
|
||||
_attr_device_class = MediaPlayerDeviceClass.SPEAKER
|
||||
_attr_volume_step = 0.05
|
||||
@@ -81,7 +95,7 @@ class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity):
|
||||
self,
|
||||
coordinator: AmazonDevicesCoordinator,
|
||||
serial_num: str,
|
||||
description: MediaPlayerEntityDescription,
|
||||
description: AmazonDevicesMediaPlayerEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize."""
|
||||
self._prev_volume: int | None = None
|
||||
@@ -142,11 +156,9 @@ class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity):
|
||||
@property
|
||||
def is_volume_muted(self) -> bool | None:
|
||||
"""Return True if the volume is muted."""
|
||||
if not self.volume_state or self.volume_state.volume is None:
|
||||
if not self.volume_state:
|
||||
return None
|
||||
# is_muted is True when Alexa has muted the device
|
||||
# volume == 0 is where we have muted by setting volume to 0
|
||||
return self.volume_state.is_muted or self.volume_state.volume == 0
|
||||
return self.volume_state.volume == 0
|
||||
|
||||
@property
|
||||
def media_title(self) -> str | None:
|
||||
@@ -200,7 +212,7 @@ class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity):
|
||||
@property
|
||||
def media_content_type(self) -> MediaType | None:
|
||||
"""Content type — tells HA what kind of media is playing."""
|
||||
if self.state in (MediaPlayerState.PLAYING, MediaPlayerState.PAUSED):
|
||||
if self.state in [MediaPlayerState.PLAYING, MediaPlayerState.PAUSED]:
|
||||
return MediaType.MUSIC
|
||||
return None
|
||||
|
||||
@@ -213,8 +225,7 @@ class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity):
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Play a piece of media."""
|
||||
provider = media_type.value if isinstance(media_type, MediaType) else media_type
|
||||
await self.async_call_alexa_music(media_id, provider)
|
||||
await self.async_call_alexa_music(media_id, media_type)
|
||||
|
||||
@alexa_api_call
|
||||
async def async_call_alexa_music(
|
||||
@@ -248,20 +259,12 @@ class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity):
|
||||
return
|
||||
if mute:
|
||||
self._prev_volume = self.volume_state.volume
|
||||
await self.async_set_volume_level(0)
|
||||
return
|
||||
|
||||
if self.volume_state.is_muted and self._prev_volume is None:
|
||||
# is muted by Alexa which we can see but not control
|
||||
# when muted this way, volume is still set
|
||||
# changing volume will unmute
|
||||
# if HA set volume to 0 then Alexa muted we just default to 30%
|
||||
self._prev_volume = self.volume_state.volume or 30
|
||||
if self._prev_volume is None:
|
||||
return
|
||||
target_volume = self._prev_volume
|
||||
target_volume = 0
|
||||
else:
|
||||
if self._prev_volume is None:
|
||||
return
|
||||
target_volume = self._prev_volume
|
||||
await self.async_set_volume_level(target_volume / 100)
|
||||
self._prev_volume = None
|
||||
|
||||
@alexa_api_call
|
||||
async def _send_media_command(self, command: AmazonMediaControls) -> None:
|
||||
|
||||
@@ -125,9 +125,6 @@
|
||||
},
|
||||
"invalid_sound_value": {
|
||||
"message": "Invalid sound {sound} specified"
|
||||
},
|
||||
"unknown_exception": {
|
||||
"message": "Unknown error occurred: {error}"
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
|
||||
@@ -4,6 +4,7 @@ import base64
|
||||
from collections import deque
|
||||
from collections.abc import AsyncIterator, Callable, Iterable
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
import json
|
||||
from mimetypes import guess_file_type
|
||||
from pathlib import Path
|
||||
@@ -113,7 +114,7 @@ from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr, llm
|
||||
from homeassistant.helpers.json import json_dumps
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from homeassistant.util import dt as dt_util, slugify
|
||||
from homeassistant.util import slugify
|
||||
from homeassistant.util.json import JsonArrayType, JsonObjectType
|
||||
|
||||
from .const import (
|
||||
@@ -371,7 +372,7 @@ def _convert_content( # noqa: C901
|
||||
)
|
||||
if (
|
||||
content.native.container is not None
|
||||
and content.native.container.expires_at > dt_util.utcnow()
|
||||
and content.native.container.expires_at > datetime.now(UTC)
|
||||
):
|
||||
container_id = content.native.container.id
|
||||
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["apprise"],
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["apprise==1.11.0"]
|
||||
"requirements": ["apprise==1.9.1"]
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
"codeowners": [],
|
||||
"dependencies": ["mqtt"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/arwn",
|
||||
"iot_class": "local_push",
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["arwn-client==0.2.1"]
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "legacy"
|
||||
}
|
||||
|
||||
@@ -3,26 +3,113 @@
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from arwn_client import parse_message
|
||||
|
||||
from homeassistant.components import mqtt
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import DEGREE, UnitOfPrecipitationDepth, UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
from homeassistant.util import slugify
|
||||
from homeassistant.util.json import json_loads_object
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DOMAIN = "arwn"
|
||||
|
||||
DATA_ARWN = "arwn"
|
||||
TOPIC = "arwn/#"
|
||||
|
||||
|
||||
def discover_sensors(topic: str, payload: dict[str, Any]) -> list[ArwnSensor] | None:
|
||||
"""Given a topic, dynamically create the right sensor type.
|
||||
|
||||
Async friendly.
|
||||
"""
|
||||
parts = topic.split("/")
|
||||
unit = payload.get("units", "")
|
||||
domain = parts[1]
|
||||
if domain == "temperature":
|
||||
name = parts[2]
|
||||
if unit == "F":
|
||||
unit = UnitOfTemperature.FAHRENHEIT
|
||||
else:
|
||||
unit = UnitOfTemperature.CELSIUS
|
||||
return [
|
||||
ArwnSensor(
|
||||
topic, name, "temp", unit, device_class=SensorDeviceClass.TEMPERATURE
|
||||
)
|
||||
]
|
||||
if domain == "moisture":
|
||||
name = f"{parts[2]} Moisture"
|
||||
return [ArwnSensor(topic, name, "moisture", unit, "mdi:water-percent")]
|
||||
if domain == "rain":
|
||||
if len(parts) >= 3 and parts[2] == "today":
|
||||
return [
|
||||
ArwnSensor(
|
||||
topic,
|
||||
"Rain Since Midnight",
|
||||
"since_midnight",
|
||||
UnitOfPrecipitationDepth.INCHES,
|
||||
device_class=SensorDeviceClass.PRECIPITATION,
|
||||
)
|
||||
]
|
||||
return [
|
||||
ArwnSensor(
|
||||
topic + "/total",
|
||||
"Total Rainfall",
|
||||
"total",
|
||||
unit,
|
||||
device_class=SensorDeviceClass.PRECIPITATION,
|
||||
),
|
||||
ArwnSensor(
|
||||
topic + "/rate",
|
||||
"Rainfall Rate",
|
||||
"rate",
|
||||
unit,
|
||||
device_class=SensorDeviceClass.PRECIPITATION,
|
||||
),
|
||||
]
|
||||
if domain == "barometer":
|
||||
return [
|
||||
ArwnSensor(topic, "Barometer", "pressure", unit, "mdi:thermometer-lines")
|
||||
]
|
||||
if domain == "wind":
|
||||
return [
|
||||
ArwnSensor(
|
||||
topic + "/speed",
|
||||
"Wind Speed",
|
||||
"speed",
|
||||
unit,
|
||||
device_class=SensorDeviceClass.WIND_SPEED,
|
||||
),
|
||||
ArwnSensor(
|
||||
topic + "/gust",
|
||||
"Wind Gust",
|
||||
"gust",
|
||||
unit,
|
||||
device_class=SensorDeviceClass.WIND_SPEED,
|
||||
),
|
||||
ArwnSensor(
|
||||
topic + "/dir",
|
||||
"Wind Direction",
|
||||
"direction",
|
||||
DEGREE,
|
||||
"mdi:compass",
|
||||
device_class=SensorDeviceClass.WIND_DIRECTION,
|
||||
state_class=SensorStateClass.MEASUREMENT_ANGLE,
|
||||
),
|
||||
]
|
||||
return None
|
||||
|
||||
|
||||
def _slug(name: str) -> str:
|
||||
return f"sensor.arwn_{slugify(name)}"
|
||||
|
||||
|
||||
async def async_setup_platform(
|
||||
hass: HomeAssistant,
|
||||
config: ConfigType,
|
||||
@@ -31,25 +118,28 @@ async def async_setup_platform(
|
||||
) -> None:
|
||||
"""Set up the ARWN platform."""
|
||||
|
||||
# Make sure MQTT integration is enabled and the client is available
|
||||
if not await mqtt.async_wait_for_mqtt_client(hass):
|
||||
_LOGGER.error("MQTT integration is not available")
|
||||
return
|
||||
|
||||
@callback
|
||||
def async_sensor_event_received(msg: mqtt.ReceiveMessage) -> None:
|
||||
"""Process MQTT events as sensors."""
|
||||
try:
|
||||
event = json_loads_object(msg.payload)
|
||||
device = parse_message(msg.topic, event)
|
||||
except Exception: # noqa: BLE001
|
||||
_LOGGER.debug(
|
||||
"Failed to parse ARWN message on topic %s",
|
||||
msg.topic,
|
||||
exc_info=True,
|
||||
)
|
||||
return
|
||||
"""Process events as sensors.
|
||||
|
||||
if device is None:
|
||||
When a new event on our topic (arwn/#) is received we map it
|
||||
into a known kind of sensor based on topic name. If we've
|
||||
never seen this before, we keep this sensor around in a global
|
||||
cache. If we have seen it before, we update the values of the
|
||||
existing sensor. Either way, we push an ha state update at the
|
||||
end for the new event we've seen.
|
||||
|
||||
This lets us dynamically incorporate sensors without any
|
||||
configuration on our side.
|
||||
"""
|
||||
event = json_loads_object(msg.payload)
|
||||
sensors = discover_sensors(msg.topic, event)
|
||||
if not sensors:
|
||||
return
|
||||
|
||||
if (store := hass.data.get(DATA_ARWN)) is None:
|
||||
@@ -58,71 +148,22 @@ async def async_setup_platform(
|
||||
if "timestamp" in event:
|
||||
del event["timestamp"]
|
||||
|
||||
new_sensors: list[ArwnSensor] = []
|
||||
for reading in device.readings:
|
||||
if not reading.expose:
|
||||
continue
|
||||
|
||||
unique_id = (
|
||||
f"{msg.topic}/{reading.sensor_key}"
|
||||
if len(device.readings) > 1
|
||||
else msg.topic
|
||||
)
|
||||
|
||||
try:
|
||||
device_class = (
|
||||
SensorDeviceClass(reading.device_class)
|
||||
if reading.device_class
|
||||
else None
|
||||
)
|
||||
except ValueError:
|
||||
_LOGGER.debug(
|
||||
"Unknown device_class=%s for sensor %s",
|
||||
reading.device_class,
|
||||
reading.sensor_name,
|
||||
)
|
||||
device_class = None
|
||||
|
||||
try:
|
||||
state_class = (
|
||||
SensorStateClass(reading.state_class)
|
||||
if reading.state_class
|
||||
else None
|
||||
)
|
||||
except ValueError:
|
||||
_LOGGER.debug(
|
||||
"Unknown state_class=%s for sensor %s",
|
||||
reading.state_class,
|
||||
reading.sensor_name,
|
||||
)
|
||||
state_class = None
|
||||
|
||||
if unique_id not in store:
|
||||
sensor = ArwnSensor(
|
||||
unique_id=unique_id,
|
||||
name=reading.sensor_name,
|
||||
state_key=reading.sensor_key,
|
||||
units=reading.unit,
|
||||
icon=reading.icon,
|
||||
device_class=device_class,
|
||||
state_class=state_class,
|
||||
event=event,
|
||||
)
|
||||
store[unique_id] = sensor
|
||||
for sensor in sensors:
|
||||
if sensor.name not in store:
|
||||
sensor.hass = hass
|
||||
sensor.set_event(event)
|
||||
store[sensor.name] = sensor
|
||||
_LOGGER.debug(
|
||||
"Registering sensor %(name)s => %(event)s",
|
||||
{"name": reading.sensor_name, "event": event},
|
||||
{"name": sensor.name, "event": event},
|
||||
)
|
||||
new_sensors.append(sensor)
|
||||
async_add_entities((sensor,), True)
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"Recording sensor %(name)s => %(event)s",
|
||||
{"name": reading.sensor_name, "event": event},
|
||||
{"name": sensor.name, "event": event},
|
||||
)
|
||||
store[unique_id].set_event(event)
|
||||
|
||||
if new_sensors:
|
||||
async_add_entities(new_sensors, True)
|
||||
store[sensor.name].set_event(event)
|
||||
|
||||
await mqtt.async_subscribe(hass, TOPIC, async_sensor_event_received, 0)
|
||||
|
||||
@@ -134,29 +175,29 @@ class ArwnSensor(SensorEntity):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
unique_id: str,
|
||||
topic: str,
|
||||
name: str,
|
||||
state_key: str,
|
||||
units: str,
|
||||
icon: str | None = None,
|
||||
device_class: SensorDeviceClass | None = None,
|
||||
state_class: SensorStateClass | None = None,
|
||||
event: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
self.entity_id = _slug(name)
|
||||
self._attr_name = name
|
||||
self._attr_unique_id = unique_id
|
||||
# This mqtt topic for the sensor which is its uid
|
||||
self._attr_unique_id = topic
|
||||
self._state_key = state_key
|
||||
self._attr_native_unit_of_measurement = units
|
||||
self._attr_icon = icon
|
||||
self._attr_device_class = device_class
|
||||
self._attr_state_class = state_class
|
||||
if event is not None:
|
||||
self._attr_extra_state_attributes = dict(event)
|
||||
self._attr_native_value = event.get(state_key)
|
||||
|
||||
def set_event(self, event: dict[str, Any]) -> None:
|
||||
"""Update the sensor with the most recent event."""
|
||||
self._attr_extra_state_attributes = dict(event)
|
||||
self._attr_native_value = event.get(self._state_key)
|
||||
ev: dict[str, Any] = {}
|
||||
ev.update(event)
|
||||
self._attr_extra_state_attributes = ev
|
||||
self._attr_native_value = ev.get(self._state_key)
|
||||
self.async_write_ha_state()
|
||||
|
||||
@@ -30,5 +30,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["pubnub", "yalexs"],
|
||||
"requirements": ["yalexs==9.2.7", "yalexs-ble==3.3.0"]
|
||||
"requirements": ["yalexs==9.2.1", "yalexs-ble==3.3.0"]
|
||||
}
|
||||
|
||||
@@ -2,18 +2,12 @@
|
||||
|
||||
import avea
|
||||
|
||||
from homeassistant.components.bluetooth import (
|
||||
BluetoothReachabilityIntent,
|
||||
async_address_reachability_diagnostics,
|
||||
async_ble_device_from_address,
|
||||
)
|
||||
from homeassistant.components.bluetooth import async_ble_device_from_address
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_ADDRESS, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
type AveaConfigEntry = ConfigEntry[avea.Bulb]
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.LIGHT]
|
||||
@@ -21,20 +15,12 @@ PLATFORMS: list[Platform] = [Platform.LIGHT]
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: AveaConfigEntry) -> bool:
|
||||
"""Set up Avea from a config entry."""
|
||||
address = entry.data[CONF_ADDRESS]
|
||||
ble_device = async_ble_device_from_address(hass, address, connectable=True)
|
||||
ble_device = async_ble_device_from_address(
|
||||
hass, entry.data[CONF_ADDRESS], connectable=True
|
||||
)
|
||||
if not ble_device:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="device_not_found",
|
||||
translation_placeholders={
|
||||
"address": address,
|
||||
"reason": async_address_reachability_diagnostics(
|
||||
hass,
|
||||
address.upper(),
|
||||
BluetoothReachabilityIntent.CONNECTION,
|
||||
),
|
||||
},
|
||||
f"Could not find Avea device with address {entry.data[CONF_ADDRESS]}"
|
||||
)
|
||||
|
||||
entry.runtime_data = avea.Bulb(ble_device)
|
||||
|
||||
@@ -8,7 +8,6 @@ import avea
|
||||
from bleak.exc import BleakError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import bluetooth
|
||||
from homeassistant.components.bluetooth import (
|
||||
BluetoothServiceInfoBleak,
|
||||
async_discovered_service_info,
|
||||
@@ -67,15 +66,6 @@ def _is_avea_discovery(discovery_info: BluetoothServiceInfoBleak) -> bool:
|
||||
return AVEA_SERVICE_UUID in discovery_info.service_uuids
|
||||
|
||||
|
||||
def _discovery_label(discovery_info: BluetoothServiceInfoBleak) -> str:
|
||||
"""Return a label for a discovered Avea bulb."""
|
||||
if (
|
||||
name := _normalize_name(discovery_info.name)
|
||||
) and name != discovery_info.address:
|
||||
return f"{name} ({discovery_info.address})"
|
||||
return discovery_info.address
|
||||
|
||||
|
||||
class AveaConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Avea."""
|
||||
|
||||
@@ -160,7 +150,6 @@ class AveaConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
if discovery := self._discovery_info:
|
||||
self._discovered_devices[discovery.address] = discovery
|
||||
else:
|
||||
await bluetooth.async_request_active_scan(self.hass)
|
||||
current_addresses = self._async_current_ids(include_ignore=False)
|
||||
for discovery in async_discovered_service_info(self.hass):
|
||||
if (
|
||||
@@ -176,10 +165,11 @@ class AveaConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
if self._discovery_info:
|
||||
disc = self._discovery_info
|
||||
label = f"{disc.name or disc.address} ({disc.address})"
|
||||
data_schema = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_ADDRESS, default=disc.address): vol.In(
|
||||
{disc.address: _discovery_label(disc)}
|
||||
{disc.address: label}
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -188,7 +178,10 @@ class AveaConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
{
|
||||
vol.Required(CONF_ADDRESS): vol.In(
|
||||
{
|
||||
service_info.address: _discovery_label(service_info)
|
||||
service_info.address: (
|
||||
f"{service_info.name or service_info.address}"
|
||||
f" ({service_info.address})"
|
||||
)
|
||||
for service_info in self._discovered_devices.values()
|
||||
}
|
||||
),
|
||||
|
||||
@@ -22,11 +22,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"device_not_found": {
|
||||
"message": "Could not find Avea device with address {address}: {reason}"
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_yaml": {
|
||||
"description": "[%key:component::homeassistant::issues::deprecated_yaml::description%]",
|
||||
|
||||
@@ -6,6 +6,7 @@ from blebox_uniapi.box import Box
|
||||
from blebox_uniapi.error import Error
|
||||
from blebox_uniapi.session import ApiHost
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
CONF_HOST,
|
||||
CONF_PASSWORD,
|
||||
@@ -17,9 +18,10 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
|
||||
from .const import DEFAULT_SETUP_TIMEOUT
|
||||
from .coordinator import BleBoxConfigEntry, BleBoxCoordinator
|
||||
from .helpers import get_maybe_authenticated_session
|
||||
|
||||
type BleBoxConfigEntry = ConfigEntry[Box]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS = [
|
||||
@@ -33,6 +35,8 @@ PLATFORMS = [
|
||||
Platform.UPDATE,
|
||||
]
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: BleBoxConfigEntry) -> bool:
|
||||
"""Set up BleBox devices from a config entry."""
|
||||
@@ -54,10 +58,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: BleBoxConfigEntry) -> bo
|
||||
_LOGGER.error("Identify failed at %s:%d (%s)", api_host.host, api_host.port, ex)
|
||||
raise ConfigEntryNotReady from ex
|
||||
|
||||
coordinator = BleBoxCoordinator(hass, entry, product)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
entry.runtime_data = coordinator
|
||||
entry.runtime_data = product
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
|
||||
@@ -11,11 +11,8 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import BleBoxConfigEntry
|
||||
from .coordinator import BleBoxCoordinator
|
||||
from .entity import BleBoxEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
BINARY_SENSOR_TYPES = (
|
||||
BinarySensorEntityDescription(
|
||||
key="moisture",
|
||||
@@ -30,27 +27,23 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up a BleBox entry."""
|
||||
coordinator = config_entry.runtime_data
|
||||
entities = [
|
||||
BleBoxBinarySensorEntity(coordinator, feature, description)
|
||||
for feature in coordinator.box.features.get("binary_sensors", [])
|
||||
BleBoxBinarySensorEntity(feature, description)
|
||||
for feature in config_entry.runtime_data.features.get("binary_sensors", [])
|
||||
for description in BINARY_SENSOR_TYPES
|
||||
if description.key == feature.device_class
|
||||
]
|
||||
async_add_entities(entities)
|
||||
async_add_entities(entities, True)
|
||||
|
||||
|
||||
class BleBoxBinarySensorEntity(BleBoxEntity[BinarySensorFeature], BinarySensorEntity):
|
||||
"""Representation of a BleBox binary sensor feature."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: BleBoxCoordinator,
|
||||
feature: BinarySensorFeature,
|
||||
description: BinarySensorEntityDescription,
|
||||
self, feature: BinarySensorFeature, description: BinarySensorEntityDescription
|
||||
) -> None:
|
||||
"""Initialize a BleBox binary sensor feature."""
|
||||
super().__init__(coordinator, feature)
|
||||
super().__init__(feature)
|
||||
self.entity_description = description
|
||||
|
||||
@property
|
||||
|
||||
@@ -7,11 +7,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import BleBoxConfigEntry
|
||||
from .coordinator import BleBoxCoordinator
|
||||
from .entity import BleBoxEntity
|
||||
from .util import blebox_command
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -20,22 +16,19 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up a BleBox button entry."""
|
||||
coordinator = config_entry.runtime_data
|
||||
entities = [
|
||||
BleBoxButtonEntity(coordinator, feature)
|
||||
for feature in coordinator.box.features.get("buttons", [])
|
||||
BleBoxButtonEntity(feature)
|
||||
for feature in config_entry.runtime_data.features.get("buttons", [])
|
||||
]
|
||||
async_add_entities(entities)
|
||||
async_add_entities(entities, True)
|
||||
|
||||
|
||||
class BleBoxButtonEntity(BleBoxEntity[blebox_uniapi.button.Button], ButtonEntity):
|
||||
"""Representation of BleBox buttons."""
|
||||
|
||||
def __init__(
|
||||
self, coordinator: BleBoxCoordinator, feature: blebox_uniapi.button.Button
|
||||
) -> None:
|
||||
def __init__(self, feature: blebox_uniapi.button.Button) -> None:
|
||||
"""Initialize a BleBox button feature."""
|
||||
super().__init__(coordinator, feature)
|
||||
super().__init__(feature)
|
||||
self._attr_icon = self.get_icon()
|
||||
|
||||
def get_icon(self) -> str | None:
|
||||
@@ -52,7 +45,6 @@ class BleBoxButtonEntity(BleBoxEntity[blebox_uniapi.button.Button], ButtonEntity
|
||||
return "mdi:arrow-down-circle"
|
||||
return None
|
||||
|
||||
@blebox_command
|
||||
async def async_press(self) -> None:
|
||||
"""Handle the button press."""
|
||||
await self._feature.set()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""BleBox climate entity."""
|
||||
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
import blebox_uniapi.climate
|
||||
@@ -16,9 +17,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import BleBoxConfigEntry
|
||||
from .entity import BleBoxEntity
|
||||
from .util import blebox_command
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
SCAN_INTERVAL = timedelta(seconds=5)
|
||||
|
||||
BLEBOX_TO_HVACMODE = {
|
||||
0: HVACMode.OFF,
|
||||
@@ -40,12 +40,11 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up a BleBox climate entity."""
|
||||
coordinator = config_entry.runtime_data
|
||||
entities = [
|
||||
BleBoxClimateEntity(coordinator, feature)
|
||||
for feature in coordinator.box.features.get("climates", [])
|
||||
BleBoxClimateEntity(feature)
|
||||
for feature in config_entry.runtime_data.features.get("climates", [])
|
||||
]
|
||||
async_add_entities(entities)
|
||||
async_add_entities(entities, True)
|
||||
|
||||
|
||||
class BleBoxClimateEntity(BleBoxEntity[blebox_uniapi.climate.Climate], ClimateEntity):
|
||||
@@ -109,7 +108,6 @@ class BleBoxClimateEntity(BleBoxEntity[blebox_uniapi.climate.Climate], ClimateEn
|
||||
"""Return the desired thermostat temperature."""
|
||||
return self._feature.desired
|
||||
|
||||
@blebox_command
|
||||
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||
"""Set the climate entity mode."""
|
||||
if hvac_mode in [HVACMode.HEAT, HVACMode.COOL]:
|
||||
@@ -118,7 +116,6 @@ class BleBoxClimateEntity(BleBoxEntity[blebox_uniapi.climate.Climate], ClimateEn
|
||||
|
||||
await self._feature.async_off()
|
||||
|
||||
@blebox_command
|
||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||
"""Set the thermostat temperature."""
|
||||
value = kwargs[ATTR_TEMPERATURE]
|
||||
|
||||
@@ -33,14 +33,23 @@ from .const import (
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
STEP_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_HOST, default=DEFAULT_HOST): str,
|
||||
vol.Required(CONF_PORT, default=DEFAULT_PORT): int,
|
||||
vol.Inclusive(CONF_USERNAME, "auth"): str,
|
||||
vol.Inclusive(CONF_PASSWORD, "auth"): str,
|
||||
}
|
||||
)
|
||||
def create_schema(previous_input=None):
|
||||
"""Create a schema with given values as default."""
|
||||
if previous_input is not None:
|
||||
host = previous_input[CONF_HOST]
|
||||
port = previous_input[CONF_PORT]
|
||||
else:
|
||||
host = DEFAULT_HOST
|
||||
port = DEFAULT_PORT
|
||||
|
||||
return vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_HOST, default=host): str,
|
||||
vol.Required(CONF_PORT, default=port): int,
|
||||
vol.Inclusive(CONF_USERNAME, "auth"): str,
|
||||
vol.Inclusive(CONF_PASSWORD, "auth"): str,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
LOG_MSG = {
|
||||
@@ -60,44 +69,18 @@ class BleBoxConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
self.device_config: dict[str, Any] = {}
|
||||
|
||||
def handle_step_exception(
|
||||
self, exception, schema, host, port, message_id, log_fn, step_id
|
||||
self, step, exception, schema, host, port, message_id, log_fn
|
||||
):
|
||||
"""Handle step exceptions."""
|
||||
log_fn("%s at %s:%d (%s)", LOG_MSG[message_id], host, port, exception)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id=step_id,
|
||||
step_id="user",
|
||||
data_schema=schema,
|
||||
errors={"base": message_id},
|
||||
description_placeholders={"address": f"{host}:{port}"},
|
||||
)
|
||||
|
||||
async def _async_from_host_or_form(
|
||||
self, api_host: ApiHost, user_input: dict[str, Any], step_id: str
|
||||
) -> tuple[Box, None] | tuple[None, ConfigFlowResult]:
|
||||
"""Try to connect to the device; return product or an error form."""
|
||||
schema = self.add_suggested_values_to_schema(STEP_SCHEMA, user_input)
|
||||
host = user_input[CONF_HOST]
|
||||
port = user_input[CONF_PORT]
|
||||
try:
|
||||
return await Box.async_from_host(api_host), None
|
||||
except UnsupportedBoxVersion as ex:
|
||||
return None, self.handle_step_exception(
|
||||
ex, schema, host, port, UNSUPPORTED_VERSION, _LOGGER.debug, step_id
|
||||
)
|
||||
except UnauthorizedRequest as ex:
|
||||
return None, self.handle_step_exception(
|
||||
ex, schema, host, port, CANNOT_CONNECT, _LOGGER.error, step_id
|
||||
)
|
||||
except Error as ex:
|
||||
return None, self.handle_step_exception(
|
||||
ex, schema, host, port, CANNOT_CONNECT, _LOGGER.warning, step_id
|
||||
)
|
||||
except RuntimeError as ex:
|
||||
return None, self.handle_step_exception(
|
||||
ex, schema, host, port, UNKNOWN, _LOGGER.error, step_id
|
||||
)
|
||||
|
||||
async def async_step_zeroconf(
|
||||
self, discovery_info: ZeroconfServiceInfo
|
||||
) -> ConfigFlowResult:
|
||||
@@ -162,11 +145,12 @@ class BleBoxConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle initial user-triggered config step."""
|
||||
hass = self.hass
|
||||
schema = create_schema(user_input)
|
||||
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=STEP_SCHEMA,
|
||||
data_schema=schema,
|
||||
errors={},
|
||||
description_placeholders={},
|
||||
)
|
||||
@@ -189,60 +173,36 @@ class BleBoxConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
api_host = ApiHost(
|
||||
host, port, DEFAULT_SETUP_TIMEOUT, websession, hass.loop, _LOGGER
|
||||
)
|
||||
product, error = await self._async_from_host_or_form(
|
||||
api_host, user_input, step_id="user"
|
||||
)
|
||||
if error is not None:
|
||||
return error
|
||||
assert product is not None
|
||||
try:
|
||||
product = await Box.async_from_host(api_host)
|
||||
|
||||
except UnsupportedBoxVersion as ex:
|
||||
return self.handle_step_exception(
|
||||
"user",
|
||||
ex,
|
||||
schema,
|
||||
host,
|
||||
port,
|
||||
UNSUPPORTED_VERSION,
|
||||
_LOGGER.debug,
|
||||
)
|
||||
except UnauthorizedRequest as ex:
|
||||
return self.handle_step_exception(
|
||||
"user", ex, schema, host, port, CANNOT_CONNECT, _LOGGER.error
|
||||
)
|
||||
|
||||
except Error as ex:
|
||||
return self.handle_step_exception(
|
||||
"user", ex, schema, host, port, CANNOT_CONNECT, _LOGGER.warning
|
||||
)
|
||||
|
||||
except RuntimeError as ex:
|
||||
return self.handle_step_exception(
|
||||
"user", ex, schema, host, port, UNKNOWN, _LOGGER.error
|
||||
)
|
||||
|
||||
# Check if configured but IP changed since
|
||||
await self.async_set_unique_id(product.unique_id, raise_on_progress=False)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
return self.async_create_entry(title=product.name, data=user_input)
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reconfiguration of a BleBox device."""
|
||||
reconfigure_entry = self._get_reconfigure_entry()
|
||||
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
step_id="reconfigure",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
STEP_SCHEMA, reconfigure_entry.data
|
||||
),
|
||||
)
|
||||
|
||||
host = user_input[CONF_HOST]
|
||||
port = user_input[CONF_PORT]
|
||||
|
||||
username = user_input.get(CONF_USERNAME)
|
||||
password = user_input.get(CONF_PASSWORD)
|
||||
websession = get_maybe_authenticated_session(self.hass, password, username)
|
||||
api_host = ApiHost(
|
||||
host, port, DEFAULT_SETUP_TIMEOUT, websession, self.hass.loop, _LOGGER
|
||||
)
|
||||
|
||||
product, error = await self._async_from_host_or_form(
|
||||
api_host, user_input, step_id="reconfigure"
|
||||
)
|
||||
if error is not None:
|
||||
return error
|
||||
assert product is not None
|
||||
|
||||
await self.async_set_unique_id(product.unique_id, raise_on_progress=False)
|
||||
self._abort_if_unique_id_mismatch()
|
||||
|
||||
data_updates: dict[str, Any] = {CONF_HOST: host, CONF_PORT: port}
|
||||
if username is not None:
|
||||
data_updates[CONF_USERNAME] = username
|
||||
if password is not None:
|
||||
data_updates[CONF_PASSWORD] = password
|
||||
|
||||
return self.async_update_reload_and_abort(
|
||||
reconfigure_entry,
|
||||
data_updates=data_updates,
|
||||
)
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
"""DataUpdateCoordinator for BleBox devices."""
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from blebox_uniapi.box import Box
|
||||
from blebox_uniapi.error import Error
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
type BleBoxConfigEntry = ConfigEntry[BleBoxCoordinator]
|
||||
|
||||
|
||||
class BleBoxCoordinator(DataUpdateCoordinator[None]):
|
||||
"""Coordinator for a single BleBox device."""
|
||||
|
||||
config_entry: BleBoxConfigEntry
|
||||
|
||||
def __init__(
|
||||
self, hass: HomeAssistant, config_entry: BleBoxConfigEntry, box: Box
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=config_entry,
|
||||
name=DOMAIN,
|
||||
update_interval=timedelta(seconds=5),
|
||||
)
|
||||
self.box = box
|
||||
|
||||
async def _async_update_data(self) -> None:
|
||||
"""Fetch data from the BleBox device."""
|
||||
try:
|
||||
await self.box.async_update_data()
|
||||
except Error as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_failed",
|
||||
translation_placeholders={"error": str(err)},
|
||||
) from err
|
||||
@@ -17,11 +17,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import BleBoxConfigEntry
|
||||
from .coordinator import BleBoxCoordinator
|
||||
from .entity import BleBoxEntity
|
||||
from .util import blebox_command
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
BLEBOX_TO_COVER_DEVICE_CLASSES = {
|
||||
"gate": CoverDeviceClass.GATE,
|
||||
@@ -63,22 +59,19 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up a BleBox entry."""
|
||||
coordinator = config_entry.runtime_data
|
||||
entities = [
|
||||
BleBoxCoverEntity(coordinator, feature)
|
||||
for feature in coordinator.box.features.get("covers", [])
|
||||
BleBoxCoverEntity(feature)
|
||||
for feature in config_entry.runtime_data.features.get("covers", [])
|
||||
]
|
||||
async_add_entities(entities)
|
||||
async_add_entities(entities, True)
|
||||
|
||||
|
||||
class BleBoxCoverEntity(BleBoxEntity[blebox_uniapi.cover.Cover], CoverEntity):
|
||||
"""Representation of a BleBox cover feature."""
|
||||
|
||||
def __init__(
|
||||
self, coordinator: BleBoxCoordinator, feature: blebox_uniapi.cover.Cover
|
||||
) -> None:
|
||||
def __init__(self, feature: blebox_uniapi.cover.Cover) -> None:
|
||||
"""Initialize a BleBox cover feature."""
|
||||
super().__init__(coordinator, feature)
|
||||
super().__init__(feature)
|
||||
self._attr_supported_features = (
|
||||
CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE
|
||||
)
|
||||
@@ -142,40 +135,33 @@ class BleBoxCoverEntity(BleBoxEntity[blebox_uniapi.cover.Cover], CoverEntity):
|
||||
"""Return whether cover is closed."""
|
||||
return self._is_state(CoverState.CLOSED)
|
||||
|
||||
@blebox_command
|
||||
async def async_open_cover(self, **kwargs: Any) -> None:
|
||||
"""Fully open the cover position."""
|
||||
await self._feature.async_open()
|
||||
|
||||
@blebox_command
|
||||
async def async_close_cover(self, **kwargs: Any) -> None:
|
||||
"""Fully close the cover position."""
|
||||
await self._feature.async_close()
|
||||
|
||||
@blebox_command
|
||||
async def async_open_cover_tilt(self, **kwargs: Any) -> None:
|
||||
"""Fully open the cover tilt."""
|
||||
position = 50 if self._feature.is_tilt_180 else 0
|
||||
await self._feature.async_set_tilt_position(position)
|
||||
|
||||
@blebox_command
|
||||
async def async_close_cover_tilt(self, **kwargs: Any) -> None:
|
||||
"""Fully close the cover tilt."""
|
||||
# note: values are reversed
|
||||
await self._feature.async_set_tilt_position(100)
|
||||
|
||||
@blebox_command
|
||||
async def async_set_cover_position(self, **kwargs: Any) -> None:
|
||||
"""Set the cover position."""
|
||||
position = kwargs[ATTR_POSITION]
|
||||
await self._feature.async_set_position(100 - position)
|
||||
|
||||
@blebox_command
|
||||
async def async_stop_cover(self, **kwargs: Any) -> None:
|
||||
"""Stop the cover."""
|
||||
await self._feature.async_stop()
|
||||
|
||||
@blebox_command
|
||||
async def async_set_cover_tilt_position(self, **kwargs: Any) -> None:
|
||||
"""Set the tilt position."""
|
||||
position = kwargs[ATTR_TILT_POSITION]
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
"""Base entity for the BleBox devices integration."""
|
||||
|
||||
import logging
|
||||
|
||||
from blebox_uniapi.error import Error
|
||||
from blebox_uniapi.feature import Feature
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from homeassistant.helpers.entity import Entity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import BleBoxCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BleBoxEntity[_FeatureT: Feature](CoordinatorEntity[BleBoxCoordinator]):
|
||||
class BleBoxEntity[_FeatureT: Feature](Entity):
|
||||
"""Implements a common class for entities representing a BleBox feature."""
|
||||
|
||||
def __init__(self, coordinator: BleBoxCoordinator, feature: _FeatureT) -> None:
|
||||
def __init__(self, feature: _FeatureT) -> None:
|
||||
"""Initialize a BleBox entity."""
|
||||
super().__init__(coordinator)
|
||||
self._feature = feature
|
||||
self._attr_name = feature.full_name
|
||||
self._attr_unique_id = feature.unique_id
|
||||
@@ -27,3 +30,10 @@ class BleBoxEntity[_FeatureT: Feature](CoordinatorEntity[BleBoxCoordinator]):
|
||||
sw_version=product.firmware_version,
|
||||
configuration_url=f"http://{product.address}",
|
||||
)
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Update the entity state."""
|
||||
try:
|
||||
await self._feature.async_update()
|
||||
except Error as ex:
|
||||
_LOGGER.error("Updating '%s' failed: %s", self.name, ex)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""BleBox light entities implementation."""
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
import math
|
||||
from typing import Any
|
||||
@@ -23,13 +24,11 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import BleBoxConfigEntry
|
||||
from .const import LIGHT_MAX_KELVINS, LIGHT_MIN_KELVINS
|
||||
from .coordinator import BleBoxCoordinator
|
||||
from .entity import BleBoxEntity
|
||||
from .util import blebox_command
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
SCAN_INTERVAL = timedelta(seconds=5)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -38,12 +37,11 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up a BleBox entry."""
|
||||
coordinator = config_entry.runtime_data
|
||||
entities = [
|
||||
BleBoxLightEntity(coordinator, feature)
|
||||
for feature in coordinator.box.features.get("lights", [])
|
||||
BleBoxLightEntity(feature)
|
||||
for feature in config_entry.runtime_data.features.get("lights", [])
|
||||
]
|
||||
async_add_entities(entities)
|
||||
async_add_entities(entities, True)
|
||||
|
||||
|
||||
COLOR_MODE_MAP = {
|
||||
@@ -63,11 +61,9 @@ class BleBoxLightEntity(BleBoxEntity[blebox_uniapi.light.Light], LightEntity):
|
||||
_attr_min_color_temp_kelvin = LIGHT_MIN_KELVINS
|
||||
_attr_max_color_temp_kelvin = LIGHT_MAX_KELVINS
|
||||
|
||||
def __init__(
|
||||
self, coordinator: BleBoxCoordinator, feature: blebox_uniapi.light.Light
|
||||
) -> None:
|
||||
def __init__(self, feature: blebox_uniapi.light.Light) -> None:
|
||||
"""Initialize a BleBox light."""
|
||||
super().__init__(coordinator, feature)
|
||||
super().__init__(feature)
|
||||
if feature.effect_list:
|
||||
self._attr_supported_features = LightEntityFeature.EFFECT
|
||||
|
||||
@@ -169,7 +165,6 @@ class BleBoxLightEntity(BleBoxEntity[blebox_uniapi.light.Light], LightEntity):
|
||||
return None
|
||||
return tuple(blebox_uniapi.light.Light.rgb_hex_to_rgb_list(rgbww_hex))
|
||||
|
||||
@blebox_command
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the light on."""
|
||||
|
||||
@@ -229,7 +224,6 @@ class BleBoxLightEntity(BleBoxEntity[blebox_uniapi.light.Light], LightEntity):
|
||||
" effect list."
|
||||
) from exc
|
||||
|
||||
@blebox_command
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the light off."""
|
||||
await self._feature.async_off()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""BleBox sensor entities."""
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import blebox_uniapi.sensor
|
||||
|
||||
@@ -28,10 +28,9 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import BleBoxConfigEntry
|
||||
from .coordinator import BleBoxCoordinator
|
||||
from .entity import BleBoxEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
SCAN_INTERVAL = timedelta(seconds=5)
|
||||
|
||||
|
||||
SENSOR_TYPES = (
|
||||
@@ -125,14 +124,13 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up a BleBox entry."""
|
||||
coordinator = config_entry.runtime_data
|
||||
entities = [
|
||||
BleBoxSensorEntity(coordinator, feature, description)
|
||||
for feature in coordinator.box.features.get("sensors", [])
|
||||
BleBoxSensorEntity(feature, description)
|
||||
for feature in config_entry.runtime_data.features.get("sensors", [])
|
||||
for description in SENSOR_TYPES
|
||||
if description.key == feature.device_class
|
||||
]
|
||||
async_add_entities(entities)
|
||||
async_add_entities(entities, True)
|
||||
|
||||
|
||||
class BleBoxSensorEntity(BleBoxEntity[blebox_uniapi.sensor.BaseSensor], SensorEntity):
|
||||
@@ -140,12 +138,11 @@ class BleBoxSensorEntity(BleBoxEntity[blebox_uniapi.sensor.BaseSensor], SensorEn
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: BleBoxCoordinator,
|
||||
feature: blebox_uniapi.sensor.BaseSensor,
|
||||
description: SensorEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize a BleBox sensor feature."""
|
||||
super().__init__(coordinator, feature)
|
||||
super().__init__(feature)
|
||||
self.entity_description = description
|
||||
|
||||
@property
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
"config": {
|
||||
"abort": {
|
||||
"address_already_configured": "A BleBox device is already configured at {address}.",
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
|
||||
"unique_id_mismatch": "The device identifier does not match the previously configured device."
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
@@ -13,16 +11,6 @@
|
||||
},
|
||||
"flow_title": "{name} ({host})",
|
||||
"step": {
|
||||
"reconfigure": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::ip%]",
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"port": "[%key:common::config_flow::data::port%]",
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
},
|
||||
"description": "Update the connection settings for your BleBox device.",
|
||||
"title": "Reconfigure BleBox device"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::ip%]",
|
||||
@@ -34,10 +22,5 @@
|
||||
"title": "Set up your BleBox device"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"update_failed": {
|
||||
"message": "An error occurred while communicating with the BleBox device: {error}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""BleBox switch implementation."""
|
||||
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
import blebox_uniapi.switch
|
||||
@@ -10,9 +11,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import BleBoxConfigEntry
|
||||
from .entity import BleBoxEntity
|
||||
from .util import blebox_command
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
SCAN_INTERVAL = timedelta(seconds=5)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -21,12 +21,11 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up a BleBox switch entity."""
|
||||
coordinator = config_entry.runtime_data
|
||||
entities = [
|
||||
BleBoxSwitchEntity(coordinator, feature)
|
||||
for feature in coordinator.box.features.get("switches", [])
|
||||
BleBoxSwitchEntity(feature)
|
||||
for feature in config_entry.runtime_data.features.get("switches", [])
|
||||
]
|
||||
async_add_entities(entities)
|
||||
async_add_entities(entities, True)
|
||||
|
||||
|
||||
class BleBoxSwitchEntity(BleBoxEntity[blebox_uniapi.switch.Switch], SwitchEntity):
|
||||
@@ -39,12 +38,10 @@ class BleBoxSwitchEntity(BleBoxEntity[blebox_uniapi.switch.Switch], SwitchEntity
|
||||
"""Return whether switch is on."""
|
||||
return self._feature.is_on
|
||||
|
||||
@blebox_command
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn on the switch."""
|
||||
await self._feature.async_turn_on()
|
||||
|
||||
@blebox_command
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn off the switch."""
|
||||
await self._feature.async_turn_off()
|
||||
|
||||
@@ -18,10 +18,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.event import async_call_later
|
||||
|
||||
from . import BleBoxConfigEntry
|
||||
from .coordinator import BleBoxCoordinator
|
||||
from .entity import BleBoxEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
SCAN_INTERVAL = timedelta(hours=1)
|
||||
|
||||
|
||||
@@ -35,12 +33,11 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up a BleBox update entry."""
|
||||
coordinator = config_entry.runtime_data
|
||||
entities = [
|
||||
BleBoxUpdateEntity(coordinator, feature)
|
||||
for feature in coordinator.box.features.get("updates", [])
|
||||
BleBoxUpdateEntity(feature)
|
||||
for feature in config_entry.runtime_data.features.get("updates", [])
|
||||
]
|
||||
async_add_entities(entities, update_before_add=True)
|
||||
async_add_entities(entities, True)
|
||||
|
||||
|
||||
class BleBoxUpdateEntity(BleBoxEntity[blebox_uniapi.update.Update], UpdateEntity):
|
||||
@@ -51,16 +48,9 @@ class BleBoxUpdateEntity(BleBoxEntity[blebox_uniapi.update.Update], UpdateEntity
|
||||
UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS
|
||||
)
|
||||
|
||||
@property
|
||||
def should_poll(self) -> bool:
|
||||
"""Return True because firmware versions cannot be fetched via coordinator."""
|
||||
return True
|
||||
|
||||
def __init__(
|
||||
self, coordinator: BleBoxCoordinator, feature: blebox_uniapi.update.Update
|
||||
) -> None:
|
||||
def __init__(self, feature: blebox_uniapi.update.Update) -> None:
|
||||
"""Initialize the update entity."""
|
||||
super().__init__(coordinator, feature)
|
||||
super().__init__(feature)
|
||||
self._in_progress_old_version: str | None = None
|
||||
self._poll_cancel: CALLBACK_TYPE | None = None
|
||||
self._poll_attempts: int = 0
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
"""Utilities for BleBox."""
|
||||
|
||||
from collections.abc import Awaitable, Callable, Coroutine
|
||||
from typing import Any, Concatenate
|
||||
|
||||
from blebox_uniapi.error import Error
|
||||
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
from .entity import BleBoxEntity
|
||||
|
||||
|
||||
def blebox_command[_BleBoxEntityT: BleBoxEntity, **_P, _R](
|
||||
func: Callable[Concatenate[_BleBoxEntityT, _P], Awaitable[_R]],
|
||||
) -> Callable[Concatenate[_BleBoxEntityT, _P], Coroutine[Any, Any, _R]]:
|
||||
"""Decorate BleBox calls that send commands to the device.
|
||||
|
||||
Catches BleBox errors and refreshes the coordinator after the command.
|
||||
"""
|
||||
|
||||
async def handler(self: _BleBoxEntityT, *args: _P.args, **kwargs: _P.kwargs) -> _R:
|
||||
try:
|
||||
return await func(self, *args, **kwargs)
|
||||
except Error as err:
|
||||
raise HomeAssistantError(str(err)) from err
|
||||
finally:
|
||||
await self.coordinator.async_refresh()
|
||||
|
||||
return handler
|
||||
@@ -27,7 +27,6 @@ from bluetooth_data_tools import monotonic_time_coarse as MONOTONIC_TIME
|
||||
from habluetooth import (
|
||||
BaseHaRemoteScanner,
|
||||
BaseHaScanner,
|
||||
BluetoothReachabilityIntent,
|
||||
BluetoothScannerDevice,
|
||||
BluetoothScanningMode,
|
||||
HaBluetoothConnector,
|
||||
@@ -56,7 +55,6 @@ from . import passive_update_processor, websocket_api
|
||||
from .api import (
|
||||
_get_manager,
|
||||
async_address_present,
|
||||
async_address_reachability_diagnostics,
|
||||
async_ble_device_from_address,
|
||||
async_clear_address_from_match_history,
|
||||
async_clear_advertisement_history,
|
||||
@@ -110,14 +108,12 @@ __all__ = [
|
||||
"BluetoothCallback",
|
||||
"BluetoothCallbackMatcher",
|
||||
"BluetoothChange",
|
||||
"BluetoothReachabilityIntent",
|
||||
"BluetoothScannerDevice",
|
||||
"BluetoothScanningMode",
|
||||
"BluetoothServiceInfo",
|
||||
"BluetoothServiceInfoBleak",
|
||||
"HaBluetoothConnector",
|
||||
"async_address_present",
|
||||
"async_address_reachability_diagnostics",
|
||||
"async_ble_device_from_address",
|
||||
"async_clear_address_from_match_history",
|
||||
"async_clear_advertisement_history",
|
||||
|
||||
@@ -11,7 +11,6 @@ from typing import TYPE_CHECKING, cast
|
||||
from bleak import BleakScanner
|
||||
from habluetooth import (
|
||||
BaseHaScanner,
|
||||
BluetoothReachabilityIntent,
|
||||
BluetoothScannerDevice,
|
||||
BluetoothScanningMode,
|
||||
HaBleakScannerWrapper,
|
||||
@@ -109,14 +108,6 @@ def async_ble_device_from_address(
|
||||
return _get_manager(hass).async_ble_device_from_address(address, connectable)
|
||||
|
||||
|
||||
@hass_callback
|
||||
def async_address_reachability_diagnostics(
|
||||
hass: HomeAssistant, address: str, intent: BluetoothReachabilityIntent
|
||||
) -> str:
|
||||
"""Return a human readable explanation of why an address may be unreachable."""
|
||||
return _get_manager(hass).async_address_reachability_diagnostics(address, intent)
|
||||
|
||||
|
||||
@hass_callback
|
||||
def async_scanner_devices_by_address(
|
||||
hass: HomeAssistant, address: str, connectable: bool = True
|
||||
|
||||
@@ -21,6 +21,6 @@
|
||||
"bluetooth-auto-recovery==1.6.4",
|
||||
"bluetooth-data-tools==1.29.18",
|
||||
"dbus-fast==5.0.16",
|
||||
"habluetooth==6.8.1"
|
||||
"habluetooth==6.7.9"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
"name": "Sony Bravia TV",
|
||||
"codeowners": ["@bieniu", "@Drafteed"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["ssdp"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/braviatv",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
|
||||
@@ -66,10 +66,5 @@ async def get_cert_expiry_timestamp(
|
||||
except ssl.SSLError as err:
|
||||
raise ValidationFailure(err.args[0]) from err
|
||||
|
||||
if not cert or "notAfter" not in cert:
|
||||
raise ValidationFailure(
|
||||
f"No certificate expiration found for: {hostname}:{port}"
|
||||
)
|
||||
|
||||
ts_seconds = ssl.cert_time_to_seconds(cert["notAfter"])
|
||||
return dt_util.utc_from_timestamp(ts_seconds)
|
||||
|
||||
@@ -24,7 +24,6 @@ from homeassistant.components.alexa import (
|
||||
entities as alexa_entities,
|
||||
errors as alexa_errors,
|
||||
)
|
||||
from homeassistant.components.frontend import DATA_THEMES
|
||||
from homeassistant.components.google_assistant import helpers as google_helpers
|
||||
from homeassistant.components.homeassistant import exposed_entities
|
||||
from homeassistant.components.http import KEY_HASS, HomeAssistantView, require_admin
|
||||
@@ -509,15 +508,6 @@ class DownloadSupportPackageView(HomeAssistantView):
|
||||
"custom_integrations": custom_integrations,
|
||||
}
|
||||
|
||||
@callback
|
||||
def _get_themes_info(self, hass: HomeAssistant) -> dict[str, Any]:
|
||||
"""Collect information about user-installed custom themes."""
|
||||
themes: dict[str, Any] = hass.data.get(DATA_THEMES, {})
|
||||
return {
|
||||
"count": len(themes),
|
||||
"themes": sorted(themes),
|
||||
}
|
||||
|
||||
async def _generate_markdown(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
@@ -579,25 +569,6 @@ class DownloadSupportPackageView(HomeAssistantView):
|
||||
)
|
||||
markdown += "\n</details>\n\n"
|
||||
|
||||
# Add custom themes information
|
||||
try:
|
||||
themes_info = self._get_themes_info(hass)
|
||||
except Exception: # noqa: BLE001
|
||||
# Broad exception catch for robustness in support package generation
|
||||
markdown += "## Custom Themes\n\n"
|
||||
markdown += "Unable to collect themes information\n\n"
|
||||
else:
|
||||
markdown += "## Custom Themes\n\n"
|
||||
markdown += f"Custom themes: {themes_info['count']}\n\n"
|
||||
|
||||
if themes_info["themes"]:
|
||||
markdown += "<details><summary>Custom themes</summary>\n\n"
|
||||
markdown += "Name\n"
|
||||
markdown += "---\n"
|
||||
for theme in themes_info["themes"]:
|
||||
markdown += f"{theme}\n"
|
||||
markdown += "\n</details>\n\n"
|
||||
|
||||
for domain, domain_info in domains_info.items():
|
||||
domain_info_md = get_domain_table_markdown(domain_info)
|
||||
markdown += (
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/conversation",
|
||||
"integration_type": "entity",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["hassil==3.5.0", "home-assistant-intents==2026.6.1"]
|
||||
"requirements": ["hassil==3.5.0", "home-assistant-intents==2026.5.5"]
|
||||
}
|
||||
|
||||
@@ -20,8 +20,6 @@ from denonavr.const import (
|
||||
from denonavr.exceptions import (
|
||||
AvrCommandError,
|
||||
AvrForbiddenError,
|
||||
AvrIncompleteResponseError,
|
||||
AvrInvalidResponseError,
|
||||
AvrNetworkError,
|
||||
AvrProcessingError,
|
||||
AvrTimoutError,
|
||||
@@ -193,17 +191,6 @@ def async_log_errors[_DenonDeviceT: DenonDevice, **_P, _R](
|
||||
self._receiver.host,
|
||||
)
|
||||
self._attr_available = False
|
||||
except AvrInvalidResponseError, AvrIncompleteResponseError:
|
||||
available = False
|
||||
if self.available:
|
||||
_LOGGER.warning(
|
||||
(
|
||||
"Denon AVR receiver at host %s returned malformed response. "
|
||||
"Device is unavailable"
|
||||
),
|
||||
self._receiver.host,
|
||||
)
|
||||
self._attr_available = False
|
||||
except AvrCommandError as err:
|
||||
available = False
|
||||
_LOGGER.error(
|
||||
|
||||
@@ -100,7 +100,7 @@ class DwdWeatherWarningsSensor(
|
||||
if warnings is None:
|
||||
return []
|
||||
|
||||
now = datetime.now(UTC) # pylint: disable=home-assistant-enforce-utcnow
|
||||
now = datetime.now(UTC)
|
||||
return [warning for warning in warnings if warning[API_ATTR_WARNING_END] > now]
|
||||
|
||||
@property
|
||||
|
||||
@@ -294,9 +294,6 @@
|
||||
"vacuum_raw_get_positions_not_supported": {
|
||||
"message": "Retrieving the positions of the chargers and the device itself is not supported"
|
||||
},
|
||||
"vacuum_send_command_not_supported": {
|
||||
"message": "The {command} command is not supported by {name}"
|
||||
},
|
||||
"vacuum_send_command_params_dict": {
|
||||
"message": "Params must be a dictionary and not a list"
|
||||
},
|
||||
|
||||
@@ -353,10 +353,11 @@ class EcovacsVacuum(
|
||||
if self._capability.clean.action.area is None:
|
||||
info = self._device.device_info
|
||||
name = info.get("nick", info["name"])
|
||||
# pylint: disable-next=home-assistant-exception-translation-key-missing
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="vacuum_send_command_not_supported",
|
||||
translation_placeholders={"command": command, "name": name},
|
||||
translation_key="vacuum_send_command_area_not_supported",
|
||||
translation_placeholders={"name": name},
|
||||
)
|
||||
|
||||
if command == "spot_area":
|
||||
|
||||
@@ -106,7 +106,7 @@ async def async_migrate_entry(
|
||||
new_options = {**config_entry.options}
|
||||
|
||||
if config_entry.minor_version < 2:
|
||||
# Add defaults only if they're not already present
|
||||
# Add defaults only if they’re not already present
|
||||
if "stt_auto_language" not in new_options:
|
||||
new_options["stt_auto_language"] = False
|
||||
if "stt_model" not in new_options:
|
||||
|
||||
@@ -221,7 +221,6 @@ def update_listeners(hass: HomeAssistant, entry: EnergyIDConfigEntry) -> None:
|
||||
):
|
||||
try:
|
||||
value = float(current_state.state)
|
||||
# pylint: disable-next=home-assistant-enforce-utcnow
|
||||
timestamp = current_state.last_updated or dt.datetime.now(dt.UTC)
|
||||
client.get_or_create_sensor(energyid_key).update(value, timestamp)
|
||||
except ValueError, TypeError:
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
"""Envertech EVT800 integration."""
|
||||
|
||||
from pyenvertechevt800 import EnvertechEVT800
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import PLATFORMS
|
||||
from .coordinator import EnvertechEVT800Coordinator
|
||||
|
||||
type EnvertechEVT800ConfigEntry = ConfigEntry[EnvertechEVT800Coordinator]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: EnvertechEVT800ConfigEntry
|
||||
) -> bool:
|
||||
"""Set up Envertech EVT800 from a config entry."""
|
||||
evt800 = EnvertechEVT800(entry.data[CONF_IP_ADDRESS], entry.data[CONF_PORT])
|
||||
evt800.start()
|
||||
|
||||
coordinator = EnvertechEVT800Coordinator(hass, evt800, entry)
|
||||
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(
|
||||
hass: HomeAssistant, entry: EnvertechEVT800ConfigEntry
|
||||
) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
@@ -1,60 +0,0 @@
|
||||
"""Config flow for the ENVERTECH EVT800 integration."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pyenvertechevt800 import EnvertechEVT800
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT, CONF_TYPE
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
|
||||
from .const import DEFAULT_PORT, DOMAIN, TYPE_TCP_SERVER_MODE
|
||||
|
||||
SCHEMA_DEVICE = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_IP_ADDRESS): cv.string,
|
||||
vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class EnvertechFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"""Config flow for Envertech EVT800."""
|
||||
|
||||
VERSION = 1
|
||||
MINOR_VERSION = 1
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""First step in config flow."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
ip_address = user_input[CONF_IP_ADDRESS]
|
||||
port = user_input[CONF_PORT]
|
||||
|
||||
self._async_abort_entries_match(
|
||||
{
|
||||
CONF_IP_ADDRESS: ip_address,
|
||||
CONF_PORT: port,
|
||||
}
|
||||
)
|
||||
evt800 = EnvertechEVT800(ip_address, port)
|
||||
|
||||
can_connect = await evt800.test_connection()
|
||||
|
||||
if not can_connect:
|
||||
errors["base"] = "cannot_connect"
|
||||
|
||||
if not errors:
|
||||
return self.async_create_entry(
|
||||
title="Envertech EVT800",
|
||||
data={CONF_TYPE: TYPE_TCP_SERVER_MODE, **user_input},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=SCHEMA_DEVICE,
|
||||
errors=errors,
|
||||
)
|
||||
@@ -1,11 +0,0 @@
|
||||
"""Constants for the ENVERTECH EVT800 integration."""
|
||||
|
||||
from homeassistant.const import Platform
|
||||
|
||||
DOMAIN = "envertech_evt800"
|
||||
|
||||
PLATFORMS = [Platform.SENSOR]
|
||||
|
||||
DEFAULT_PORT = 14889
|
||||
TYPE_TCP_SERVER_MODE = ["TCP_SERVER"]
|
||||
DEFAULT_SCAN_INTERVAL = 60
|
||||
@@ -1,44 +0,0 @@
|
||||
"""Coordinator for Envertech EVT800 integration."""
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import pyenvertechevt800
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
||||
from .const import DEFAULT_SCAN_INTERVAL, DOMAIN
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import EnvertechEVT800ConfigEntry
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EnvertechEVT800Coordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
"""Data update coordinator for Envertech EVT800."""
|
||||
|
||||
config_entry: EnvertechEVT800ConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
client: pyenvertechevt800.EnvertechEVT800,
|
||||
config_entry: EnvertechEVT800ConfigEntry,
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
logger=_LOGGER,
|
||||
name=DOMAIN,
|
||||
update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL),
|
||||
config_entry=config_entry,
|
||||
)
|
||||
self.client = client
|
||||
client.set_data_listener(self.async_set_updated_data)
|
||||
|
||||
async def _async_update_data(self) -> dict[str, Any]:
|
||||
"""Fetch data from the device."""
|
||||
return self.client.data
|
||||
@@ -1,29 +0,0 @@
|
||||
"""Envertech EVT800 entity."""
|
||||
|
||||
from homeassistant.const import CONF_IP_ADDRESS
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import EnvertechEVT800Coordinator
|
||||
|
||||
|
||||
class EnvertechEVT800Entity(CoordinatorEntity[EnvertechEVT800Coordinator]):
|
||||
"""Envertech EVT800 entity."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(self, coordinator: EnvertechEVT800Coordinator) -> None:
|
||||
"""Initialize Envertech EVT800 entity."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, coordinator.config_entry.entry_id)},
|
||||
configuration_url=f"http://{coordinator.config_entry.data[CONF_IP_ADDRESS]}/",
|
||||
manufacturer="Envertech",
|
||||
model_id="EVT800",
|
||||
)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available."""
|
||||
return super().available and self.coordinator.client.online
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"domain": "envertech_evt800",
|
||||
"name": "ENVERTECH EVT800",
|
||||
"codeowners": ["@daniel-bergmann-00"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/envertech_evt800",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pyenvertechevt800"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pyenvertechevt800==0.2.4"]
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: |
|
||||
The integration does not provide any additional 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: |
|
||||
The integration does not provide any additional actions.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup:
|
||||
status: done
|
||||
comment: |
|
||||
Entities of this integration does not explicitly subscribe to events.
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions:
|
||||
status: exempt
|
||||
comment: |
|
||||
The integration does not provide any actions.
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: done
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: todo
|
||||
reauthentication-flow:
|
||||
status: todo
|
||||
comment: |
|
||||
The integration does not have any authentication.
|
||||
test-coverage: done
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery-update-info: todo
|
||||
discovery: todo
|
||||
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: exempt
|
||||
comment: |
|
||||
Integration connects to a single device
|
||||
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
entity-translations: done
|
||||
exception-translations:
|
||||
status: exempt
|
||||
comment: |
|
||||
The integration does not have any own exceptions.
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: todo
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: |
|
||||
The integration does not support repairing issues.
|
||||
stale-devices:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration connects to a single device per configuration entry.
|
||||
|
||||
# Platinum
|
||||
async-dependency: todo
|
||||
inject-websession:
|
||||
status: exempt
|
||||
comment: |
|
||||
No websession is used
|
||||
strict-typing: todo
|
||||
@@ -1,185 +0,0 @@
|
||||
"""Envertech EVT800 sensor."""
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
UnitOfElectricCurrent,
|
||||
UnitOfElectricPotential,
|
||||
UnitOfEnergy,
|
||||
UnitOfFrequency,
|
||||
UnitOfPower,
|
||||
UnitOfTemperature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.typing import StateType
|
||||
|
||||
from . import EnvertechEVT800ConfigEntry
|
||||
from .coordinator import EnvertechEVT800Coordinator
|
||||
from .entity import EnvertechEVT800Entity
|
||||
|
||||
SENSORS: tuple[SensorEntityDescription, ...] = (
|
||||
SensorEntityDescription(
|
||||
key="id_1",
|
||||
entity_registry_enabled_default=False,
|
||||
translation_key="mppt_id_1",
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="id_2",
|
||||
entity_registry_enabled_default=False,
|
||||
translation_key="mppt_id_2",
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="input_voltage_1",
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
suggested_display_precision=2,
|
||||
translation_key="input_voltage_1",
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="input_voltage_2",
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
suggested_display_precision=2,
|
||||
translation_key="input_voltage_2",
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="power_1",
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
suggested_display_precision=0,
|
||||
translation_key="power_1",
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="power_2",
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
suggested_display_precision=0,
|
||||
translation_key="power_2",
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="current_1",
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
suggested_display_precision=2,
|
||||
translation_key="current_1",
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="current_2",
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
suggested_display_precision=2,
|
||||
translation_key="current_2",
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="ac_frequency_1",
|
||||
native_unit_of_measurement=UnitOfFrequency.HERTZ,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.FREQUENCY,
|
||||
suggested_display_precision=1,
|
||||
translation_key="ac_frequency_1",
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="ac_frequency_2",
|
||||
native_unit_of_measurement=UnitOfFrequency.HERTZ,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.FREQUENCY,
|
||||
suggested_display_precision=1,
|
||||
translation_key="ac_frequency_2",
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="ac_voltage_1",
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
suggested_display_precision=0,
|
||||
translation_key="ac_voltage_1",
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="ac_voltage_2",
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
suggested_display_precision=0,
|
||||
translation_key="ac_voltage_2",
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="temperature_1",
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
suggested_display_precision=1,
|
||||
translation_key="temperature_1",
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="temperature_2",
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
suggested_display_precision=1,
|
||||
translation_key="temperature_2",
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="total_energy_1",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
suggested_display_precision=2,
|
||||
translation_key="total_energy_1",
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="total_energy_2",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
suggested_display_precision=2,
|
||||
translation_key="total_energy_2",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: EnvertechEVT800ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Envertech EVT800 sensors."""
|
||||
coordinator = config_entry.runtime_data
|
||||
|
||||
async_add_entities(
|
||||
EnvertechEVT800Sensor(coordinator, description) for description in SENSORS
|
||||
)
|
||||
|
||||
|
||||
class EnvertechEVT800Sensor(EnvertechEVT800Entity, SensorEntity):
|
||||
"""Representation of an Envertech EVT800 sensor."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: EnvertechEVT800Coordinator,
|
||||
description: SensorEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{description.key}"
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
"""Return the native value of the sensor."""
|
||||
return self.coordinator.client.data.get(self.entity_description.key)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Unavailable if evt800 isn't connected."""
|
||||
return super().available and self.native_value is not None
|
||||
@@ -1,76 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"ip_address": "[%key:common::config_flow::data::ip%]",
|
||||
"port": "[%key:common::config_flow::data::port%]"
|
||||
},
|
||||
"data_description": {
|
||||
"ip_address": "The IP address of your Envertech EVT800 device.",
|
||||
"port": "The Port of your Envertech EVT800 device."
|
||||
},
|
||||
"description": "Enter your EVT800 device information.",
|
||||
"title": "Setup EVT800 device"
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"ac_frequency_1": {
|
||||
"name": "AC Frequency MPPT 1"
|
||||
},
|
||||
"ac_frequency_2": {
|
||||
"name": "AC Frequency MPPT 2"
|
||||
},
|
||||
"ac_voltage_1": {
|
||||
"name": "AC Voltage MPPT 1"
|
||||
},
|
||||
"ac_voltage_2": {
|
||||
"name": "AC Voltage MPPT 2"
|
||||
},
|
||||
"current_1": {
|
||||
"name": "DC Current MPPT 1"
|
||||
},
|
||||
"current_2": {
|
||||
"name": "DC Current MPPT 2"
|
||||
},
|
||||
"input_voltage_1": {
|
||||
"name": "DC Voltage MPPT 1"
|
||||
},
|
||||
"input_voltage_2": {
|
||||
"name": "DC Voltage MPPT 2"
|
||||
},
|
||||
"mppt_id_1": {
|
||||
"name": "MPPT ID 1"
|
||||
},
|
||||
"mppt_id_2": {
|
||||
"name": "MPPT ID 2"
|
||||
},
|
||||
"power_1": {
|
||||
"name": "DC Power MPPT 1"
|
||||
},
|
||||
"power_2": {
|
||||
"name": "DC Power MPPT 2"
|
||||
},
|
||||
"temperature_1": {
|
||||
"name": "Temperature MPPT 1"
|
||||
},
|
||||
"temperature_2": {
|
||||
"name": "Temperature MPPT 2"
|
||||
},
|
||||
"total_energy_1": {
|
||||
"name": "Total Energy MPPT 1"
|
||||
},
|
||||
"total_energy_2": {
|
||||
"name": "Total Energy MPPT 2"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -196,6 +196,4 @@ class EphEmberThermostat(ClimateEntity):
|
||||
@staticmethod
|
||||
def map_mode_eph_hass(operation_mode):
|
||||
"""Map from eph mode to Home Assistant mode."""
|
||||
if operation_mode is None:
|
||||
return HVACMode.HEAT_COOL
|
||||
return EPH_TO_HA_STATE.get(operation_mode.name, HVACMode.HEAT_COOL)
|
||||
|
||||
@@ -8,14 +8,13 @@ from eq3btsmart import Thermostat
|
||||
from eq3btsmart.exceptions import Eq3Exception
|
||||
|
||||
from homeassistant.components import bluetooth
|
||||
from homeassistant.components.bluetooth import BluetoothReachabilityIntent
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
|
||||
from .const import DOMAIN, SIGNAL_THERMOSTAT_CONNECTED, SIGNAL_THERMOSTAT_DISCONNECTED
|
||||
from .const import SIGNAL_THERMOSTAT_CONNECTED, SIGNAL_THERMOSTAT_DISCONNECTED
|
||||
from .models import Eq3Config, Eq3ConfigEntryData
|
||||
|
||||
PLATFORMS = [
|
||||
@@ -50,16 +49,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: Eq3ConfigEntry) -> bool:
|
||||
|
||||
if device is None:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="device_not_found",
|
||||
translation_placeholders={
|
||||
"mac_address": eq3_config.mac_address,
|
||||
"reason": bluetooth.async_address_reachability_diagnostics(
|
||||
hass,
|
||||
mac_address.upper(),
|
||||
BluetoothReachabilityIntent.CONNECTION,
|
||||
),
|
||||
},
|
||||
f"[{eq3_config.mac_address}] Device could not be found"
|
||||
)
|
||||
|
||||
thermostat = Thermostat(device)
|
||||
|
||||
@@ -61,10 +61,5 @@
|
||||
"name": "Lock"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"device_not_found": {
|
||||
"message": "[{mac_address}] Device could not be found: {reason}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,19 +284,6 @@ class ESPHomeUpdateEntity(EsphomeEntity[UpdateInfo, UpdateState], UpdateEntity):
|
||||
UpdateDeviceClass, static_info.device_class
|
||||
)
|
||||
|
||||
def version_is_newer(self, latest_version: str, installed_version: str) -> bool:
|
||||
"""Return True if latest_version is newer than installed_version.
|
||||
|
||||
ESPHome project versions can carry a build suffix (e.g.
|
||||
2025.11.5_c51f7548) that AwesomeVersion cannot parse. Without stripping
|
||||
it the base comparison raises and the entity is forced on for every
|
||||
build mismatch. Drop the suffix so the versions compare cleanly and we
|
||||
only report genuinely newer firmware.
|
||||
"""
|
||||
return super().version_is_newer(
|
||||
latest_version.partition("_")[0], installed_version.partition("_")[0]
|
||||
)
|
||||
|
||||
@property
|
||||
@esphome_state_property
|
||||
def installed_version(self) -> str:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Support for entities of the Evohome integration."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from datetime import UTC, datetime
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
@@ -13,7 +14,6 @@ from evohomeasync2.schemas.typedefs import DayOfWeekDhwT
|
||||
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .coordinator import EvoDataUpdateCoordinator
|
||||
|
||||
@@ -161,7 +161,7 @@ class EvoChild(EvoEntity):
|
||||
or self._schedule is None
|
||||
or (
|
||||
(until := self._setpoints.get("next_sp_from")) is not None
|
||||
and until < dt_util.utcnow()
|
||||
and until < datetime.now(UTC)
|
||||
)
|
||||
): # must use self._setpoints, not self.setpoints
|
||||
await get_schedule()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Support for (EMEA/EU-based) Honeywell TCC systems."""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any, NotRequired, TypedDict
|
||||
|
||||
from evohomeasync.auth import (
|
||||
@@ -12,7 +12,6 @@ from evohomeasync2.auth import AbstractTokenManager
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.storage import Store
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import STORAGE_KEY, STORAGE_VER
|
||||
|
||||
@@ -92,7 +91,7 @@ class TokenManager(AbstractTokenManager, AbstractSessionManager):
|
||||
|
||||
session_id_expires = session.get(SZ_SESSION_ID_EXPIRES)
|
||||
if session_id_expires is None:
|
||||
self._session_id_expires = dt_util.utcnow() + timedelta(minutes=15)
|
||||
self._session_id_expires = datetime.now(tz=UTC) + timedelta(minutes=15)
|
||||
else:
|
||||
self._session_id_expires = datetime.fromisoformat(session_id_expires)
|
||||
|
||||
|
||||
@@ -23,18 +23,14 @@ from homeassistant.helpers.selector import (
|
||||
TextSelectorType,
|
||||
)
|
||||
|
||||
from .const import CONF_MAX_ENTRIES, DEFAULT_MAX_ENTRIES, DOMAIN, USER_AGENT
|
||||
from .const import CONF_MAX_ENTRIES, DEFAULT_MAX_ENTRIES, DOMAIN
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_fetch_feed(hass: HomeAssistant, url: str) -> feedparser.FeedParserDict:
|
||||
"""Fetch the feed."""
|
||||
|
||||
def _parse_feed() -> feedparser.FeedParserDict:
|
||||
return feedparser.parse(url, agent=USER_AGENT)
|
||||
|
||||
return await hass.async_add_executor_job(_parse_feed)
|
||||
return await hass.async_add_executor_job(feedparser.parse, url)
|
||||
|
||||
|
||||
class FeedReaderConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
from datetime import timedelta
|
||||
from typing import Final
|
||||
|
||||
from homeassistant.const import APPLICATION_NAME, __version__ as ha_version
|
||||
|
||||
DOMAIN: Final[str] = "feedreader"
|
||||
|
||||
CONF_MAX_ENTRIES: Final[str] = "max_entries"
|
||||
@@ -12,5 +10,3 @@ DEFAULT_MAX_ENTRIES: Final[int] = 20
|
||||
DEFAULT_SCAN_INTERVAL: Final[timedelta] = timedelta(hours=1)
|
||||
|
||||
EVENT_FEEDREADER: Final[str] = "feedreader"
|
||||
|
||||
USER_AGENT: Final[str] = f"{APPLICATION_NAME}/{ha_version}"
|
||||
|
||||
@@ -18,13 +18,7 @@ from homeassistant.helpers.storage import Store
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import (
|
||||
CONF_MAX_ENTRIES,
|
||||
DEFAULT_SCAN_INTERVAL,
|
||||
DOMAIN,
|
||||
EVENT_FEEDREADER,
|
||||
USER_AGENT,
|
||||
)
|
||||
from .const import CONF_MAX_ENTRIES, DEFAULT_SCAN_INTERVAL, DOMAIN, EVENT_FEEDREADER
|
||||
|
||||
DELAY_SAVE = 30
|
||||
STORAGE_VERSION = 1
|
||||
@@ -80,7 +74,6 @@ class FeedReaderCoordinator(
|
||||
self.url,
|
||||
etag=None if not self._feed else self._feed.get("etag"),
|
||||
modified=None if not self._feed else self._feed.get("modified"),
|
||||
agent=USER_AGENT,
|
||||
)
|
||||
|
||||
feed = await self.hass.async_add_executor_job(_parse_feed)
|
||||
|
||||
@@ -2,12 +2,10 @@
|
||||
|
||||
from flexit_bacnet import (
|
||||
OPERATION_MODE_AWAY,
|
||||
OPERATION_MODE_COOKER_HOOD,
|
||||
OPERATION_MODE_FIREPLACE,
|
||||
OPERATION_MODE_HIGH,
|
||||
OPERATION_MODE_HOME,
|
||||
OPERATION_MODE_OFF,
|
||||
OPERATION_MODE_TEMPORARY_HIGH,
|
||||
VENTILATION_MODE_AWAY,
|
||||
VENTILATION_MODE_HIGH,
|
||||
VENTILATION_MODE_HOME,
|
||||
@@ -30,9 +28,7 @@ OPERATION_TO_PRESET_MODE_MAP = {
|
||||
OPERATION_MODE_AWAY: PRESET_AWAY,
|
||||
OPERATION_MODE_HOME: PRESET_HOME,
|
||||
OPERATION_MODE_HIGH: PRESET_HIGH,
|
||||
OPERATION_MODE_COOKER_HOOD: PRESET_HIGH,
|
||||
OPERATION_MODE_FIREPLACE: PRESET_FIREPLACE,
|
||||
OPERATION_MODE_TEMPORARY_HIGH: PRESET_HIGH,
|
||||
}
|
||||
|
||||
# Map preset to ventilation mode (for setting standard modes)
|
||||
|
||||
@@ -5,7 +5,7 @@ from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import FlussConfigEntry, FlussDataUpdateCoordinator
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.COVER]
|
||||
PLATFORMS: list[Platform] = [Platform.BUTTON]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
|
||||
@@ -6,4 +6,3 @@ import logging
|
||||
DOMAIN = "fluss"
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
UPDATE_INTERVAL = timedelta(minutes=30)
|
||||
COMMAND_REFRESH_COOLDOWN = 10
|
||||
|
||||
@@ -13,11 +13,10 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryError
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.debounce import Debouncer
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
from homeassistant.util import slugify
|
||||
|
||||
from .const import COMMAND_REFRESH_COOLDOWN, LOGGER, UPDATE_INTERVAL
|
||||
from .const import LOGGER, UPDATE_INTERVAL
|
||||
|
||||
type FlussConfigEntry = ConfigEntry[FlussDataUpdateCoordinator]
|
||||
|
||||
@@ -36,24 +35,18 @@ class FlussDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]
|
||||
name=f"Fluss+ ({slugify(api_key[:8])})",
|
||||
config_entry=config_entry,
|
||||
update_interval=UPDATE_INTERVAL,
|
||||
request_refresh_debouncer=Debouncer(
|
||||
hass,
|
||||
LOGGER,
|
||||
cooldown=COMMAND_REFRESH_COOLDOWN,
|
||||
immediate=False,
|
||||
),
|
||||
)
|
||||
|
||||
async def _async_get_status(self, device_id: str) -> dict[str, Any]:
|
||||
"""Return per-device status."""
|
||||
async def _async_get_connectivity(self, device_id: str) -> bool:
|
||||
"""Return connectivity for a device; False if the status call fails."""
|
||||
try:
|
||||
response = await self.api.async_get_device_status(device_id)
|
||||
except FlussApiClientError as err:
|
||||
raise UpdateFailed(f"Error fetching status for {device_id}: {err}") from err
|
||||
return response["status"]
|
||||
status = await self.api.async_get_device_status(device_id)
|
||||
except FlussApiClientError:
|
||||
return False
|
||||
return status["status"]["internetConnected"]
|
||||
|
||||
async def _async_update_data(self) -> dict[str, dict[str, Any]]:
|
||||
"""Fetch Fluss+ devices and merge per-device status."""
|
||||
"""Fetch Fluss+ devices and merge per-device connectivity status."""
|
||||
try:
|
||||
devices = await self.api.async_get_devices()
|
||||
except FlussApiClientAuthenticationError as err:
|
||||
@@ -66,11 +59,10 @@ class FlussDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]
|
||||
for device in devices["devices"]
|
||||
if device["userPermissions"]["canUseWiFi"]
|
||||
]
|
||||
|
||||
statuses = await asyncio.gather(
|
||||
*(self._async_get_status(d["deviceId"]) for d in device_list)
|
||||
connectivity = await asyncio.gather(
|
||||
*(self._async_get_connectivity(d["deviceId"]) for d in device_list)
|
||||
)
|
||||
return {
|
||||
device["deviceId"]: {**device, **status}
|
||||
for device, status in zip(device_list, statuses, strict=False)
|
||||
device["deviceId"]: {**device, "internetConnected": connected}
|
||||
for device, connected in zip(device_list, connectivity, strict=False)
|
||||
}
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
"""Cover platform for Fluss+ devices that report an open/closed status."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.cover import (
|
||||
CoverDeviceClass,
|
||||
CoverEntity,
|
||||
CoverEntityFeature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import FlussApiClientError, FlussConfigEntry
|
||||
from .entity import FlussEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
STATUS_OPEN = "Open"
|
||||
STATUS_CLOSED = "Closed"
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: FlussConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Fluss covers for devices that report an open/closed status."""
|
||||
coordinator = entry.runtime_data
|
||||
added_device_ids: set[str] = set()
|
||||
|
||||
def _async_add_new_entities() -> None:
|
||||
new_entities = [
|
||||
FlussCover(coordinator, device_id, device)
|
||||
for device_id, device in coordinator.data.items()
|
||||
if "openCloseStatus" in device and device_id not in added_device_ids
|
||||
]
|
||||
if not new_entities:
|
||||
return
|
||||
|
||||
added_device_ids.update(entity.device_id for entity in new_entities)
|
||||
async_add_entities(new_entities)
|
||||
|
||||
_async_add_new_entities()
|
||||
entry.async_on_unload(coordinator.async_add_listener(_async_add_new_entities))
|
||||
|
||||
|
||||
class FlussCover(FlussEntity, CoverEntity):
|
||||
"""Representation of a Fluss+ cover."""
|
||||
|
||||
_attr_device_class = CoverDeviceClass.GARAGE
|
||||
_attr_name = None
|
||||
_attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True only when the device is online."""
|
||||
return super().available and self.device["internetConnected"]
|
||||
|
||||
@property
|
||||
def is_closed(self) -> bool | None:
|
||||
"""Return whether the cover is closed."""
|
||||
status = self.device.get("openCloseStatus")
|
||||
if status == STATUS_CLOSED:
|
||||
return True
|
||||
if status == STATUS_OPEN:
|
||||
return False
|
||||
return None
|
||||
|
||||
async def async_open_cover(self, **kwargs: Any) -> None:
|
||||
"""Open the cover."""
|
||||
try:
|
||||
await self.coordinator.api.async_open_device(self.device_id)
|
||||
except FlussApiClientError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN, translation_key="command_failed"
|
||||
) from err
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_close_cover(self, **kwargs: Any) -> None:
|
||||
"""Close the cover."""
|
||||
try:
|
||||
await self.coordinator.api.async_close_device(self.device_id)
|
||||
except FlussApiClientError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN, translation_key="command_failed"
|
||||
) from err
|
||||
await self.coordinator.async_request_refresh()
|
||||
@@ -19,10 +19,5 @@
|
||||
"description": "Your Fluss API key, available in the profile page of the Fluss+ app"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"command_failed": {
|
||||
"message": "Failed to send command to Fluss+ device"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -938,15 +938,3 @@ class AvmWrapper(FritzBoxTools):
|
||||
"X_AVM-DE_WakeOnLANByMACAddress",
|
||||
NewMACAddress=mac_address,
|
||||
)
|
||||
|
||||
async def async_get_firmware_extra_infos(self) -> dict[str, Any]:
|
||||
"""Return extra infos for firmware."""
|
||||
return await self._async_service_call("UserInterface", "1", "X_AVM-DE_GetInfo")
|
||||
|
||||
async def async_get_device_uptime_hours(self) -> int:
|
||||
"""Get device uptime in hours."""
|
||||
|
||||
def _get_uptime_hours() -> int:
|
||||
return int(self.fritz_status.device_uptime // 3600)
|
||||
|
||||
return await self.hass.async_add_executor_job(_get_uptime_hours)
|
||||
|
||||
@@ -24,11 +24,9 @@ async def async_get_config_entry_diagnostics(
|
||||
"unique_id": avm_wrapper.unique_id.replace(
|
||||
avm_wrapper.unique_id[6:11], "XX:XX"
|
||||
),
|
||||
"device_uptime_hours": await avm_wrapper.async_get_device_uptime_hours(),
|
||||
"current_firmware": avm_wrapper.current_firmware,
|
||||
"latest_firmware": avm_wrapper.latest_firmware,
|
||||
"update_available": avm_wrapper.update_available,
|
||||
"firmware_extra_infos": await avm_wrapper.async_get_firmware_extra_infos(),
|
||||
"connection_type": avm_wrapper.device_conn_type,
|
||||
"is_router": avm_wrapper.device_is_router,
|
||||
"mesh_role": avm_wrapper.mesh_role,
|
||||
|
||||
@@ -21,5 +21,5 @@
|
||||
"integration_type": "system",
|
||||
"preview_features": { "winter_mode": {} },
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20260527.4"]
|
||||
"requirements": ["home-assistant-frontend==20260527.1"]
|
||||
}
|
||||
|
||||
@@ -320,7 +320,7 @@ class AFSAPIDevice(MediaPlayerEntity):
|
||||
@fs_command_exception_wrap
|
||||
async def async_media_play(self) -> None:
|
||||
"""Send play command."""
|
||||
if (await self.fs_device.get_play_status()) == PlayState.STOPPED:
|
||||
if (await self.fs_device.get_play_state()) == PlayState.STOPPED:
|
||||
# The 'play' command only seems to work when the current stream is paused.
|
||||
# We need to send a 'stop' command instead to resume a stopped stream.
|
||||
await self.fs_device.stop()
|
||||
|
||||
@@ -279,7 +279,7 @@ class GardenaBluetoothRemainSensor(GardenaBluetoothEntity, SensorEntity):
|
||||
super()._handle_coordinator_update()
|
||||
return
|
||||
|
||||
time = datetime.now(UTC) + timedelta(seconds=value) # pylint: disable=home-assistant-enforce-utcnow
|
||||
time = datetime.now(UTC) + timedelta(seconds=value)
|
||||
if not self._attr_native_value:
|
||||
self._attr_native_value = time
|
||||
super()._handle_coordinator_update()
|
||||
|
||||
@@ -37,7 +37,7 @@ class GreenPlanetEnergySensorEntityDescription(SensorEntityDescription):
|
||||
def _get_lowest_price_day_time(
|
||||
api: GreenPlanetEnergyAPI, data: dict[str, Any]
|
||||
) -> datetime | None:
|
||||
"""Return timestamp of the lowest-priced day hour (06:00-18:00)."""
|
||||
"""Return timestamp of the lowest-priced day hour (06:00–18:00)."""
|
||||
now = dt_util.now()
|
||||
now_h = now.hour
|
||||
hour = api.get_lowest_price_day_with_hour(data, now_h)[1]
|
||||
|
||||
@@ -10,8 +10,8 @@ Classic API (username/password):
|
||||
|
||||
Open API V1 (API token):
|
||||
- Stateless — no login call, token is sent as a Bearer header on every request.
|
||||
- Auth failure is signalled by raising GrowattV1ApiError with
|
||||
error_code=GrowattV1ApiErrorCode.NO_PRIVILEGE. The library NEVER returns a failure silently;
|
||||
- Auth failure is signalled by raising GrowattV1ApiError with error_code=10011
|
||||
(V1_API_ERROR_NO_PRIVILEGE). The library NEVER returns a failure silently;
|
||||
any non-zero error_code raises an exception via _process_response().
|
||||
- Because the library always raises on error, return-value validation after a
|
||||
successful V1 API call is unnecessary — if it returned, the token was valid.
|
||||
@@ -19,7 +19,7 @@ Open API V1 (API token):
|
||||
Error handling pattern for reauth:
|
||||
- Classic API: check NOT login_response["success"] and msg == LOGIN_INVALID_AUTH_CODE
|
||||
→ raise ConfigEntryAuthFailed
|
||||
- V1 API: catch GrowattV1ApiError with error_code == GrowattV1ApiErrorCode.NO_PRIVILEGE
|
||||
- V1 API: catch GrowattV1ApiError with error_code == V1_API_ERROR_NO_PRIVILEGE
|
||||
→ raise ConfigEntryAuthFailed
|
||||
- All other errors → ConfigEntryError (setup) or UpdateFailed (coordinator)
|
||||
"""
|
||||
@@ -30,16 +30,11 @@ from json import JSONDecodeError
|
||||
import logging
|
||||
|
||||
import growattServer
|
||||
from growattServer import GrowattV1ApiErrorCode
|
||||
from requests import RequestException
|
||||
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_URL, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import (
|
||||
ConfigEntryAuthFailed,
|
||||
ConfigEntryError,
|
||||
ConfigEntryNotReady,
|
||||
)
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryError
|
||||
from homeassistant.helpers import config_validation as cv, device_registry as dr
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.helpers.event import async_track_time_interval
|
||||
@@ -59,6 +54,7 @@ from .const import (
|
||||
LOGIN_INVALID_AUTH_CODE,
|
||||
PLATFORMS,
|
||||
SUPPORTED_DEVICE_TYPES,
|
||||
V1_API_ERROR_NO_PRIVILEGE,
|
||||
V1_DEVICE_TYPES,
|
||||
)
|
||||
from .coordinator import GrowattConfigEntry, GrowattCoordinator
|
||||
@@ -238,24 +234,15 @@ def _login_classic_api(
|
||||
login_response = api.login(username, password)
|
||||
except (RequestException, JSONDecodeError) as ex:
|
||||
raise ConfigEntryError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="communication_error",
|
||||
translation_placeholders={"error": str(ex)},
|
||||
f"Error communicating with Growatt API during login: {ex}"
|
||||
) from ex
|
||||
|
||||
if not login_response.get("success"):
|
||||
msg = login_response.get("msg", "Unknown error")
|
||||
_LOGGER.debug("Growatt login failed: %s", msg)
|
||||
if msg == LOGIN_INVALID_AUTH_CODE:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_credentials",
|
||||
)
|
||||
raise ConfigEntryError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="login_failed",
|
||||
translation_placeholders={"message": msg},
|
||||
)
|
||||
raise ConfigEntryAuthFailed("Username, Password or URL may be incorrect!")
|
||||
raise ConfigEntryError(f"Growatt login failed: {msg}")
|
||||
|
||||
return login_response
|
||||
|
||||
@@ -273,25 +260,13 @@ def get_device_list_v1(
|
||||
try:
|
||||
devices_dict = api.device_list(plant_id)
|
||||
except growattServer.GrowattV1ApiError as e:
|
||||
if e.error_code == GrowattV1ApiErrorCode.NO_PRIVILEGE:
|
||||
if e.error_code == V1_API_ERROR_NO_PRIVILEGE:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="auth_failed",
|
||||
translation_placeholders={"error": e.error_msg or str(e)},
|
||||
) from e
|
||||
if e.error_code == GrowattV1ApiErrorCode.RATE_LIMITED:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="rate_limited",
|
||||
translation_placeholders={"error": e.error_msg or str(e)},
|
||||
f"Authentication failed for Growatt API: {e.error_msg or str(e)}"
|
||||
) from e
|
||||
raise ConfigEntryError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="api_error_with_code",
|
||||
translation_placeholders={
|
||||
"error": e.error_msg or str(e),
|
||||
"code": str(e.error_code),
|
||||
},
|
||||
f"API error during device list: {e.error_msg or str(e)}"
|
||||
f" (Code: {e.error_code})"
|
||||
) from e
|
||||
devices = devices_dict.get("devices", [])
|
||||
supported_devices = [
|
||||
@@ -365,15 +340,10 @@ async def async_setup_entry(
|
||||
devices = await hass.async_add_executor_job(api.device_list, plant_id)
|
||||
except (RequestException, JSONDecodeError) as ex:
|
||||
raise ConfigEntryError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="communication_error",
|
||||
translation_placeholders={"error": str(ex)},
|
||||
f"Error communicating with Growatt API during device list: {ex}"
|
||||
) from ex
|
||||
else:
|
||||
raise ConfigEntryError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="unknown_auth_type",
|
||||
)
|
||||
raise ConfigEntryError("Unknown authentication type in config entry.")
|
||||
|
||||
# Create a coordinator for the total sensors
|
||||
total_coordinator = GrowattCoordinator(
|
||||
|
||||
@@ -5,7 +5,6 @@ import logging
|
||||
from typing import Any
|
||||
|
||||
import growattServer
|
||||
from growattServer import GrowattV1ApiErrorCode
|
||||
import requests
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -33,6 +32,7 @@ from .const import (
|
||||
ERROR_INVALID_AUTH,
|
||||
LOGIN_INVALID_AUTH_CODE,
|
||||
SERVER_URLS_NAMES,
|
||||
V1_API_ERROR_NO_PRIVILEGE,
|
||||
)
|
||||
|
||||
_URL_TO_REGION = {v: k for k, v in SERVER_URLS_NAMES.items()}
|
||||
@@ -148,7 +148,7 @@ class GrowattServerConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
_LOGGER.debug("Network error during credential update: %s", ex)
|
||||
errors["base"] = ERROR_CANNOT_CONNECT
|
||||
except growattServer.GrowattV1ApiError as err:
|
||||
if err.error_code == GrowattV1ApiErrorCode.NO_PRIVILEGE:
|
||||
if err.error_code == V1_API_ERROR_NO_PRIVILEGE:
|
||||
errors["base"] = ERROR_INVALID_AUTH
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
@@ -301,7 +301,7 @@ class GrowattServerConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
e.error_msg or str(e),
|
||||
e.error_code,
|
||||
)
|
||||
if e.error_code == GrowattV1ApiErrorCode.NO_PRIVILEGE:
|
||||
if e.error_code == V1_API_ERROR_NO_PRIVILEGE:
|
||||
return self._async_show_token_form({"base": ERROR_INVALID_AUTH})
|
||||
return self._async_show_token_form({"base": ERROR_CANNOT_CONNECT})
|
||||
except (ValueError, KeyError, TypeError, AttributeError) as ex:
|
||||
|
||||
@@ -42,6 +42,13 @@ PLATFORMS = [Platform.NUMBER, Platform.SENSOR, Platform.SWITCH]
|
||||
# Growatt Classic API error codes
|
||||
LOGIN_INVALID_AUTH_CODE = "502"
|
||||
|
||||
# Growatt Open API V1 error codes
|
||||
# Reference: https://www.showdoc.com.cn/262556420217021/1494055648380019
|
||||
V1_API_ERROR_WRONG_DOMAIN = -1 # Use correct regional domain
|
||||
V1_API_ERROR_NO_PRIVILEGE = 10011 # No privilege access — invalid or expired token
|
||||
V1_API_ERROR_RATE_LIMITED = 10012 # Access frequency limit (5 minutes per call)
|
||||
V1_API_ERROR_PAGE_SIZE = 10013 # Page size cannot exceed 100
|
||||
V1_API_ERROR_PAGE_COUNT = 10014 # Page count cannot exceed 250
|
||||
|
||||
# Config flow error types (also used as abort reasons)
|
||||
ERROR_CANNOT_CONNECT = "cannot_connect" # Used for both form errors and aborts
|
||||
|
||||
@@ -6,7 +6,6 @@ import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import growattServer
|
||||
from growattServer import GrowattV1ApiErrorCode
|
||||
from requests import RequestException
|
||||
|
||||
from homeassistant.components.sensor import SensorStateClass
|
||||
@@ -28,6 +27,7 @@ from .const import (
|
||||
DEFAULT_URL,
|
||||
DOMAIN,
|
||||
LOGIN_INVALID_AUTH_CODE,
|
||||
V1_API_ERROR_NO_PRIVILEGE,
|
||||
V1_DEVICE_TYPES,
|
||||
)
|
||||
from .models import GrowattRuntimeData
|
||||
@@ -113,11 +113,9 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
if device.get("type") in V1_DEVICE_TYPES
|
||||
]
|
||||
except growattServer.GrowattV1ApiError as err:
|
||||
if err.error_code == GrowattV1ApiErrorCode.NO_PRIVILEGE:
|
||||
if err.error_code == V1_API_ERROR_NO_PRIVILEGE:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="auth_failed",
|
||||
translation_placeholders={"error": err.error_msg or str(err)},
|
||||
f"Authentication failed for Growatt API: {err.error_msg or str(err)}"
|
||||
) from err
|
||||
_LOGGER.debug("Failed to fetch V1 device list during scan: %s", err)
|
||||
self.device_list = None
|
||||
@@ -159,14 +157,9 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
msg = login_response.get("msg", "Unknown error")
|
||||
if msg == LOGIN_INVALID_AUTH_CODE:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_credentials",
|
||||
"Username, password, or URL may be incorrect"
|
||||
)
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="login_failed",
|
||||
translation_placeholders={"message": msg},
|
||||
)
|
||||
raise UpdateFailed(f"Growatt login failed: {msg}")
|
||||
|
||||
if self.device_type == "total":
|
||||
if self.api_version == "v1":
|
||||
@@ -186,18 +179,13 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
try:
|
||||
total_info = self.api.plant_energy_overview(self.plant_id)
|
||||
except growattServer.GrowattV1ApiError as err:
|
||||
if err.error_code == GrowattV1ApiErrorCode.NO_PRIVILEGE:
|
||||
if err.error_code == V1_API_ERROR_NO_PRIVILEGE:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="auth_failed",
|
||||
translation_placeholders={
|
||||
"error": err.error_msg or str(err)
|
||||
},
|
||||
"Authentication failed for Growatt API:"
|
||||
f" {err.error_msg or str(err)}"
|
||||
) from err
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="fetch_data_failed",
|
||||
translation_placeholders={"error": str(err)},
|
||||
f"Error fetching plant energy overview: {err}"
|
||||
) from err
|
||||
total_info["todayEnergy"] = total_info["today_energy"]
|
||||
total_info["totalEnergy"] = total_info["total_energy"]
|
||||
@@ -224,17 +212,12 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
min_settings = self.api.min_settings(self.device_id)
|
||||
min_energy = self.api.min_energy(self.device_id)
|
||||
except growattServer.GrowattV1ApiError as err:
|
||||
if err.error_code == GrowattV1ApiErrorCode.NO_PRIVILEGE:
|
||||
if err.error_code == V1_API_ERROR_NO_PRIVILEGE:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="auth_failed",
|
||||
translation_placeholders={"error": err.error_msg or str(err)},
|
||||
"Authentication failed for Growatt API:"
|
||||
f" {err.error_msg or str(err)}"
|
||||
) from err
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="fetch_data_failed",
|
||||
translation_placeholders={"error": str(err)},
|
||||
) from err
|
||||
raise UpdateFailed(f"Error fetching min device data: {err}") from err
|
||||
|
||||
min_info = {**min_details, **min_settings, **min_energy}
|
||||
self.data = min_info
|
||||
@@ -257,17 +240,12 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
sph_detail = self.api.sph_detail(self.device_id)
|
||||
sph_energy = self.api.sph_energy(self.device_id)
|
||||
except growattServer.GrowattV1ApiError as err:
|
||||
if err.error_code == GrowattV1ApiErrorCode.NO_PRIVILEGE:
|
||||
if err.error_code == V1_API_ERROR_NO_PRIVILEGE:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="auth_failed",
|
||||
translation_placeholders={"error": err.error_msg or str(err)},
|
||||
"Authentication failed for Growatt API:"
|
||||
f" {err.error_msg or str(err)}"
|
||||
) from err
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="fetch_data_failed",
|
||||
translation_placeholders={"error": str(err)},
|
||||
) from err
|
||||
raise UpdateFailed(f"Error fetching SPH device data: {err}") from err
|
||||
|
||||
combined = {**sph_detail, **sph_energy}
|
||||
|
||||
@@ -335,11 +313,7 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
try:
|
||||
return await self.hass.async_add_executor_job(self._sync_update_data)
|
||||
except json.decoder.JSONDecodeError as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="fetch_data_failed",
|
||||
translation_placeholders={"error": str(err)},
|
||||
) from err
|
||||
raise UpdateFailed(f"Error fetching data: {err}") from err
|
||||
|
||||
def request_device_list_scan(self) -> None:
|
||||
"""Request that the next _sync_update_data also fetches the device list.
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["growattServer"],
|
||||
"quality_scale": "gold",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["growattServer==2.1.0"]
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ rules:
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
entity-translations: done
|
||||
exception-translations: done
|
||||
exception-translations: todo
|
||||
icon-translations: done
|
||||
reconfiguration-flow: done
|
||||
repair-issues:
|
||||
|
||||
@@ -595,15 +595,6 @@
|
||||
"api_error": {
|
||||
"message": "Growatt API error: {error}"
|
||||
},
|
||||
"api_error_with_code": {
|
||||
"message": "API error: {error} (Code: {code})"
|
||||
},
|
||||
"auth_failed": {
|
||||
"message": "Authentication failed for Growatt API: {error}"
|
||||
},
|
||||
"communication_error": {
|
||||
"message": "Error communicating with Growatt API: {error}"
|
||||
},
|
||||
"device_not_configured": {
|
||||
"message": "{device_type} device {serial_number} is not configured for actions."
|
||||
},
|
||||
@@ -613,9 +604,6 @@
|
||||
"device_not_growatt": {
|
||||
"message": "Device {device_id} is not a Growatt device."
|
||||
},
|
||||
"fetch_data_failed": {
|
||||
"message": "Error fetching data from Growatt API: {error}"
|
||||
},
|
||||
"invalid_batt_mode": {
|
||||
"message": "{batt_mode} is not a valid battery mode. Allowed values: {allowed_modes}."
|
||||
},
|
||||
@@ -625,9 +613,6 @@
|
||||
"invalid_charge_stop_soc": {
|
||||
"message": "'Charge stop SOC' must be between 0 and 100, got {value}."
|
||||
},
|
||||
"invalid_credentials": {
|
||||
"message": "Username, password, or URL may be incorrect"
|
||||
},
|
||||
"invalid_discharge_power": {
|
||||
"message": "'Discharge power' must be between 0 and 100, got {value}."
|
||||
},
|
||||
@@ -649,20 +634,11 @@
|
||||
"invalid_time_format_start_time": {
|
||||
"message": "'Start time' must be in HH:MM or HH:MM:SS format."
|
||||
},
|
||||
"login_failed": {
|
||||
"message": "Growatt login failed: {message}"
|
||||
},
|
||||
"no_devices_configured": {
|
||||
"message": "No {device_type} devices with token authentication are configured. Actions require {device_type} devices with V1 API access."
|
||||
},
|
||||
"rate_limited": {
|
||||
"message": "Growatt API rate limited, will retry: {error}"
|
||||
},
|
||||
"token_auth_required": {
|
||||
"message": "This action requires token authentication (V1 API)."
|
||||
},
|
||||
"unknown_auth_type": {
|
||||
"message": "Unknown authentication type in config entry"
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
|
||||
@@ -8,7 +8,7 @@ import os
|
||||
import struct
|
||||
from typing import Any
|
||||
|
||||
from aiohasupervisor import SupervisorBadRequestError, SupervisorError
|
||||
from aiohasupervisor import SupervisorError
|
||||
from aiohasupervisor.models import (
|
||||
GreenOptions,
|
||||
HomeAssistantOptions,
|
||||
@@ -25,7 +25,6 @@ from homeassistant.components.http import (
|
||||
CONF_SERVER_PORT,
|
||||
CONF_SSL_CERTIFICATE,
|
||||
)
|
||||
from homeassistant.components.onboarding import async_is_onboarded
|
||||
from homeassistant.config_entries import SOURCE_SYSTEM, ConfigEntry
|
||||
from homeassistant.const import (
|
||||
EVENT_CORE_CONFIG_UPDATE,
|
||||
@@ -302,28 +301,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
translation_key="supervisor_not_connected",
|
||||
) from err
|
||||
|
||||
# During onboarding, Supervisor may be out of date. Attempt an update now
|
||||
# so that core loads against an up-to-date Supervisor. A
|
||||
# SupervisorBadRequestError means there is no update available, proceed
|
||||
# normally. No exception means an update was triggered and we must wait for
|
||||
# it to complete. Any other SupervisorError means something unexpected went
|
||||
# wrong and we cannot proceed right now.
|
||||
if not async_is_onboarded(hass):
|
||||
try:
|
||||
await supervisor_client.supervisor.update()
|
||||
except SupervisorBadRequestError:
|
||||
pass # No update available, proceed normally.
|
||||
except SupervisorError as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="supervisor_not_connected",
|
||||
) from err
|
||||
else:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="supervisor_update_pending",
|
||||
)
|
||||
|
||||
# Get or create a refresh token for the Supervisor user
|
||||
user = hass.data[DATA_HASSIO_SUPERVISOR_USER]
|
||||
if user.refresh_tokens:
|
||||
|
||||
@@ -55,9 +55,6 @@
|
||||
},
|
||||
"supervisor_not_connected": {
|
||||
"message": "Not connected with the supervisor / system too busy"
|
||||
},
|
||||
"supervisor_update_pending": {
|
||||
"message": "Supervisor was out-of-date during onboarding. Update triggered, will retry when complete"
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
"""The Helty Flow integration."""
|
||||
|
||||
from pyhelty import HeltyClient
|
||||
|
||||
from homeassistant.const import CONF_HOST, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import HeltyConfigEntry, HeltyDataUpdateCoordinator
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.FAN, Platform.SENSOR]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: HeltyConfigEntry) -> bool:
|
||||
"""Set up Helty Flow from a config entry."""
|
||||
client = HeltyClient(entry.data[CONF_HOST])
|
||||
coordinator = HeltyDataUpdateCoordinator(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: HeltyConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
@@ -1,40 +0,0 @@
|
||||
"""Config flow for the Helty Flow integration."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pyhelty import HeltyClient, HeltyConnectionError, HeltyError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_HOST
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str})
|
||||
|
||||
|
||||
class HeltyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Helty Flow."""
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial setup step."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]})
|
||||
client = HeltyClient(user_input[CONF_HOST])
|
||||
try:
|
||||
name = await client.async_get_name()
|
||||
except HeltyConnectionError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except HeltyError:
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
return self.async_create_entry(
|
||||
title=name or user_input[CONF_HOST], data=user_input
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
)
|
||||
@@ -1,13 +0,0 @@
|
||||
"""Constants for the Helty Flow integration."""
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
DOMAIN = "helty"
|
||||
|
||||
#: How often the coordinator polls the unit.
|
||||
SCAN_INTERVAL = timedelta(seconds=60)
|
||||
|
||||
# Fan preset mode identifiers (also used as translation keys).
|
||||
PRESET_BOOST = "boost"
|
||||
PRESET_NIGHT = "night"
|
||||
PRESET_FREE_COOLING = "free_cooling"
|
||||
@@ -1,45 +0,0 @@
|
||||
"""DataUpdateCoordinator for the Helty Flow integration."""
|
||||
|
||||
import logging
|
||||
|
||||
from pyhelty import HeltyClient, HeltyConnectionError, HeltyData, HeltyError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN, SCAN_INTERVAL
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
type HeltyConfigEntry = ConfigEntry[HeltyDataUpdateCoordinator]
|
||||
|
||||
|
||||
class HeltyDataUpdateCoordinator(DataUpdateCoordinator[HeltyData]):
|
||||
"""Coordinate a single poll of the Helty unit for all entities."""
|
||||
|
||||
config_entry: HeltyConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: HeltyConfigEntry,
|
||||
client: HeltyClient,
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=config_entry,
|
||||
name=DOMAIN,
|
||||
update_interval=SCAN_INTERVAL,
|
||||
)
|
||||
self.client = client
|
||||
|
||||
async def _async_update_data(self) -> HeltyData:
|
||||
try:
|
||||
return await self.client.async_get_data()
|
||||
except HeltyConnectionError as err:
|
||||
raise UpdateFailed(f"Error communicating with Helty unit: {err}") from err
|
||||
except HeltyError as err:
|
||||
raise UpdateFailed(f"Unexpected response from Helty unit: {err}") from err
|
||||
@@ -1,25 +0,0 @@
|
||||
"""Base entity for the Helty Flow integration."""
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import HeltyDataUpdateCoordinator
|
||||
|
||||
|
||||
class HeltyEntity(CoordinatorEntity[HeltyDataUpdateCoordinator]):
|
||||
"""Common base for Helty entities sharing one device and coordinator."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(self, coordinator: HeltyDataUpdateCoordinator) -> None:
|
||||
"""Initialize the entity and its shared device info."""
|
||||
super().__init__(coordinator)
|
||||
# The unit exposes no serial/MAC, so the config entry id identifies it.
|
||||
self._device_id = coordinator.config_entry.entry_id
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, self._device_id)},
|
||||
name=coordinator.data.name,
|
||||
manufacturer="Helty",
|
||||
model="Flow",
|
||||
)
|
||||
@@ -1,120 +0,0 @@
|
||||
"""Fan platform for the Helty Flow integration."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pyhelty import FanMode
|
||||
|
||||
from homeassistant.components.fan import FanEntity, FanEntityFeature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util.percentage import (
|
||||
ordered_list_item_to_percentage,
|
||||
percentage_to_ordered_list_item,
|
||||
)
|
||||
|
||||
from .const import PRESET_BOOST, PRESET_FREE_COOLING, PRESET_NIGHT
|
||||
from .coordinator import HeltyConfigEntry, HeltyDataUpdateCoordinator
|
||||
from .entity import HeltyEntity
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
# Ordered list of discrete fan speeds, lowest to highest.
|
||||
ORDERED_SPEEDS: list[FanMode] = [
|
||||
FanMode.LOW,
|
||||
FanMode.MEDIUM,
|
||||
FanMode.HIGH,
|
||||
FanMode.MAX,
|
||||
]
|
||||
|
||||
PRESET_TO_MODE: dict[str, FanMode] = {
|
||||
PRESET_BOOST: FanMode.BOOST,
|
||||
PRESET_NIGHT: FanMode.NIGHT,
|
||||
PRESET_FREE_COOLING: FanMode.FREE_COOLING,
|
||||
}
|
||||
MODE_TO_PRESET: dict[FanMode, str] = {
|
||||
mode: preset for preset, mode in PRESET_TO_MODE.items()
|
||||
}
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: HeltyConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Helty fan."""
|
||||
async_add_entities([HeltyFan(entry.runtime_data)])
|
||||
|
||||
|
||||
class HeltyFan(HeltyEntity, FanEntity):
|
||||
"""The ventilation unit's fan, the device's primary feature."""
|
||||
|
||||
_attr_name = None
|
||||
_attr_speed_count = len(ORDERED_SPEEDS)
|
||||
_attr_supported_features = (
|
||||
FanEntityFeature.SET_SPEED
|
||||
| FanEntityFeature.PRESET_MODE
|
||||
| FanEntityFeature.TURN_ON
|
||||
| FanEntityFeature.TURN_OFF
|
||||
)
|
||||
|
||||
def __init__(self, coordinator: HeltyDataUpdateCoordinator) -> None:
|
||||
"""Initialize the fan."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_unique_id = self._device_id
|
||||
self._attr_preset_modes = list(PRESET_TO_MODE)
|
||||
|
||||
@property
|
||||
def _mode(self) -> FanMode:
|
||||
return self.coordinator.data.fan_mode
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return whether the fan is running."""
|
||||
return self._mode is not FanMode.OFF
|
||||
|
||||
@property
|
||||
def percentage(self) -> int | None:
|
||||
"""Return the current speed as a percentage, or None when on a preset."""
|
||||
if self._mode in ORDERED_SPEEDS:
|
||||
return ordered_list_item_to_percentage(ORDERED_SPEEDS, self._mode)
|
||||
return None
|
||||
|
||||
@property
|
||||
def preset_mode(self) -> str | None:
|
||||
"""Return the active preset, or None when running on a discrete speed."""
|
||||
return MODE_TO_PRESET.get(self._mode)
|
||||
|
||||
async def async_set_percentage(self, percentage: int) -> None:
|
||||
"""Set a discrete fan speed from a percentage."""
|
||||
if percentage == 0:
|
||||
await self._async_set_mode(FanMode.OFF)
|
||||
return
|
||||
await self._async_set_mode(
|
||||
percentage_to_ordered_list_item(ORDERED_SPEEDS, percentage)
|
||||
)
|
||||
|
||||
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
||||
"""Set a preset mode."""
|
||||
await self._async_set_mode(PRESET_TO_MODE[preset_mode])
|
||||
|
||||
async def async_turn_on(
|
||||
self,
|
||||
percentage: int | None = None,
|
||||
preset_mode: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Turn the fan on."""
|
||||
if preset_mode is not None:
|
||||
await self.async_set_preset_mode(preset_mode)
|
||||
elif percentage is not None:
|
||||
await self.async_set_percentage(percentage)
|
||||
else:
|
||||
await self._async_set_mode(FanMode.LOW)
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the fan off."""
|
||||
await self._async_set_mode(FanMode.OFF)
|
||||
|
||||
async def _async_set_mode(self, mode: FanMode) -> None:
|
||||
await self.coordinator.client.async_set_fan_mode(mode)
|
||||
await self.coordinator.async_request_refresh()
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"domain": "helty",
|
||||
"name": "Helty Flow",
|
||||
"codeowners": ["@ebaschiera"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/helty",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pyhelty"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pyhelty==0.2.0"]
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: This integration does not provide additional 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: This integration does not provide additional actions.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: This integration does not subscribe to external events.
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions:
|
||||
status: exempt
|
||||
comment: This integration does not provide additional actions.
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters:
|
||||
status: exempt
|
||||
comment: This integration has no options to configure.
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow:
|
||||
status: exempt
|
||||
comment: This integration does not require authentication.
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: The device does not support discovery.
|
||||
discovery:
|
||||
status: exempt
|
||||
comment: |
|
||||
The device exposes no discovery protocol (no mDNS/SSDP) and no stable
|
||||
identifier such as a serial number or MAC over its interface.
|
||||
docs-data-update: done
|
||||
docs-examples: todo
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices:
|
||||
status: exempt
|
||||
comment: A config entry represents a single fixed device.
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
entity-translations: done
|
||||
exception-translations: todo
|
||||
icon-translations:
|
||||
status: exempt
|
||||
comment: The fan entity uses the default fan icon.
|
||||
reconfiguration-flow: todo
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: This integration has no repairable issues to surface.
|
||||
stale-devices:
|
||||
status: exempt
|
||||
comment: A config entry represents a single fixed device.
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession:
|
||||
status: exempt
|
||||
comment: |
|
||||
The device is controlled over a raw TCP socket, not HTTP, so there is no
|
||||
web session to inject.
|
||||
strict-typing: todo
|
||||
@@ -1,87 +0,0 @@
|
||||
"""Sensor platform for the Helty Flow integration."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from pyhelty import HeltyData
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import PERCENTAGE, UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import HeltyConfigEntry, HeltyDataUpdateCoordinator
|
||||
from .entity import HeltyEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class HeltySensorEntityDescription(SensorEntityDescription):
|
||||
"""Describes a Helty sensor."""
|
||||
|
||||
value_fn: Callable[[HeltyData], float | None]
|
||||
|
||||
|
||||
SENSORS: tuple[HeltySensorEntityDescription, ...] = (
|
||||
HeltySensorEntityDescription(
|
||||
key="indoor_temperature",
|
||||
translation_key="indoor_temperature",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda data: data.indoor_temperature,
|
||||
),
|
||||
HeltySensorEntityDescription(
|
||||
key="outdoor_temperature",
|
||||
translation_key="outdoor_temperature",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda data: data.outdoor_temperature,
|
||||
),
|
||||
HeltySensorEntityDescription(
|
||||
key="indoor_humidity",
|
||||
translation_key="indoor_humidity",
|
||||
device_class=SensorDeviceClass.HUMIDITY,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda data: data.indoor_humidity,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: HeltyConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Helty sensors."""
|
||||
coordinator = entry.runtime_data
|
||||
async_add_entities(HeltySensor(coordinator, description) for description in SENSORS)
|
||||
|
||||
|
||||
class HeltySensor(HeltyEntity, SensorEntity):
|
||||
"""An environmental sensor reported by the ventilation unit."""
|
||||
|
||||
entity_description: HeltySensorEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: HeltyDataUpdateCoordinator,
|
||||
description: HeltySensorEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{self._device_id}_{description.key}"
|
||||
|
||||
@property
|
||||
def native_value(self) -> float | None:
|
||||
"""Return the current sensor reading."""
|
||||
return self.entity_description.value_fn(self.coordinator.data)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user