Compare commits

..

68 Commits

Author SHA1 Message Date
Franck Nijhof
c82706eaf5 Bump version to 2025.12.0b3 2025-12-01 19:26:30 +00:00
andreimoraru
07f9bec8b6 bump yt-dlp to 2025.11.12 (#157645) 2025-12-01 19:26:14 +00:00
Åke Strandberg
33d576234b Add code mappings for Miele WQ1000 (#157642) 2025-12-01 19:26:13 +00:00
Bram Kragten
9e2b4615f1 Update frontend to 20251201.0 (#157638) 2025-12-01 19:26:11 +00:00
Petro31
a46dc7e05f Reload config entry templates when labs flag automation.new_triggers_conditions is set (#157637)
Co-authored-by: Erik Montnemery <erik@montnemery.com>
2025-12-01 19:26:09 +00:00
Erik Montnemery
7dd9953345 Bump area registry to version 1.9 and sort areas (#157634) 2025-12-01 19:26:08 +00:00
Maciej Bieniek
1145026190 Bump aioshelly to version 13.22.0 (#157629) 2025-12-01 19:26:06 +00:00
Erik Montnemery
d8f9574bc3 Remove cover triggers (#157621) 2025-12-01 19:26:05 +00:00
Erik Montnemery
e91f8d3a81 Remove description_configured from condition and trigger translations (#157620) 2025-12-01 19:26:04 +00:00
Aidan Timson
8c0fd0565e Default area icons for new instances (#157619) 2025-12-01 19:26:02 +00:00
Paul Bottein
cc620fc0f8 Fix user store not loaded on restart (#157616) 2025-12-01 19:26:00 +00:00
Erik Montnemery
5a89332680 Bump floor registry to version 1.3 and sort floors (#157614)
Co-authored-by: Franck Nijhof <git@frenck.dev>
2025-12-01 19:25:59 +00:00
LG-ThinQ-Integration
1831c5e249 Bump thinqconnect to 1.0.9 (#157607) 2025-12-01 19:25:57 +00:00
cdnninja
dddd2503ea Bump pyvesync to 3.3.2 (#157605) 2025-12-01 19:25:56 +00:00
Norbert Rittel
91ba510a1e Fix spelling of "to log in" in anglian_water (#157594) 2025-12-01 19:25:54 +00:00
Norbert Rittel
6e5e739496 Fix spelling of "to set up" in hue_ble (#157593) 2025-12-01 19:25:53 +00:00
Sanjay Govind
6b39eb069c Bump bosch-alarm-mode2 to v0.4.10 (#157564) 2025-12-01 19:25:51 +00:00
Allen Porter
847c332c70 Bump google-nest-sdm to 9.1.1 (#157562) 2025-12-01 19:25:50 +00:00
J. Nick Koston
1a19f3b527 Bump aioesphomeapi to 42.9.0 (#157558) 2025-12-01 19:25:49 +00:00
Thomas55555
8110935d2d Bump google air quality api to 1.1.3 (#157555) 2025-12-01 19:25:47 +00:00
Raphael Hehl
af69da94f5 Bump uiprotect to 7.31.0 (#157543) 2025-12-01 19:25:46 +00:00
Jan Bouwhuis
c1cf17d4db Fix MQTT entity cannot be renamed (#157540) 2025-12-01 19:25:44 +00:00
Allen Porter
6079637909 Bump python-roborock to 3.8.4 (#157538) 2025-12-01 19:25:43 +00:00
Jordan Harvey
9268e12b20 Disable cookie quotes for Anglian Water (#157518) 2025-12-01 19:25:41 +00:00
Raphael Hehl
d07993f4a4 Fix UniFi Protect RTSP repair warnings when globally disabled (#157516) 2025-12-01 19:25:40 +00:00
Allen Porter
441cb4197c Bump python-roborock to 3.8.3 (#157512) 2025-12-01 19:25:38 +00:00
J. Nick Koston
d2a095588d Bump ESPHome stable BLE version to 2025.11.0 (#157511) 2025-12-01 19:25:37 +00:00
Arie Catsman
f2578da7db Bump pyenphase to 2.4.2 (#157500) 2025-12-01 19:25:35 +00:00
Jan Bouwhuis
22200d6804 Fix subentry ID is not updated when renaming the entity ID (#157498) 2025-12-01 19:25:34 +00:00
Maciej Bieniek
8a4e5c3a28 Remove name from Shelly RGBCCT sensors (#157492) 2025-12-01 19:25:32 +00:00
Maciej Bieniek
30f31c7d8c Remove name for Shelly gas valve (gen1) entity (#157490) 2025-12-01 19:25:30 +00:00
Maciej Bieniek
232c4255a1 Add missing string for Shelly away mode switch (#157488) 2025-12-01 19:25:28 +00:00
Petro31
236f7cd22c Ensure platform template does not appear in repair (#157486) 2025-12-01 19:25:27 +00:00
Åke Strandberg
5948ff2e31 Add loggers to senz manifest (#157479) 2025-12-01 19:25:25 +00:00
epenet
380127bc70 Fix blocking call in Tuya initialisation (#157477) 2025-12-01 19:25:23 +00:00
epenet
b6a1e8251a Remove unnecessary instanciating in Tuya find_dpcode (#157473) 2025-12-01 19:25:22 +00:00
David Woodhouse
c20236717c Clarify percentage_command_topic and percentage_state_topic for MQTT fan (#157460)
Co-authored-by: Jan Bouwhuis <jbouwh@users.noreply.github.com>
2025-12-01 19:25:21 +00:00
Artur Pragacz
1fd9feaace Provide log info for discovered flows in logger (#157454) 2025-12-01 19:25:19 +00:00
ElectricSteve
7ce072b4dc bump: youtubeaio to 2.1.1 (#157452) 2025-12-01 19:25:17 +00:00
Artur Pragacz
45aa0399c7 Add tools in default agent also in fallback pipeline (#157441) 2025-12-01 19:25:16 +00:00
Hem Bhagat
d82b3871c1 Move translatable URLs out of strings.json for opentherm_gw integration (#157437) 2025-12-01 19:25:14 +00:00
Thomas55555
8f6d1162e5 Fix strings in Google Air Quality (#157297) 2025-12-01 19:21:28 +00:00
puddly
dafce97341 Disable owning integrations for the entire firmware interaction process (#157082) 2025-12-01 19:21:26 +00:00
Sebastian Schneider
ffd5d33bbc Support UniFi LED control for devices without RGB (#156812) 2025-12-01 19:21:24 +00:00
Franck Nijhof
bac32bc379 Bump version to 2025.12.0b2 2025-11-27 17:13:26 +00:00
Allen Porter
6344837009 Fix regression in roborock image entity naming (#157432) 2025-11-27 17:12:08 +00:00
Allen Porter
9079ff5ea8 Update roborock test typing (#157370) 2025-11-27 17:12:06 +00:00
Bram Kragten
cd646aea11 Update frontend to 20251127.0 (#157431) 2025-11-27 17:09:23 +00:00
Shay Levy
b3a93d9fab Fix Shelly support for button5 trigger (#157422) 2025-11-27 17:09:22 +00:00
Denis Shulyaka
db98fb138b Fix Anthropic init with incorrect model (#157421) 2025-11-27 17:09:21 +00:00
Petro31
348c8bca7c Avoid custom template platform deprecations (#157415) 2025-11-27 17:09:20 +00:00
Allen Porter
e30707ad5e Bump python-roborock to 3.8.1 (#157376) 2025-11-27 17:09:18 +00:00
Petro31
3fa4dcb980 Reload templates when labs flag automation.new_triggers_conditions is set (#157368) 2025-11-27 17:09:17 +00:00
Kamil Breguła
57835efc9d Fix MAC address mix-ups between WLED devices (#155491)
Co-authored-by: mik-laj <12058428+mik-laj@users.noreply.github.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-11-27 17:09:16 +00:00
Franck Nijhof
f8d5a8bc58 Bump version to 2025.12.0b1 2025-11-27 11:49:46 +00:00
epenet
3f1f8da6f5 Bump renault-api to 0.5.1 (#157411) 2025-11-27 11:48:09 +00:00
Jan Čermák
55613f56b6 Fix state classes of Ecowitt rain sensors (#157409) 2025-11-27 11:48:08 +00:00
victorigualada
3ee2a78663 Bump hass-nabucasa from 1.6.1 to 1.6.2 (#157405) 2025-11-27 11:48:06 +00:00
victorigualada
814a0c4cc9 Return early when setting cloud ai_task and conversation and not logged in to cloud (#157402) 2025-11-27 11:48:04 +00:00
starkillerOG
71b674d8f1 Bump reolink-aio to 0.16.6 (#157399) 2025-11-27 11:48:03 +00:00
Erik Montnemery
c952fc5e31 Minor polish of cover trigger tests (#157397) 2025-11-27 11:48:02 +00:00
Allen Porter
8c3d40a348 Remove old roborock map storage (#157379) 2025-11-27 11:48:01 +00:00
Paulus Schoutsen
2451dfb63d Default conversation agent to store tool calls in chat log (#157377) 2025-11-27 11:48:00 +00:00
Sarah Seidman
8e5921eab6 Normalize input for Droplet pairing code (#157361) 2025-11-27 11:47:59 +00:00
Jaap Pieroen
bc730da9b1 Bugfix: Essent remove average gas price today (#157317) 2025-11-27 11:47:57 +00:00
abelyliu
28b7ebea6e Fix parsing of Tuya electricity RAW values (#157039) 2025-11-27 11:47:56 +00:00
Erik Montnemery
cfa447c7a9 Add climate started_cooling and started_drying triggers (#156945) 2025-11-27 11:47:55 +00:00
Franck Nijhof
f64c870e42 Bump version to 2025.12.0b0 2025-11-26 17:13:42 +00:00
212 changed files with 2222 additions and 7393 deletions

View File

@@ -190,8 +190,7 @@ jobs:
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- &install_cosign
name: Install Cosign
- name: Install Cosign
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
with:
cosign-release: "v2.5.3"
@@ -295,7 +294,7 @@ jobs:
# home-assistant/builder doesn't support sha pinning
- name: Build base image
uses: home-assistant/builder@2025.11.0
uses: home-assistant/builder@2025.09.0
with:
args: |
$BUILD_ARGS \
@@ -354,7 +353,10 @@ jobs:
matrix:
registry: ["ghcr.io/home-assistant", "docker.io/homeassistant"]
steps:
- *install_cosign
- name: Install Cosign
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
with:
cosign-release: "v2.2.3"
- name: Login to DockerHub
if: matrix.registry == 'docker.io/homeassistant'
@@ -391,7 +393,7 @@ jobs:
# 2025.12.0.dev202511250240 -> tags: 2025.12.0.dev202511250240, dev
- name: Generate Docker metadata
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
uses: docker/metadata-action@8e5442c4ef9f78752691e2d8f8d19755c6f78e81 # v5.5.1
with:
images: ${{ matrix.registry }}/home-assistant
sep-tags: ","
@@ -405,7 +407,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@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.7.1
uses: docker/setup-buildx-action@aa33708b10e362ff993539393ff100fa93ed6a27 # v3.7.1
- name: Copy architecture images to DockerHub
if: matrix.registry == 'docker.io/homeassistant'

View File

@@ -40,7 +40,7 @@ env:
CACHE_VERSION: 2
UV_CACHE_VERSION: 1
MYPY_CACHE_VERSION: 1
HA_SHORT_VERSION: "2026.1"
HA_SHORT_VERSION: "2025.12"
DEFAULT_PYTHON: "3.13"
ALL_PYTHON_VERSIONS: "['3.13', '3.14']"
# 10.3 is the oldest supported version

View File

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

View File

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

2
CODEOWNERS generated
View File

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

View File

@@ -35,22 +35,25 @@ COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
USER vscode
COPY .python-version ./
RUN uv python install
ENV VIRTUAL_ENV="/home/vscode/.local/ha-venv"
RUN --mount=type=bind,source=.python-version,target=.python-version \
uv python install \
&& uv venv $VIRTUAL_ENV
RUN uv venv $VIRTUAL_ENV
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
WORKDIR /tmp
# Setup hass-release
RUN git clone --depth 1 https://github.com/home-assistant/hass-release ~/hass-release \
&& uv pip install -e ~/hass-release/
# Install Python dependencies from requirements
RUN --mount=type=bind,source=requirements.txt,target=requirements.txt \
--mount=type=bind,source=homeassistant/package_constraints.txt,target=homeassistant/package_constraints.txt \
--mount=type=bind,source=requirements_test.txt,target=requirements_test.txt \
--mount=type=bind,source=requirements_test_pre_commit.txt,target=requirements_test_pre_commit.txt \
uv pip install -r requirements.txt -r requirements_test.txt
COPY requirements.txt ./
COPY homeassistant/package_constraints.txt homeassistant/package_constraints.txt
RUN uv pip install -r requirements.txt
COPY requirements_test.txt requirements_test_pre_commit.txt ./
RUN uv pip install -r requirements_test.txt
WORKDIR /workspaces

View File

@@ -1000,7 +1000,7 @@ class _WatchPendingSetups:
# We log every LOG_SLOW_STARTUP_INTERVAL until all integrations are done
# once we take over LOG_SLOW_STARTUP_INTERVAL (60s) to start up
_LOGGER.warning(
"Waiting for integrations to complete setup: %s",
"Waiting on integrations to complete setup: %s",
self._setup_started,
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -18,21 +18,17 @@ _LOGGER = logging.getLogger(__name__)
class AnglianWaterEntity(CoordinatorEntity[AnglianWaterUpdateCoordinator]):
"""Defines a Anglian Water entity."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: AnglianWaterUpdateCoordinator,
smart_meter: SmartMeter,
key: str,
) -> None:
"""Initialize Anglian Water entity."""
super().__init__(coordinator)
self.smart_meter = smart_meter
self._attr_unique_id = f"{smart_meter.serial_number}_{key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, smart_meter.serial_number)},
name=smart_meter.serial_number,
name="Smart Water Meter",
manufacturer="Anglian Water",
serial_number=smart_meter.serial_number,
)

View File

@@ -108,8 +108,9 @@ class AnglianWaterSensorEntity(AnglianWaterEntity, SensorEntity):
description: AnglianWaterSensorEntityDescription,
) -> None:
"""Initialize Anglian Water sensor."""
super().__init__(coordinator, smart_meter, description.key)
super().__init__(coordinator, smart_meter)
self.entity_description = description
self._attr_unique_id = f"{smart_meter.serial_number}_{description.key}"
@property
def native_value(self) -> float | None:

View File

@@ -421,8 +421,6 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
)
if short_form.search(model_alias):
model_alias += "-0"
if model_alias.endswith(("haiku", "opus", "sonnet")):
model_alias += "-latest"
model_options.append(
SelectOptionDict(
label=model_info.display_name,

View File

@@ -583,7 +583,7 @@ class AnthropicBaseLLMEntity(Entity):
identifiers={(DOMAIN, subentry.subentry_id)},
name=subentry.title,
manufacturer="Anthropic",
model=subentry.data.get(CONF_CHAT_MODEL, DEFAULT[CONF_CHAT_MODEL]),
model="Claude",
entry_type=dr.DeviceEntryType.SERVICE,
)

View File

@@ -8,5 +8,5 @@
"documentation": "https://www.home-assistant.io/integrations/anthropic",
"integration_type": "service",
"iot_class": "cloud_polling",
"requirements": ["anthropic==0.75.0"]
"requirements": ["anthropic==0.73.0"]
}

View File

@@ -17,12 +17,8 @@ from homeassistant.components.media_player import (
class BangOlufsenSource:
"""Class used for associating device source ids with friendly names. May not include all sources."""
DEEZER: Final[Source] = Source(name="Deezer", id="deezer")
LINE_IN: Final[Source] = Source(name="Line-In", id="lineIn")
NET_RADIO: Final[Source] = Source(name="B&O Radio", id="netRadio")
SPDIF: Final[Source] = Source(name="Optical", id="spdif")
TIDAL: Final[Source] = Source(name="Tidal", id="tidal")
UNKNOWN: Final[Source] = Source(name="Unknown Source", id="unknown")
URI_STREAMER: Final[Source] = Source(name="Audio Streamer", id="uriStreamer")
@@ -82,16 +78,6 @@ class BangOlufsenModel(StrEnum):
BEOREMOTE_ONE = "Beoremote One"
class BangOlufsenAttribute(StrEnum):
"""Enum for extra_state_attribute keys."""
BEOLINK = "beolink"
BEOLINK_PEERS = "peers"
BEOLINK_SELF = "self"
BEOLINK_LEADER = "leader"
BEOLINK_LISTENERS = "listeners"
# Physical "buttons" on devices
class BangOlufsenButtons(StrEnum):
"""Enum for device buttons."""

View File

@@ -82,7 +82,6 @@ from .const import (
FALLBACK_SOURCES,
MANUFACTURER,
VALID_MEDIA_TYPES,
BangOlufsenAttribute,
BangOlufsenMediaType,
BangOlufsenSource,
WebsocketNotification,
@@ -225,8 +224,7 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
# Beolink compatible sources
self._beolink_sources: dict[str, bool] = {}
self._remote_leader: BeolinkLeader | None = None
# Extra state attributes:
# Beolink: peer(s), listener(s), leader and self
# Extra state attributes for showing Beolink: peer(s), listener(s), leader and self
self._beolink_attributes: dict[str, dict[str, dict[str, str]]] = {}
async def async_added_to_hass(self) -> None:
@@ -438,10 +436,7 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
await self._async_update_beolink()
async def _async_update_beolink(self) -> None:
"""Update the current Beolink leader, listeners, peers and self.
Updates Home Assistant state.
"""
"""Update the current Beolink leader, listeners, peers and self."""
self._beolink_attributes = {}
@@ -450,24 +445,18 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
# Add Beolink self
self._beolink_attributes = {
BangOlufsenAttribute.BEOLINK: {
BangOlufsenAttribute.BEOLINK_SELF: {
self.device_entry.name: self._beolink_jid
}
}
"beolink": {"self": {self.device_entry.name: self._beolink_jid}}
}
# Add Beolink peers
peers = await self._client.get_beolink_peers()
if len(peers) > 0:
self._beolink_attributes[BangOlufsenAttribute.BEOLINK][
BangOlufsenAttribute.BEOLINK_PEERS
] = {}
self._beolink_attributes["beolink"]["peers"] = {}
for peer in peers:
self._beolink_attributes[BangOlufsenAttribute.BEOLINK][
BangOlufsenAttribute.BEOLINK_PEERS
][peer.friendly_name] = peer.jid
self._beolink_attributes["beolink"]["peers"][peer.friendly_name] = (
peer.jid
)
# Add Beolink listeners / leader
self._remote_leader = self._playback_metadata.remote_leader
@@ -488,9 +477,7 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
# Add self
group_members.append(self.entity_id)
self._beolink_attributes[BangOlufsenAttribute.BEOLINK][
BangOlufsenAttribute.BEOLINK_LEADER
] = {
self._beolink_attributes["beolink"]["leader"] = {
self._remote_leader.friendly_name: self._remote_leader.jid,
}
@@ -527,9 +514,9 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
beolink_listener.jid
)
break
self._beolink_attributes[BangOlufsenAttribute.BEOLINK][
BangOlufsenAttribute.BEOLINK_LISTENERS
] = beolink_listeners_attribute
self._beolink_attributes["beolink"]["listeners"] = (
beolink_listeners_attribute
)
self._attr_group_members = group_members
@@ -628,18 +615,11 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
return None
@property
def media_content_type(self) -> MediaType | str | None:
def media_content_type(self) -> str:
"""Return the current media type."""
content_type = {
BangOlufsenSource.URI_STREAMER.id: MediaType.URL,
BangOlufsenSource.DEEZER.id: BangOlufsenMediaType.DEEZER,
BangOlufsenSource.TIDAL.id: BangOlufsenMediaType.TIDAL,
BangOlufsenSource.NET_RADIO.id: BangOlufsenMediaType.RADIO,
}
# Hard to determine content type.
if self._source_change.id in content_type:
return content_type[self._source_change.id]
# Hard to determine content type
if self._source_change.id == BangOlufsenSource.URI_STREAMER.id:
return MediaType.URL
return MediaType.MUSIC
@property
@@ -652,11 +632,6 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
"""Return the current playback progress."""
return self._playback_progress.progress
@property
def media_content_id(self) -> str | None:
"""Return internal ID of Deezer, Tidal and radio stations."""
return self._playback_metadata.source_internal_id
@property
def media_image_url(self) -> str | None:
"""Return URL of the currently playing music."""

View File

@@ -8,10 +8,6 @@ from typing import Any
from pycoolmasternet_async import SWING_MODES
from homeassistant.components.climate import (
FAN_AUTO,
FAN_HIGH,
FAN_LOW,
FAN_MEDIUM,
ClimateEntity,
ClimateEntityFeature,
HVACMode,
@@ -35,16 +31,7 @@ CM_TO_HA_STATE = {
HA_STATE_TO_CM = {value: key for key, value in CM_TO_HA_STATE.items()}
CM_TO_HA_FAN = {
"low": FAN_LOW,
"med": FAN_MEDIUM,
"high": FAN_HIGH,
"auto": FAN_AUTO,
}
HA_FAN_TO_CM = {value: key for key, value in CM_TO_HA_FAN.items()}
FAN_MODES = list(CM_TO_HA_FAN.values())
FAN_MODES = ["low", "med", "high", "auto"]
_LOGGER = logging.getLogger(__name__)
@@ -124,7 +111,7 @@ class CoolmasterClimate(CoolmasterEntity, ClimateEntity):
@property
def fan_mode(self):
"""Return the fan setting."""
return CM_TO_HA_FAN[self._unit.fan_speed]
return self._unit.fan_speed
@property
def fan_modes(self):
@@ -151,7 +138,7 @@ class CoolmasterClimate(CoolmasterEntity, ClimateEntity):
async def async_set_fan_mode(self, fan_mode: str) -> None:
"""Set new fan mode."""
_LOGGER.debug("Setting fan mode of %s to %s", self.unique_id, fan_mode)
self._unit = await self._unit.set_fan_speed(HA_FAN_TO_CM[fan_mode])
self._unit = await self._unit.set_fan_speed(fan_mode)
self.async_write_ha_state()
async def async_set_swing_mode(self, swing_mode: str) -> None:

View File

@@ -5,7 +5,6 @@
"config_flow": true,
"dependencies": ["webhook"],
"documentation": "https://www.home-assistant.io/integrations/ecowitt",
"integration_type": "device",
"iot_class": "local_push",
"requirements": ["aioecowitt==2025.9.2"]
}

View File

@@ -1,30 +1,22 @@
"""API for fitbit bound to Home Assistant OAuth."""
from abc import ABC, abstractmethod
from collections.abc import Awaitable, Callable
from collections.abc import Callable
import logging
from typing import Any, cast
from fitbit import Fitbit
from fitbit.exceptions import HTTPException, HTTPUnauthorized
from fitbit_web_api import ApiClient, Configuration, DevicesApi
from fitbit_web_api.exceptions import (
ApiException,
OpenApiException,
UnauthorizedException,
)
from fitbit_web_api.models.device import Device
from requests.exceptions import ConnectionError as RequestsConnectionError
from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_entry_oauth2_flow
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.util.unit_system import METRIC_SYSTEM
from .const import FitbitUnitSystem
from .exceptions import FitbitApiException, FitbitAuthException
from .model import FitbitProfile
from .model import FitbitDevice, FitbitProfile
_LOGGER = logging.getLogger(__name__)
@@ -66,14 +58,6 @@ class FitbitApi(ABC):
expires_at=float(token[CONF_EXPIRES_AT]),
)
async def _async_get_fitbit_web_api(self) -> ApiClient:
"""Create and return an ApiClient configured with the current access token."""
token = await self.async_get_access_token()
configuration = Configuration()
configuration.pool_manager = async_get_clientsession(self._hass)
configuration.access_token = token[CONF_ACCESS_TOKEN]
return ApiClient(configuration)
async def async_get_user_profile(self) -> FitbitProfile:
"""Return the user profile from the API."""
if self._profile is None:
@@ -110,13 +94,21 @@ class FitbitApi(ABC):
return FitbitUnitSystem.METRIC
return FitbitUnitSystem.EN_US
async def async_get_devices(self) -> list[Device]:
"""Return available devices using fitbit-web-api."""
client = await self._async_get_fitbit_web_api()
devices_api = DevicesApi(client)
devices: list[Device] = await self._run_async(devices_api.get_devices)
async def async_get_devices(self) -> list[FitbitDevice]:
"""Return available devices."""
client = await self._async_get_client()
devices: list[dict[str, str]] = await self._run(client.get_devices)
_LOGGER.debug("get_devices=%s", devices)
return devices
return [
FitbitDevice(
id=device["id"],
device_version=device["deviceVersion"],
battery_level=int(device["batteryLevel"]),
battery=device["battery"],
type=device["type"],
)
for device in devices
]
async def async_get_latest_time_series(self, resource_type: str) -> dict[str, Any]:
"""Return the most recent value from the time series for the specified resource type."""
@@ -148,20 +140,6 @@ class FitbitApi(ABC):
_LOGGER.debug("Error from fitbit API: %s", err)
raise FitbitApiException("Error from fitbit API") from err
async def _run_async[_T](self, func: Callable[[], Awaitable[_T]]) -> _T:
"""Run client command."""
try:
return await func()
except UnauthorizedException as err:
_LOGGER.debug("Unauthorized error from fitbit API: %s", err)
raise FitbitAuthException("Authentication error from fitbit API") from err
except ApiException as err:
_LOGGER.debug("Error from fitbit API: %s", err)
raise FitbitApiException("Error from fitbit API") from err
except OpenApiException as err:
_LOGGER.debug("Error communicating with fitbit API: %s", err)
raise FitbitApiException("Communication error from fitbit API") from err
class OAuthFitbitApi(FitbitApi):
"""Provide fitbit authentication tied to an OAuth2 based config entry."""

View File

@@ -6,8 +6,6 @@ import datetime
import logging
from typing import Final
from fitbit_web_api.models.device import Device
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
@@ -15,6 +13,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
from .api import FitbitApi
from .exceptions import FitbitApiException, FitbitAuthException
from .model import FitbitDevice
_LOGGER = logging.getLogger(__name__)
@@ -24,7 +23,7 @@ TIMEOUT = 10
type FitbitConfigEntry = ConfigEntry[FitbitData]
class FitbitDeviceCoordinator(DataUpdateCoordinator[dict[str, Device]]):
class FitbitDeviceCoordinator(DataUpdateCoordinator[dict[str, FitbitDevice]]):
"""Coordinator for fetching fitbit devices from the API."""
config_entry: FitbitConfigEntry
@@ -42,7 +41,7 @@ class FitbitDeviceCoordinator(DataUpdateCoordinator[dict[str, Device]]):
)
self._api = api
async def _async_update_data(self) -> dict[str, Device]:
async def _async_update_data(self) -> dict[str, FitbitDevice]:
"""Fetch data from API endpoint."""
async with asyncio.timeout(TIMEOUT):
try:
@@ -51,7 +50,7 @@ class FitbitDeviceCoordinator(DataUpdateCoordinator[dict[str, Device]]):
raise ConfigEntryAuthFailed(err) from err
except FitbitApiException as err:
raise UpdateFailed(err) from err
return {device.id: device for device in devices if device.id is not None}
return {device.id: device for device in devices}
@dataclass

View File

@@ -6,6 +6,6 @@
"dependencies": ["application_credentials", "http"],
"documentation": "https://www.home-assistant.io/integrations/fitbit",
"iot_class": "cloud_polling",
"loggers": ["fitbit", "fitbit_web_api"],
"requirements": ["fitbit==0.3.1", "fitbit-web-api==2.13.5"]
"loggers": ["fitbit"],
"requirements": ["fitbit==0.3.1"]
}

View File

@@ -21,6 +21,26 @@ class FitbitProfile:
"""The locale defined in the user's Fitbit account settings."""
@dataclass
class FitbitDevice:
"""Device from the Fitbit API response."""
id: str
"""The device ID."""
device_version: str
"""The product name of the device."""
battery_level: int
"""The battery level as a percentage."""
battery: str
"""Returns the battery level of the device."""
type: str
"""The type of the device such as TRACKER or SCALE."""
@dataclass
class FitbitConfig:
"""Information from the fitbit ConfigEntry data."""

View File

@@ -8,8 +8,6 @@ import datetime
import logging
from typing import Any, Final, cast
from fitbit_web_api.models.device import Device
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
@@ -34,7 +32,7 @@ from .api import FitbitApi
from .const import ATTRIBUTION, BATTERY_LEVELS, DOMAIN, FitbitScope, FitbitUnitSystem
from .coordinator import FitbitConfigEntry, FitbitDeviceCoordinator
from .exceptions import FitbitApiException, FitbitAuthException
from .model import config_from_entry_data
from .model import FitbitDevice, config_from_entry_data
_LOGGER: Final = logging.getLogger(__name__)
@@ -659,7 +657,7 @@ class FitbitBatterySensor(CoordinatorEntity[FitbitDeviceCoordinator], SensorEnti
coordinator: FitbitDeviceCoordinator,
user_profile_id: str,
description: FitbitSensorEntityDescription,
device: Device,
device: FitbitDevice,
enable_default_override: bool,
) -> None:
"""Initialize the Fitbit sensor."""
@@ -679,9 +677,7 @@ class FitbitBatterySensor(CoordinatorEntity[FitbitDeviceCoordinator], SensorEnti
@property
def icon(self) -> str | None:
"""Icon to use in the frontend, if any."""
if self.device.battery is not None and (
battery_level := BATTERY_LEVELS.get(self.device.battery)
):
if battery_level := BATTERY_LEVELS.get(self.device.battery):
return icon_for_battery_level(battery_level=battery_level)
return self.entity_description.icon
@@ -701,7 +697,7 @@ class FitbitBatterySensor(CoordinatorEntity[FitbitDeviceCoordinator], SensorEnti
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
self.device = self.coordinator.data[cast(str, self.device.id)]
self.device = self.coordinator.data[self.device.id]
self._attr_native_value = self.device.battery
self.async_write_ha_state()
@@ -719,7 +715,7 @@ class FitbitBatteryLevelSensor(
coordinator: FitbitDeviceCoordinator,
user_profile_id: str,
description: FitbitSensorEntityDescription,
device: Device,
device: FitbitDevice,
) -> None:
"""Initialize the Fitbit sensor."""
super().__init__(coordinator)
@@ -740,6 +736,6 @@ class FitbitBatteryLevelSensor(
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
self.device = self.coordinator.data[cast(str, self.device.id)]
self.device = self.coordinator.data[self.device.id]
self._attr_native_value = self.device.battery_level
self.async_write_ha_state()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -53,7 +53,7 @@ from homeassistant.helpers.issue_registry import (
async_create_issue,
async_delete_issue,
)
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType
from .const import CONF_IGNORE_NON_NUMERIC, DOMAIN
from .entity import GroupEntity
@@ -374,7 +374,7 @@ class SensorGroup(GroupEntity, SensorEntity):
def async_update_group_state(self) -> None:
"""Query all members and determine the sensor group state."""
self.calculate_state_attributes(self._get_valid_entities())
states: list[str] = []
states: list[StateType] = []
valid_units = self._valid_units
valid_states: list[bool] = []
sensor_values: list[tuple[str, float, State]] = []

View File

@@ -211,7 +211,7 @@ async def ws_start_preview(
@callback
def async_preview_updated(
last_exception: BaseException | None, state: str, attributes: Mapping[str, Any]
last_exception: Exception | None, state: str, attributes: Mapping[str, Any]
) -> None:
"""Forward config entry state events to websocket."""
if last_exception:

View File

@@ -241,9 +241,7 @@ class HistoryStatsSensor(HistoryStatsSensorBase):
async def async_start_preview(
self,
preview_callback: Callable[
[BaseException | None, str, Mapping[str, Any]], None
],
preview_callback: Callable[[Exception | None, str, Mapping[str, Any]], None],
) -> CALLBACK_TYPE:
"""Render a preview."""

View File

@@ -39,10 +39,6 @@ if TYPE_CHECKING:
_LOGGER = logging.getLogger(__name__)
_DESCRIPTION_PLACEHOLDERS = {
"sensor_value_types_url": "https://www.home-assistant.io/integrations/knx/#value-types"
}
@callback
def async_setup_services(hass: HomeAssistant) -> None:
@@ -52,7 +48,6 @@ def async_setup_services(hass: HomeAssistant) -> None:
SERVICE_KNX_SEND,
service_send_to_knx_bus,
schema=SERVICE_KNX_SEND_SCHEMA,
description_placeholders=_DESCRIPTION_PLACEHOLDERS,
)
hass.services.async_register(
@@ -68,7 +63,6 @@ def async_setup_services(hass: HomeAssistant) -> None:
SERVICE_KNX_EVENT_REGISTER,
service_event_register_modify,
schema=SERVICE_KNX_EVENT_REGISTER_SCHEMA,
description_placeholders=_DESCRIPTION_PLACEHOLDERS,
)
async_register_admin_service(
@@ -77,7 +71,6 @@ def async_setup_services(hass: HomeAssistant) -> None:
SERVICE_KNX_EXPOSURE_REGISTER,
service_exposure_register_modify,
schema=SERVICE_KNX_EXPOSURE_REGISTER_SCHEMA,
description_placeholders=_DESCRIPTION_PLACEHOLDERS,
)
async_register_admin_service(

View File

@@ -674,7 +674,7 @@
"name": "Remove event registration"
},
"type": {
"description": "If set, the payload will be decoded as given DPT in the event data `value` key. KNX sensor types are valid values (see {sensor_value_types_url}).",
"description": "If set, the payload will be decoded as given DPT in the event data `value` key. KNX sensor types are valid values (see https://www.home-assistant.io/integrations/knx/#value-types).",
"name": "Value type"
}
},
@@ -704,7 +704,7 @@
"name": "Remove exposure"
},
"type": {
"description": "Telegrams will be encoded as given DPT. 'binary' and all KNX sensor types are valid values (see {sensor_value_types_url}).",
"description": "Telegrams will be encoded as given DPT. 'binary' and all KNX sensor types are valid values (see https://www.home-assistant.io/integrations/knx/#value-types).",
"name": "Value type"
}
},
@@ -740,7 +740,7 @@
"name": "Send as Response"
},
"type": {
"description": "If set, the payload will not be sent as raw bytes, but encoded as given DPT. KNX sensor types are valid values (see {sensor_value_types_url}).",
"description": "If set, the payload will not be sent as raw bytes, but encoded as given DPT. KNX sensor types are valid values (see https://www.home-assistant.io/integrations/knx/#value-types).",
"name": "Value type"
}
},

View File

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

View File

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

View File

@@ -19,7 +19,6 @@ from google_nest_sdm.exceptions import (
ConfigurationException,
DecodeException,
SubscriberException,
SubscriberTimeoutException,
)
from google_nest_sdm.traits import TraitType
import voluptuous as vol
@@ -204,16 +203,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: NestConfigEntry) -> bool
await auth.async_get_access_token()
except ClientResponseError as err:
if 400 <= err.status < 500:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN, translation_key="reauth_required"
) from err
raise ConfigEntryNotReady(
translation_domain=DOMAIN, translation_key="auth_server_error"
) from err
raise ConfigEntryAuthFailed from err
raise ConfigEntryNotReady from err
except ClientError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN, translation_key="auth_client_error"
) from err
raise ConfigEntryNotReady from err
subscriber = await api.new_subscriber(hass, entry, auth)
if not subscriber:
@@ -234,32 +227,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: NestConfigEntry) -> bool
unsub = await subscriber.start_async()
except AuthException as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="reauth_required",
f"Subscriber authentication error: {err!s}"
) from err
except ConfigurationException as err:
_LOGGER.error("Configuration error: %s", err)
return False
except SubscriberTimeoutException as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="subscriber_timeout",
) from err
except SubscriberException as err:
_LOGGER.error("Subscriber error: %s", err)
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="subscriber_error",
) from err
raise ConfigEntryNotReady(f"Subscriber error: {err!s}") from err
try:
device_manager = await subscriber.async_get_device_manager()
except ApiException as err:
unsub()
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="device_api_error",
) from err
raise ConfigEntryNotReady(f"Device manager error: {err!s}") from err
@callback
def on_hass_stop(_: Event) -> None:

View File

@@ -23,7 +23,12 @@ rules:
entity-unique-id: done
docs-installation-instructions: done
docs-removal-instructions: todo
test-before-setup: done
test-before-setup:
status: todo
comment: |
The integration does tests on setup, however the most common issues
observed are related to ipv6 misconfigurations and the error messages
are not self explanatory and can be improved.
docs-high-level-description: done
config-flow-test-coverage: done
docs-actions: done

View File

@@ -131,26 +131,6 @@
}
}
},
"exceptions": {
"auth_client_error": {
"message": "Client error during authentication, please check your network connection."
},
"auth_server_error": {
"message": "Error response from authentication server, please see logs for details."
},
"device_api_error": {
"message": "Error communicating with the Device Access API, please see logs for details."
},
"reauth_required": {
"message": "Reauthentication is required, please follow the instructions in the UI to reauthenticate your account."
},
"subscriber_error": {
"message": "Subscriber failed to connect to Google, please see logs for details."
},
"subscriber_timeout": {
"message": "Subscriber timed out while attempting to connect to Google. Please check your network connection and IPv6 configuration if applicable."
}
},
"selector": {
"subscription_name": {
"options": {

View File

@@ -432,7 +432,7 @@ class NumberDeviceClass(StrEnum):
Unit of measurement: UnitOfVolumeFlowRate
- SI / metric: `m³/h`, `m³/min`, `m³/s`, `L/h`, `L/min`, `L/s`, `mL/s`
- USCS / imperial: `ft³/min`, `gal/min`, `gal/d`
- USCS / imperial: `ft³/min`, `gal/min`
"""
WATER = "water"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -468,7 +468,7 @@ class SensorDeviceClass(StrEnum):
Unit of measurement: UnitOfVolumeFlowRate
- SI / metric: `m³/h`, `m³/min`, `m³/s`, `L/h`, `L/min`, `L/s`, `mL/s`
- USCS / imperial: `ft³/min`, `gal/min`, `gal/d`
- USCS / imperial: `ft³/min`, `gal/min`
"""
WATER = "water"

View File

@@ -20,9 +20,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import SFRConfigEntry
from .entity import SFRCoordinatorEntity
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class SFRBoxBinarySensorEntityDescription[_T](BinarySensorEntityDescription):
@@ -97,4 +94,6 @@ class SFRBoxBinarySensor[_T](SFRCoordinatorEntity[_T], BinarySensorEntity):
@property
def is_on(self) -> bool | None:
"""Return the native value of the device."""
if self.coordinator.data is None:
return None
return self.entity_description.value_fn(self.coordinator.data)

View File

@@ -24,10 +24,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import SFRConfigEntry
from .entity import SFREntity
# Coordinator is used to centralize the data updates
# but better to queue action calls to avoid conflicts
PARALLEL_UPDATES = 1
def with_error_wrapping[**_P, _R](
func: Callable[Concatenate[SFRBoxButton, _P], Awaitable[_R]],

View File

@@ -39,10 +39,7 @@ class SFRBoxFlowHandler(ConfigFlow, domain=DOMAIN):
VERSION = 1
_box: SFRBox
def __init__(self) -> None:
"""Initialize SFR Box flow."""
self._config: dict[str, Any] = {}
_config: dict[str, Any] = {}
async def async_step_user(
self, user_input: dict[str, str] | None = None
@@ -50,7 +47,6 @@ class SFRBoxFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a flow initialized by the user."""
errors = {}
if user_input is not None:
self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]})
box = SFRBox(
ip=user_input[CONF_HOST], client=async_get_clientsession(self.hass)
)
@@ -64,6 +60,7 @@ class SFRBoxFlowHandler(ConfigFlow, domain=DOMAIN):
assert system_info is not None
await self.async_set_unique_id(system_info.mac_addr)
self._abort_if_unique_id_configured()
self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]})
self._box = box
self._config.update(user_input)
return await self.async_step_choose_auth()

View File

@@ -33,7 +33,7 @@ class SFRRuntimeData:
wan: SFRDataUpdateCoordinator[WanInfo]
class SFRDataUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]):
class SFRDataUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT | None]):
"""Coordinator to manage data updates."""
config_entry: SFRConfigEntry
@@ -57,11 +57,9 @@ class SFRDataUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]):
update_interval=_SCAN_INTERVAL,
)
async def _async_update_data(self) -> _DataT:
async def _async_update_data(self) -> _DataT | None:
"""Update data."""
try:
if data := await self._method(self.box):
return data
return await self._method(self.box)
except SFRBoxError as err:
raise UpdateFailed from err
raise UpdateFailed("No data received from SFR Box")

View File

@@ -6,6 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/sfr_box",
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["sfrbox-api==0.1.0"]
}

View File

@@ -1,101 +0,0 @@
rules:
## Bronze
config-flow: done
test-before-configure: done
unique-config-entry: done
config-flow-test-coverage: done
runtime-data: done
test-before-setup: done
appropriate-polling: done
entity-unique-id: done
has-entity-name: done
entity-event-setup:
status: exempt
comment: local_polling without events
dependency-transparency: done
action-setup:
status: exempt
comment: There are no service actions
common-modules: done
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
docs-actions:
status: exempt
comment: There are no service actions
brands: done
## Silver
config-entry-unloading: done
log-when-unavailable: done
entity-unavailable: done
action-exceptions: done
reauthentication-flow: done
parallel-updates: done
test-coverage: done
integration-owner: done
docs-installation-parameters:
status: todo
comment: not yet documented
docs-configuration-parameters:
status: exempt
comment: No options flow
## Gold
entity-translations: done
entity-device-class:
status: todo
comment: |
What does DSL counter count?
What is the state of CRC?
line_status and training and net_infra and mode -> unknown shouldn't be an option and the entity should return None instead
devices:
status: todo
comment: MAC address can be set to the connections
entity-category: done
entity-disabled-by-default: done
discovery:
status: todo
comment: Should be possible
stale-devices: done
diagnostics: done
exception-translations:
status: todo
comment: not yet documented
icon-translations: done
reconfiguration-flow:
status: todo
comment: Need to be able to manually change the IP address
dynamic-devices: done
discovery-update-info:
status: todo
comment: Discovery is not yet implemented
repair-issues: done
docs-use-cases:
status: todo
comment: not yet documented
docs-supported-devices: done
docs-supported-functions: done
docs-data-update:
status: todo
comment: not yet documented
docs-known-limitations:
status: todo
comment: not yet documented
docs-troubleshooting:
status: todo
comment: not yet documented
docs-examples:
status: todo
comment: not yet documented
## Platinum
async-dependency:
status: done
comment: sfrbox-api is asynchronous
inject-websession:
status: done
comment: sfrbox-api uses injected aiohttp websession
strict-typing:
status: done
comment: sfrbox-api is fully typed, and integration uses strict typing

View File

@@ -26,9 +26,6 @@ from homeassistant.helpers.typing import StateType
from .coordinator import SFRConfigEntry
from .entity import SFRCoordinatorEntity
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class SFRBoxSensorEntityDescription[_T](SensorEntityDescription):
@@ -253,4 +250,6 @@ class SFRBoxSensor[_T](SFRCoordinatorEntity[_T], SensorEntity):
@property
def native_value(self) -> StateType:
"""Return the native value of the device."""
if self.coordinator.data is None:
return None
return self.entity_description.value_fn(self.coordinator.data)

View File

@@ -30,7 +30,7 @@ from .entity import (
ShellyRpcAttributeEntity,
ShellySleepingBlockAttributeEntity,
ShellySleepingRpcAttributeEntity,
async_setup_entry_block,
async_setup_entry_attribute_entities,
async_setup_entry_rest,
async_setup_entry_rpc,
)
@@ -127,7 +127,7 @@ class RpcBluTrvBinarySensor(RpcBinarySensor):
)
BLOCK_SENSORS: dict[tuple[str, str], BlockBinarySensorDescription] = {
SENSORS: dict[tuple[str, str], BlockBinarySensorDescription] = {
("device", "overtemp"): BlockBinarySensorDescription(
key="device|overtemp",
translation_key="overheating",
@@ -372,19 +372,19 @@ def _async_setup_block_entry(
) -> None:
"""Set up entities for BLOCK device."""
if config_entry.data[CONF_SLEEP_PERIOD]:
async_setup_entry_block(
async_setup_entry_attribute_entities(
hass,
config_entry,
async_add_entities,
BLOCK_SENSORS,
SENSORS,
BlockSleepingBinarySensor,
)
else:
async_setup_entry_block(
async_setup_entry_attribute_entities(
hass,
config_entry,
async_add_entities,
BLOCK_SENSORS,
SENSORS,
BlockBinarySensor,
)
async_setup_entry_rest(

View File

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

View File

@@ -79,7 +79,6 @@ from .utils import (
get_rpc_device_wakeup_period,
get_rpc_ws_url,
get_shelly_model_name,
is_rpc_ble_scanner_supported,
update_device_fw_info,
)
@@ -727,7 +726,6 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]):
"""Handle device connected."""
async with self._connection_lock:
if self.connected: # Already connected
LOGGER.debug("Device %s already connected", self.name)
return
self.connected = True
try:
@@ -745,7 +743,10 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]):
is updated.
"""
if not self.sleep_period:
if is_rpc_ble_scanner_supported(self.config_entry):
if (
self.config_entry.runtime_data.rpc_supports_scripts
and not self.config_entry.runtime_data.rpc_zigbee_firmware
):
await self._async_connect_ble_scanner()
else:
await self._async_setup_outbound_websocket()
@@ -775,10 +776,6 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]):
if await async_ensure_ble_enabled(self.device):
# BLE enable required a reboot, don't bother connecting
# the scanner since it will be disconnected anyway
LOGGER.debug(
"Device %s BLE enable required a reboot, skipping scanner connect",
self.name,
)
return
assert self.device_id is not None
self._disconnected_callbacks.append(
@@ -847,14 +844,21 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]):
"""Shutdown the coordinator."""
if self.device.connected:
try:
if not self.sleep_period and is_rpc_ble_scanner_supported(
self.config_entry
):
if not self.sleep_period:
await async_stop_scanner(self.device)
await super().shutdown()
except InvalidAuthError:
self.config_entry.async_start_reauth(self.hass)
return
except RpcCallError as err:
# Ignore 404 (No handler for) error
if err.code != 404:
LOGGER.debug(
"Error during shutdown for device %s: %s",
self.name,
err.message,
)
return
except DeviceConnectionError as err:
# If the device is restarting or has gone offline before
# the ping/pong timeout happens, the shutdown command

View File

@@ -27,7 +27,7 @@ from .entity import (
RpcEntityDescription,
ShellyBlockAttributeEntity,
ShellyRpcAttributeEntity,
async_setup_entry_block,
async_setup_entry_attribute_entities,
async_setup_entry_rpc,
rpc_call,
)
@@ -81,7 +81,7 @@ def _async_setup_block_entry(
coordinator = config_entry.runtime_data.block
assert coordinator
async_setup_entry_block(
async_setup_entry_attribute_entities(
hass, config_entry, async_add_entities, BLOCK_COVERS, BlockShellyCover
)

View File

@@ -34,14 +34,14 @@ from .utils import (
@callback
def async_setup_entry_block(
def async_setup_entry_attribute_entities(
hass: HomeAssistant,
config_entry: ShellyConfigEntry,
async_add_entities: AddEntitiesCallback,
sensors: Mapping[tuple[str, str], BlockEntityDescription],
sensor_class: Callable,
) -> None:
"""Set up block entities."""
"""Set up entities for attributes."""
coordinator = config_entry.runtime_data.block
assert coordinator
if coordinator.device.initialized:
@@ -150,7 +150,7 @@ def async_setup_entry_rpc(
sensors: Mapping[str, RpcEntityDescription],
sensor_class: Callable,
) -> None:
"""Set up RPC entities."""
"""Set up entities for RPC sensors."""
coordinator = config_entry.runtime_data.rpc
assert coordinator

View File

@@ -18,6 +18,7 @@ from homeassistant.components.event import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import (
BASIC_INPUTS_EVENTS_TYPES,
@@ -25,11 +26,12 @@ from .const import (
SHIX3_1_INPUTS_EVENTS_TYPES,
)
from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator
from .entity import ShellyBlockEntity, ShellyRpcEntity
from .entity import ShellyBlockEntity, get_entity_rpc_device_info
from .utils import (
async_remove_orphaned_entities,
async_remove_shelly_entity,
get_block_channel,
get_block_channel_name,
get_block_custom_name,
get_block_number_of_channels,
get_device_entry_gen,
@@ -135,7 +137,7 @@ def _async_setup_rpc_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up entities for RPC device."""
entities: list[ShellyRpcEvent | ShellyRpcScriptEvent] = []
entities: list[ShellyRpcEvent] = []
coordinator = config_entry.runtime_data.rpc
if TYPE_CHECKING:
@@ -161,9 +163,7 @@ def _async_setup_rpc_entry(
continue
if script_events and (event_types := script_events[get_rpc_key_id(script)]):
entities.append(
ShellyRpcScriptEvent(coordinator, script, SCRIPT_EVENT, event_types)
)
entities.append(ShellyRpcScriptEvent(coordinator, script, event_types))
# If a script is removed, from the device configuration, we need to remove orphaned entities
async_remove_orphaned_entities(
@@ -211,7 +211,7 @@ class ShellyBlockEvent(ShellyBlockEntity, EventEntity):
else ""
}
else:
self._attr_name = get_block_custom_name(coordinator.device, block)
self._attr_name = get_block_channel_name(coordinator.device, block)
async def async_added_to_hass(self) -> None:
"""When entity is added to hass."""
@@ -228,7 +228,7 @@ class ShellyBlockEvent(ShellyBlockEntity, EventEntity):
self.async_write_ha_state()
class ShellyRpcEvent(ShellyRpcEntity, EventEntity):
class ShellyRpcEvent(CoordinatorEntity[ShellyRpcCoordinator], EventEntity):
"""Represent RPC event entity."""
_attr_has_entity_name = True
@@ -241,19 +241,25 @@ class ShellyRpcEvent(ShellyRpcEntity, EventEntity):
description: ShellyRpcEventDescription,
) -> None:
"""Initialize Shelly entity."""
super().__init__(coordinator, key)
super().__init__(coordinator)
self._attr_device_info = get_entity_rpc_device_info(coordinator, key)
self._attr_unique_id = f"{coordinator.mac}-{key}"
self.entity_description = description
_, component, component_id = get_rpc_key(key)
if custom_name := get_rpc_custom_name(coordinator.device, key):
self._attr_name = custom_name
else:
self._attr_translation_placeholders = {
"input_number": component_id
if get_rpc_number_of_channels(coordinator.device, component) > 1
else ""
}
self.event_id = int(component_id)
if description.key == "input":
_, component, component_id = get_rpc_key(key)
if custom_name := get_rpc_custom_name(coordinator.device, key):
self._attr_name = custom_name
else:
self._attr_translation_placeholders = {
"input_number": component_id
if get_rpc_number_of_channels(coordinator.device, component) > 1
else ""
}
self.event_id = int(component_id)
elif description.key == "script":
self._attr_name = get_rpc_custom_name(coordinator.device, key)
self.event_id = get_rpc_key_id(key)
async def async_added_to_hass(self) -> None:
"""When entity is added to hass."""
@@ -265,36 +271,30 @@ class ShellyRpcEvent(ShellyRpcEntity, EventEntity):
@callback
def _async_handle_event(self, event: dict[str, Any]) -> None:
"""Handle the event."""
"""Handle the demo button event."""
if event["id"] == self.event_id:
self._trigger_event(event["event"])
self.async_write_ha_state()
class ShellyRpcScriptEvent(ShellyRpcEntity, EventEntity):
class ShellyRpcScriptEvent(ShellyRpcEvent):
"""Represent RPC script event entity."""
_attr_has_entity_name = True
entity_description: ShellyRpcEventDescription
def __init__(
self,
coordinator: ShellyRpcCoordinator,
key: str,
description: ShellyRpcEventDescription,
event_types: list[str],
) -> None:
"""Initialize Shelly script event entity."""
super().__init__(coordinator, key)
self.entity_description = description
self._attr_event_types = event_types
super().__init__(coordinator, key, SCRIPT_EVENT)
self._attr_name = get_rpc_custom_name(coordinator.device, key)
self.event_id = get_rpc_key_id(key)
self.component = key
self._attr_event_types = event_types
async def async_added_to_hass(self) -> None:
"""When entity is added to hass."""
await super().async_added_to_hass()
await super(CoordinatorEntity, self).async_added_to_hass()
self.async_on_remove(
self.coordinator.async_subscribe_events(self._async_handle_event)
@@ -303,7 +303,7 @@ class ShellyRpcScriptEvent(ShellyRpcEntity, EventEntity):
@callback
def _async_handle_event(self, event: dict[str, Any]) -> None:
"""Handle script event."""
if event.get("component") == self.key:
if event.get("component") == self.component:
event_type = event.get("event")
if event_type not in self.event_types:
# This can happen if we didn't find this event type in the script

View File

@@ -44,7 +44,7 @@ from .entity import (
RpcEntityDescription,
ShellyBlockAttributeEntity,
ShellyRpcAttributeEntity,
async_setup_entry_block,
async_setup_entry_attribute_entities,
async_setup_entry_rpc,
)
from .utils import (
@@ -101,7 +101,7 @@ def _async_setup_block_entry(
coordinator = config_entry.runtime_data.block
assert coordinator
async_setup_entry_block(
async_setup_entry_attribute_entities(
hass, config_entry, async_add_entities, BLOCK_LIGHTS, BlockShellyLight
)

View File

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

View File

@@ -53,7 +53,7 @@ from .entity import (
ShellyRpcAttributeEntity,
ShellySleepingBlockAttributeEntity,
ShellySleepingRpcAttributeEntity,
async_setup_entry_block,
async_setup_entry_attribute_entities,
async_setup_entry_rest,
async_setup_entry_rpc,
get_entity_rpc_device_info,
@@ -198,7 +198,7 @@ class RpcBluTrvSensor(RpcSensor):
)
BLOCK_SENSORS: dict[tuple[str, str], BlockSensorDescription] = {
SENSORS: dict[tuple[str, str], BlockSensorDescription] = {
("device", "battery"): BlockSensorDescription(
key="device|battery",
native_unit_of_measurement=PERCENTAGE,
@@ -1734,19 +1734,19 @@ def _async_setup_block_entry(
) -> None:
"""Set up entities for BLOCK device."""
if config_entry.data[CONF_SLEEP_PERIOD]:
async_setup_entry_block(
async_setup_entry_attribute_entities(
hass,
config_entry,
async_add_entities,
BLOCK_SENSORS,
SENSORS,
BlockSleepingSensor,
)
else:
async_setup_entry_block(
async_setup_entry_attribute_entities(
hass,
config_entry,
async_add_entities,
BLOCK_SENSORS,
SENSORS,
BlockSensor,
)
async_setup_entry_rest(

View File

@@ -36,7 +36,7 @@ from .entity import (
ShellyBlockAttributeEntity,
ShellyRpcAttributeEntity,
ShellySleepingBlockAttributeEntity,
async_setup_entry_block,
async_setup_entry_attribute_entities,
async_setup_entry_rpc,
rpc_call,
)
@@ -337,11 +337,11 @@ def _async_setup_block_entry(
coordinator = config_entry.runtime_data.block
assert coordinator
async_setup_entry_block(
async_setup_entry_attribute_entities(
hass, config_entry, async_add_entities, BLOCK_RELAY_SWITCHES, BlockRelaySwitch
)
async_setup_entry_block(
async_setup_entry_attribute_entities(
hass,
config_entry,
async_add_entities,

View File

@@ -110,6 +110,8 @@ def get_block_number_of_channels(device: BlockDevice, block: Block) -> int:
channels = device.shelly.get("num_emeters")
elif block.type in ["relay", "light"]:
channels = device.shelly.get("num_outputs")
elif block.type in ["roller", "device"]:
channels = 1
return channels or 1
@@ -132,6 +134,21 @@ def get_block_channel(block: Block | None, base: str = "1") -> str:
return chr(int(block.channel) + ord(base))
def get_block_channel_name(device: BlockDevice, block: Block | None) -> str | None:
"""Get name based on device and channel name."""
if (
not block
or block.type in ("device", "light", "relay", "emeter")
or get_block_number_of_channels(device, block) == 1
):
return None
if custom_name := get_block_custom_name(device, block):
return custom_name
return f"Channel {get_block_channel(block)}"
def get_block_sub_device_name(device: BlockDevice, block: Block) -> str:
"""Get name of block sub-device."""
if TYPE_CHECKING:
@@ -647,7 +664,10 @@ def async_remove_shelly_rpc_entities(
def get_virtual_component_ids(config: dict[str, Any], platform: str) -> list[str]:
"""Return a list of virtual component IDs for a platform."""
component = VIRTUAL_COMPONENTS_MAP[platform]
component = VIRTUAL_COMPONENTS_MAP.get(platform)
if not component:
return []
ids: list[str] = []
@@ -955,10 +975,10 @@ def async_migrate_rpc_virtual_components_unique_ids(
The new unique_id format is: {mac}-{key}-{component}_{role}
"""
for component in VIRTUAL_COMPONENTS:
if (
entity_entry.unique_id.endswith(f"-{component!s}")
and (key := entity_entry.unique_id.split("-")[-2]) in config
):
if entity_entry.unique_id.endswith(f"-{component!s}"):
key = entity_entry.unique_id.split("-")[-2]
if key not in config:
continue
role = get_rpc_role_by_key(config, key)
new_unique_id = f"{entity_entry.unique_id}_{role}"
LOGGER.debug(
@@ -974,11 +994,3 @@ def async_migrate_rpc_virtual_components_unique_ids(
}
return None
def is_rpc_ble_scanner_supported(entry: ConfigEntry) -> bool:
"""Return true if BLE scanner is supported."""
return (
entry.runtime_data.rpc_supports_scripts
and not entry.runtime_data.rpc_zigbee_firmware
)

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/stiebel_eltron",
"iot_class": "local_polling",
"loggers": ["pymodbus", "pystiebeleltron"],
"requirements": ["pystiebeleltron==0.2.5"]
"requirements": ["pystiebeleltron==0.2.3"]
}

View File

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

View File

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

View File

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

View File

@@ -48,7 +48,6 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import dt as dt_util
from . import TriggerUpdateCoordinator
from .entity import AbstractTemplateEntity
from .helpers import (
async_setup_template_entry,
async_setup_template_platform,
@@ -169,27 +168,11 @@ def async_create_preview_binary_sensor(
)
class AbstractTemplateBinarySensor(
AbstractTemplateEntity, BinarySensorEntity, RestoreEntity
):
"""Representation of a template binary sensor features."""
_entity_id_format = ENTITY_ID_FORMAT
# The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__.
# This ensures that the __init__ on AbstractTemplateEntity is not called twice.
def __init__(self, config: dict[str, Any]) -> None: # pylint: disable=super-init-not-called
"""Initialize the features."""
self._attr_device_class = config.get(CONF_DEVICE_CLASS)
self._template: template.Template = config[CONF_STATE]
self._delay_cancel: CALLBACK_TYPE | None = None
class StateBinarySensorEntity(TemplateEntity, AbstractTemplateBinarySensor):
class StateBinarySensorEntity(TemplateEntity, BinarySensorEntity, RestoreEntity):
"""A virtual binary sensor that triggers from another sensor."""
_attr_should_poll = False
_entity_id_format = ENTITY_ID_FORMAT
def __init__(
self,
@@ -199,19 +182,19 @@ class StateBinarySensorEntity(TemplateEntity, AbstractTemplateBinarySensor):
) -> None:
"""Initialize the Template binary sensor."""
TemplateEntity.__init__(self, hass, config, unique_id)
AbstractTemplateBinarySensor.__init__(self, config)
self._attr_device_class = config.get(CONF_DEVICE_CLASS)
self._template: template.Template = config[CONF_STATE]
self._delay_cancel = None
self._delay_on = None
self._delay_on_template = config.get(CONF_DELAY_ON)
self._delay_on_raw = config.get(CONF_DELAY_ON)
self._delay_off = None
self._delay_off_template = config.get(CONF_DELAY_OFF)
self._delay_off_raw = config.get(CONF_DELAY_OFF)
async def async_added_to_hass(self) -> None:
"""Restore state."""
if (
(
self._delay_on_template is not None
or self._delay_off_template is not None
)
(self._delay_on_raw is not None or self._delay_off_raw is not None)
and (last_state := await self.async_get_last_state()) is not None
and last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE)
):
@@ -223,20 +206,20 @@ class StateBinarySensorEntity(TemplateEntity, AbstractTemplateBinarySensor):
"""Set up templates."""
self.add_template_attribute("_state", self._template, None, self._update_state)
if self._delay_on_template is not None:
if self._delay_on_raw is not None:
try:
self._delay_on = cv.positive_time_period(self._delay_on_template)
self._delay_on = cv.positive_time_period(self._delay_on_raw)
except vol.Invalid:
self.add_template_attribute(
"_delay_on", self._delay_on_template, cv.positive_time_period
"_delay_on", self._delay_on_raw, cv.positive_time_period
)
if self._delay_off_template is not None:
if self._delay_off_raw is not None:
try:
self._delay_off = cv.positive_time_period(self._delay_off_template)
self._delay_off = cv.positive_time_period(self._delay_off_raw)
except vol.Invalid:
self.add_template_attribute(
"_delay_off", self._delay_off_template, cv.positive_time_period
"_delay_off", self._delay_off_raw, cv.positive_time_period
)
super()._async_setup_templates()
@@ -276,10 +259,12 @@ class StateBinarySensorEntity(TemplateEntity, AbstractTemplateBinarySensor):
self._delay_cancel = async_call_later(self.hass, delay, _set_state)
class TriggerBinarySensorEntity(TriggerEntity, AbstractTemplateBinarySensor):
class TriggerBinarySensorEntity(TriggerEntity, BinarySensorEntity, RestoreEntity):
"""Sensor entity based on trigger data."""
_entity_id_format = ENTITY_ID_FORMAT
domain = BINARY_SENSOR_DOMAIN
extra_template_keys = (CONF_STATE,)
def __init__(
self,
@@ -288,8 +273,7 @@ class TriggerBinarySensorEntity(TriggerEntity, AbstractTemplateBinarySensor):
config: dict,
) -> None:
"""Initialize the entity."""
TriggerEntity.__init__(self, hass, coordinator, config)
AbstractTemplateBinarySensor.__init__(self, config)
super().__init__(hass, coordinator, config)
for key in (CONF_STATE, CONF_DELAY_ON, CONF_DELAY_OFF, CONF_AUTO_OFF):
if isinstance(config.get(key), template.Template):
@@ -298,6 +282,7 @@ class TriggerBinarySensorEntity(TriggerEntity, AbstractTemplateBinarySensor):
self._last_delay_from: bool | None = None
self._last_delay_to: bool | None = None
self._delay_cancel: CALLBACK_TYPE | None = None
self._auto_off_cancel: CALLBACK_TYPE | None = None
self._auto_off_time: datetime | None = None

View File

@@ -26,12 +26,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import (
config_validation as cv,
device_registry as dr,
entity_registry as er,
)
from homeassistant.helpers.device_registry import DeviceEntryType
from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.typing import ConfigType
from .const import DEFAULT_PATH, DEFAULT_SSL, DOMAIN
@@ -98,19 +93,6 @@ async def async_setup_entry(
except (AuthenticationError, UnknownError) as error:
raise ConfigEntryAuthFailed from error
protocol: Final = "https" if config_entry.data[CONF_SSL] else "http"
device_registry = dr.async_get(hass)
device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
identifiers={(DOMAIN, config_entry.entry_id)},
manufacturer="Transmission",
entry_type=DeviceEntryType.SERVICE,
sw_version=api.server_version,
configuration_url=(
f"{protocol}://{config_entry.data[CONF_HOST]}:{config_entry.data[CONF_PORT]}"
),
)
coordinator = TransmissionDataUpdateCoordinator(hass, config_entry, api)
await hass.async_add_executor_job(coordinator.init_torrent_list)

View File

@@ -26,4 +26,5 @@ class TransmissionEntity(CoordinatorEntity[TransmissionDataUpdateCoordinator]):
)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, coordinator.config_entry.entry_id)},
manufacturer="Transmission",
)

View File

@@ -1,43 +1,4 @@
{
"entity": {
"sensor": {
"active_torrents": {
"default": "mdi:counter"
},
"completed_torrents": {
"default": "mdi:counter"
},
"download_speed": {
"default": "mdi:cloud-download"
},
"paused_torrents": {
"default": "mdi:counter"
},
"started_torrents": {
"default": "mdi:counter"
},
"total_torrents": {
"default": "mdi:counter"
},
"transmission_status": {
"default": "mdi:information-outline"
},
"upload_speed": {
"default": "mdi:cloud-upload"
}
},
"switch": {
"on_off": {
"default": "mdi:cloud",
"state": {
"off": "mdi:cloud-off"
}
},
"turtle_mode": {
"default": "mdi:tortoise"
}
}
},
"services": {
"add_torrent": {
"service": "mdi:download"

View File

@@ -30,12 +30,18 @@ rules:
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
parallel-updates: todo
reauthentication-flow: done
test-coverage: done
test-coverage:
status: todo
comment: |
Change to mock_setup_entry to avoid repetition when expanding tests.
# Gold
devices: done
devices:
status: todo
comment: |
Add additional device detail including link to ui.
diagnostics: todo
discovery-update-info: todo
discovery: todo
@@ -55,7 +61,10 @@ rules:
Speed sensors change so frequently that disabling by default may be appropriate.
entity-translations: done
exception-translations: done
icon-translations: done
icon-translations:
status: todo
comment: |
Add icons for sensors & switches.
reconfiguration-flow: todo
repair-issues: todo
stale-devices: todo

View File

@@ -29,8 +29,6 @@ from .const import (
from .coordinator import TransmissionConfigEntry, TransmissionDataUpdateCoordinator
from .entity import TransmissionEntity
PARALLEL_UPDATES = 0
MODES: dict[str, list[str] | None] = {
"started_torrents": ["downloading"],
"completed_torrents": ["seeding"],

View File

@@ -11,8 +11,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import TransmissionConfigEntry, TransmissionDataUpdateCoordinator
from .entity import TransmissionEntity
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class TransmissionSwitchEntityDescription(SwitchEntityDescription):

View File

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

View File

@@ -461,4 +461,4 @@ class TuyaBinarySensorEntity(TuyaEntity, BinarySensorEntity):
@property
def is_on(self) -> bool | None:
"""Return true if sensor is on."""
return self._read_wrapper(self._dpcode_wrapper)
return self._dpcode_wrapper.read_device_status(self.device)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -192,7 +192,7 @@ class TuyaHumidifierEntity(TuyaEntity, HumidifierEntity):
self.device,
self.entity_description.dpcode or self.entity_description.key,
)
await self._async_send_wrapper_updates(self._switch_wrapper, True)
await self._async_send_dpcode_update(self._switch_wrapper, True)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the device off."""
@@ -201,7 +201,7 @@ class TuyaHumidifierEntity(TuyaEntity, HumidifierEntity):
self.device,
self.entity_description.dpcode or self.entity_description.key,
)
await self._async_send_wrapper_updates(self._switch_wrapper, False)
await self._async_send_dpcode_update(self._switch_wrapper, False)
async def async_set_humidity(self, humidity: int) -> None:
"""Set new target humidity."""
@@ -210,8 +210,8 @@ class TuyaHumidifierEntity(TuyaEntity, HumidifierEntity):
self.device,
self.entity_description.humidity,
)
await self._async_send_wrapper_updates(self._target_humidity_wrapper, humidity)
await self._async_send_dpcode_update(self._target_humidity_wrapper, humidity)
async def async_set_mode(self, mode: str) -> None:
"""Set new target preset mode."""
await self._async_send_wrapper_updates(self._mode_wrapper, mode)
await self._async_send_dpcode_update(self._mode_wrapper, mode)

View File

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

View File

@@ -196,24 +196,20 @@ class DPCodeWrapper:
def _convert_value_to_raw_value(self, device: CustomerDevice, value: Any) -> Any:
"""Convert a Home Assistant value back to a raw device value.
This is called by `get_update_commands` to prepare the value for sending
This is called by `get_update_command` to prepare the value for sending
back to the device, and should be implemented in concrete classes if needed.
"""
raise NotImplementedError
def get_update_commands(
self, device: CustomerDevice, value: Any
) -> list[dict[str, Any]]:
"""Get the update commands for the dpcode.
def get_update_command(self, device: CustomerDevice, value: Any) -> dict[str, Any]:
"""Get the update command for the dpcode.
The Home Assistant value is converted back to a raw device value.
"""
return [
{
"code": self.dpcode,
"value": self._convert_value_to_raw_value(device, value),
}
]
return {
"code": self.dpcode,
"value": self._convert_value_to_raw_value(device, value),
}
class DPCodeTypeInformationWrapper[T: TypeInformation](DPCodeWrapper):

View File

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

View File

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

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