mirror of
https://github.com/home-assistant/core.git
synced 2025-11-29 04:28:18 +00:00
Compare commits
1 Commits
dev
...
2025.12.0b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f64c870e42 |
14
.github/workflows/builder.yml
vendored
14
.github/workflows/builder.yml
vendored
@@ -190,8 +190,7 @@ jobs:
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- &install_cosign
|
||||
name: Install Cosign
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
|
||||
with:
|
||||
cosign-release: "v2.5.3"
|
||||
@@ -295,7 +294,7 @@ jobs:
|
||||
|
||||
# home-assistant/builder doesn't support sha pinning
|
||||
- name: Build base image
|
||||
uses: home-assistant/builder@2025.11.0
|
||||
uses: home-assistant/builder@2025.09.0
|
||||
with:
|
||||
args: |
|
||||
$BUILD_ARGS \
|
||||
@@ -354,7 +353,10 @@ jobs:
|
||||
matrix:
|
||||
registry: ["ghcr.io/home-assistant", "docker.io/homeassistant"]
|
||||
steps:
|
||||
- *install_cosign
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
|
||||
with:
|
||||
cosign-release: "v2.2.3"
|
||||
|
||||
- name: Login to DockerHub
|
||||
if: matrix.registry == 'docker.io/homeassistant'
|
||||
@@ -391,7 +393,7 @@ jobs:
|
||||
# 2025.12.0.dev202511250240 -> tags: 2025.12.0.dev202511250240, dev
|
||||
- name: Generate Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
uses: docker/metadata-action@8e5442c4ef9f78752691e2d8f8d19755c6f78e81 # v5.5.1
|
||||
with:
|
||||
images: ${{ matrix.registry }}/home-assistant
|
||||
sep-tags: ","
|
||||
@@ -405,7 +407,7 @@ jobs:
|
||||
type=semver,pattern={{major}}.{{minor}},value=${{ needs.init.outputs.version }},enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.7.1
|
||||
uses: docker/setup-buildx-action@aa33708b10e362ff993539393ff100fa93ed6a27 # v3.7.1
|
||||
|
||||
- name: Copy architecture images to DockerHub
|
||||
if: matrix.registry == 'docker.io/homeassistant'
|
||||
|
||||
2
.github/workflows/ci.yaml
vendored
2
.github/workflows/ci.yaml
vendored
@@ -40,7 +40,7 @@ env:
|
||||
CACHE_VERSION: 2
|
||||
UV_CACHE_VERSION: 1
|
||||
MYPY_CACHE_VERSION: 1
|
||||
HA_SHORT_VERSION: "2026.1"
|
||||
HA_SHORT_VERSION: "2025.12"
|
||||
DEFAULT_PYTHON: "3.13"
|
||||
ALL_PYTHON_VERSIONS: "['3.13', '3.14']"
|
||||
# 10.3 is the oldest supported version
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -98,12 +98,6 @@
|
||||
}
|
||||
},
|
||||
"triggers": {
|
||||
"started_cooling": {
|
||||
"trigger": "mdi:snowflake"
|
||||
},
|
||||
"started_drying": {
|
||||
"trigger": "mdi:water-percent"
|
||||
},
|
||||
"started_heating": {
|
||||
"trigger": "mdi:fire"
|
||||
},
|
||||
|
||||
@@ -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%]",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)])
|
||||
|
||||
@@ -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)])
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -15,11 +15,6 @@ from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
def normalize_pairing_code(code: str) -> str:
|
||||
"""Normalize pairing code by removing spaces and capitalizing."""
|
||||
return code.replace(" ", "").upper()
|
||||
|
||||
|
||||
class DropletConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle Droplet config flow."""
|
||||
|
||||
@@ -57,13 +52,14 @@ class DropletConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
if user_input is not None:
|
||||
# Test if we can connect before returning
|
||||
session = async_get_clientsession(self.hass)
|
||||
code = normalize_pairing_code(user_input[CONF_CODE])
|
||||
if await self._droplet_discovery.try_connect(session, code):
|
||||
if await self._droplet_discovery.try_connect(
|
||||
session, user_input[CONF_CODE]
|
||||
):
|
||||
device_data = {
|
||||
CONF_IP_ADDRESS: self._droplet_discovery.host,
|
||||
CONF_PORT: self._droplet_discovery.port,
|
||||
CONF_DEVICE_ID: device_id,
|
||||
CONF_CODE: code,
|
||||
CONF_CODE: user_input[CONF_CODE],
|
||||
}
|
||||
|
||||
return self.async_create_entry(
|
||||
@@ -94,15 +90,14 @@ class DropletConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
user_input[CONF_IP_ADDRESS], DropletConnection.DEFAULT_PORT, ""
|
||||
)
|
||||
session = async_get_clientsession(self.hass)
|
||||
code = normalize_pairing_code(user_input[CONF_CODE])
|
||||
if await self._droplet_discovery.try_connect(session, code) and (
|
||||
device_id := await self._droplet_discovery.get_device_id()
|
||||
):
|
||||
if await self._droplet_discovery.try_connect(
|
||||
session, user_input[CONF_CODE]
|
||||
) and (device_id := await self._droplet_discovery.get_device_id()):
|
||||
device_data = {
|
||||
CONF_IP_ADDRESS: self._droplet_discovery.host,
|
||||
CONF_PORT: self._droplet_discovery.port,
|
||||
CONF_DEVICE_ID: device_id,
|
||||
CONF_CODE: code,
|
||||
CONF_CODE: user_input[CONF_CODE],
|
||||
}
|
||||
await self.async_set_unique_id(device_id, raise_on_progress=False)
|
||||
self._abort_if_unique_id_configured(
|
||||
|
||||
@@ -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)])
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -1,30 +1,22 @@
|
||||
"""API for fitbit bound to Home Assistant OAuth."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Awaitable, Callable
|
||||
from collections.abc import Callable
|
||||
import logging
|
||||
from typing import Any, cast
|
||||
|
||||
from fitbit import Fitbit
|
||||
from fitbit.exceptions import HTTPException, HTTPUnauthorized
|
||||
from fitbit_web_api import ApiClient, Configuration, DevicesApi
|
||||
from fitbit_web_api.exceptions import (
|
||||
ApiException,
|
||||
OpenApiException,
|
||||
UnauthorizedException,
|
||||
)
|
||||
from fitbit_web_api.models.device import Device
|
||||
from requests.exceptions import ConnectionError as RequestsConnectionError
|
||||
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_entry_oauth2_flow
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.util.unit_system import METRIC_SYSTEM
|
||||
|
||||
from .const import FitbitUnitSystem
|
||||
from .exceptions import FitbitApiException, FitbitAuthException
|
||||
from .model import FitbitProfile
|
||||
from .model import FitbitDevice, FitbitProfile
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -66,14 +58,6 @@ class FitbitApi(ABC):
|
||||
expires_at=float(token[CONF_EXPIRES_AT]),
|
||||
)
|
||||
|
||||
async def _async_get_fitbit_web_api(self) -> ApiClient:
|
||||
"""Create and return an ApiClient configured with the current access token."""
|
||||
token = await self.async_get_access_token()
|
||||
configuration = Configuration()
|
||||
configuration.pool_manager = async_get_clientsession(self._hass)
|
||||
configuration.access_token = token[CONF_ACCESS_TOKEN]
|
||||
return ApiClient(configuration)
|
||||
|
||||
async def async_get_user_profile(self) -> FitbitProfile:
|
||||
"""Return the user profile from the API."""
|
||||
if self._profile is None:
|
||||
@@ -110,13 +94,21 @@ class FitbitApi(ABC):
|
||||
return FitbitUnitSystem.METRIC
|
||||
return FitbitUnitSystem.EN_US
|
||||
|
||||
async def async_get_devices(self) -> list[Device]:
|
||||
"""Return available devices using fitbit-web-api."""
|
||||
client = await self._async_get_fitbit_web_api()
|
||||
devices_api = DevicesApi(client)
|
||||
devices: list[Device] = await self._run_async(devices_api.get_devices)
|
||||
async def async_get_devices(self) -> list[FitbitDevice]:
|
||||
"""Return available devices."""
|
||||
client = await self._async_get_client()
|
||||
devices: list[dict[str, str]] = await self._run(client.get_devices)
|
||||
_LOGGER.debug("get_devices=%s", devices)
|
||||
return devices
|
||||
return [
|
||||
FitbitDevice(
|
||||
id=device["id"],
|
||||
device_version=device["deviceVersion"],
|
||||
battery_level=int(device["batteryLevel"]),
|
||||
battery=device["battery"],
|
||||
type=device["type"],
|
||||
)
|
||||
for device in devices
|
||||
]
|
||||
|
||||
async def async_get_latest_time_series(self, resource_type: str) -> dict[str, Any]:
|
||||
"""Return the most recent value from the time series for the specified resource type."""
|
||||
@@ -148,20 +140,6 @@ class FitbitApi(ABC):
|
||||
_LOGGER.debug("Error from fitbit API: %s", err)
|
||||
raise FitbitApiException("Error from fitbit API") from err
|
||||
|
||||
async def _run_async[_T](self, func: Callable[[], Awaitable[_T]]) -> _T:
|
||||
"""Run client command."""
|
||||
try:
|
||||
return await func()
|
||||
except UnauthorizedException as err:
|
||||
_LOGGER.debug("Unauthorized error from fitbit API: %s", err)
|
||||
raise FitbitAuthException("Authentication error from fitbit API") from err
|
||||
except ApiException as err:
|
||||
_LOGGER.debug("Error from fitbit API: %s", err)
|
||||
raise FitbitApiException("Error from fitbit API") from err
|
||||
except OpenApiException as err:
|
||||
_LOGGER.debug("Error communicating with fitbit API: %s", err)
|
||||
raise FitbitApiException("Communication error from fitbit API") from err
|
||||
|
||||
|
||||
class OAuthFitbitApi(FitbitApi):
|
||||
"""Provide fitbit authentication tied to an OAuth2 based config entry."""
|
||||
|
||||
@@ -6,8 +6,6 @@ import datetime
|
||||
import logging
|
||||
from typing import Final
|
||||
|
||||
from fitbit_web_api.models.device import Device
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
@@ -15,6 +13,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
|
||||
|
||||
from .api import FitbitApi
|
||||
from .exceptions import FitbitApiException, FitbitAuthException
|
||||
from .model import FitbitDevice
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -24,7 +23,7 @@ TIMEOUT = 10
|
||||
type FitbitConfigEntry = ConfigEntry[FitbitData]
|
||||
|
||||
|
||||
class FitbitDeviceCoordinator(DataUpdateCoordinator[dict[str, Device]]):
|
||||
class FitbitDeviceCoordinator(DataUpdateCoordinator[dict[str, FitbitDevice]]):
|
||||
"""Coordinator for fetching fitbit devices from the API."""
|
||||
|
||||
config_entry: FitbitConfigEntry
|
||||
@@ -42,7 +41,7 @@ class FitbitDeviceCoordinator(DataUpdateCoordinator[dict[str, Device]]):
|
||||
)
|
||||
self._api = api
|
||||
|
||||
async def _async_update_data(self) -> dict[str, Device]:
|
||||
async def _async_update_data(self) -> dict[str, FitbitDevice]:
|
||||
"""Fetch data from API endpoint."""
|
||||
async with asyncio.timeout(TIMEOUT):
|
||||
try:
|
||||
@@ -51,7 +50,7 @@ class FitbitDeviceCoordinator(DataUpdateCoordinator[dict[str, Device]]):
|
||||
raise ConfigEntryAuthFailed(err) from err
|
||||
except FitbitApiException as err:
|
||||
raise UpdateFailed(err) from err
|
||||
return {device.id: device for device in devices if device.id is not None}
|
||||
return {device.id: device for device in devices}
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -6,6 +6,6 @@
|
||||
"dependencies": ["application_credentials", "http"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/fitbit",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["fitbit", "fitbit_web_api"],
|
||||
"requirements": ["fitbit==0.3.1", "fitbit-web-api==2.13.5"]
|
||||
"loggers": ["fitbit"],
|
||||
"requirements": ["fitbit==0.3.1"]
|
||||
}
|
||||
|
||||
@@ -21,6 +21,26 @@ class FitbitProfile:
|
||||
"""The locale defined in the user's Fitbit account settings."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class FitbitDevice:
|
||||
"""Device from the Fitbit API response."""
|
||||
|
||||
id: str
|
||||
"""The device ID."""
|
||||
|
||||
device_version: str
|
||||
"""The product name of the device."""
|
||||
|
||||
battery_level: int
|
||||
"""The battery level as a percentage."""
|
||||
|
||||
battery: str
|
||||
"""Returns the battery level of the device."""
|
||||
|
||||
type: str
|
||||
"""The type of the device such as TRACKER or SCALE."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class FitbitConfig:
|
||||
"""Information from the fitbit ConfigEntry data."""
|
||||
|
||||
@@ -8,8 +8,6 @@ import datetime
|
||||
import logging
|
||||
from typing import Any, Final, cast
|
||||
|
||||
from fitbit_web_api.models.device import Device
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
@@ -34,7 +32,7 @@ from .api import FitbitApi
|
||||
from .const import ATTRIBUTION, BATTERY_LEVELS, DOMAIN, FitbitScope, FitbitUnitSystem
|
||||
from .coordinator import FitbitConfigEntry, FitbitDeviceCoordinator
|
||||
from .exceptions import FitbitApiException, FitbitAuthException
|
||||
from .model import config_from_entry_data
|
||||
from .model import FitbitDevice, config_from_entry_data
|
||||
|
||||
_LOGGER: Final = logging.getLogger(__name__)
|
||||
|
||||
@@ -659,7 +657,7 @@ class FitbitBatterySensor(CoordinatorEntity[FitbitDeviceCoordinator], SensorEnti
|
||||
coordinator: FitbitDeviceCoordinator,
|
||||
user_profile_id: str,
|
||||
description: FitbitSensorEntityDescription,
|
||||
device: Device,
|
||||
device: FitbitDevice,
|
||||
enable_default_override: bool,
|
||||
) -> None:
|
||||
"""Initialize the Fitbit sensor."""
|
||||
@@ -679,9 +677,7 @@ class FitbitBatterySensor(CoordinatorEntity[FitbitDeviceCoordinator], SensorEnti
|
||||
@property
|
||||
def icon(self) -> str | None:
|
||||
"""Icon to use in the frontend, if any."""
|
||||
if self.device.battery is not None and (
|
||||
battery_level := BATTERY_LEVELS.get(self.device.battery)
|
||||
):
|
||||
if battery_level := BATTERY_LEVELS.get(self.device.battery):
|
||||
return icon_for_battery_level(battery_level=battery_level)
|
||||
return self.entity_description.icon
|
||||
|
||||
@@ -701,7 +697,7 @@ class FitbitBatterySensor(CoordinatorEntity[FitbitDeviceCoordinator], SensorEnti
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
self.device = self.coordinator.data[cast(str, self.device.id)]
|
||||
self.device = self.coordinator.data[self.device.id]
|
||||
self._attr_native_value = self.device.battery
|
||||
self.async_write_ha_state()
|
||||
|
||||
@@ -719,7 +715,7 @@ class FitbitBatteryLevelSensor(
|
||||
coordinator: FitbitDeviceCoordinator,
|
||||
user_profile_id: str,
|
||||
description: FitbitSensorEntityDescription,
|
||||
device: Device,
|
||||
device: FitbitDevice,
|
||||
) -> None:
|
||||
"""Initialize the Fitbit sensor."""
|
||||
super().__init__(coordinator)
|
||||
@@ -740,6 +736,6 @@ class FitbitBatteryLevelSensor(
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
self.device = self.coordinator.data[cast(str, self.device.id)]
|
||||
self.device = self.coordinator.data[self.device.id]
|
||||
self._attr_native_value = self.device.battery_level
|
||||
self.async_write_ha_state()
|
||||
|
||||
@@ -23,5 +23,5 @@
|
||||
"winter_mode": {}
|
||||
},
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20251127.0"]
|
||||
"requirements": ["home-assistant-frontend==20251126.0"]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ from homeassistant.helpers.issue_registry import (
|
||||
async_create_issue,
|
||||
async_delete_issue,
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType
|
||||
|
||||
from .const import CONF_IGNORE_NON_NUMERIC, DOMAIN
|
||||
from .entity import GroupEntity
|
||||
@@ -374,7 +374,7 @@ class SensorGroup(GroupEntity, SensorEntity):
|
||||
def async_update_group_state(self) -> None:
|
||||
"""Query all members and determine the sensor group state."""
|
||||
self.calculate_state_attributes(self._get_valid_entities())
|
||||
states: list[str] = []
|
||||
states: list[StateType] = []
|
||||
valid_units = self._valid_units
|
||||
valid_states: list[bool] = []
|
||||
sensor_values: list[tuple[str, float, State]] = []
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -39,10 +39,6 @@ if TYPE_CHECKING:
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_DESCRIPTION_PLACEHOLDERS = {
|
||||
"sensor_value_types_url": "https://www.home-assistant.io/integrations/knx/#value-types"
|
||||
}
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
@@ -52,7 +48,6 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
||||
SERVICE_KNX_SEND,
|
||||
service_send_to_knx_bus,
|
||||
schema=SERVICE_KNX_SEND_SCHEMA,
|
||||
description_placeholders=_DESCRIPTION_PLACEHOLDERS,
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
@@ -68,7 +63,6 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
||||
SERVICE_KNX_EVENT_REGISTER,
|
||||
service_event_register_modify,
|
||||
schema=SERVICE_KNX_EVENT_REGISTER_SCHEMA,
|
||||
description_placeholders=_DESCRIPTION_PLACEHOLDERS,
|
||||
)
|
||||
|
||||
async_register_admin_service(
|
||||
@@ -77,7 +71,6 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
||||
SERVICE_KNX_EXPOSURE_REGISTER,
|
||||
service_exposure_register_modify,
|
||||
schema=SERVICE_KNX_EXPOSURE_REGISTER_SCHEMA,
|
||||
description_placeholders=_DESCRIPTION_PLACEHOLDERS,
|
||||
)
|
||||
|
||||
async_register_admin_service(
|
||||
|
||||
@@ -674,7 +674,7 @@
|
||||
"name": "Remove event registration"
|
||||
},
|
||||
"type": {
|
||||
"description": "If set, the payload will be decoded as given DPT in the event data `value` key. KNX sensor types are valid values (see {sensor_value_types_url}).",
|
||||
"description": "If set, the payload will be decoded as given DPT in the event data `value` key. KNX sensor types are valid values (see https://www.home-assistant.io/integrations/knx/#value-types).",
|
||||
"name": "Value type"
|
||||
}
|
||||
},
|
||||
@@ -704,7 +704,7 @@
|
||||
"name": "Remove exposure"
|
||||
},
|
||||
"type": {
|
||||
"description": "Telegrams will be encoded as given DPT. 'binary' and all KNX sensor types are valid values (see {sensor_value_types_url}).",
|
||||
"description": "Telegrams will be encoded as given DPT. 'binary' and all KNX sensor types are valid values (see https://www.home-assistant.io/integrations/knx/#value-types).",
|
||||
"name": "Value type"
|
||||
}
|
||||
},
|
||||
@@ -740,7 +740,7 @@
|
||||
"name": "Send as Response"
|
||||
},
|
||||
"type": {
|
||||
"description": "If set, the payload will not be sent as raw bytes, but encoded as given DPT. KNX sensor types are valid values (see {sensor_value_types_url}).",
|
||||
"description": "If set, the payload will not be sent as raw bytes, but encoded as given DPT. KNX sensor types are valid values (see https://www.home-assistant.io/integrations/knx/#value-types).",
|
||||
"name": "Value type"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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%]"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -11,5 +11,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/pooldose",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["python-pooldose==0.8.0"]
|
||||
"requirements": ["python-pooldose==0.7.8"]
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -19,5 +19,5 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["reolink_aio"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["reolink-aio==0.16.6"]
|
||||
"requirements": ["reolink-aio==0.16.5"]
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ from .coordinator import (
|
||||
RoborockWashingMachineUpdateCoordinator,
|
||||
RoborockWetDryVacUpdateCoordinator,
|
||||
)
|
||||
from .roborock_storage import CacheStore, async_cleanup_map_storage
|
||||
from .roborock_storage import CacheStore, async_remove_map_storage
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=30)
|
||||
|
||||
@@ -42,7 +42,6 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> bool:
|
||||
"""Set up roborock from a config entry."""
|
||||
await async_cleanup_map_storage(hass, entry.entry_id)
|
||||
|
||||
user_data = UserData.from_dict(entry.data[CONF_USER_DATA])
|
||||
user_params = UserParams(
|
||||
@@ -246,5 +245,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: RoborockConfigEntry) ->
|
||||
|
||||
async def async_remove_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> None:
|
||||
"""Handle removal of an entry."""
|
||||
await async_remove_map_storage(hass, entry.entry_id)
|
||||
store = CacheStore(hass, entry.entry_id)
|
||||
await store.async_remove()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"loggers": ["roborock"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": [
|
||||
"python-roborock==3.8.1",
|
||||
"python-roborock==3.7.1",
|
||||
"vacuum-map-parser-roborock==0.1.4"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -25,8 +25,8 @@ def _storage_path_prefix(hass: HomeAssistant, entry_id: str) -> Path:
|
||||
return Path(hass.config.path(STORAGE_PATH)) / entry_id
|
||||
|
||||
|
||||
async def async_cleanup_map_storage(hass: HomeAssistant, entry_id: str) -> None:
|
||||
"""Remove map storage in the old format, if any.
|
||||
async def async_remove_map_storage(hass: HomeAssistant, entry_id: str) -> None:
|
||||
"""Remove all map storage associated with a config entry.
|
||||
|
||||
This removes all on-disk map files for the given config entry. This is the
|
||||
old format that was replaced by the `CacheStore` implementation.
|
||||
@@ -34,13 +34,13 @@ async def async_cleanup_map_storage(hass: HomeAssistant, entry_id: str) -> None:
|
||||
|
||||
def remove(path_prefix: Path) -> None:
|
||||
try:
|
||||
if path_prefix.exists() and path_prefix.is_dir():
|
||||
_LOGGER.debug("Removing maps from disk store: %s", path_prefix)
|
||||
if path_prefix.exists():
|
||||
shutil.rmtree(path_prefix, ignore_errors=True)
|
||||
except OSError as err:
|
||||
_LOGGER.error("Unable to remove map files in %s: %s", path_prefix, err)
|
||||
|
||||
path_prefix = _storage_path_prefix(hass, entry_id)
|
||||
_LOGGER.debug("Removing maps from disk store: %s", path_prefix)
|
||||
await hass.async_add_executor_job(remove, path_prefix)
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]],
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -168,7 +168,6 @@ INPUTS_EVENTS_SUBTYPES: Final = {
|
||||
"button2": 2,
|
||||
"button3": 3,
|
||||
"button4": 4,
|
||||
"button5": 5,
|
||||
}
|
||||
|
||||
SHBTN_MODELS: Final = [MODEL_BUTTON1, MODEL_BUTTON1_V2]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -31,6 +31,7 @@ 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,
|
||||
@@ -210,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."""
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -26,4 +26,5 @@ class TransmissionEntity(CoordinatorEntity[TransmissionDataUpdateCoordinator]):
|
||||
)
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, coordinator.config_entry.entry_id)},
|
||||
manufacturer="Transmission",
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -21,13 +21,6 @@ class VeSyncBaseEntity(CoordinatorEntity[VeSyncDataCoordinator]):
|
||||
super().__init__(coordinator)
|
||||
self.device = device
|
||||
self._attr_unique_id = self.base_unique_id
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, self.base_unique_id)},
|
||||
name=self.device.device_name,
|
||||
model=self.device.device_type,
|
||||
manufacturer="VeSync",
|
||||
sw_version=self.device.current_firm_version,
|
||||
)
|
||||
|
||||
@property
|
||||
def base_unique_id(self):
|
||||
@@ -43,3 +36,14 @@ class VeSyncBaseEntity(CoordinatorEntity[VeSyncDataCoordinator]):
|
||||
def available(self) -> bool:
|
||||
"""Return True if device is available."""
|
||||
return self.device.state.connection_status == "online"
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
"""Return device information."""
|
||||
return DeviceInfo(
|
||||
identifiers={(DOMAIN, self.base_unique_id)},
|
||||
name=self.device.device_name,
|
||||
model=self.device.device_type,
|
||||
manufacturer="VeSync",
|
||||
sw_version=self.device.current_firm_version,
|
||||
)
|
||||
|
||||
@@ -9,7 +9,6 @@ from wled import WLED, Device, WLEDConnectionError
|
||||
|
||||
from homeassistant.components import onboarding
|
||||
from homeassistant.config_entries import (
|
||||
SOURCE_RECONFIGURE,
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
OptionsFlowWithReload,
|
||||
@@ -17,7 +16,6 @@ from homeassistant.config_entries import (
|
||||
from homeassistant.const import CONF_HOST, CONF_MAC
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.device_registry import format_mac
|
||||
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
|
||||
|
||||
from .const import CONF_KEEP_MAIN_LIGHT, DEFAULT_KEEP_MAIN_LIGHT, DOMAIN
|
||||
@@ -54,19 +52,6 @@ class WLEDFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
await self.async_set_unique_id(
|
||||
device.info.mac_address, raise_on_progress=False
|
||||
)
|
||||
if self.source == SOURCE_RECONFIGURE:
|
||||
entry = self._get_reconfigure_entry()
|
||||
self._abort_if_unique_id_mismatch(
|
||||
reason="unique_id_mismatch",
|
||||
description_placeholders={
|
||||
"expected_mac": format_mac(entry.unique_id).upper(),
|
||||
"actual_mac": format_mac(self.unique_id).upper(),
|
||||
},
|
||||
)
|
||||
return self.async_update_reload_and_abort(
|
||||
entry,
|
||||
data_updates=user_input,
|
||||
)
|
||||
self._abort_if_unique_id_configured(
|
||||
updates={CONF_HOST: user_input[CONF_HOST]}
|
||||
)
|
||||
@@ -76,26 +61,13 @@ class WLEDFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
CONF_HOST: user_input[CONF_HOST],
|
||||
},
|
||||
)
|
||||
data_schema = vol.Schema({vol.Required(CONF_HOST): str})
|
||||
if self.source == SOURCE_RECONFIGURE:
|
||||
entry = self._get_reconfigure_entry()
|
||||
data_schema = self.add_suggested_values_to_schema(
|
||||
data_schema,
|
||||
entry.data,
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=data_schema,
|
||||
data_schema=vol.Schema({vol.Required(CONF_HOST): str}),
|
||||
errors=errors or {},
|
||||
)
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reconfigure flow for WLED entry."""
|
||||
return await self.async_step_user(user_input)
|
||||
|
||||
async def async_step_zeroconf(
|
||||
self, discovery_info: ZeroconfServiceInfo
|
||||
) -> ConfigFlowResult:
|
||||
|
||||
@@ -14,9 +14,7 @@ from wled import (
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP
|
||||
from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConfigEntryError
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.device_registry import format_mac
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import (
|
||||
@@ -122,16 +120,6 @@ class WLEDDataUpdateCoordinator(DataUpdateCoordinator[WLEDDevice]):
|
||||
translation_placeholders={"error": str(error)},
|
||||
) from error
|
||||
|
||||
if device.info.mac_address != self.config_entry.unique_id:
|
||||
raise ConfigEntryError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="mac_address_mismatch",
|
||||
translation_placeholders={
|
||||
"expected_mac": format_mac(self.config_entry.unique_id).upper(),
|
||||
"actual_mac": format_mac(device.info.mac_address).upper(),
|
||||
},
|
||||
)
|
||||
|
||||
# If the device supports a WebSocket, try activating it.
|
||||
if (
|
||||
device.info.websocket is not None
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
|
||||
"unique_id_mismatch": "MAC address does not match the configured device. Expected to connect to device with MAC: `{expected_mac}`, but connected to device with MAC: `{actual_mac}`. \n\nPlease ensure you reconfigure against the same device."
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
|
||||
@@ -135,9 +133,6 @@
|
||||
},
|
||||
"invalid_response_wled_error": {
|
||||
"message": "Invalid response from WLED API: {error}"
|
||||
},
|
||||
"mac_address_mismatch": {
|
||||
"message": "MAC address does not match the configured device. Expected to connect to device with MAC: {expected_mac}, but connected to device with MAC: {actual_mac}."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/youtube",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["youtubeaio==2.1.1"]
|
||||
"requirements": ["youtubeaio==2.1.0"]
|
||||
}
|
||||
|
||||
@@ -79,9 +79,6 @@ def async_describe_events(
|
||||
if params := event_data.get("params"):
|
||||
message = f"{message} with parameters: {params}"
|
||||
|
||||
if args := event_data.get("args"):
|
||||
message = f"{message} with arguments: {args}"
|
||||
|
||||
return {
|
||||
LOGBOOK_ENTRY_NAME: device_name,
|
||||
LOGBOOK_ENTRY_MESSAGE: message,
|
||||
|
||||
@@ -754,7 +754,6 @@ class ConfigEntry[_DataT = Any]:
|
||||
error_reason_translation_key = None
|
||||
error_reason_translation_placeholders = None
|
||||
|
||||
result = False
|
||||
try:
|
||||
with async_start_setup(
|
||||
hass, integration=self.domain, group=self.entry_id, phase=setup_phase
|
||||
@@ -776,6 +775,8 @@ class ConfigEntry[_DataT = Any]:
|
||||
self.domain,
|
||||
error_reason,
|
||||
)
|
||||
await self._async_process_on_unload(hass)
|
||||
result = False
|
||||
except ConfigEntryAuthFailed as exc:
|
||||
message = str(exc)
|
||||
auth_base_message = "could not authenticate"
|
||||
@@ -791,7 +792,9 @@ class ConfigEntry[_DataT = Any]:
|
||||
self.domain,
|
||||
auth_message,
|
||||
)
|
||||
await self._async_process_on_unload(hass)
|
||||
self.async_start_reauth(hass)
|
||||
result = False
|
||||
except ConfigEntryNotReady as exc:
|
||||
message = str(exc)
|
||||
error_reason_translation_key = exc.translation_key
|
||||
@@ -832,39 +835,14 @@ class ConfigEntry[_DataT = Any]:
|
||||
functools.partial(self._async_setup_again, hass),
|
||||
)
|
||||
|
||||
await self._async_process_on_unload(hass)
|
||||
return
|
||||
|
||||
except asyncio.CancelledError:
|
||||
# We want to propagate CancelledError if we are being cancelled.
|
||||
if (task := asyncio.current_task()) and task.cancelling() > 0:
|
||||
_LOGGER.exception(
|
||||
"Setup of config entry '%s' for %s integration cancelled",
|
||||
self.title,
|
||||
self.domain,
|
||||
)
|
||||
self._async_set_state(
|
||||
hass,
|
||||
ConfigEntryState.SETUP_ERROR,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
raise
|
||||
|
||||
# This was not a "real" cancellation, log it and treat as a normal error.
|
||||
_LOGGER.exception(
|
||||
"Error setting up entry %s for %s", self.title, integration.domain
|
||||
)
|
||||
|
||||
# pylint: disable-next=broad-except
|
||||
except (SystemExit, Exception):
|
||||
except (asyncio.CancelledError, SystemExit, Exception):
|
||||
_LOGGER.exception(
|
||||
"Error setting up entry %s for %s", self.title, integration.domain
|
||||
)
|
||||
|
||||
finally:
|
||||
if not result and domain_is_integration:
|
||||
await self._async_process_on_unload(hass)
|
||||
result = False
|
||||
|
||||
#
|
||||
# After successfully calling async_setup_entry, it is important that this function
|
||||
|
||||
@@ -15,9 +15,9 @@ if TYPE_CHECKING:
|
||||
from .helpers.typing import NoEventData
|
||||
|
||||
APPLICATION_NAME: Final = "HomeAssistant"
|
||||
MAJOR_VERSION: Final = 2026
|
||||
MINOR_VERSION: Final = 1
|
||||
PATCH_VERSION: Final = "0.dev0"
|
||||
MAJOR_VERSION: Final = 2025
|
||||
MINOR_VERSION: Final = 12
|
||||
PATCH_VERSION: Final = "0b0"
|
||||
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
|
||||
__version__: Final = f"{__short_version__}.{PATCH_VERSION}"
|
||||
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 2)
|
||||
@@ -653,7 +653,6 @@ class UnitOfVolumeFlowRate(StrEnum):
|
||||
LITERS_PER_SECOND = "L/s"
|
||||
GALLONS_PER_HOUR = "gal/h"
|
||||
GALLONS_PER_MINUTE = "gal/min"
|
||||
GALLONS_PER_DAY = "gal/d"
|
||||
MILLILITERS_PER_SECOND = "mL/s"
|
||||
|
||||
|
||||
|
||||
@@ -131,7 +131,7 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]):
|
||||
self._request_refresh_task: asyncio.TimerHandle | None = None
|
||||
self._retry_after: float | None = None
|
||||
self.last_update_success = True
|
||||
self.last_exception: BaseException | None = None
|
||||
self.last_exception: Exception | None = None
|
||||
|
||||
if request_refresh_debouncer is None:
|
||||
request_refresh_debouncer = Debouncer(
|
||||
@@ -492,16 +492,8 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]):
|
||||
self.config_entry.async_start_reauth(self.hass)
|
||||
except NotImplementedError as err:
|
||||
self.last_exception = err
|
||||
self.last_update_success = False
|
||||
raise
|
||||
|
||||
except asyncio.CancelledError as err:
|
||||
self.last_exception = err
|
||||
self.last_update_success = False
|
||||
|
||||
if (task := asyncio.current_task()) and task.cancelling() > 0:
|
||||
raise
|
||||
|
||||
except Exception as err:
|
||||
self.last_exception = err
|
||||
self.last_update_success = False
|
||||
|
||||
@@ -36,10 +36,10 @@ fnv-hash-fast==1.6.0
|
||||
go2rtc-client==0.3.0
|
||||
ha-ffmpeg==3.2.2
|
||||
habluetooth==5.7.0
|
||||
hass-nabucasa==1.6.2
|
||||
hass-nabucasa==1.6.1
|
||||
hassil==3.4.0
|
||||
home-assistant-bluetooth==1.13.1
|
||||
home-assistant-frontend==20251127.0
|
||||
home-assistant-frontend==20251126.0
|
||||
home-assistant-intents==2025.11.24
|
||||
httpx==0.28.1
|
||||
ifaddr==0.2.0
|
||||
|
||||
@@ -69,8 +69,7 @@ _HECTARE_TO_M2 = 100 * 100 # 1 hectare = 10,000 m²
|
||||
_MIN_TO_SEC = 60 # 1 min = 60 seconds
|
||||
_HRS_TO_MINUTES = 60 # 1 hr = 60 minutes
|
||||
_HRS_TO_SECS = _HRS_TO_MINUTES * _MIN_TO_SEC # 1 hr = 60 minutes = 3600 seconds
|
||||
_DAYS_TO_HRS = 24 # 1 day = 24 hours
|
||||
_DAYS_TO_SECS = _DAYS_TO_HRS * _HRS_TO_SECS # 1 day = 24 hours = 86400 seconds
|
||||
_DAYS_TO_SECS = 24 * _HRS_TO_SECS # 1 day = 24 hours = 86400 seconds
|
||||
|
||||
# Energy conversion constants
|
||||
_WH_TO_J = 3600 # 1 Wh = 3600 J
|
||||
@@ -853,7 +852,6 @@ class VolumeFlowRateConverter(BaseUnitConverter):
|
||||
UnitOfVolumeFlowRate.GALLONS_PER_HOUR: 1 / _GALLON_TO_CUBIC_METER,
|
||||
UnitOfVolumeFlowRate.GALLONS_PER_MINUTE: 1
|
||||
/ (_HRS_TO_MINUTES * _GALLON_TO_CUBIC_METER),
|
||||
UnitOfVolumeFlowRate.GALLONS_PER_DAY: _DAYS_TO_HRS / _GALLON_TO_CUBIC_METER,
|
||||
UnitOfVolumeFlowRate.MILLILITERS_PER_SECOND: 1
|
||||
/ (_HRS_TO_SECS * _ML_TO_CUBIC_METER),
|
||||
}
|
||||
@@ -867,7 +865,6 @@ class VolumeFlowRateConverter(BaseUnitConverter):
|
||||
UnitOfVolumeFlowRate.LITERS_PER_SECOND,
|
||||
UnitOfVolumeFlowRate.GALLONS_PER_HOUR,
|
||||
UnitOfVolumeFlowRate.GALLONS_PER_MINUTE,
|
||||
UnitOfVolumeFlowRate.GALLONS_PER_DAY,
|
||||
UnitOfVolumeFlowRate.MILLILITERS_PER_SECOND,
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "homeassistant"
|
||||
version = "2026.1.0.dev0"
|
||||
version = "2025.12.0b0"
|
||||
license = "Apache-2.0"
|
||||
license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"]
|
||||
description = "Open-source home automation platform running on Python 3."
|
||||
@@ -48,7 +48,7 @@ dependencies = [
|
||||
"fnv-hash-fast==1.6.0",
|
||||
# hass-nabucasa is imported by helpers which don't depend on the cloud
|
||||
# integration
|
||||
"hass-nabucasa==1.6.2",
|
||||
"hass-nabucasa==1.6.1",
|
||||
# When bumping httpx, please check the version pins of
|
||||
# httpcore, anyio, and h11 in gen_requirements_all
|
||||
"httpx==0.28.1",
|
||||
@@ -830,7 +830,7 @@ ignore = [
|
||||
# Disabled because ruff does not understand type of __all__ generated by a function
|
||||
"PLE0605",
|
||||
|
||||
"FURB116",
|
||||
"FURB116"
|
||||
]
|
||||
|
||||
[tool.ruff.lint.flake8-import-conventions.extend-aliases]
|
||||
|
||||
2
requirements.txt
generated
2
requirements.txt
generated
@@ -22,7 +22,7 @@ certifi>=2021.5.30
|
||||
ciso8601==2.3.3
|
||||
cronsim==2.7
|
||||
fnv-hash-fast==1.6.0
|
||||
hass-nabucasa==1.6.2
|
||||
hass-nabucasa==1.6.1
|
||||
httpx==0.28.1
|
||||
home-assistant-bluetooth==1.13.1
|
||||
ifaddr==0.2.0
|
||||
|
||||
21
requirements_all.txt
generated
21
requirements_all.txt
generated
@@ -958,9 +958,6 @@ fing_agent_api==1.0.3
|
||||
# homeassistant.components.fints
|
||||
fints==3.1.0
|
||||
|
||||
# homeassistant.components.fitbit
|
||||
fitbit-web-api==2.13.5
|
||||
|
||||
# homeassistant.components.fitbit
|
||||
fitbit==0.3.1
|
||||
|
||||
@@ -1160,7 +1157,7 @@ habluetooth==5.7.0
|
||||
hanna-cloud==0.0.6
|
||||
|
||||
# homeassistant.components.cloud
|
||||
hass-nabucasa==1.6.2
|
||||
hass-nabucasa==1.6.1
|
||||
|
||||
# homeassistant.components.splunk
|
||||
hass-splunk==0.1.1
|
||||
@@ -1201,7 +1198,7 @@ hole==0.9.0
|
||||
holidays==0.84
|
||||
|
||||
# homeassistant.components.frontend
|
||||
home-assistant-frontend==20251127.0
|
||||
home-assistant-frontend==20251126.0
|
||||
|
||||
# homeassistant.components.conversation
|
||||
home-assistant-intents==2025.11.24
|
||||
@@ -2007,7 +2004,7 @@ pyegps==0.2.5
|
||||
pyemoncms==0.1.3
|
||||
|
||||
# homeassistant.components.enphase_envoy
|
||||
pyenphase==2.4.2
|
||||
pyenphase==2.4.0
|
||||
|
||||
# homeassistant.components.envisalink
|
||||
pyenvisalink==4.7
|
||||
@@ -2436,7 +2433,7 @@ pyspeex-noise==1.0.2
|
||||
pysqueezebox==0.13.0
|
||||
|
||||
# homeassistant.components.stiebel_eltron
|
||||
pystiebeleltron==0.2.5
|
||||
pystiebeleltron==0.2.3
|
||||
|
||||
# homeassistant.components.suez_water
|
||||
pysuezV2==2.0.7
|
||||
@@ -2551,7 +2548,7 @@ python-overseerr==0.7.1
|
||||
python-picnic-api2==1.3.1
|
||||
|
||||
# homeassistant.components.pooldose
|
||||
python-pooldose==0.8.0
|
||||
python-pooldose==0.7.8
|
||||
|
||||
# homeassistant.components.rabbitair
|
||||
python-rabbitair==0.0.8
|
||||
@@ -2560,7 +2557,7 @@ python-rabbitair==0.0.8
|
||||
python-ripple-api==0.0.3
|
||||
|
||||
# homeassistant.components.roborock
|
||||
python-roborock==3.8.1
|
||||
python-roborock==3.7.1
|
||||
|
||||
# homeassistant.components.smarttub
|
||||
python-smarttub==0.0.45
|
||||
@@ -2714,13 +2711,13 @@ refoss-ha==1.2.5
|
||||
regenmaschine==2024.03.0
|
||||
|
||||
# homeassistant.components.renault
|
||||
renault-api==0.5.1
|
||||
renault-api==0.5.0
|
||||
|
||||
# homeassistant.components.renson
|
||||
renson-endura-delta==1.7.2
|
||||
|
||||
# homeassistant.components.reolink
|
||||
reolink-aio==0.16.6
|
||||
reolink-aio==0.16.5
|
||||
|
||||
# homeassistant.components.idteck_prox
|
||||
rfk101py==0.0.1
|
||||
@@ -3228,7 +3225,7 @@ yolink-api==0.5.8
|
||||
youless-api==2.2.0
|
||||
|
||||
# homeassistant.components.youtube
|
||||
youtubeaio==2.1.1
|
||||
youtubeaio==2.1.0
|
||||
|
||||
# homeassistant.components.media_extractor
|
||||
yt-dlp[default]==2025.10.22
|
||||
|
||||
21
requirements_test_all.txt
generated
21
requirements_test_all.txt
generated
@@ -846,9 +846,6 @@ fing_agent_api==1.0.3
|
||||
# homeassistant.components.fints
|
||||
fints==3.1.0
|
||||
|
||||
# homeassistant.components.fitbit
|
||||
fitbit-web-api==2.13.5
|
||||
|
||||
# homeassistant.components.fitbit
|
||||
fitbit==0.3.1
|
||||
|
||||
@@ -1030,7 +1027,7 @@ habluetooth==5.7.0
|
||||
hanna-cloud==0.0.6
|
||||
|
||||
# homeassistant.components.cloud
|
||||
hass-nabucasa==1.6.2
|
||||
hass-nabucasa==1.6.1
|
||||
|
||||
# homeassistant.components.assist_satellite
|
||||
# homeassistant.components.conversation
|
||||
@@ -1059,7 +1056,7 @@ hole==0.9.0
|
||||
holidays==0.84
|
||||
|
||||
# homeassistant.components.frontend
|
||||
home-assistant-frontend==20251127.0
|
||||
home-assistant-frontend==20251126.0
|
||||
|
||||
# homeassistant.components.conversation
|
||||
home-assistant-intents==2025.11.24
|
||||
@@ -1693,7 +1690,7 @@ pyegps==0.2.5
|
||||
pyemoncms==0.1.3
|
||||
|
||||
# homeassistant.components.enphase_envoy
|
||||
pyenphase==2.4.2
|
||||
pyenphase==2.4.0
|
||||
|
||||
# homeassistant.components.everlights
|
||||
pyeverlights==0.1.0
|
||||
@@ -2050,7 +2047,7 @@ pyspeex-noise==1.0.2
|
||||
pysqueezebox==0.13.0
|
||||
|
||||
# homeassistant.components.stiebel_eltron
|
||||
pystiebeleltron==0.2.5
|
||||
pystiebeleltron==0.2.3
|
||||
|
||||
# homeassistant.components.suez_water
|
||||
pysuezV2==2.0.7
|
||||
@@ -2132,13 +2129,13 @@ python-overseerr==0.7.1
|
||||
python-picnic-api2==1.3.1
|
||||
|
||||
# homeassistant.components.pooldose
|
||||
python-pooldose==0.8.0
|
||||
python-pooldose==0.7.8
|
||||
|
||||
# homeassistant.components.rabbitair
|
||||
python-rabbitair==0.0.8
|
||||
|
||||
# homeassistant.components.roborock
|
||||
python-roborock==3.8.1
|
||||
python-roborock==3.7.1
|
||||
|
||||
# homeassistant.components.smarttub
|
||||
python-smarttub==0.0.45
|
||||
@@ -2268,13 +2265,13 @@ refoss-ha==1.2.5
|
||||
regenmaschine==2024.03.0
|
||||
|
||||
# homeassistant.components.renault
|
||||
renault-api==0.5.1
|
||||
renault-api==0.5.0
|
||||
|
||||
# homeassistant.components.renson
|
||||
renson-endura-delta==1.7.2
|
||||
|
||||
# homeassistant.components.reolink
|
||||
reolink-aio==0.16.6
|
||||
reolink-aio==0.16.5
|
||||
|
||||
# homeassistant.components.rflink
|
||||
rflink==0.0.67
|
||||
@@ -2686,7 +2683,7 @@ yolink-api==0.5.8
|
||||
youless-api==2.2.0
|
||||
|
||||
# homeassistant.components.youtube
|
||||
youtubeaio==2.1.1
|
||||
youtubeaio==2.1.0
|
||||
|
||||
# homeassistant.components.media_extractor
|
||||
yt-dlp[default]==2025.10.22
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user