Merge branch 'dev' into gj-20250813-01

This commit is contained in:
Jan Bouwhuis
2025-08-15 20:36:39 +02:00
committed by GitHub
192 changed files with 9318 additions and 800 deletions

View File

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

View File

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

View File

@@ -18,7 +18,7 @@ repos:
exclude_types: [csv, json, html]
exclude: ^tests/fixtures/|homeassistant/generated/|tests/components/.*/snapshots/
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
rev: v6.0.0
hooks:
- id: check-executables-have-shebangs
stages: [manual]

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/airos",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["airos==0.2.8"]
"requirements": ["airos==0.3.0"]
}

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
from aioambient.util import get_public_device_id
from homeassistant.core import callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity, EntityDescription
@@ -37,6 +37,7 @@ class AmbientWeatherEntity(Entity):
identifiers={(DOMAIN, mac_address)},
manufacturer="Ambient Weather",
name=station_name.capitalize(),
connections={(CONNECTION_NETWORK_MAC, mac_address)},
)
self._attr_unique_id = f"{mac_address}_{description.key}"

View File

@@ -30,10 +30,9 @@ class AndroidIPCamDataUpdateCoordinator(DataUpdateCoordinator[None]):
cam: PyDroidIPCam,
) -> None:
"""Initialize the Android IP Webcam."""
self.hass = hass
self.cam = cam
super().__init__(
self.hass,
hass,
_LOGGER,
config_entry=config_entry,
name=f"{DOMAIN} {config_entry.data[CONF_HOST]}",

View File

@@ -2,11 +2,10 @@
from collections.abc import AsyncGenerator, Callable, Iterable
import json
from typing import Any, cast
from typing import Any
import anthropic
from anthropic import AsyncStream
from anthropic._types import NOT_GIVEN
from anthropic.types import (
InputJSONDelta,
MessageDeltaUsage,
@@ -17,7 +16,6 @@ from anthropic.types import (
RawContentBlockStopEvent,
RawMessageDeltaEvent,
RawMessageStartEvent,
RawMessageStopEvent,
RedactedThinkingBlock,
RedactedThinkingBlockParam,
SignatureDelta,
@@ -35,6 +33,7 @@ from anthropic.types import (
ToolUseBlockParam,
Usage,
)
from anthropic.types.message_create_params import MessageCreateParamsStreaming
from voluptuous_openapi import convert
from homeassistant.components import conversation
@@ -129,6 +128,28 @@ def _convert_content(
)
)
if isinstance(content.native, ThinkingBlock):
messages[-1]["content"].append( # type: ignore[union-attr]
ThinkingBlockParam(
type="thinking",
thinking=content.thinking_content or "",
signature=content.native.signature,
)
)
elif isinstance(content.native, RedactedThinkingBlock):
redacted_thinking_block = RedactedThinkingBlockParam(
type="redacted_thinking",
data=content.native.data,
)
if isinstance(messages[-1]["content"], str):
messages[-1]["content"] = [
TextBlockParam(type="text", text=messages[-1]["content"]),
redacted_thinking_block,
]
else:
messages[-1]["content"].append( # type: ignore[attr-defined]
redacted_thinking_block
)
if content.content:
messages[-1]["content"].append( # type: ignore[union-attr]
TextBlockParam(type="text", text=content.content)
@@ -152,10 +173,9 @@ def _convert_content(
return messages
async def _transform_stream( # noqa: C901 - This is complex, but better to have it in one place
async def _transform_stream(
chat_log: conversation.ChatLog,
result: AsyncStream[MessageStreamEvent],
messages: list[MessageParam],
stream: AsyncStream[MessageStreamEvent],
) -> AsyncGenerator[conversation.AssistantContentDeltaDict]:
"""Transform the response stream into HA format.
@@ -186,31 +206,25 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
Each message could contain multiple blocks of the same type.
"""
if result is None:
if stream is None:
raise TypeError("Expected a stream of messages")
current_message: MessageParam | None = None
current_block: (
TextBlockParam
| ToolUseBlockParam
| ThinkingBlockParam
| RedactedThinkingBlockParam
| None
) = None
current_tool_block: ToolUseBlockParam | None = None
current_tool_args: str
input_usage: Usage | None = None
has_content = False
has_native = False
async for response in result:
async for response in stream:
LOGGER.debug("Received response: %s", response)
if isinstance(response, RawMessageStartEvent):
if response.message.role != "assistant":
raise ValueError("Unexpected message role")
current_message = MessageParam(role=response.message.role, content=[])
input_usage = response.message.usage
elif isinstance(response, RawContentBlockStartEvent):
if isinstance(response.content_block, ToolUseBlock):
current_block = ToolUseBlockParam(
current_tool_block = ToolUseBlockParam(
type="tool_use",
id=response.content_block.id,
name=response.content_block.name,
@@ -218,75 +232,64 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
)
current_tool_args = ""
elif isinstance(response.content_block, TextBlock):
current_block = TextBlockParam(
type="text", text=response.content_block.text
)
yield {"role": "assistant"}
if has_content:
yield {"role": "assistant"}
has_native = False
has_content = True
if response.content_block.text:
yield {"content": response.content_block.text}
elif isinstance(response.content_block, ThinkingBlock):
current_block = ThinkingBlockParam(
type="thinking",
thinking=response.content_block.thinking,
signature=response.content_block.signature,
)
if has_native:
yield {"role": "assistant"}
has_native = False
has_content = False
elif isinstance(response.content_block, RedactedThinkingBlock):
current_block = RedactedThinkingBlockParam(
type="redacted_thinking", data=response.content_block.data
)
LOGGER.debug(
"Some of Claudes internal reasoning has been automatically "
"encrypted for safety reasons. This doesnt affect the quality of "
"responses"
)
if has_native:
yield {"role": "assistant"}
has_native = False
has_content = False
yield {"native": response.content_block}
has_native = True
elif isinstance(response, RawContentBlockDeltaEvent):
if current_block is None:
raise ValueError("Unexpected delta without a block")
if isinstance(response.delta, InputJSONDelta):
current_tool_args += response.delta.partial_json
elif isinstance(response.delta, TextDelta):
text_block = cast(TextBlockParam, current_block)
text_block["text"] += response.delta.text
yield {"content": response.delta.text}
elif isinstance(response.delta, ThinkingDelta):
thinking_block = cast(ThinkingBlockParam, current_block)
thinking_block["thinking"] += response.delta.thinking
yield {"thinking_content": response.delta.thinking}
elif isinstance(response.delta, SignatureDelta):
thinking_block = cast(ThinkingBlockParam, current_block)
thinking_block["signature"] += response.delta.signature
yield {
"native": ThinkingBlock(
type="thinking",
thinking="",
signature=response.delta.signature,
)
}
has_native = True
elif isinstance(response, RawContentBlockStopEvent):
if current_block is None:
raise ValueError("Unexpected stop event without a current block")
if current_block["type"] == "tool_use":
# tool block
if current_tool_block is not None:
tool_args = json.loads(current_tool_args) if current_tool_args else {}
current_block["input"] = tool_args
current_tool_block["input"] = tool_args
yield {
"tool_calls": [
llm.ToolInput(
id=current_block["id"],
tool_name=current_block["name"],
id=current_tool_block["id"],
tool_name=current_tool_block["name"],
tool_args=tool_args,
)
]
}
elif current_block["type"] == "thinking":
# thinking block
LOGGER.debug("Thinking: %s", current_block["thinking"])
if current_message is None:
raise ValueError("Unexpected stop event without a current message")
current_message["content"].append(current_block) # type: ignore[union-attr]
current_block = None
current_tool_block = None
elif isinstance(response, RawMessageDeltaEvent):
if (usage := response.usage) is not None:
chat_log.async_trace(_create_token_stats(input_usage, usage))
if response.delta.stop_reason == "refusal":
raise HomeAssistantError("Potential policy violation detected")
elif isinstance(response, RawMessageStopEvent):
if current_message is not None:
messages.append(current_message)
current_message = None
def _create_token_stats(
@@ -351,48 +354,48 @@ class AnthropicBaseLLMEntity(Entity):
thinking_budget = options.get(CONF_THINKING_BUDGET, RECOMMENDED_THINKING_BUDGET)
model = options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL)
model_args = MessageCreateParamsStreaming(
model=model,
messages=messages,
max_tokens=options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS),
system=system.content,
stream=True,
)
if tools:
model_args["tools"] = tools
if (
model.startswith(tuple(THINKING_MODELS))
and thinking_budget >= MIN_THINKING_BUDGET
):
model_args["thinking"] = ThinkingConfigEnabledParam(
type="enabled", budget_tokens=thinking_budget
)
else:
model_args["thinking"] = ThinkingConfigDisabledParam(type="disabled")
model_args["temperature"] = options.get(
CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE
)
# To prevent infinite loops, we limit the number of iterations
for _iteration in range(MAX_TOOL_ITERATIONS):
model_args = {
"model": model,
"messages": messages,
"tools": tools or NOT_GIVEN,
"max_tokens": options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS),
"system": system.content,
"stream": True,
}
if (
model.startswith(tuple(THINKING_MODELS))
and thinking_budget >= MIN_THINKING_BUDGET
):
model_args["thinking"] = ThinkingConfigEnabledParam(
type="enabled", budget_tokens=thinking_budget
)
else:
model_args["thinking"] = ThinkingConfigDisabledParam(type="disabled")
model_args["temperature"] = options.get(
CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE
)
try:
stream = await client.messages.create(**model_args)
messages.extend(
_convert_content(
[
content
async for content in chat_log.async_add_delta_content_stream(
self.entity_id,
_transform_stream(chat_log, stream),
)
]
)
)
except anthropic.AnthropicError as err:
raise HomeAssistantError(
f"Sorry, I had a problem talking to Anthropic: {err}"
) from err
messages.extend(
_convert_content(
[
content
async for content in chat_log.async_add_delta_content_stream(
self.entity_id,
_transform_stream(chat_log, stream, messages),
)
if not isinstance(content, conversation.AssistantContent)
]
)
)
if not chat_log.unresponded_tool_results:
break

View File

@@ -11,7 +11,7 @@ import time
from typing import Any, Literal, final
from hassil import Intents, recognize
from hassil.expression import Expression, ListReference, Sequence
from hassil.expression import Expression, Group, ListReference
from hassil.intents import WildcardSlotList
from homeassistant.components import conversation, media_source, stt, tts
@@ -413,7 +413,7 @@ class AssistSatelliteEntity(entity.Entity):
for intent in intents.intents.values():
for intent_data in intent.data:
for sentence in intent_data.sentences:
_collect_list_references(sentence, wildcard_names)
_collect_list_references(sentence.expression, wildcard_names)
for wildcard_name in wildcard_names:
intents.slot_lists[wildcard_name] = WildcardSlotList(wildcard_name)
@@ -727,9 +727,9 @@ class AssistSatelliteEntity(entity.Entity):
def _collect_list_references(expression: Expression, list_names: set[str]) -> None:
"""Collect list reference names recursively."""
if isinstance(expression, Sequence):
seq: Sequence = expression
for item in seq.items:
if isinstance(expression, Group):
grp: Group = expression
for item in grp.items:
_collect_list_references(item, list_names)
elif isinstance(expression, ListReference):
# {list}

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/assist_satellite",
"integration_type": "entity",
"quality_scale": "internal",
"requirements": ["hassil==2.2.3"]
"requirements": ["hassil==3.1.0"]
}

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/blue_current",
"iot_class": "cloud_push",
"loggers": ["bluecurrent_api"],
"requirements": ["bluecurrent-api==1.2.4"]
"requirements": ["bluecurrent-api==1.3.1"]
}

View File

@@ -255,7 +255,7 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
)
entity_description: ClimateEntityDescription
_attr_current_humidity: int | None = None
_attr_current_humidity: float | None = None
_attr_current_temperature: float | None = None
_attr_fan_mode: str | None
_attr_fan_modes: list[str] | None

View File

@@ -7,7 +7,7 @@ from http import HTTPStatus
import logging
from typing import TYPE_CHECKING, Any
from hass_nabucasa import Cloud, cloud_api
from hass_nabucasa import Cloud
from hass_nabucasa.google_report_state import ErrorResponse
from homeassistant.components.binary_sensor import BinarySensorDeviceClass
@@ -377,7 +377,7 @@ class CloudGoogleConfig(AbstractConfig):
return HTTPStatus.OK
async with self._sync_entities_lock:
resp = await cloud_api.async_google_actions_request_sync(self._cloud)
resp = await self._cloud.google_report_state.request_sync()
return resp.status
async def async_connect_agent_user(self, agent_user_id: str) -> None:

View File

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

View File

@@ -12,7 +12,6 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers import entity_registry as er
from homeassistant.util import Throttle
from .const import (
@@ -30,9 +29,7 @@ from .const import (
API_RESOURCE_TYPE,
API_V3_ACCOUNT_ID,
API_V3_TYPE_VAULT,
CONF_CURRENCIES,
CONF_EXCHANGE_BASE,
CONF_EXCHANGE_RATES,
)
_LOGGER = logging.getLogger(__name__)
@@ -47,9 +44,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: CoinbaseConfigEntry) ->
"""Set up Coinbase from a config entry."""
instance = await hass.async_add_executor_job(create_and_update_instance, entry)
entry.async_on_unload(entry.add_update_listener(update_listener))
entry.runtime_data = instance
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@@ -83,29 +77,6 @@ def create_and_update_instance(entry: CoinbaseConfigEntry) -> CoinbaseData:
return instance
async def update_listener(
hass: HomeAssistant, config_entry: CoinbaseConfigEntry
) -> None:
"""Handle options update."""
await hass.config_entries.async_reload(config_entry.entry_id)
registry = er.async_get(hass)
entities = er.async_entries_for_config_entry(registry, config_entry.entry_id)
# Remove orphaned entities
for entity in entities:
currency = entity.unique_id.split("-")[-1]
if (
"xe" in entity.unique_id
and currency not in config_entry.options.get(CONF_EXCHANGE_RATES, [])
) or (
"wallet" in entity.unique_id
and currency not in config_entry.options.get(CONF_CURRENCIES, [])
):
registry.async_remove(entity.entity_id)
def get_accounts(client):
"""Handle paginated accounts."""
response = client.get_accounts()

View File

@@ -10,7 +10,11 @@ from coinbase.rest import RESTClient
from coinbase.rest.rest_base import HTTPError
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow
from homeassistant.config_entries import (
ConfigFlow,
ConfigFlowResult,
OptionsFlowWithReload,
)
from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
@@ -204,7 +208,7 @@ class CoinbaseConfigFlow(ConfigFlow, domain=DOMAIN):
return OptionsFlowHandler()
class OptionsFlowHandler(OptionsFlow):
class OptionsFlowHandler(OptionsFlowWithReload):
"""Handle a option flow for Coinbase."""
async def async_step_init(

View File

@@ -6,6 +6,7 @@ import logging
from homeassistant.components.sensor import SensorEntity, SensorStateClass
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -68,6 +69,22 @@ async def async_setup_entry(
CONF_EXCHANGE_PRECISION, CONF_EXCHANGE_PRECISION_DEFAULT
)
# Remove orphaned entities
registry = er.async_get(hass)
existing_entities = er.async_entries_for_config_entry(
registry, config_entry.entry_id
)
for entity in existing_entities:
currency = entity.unique_id.split("-")[-1]
if (
"xe" in entity.unique_id
and currency not in config_entry.options.get(CONF_EXCHANGE_RATES, [])
) or (
"wallet" in entity.unique_id
and currency not in config_entry.options.get(CONF_CURRENCIES, [])
):
registry.async_remove(entity.entity_id)
for currency in desired_currencies:
_LOGGER.debug(
"Attempting to set up %s account sensor",

View File

@@ -117,7 +117,7 @@ CONFIG_SCHEMA = vol.Schema(
{cv.string: vol.All(cv.ensure_list, [cv.string])}
)
}
)
),
},
extra=vol.ALLOW_EXTRA,
)
@@ -268,8 +268,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
entity_component = EntityComponent[ConversationEntity](_LOGGER, DOMAIN, hass)
hass.data[DATA_COMPONENT] = entity_component
agent_config = config.get(DOMAIN, {})
await async_setup_default_agent(
hass, entity_component, config.get(DOMAIN, {}).get("intents", {})
hass, entity_component, config_intents=agent_config.get("intents", {})
)
async def handle_process(service: ServiceCall) -> ServiceResponse:

