mirror of
https://github.com/home-assistant/core.git
synced 2025-08-30 18:01:31 +02:00
Merge branch 'dev' into gj-20250813-01
This commit is contained in:
@@ -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: |
|
||||
|
@@ -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: |
|
||||
|
@@ -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]
|
||||
|
@@ -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"]
|
||||
}
|
||||
|
@@ -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}"
|
||||
|
@@ -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]}",
|
||||
|
@@ -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 Claude’s internal reasoning has been automatically "
|
||||
"encrypted for safety reasons. This doesn’t 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
|
||||
|
@@ -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}
|
||||
|
@@ -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"]
|
||||
}
|
||||
|
@@ -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"]
|
||||
}
|
||||
|
@@ -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
|
||||
|
@@ -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:
|
||||
|
@@ -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
|
||||
}
|
||||
|
@@ -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()
|
||||
|
@@ -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(
|
||||
|
@@ -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",
|
||||
|
@@ -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:
|
||||
|
@@ -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)
|
||||
|
@@ -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)
|
||||
|
||||
|
@@ -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"]
|
||||
}
|
||||
|
@@ -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."""
|
||||
|
@@ -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(),
|
||||
|
@@ -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)
|
||||
|
@@ -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)
|
||||
|
@@ -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."
|
||||
|
@@ -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)
|
||||
|
@@ -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": {
|
||||
|
@@ -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."""
|
||||
|
31
homeassistant/components/fan/intent.py
Normal file
31
homeassistant/components/fan/intent.py
Normal 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)),
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
@@ -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
|
||||
|
@@ -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__(
|
||||
|
@@ -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}"
|
||||
|
@@ -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)
|
||||
|
@@ -28,6 +28,7 @@ class JustNimbusEntity(
|
||||
identifiers={(DOMAIN, device_id)},
|
||||
name="JustNimbus Sensor",
|
||||
manufacturer="JustNimbus",
|
||||
sw_version=coordinator.data.api_version,
|
||||
)
|
||||
|
||||
@property
|
||||
|
@@ -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__(
|
||||
|
@@ -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()
|
||||
|
@@ -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
|
||||
|
@@ -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"]
|
||||
}
|
||||
|
@@ -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."]
|
||||
}
|
||||
|
@@ -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(
|
||||
|
@@ -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": {
|
||||
|
@@ -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
|
||||
|
@@ -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."]
|
||||
}
|
||||
|
@@ -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,
|
||||
|
@@ -7,5 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["nextdns"],
|
||||
"requirements": ["nextdns==4.0.0"]
|
||||
"requirements": ["nextdns==4.1.0"]
|
||||
}
|
||||
|
@@ -52,6 +52,9 @@ async def async_setup_entry(
|
||||
)
|
||||
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
class NINAMessage(CoordinatorEntity[NINADataUpdateCoordinator], BinarySensorEntity):
|
||||
"""Representation of an NINA warning."""
|
||||
|
||||
|
24
homeassistant/components/nina/diagnostics.py
Normal file
24
homeassistant/components/nina/diagnostics.py
Normal 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,
|
||||
}
|
@@ -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:
|
||||
|
@@ -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,
|
||||
|
@@ -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):
|
||||
|
@@ -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"
|
||||
|
@@ -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
|
||||
|
@@ -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}",
|
||||
|
@@ -37,6 +37,9 @@
|
||||
},
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"sensed": {
|
||||
"name": "Sensed"
|
||||
},
|
||||
"sensed_id": {
|
||||
"name": "Sensed {id}"
|
||||
},
|
||||
|
@@ -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"]
|
||||
}
|
||||
|
@@ -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 = {}
|
||||
|
@@ -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",
|
||||
|
@@ -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")
|
||||
|
@@ -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()
|
||||
|
@@ -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] = []
|
||||
|
||||
|
@@ -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"]),
|
||||
)
|
||||
|
@@ -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)
|
||||
|
@@ -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(
|
||||
|
@@ -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(
|
||||
|
144
homeassistant/components/qbus/binary_sensor.py
Normal file
144
homeassistant/components/qbus/binary_sensor.py
Normal 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()
|
@@ -6,6 +6,7 @@ from homeassistant.const import Platform
|
||||
|
||||
DOMAIN: Final = "qbus"
|
||||
PLATFORMS: list[Platform] = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.CLIMATE,
|
||||
Platform.COVER,
|
||||
Platform.LIGHT,
|
||||
|
@@ -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:
|
||||
|
@@ -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:
|
||||
|
12
homeassistant/components/qbus/icons.json
Normal file
12
homeassistant/components/qbus/icons.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"raining": {
|
||||
"default": "mdi:weather-pouring"
|
||||
},
|
||||
"twilight": {
|
||||
"default": "mdi:weather-sunset"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -17,6 +17,14 @@
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"raining": {
|
||||
"name": "Raining"
|
||||
},
|
||||
"twilight": {
|
||||
"name": "Twilight"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"daylight": {
|
||||
"name": "Daylight"
|
||||
|
@@ -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__(
|
||||
|
@@ -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
|
||||
|
@@ -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),
|
||||
|
@@ -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),
|
||||
|
@@ -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
|
||||
|
@@ -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:
|
||||
|
@@ -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,
|
||||
|
@@ -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"
|
||||
|
@@ -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)
|
||||
|
@@ -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"]
|
||||
}
|
||||
|
@@ -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,
|
||||
|
@@ -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",
|
||||
|
@@ -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,),
|
||||
}
|
||||
|
||||
|
||||
|
@@ -17,3 +17,5 @@ VACUUM_FAN_SPEED_STRONG = "strong"
|
||||
VACUUM_FAN_SPEED_MAX = "max"
|
||||
|
||||
AFTER_COMMAND_REFRESH = 5
|
||||
|
||||
COVER_ENTITY_AFTER_COMMAND_REFRESH = 10
|
||||
|
233
homeassistant/components/switchbot_cloud/cover.py
Normal file
233
homeassistant/components/switchbot_cloud/cover.py
Normal 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)
|
@@ -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()
|
||||
|
@@ -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,),
|
||||
}
|
||||
|
||||
|
||||
|
@@ -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(
|
||||
|
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
@@ -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"]
|
||||
}
|
||||
|
@@ -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"]
|
||||
}
|
||||
|
@@ -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",
|
||||
|
@@ -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)
|
||||
|
@@ -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,
|
||||
)
|
||||
|
@@ -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],
|
||||
|
@@ -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)
|
||||
|
@@ -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 = {
|
||||
|
@@ -27,4 +27,5 @@ class ZeversolarEntity(
|
||||
identifiers={(DOMAIN, coordinator.data.serial_number)},
|
||||
name="Zeversolar Sensor",
|
||||
manufacturer="Zeversolar",
|
||||
serial_number=coordinator.data.serial_number,
|
||||
)
|
||||
|
@@ -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(
|
||||
|
@@ -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
Reference in New Issue
Block a user