Compare commits

..

3 Commits

Author SHA1 Message Date
Erik Montnemery
ccbd681d20 Merge branch 'dev' into adjust_sensor_group_behavior 2025-11-27 11:06:14 +01:00
Erik Montnemery
01aa70e249 Merge branch 'dev' into adjust_sensor_group_behavior 2025-09-18 10:56:53 +02:00
Erik
9f6a0c0c77 Adjust sensor group behavior 2025-09-12 14:29:19 +02:00
205 changed files with 2224 additions and 6686 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@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0
with:
images: ${{ matrix.registry }}/home-assistant
sep-tags: ","

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@02c6cc30ae592ce65ee356387748dfc2fd5f7993 # v2.0.3
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@02c6cc30ae592ce65ee356387748dfc2fd5f7993 # v2.0.3
uses: actions/ai-inference@5022b33bc1431add9b2831934daf8147a2ad9331 # v2.0.2
with:
model: openai/gpt-4o-mini
system-prompt: |

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 aiohttp import CookieJar
from pyanglianwater import AnglianWater
from pyanglianwater.auth import MSOB2CAuth
from pyanglianwater.exceptions import (
@@ -19,7 +18,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryError
from homeassistant.helpers.aiohttp_client import async_create_clientsession
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import CONF_ACCOUNT_NUMBER, DOMAIN
from .coordinator import AnglianWaterConfigEntry, AnglianWaterUpdateCoordinator
@@ -34,10 +33,7 @@ async def async_setup_entry(
auth = MSOB2CAuth(
username=entry.data[CONF_USERNAME],
password=entry.data[CONF_PASSWORD],
session=async_create_clientsession(
hass,
cookie_jar=CookieJar(quote_cookie=False),
),
session=async_get_clientsession(hass),
refresh_token=entry.data[CONF_ACCESS_TOKEN],
account_number=entry.data[CONF_ACCOUNT_NUMBER],
)

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

@@ -17,7 +17,7 @@ from homeassistant.helpers import (
)
from homeassistant.helpers.typing import ConfigType
from .const import DEFAULT_CONVERSATION_NAME, DOMAIN, LOGGER
from .const import CONF_CHAT_MODEL, DEFAULT, DEFAULT_CONVERSATION_NAME, DOMAIN, LOGGER
PLATFORMS = (Platform.AI_TASK, Platform.CONVERSATION)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
@@ -37,7 +37,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: AnthropicConfigEntry) ->
partial(anthropic.AsyncAnthropic, api_key=entry.data[CONF_API_KEY])
)
try:
await client.models.list(timeout=10.0)
# Use model from first conversation subentry for validation
subentries = list(entry.subentries.values())
if subentries:
model_id = subentries[0].data.get(CONF_CHAT_MODEL, DEFAULT[CONF_CHAT_MODEL])
else:
model_id = DEFAULT[CONF_CHAT_MODEL]
model = await client.models.retrieve(model_id=model_id, timeout=10.0)
LOGGER.debug("Anthropic model: %s", model.display_name)
except anthropic.AuthenticationError as err:
LOGGER.error("Invalid API key: %s", err)
return False

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

