mirror of
https://github.com/home-assistant/core.git
synced 2026-05-12 16:34:19 +00:00
Compare commits
297 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9aa9278eec | |||
| 05121b89c6 | |||
| 326895f0a1 | |||
| 45121eddf1 | |||
| 5e4f8f8bff | |||
| b9bbe36af0 | |||
| b56cdb9106 | |||
| e975496145 | |||
| cdeb550b87 | |||
| 62082bdf14 | |||
| 891efeb9cb | |||
| dc8abff6b9 | |||
| aa7474839b | |||
| 06a96712f6 | |||
| 97be8f485a | |||
| a9c23ff445 | |||
| cd92cb1258 | |||
| c3f01b3a23 | |||
| 4b232be04a | |||
| cd5e21d3ac | |||
| 84d5085f3b | |||
| 44e94a82f1 | |||
| fe0da5c34f | |||
| c0200084ec | |||
| ef63ab5def | |||
| 3683607820 | |||
| 4c70fef2da | |||
| d956af095e | |||
| ea34fe4107 | |||
| e1c81c9b9e | |||
| 4ea0e6b240 | |||
| 0ae5a19602 | |||
| 80c7e47c42 | |||
| dfe4085189 | |||
| 65a12b48e7 | |||
| cd639b829c | |||
| ea5b633574 | |||
| 2f2413c979 | |||
| 799bcb0f88 | |||
| d3cf5d9aab | |||
| d2fddf129d | |||
| d19c2506bf | |||
| 8fd3d0bb44 | |||
| d62f136c58 | |||
| 86e8b9df9b | |||
| aa5e942528 | |||
| 6636e67af6 | |||
| 30f310fc24 | |||
| de4e1c444e | |||
| eaf72106f8 | |||
| b90a074fb4 | |||
| 3aea7f0695 | |||
| ba8b1b2daf | |||
| 5ff1c15df3 | |||
| 0280d921e5 | |||
| 955e8362e4 | |||
| 1fc0b620c0 | |||
| ab08153d62 | |||
| b47b7fa58c | |||
| c50676dee9 | |||
| 96bd991bb8 | |||
| 7e2a7b9393 | |||
| eb2217cfa6 | |||
| b2269b3dba | |||
| eb85d7cd98 | |||
| 6663717d59 | |||
| 33e5a96a57 | |||
| 7cb4d5ca9c | |||
| 7dacd0080b | |||
| 7f44fe031c | |||
| 9656aaa6bd | |||
| bf4b865e83 | |||
| 73dcc2f5a8 | |||
| fa0cf37e2c | |||
| fa6c6ee4fc | |||
| 4eb000d863 | |||
| d3809dd4cb | |||
| 2f3a6243f7 | |||
| f36799d139 | |||
| 8d5f83e5f1 | |||
| 308cb686d2 | |||
| 63d4f4d03d | |||
| d8a4b36381 | |||
| c048af2e4e | |||
| 1a25864890 | |||
| d1922189aa | |||
| 7594ead857 | |||
| 8ce14877a4 | |||
| 2fb0de3cdb | |||
| 3e77a4bfb2 | |||
| 4b9dd68fe7 | |||
| f21ed9054b | |||
| 53e4d6c8fc | |||
| 6a67c0faf7 | |||
| d590f4f0b5 | |||
| 45a6134209 | |||
| 6893d2b13d | |||
| 370babf542 | |||
| 6c89ecb98b | |||
| cc1eaa72a6 | |||
| 21a3c5b0ed | |||
| 080eb6af84 | |||
| 663538c492 | |||
| d119bbe4ef | |||
| 0eb204508c | |||
| ad836b48b0 | |||
| 9cc9f240e7 | |||
| 91d5c080de | |||
| 9390bf3414 | |||
| a5b65766db | |||
| 9321ff504c | |||
| 8a22e84db0 | |||
| 642206699d | |||
| 5d98f467fb | |||
| 54727a6f20 | |||
| ed371bc644 | |||
| 3cc6cc9519 | |||
| 2053e61a80 | |||
| 3673a80a37 | |||
| 0cc531e333 | |||
| 12280dbe63 | |||
| 84a5ba26d3 | |||
| 6ec4466ad7 | |||
| cb62562f5b | |||
| a381a3a741 | |||
| 6902504087 | |||
| 64f2fa42fc | |||
| 64c9a76fc8 | |||
| e9fc6b3e74 | |||
| 605eea6274 | |||
| 86af61d7b5 | |||
| 45978f41cd | |||
| 4d4e45854f | |||
| 43fa4f2646 | |||
| 5cedb0b726 | |||
| e3de695b99 | |||
| e684490219 | |||
| 70947c612c | |||
| 07c144841f | |||
| 392c46c028 | |||
| 5af3b361c8 | |||
| 040c960ced | |||
| f2787115d0 | |||
| 2dd1632fc3 | |||
| ed1aefc643 | |||
| f1bbe4204b | |||
| 929379799c | |||
| d20d1df382 | |||
| b5e66bbcd0 | |||
| f479b0ad6a | |||
| 458b5fe8bf | |||
| 9621307cb0 | |||
| 4507f9a8d8 | |||
| 19dd68b7fc | |||
| 245b9ed4c0 | |||
| 838feef660 | |||
| ca4d36db1a | |||
| 39b690b22c | |||
| ed560f0ba7 | |||
| 0db50acb89 | |||
| f84bf99105 | |||
| 7fad242ad0 | |||
| fcd6f78f35 | |||
| 056ff957e8 | |||
| 9cf95404cf | |||
| 1fec38ef28 | |||
| 9c4b6951ef | |||
| 7d2f303035 | |||
| c61c09fba3 | |||
| 83c807d01c | |||
| 1b0386ddfc | |||
| 0af6a85049 | |||
| 7bd0bc9c8a | |||
| b200930fd4 | |||
| 5c046a3750 | |||
| c1a013d718 | |||
| f1f6cdae2a | |||
| f98de4618a | |||
| 6b2033b060 | |||
| d9dc2bbae4 | |||
| 82a1884085 | |||
| c46f6721bc | |||
| ed3ff38d30 | |||
| fd3e12a85f | |||
| 1e0a0b70f4 | |||
| 2598dde7aa | |||
| 9af7fe22bd | |||
| 546eef2eee | |||
| d65b7ce2f3 | |||
| b09671a409 | |||
| 412771465d | |||
| 3a72bc23b9 | |||
| b3d7ba5ce5 | |||
| 4e7b6838eb | |||
| 437f5ef66c | |||
| f886b03e14 | |||
| 190ee49e3a | |||
| f7c5a51f46 | |||
| e4e9c22016 | |||
| f2df848e3f | |||
| cdce98faaf | |||
| fde103cdfd | |||
| fcd6c6e335 | |||
| 8f2cec26e3 | |||
| 05463cde99 | |||
| a948799a6e | |||
| 624fab064a | |||
| a331cb7199 | |||
| 7d6eaf40a6 | |||
| 1ae9e7c87d | |||
| 6bcfc32d48 | |||
| 0b5f85bdb9 | |||
| d153eee822 | |||
| afcc2113ce | |||
| ae5bd63993 | |||
| 78107c478d | |||
| 84490ef0bb | |||
| 887e14638b | |||
| 818bde1d5e | |||
| 83da18b761 | |||
| bd904caea1 | |||
| 500f030eaa | |||
| ce755f5f8f | |||
| fb766d164b | |||
| 394670e33f | |||
| f79285f9ab | |||
| a422611ada | |||
| 4c34dcd560 | |||
| 1aca993c12 | |||
| a8cc099b66 | |||
| c56d67c02f | |||
| 0ce98cfb34 | |||
| 4a13ab9aff | |||
| dc65646d8b | |||
| 39fbdad775 | |||
| b4f6a43a14 | |||
| e5ff7a9944 | |||
| ca9945f750 | |||
| b028e2a6ae | |||
| 6f4aca495b | |||
| a892b5364d | |||
| f57e682a98 | |||
| 3493517b6d | |||
| b5842b8484 | |||
| 3333b8d019 | |||
| 745860553c | |||
| 7188a09a59 | |||
| 96a9b89412 | |||
| 586d7ab526 | |||
| 5f2fe4ffd4 | |||
| 040192c103 | |||
| e85430105e | |||
| 5c7c0a6e83 | |||
| c7bd673d01 | |||
| 6d3a93df81 | |||
| 6a934b5fe3 | |||
| d644348dc8 | |||
| dbfde9266c | |||
| ed0b68ec4a | |||
| c32d523f63 | |||
| 98a4e27e35 | |||
| fb1365e9a4 | |||
| 850b034a5f | |||
| b880876e0e | |||
| ab601e5717 | |||
| 7eda592c72 | |||
| b981ece163 | |||
| 7ea931fdc8 | |||
| f3038a20af | |||
| de234c7190 | |||
| 399681984f | |||
| 5ca14ca7d7 | |||
| ac53cfa85a | |||
| 02f1a9c3a9 | |||
| f93fdceac9 | |||
| 711a89f7b8 | |||
| 19e58c554e | |||
| feb6c2bfe6 | |||
| 6bb91422ff | |||
| 3bd699285b | |||
| 6d10305197 | |||
| 42a9c8488d | |||
| c6c273559e | |||
| f7394ce302 | |||
| 175dec6f1a | |||
| d137761cb5 | |||
| 8055cbc58d | |||
| c9dff27590 | |||
| c913a858b6 | |||
| 4ed33a804e | |||
| 8bf5674826 | |||
| b8a0b0083b | |||
| a57c101b5e | |||
| 957b8c1c52 | |||
| bb002d051b | |||
| 2b2fd4ac92 | |||
| f4c270629b |
@@ -5,7 +5,7 @@
|
||||
# Copilot code review instructions
|
||||
|
||||
- Start review comments with a short, one-sentence summary of the suggested fix.
|
||||
- Do not add comments about code style, formatting or linting issues.
|
||||
- Do not comment on code style, formatting or linting issues.
|
||||
|
||||
# GitHub Copilot & Claude Code Instructions
|
||||
|
||||
@@ -34,8 +34,3 @@ Integrations with Platinum or Gold level in the Integration Quality Scale reflec
|
||||
|
||||
When reviewing entity actions, do not suggest extra defensive checks for input fields that are already validated by Home Assistant's service/action schemas and entity selection filters. Suggest additional guards only when data bypasses those validators or is transformed into a less-safe form.
|
||||
When validation guarantees a dict key exists, prefer direct key access (`data["key"]`) instead of `.get("key")` so contract violations are surfaced instead of silently masked.
|
||||
|
||||
|
||||
# Skills
|
||||
|
||||
- ha-integration-knowledge: .claude/skills/ha-integration-knowledge/SKILL.md
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
applyTo: "homeassistant/components/**, tests/components/**"
|
||||
excludeAgent: "cloud-agent"
|
||||
---
|
||||
|
||||
<!-- Automatically generated by gen_copilot_instructions.py, do not edit -->
|
||||
|
||||
|
||||
## File Locations
|
||||
- **Integration code**: `./homeassistant/components/<integration_domain>/`
|
||||
- **Integration tests**: `./tests/components/<integration_domain>/`
|
||||
|
||||
## General guidelines
|
||||
|
||||
- When looking for examples, prefer integrations with the platinum or gold quality scale level first.
|
||||
- Polling intervals are NOT user-configurable. Never add scan_interval, update_interval, or polling frequency options to config flows or config entries.
|
||||
- Do NOT allow users to set config entry names in config flows. Names are automatically generated or can be customized later in UI. Exception: helper integrations may allow custom names.
|
||||
- For entity actions and entity services, avoid requesting redundant defensive checks for fields already enforced by Home Assistant validation schemas and entity filters; only request extra guards when values bypass validation or are transformed unsafely.
|
||||
- When validation guarantees a key is present, prefer direct dictionary indexing (`data["key"]`) over `.get("key")` so invalid assumptions fail fast.
|
||||
- Integrations should be thin wrappers. Protocol parsing, device state machines, or other domain logic belong in a separate PyPI library, not in the integration itself. If unsure, ask before inlining.
|
||||
- "potato" is a forbidden word for an integration and should never be used.
|
||||
|
||||
The following platforms have extra guidelines:
|
||||
- **Diagnostics**: [`platform-diagnostics.md`](platform-diagnostics.md) for diagnostic data collection
|
||||
- **Repairs**: [`platform-repairs.md`](platform-repairs.md) for user-actionable repair issues
|
||||
|
||||
|
||||
## Integration Quality Scale
|
||||
|
||||
- When validating the quality scale rules, check them at https://developers.home-assistant.io/docs/core/integration-quality-scale/rules
|
||||
- When implementing or reviewing an integration, always consider the quality scale rules, since they promote best practices.
|
||||
|
||||
Template scale file: `./script/scaffold/templates/integration/integration/quality_scale.yaml`
|
||||
|
||||
### How Rules Apply
|
||||
1. **Check `manifest.json`**: Look for `"quality_scale"` key to determine integration level
|
||||
2. **Bronze Rules**: Always required for any integration with quality scale
|
||||
3. **Higher Tier Rules**: Only apply if integration targets that tier or higher
|
||||
4. **Rule Status**: Check `quality_scale.yaml` in integration folder for:
|
||||
- `done`: Rule implemented
|
||||
- `exempt`: Rule doesn't apply (with reason in comment)
|
||||
- `todo`: Rule needs implementation
|
||||
|
||||
|
||||
## Testing Requirements
|
||||
|
||||
- Tests should avoid interacting or mocking internal integration details. For more info, see https://developers.home-assistant.io/docs/development_testing/#writing-tests-for-integrations
|
||||
@@ -14,7 +14,7 @@ env:
|
||||
UV_HTTP_TIMEOUT: 60
|
||||
UV_SYSTEM_PYTHON: "true"
|
||||
# Base image version from https://github.com/home-assistant/docker
|
||||
BASE_IMAGE_VERSION: "2026.01.0"
|
||||
BASE_IMAGE_VERSION: "2026.02.0"
|
||||
ARCHITECTURES: '["amd64", "aarch64"]'
|
||||
|
||||
permissions: {}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
3.14.2
|
||||
3.14.3
|
||||
|
||||
Generated
+2
@@ -1203,6 +1203,8 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/notify_events/ @matrozov @papajojo
|
||||
/homeassistant/components/notion/ @bachya
|
||||
/tests/components/notion/ @bachya
|
||||
/homeassistant/components/novy_cooker_hood/ @piitaya
|
||||
/tests/components/novy_cooker_hood/ @piitaya
|
||||
/homeassistant/components/nrgkick/ @andijakl
|
||||
/tests/components/nrgkick/ @andijakl
|
||||
/homeassistant/components/nsw_fuel_station/ @nickw444
|
||||
|
||||
@@ -25,8 +25,7 @@
|
||||
"data": {
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]",
|
||||
"latitude": "[%key:common::config_flow::data::latitude%]",
|
||||
"longitude": "[%key:common::config_flow::data::longitude%]",
|
||||
"name": "[%key:common::config_flow::data::name%]"
|
||||
"longitude": "[%key:common::config_flow::data::longitude%]"
|
||||
},
|
||||
"data_description": {
|
||||
"api_key": "API key generated in the AccuWeather APIs portal."
|
||||
|
||||
@@ -38,6 +38,7 @@ HVAC_MODE_MAPPING_ACTRONAIR_TO_HA = {
|
||||
"HEAT": HVACMode.HEAT,
|
||||
"FAN": HVACMode.FAN_ONLY,
|
||||
"AUTO": HVACMode.AUTO,
|
||||
"DRY": HVACMode.DRY,
|
||||
"OFF": HVACMode.OFF,
|
||||
}
|
||||
HVAC_MODE_MAPPING_HA_TO_ACTRONAIR = {
|
||||
@@ -79,7 +80,6 @@ class ActronAirClimateEntity(ClimateEntity):
|
||||
)
|
||||
_attr_name = None
|
||||
_attr_fan_modes = list(FAN_MODE_MAPPING_ACTRONAIR_TO_HA.values())
|
||||
_attr_hvac_modes = list(HVAC_MODE_MAPPING_ACTRONAIR_TO_HA.values())
|
||||
|
||||
|
||||
class ActronSystemClimate(ActronAirAcEntity, ActronAirClimateEntity):
|
||||
@@ -93,6 +93,17 @@ class ActronSystemClimate(ActronAirAcEntity, ActronAirClimateEntity):
|
||||
super().__init__(coordinator)
|
||||
self._attr_unique_id = self._serial_number
|
||||
|
||||
@property
|
||||
def hvac_modes(self) -> list[HVACMode]:
|
||||
"""Return the list of supported HVAC modes."""
|
||||
modes = [
|
||||
HVAC_MODE_MAPPING_ACTRONAIR_TO_HA[mode]
|
||||
for mode in self._status.user_aircon_settings.supported_modes
|
||||
if mode in HVAC_MODE_MAPPING_ACTRONAIR_TO_HA
|
||||
]
|
||||
modes.append(HVACMode.OFF)
|
||||
return modes
|
||||
|
||||
@property
|
||||
def min_temp(self) -> float:
|
||||
"""Return the minimum temperature that can be set."""
|
||||
@@ -179,6 +190,18 @@ class ActronZoneClimate(ActronAirZoneEntity, ActronAirClimateEntity):
|
||||
super().__init__(coordinator, zone)
|
||||
self._attr_unique_id: str = self._zone_identifier
|
||||
|
||||
@property
|
||||
def hvac_modes(self) -> list[HVACMode]:
|
||||
"""Return the list of supported HVAC modes."""
|
||||
status = self.coordinator.data
|
||||
modes = [
|
||||
HVAC_MODE_MAPPING_ACTRONAIR_TO_HA[mode]
|
||||
for mode in status.user_aircon_settings.supported_modes
|
||||
if mode in HVAC_MODE_MAPPING_ACTRONAIR_TO_HA
|
||||
]
|
||||
modes.append(HVACMode.OFF)
|
||||
return modes
|
||||
|
||||
@property
|
||||
def min_temp(self) -> float:
|
||||
"""Return the minimum temperature that can be set."""
|
||||
|
||||
@@ -13,5 +13,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["actron-neo-api==0.5.5"]
|
||||
"requirements": ["actron-neo-api==0.5.6"]
|
||||
}
|
||||
|
||||
@@ -127,7 +127,7 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = (
|
||||
),
|
||||
AirlySensorEntityDescription(
|
||||
key=ATTR_API_CO,
|
||||
translation_key="co",
|
||||
device_class=SensorDeviceClass.CO,
|
||||
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=0,
|
||||
|
||||
@@ -23,9 +23,6 @@
|
||||
"sensor": {
|
||||
"caqi": {
|
||||
"name": "Common air quality index"
|
||||
},
|
||||
"co": {
|
||||
"name": "[%key:component::sensor::entity_component::carbon_monoxide::name%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""Base entity for Anthropic."""
|
||||
|
||||
import base64
|
||||
from collections.abc import AsyncGenerator, Callable, Iterable
|
||||
from collections import deque
|
||||
from collections.abc import AsyncIterator, Callable, Iterable
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
import json
|
||||
@@ -20,18 +21,22 @@ from anthropic.types import (
|
||||
CitationWebSearchResultLocationParam,
|
||||
CodeExecutionTool20250825Param,
|
||||
CodeExecutionToolResultBlock,
|
||||
CodeExecutionToolResultBlockContent,
|
||||
CodeExecutionToolResultBlockParamContentParam,
|
||||
Container,
|
||||
ContentBlock,
|
||||
ContentBlockParam,
|
||||
DocumentBlockParam,
|
||||
ImageBlockParam,
|
||||
InputJSONDelta,
|
||||
JSONOutputFormatParam,
|
||||
Message,
|
||||
MessageDeltaUsage,
|
||||
MessageParam,
|
||||
MessageStreamEvent,
|
||||
ModelInfo,
|
||||
OutputConfigParam,
|
||||
RawContentBlockDelta,
|
||||
RawContentBlockDeltaEvent,
|
||||
RawContentBlockStartEvent,
|
||||
RawContentBlockStopEvent,
|
||||
@@ -68,18 +73,30 @@ from anthropic.types import (
|
||||
WebSearchTool20250305Param,
|
||||
WebSearchTool20260209Param,
|
||||
WebSearchToolResultBlock,
|
||||
WebSearchToolResultBlockContent,
|
||||
WebSearchToolResultBlockParamContentParam,
|
||||
)
|
||||
from anthropic.types.bash_code_execution_tool_result_block import (
|
||||
Content as BashCodeExecutionToolResultBlockContent,
|
||||
)
|
||||
from anthropic.types.bash_code_execution_tool_result_block_param import (
|
||||
Content as BashCodeExecutionToolResultBlockParamContentParam,
|
||||
)
|
||||
from anthropic.types.message_create_params import MessageCreateParamsStreaming
|
||||
from anthropic.types.raw_message_delta_event import Delta
|
||||
from anthropic.types.text_editor_code_execution_tool_result_block import (
|
||||
Content as TextEditorCodeExecutionToolResultBlockContent,
|
||||
)
|
||||
from anthropic.types.text_editor_code_execution_tool_result_block_param import (
|
||||
Content as TextEditorCodeExecutionToolResultBlockParamContentParam,
|
||||
)
|
||||
from anthropic.types.tool_search_tool_result_block import (
|
||||
Content as ToolSearchToolResultBlockContent,
|
||||
)
|
||||
from anthropic.types.tool_search_tool_result_block_param import (
|
||||
Content as ToolSearchToolResultBlockParamContentParam,
|
||||
)
|
||||
from anthropic.types.tool_use_block import Caller
|
||||
import voluptuous as vol
|
||||
from voluptuous_openapi import convert
|
||||
|
||||
@@ -91,7 +108,7 @@ from homeassistant.helpers import device_registry as dr, llm
|
||||
from homeassistant.helpers.json import json_dumps
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from homeassistant.util import slugify
|
||||
from homeassistant.util.json import JsonObjectType
|
||||
from homeassistant.util.json import JsonArrayType, JsonObjectType
|
||||
|
||||
from .const import (
|
||||
CONF_CHAT_MODEL,
|
||||
@@ -445,13 +462,7 @@ def _convert_content( # noqa: C901
|
||||
return messages, container_id
|
||||
|
||||
|
||||
async def _transform_stream( # noqa: C901 - This is complex, but better to have it in one place
|
||||
chat_log: conversation.ChatLog,
|
||||
stream: AsyncStream[MessageStreamEvent],
|
||||
output_tool: str | None = None,
|
||||
) -> AsyncGenerator[
|
||||
conversation.AssistantContentDeltaDict | conversation.ToolResultContentDeltaDict
|
||||
]:
|
||||
class AnthropicDeltaStream:
|
||||
"""Transform the response stream into HA format.
|
||||
|
||||
A typical stream of responses might look something like the following:
|
||||
@@ -481,201 +492,376 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
|
||||
|
||||
Each message could contain multiple blocks of the same type.
|
||||
"""
|
||||
if stream is None or not hasattr(stream, "__aiter__"):
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN, translation_key="unexpected_stream_object"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
chat_log: conversation.ChatLog,
|
||||
stream: AsyncStream[MessageStreamEvent],
|
||||
output_tool: str | None = None,
|
||||
) -> None:
|
||||
"""Initialize the delta stream."""
|
||||
self._chat_log: conversation.ChatLog = chat_log
|
||||
self._stream: AsyncStream[MessageStreamEvent] = stream
|
||||
self._output_tool: str | None = output_tool
|
||||
|
||||
self._buffer: deque[
|
||||
conversation.AssistantContentDeltaDict
|
||||
| conversation.ToolResultContentDeltaDict
|
||||
] = deque()
|
||||
self._stream_iterator: AsyncIterator[MessageStreamEvent] | None = None
|
||||
|
||||
self._current_tool_block: ToolUseBlockParam | ServerToolUseBlockParam | None = (
|
||||
None
|
||||
)
|
||||
self._current_tool_args: str = ""
|
||||
self._content_details = ContentDetails()
|
||||
self._content_details.add_citation_detail()
|
||||
self._input_usage: Usage | None = None
|
||||
self._first_block: bool = True
|
||||
|
||||
current_tool_block: ToolUseBlockParam | ServerToolUseBlockParam | None = None
|
||||
current_tool_args: str
|
||||
content_details = ContentDetails()
|
||||
content_details.add_citation_detail()
|
||||
input_usage: Usage | None = None
|
||||
first_block: bool = True
|
||||
def __aiter__(
|
||||
self,
|
||||
) -> AsyncIterator[
|
||||
conversation.AssistantContentDeltaDict | conversation.ToolResultContentDeltaDict
|
||||
]:
|
||||
"""Initialize the stream and return the async iterator."""
|
||||
if self._stream is None or not hasattr(self._stream, "__aiter__"):
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN, translation_key="unexpected_stream_object"
|
||||
)
|
||||
if self._stream_iterator is None:
|
||||
self._stream_iterator = self._stream.__aiter__()
|
||||
return self
|
||||
|
||||
async for response in stream:
|
||||
LOGGER.debug("Received response: %s", response)
|
||||
async def __anext__(
|
||||
self,
|
||||
) -> (
|
||||
conversation.AssistantContentDeltaDict | conversation.ToolResultContentDeltaDict
|
||||
):
|
||||
"""Get the next item from the stream."""
|
||||
while True:
|
||||
if self._buffer:
|
||||
return self._buffer.popleft()
|
||||
|
||||
if isinstance(response, RawMessageStartEvent):
|
||||
input_usage = response.message.usage
|
||||
first_block = True
|
||||
elif isinstance(response, RawContentBlockStartEvent):
|
||||
if isinstance(response.content_block, ToolUseBlock):
|
||||
current_tool_block = ToolUseBlockParam(
|
||||
type="tool_use",
|
||||
id=response.content_block.id,
|
||||
name=response.content_block.name,
|
||||
input=response.content_block.input or {},
|
||||
)
|
||||
current_tool_args = ""
|
||||
if response.content_block.name == output_tool:
|
||||
if first_block or content_details.has_content():
|
||||
if content_details:
|
||||
content_details.delete_empty()
|
||||
yield {"native": content_details}
|
||||
content_details = ContentDetails()
|
||||
content_details.add_citation_detail()
|
||||
yield {"role": "assistant"}
|
||||
first_block = False
|
||||
elif isinstance(response.content_block, TextBlock):
|
||||
if ( # Do not start a new assistant content just for citations, concatenate consecutive blocks with citations instead.
|
||||
first_block
|
||||
or (
|
||||
not content_details.has_citations()
|
||||
and response.content_block.citations is None
|
||||
and content_details.has_content()
|
||||
)
|
||||
):
|
||||
if content_details:
|
||||
content_details.delete_empty()
|
||||
yield {"native": content_details}
|
||||
content_details = ContentDetails()
|
||||
yield {"role": "assistant"}
|
||||
first_block = False
|
||||
content_details.add_citation_detail()
|
||||
if response.content_block.text:
|
||||
content_details.citation_details[-1].length += len(
|
||||
response.content_block.text
|
||||
)
|
||||
yield {"content": response.content_block.text}
|
||||
elif isinstance(response.content_block, ThinkingBlock):
|
||||
if first_block or content_details.thinking_signature:
|
||||
if content_details:
|
||||
content_details.delete_empty()
|
||||
yield {"native": content_details}
|
||||
content_details = ContentDetails()
|
||||
content_details.add_citation_detail()
|
||||
yield {"role": "assistant"}
|
||||
first_block = False
|
||||
elif isinstance(response.content_block, RedactedThinkingBlock):
|
||||
LOGGER.debug(
|
||||
"Some of Claude’s internal reasoning has been automatically "
|
||||
"encrypted for safety reasons. This doesn’t affect the quality of "
|
||||
"responses"
|
||||
)
|
||||
if first_block or content_details.redacted_thinking:
|
||||
if content_details:
|
||||
content_details.delete_empty()
|
||||
yield {"native": content_details}
|
||||
content_details = ContentDetails()
|
||||
content_details.add_citation_detail()
|
||||
yield {"role": "assistant"}
|
||||
first_block = False
|
||||
content_details.redacted_thinking = response.content_block.data
|
||||
elif isinstance(response.content_block, ServerToolUseBlock):
|
||||
current_tool_block = ServerToolUseBlockParam(
|
||||
type="server_tool_use",
|
||||
id=response.content_block.id,
|
||||
name=response.content_block.name,
|
||||
input=response.content_block.input or {},
|
||||
)
|
||||
current_tool_args = ""
|
||||
elif isinstance(
|
||||
response.content_block,
|
||||
(
|
||||
WebSearchToolResultBlock,
|
||||
CodeExecutionToolResultBlock,
|
||||
BashCodeExecutionToolResultBlock,
|
||||
TextEditorCodeExecutionToolResultBlock,
|
||||
ToolSearchToolResultBlock,
|
||||
),
|
||||
):
|
||||
if content_details:
|
||||
content_details.delete_empty()
|
||||
yield {"native": content_details}
|
||||
content_details = ContentDetails()
|
||||
content_details.add_citation_detail()
|
||||
yield {
|
||||
"role": "tool_result",
|
||||
"tool_call_id": response.content_block.tool_use_id,
|
||||
"tool_name": response.content_block.type.removesuffix(
|
||||
"_tool_result"
|
||||
),
|
||||
"tool_result": {
|
||||
"content": cast(
|
||||
JsonObjectType, response.content_block.to_dict()["content"]
|
||||
)
|
||||
}
|
||||
if isinstance(response.content_block.content, list)
|
||||
else cast(JsonObjectType, response.content_block.content.to_dict()),
|
||||
response = await self._stream_iterator.__anext__() # type: ignore[union-attr]
|
||||
|
||||
LOGGER.debug("Received response: %s", response)
|
||||
self.on_message_stream_event(response)
|
||||
|
||||
def on_message_stream_event(self, event: MessageStreamEvent) -> None:
|
||||
"""Handle MessageStreamEvent."""
|
||||
if isinstance(event, RawMessageStartEvent):
|
||||
self.on_message_start_event(event.message)
|
||||
return
|
||||
if isinstance(event, RawContentBlockStartEvent):
|
||||
self.on_content_block_start_event(event.content_block, event.index)
|
||||
return
|
||||
if isinstance(event, RawContentBlockDeltaEvent):
|
||||
self.on_content_block_delta_event(event.delta)
|
||||
return
|
||||
if isinstance(event, RawContentBlockStopEvent):
|
||||
self.on_content_block_stop_event(event.index)
|
||||
return
|
||||
if isinstance(event, RawMessageDeltaEvent):
|
||||
self.on_message_delta_event(event.delta, event.usage)
|
||||
return
|
||||
if isinstance(event, RawMessageStopEvent):
|
||||
self.on_message_stop_event()
|
||||
return
|
||||
LOGGER.debug("Unhandled event type: %s", event.type) # type: ignore[unreachable] # pragma: no cover - All types are handled but we want to verify that
|
||||
|
||||
def on_message_start_event(self, message: Message) -> None:
|
||||
"""Handle RawMessageStartEvent."""
|
||||
self._input_usage = message.usage
|
||||
self._first_block = True
|
||||
|
||||
def on_content_block_start_event(
|
||||
self, content_block: ContentBlock, index: int
|
||||
) -> None:
|
||||
"""Handle RawContentBlockStartEvent."""
|
||||
if isinstance(content_block, ToolUseBlock):
|
||||
self.on_tool_use_block(
|
||||
content_block.id,
|
||||
content_block.input,
|
||||
content_block.name,
|
||||
content_block.caller,
|
||||
)
|
||||
return
|
||||
if isinstance(content_block, TextBlock):
|
||||
self.on_text_block(content_block.text, content_block.citations)
|
||||
return
|
||||
if isinstance(content_block, ThinkingBlock):
|
||||
self.on_thinking_block(content_block.thinking, content_block.signature)
|
||||
return
|
||||
if isinstance(content_block, RedactedThinkingBlock):
|
||||
self.on_redacted_thinking_block(content_block.data)
|
||||
return
|
||||
if isinstance(content_block, ServerToolUseBlock):
|
||||
self.on_server_tool_use_block(
|
||||
content_block.id,
|
||||
content_block.name,
|
||||
content_block.input,
|
||||
content_block.caller,
|
||||
)
|
||||
return
|
||||
if isinstance(
|
||||
content_block,
|
||||
(
|
||||
WebSearchToolResultBlock,
|
||||
CodeExecutionToolResultBlock,
|
||||
BashCodeExecutionToolResultBlock,
|
||||
TextEditorCodeExecutionToolResultBlock,
|
||||
ToolSearchToolResultBlock,
|
||||
),
|
||||
):
|
||||
self.on_server_tool_result_block(
|
||||
content_block.tool_use_id,
|
||||
content_block.type,
|
||||
content_block.content,
|
||||
content_block.caller if hasattr(content_block, "caller") else None,
|
||||
)
|
||||
return
|
||||
LOGGER.debug("Unhandled content block type: %s", content_block.type)
|
||||
|
||||
def on_tool_use_block(
|
||||
self, id: str, input: dict[str, Any], name: str, caller: Caller | None
|
||||
) -> None:
|
||||
"""Handle ToolUseBlock."""
|
||||
self._current_tool_block = ToolUseBlockParam(
|
||||
type="tool_use",
|
||||
id=id,
|
||||
name=name,
|
||||
input=input,
|
||||
)
|
||||
self._current_tool_args = ""
|
||||
if name == self._output_tool:
|
||||
if self._first_block or self._content_details.has_content():
|
||||
if self._content_details:
|
||||
self._content_details.delete_empty()
|
||||
self._buffer.append({"native": self._content_details})
|
||||
self._content_details = ContentDetails()
|
||||
self._content_details.add_citation_detail()
|
||||
self._buffer.append({"role": "assistant"})
|
||||
self._first_block = False
|
||||
|
||||
def on_text_block(self, text: str, citations: list[TextCitation] | None) -> None:
|
||||
"""Handle TextBlock."""
|
||||
if ( # Do not start a new assistant content just for citations, concatenate consecutive blocks with citations instead.
|
||||
self._first_block
|
||||
or (
|
||||
not self._content_details.has_citations()
|
||||
and citations is None
|
||||
and self._content_details.has_content()
|
||||
)
|
||||
):
|
||||
if self._content_details:
|
||||
self._content_details.delete_empty()
|
||||
self._buffer.append({"native": self._content_details})
|
||||
self._content_details = ContentDetails()
|
||||
self._buffer.append({"role": "assistant"})
|
||||
self._first_block = False
|
||||
self._content_details.add_citation_detail()
|
||||
if text:
|
||||
self._content_details.citation_details[-1].length += len(text)
|
||||
self._buffer.append({"content": text})
|
||||
|
||||
def on_thinking_block(self, thinking: str, signature: str) -> None:
|
||||
"""Handle ThinkingBlock."""
|
||||
if self._first_block or self._content_details.thinking_signature:
|
||||
if self._content_details:
|
||||
self._content_details.delete_empty()
|
||||
self._buffer.append({"native": self._content_details})
|
||||
self._content_details = ContentDetails()
|
||||
self._content_details.add_citation_detail()
|
||||
self._buffer.append({"role": "assistant"})
|
||||
self._first_block = False
|
||||
|
||||
def on_redacted_thinking_block(self, data: str) -> None:
|
||||
"""Handle RedactedThinkingBlock."""
|
||||
LOGGER.debug(
|
||||
"Some of Claude’s internal reasoning has been automatically "
|
||||
"encrypted for safety reasons. This doesn’t affect the quality of "
|
||||
"responses"
|
||||
)
|
||||
if self._first_block or self._content_details.redacted_thinking:
|
||||
if self._content_details:
|
||||
self._content_details.delete_empty()
|
||||
self._buffer.append({"native": self._content_details})
|
||||
self._content_details = ContentDetails()
|
||||
self._content_details.add_citation_detail()
|
||||
self._buffer.append({"role": "assistant"})
|
||||
self._first_block = False
|
||||
self._content_details.redacted_thinking = data
|
||||
|
||||
def on_server_tool_use_block(
|
||||
self,
|
||||
id: str,
|
||||
name: Literal[
|
||||
"web_search",
|
||||
"web_fetch",
|
||||
"code_execution",
|
||||
"bash_code_execution",
|
||||
"text_editor_code_execution",
|
||||
"tool_search_tool_regex",
|
||||
"tool_search_tool_bm25",
|
||||
],
|
||||
input: dict[str, Any],
|
||||
caller: Caller | None,
|
||||
) -> None:
|
||||
"""Handle ServerToolUseBlock."""
|
||||
self._current_tool_block = ServerToolUseBlockParam(
|
||||
type="server_tool_use",
|
||||
id=id,
|
||||
name=name,
|
||||
input=input,
|
||||
)
|
||||
self._current_tool_args = ""
|
||||
|
||||
def on_server_tool_result_block(
|
||||
self,
|
||||
tool_use_id: str,
|
||||
tool_name: Literal[
|
||||
"web_search_tool_result",
|
||||
"code_execution_tool_result",
|
||||
"bash_code_execution_tool_result",
|
||||
"text_editor_code_execution_tool_result",
|
||||
"tool_search_tool_result",
|
||||
],
|
||||
content: WebSearchToolResultBlockContent
|
||||
| CodeExecutionToolResultBlockContent
|
||||
| BashCodeExecutionToolResultBlockContent
|
||||
| TextEditorCodeExecutionToolResultBlockContent
|
||||
| ToolSearchToolResultBlockContent,
|
||||
caller: Caller | None,
|
||||
) -> None:
|
||||
"""Handle various server tool result blocks."""
|
||||
if self._content_details:
|
||||
self._content_details.delete_empty()
|
||||
self._buffer.append({"native": self._content_details})
|
||||
self._content_details = ContentDetails()
|
||||
self._content_details.add_citation_detail()
|
||||
self._buffer.append(
|
||||
{
|
||||
"role": "tool_result",
|
||||
"tool_call_id": tool_use_id,
|
||||
"tool_name": tool_name.removesuffix("_tool_result"),
|
||||
"tool_result": {
|
||||
"content": cast(JsonArrayType, [x.to_dict() for x in content])
|
||||
}
|
||||
first_block = True
|
||||
elif isinstance(response, RawContentBlockDeltaEvent):
|
||||
if isinstance(response.delta, InputJSONDelta):
|
||||
if (
|
||||
current_tool_block is not None
|
||||
and current_tool_block["name"] == output_tool
|
||||
):
|
||||
content_details.citation_details[-1].length += len(
|
||||
response.delta.partial_json
|
||||
)
|
||||
yield {"content": response.delta.partial_json}
|
||||
else:
|
||||
current_tool_args += response.delta.partial_json
|
||||
elif isinstance(response.delta, TextDelta):
|
||||
if response.delta.text:
|
||||
content_details.citation_details[-1].length += len(
|
||||
response.delta.text
|
||||
)
|
||||
yield {"content": response.delta.text}
|
||||
elif isinstance(response.delta, ThinkingDelta):
|
||||
if response.delta.thinking:
|
||||
yield {"thinking_content": response.delta.thinking}
|
||||
elif isinstance(response.delta, SignatureDelta):
|
||||
content_details.thinking_signature = response.delta.signature
|
||||
elif isinstance(response.delta, CitationsDelta):
|
||||
content_details.add_citation(response.delta.citation)
|
||||
elif isinstance(response, RawContentBlockStopEvent):
|
||||
if current_tool_block is not None:
|
||||
if current_tool_block["name"] == output_tool:
|
||||
current_tool_block = None
|
||||
continue
|
||||
tool_args = json.loads(current_tool_args) if current_tool_args else {}
|
||||
current_tool_block["input"] |= tool_args
|
||||
yield {
|
||||
if isinstance(content, list)
|
||||
else cast(JsonObjectType, content.to_dict()),
|
||||
}
|
||||
)
|
||||
self._first_block = True
|
||||
|
||||
def on_content_block_delta_event(self, delta: RawContentBlockDelta) -> None:
|
||||
"""Handle RawContentBlockDeltaEvent."""
|
||||
if isinstance(delta, InputJSONDelta):
|
||||
self.on_input_json_delta(delta.partial_json)
|
||||
return
|
||||
if isinstance(delta, TextDelta):
|
||||
self.on_text_delta(delta.text)
|
||||
return
|
||||
if isinstance(delta, ThinkingDelta):
|
||||
self.on_thinking_delta(delta.thinking)
|
||||
return
|
||||
if isinstance(delta, SignatureDelta):
|
||||
self.on_signature_delta(delta.signature)
|
||||
return
|
||||
if isinstance(delta, CitationsDelta):
|
||||
self.on_citations_delta(delta.citation)
|
||||
return
|
||||
LOGGER.debug("Unhandled content delta type: %s", delta.type) # type: ignore[unreachable] # pragma: no cover - All types are handled but we want to verify that
|
||||
|
||||
def on_input_json_delta(self, partial_json: str) -> None:
|
||||
"""Handle InputJSONDelta."""
|
||||
if (
|
||||
self._current_tool_block is not None
|
||||
and self._current_tool_block["name"] == self._output_tool
|
||||
):
|
||||
self._content_details.citation_details[-1].length += len(partial_json)
|
||||
self._buffer.append({"content": partial_json})
|
||||
else:
|
||||
self._current_tool_args += partial_json
|
||||
|
||||
def on_text_delta(self, text: str) -> None:
|
||||
"""Handle TextDelta."""
|
||||
if text:
|
||||
self._content_details.citation_details[-1].length += len(text)
|
||||
self._buffer.append({"content": text})
|
||||
|
||||
def on_thinking_delta(self, thinking: str) -> None:
|
||||
"""Handle ThinkingDelta."""
|
||||
if thinking:
|
||||
self._buffer.append({"thinking_content": thinking})
|
||||
|
||||
def on_signature_delta(self, signature: str) -> None:
|
||||
"""Handle SignatureDelta."""
|
||||
self._content_details.thinking_signature = signature
|
||||
|
||||
def on_citations_delta(self, citation: TextCitation) -> None:
|
||||
"""Handle CitationsDelta."""
|
||||
self._content_details.add_citation(citation)
|
||||
|
||||
def on_content_block_stop_event(self, index: int) -> None:
|
||||
"""Handle RawContentBlockStopEvent."""
|
||||
if self._current_tool_block is not None:
|
||||
if self._current_tool_block["name"] == self._output_tool:
|
||||
self._current_tool_block = None
|
||||
return
|
||||
tool_args = (
|
||||
json.loads(self._current_tool_args) if self._current_tool_args else {}
|
||||
)
|
||||
self._current_tool_block["input"] |= tool_args
|
||||
self._buffer.append(
|
||||
{
|
||||
"tool_calls": [
|
||||
llm.ToolInput(
|
||||
id=current_tool_block["id"],
|
||||
tool_name=current_tool_block["name"],
|
||||
tool_args=current_tool_block["input"],
|
||||
external=current_tool_block["type"] == "server_tool_use",
|
||||
id=self._current_tool_block["id"],
|
||||
tool_name=self._current_tool_block["name"],
|
||||
tool_args=self._current_tool_block["input"],
|
||||
external=self._current_tool_block["type"]
|
||||
== "server_tool_use",
|
||||
)
|
||||
]
|
||||
}
|
||||
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))
|
||||
content_details.container = response.delta.container
|
||||
if response.delta.stop_reason == "refusal":
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN, translation_key="api_refusal"
|
||||
)
|
||||
elif isinstance(response, RawMessageStopEvent):
|
||||
if content_details:
|
||||
content_details.delete_empty()
|
||||
yield {"native": content_details}
|
||||
content_details = ContentDetails()
|
||||
content_details.add_citation_detail()
|
||||
)
|
||||
self._current_tool_block = None
|
||||
|
||||
def on_message_delta_event(self, delta: Delta, usage: MessageDeltaUsage) -> None:
|
||||
"""Handle RawMessageDeltaEvent."""
|
||||
self._chat_log.async_trace(self._create_token_stats(self._input_usage, usage))
|
||||
self._content_details.container = delta.container
|
||||
if delta.stop_reason == "refusal":
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN, translation_key="api_refusal"
|
||||
)
|
||||
|
||||
def _create_token_stats(
|
||||
input_usage: Usage | None, response_usage: MessageDeltaUsage
|
||||
) -> dict[str, Any]:
|
||||
"""Create token stats for conversation agent tracing."""
|
||||
input_tokens = 0
|
||||
cached_input_tokens = 0
|
||||
if input_usage:
|
||||
input_tokens = input_usage.input_tokens
|
||||
cached_input_tokens = input_usage.cache_creation_input_tokens or 0
|
||||
output_tokens = response_usage.output_tokens
|
||||
return {
|
||||
"stats": {
|
||||
"input_tokens": input_tokens,
|
||||
"cached_input_tokens": cached_input_tokens,
|
||||
"output_tokens": output_tokens,
|
||||
def on_message_stop_event(self) -> None:
|
||||
"""Handle RawMessageStopEvent."""
|
||||
if self._content_details:
|
||||
self._content_details.delete_empty()
|
||||
self._buffer.append({"native": self._content_details})
|
||||
self._content_details = ContentDetails()
|
||||
self._content_details.add_citation_detail()
|
||||
|
||||
def _create_token_stats(
|
||||
self, input_usage: Usage | None, response_usage: MessageDeltaUsage
|
||||
) -> dict[str, Any]:
|
||||
"""Create token stats for conversation agent tracing."""
|
||||
input_tokens = 0
|
||||
cached_input_tokens = 0
|
||||
if input_usage:
|
||||
input_tokens = input_usage.input_tokens
|
||||
cached_input_tokens = input_usage.cache_creation_input_tokens or 0
|
||||
output_tokens = response_usage.output_tokens
|
||||
return {
|
||||
"stats": {
|
||||
"input_tokens": input_tokens,
|
||||
"cached_input_tokens": cached_input_tokens,
|
||||
"output_tokens": output_tokens,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
|
||||
@@ -963,7 +1149,7 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
|
||||
content
|
||||
async for content in chat_log.async_add_delta_content_stream(
|
||||
self.entity_id,
|
||||
_transform_stream(
|
||||
AnthropicDeltaStream(
|
||||
chat_log,
|
||||
stream,
|
||||
output_tool=structure_name or None,
|
||||
|
||||
@@ -155,7 +155,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
hass.data[DATA_COMPONENT] = storage_collection
|
||||
|
||||
collection.DictStorageCollectionWebsocket(
|
||||
storage_collection, DOMAIN, DOMAIN, CREATE_FIELDS, UPDATE_FIELDS
|
||||
storage_collection,
|
||||
DOMAIN,
|
||||
DOMAIN,
|
||||
CREATE_FIELDS,
|
||||
UPDATE_FIELDS,
|
||||
admin_only=True,
|
||||
).async_setup(hass)
|
||||
|
||||
websocket_api.async_register_command(hass, handle_integration_list)
|
||||
@@ -341,6 +346,7 @@ async def handle_integration_list(
|
||||
vol.Required("config_entry_id"): str,
|
||||
}
|
||||
)
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.async_response
|
||||
async def handle_config_entry(
|
||||
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
|
||||
|
||||
@@ -13,11 +13,12 @@ from hassil.util import (
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.auth.permissions.const import CAT_ENTITIES, POLICY_CONTROL
|
||||
from homeassistant.components.http import StaticPathConfig
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import ATTR_ENTITY_ID
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.exceptions import HomeAssistantError, Unauthorized, UnknownUser
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
@@ -103,6 +104,22 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
async def handle_ask_question(call: ServiceCall) -> dict[str, Any]:
|
||||
"""Handle a Show View service call."""
|
||||
satellite_entity_id: str = call.data[ATTR_ENTITY_ID]
|
||||
if call.context.user_id:
|
||||
user = await hass.auth.async_get_user(call.context.user_id)
|
||||
if user is None:
|
||||
raise UnknownUser(
|
||||
context=call.context,
|
||||
permission=POLICY_CONTROL,
|
||||
user_id=call.context.user_id,
|
||||
)
|
||||
if not user.permissions.check_entity(satellite_entity_id, POLICY_CONTROL):
|
||||
raise Unauthorized(
|
||||
context=call.context,
|
||||
permission=POLICY_CONTROL,
|
||||
user_id=call.context.user_id,
|
||||
perm_category=CAT_ENTITIES,
|
||||
)
|
||||
|
||||
satellite_entity: AssistSatelliteEntity | None = component.get_entity(
|
||||
satellite_entity_id
|
||||
)
|
||||
|
||||
@@ -165,6 +165,7 @@ async def websocket_set_wake_words(
|
||||
vol.Required("entity_id"): cv.entity_domain(DOMAIN),
|
||||
}
|
||||
)
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.async_response
|
||||
async def websocket_test_connection(
|
||||
hass: HomeAssistant,
|
||||
|
||||
@@ -15,24 +15,6 @@ from homeassistant.data_entry_flow import FlowContext
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
WS_TYPE_SETUP_MFA = "auth/setup_mfa"
|
||||
SCHEMA_WS_SETUP_MFA = vol.All(
|
||||
websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend(
|
||||
{
|
||||
vol.Required("type"): WS_TYPE_SETUP_MFA,
|
||||
vol.Exclusive("mfa_module_id", "module_or_flow_id"): str,
|
||||
vol.Exclusive("flow_id", "module_or_flow_id"): str,
|
||||
vol.Optional("user_input"): object,
|
||||
}
|
||||
),
|
||||
cv.has_at_least_one_key("mfa_module_id", "flow_id"),
|
||||
)
|
||||
|
||||
WS_TYPE_DEPOSE_MFA = "auth/depose_mfa"
|
||||
SCHEMA_WS_DEPOSE_MFA = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend(
|
||||
{vol.Required("type"): WS_TYPE_DEPOSE_MFA, vol.Required("mfa_module_id"): str}
|
||||
)
|
||||
|
||||
DATA_SETUP_FLOW_MGR: HassKey[MfaFlowManager] = HassKey("auth_mfa_setup_flow_manager")
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -73,16 +55,24 @@ def async_setup(hass: HomeAssistant) -> None:
|
||||
"""Init mfa setup flow manager."""
|
||||
hass.data[DATA_SETUP_FLOW_MGR] = MfaFlowManager(hass)
|
||||
|
||||
websocket_api.async_register_command(
|
||||
hass, WS_TYPE_SETUP_MFA, websocket_setup_mfa, SCHEMA_WS_SETUP_MFA
|
||||
)
|
||||
|
||||
websocket_api.async_register_command(
|
||||
hass, WS_TYPE_DEPOSE_MFA, websocket_depose_mfa, SCHEMA_WS_DEPOSE_MFA
|
||||
)
|
||||
websocket_api.async_register_command(hass, websocket_setup_mfa)
|
||||
websocket_api.async_register_command(hass, websocket_depose_mfa)
|
||||
|
||||
|
||||
@callback
|
||||
@websocket_api.websocket_command(
|
||||
vol.All(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required("type"): "auth/setup_mfa",
|
||||
vol.Exclusive("mfa_module_id", "module_or_flow_id"): str,
|
||||
vol.Exclusive("flow_id", "module_or_flow_id"): str,
|
||||
vol.Optional("user_input"): object,
|
||||
}
|
||||
),
|
||||
cv.has_at_least_one_key("mfa_module_id", "flow_id"),
|
||||
)
|
||||
)
|
||||
@websocket_api.ws_require_user(allow_system_user=False)
|
||||
def websocket_setup_mfa(
|
||||
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
|
||||
@@ -121,6 +111,9 @@ def websocket_setup_mfa(
|
||||
|
||||
|
||||
@callback
|
||||
@websocket_api.websocket_command(
|
||||
{vol.Required("type"): "auth/depose_mfa", vol.Required("mfa_module_id"): str}
|
||||
)
|
||||
@websocket_api.ws_require_user(allow_system_user=False)
|
||||
def websocket_depose_mfa(
|
||||
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
|
||||
|
||||
@@ -4,10 +4,10 @@ from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
import asyncio
|
||||
from collections.abc import Callable, Mapping
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import Any, Protocol, cast
|
||||
from typing import Any, cast
|
||||
|
||||
from propcache.api import cached_property
|
||||
import voluptuous as vol
|
||||
@@ -229,14 +229,11 @@ def is_disabled_experimental_trigger(hass: HomeAssistant, platform: str) -> bool
|
||||
)
|
||||
|
||||
|
||||
class IfAction(Protocol):
|
||||
class IfAction(condition_helper.ConditionsChecker):
|
||||
"""Define the format of if_action."""
|
||||
|
||||
config: list[ConfigType]
|
||||
|
||||
def __call__(self, variables: Mapping[str, Any] | None = None) -> bool:
|
||||
"""AND all conditions."""
|
||||
|
||||
|
||||
def is_on(hass: HomeAssistant, entity_id: str) -> bool:
|
||||
"""Return true if specified automation entity_id is on.
|
||||
@@ -835,7 +832,7 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
|
||||
if (
|
||||
not skip_condition
|
||||
and self._condition is not None
|
||||
and not self._condition(variables)
|
||||
and not self._condition.async_check(variables=variables)
|
||||
):
|
||||
self._logger.debug(
|
||||
"Conditions not met, aborting automation. Condition summary: %s",
|
||||
@@ -904,6 +901,9 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
|
||||
"""Remove listeners when removing automation from Home Assistant."""
|
||||
await super().async_will_remove_from_hass()
|
||||
await self._async_disable()
|
||||
self.action_script.async_unload()
|
||||
if self._condition is not None:
|
||||
self._condition.async_unload()
|
||||
|
||||
async def _async_enable_automation(self, event: Event) -> None:
|
||||
"""Start automation on startup."""
|
||||
@@ -1276,6 +1276,7 @@ async def _async_process_if(
|
||||
|
||||
|
||||
@websocket_api.websocket_command({"type": "automation/config", "entity_id": str})
|
||||
@websocket_api.require_admin
|
||||
def websocket_config(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
|
||||
@@ -36,6 +36,7 @@ async def get_axis_api(
|
||||
username=config[CONF_USERNAME],
|
||||
password=config[CONF_PASSWORD],
|
||||
web_proto=config.get(CONF_PROTOCOL, "http"),
|
||||
websocket_enabled=True,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["axis"],
|
||||
"requirements": ["axis==68"],
|
||||
"requirements": ["axis==69"],
|
||||
"ssdp": [
|
||||
{
|
||||
"manufacturer": "AXIS"
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.helpers.hassio import is_hassio
|
||||
from homeassistant.helpers.service import async_register_admin_service
|
||||
|
||||
from .const import DATA_MANAGER, DOMAIN
|
||||
|
||||
@@ -30,7 +31,9 @@ async def _async_handle_create_automatic_service(call: ServiceCall) -> None:
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Register services."""
|
||||
if not is_hassio(hass):
|
||||
hass.services.async_register(DOMAIN, "create", _async_handle_create_service)
|
||||
hass.services.async_register(
|
||||
DOMAIN, "create_automatic", _async_handle_create_automatic_service
|
||||
async_register_admin_service(
|
||||
hass, DOMAIN, "create", _async_handle_create_service
|
||||
)
|
||||
async_register_admin_service(
|
||||
hass, DOMAIN, "create_automatic", _async_handle_create_automatic_service
|
||||
)
|
||||
|
||||
@@ -32,19 +32,27 @@ BATTERY_PERCENTAGE_DOMAIN_SPECS: dict[str, DomainSpec] = {
|
||||
}
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"low": make_entity_target_state_trigger(BATTERY_LOW_DOMAIN_SPECS, STATE_ON),
|
||||
"not_low": make_entity_target_state_trigger(BATTERY_LOW_DOMAIN_SPECS, STATE_OFF),
|
||||
"low": make_entity_target_state_trigger(
|
||||
BATTERY_LOW_DOMAIN_SPECS, STATE_ON, primary_entities_only=False
|
||||
),
|
||||
"not_low": make_entity_target_state_trigger(
|
||||
BATTERY_LOW_DOMAIN_SPECS, STATE_OFF, primary_entities_only=False
|
||||
),
|
||||
"started_charging": make_entity_target_state_trigger(
|
||||
BATTERY_CHARGING_DOMAIN_SPECS, STATE_ON
|
||||
BATTERY_CHARGING_DOMAIN_SPECS, STATE_ON, primary_entities_only=False
|
||||
),
|
||||
"stopped_charging": make_entity_target_state_trigger(
|
||||
BATTERY_CHARGING_DOMAIN_SPECS, STATE_OFF
|
||||
BATTERY_CHARGING_DOMAIN_SPECS, STATE_OFF, primary_entities_only=False
|
||||
),
|
||||
"level_changed": make_entity_numerical_state_changed_trigger(
|
||||
BATTERY_PERCENTAGE_DOMAIN_SPECS, valid_unit="%"
|
||||
BATTERY_PERCENTAGE_DOMAIN_SPECS,
|
||||
valid_unit="%",
|
||||
primary_entities_only=False,
|
||||
),
|
||||
"level_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
|
||||
BATTERY_PERCENTAGE_DOMAIN_SPECS, valid_unit="%"
|
||||
BATTERY_PERCENTAGE_DOMAIN_SPECS,
|
||||
valid_unit="%",
|
||||
primary_entities_only=False,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -33,16 +33,19 @@
|
||||
entity:
|
||||
- domain: binary_sensor
|
||||
device_class: battery
|
||||
primary_entities_only: false
|
||||
|
||||
.trigger_target_charging: &trigger_target_charging
|
||||
entity:
|
||||
- domain: binary_sensor
|
||||
device_class: battery_charging
|
||||
primary_entities_only: false
|
||||
|
||||
.trigger_target_percentage: &trigger_target_percentage
|
||||
entity:
|
||||
- domain: sensor
|
||||
device_class: battery
|
||||
primary_entities_only: false
|
||||
|
||||
low:
|
||||
fields:
|
||||
|
||||
@@ -21,9 +21,6 @@
|
||||
"save_video": {
|
||||
"service": "mdi:file-video"
|
||||
},
|
||||
"send_pin": {
|
||||
"service": "mdi:two-factor-authentication"
|
||||
},
|
||||
"trigger_camera": {
|
||||
"service": "mdi:image-refresh"
|
||||
}
|
||||
|
||||
@@ -5,15 +5,9 @@ from __future__ import annotations
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN
|
||||
from homeassistant.const import (
|
||||
ATTR_CONFIG_ENTRY_ID,
|
||||
CONF_FILE_PATH,
|
||||
CONF_FILENAME,
|
||||
CONF_PIN,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv, issue_registry as ir, service
|
||||
from homeassistant.const import CONF_FILE_PATH, CONF_FILENAME
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import config_validation as cv, service
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
@@ -23,50 +17,10 @@ SERVICE_SAVE_VIDEO = "save_video"
|
||||
SERVICE_SAVE_RECENT_CLIPS = "save_recent_clips"
|
||||
|
||||
|
||||
# Deprecated
|
||||
SERVICE_SEND_PIN = "send_pin"
|
||||
SERVICE_SEND_PIN_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_CONFIG_ENTRY_ID): vol.All(cv.ensure_list, [cv.string]),
|
||||
vol.Optional(CONF_PIN): cv.string,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def _send_pin(call: ServiceCall) -> None:
|
||||
"""Call blink to send new pin."""
|
||||
# Create repair issue to inform user about service removal
|
||||
ir.async_create_issue(
|
||||
call.hass,
|
||||
DOMAIN,
|
||||
"service_send_pin_deprecation",
|
||||
is_fixable=False,
|
||||
issue_domain=DOMAIN,
|
||||
severity=ir.IssueSeverity.ERROR,
|
||||
breaks_in_ha_version="2026.5.0",
|
||||
translation_key="service_send_pin_deprecation",
|
||||
translation_placeholders={"service_name": f"{DOMAIN}.{SERVICE_SEND_PIN}"},
|
||||
)
|
||||
|
||||
# Service has been removed - raise exception
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="service_removed",
|
||||
translation_placeholders={"service_name": f"{DOMAIN}.{SERVICE_SEND_PIN}"},
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Set up the services for the Blink integration."""
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_SEND_PIN,
|
||||
_send_pin,
|
||||
schema=SERVICE_SEND_PIN_SCHEMA,
|
||||
)
|
||||
|
||||
service.async_register_platform_entity_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
|
||||
@@ -35,15 +35,3 @@ save_recent_clips:
|
||||
example: "/tmp"
|
||||
selector:
|
||||
text:
|
||||
|
||||
send_pin:
|
||||
fields:
|
||||
config_entry_id:
|
||||
required: true
|
||||
selector:
|
||||
config_entry:
|
||||
integration: blink
|
||||
pin:
|
||||
example: "abc123"
|
||||
selector:
|
||||
text:
|
||||
|
||||
@@ -82,9 +82,6 @@
|
||||
},
|
||||
"not_loaded": {
|
||||
"message": "{target} is not loaded."
|
||||
},
|
||||
"service_removed": {
|
||||
"message": "The service {service_name} has been removed and is no longer needed. Home Assistant will automatically prompt for reauthentication when required."
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
@@ -98,10 +95,6 @@
|
||||
}
|
||||
},
|
||||
"title": "Blink update service is being removed"
|
||||
},
|
||||
"service_send_pin_deprecation": {
|
||||
"description": "The service {service_name} has been removed and is no longer needed. When a new two-factor authentication code is required, Home Assistant will automatically prompt you to reauthenticate through the integration configuration. Please remove any automations or scripts that call this service.",
|
||||
"title": "Blink send PIN service has been removed"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
@@ -140,20 +133,6 @@
|
||||
},
|
||||
"name": "Save video"
|
||||
},
|
||||
"send_pin": {
|
||||
"description": "Sends a new PIN to Blink for 2FA.",
|
||||
"fields": {
|
||||
"config_entry_id": {
|
||||
"description": "The Blink integration ID.",
|
||||
"name": "Integration ID"
|
||||
},
|
||||
"pin": {
|
||||
"description": "PIN received from Blink. Leave empty if you only received a verification email.",
|
||||
"name": "PIN"
|
||||
}
|
||||
},
|
||||
"name": "Send PIN"
|
||||
},
|
||||
"trigger_camera": {
|
||||
"description": "Requests camera to take new image.",
|
||||
"name": "Trigger camera"
|
||||
|
||||
@@ -15,7 +15,10 @@ from aiohttp import web
|
||||
from dateutil.rrule import rrulestr
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.auth.models import User
|
||||
from homeassistant.auth.permissions.const import POLICY_CONTROL, POLICY_READ
|
||||
from homeassistant.components import frontend, http, websocket_api
|
||||
from homeassistant.components.http import KEY_HASS_USER
|
||||
from homeassistant.components.websocket_api import (
|
||||
ERR_INVALID_FORMAT,
|
||||
ERR_NOT_FOUND,
|
||||
@@ -32,7 +35,7 @@ from homeassistant.core import (
|
||||
SupportsResponse,
|
||||
callback,
|
||||
)
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.exceptions import HomeAssistantError, Unauthorized
|
||||
from homeassistant.helpers import config_validation as cv, entity_registry as er
|
||||
from homeassistant.helpers.debounce import Debouncer
|
||||
from homeassistant.helpers.entity import Entity, EntityDescription
|
||||
@@ -786,6 +789,10 @@ class CalendarEventView(http.HomeAssistantView):
|
||||
|
||||
async def get(self, request: web.Request, entity_id: str) -> web.Response:
|
||||
"""Return calendar events."""
|
||||
user: User = request[KEY_HASS_USER]
|
||||
if not user.permissions.check_entity(entity_id, POLICY_READ):
|
||||
raise Unauthorized(entity_id=entity_id)
|
||||
|
||||
if not (entity := self.component.get_entity(entity_id)) or not isinstance(
|
||||
entity, CalendarEntity
|
||||
):
|
||||
@@ -837,10 +844,14 @@ class CalendarListView(http.HomeAssistantView):
|
||||
|
||||
async def get(self, request: web.Request) -> web.Response:
|
||||
"""Retrieve calendar list."""
|
||||
user: User = request[KEY_HASS_USER]
|
||||
hass = request.app[http.KEY_HASS]
|
||||
entity_perm = user.permissions.check_entity
|
||||
calendar_list: list[dict[str, str]] = []
|
||||
|
||||
for entity in self.component.entities:
|
||||
if not entity_perm(entity.entity_id, POLICY_READ):
|
||||
continue
|
||||
state = hass.states.get(entity.entity_id)
|
||||
assert state
|
||||
calendar_list.append({"name": state.name, "entity_id": entity.entity_id})
|
||||
@@ -860,6 +871,9 @@ async def handle_calendar_event_create(
|
||||
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
|
||||
) -> None:
|
||||
"""Handle creation of a calendar event."""
|
||||
if not connection.user.permissions.check_entity(msg["entity_id"], POLICY_CONTROL):
|
||||
raise Unauthorized(entity_id=msg["entity_id"])
|
||||
|
||||
if not (entity := hass.data[DATA_COMPONENT].get_entity(msg["entity_id"])):
|
||||
connection.send_error(msg["id"], ERR_NOT_FOUND, "Entity not found")
|
||||
return
|
||||
@@ -899,6 +913,8 @@ async def handle_calendar_event_delete(
|
||||
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
|
||||
) -> None:
|
||||
"""Handle delete of a calendar event."""
|
||||
if not connection.user.permissions.check_entity(msg["entity_id"], POLICY_CONTROL):
|
||||
raise Unauthorized(entity_id=msg["entity_id"])
|
||||
|
||||
if not (entity := hass.data[DATA_COMPONENT].get_entity(msg["entity_id"])):
|
||||
connection.send_error(msg["id"], ERR_NOT_FOUND, "Entity not found")
|
||||
@@ -944,7 +960,10 @@ async def handle_calendar_event_delete(
|
||||
async def handle_calendar_event_update(
|
||||
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
|
||||
) -> None:
|
||||
"""Handle creation of a calendar event."""
|
||||
"""Handle update of a calendar event."""
|
||||
if not connection.user.permissions.check_entity(msg["entity_id"], POLICY_CONTROL):
|
||||
raise Unauthorized(entity_id=msg["entity_id"])
|
||||
|
||||
if not (entity := hass.data[DATA_COMPONENT].get_entity(msg["entity_id"])):
|
||||
connection.send_error(msg["id"], ERR_NOT_FOUND, "Entity not found")
|
||||
return
|
||||
@@ -989,6 +1008,9 @@ async def handle_calendar_event_subscribe(
|
||||
"""Subscribe to calendar event updates."""
|
||||
entity_id: str = msg["entity_id"]
|
||||
|
||||
if not connection.user.permissions.check_entity(entity_id, POLICY_READ):
|
||||
raise Unauthorized(entity_id=entity_id)
|
||||
|
||||
if not (entity := hass.data[DATA_COMPONENT].get_entity(entity_id)):
|
||||
connection.send_error(
|
||||
msg["id"],
|
||||
|
||||
@@ -926,6 +926,7 @@ async def websocket_get_prefs(
|
||||
vol.Optional(PREF_ORIENTATION): vol.Coerce(Orientation),
|
||||
}
|
||||
)
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.async_response
|
||||
async def websocket_update_prefs(
|
||||
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
|
||||
|
||||
@@ -374,6 +374,7 @@ class CloudClient(Interface):
|
||||
method=payload["method"],
|
||||
query_string=payload["query"],
|
||||
mock_source=DOMAIN,
|
||||
remote=None, # Remote will be used for the local_only check, but since this is from the cloud we want it to be None to mark it as non-local and bypass the ip parsing and remote checks
|
||||
)
|
||||
|
||||
response = await webhook.async_handle_webhook(
|
||||
|
||||
@@ -615,6 +615,7 @@ class DownloadSupportPackageView(HomeAssistantView):
|
||||
|
||||
return markdown
|
||||
|
||||
@require_admin
|
||||
async def get(self, request: web.Request) -> web.Response:
|
||||
"""Download support package file."""
|
||||
|
||||
@@ -709,6 +710,7 @@ def _require_cloud_login(
|
||||
return with_cloud_auth
|
||||
|
||||
|
||||
@websocket_api.require_admin
|
||||
@_require_cloud_login
|
||||
@websocket_api.websocket_command({vol.Required("type"): "cloud/subscription"})
|
||||
@websocket_api.async_response
|
||||
@@ -750,6 +752,7 @@ def validate_language_voice(value: tuple[str, str]) -> tuple[str, str]:
|
||||
return value
|
||||
|
||||
|
||||
@websocket_api.require_admin
|
||||
@_require_cloud_login
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
@@ -809,6 +812,7 @@ async def websocket_update_prefs(
|
||||
connection.send_message(websocket_api.result_message(msg["id"]))
|
||||
|
||||
|
||||
@websocket_api.require_admin
|
||||
@_require_cloud_login
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
@@ -829,6 +833,7 @@ async def websocket_hook_create(
|
||||
connection.send_message(websocket_api.result_message(msg["id"], hook))
|
||||
|
||||
|
||||
@websocket_api.require_admin
|
||||
@_require_cloud_login
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["aiocomelit"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aiocomelit==2.0.2"]
|
||||
"requirements": ["aiocomelit==2.0.3"]
|
||||
}
|
||||
|
||||
@@ -10,32 +10,19 @@ from homeassistant.auth.models import User
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
|
||||
WS_TYPE_LIST = "config/auth/list"
|
||||
SCHEMA_WS_LIST = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend(
|
||||
{vol.Required("type"): WS_TYPE_LIST}
|
||||
)
|
||||
|
||||
WS_TYPE_DELETE = "config/auth/delete"
|
||||
SCHEMA_WS_DELETE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend(
|
||||
{vol.Required("type"): WS_TYPE_DELETE, vol.Required("user_id"): str}
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup(hass: HomeAssistant) -> bool:
|
||||
"""Enable the Home Assistant views."""
|
||||
websocket_api.async_register_command(
|
||||
hass, WS_TYPE_LIST, websocket_list, SCHEMA_WS_LIST
|
||||
)
|
||||
websocket_api.async_register_command(
|
||||
hass, WS_TYPE_DELETE, websocket_delete, SCHEMA_WS_DELETE
|
||||
)
|
||||
websocket_api.async_register_command(hass, websocket_list)
|
||||
websocket_api.async_register_command(hass, websocket_delete)
|
||||
websocket_api.async_register_command(hass, websocket_create)
|
||||
websocket_api.async_register_command(hass, websocket_update)
|
||||
return True
|
||||
|
||||
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.websocket_command({vol.Required("type"): "config/auth/list"})
|
||||
@websocket_api.async_response
|
||||
async def websocket_list(
|
||||
hass: HomeAssistant,
|
||||
@@ -49,6 +36,9 @@ async def websocket_list(
|
||||
|
||||
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.websocket_command(
|
||||
{vol.Required("type"): "config/auth/delete", vol.Required("user_id"): str}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def websocket_delete(
|
||||
hass: HomeAssistant,
|
||||
|
||||
@@ -14,6 +14,8 @@ from datetime import datetime
|
||||
import functools as ft
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import ATTR_ENTITY_PICTURE, ATTR_FRIENDLY_NAME
|
||||
from homeassistant.core import (
|
||||
HassJob,
|
||||
@@ -24,6 +26,7 @@ from homeassistant.core import (
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.entity import async_generate_entity_id
|
||||
from homeassistant.helpers.event import async_call_later
|
||||
from homeassistant.helpers.service import async_register_admin_service
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util.async_ import run_callback_threadsafe
|
||||
|
||||
@@ -149,8 +152,12 @@ class Configurator:
|
||||
self._requests: dict[
|
||||
str, tuple[str, list[dict[str, str]], ConfiguratorCallback | None]
|
||||
] = {}
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_CONFIGURE, self.async_handle_service_call
|
||||
async_register_admin_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
SERVICE_CONFIGURE,
|
||||
self.async_handle_service_call,
|
||||
schema=vol.Schema({}, extra=vol.ALLOW_EXTRA),
|
||||
)
|
||||
|
||||
@async_callback
|
||||
|
||||
@@ -4,7 +4,11 @@ from collections.abc import Mapping
|
||||
|
||||
from homeassistant.const import STATE_OFF, STATE_ON
|
||||
from homeassistant.core import HomeAssistant, State
|
||||
from homeassistant.helpers.condition import Condition, EntityConditionBase
|
||||
from homeassistant.helpers.condition import (
|
||||
ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL_FOR,
|
||||
Condition,
|
||||
EntityConditionBase,
|
||||
)
|
||||
|
||||
from .const import ATTR_IS_CLOSED, DOMAIN, CoverDeviceClass
|
||||
from .models import CoverDomainSpec
|
||||
@@ -14,6 +18,7 @@ class CoverConditionBase(EntityConditionBase):
|
||||
"""Base condition for cover state checks."""
|
||||
|
||||
_domain_specs: Mapping[str, CoverDomainSpec]
|
||||
_schema = ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL_FOR
|
||||
|
||||
def is_valid_state(self, entity_state: State) -> bool:
|
||||
"""Check if the state matches the expected cover state."""
|
||||
|
||||
@@ -8,6 +8,11 @@
|
||||
options:
|
||||
- all
|
||||
- any
|
||||
for:
|
||||
required: true
|
||||
default: 00:00:00
|
||||
selector:
|
||||
duration:
|
||||
|
||||
awning_is_closed:
|
||||
fields: *condition_common_fields
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"common": {
|
||||
"condition_behavior_name": "Condition passes if",
|
||||
"condition_for_name": "For at least",
|
||||
"trigger_behavior_name": "Trigger when",
|
||||
"trigger_for_name": "For at least"
|
||||
},
|
||||
@@ -10,6 +11,9 @@
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"name": "[%key:component::cover::common::condition_behavior_name%]"
|
||||
},
|
||||
"for": {
|
||||
"name": "[%key:component::cover::common::condition_for_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Awning is closed"
|
||||
@@ -19,6 +23,9 @@
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"name": "[%key:component::cover::common::condition_behavior_name%]"
|
||||
},
|
||||
"for": {
|
||||
"name": "[%key:component::cover::common::condition_for_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Awning is open"
|
||||
@@ -28,6 +35,9 @@
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"name": "[%key:component::cover::common::condition_behavior_name%]"
|
||||
},
|
||||
"for": {
|
||||
"name": "[%key:component::cover::common::condition_for_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Blind is closed"
|
||||
@@ -37,6 +47,9 @@
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"name": "[%key:component::cover::common::condition_behavior_name%]"
|
||||
},
|
||||
"for": {
|
||||
"name": "[%key:component::cover::common::condition_for_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Blind is open"
|
||||
@@ -46,6 +59,9 @@
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"name": "[%key:component::cover::common::condition_behavior_name%]"
|
||||
},
|
||||
"for": {
|
||||
"name": "[%key:component::cover::common::condition_for_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Curtain is closed"
|
||||
@@ -55,6 +71,9 @@
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"name": "[%key:component::cover::common::condition_behavior_name%]"
|
||||
},
|
||||
"for": {
|
||||
"name": "[%key:component::cover::common::condition_for_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Curtain is open"
|
||||
@@ -64,6 +83,9 @@
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"name": "[%key:component::cover::common::condition_behavior_name%]"
|
||||
},
|
||||
"for": {
|
||||
"name": "[%key:component::cover::common::condition_for_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Shade is closed"
|
||||
@@ -73,6 +95,9 @@
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"name": "[%key:component::cover::common::condition_behavior_name%]"
|
||||
},
|
||||
"for": {
|
||||
"name": "[%key:component::cover::common::condition_for_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Shade is open"
|
||||
@@ -82,6 +107,9 @@
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"name": "[%key:component::cover::common::condition_behavior_name%]"
|
||||
},
|
||||
"for": {
|
||||
"name": "[%key:component::cover::common::condition_for_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Shutter is closed"
|
||||
@@ -91,6 +119,9 @@
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"name": "[%key:component::cover::common::condition_behavior_name%]"
|
||||
},
|
||||
"for": {
|
||||
"name": "[%key:component::cover::common::condition_for_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Shutter is open"
|
||||
|
||||
@@ -11,6 +11,7 @@ from homeassistant.helpers import (
|
||||
device_registry as dr,
|
||||
entity_registry as er,
|
||||
)
|
||||
from homeassistant.helpers.service import async_register_admin_service
|
||||
from homeassistant.util.read_only_dict import ReadOnlyDict
|
||||
|
||||
from .const import CONF_BRIDGE_ID, DOMAIN, LOGGER
|
||||
@@ -98,7 +99,8 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
||||
await async_remove_orphaned_entries_service(hub)
|
||||
|
||||
for service in SUPPORTED_SERVICES:
|
||||
hass.services.async_register(
|
||||
async_register_admin_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
service,
|
||||
async_call_deconz_service,
|
||||
|
||||
@@ -19,7 +19,7 @@ from homeassistant.const import (
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import Event, HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
|
||||
|
||||
PLATFORMS = [Platform.LIGHT]
|
||||
|
||||
@@ -40,7 +40,7 @@ def _login_and_get_switches(email: str, password: str) -> DecoraWifiData:
|
||||
success = session.login(email, password)
|
||||
|
||||
if success is None:
|
||||
raise ConfigEntryAuthFailed("Invalid credentials for myLeviton account")
|
||||
raise ConfigEntryError("Invalid credentials for myLeviton account")
|
||||
|
||||
perms = session.user.get_residential_permissions()
|
||||
all_switches: list[IotSwitch] = []
|
||||
|
||||
@@ -187,6 +187,7 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
|
||||
|
||||
_attr_translation_key = "derivative"
|
||||
_attr_should_poll = False
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
|
||||
@@ -245,6 +245,7 @@ class DownloadDiagnosticsView(http.HomeAssistantView):
|
||||
extra_urls = ["/api/diagnostics/{d_type}/{d_id}/{sub_type}/{sub_id}"]
|
||||
name = "api:diagnostics"
|
||||
|
||||
@http.require_admin
|
||||
async def get(
|
||||
self,
|
||||
request: web.Request,
|
||||
|
||||
@@ -30,12 +30,10 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
|
||||
return False
|
||||
|
||||
if config_entry.version < 2 and config_entry.minor_version < 2:
|
||||
version = config_entry.version
|
||||
minor_version = config_entry.minor_version
|
||||
_LOGGER.debug(
|
||||
"Migrating configuration from version %s.%s",
|
||||
version,
|
||||
minor_version,
|
||||
config_entry.version,
|
||||
config_entry.minor_version,
|
||||
)
|
||||
|
||||
new_options = {**config_entry.options}
|
||||
@@ -46,10 +44,19 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
|
||||
config_entry, options=new_options, minor_version=2
|
||||
)
|
||||
|
||||
_LOGGER.debug("Migration to configuration version %s.%s successful", 1, 2)
|
||||
|
||||
if config_entry.version < 2 and config_entry.minor_version < 3:
|
||||
_LOGGER.debug(
|
||||
"Migration to configuration version %s.%s successful",
|
||||
1,
|
||||
2,
|
||||
"Migrating configuration from version %s.%s",
|
||||
config_entry.version,
|
||||
config_entry.minor_version,
|
||||
)
|
||||
|
||||
hass.config_entries.async_update_entry(
|
||||
config_entry, unique_id=None, minor_version=3
|
||||
)
|
||||
|
||||
_LOGGER.debug("Migration to configuration version %s.%s successful", 1, 3)
|
||||
|
||||
return True
|
||||
|
||||
@@ -93,7 +93,7 @@ class DnsIPConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for dnsip integration."""
|
||||
|
||||
VERSION = 1
|
||||
MINOR_VERSION = 2
|
||||
MINOR_VERSION = 3
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
@@ -133,10 +133,7 @@ class DnsIPConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
):
|
||||
errors["base"] = "invalid_hostname"
|
||||
else:
|
||||
# Uses hostname as unique ID, which is no longer allowed
|
||||
# pylint: disable-next=hass-unique-id-ip-based
|
||||
await self.async_set_unique_id(hostname)
|
||||
self._abort_if_unique_id_configured()
|
||||
self._async_abort_entries_match({CONF_HOSTNAME: hostname})
|
||||
|
||||
return self.async_create_entry(
|
||||
title=name,
|
||||
|
||||
@@ -8,6 +8,11 @@
|
||||
options:
|
||||
- all
|
||||
- any
|
||||
for:
|
||||
required: true
|
||||
default: 00:00:00
|
||||
selector:
|
||||
duration:
|
||||
|
||||
is_closed:
|
||||
fields: *condition_common_fields
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"common": {
|
||||
"condition_behavior_name": "Condition passes if",
|
||||
"condition_for_name": "For at least",
|
||||
"trigger_behavior_name": "Trigger when",
|
||||
"trigger_for_name": "For at least"
|
||||
},
|
||||
@@ -10,6 +11,9 @@
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"name": "[%key:component::door::common::condition_behavior_name%]"
|
||||
},
|
||||
"for": {
|
||||
"name": "[%key:component::door::common::condition_for_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Door is closed"
|
||||
@@ -19,6 +23,9 @@
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"name": "[%key:component::door::common::condition_behavior_name%]"
|
||||
},
|
||||
"for": {
|
||||
"name": "[%key:component::door::common::condition_for_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Door is open"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from duco import DucoClient
|
||||
from duco import DucoClient, build_ssl_context
|
||||
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -14,10 +14,11 @@ from .coordinator import DucoConfigEntry, DucoCoordinator
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: DucoConfigEntry) -> bool:
|
||||
"""Set up Duco from a config entry."""
|
||||
ssl_context = await hass.async_add_executor_job(build_ssl_context)
|
||||
client = DucoClient(
|
||||
session=async_get_clientsession(hass, verify_ssl=False),
|
||||
session=async_get_clientsession(hass),
|
||||
host=entry.data[CONF_HOST],
|
||||
scheme="https",
|
||||
ssl_context=ssl_context,
|
||||
)
|
||||
|
||||
coordinator = DucoCoordinator(hass, entry, client)
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from duco import DucoClient
|
||||
from duco import DucoClient, build_ssl_context
|
||||
from duco.exceptions import DucoConnectionError, DucoError
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -160,10 +160,11 @@ class DucoConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
Returns a tuple of (box_name, mac_address).
|
||||
"""
|
||||
ssl_context = await self.hass.async_add_executor_job(build_ssl_context)
|
||||
client = DucoClient(
|
||||
session=async_get_clientsession(self.hass, verify_ssl=False),
|
||||
session=async_get_clientsession(self.hass),
|
||||
host=host,
|
||||
scheme="https",
|
||||
ssl_context=ssl_context,
|
||||
)
|
||||
board_info = await client.async_get_board_info()
|
||||
lan_info = await client.async_get_lan_info()
|
||||
|
||||
@@ -2,14 +2,17 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import asdict
|
||||
from typing import Any
|
||||
|
||||
from duco.exceptions import DucoConnectionError
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import DucoConfigEntry
|
||||
|
||||
TO_REDACT = {
|
||||
@@ -32,11 +35,15 @@ async def async_get_config_entry_diagnostics(
|
||||
board = asdict(coordinator.board_info)
|
||||
board.pop("time")
|
||||
|
||||
lan_info, duco_diags, write_remaining = await asyncio.gather(
|
||||
coordinator.client.async_get_lan_info(),
|
||||
coordinator.client.async_get_diagnostics(),
|
||||
coordinator.client.async_get_write_req_remaining(),
|
||||
)
|
||||
try:
|
||||
lan_info = await coordinator.client.async_get_lan_info()
|
||||
duco_diags = await coordinator.client.async_get_diagnostics()
|
||||
write_remaining = await coordinator.client.async_get_write_req_remaining()
|
||||
except DucoConnectionError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="connection_error",
|
||||
) from err
|
||||
|
||||
return async_redact_data(
|
||||
{
|
||||
|
||||
@@ -35,7 +35,7 @@ PRESET_AUTO = "auto"
|
||||
# again always round-trips to the same Duco state.
|
||||
_SPEED_LEVEL_PERCENTAGES: list[int] = [
|
||||
(i + 1) * 100 // len(ORDERED_NAMED_FAN_SPEEDS)
|
||||
for i in range(len(ORDERED_NAMED_FAN_SPEEDS))
|
||||
for i, _ in enumerate(ORDERED_NAMED_FAN_SPEEDS)
|
||||
]
|
||||
|
||||
# Maps every active Duco state (including timed MAN variants) to its
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["duco"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["python-duco-client==0.3.6"],
|
||||
"requirements": ["python-duco-client==0.3.9"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"name": "duco [[][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][]].*",
|
||||
|
||||
@@ -87,6 +87,9 @@
|
||||
"cannot_connect": {
|
||||
"message": "An error occurred while trying to connect to the Duco instance: {error}"
|
||||
},
|
||||
"connection_error": {
|
||||
"message": "Could not connect to the Duco device."
|
||||
},
|
||||
"failed_to_set_state": {
|
||||
"message": "Failed to set ventilation state: {error}"
|
||||
},
|
||||
|
||||
@@ -17,6 +17,7 @@ from homeassistant.const import (
|
||||
CONF_HOST,
|
||||
CONF_PASSWORD,
|
||||
CONF_PORT,
|
||||
EVENT_HOMEASSISTANT_STOP,
|
||||
__version__ as ha_version,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
@@ -80,7 +81,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
|
||||
if "usb" in hass.config.components:
|
||||
async_register_serial_port_scanner(hass, _async_scan_serial_ports)
|
||||
serial_proxy.set_hass_loop(hass.loop)
|
||||
hass.bus.async_listen_once(
|
||||
EVENT_HOMEASSISTANT_STOP,
|
||||
serial_proxy.register_serialx_transport(hass.loop),
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
"""ESPHome constants."""
|
||||
|
||||
from typing import Final
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
from awesomeversion import AwesomeVersion
|
||||
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .domain_data import DomainData
|
||||
|
||||
DOMAIN = "esphome"
|
||||
|
||||
ESPHOME_DATA: HassKey[DomainData] = HassKey(DOMAIN)
|
||||
|
||||
CONF_ALLOW_SERVICE_CALLS = "allow_service_calls"
|
||||
CONF_SUBSCRIBE_LOGS = "subscribe_logs"
|
||||
CONF_DEVICE_NAME = "device_name"
|
||||
|
||||
@@ -4,12 +4,11 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from functools import cache
|
||||
from typing import Self
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.json import JSONEncoder
|
||||
|
||||
from .const import DOMAIN
|
||||
from .const import ESPHOME_DATA
|
||||
from .entry_data import ESPHomeConfigEntry, ESPHomeStorage, RuntimeEntryData
|
||||
|
||||
STORAGE_VERSION = 1
|
||||
@@ -36,11 +35,9 @@ class DomainData:
|
||||
),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@staticmethod
|
||||
@cache
|
||||
def get(cls, hass: HomeAssistant) -> Self:
|
||||
def get(hass: HomeAssistant) -> DomainData:
|
||||
"""Get the global DomainData instance stored in hass.data."""
|
||||
# Uses legacy hass.data[DOMAIN] pattern
|
||||
# pylint: disable-next=hass-use-runtime-data
|
||||
ret = hass.data[DOMAIN] = cls()
|
||||
ret = hass.data[ESPHOME_DATA] = DomainData()
|
||||
return ret
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
from typing import cast
|
||||
|
||||
from aioesphomeapi import APIClient
|
||||
@@ -15,25 +16,17 @@ from serialx.platforms.serial_esphome import (
|
||||
from yarl import URL
|
||||
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.core import HomeAssistant, async_get_hass
|
||||
from homeassistant.core import Event, HomeAssistant, async_get_hass, callback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .entry_data import ESPHomeConfigEntry
|
||||
|
||||
SCHEME = "esphome-hass://"
|
||||
|
||||
# This is required so that serialx can safely query Core for an instance of an
|
||||
# aioesphomeapi client. We cannot make any assumptions here, some packages run separate
|
||||
# asyncio event loops in dedicated threads.
|
||||
_HASS_LOOP: asyncio.AbstractEventLoop | None = None
|
||||
|
||||
|
||||
def set_hass_loop(loop: asyncio.AbstractEventLoop) -> None:
|
||||
"""Store a reference to the Core event loop."""
|
||||
global _HASS_LOOP # noqa: PLW0603 # pylint: disable=global-statement
|
||||
_HASS_LOOP = loop
|
||||
|
||||
|
||||
def build_url(entry_id: str, port_name: str) -> URL:
|
||||
"""Build a canonical `esphome-hass://` URL."""
|
||||
return URL.build(
|
||||
@@ -105,9 +98,24 @@ class HassESPHomeSerialTransport(ESPHomeSerialTransport):
|
||||
_serial_cls = HassESPHomeSerial
|
||||
|
||||
|
||||
register_uri_handler(
|
||||
scheme=SCHEME,
|
||||
unique_scheme=SCHEME,
|
||||
sync_cls=HassESPHomeSerial,
|
||||
async_transport_cls=HassESPHomeSerialTransport,
|
||||
)
|
||||
def register_serialx_transport(
|
||||
loop: asyncio.AbstractEventLoop,
|
||||
) -> Callable[[Event], None]:
|
||||
"""Register the ESPHome URI handler."""
|
||||
global _HASS_LOOP # noqa: PLW0603 # pylint: disable=global-statement
|
||||
_HASS_LOOP = loop
|
||||
|
||||
unregister = register_uri_handler(
|
||||
scheme="esphome-hass://",
|
||||
unique_scheme="esphome-hass-internal://", # The unique scheme must differ
|
||||
sync_cls=HassESPHomeSerial,
|
||||
async_transport_cls=HassESPHomeSerialTransport,
|
||||
)
|
||||
|
||||
@callback
|
||||
def _unregister(event: Event) -> None:
|
||||
global _HASS_LOOP # noqa: PLW0603 # pylint: disable=global-statement
|
||||
unregister()
|
||||
_HASS_LOOP = None
|
||||
|
||||
return _unregister
|
||||
|
||||
@@ -9,6 +9,7 @@ import yaml
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse, callback
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.service import async_register_admin_service
|
||||
|
||||
from .const import ATTR_FILE_ENCODING, ATTR_FILE_NAME, DOMAIN, SERVICE_READ_FILE
|
||||
|
||||
@@ -17,7 +18,8 @@ from .const import ATTR_FILE_ENCODING, ATTR_FILE_NAME, DOMAIN, SERVICE_READ_FILE
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Register services for File integration."""
|
||||
|
||||
hass.services.async_register(
|
||||
async_register_admin_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
SERVICE_READ_FILE,
|
||||
read_file,
|
||||
|
||||
@@ -15,7 +15,7 @@ from homeassistant.config_entries import (
|
||||
OptionsFlow,
|
||||
SubentryFlowResult,
|
||||
)
|
||||
from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME
|
||||
from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers import config_validation as cv, selector
|
||||
|
||||
@@ -94,7 +94,7 @@ class ForecastSolarFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a flow initiated by the user."""
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(
|
||||
title=user_input[CONF_NAME],
|
||||
title="",
|
||||
data={
|
||||
CONF_LATITUDE: user_input[CONF_LATITUDE],
|
||||
CONF_LONGITUDE: user_input[CONF_LONGITUDE],
|
||||
@@ -118,13 +118,11 @@ class ForecastSolarFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_NAME): str,
|
||||
vol.Required(CONF_LATITUDE): cv.latitude,
|
||||
vol.Required(CONF_LONGITUDE): cv.longitude,
|
||||
}
|
||||
).extend(PLANE_SCHEMA.schema),
|
||||
{
|
||||
CONF_NAME: self.hass.config.location_name,
|
||||
CONF_LATITUDE: self.hass.config.latitude,
|
||||
CONF_LONGITUDE: self.hass.config.longitude,
|
||||
CONF_DECLINATION: DEFAULT_DECLINATION,
|
||||
|
||||
@@ -7,8 +7,7 @@
|
||||
"declination": "Declination (0 = Horizontal, 90 = Vertical)",
|
||||
"latitude": "[%key:common::config_flow::data::latitude%]",
|
||||
"longitude": "[%key:common::config_flow::data::longitude%]",
|
||||
"modules_power": "Total Watt peak power of your solar modules",
|
||||
"name": "[%key:common::config_flow::data::name%]"
|
||||
"modules_power": "Total Watt peak power of your solar modules"
|
||||
},
|
||||
"description": "Fill in the data of your solar panels. Please refer to the documentation if a field is unclear."
|
||||
}
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["freebox_api"],
|
||||
"requirements": ["freebox-api==1.3.0"],
|
||||
"requirements": ["freebox-api==1.3.1"],
|
||||
"zeroconf": ["_fbx-api._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ BUTTONS: Final = [
|
||||
device_class=ButtonDeviceClass.UPDATE,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
press_action=lambda avm_wrapper: avm_wrapper.async_trigger_firmware_update(),
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
FritzButtonDescription(
|
||||
key="reboot",
|
||||
@@ -96,6 +97,33 @@ def repair_issue_cleanup(hass: HomeAssistant, avm_wrapper: AvmWrapper) -> None:
|
||||
)
|
||||
|
||||
|
||||
def repair_issue_firmware_update(hass: HomeAssistant, avm_wrapper: AvmWrapper) -> None:
|
||||
"""Repair issue for firmware update button."""
|
||||
entity_registry = er.async_get(hass)
|
||||
|
||||
if (
|
||||
(
|
||||
entity_button := entity_registry.async_get_entity_id(
|
||||
"button", DOMAIN, f"{avm_wrapper.unique_id}-firmware_update"
|
||||
)
|
||||
)
|
||||
and (entity_entry := entity_registry.async_get(entity_button))
|
||||
and not entity_entry.disabled
|
||||
):
|
||||
# Deprecate the 'firmware update' button: create a Repairs issue for users
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
domain=DOMAIN,
|
||||
issue_id="deprecated_firmware_update_button",
|
||||
is_fixable=False,
|
||||
is_persistent=True,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="deprecated_firmware_update_button",
|
||||
translation_placeholders={"removal_version": "2026.11.0"},
|
||||
breaks_in_ha_version="2026.11.0",
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: FritzConfigEntry,
|
||||
@@ -112,6 +140,7 @@ async def async_setup_entry(
|
||||
if avm_wrapper.mesh_role == MeshRoles.SLAVE:
|
||||
async_add_entities(entities_list)
|
||||
repair_issue_cleanup(hass, avm_wrapper)
|
||||
repair_issue_firmware_update(hass, avm_wrapper)
|
||||
return
|
||||
|
||||
data_fritz = hass.data[FRITZ_DATA_KEY]
|
||||
@@ -131,6 +160,7 @@ async def async_setup_entry(
|
||||
)
|
||||
|
||||
repair_issue_cleanup(hass, avm_wrapper)
|
||||
repair_issue_firmware_update(hass, avm_wrapper)
|
||||
|
||||
|
||||
class FritzButton(ButtonEntity):
|
||||
@@ -164,6 +194,12 @@ class FritzButton(ButtonEntity):
|
||||
"Please update your automations and dashboards to remove any usage of this button. "
|
||||
"The action is now performed automatically at each data refresh",
|
||||
)
|
||||
elif self.entity_description.key == "firmware_update":
|
||||
_LOGGER.warning(
|
||||
"The 'firmware update' button is deprecated and will be removed in Home Assistant Core "
|
||||
"2026.11.0. It has been superseded by an update entity. Please update your automations "
|
||||
"and dashboards to remove any usage of this button",
|
||||
)
|
||||
await self.entity_description.press_action(self.avm_wrapper)
|
||||
|
||||
|
||||
|
||||
@@ -13,7 +13,10 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers.service import async_extract_config_entry_ids
|
||||
from homeassistant.helpers.service import (
|
||||
async_extract_config_entry_ids,
|
||||
async_register_admin_service,
|
||||
)
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import FritzConfigEntry
|
||||
@@ -118,7 +121,8 @@ async def _async_dial(service_call: ServiceCall) -> None:
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Set up services for Fritz integration."""
|
||||
|
||||
hass.services.async_register(
|
||||
async_register_admin_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
SERVICE_SET_GUEST_WIFI_PW,
|
||||
_async_set_guest_wifi_password,
|
||||
|
||||
@@ -211,6 +211,10 @@
|
||||
"deprecated_cleanup_button": {
|
||||
"description": "The 'Cleanup' button is deprecated and will be removed in Home Assistant Core {removal_version}. Please update your automations and dashboards to remove any usage of this button. The action is now performed automatically at each data refresh.",
|
||||
"title": "'Cleanup' button is deprecated"
|
||||
},
|
||||
"deprecated_firmware_update_button": {
|
||||
"description": "The 'Firmware update' button is deprecated and will be removed in Home Assistant Core {removal_version}. It has been superseded by an update entity. Please update your automations and dashboards to remove any usage of this button.",
|
||||
"title": "'Firmware update' button is deprecated"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
|
||||
@@ -7,7 +7,12 @@ from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import FumisConfigEntry, FumisDataUpdateCoordinator
|
||||
|
||||
PLATFORMS = [Platform.BUTTON, Platform.CLIMATE, Platform.SENSOR]
|
||||
PLATFORMS = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.BUTTON,
|
||||
Platform.CLIMATE,
|
||||
Platform.SENSOR,
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: FumisConfigEntry) -> bool:
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Support for Fumis binary sensor entities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from fumis import FumisInfo
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
BinarySensorEntity,
|
||||
BinarySensorEntityDescription,
|
||||
)
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import FumisConfigEntry, FumisDataUpdateCoordinator
|
||||
from .entity import FumisEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class FumisBinarySensorEntityDescription(BinarySensorEntityDescription):
|
||||
"""Describes a Fumis binary sensor entity."""
|
||||
|
||||
has_fn: Callable[[FumisInfo], bool] = lambda _: True
|
||||
is_on_fn: Callable[[FumisInfo], bool | None]
|
||||
|
||||
|
||||
BINARY_SENSORS: tuple[FumisBinarySensorEntityDescription, ...] = (
|
||||
FumisBinarySensorEntityDescription(
|
||||
key="door",
|
||||
device_class=BinarySensorDeviceClass.DOOR,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
has_fn=lambda data: data.controller.door_open is not None,
|
||||
is_on_fn=lambda data: data.controller.door_open,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: FumisConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Fumis binary sensor entities based on a config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
async_add_entities(
|
||||
FumisBinarySensorEntity(coordinator=coordinator, description=description)
|
||||
for description in BINARY_SENSORS
|
||||
if description.has_fn(coordinator.data)
|
||||
)
|
||||
|
||||
|
||||
class FumisBinarySensorEntity(FumisEntity, BinarySensorEntity):
|
||||
"""Defines a Fumis binary sensor entity."""
|
||||
|
||||
entity_description: FumisBinarySensorEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: FumisDataUpdateCoordinator,
|
||||
description: FumisBinarySensorEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the Fumis binary sensor entity."""
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{description.key}"
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return the state of the binary sensor."""
|
||||
return self.entity_description.is_on_fn(self.coordinator.data)
|
||||
@@ -8,6 +8,11 @@
|
||||
options:
|
||||
- all
|
||||
- any
|
||||
for:
|
||||
required: true
|
||||
default: 00:00:00
|
||||
selector:
|
||||
duration:
|
||||
|
||||
is_closed:
|
||||
fields: *condition_common_fields
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"common": {
|
||||
"condition_behavior_name": "Condition passes if",
|
||||
"condition_for_name": "For at least",
|
||||
"trigger_behavior_name": "Trigger when",
|
||||
"trigger_for_name": "For at least"
|
||||
},
|
||||
@@ -10,6 +11,9 @@
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"name": "[%key:component::garage_door::common::condition_behavior_name%]"
|
||||
},
|
||||
"for": {
|
||||
"name": "[%key:component::garage_door::common::condition_for_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Garage door is closed"
|
||||
@@ -19,6 +23,9 @@
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"name": "[%key:component::garage_door::common::condition_behavior_name%]"
|
||||
},
|
||||
"for": {
|
||||
"name": "[%key:component::garage_door::common::condition_for_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Garage door is open"
|
||||
|
||||
@@ -8,6 +8,11 @@
|
||||
options:
|
||||
- all
|
||||
- any
|
||||
for:
|
||||
required: true
|
||||
default: 00:00:00
|
||||
selector:
|
||||
duration:
|
||||
|
||||
is_closed:
|
||||
fields: *condition_common_fields
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"common": {
|
||||
"condition_behavior_name": "Condition passes if",
|
||||
"condition_for_name": "For at least",
|
||||
"trigger_behavior_name": "Trigger when",
|
||||
"trigger_for_name": "For at least"
|
||||
},
|
||||
@@ -10,6 +11,9 @@
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"name": "[%key:component::gate::common::condition_behavior_name%]"
|
||||
},
|
||||
"for": {
|
||||
"name": "[%key:component::gate::common::condition_for_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Gate is closed"
|
||||
@@ -19,6 +23,9 @@
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"name": "[%key:component::gate::common::condition_behavior_name%]"
|
||||
},
|
||||
"for": {
|
||||
"name": "[%key:component::gate::common::condition_for_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Gate is open"
|
||||
|
||||
@@ -67,7 +67,7 @@ from .const import (
|
||||
RECOMMENDED_VERSION,
|
||||
)
|
||||
from .server import Server
|
||||
from .util import get_go2rtc_unix_socket_path
|
||||
from .util import get_camera_identifier, get_go2rtc_unix_socket_path
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -308,7 +308,7 @@ class WebRTCProvider(CameraWebRTCProvider):
|
||||
return
|
||||
|
||||
self._sessions[session_id] = ws_client = Go2RtcWsClient(
|
||||
self._session, self._url, source=camera.entity_id
|
||||
self._session, self._url, source=get_camera_identifier(camera)
|
||||
)
|
||||
|
||||
@callback
|
||||
@@ -354,7 +354,7 @@ class WebRTCProvider(CameraWebRTCProvider):
|
||||
"""Get an image from the camera."""
|
||||
await self._update_stream_source(camera)
|
||||
return await self._rest_client.get_jpeg_snapshot(
|
||||
camera.entity_id, width, height
|
||||
get_camera_identifier(camera), width, height
|
||||
)
|
||||
|
||||
async def _update_stream_source(self, camera: Camera) -> None:
|
||||
@@ -399,18 +399,19 @@ class WebRTCProvider(CameraWebRTCProvider):
|
||||
stream_source += "#rotate=90"
|
||||
|
||||
streams = await self._rest_client.streams.list()
|
||||
identifier = get_camera_identifier(camera)
|
||||
|
||||
if (stream := streams.get(camera.entity_id)) is None or not any(
|
||||
if (stream := streams.get(identifier)) is None or not any(
|
||||
stream_source == producer.url for producer in stream.producers
|
||||
):
|
||||
await self._rest_client.streams.add(
|
||||
camera.entity_id,
|
||||
identifier,
|
||||
[
|
||||
stream_source,
|
||||
# We are setting any ffmpeg rtsp related logs to debug
|
||||
# Connection problems to the camera will be logged by the first stream
|
||||
# Therefore setting it to debug will not hide any important logs
|
||||
f"ffmpeg:{camera.entity_id}#audio=opus#query=log_level=debug",
|
||||
f"ffmpeg:{identifier}#audio=opus#query=log_level=debug",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
"""Go2rtc utility functions."""
|
||||
|
||||
from pathlib import Path
|
||||
import string
|
||||
from urllib.parse import quote
|
||||
|
||||
from homeassistant.components.camera import Camera
|
||||
|
||||
_HA_MANAGED_UNIX_SOCKET_FILE = "go2rtc.sock"
|
||||
# Go2rtc is not validating the camera identifier, but some characters (e.g. : or #)
|
||||
# have special meaning in URLs and could cause issues.
|
||||
_SAFE_CHARS = string.ascii_letters + string.digits + "._-"
|
||||
|
||||
|
||||
def get_go2rtc_unix_socket_path(path: str | Path) -> str:
|
||||
@@ -10,3 +17,11 @@ def get_go2rtc_unix_socket_path(path: str | Path) -> str:
|
||||
if not isinstance(path, Path):
|
||||
path = Path(path)
|
||||
return str(path / _HA_MANAGED_UNIX_SOCKET_FILE)
|
||||
|
||||
|
||||
def get_camera_identifier(camera: Camera) -> str:
|
||||
"""Get the Go2rtc camera identifier."""
|
||||
attr = camera.entity_id
|
||||
if camera.unique_id is not None:
|
||||
attr = f"{camera.platform.platform_name}_{camera.unique_id}"
|
||||
return quote(attr, safe=_SAFE_CHARS)
|
||||
|
||||
@@ -3,38 +3,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
from types import MappingProxyType
|
||||
|
||||
from google.genai import Client
|
||||
from google.genai.errors import APIError, ClientError
|
||||
from requests.exceptions import Timeout
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
|
||||
from homeassistant.const import CONF_API_KEY, Platform
|
||||
from homeassistant.core import (
|
||||
HomeAssistant,
|
||||
ServiceCall,
|
||||
ServiceResponse,
|
||||
SupportsResponse,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import (
|
||||
ConfigEntryAuthFailed,
|
||||
ConfigEntryError,
|
||||
ConfigEntryNotReady,
|
||||
HomeAssistantError,
|
||||
)
|
||||
from homeassistant.helpers import (
|
||||
config_validation as cv,
|
||||
device_registry as dr,
|
||||
entity_registry as er,
|
||||
issue_registry as ir,
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import (
|
||||
CONF_PROMPT,
|
||||
DEFAULT_AI_TASK_NAME,
|
||||
DEFAULT_STT_NAME,
|
||||
DEFAULT_TITLE,
|
||||
@@ -47,11 +37,6 @@ from .const import (
|
||||
RECOMMENDED_TTS_OPTIONS,
|
||||
TIMEOUT_MILLIS,
|
||||
)
|
||||
from .entity import async_prepare_files_for_prompt
|
||||
|
||||
SERVICE_GENERATE_CONTENT = "generate_content"
|
||||
CONF_IMAGE_FILENAME = "image_filename"
|
||||
CONF_FILENAMES = "filenames"
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
PLATFORMS = (
|
||||
@@ -69,88 +54,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
|
||||
await async_migrate_integration(hass)
|
||||
|
||||
async def generate_content(call: ServiceCall) -> ServiceResponse:
|
||||
"""Generate content from text and optionally images."""
|
||||
LOGGER.warning(
|
||||
"Action '%s.%s' is deprecated and will be removed in the 2026.4.0 release. "
|
||||
"Please use the 'ai_task.generate_data' action instead",
|
||||
DOMAIN,
|
||||
SERVICE_GENERATE_CONTENT,
|
||||
)
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"deprecated_generate_content",
|
||||
breaks_in_ha_version="2026.4.0",
|
||||
is_fixable=False,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="deprecated_generate_content",
|
||||
)
|
||||
|
||||
prompt_parts = [call.data[CONF_PROMPT]]
|
||||
|
||||
config_entry: GoogleGenerativeAIConfigEntry = (
|
||||
hass.config_entries.async_loaded_entries(DOMAIN)[0]
|
||||
)
|
||||
|
||||
client = config_entry.runtime_data
|
||||
|
||||
files = call.data[CONF_FILENAMES]
|
||||
|
||||
if files:
|
||||
for filename in files:
|
||||
if not hass.config.is_allowed_path(filename):
|
||||
raise HomeAssistantError(
|
||||
f"Cannot read `{filename}`, no access to path; "
|
||||
"`allowlist_external_dirs` may need to be adjusted in "
|
||||
"`configuration.yaml`"
|
||||
)
|
||||
|
||||
prompt_parts.extend(
|
||||
await async_prepare_files_for_prompt(
|
||||
hass, client, [(Path(filename), None) for filename in files]
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
response = await client.aio.models.generate_content(
|
||||
model=RECOMMENDED_CHAT_MODEL, contents=prompt_parts
|
||||
)
|
||||
except (
|
||||
APIError,
|
||||
ValueError,
|
||||
) as err:
|
||||
raise HomeAssistantError(f"Error generating content: {err}") from err
|
||||
|
||||
if response.prompt_feedback:
|
||||
raise HomeAssistantError(
|
||||
f"Error generating content due to content violations, reason: {response.prompt_feedback.block_reason_message}"
|
||||
)
|
||||
|
||||
if (
|
||||
not response.candidates
|
||||
or not response.candidates[0].content
|
||||
or not response.candidates[0].content.parts
|
||||
):
|
||||
raise HomeAssistantError("Unknown error generating content")
|
||||
|
||||
return {"text": response.text}
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_GENERATE_CONTENT,
|
||||
generate_content,
|
||||
schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_PROMPT): cv.string,
|
||||
vol.Optional(CONF_FILENAMES, default=[]): vol.All(
|
||||
cv.ensure_list, [cv.string]
|
||||
),
|
||||
}
|
||||
),
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
description_placeholders={"example_image_path": "/config/www/image.jpg"},
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -338,6 +338,7 @@ def _convert_content(
|
||||
|
||||
|
||||
async def _transform_stream(
|
||||
chat_log: conversation.ChatLog,
|
||||
result: AsyncIterator[GenerateContentResponse],
|
||||
) -> AsyncGenerator[conversation.AssistantContentDeltaDict]:
|
||||
new_message = True
|
||||
@@ -346,6 +347,19 @@ async def _transform_stream(
|
||||
async for response in result:
|
||||
LOGGER.debug("Received response chunk: %s", response)
|
||||
|
||||
if (usage := response.usage_metadata) is not None:
|
||||
chat_log.async_trace(
|
||||
{
|
||||
"stats": {
|
||||
"input_tokens": usage.prompt_token_count,
|
||||
"cached_input_tokens": (
|
||||
usage.cached_content_token_count or 0
|
||||
),
|
||||
"output_tokens": usage.candidates_token_count,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if new_message:
|
||||
if part_details:
|
||||
yield {"native": ContentDetails(part_details=part_details)}
|
||||
@@ -623,7 +637,7 @@ class GoogleGenerativeAILLMBaseEntity(Entity):
|
||||
content
|
||||
async for content in chat_log.async_add_delta_content_stream(
|
||||
self.entity_id,
|
||||
_transform_stream(chat_response_generator),
|
||||
_transform_stream(chat_log, chat_response_generator),
|
||||
)
|
||||
if isinstance(content, conversation.ToolResultContent)
|
||||
]
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"services": {
|
||||
"generate_content": {
|
||||
"service": "mdi:receipt-text"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
generate_content:
|
||||
fields:
|
||||
prompt:
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
multiline: true
|
||||
filenames:
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
multiple: true
|
||||
@@ -149,29 +149,5 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_generate_content": {
|
||||
"description": "Action 'google_generative_ai_conversation.generate_content' is deprecated and will be removed in the 2026.4.0 release. Please use the 'ai_task.generate_data' action instead",
|
||||
"title": "Deprecated 'generate_content' action"
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"generate_content": {
|
||||
"description": "Generate content from a prompt consisting of text and optionally images (deprecated)",
|
||||
"fields": {
|
||||
"filenames": {
|
||||
"description": "Attachments to add to the prompt (images, PDFs, etc)",
|
||||
"example": "{example_image_path}",
|
||||
"name": "Attachment filenames"
|
||||
},
|
||||
"prompt": {
|
||||
"description": "The prompt",
|
||||
"example": "Describe what you see in these images",
|
||||
"name": "Prompt"
|
||||
}
|
||||
},
|
||||
"name": "Generate content (deprecated)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
@@ -36,6 +36,40 @@ class GreenPlanetEnergySensorEntityDescription(SensorEntityDescription):
|
||||
value_fn: Callable[[GreenPlanetEnergyAPI, dict[str, Any]], float | datetime | None]
|
||||
|
||||
|
||||
def _get_lowest_price_day_time(
|
||||
api: GreenPlanetEnergyAPI, data: dict[str, Any]
|
||||
) -> datetime | None:
|
||||
"""Return timestamp of the lowest-priced day hour (06:00–18:00)."""
|
||||
now = dt_util.now()
|
||||
now_h = now.hour
|
||||
hour = api.get_lowest_price_day_with_hour(data, now_h)[1]
|
||||
if hour is None:
|
||||
return None
|
||||
# After 18:00 the day period is over; use tomorrow's date
|
||||
base = dt_util.start_of_local_day(now + timedelta(days=1) if now_h >= 18 else now)
|
||||
return base.replace(hour=hour)
|
||||
|
||||
|
||||
def _get_lowest_price_night_time(
|
||||
api: GreenPlanetEnergyAPI, data: dict[str, Any]
|
||||
) -> datetime | None:
|
||||
"""Return timestamp of the lowest-priced night hour (18:00-06:00)."""
|
||||
now = dt_util.now()
|
||||
now_h = now.hour
|
||||
hour = api.get_lowest_price_night_with_hour(data)[1]
|
||||
if hour is None:
|
||||
return None
|
||||
|
||||
if now_h < 6:
|
||||
base = dt_util.start_of_local_day(
|
||||
now - timedelta(days=1) if hour >= 18 else now
|
||||
)
|
||||
else:
|
||||
base = dt_util.start_of_local_day(now + timedelta(days=1) if hour < 6 else now)
|
||||
|
||||
return base.replace(hour=hour)
|
||||
|
||||
|
||||
SENSOR_DESCRIPTIONS: list[GreenPlanetEnergySensorEntityDescription] = [
|
||||
# Statistical sensors only - hourly prices available via service
|
||||
GreenPlanetEnergySensorEntityDescription(
|
||||
@@ -67,7 +101,7 @@ SENSOR_DESCRIPTIONS: list[GreenPlanetEnergySensorEntityDescription] = [
|
||||
translation_placeholders={"time_range": "(06:00-18:00)"},
|
||||
value_fn=lambda api, data: (
|
||||
price / 100
|
||||
if (price := api.get_lowest_price_day(data)) is not None
|
||||
if (price := api.get_lowest_price_day(data, dt_util.now().hour)) is not None
|
||||
else None
|
||||
),
|
||||
),
|
||||
@@ -76,11 +110,7 @@ SENSOR_DESCRIPTIONS: list[GreenPlanetEnergySensorEntityDescription] = [
|
||||
translation_key="lowest_price_day_time",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
translation_placeholders={"time_range": "(06:00-18:00)"},
|
||||
value_fn=lambda api, data: (
|
||||
dt_util.start_of_local_day().replace(hour=hour)
|
||||
if (hour := api.get_lowest_price_day_with_hour(data)[1]) is not None
|
||||
else None
|
||||
),
|
||||
value_fn=_get_lowest_price_day_time,
|
||||
),
|
||||
GreenPlanetEnergySensorEntityDescription(
|
||||
key="gpe_lowest_price_night",
|
||||
@@ -99,11 +129,7 @@ SENSOR_DESCRIPTIONS: list[GreenPlanetEnergySensorEntityDescription] = [
|
||||
translation_key="lowest_price_night_time",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
translation_placeholders={"time_range": "(18:00-06:00)"},
|
||||
value_fn=lambda api, data: (
|
||||
dt_util.start_of_local_day().replace(hour=hour)
|
||||
if (hour := api.get_lowest_price_night_with_hour(data)[1]) is not None
|
||||
else None
|
||||
),
|
||||
value_fn=_get_lowest_price_night_time,
|
||||
),
|
||||
GreenPlanetEnergySensorEntityDescription(
|
||||
key="gpe_current_price",
|
||||
|
||||
@@ -8,7 +8,7 @@ from aiohasupervisor.models import IngressPanel
|
||||
from aiohttp import web
|
||||
|
||||
from homeassistant.components import frontend
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
from homeassistant.components.http import HomeAssistantView, require_admin
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .handler import get_supervisor_client
|
||||
@@ -43,6 +43,7 @@ class HassIOAddonPanel(HomeAssistantView):
|
||||
self.hass = hass
|
||||
self.client = get_supervisor_client(hass)
|
||||
|
||||
@require_admin
|
||||
async def post(self, request: web.Request, addon: str) -> web.Response:
|
||||
"""Handle new add-on panel requests."""
|
||||
panels = await self.get_panels()
|
||||
@@ -56,6 +57,7 @@ class HassIOAddonPanel(HomeAssistantView):
|
||||
_register_panel(self.hass, addon, panels[addon])
|
||||
return web.Response()
|
||||
|
||||
@require_admin
|
||||
async def delete(self, request: web.Request, addon: str) -> web.Response:
|
||||
"""Handle remove add-on panel requests."""
|
||||
frontend.async_remove_panel(self.hass, addon)
|
||||
|
||||
@@ -72,8 +72,6 @@ ATTR_WS_EVENT = "event"
|
||||
|
||||
X_AUTH_TOKEN = "X-Supervisor-Token"
|
||||
X_INGRESS_PATH = "X-Ingress-Path"
|
||||
X_HASS_USER_ID = "X-Hass-User-ID"
|
||||
X_HASS_IS_ADMIN = "X-Hass-Is-Admin"
|
||||
X_HASS_SOURCE = "X-Hass-Source"
|
||||
|
||||
WS_TYPE = "type"
|
||||
|
||||
@@ -13,7 +13,7 @@ from aiohttp import web
|
||||
from aiohttp.web_exceptions import HTTPServiceUnavailable
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
from homeassistant.components.http import HomeAssistantView, require_admin
|
||||
from homeassistant.const import ATTR_SERVICE, EVENT_HOMEASSISTANT_START
|
||||
from homeassistant.core import Event, HomeAssistant, callback
|
||||
from homeassistant.helpers import discovery_flow
|
||||
@@ -82,6 +82,7 @@ class HassIODiscovery(HomeAssistantView):
|
||||
self.hass = hass
|
||||
self._supervisor_client = get_supervisor_client(hass)
|
||||
|
||||
@require_admin
|
||||
async def post(self, request: web.Request, uuid: str) -> web.Response:
|
||||
"""Handle new discovery requests."""
|
||||
# Fetch discovery data and prevent injections
|
||||
@@ -94,6 +95,7 @@ class HassIODiscovery(HomeAssistantView):
|
||||
await self.async_process_new(data)
|
||||
return web.Response()
|
||||
|
||||
@require_admin
|
||||
async def delete(self, request: web.Request, uuid: str) -> web.Response:
|
||||
"""Handle remove discovery requests."""
|
||||
data: dict[str, Any] = await request.json()
|
||||
|
||||
@@ -24,11 +24,9 @@ from aiohttp.web_exceptions import HTTPBadGateway
|
||||
|
||||
from homeassistant.components.http import (
|
||||
KEY_AUTHENTICATED,
|
||||
KEY_HASS,
|
||||
KEY_HASS_USER,
|
||||
HomeAssistantView,
|
||||
)
|
||||
from homeassistant.components.onboarding import async_is_onboarded
|
||||
|
||||
from .const import X_HASS_SOURCE
|
||||
|
||||
@@ -53,16 +51,7 @@ NO_TIMEOUT = re.compile(
|
||||
r")$"
|
||||
)
|
||||
|
||||
# fmt: off
|
||||
# Onboarding can upload backups and restore it
|
||||
PATHS_NOT_ONBOARDED = re.compile(
|
||||
r"^(?:"
|
||||
r"|backups/[a-f0-9]{8}(/info|/new/upload|/download|/restore/full|/restore/partial)?"
|
||||
r"|backups/new/upload"
|
||||
r")$"
|
||||
)
|
||||
|
||||
# Authenticated users manage backups + download logs, changelog and documentation
|
||||
# Admin users manage backups + download logs, changelog and documentation
|
||||
PATHS_ADMIN = re.compile(
|
||||
r"^(?:"
|
||||
r"|backups/[a-f0-9]{8}(/info|/download|/restore/full|/restore/partial)?"
|
||||
@@ -142,27 +131,19 @@ class HassIOView(HomeAssistantView):
|
||||
"""Return a client request with proxy origin for Hass.io supervisor.
|
||||
|
||||
Use cases:
|
||||
- Onboarding allows restoring backups
|
||||
- Load Supervisor panel and add-on logo unauthenticated
|
||||
- User upload/restore backups
|
||||
- Admin users upload/restore backups and access logs
|
||||
"""
|
||||
# No bullshit
|
||||
if path != unquote(path):
|
||||
return web.Response(status=HTTPStatus.BAD_REQUEST)
|
||||
|
||||
hass = request.app[KEY_HASS]
|
||||
is_admin = request[KEY_AUTHENTICATED] and request[KEY_HASS_USER].is_admin
|
||||
authorized = is_admin
|
||||
|
||||
if is_admin:
|
||||
allowed_paths = PATHS_ADMIN
|
||||
|
||||
elif not async_is_onboarded(hass):
|
||||
allowed_paths = PATHS_NOT_ONBOARDED
|
||||
|
||||
# During onboarding we need the user to manage backups
|
||||
authorized = True
|
||||
|
||||
else:
|
||||
# Either unauthenticated or not an admin
|
||||
allowed_paths = PATHS_NO_AUTH
|
||||
|
||||
@@ -29,6 +29,7 @@ from homeassistant.helpers import (
|
||||
device_registry as dr,
|
||||
selector,
|
||||
)
|
||||
from homeassistant.helpers.service import async_register_admin_service
|
||||
from homeassistant.util.dt import now
|
||||
|
||||
from .const import (
|
||||
@@ -196,8 +197,8 @@ def async_register_app_services(
|
||||
) from err
|
||||
|
||||
for service in simple_app_services:
|
||||
hass.services.async_register(
|
||||
DOMAIN, service, async_simple_app_service_handler, schema=SCHEMA_APP
|
||||
async_register_admin_service(
|
||||
hass, DOMAIN, service, async_simple_app_service_handler, schema=SCHEMA_APP
|
||||
)
|
||||
|
||||
async def async_app_stdin_service_handler(service: ServiceCall) -> None:
|
||||
@@ -220,7 +221,8 @@ def async_register_app_services(
|
||||
f"Failed to write stdin to app {app_slug}: {err}"
|
||||
) from err
|
||||
|
||||
hass.services.async_register(
|
||||
async_register_admin_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
SERVICE_APP_STDIN,
|
||||
async_app_stdin_service_handler,
|
||||
@@ -247,8 +249,12 @@ def async_register_app_services(
|
||||
) from err
|
||||
|
||||
for service in simple_addon_services:
|
||||
hass.services.async_register(
|
||||
DOMAIN, service, async_simple_addon_service_handler, schema=SCHEMA_ADDON
|
||||
async_register_admin_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
service,
|
||||
async_simple_addon_service_handler,
|
||||
schema=SCHEMA_ADDON,
|
||||
)
|
||||
|
||||
async def async_addon_stdin_service_handler(service: ServiceCall) -> None:
|
||||
@@ -267,7 +273,8 @@ def async_register_app_services(
|
||||
f"Failed to write stdin to app {addon_slug}: {err}"
|
||||
) from err
|
||||
|
||||
hass.services.async_register(
|
||||
async_register_admin_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
SERVICE_ADDON_STDIN,
|
||||
async_addon_stdin_service_handler,
|
||||
@@ -294,8 +301,12 @@ def async_register_host_services(
|
||||
raise HomeAssistantError(f"Failed to {action} the host: {err}") from err
|
||||
|
||||
for service in simple_host_services:
|
||||
hass.services.async_register(
|
||||
DOMAIN, service, async_simple_host_service_handler, schema=SCHEMA_NO_DATA
|
||||
async_register_admin_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
service,
|
||||
async_simple_host_service_handler,
|
||||
schema=SCHEMA_NO_DATA,
|
||||
)
|
||||
|
||||
|
||||
@@ -319,7 +330,8 @@ def async_register_backup_restore_services(
|
||||
|
||||
return {"backup": backup.slug}
|
||||
|
||||
hass.services.async_register(
|
||||
async_register_admin_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
SERVICE_BACKUP_FULL,
|
||||
async_full_backup_service_handler,
|
||||
@@ -345,7 +357,8 @@ def async_register_backup_restore_services(
|
||||
|
||||
return {"backup": backup.slug}
|
||||
|
||||
hass.services.async_register(
|
||||
async_register_admin_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
SERVICE_BACKUP_PARTIAL,
|
||||
async_partial_backup_service_handler,
|
||||
@@ -367,7 +380,8 @@ def async_register_backup_restore_services(
|
||||
f"Failed to full restore from backup {backup_slug}: {err}"
|
||||
) from err
|
||||
|
||||
hass.services.async_register(
|
||||
async_register_admin_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
SERVICE_RESTORE_FULL,
|
||||
async_full_restore_service_handler,
|
||||
@@ -389,7 +403,8 @@ def async_register_backup_restore_services(
|
||||
f"Failed to partial restore from backup {backup_slug}: {err}"
|
||||
) from err
|
||||
|
||||
hass.services.async_register(
|
||||
async_register_admin_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
SERVICE_RESTORE_PARTIAL,
|
||||
async_partial_restore_service_handler,
|
||||
@@ -434,6 +449,6 @@ def async_register_network_storage_services(
|
||||
translation_placeholders={"name": device.name, "error": str(error)},
|
||||
) from error
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_MOUNT_RELOAD, async_mount_reload, SCHEMA_MOUNT_RELOAD
|
||||
async_register_admin_service(
|
||||
hass, DOMAIN, SERVICE_MOUNT_RELOAD, async_mount_reload, SCHEMA_MOUNT_RELOAD
|
||||
)
|
||||
|
||||
@@ -47,15 +47,15 @@ SCHEMA_WEBSOCKET_EVENT = vol.Schema(
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
|
||||
# Endpoints needed for ingress can't require admin because addons can set `panel_admin: false`
|
||||
# fmt: off
|
||||
# Endpoints needed for ingress can't require admin because add-ons can set `panel_admin: false`
|
||||
RE_ADDONS_INFO_ENDPOINT = r"/addons/[^/]+/info"
|
||||
WS_ADDONS_INFO_ENDPOINT = re.compile(r"^" + RE_ADDONS_INFO_ENDPOINT + r"$")
|
||||
WS_NO_ADMIN_ENDPOINTS = re.compile(
|
||||
r"^(?:"
|
||||
r"|/ingress/(session|validate_session)"
|
||||
r"|/addons/[^/]+/info"
|
||||
r"/ingress/(session|validate_session)"
|
||||
f"|{RE_ADDONS_INFO_ENDPOINT}"
|
||||
r")$"
|
||||
)
|
||||
# fmt: on
|
||||
|
||||
_LOGGER: logging.Logger = logging.getLogger(__package__)
|
||||
|
||||
@@ -92,6 +92,7 @@ def websocket_subscribe(
|
||||
|
||||
|
||||
@callback
|
||||
@websocket_api.ws_require_user(only_supervisor=True)
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required(WS_TYPE): WS_TYPE_EVENT,
|
||||
@@ -150,7 +151,12 @@ async def websocket_supervisor_api(
|
||||
msg[WS_ID], code=websocket_api.ERR_UNKNOWN_ERROR, message=str(err)
|
||||
)
|
||||
else:
|
||||
connection.send_result(msg[WS_ID], result.get(ATTR_DATA, {}))
|
||||
data = result.get(ATTR_DATA, {})
|
||||
# Remove options from add-on info for non-admin users, as options can contain
|
||||
# sensitive information and the frontend does not require it for ingress.
|
||||
if not connection.user.is_admin and WS_ADDONS_INFO_ENDPOINT.match(command):
|
||||
data.pop("options", None)
|
||||
connection.send_result(msg[WS_ID], data)
|
||||
|
||||
|
||||
@websocket_api.require_admin
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/holiday",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["holidays==0.94", "babel==2.15.0"]
|
||||
"requirements": ["holidays==0.95", "babel==2.15.0"]
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@
|
||||
"title": "[%key:component::homeassistant::issues::config_entry_unique_id_collision::title%]"
|
||||
},
|
||||
"country_not_configured": {
|
||||
"description": "No country has been configured, please update the configuration by clicking on the \"learn more\" button below.",
|
||||
"description": "No country has been configured. Click the \"Learn more\" button below to set your country.",
|
||||
"title": "The country has not been configured"
|
||||
},
|
||||
"deprecated_architecture": {
|
||||
|
||||
@@ -26,7 +26,9 @@ from homeassistant.const import (
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.helpers import config_validation as cv, discovery
|
||||
from homeassistant.helpers.service import async_register_admin_service
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util.async_ import run_callback_threadsafe
|
||||
|
||||
from .const import (
|
||||
ATTR_ADDRESS,
|
||||
@@ -381,12 +383,15 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
|
||||
homematic.setInstallMode(interface, t=time, mode=mode, address=address)
|
||||
|
||||
hass.services.register(
|
||||
run_callback_threadsafe(
|
||||
hass.loop,
|
||||
async_register_admin_service,
|
||||
hass,
|
||||
DOMAIN,
|
||||
SERVICE_SET_INSTALL_MODE,
|
||||
_service_handle_install_mode,
|
||||
schema=SCHEMA_SERVICE_SET_INSTALL_MODE,
|
||||
)
|
||||
SCHEMA_SERVICE_SET_INSTALL_MODE,
|
||||
).result()
|
||||
|
||||
def _service_put_paramset(service: ServiceCall) -> None:
|
||||
"""Service to call the putParamset method on a HomeMatic connection."""
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "assumed_state",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["rf-protocols==2.1.0"]
|
||||
"requirements": ["rf-protocols==2.2.0"]
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"config_flow": true,
|
||||
"dependencies": ["http"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/html5",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["http_ece", "py_vapid", "pywebpush"],
|
||||
"requirements": ["pywebpush==2.3.0", "py_vapid==1.9.4"],
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from aiohue.v2 import HueBridgeV2
|
||||
@@ -29,6 +30,8 @@ ATTR_DYNAMIC = "dynamic"
|
||||
ATTR_SPEED = "speed"
|
||||
ATTR_BRIGHTNESS = "brightness"
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
@@ -49,10 +52,18 @@ async def async_setup_entry(
|
||||
event_type: EventType, resource: HueScene | HueSmartScene
|
||||
) -> None:
|
||||
"""Add entity from Hue resource."""
|
||||
if isinstance(resource, HueSmartScene):
|
||||
async_add_entities([HueSmartSceneEntity(bridge, api.scenes, resource)])
|
||||
else:
|
||||
async_add_entities([HueSceneEntity(bridge, api.scenes, resource)])
|
||||
# Catch creation errors to continue adding other scenes even if one fails
|
||||
try:
|
||||
entity: HueSceneEntityBase
|
||||
if isinstance(resource, HueSmartScene):
|
||||
entity = HueSmartSceneEntity(bridge, api.scenes, resource)
|
||||
else:
|
||||
entity = HueSceneEntity(bridge, api.scenes, resource)
|
||||
except KeyError, StopIteration:
|
||||
LOGGER.exception("Unable to create Hue scene entity for %s", resource.id)
|
||||
return
|
||||
|
||||
async_add_entities([entity])
|
||||
|
||||
# add all current items in controller
|
||||
for item in api.scenes:
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["aioautomower"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["aioautomower==2.7.3"]
|
||||
"requirements": ["aioautomower==2.7.4"]
|
||||
}
|
||||
|
||||
@@ -13,7 +13,11 @@ from iaqualink.exception import (
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.config_entries import (
|
||||
SOURCE_RECONFIGURE,
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
)
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.helpers.httpx_client import get_async_client
|
||||
from homeassistant.util.ssl import SSL_ALPN_HTTP11_HTTP2
|
||||
@@ -84,12 +88,16 @@ class AqualinkFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle confirmation of reauthentication."""
|
||||
errors = {}
|
||||
|
||||
reauth_entry = self._get_reauth_entry()
|
||||
config_entry = (
|
||||
self._get_reconfigure_entry()
|
||||
if self.source == SOURCE_RECONFIGURE
|
||||
else self._get_reauth_entry()
|
||||
)
|
||||
if user_input is not None:
|
||||
errors = await self._async_test_credentials(user_input)
|
||||
if not errors:
|
||||
return self.async_update_reload_and_abort(
|
||||
reauth_entry,
|
||||
config_entry,
|
||||
title=user_input[CONF_USERNAME],
|
||||
data_updates={
|
||||
CONF_USERNAME: user_input[CONF_USERNAME],
|
||||
@@ -98,7 +106,15 @@ class AqualinkFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
step_id=(
|
||||
"reconfigure" if self.source == SOURCE_RECONFIGURE else "reauth_confirm"
|
||||
),
|
||||
data_schema=CREDENTIALS_DATA_SCHEMA,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reconfiguration of the integration."""
|
||||
return await self.async_step_reauth_confirm(user_input)
|
||||
|
||||
@@ -9,6 +9,6 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["iaqualink"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["iaqualink==0.6.0", "h2==4.3.0"],
|
||||
"requirements": ["iaqualink==0.7.0", "h2==4.3.0"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ rules:
|
||||
entity-translations: todo
|
||||
exception-translations: todo
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: todo
|
||||
reconfiguration-flow: done
|
||||
repair-issues: todo
|
||||
stale-devices: todo
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
|
||||
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
@@ -19,9 +20,21 @@
|
||||
"password": "[%key:component::iaqualink::config::step::user::data_description::password%]",
|
||||
"username": "[%key:component::iaqualink::config::step::user::data_description::username%]"
|
||||
},
|
||||
"description": "Please enter the username and password for your iAquaLink account.",
|
||||
"description": "[%key:component::iaqualink::config::step::user::description%]",
|
||||
"title": "Reauthenticate iAquaLink"
|
||||
},
|
||||
"reconfigure": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
},
|
||||
"data_description": {
|
||||
"password": "[%key:component::iaqualink::config::step::user::data_description::password%]",
|
||||
"username": "[%key:component::iaqualink::config::step::user::data_description::username%]"
|
||||
},
|
||||
"description": "[%key:component::iaqualink::config::step::user::description%]",
|
||||
"title": "Reconnect iAquaLink"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
|
||||
@@ -4,8 +4,12 @@ from __future__ import annotations
|
||||
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import IndevoltConfigEntry, IndevoltCoordinator
|
||||
from .services import async_setup_services
|
||||
|
||||
PLATFORMS: list[Platform] = [
|
||||
Platform.BUTTON,
|
||||
@@ -14,6 +18,7 @@ PLATFORMS: list[Platform] = [
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
]
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: IndevoltConfigEntry) -> bool:
|
||||
@@ -29,6 +34,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: IndevoltConfigEntry) ->
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up indevolt services (actions)."""
|
||||
|
||||
await async_setup_services(hass)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: IndevoltConfigEntry) -> bool:
|
||||
"""Unload a config entry / clean up resources (when integration is removed / reloaded)."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
|
||||
from typing import Final
|
||||
|
||||
from indevolt_api import (
|
||||
IndevoltBattery,
|
||||
IndevoltConfig,
|
||||
IndevoltGrid,
|
||||
IndevoltSolar,
|
||||
IndevoltSystem,
|
||||
)
|
||||
|
||||
DOMAIN: Final = "indevolt"
|
||||
|
||||
# Default configurations
|
||||
@@ -11,107 +19,99 @@ DEFAULT_PORT: Final = 8080
|
||||
CONF_SERIAL_NUMBER: Final = "serial_number"
|
||||
CONF_GENERATION: Final = "generation"
|
||||
|
||||
# API write/read keys for energy and value for outdoor/portable mode
|
||||
ENERGY_MODE_READ_KEY: Final = "7101"
|
||||
ENERGY_MODE_WRITE_KEY: Final = "47005"
|
||||
PORTABLE_MODE: Final = 0
|
||||
|
||||
# Value for real-time control mode
|
||||
REALTIME_ACTION_MODE: Final = 4
|
||||
|
||||
# API key fields
|
||||
SENSOR_KEYS: Final[dict[int, list[str]]] = {
|
||||
1: [
|
||||
"606",
|
||||
"7101",
|
||||
"2101",
|
||||
"2108",
|
||||
"2107",
|
||||
"6000",
|
||||
"6001",
|
||||
"6002",
|
||||
"1501",
|
||||
"1502",
|
||||
"1664",
|
||||
"1665",
|
||||
"1666",
|
||||
"1667",
|
||||
"6105",
|
||||
"21028",
|
||||
"1505",
|
||||
IndevoltSystem.OPERATING_MODE,
|
||||
IndevoltConfig.READ_ENERGY_MODE,
|
||||
IndevoltSystem.INPUT_POWER,
|
||||
IndevoltSystem.OUTPUT_POWER,
|
||||
IndevoltSystem.TOTAL_INPUT_ENERGY,
|
||||
IndevoltBattery.POWER,
|
||||
IndevoltBattery.CHARGE_DISCHARGE_STATE,
|
||||
IndevoltBattery.SOC,
|
||||
IndevoltSolar.DC_OUTPUT_POWER,
|
||||
IndevoltSolar.DAILY_PRODUCTION,
|
||||
IndevoltSolar.DC_INPUT_POWER_1,
|
||||
IndevoltSolar.DC_INPUT_POWER_2,
|
||||
IndevoltSolar.DC_INPUT_POWER_3,
|
||||
IndevoltSolar.DC_INPUT_POWER_4,
|
||||
IndevoltConfig.READ_DISCHARGE_LIMIT,
|
||||
IndevoltGrid.METER_POWER_GEN1,
|
||||
IndevoltSolar.CUMULATIVE_PRODUCTION,
|
||||
],
|
||||
2: [
|
||||
"606",
|
||||
"7101",
|
||||
"2101",
|
||||
"2108",
|
||||
"2107",
|
||||
"6000",
|
||||
"6001",
|
||||
"6002",
|
||||
"1501",
|
||||
"1502",
|
||||
"1664",
|
||||
"1665",
|
||||
"1666",
|
||||
"1667",
|
||||
"142",
|
||||
"667",
|
||||
"2104",
|
||||
"2105",
|
||||
"11034",
|
||||
"6004",
|
||||
"6005",
|
||||
"6006",
|
||||
"6007",
|
||||
"11016",
|
||||
"2600",
|
||||
"2612",
|
||||
"1632",
|
||||
"1600",
|
||||
"1633",
|
||||
"1601",
|
||||
"1634",
|
||||
"1602",
|
||||
"1635",
|
||||
"1603",
|
||||
"9008",
|
||||
"9032",
|
||||
"9051",
|
||||
"9070",
|
||||
"9165",
|
||||
"9218",
|
||||
"9000",
|
||||
"9016",
|
||||
"9035",
|
||||
"9054",
|
||||
"9149",
|
||||
"9202",
|
||||
"9012",
|
||||
"9030",
|
||||
"9049",
|
||||
"9068",
|
||||
"9163",
|
||||
"9216",
|
||||
"9004",
|
||||
"9020",
|
||||
"9039",
|
||||
"9058",
|
||||
"9153",
|
||||
"9206",
|
||||
"9013",
|
||||
"19173",
|
||||
"19174",
|
||||
"19175",
|
||||
"19176",
|
||||
"19177",
|
||||
"680",
|
||||
"2618",
|
||||
"7171",
|
||||
"11011",
|
||||
"11009",
|
||||
"11010",
|
||||
"6105",
|
||||
"1505",
|
||||
IndevoltSystem.OPERATING_MODE,
|
||||
IndevoltConfig.READ_ENERGY_MODE,
|
||||
IndevoltSystem.INPUT_POWER,
|
||||
IndevoltSystem.OUTPUT_POWER,
|
||||
IndevoltSystem.TOTAL_INPUT_ENERGY,
|
||||
IndevoltBattery.POWER,
|
||||
IndevoltBattery.CHARGE_DISCHARGE_STATE,
|
||||
IndevoltBattery.SOC,
|
||||
IndevoltSolar.DC_OUTPUT_POWER,
|
||||
IndevoltSolar.DAILY_PRODUCTION,
|
||||
IndevoltSolar.DC_INPUT_POWER_1,
|
||||
IndevoltSolar.DC_INPUT_POWER_2,
|
||||
IndevoltSolar.DC_INPUT_POWER_3,
|
||||
IndevoltSolar.DC_INPUT_POWER_4,
|
||||
IndevoltBattery.RATED_CAPACITY_GEN2,
|
||||
IndevoltSystem.BYPASS_POWER,
|
||||
IndevoltSystem.TOTAL_OUTPUT_ENERGY,
|
||||
IndevoltSystem.OFF_GRID_OUTPUT_ENERGY,
|
||||
IndevoltSystem.BYPASS_INPUT_ENERGY,
|
||||
IndevoltBattery.DAILY_CHARGING_ENERGY,
|
||||
IndevoltBattery.DAILY_DISCHARGING_ENERGY,
|
||||
IndevoltBattery.TOTAL_CHARGING_ENERGY,
|
||||
IndevoltBattery.TOTAL_DISCHARGING_ENERGY,
|
||||
IndevoltGrid.METER_POWER_GEN2,
|
||||
IndevoltGrid.VOLTAGE,
|
||||
IndevoltGrid.FREQUENCY,
|
||||
IndevoltSolar.DC_INPUT_CURRENT_1,
|
||||
IndevoltSolar.DC_INPUT_VOLTAGE_1,
|
||||
IndevoltSolar.DC_INPUT_CURRENT_2,
|
||||
IndevoltSolar.DC_INPUT_VOLTAGE_2,
|
||||
IndevoltSolar.DC_INPUT_CURRENT_3,
|
||||
IndevoltSolar.DC_INPUT_VOLTAGE_3,
|
||||
IndevoltSolar.DC_INPUT_CURRENT_4,
|
||||
IndevoltSolar.DC_INPUT_VOLTAGE_4,
|
||||
IndevoltBattery.MAIN_SERIAL_NUMBER,
|
||||
IndevoltBattery.PACK_1_SERIAL_NUMBER,
|
||||
IndevoltBattery.PACK_2_SERIAL_NUMBER,
|
||||
IndevoltBattery.PACK_3_SERIAL_NUMBER,
|
||||
IndevoltBattery.PACK_4_SERIAL_NUMBER,
|
||||
IndevoltBattery.PACK_5_SERIAL_NUMBER,
|
||||
IndevoltBattery.MAIN_SOC,
|
||||
IndevoltBattery.PACK_1_SOC,
|
||||
IndevoltBattery.PACK_2_SOC,
|
||||
IndevoltBattery.PACK_3_SOC,
|
||||
IndevoltBattery.PACK_4_SOC,
|
||||
IndevoltBattery.PACK_5_SOC,
|
||||
IndevoltBattery.MAIN_TEMPERATURE,
|
||||
IndevoltBattery.PACK_1_TEMPERATURE,
|
||||
IndevoltBattery.PACK_2_TEMPERATURE,
|
||||
IndevoltBattery.PACK_3_TEMPERATURE,
|
||||
IndevoltBattery.PACK_4_TEMPERATURE,
|
||||
IndevoltBattery.PACK_5_TEMPERATURE,
|
||||
IndevoltBattery.MAIN_VOLTAGE,
|
||||
IndevoltBattery.PACK_1_VOLTAGE,
|
||||
IndevoltBattery.PACK_2_VOLTAGE,
|
||||
IndevoltBattery.PACK_3_VOLTAGE,
|
||||
IndevoltBattery.PACK_4_VOLTAGE,
|
||||
IndevoltBattery.PACK_5_VOLTAGE,
|
||||
IndevoltBattery.MAIN_CURRENT,
|
||||
IndevoltBattery.PACK_1_CURRENT,
|
||||
IndevoltBattery.PACK_2_CURRENT,
|
||||
IndevoltBattery.PACK_3_CURRENT,
|
||||
IndevoltBattery.PACK_4_CURRENT,
|
||||
IndevoltBattery.PACK_5_CURRENT,
|
||||
IndevoltConfig.READ_BYPASS,
|
||||
IndevoltConfig.READ_GRID_CHARGING,
|
||||
IndevoltConfig.READ_LIGHT,
|
||||
IndevoltConfig.READ_MAX_AC_OUTPUT_POWER,
|
||||
IndevoltConfig.READ_INVERTER_INPUT_LIMIT,
|
||||
IndevoltConfig.READ_FEEDIN_POWER_LIMIT,
|
||||
IndevoltConfig.READ_DISCHARGE_LIMIT,
|
||||
IndevoltSolar.CUMULATIVE_PRODUCTION,
|
||||
],
|
||||
}
|
||||
|
||||
@@ -7,7 +7,13 @@ import logging
|
||||
from typing import Any, Final
|
||||
|
||||
from aiohttp import ClientError
|
||||
from indevolt_api import IndevoltAPI, IndevoltRealtimeAction, TimeOutException
|
||||
from indevolt_api import (
|
||||
IndevoltAPI,
|
||||
IndevoltConfig,
|
||||
IndevoltEnergyMode,
|
||||
IndevoltRealtimeAction,
|
||||
TimeOutException,
|
||||
)
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST, CONF_MODEL
|
||||
@@ -21,10 +27,6 @@ from .const import (
|
||||
CONF_SERIAL_NUMBER,
|
||||
DEFAULT_PORT,
|
||||
DOMAIN,
|
||||
ENERGY_MODE_READ_KEY,
|
||||
ENERGY_MODE_WRITE_KEY,
|
||||
PORTABLE_MODE,
|
||||
REALTIME_ACTION_MODE,
|
||||
SENSOR_KEYS,
|
||||
)
|
||||
|
||||
@@ -69,10 +71,10 @@ class IndevoltCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
session=async_get_clientsession(hass),
|
||||
)
|
||||
|
||||
self.friendly_name = entry.title
|
||||
self.serial_number = entry.data[CONF_SERIAL_NUMBER]
|
||||
self.device_model = entry.data[CONF_MODEL]
|
||||
self.generation = entry.data[CONF_GENERATION]
|
||||
self.friendly_name: str = entry.title
|
||||
self.serial_number: str = entry.data[CONF_SERIAL_NUMBER]
|
||||
self.device_model: str = entry.data[CONF_MODEL]
|
||||
self.generation: int = entry.data[CONF_GENERATION]
|
||||
|
||||
async def _async_setup(self) -> None:
|
||||
"""Fetch device info once on boot."""
|
||||
@@ -107,10 +109,10 @@ class IndevoltCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
raise DeviceConnectionError(f"Device push failed: {err}") from err
|
||||
|
||||
async def async_switch_energy_mode(
|
||||
self, target_mode: int, refresh: bool = True
|
||||
self, target_mode: IndevoltEnergyMode, refresh: bool = True
|
||||
) -> None:
|
||||
"""Attempt to switch device to given energy mode."""
|
||||
current_mode = self.data.get(ENERGY_MODE_READ_KEY)
|
||||
current_mode = self.data.get(IndevoltConfig.READ_ENERGY_MODE)
|
||||
|
||||
# Ensure current energy mode is known
|
||||
if current_mode is None:
|
||||
@@ -120,7 +122,7 @@ class IndevoltCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
)
|
||||
|
||||
# Ensure device is not in "Outdoor/Portable mode"
|
||||
if current_mode == PORTABLE_MODE:
|
||||
if current_mode == IndevoltEnergyMode.OUTDOOR_PORTABLE:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="energy_mode_change_unavailable_outdoor_portable",
|
||||
@@ -129,7 +131,9 @@ class IndevoltCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
# Switch energy mode if required
|
||||
if current_mode != target_mode:
|
||||
try:
|
||||
success = await self.async_push_data(ENERGY_MODE_WRITE_KEY, target_mode)
|
||||
success = await self.async_push_data(
|
||||
IndevoltConfig.WRITE_ENERGY_MODE, target_mode
|
||||
)
|
||||
except (DeviceTimeoutError, DeviceConnectionError) as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
@@ -147,12 +151,23 @@ class IndevoltCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
|
||||
async def async_realtime_action(
|
||||
self,
|
||||
action_code: IndevoltRealtimeAction,
|
||||
action: IndevoltRealtimeAction,
|
||||
power: int = 0,
|
||||
target_soc: int = 0,
|
||||
) -> None:
|
||||
"""Switch mode, execute action, and refresh for real-time control."""
|
||||
await self.async_switch_energy_mode(REALTIME_ACTION_MODE, refresh=False)
|
||||
|
||||
match action_code:
|
||||
await self.async_switch_energy_mode(
|
||||
IndevoltEnergyMode.REAL_TIME_CONTROL, refresh=False
|
||||
)
|
||||
|
||||
success = False
|
||||
|
||||
match action:
|
||||
case IndevoltRealtimeAction.CHARGE:
|
||||
success = await self.api.charge(power, target_soc)
|
||||
case IndevoltRealtimeAction.DISCHARGE:
|
||||
success = await self.api.discharge(power, target_soc)
|
||||
case IndevoltRealtimeAction.STOP:
|
||||
success = await self.api.stop()
|
||||
|
||||
@@ -163,3 +178,7 @@ class IndevoltCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
)
|
||||
|
||||
await self.async_request_refresh()
|
||||
|
||||
def get_emergency_soc(self) -> int:
|
||||
"""Get the emergency SOC value."""
|
||||
return int(self.data[IndevoltConfig.READ_DISCHARGE_LIMIT])
|
||||
|
||||
@@ -4,6 +4,8 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from indevolt_api import IndevoltBattery, IndevoltSystem
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -15,13 +17,13 @@ from .coordinator import IndevoltConfigEntry
|
||||
TO_REDACT = {
|
||||
CONF_HOST,
|
||||
CONF_SERIAL_NUMBER,
|
||||
"0",
|
||||
"9008",
|
||||
"9032",
|
||||
"9051",
|
||||
"9070",
|
||||
"9218",
|
||||
"9165",
|
||||
IndevoltSystem.SERIAL_NUMBER,
|
||||
IndevoltBattery.MAIN_SERIAL_NUMBER,
|
||||
IndevoltBattery.PACK_1_SERIAL_NUMBER,
|
||||
IndevoltBattery.PACK_2_SERIAL_NUMBER,
|
||||
IndevoltBattery.PACK_3_SERIAL_NUMBER,
|
||||
IndevoltBattery.PACK_4_SERIAL_NUMBER,
|
||||
IndevoltBattery.PACK_5_SERIAL_NUMBER,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"services": {
|
||||
"charge": {
|
||||
"service": "mdi:battery-arrow-up"
|
||||
},
|
||||
"discharge": {
|
||||
"service": "mdi:battery-arrow-down"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["indevolt-api==1.4.3"]
|
||||
"requirements": ["indevolt-api==1.6.4"]
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Final
|
||||
|
||||
from indevolt_api import IndevoltConfig
|
||||
|
||||
from homeassistant.components.number import (
|
||||
NumberDeviceClass,
|
||||
NumberEntity,
|
||||
@@ -37,8 +39,8 @@ NUMBERS: Final = (
|
||||
key="discharge_limit",
|
||||
generation=[2],
|
||||
translation_key="discharge_limit",
|
||||
read_key="6105",
|
||||
write_key="1142",
|
||||
read_key=IndevoltConfig.READ_DISCHARGE_LIMIT,
|
||||
write_key=IndevoltConfig.WRITE_DISCHARGE_LIMIT,
|
||||
native_min_value=0,
|
||||
native_max_value=100,
|
||||
native_step=1,
|
||||
@@ -48,8 +50,8 @@ NUMBERS: Final = (
|
||||
key="max_ac_output_power",
|
||||
generation=[2],
|
||||
translation_key="max_ac_output_power",
|
||||
read_key="11011",
|
||||
write_key="1147",
|
||||
read_key=IndevoltConfig.READ_MAX_AC_OUTPUT_POWER,
|
||||
write_key=IndevoltConfig.WRITE_MAX_AC_OUTPUT_POWER,
|
||||
native_min_value=0,
|
||||
native_max_value=2400,
|
||||
native_step=100,
|
||||
@@ -60,8 +62,8 @@ NUMBERS: Final = (
|
||||
key="inverter_input_limit",
|
||||
generation=[2],
|
||||
translation_key="inverter_input_limit",
|
||||
read_key="11009",
|
||||
write_key="1138",
|
||||
read_key=IndevoltConfig.READ_INVERTER_INPUT_LIMIT,
|
||||
write_key=IndevoltConfig.WRITE_INVERTER_INPUT_LIMIT,
|
||||
native_min_value=100,
|
||||
native_max_value=2400,
|
||||
native_step=100,
|
||||
@@ -72,8 +74,8 @@ NUMBERS: Final = (
|
||||
key="feedin_power_limit",
|
||||
generation=[2],
|
||||
translation_key="feedin_power_limit",
|
||||
read_key="11010",
|
||||
write_key="1146",
|
||||
read_key=IndevoltConfig.READ_FEEDIN_POWER_LIMIT,
|
||||
write_key=IndevoltConfig.WRITE_FEEDIN_POWER_LIMIT,
|
||||
native_min_value=0,
|
||||
native_max_value=2400,
|
||||
native_step=100,
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
rules:
|
||||
# Bronze (mandatory for core integrations)
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: Integration does not register custom actions
|
||||
action-setup: done
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: Integration does not register custom actions
|
||||
docs-actions: done
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
@@ -26,9 +22,7 @@ rules:
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions:
|
||||
status: exempt
|
||||
comment: Integration does not register custom actions
|
||||
action-exceptions: done
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters:
|
||||
status: exempt
|
||||
|
||||
@@ -5,6 +5,8 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Final
|
||||
|
||||
from indevolt_api import IndevoltConfig, IndevoltEnergyMode
|
||||
|
||||
from homeassistant.components.select import SelectEntity, SelectEntityDescription
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
@@ -23,8 +25,8 @@ class IndevoltSelectEntityDescription(SelectEntityDescription):
|
||||
|
||||
read_key: str
|
||||
write_key: str
|
||||
value_to_option: dict[int, str]
|
||||
unavailable_values: list[int] = field(default_factory=list)
|
||||
value_to_option: dict[IndevoltEnergyMode, str]
|
||||
unavailable_values: list[IndevoltEnergyMode] = field(default_factory=list)
|
||||
generation: list[int] = field(default_factory=lambda: [1, 2])
|
||||
|
||||
|
||||
@@ -32,14 +34,14 @@ SELECTS: Final = (
|
||||
IndevoltSelectEntityDescription(
|
||||
key="energy_mode",
|
||||
translation_key="energy_mode",
|
||||
read_key="7101",
|
||||
write_key="47005",
|
||||
read_key=IndevoltConfig.READ_ENERGY_MODE,
|
||||
write_key=IndevoltConfig.WRITE_ENERGY_MODE,
|
||||
value_to_option={
|
||||
1: "self_consumed_prioritized",
|
||||
4: "real_time_control",
|
||||
5: "charge_discharge_schedule",
|
||||
IndevoltEnergyMode.SELF_CONSUMED_PRIORITIZED: "self_consumed_prioritized",
|
||||
IndevoltEnergyMode.REAL_TIME_CONTROL: "real_time_control",
|
||||
IndevoltEnergyMode.CHARGE_DISCHARGE_SCHEDULE: "charge_discharge_schedule",
|
||||
},
|
||||
unavailable_values=[0],
|
||||
unavailable_values=[IndevoltEnergyMode.OUTDOOR_PORTABLE],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user