View File

@@ -14,14 +14,19 @@ import re
import time
from typing import IO, Any, cast
from hassil.expression import Expression, ListReference, Sequence, TextChunk
from hassil.expression import Expression, Group, ListReference, TextChunk
from hassil.fuzzy import FuzzyNgramMatcher, SlotCombinationInfo
from hassil.intents import (
Intent,
IntentData,
Intents,
SlotList,
TextSlotList,
TextSlotValue,
WildcardSlotList,
)
from hassil.models import MatchEntity
from hassil.ngram import Sqlite3NgramModel
from hassil.recognize import (
MISSING_ENTITY,
RecognizeResult,
@@ -31,7 +36,15 @@ from hassil.recognize import (
from hassil.string_matcher import UnmatchedRangeEntity, UnmatchedTextEntity
from hassil.trie import Trie
from hassil.util import merge_dict
from home_assistant_intents import ErrorKey, get_intents, get_languages
from home_assistant_intents import (
ErrorKey,
FuzzyConfig,
FuzzyLanguageResponses,
get_fuzzy_config,
get_fuzzy_language,
get_intents,
get_languages,
)
import yaml
from homeassistant import core
@@ -76,6 +89,7 @@ TRIGGER_CALLBACK_TYPE = Callable[
]
METADATA_CUSTOM_SENTENCE = "hass_custom_sentence"
METADATA_CUSTOM_FILE = "hass_custom_file"
METADATA_FUZZY_MATCH = "hass_fuzzy_match"
ERROR_SENTINEL = object()
@@ -94,6 +108,8 @@ class LanguageIntents:
intent_responses: dict[str, Any]
error_responses: dict[str, Any]
language_variant: str | None
fuzzy_matcher: FuzzyNgramMatcher | None = None
fuzzy_responses: FuzzyLanguageResponses | None = None
@dataclass(slots=True)
@@ -119,10 +135,13 @@ class IntentMatchingStage(Enum):
EXPOSED_ENTITIES_ONLY = auto()
"""Match against exposed entities only."""
FUZZY = auto()
"""Use fuzzy matching to guess intent."""
UNEXPOSED_ENTITIES = auto()
"""Match against unexposed entities in Home Assistant."""
FUZZY = auto()
UNKNOWN_NAMES = auto()
"""Capture names that are not known to Home Assistant."""
@@ -241,6 +260,10 @@ class DefaultAgent(ConversationEntity):
# LRU cache to avoid unnecessary intent matching
self._intent_cache = IntentCache(capacity=128)
# Shared configuration for fuzzy matching
self.fuzzy_matching = True
self._fuzzy_config: FuzzyConfig | None = None
@property
def supported_languages(self) -> list[str]:
"""Return a list of supported languages."""
@@ -299,7 +322,7 @@ class DefaultAgent(ConversationEntity):
_LOGGER.warning("No intents were loaded for language: %s", language)
return None
slot_lists = self._make_slot_lists()
slot_lists = await self._make_slot_lists()
intent_context = self._make_intent_context(user_input)
if self._exposed_names_trie is not None:
@@ -556,6 +579,36 @@ class DefaultAgent(ConversationEntity):
# Don't try matching against all entities or doing a fuzzy match
return None
# Use fuzzy matching
skip_fuzzy_match = False
if cache_value is not None:
if (cache_value.result is not None) and (
cache_value.stage == IntentMatchingStage.FUZZY
):
_LOGGER.debug("Got cached result for fuzzy match")
return cache_value.result
# Continue with matching, but we know we won't succeed for fuzzy
# match.
skip_fuzzy_match = True
if (not skip_fuzzy_match) and self.fuzzy_matching:
start_time = time.monotonic()
fuzzy_result = self._recognize_fuzzy(lang_intents, user_input)
# Update cache
self._intent_cache.put(
cache_key,
IntentCacheValue(result=fuzzy_result, stage=IntentMatchingStage.FUZZY),
)
_LOGGER.debug(
"Did fuzzy match in %s second(s)", time.monotonic() - start_time
)
if fuzzy_result is not None:
return fuzzy_result
# Try again with all entities (including unexposed)
skip_unexposed_entities_match = False
if cache_value is not None:
@@ -601,102 +654,160 @@ class DefaultAgent(ConversationEntity):
# This should fail the intent handling phase (async_match_targets).
return strict_result
# Try again with missing entities enabled
skip_fuzzy_match = False
# Check unknown names
skip_unknown_names = False
if cache_value is not None:
if (cache_value.result is not None) and (
cache_value.stage == IntentMatchingStage.FUZZY
cache_value.stage == IntentMatchingStage.UNKNOWN_NAMES
):
_LOGGER.debug("Got cached result for fuzzy match")
_LOGGER.debug("Got cached result for unknown names")
return cache_value.result
# We know we won't succeed for fuzzy matching.
skip_fuzzy_match = True
skip_unknown_names = True
maybe_result: RecognizeResult | None = None
if not skip_fuzzy_match:
if not skip_unknown_names:
start_time = time.monotonic()
best_num_matched_entities = 0
best_num_unmatched_entities = 0
best_num_unmatched_ranges = 0
for result in recognize_all(
user_input.text,
lang_intents.intents,
slot_lists=slot_lists,
intent_context=intent_context,
allow_unmatched_entities=True,
):
if result.text_chunks_matched < 1:
# Skip results that don't match any literal text
continue
# Don't count missing entities that couldn't be filled from context
num_matched_entities = 0
for matched_entity in result.entities_list:
if matched_entity.name not in result.unmatched_entities:
num_matched_entities += 1
num_unmatched_entities = 0
num_unmatched_ranges = 0
for unmatched_entity in result.unmatched_entities_list:
if isinstance(unmatched_entity, UnmatchedTextEntity):
if unmatched_entity.text != MISSING_ENTITY:
num_unmatched_entities += 1
elif isinstance(unmatched_entity, UnmatchedRangeEntity):
num_unmatched_ranges += 1
num_unmatched_entities += 1
else:
num_unmatched_entities += 1
if (
(maybe_result is None) # first result
or (
# More literal text matched
result.text_chunks_matched > maybe_result.text_chunks_matched
)
or (
# More entities matched
num_matched_entities > best_num_matched_entities
)
or (
# Fewer unmatched entities
(num_matched_entities == best_num_matched_entities)
and (num_unmatched_entities < best_num_unmatched_entities)
)
or (
# Prefer unmatched ranges
(num_matched_entities == best_num_matched_entities)
and (num_unmatched_entities == best_num_unmatched_entities)
and (num_unmatched_ranges > best_num_unmatched_ranges)
)
or (
# Prefer match failures with entities
(result.text_chunks_matched == maybe_result.text_chunks_matched)
and (num_unmatched_entities == best_num_unmatched_entities)
and (num_unmatched_ranges == best_num_unmatched_ranges)
and (
("name" in result.entities)
or ("name" in result.unmatched_entities)
)
)
):
maybe_result = result
best_num_matched_entities = num_matched_entities
best_num_unmatched_entities = num_unmatched_entities
best_num_unmatched_ranges = num_unmatched_ranges
maybe_result = self._recognize_unknown_names(
lang_intents, user_input, slot_lists, intent_context
)
# Update cache
self._intent_cache.put(
cache_key,
IntentCacheValue(result=maybe_result, stage=IntentMatchingStage.FUZZY),
IntentCacheValue(
result=maybe_result, stage=IntentMatchingStage.UNKNOWN_NAMES
),
)
_LOGGER.debug(
"Did fuzzy match in %s second(s)", time.monotonic() - start_time
"Did unknown names match in %s second(s)", time.monotonic() - start_time
)
return maybe_result
def _recognize_fuzzy(
self, lang_intents: LanguageIntents, user_input: ConversationInput
) -> RecognizeResult | None:
"""Return fuzzy recognition from hassil."""
if lang_intents.fuzzy_matcher is None:
return None
fuzzy_result = lang_intents.fuzzy_matcher.match(user_input.text)
if fuzzy_result is None:
return None
response = "default"
if lang_intents.fuzzy_responses:
domain = "" # no domain
if "name" in fuzzy_result.slots:
domain = fuzzy_result.name_domain
elif "domain" in fuzzy_result.slots:
domain = fuzzy_result.slots["domain"].value
slot_combo = tuple(sorted(fuzzy_result.slots))
if (
intent_responses := lang_intents.fuzzy_responses.get(
fuzzy_result.intent_name
)
) and (combo_responses := intent_responses.get(slot_combo)):
response = combo_responses.get(domain, response)
entities = [
MatchEntity(name=slot_name, value=slot_value.value, text=slot_value.text)
for slot_name, slot_value in fuzzy_result.slots.items()
]
return RecognizeResult(
intent=Intent(name=fuzzy_result.intent_name),
intent_data=IntentData(sentence_texts=[]),
intent_metadata={METADATA_FUZZY_MATCH: True},
entities={entity.name: entity for entity in entities},
entities_list=entities,
response=response,
)
def _recognize_unknown_names(
self,
lang_intents: LanguageIntents,
user_input: ConversationInput,
slot_lists: dict[str, SlotList],
intent_context: dict[str, Any] | None,
) -> RecognizeResult | None:
"""Return result with unknown names for an error message."""
maybe_result: RecognizeResult | None = None
best_num_matched_entities = 0
best_num_unmatched_entities = 0
best_num_unmatched_ranges = 0
for result in recognize_all(
user_input.text,
lang_intents.intents,
slot_lists=slot_lists,
intent_context=intent_context,
allow_unmatched_entities=True,
):
if result.text_chunks_matched < 1:
# Skip results that don't match any literal text
continue
# Don't count missing entities that couldn't be filled from context
num_matched_entities = 0
for matched_entity in result.entities_list:
if matched_entity.name not in result.unmatched_entities:
num_matched_entities += 1
num_unmatched_entities = 0
num_unmatched_ranges = 0
for unmatched_entity in result.unmatched_entities_list:
if isinstance(unmatched_entity, UnmatchedTextEntity):
if unmatched_entity.text != MISSING_ENTITY:
num_unmatched_entities += 1
elif isinstance(unmatched_entity, UnmatchedRangeEntity):
num_unmatched_ranges += 1
num_unmatched_entities += 1
else:
num_unmatched_entities += 1
if (
(maybe_result is None) # first result
or (
# More literal text matched
result.text_chunks_matched > maybe_result.text_chunks_matched
)
or (
# More entities matched
num_matched_entities > best_num_matched_entities
)
or (
# Fewer unmatched entities
(num_matched_entities == best_num_matched_entities)
and (num_unmatched_entities < best_num_unmatched_entities)
)
or (
# Prefer unmatched ranges
(num_matched_entities == best_num_matched_entities)
and (num_unmatched_entities == best_num_unmatched_entities)
and (num_unmatched_ranges > best_num_unmatched_ranges)
)
or (
# Prefer match failures with entities
(result.text_chunks_matched == maybe_result.text_chunks_matched)
and (num_unmatched_entities == best_num_unmatched_entities)
and (num_unmatched_ranges == best_num_unmatched_ranges)
and (
("name" in result.entities)
or ("name" in result.unmatched_entities)
)
)
):
maybe_result = result
best_num_matched_entities = num_matched_entities
best_num_unmatched_entities = num_unmatched_entities
best_num_unmatched_ranges = num_unmatched_ranges
return maybe_result
def _get_unexposed_entity_names(self, text: str) -> TextSlotList:
"""Get filtered slot list with unexposed entity names in Home Assistant."""
if self._unexposed_names_trie is None:
@@ -851,7 +962,7 @@ class DefaultAgent(ConversationEntity):
if lang_intents is None:
return
self._make_slot_lists()
await self._make_slot_lists()
async def async_get_or_load_intents(self, language: str) -> LanguageIntents | None:
"""Load all intents of a language with lock."""
@@ -1002,12 +1113,85 @@ class DefaultAgent(ConversationEntity):
intent_responses = responses_dict.get("intents", {})
error_responses = responses_dict.get("errors", {})
if not self.fuzzy_matching:
_LOGGER.debug("Fuzzy matching is disabled")
return LanguageIntents(
intents,
intents_dict,
intent_responses,
error_responses,
language_variant,
)
# Load fuzzy
fuzzy_info = get_fuzzy_language(language_variant, json_load=json_load)
if fuzzy_info is None:
_LOGGER.debug(
"Fuzzy matching not available for language: %s", language_variant
)
return LanguageIntents(
intents,
intents_dict,
intent_responses,
error_responses,
language_variant,
)
if self._fuzzy_config is None:
# Load shared config
self._fuzzy_config = get_fuzzy_config(json_load=json_load)
_LOGGER.debug("Loaded shared fuzzy matching config")
assert self._fuzzy_config is not None
fuzzy_matcher: FuzzyNgramMatcher | None = None
fuzzy_responses: FuzzyLanguageResponses | None = None
start_time = time.monotonic()
fuzzy_responses = fuzzy_info.responses
fuzzy_matcher = FuzzyNgramMatcher(
intents=intents,
intent_models={
intent_name: Sqlite3NgramModel(
order=fuzzy_model.order,
words={
word: str(word_id)
for word, word_id in fuzzy_model.words.items()
},
database_path=fuzzy_model.database_path,
)
for intent_name, fuzzy_model in fuzzy_info.ngram_models.items()
},
intent_slot_list_names=self._fuzzy_config.slot_list_names,
slot_combinations={
intent_name: {
combo_key: [
SlotCombinationInfo(
name_domains=(set(name_domains) if name_domains else None)
)
]
for combo_key, name_domains in intent_combos.items()
}
for intent_name, intent_combos in self._fuzzy_config.slot_combinations.items()
},
domain_keywords=fuzzy_info.domain_keywords,
stop_words=fuzzy_info.stop_words,
)
_LOGGER.debug(
"Loaded fuzzy matcher in %s second(s): language=%s, intents=%s",
time.monotonic() - start_time,
language_variant,
sorted(fuzzy_matcher.intent_models.keys()),
)
return LanguageIntents(
intents,
intents_dict,
intent_responses,
error_responses,
language_variant,
fuzzy_matcher=fuzzy_matcher,
fuzzy_responses=fuzzy_responses,
)
@core.callback
@@ -1027,8 +1211,7 @@ class DefaultAgent(ConversationEntity):
# Slot lists have changed, so we must clear the cache
self._intent_cache.clear()
@core.callback
def _make_slot_lists(self) -> dict[str, SlotList]:
async def _make_slot_lists(self) -> dict[str, SlotList]:
"""Create slot lists with areas and entity names/aliases."""
if self._slot_lists is not None:
return self._slot_lists
@@ -1089,6 +1272,10 @@ class DefaultAgent(ConversationEntity):
"floor": TextSlotList.from_tuples(floor_names, allow_template=False),
}
# Reload fuzzy matchers with new slot lists
if self.fuzzy_matching:
await self.hass.async_add_executor_job(self._load_fuzzy_matchers)
self._listen_clear_slot_list()
_LOGGER.debug(
@@ -1098,6 +1285,25 @@ class DefaultAgent(ConversationEntity):
return self._slot_lists
def _load_fuzzy_matchers(self) -> None:
"""Reload fuzzy matchers for all loaded languages."""
for lang_intents in self._lang_intents.values():
if (not isinstance(lang_intents, LanguageIntents)) or (
lang_intents.fuzzy_matcher is None
):
continue
lang_matcher = lang_intents.fuzzy_matcher
lang_intents.fuzzy_matcher = FuzzyNgramMatcher(
intents=lang_matcher.intents,
intent_models=lang_matcher.intent_models,
intent_slot_list_names=lang_matcher.intent_slot_list_names,
slot_combinations=lang_matcher.slot_combinations,
domain_keywords=lang_matcher.domain_keywords,
stop_words=lang_matcher.stop_words,
slot_lists=self._slot_lists,
)
def _make_intent_context(
self, user_input: ConversationInput
) -> dict[str, Any] | None:
@@ -1183,7 +1389,7 @@ class DefaultAgent(ConversationEntity):
for trigger_intent in trigger_intents.intents.values():
for intent_data in trigger_intent.data:
for sentence in intent_data.sentences:
_collect_list_references(sentence, wildcard_names)
_collect_list_references(sentence.expression, wildcard_names)
for wildcard_name in wildcard_names:
trigger_intents.slot_lists[wildcard_name] = WildcardSlotList(wildcard_name)
@@ -1520,11 +1726,9 @@ def _get_match_error_response(
def _collect_list_references(expression: Expression, list_names: set[str]) -> None:
"""Collect list reference names recursively."""
if isinstance(expression, Sequence):
seq: Sequence = expression
for item in seq.items:
if isinstance(expression, Group):
for item in expression.items:
_collect_list_references(item, list_names)
elif isinstance(expression, ListReference):
# {list}
list_ref: ListReference = expression
list_names.add(list_ref.slot_name)
list_names.add(expression.slot_name)

View File

@@ -26,7 +26,11 @@ from .agent_manager import (
get_agent_manager,
)
from .const import DATA_COMPONENT, DATA_DEFAULT_ENTITY
from .default_agent import METADATA_CUSTOM_FILE, METADATA_CUSTOM_SENTENCE
from .default_agent import (
METADATA_CUSTOM_FILE,
METADATA_CUSTOM_SENTENCE,
METADATA_FUZZY_MATCH,
)
from .entity import ConversationEntity
from .models import ConversationInput
@@ -240,6 +244,8 @@ async def websocket_hass_agent_debug(
"sentence_template": "",
# When match is incomplete, this will contain the best slot guesses
"unmatched_slots": _get_unmatched_slots(intent_result),
# True if match was not exact
"fuzzy_match": False,
}
if successful_match:
@@ -251,16 +257,19 @@ async def websocket_hass_agent_debug(
if intent_result.intent_sentence is not None:
result_dict["sentence_template"] = intent_result.intent_sentence.text
# Inspect metadata to determine if this matched a custom sentence
if intent_result.intent_metadata and intent_result.intent_metadata.get(
METADATA_CUSTOM_SENTENCE
):
result_dict["source"] = "custom"
result_dict["file"] = intent_result.intent_metadata.get(
METADATA_CUSTOM_FILE
if intent_result.intent_metadata:
# Inspect metadata to determine if this matched a custom sentence
if intent_result.intent_metadata.get(METADATA_CUSTOM_SENTENCE):
result_dict["source"] = "custom"
result_dict["file"] = intent_result.intent_metadata.get(
METADATA_CUSTOM_FILE
)
else:
result_dict["source"] = "builtin"
result_dict["fuzzy_match"] = intent_result.intent_metadata.get(
METADATA_FUZZY_MATCH, False
)
else:
result_dict["source"] = "builtin"
result_dicts.append(result_dict)

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/conversation",
"integration_type": "system",
"quality_scale": "internal",
"requirements": ["hassil==2.2.3", "home-assistant-intents==2025.7.30"]
"requirements": ["hassil==3.1.0", "home-assistant-intents==2025.7.30"]
}

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
from datetime import datetime
from typing import Any
from homeassistant.components import media_source
from homeassistant.components.media_player import (
BrowseMedia,
MediaClass,
@@ -396,6 +397,15 @@ class DemoBrowsePlayer(AbstractDemoPlayer):
_attr_supported_features = BROWSE_PLAYER_SUPPORT
async def async_browse_media(
self,
media_content_type: MediaType | str | None = None,
media_content_id: str | None = None,
) -> BrowseMedia:
"""Implement the websocket media browsing helper."""
return await media_source.async_browse_media(self.hass, media_content_id)
class DemoGroupPlayer(AbstractDemoPlayer):
"""A Demo media player that supports grouping."""

View File

@@ -30,6 +30,7 @@ class Dremel3DPrinterEntity(CoordinatorEntity[Dremel3DPrinterDataUpdateCoordinat
"""Return device information about this Dremel printer."""
return DeviceInfo(
identifiers={(DOMAIN, self._api.get_serial_number())},
serial_number=self._api.get_serial_number(),
manufacturer=self._api.get_manufacturer(),
model=self._api.get_model(),
name=self._api.get_title(),

View File

@@ -93,6 +93,7 @@ class EmonitorPowerSensor(CoordinatorEntity[EmonitorStatus], SensorEntity):
manufacturer="Powerhouse Dynamics, Inc.",
name=device_name,
sw_version=emonitor_status.hardware.firmware_version,
serial_number=emonitor_status.hardware.serial_number,
)
self._attr_extra_state_attributes = {"channel": channel_number}
self._attr_native_value = self._paired_attr(self.entity_description.key)

View File

@@ -6,7 +6,7 @@ from collections.abc import Callable
from dataclasses import dataclass
from operator import attrgetter
from pyenphase import EnvoyEncharge, EnvoyEnpower
from pyenphase import EnvoyC6CC, EnvoyCollar, EnvoyEncharge, EnvoyEnpower
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
@@ -72,6 +72,42 @@ ENPOWER_SENSORS = (
)
@dataclass(frozen=True, kw_only=True)
class EnvoyCollarBinarySensorEntityDescription(BinarySensorEntityDescription):
"""Describes an Envoy IQ Meter Collar binary sensor entity."""
value_fn: Callable[[EnvoyCollar], bool]
COLLAR_SENSORS = (
EnvoyCollarBinarySensorEntityDescription(
key="communicating",
translation_key="communicating",
device_class=BinarySensorDeviceClass.CONNECTIVITY,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=attrgetter("communicating"),
),
)
@dataclass(frozen=True, kw_only=True)
class EnvoyC6CCBinarySensorEntityDescription(BinarySensorEntityDescription):
"""Describes an C6 Combiner controller binary sensor entity."""
value_fn: Callable[[EnvoyC6CC], bool]
C6CC_SENSORS = (
EnvoyC6CCBinarySensorEntityDescription(
key="communicating",
translation_key="communicating",
device_class=BinarySensorDeviceClass.CONNECTIVITY,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=attrgetter("communicating"),
),
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: EnphaseConfigEntry,
@@ -95,6 +131,18 @@ async def async_setup_entry(
for description in ENPOWER_SENSORS
)
if envoy_data.collar:
entities.extend(
EnvoyCollarBinarySensorEntity(coordinator, description)
for description in COLLAR_SENSORS
)
if envoy_data.c6cc:
entities.extend(
EnvoyC6CCBinarySensorEntity(coordinator, description)
for description in C6CC_SENSORS
)
async_add_entities(entities)
@@ -168,3 +216,69 @@ class EnvoyEnpowerBinarySensorEntity(EnvoyBaseBinarySensorEntity):
enpower = self.data.enpower
assert enpower is not None
return self.entity_description.value_fn(enpower)
class EnvoyCollarBinarySensorEntity(EnvoyBaseBinarySensorEntity):
"""Defines an IQ Meter Collar binary_sensor entity."""
entity_description: EnvoyCollarBinarySensorEntityDescription
def __init__(
self,
coordinator: EnphaseUpdateCoordinator,
description: EnvoyCollarBinarySensorEntityDescription,
) -> None:
"""Init the Collar base entity."""
super().__init__(coordinator, description)
collar_data = self.data.collar
assert collar_data is not None
self._attr_unique_id = f"{collar_data.serial_number}_{description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, collar_data.serial_number)},
manufacturer="Enphase",
model="IQ Meter Collar",
name=f"Collar {collar_data.serial_number}",
sw_version=str(collar_data.firmware_version),
via_device=(DOMAIN, self.envoy_serial_num),
serial_number=collar_data.serial_number,
)
@property
def is_on(self) -> bool:
"""Return the state of the Collar binary_sensor."""
collar_data = self.data.collar
assert collar_data is not None
return self.entity_description.value_fn(collar_data)
class EnvoyC6CCBinarySensorEntity(EnvoyBaseBinarySensorEntity):
"""Defines an C6 Combiner binary_sensor entity."""
entity_description: EnvoyC6CCBinarySensorEntityDescription
def __init__(
self,
coordinator: EnphaseUpdateCoordinator,
description: EnvoyC6CCBinarySensorEntityDescription,
) -> None:
"""Init the C6 Combiner base entity."""
super().__init__(coordinator, description)
c6cc_data = self.data.c6cc
assert c6cc_data is not None
self._attr_unique_id = f"{c6cc_data.serial_number}_{description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, c6cc_data.serial_number)},
manufacturer="Enphase",
model="C6 COMBINER CONTROLLER",
name=f"C6 Combiner {c6cc_data.serial_number}",
sw_version=str(c6cc_data.firmware_version),
via_device=(DOMAIN, self.envoy_serial_num),
serial_number=c6cc_data.serial_number,
)
@property
def is_on(self) -> bool:
"""Return the state of the C6 Combiner binary_sensor."""
c6cc_data = self.data.c6cc
assert c6cc_data is not None
return self.entity_description.value_fn(c6cc_data)

View File

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

View File

@@ -12,6 +12,8 @@ from typing import TYPE_CHECKING
from pyenphase import (
EnvoyACBPower,
EnvoyBatteryAggregate,
EnvoyC6CC,
EnvoyCollar,
EnvoyEncharge,
EnvoyEnchargeAggregate,
EnvoyEnchargePower,
@@ -790,6 +792,58 @@ ENPOWER_SENSORS = (
)
@dataclass(frozen=True, kw_only=True)
class EnvoyCollarSensorEntityDescription(SensorEntityDescription):
"""Describes an Envoy Collar sensor entity."""
value_fn: Callable[[EnvoyCollar], datetime.datetime | int | float | str]
COLLAR_SENSORS = (
EnvoyCollarSensorEntityDescription(
key="temperature",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
value_fn=attrgetter("temperature"),
),
EnvoyCollarSensorEntityDescription(
key=LAST_REPORTED_KEY,
translation_key=LAST_REPORTED_KEY,
native_unit_of_measurement=None,
device_class=SensorDeviceClass.TIMESTAMP,
value_fn=lambda collar: dt_util.utc_from_timestamp(collar.last_report_date),
),
EnvoyCollarSensorEntityDescription(
key="grid_state",
translation_key="grid_status",
value_fn=lambda collar: collar.grid_state,
),
EnvoyCollarSensorEntityDescription(
key="mid_state",
translation_key="mid_state",
value_fn=lambda collar: collar.mid_state,
),
)
@dataclass(frozen=True, kw_only=True)
class EnvoyC6CCSensorEntityDescription(SensorEntityDescription):
"""Describes an Envoy C6 Combiner controller sensor entity."""
value_fn: Callable[[EnvoyC6CC], datetime.datetime]
C6CC_SENSORS = (
EnvoyC6CCSensorEntityDescription(
key=LAST_REPORTED_KEY,
translation_key=LAST_REPORTED_KEY,
native_unit_of_measurement=None,
device_class=SensorDeviceClass.TIMESTAMP,
value_fn=lambda c6cc: dt_util.utc_from_timestamp(c6cc.last_report_date),
),
)
@dataclass(frozen=True)
class EnvoyEnchargeAggregateRequiredKeysMixin:
"""Mixin for required keys."""
@@ -1050,6 +1104,15 @@ async def async_setup_entry(
AggregateBatteryEntity(coordinator, description)
for description in AGGREGATE_BATTERY_SENSORS
)
if envoy_data.collar:
entities.extend(
EnvoyCollarEntity(coordinator, description)
for description in COLLAR_SENSORS
)
if envoy_data.c6cc:
entities.extend(
EnvoyC6CCEntity(coordinator, description) for description in C6CC_SENSORS
)
async_add_entities(entities)
@@ -1488,3 +1551,70 @@ class AggregateBatteryEntity(EnvoySystemSensorEntity):
battery_aggregate = self.data.battery_aggregate
assert battery_aggregate is not None
return self.entity_description.value_fn(battery_aggregate)
class EnvoyCollarEntity(EnvoySensorBaseEntity):
"""Envoy Collar sensor entity."""
entity_description: EnvoyCollarSensorEntityDescription
def __init__(
self,
coordinator: EnphaseUpdateCoordinator,
description: EnvoyCollarSensorEntityDescription,
) -> None:
"""Initialize Collar entity."""
super().__init__(coordinator, description)
collar_data = self.data.collar
assert collar_data is not None
self._serial_number = collar_data.serial_number
self._attr_unique_id = f"{collar_data.serial_number}_{description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, collar_data.serial_number)},
manufacturer="Enphase",
model="IQ Meter Collar",
name=f"Collar {collar_data.serial_number}",
sw_version=str(collar_data.firmware_version),
via_device=(DOMAIN, self.envoy_serial_num),
serial_number=collar_data.serial_number,
)
@property
def native_value(self) -> datetime.datetime | int | float | str:
"""Return the state of the collar sensors."""
collar_data = self.data.collar
assert collar_data is not None
return self.entity_description.value_fn(collar_data)
class EnvoyC6CCEntity(EnvoySensorBaseEntity):
"""Envoy C6CC sensor entity."""
entity_description: EnvoyC6CCSensorEntityDescription
def __init__(
self,
coordinator: EnphaseUpdateCoordinator,
description: EnvoyC6CCSensorEntityDescription,
) -> None:
"""Initialize Encharge entity."""
super().__init__(coordinator, description)
c6cc_data = self.data.c6cc
assert c6cc_data is not None
self._attr_unique_id = f"{c6cc_data.serial_number}_{description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, c6cc_data.serial_number)},
manufacturer="Enphase",
model="C6 COMBINER CONTROLLER",
name=f"C6 Combiner {c6cc_data.serial_number}",
sw_version=str(c6cc_data.firmware_version),
via_device=(DOMAIN, self.envoy_serial_num),
serial_number=c6cc_data.serial_number,
)
@property
def native_value(self) -> datetime.datetime:
"""Return the state of the c6cc inventory sensors."""
c6cc_data = self.data.c6cc
assert c6cc_data is not None
return self.entity_description.value_fn(c6cc_data)

View File

@@ -407,6 +407,12 @@
},
"last_report_duration": {
"name": "Last report duration"
},
"grid_status": {
"name": "[%key:component::enphase_envoy::entity::binary_sensor::grid_status::name%]"
},
"mid_state": {
"name": "MID state"
}
},
"switch": {

View File

@@ -10,6 +10,7 @@ from urllib.parse import urlparse
from aioesphomeapi import (
EntityInfo,
MediaPlayerCommand,
MediaPlayerEntityFeature as EspMediaPlayerEntityFeature,
MediaPlayerEntityState,
MediaPlayerFormatPurpose,
MediaPlayerInfo,
@@ -50,9 +51,36 @@ _STATES: EsphomeEnumMapper[EspMediaPlayerState, MediaPlayerState] = EsphomeEnumM
EspMediaPlayerState.IDLE: MediaPlayerState.IDLE,
EspMediaPlayerState.PLAYING: MediaPlayerState.PLAYING,
EspMediaPlayerState.PAUSED: MediaPlayerState.PAUSED,
EspMediaPlayerState.OFF: MediaPlayerState.OFF,
EspMediaPlayerState.ON: MediaPlayerState.ON,
}
)
_FEATURES = {
EspMediaPlayerEntityFeature.PAUSE: MediaPlayerEntityFeature.PAUSE,
EspMediaPlayerEntityFeature.SEEK: MediaPlayerEntityFeature.SEEK,
EspMediaPlayerEntityFeature.VOLUME_SET: MediaPlayerEntityFeature.VOLUME_SET,
EspMediaPlayerEntityFeature.VOLUME_MUTE: MediaPlayerEntityFeature.VOLUME_MUTE,
EspMediaPlayerEntityFeature.PREVIOUS_TRACK: MediaPlayerEntityFeature.PREVIOUS_TRACK,
EspMediaPlayerEntityFeature.NEXT_TRACK: MediaPlayerEntityFeature.NEXT_TRACK,
EspMediaPlayerEntityFeature.TURN_ON: MediaPlayerEntityFeature.TURN_ON,
EspMediaPlayerEntityFeature.TURN_OFF: MediaPlayerEntityFeature.TURN_OFF,
EspMediaPlayerEntityFeature.PLAY_MEDIA: MediaPlayerEntityFeature.PLAY_MEDIA,
EspMediaPlayerEntityFeature.VOLUME_STEP: MediaPlayerEntityFeature.VOLUME_STEP,
EspMediaPlayerEntityFeature.SELECT_SOURCE: MediaPlayerEntityFeature.SELECT_SOURCE,
EspMediaPlayerEntityFeature.STOP: MediaPlayerEntityFeature.STOP,
EspMediaPlayerEntityFeature.CLEAR_PLAYLIST: MediaPlayerEntityFeature.CLEAR_PLAYLIST,
EspMediaPlayerEntityFeature.PLAY: MediaPlayerEntityFeature.PLAY,
EspMediaPlayerEntityFeature.SHUFFLE_SET: MediaPlayerEntityFeature.SHUFFLE_SET,
EspMediaPlayerEntityFeature.SELECT_SOUND_MODE: MediaPlayerEntityFeature.SELECT_SOUND_MODE,
EspMediaPlayerEntityFeature.BROWSE_MEDIA: MediaPlayerEntityFeature.BROWSE_MEDIA,
EspMediaPlayerEntityFeature.REPEAT_SET: MediaPlayerEntityFeature.REPEAT_SET,
EspMediaPlayerEntityFeature.GROUPING: MediaPlayerEntityFeature.GROUPING,
EspMediaPlayerEntityFeature.MEDIA_ANNOUNCE: MediaPlayerEntityFeature.MEDIA_ANNOUNCE,
EspMediaPlayerEntityFeature.MEDIA_ENQUEUE: MediaPlayerEntityFeature.MEDIA_ENQUEUE,
EspMediaPlayerEntityFeature.SEARCH_MEDIA: MediaPlayerEntityFeature.SEARCH_MEDIA,
}
ATTR_BYPASS_PROXY = "bypass_proxy"
@@ -67,16 +95,12 @@ class EsphomeMediaPlayer(
def _on_static_info_update(self, static_info: EntityInfo) -> None:
"""Set attrs from static info."""
super()._on_static_info_update(static_info)
flags = (
MediaPlayerEntityFeature.PLAY_MEDIA
| MediaPlayerEntityFeature.BROWSE_MEDIA
| MediaPlayerEntityFeature.STOP
| MediaPlayerEntityFeature.VOLUME_SET
| MediaPlayerEntityFeature.VOLUME_MUTE
| MediaPlayerEntityFeature.MEDIA_ANNOUNCE
esp_flags = EspMediaPlayerEntityFeature(
self._static_info.feature_flags_compat(self._api_version)
)
if self._static_info.supports_pause:
flags |= MediaPlayerEntityFeature.PAUSE | MediaPlayerEntityFeature.PLAY
flags = MediaPlayerEntityFeature(0)
for espflag in esp_flags:
flags |= _FEATURES[espflag]
self._attr_supported_features = flags
self._entry_data.media_player_formats[self.unique_id] = cast(
MediaPlayerInfo, static_info
@@ -257,6 +281,24 @@ class EsphomeMediaPlayer(
device_id=self._static_info.device_id,
)
@convert_api_error_ha_error
async def async_turn_on(self) -> None:
"""Send turn on command."""
self._client.media_player_command(
self._key,
command=MediaPlayerCommand.TURN_ON,
device_id=self._static_info.device_id,
)
@convert_api_error_ha_error
async def async_turn_off(self) -> None:
"""Send turn off command."""
self._client.media_player_command(
self._key,
command=MediaPlayerCommand.TURN_OFF,
device_id=self._static_info.device_id,
)
def _is_url(url: str) -> bool:
"""Validate the URL can be parsed and at least has scheme + netloc."""

View File

@@ -0,0 +1,31 @@
"""Intents for the fan integration."""
import voluptuous as vol
from homeassistant.core import HomeAssistant
from homeassistant.helpers import intent
from . import ATTR_PERCENTAGE, DOMAIN, SERVICE_TURN_ON
INTENT_FAN_SET_SPEED = "HassFanSetSpeed"
async def async_setup_intents(hass: HomeAssistant) -> None:
"""Set up the fan intents."""
intent.async_register(
hass,
intent.ServiceIntentHandler(
INTENT_FAN_SET_SPEED,
DOMAIN,
SERVICE_TURN_ON,
description="Sets a fan's speed by percentage",
required_domains={DOMAIN},
platforms={DOMAIN},
required_slots={
ATTR_PERCENTAGE: intent.IntentSlotInfo(
description="The speed percentage of the fan",
value_schema=vol.All(vol.Coerce(int), vol.Range(min=0, max=100)),
)
},
),
)

View File

@@ -120,7 +120,6 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]):
self.fritz_guest_wifi: FritzGuestWLAN = None
self.fritz_hosts: FritzHosts = None
self.fritz_status: FritzStatus = None
self.hass = hass
self.host = host
self.mesh_role = MeshRoles.NONE
self.mesh_wifi_uplink = False

View File

@@ -29,7 +29,6 @@ class GlancesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self, hass: HomeAssistant, entry: GlancesConfigEntry, api: Glances
) -> None:
"""Initialize the Glances data."""
self.hass = hass
self.host: str = entry.data[CONF_HOST]
self.api = api
super().__init__(

View File

@@ -74,7 +74,7 @@ class ValveControllerEntity(GuardianEntity):
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, entry.data[CONF_UID])},
manufacturer="Elexa",
model=self._diagnostics_coordinator.data["firmware"],
sw_version=self._diagnostics_coordinator.data["firmware"],
name=f"Guardian valve controller {entry.data[CONF_UID]}",
)
self._attr_unique_id = f"{entry.data[CONF_UID]}_{description.key}"

View File

@@ -40,7 +40,6 @@ class FirmwareUpdateCoordinator(DataUpdateCoordinator[FirmwareManifest]):
update_interval=FIRMWARE_REFRESH_INTERVAL,
config_entry=config_entry,
)
self.hass = hass
self.session = session
self.client = FirmwareUpdateClient(url, session)