@@ -1123,6 +1123,63 @@ class PipelineRun:
)
try:
user_input = conversation.ConversationInput(
text=intent_input,
context=self.context,
conversation_id=conversation_id,
device_id=self._device_id,
satellite_id=self._satellite_id,
language=input_language,
agent_id=self.intent_agent.id,
extra_system_prompt=conversation_extra_system_prompt,
)
agent_id = self.intent_agent.id
processed_locally = agent_id == conversation.HOME_ASSISTANT_AGENT
all_targets_in_satellite_area = False
intent_response: intent.IntentResponse | None = None
if not processed_locally and not self._intent_agent_only:
# Sentence triggers override conversation agent
if (
trigger_response_text
:= await conversation.async_handle_sentence_triggers(
self.hass, user_input
)
) is not None:
# Sentence trigger matched
agent_id = "sentence_trigger"
processed_locally = True
intent_response = intent.IntentResponse(
self.pipeline.conversation_language
)
intent_response.async_set_speech(trigger_response_text)
intent_filter: Callable[[RecognizeResult], bool] | None = None
# If the LLM has API access, we filter out some sentences that are
# interfering with LLM operation.
if (
intent_agent_state := self.hass.states.get(self.intent_agent.id)
) and intent_agent_state.attributes.get(
ATTR_SUPPORTED_FEATURES, 0
) & conversation.ConversationEntityFeature.CONTROL:
intent_filter = _async_local_fallback_intent_filter
# Try local intents
if (
intent_response is None
and self.pipeline.prefer_local_intents
and (
intent_response := await conversation.async_handle_intents(
self.hass,
user_input,
intent_filter=intent_filter,
)
)
):
# Local intent matched
agent_id = conversation.HOME_ASSISTANT_AGENT
processed_locally = True
if self.tts_stream and self.tts_stream.supports_streaming_input:
tts_input_stream: asyncio.Queue[str | None] | None = asyncio.Queue()
else:
@@ -1208,17 +1265,6 @@ class PipelineRun:
assert self.tts_stream is not None
self.tts_stream.async_set_message_stream(tts_input_stream_generator())
user_input = conversation.ConversationInput(
text=intent_input,
context=self.context,
conversation_id=conversation_id,
device_id=self._device_id,
satellite_id=self._satellite_id,
language=input_language,
agent_id=self.intent_agent.id,
extra_system_prompt=conversation_extra_system_prompt,
)
with (
chat_session.async_get_chat_session(
self.hass, user_input.conversation_id
@@ -1230,53 +1276,6 @@ class PipelineRun:
chat_log_delta_listener=chat_log_delta_listener,
) as chat_log,
):
agent_id = self.intent_agent.id
processed_locally = agent_id == conversation.HOME_ASSISTANT_AGENT
all_targets_in_satellite_area = False
intent_response: intent.IntentResponse | None = None
if not processed_locally and not self._intent_agent_only:
# Sentence triggers override conversation agent
if (
trigger_response_text
:= await conversation.async_handle_sentence_triggers(
self.hass, user_input, chat_log
)
) is not None:
# Sentence trigger matched
agent_id = "sentence_trigger"
processed_locally = True
intent_response = intent.IntentResponse(
self.pipeline.conversation_language
)
intent_response.async_set_speech(trigger_response_text)
intent_filter: Callable[[RecognizeResult], bool] | None = None
# If the LLM has API access, we filter out some sentences that are
# interfering with LLM operation.
if (
intent_agent_state := self.hass.states.get(self.intent_agent.id)
) and intent_agent_state.attributes.get(
ATTR_SUPPORTED_FEATURES, 0
) & conversation.ConversationEntityFeature.CONTROL:
intent_filter = _async_local_fallback_intent_filter
# Try local intents
if (
intent_response is None
and self.pipeline.prefer_local_intents
and (
intent_response := await conversation.async_handle_intents(
self.hass,
user_input,
chat_log,
intent_filter=intent_filter,
)
)
):
# Local intent matched
agent_id = conversation.HOME_ASSISTANT_AGENT
processed_locally = True
# It was already handled, create response and add to chat history
if intent_response is not None:
speech: str = intent_response.speech.get("plain", {}).get(

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -98,12 +98,6 @@
}
},
"triggers": {
"started_cooling": {
"trigger": "mdi:snowflake"
},
"started_drying": {
"trigger": "mdi:water-percent"
},
"started_heating": {
"trigger": "mdi:fire"
},

View File

@@ -298,28 +298,6 @@
},
"title": "Climate",
"triggers": {
"started_cooling": {
"description": "Triggers when a climate started cooling.",
"description_configured": "[%key:component::climate::triggers::started_cooling::description%]",
"fields": {
"behavior": {
"description": "[%key:component::climate::common::trigger_behavior_description%]",
"name": "[%key:component::climate::common::trigger_behavior_name%]"
}
},
"name": "When a climate started cooling"
},
"started_drying": {
"description": "Triggers when a climate started drying.",
"description_configured": "[%key:component::climate::triggers::started_drying::description%]",
"fields": {
"behavior": {
"description": "[%key:component::climate::common::trigger_behavior_description%]",
"name": "[%key:component::climate::common::trigger_behavior_name%]"
}
},
"name": "When a climate started drying"
},
"started_heating": {
"description": "Triggers when a climate starts to heat.",
"description_configured": "[%key:component::climate::triggers::started_heating::description%]",

View File

@@ -11,12 +11,6 @@ from homeassistant.helpers.trigger import (
from .const import ATTR_HVAC_ACTION, DOMAIN, HVACAction, HVACMode
TRIGGERS: dict[str, type[Trigger]] = {
"started_cooling": make_entity_state_attribute_trigger(
DOMAIN, ATTR_HVAC_ACTION, HVACAction.COOLING
),
"started_drying": make_entity_state_attribute_trigger(
DOMAIN, ATTR_HVAC_ACTION, HVACAction.DRYING
),
"turned_off": make_entity_state_trigger(DOMAIN, HVACMode.OFF),
"turned_on": make_conditional_entity_state_trigger(
DOMAIN,

View File

@@ -14,8 +14,6 @@
- last
- any
started_cooling: *trigger_common
started_drying: *trigger_common
started_heating: *trigger_common
turned_off: *trigger_common
turned_on: *trigger_common

View File

@@ -6,7 +6,6 @@ import io
from json import JSONDecodeError
import logging
from hass_nabucasa import NabuCasaBaseError
from hass_nabucasa.llm import (
LLMAuthenticationError,
LLMError,
@@ -94,11 +93,10 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Home Assistant Cloud AI Task entity."""
if not (cloud := hass.data[DATA_CLOUD]).is_logged_in:
return
cloud = hass.data[DATA_CLOUD]
try:
await cloud.llm.async_ensure_token()
except (LLMError, NabuCasaBaseError):
except LLMError:
return
async_add_entities([CloudLLMTaskEntity(cloud, config_entry)])

View File

@@ -4,7 +4,6 @@ from __future__ import annotations
from typing import Literal
from hass_nabucasa import NabuCasaBaseError
from hass_nabucasa.llm import LLMError
from homeassistant.components import conversation
@@ -24,11 +23,10 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Home Assistant Cloud conversation entity."""
if not (cloud := hass.data[DATA_CLOUD]).is_logged_in:
return
cloud = hass.data[DATA_CLOUD]
try:
await cloud.llm.async_ensure_token()
except (LLMError, NabuCasaBaseError):
except LLMError:
return
async_add_entities([CloudConversationEntity(cloud, config_entry)])

View File

@@ -13,6 +13,6 @@
"integration_type": "system",
"iot_class": "cloud_push",
"loggers": ["acme", "hass_nabucasa", "snitun"],
"requirements": ["hass-nabucasa==1.6.2"],
"requirements": ["hass-nabucasa==1.6.1"],
"single_config_entry": true
}

View File

@@ -236,9 +236,7 @@ async def async_prepare_agent(
async def async_handle_sentence_triggers(
hass: HomeAssistant,
user_input: ConversationInput,
chat_log: ChatLog,
hass: HomeAssistant, user_input: ConversationInput
) -> str | None:
"""Try to match input against sentence triggers and return response text.
@@ -247,13 +245,12 @@ async def async_handle_sentence_triggers(
agent = get_agent_manager(hass).default_agent
assert agent is not None
return await agent.async_handle_sentence_triggers(user_input, chat_log)
return await agent.async_handle_sentence_triggers(user_input)
async def async_handle_intents(
hass: HomeAssistant,
user_input: ConversationInput,
chat_log: ChatLog,
*,
intent_filter: Callable[[RecognizeResult], bool] | None = None,
) -> intent.IntentResponse | None:
@@ -264,9 +261,7 @@ async def async_handle_intents(
agent = get_agent_manager(hass).default_agent
assert agent is not None
return await agent.async_handle_intents(
user_input, chat_log, intent_filter=intent_filter
)
return await agent.async_handle_intents(user_input, intent_filter=intent_filter)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:

View File

@@ -66,7 +66,6 @@ from homeassistant.helpers import (
entity_registry as er,
floor_registry as fr,
intent,
llm,
start as ha_start,
template,
translation,
@@ -77,7 +76,7 @@ from homeassistant.util import language as language_util
from homeassistant.util.json import JsonObjectType, json_loads_object
from .agent_manager import get_agent_manager
from .chat_log import AssistantContent, ChatLog, ToolResultContent
from .chat_log import AssistantContent, ChatLog
from .const import (
DOMAIN,
METADATA_CUSTOM_FILE,
@@ -436,7 +435,7 @@ class DefaultAgent(ConversationEntity):
if trigger_result := await self.async_recognize_sentence_trigger(user_input):
# Process callbacks and get response
response_text = await self._handle_trigger_result(
trigger_result, user_input, chat_log
trigger_result, user_input
)
# Convert to conversation result
@@ -448,9 +447,8 @@ class DefaultAgent(ConversationEntity):
if response is None:
# Match intents
intent_result = await self.async_recognize_intent(user_input)
response = await self._async_process_intent_result(
intent_result, user_input, chat_log
intent_result, user_input
)
speech: str = response.speech.get("plain", {}).get("speech", "")
@@ -469,7 +467,6 @@ class DefaultAgent(ConversationEntity):
self,
result: RecognizeResult | None,
user_input: ConversationInput,
chat_log: ChatLog,
) -> intent.IntentResponse:
"""Process user input with intents."""
language = user_input.language or self.hass.config.language
@@ -532,21 +529,12 @@ class DefaultAgent(ConversationEntity):
ConversationTraceEventType.TOOL_CALL,
{
"intent_name": result.intent.name,
"slots": {entity.name: entity.value for entity in result.entities_list},
"slots": {
entity.name: entity.value or entity.text
for entity in result.entities_list
},
},
)
tool_input = llm.ToolInput(
tool_name=result.intent.name,
tool_args={entity.name: entity.value for entity in result.entities_list},
external=True,
)
chat_log.async_add_assistant_content_without_tools(
AssistantContent(
agent_id=user_input.agent_id,
content=None,
tool_calls=[tool_input],
)
)
try:
intent_response = await intent.async_handle(
@@ -609,16 +597,6 @@ class DefaultAgent(ConversationEntity):
)
intent_response.async_set_speech(speech)
tool_result = llm.IntentResponseDict(intent_response)
chat_log.async_add_assistant_content_without_tools(
ToolResultContent(
agent_id=user_input.agent_id,
tool_call_id=tool_input.id,
tool_name=tool_input.tool_name,
tool_result=tool_result,
)
)
return intent_response
def _recognize(
@@ -1545,31 +1523,16 @@ class DefaultAgent(ConversationEntity):
)
async def _handle_trigger_result(
self,
result: SentenceTriggerResult,
user_input: ConversationInput,
chat_log: ChatLog,
self, result: SentenceTriggerResult, user_input: ConversationInput
) -> str:
"""Run sentence trigger callbacks and return response text."""
# Gather callback responses in parallel
trigger_callbacks = [
self._triggers_details[trigger_id].callback(user_input, trigger_result)
for trigger_id, trigger_result in result.matched_triggers.items()
]
tool_input = llm.ToolInput(
tool_name="trigger_sentence",
tool_args={},
external=True,
)
chat_log.async_add_assistant_content_without_tools(
AssistantContent(
agent_id=user_input.agent_id,
content=None,
tool_calls=[tool_input],
)
)
# Use first non-empty result as response.
#
# There may be multiple copies of a trigger running when editing in
@@ -1598,38 +1561,23 @@ class DefaultAgent(ConversationEntity):
f"component.{DOMAIN}.conversation.agent.done", "Done"
)
tool_result: dict[str, Any] = {"response": response_text}
chat_log.async_add_assistant_content_without_tools(
ToolResultContent(
agent_id=user_input.agent_id,
tool_call_id=tool_input.id,
tool_name=tool_input.tool_name,
tool_result=tool_result,
)
)
return response_text
async def async_handle_sentence_triggers(
self,
user_input: ConversationInput,
chat_log: ChatLog,
self, user_input: ConversationInput
) -> str | None:
"""Try to input sentence against sentence triggers and return response text.
Returns None if no match occurred.
"""
if trigger_result := await self.async_recognize_sentence_trigger(user_input):
return await self._handle_trigger_result(
trigger_result, user_input, chat_log
)
return await self._handle_trigger_result(trigger_result, user_input)
return None
async def async_handle_intents(
self,
user_input: ConversationInput,
chat_log: ChatLog,
*,
intent_filter: Callable[[RecognizeResult], bool] | None = None,
) -> intent.IntentResponse | None:
@@ -1645,7 +1593,7 @@ class DefaultAgent(ConversationEntity):
# No error message on failed match
return None
response = await self._async_process_intent_result(result, user_input, chat_log)
response = await self._async_process_intent_result(result, user_input)
if (
response.response_type == intent.IntentResponseType.ERROR
and response.error_code

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

@@ -285,14 +285,16 @@ async def async_setup_entry(
name=sensor.name,
)
# Only total rain needs state class for long-term statistics
# Hourly rain doesn't reset to fixed hours, it must be measurement state classes
if sensor.key in (
"totalrainin",
"totalrainmm",
"hrain_piezomm",
"hrain_piezo",
"hourlyrainmm",
"hourlyrainin",
):
description = dataclasses.replace(
description,
state_class=SensorStateClass.TOTAL_INCREASING,
state_class=SensorStateClass.MEASUREMENT,
)
async_add_entities([EcowittSensorEntity(sensor, description)])

View File

@@ -7,7 +7,7 @@
"iot_class": "local_polling",
"loggers": ["pyenphase"],
"quality_scale": "platinum",
"requirements": ["pyenphase==2.4.2"],
"requirements": ["pyenphase==2.4.0"],
"zeroconf": [
{
"type": "_enphase-envoy._tcp.local."

View File

@@ -17,7 +17,7 @@ DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS = False
DEFAULT_PORT: Final = 6053
STABLE_BLE_VERSION_STR = "2025.11.0"
STABLE_BLE_VERSION_STR = "2025.8.0"
STABLE_BLE_VERSION = AwesomeVersion(STABLE_BLE_VERSION_STR)
PROJECT_URLS = {
"esphome.bluetooth-proxy": "https://esphome.github.io/bluetooth-proxies/",

View File

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

View File

@@ -157,7 +157,7 @@
"title": "[%key:component::assist_pipeline::issues::assist_in_progress_deprecated::title%]"
},
"ble_firmware_outdated": {
"description": "ESPHome {version} introduces ultra-low latency event processing, reducing BLE event delays from 0-16 milliseconds to approximately 12 microseconds. This resolves stability issues when pairing, connecting, or handshaking with devices that require low latency, and makes Bluetooth proxy operations rival or exceed local adapters. We highly recommend updating {name} to take advantage of these improvements.",
"description": "To improve Bluetooth reliability and performance, we highly recommend updating {name} with ESPHome {version} or later. When updating the device from ESPHome earlier than 2022.12.0, it is recommended to use a serial cable instead of an over-the-air update to take advantage of the new partition scheme.",
"title": "Update {name} with ESPHome {version} or later"
},
"device_conflict": {

View File

@@ -102,7 +102,6 @@ SENSORS: tuple[EssentSensorEntityDescription, ...] = (
key="average_today",
translation_key="average_today",
value_fn=lambda energy_data: energy_data.avg_price,
energy_types=(EnergyType.ELECTRICITY,),
),
EssentSensorEntityDescription(
key="lowest_price_today",

View File

@@ -44,6 +44,9 @@
"electricity_next_price": {
"name": "Next electricity price"
},
"gas_average_today": {
"name": "Average gas price today"
},
"gas_current_price": {
"name": "Current gas price"
},

View File

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

View File

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

View File

@@ -132,6 +132,7 @@
"heavily_polluted": "Heavily polluted",
"heavy_air_pollution": "Heavy air pollution",
"high_air_pollution": "High air pollution",
"high_air_quality": "High air pollution",
"high_health_risk": "High health risk",
"horrible_air_quality": "Horrible air quality",
"light_air_pollution": "Light air pollution",
@@ -164,18 +165,20 @@
"slightly_polluted": "Slightly polluted",
"sufficient_air_quality": "Sufficient air quality",
"unfavorable_air_quality": "Unfavorable air quality",
"unfavorable_air_quality_for_sensitive_groups": "Unfavorable air quality for sensitive groups",
"unfavorable_sensitive": "Unfavorable air quality for sensitive groups",
"unhealthy_air_quality": "Unhealthy air quality",
"unhealthy_sensitive": "Unhealthy air quality for sensitive groups",
"unsatisfactory_air_quality": "Unsatisfactory air quality",
"very_bad_air_quality": "Very bad air quality",
"very_good_air_quality": "Very good air quality",
"very_high_air_pollution": "Very high air pollution",
"very_high_air_quality": "Very High air pollution",
"very_high_health_risk": "Very high health risk",
"very_low_air_pollution": "Very low air pollution",
"very_polluted": "Very polluted",
"very_poor_air_quality": "Very poor air quality",
"very_unfavorable_air_quality": "Very unfavorable air quality",
"very_unhealthy": "Very unhealthy air quality",
"very_unhealthy_air_quality": "Very unhealthy air quality",
"warning_air_pollution": "Warning level air pollution"
}

View File

@@ -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[str | None] = []
valid_units = self._valid_units
valid_states: list[bool] = []
sensor_values: list[tuple[str, float, State]] = []
@@ -435,9 +435,12 @@ class SensorGroup(GroupEntity, SensorEntity):
state.attributes.get("unit_of_measurement"),
self.entity_id,
)
else:
states.append(None)
valid_states.append(False)
# Set group as unavailable if all members do not have numeric values
self._attr_available = any(numeric_state for numeric_state in valid_states)
# Set group as unavailable if all members are unavailable or missing
self._attr_available = not all(s in (STATE_UNAVAILABLE, None) for s in states)
valid_state = self.mode(
state not in (STATE_UNKNOWN, STATE_UNAVAILABLE) for state in states
@@ -446,6 +449,7 @@ class SensorGroup(GroupEntity, SensorEntity):
if not valid_state or not valid_state_numeric:
self._attr_native_value = None
self._extra_state_attribute = {}
return
# Calculate values

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

@@ -181,16 +181,6 @@ class LoggerSettings:
"""Save settings."""
self._store.async_delay_save(self._async_data_to_save, delay)
@callback
def async_get_integration_domains(self) -> set[str]:
"""Get domains that have integration-level log settings."""
stored_log_config = self._stored_config[STORAGE_LOG_KEY]
return {
domain
for domain, setting in stored_log_config.items()
if setting.type == LogSettingsType.INTEGRATION
}
@callback
def _async_get_logger_logs(self) -> dict[str, int]:
"""Get the logger logs."""

View File

@@ -6,7 +6,6 @@ import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.components.websocket_api import ActiveConnection
from homeassistant.config_entries import DISCOVERY_SOURCES
from homeassistant.core import HomeAssistant, callback
from homeassistant.loader import IntegrationNotFound, async_get_integration
from homeassistant.setup import async_get_loaded_integrations
@@ -35,16 +34,6 @@ def handle_integration_log_info(
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
) -> None:
"""Handle integrations logger info."""
integrations = set(async_get_loaded_integrations(hass))
# Add discovered config flows that are not yet loaded
for flow in hass.config_entries.flow.async_progress():
if flow["context"].get("source") in DISCOVERY_SOURCES:
integrations.add(flow["handler"])
# Add integrations with custom log settings
integrations.update(hass.data[DATA_LOGGER].settings.async_get_integration_domains())
connection.send_result(
msg["id"],
[
@@ -54,7 +43,7 @@ def handle_integration_log_info(
f"homeassistant.components.{integration}"
).getEffectiveLevel(),
}
for integration in integrations
for integration in async_get_loaded_integrations(hass)
],
)

View File

@@ -183,16 +183,6 @@ PUMP_CONTROL_MODE_MAP = {
clusters.PumpConfigurationAndControl.Enums.ControlModeEnum.kUnknownEnumValue: None,
}
SETPOINT_CHANGE_SOURCE_MAP = {
clusters.Thermostat.Enums.SetpointChangeSourceEnum.kManual: "manual",
clusters.Thermostat.Enums.SetpointChangeSourceEnum.kSchedule: "schedule",
clusters.Thermostat.Enums.SetpointChangeSourceEnum.kExternal: "external",
clusters.Thermostat.Enums.SetpointChangeSourceEnum.kUnknownEnumValue: None,
}
MATTER_2000_TO_UNIX_EPOCH_OFFSET = (
946684800 # Seconds from Matter 2000 epoch to Unix epoch
)
HUMIDITY_SCALING_FACTOR = 100
TEMPERATURE_SCALING_FACTOR = 100
@@ -1498,54 +1488,4 @@ DISCOVERY_SCHEMAS = [
entity_class=MatterSensor,
required_attributes=(clusters.ServiceArea.Attributes.EstimatedEndTime,),
),
MatterDiscoverySchema(
platform=Platform.SENSOR,
entity_description=MatterSensorEntityDescription(
key="SetpointChangeSource",
translation_key="setpoint_change_source",
device_class=SensorDeviceClass.ENUM,
state_class=None,
options=[x for x in SETPOINT_CHANGE_SOURCE_MAP.values() if x is not None],
device_to_ha=lambda x: SETPOINT_CHANGE_SOURCE_MAP[x],
),
entity_class=MatterSensor,
required_attributes=(clusters.Thermostat.Attributes.SetpointChangeSource,),
device_type=(device_types.Thermostat,),
),
MatterDiscoverySchema(
platform=Platform.SENSOR,
entity_description=MatterSensorEntityDescription(
key="SetpointChangeSourceTimestamp",
translation_key="setpoint_change_timestamp",
device_class=SensorDeviceClass.TIMESTAMP,
state_class=None,
device_to_ha=(
lambda x: (
dt_util.utc_from_timestamp(x + MATTER_2000_TO_UNIX_EPOCH_OFFSET)
if x > 0
else None
)
),
),
entity_class=MatterSensor,
required_attributes=(
clusters.Thermostat.Attributes.SetpointChangeSourceTimestamp,
),
device_type=(device_types.Thermostat,),
),
MatterDiscoverySchema(
platform=Platform.SENSOR,
entity_description=MatterSensorEntityDescription(
key="ThermostatSetpointChangeAmount",
translation_key="setpoint_change_amount",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
suggested_display_precision=1,
device_class=SensorDeviceClass.TEMPERATURE,
device_to_ha=lambda x: x / TEMPERATURE_SCALING_FACTOR,
state_class=SensorStateClass.MEASUREMENT,
),
entity_class=MatterSensor,
required_attributes=(clusters.Thermostat.Attributes.SetpointChangeAmount,),
device_type=(device_types.Thermostat,),
),
]

View File

@@ -528,20 +528,6 @@
"rms_voltage": {
"name": "Effective voltage"
},
"setpoint_change_amount": {
"name": "Last change amount"
},
"setpoint_change_source": {
"name": "Last change source",
"state": {
"external": "External",
"manual": "Manual",
"schedule": "Schedule"
}
},
"setpoint_change_timestamp": {
"name": "Last change"
},
"switch_current_position": {
"name": "Current switch position"
},

View File

@@ -1486,7 +1486,6 @@ class MqttEntity(
entity_registry.async_update_entity(
self.entity_id, new_entity_id=self._update_registry_entity_id
)
self._update_registry_entity_id = None
await super().async_added_to_hass()
self._subscriptions = {}

View File

@@ -729,8 +729,8 @@
"data_description": {
"payload_reset_percentage": "A special payload that resets the fan speed percentage state attribute to unknown when received at the percentage state topic.",
"percentage_command_template": "A [template]({command_templating_url}) to compose the payload to be published at the percentage command topic.",
"percentage_command_topic": "The MQTT topic to publish commands to change the fan speed state based on a percentage setting. The value shall be in the range from \"speed range min\" to \"speed range max\". [Learn more.]({url}#percentage_command_topic)",
"percentage_state_topic": "The MQTT topic subscribed to receive fan speed state. This is a value in the range from \"speed range min\" to \"speed range max\". [Learn more.]({url}#percentage_state_topic)",
"percentage_command_topic": "The MQTT topic to publish commands to change the fan speed state based on a percentage. [Learn more.]({url}#percentage_command_topic)",
"percentage_state_topic": "The MQTT topic subscribed to receive fan speed based on percentage. [Learn more.]({url}#percentage_state_topic)",
"percentage_value_template": "Defines a [template]({value_templating_url}) to extract the speed percentage value.",
"speed_range_max": "The maximum of numeric output range (representing 100 %). The percentage step is 100 / number of speeds within the \"speed range\".",
"speed_range_min": "The minimum of numeric output range (off not included, so speed_range_min - 1 represents 0 %). The percentage step is 100 / the number of speeds within the \"speed range\"."

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

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

View File

@@ -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

@@ -237,13 +237,7 @@ def async_setup_services(hass: HomeAssistant) -> None:
await gw_hub.gateway.set_gpio_mode(gpio_id, gpio_mode)
hass.services.async_register(
DOMAIN,
SERVICE_SET_GPIO_MODE,
set_gpio_mode,
service_set_gpio_mode_schema,
description_placeholders={
"gpio_modes_documentation_url": "https://www.home-assistant.io/integrations/opentherm_gw/#gpio-modes"
},
DOMAIN, SERVICE_SET_GPIO_MODE, set_gpio_mode, service_set_gpio_mode_schema
)
async def set_led_mode(call: ServiceCall) -> None:
@@ -254,13 +248,7 @@ def async_setup_services(hass: HomeAssistant) -> None:
await gw_hub.gateway.set_led_mode(led_id, led_mode)
hass.services.async_register(
DOMAIN,
SERVICE_SET_LED_MODE,
set_led_mode,
service_set_led_mode_schema,
description_placeholders={
"led_modes_documentation_url": "https://www.home-assistant.io/integrations/opentherm_gw/#led-modes"
},
DOMAIN, SERVICE_SET_LED_MODE, set_led_mode, service_set_led_mode_schema
)
async def set_max_mod(call: ServiceCall) -> None:
@@ -306,7 +294,4 @@ def async_setup_services(hass: HomeAssistant) -> None:
SERVICE_SEND_TRANSP_CMD,
send_transparent_cmd,
service_send_transp_cmd_schema,
description_placeholders={
"opentherm_gateway_firmware_url": "https://otgw.tclcode.com/firmware.html"
},
)

View File

@@ -386,7 +386,7 @@
"name": "Reset gateway"
},
"send_transparent_command": {
"description": "Sends custom OTGW commands ({opentherm_gateway_firmware_url}) through a transparent interface.",
"description": "Sends custom otgw commands (https://otgw.tclcode.com/firmware.html) through a transparent interface.",
"fields": {
"gateway_id": {
"description": "[%key:component::opentherm_gw::services::reset_gateway::fields::gateway_id::description%]",
@@ -461,7 +461,7 @@
"name": "ID"
},
"mode": {
"description": "Mode to set on the GPIO pin. Values 0 through 6 are accepted for both GPIOs, 7 is only accepted for GPIO \"B\". See {gpio_modes_documentation_url} for an explanation of the values.",
"description": "Mode to set on the GPIO pin. Values 0 through 6 are accepted for both GPIOs, 7 is only accepted for GPIO \"B\". See https://www.home-assistant.io/integrations/opentherm_gw/#gpio-modes for an explanation of the values.",
"name": "[%key:common::config_flow::data::mode%]"
}
},
@@ -507,7 +507,7 @@
"name": "ID"
},
"mode": {
"description": "The function to assign to the LED. See {led_modes_documentation_url} for an explanation of the values.",
"description": "The function to assign to the LED. See https://www.home-assistant.io/integrations/opentherm_gw/#led-modes for an explanation of the values.",
"name": "[%key:common::config_flow::data::mode%]"
}
},

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["renault_api"],
"quality_scale": "silver",
"requirements": ["renault-api==0.5.1"]
"requirements": ["renault-api==0.5.0"]
}

View File

@@ -32,6 +32,7 @@ async def async_setup_entry(
(
RoborockMap(
config_entry,
f"{coord.duid_slug}_map_{map_info.name}",
coord,
coord.properties_api.home,
map_info.map_flag,
@@ -54,17 +55,13 @@ class RoborockMap(RoborockCoordinatedEntityV1, ImageEntity):
def __init__(
self,
config_entry: ConfigEntry,
unique_id: str,
coordinator: RoborockDataUpdateCoordinator,
home_trait: HomeTrait,
map_flag: int,
map_name: str,
) -> None:
"""Initialize a Roborock map."""
map_name = map_name or f"Map {map_flag}"
# Note: Map names are not a valid unique id since they can be changed
# in the roborock app. This should be migrated to use map flag for
# the unique id.
unique_id = f"{coordinator.duid_slug}_map_{map_name}"
RoborockCoordinatedEntityV1.__init__(self, unique_id, coordinator)
ImageEntity.__init__(self, coordinator.hass)
self.config_entry = config_entry

View File

@@ -19,7 +19,7 @@
"loggers": ["roborock"],
"quality_scale": "silver",
"requirements": [
"python-roborock==3.8.4",
"python-roborock==3.8.1",
"vacuum-map-parser-roborock==0.1.4"
]
}

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

@@ -6,6 +6,5 @@
"dependencies": ["application_credentials"],
"documentation": "https://www.home-assistant.io/integrations/senz",
"iot_class": "cloud_polling",
"loggers": ["aiosenz"],
"requirements": ["aiosenz==1.0.0"]
}

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

@@ -168,7 +168,6 @@ INPUTS_EVENTS_SUBTYPES: Final = {
"button2": 2,
"button3": 3,
"button4": 4,
"button5": 5,
}
SHBTN_MODELS: Final = [MODEL_BUTTON1, MODEL_BUTTON1_V2]

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

@@ -42,7 +42,7 @@ from .entity import (
RpcEntityDescription,
ShellyRpcAttributeEntity,
ShellySleepingBlockAttributeEntity,
async_setup_entry_block,
async_setup_entry_attribute_entities,
async_setup_entry_rpc,
rpc_call,
)
@@ -353,7 +353,7 @@ 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,

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,
@@ -1736,19 +1736,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

@@ -557,9 +557,6 @@
"child_lock": {
"name": "Child lock"
},
"cury_away_mode": {
"name": "Away mode"
},
"frost_protection": {
"name": "[%key:component::shelly::entity::climate::thermostat::state_attributes::preset_mode::state::frost_protection%]"
},
@@ -592,11 +589,6 @@
"beta_firmware": {
"name": "Beta firmware"
}
},
"valve": {
"gas_valve": {
"name": "Valve"
}
}
},
"exceptions": {

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,
)
@@ -306,6 +306,7 @@ RPC_SWITCHES = {
"cury_away_mode": RpcSwitchDescription(
key="cury",
sub_key="away_mode",
name="Away mode",
translation_key="cury_away_mode",
is_on=lambda status: status["away_mode"],
method_on="cury_set_away_mode",
@@ -337,11 +338,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

@@ -49,7 +49,7 @@ class RpcValveDescription(RpcEntityDescription, ValveEntityDescription):
BLOCK_VALVES: dict[tuple[str, str], BlockValveDescription] = {
("valve", "valve"): BlockValveDescription(
key="valve|valve",
translation_key="gas_valve",
name="Valve",
available=lambda block: block.valve not in ("failure", "checking"),
removal_condition=lambda _, block: block.valve in ("not_connected", "unknown"),
models={MODEL_GAS},

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

@@ -8,11 +8,6 @@ import logging
from typing import Any
from homeassistant import config as conf_util
from homeassistant.components.automation import (
DOMAIN as AUTOMATION_DOMAIN,
NEW_TRIGGERS_CONDITIONS_FEATURE_FLAG,
)
from homeassistant.components.labs import async_listen as async_labs_listen
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_DEVICE_ID,
@@ -21,7 +16,7 @@ from homeassistant.const import (
CONF_UNIQUE_ID,
SERVICE_RELOAD,
)
from homeassistant.core import Event, HomeAssistant, ServiceCall, callback
from homeassistant.core import Event, HomeAssistant, ServiceCall
from homeassistant.exceptions import ConfigEntryError, HomeAssistantError
from homeassistant.helpers import discovery, issue_registry as ir
from homeassistant.helpers.device import (
@@ -95,20 +90,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async_register_admin_service(hass, DOMAIN, SERVICE_RELOAD, _reload_config)
@callback
def new_triggers_conditions_listener() -> None:
"""Handle new_triggers_conditions flag change."""
hass.async_create_task(
_reload_config(ServiceCall(hass, DOMAIN, SERVICE_RELOAD))
)
async_labs_listen(
hass,
AUTOMATION_DOMAIN,
NEW_TRIGGERS_CONDITIONS_FEATURE_FLAG,
new_triggers_conditions_listener,
)
return True

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

@@ -46,7 +46,6 @@ from .const import (
CONF_DEFAULT_ENTITY_ID,
CONF_PICTURE,
DOMAIN,
PLATFORMS,
)
from .entity import AbstractTemplateEntity
from .template_entity import TemplateEntity
@@ -235,8 +234,6 @@ def create_legacy_template_issue(
hass: HomeAssistant, config: ConfigType, domain: str
) -> None:
"""Create a repair for legacy template entities."""
if domain not in PLATFORMS:
return
breadcrumb = "Template Entity"
# Default entity id should be in most legacy configuration because

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

@@ -44,9 +44,10 @@ class HomeAssistantTuyaData(NamedTuple):
listener: SharingDeviceListener
def _create_manager(entry: TuyaConfigEntry, token_listener: TokenListener) -> Manager:
"""Create a Tuya Manager instance."""
return Manager(
async def async_setup_entry(hass: HomeAssistant, entry: TuyaConfigEntry) -> bool:
"""Async setup hass config entry."""
token_listener = TokenListener(hass, entry)
manager = Manager(
TUYA_CLIENT_ID,
entry.data[CONF_USER_CODE],
entry.data[CONF_TERMINAL_ID],
@@ -55,15 +56,6 @@ def _create_manager(entry: TuyaConfigEntry, token_listener: TokenListener) -> Ma
token_listener,
)
async def async_setup_entry(hass: HomeAssistant, entry: TuyaConfigEntry) -> bool:
"""Async setup hass config entry."""
token_listener = TokenListener(hass, entry)
# Move to executor as it makes blocking call to import_module
# with args ('.system', 'urllib3.contrib.resolver')
manager = await hass.async_add_executor_job(_create_manager, entry, token_listener)
listener = DeviceListener(hass, manager)
manager.add_device_listener(listener)

View File

@@ -1,60 +0,0 @@
"""Parsers for RAW (base64-encoded bytes) values."""
from dataclasses import dataclass
import struct
from typing import Self
@dataclass(kw_only=True)
class ElectricityData:
"""Electricity RAW value."""
current: float
power: float
voltage: float
@classmethod
def from_bytes(cls, raw: bytes) -> Self | None:
"""Parse bytes and return an ElectricityValue object."""
# Format:
# - legacy: 8 bytes
# - v01: [ver=0x01][len=0x0F][data(15 bytes)]
# - v02: [ver=0x02][len=0x0F][data(15 bytes)][sign_bitmap(1 byte)]
# Data layout (big-endian):
# - voltage: 2B, unit 0.1 V
# - current: 3B, unit 0.001 A (i.e., mA)
# - active power: 3B, unit 0.001 kW (i.e., W)
# - reactive power: 3B, unit 0.001 kVar
# - apparent power: 3B, unit 0.001 kVA
# - power factor: 1B, unit 0.01
# Sign bitmap (v02 only, 1 bit means negative):
# - bit0 current
# - bit1 active power
# - bit2 reactive
# - bit3 power factor
is_v1 = len(raw) == 17 and raw[0:2] == b"\x01\x0f"
is_v2 = len(raw) == 18 and raw[0:2] == b"\x02\x0f"
if is_v1 or is_v2:
data = raw[2:17]
voltage = struct.unpack(">H", data[0:2])[0] / 10.0
current = struct.unpack(">L", b"\x00" + data[2:5])[0]
power = struct.unpack(">L", b"\x00" + data[5:8])[0]
if is_v2:
sign_bitmap = raw[17]
if sign_bitmap & 0x01:
current = -current
if sign_bitmap & 0x02:
power = -power
return cls(current=current, power=power, voltage=voltage)
if len(raw) >= 8:
voltage = struct.unpack(">H", raw[0:2])[0] / 10.0
current = struct.unpack(">L", b"\x00" + raw[2:5])[0]
power = struct.unpack(">L", b"\x00" + raw[5:8])[0]
return cls(current=current, power=power, voltage=voltage)
return None

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
from dataclasses import dataclass
import struct
from tuya_sharing import CustomerDevice, Manager
@@ -48,7 +49,6 @@ from .models import (
DPCodeWrapper,
EnumTypeData,
)
from .raw_data_models import ElectricityData
class _WindDirectionWrapper(DPCodeTypeInformationWrapper[EnumTypeData]):
@@ -120,52 +120,42 @@ class _JsonElectricityVoltageWrapper(DPCodeJsonWrapper):
return raw_value.get("voltage")
class _RawElectricityDataWrapper(DPCodeBase64Wrapper):
"""Custom DPCode Wrapper for extracting ElectricityData from base64."""
def _convert(self, value: ElectricityData) -> float:
"""Extract specific value from T."""
raise NotImplementedError
def read_device_status(self, device: CustomerDevice) -> float | None:
"""Read the device value for the dpcode."""
if (raw_value := super().read_bytes(device)) is None or (
value := ElectricityData.from_bytes(raw_value)
) is None:
return None
return self._convert(value)
class _RawElectricityCurrentWrapper(_RawElectricityDataWrapper):
class _RawElectricityCurrentWrapper(DPCodeBase64Wrapper):
"""Custom DPCode Wrapper for extracting electricity current from base64."""
native_unit = UnitOfElectricCurrent.MILLIAMPERE
suggested_unit = UnitOfElectricCurrent.AMPERE
def _convert(self, value: ElectricityData) -> float:
"""Extract specific value from ElectricityData."""
return value.current
def read_device_status(self, device: CustomerDevice) -> float | None:
"""Read the device value for the dpcode."""
if (raw_value := super().read_bytes(device)) is None:
return None
return struct.unpack(">L", b"\x00" + raw_value[2:5])[0]
class _RawElectricityPowerWrapper(_RawElectricityDataWrapper):
class _RawElectricityPowerWrapper(DPCodeBase64Wrapper):
"""Custom DPCode Wrapper for extracting electricity power from base64."""
native_unit = UnitOfPower.WATT
suggested_unit = UnitOfPower.KILO_WATT
def _convert(self, value: ElectricityData) -> float:
"""Extract specific value from ElectricityData."""
return value.power
def read_device_status(self, device: CustomerDevice) -> float | None:
"""Read the device value for the dpcode."""
if (raw_value := super().read_bytes(device)) is None:
return None
return struct.unpack(">L", b"\x00" + raw_value[5:8])[0]
class _RawElectricityVoltageWrapper(_RawElectricityDataWrapper):
class _RawElectricityVoltageWrapper(DPCodeBase64Wrapper):
"""Custom DPCode Wrapper for extracting electricity voltage from base64."""
native_unit = UnitOfElectricPotential.VOLT
def _convert(self, value: ElectricityData) -> float:
"""Extract specific value from ElectricityData."""
return value.voltage
def read_device_status(self, device: CustomerDevice) -> float | None:
"""Read the device value for the dpcode."""
if (raw_value := super().read_bytes(device)) is None:
return None
return struct.unpack(">H", raw_value[0:2])[0] / 10.0
CURRENT_WRAPPER = (_RawElectricityCurrentWrapper, _JsonElectricityCurrentWrapper)

View File

@@ -35,29 +35,11 @@ if TYPE_CHECKING:
from .hub import UnifiHub
def convert_brightness_to_unifi(ha_brightness: int) -> int:
"""Convert Home Assistant brightness (0-255) to UniFi brightness (0-100)."""
return round((ha_brightness / 255) * 100)
def convert_brightness_to_ha(
unifi_brightness: int,
) -> int:
"""Convert UniFi brightness (0-100) to Home Assistant brightness (0-255)."""
return round((unifi_brightness / 100) * 255)
def get_device_brightness_or_default(device: Device) -> int:
"""Get device's current LED brightness. Defaults to 100 (full brightness) if not set."""
value = device.led_override_color_brightness
return value if value is not None else 100
@callback
def async_device_led_supported_fn(hub: UnifiHub, obj_id: str) -> bool:
"""Check if device supports LED control."""
device: Device = hub.api.devices[obj_id]
return device.led_override is not None or device.supports_led_ring
return device.supports_led_ring
@callback
@@ -74,24 +56,17 @@ async def async_device_led_control_fn(
status = "on" if turn_on else "off"
# Only send brightness and RGB if device has LED_RING hardware support
if device.supports_led_ring:
# Use provided brightness or fall back to device's current brightness
if ATTR_BRIGHTNESS in kwargs:
brightness = convert_brightness_to_unifi(kwargs[ATTR_BRIGHTNESS])
else:
brightness = get_device_brightness_or_default(device)
brightness = (
int((kwargs[ATTR_BRIGHTNESS] / 255) * 100)
if ATTR_BRIGHTNESS in kwargs
else device.led_override_color_brightness
)
# Use provided RGB color or fall back to device's current color
color: str | None
if ATTR_RGB_COLOR in kwargs:
rgb = kwargs[ATTR_RGB_COLOR]
color = f"#{rgb[0]:02x}{rgb[1]:02x}{rgb[2]:02x}"
else:
color = device.led_override_color
else:
brightness = None
color = None
color = (
f"#{kwargs[ATTR_RGB_COLOR][0]:02x}{kwargs[ATTR_RGB_COLOR][1]:02x}{kwargs[ATTR_RGB_COLOR][2]:02x}"
if ATTR_RGB_COLOR in kwargs
else device.led_override_color
)
await hub.api.request(
DeviceSetLedStatus.create(
@@ -152,19 +127,12 @@ class UnifiLightEntity[HandlerT: APIHandler, ApiItemT: ApiItem](
entity_description: UnifiLightEntityDescription[HandlerT, ApiItemT]
_attr_supported_features = LightEntityFeature(0)
_attr_color_mode = ColorMode.RGB
_attr_supported_color_modes = {ColorMode.RGB}
@callback
def async_initiate_state(self) -> None:
"""Initiate entity state."""
device = cast(Device, self.entity_description.object_fn(self.api, self._obj_id))
if device.supports_led_ring:
self._attr_supported_color_modes = {ColorMode.RGB}
self._attr_color_mode = ColorMode.RGB
else:
self._attr_supported_color_modes = {ColorMode.ONOFF}
self._attr_color_mode = ColorMode.ONOFF
self.async_update_state(ItemEvent.ADDED, self._obj_id)
async def async_turn_on(self, **kwargs: Any) -> None:
@@ -182,24 +150,23 @@ class UnifiLightEntity[HandlerT: APIHandler, ApiItemT: ApiItem](
"""Update entity state."""
description = self.entity_description
device_obj = description.object_fn(self.api, self._obj_id)
device = cast(Device, device_obj)
self._attr_is_on = description.is_on_fn(self.hub, device_obj)
# Only set brightness and RGB if device has LED_RING hardware support
if device.supports_led_ring:
self._attr_brightness = convert_brightness_to_ha(
get_device_brightness_or_default(device)
)
brightness = device.led_override_color_brightness
self._attr_brightness = (
int((int(brightness) / 100) * 255) if brightness is not None else None
)
# Parse hex color from device and convert to RGB tuple
hex_color = (
device.led_override_color.lstrip("#")
if self._attr_is_on and device.led_override_color
else None
)
if hex_color and len(hex_color) == 6:
rgb_list = rgb_hex_to_rgb_list(hex_color)
self._attr_rgb_color = (rgb_list[0], rgb_list[1], rgb_list[2])
else:
self._attr_rgb_color = None
hex_color = (
device.led_override_color.lstrip("#")
if self._attr_is_on and device.led_override_color
else None
)
if hex_color and len(hex_color) == 6:
rgb_list = rgb_hex_to_rgb_list(hex_color)
self._attr_rgb_color = (rgb_list[0], rgb_list[1], rgb_list[2])
else:
self._attr_rgb_color = None

View File

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

View File

@@ -39,7 +39,6 @@ from .entity import (
)
_KEY_DOOR = "door"
PARALLEL_UPDATES = 0
@dataclasses.dataclass(frozen=True, kw_only=True)

View File

@@ -33,7 +33,6 @@ from .entity import (
)
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)

View File

@@ -32,7 +32,6 @@ from .entity import ProtectDeviceEntity
from .utils import get_camera_base_name
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
@callback
@@ -92,11 +91,7 @@ def _get_camera_channels(
# no RTSP enabled use first channel with no stream
if is_default and not camera.is_third_party_camera:
# Only create repair issue if RTSP is not disabled globally
if not data.disable_stream:
_create_rtsp_repair(hass, entry, data, camera)
else:
ir.async_delete_issue(hass, DOMAIN, f"rtsp_disabled_{camera.id}")
_create_rtsp_repair(hass, entry, data, camera)
yield camera, camera.channels[0], True
else:
ir.async_delete_issue(hass, DOMAIN, f"rtsp_disabled_{camera.id}")

View File

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

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