View File

@@ -28,6 +28,7 @@ class JustNimbusEntity(
identifiers={(DOMAIN, device_id)},
name="JustNimbus Sensor",
manufacturer="JustNimbus",
sw_version=coordinator.data.api_version,
)
@property

View File

@@ -42,7 +42,6 @@ class LaCrosseUpdateCoordinator(DataUpdateCoordinator[list[Sensor]]):
self.last_update = time()
self.username = entry.data["username"]
self.password = entry.data["password"]
self.hass = hass
self.name = entry.data["name"]
self.id = entry.data["id"]
super().__init__(

View File

@@ -45,7 +45,6 @@ class LivisiDataUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]):
name="Livisi devices",
update_interval=timedelta(seconds=DEVICE_POLLING_DELAY),
)
self.hass = hass
self.aiolivisi = aiolivisi
self.websocket = Websocket(aiolivisi)
self.devices: set[str] = set()

View File

@@ -91,7 +91,7 @@ async def async_setup_entry(
class ConditionerEntity(LookinCoordinatorEntity, ClimateEntity):
"""An aircon or heat pump."""
_attr_current_humidity: float | None = None # type: ignore[assignment]
_attr_current_humidity: float | None = None
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_supported_features = (
ClimateEntityFeature.TARGET_TEMPERATURE

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/madvr",
"integration_type": "device",
"iot_class": "local_push",
"requirements": ["py-madvr2==1.6.32"]
"requirements": ["py-madvr2==1.6.40"]
}

View File

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

View File

@@ -32,11 +32,13 @@ from homeassistant.const import (
REVOLUTIONS_PER_MINUTE,
EntityCategory,
Platform,
UnitOfApparentPower,
UnitOfElectricCurrent,
UnitOfElectricPotential,
UnitOfEnergy,
UnitOfPower,
UnitOfPressure,
UnitOfReactivePower,
UnitOfTemperature,
UnitOfTime,
UnitOfVolume,
@@ -782,10 +784,43 @@ DISCOVERY_SCHEMAS = [
clusters.ElectricalPowerMeasurement.Attributes.ActivePower,
),
),
MatterDiscoverySchema(
platform=Platform.SENSOR,
entity_description=MatterSensorEntityDescription(
key="ElectricalPowerMeasurementApparentPower",
device_class=SensorDeviceClass.APPARENT_POWER,
entity_category=EntityCategory.DIAGNOSTIC,
native_unit_of_measurement=UnitOfApparentPower.MILLIVOLT_AMPERE,
suggested_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE,
suggested_display_precision=2,
state_class=SensorStateClass.MEASUREMENT,
),
entity_class=MatterSensor,
required_attributes=(
clusters.ElectricalPowerMeasurement.Attributes.ApparentPower,
),
),
MatterDiscoverySchema(
platform=Platform.SENSOR,
entity_description=MatterSensorEntityDescription(
key="ElectricalPowerMeasurementReactivePower",
device_class=SensorDeviceClass.REACTIVE_POWER,
entity_category=EntityCategory.DIAGNOSTIC,
native_unit_of_measurement=UnitOfReactivePower.MILLIVOLT_AMPERE_REACTIVE,
suggested_unit_of_measurement=UnitOfReactivePower.VOLT_AMPERE_REACTIVE,
suggested_display_precision=2,
state_class=SensorStateClass.MEASUREMENT,
),
entity_class=MatterSensor,
required_attributes=(
clusters.ElectricalPowerMeasurement.Attributes.ReactivePower,
),
),
MatterDiscoverySchema(
platform=Platform.SENSOR,
entity_description=MatterSensorEntityDescription(
key="ElectricalPowerMeasurementVoltage",
translation_key="voltage",
device_class=SensorDeviceClass.VOLTAGE,
entity_category=EntityCategory.DIAGNOSTIC,
native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT,
@@ -796,10 +831,45 @@ DISCOVERY_SCHEMAS = [
entity_class=MatterSensor,
required_attributes=(clusters.ElectricalPowerMeasurement.Attributes.Voltage,),
),
MatterDiscoverySchema(
platform=Platform.SENSOR,
entity_description=MatterSensorEntityDescription(
key="ElectricalPowerMeasurementRMSVoltage",
translation_key="rms_voltage",
device_class=SensorDeviceClass.VOLTAGE,
entity_category=EntityCategory.DIAGNOSTIC,
native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT,
suggested_unit_of_measurement=UnitOfElectricPotential.VOLT,
suggested_display_precision=0,
state_class=SensorStateClass.MEASUREMENT,
),
entity_class=MatterSensor,
required_attributes=(
clusters.ElectricalPowerMeasurement.Attributes.RMSVoltage,
),
),
MatterDiscoverySchema(
platform=Platform.SENSOR,
entity_description=MatterSensorEntityDescription(
key="ElectricalPowerMeasurementApparentCurrent",
translation_key="apparent_current",
device_class=SensorDeviceClass.CURRENT,
entity_category=EntityCategory.DIAGNOSTIC,
native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE,
suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
suggested_display_precision=2,
state_class=SensorStateClass.MEASUREMENT,
),
entity_class=MatterSensor,
required_attributes=(
clusters.ElectricalPowerMeasurement.Attributes.ApparentCurrent,
),
),
MatterDiscoverySchema(
platform=Platform.SENSOR,
entity_description=MatterSensorEntityDescription(
key="ElectricalPowerMeasurementActiveCurrent",
translation_key="active_current",
device_class=SensorDeviceClass.CURRENT,
entity_category=EntityCategory.DIAGNOSTIC,
native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE,
@@ -812,6 +882,40 @@ DISCOVERY_SCHEMAS = [
clusters.ElectricalPowerMeasurement.Attributes.ActiveCurrent,
),
),
MatterDiscoverySchema(
platform=Platform.SENSOR,
entity_description=MatterSensorEntityDescription(
key="ElectricalPowerMeasurementReactiveCurrent",
translation_key="reactive_current",
device_class=SensorDeviceClass.CURRENT,
entity_category=EntityCategory.DIAGNOSTIC,
native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE,
suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
suggested_display_precision=2,
state_class=SensorStateClass.MEASUREMENT,
),
entity_class=MatterSensor,
required_attributes=(
clusters.ElectricalPowerMeasurement.Attributes.ReactiveCurrent,
),
),
MatterDiscoverySchema(
platform=Platform.SENSOR,
entity_description=MatterSensorEntityDescription(
key="ElectricalPowerMeasurementRMSCurrent",
translation_key="rms_current",
device_class=SensorDeviceClass.CURRENT,
entity_category=EntityCategory.DIAGNOSTIC,
native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE,
suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
suggested_display_precision=2,
state_class=SensorStateClass.MEASUREMENT,
),
entity_class=MatterSensor,
required_attributes=(
clusters.ElectricalPowerMeasurement.Attributes.RMSCurrent,
),
),
MatterDiscoverySchema(
platform=Platform.SENSOR,
entity_description=MatterSensorEntityDescription(

View File

@@ -451,6 +451,24 @@
},
"window_covering_target_position": {
"name": "Target opening position"
},
"active_current": {
"name": "Active current"
},
"apparent_current": {
"name": "Apparent current"
},
"reactive_current": {
"name": "Reactive current"
},
"rms_current": {
"name": "Effective current"
},
"rms_voltage": {
"name": "Effective voltage"
},
"voltage": {
"name": "Voltage"
}
},
"switch": {

View File

@@ -1,5 +1,6 @@
"""Intents for the media_player integration."""
import asyncio
from collections.abc import Iterable
from dataclasses import dataclass, field
import logging
@@ -14,21 +15,21 @@ from homeassistant.const import (
SERVICE_MEDIA_PLAY,
SERVICE_MEDIA_PREVIOUS_TRACK,
SERVICE_VOLUME_SET,
STATE_PLAYING,
)
from homeassistant.core import Context, HomeAssistant, State
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, intent
from homeassistant.helpers.entity_component import EntityComponent
from . import (
from . import MediaPlayerDeviceClass, MediaPlayerEntity
from .browse_media import SearchMedia
from .const import (
ATTR_MEDIA_FILTER_CLASSES,
ATTR_MEDIA_VOLUME_LEVEL,
DOMAIN,
SERVICE_PLAY_MEDIA,
SERVICE_SEARCH_MEDIA,
MediaPlayerDeviceClass,
SearchMedia,
)
from .const import (
ATTR_MEDIA_FILTER_CLASSES,
MediaClass,
MediaPlayerEntityFeature,
MediaPlayerState,
@@ -39,6 +40,7 @@ INTENT_MEDIA_UNPAUSE = "HassMediaUnpause"
INTENT_MEDIA_NEXT = "HassMediaNext"
INTENT_MEDIA_PREVIOUS = "HassMediaPrevious"
INTENT_SET_VOLUME = "HassSetVolume"
INTENT_SET_VOLUME_RELATIVE = "HassSetVolumeRelative"
INTENT_MEDIA_SEARCH_AND_PLAY = "HassMediaSearchAndPlay"
_LOGGER = logging.getLogger(__name__)
@@ -127,6 +129,7 @@ async def async_setup_intents(hass: HomeAssistant) -> None:
device_classes={MediaPlayerDeviceClass},
),
)
intent.async_register(hass, MediaSetVolumeRelativeHandler())
intent.async_register(hass, MediaSearchAndPlayHandler())
@@ -354,3 +357,120 @@ class MediaSearchAndPlayHandler(intent.IntentHandler):
response.async_set_speech_slots({"media": first_result.as_dict()})
response.response_type = intent.IntentResponseType.ACTION_DONE
return response
class MediaSetVolumeRelativeHandler(intent.IntentHandler):
"""Handler for setting relative volume."""
description = "Increases or decreases the volume of a media player"
intent_type = INTENT_SET_VOLUME_RELATIVE
slot_schema = {
vol.Required("volume_step"): vol.Any(
"up",
"down",
vol.All(
vol.Coerce(int),
vol.Range(min=-100, max=100),
lambda val: val / 100,
),
),
# Optional name/area/floor slots handled by intent matcher
vol.Optional("name"): cv.string,
vol.Optional("area"): cv.string,
vol.Optional("floor"): cv.string,
vol.Optional("preferred_area_id"): cv.string,
vol.Optional("preferred_floor_id"): cv.string,
}
platforms = {DOMAIN}
async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
"""Handle the intent."""
hass = intent_obj.hass
component: EntityComponent[MediaPlayerEntity] = hass.data[DOMAIN]
slots = self.async_validate_slots(intent_obj.slots)
volume_step = slots["volume_step"]["value"]
# Entity name to match
name_slot = slots.get("name", {})
entity_name: str | None = name_slot.get("value")
# Get area/floor info
area_slot = slots.get("area", {})
area_id = area_slot.get("value")
floor_slot = slots.get("floor", {})
floor_id = floor_slot.get("value")
# Find matching entities
match_constraints = intent.MatchTargetsConstraints(
name=entity_name,
area_name=area_id,
floor_name=floor_id,
domains={DOMAIN},
assistant=intent_obj.assistant,
features=MediaPlayerEntityFeature.VOLUME_SET,
)
match_preferences = intent.MatchTargetsPreferences(
area_id=slots.get("preferred_area_id", {}).get("value"),
floor_id=slots.get("preferred_floor_id", {}).get("value"),
)
match_result = intent.async_match_targets(
hass, match_constraints, match_preferences
)
if not match_result.is_match:
# No targets
raise intent.MatchFailedError(
result=match_result, constraints=match_constraints
)
if (
match_result.is_match
and (len(match_result.states) > 1)
and ("name" not in intent_obj.slots)
):
# Multiple targets not by name, so we need to check state
match_result.states = [
s for s in match_result.states if s.state == STATE_PLAYING
]
if not match_result.states:
# No media players are playing
raise intent.MatchFailedError(
result=intent.MatchTargetsResult(
is_match=False, no_match_reason=intent.MatchFailedReason.STATE
),
constraints=match_constraints,
preferences=match_preferences,
)
target_entity_ids = {s.entity_id for s in match_result.states}
target_entities = [
e for e in component.entities if e.entity_id in target_entity_ids
]
if volume_step == "up":
coros = [e.async_volume_up() for e in target_entities]
elif volume_step == "down":
coros = [e.async_volume_down() for e in target_entities]
else:
coros = [
e.async_set_volume_level(
max(0.0, min(1.0, e.volume_level + volume_step))
)
for e in target_entities
]
try:
await asyncio.gather(*coros)
except HomeAssistantError as err:
_LOGGER.error("Error setting relative volume: %s", err)
raise intent.IntentHandleError(
f"Error setting relative volume: {err}"
) from err
response = intent_obj.create_response()
response.response_type = intent.IntentResponseType.ACTION_DONE
response.async_set_states(match_result.states)
return response

View File

@@ -8,7 +8,7 @@
"iot_class": "cloud_push",
"loggers": ["pymiele"],
"quality_scale": "bronze",
"requirements": ["pymiele==0.5.3"],
"requirements": ["pymiele==0.5.4"],
"single_config_entry": true,
"zeroconf": ["_mieleathome._tcp.local."]
}

View File

@@ -2,7 +2,7 @@
from __future__ import annotations
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
@@ -31,6 +31,9 @@ class ModernFormsDeviceEntity(CoordinatorEntity[ModernFormsDataUpdateCoordinator
"""Return device information about this Modern Forms device."""
return DeviceInfo(
identifiers={(DOMAIN, self.coordinator.data.info.mac_address)},
connections={
(CONNECTION_NETWORK_MAC, self.coordinator.data.info.mac_address)
},
name=self.coordinator.data.info.device_name,
manufacturer="Modern Forms",
model=self.coordinator.data.info.fan_type,

View File

@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["nextdns"],
"requirements": ["nextdns==4.0.0"]
"requirements": ["nextdns==4.1.0"]
}

View File

@@ -52,6 +52,9 @@ async def async_setup_entry(
)
PARALLEL_UPDATES = 0
class NINAMessage(CoordinatorEntity[NINADataUpdateCoordinator], BinarySensorEntity):
"""Representation of an NINA warning."""

View File

@@ -0,0 +1,24 @@
"""Diagnostics for the Nina integration."""
from dataclasses import asdict
from typing import Any
from homeassistant.core import HomeAssistant
from .coordinator import NinaConfigEntry
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: NinaConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
runtime_data_dict = {
region_key: [asdict(warning) for warning in region_data]
for region_key, region_data in entry.runtime_data.data.items()
}
return {
"entry_data": dict(entry.data),
"data": runtime_data_dict,
}

View File

@@ -69,10 +69,12 @@ class NoboGlobalSelector(SelectEntity):
self._override_type = override_type
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, hub.hub_serial)},
serial_number=hub.hub_serial,
name=hub.hub_info[ATTR_NAME],
manufacturer=NOBO_MANUFACTURER,
model=f"Nobø Ecohub ({hub.hub_info[ATTR_HARDWARE_VERSION]})",
model="Nobø Ecohub",
sw_version=hub.hub_info[ATTR_SOFTWARE_VERSION],
hw_version=hub.hub_info[ATTR_HARDWARE_VERSION],
)
async def async_added_to_hass(self) -> None:

View File

@@ -58,6 +58,7 @@ class NoboTemperatureSensor(SensorEntity):
suggested_area = hub.zones[zone_id][ATTR_NAME]
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, component[ATTR_SERIAL])},
serial_number=component[ATTR_SERIAL],
name=component[ATTR_NAME],
manufacturer=NOBO_MANUFACTURER,
model=component[ATTR_MODEL].name,

View File

@@ -45,9 +45,9 @@ class NotionEntity(CoordinatorEntity[NotionDataUpdateCoordinator]):
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, sensor.hardware_id)},
manufacturer="Silicon Labs",
model=str(sensor.hardware_revision),
name=str(sensor.name).capitalize(),
sw_version=sensor.firmware_version,
hw_version=str(sensor.hardware_revision),
)
if bridge := self._async_get_bridge(bridge_id):

View File

@@ -88,7 +88,7 @@ class NumberDeviceClass(StrEnum):
APPARENT_POWER = "apparent_power"
"""Apparent power.
Unit of measurement: `VA`
Unit of measurement: `mVA`, `VA`
"""
AQI = "aqi"
@@ -338,7 +338,7 @@ class NumberDeviceClass(StrEnum):
REACTIVE_POWER = "reactive_power"
"""Reactive power.
Unit of measurement: `var`, `kvar`
Unit of measurement: `mvar`, `var`, `kvar`
"""
SIGNAL_STRENGTH = "signal_strength"

View File

@@ -137,9 +137,11 @@ class OndiloICO(CoordinatorEntity[OndiloIcoMeasuresCoordinator], SensorEntity):
super().__init__(coordinator)
self.entity_description = description
self._pool_id = pool_id
self._attr_unique_id = f"{pool_data.ico['serial_number']}-{description.key}"
serial_number = pool_data.ico["serial_number"]
self._attr_unique_id = f"{serial_number}-{description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, pool_data.ico["serial_number"])},
identifiers={(DOMAIN, serial_number)},
serial_number=serial_number,
)
@property

View File

@@ -41,6 +41,13 @@ class OneWireBinarySensorEntityDescription(
DEVICE_BINARY_SENSORS: dict[str, tuple[OneWireBinarySensorEntityDescription, ...]] = {
"05": (
OneWireBinarySensorEntityDescription(
key="sensed",
entity_registry_enabled_default=False,
translation_key="sensed",
),
),
"12": tuple(
OneWireBinarySensorEntityDescription(
key=f"sensed.{device_key}",

View File

@@ -37,6 +37,9 @@
},
"entity": {
"binary_sensor": {
"sensed": {
"name": "Sensed"
},
"sensed_id": {
"name": "Sensed {id}"
},

View File

@@ -8,5 +8,5 @@
"documentation": "https://www.home-assistant.io/integrations/onvif",
"iot_class": "local_push",
"loggers": ["onvif", "wsdiscovery", "zeep"],
"requirements": ["onvif-zeep-async==4.0.2", "WSDiscovery==2.1.2"]
"requirements": ["onvif-zeep-async==4.0.3", "WSDiscovery==2.1.2"]
}

View File

@@ -48,7 +48,7 @@ async def async_setup_entry(
device = hass.data[DOMAIN][config_entry.entry_id]
entity = OpenhomeDevice(hass, device)
entity = OpenhomeDevice(device)
async_add_entities([entity])
@@ -100,9 +100,8 @@ class OpenhomeDevice(MediaPlayerEntity):
_attr_state = MediaPlayerState.PLAYING
_attr_available = True
def __init__(self, hass, device):
def __init__(self, device):
"""Initialise the Openhome device."""
self.hass = hass
self._device = device
self._attr_unique_id = device.uuid()
self._source_index = {}

View File

@@ -51,6 +51,7 @@ from .const import (
ATTR_API_WEATHER,
ATTR_API_WEATHER_CODE,
ATTR_API_WIND_BEARING,
ATTR_API_WIND_GUST,
ATTR_API_WIND_SPEED,
ATTRIBUTION,
DOMAIN,
@@ -93,6 +94,13 @@ WEATHER_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
device_class=SensorDeviceClass.WIND_SPEED,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key=ATTR_API_WIND_GUST,
name="Wind gust",
native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND,
device_class=SensorDeviceClass.WIND_SPEED,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key=ATTR_API_WIND_BEARING,
name="Wind bearing",

View File

@@ -82,7 +82,7 @@ class PhilipsJSConfigFlow(ConfigFlow, domain=DOMAIN):
)
await hub.getSystem()
await hub.setTransport(hub.secured_transport)
await hub.setTransport(hub.secured_transport, hub.api_version_detected)
if not hub.system or not hub.name:
raise ConnectionFailure("System data or name is empty")

View File

@@ -217,6 +217,13 @@ async def determine_api_version(
_LOGGER.debug(
"Connection to %s failed: %s, trying API version 5", holeV6.base_url, ex_v6
)
else:
# It seems that occasionally the auth can succeed unexpectedly when there is a valid session
_LOGGER.warning(
"Authenticated with %s through v6 API, but succeeded with an incorrect password. This is a known bug",
holeV6.base_url,
)
return 6
holeV5 = api_by_version(hass, entry, 5, password="wrong_token")
try:
await holeV5.get_data()

View File

@@ -31,7 +31,6 @@ class PlaatoCoordinator(DataUpdateCoordinator):
) -> None:
"""Initialize."""
self.api = Plaato(auth_token=auth_token)
self.hass = hass
self.device_type = device_type
self.platforms: list[Platform] = []

View File

@@ -28,8 +28,9 @@ class MinutPointEntity(CoordinatorEntity[PointDataUpdateCoordinator]):
connections={(dr.CONNECTION_NETWORK_MAC, device["device_mac"])},
identifiers={(DOMAIN, device["device_id"])},
manufacturer="Minut",
model=f"Point v{device['hardware_version']}",
model="Point",
name=device["description"],
hw_version=device["hardware_version"],
sw_version=device["firmware"]["installed"],
via_device=(DOMAIN, device["home"]),
)

View File

@@ -1,18 +1,17 @@
"""The pvpc_hourly_pricing integration to collect Spain official electric prices."""
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_TOKEN, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from .const import ATTR_POWER, ATTR_POWER_P3, DOMAIN
from .coordinator import ElecPricesDataUpdateCoordinator
from .const import ATTR_POWER, ATTR_POWER_P3
from .coordinator import ElecPricesDataUpdateCoordinator, PVPCConfigEntry
from .helpers import get_enabled_sensor_keys
PLATFORMS: list[Platform] = [Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: PVPCConfigEntry) -> bool:
"""Set up pvpc hourly pricing from a config entry."""
entity_registry = er.async_get(hass)
sensor_keys = get_enabled_sensor_keys(
@@ -22,13 +21,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
coordinator = ElecPricesDataUpdateCoordinator(hass, entry, sensor_keys)
await coordinator.async_config_entry_first_refresh()
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(entry.add_update_listener(async_update_options))
return True
async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None:
async def async_update_options(hass: HomeAssistant, entry: PVPCConfigEntry) -> None:
"""Handle options update."""
if any(
entry.data.get(attrib) != entry.options.get(attrib)
@@ -41,9 +40,6 @@ async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None:
await hass.config_entries.async_reload(entry.entry_id)
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: PVPCConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -17,14 +17,16 @@ from .const import ATTR_POWER, ATTR_POWER_P3, ATTR_TARIFF, DOMAIN
_LOGGER = logging.getLogger(__name__)
type PVPCConfigEntry = ConfigEntry[ElecPricesDataUpdateCoordinator]
class ElecPricesDataUpdateCoordinator(DataUpdateCoordinator[EsiosApiData]):
"""Class to manage fetching Electricity prices data from API."""
config_entry: ConfigEntry
config_entry: PVPCConfigEntry
def __init__(
self, hass: HomeAssistant, entry: ConfigEntry, sensor_keys: set[str]
self, hass: HomeAssistant, entry: PVPCConfigEntry, sensor_keys: set[str]
) -> None:
"""Initialize."""
self.api = PVPCData(

View File

@@ -14,7 +14,6 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CURRENCY_EURO, UnitOfEnergy
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
@@ -24,7 +23,7 @@ from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import ElecPricesDataUpdateCoordinator
from .coordinator import ElecPricesDataUpdateCoordinator, PVPCConfigEntry
from .helpers import make_sensor_unique_id
_LOGGER = logging.getLogger(__name__)
@@ -149,11 +148,11 @@ _PRICE_SENSOR_ATTRIBUTES_MAP = {
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
entry: PVPCConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the electricity price sensor from config_entry."""
coordinator: ElecPricesDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
coordinator = entry.runtime_data
sensors = [ElecPriceSensor(coordinator, SENSOR_TYPES[0], entry.unique_id)]
if coordinator.api.using_private_api:
sensors.extend(

View File

@@ -0,0 +1,144 @@
"""Support for Qbus binary sensor."""
from dataclasses import dataclass
from typing import cast
from qbusmqttapi.discovery import QbusMqttDevice, QbusMqttOutput
from qbusmqttapi.factory import QbusMqttTopicFactory
from qbusmqttapi.state import QbusMqttDeviceState, QbusMqttWeatherState
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
BinarySensorEntityDescription,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import QbusConfigEntry
from .entity import (
QbusEntity,
create_device_identifier,
create_unique_id,
determine_new_outputs,
)
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class QbusWeatherDescription(BinarySensorEntityDescription):
"""Description for Qbus weather entities."""
property: str
_WEATHER_DESCRIPTIONS = (
QbusWeatherDescription(
key="raining",
property="raining",
translation_key="raining",
),
QbusWeatherDescription(
key="twilight",
property="twilight",
translation_key="twilight",
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: QbusConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up binary sensor entities."""
coordinator = entry.runtime_data
added_outputs: list[QbusMqttOutput] = []
added_controllers: list[str] = []
def _create_weather_entities() -> list[BinarySensorEntity]:
new_outputs = determine_new_outputs(
coordinator, added_outputs, lambda output: output.type == "weatherstation"
)
return [
QbusWeatherBinarySensor(output, description)
for output in new_outputs
for description in _WEATHER_DESCRIPTIONS
]
def _create_controller_entities() -> list[BinarySensorEntity]:
if coordinator.data and coordinator.data.id not in added_controllers:
added_controllers.extend(coordinator.data.id)
return [QbusControllerConnectedBinarySensor(coordinator.data)]
return []
def _check_outputs() -> None:
entities = [*_create_weather_entities(), *_create_controller_entities()]
async_add_entities(entities)
_check_outputs()
entry.async_on_unload(coordinator.async_add_listener(_check_outputs))
class QbusWeatherBinarySensor(QbusEntity, BinarySensorEntity):
"""Representation of a Qbus weather binary sensor."""
_state_cls = QbusMqttWeatherState
entity_description: QbusWeatherDescription
def __init__(
self, mqtt_output: QbusMqttOutput, description: QbusWeatherDescription
) -> None:
"""Initialize binary sensor entity."""
super().__init__(mqtt_output, id_suffix=description.key)
self.entity_description = description
async def _handle_state_received(self, state: QbusMqttWeatherState) -> None:
if value := state.read_property(self.entity_description.property, None):
self._attr_is_on = (
None if value is None else cast(str, value).lower() == "true"
)
class QbusControllerConnectedBinarySensor(BinarySensorEntity):
"""Representation of the Qbus controller connected sensor."""
_attr_has_entity_name = True
_attr_name = None
_attr_should_poll = False
_attr_device_class = BinarySensorDeviceClass.CONNECTIVITY
def __init__(self, controller: QbusMqttDevice) -> None:
"""Initialize binary sensor entity."""
self._controller = controller
self._attr_unique_id = create_unique_id(controller.serial_number, "connected")
self._attr_device_info = DeviceInfo(
identifiers={create_device_identifier(controller)}
)
async def async_added_to_hass(self) -> None:
"""Run when entity about to be added to hass."""
topic = QbusMqttTopicFactory().get_device_state_topic(self._controller.id)
self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"{DOMAIN}_{topic}",
self._state_received,
)
)
@callback
def _state_received(self, state: QbusMqttDeviceState) -> None:
self._attr_is_on = state.properties.connected if state.properties else None
self.async_schedule_update_ha_state()

View File

@@ -6,6 +6,7 @@ from homeassistant.const import Platform
DOMAIN: Final = "qbus"
PLATFORMS: list[Platform] = [
Platform.BINARY_SENSOR,
Platform.CLIMATE,
Platform.COVER,
Platform.LIGHT,

View File

@@ -6,7 +6,7 @@ from datetime import datetime
import logging
from typing import cast
from qbusmqttapi.discovery import QbusDiscovery, QbusMqttDevice, QbusMqttOutput
from qbusmqttapi.discovery import QbusDiscovery, QbusMqttDevice
from qbusmqttapi.factory import QbusMqttMessageFactory, QbusMqttTopicFactory
from homeassistant.components.mqtt import (
@@ -19,6 +19,7 @@ from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.util.hass_dict import HassKey
@@ -32,7 +33,7 @@ type QbusConfigEntry = ConfigEntry[QbusControllerCoordinator]
QBUS_KEY: HassKey[QbusConfigCoordinator] = HassKey(DOMAIN)
class QbusControllerCoordinator(DataUpdateCoordinator[list[QbusMqttOutput]]):
class QbusControllerCoordinator(DataUpdateCoordinator[QbusMqttDevice | None]):
"""Qbus data coordinator."""
_STATE_REQUEST_DELAY = 3
@@ -63,8 +64,8 @@ class QbusControllerCoordinator(DataUpdateCoordinator[list[QbusMqttOutput]]):
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.shutdown)
)
async def _async_update_data(self) -> list[QbusMqttOutput]:
return self._controller.outputs if self._controller else []
async def _async_update_data(self) -> QbusMqttDevice | None:
return self._controller
def shutdown(self, event: Event | None = None) -> None:
"""Shutdown Qbus coordinator."""
@@ -140,20 +141,25 @@ class QbusControllerCoordinator(DataUpdateCoordinator[list[QbusMqttOutput]]):
"%s - Receiving controller state %s", self.config_entry.unique_id, msg.topic
)
if self._controller is None or self._controller_activated:
if self._controller is None:
return
state = self._message_factory.parse_device_state(msg.payload)
if state and state.properties and state.properties.connectable is False:
_LOGGER.debug(
"%s - Activating controller %s", self.config_entry.unique_id, state.id
)
self._controller_activated = True
request = self._message_factory.create_device_activate_request(
self._controller
)
await mqtt.async_publish(self.hass, request.topic, request.payload)
if state and state.properties:
async_dispatcher_send(self.hass, f"{DOMAIN}_{msg.topic}", state)
if not self._controller_activated and state.properties.connectable is False:
_LOGGER.debug(
"%s - Activating controller %s",
self.config_entry.unique_id,
state.id,
)
self._controller_activated = True
request = self._message_factory.create_device_activate_request(
self._controller
)
await mqtt.async_publish(self.hass, request.topic, request.payload)
def _request_entity_states(self) -> None:
async def request_state(_: datetime) -> None:

View File

@@ -7,7 +7,7 @@ from collections.abc import Callable
import re
from typing import Generic, TypeVar, cast
from qbusmqttapi.discovery import QbusMqttOutput
from qbusmqttapi.discovery import QbusMqttDevice, QbusMqttOutput
from qbusmqttapi.factory import QbusMqttMessageFactory, QbusMqttTopicFactory
from qbusmqttapi.state import QbusMqttState
@@ -44,11 +44,15 @@ def determine_new_outputs(
added_ref_ids = {k.ref_id for k in added_outputs}
new_outputs = [
output
for output in coordinator.data
if filter_fn(output) and output.ref_id not in added_ref_ids
]
new_outputs = (
[
output
for output in coordinator.data.outputs
if filter_fn(output) and output.ref_id not in added_ref_ids
]
if coordinator.data
else []
)
if new_outputs:
added_outputs.extend(new_outputs)
@@ -64,9 +68,14 @@ def format_ref_id(ref_id: str) -> str | None:
return None
def create_main_device_identifier(mqtt_output: QbusMqttOutput) -> tuple[str, str]:
"""Create the identifier referring to the main device this output belongs to."""
return (DOMAIN, format_mac(mqtt_output.device.mac))
def create_device_identifier(mqtt_device: QbusMqttDevice) -> tuple[str, str]:
"""Create the device identifier."""
return (DOMAIN, format_mac(mqtt_device.mac))
def create_unique_id(serial_number: str, suffix: str) -> str:
"""Create the unique id."""
return f"ctd_{serial_number}_{suffix}"
class QbusEntity(Entity, Generic[StateT], ABC):
@@ -95,16 +104,18 @@ class QbusEntity(Entity, Generic[StateT], ABC):
)
ref_id = format_ref_id(mqtt_output.ref_id)
unique_id = f"ctd_{mqtt_output.device.serial_number}_{ref_id}"
suffix = ref_id or ""
if id_suffix:
unique_id += f"_{id_suffix}"
suffix += f"_{id_suffix}"
self._attr_unique_id = unique_id
self._attr_unique_id = create_unique_id(
mqtt_output.device.serial_number, suffix
)
if link_to_main_device:
self._attr_device_info = DeviceInfo(
identifiers={create_main_device_identifier(mqtt_output)}
identifiers={create_device_identifier(mqtt_output.device)}
)
else:
self._attr_device_info = DeviceInfo(
@@ -112,7 +123,7 @@ class QbusEntity(Entity, Generic[StateT], ABC):
manufacturer=MANUFACTURER,
identifiers={(DOMAIN, f"{mqtt_output.device.serial_number}_{ref_id}")},
suggested_area=mqtt_output.location.title(),
via_device=create_main_device_identifier(mqtt_output),
via_device=create_device_identifier(mqtt_output.device),
)
async def async_added_to_hass(self) -> None:

View File

@@ -0,0 +1,12 @@
{
"entity": {
"binary_sensor": {
"raining": {
"default": "mdi:weather-pouring"
},
"twilight": {
"default": "mdi:weather-sunset"
}
}
}
}

View File

@@ -17,6 +17,14 @@
}
},
"entity": {
"binary_sensor": {
"raining": {
"name": "Raining"
},
"twilight": {
"name": "Twilight"
}
},
"sensor": {
"daylight": {
"name": "Daylight"

View File

@@ -44,7 +44,6 @@ class RachioUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
base_count: int,
) -> None:
"""Initialize the Rachio Update Coordinator."""
self.hass = hass
self.rachio = rachio
self.base_station = base_station
super().__init__(
@@ -83,7 +82,6 @@ class RachioScheduleUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]
base_station,
) -> None:
"""Initialize a Rachio schedule coordinator."""
self.hass = hass
self.rachio = rachio
self.base_station = base_station
super().__init__(

View File

@@ -56,11 +56,9 @@ class RainMachineEntity(CoordinatorEntity[RainMachineDataUpdateCoordinator]):
connections={(dr.CONNECTION_NETWORK_MAC, self._data.controller.mac)},
name=self._data.controller.name.capitalize(),
manufacturer="RainMachine",
model=(
f"Version {self._version_coordinator.data['hwVer']} "
f"(API: {self._version_coordinator.data['apiVer']})"
),
sw_version=self._version_coordinator.data["swVer"],
hw_version=self._version_coordinator.data["hwVer"],
sw_version=f"{self._version_coordinator.data['swVer']} "
f"(API: {self._version_coordinator.data['apiVer']})",
)
@callback

View File

@@ -42,6 +42,7 @@ from homeassistant.util import dt as dt_util
from homeassistant.util.collection import chunked_or_all
from homeassistant.util.enum import try_parse_enum
from homeassistant.util.unit_conversion import (
ApparentPowerConverter,
AreaConverter,
BaseUnitConverter,
BloodGlucoseConcentrationConverter,
@@ -59,6 +60,7 @@ from homeassistant.util.unit_conversion import (
PowerConverter,
PressureConverter,
ReactiveEnergyConverter,
ReactivePowerConverter,
SpeedConverter,
TemperatureConverter,
UnitlessRatioConverter,
@@ -193,6 +195,7 @@ QUERY_STATISTICS_SUMMARY_SUM = (
STATISTIC_UNIT_TO_UNIT_CONVERTER: dict[str | None, type[BaseUnitConverter]] = {
**dict.fromkeys(ApparentPowerConverter.VALID_UNITS, ApparentPowerConverter),
**dict.fromkeys(AreaConverter.VALID_UNITS, AreaConverter),
**dict.fromkeys(
BloodGlucoseConcentrationConverter.VALID_UNITS,
@@ -214,6 +217,7 @@ STATISTIC_UNIT_TO_UNIT_CONVERTER: dict[str | None, type[BaseUnitConverter]] = {
**dict.fromkeys(PowerConverter.VALID_UNITS, PowerConverter),
**dict.fromkeys(PressureConverter.VALID_UNITS, PressureConverter),
**dict.fromkeys(ReactiveEnergyConverter.VALID_UNITS, ReactiveEnergyConverter),
**dict.fromkeys(ReactivePowerConverter.VALID_UNITS, ReactivePowerConverter),
**dict.fromkeys(SpeedConverter.VALID_UNITS, SpeedConverter),
**dict.fromkeys(TemperatureConverter.VALID_UNITS, TemperatureConverter),
**dict.fromkeys(UnitlessRatioConverter.VALID_UNITS, UnitlessRatioConverter),

View File

@@ -16,6 +16,7 @@ from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.json import json_bytes
from homeassistant.util import dt as dt_util
from homeassistant.util.unit_conversion import (
ApparentPowerConverter,
AreaConverter,
BloodGlucoseConcentrationConverter,
ConductivityConverter,
@@ -32,6 +33,7 @@ from homeassistant.util.unit_conversion import (
PowerConverter,
PressureConverter,
ReactiveEnergyConverter,
ReactivePowerConverter,
SpeedConverter,
TemperatureConverter,
UnitlessRatioConverter,
@@ -59,6 +61,7 @@ UPDATE_STATISTICS_METADATA_TIME_OUT = 10
UNIT_SCHEMA = vol.Schema(
{
vol.Optional("apparent_power"): vol.In(ApparentPowerConverter.VALID_UNITS),
vol.Optional("area"): vol.In(AreaConverter.VALID_UNITS),
vol.Optional("blood_glucose_concentration"): vol.In(
BloodGlucoseConcentrationConverter.VALID_UNITS
@@ -79,6 +82,7 @@ UNIT_SCHEMA = vol.Schema(
vol.Optional("power"): vol.In(PowerConverter.VALID_UNITS),
vol.Optional("pressure"): vol.In(PressureConverter.VALID_UNITS),
vol.Optional("reactive_energy"): vol.In(ReactiveEnergyConverter.VALID_UNITS),
vol.Optional("reactive_power"): vol.In(ReactivePowerConverter.VALID_UNITS),
vol.Optional("speed"): vol.In(SpeedConverter.VALID_UNITS),
vol.Optional("temperature"): vol.In(TemperatureConverter.VALID_UNITS),
vol.Optional("unitless"): vol.In(UnitlessRatioConverter.VALID_UNITS),

View File

@@ -45,6 +45,7 @@ class RestData:
self._method = method
self._resource = resource
self._encoding = encoding
self._force_use_set_encoding = False
# Convert auth tuple to aiohttp.BasicAuth if needed
if isinstance(auth, tuple) and len(auth) == 2:
@@ -152,10 +153,19 @@ class RestData:
# Read the response
# Only use configured encoding if no charset in Content-Type header
# If charset is present in Content-Type, let aiohttp use it
if response.charset:
if self._force_use_set_encoding is False and response.charset:
# Let aiohttp use the charset from Content-Type header
self.data = await response.text()
else:
try:
self.data = await response.text()
except UnicodeDecodeError as ex:
self._force_use_set_encoding = True
_LOGGER.debug(
"Response charset came back as %s but could not be decoded, continue with configured encoding %s. %s",
response.charset,
self._encoding,
ex,
)
if self._force_use_set_encoding or not response.charset:
# Use configured encoding as fallback
self.data = await response.text(encoding=self._encoding)
self.headers = response.headers

View File

@@ -25,7 +25,6 @@ class RomyVacuumCoordinator(DataUpdateCoordinator[None]):
name=DOMAIN,
update_interval=UPDATE_INTERVAL,
)
self.hass = hass
self.romy = romy
async def _async_update_data(self) -> None:

View File

@@ -46,6 +46,7 @@ from homeassistant.const import (
UnitOfVolumetricFlux,
)
from homeassistant.util.unit_conversion import (
ApparentPowerConverter,
AreaConverter,
BaseUnitConverter,
BloodGlucoseConcentrationConverter,
@@ -63,6 +64,7 @@ from homeassistant.util.unit_conversion import (
PowerConverter,
PressureConverter,
ReactiveEnergyConverter,
ReactivePowerConverter,
SpeedConverter,
TemperatureConverter,
UnitlessRatioConverter,
@@ -117,7 +119,7 @@ class SensorDeviceClass(StrEnum):
APPARENT_POWER = "apparent_power"
"""Apparent power.
Unit of measurement: `VA`
Unit of measurement: `mVA`, `VA`
"""
AQI = "aqi"
@@ -369,7 +371,7 @@ class SensorDeviceClass(StrEnum):
REACTIVE_POWER = "reactive_power"
"""Reactive power.
Unit of measurement: `var`, `kvar`
Unit of measurement: `mvar`, `var`, `kvar`
"""
SIGNAL_STRENGTH = "signal_strength"
@@ -528,6 +530,7 @@ STATE_CLASSES_SCHEMA: Final = vol.All(vol.Lower, vol.Coerce(SensorStateClass))
STATE_CLASSES: Final[list[str]] = [cls.value for cls in SensorStateClass]
UNIT_CONVERTERS: dict[SensorDeviceClass | str | None, type[BaseUnitConverter]] = {
SensorDeviceClass.APPARENT_POWER: ApparentPowerConverter,
SensorDeviceClass.ABSOLUTE_HUMIDITY: MassVolumeConcentrationConverter,
SensorDeviceClass.AREA: AreaConverter,
SensorDeviceClass.ATMOSPHERIC_PRESSURE: PressureConverter,
@@ -548,6 +551,7 @@ UNIT_CONVERTERS: dict[SensorDeviceClass | str | None, type[BaseUnitConverter]] =
SensorDeviceClass.PRECIPITATION_INTENSITY: SpeedConverter,
SensorDeviceClass.PRESSURE: PressureConverter,
SensorDeviceClass.REACTIVE_ENERGY: ReactiveEnergyConverter,
SensorDeviceClass.REACTIVE_POWER: ReactivePowerConverter,
SensorDeviceClass.SPEED: SpeedConverter,
SensorDeviceClass.TEMPERATURE: TemperatureConverter,
SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS: MassVolumeConcentrationConverter,

View File

@@ -25,7 +25,7 @@
"is_illuminance": "Current {entity_name} illuminance",
"is_irradiance": "Current {entity_name} irradiance",
"is_moisture": "Current {entity_name} moisture",
"is_monetary": "Current {entity_name} balance",
"is_monetary": "Current {entity_name} monetary balance",
"is_nitrogen_dioxide": "Current {entity_name} nitrogen dioxide concentration level",
"is_nitrogen_monoxide": "Current {entity_name} nitrogen monoxide concentration level",
"is_nitrous_oxide": "Current {entity_name} nitrous oxide concentration level",
@@ -81,7 +81,7 @@
"illuminance": "{entity_name} illuminance changes",
"irradiance": "{entity_name} irradiance changes",
"moisture": "{entity_name} moisture changes",
"monetary": "{entity_name} balance changes",
"monetary": "{entity_name} monetary balance changes",
"nitrogen_dioxide": "{entity_name} nitrogen dioxide concentration changes",
"nitrogen_monoxide": "{entity_name} nitrogen monoxide concentration changes",
"nitrous_oxide": "{entity_name} nitrous oxide concentration changes",
@@ -223,7 +223,7 @@
"name": "Moisture"
},
"monetary": {
"name": "Balance"
"name": "Monetary balance"
},
"nitrogen_dioxide": {
"name": "Nitrogen dioxide"

View File

@@ -40,7 +40,7 @@ class SnooCoordinator(DataUpdateCoordinator[SnooData]):
async def setup(self) -> None:
"""Perform setup needed on every coordintaor creation."""
await self.snoo.subscribe(self.device, self.async_set_updated_data)
self.snoo.start_subscribe(self.device, self.async_set_updated_data)
# After we subscribe - get the status so that we have something to start with.
# We only need to do this once. The device will auto update otherwise.
await self.snoo.get_status(self.device)

View File

@@ -7,5 +7,5 @@
"iot_class": "cloud_push",
"loggers": ["snoo"],
"quality_scale": "bronze",
"requirements": ["python-snoo==0.8.1"]
"requirements": ["python-snoo==0.8.3"]
}

View File

@@ -29,11 +29,10 @@ class SpeedTestDataCoordinator(DataUpdateCoordinator[dict[str, Any]]):
api: speedtest.Speedtest,
) -> None:
"""Initialize the data object."""
self.hass = hass
self.api = api
self.servers: dict[str, dict] = {DEFAULT_SERVER: {}}
super().__init__(
self.hass,
hass,
_LOGGER,
config_entry=config_entry,
name=DOMAIN,

View File

@@ -29,6 +29,7 @@ PLATFORMS: list[Platform] = [
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.CLIMATE,
Platform.COVER,
Platform.FAN,
Platform.LIGHT,
Platform.LOCK,
@@ -47,6 +48,7 @@ class SwitchbotDevices:
)
buttons: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list)
climates: list[tuple[Remote, SwitchBotCoordinator]] = field(default_factory=list)
covers: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list)
switches: list[tuple[Device | Remote, SwitchBotCoordinator]] = field(
default_factory=list
)
@@ -192,6 +194,27 @@ async def make_device_data(
)
devices_data.fans.append((device, coordinator))
devices_data.sensors.append((device, coordinator))
if isinstance(device, Device) and device.device_type in [
"Curtain",
"Curtain3",
"Roller Shade",
"Blind Tilt",
]:
coordinator = await coordinator_for_device(
hass, entry, api, device, coordinators_by_id
)
devices_data.covers.append((device, coordinator))
devices_data.binary_sensors.append((device, coordinator))
devices_data.sensors.append((device, coordinator))
if isinstance(device, Device) and device.device_type in [
"Garage Door Opener",
]:
coordinator = await coordinator_for_device(
hass, entry, api, device, coordinators_by_id
)
devices_data.covers.append((device, coordinator))
devices_data.binary_sensors.append((device, coordinator))
if isinstance(device, Device) and device.device_type in [
"Strip Light",

View File

@@ -60,6 +60,11 @@ BINARY_SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES = {
CALIBRATION_DESCRIPTION,
DOOR_OPEN_DESCRIPTION,
),
"Curtain": (CALIBRATION_DESCRIPTION,),
"Curtain3": (CALIBRATION_DESCRIPTION,),
"Roller Shade": (CALIBRATION_DESCRIPTION,),
"Blind Tilt": (CALIBRATION_DESCRIPTION,),
"Garage Door Opener": (DOOR_OPEN_DESCRIPTION,),
}

View File

@@ -17,3 +17,5 @@ VACUUM_FAN_SPEED_STRONG = "strong"
VACUUM_FAN_SPEED_MAX = "max"
AFTER_COMMAND_REFRESH = 5
COVER_ENTITY_AFTER_COMMAND_REFRESH = 10

View File

@@ -0,0 +1,233 @@
"""Support for the Switchbot BlindTilt, Curtain, Curtain3, RollerShade as Cover."""
import asyncio
from typing import Any
from switchbot_api import (
BlindTiltCommands,
CommonCommands,
CurtainCommands,
Device,
Remote,
RollerShadeCommands,
SwitchBotAPI,
)
from homeassistant.components.cover import (
CoverDeviceClass,
CoverEntity,
CoverEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import SwitchbotCloudData, SwitchBotCoordinator
from .const import COVER_ENTITY_AFTER_COMMAND_REFRESH, DOMAIN
from .entity import SwitchBotCloudEntity
async def async_setup_entry(
hass: HomeAssistant,
config: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up SwitchBot Cloud entry."""
data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id]
async_add_entities(
_async_make_entity(data.api, device, coordinator)
for device, coordinator in data.devices.covers
)
class SwitchBotCloudCover(SwitchBotCloudEntity, CoverEntity):
"""Representation of a SwitchBot Cover."""
_attr_name = None
_attr_is_closed: bool | None = None
def _set_attributes(self) -> None:
if self.coordinator.data is None:
return
position: int | None = self.coordinator.data.get("slidePosition")
if position is None:
return
self._attr_current_cover_position = 100 - position
self._attr_current_cover_tilt_position = 100 - position
self._attr_is_closed = position == 100
class SwitchBotCloudCoverCurtain(SwitchBotCloudCover):
"""Representation of a SwitchBot Curtain & Curtain3."""
_attr_device_class = CoverDeviceClass.CURTAIN
_attr_supported_features: CoverEntityFeature = (
CoverEntityFeature.OPEN
| CoverEntityFeature.CLOSE
| CoverEntityFeature.STOP
| CoverEntityFeature.SET_POSITION
)
async def async_open_cover(self, **kwargs: Any) -> None:
"""Open the cover."""
await self.send_api_command(CommonCommands.ON)
await asyncio.sleep(COVER_ENTITY_AFTER_COMMAND_REFRESH)
await self.coordinator.async_request_refresh()
async def async_close_cover(self, **kwargs: Any) -> None:
"""Close the cover."""
await self.send_api_command(CommonCommands.OFF)
await asyncio.sleep(COVER_ENTITY_AFTER_COMMAND_REFRESH)
await self.coordinator.async_request_refresh()
async def async_set_cover_position(self, **kwargs: Any) -> None:
"""Move the cover to a specific position."""
position: int | None = kwargs.get("position")
if position is not None:
await self.send_api_command(
CurtainCommands.SET_POSITION,
parameters=f"{0},ff,{100 - position}",
)
await asyncio.sleep(COVER_ENTITY_AFTER_COMMAND_REFRESH)
await self.coordinator.async_request_refresh()
async def async_stop_cover(self, **kwargs: Any) -> None:
"""Stop the cover."""
await self.send_api_command(CurtainCommands.PAUSE)
await self.coordinator.async_request_refresh()
class SwitchBotCloudCoverRollerShade(SwitchBotCloudCover):
"""Representation of a SwitchBot RollerShade."""
_attr_device_class = CoverDeviceClass.SHADE
_attr_supported_features: CoverEntityFeature = (
CoverEntityFeature.SET_POSITION
| CoverEntityFeature.OPEN
| CoverEntityFeature.CLOSE
)
async def async_open_cover(self, **kwargs: Any) -> None:
"""Open the cover."""
await self.send_api_command(RollerShadeCommands.SET_POSITION, parameters=str(0))
await asyncio.sleep(COVER_ENTITY_AFTER_COMMAND_REFRESH)
await self.coordinator.async_request_refresh()
async def async_close_cover(self, **kwargs: Any) -> None:
"""Close the cover."""
await self.send_api_command(
RollerShadeCommands.SET_POSITION, parameters=str(100)
)
await asyncio.sleep(COVER_ENTITY_AFTER_COMMAND_REFRESH)
await self.coordinator.async_request_refresh()
async def async_set_cover_position(self, **kwargs: Any) -> None:
"""Move the cover to a specific position."""
position: int | None = kwargs.get("position")
if position is not None:
await self.send_api_command(
RollerShadeCommands.SET_POSITION, parameters=str(100 - position)
)
await asyncio.sleep(COVER_ENTITY_AFTER_COMMAND_REFRESH)
await self.coordinator.async_request_refresh()
class SwitchBotCloudCoverBlindTilt(SwitchBotCloudCover):
"""Representation of a SwitchBot Blind Tilt."""
_attr_direction: str | None = None
_attr_device_class = CoverDeviceClass.BLIND
_attr_supported_features: CoverEntityFeature = (
CoverEntityFeature.SET_TILT_POSITION
| CoverEntityFeature.OPEN_TILT
| CoverEntityFeature.CLOSE_TILT
)
def _set_attributes(self) -> None:
if self.coordinator.data is None:
return
position: int | None = self.coordinator.data.get("slidePosition")
if position is None:
return
self._attr_is_closed = position in [0, 100]
if position > 50:
percent = 100 - ((position - 50) * 2)
else:
percent = 100 - (50 - position) * 2
self._attr_current_cover_position = percent
self._attr_current_cover_tilt_position = percent
direction = self.coordinator.data.get("direction")
self._attr_direction = direction.lower() if direction else None
async def async_set_cover_tilt_position(self, **kwargs: Any) -> None:
"""Move the cover to a specific position."""
percent: int | None = kwargs.get("tilt_position")
if percent is not None:
await self.send_api_command(
BlindTiltCommands.SET_POSITION,
parameters=f"{self._attr_direction};{percent}",
)
await asyncio.sleep(COVER_ENTITY_AFTER_COMMAND_REFRESH)
await self.coordinator.async_request_refresh()
async def async_open_cover_tilt(self, **kwargs: Any) -> None:
"""Open the cover."""
await self.send_api_command(BlindTiltCommands.FULLY_OPEN)
await asyncio.sleep(COVER_ENTITY_AFTER_COMMAND_REFRESH)
await self.coordinator.async_request_refresh()
async def async_close_cover_tilt(self, **kwargs: Any) -> None:
"""Close the cover."""
if self._attr_direction is not None:
if "up" in self._attr_direction:
await self.send_api_command(BlindTiltCommands.CLOSE_UP)
else:
await self.send_api_command(BlindTiltCommands.CLOSE_DOWN)
await asyncio.sleep(COVER_ENTITY_AFTER_COMMAND_REFRESH)
await self.coordinator.async_request_refresh()
class SwitchBotCloudCoverGarageDoorOpener(SwitchBotCloudCover):
"""Representation of a SwitchBot Garage Door Opener."""
_attr_device_class = CoverDeviceClass.GARAGE
_attr_supported_features: CoverEntityFeature = (
CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE
)
def _set_attributes(self) -> None:
if self.coordinator.data is None:
return
door_status: int | None = self.coordinator.data.get("doorStatus")
self._attr_is_closed = None if door_status is None else door_status == 1
async def async_open_cover(self, **kwargs: Any) -> None:
"""Open the cover."""
await self.send_api_command(CommonCommands.ON)
await asyncio.sleep(COVER_ENTITY_AFTER_COMMAND_REFRESH)
await self.coordinator.async_request_refresh()
async def async_close_cover(self, **kwargs: Any) -> None:
"""Close the cover."""
await self.send_api_command(CommonCommands.OFF)
await asyncio.sleep(COVER_ENTITY_AFTER_COMMAND_REFRESH)
await self.coordinator.async_request_refresh()
@callback
def _async_make_entity(
api: SwitchBotAPI, device: Device | Remote, coordinator: SwitchBotCoordinator
) -> (
SwitchBotCloudCoverBlindTilt
| SwitchBotCloudCoverRollerShade
| SwitchBotCloudCoverCurtain
| SwitchBotCloudCoverGarageDoorOpener
):
"""Make a SwitchBotCloudCover device."""
if device.device_type == "Blind Tilt":
return SwitchBotCloudCoverBlindTilt(api, device, coordinator)
if device.device_type == "Roller Shade":
return SwitchBotCloudCoverRollerShade(api, device, coordinator)
if device.device_type == "Garage Door Opener":
return SwitchBotCloudCoverGarageDoorOpener(api, device, coordinator)
return SwitchBotCloudCoverCurtain(api, device, coordinator)

View File

@@ -15,7 +15,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import SwitchbotCloudData
from .const import DOMAIN
from .const import AFTER_COMMAND_REFRESH, DOMAIN
from .entity import SwitchBotCloudEntity
@@ -88,13 +88,13 @@ class SwitchBotCloudFan(SwitchBotCloudEntity, FanEntity):
command=BatteryCirculatorFanCommands.SET_WIND_SPEED,
parameters=str(self.percentage),
)
await asyncio.sleep(5)
await asyncio.sleep(AFTER_COMMAND_REFRESH)
await self.coordinator.async_request_refresh()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the fan."""
await self.send_api_command(CommonCommands.OFF)
await asyncio.sleep(5)
await asyncio.sleep(AFTER_COMMAND_REFRESH)
await self.coordinator.async_request_refresh()
async def async_set_percentage(self, percentage: int) -> None:
@@ -107,7 +107,7 @@ class SwitchBotCloudFan(SwitchBotCloudEntity, FanEntity):
command=BatteryCirculatorFanCommands.SET_WIND_SPEED,
parameters=str(percentage),
)
await asyncio.sleep(5)
await asyncio.sleep(AFTER_COMMAND_REFRESH)
await self.coordinator.async_request_refresh()
async def async_set_preset_mode(self, preset_mode: str) -> None:
@@ -116,5 +116,5 @@ class SwitchBotCloudFan(SwitchBotCloudEntity, FanEntity):
command=BatteryCirculatorFanCommands.SET_WIND_MODE,
parameters=preset_mode,
)
await asyncio.sleep(5)
await asyncio.sleep(AFTER_COMMAND_REFRESH)
await self.coordinator.async_request_refresh()

View File

@@ -139,6 +139,10 @@ SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES = {
"Smart Lock Lite": (BATTERY_DESCRIPTION,),
"Smart Lock Pro": (BATTERY_DESCRIPTION,),
"Smart Lock Ultra": (BATTERY_DESCRIPTION,),
"Curtain": (BATTERY_DESCRIPTION,),
"Curtain3": (BATTERY_DESCRIPTION,),
"Roller Shade": (BATTERY_DESCRIPTION,),
"Blind Tilt": (BATTERY_DESCRIPTION,),
}

View File

@@ -34,16 +34,20 @@ class AbstractTemplateEntity(Entity):
self._action_scripts: dict[str, Script] = {}
if self._optimistic_entity:
optimistic = config.get(CONF_OPTIMISTIC)
self._template = config.get(CONF_STATE)
optimistic = self._template is None
assumed_optimistic = self._template is None
if self._extra_optimistic_options:
optimistic = optimistic and all(
assumed_optimistic = assumed_optimistic and all(
config.get(option) is None
for option in self._extra_optimistic_options
)
self._attr_assumed_state = optimistic or config.get(CONF_OPTIMISTIC, False)
self._attr_assumed_state = optimistic or (
optimistic is None and assumed_optimistic
)
if (object_id := config.get(CONF_OBJECT_ID)) is not None:
self.entity_id = async_generate_entity_id(

View File

@@ -102,7 +102,7 @@ TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA = vol.Schema(
TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA = {
vol.Optional(CONF_OPTIMISTIC, default=False): cv.boolean,
vol.Optional(CONF_OPTIMISTIC): cv.boolean,
}

View File

@@ -12,5 +12,5 @@
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/tilt_ble",
"iot_class": "local_push",
"requirements": ["tilt-ble==0.2.3"]
"requirements": ["tilt-ble==0.3.1"]
}

View File

@@ -14,5 +14,5 @@
"documentation": "https://www.home-assistant.io/integrations/togrill",
"iot_class": "local_push",
"quality_scale": "bronze",
"requirements": ["togrill-bluetooth==0.4.0"]
"requirements": ["togrill-bluetooth==0.7.0"]
}

View File

@@ -40,7 +40,7 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["uiprotect", "unifi_discovery"],
"requirements": ["uiprotect==7.20.0", "unifi-discovery==1.2.0"],
"requirements": ["uiprotect==7.21.1", "unifi-discovery==1.2.0"],
"ssdp": [
{
"manufacturer": "Ubiquiti Networks",

View File

@@ -48,6 +48,10 @@ class VeraEntity[_DeviceTypeT: veraApi.VeraDevice](Entity):
"""Subscribe to updates."""
self.controller.register(self.vera_device, self._update_callback)
async def async_will_remove_from_hass(self) -> None:
"""Unsubscribe from updates."""
self.controller.unregister(self.vera_device, self._update_callback)
def _update_callback(self, _device: _DeviceTypeT) -> None:
"""Update the state."""
self.schedule_update_ha_state(True)

View File

@@ -187,4 +187,5 @@ class VodafoneStationRouter(DataUpdateCoordinator[UpdateCoordinatorDataType]):
model=sensors_data.get("sys_model_name"),
hw_version=sensors_data["sys_hardware_version"],
sw_version=sensors_data["sys_firmware_version"],
serial_number=self.serial_number,
)

View File

@@ -69,7 +69,7 @@ class VolvoOAuth2FlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN):
async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult:
"""Create an entry for the flow."""
self._config_data |= data
self._config_data |= (self.init_data or {}) | data
return await self.async_step_api_key()
async def async_step_reauth(self, _: Mapping[str, Any]) -> ConfigFlowResult:
@@ -77,7 +77,7 @@ class VolvoOAuth2FlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN):
return await self.async_step_reauth_confirm()
async def async_step_reconfigure(
self, _: dict[str, Any] | None = None
self, data: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Reconfigure the entry."""
return await self.async_step_api_key()
@@ -121,7 +121,7 @@ class VolvoOAuth2FlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN):
if user_input is None:
if self.source == SOURCE_REAUTH:
user_input = self._config_data = dict(self._get_reauth_entry().data)
user_input = self._config_data
api = _create_volvo_cars_api(
self.hass,
self._config_data[CONF_TOKEN][CONF_ACCESS_TOKEN],

View File

@@ -102,7 +102,6 @@ class DeviceCoordinator(DataUpdateCoordinator[None]):
name=wemo.name,
update_interval=timedelta(seconds=30),
)
self.hass = hass
self.wemo = wemo
self.device_id: str | None = None
self.device_info = _create_device_info(wemo)

View File

@@ -86,6 +86,9 @@ def add_province_and_language_to_schema(
SelectOptionDict(value=k, label=", ".join(v))
for k, v in subdiv_aliases.items()
]
for option in province_options:
if option["label"] == "":
option["label"] = option["value"]
else:
province_options = provinces
province_schema = {

View File

@@ -27,4 +27,5 @@ class ZeversolarEntity(
identifiers={(DOMAIN, coordinator.data.serial_number)},
name="Zeversolar Sensor",
manufacturer="Zeversolar",
serial_number=coordinator.data.serial_number,
)

View File

@@ -760,7 +760,7 @@ DISCOVERY_SCHEMAS = [
platform=Platform.SELECT,
hint="multilevel_switch",
manufacturer_id={0x0084},
product_id={0x0107, 0x0108, 0x010B, 0x0205},
product_id={0x0107, 0x0108, 0x0109, 0x010B, 0x0205},
product_type={0x0311, 0x0313, 0x0331, 0x0341, 0x0343},
primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA,
data_template=BaseDiscoverySchemaDataTemplate(

View File

@@ -588,6 +588,7 @@ ATTR_PERSONS: Final = "persons"
class UnitOfApparentPower(StrEnum):
"""Apparent power units."""
MILLIVOLT_AMPERE = "mVA"
VOLT_AMPERE = "VA"
@@ -608,6 +609,7 @@ class UnitOfPower(StrEnum):
class UnitOfReactivePower(StrEnum):
"""Reactive power units."""
MILLIVOLT_AMPERE_REACTIVE = "mvar"
VOLT_AMPERE_REACTIVE = "var"
KILO_VOLT_AMPERE_REACTIVE = "kvar"

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