mirror of
https://github.com/home-assistant/core.git
synced 2026-05-19 23:35:20 +02:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 673971d395 | |||
| 31e861b6de |
+1
-2
@@ -14,8 +14,7 @@ tests
|
||||
|
||||
# Other virtualization methods
|
||||
venv
|
||||
.venv
|
||||
.vagrant
|
||||
|
||||
# Temporary files
|
||||
**/__pycache__
|
||||
**/__pycache__
|
||||
@@ -1073,11 +1073,7 @@ async def test_flow_connection_error(hass, mock_api_error):
|
||||
|
||||
### Entity Testing Patterns
|
||||
```python
|
||||
@pytest.fixture
|
||||
def platforms() -> list[Platform]:
|
||||
"""Overridden fixture to specify platforms to test."""
|
||||
return [Platform.SENSOR] # Or another specific platform as needed.
|
||||
|
||||
@pytest.mark.parametrize("init_integration", [Platform.SENSOR], indirect=True)
|
||||
@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration")
|
||||
async def test_entities(
|
||||
hass: HomeAssistant,
|
||||
@@ -1124,25 +1120,16 @@ def mock_device_api() -> Generator[MagicMock]:
|
||||
)
|
||||
yield api
|
||||
|
||||
@pytest.fixture
|
||||
def platforms() -> list[Platform]:
|
||||
"""Fixture to specify platforms to test."""
|
||||
return PLATFORMS
|
||||
|
||||
@pytest.fixture
|
||||
async def init_integration(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_device_api: MagicMock,
|
||||
platforms: list[Platform],
|
||||
) -> MockConfigEntry:
|
||||
"""Set up the integration for testing."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
with patch("homeassistant.components.my_integration.PLATFORMS", platforms):
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
return mock_config_entry
|
||||
```
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ on:
|
||||
type: boolean
|
||||
|
||||
env:
|
||||
CACHE_VERSION: 7
|
||||
CACHE_VERSION: 5
|
||||
UV_CACHE_VERSION: 1
|
||||
MYPY_CACHE_VERSION: 1
|
||||
HA_SHORT_VERSION: "2025.9"
|
||||
@@ -653,7 +653,7 @@ jobs:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v5.0.0
|
||||
- name: Dependency review
|
||||
uses: actions/dependency-review-action@v4.7.3
|
||||
uses: actions/dependency-review-action@v4.7.2
|
||||
with:
|
||||
license-check: false # We use our own license audit checks
|
||||
|
||||
|
||||
@@ -24,11 +24,11 @@ jobs:
|
||||
uses: actions/checkout@v5.0.0
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3.29.11
|
||||
uses: github/codeql-action/init@v3.29.10
|
||||
with:
|
||||
languages: python
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3.29.11
|
||||
uses: github/codeql-action/analyze@v3.29.10
|
||||
with:
|
||||
category: "/language:python"
|
||||
|
||||
@@ -231,7 +231,7 @@ jobs:
|
||||
- name: Detect duplicates using AI
|
||||
id: ai_detection
|
||||
if: steps.extract.outputs.should_continue == 'true' && steps.fetch_similar.outputs.has_similar == 'true'
|
||||
uses: actions/ai-inference@v2.0.1
|
||||
uses: actions/ai-inference@v2.0.0
|
||||
with:
|
||||
model: openai/gpt-4o
|
||||
system-prompt: |
|
||||
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
- name: Detect language using AI
|
||||
id: ai_language_detection
|
||||
if: steps.detect_language.outputs.should_continue == 'true'
|
||||
uses: actions/ai-inference@v2.0.1
|
||||
uses: actions/ai-inference@v2.0.0
|
||||
with:
|
||||
model: openai/gpt-4o-mini
|
||||
system-prompt: |
|
||||
|
||||
Generated
-4
@@ -87,8 +87,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/airzone/ @Noltari
|
||||
/homeassistant/components/airzone_cloud/ @Noltari
|
||||
/tests/components/airzone_cloud/ @Noltari
|
||||
/homeassistant/components/aladdin_connect/ @swcloudgenie
|
||||
/tests/components/aladdin_connect/ @swcloudgenie
|
||||
/homeassistant/components/alarm_control_panel/ @home-assistant/core
|
||||
/tests/components/alarm_control_panel/ @home-assistant/core
|
||||
/homeassistant/components/alert/ @home-assistant/core @frenck
|
||||
@@ -1185,8 +1183,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/plum_lightpad/ @ColinHarrington @prystupa
|
||||
/homeassistant/components/point/ @fredrike
|
||||
/tests/components/point/ @fredrike
|
||||
/homeassistant/components/pooldose/ @lmaertin
|
||||
/tests/components/pooldose/ @lmaertin
|
||||
/homeassistant/components/poolsense/ @haemishkyd
|
||||
/tests/components/poolsense/ @haemishkyd
|
||||
/homeassistant/components/powerfox/ @klaasnicolaas
|
||||
|
||||
@@ -3,10 +3,8 @@
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import web
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.http import KEY_HASS, HomeAssistantView
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import ATTR_ENTITY_ID, CONF_DESCRIPTION, CONF_SELECTOR
|
||||
from homeassistant.core import (
|
||||
@@ -28,24 +26,14 @@ from .const import (
|
||||
ATTR_STRUCTURE,
|
||||
ATTR_TASK_NAME,
|
||||
DATA_COMPONENT,
|
||||
DATA_IMAGES,
|
||||
DATA_PREFERENCES,
|
||||
DOMAIN,
|
||||
SERVICE_GENERATE_DATA,
|
||||
SERVICE_GENERATE_IMAGE,
|
||||
AITaskEntityFeature,
|
||||
)
|
||||
from .entity import AITaskEntity
|
||||
from .http import async_setup as async_setup_http
|
||||
from .task import (
|
||||
GenDataTask,
|
||||
GenDataTaskResult,
|
||||
GenImageTask,
|
||||
GenImageTaskResult,
|
||||
ImageData,
|
||||
async_generate_data,
|
||||
async_generate_image,
|
||||
)
|
||||
from .task import GenDataTask, GenDataTaskResult, async_generate_data
|
||||
|
||||
__all__ = [
|
||||
"DOMAIN",
|
||||
@@ -53,11 +41,7 @@ __all__ = [
|
||||
"AITaskEntityFeature",
|
||||
"GenDataTask",
|
||||
"GenDataTaskResult",
|
||||
"GenImageTask",
|
||||
"GenImageTaskResult",
|
||||
"ImageData",
|
||||
"async_generate_data",
|
||||
"async_generate_image",
|
||||
"async_setup",
|
||||
"async_setup_entry",
|
||||
"async_unload_entry",
|
||||
@@ -94,10 +78,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
entity_component = EntityComponent[AITaskEntity](_LOGGER, DOMAIN, hass)
|
||||
hass.data[DATA_COMPONENT] = entity_component
|
||||
hass.data[DATA_PREFERENCES] = AITaskPreferences(hass)
|
||||
hass.data[DATA_IMAGES] = {}
|
||||
await hass.data[DATA_PREFERENCES].async_load()
|
||||
async_setup_http(hass)
|
||||
hass.http.register_view(ImageView)
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_GENERATE_DATA,
|
||||
@@ -119,23 +101,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
job_type=HassJobType.Coroutinefunction,
|
||||
)
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_GENERATE_IMAGE,
|
||||
async_service_generate_image,
|
||||
schema=vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_TASK_NAME): cv.string,
|
||||
vol.Required(ATTR_ENTITY_ID): cv.entity_id,
|
||||
vol.Required(ATTR_INSTRUCTIONS): cv.string,
|
||||
vol.Optional(ATTR_ATTACHMENTS): vol.All(
|
||||
cv.ensure_list, [selector.MediaSelector({"accept": ["*/*"]})]
|
||||
),
|
||||
}
|
||||
),
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
job_type=HassJobType.Coroutinefunction,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
@@ -150,16 +115,11 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
|
||||
|
||||
async def async_service_generate_data(call: ServiceCall) -> ServiceResponse:
|
||||
"""Run the data task service."""
|
||||
"""Run the run task service."""
|
||||
result = await async_generate_data(hass=call.hass, **call.data)
|
||||
return result.as_dict()
|
||||
|
||||
|
||||
async def async_service_generate_image(call: ServiceCall) -> ServiceResponse:
|
||||
"""Run the image task service."""
|
||||
return await async_generate_image(hass=call.hass, **call.data)
|
||||
|
||||
|
||||
class AITaskPreferences:
|
||||
"""AI Task preferences."""
|
||||
|
||||
@@ -204,29 +164,3 @@ class AITaskPreferences:
|
||||
def as_dict(self) -> dict[str, str | None]:
|
||||
"""Get the current preferences."""
|
||||
return {key: getattr(self, key) for key in self.KEYS}
|
||||
|
||||
|
||||
class ImageView(HomeAssistantView):
|
||||
"""View to generated images."""
|
||||
|
||||
url = f"/api/{DOMAIN}/images/{{filename}}"
|
||||
name = f"api:{DOMAIN}/images"
|
||||
requires_auth = False
|
||||
|
||||
async def get(
|
||||
self,
|
||||
request: web.Request,
|
||||
filename: str,
|
||||
) -> web.Response:
|
||||
"""Serve image."""
|
||||
hass = request.app[KEY_HASS]
|
||||
image_storage = hass.data[DATA_IMAGES]
|
||||
image_data = image_storage.get(filename)
|
||||
|
||||
if image_data is None:
|
||||
raise web.HTTPNotFound
|
||||
|
||||
return web.Response(
|
||||
body=image_data.data,
|
||||
content_type=image_data.mime_type,
|
||||
)
|
||||
|
||||
@@ -12,18 +12,12 @@ if TYPE_CHECKING:
|
||||
|
||||
from . import AITaskPreferences
|
||||
from .entity import AITaskEntity
|
||||
from .task import ImageData
|
||||
|
||||
DOMAIN = "ai_task"
|
||||
DATA_COMPONENT: HassKey[EntityComponent[AITaskEntity]] = HassKey(DOMAIN)
|
||||
DATA_PREFERENCES: HassKey[AITaskPreferences] = HassKey(f"{DOMAIN}_preferences")
|
||||
DATA_IMAGES: HassKey[dict[str, ImageData]] = HassKey(f"{DOMAIN}_images")
|
||||
|
||||
IMAGE_EXPIRY_TIME = 60 * 60 # 1 hour
|
||||
MAX_IMAGES = 20
|
||||
|
||||
SERVICE_GENERATE_DATA = "generate_data"
|
||||
SERVICE_GENERATE_IMAGE = "generate_image"
|
||||
|
||||
ATTR_INSTRUCTIONS: Final = "instructions"
|
||||
ATTR_TASK_NAME: Final = "task_name"
|
||||
@@ -44,6 +38,3 @@ class AITaskEntityFeature(IntFlag):
|
||||
|
||||
SUPPORT_ATTACHMENTS = 2
|
||||
"""Support attachments with generate data."""
|
||||
|
||||
GENERATE_IMAGE = 4
|
||||
"""Generate images based on instructions."""
|
||||
|
||||
@@ -18,7 +18,7 @@ from homeassistant.helpers.restore_state import RestoreEntity
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import DEFAULT_SYSTEM_PROMPT, DOMAIN, AITaskEntityFeature
|
||||
from .task import GenDataTask, GenDataTaskResult, GenImageTask, GenImageTaskResult
|
||||
from .task import GenDataTask, GenDataTaskResult
|
||||
|
||||
|
||||
class AITaskEntity(RestoreEntity):
|
||||
@@ -57,7 +57,7 @@ class AITaskEntity(RestoreEntity):
|
||||
async def _async_get_ai_task_chat_log(
|
||||
self,
|
||||
session: ChatSession,
|
||||
task: GenDataTask | GenImageTask,
|
||||
task: GenDataTask,
|
||||
) -> AsyncGenerator[ChatLog]:
|
||||
"""Context manager used to manage the ChatLog used during an AI Task."""
|
||||
# pylint: disable-next=contextmanager-generator-missing-cleanup
|
||||
@@ -104,23 +104,3 @@ class AITaskEntity(RestoreEntity):
|
||||
) -> GenDataTaskResult:
|
||||
"""Handle a gen data task."""
|
||||
raise NotImplementedError
|
||||
|
||||
@final
|
||||
async def internal_async_generate_image(
|
||||
self,
|
||||
session: ChatSession,
|
||||
task: GenImageTask,
|
||||
) -> GenImageTaskResult:
|
||||
"""Run a gen image task."""
|
||||
self.__last_activity = dt_util.utcnow().isoformat()
|
||||
self.async_write_ha_state()
|
||||
async with self._async_get_ai_task_chat_log(session, task) as chat_log:
|
||||
return await self._async_generate_image(task, chat_log)
|
||||
|
||||
async def _async_generate_image(
|
||||
self,
|
||||
task: GenImageTask,
|
||||
chat_log: ChatLog,
|
||||
) -> GenImageTaskResult:
|
||||
"""Handle a gen image task."""
|
||||
raise NotImplementedError
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
{
|
||||
"entity_component": {
|
||||
"_": {
|
||||
"default": "mdi:star-four-points"
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"generate_data": {
|
||||
"service": "mdi:file-star-four-points-outline"
|
||||
},
|
||||
"generate_image": {
|
||||
"service": "mdi:star-four-points-box-outline"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"domain": "ai_task",
|
||||
"name": "AI Task",
|
||||
"after_dependencies": ["camera", "http"],
|
||||
"after_dependencies": ["camera"],
|
||||
"codeowners": ["@home-assistant/core"],
|
||||
"dependencies": ["conversation", "media_source"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/ai_task",
|
||||
"integration_type": "entity",
|
||||
"integration_type": "system",
|
||||
"quality_scale": "internal"
|
||||
}
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
"""Expose images as media sources."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from homeassistant.components.media_player import BrowseError, MediaClass
|
||||
from homeassistant.components.media_source import (
|
||||
BrowseMediaSource,
|
||||
MediaSource,
|
||||
MediaSourceItem,
|
||||
PlayMedia,
|
||||
Unresolvable,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import DATA_IMAGES, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_get_media_source(hass: HomeAssistant) -> ImageMediaSource:
|
||||
"""Set up image media source."""
|
||||
_LOGGER.debug("Setting up image media source")
|
||||
return ImageMediaSource(hass)
|
||||
|
||||
|
||||
class ImageMediaSource(MediaSource):
|
||||
"""Provide images as media sources."""
|
||||
|
||||
name: str = "AI Generated Images"
|
||||
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Initialize ImageMediaSource."""
|
||||
super().__init__(DOMAIN)
|
||||
self.hass = hass
|
||||
|
||||
async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia:
|
||||
"""Resolve media to a url."""
|
||||
image_storage = self.hass.data[DATA_IMAGES]
|
||||
image = image_storage.get(item.identifier)
|
||||
|
||||
if image is None:
|
||||
raise Unresolvable(f"Could not resolve media item: {item.identifier}")
|
||||
|
||||
return PlayMedia(f"/api/{DOMAIN}/images/{item.identifier}", image.mime_type)
|
||||
|
||||
async def async_browse_media(
|
||||
self,
|
||||
item: MediaSourceItem,
|
||||
) -> BrowseMediaSource:
|
||||
"""Return media."""
|
||||
if item.identifier:
|
||||
raise BrowseError("Unknown item")
|
||||
|
||||
image_storage = self.hass.data[DATA_IMAGES]
|
||||
|
||||
children = [
|
||||
BrowseMediaSource(
|
||||
domain=DOMAIN,
|
||||
identifier=filename,
|
||||
media_class=MediaClass.IMAGE,
|
||||
media_content_type=image.mime_type,
|
||||
title=image.title or filename,
|
||||
can_play=True,
|
||||
can_expand=False,
|
||||
)
|
||||
for filename, image in image_storage.items()
|
||||
]
|
||||
|
||||
return BrowseMediaSource(
|
||||
domain=DOMAIN,
|
||||
identifier=None,
|
||||
media_class=MediaClass.APP,
|
||||
media_content_type="",
|
||||
title="AI Generated Images",
|
||||
can_play=False,
|
||||
can_expand=True,
|
||||
children_media_class=MediaClass.IMAGE,
|
||||
children=children,
|
||||
)
|
||||
@@ -31,30 +31,3 @@ generate_data:
|
||||
media:
|
||||
accept:
|
||||
- "*"
|
||||
generate_image:
|
||||
fields:
|
||||
task_name:
|
||||
example: "picture of a dog"
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
instructions:
|
||||
example: "Generate a high quality square image of a dog on transparent background"
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
multiline: true
|
||||
entity_id:
|
||||
required: true
|
||||
selector:
|
||||
entity:
|
||||
filter:
|
||||
domain: ai_task
|
||||
supported_features:
|
||||
- ai_task.AITaskEntityFeature.GENERATE_IMAGE
|
||||
attachments:
|
||||
required: false
|
||||
selector:
|
||||
media:
|
||||
accept:
|
||||
- "*"
|
||||
|
||||
@@ -25,28 +25,6 @@
|
||||
"description": "List of files to attach for multi-modal AI analysis."
|
||||
}
|
||||
}
|
||||
},
|
||||
"generate_image": {
|
||||
"name": "Generate image",
|
||||
"description": "Uses AI to generate image.",
|
||||
"fields": {
|
||||
"task_name": {
|
||||
"name": "Task name",
|
||||
"description": "Name of the task."
|
||||
},
|
||||
"instructions": {
|
||||
"name": "Instructions",
|
||||
"description": "Instructions that explains the image to be generated."
|
||||
},
|
||||
"entity_id": {
|
||||
"name": "Entity ID",
|
||||
"description": "Entity ID to run the task on."
|
||||
},
|
||||
"attachments": {
|
||||
"name": "Attachments",
|
||||
"description": "List of files to attach for using as references."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from functools import partial
|
||||
import mimetypes
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
@@ -13,22 +11,11 @@ from typing import Any
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import camera, conversation, media_source
|
||||
from homeassistant.core import HomeAssistant, ServiceResponse, callback
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.chat_session import ChatSession, async_get_chat_session
|
||||
from homeassistant.helpers.event import async_call_later
|
||||
from homeassistant.helpers.network import get_url
|
||||
from homeassistant.util import RE_SANITIZE_FILENAME, slugify
|
||||
from homeassistant.helpers.chat_session import async_get_chat_session
|
||||
|
||||
from .const import (
|
||||
DATA_COMPONENT,
|
||||
DATA_IMAGES,
|
||||
DATA_PREFERENCES,
|
||||
DOMAIN,
|
||||
IMAGE_EXPIRY_TIME,
|
||||
MAX_IMAGES,
|
||||
AITaskEntityFeature,
|
||||
)
|
||||
from .const import DATA_COMPONENT, DATA_PREFERENCES, AITaskEntityFeature
|
||||
|
||||
|
||||
def _save_camera_snapshot(image: camera.Image) -> Path:
|
||||
@@ -42,15 +29,43 @@ def _save_camera_snapshot(image: camera.Image) -> Path:
|
||||
return Path(temp_file.name)
|
||||
|
||||
|
||||
async def _resolve_attachments(
|
||||
async def async_generate_data(
|
||||
hass: HomeAssistant,
|
||||
session: ChatSession,
|
||||
*,
|
||||
task_name: str,
|
||||
entity_id: str | None = None,
|
||||
instructions: str,
|
||||
structure: vol.Schema | None = None,
|
||||
attachments: list[dict] | None = None,
|
||||
) -> list[conversation.Attachment]:
|
||||
"""Resolve attachments for a task."""
|
||||
) -> GenDataTaskResult:
|
||||
"""Run a task in the AI Task integration."""
|
||||
if entity_id is None:
|
||||
entity_id = hass.data[DATA_PREFERENCES].gen_data_entity_id
|
||||
|
||||
if entity_id is None:
|
||||
raise HomeAssistantError("No entity_id provided and no preferred entity set")
|
||||
|
||||
entity = hass.data[DATA_COMPONENT].get_entity(entity_id)
|
||||
if entity is None:
|
||||
raise HomeAssistantError(f"AI Task entity {entity_id} not found")
|
||||
|
||||
if AITaskEntityFeature.GENERATE_DATA not in entity.supported_features:
|
||||
raise HomeAssistantError(
|
||||
f"AI Task entity {entity_id} does not support generating data"
|
||||
)
|
||||
|
||||
# Resolve attachments
|
||||
resolved_attachments: list[conversation.Attachment] = []
|
||||
created_files: list[Path] = []
|
||||
|
||||
if (
|
||||
attachments
|
||||
and AITaskEntityFeature.SUPPORT_ATTACHMENTS not in entity.supported_features
|
||||
):
|
||||
raise HomeAssistantError(
|
||||
f"AI Task entity {entity_id} does not support attachments"
|
||||
)
|
||||
|
||||
for attachment in attachments or []:
|
||||
media_content_id = attachment["media_content_id"]
|
||||
|
||||
@@ -89,59 +104,20 @@ async def _resolve_attachments(
|
||||
)
|
||||
)
|
||||
|
||||
if not created_files:
|
||||
return resolved_attachments
|
||||
|
||||
def cleanup_files() -> None:
|
||||
"""Cleanup temporary files."""
|
||||
for file in created_files:
|
||||
file.unlink(missing_ok=True)
|
||||
|
||||
@callback
|
||||
def cleanup_files_callback() -> None:
|
||||
"""Cleanup temporary files."""
|
||||
hass.async_add_executor_job(cleanup_files)
|
||||
|
||||
session.async_on_cleanup(cleanup_files_callback)
|
||||
|
||||
return resolved_attachments
|
||||
|
||||
|
||||
async def async_generate_data(
|
||||
hass: HomeAssistant,
|
||||
*,
|
||||
task_name: str,
|
||||
entity_id: str | None = None,
|
||||
instructions: str,
|
||||
structure: vol.Schema | None = None,
|
||||
attachments: list[dict] | None = None,
|
||||
) -> GenDataTaskResult:
|
||||
"""Run a data generation task in the AI Task integration."""
|
||||
if entity_id is None:
|
||||
entity_id = hass.data[DATA_PREFERENCES].gen_data_entity_id
|
||||
|
||||
if entity_id is None:
|
||||
raise HomeAssistantError("No entity_id provided and no preferred entity set")
|
||||
|
||||
entity = hass.data[DATA_COMPONENT].get_entity(entity_id)
|
||||
if entity is None:
|
||||
raise HomeAssistantError(f"AI Task entity {entity_id} not found")
|
||||
|
||||
if AITaskEntityFeature.GENERATE_DATA not in entity.supported_features:
|
||||
raise HomeAssistantError(
|
||||
f"AI Task entity {entity_id} does not support generating data"
|
||||
)
|
||||
|
||||
if (
|
||||
attachments
|
||||
and AITaskEntityFeature.SUPPORT_ATTACHMENTS not in entity.supported_features
|
||||
):
|
||||
raise HomeAssistantError(
|
||||
f"AI Task entity {entity_id} does not support attachments"
|
||||
)
|
||||
|
||||
with async_get_chat_session(hass) as session:
|
||||
resolved_attachments = await _resolve_attachments(hass, session, attachments)
|
||||
if created_files:
|
||||
|
||||
def cleanup_files() -> None:
|
||||
"""Cleanup temporary files."""
|
||||
for file in created_files:
|
||||
file.unlink(missing_ok=True)
|
||||
|
||||
@callback
|
||||
def cleanup_files_callback() -> None:
|
||||
"""Cleanup temporary files."""
|
||||
hass.async_add_executor_job(cleanup_files)
|
||||
|
||||
session.async_on_cleanup(cleanup_files_callback)
|
||||
|
||||
return await entity.internal_async_generate_data(
|
||||
session,
|
||||
@@ -154,97 +130,6 @@ async def async_generate_data(
|
||||
)
|
||||
|
||||
|
||||
def _cleanup_images(image_storage: dict[str, ImageData], num_to_remove: int) -> None:
|
||||
"""Remove old images to keep the storage size under the limit."""
|
||||
if num_to_remove <= 0:
|
||||
return
|
||||
|
||||
if num_to_remove >= len(image_storage):
|
||||
image_storage.clear()
|
||||
return
|
||||
|
||||
sorted_images = sorted(
|
||||
image_storage.items(),
|
||||
key=lambda item: item[1].timestamp,
|
||||
)
|
||||
|
||||
for filename, _ in sorted_images[:num_to_remove]:
|
||||
image_storage.pop(filename, None)
|
||||
|
||||
|
||||
async def async_generate_image(
|
||||
hass: HomeAssistant,
|
||||
*,
|
||||
task_name: str,
|
||||
entity_id: str,
|
||||
instructions: str,
|
||||
attachments: list[dict] | None = None,
|
||||
) -> ServiceResponse:
|
||||
"""Run an image generation task in the AI Task integration."""
|
||||
entity = hass.data[DATA_COMPONENT].get_entity(entity_id)
|
||||
if entity is None:
|
||||
raise HomeAssistantError(f"AI Task entity {entity_id} not found")
|
||||
|
||||
if AITaskEntityFeature.GENERATE_IMAGE not in entity.supported_features:
|
||||
raise HomeAssistantError(
|
||||
f"AI Task entity {entity_id} does not support generating images"
|
||||
)
|
||||
|
||||
if (
|
||||
attachments
|
||||
and AITaskEntityFeature.SUPPORT_ATTACHMENTS not in entity.supported_features
|
||||
):
|
||||
raise HomeAssistantError(
|
||||
f"AI Task entity {entity_id} does not support attachments"
|
||||
)
|
||||
|
||||
with async_get_chat_session(hass) as session:
|
||||
resolved_attachments = await _resolve_attachments(hass, session, attachments)
|
||||
|
||||
task_result = await entity.internal_async_generate_image(
|
||||
session,
|
||||
GenImageTask(
|
||||
name=task_name,
|
||||
instructions=instructions,
|
||||
attachments=resolved_attachments or None,
|
||||
),
|
||||
)
|
||||
|
||||
service_result = task_result.as_dict()
|
||||
image_data = service_result.pop("image_data")
|
||||
if service_result.get("revised_prompt") is None:
|
||||
service_result["revised_prompt"] = instructions
|
||||
|
||||
image_storage = hass.data[DATA_IMAGES]
|
||||
|
||||
if len(image_storage) + 1 > MAX_IMAGES:
|
||||
_cleanup_images(image_storage, len(image_storage) + 1 - MAX_IMAGES)
|
||||
|
||||
current_time = datetime.now()
|
||||
ext = mimetypes.guess_extension(task_result.mime_type, False) or ".png"
|
||||
sanitized_task_name = RE_SANITIZE_FILENAME.sub("", slugify(task_name))
|
||||
filename = f"{current_time.strftime('%Y-%m-%d_%H%M%S')}_{sanitized_task_name}{ext}"
|
||||
|
||||
image_storage[filename] = ImageData(
|
||||
data=image_data,
|
||||
timestamp=int(current_time.timestamp()),
|
||||
mime_type=task_result.mime_type,
|
||||
title=service_result["revised_prompt"],
|
||||
)
|
||||
|
||||
def _purge_image(filename: str, now: datetime) -> None:
|
||||
"""Remove image from storage."""
|
||||
image_storage.pop(filename, None)
|
||||
|
||||
if IMAGE_EXPIRY_TIME > 0:
|
||||
async_call_later(hass, IMAGE_EXPIRY_TIME, partial(_purge_image, filename))
|
||||
|
||||
service_result["url"] = get_url(hass) + f"/api/{DOMAIN}/images/{filename}"
|
||||
service_result["media_source_id"] = f"media-source://{DOMAIN}/images/{filename}"
|
||||
|
||||
return service_result
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class GenDataTask:
|
||||
"""Gen data task to be processed."""
|
||||
@@ -282,80 +167,3 @@ class GenDataTaskResult:
|
||||
"conversation_id": self.conversation_id,
|
||||
"data": self.data,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class GenImageTask:
|
||||
"""Gen image task to be processed."""
|
||||
|
||||
name: str
|
||||
"""Name of the task."""
|
||||
|
||||
instructions: str
|
||||
"""Instructions on what needs to be done."""
|
||||
|
||||
attachments: list[conversation.Attachment] | None = None
|
||||
"""List of attachments to go along the instructions."""
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Return task as a string."""
|
||||
return f"<GenImageTask {self.name}: {id(self)}>"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class GenImageTaskResult:
|
||||
"""Result of gen image task."""
|
||||
|
||||
image_data: bytes
|
||||
"""Raw image data generated by the model."""
|
||||
|
||||
conversation_id: str
|
||||
"""Unique identifier for the conversation."""
|
||||
|
||||
mime_type: str
|
||||
"""MIME type of the generated image."""
|
||||
|
||||
width: int | None = None
|
||||
"""Width of the generated image, if available."""
|
||||
|
||||
height: int | None = None
|
||||
"""Height of the generated image, if available."""
|
||||
|
||||
model: str | None = None
|
||||
"""Model used to generate the image, if available."""
|
||||
|
||||
revised_prompt: str | None = None
|
||||
"""Revised prompt used to generate the image, if applicable."""
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
"""Return result as a dict."""
|
||||
return {
|
||||
"image_data": self.image_data,
|
||||
"conversation_id": self.conversation_id,
|
||||
"mime_type": self.mime_type,
|
||||
"width": self.width,
|
||||
"height": self.height,
|
||||
"model": self.model,
|
||||
"revised_prompt": self.revised_prompt,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ImageData:
|
||||
"""Image data for stored generated images."""
|
||||
|
||||
data: bytes
|
||||
"""Raw image data."""
|
||||
|
||||
timestamp: int
|
||||
"""Timestamp when the image was generated, as a Unix timestamp."""
|
||||
|
||||
mime_type: str
|
||||
"""MIME type of the image."""
|
||||
|
||||
title: str
|
||||
"""Title of the image, usually the prompt used to generate it."""
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Return image data as a string."""
|
||||
return f"<ImageData {self.title}: {id(self)}>"
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/airos",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["airos==0.4.4"]
|
||||
"requirements": ["airos==0.3.0"]
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/airzone_cloud",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["aioairzone_cloud"],
|
||||
"requirements": ["aioairzone-cloud==0.7.2"]
|
||||
"requirements": ["aioairzone-cloud==0.7.1"]
|
||||
}
|
||||
|
||||
@@ -2,112 +2,39 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from genie_partner_sdk.client import AladdinConnectClient
|
||||
from genie_partner_sdk.model import GarageDoor
|
||||
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import (
|
||||
aiohttp_client,
|
||||
config_entry_oauth2_flow,
|
||||
device_registry as dr,
|
||||
)
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
|
||||
from . import api
|
||||
from .const import CONFIG_FLOW_MINOR_VERSION, CONFIG_FLOW_VERSION, DOMAIN
|
||||
from .coordinator import AladdinConnectConfigEntry, AladdinConnectCoordinator
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.COVER, Platform.SENSOR]
|
||||
DOMAIN = "aladdin_connect"
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: AladdinConnectConfigEntry
|
||||
) -> bool:
|
||||
"""Set up Aladdin Connect Genie from a config entry."""
|
||||
implementation = (
|
||||
await config_entry_oauth2_flow.async_get_config_entry_implementation(
|
||||
hass, entry
|
||||
)
|
||||
async def async_setup_entry(hass: HomeAssistant, _: ConfigEntry) -> bool:
|
||||
"""Set up Aladdin Connect from a config entry."""
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
DOMAIN,
|
||||
is_fixable=False,
|
||||
severity=ir.IssueSeverity.ERROR,
|
||||
translation_key="integration_removed",
|
||||
translation_placeholders={
|
||||
"entries": "/config/integrations/integration/aladdin_connect",
|
||||
},
|
||||
)
|
||||
|
||||
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
|
||||
|
||||
client = AladdinConnectClient(
|
||||
api.AsyncConfigEntryAuth(aiohttp_client.async_get_clientsession(hass), session)
|
||||
)
|
||||
|
||||
sdk_doors = await client.get_doors()
|
||||
|
||||
# Convert SDK GarageDoor objects to integration GarageDoor objects
|
||||
doors = [
|
||||
GarageDoor(
|
||||
{
|
||||
"device_id": door.device_id,
|
||||
"door_number": door.door_number,
|
||||
"name": door.name,
|
||||
"status": door.status,
|
||||
"link_status": door.link_status,
|
||||
"battery_level": door.battery_level,
|
||||
}
|
||||
)
|
||||
for door in sdk_doors
|
||||
]
|
||||
|
||||
entry.runtime_data = {
|
||||
door.unique_id: AladdinConnectCoordinator(hass, entry, client, door)
|
||||
for door in doors
|
||||
}
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
remove_stale_devices(hass, entry)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(
|
||||
hass: HomeAssistant, entry: AladdinConnectConfigEntry
|
||||
) -> bool:
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
|
||||
async def async_migrate_entry(
|
||||
hass: HomeAssistant, config_entry: AladdinConnectConfigEntry
|
||||
) -> bool:
|
||||
"""Migrate old config."""
|
||||
if config_entry.version < CONFIG_FLOW_VERSION:
|
||||
config_entry.async_start_reauth(hass)
|
||||
new_data = {**config_entry.data}
|
||||
hass.config_entries.async_update_entry(
|
||||
config_entry,
|
||||
data=new_data,
|
||||
version=CONFIG_FLOW_VERSION,
|
||||
minor_version=CONFIG_FLOW_MINOR_VERSION,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def remove_stale_devices(
|
||||
hass: HomeAssistant,
|
||||
config_entry: AladdinConnectConfigEntry,
|
||||
) -> None:
|
||||
"""Remove stale devices from device registry."""
|
||||
device_registry = dr.async_get(hass)
|
||||
device_entries = dr.async_entries_for_config_entry(
|
||||
device_registry, config_entry.entry_id
|
||||
)
|
||||
all_device_ids = set(config_entry.runtime_data)
|
||||
|
||||
for device_entry in device_entries:
|
||||
device_id: str | None = None
|
||||
for identifier in device_entry.identifiers:
|
||||
if identifier[0] == DOMAIN:
|
||||
device_id = identifier[1]
|
||||
break
|
||||
|
||||
if device_id and device_id not in all_device_ids:
|
||||
device_registry.async_update_device(
|
||||
device_entry.id, remove_config_entry_id=config_entry.entry_id
|
||||
)
|
||||
async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Remove a config entry."""
|
||||
if not hass.config_entries.async_loaded_entries(DOMAIN):
|
||||
ir.async_delete_issue(hass, DOMAIN, DOMAIN)
|
||||
# Remove any remaining disabled or ignored entries
|
||||
for _entry in hass.config_entries.async_entries(DOMAIN):
|
||||
hass.async_create_task(hass.config_entries.async_remove(_entry.entry_id))
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
"""API for Aladdin Connect Genie bound to Home Assistant OAuth."""
|
||||
|
||||
from typing import cast
|
||||
|
||||
from aiohttp import ClientSession
|
||||
from genie_partner_sdk.auth import Auth
|
||||
|
||||
from homeassistant.helpers import config_entry_oauth2_flow
|
||||
|
||||
API_URL = "https://twdvzuefzh.execute-api.us-east-2.amazonaws.com/v1"
|
||||
API_KEY = "k6QaiQmcTm2zfaNns5L1Z8duBtJmhDOW8JawlCC3"
|
||||
|
||||
|
||||
class AsyncConfigEntryAuth(Auth):
|
||||
"""Provide Aladdin Connect Genie authentication tied to an OAuth2 based config entry."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
websession: ClientSession,
|
||||
oauth_session: config_entry_oauth2_flow.OAuth2Session,
|
||||
) -> None:
|
||||
"""Initialize Aladdin Connect Genie auth."""
|
||||
super().__init__(
|
||||
websession, API_URL, oauth_session.token["access_token"], API_KEY
|
||||
)
|
||||
self._oauth_session = oauth_session
|
||||
|
||||
async def async_get_access_token(self) -> str:
|
||||
"""Return a valid access token."""
|
||||
if not self._oauth_session.valid_token:
|
||||
await self._oauth_session.async_ensure_token_valid()
|
||||
|
||||
return cast(str, self._oauth_session.token["access_token"])
|
||||
@@ -1,14 +0,0 @@
|
||||
"""application_credentials platform the Aladdin Connect Genie integration."""
|
||||
|
||||
from homeassistant.components.application_credentials import AuthorizationServer
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN
|
||||
|
||||
|
||||
async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer:
|
||||
"""Return authorization server."""
|
||||
return AuthorizationServer(
|
||||
authorize_url=OAUTH2_AUTHORIZE,
|
||||
token_url=OAUTH2_TOKEN,
|
||||
)
|
||||
@@ -1,63 +1,11 @@
|
||||
"""Config flow for Aladdin Connect Genie."""
|
||||
"""Config flow for Aladdin Connect integration."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import Any
|
||||
from homeassistant.config_entries import ConfigFlow
|
||||
|
||||
import jwt
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult
|
||||
from homeassistant.helpers import config_entry_oauth2_flow
|
||||
|
||||
from .const import CONFIG_FLOW_MINOR_VERSION, CONFIG_FLOW_VERSION, DOMAIN
|
||||
from . import DOMAIN
|
||||
|
||||
|
||||
class OAuth2FlowHandler(
|
||||
config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN
|
||||
):
|
||||
"""Config flow to handle Aladdin Connect Genie OAuth2 authentication."""
|
||||
class AladdinConnectConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Aladdin Connect."""
|
||||
|
||||
DOMAIN = DOMAIN
|
||||
VERSION = CONFIG_FLOW_VERSION
|
||||
MINOR_VERSION = CONFIG_FLOW_MINOR_VERSION
|
||||
|
||||
async def async_step_reauth(
|
||||
self, user_input: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Perform reauth upon API auth error or upgrade from v1 to v2."""
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: Mapping[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Dialog that informs the user that reauth is required."""
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=vol.Schema({}),
|
||||
)
|
||||
return await self.async_step_user()
|
||||
|
||||
async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult:
|
||||
"""Create an oauth config entry or update existing entry for reauth."""
|
||||
# Extract the user ID from the JWT token's 'sub' field
|
||||
token = jwt.decode(
|
||||
data["token"]["access_token"], options={"verify_signature": False}
|
||||
)
|
||||
user_id = token["sub"]
|
||||
await self.async_set_unique_id(user_id)
|
||||
|
||||
if self.source == SOURCE_REAUTH:
|
||||
self._abort_if_unique_id_mismatch(reason="wrong_account")
|
||||
return self.async_update_reload_and_abort(
|
||||
self._get_reauth_entry(), data=data
|
||||
)
|
||||
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(title="Aladdin Connect", data=data)
|
||||
|
||||
@property
|
||||
def logger(self) -> logging.Logger:
|
||||
"""Return logger."""
|
||||
return logging.getLogger(__name__)
|
||||
VERSION = 1
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
"""Constants for the Aladdin Connect Genie integration."""
|
||||
|
||||
from typing import Final
|
||||
|
||||
from homeassistant.components.cover import CoverEntityFeature
|
||||
|
||||
DOMAIN = "aladdin_connect"
|
||||
CONFIG_FLOW_VERSION = 2
|
||||
CONFIG_FLOW_MINOR_VERSION = 1
|
||||
|
||||
OAUTH2_AUTHORIZE = "https://app.aladdinconnect.com/login.html"
|
||||
OAUTH2_TOKEN = "https://twdvzuefzh.execute-api.us-east-2.amazonaws.com/v1/oauth2/token"
|
||||
|
||||
SUPPORTED_FEATURES: Final = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE
|
||||
@@ -1,44 +0,0 @@
|
||||
"""Coordinator for Aladdin Connect integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from genie_partner_sdk.client import AladdinConnectClient
|
||||
from genie_partner_sdk.model import GarageDoor
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
type AladdinConnectConfigEntry = ConfigEntry[dict[str, AladdinConnectCoordinator]]
|
||||
SCAN_INTERVAL = timedelta(seconds=15)
|
||||
|
||||
|
||||
class AladdinConnectCoordinator(DataUpdateCoordinator[GarageDoor]):
|
||||
"""Coordinator for Aladdin Connect integration."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
entry: AladdinConnectConfigEntry,
|
||||
client: AladdinConnectClient,
|
||||
garage_door: GarageDoor,
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
logger=_LOGGER,
|
||||
config_entry=entry,
|
||||
name="Aladdin Connect Coordinator",
|
||||
update_interval=SCAN_INTERVAL,
|
||||
)
|
||||
self.client = client
|
||||
self.data = garage_door
|
||||
|
||||
async def _async_update_data(self) -> GarageDoor:
|
||||
"""Fetch data from the Aladdin Connect API."""
|
||||
await self.client.update_door(self.data.device_id, self.data.door_number)
|
||||
return self.data
|
||||
@@ -1,62 +0,0 @@
|
||||
"""Cover Entity for Genie Garage Door."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.cover import CoverDeviceClass, CoverEntity
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import SUPPORTED_FEATURES
|
||||
from .coordinator import AladdinConnectConfigEntry, AladdinConnectCoordinator
|
||||
from .entity import AladdinConnectEntity
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: AladdinConnectConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the cover platform."""
|
||||
coordinators = entry.runtime_data
|
||||
|
||||
async_add_entities(
|
||||
AladdinCoverEntity(coordinator) for coordinator in coordinators.values()
|
||||
)
|
||||
|
||||
|
||||
class AladdinCoverEntity(AladdinConnectEntity, CoverEntity):
|
||||
"""Representation of Aladdin Connect cover."""
|
||||
|
||||
_attr_device_class = CoverDeviceClass.GARAGE
|
||||
_attr_supported_features = SUPPORTED_FEATURES
|
||||
_attr_name = None
|
||||
|
||||
def __init__(self, coordinator: AladdinConnectCoordinator) -> None:
|
||||
"""Initialize the Aladdin Connect cover."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_unique_id = coordinator.data.unique_id
|
||||
|
||||
async def async_open_cover(self, **kwargs: Any) -> None:
|
||||
"""Issue open command to cover."""
|
||||
await self.client.open_door(self._device_id, self._number)
|
||||
|
||||
async def async_close_cover(self, **kwargs: Any) -> None:
|
||||
"""Issue close command to cover."""
|
||||
await self.client.close_door(self._device_id, self._number)
|
||||
|
||||
@property
|
||||
def is_closed(self) -> bool | None:
|
||||
"""Update is closed attribute."""
|
||||
return self.coordinator.data.status == "closed"
|
||||
|
||||
@property
|
||||
def is_closing(self) -> bool | None:
|
||||
"""Update is closing attribute."""
|
||||
return self.coordinator.data.status == "closing"
|
||||
|
||||
@property
|
||||
def is_opening(self) -> bool | None:
|
||||
"""Update is opening attribute."""
|
||||
return self.coordinator.data.status == "opening"
|
||||
@@ -1,32 +0,0 @@
|
||||
"""Base class for Aladdin Connect entities."""
|
||||
|
||||
from genie_partner_sdk.client import AladdinConnectClient
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import AladdinConnectCoordinator
|
||||
|
||||
|
||||
class AladdinConnectEntity(CoordinatorEntity[AladdinConnectCoordinator]):
|
||||
"""Defines a base Aladdin Connect entity."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(self, coordinator: AladdinConnectCoordinator) -> None:
|
||||
"""Initialize Aladdin Connect entity."""
|
||||
super().__init__(coordinator)
|
||||
device = coordinator.data
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, device.unique_id)},
|
||||
manufacturer="Aladdin Connect",
|
||||
name=device.name,
|
||||
)
|
||||
self._device_id = device.device_id
|
||||
self._number = device.door_number
|
||||
|
||||
@property
|
||||
def client(self) -> AladdinConnectClient:
|
||||
"""Return the client for this entity."""
|
||||
return self.coordinator.client
|
||||
@@ -1,11 +1,9 @@
|
||||
{
|
||||
"domain": "aladdin_connect",
|
||||
"name": "Aladdin Connect",
|
||||
"codeowners": ["@swcloudgenie"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["application_credentials"],
|
||||
"codeowners": [],
|
||||
"documentation": "https://www.home-assistant.io/integrations/aladdin_connect",
|
||||
"integration_type": "system",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["genie-partner-sdk==1.0.10"]
|
||||
"requirements": []
|
||||
}
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: Integration does not register any service actions.
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow: done
|
||||
config-flow-test-coverage: todo
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: Integration does not register any service actions.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions:
|
||||
status: todo
|
||||
comment: Documentation needs to be created.
|
||||
docs-removal-instructions:
|
||||
status: todo
|
||||
comment: Documentation needs to be created.
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: Integration does not subscribe to external events.
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure:
|
||||
status: todo
|
||||
comment: Config flow does not currently test connection during setup.
|
||||
test-before-setup: todo
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions: todo
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters:
|
||||
status: todo
|
||||
comment: Documentation needs to be created.
|
||||
docs-installation-parameters:
|
||||
status: todo
|
||||
comment: Documentation needs to be created.
|
||||
entity-unavailable: todo
|
||||
integration-owner: done
|
||||
log-when-unavailable: todo
|
||||
parallel-updates: todo
|
||||
reauthentication-flow: done
|
||||
test-coverage:
|
||||
status: todo
|
||||
comment: Platform tests for cover and sensor need to be implemented to reach 95% coverage.
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery: todo
|
||||
discovery-update-info: todo
|
||||
docs-data-update:
|
||||
status: todo
|
||||
comment: Documentation needs to be created.
|
||||
docs-examples:
|
||||
status: todo
|
||||
comment: Documentation needs to be created.
|
||||
docs-known-limitations:
|
||||
status: todo
|
||||
comment: Documentation needs to be created.
|
||||
docs-supported-devices:
|
||||
status: todo
|
||||
comment: Documentation needs to be created.
|
||||
docs-supported-functions:
|
||||
status: todo
|
||||
comment: Documentation needs to be created.
|
||||
docs-troubleshooting:
|
||||
status: todo
|
||||
comment: Documentation needs to be created.
|
||||
docs-use-cases:
|
||||
status: todo
|
||||
comment: Documentation needs to be created.
|
||||
dynamic-devices: todo
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
entity-translations: done
|
||||
exception-translations: todo
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: todo
|
||||
repair-issues: todo
|
||||
stale-devices:
|
||||
status: todo
|
||||
comment: Stale devices can be done dynamically
|
||||
|
||||
# Platinum
|
||||
async-dependency: todo
|
||||
inject-websession: done
|
||||
strict-typing: done
|
||||
@@ -1,77 +0,0 @@
|
||||
"""Support for Aladdin Connect Genie sensors."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from genie_partner_sdk.model import GarageDoor
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import PERCENTAGE, EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import AladdinConnectConfigEntry, AladdinConnectCoordinator
|
||||
from .entity import AladdinConnectEntity
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class AladdinConnectSensorEntityDescription(SensorEntityDescription):
|
||||
"""Sensor entity description for Aladdin Connect."""
|
||||
|
||||
value_fn: Callable[[GarageDoor], float | None]
|
||||
|
||||
|
||||
SENSOR_TYPES: tuple[AladdinConnectSensorEntityDescription, ...] = (
|
||||
AladdinConnectSensorEntityDescription(
|
||||
key="battery_level",
|
||||
device_class=SensorDeviceClass.BATTERY,
|
||||
entity_registry_enabled_default=False,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
value_fn=lambda garage_door: garage_door.battery_level,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: AladdinConnectConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Aladdin Connect sensor devices."""
|
||||
coordinators = entry.runtime_data
|
||||
|
||||
async_add_entities(
|
||||
AladdinConnectSensor(coordinator, description)
|
||||
for coordinator in coordinators.values()
|
||||
for description in SENSOR_TYPES
|
||||
)
|
||||
|
||||
|
||||
class AladdinConnectSensor(AladdinConnectEntity, SensorEntity):
|
||||
"""A sensor implementation for Aladdin Connect device."""
|
||||
|
||||
entity_description: AladdinConnectSensorEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: AladdinConnectCoordinator,
|
||||
entity_description: AladdinConnectSensorEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the Aladdin Connect sensor."""
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = entity_description
|
||||
self._attr_unique_id = f"{coordinator.data.unique_id}-{entity_description.key}"
|
||||
|
||||
@property
|
||||
def native_value(self) -> float | None:
|
||||
"""Return the state of the sensor."""
|
||||
return self.entity_description.value_fn(self.coordinator.data)
|
||||
@@ -1,30 +1,8 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"pick_implementation": {
|
||||
"title": "[%key:common::config_flow::title::oauth2_pick_implementation%]"
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"title": "[%key:common::config_flow::title::reauth%]",
|
||||
"description": "Aladdin Connect needs to re-authenticate your account"
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
|
||||
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
|
||||
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
|
||||
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]",
|
||||
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
|
||||
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]",
|
||||
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
|
||||
"authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
|
||||
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
|
||||
"user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"wrong_account": "You are authenticated with a different account than the one set up. Please authenticate with the configured account."
|
||||
},
|
||||
"create_entry": {
|
||||
"default": "[%key:common::config_flow::create_entry::authenticated%]"
|
||||
"issues": {
|
||||
"integration_removed": {
|
||||
"title": "The Aladdin Connect integration has been removed",
|
||||
"description": "The Aladdin Connect integration has been removed from Home Assistant.\n\nTo resolve this issue, please remove the (now defunct) integration entries from your Home Assistant setup. [Click here to see your existing Aladdin Connect integration entries]({entries})."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"""Alexa Devices integration."""
|
||||
|
||||
from homeassistant.const import CONF_COUNTRY, Platform
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import aiohttp_client, config_validation as cv
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import _LOGGER, CONF_LOGIN_DATA, COUNTRY_DOMAINS, DOMAIN
|
||||
from .const import DOMAIN
|
||||
from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator
|
||||
from .services import async_setup_services
|
||||
|
||||
@@ -40,32 +40,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bo
|
||||
return True
|
||||
|
||||
|
||||
async def async_migrate_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bool:
|
||||
"""Migrate old entry."""
|
||||
if entry.version == 1 and entry.minor_version == 1:
|
||||
_LOGGER.debug(
|
||||
"Migrating from version %s.%s", entry.version, entry.minor_version
|
||||
)
|
||||
|
||||
# Convert country in domain
|
||||
country = entry.data[CONF_COUNTRY].lower()
|
||||
domain = COUNTRY_DOMAINS.get(country, country)
|
||||
|
||||
# Add site to login data
|
||||
new_data = entry.data.copy()
|
||||
new_data[CONF_LOGIN_DATA]["site"] = f"https://www.amazon.{domain}"
|
||||
|
||||
hass.config_entries.async_update_entry(
|
||||
entry, data=new_data, version=1, minor_version=2
|
||||
)
|
||||
|
||||
_LOGGER.info(
|
||||
"Migration to version %s.%s successful", entry.version, entry.minor_version
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
@@ -10,14 +10,16 @@ from aioamazondevices.exceptions import (
|
||||
CannotAuthenticate,
|
||||
CannotConnect,
|
||||
CannotRetrieveData,
|
||||
WrongCountry,
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_CODE, CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.const import CONF_CODE, CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import aiohttp_client
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.selector import CountrySelector
|
||||
|
||||
from .const import CONF_LOGIN_DATA, DOMAIN
|
||||
|
||||
@@ -27,12 +29,6 @@ STEP_REAUTH_DATA_SCHEMA = vol.Schema(
|
||||
vol.Required(CONF_CODE): cv.string,
|
||||
}
|
||||
)
|
||||
STEP_RECONFIGURE = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_PASSWORD): cv.string,
|
||||
vol.Required(CONF_CODE): cv.string,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]:
|
||||
@@ -41,6 +37,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str,
|
||||
session = aiohttp_client.async_create_clientsession(hass)
|
||||
api = AmazonEchoApi(
|
||||
session,
|
||||
data[CONF_COUNTRY],
|
||||
data[CONF_USERNAME],
|
||||
data[CONF_PASSWORD],
|
||||
)
|
||||
@@ -51,9 +48,6 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str,
|
||||
class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Alexa Devices."""
|
||||
|
||||
VERSION = 1
|
||||
MINOR_VERSION = 2
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
@@ -64,10 +58,12 @@ class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
data = await validate_input(self.hass, user_input)
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
except (CannotAuthenticate, TypeError):
|
||||
except CannotAuthenticate:
|
||||
errors["base"] = "invalid_auth"
|
||||
except CannotRetrieveData:
|
||||
errors["base"] = "cannot_retrieve_data"
|
||||
except WrongCountry:
|
||||
errors["base"] = "wrong_country"
|
||||
else:
|
||||
await self.async_set_unique_id(data["customer_info"]["user_id"])
|
||||
self._abort_if_unique_id_configured()
|
||||
@@ -82,6 +78,9 @@ class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
errors=errors,
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_COUNTRY, default=self.hass.config.country
|
||||
): CountrySelector(),
|
||||
vol.Required(CONF_USERNAME): cv.string,
|
||||
vol.Required(CONF_PASSWORD): cv.string,
|
||||
vol.Required(CONF_CODE): cv.string,
|
||||
@@ -110,7 +109,7 @@ class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
await validate_input(self.hass, {**reauth_entry.data, **user_input})
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
except (CannotAuthenticate, TypeError):
|
||||
except CannotAuthenticate:
|
||||
errors["base"] = "invalid_auth"
|
||||
except CannotRetrieveData:
|
||||
errors["base"] = "cannot_retrieve_data"
|
||||
@@ -130,47 +129,3 @@ class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
data_schema=STEP_REAUTH_DATA_SCHEMA,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reconfiguration of the device."""
|
||||
reconfigure_entry = self._get_reconfigure_entry()
|
||||
if not user_input:
|
||||
return self.async_show_form(
|
||||
step_id="reconfigure",
|
||||
data_schema=STEP_RECONFIGURE,
|
||||
)
|
||||
|
||||
updated_password = user_input[CONF_PASSWORD]
|
||||
|
||||
self._async_abort_entries_match(
|
||||
{CONF_USERNAME: reconfigure_entry.data[CONF_USERNAME]}
|
||||
)
|
||||
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
try:
|
||||
data = await validate_input(
|
||||
self.hass, {**reconfigure_entry.data, **user_input}
|
||||
)
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
except CannotAuthenticate:
|
||||
errors["base"] = "invalid_auth"
|
||||
except CannotRetrieveData:
|
||||
errors["base"] = "cannot_retrieve_data"
|
||||
else:
|
||||
return self.async_update_reload_and_abort(
|
||||
reconfigure_entry,
|
||||
data_updates={
|
||||
CONF_PASSWORD: updated_password,
|
||||
CONF_LOGIN_DATA: data,
|
||||
},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reconfigure",
|
||||
data_schema=STEP_RECONFIGURE,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
@@ -6,22 +6,3 @@ _LOGGER = logging.getLogger(__package__)
|
||||
|
||||
DOMAIN = "alexa_devices"
|
||||
CONF_LOGIN_DATA = "login_data"
|
||||
|
||||
DEFAULT_DOMAIN = "com"
|
||||
COUNTRY_DOMAINS = {
|
||||
"ar": DEFAULT_DOMAIN,
|
||||
"at": DEFAULT_DOMAIN,
|
||||
"au": "com.au",
|
||||
"be": "com.be",
|
||||
"br": DEFAULT_DOMAIN,
|
||||
"gb": "co.uk",
|
||||
"il": DEFAULT_DOMAIN,
|
||||
"jp": "co.jp",
|
||||
"mx": "com.mx",
|
||||
"no": DEFAULT_DOMAIN,
|
||||
"nz": "com.au",
|
||||
"pl": DEFAULT_DOMAIN,
|
||||
"tr": "com.tr",
|
||||
"us": DEFAULT_DOMAIN,
|
||||
"za": "co.za",
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ from aioamazondevices.exceptions import (
|
||||
from aiohttp import ClientSession
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.const import CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
@@ -44,6 +44,7 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
|
||||
)
|
||||
self.api = AmazonEchoApi(
|
||||
session,
|
||||
entry.data[CONF_COUNTRY],
|
||||
entry.data[CONF_USERNAME],
|
||||
entry.data[CONF_PASSWORD],
|
||||
entry.data[CONF_LOGIN_DATA],
|
||||
@@ -66,7 +67,7 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
|
||||
translation_key="cannot_retrieve_data_with_error",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
except (CannotAuthenticate, TypeError) as err:
|
||||
except CannotAuthenticate as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_auth",
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioamazondevices"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["aioamazondevices==5.0.1"]
|
||||
"requirements": ["aioamazondevices==4.0.0"]
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ rules:
|
||||
entity-translations: done
|
||||
exception-translations: done
|
||||
icon-translations: done
|
||||
reconfiguration-flow: done
|
||||
reconfiguration-flow: todo
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: no known use cases for repair issues or flows, yet
|
||||
|
||||
@@ -12,7 +12,6 @@ from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import LIGHT_LUX, UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -42,13 +41,11 @@ SENSORS: Final = (
|
||||
if device.sensors[_key].scale == "CELSIUS"
|
||||
else UnitOfTemperature.FAHRENHEIT
|
||||
),
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
AmazonSensorEntityDescription(
|
||||
key="illuminance",
|
||||
device_class=SensorDeviceClass.ILLUMINANCE,
|
||||
native_unit_of_measurement=LIGHT_LUX,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"common": {
|
||||
"data_code": "One-time password (OTP code)",
|
||||
"data_description_country": "The country where your Amazon account is registered.",
|
||||
"data_description_username": "The email address of your Amazon account.",
|
||||
"data_description_password": "The password of your Amazon account.",
|
||||
"data_description_code": "The one-time password to log in to your account. Currently, only tokens from OTP applications are supported.",
|
||||
@@ -11,11 +12,13 @@
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"country": "[%key:common::config_flow::data::country%]",
|
||||
"username": "[%key:common::config_flow::data::username%]",
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"code": "[%key:component::alexa_devices::common::data_code%]"
|
||||
},
|
||||
"data_description": {
|
||||
"country": "[%key:component::alexa_devices::common::data_description_country%]",
|
||||
"username": "[%key:component::alexa_devices::common::data_description_username%]",
|
||||
"password": "[%key:component::alexa_devices::common::data_description_password%]",
|
||||
"code": "[%key:component::alexa_devices::common::data_description_code%]"
|
||||
@@ -30,16 +33,6 @@
|
||||
"password": "[%key:component::alexa_devices::common::data_description_password%]",
|
||||
"code": "[%key:component::alexa_devices::common::data_description_code%]"
|
||||
}
|
||||
},
|
||||
"reconfigure": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"code": "[%key:component::alexa_devices::common::data_code%]"
|
||||
},
|
||||
"data_description": {
|
||||
"password": "[%key:component::alexa_devices::common::data_description_password%]",
|
||||
"code": "[%key:component::alexa_devices::common::data_description_code%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
@@ -47,13 +40,13 @@
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"cannot_retrieve_data": "Unable to retrieve data from Amazon. Please try again later.",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"wrong_country": "Wrong country selected. Please select the country where your Amazon account is registered.",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -129,9 +129,9 @@ async def async_migrate_integration(hass: HomeAssistant) -> None:
|
||||
entity_disabled_by is er.RegistryEntryDisabler.CONFIG_ENTRY
|
||||
and not all_disabled
|
||||
):
|
||||
# Device and entity registries will set the disabled_by flag to None
|
||||
# when moving a device or entity disabled by CONFIG_ENTRY to an enabled
|
||||
# config entry, but we want to set it to DEVICE or USER instead,
|
||||
# Device and entity registries don't update the disabled_by flag
|
||||
# when moving a device or entity from one config entry to another,
|
||||
# so we need to do it manually.
|
||||
entity_disabled_by = (
|
||||
er.RegistryEntryDisabler.DEVICE
|
||||
if device
|
||||
@@ -146,9 +146,9 @@ async def async_migrate_integration(hass: HomeAssistant) -> None:
|
||||
)
|
||||
|
||||
if device is not None:
|
||||
# Device and entity registries will set the disabled_by flag to None
|
||||
# when moving a device or entity disabled by CONFIG_ENTRY to an enabled
|
||||
# config entry, but we want to set it to USER instead,
|
||||
# Device and entity registries don't update the disabled_by flag when
|
||||
# moving a device or entity from one config entry to another, so we
|
||||
# need to do it manually.
|
||||
device_disabled_by = device.disabled_by
|
||||
if (
|
||||
device.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Virtual integration: Arizona Public Service (APS)."""
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"domain": "aps",
|
||||
"name": "Arizona Public Service (APS)",
|
||||
"integration_type": "virtual",
|
||||
"supported_by": "opower"
|
||||
}
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/assist_satellite",
|
||||
"integration_type": "entity",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["hassil==3.2.0"]
|
||||
"requirements": ["hassil==3.1.0"]
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["aioasuswrt", "asusrouter", "asyncssh"],
|
||||
"requirements": ["aioasuswrt==1.4.0", "asusrouter==1.20.1"]
|
||||
"requirements": ["aioasuswrt==1.4.0", "asusrouter==1.19.0"]
|
||||
}
|
||||
|
||||
@@ -6,21 +6,18 @@ from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
from aiohttp import ClientResponseError
|
||||
from yalexs.const import Brand
|
||||
from yalexs.exceptions import AugustApiAIOHTTPError
|
||||
from yalexs.manager.exceptions import CannotConnect, InvalidAuth, RequireValidation
|
||||
from yalexs.manager.gateway import Config as YaleXSConfig
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers import (
|
||||
config_entry_oauth2_flow,
|
||||
device_registry as dr,
|
||||
issue_registry as ir,
|
||||
)
|
||||
from homeassistant.helpers import device_registry as dr, issue_registry as ir
|
||||
|
||||
from .const import DEFAULT_AUGUST_BRAND, DOMAIN, PLATFORMS
|
||||
from .const import DOMAIN, PLATFORMS
|
||||
from .data import AugustData
|
||||
from .gateway import AugustGateway
|
||||
from .util import async_create_august_clientsession
|
||||
@@ -28,21 +25,30 @@ from .util import async_create_august_clientsession
|
||||
type AugustConfigEntry = ConfigEntry[AugustData]
|
||||
|
||||
|
||||
@callback
|
||||
def _async_create_yale_brand_migration_issue(
|
||||
hass: HomeAssistant, entry: AugustConfigEntry
|
||||
) -> None:
|
||||
"""Create an issue for a brand migration."""
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"yale_brand_migration",
|
||||
breaks_in_ha_version="2024.9",
|
||||
learn_more_url="https://www.home-assistant.io/integrations/yale",
|
||||
translation_key="yale_brand_migration",
|
||||
is_fixable=False,
|
||||
severity=ir.IssueSeverity.CRITICAL,
|
||||
translation_placeholders={
|
||||
"migrate_url": "https://my.home-assistant.io/redirect/config_flow_start?domain=yale"
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: AugustConfigEntry) -> bool:
|
||||
"""Set up August from a config entry."""
|
||||
# Check if this is a legacy config entry that needs migration to OAuth
|
||||
if "auth_implementation" not in entry.data:
|
||||
# This is a legacy entry using username/password, trigger reauth
|
||||
raise ConfigEntryAuthFailed("Migration to OAuth required")
|
||||
|
||||
session = async_create_august_clientsession(hass)
|
||||
implementation = (
|
||||
await config_entry_oauth2_flow.async_get_config_entry_implementation(
|
||||
hass, entry
|
||||
)
|
||||
)
|
||||
oauth_session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
|
||||
august_gateway = AugustGateway(Path(hass.config.config_dir), session, oauth_session)
|
||||
august_gateway = AugustGateway(Path(hass.config.config_dir), session)
|
||||
try:
|
||||
await async_setup_august(hass, entry, august_gateway)
|
||||
except (RequireValidation, InvalidAuth) as err:
|
||||
@@ -70,7 +76,9 @@ async def async_setup_august(
|
||||
) -> None:
|
||||
"""Set up the August component."""
|
||||
config = cast(YaleXSConfig, entry.data)
|
||||
await august_gateway.async_setup({**config, "brand": DEFAULT_AUGUST_BRAND})
|
||||
await august_gateway.async_setup(config)
|
||||
if august_gateway.api.brand == Brand.YALE_HOME:
|
||||
_async_create_yale_brand_migration_issue(hass, entry)
|
||||
await august_gateway.async_authenticate()
|
||||
await august_gateway.async_refresh_access_token_if_needed()
|
||||
data = entry.runtime_data = AugustData(hass, august_gateway)
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
"""application_credentials platform for the august integration."""
|
||||
|
||||
from homeassistant.components.application_credentials import AuthorizationServer
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
OAUTH2_AUTHORIZE = "https://auth.august.com/authorization"
|
||||
OAUTH2_TOKEN = "https://auth.august.com/access_token"
|
||||
|
||||
|
||||
async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer:
|
||||
"""Return authorization server."""
|
||||
return AuthorizationServer(
|
||||
authorize_url=OAUTH2_AUTHORIZE,
|
||||
token_url=OAUTH2_TOKEN,
|
||||
)
|
||||
@@ -1,86 +1,284 @@
|
||||
"""Config flow for August integration."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import jwt
|
||||
import aiohttp
|
||||
import voluptuous as vol
|
||||
from yalexs.authenticator_common import ValidationResult
|
||||
from yalexs.const import BRANDS_WITHOUT_OAUTH, DEFAULT_BRAND, Brand
|
||||
from yalexs.manager.exceptions import CannotConnect, InvalidAuth, RequireValidation
|
||||
|
||||
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult
|
||||
from homeassistant.helpers import config_entry_oauth2_flow
|
||||
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import callback
|
||||
|
||||
from .const import (
|
||||
CONF_ACCESS_TOKEN_CACHE_FILE,
|
||||
CONF_BRAND,
|
||||
CONF_LOGIN_METHOD,
|
||||
DEFAULT_LOGIN_METHOD,
|
||||
DOMAIN,
|
||||
LOGIN_METHODS,
|
||||
VERIFICATION_CODE_KEY,
|
||||
)
|
||||
from .gateway import AugustGateway
|
||||
from .util import async_create_august_clientsession
|
||||
|
||||
# The Yale Home Brand is not supported by the August integration
|
||||
# anymore and should migrate to the Yale integration
|
||||
AVAILABLE_BRANDS = BRANDS_WITHOUT_OAUTH.copy()
|
||||
del AVAILABLE_BRANDS[Brand.YALE_HOME]
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AugustConfigFlow(
|
||||
config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN
|
||||
):
|
||||
async def async_validate_input(
|
||||
data: dict[str, Any], august_gateway: AugustGateway
|
||||
) -> dict[str, Any]:
|
||||
"""Validate the user input allows us to connect.
|
||||
|
||||
Data has the keys from DATA_SCHEMA with values provided by the user.
|
||||
|
||||
Request configuration steps from the user.
|
||||
"""
|
||||
assert august_gateway.authenticator is not None
|
||||
authenticator = august_gateway.authenticator
|
||||
if (code := data.get(VERIFICATION_CODE_KEY)) is not None:
|
||||
result = await authenticator.async_validate_verification_code(code)
|
||||
_LOGGER.debug("Verification code validation: %s", result)
|
||||
if result != ValidationResult.VALIDATED:
|
||||
raise RequireValidation
|
||||
|
||||
try:
|
||||
await august_gateway.async_authenticate()
|
||||
except RequireValidation:
|
||||
_LOGGER.debug(
|
||||
"Requesting new verification code for %s via %s",
|
||||
data.get(CONF_USERNAME),
|
||||
data.get(CONF_LOGIN_METHOD),
|
||||
)
|
||||
if code is None:
|
||||
await august_gateway.authenticator.async_send_verification_code()
|
||||
raise
|
||||
|
||||
return {
|
||||
"title": data.get(CONF_USERNAME),
|
||||
"data": august_gateway.config_entry(),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ValidateResult:
|
||||
"""Result from validation."""
|
||||
|
||||
validation_required: bool
|
||||
info: dict[str, Any]
|
||||
errors: dict[str, str]
|
||||
description_placeholders: dict[str, str]
|
||||
|
||||
|
||||
class AugustConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for August."""
|
||||
|
||||
VERSION = 1
|
||||
DOMAIN = DOMAIN
|
||||
|
||||
@property
|
||||
def logger(self) -> logging.Logger:
|
||||
"""Return logger."""
|
||||
return _LOGGER
|
||||
def __init__(self) -> None:
|
||||
"""Store an AugustGateway()."""
|
||||
self._august_gateway: AugustGateway | None = None
|
||||
self._aiohttp_session: aiohttp.ClientSession | None = None
|
||||
self._user_auth_details: dict[str, Any] = {}
|
||||
self._needs_reset = True
|
||||
super().__init__()
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
return await self.async_step_user_validate()
|
||||
|
||||
async def async_step_user_validate(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle authentication."""
|
||||
errors: dict[str, str] = {}
|
||||
description_placeholders: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
self._user_auth_details.update(user_input)
|
||||
validate_result = await self._async_auth_or_validate()
|
||||
description_placeholders = validate_result.description_placeholders
|
||||
if validate_result.validation_required:
|
||||
return await self.async_step_validation()
|
||||
if not (errors := validate_result.errors):
|
||||
return await self._async_update_or_create_entry(validate_result.info)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user_validate",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_BRAND,
|
||||
default=self._user_auth_details.get(CONF_BRAND, DEFAULT_BRAND),
|
||||
): vol.In(AVAILABLE_BRANDS),
|
||||
vol.Required(
|
||||
CONF_LOGIN_METHOD,
|
||||
default=self._user_auth_details.get(
|
||||
CONF_LOGIN_METHOD, DEFAULT_LOGIN_METHOD
|
||||
),
|
||||
): vol.In(LOGIN_METHODS),
|
||||
vol.Required(
|
||||
CONF_USERNAME,
|
||||
default=self._user_auth_details.get(CONF_USERNAME),
|
||||
): str,
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
description_placeholders=description_placeholders,
|
||||
)
|
||||
|
||||
async def async_step_validation(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle validation (2fa) step."""
|
||||
if user_input:
|
||||
if self.source == SOURCE_REAUTH:
|
||||
return await self.async_step_reauth_validate(user_input)
|
||||
return await self.async_step_user_validate(user_input)
|
||||
|
||||
previously_failed = VERIFICATION_CODE_KEY in self._user_auth_details
|
||||
return self.async_show_form(
|
||||
step_id="validation",
|
||||
data_schema=vol.Schema(
|
||||
{vol.Required(VERIFICATION_CODE_KEY): vol.All(str, vol.Strip)}
|
||||
),
|
||||
errors={"base": "invalid_verification_code"} if previously_failed else None,
|
||||
description_placeholders={
|
||||
CONF_BRAND: self._user_auth_details[CONF_BRAND],
|
||||
CONF_USERNAME: self._user_auth_details[CONF_USERNAME],
|
||||
CONF_LOGIN_METHOD: self._user_auth_details[CONF_LOGIN_METHOD],
|
||||
},
|
||||
)
|
||||
|
||||
@callback
|
||||
def _async_get_gateway(self) -> AugustGateway:
|
||||
"""Set up the gateway."""
|
||||
if self._august_gateway is not None:
|
||||
return self._august_gateway
|
||||
self._aiohttp_session = async_create_august_clientsession(self.hass)
|
||||
self._august_gateway = AugustGateway(
|
||||
Path(self.hass.config.config_dir), self._aiohttp_session
|
||||
)
|
||||
return self._august_gateway
|
||||
|
||||
@callback
|
||||
def _async_shutdown_gateway(self) -> None:
|
||||
"""Shutdown the gateway."""
|
||||
if self._aiohttp_session is not None:
|
||||
self._aiohttp_session.detach()
|
||||
self._august_gateway = None
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle configuration by re-auth."""
|
||||
return await self.async_step_user()
|
||||
self._user_auth_details = dict(entry_data)
|
||||
return await self.async_step_reauth_validate()
|
||||
|
||||
def _async_decode_jwt(self, encoded: str) -> dict[str, Any]:
|
||||
"""Decode JWT token."""
|
||||
return jwt.decode(
|
||||
encoded,
|
||||
"",
|
||||
verify=False,
|
||||
options={"verify_signature": False},
|
||||
algorithms=["HS256"],
|
||||
)
|
||||
|
||||
async def _async_handle_reauth(
|
||||
self, data: dict, decoded: dict[str, Any], user_id: str
|
||||
async def async_step_reauth_validate(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reauth flow."""
|
||||
reauth_entry = self._get_reauth_entry()
|
||||
assert reauth_entry.unique_id is not None
|
||||
# Check if this is a migration from username (contains @) to user ID
|
||||
if "@" not in reauth_entry.unique_id:
|
||||
# This is a normal oauth reauth, enforce ID matching for security
|
||||
await self.async_set_unique_id(user_id)
|
||||
self._abort_if_unique_id_mismatch(reason="reauth_invalid_user")
|
||||
return self.async_update_reload_and_abort(reauth_entry, data=data)
|
||||
"""Handle reauth and validation."""
|
||||
errors: dict[str, str] = {}
|
||||
description_placeholders: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
self._user_auth_details.update(user_input)
|
||||
validate_result = await self._async_auth_or_validate()
|
||||
description_placeholders = validate_result.description_placeholders
|
||||
if validate_result.validation_required:
|
||||
return await self.async_step_validation()
|
||||
if not (errors := validate_result.errors):
|
||||
return await self._async_update_or_create_entry(validate_result.info)
|
||||
|
||||
# This is a one-time migration from username to user ID
|
||||
# Only validate if the account has emails
|
||||
emails: list[str]
|
||||
if emails := decoded.get("email", []):
|
||||
# Validate that the email matches before allowing migration
|
||||
email_to_check_lower = reauth_entry.unique_id.casefold()
|
||||
if not any(email.casefold() == email_to_check_lower for email in emails):
|
||||
# Email doesn't match - this is a different account
|
||||
return self.async_abort(reason="reauth_invalid_user")
|
||||
|
||||
# Email matches or no emails on account, update with new unique ID
|
||||
return self.async_update_reload_and_abort(
|
||||
reauth_entry, data=data, unique_id=user_id
|
||||
return self.async_show_form(
|
||||
step_id="reauth_validate",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_BRAND,
|
||||
default=self._user_auth_details.get(CONF_BRAND, DEFAULT_BRAND),
|
||||
): vol.In(BRANDS_WITHOUT_OAUTH),
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
description_placeholders=description_placeholders
|
||||
| {
|
||||
CONF_USERNAME: self._user_auth_details[CONF_USERNAME],
|
||||
},
|
||||
)
|
||||
|
||||
async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult:
|
||||
"""Create an entry for the flow."""
|
||||
# Decode JWT once
|
||||
access_token = data["token"]["access_token"]
|
||||
decoded = self._async_decode_jwt(access_token)
|
||||
user_id = decoded["userId"]
|
||||
async def _async_reset_access_token_cache_if_needed(
|
||||
self, gateway: AugustGateway, username: str, access_token_cache_file: str | None
|
||||
) -> None:
|
||||
"""Reset the access token cache if needed."""
|
||||
# We need to configure the access token cache file before we setup the gateway
|
||||
# since we need to reset it if the brand changes BEFORE we setup the gateway
|
||||
gateway.async_configure_access_token_cache_file(
|
||||
username, access_token_cache_file
|
||||
)
|
||||
if self._needs_reset:
|
||||
self._needs_reset = False
|
||||
await gateway.async_reset_authentication()
|
||||
|
||||
if self.source == SOURCE_REAUTH:
|
||||
return await self._async_handle_reauth(data, decoded, user_id)
|
||||
async def _async_auth_or_validate(self) -> ValidateResult:
|
||||
"""Authenticate or validate."""
|
||||
user_auth_details = self._user_auth_details
|
||||
gateway = self._async_get_gateway()
|
||||
assert gateway is not None
|
||||
await self._async_reset_access_token_cache_if_needed(
|
||||
gateway,
|
||||
user_auth_details[CONF_USERNAME],
|
||||
user_auth_details.get(CONF_ACCESS_TOKEN_CACHE_FILE),
|
||||
)
|
||||
await gateway.async_setup(user_auth_details)
|
||||
|
||||
await self.async_set_unique_id(user_id)
|
||||
self._abort_if_unique_id_configured()
|
||||
return await super().async_oauth_create_entry(data)
|
||||
errors: dict[str, str] = {}
|
||||
info: dict[str, Any] = {}
|
||||
description_placeholders: dict[str, str] = {}
|
||||
validation_required = False
|
||||
|
||||
try:
|
||||
info = await async_validate_input(user_auth_details, gateway)
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
except InvalidAuth:
|
||||
errors["base"] = "invalid_auth"
|
||||
except RequireValidation:
|
||||
validation_required = True
|
||||
except Exception as ex:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unhandled"
|
||||
description_placeholders = {"error": str(ex)}
|
||||
|
||||
return ValidateResult(
|
||||
validation_required, info, errors, description_placeholders
|
||||
)
|
||||
|
||||
async def _async_update_or_create_entry(
|
||||
self, info: dict[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Update existing entry or create a new one."""
|
||||
self._async_shutdown_gateway()
|
||||
|
||||
existing_entry = await self.async_set_unique_id(
|
||||
self._user_auth_details[CONF_USERNAME]
|
||||
)
|
||||
if not existing_entry:
|
||||
return self.async_create_entry(title=info["title"], data=info["data"])
|
||||
|
||||
return self.async_update_reload_and_abort(existing_entry, data=info["data"])
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"""Constants for August devices."""
|
||||
|
||||
from yalexs.const import Brand
|
||||
|
||||
from homeassistant.const import Platform
|
||||
|
||||
DEFAULT_TIMEOUT = 25
|
||||
@@ -11,8 +9,6 @@ CONF_BRAND = "brand"
|
||||
CONF_LOGIN_METHOD = "login_method"
|
||||
CONF_INSTALL_ID = "install_id"
|
||||
|
||||
DEFAULT_AUGUST_BRAND = Brand.YALE_AUGUST
|
||||
|
||||
VERIFICATION_CODE_KEY = "verification_code"
|
||||
|
||||
NOTIFICATION_ID = "august_notification"
|
||||
|
||||
@@ -1,43 +1,30 @@
|
||||
"""Handle August connection setup and authentication."""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import ClientSession
|
||||
from yalexs.authenticator_common import Authentication, AuthenticationState
|
||||
from yalexs.const import DEFAULT_BRAND
|
||||
from yalexs.manager.gateway import Gateway
|
||||
|
||||
from homeassistant.helpers import config_entry_oauth2_flow
|
||||
from homeassistant.const import CONF_USERNAME
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
from .const import (
|
||||
CONF_ACCESS_TOKEN_CACHE_FILE,
|
||||
CONF_BRAND,
|
||||
CONF_INSTALL_ID,
|
||||
CONF_LOGIN_METHOD,
|
||||
)
|
||||
|
||||
|
||||
class AugustGateway(Gateway):
|
||||
"""Handle the connection to August."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config_path: Path,
|
||||
aiohttp_session: ClientSession,
|
||||
oauth_session: config_entry_oauth2_flow.OAuth2Session,
|
||||
) -> None:
|
||||
"""Init the connection."""
|
||||
super().__init__(config_path, aiohttp_session)
|
||||
self._oauth_session = oauth_session
|
||||
|
||||
async def async_get_access_token(self) -> str:
|
||||
"""Get access token."""
|
||||
await self._oauth_session.async_ensure_token_valid()
|
||||
return self._oauth_session.token["access_token"]
|
||||
|
||||
async def async_refresh_access_token_if_needed(self) -> None:
|
||||
"""Refresh the access token if needed."""
|
||||
await self._oauth_session.async_ensure_token_valid()
|
||||
|
||||
async def async_authenticate(self) -> Authentication:
|
||||
"""Authenticate with the details provided to setup."""
|
||||
await self._oauth_session.async_ensure_token_valid()
|
||||
self.authentication = Authentication(
|
||||
AuthenticationState.AUTHENTICATED, None, None, None
|
||||
)
|
||||
return self.authentication
|
||||
def config_entry(self) -> dict[str, Any]:
|
||||
"""Config entry."""
|
||||
assert self._config is not None
|
||||
return {
|
||||
CONF_BRAND: self._config.get(CONF_BRAND, DEFAULT_BRAND),
|
||||
CONF_LOGIN_METHOD: self._config[CONF_LOGIN_METHOD],
|
||||
CONF_USERNAME: self._config[CONF_USERNAME],
|
||||
CONF_INSTALL_ID: self._config.get(CONF_INSTALL_ID),
|
||||
CONF_ACCESS_TOKEN_CACHE_FILE: self._access_token_cache_file,
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
"name": "August",
|
||||
"codeowners": ["@bdraco"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["application_credentials", "cloud"],
|
||||
"dhcp": [
|
||||
{
|
||||
"hostname": "connect",
|
||||
@@ -29,5 +28,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/august",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["pubnub", "yalexs"],
|
||||
"requirements": ["yalexs==9.0.1", "yalexs-ble==3.1.2"]
|
||||
"requirements": ["yalexs==8.11.1", "yalexs-ble==3.1.2"]
|
||||
}
|
||||
|
||||
@@ -6,34 +6,42 @@
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"step": {
|
||||
"pick_implementation": {
|
||||
"title": "[%key:common::config_flow::title::oauth2_pick_implementation%]",
|
||||
"data": {
|
||||
"implementation": "[%key:common::config_flow::data::implementation%]"
|
||||
},
|
||||
"data_description": {
|
||||
"implementation": "[%key:common::config_flow::description::implementation%]"
|
||||
}
|
||||
}
|
||||
"error": {
|
||||
"unhandled": "Unhandled error: {error}",
|
||||
"invalid_verification_code": "Invalid verification code",
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
|
||||
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
|
||||
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
|
||||
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
|
||||
"missing_credentials": "[%key:common::config_flow::abort::oauth2_missing_credentials%]",
|
||||
"authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
|
||||
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
|
||||
"user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
|
||||
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]",
|
||||
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]",
|
||||
"reauth_invalid_user": "Reauthenticate must use the same account."
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
},
|
||||
"create_entry": {
|
||||
"default": "[%key:common::config_flow::create_entry::authenticated%]"
|
||||
"step": {
|
||||
"validation": {
|
||||
"title": "Two-factor authentication",
|
||||
"data": {
|
||||
"verification_code": "Verification code"
|
||||
},
|
||||
"description": "Please check your {login_method} ({username}) and enter the verification code below. Codes may take a few minutes to arrive."
|
||||
},
|
||||
"user_validate": {
|
||||
"description": "It is recommended to use the 'email' login method as some brands may not work with the 'phone' method. If the Login Method is 'email', Username is the email address. If the Login Method is 'phone', Username is the phone number in the format '+NNNNNNNNN'. If you choose the wrong brand, you may be able to authenticate initially; however, you will not be able to operate devices. If you are unsure of the brand, create the integration again and try another brand.",
|
||||
"data": {
|
||||
"brand": "Brand",
|
||||
"login_method": "Login Method",
|
||||
"username": "[%key:common::config_flow::data::username%]",
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
},
|
||||
"title": "Set up an August account"
|
||||
},
|
||||
"reauth_validate": {
|
||||
"description": "Choose the correct brand for your device, and enter the password for {username}. If you choose the wrong brand, you may be able to authenticate initially; however, you will not be able to operate devices. If you are unsure of the brand, create the integration again and try another brand.",
|
||||
"data": {
|
||||
"brand": "[%key:component::august::config::step::user_validate::data::brand%]",
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
},
|
||||
"title": "Reauthenticate an August account"
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/azure_devops",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioazuredevops"],
|
||||
"requirements": ["aioazuredevops==2.2.2"]
|
||||
"requirements": ["aioazuredevops==2.2.1"]
|
||||
}
|
||||
|
||||
@@ -1,24 +1,6 @@
|
||||
"""The bayesian component."""
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.const import Platform
|
||||
|
||||
from .const import PLATFORMS
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Bayesian from a config entry."""
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
entry.async_on_unload(entry.add_update_listener(update_listener))
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload Bayesian config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
|
||||
async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Handle options update."""
|
||||
hass.config_entries.async_schedule_reload(entry.entry_id)
|
||||
DOMAIN = "bayesian"
|
||||
PLATFORMS = [Platform.BINARY_SENSOR]
|
||||
|
||||
@@ -16,7 +16,6 @@ from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
BinarySensorEntity,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
CONF_ABOVE,
|
||||
CONF_BELOW,
|
||||
@@ -33,10 +32,7 @@ from homeassistant.const import (
|
||||
from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConditionError, TemplateError
|
||||
from homeassistant.helpers import condition, config_validation as cv
|
||||
from homeassistant.helpers.entity_platform import (
|
||||
AddConfigEntryEntitiesCallback,
|
||||
AddEntitiesCallback,
|
||||
)
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.event import (
|
||||
TrackTemplate,
|
||||
TrackTemplateResult,
|
||||
@@ -48,6 +44,7 @@ from homeassistant.helpers.reload import async_setup_reload_service
|
||||
from homeassistant.helpers.template import Template, result_as_boolean
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
|
||||
from . import DOMAIN, PLATFORMS
|
||||
from .const import (
|
||||
ATTR_OBSERVATIONS,
|
||||
ATTR_OCCURRED_OBSERVATION_ENTITIES,
|
||||
@@ -63,8 +60,6 @@ from .const import (
|
||||
CONF_TO_STATE,
|
||||
DEFAULT_NAME,
|
||||
DEFAULT_PROBABILITY_THRESHOLD,
|
||||
DOMAIN,
|
||||
PLATFORMS,
|
||||
)
|
||||
from .helpers import Observation
|
||||
from .issues import raise_mirrored_entries, raise_no_prob_given_false
|
||||
@@ -72,13 +67,7 @@ from .issues import raise_mirrored_entries, raise_no_prob_given_false
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def above_greater_than_below(config: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Validate above and below options.
|
||||
|
||||
If the observation is of type/platform NUMERIC_STATE, then ensure that the
|
||||
value given for 'above' is not greater than that for 'below'. Also check
|
||||
that at least one of the two is specified.
|
||||
"""
|
||||
def _above_greater_than_below(config: dict[str, Any]) -> dict[str, Any]:
|
||||
if config[CONF_PLATFORM] == CONF_NUMERIC_STATE:
|
||||
above = config.get(CONF_ABOVE)
|
||||
below = config.get(CONF_BELOW)
|
||||
@@ -87,7 +76,9 @@ def above_greater_than_below(config: dict[str, Any]) -> dict[str, Any]:
|
||||
"For bayesian numeric state for entity: %s at least one of 'above' or 'below' must be specified",
|
||||
config[CONF_ENTITY_ID],
|
||||
)
|
||||
raise vol.Invalid("above_or_below")
|
||||
raise vol.Invalid(
|
||||
"For bayesian numeric state at least one of 'above' or 'below' must be specified."
|
||||
)
|
||||
if above is not None and below is not None:
|
||||
if above > below:
|
||||
_LOGGER.error(
|
||||
@@ -95,7 +86,7 @@ def above_greater_than_below(config: dict[str, Any]) -> dict[str, Any]:
|
||||
above,
|
||||
below,
|
||||
)
|
||||
raise vol.Invalid("above_below")
|
||||
raise vol.Invalid("'above' is greater than 'below'")
|
||||
return config
|
||||
|
||||
|
||||
@@ -111,16 +102,11 @@ NUMERIC_STATE_SCHEMA = vol.All(
|
||||
},
|
||||
required=True,
|
||||
),
|
||||
above_greater_than_below,
|
||||
_above_greater_than_below,
|
||||
)
|
||||
|
||||
|
||||
def no_overlapping(configs: list[dict]) -> list[dict]:
|
||||
"""Validate that intervals are not overlapping.
|
||||
|
||||
For a list of observations ensure that there are no overlapping intervals
|
||||
for NUMERIC_STATE observations for the same entity.
|
||||
"""
|
||||
def _no_overlapping(configs: list[dict]) -> list[dict]:
|
||||
numeric_configs = [
|
||||
config for config in configs if config[CONF_PLATFORM] == CONF_NUMERIC_STATE
|
||||
]
|
||||
@@ -143,16 +129,11 @@ def no_overlapping(configs: list[dict]) -> list[dict]:
|
||||
|
||||
for i, tup in enumerate(intervals):
|
||||
if len(intervals) > i + 1 and tup.below > intervals[i + 1].above:
|
||||
_LOGGER.error(
|
||||
"Ranges for bayesian numeric state entities must not overlap, but %s has overlapping ranges, above:%s, below:%s overlaps with above:%s, below:%s",
|
||||
ent_id,
|
||||
tup.above,
|
||||
tup.below,
|
||||
intervals[i + 1].above,
|
||||
intervals[i + 1].below,
|
||||
)
|
||||
raise vol.Invalid(
|
||||
"overlapping_ranges",
|
||||
"Ranges for bayesian numeric state entities must not overlap, "
|
||||
f"but {ent_id} has overlapping ranges, above:{tup.above}, "
|
||||
f"below:{tup.below} overlaps with above:{intervals[i + 1].above}, "
|
||||
f"below:{intervals[i + 1].below}."
|
||||
)
|
||||
return configs
|
||||
|
||||
@@ -187,7 +168,7 @@ PLATFORM_SCHEMA = BINARY_SENSOR_PLATFORM_SCHEMA.extend(
|
||||
vol.All(
|
||||
cv.ensure_list,
|
||||
[vol.Any(TEMPLATE_SCHEMA, STATE_SCHEMA, NUMERIC_STATE_SCHEMA)],
|
||||
no_overlapping,
|
||||
_no_overlapping,
|
||||
)
|
||||
),
|
||||
vol.Required(CONF_PRIOR): vol.Coerce(float),
|
||||
@@ -213,13 +194,9 @@ async def async_setup_platform(
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
discovery_info: DiscoveryInfoType | None = None,
|
||||
) -> None:
|
||||
"""Set up the Bayesian Binary sensor from a yaml config."""
|
||||
_LOGGER.debug(
|
||||
"Setting up config entry for Bayesian sensor: '%s' with %s observations",
|
||||
config[CONF_NAME],
|
||||
len(config.get(CONF_OBSERVATIONS, [])),
|
||||
)
|
||||
"""Set up the Bayesian Binary sensor."""
|
||||
await async_setup_reload_service(hass, DOMAIN, PLATFORMS)
|
||||
|
||||
name: str = config[CONF_NAME]
|
||||
unique_id: str | None = config.get(CONF_UNIQUE_ID)
|
||||
observations: list[ConfigType] = config[CONF_OBSERVATIONS]
|
||||
@@ -254,42 +231,6 @@ async def async_setup_platform(
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
discovery_info: DiscoveryInfoType | None = None,
|
||||
) -> None:
|
||||
"""Set up the Bayesian Binary sensor from a config entry."""
|
||||
_LOGGER.debug(
|
||||
"Setting up config entry for Bayesian sensor: '%s' with %s observations",
|
||||
config_entry.options[CONF_NAME],
|
||||
len(config_entry.subentries),
|
||||
)
|
||||
config = config_entry.options
|
||||
name: str = config[CONF_NAME]
|
||||
unique_id: str | None = config.get(CONF_UNIQUE_ID, config_entry.entry_id)
|
||||
observations: list[ConfigType] = [
|
||||
dict(subentry.data) for subentry in config_entry.subentries.values()
|
||||
]
|
||||
prior: float = config[CONF_PRIOR]
|
||||
probability_threshold: float = config[CONF_PROBABILITY_THRESHOLD]
|
||||
device_class: BinarySensorDeviceClass | None = config.get(CONF_DEVICE_CLASS)
|
||||
|
||||
async_add_entities(
|
||||
[
|
||||
BayesianBinarySensor(
|
||||
name,
|
||||
unique_id,
|
||||
prior,
|
||||
observations,
|
||||
probability_threshold,
|
||||
device_class,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class BayesianBinarySensor(BinarySensorEntity):
|
||||
"""Representation of a Bayesian sensor."""
|
||||
|
||||
@@ -307,7 +248,6 @@ class BayesianBinarySensor(BinarySensorEntity):
|
||||
"""Initialize the Bayesian sensor."""
|
||||
self._attr_name = name
|
||||
self._attr_unique_id = unique_id and f"bayesian-{unique_id}"
|
||||
|
||||
self._observations = [
|
||||
Observation(
|
||||
entity_id=observation.get(CONF_ENTITY_ID),
|
||||
@@ -492,7 +432,7 @@ class BayesianBinarySensor(BinarySensorEntity):
|
||||
1 - observation.prob_given_false,
|
||||
)
|
||||
continue
|
||||
# Entity exists but observation.observed is None
|
||||
# observation.observed is None
|
||||
if observation.entity_id is not None:
|
||||
_LOGGER.debug(
|
||||
(
|
||||
@@ -555,10 +495,7 @@ class BayesianBinarySensor(BinarySensorEntity):
|
||||
for observation in self._observations:
|
||||
if observation.value_template is None:
|
||||
continue
|
||||
if isinstance(observation.value_template, str):
|
||||
observation.value_template = Template(
|
||||
observation.value_template, hass=self.hass
|
||||
)
|
||||
|
||||
template = observation.value_template
|
||||
observations_by_template.setdefault(template, []).append(observation)
|
||||
|
||||
|
||||
@@ -1,646 +0,0 @@
|
||||
"""Config flow for the Bayesian integration."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from enum import StrEnum
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.alarm_control_panel import DOMAIN as ALARM_DOMAIN
|
||||
from homeassistant.components.binary_sensor import (
|
||||
DOMAIN as BINARY_SENSOR_DOMAIN,
|
||||
BinarySensorDeviceClass,
|
||||
)
|
||||
from homeassistant.components.calendar import DOMAIN as CALENDAR_DOMAIN
|
||||
from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN
|
||||
from homeassistant.components.cover import DOMAIN as COVER_DOMAIN
|
||||
from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN
|
||||
from homeassistant.components.input_boolean import DOMAIN as INPUT_BOOLEAN_DOMAIN
|
||||
from homeassistant.components.input_number import DOMAIN as INPUT_NUMBER_DOMAIN
|
||||
from homeassistant.components.input_text import DOMAIN as INPUT_TEXT_DOMAIN
|
||||
from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
|
||||
from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN
|
||||
from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN
|
||||
from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN
|
||||
from homeassistant.components.person import DOMAIN as PERSON_DOMAIN
|
||||
from homeassistant.components.select import DOMAIN as SELECT_DOMAIN
|
||||
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
|
||||
from homeassistant.components.sun import DOMAIN as SUN_DOMAIN
|
||||
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
|
||||
from homeassistant.components.todo import DOMAIN as TODO_DOMAIN
|
||||
from homeassistant.components.update import DOMAIN as UPDATE_DOMAIN
|
||||
from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN
|
||||
from homeassistant.components.zone import DOMAIN as ZONE_DOMAIN
|
||||
from homeassistant.config_entries import (
|
||||
ConfigEntry,
|
||||
ConfigFlowResult,
|
||||
ConfigSubentry,
|
||||
ConfigSubentryData,
|
||||
ConfigSubentryFlow,
|
||||
SubentryFlowResult,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONF_ABOVE,
|
||||
CONF_BELOW,
|
||||
CONF_DEVICE_CLASS,
|
||||
CONF_ENTITY_ID,
|
||||
CONF_NAME,
|
||||
CONF_PLATFORM,
|
||||
CONF_STATE,
|
||||
CONF_VALUE_TEMPLATE,
|
||||
)
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers import selector, translation
|
||||
from homeassistant.helpers.schema_config_entry_flow import (
|
||||
SchemaCommonFlowHandler,
|
||||
SchemaConfigFlowHandler,
|
||||
SchemaFlowError,
|
||||
SchemaFlowFormStep,
|
||||
SchemaFlowMenuStep,
|
||||
)
|
||||
|
||||
from .binary_sensor import above_greater_than_below, no_overlapping
|
||||
from .const import (
|
||||
CONF_OBSERVATIONS,
|
||||
CONF_P_GIVEN_F,
|
||||
CONF_P_GIVEN_T,
|
||||
CONF_PRIOR,
|
||||
CONF_PROBABILITY_THRESHOLD,
|
||||
CONF_TEMPLATE,
|
||||
CONF_TO_STATE,
|
||||
DEFAULT_NAME,
|
||||
DEFAULT_PROBABILITY_THRESHOLD,
|
||||
DOMAIN,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
USER = "user"
|
||||
OBSERVATION_SELECTOR = "observation_selector"
|
||||
ALLOWED_STATE_DOMAINS = [
|
||||
ALARM_DOMAIN,
|
||||
BINARY_SENSOR_DOMAIN,
|
||||
CALENDAR_DOMAIN,
|
||||
CLIMATE_DOMAIN,
|
||||
COVER_DOMAIN,
|
||||
DEVICE_TRACKER_DOMAIN,
|
||||
INPUT_BOOLEAN_DOMAIN,
|
||||
INPUT_NUMBER_DOMAIN,
|
||||
INPUT_TEXT_DOMAIN,
|
||||
LIGHT_DOMAIN,
|
||||
MEDIA_PLAYER_DOMAIN,
|
||||
NOTIFY_DOMAIN,
|
||||
NUMBER_DOMAIN,
|
||||
PERSON_DOMAIN,
|
||||
"schedule", # Avoids an import that would introduce a dependency.
|
||||
SELECT_DOMAIN,
|
||||
SENSOR_DOMAIN,
|
||||
SUN_DOMAIN,
|
||||
SWITCH_DOMAIN,
|
||||
TODO_DOMAIN,
|
||||
UPDATE_DOMAIN,
|
||||
WEATHER_DOMAIN,
|
||||
]
|
||||
ALLOWED_NUMERIC_DOMAINS = [
|
||||
SENSOR_DOMAIN,
|
||||
INPUT_NUMBER_DOMAIN,
|
||||
NUMBER_DOMAIN,
|
||||
TODO_DOMAIN,
|
||||
ZONE_DOMAIN,
|
||||
]
|
||||
|
||||
|
||||
class ObservationTypes(StrEnum):
|
||||
"""StrEnum for all the different observation types."""
|
||||
|
||||
STATE = CONF_STATE
|
||||
NUMERIC_STATE = "numeric_state"
|
||||
TEMPLATE = CONF_TEMPLATE
|
||||
|
||||
|
||||
class OptionsFlowSteps(StrEnum):
|
||||
"""StrEnum for all the different options flow steps."""
|
||||
|
||||
INIT = "init"
|
||||
ADD_OBSERVATION = OBSERVATION_SELECTOR
|
||||
|
||||
|
||||
OPTIONS_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_PROBABILITY_THRESHOLD, default=DEFAULT_PROBABILITY_THRESHOLD * 100
|
||||
): vol.All(
|
||||
selector.NumberSelector(
|
||||
selector.NumberSelectorConfig(
|
||||
mode=selector.NumberSelectorMode.SLIDER,
|
||||
step=1.0,
|
||||
min=0,
|
||||
max=100,
|
||||
unit_of_measurement="%",
|
||||
),
|
||||
),
|
||||
vol.Range(
|
||||
min=0,
|
||||
max=100,
|
||||
min_included=False,
|
||||
max_included=False,
|
||||
msg="extreme_threshold_error",
|
||||
),
|
||||
),
|
||||
vol.Required(CONF_PRIOR, default=DEFAULT_PROBABILITY_THRESHOLD * 100): vol.All(
|
||||
selector.NumberSelector(
|
||||
selector.NumberSelectorConfig(
|
||||
mode=selector.NumberSelectorMode.SLIDER,
|
||||
step=1.0,
|
||||
min=0,
|
||||
max=100,
|
||||
unit_of_measurement="%",
|
||||
),
|
||||
),
|
||||
vol.Range(
|
||||
min=0,
|
||||
max=100,
|
||||
min_included=False,
|
||||
max_included=False,
|
||||
msg="extreme_prior_error",
|
||||
),
|
||||
),
|
||||
vol.Optional(CONF_DEVICE_CLASS): selector.SelectSelector(
|
||||
selector.SelectSelectorConfig(
|
||||
options=[cls.value for cls in BinarySensorDeviceClass],
|
||||
mode=selector.SelectSelectorMode.DROPDOWN,
|
||||
translation_key="binary_sensor_device_class",
|
||||
sort=True,
|
||||
),
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_NAME, default=DEFAULT_NAME): selector.TextSelector(),
|
||||
}
|
||||
).extend(OPTIONS_SCHEMA.schema)
|
||||
|
||||
OBSERVATION_BOILERPLATE = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_P_GIVEN_T): vol.All(
|
||||
selector.NumberSelector(
|
||||
selector.NumberSelectorConfig(
|
||||
mode=selector.NumberSelectorMode.SLIDER,
|
||||
step=1.0,
|
||||
min=0,
|
||||
max=100,
|
||||
unit_of_measurement="%",
|
||||
),
|
||||
),
|
||||
vol.Range(
|
||||
min=0,
|
||||
max=100,
|
||||
min_included=False,
|
||||
max_included=False,
|
||||
msg="extreme_prob_given_error",
|
||||
),
|
||||
),
|
||||
vol.Required(CONF_P_GIVEN_F): vol.All(
|
||||
selector.NumberSelector(
|
||||
selector.NumberSelectorConfig(
|
||||
mode=selector.NumberSelectorMode.SLIDER,
|
||||
step=1.0,
|
||||
min=0,
|
||||
max=100,
|
||||
unit_of_measurement="%",
|
||||
),
|
||||
),
|
||||
vol.Range(
|
||||
min=0,
|
||||
max=100,
|
||||
min_included=False,
|
||||
max_included=False,
|
||||
msg="extreme_prob_given_error",
|
||||
),
|
||||
),
|
||||
vol.Required(CONF_NAME): selector.TextSelector(),
|
||||
}
|
||||
)
|
||||
|
||||
STATE_SUBSCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_ENTITY_ID): selector.EntitySelector(
|
||||
selector.EntitySelectorConfig(domain=ALLOWED_STATE_DOMAINS)
|
||||
),
|
||||
vol.Required(CONF_TO_STATE): selector.TextSelector(
|
||||
selector.TextSelectorConfig(
|
||||
multiline=False, type=selector.TextSelectorType.TEXT, multiple=False
|
||||
) # ideally this would be a state selector context-linked to the above entity.
|
||||
),
|
||||
},
|
||||
).extend(OBSERVATION_BOILERPLATE.schema)
|
||||
|
||||
NUMERIC_STATE_SUBSCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_ENTITY_ID): selector.EntitySelector(
|
||||
selector.EntitySelectorConfig(domain=ALLOWED_NUMERIC_DOMAINS)
|
||||
),
|
||||
vol.Optional(CONF_ABOVE): selector.NumberSelector(
|
||||
selector.NumberSelectorConfig(
|
||||
mode=selector.NumberSelectorMode.BOX, step="any"
|
||||
),
|
||||
),
|
||||
vol.Optional(CONF_BELOW): selector.NumberSelector(
|
||||
selector.NumberSelectorConfig(
|
||||
mode=selector.NumberSelectorMode.BOX, step="any"
|
||||
),
|
||||
),
|
||||
},
|
||||
).extend(OBSERVATION_BOILERPLATE.schema)
|
||||
|
||||
|
||||
TEMPLATE_SUBSCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_VALUE_TEMPLATE): selector.TemplateSelector(
|
||||
selector.TemplateSelectorConfig(),
|
||||
),
|
||||
},
|
||||
).extend(OBSERVATION_BOILERPLATE.schema)
|
||||
|
||||
|
||||
def _convert_percentages_to_fractions(
|
||||
data: dict[str, str | float | int],
|
||||
) -> dict[str, str | float]:
|
||||
"""Convert percentage probability values in a dictionary to fractions for storing in the config entry."""
|
||||
probabilities = [
|
||||
CONF_P_GIVEN_T,
|
||||
CONF_P_GIVEN_F,
|
||||
CONF_PRIOR,
|
||||
CONF_PROBABILITY_THRESHOLD,
|
||||
]
|
||||
return {
|
||||
key: (
|
||||
value / 100
|
||||
if isinstance(value, (int, float)) and key in probabilities
|
||||
else value
|
||||
)
|
||||
for key, value in data.items()
|
||||
}
|
||||
|
||||
|
||||
def _convert_fractions_to_percentages(
|
||||
data: dict[str, str | float],
|
||||
) -> dict[str, str | float]:
|
||||
"""Convert fraction probability values in a dictionary to percentages for loading into the UI."""
|
||||
probabilities = [
|
||||
CONF_P_GIVEN_T,
|
||||
CONF_P_GIVEN_F,
|
||||
CONF_PRIOR,
|
||||
CONF_PROBABILITY_THRESHOLD,
|
||||
]
|
||||
return {
|
||||
key: (
|
||||
value * 100
|
||||
if isinstance(value, (int, float)) and key in probabilities
|
||||
else value
|
||||
)
|
||||
for key, value in data.items()
|
||||
}
|
||||
|
||||
|
||||
def _select_observation_schema(
|
||||
obs_type: ObservationTypes,
|
||||
) -> vol.Schema:
|
||||
"""Return the schema for editing the correct observation (SubEntry) type."""
|
||||
if obs_type == str(ObservationTypes.STATE):
|
||||
return STATE_SUBSCHEMA
|
||||
if obs_type == str(ObservationTypes.NUMERIC_STATE):
|
||||
return NUMERIC_STATE_SUBSCHEMA
|
||||
|
||||
return TEMPLATE_SUBSCHEMA
|
||||
|
||||
|
||||
async def _get_base_suggested_values(
|
||||
handler: SchemaCommonFlowHandler,
|
||||
) -> dict[str, Any]:
|
||||
"""Return suggested values for the base sensor options."""
|
||||
|
||||
return _convert_fractions_to_percentages(dict(handler.options))
|
||||
|
||||
|
||||
def _get_observation_values_for_editing(
|
||||
subentry: ConfigSubentry,
|
||||
) -> dict[str, Any]:
|
||||
"""Return the values for editing in the observation subentry."""
|
||||
|
||||
return _convert_fractions_to_percentages(dict(subentry.data))
|
||||
|
||||
|
||||
async def _validate_user(
|
||||
handler: SchemaCommonFlowHandler, user_input: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Modify user input to convert to fractions for storage. Validation is done entirely by the schemas."""
|
||||
user_input = _convert_percentages_to_fractions(user_input)
|
||||
return {**user_input}
|
||||
|
||||
|
||||
def _validate_observation_subentry(
|
||||
obs_type: ObservationTypes,
|
||||
user_input: dict[str, Any],
|
||||
other_subentries: list[dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Validate an observation input and manually update options with observations as they are nested items."""
|
||||
|
||||
if user_input[CONF_P_GIVEN_T] == user_input[CONF_P_GIVEN_F]:
|
||||
raise SchemaFlowError("equal_probabilities")
|
||||
user_input = _convert_percentages_to_fractions(user_input)
|
||||
|
||||
# Save the observation type in the user input as it is needed in binary_sensor.py
|
||||
user_input[CONF_PLATFORM] = str(obs_type)
|
||||
|
||||
# Additional validation for multiple numeric state observations
|
||||
if (
|
||||
user_input[CONF_PLATFORM] == ObservationTypes.NUMERIC_STATE
|
||||
and other_subentries is not None
|
||||
):
|
||||
_LOGGER.debug(
|
||||
"Comparing with other subentries: %s", [*other_subentries, user_input]
|
||||
)
|
||||
try:
|
||||
above_greater_than_below(user_input)
|
||||
no_overlapping([*other_subentries, user_input])
|
||||
except vol.Invalid as err:
|
||||
raise SchemaFlowError(err) from err
|
||||
|
||||
_LOGGER.debug("Processed observation with settings: %s", user_input)
|
||||
return user_input
|
||||
|
||||
|
||||
async def _validate_subentry_from_config_entry(
|
||||
handler: SchemaCommonFlowHandler, user_input: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
# Standard behavior is to merge the result with the options.
|
||||
# In this case, we want to add a subentry so we update the options directly.
|
||||
observations: list[dict[str, Any]] = handler.options.setdefault(
|
||||
CONF_OBSERVATIONS, []
|
||||
)
|
||||
|
||||
if handler.parent_handler.cur_step is not None:
|
||||
user_input[CONF_PLATFORM] = handler.parent_handler.cur_step["step_id"]
|
||||
user_input = _validate_observation_subentry(
|
||||
user_input[CONF_PLATFORM],
|
||||
user_input,
|
||||
other_subentries=handler.options[CONF_OBSERVATIONS],
|
||||
)
|
||||
observations.append(user_input)
|
||||
return {}
|
||||
|
||||
|
||||
async def _get_description_placeholders(
|
||||
handler: SchemaCommonFlowHandler,
|
||||
) -> dict[str, str]:
|
||||
# Current step is None when were are about to start the first step
|
||||
if handler.parent_handler.cur_step is None:
|
||||
return {"url": "https://www.home-assistant.io/integrations/bayesian/"}
|
||||
return {
|
||||
"parent_sensor_name": handler.options[CONF_NAME],
|
||||
"device_class_on": translation.async_translate_state(
|
||||
handler.parent_handler.hass,
|
||||
"on",
|
||||
BINARY_SENSOR_DOMAIN,
|
||||
platform=None,
|
||||
translation_key=None,
|
||||
device_class=handler.options.get(CONF_DEVICE_CLASS, None),
|
||||
),
|
||||
"device_class_off": translation.async_translate_state(
|
||||
handler.parent_handler.hass,
|
||||
"off",
|
||||
BINARY_SENSOR_DOMAIN,
|
||||
platform=None,
|
||||
translation_key=None,
|
||||
device_class=handler.options.get(CONF_DEVICE_CLASS, None),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def _get_observation_menu_options(handler: SchemaCommonFlowHandler) -> list[str]:
|
||||
"""Return the menu options for the observation selector."""
|
||||
options = [typ.value for typ in ObservationTypes]
|
||||
if handler.options.get(CONF_OBSERVATIONS):
|
||||
options.append("finish")
|
||||
return options
|
||||
|
||||
|
||||
CONFIG_FLOW: dict[str, SchemaFlowMenuStep | SchemaFlowFormStep] = {
|
||||
str(USER): SchemaFlowFormStep(
|
||||
CONFIG_SCHEMA,
|
||||
validate_user_input=_validate_user,
|
||||
next_step=str(OBSERVATION_SELECTOR),
|
||||
description_placeholders=_get_description_placeholders,
|
||||
),
|
||||
str(OBSERVATION_SELECTOR): SchemaFlowMenuStep(
|
||||
_get_observation_menu_options,
|
||||
),
|
||||
str(ObservationTypes.STATE): SchemaFlowFormStep(
|
||||
STATE_SUBSCHEMA,
|
||||
next_step=str(OBSERVATION_SELECTOR),
|
||||
validate_user_input=_validate_subentry_from_config_entry,
|
||||
# Prevent the name of the bayesian sensor from being used as the suggested
|
||||
# name of the observations
|
||||
suggested_values=None,
|
||||
description_placeholders=_get_description_placeholders,
|
||||
),
|
||||
str(ObservationTypes.NUMERIC_STATE): SchemaFlowFormStep(
|
||||
NUMERIC_STATE_SUBSCHEMA,
|
||||
next_step=str(OBSERVATION_SELECTOR),
|
||||
validate_user_input=_validate_subentry_from_config_entry,
|
||||
suggested_values=None,
|
||||
description_placeholders=_get_description_placeholders,
|
||||
),
|
||||
str(ObservationTypes.TEMPLATE): SchemaFlowFormStep(
|
||||
TEMPLATE_SUBSCHEMA,
|
||||
next_step=str(OBSERVATION_SELECTOR),
|
||||
validate_user_input=_validate_subentry_from_config_entry,
|
||||
suggested_values=None,
|
||||
description_placeholders=_get_description_placeholders,
|
||||
),
|
||||
"finish": SchemaFlowFormStep(),
|
||||
}
|
||||
|
||||
|
||||
OPTIONS_FLOW: dict[str, SchemaFlowMenuStep | SchemaFlowFormStep] = {
|
||||
str(OptionsFlowSteps.INIT): SchemaFlowFormStep(
|
||||
OPTIONS_SCHEMA,
|
||||
suggested_values=_get_base_suggested_values,
|
||||
validate_user_input=_validate_user,
|
||||
description_placeholders=_get_description_placeholders,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class BayesianConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
|
||||
"""Bayesian config flow."""
|
||||
|
||||
VERSION = 1
|
||||
MINOR_VERSION = 1
|
||||
|
||||
config_flow = CONFIG_FLOW
|
||||
options_flow = OPTIONS_FLOW
|
||||
|
||||
@classmethod
|
||||
@callback
|
||||
def async_get_supported_subentry_types(
|
||||
cls, config_entry: ConfigEntry
|
||||
) -> dict[str, type[ConfigSubentryFlow]]:
|
||||
"""Return subentries supported by this integration."""
|
||||
return {"observation": ObservationSubentryFlowHandler}
|
||||
|
||||
def async_config_entry_title(self, options: Mapping[str, str]) -> str:
|
||||
"""Return config entry title."""
|
||||
name: str = options[CONF_NAME]
|
||||
return name
|
||||
|
||||
@callback
|
||||
def async_create_entry(
|
||||
self,
|
||||
data: Mapping[str, Any],
|
||||
**kwargs: Any,
|
||||
) -> ConfigFlowResult:
|
||||
"""Finish config flow and create a config entry."""
|
||||
data = dict(data)
|
||||
observations = data.pop(CONF_OBSERVATIONS)
|
||||
subentries: list[ConfigSubentryData] = [
|
||||
ConfigSubentryData(
|
||||
data=observation,
|
||||
title=observation[CONF_NAME],
|
||||
subentry_type="observation",
|
||||
unique_id=None,
|
||||
)
|
||||
for observation in observations
|
||||
]
|
||||
|
||||
self.async_config_flow_finished(data)
|
||||
return super().async_create_entry(data=data, subentries=subentries, **kwargs)
|
||||
|
||||
|
||||
class ObservationSubentryFlowHandler(ConfigSubentryFlow):
|
||||
"""Handle subentry flow for adding and modifying a topic."""
|
||||
|
||||
async def step_common(
|
||||
self,
|
||||
user_input: dict[str, Any] | None,
|
||||
obs_type: ObservationTypes,
|
||||
reconfiguring: bool = False,
|
||||
) -> SubentryFlowResult:
|
||||
"""Use common logic within the named steps."""
|
||||
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
other_subentries = None
|
||||
if obs_type == str(ObservationTypes.NUMERIC_STATE):
|
||||
other_subentries = [
|
||||
dict(se.data) for se in self._get_entry().subentries.values()
|
||||
]
|
||||
# If we are reconfiguring a subentry we don't want to compare with self
|
||||
if reconfiguring:
|
||||
sub_entry = self._get_reconfigure_subentry()
|
||||
if other_subentries is not None:
|
||||
other_subentries.remove(dict(sub_entry.data))
|
||||
|
||||
if user_input is not None:
|
||||
try:
|
||||
user_input = _validate_observation_subentry(
|
||||
obs_type,
|
||||
user_input,
|
||||
other_subentries=other_subentries,
|
||||
)
|
||||
if reconfiguring:
|
||||
return self.async_update_and_abort(
|
||||
self._get_entry(),
|
||||
sub_entry,
|
||||
title=user_input.get(CONF_NAME, sub_entry.data[CONF_NAME]),
|
||||
data_updates=user_input,
|
||||
)
|
||||
return self.async_create_entry(
|
||||
title=user_input.get(CONF_NAME),
|
||||
data=user_input,
|
||||
)
|
||||
except SchemaFlowError as err:
|
||||
errors["base"] = str(err)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reconfigure" if reconfiguring else str(obs_type),
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
data_schema=_select_observation_schema(obs_type),
|
||||
suggested_values=_get_observation_values_for_editing(sub_entry)
|
||||
if reconfiguring
|
||||
else None,
|
||||
),
|
||||
errors=errors,
|
||||
description_placeholders={
|
||||
"parent_sensor_name": self._get_entry().title,
|
||||
"device_class_on": translation.async_translate_state(
|
||||
self.hass,
|
||||
"on",
|
||||
BINARY_SENSOR_DOMAIN,
|
||||
platform=None,
|
||||
translation_key=None,
|
||||
device_class=self._get_entry().options.get(CONF_DEVICE_CLASS, None),
|
||||
),
|
||||
"device_class_off": translation.async_translate_state(
|
||||
self.hass,
|
||||
"off",
|
||||
BINARY_SENSOR_DOMAIN,
|
||||
platform=None,
|
||||
translation_key=None,
|
||||
device_class=self._get_entry().options.get(CONF_DEVICE_CLASS, None),
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> SubentryFlowResult:
|
||||
"""User flow to add a new observation."""
|
||||
|
||||
return self.async_show_menu(
|
||||
step_id="user",
|
||||
menu_options=[typ.value for typ in ObservationTypes],
|
||||
)
|
||||
|
||||
async def async_step_state(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> SubentryFlowResult:
|
||||
"""User flow to add a state observation. Function name must be in the format async_step_{observation_type}."""
|
||||
|
||||
return await self.step_common(
|
||||
user_input=user_input, obs_type=ObservationTypes.STATE
|
||||
)
|
||||
|
||||
async def async_step_numeric_state(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> SubentryFlowResult:
|
||||
"""User flow to add a new numeric state observation, (a numeric range). Function name must be in the format async_step_{observation_type}."""
|
||||
|
||||
return await self.step_common(
|
||||
user_input=user_input, obs_type=ObservationTypes.NUMERIC_STATE
|
||||
)
|
||||
|
||||
async def async_step_template(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> SubentryFlowResult:
|
||||
"""User flow to add a new template observation. Function name must be in the format async_step_{observation_type}."""
|
||||
|
||||
return await self.step_common(
|
||||
user_input=user_input, obs_type=ObservationTypes.TEMPLATE
|
||||
)
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> SubentryFlowResult:
|
||||
"""Enable the reconfigure button for observations. Function name must be async_step_reconfigure to be recognised by hass."""
|
||||
|
||||
sub_entry = self._get_reconfigure_subentry()
|
||||
|
||||
return await self.step_common(
|
||||
user_input=user_input,
|
||||
obs_type=ObservationTypes(sub_entry.data[CONF_PLATFORM]),
|
||||
reconfiguring=True,
|
||||
)
|
||||
@@ -1,9 +1,5 @@
|
||||
"""Consts for using in modules."""
|
||||
|
||||
from homeassistant.const import Platform
|
||||
|
||||
DOMAIN = "bayesian"
|
||||
PLATFORMS = [Platform.BINARY_SENSOR]
|
||||
ATTR_OBSERVATIONS = "observations"
|
||||
ATTR_OCCURRED_OBSERVATION_ENTITIES = "occurred_observation_entities"
|
||||
ATTR_PROBABILITY = "probability"
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
|
||||
from .const import DOMAIN
|
||||
from . import DOMAIN
|
||||
from .helpers import Observation
|
||||
|
||||
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
"domain": "bayesian",
|
||||
"name": "Bayesian",
|
||||
"codeowners": ["@HarvsG"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/bayesian",
|
||||
"integration_type": "service",
|
||||
"iot_class": "calculated",
|
||||
"integration_type": "helper",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "internal"
|
||||
}
|
||||
|
||||
@@ -14,264 +14,5 @@
|
||||
"name": "[%key:common::action::reload%]",
|
||||
"description": "Reloads Bayesian sensors from the YAML-configuration."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"error": {
|
||||
"extreme_prior_error": "[%key:component::bayesian::config::error::extreme_prior_error%]",
|
||||
"extreme_threshold_error": "[%key:component::bayesian::config::error::extreme_threshold_error%]",
|
||||
"equal_probabilities": "[%key:component::bayesian::config::error::equal_probabilities%]",
|
||||
"extreme_prob_given_error": "[%key:component::bayesian::config::error::extreme_prob_given_error%]"
|
||||
},
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Sensor options",
|
||||
"description": "These options affect how much evidence is required for the Bayesian sensor to be considered 'on'.",
|
||||
"data": {
|
||||
"probability_threshold": "[%key:component::bayesian::config::step::user::data::probability_threshold%]",
|
||||
"prior": "[%key:component::bayesian::config::step::user::data::prior%]",
|
||||
"device_class": "[%key:component::bayesian::config::step::user::data::device_class%]",
|
||||
"name": "[%key:common::config_flow::data::name%]"
|
||||
},
|
||||
"data_description": {
|
||||
"probability_threshold": "[%key:component::bayesian::config::step::user::data_description::probability_threshold%]",
|
||||
"prior": "[%key:component::bayesian::config::step::user::data_description::prior%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"error": {
|
||||
"extreme_prior_error": "'Prior' set to 0% means that it is impossible for the sensor to show 'on' and 100% means it will never show 'off', use a close number like 0.1% or 99.9% instead",
|
||||
"extreme_threshold_error": "'Probability threshold' set to 0% means that the sensor will always be 'on' and 100% mean it will always be 'off', use a close number like 0.1% or 99.9% instead",
|
||||
"equal_probabilities": "If 'Probability given true' and 'Probability given false' are equal, this observation can have no effect, and is therefore redundant",
|
||||
"extreme_prob_given_error": "If either 'Probability given false' or 'Probability given true' is 0 or 100 this will create certainties that override all other observations, use numbers close to 0 or 100 instead",
|
||||
"above_below": "Invalid range: 'Above' must be less than 'Below' when both are set.",
|
||||
"above_or_below": "Invalid range: At least one of 'Above' or 'Below' must be set.",
|
||||
"overlapping_ranges": "Invalid range: The 'Above' and 'Below' values overlap with another observation for the same entity."
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Add a Bayesian sensor",
|
||||
"description": "Create a binary sensor which observes the state of multiple sensors to estimate whether an event is occurring, or if something is true. See [the documentation]({url}) for more details.",
|
||||
"data": {
|
||||
"probability_threshold": "Probability threshold",
|
||||
"prior": "Prior",
|
||||
"device_class": "Device class",
|
||||
"name": "[%key:common::config_flow::data::name%]"
|
||||
},
|
||||
"data_description": {
|
||||
"probability_threshold": "The probability above which the sensor will show as 'on'. 50% should produce the most accurate result. Use numbers greater than 50% if avoiding false positives is important, or vice-versa.",
|
||||
"prior": "The baseline probability the sensor should be 'on', this is usually the percentage of time it is true. For example, for a sensor 'Everyone sleeping' it might be 8 hours a day, 33%.",
|
||||
"device_class": "Choose the device class you would like the sensor to show as."
|
||||
}
|
||||
},
|
||||
"observation_selector": {
|
||||
"title": "[%key:component::bayesian::config_subentries::observation::step::user::title%]",
|
||||
"description": "[%key:component::bayesian::config_subentries::observation::step::user::description%]",
|
||||
"menu_options": {
|
||||
"state": "[%key:component::bayesian::config_subentries::observation::step::user::menu_options::state%]",
|
||||
"numeric_state": "[%key:component::bayesian::config_subentries::observation::step::user::menu_options::numeric_state%]",
|
||||
"template": "[%key:component::bayesian::config_subentries::observation::step::user::menu_options::template%]",
|
||||
"finish": "Finish"
|
||||
}
|
||||
},
|
||||
"state": {
|
||||
"title": "[%key:component::bayesian::config_subentries::observation::step::state::title%]",
|
||||
"description": "[%key:component::bayesian::config_subentries::observation::step::state::description%]",
|
||||
|
||||
"data": {
|
||||
"name": "[%key:common::config_flow::data::name%]",
|
||||
"entity_id": "[%key:component::bayesian::config_subentries::observation::step::state::data::entity_id%]",
|
||||
"to_state": "[%key:component::bayesian::config_subentries::observation::step::state::data::to_state%]",
|
||||
"prob_given_true": "[%key:component::bayesian::config_subentries::observation::step::state::data::prob_given_true%]",
|
||||
"prob_given_false": "[%key:component::bayesian::config_subentries::observation::step::state::data::prob_given_false%]"
|
||||
},
|
||||
"data_description": {
|
||||
"name": "[%key:component::bayesian::config_subentries::observation::step::state::data_description::name%]",
|
||||
"entity_id": "[%key:component::bayesian::config_subentries::observation::step::state::data_description::entity_id%]",
|
||||
"to_state": "[%key:component::bayesian::config_subentries::observation::step::state::data_description::to_state%]",
|
||||
"prob_given_true": "[%key:component::bayesian::config_subentries::observation::step::state::data_description::prob_given_true%]",
|
||||
"prob_given_false": "[%key:component::bayesian::config_subentries::observation::step::state::data_description::prob_given_false%]"
|
||||
}
|
||||
},
|
||||
"numeric_state": {
|
||||
"title": "[%key:component::bayesian::config_subentries::observation::step::state::title%]",
|
||||
"description": "[%key:component::bayesian::config_subentries::observation::step::state::description%]",
|
||||
"data": {
|
||||
"name": "[%key:common::config_flow::data::name%]",
|
||||
"entity_id": "[%key:component::bayesian::config_subentries::observation::step::state::data::entity_id%]",
|
||||
"above": "[%key:component::bayesian::config_subentries::observation::step::numeric_state::data::above%]",
|
||||
"below": "[%key:component::bayesian::config_subentries::observation::step::numeric_state::data::below%]",
|
||||
"prob_given_true": "[%key:component::bayesian::config_subentries::observation::step::state::data::prob_given_true%]",
|
||||
"prob_given_false": "[%key:component::bayesian::config_subentries::observation::step::state::data::prob_given_false%]"
|
||||
},
|
||||
"data_description": {
|
||||
"name": "[%key:component::bayesian::config_subentries::observation::step::state::data_description::name%]",
|
||||
"entity_id": "[%key:component::bayesian::config_subentries::observation::step::state::data_description::entity_id%]",
|
||||
"above": "[%key:component::bayesian::config_subentries::observation::step::numeric_state::data_description::above%]",
|
||||
"below": "[%key:component::bayesian::config_subentries::observation::step::numeric_state::data_description::below%]",
|
||||
"prob_given_true": "[%key:component::bayesian::config_subentries::observation::step::state::data_description::prob_given_true%]",
|
||||
"prob_given_false": "[%key:component::bayesian::config_subentries::observation::step::state::data_description::prob_given_false%]"
|
||||
}
|
||||
},
|
||||
"template": {
|
||||
"title": "[%key:component::bayesian::config_subentries::observation::step::state::title%]",
|
||||
"description": "[%key:component::bayesian::config_subentries::observation::step::template::description%]",
|
||||
"data": {
|
||||
"name": "[%key:common::config_flow::data::name%]",
|
||||
"value_template": "[%key:component::bayesian::config_subentries::observation::step::template::data::value_template%]",
|
||||
"prob_given_true": "[%key:component::bayesian::config_subentries::observation::step::state::data::prob_given_true%]",
|
||||
"prob_given_false": "[%key:component::bayesian::config_subentries::observation::step::state::data::prob_given_false%]"
|
||||
},
|
||||
"data_description": {
|
||||
"name": "[%key:component::bayesian::config_subentries::observation::step::state::data_description::name%]",
|
||||
"value_template": "[%key:component::bayesian::config_subentries::observation::step::template::data_description::value_template%]",
|
||||
"prob_given_true": "[%key:component::bayesian::config_subentries::observation::step::state::data_description::prob_given_true%]",
|
||||
"prob_given_false": "[%key:component::bayesian::config_subentries::observation::step::state::data_description::prob_given_false%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"config_subentries": {
|
||||
"observation": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Add an observation",
|
||||
"description": "'Observations' are the sensor or template values that are monitored and then combined in order to inform the Bayesian sensor's final probability. Each observation will update the probability of the Bayesian sensor if it is detected, or if it is not detected. If the state of the entity becomes `unavailable` or `unknown` it will be ignored. If more than one state or more than one numeric range is configured for the same entity then inverse detections will be ignored.",
|
||||
"menu_options": {
|
||||
"state": "Add an observation for a sensor's state",
|
||||
"numeric_state": "Add an observation for a numeric range",
|
||||
"template": "Add an observation for a template"
|
||||
}
|
||||
},
|
||||
"state": {
|
||||
"title": "Add a Bayesian sensor",
|
||||
"description": "Add an observation which evaluates to `True` when the value of the sensor exactly matches *'To state'*. When `False`, it will update the prior with probabilities that are the inverse of those set below. This behaviour can be overridden by adding observations for the same entity's other states.",
|
||||
|
||||
"data": {
|
||||
"name": "[%key:common::config_flow::data::name%]",
|
||||
"entity_id": "Entity",
|
||||
"to_state": "To state",
|
||||
"prob_given_true": "Probability when {parent_sensor_name} is {device_class_on}",
|
||||
"prob_given_false": "Probability when {parent_sensor_name} is {device_class_off}"
|
||||
},
|
||||
"data_description": {
|
||||
"name": "This name will be used for to identify this observation for editing in the future.",
|
||||
"entity_id": "An entity that is correlated with `{parent_sensor_name}`.",
|
||||
"to_state": "The state of the sensor for which the observation will be considered `True`.",
|
||||
"prob_given_true": "The estimated probability or proportion of time this observation is `True` while `{parent_sensor_name}` is, or should be, `{device_class_on}`.",
|
||||
"prob_given_false": "The estimated probability or proportion of time this observation is `True` while `{parent_sensor_name}` is, or should be, `{device_class_off}`."
|
||||
}
|
||||
},
|
||||
"numeric_state": {
|
||||
"title": "[%key:component::bayesian::config_subentries::observation::step::state::title%]",
|
||||
"description": "Add an observation which evaluates to `True` when a numeric sensor is within a chosen range.",
|
||||
"data": {
|
||||
"name": "[%key:common::config_flow::data::name%]",
|
||||
"entity_id": "[%key:component::bayesian::config_subentries::observation::step::state::data::entity_id%]",
|
||||
"above": "Above",
|
||||
"below": "Below",
|
||||
"prob_given_true": "[%key:component::bayesian::config_subentries::observation::step::state::data::prob_given_true%]",
|
||||
"prob_given_false": "[%key:component::bayesian::config_subentries::observation::step::state::data::prob_given_false%]"
|
||||
},
|
||||
"data_description": {
|
||||
"name": "[%key:component::bayesian::config_subentries::observation::step::state::data_description::name%]",
|
||||
"entity_id": "[%key:component::bayesian::config_subentries::observation::step::state::data_description::entity_id%]",
|
||||
"above": "Optional - the lower end of the numeric range. Values exactly matching this will not count",
|
||||
"below": "Optional - the upper end of the numeric range. Values exactly matching this will only count if more than one range is configured for the same entity.",
|
||||
"prob_given_true": "[%key:component::bayesian::config_subentries::observation::step::state::data_description::prob_given_true%]",
|
||||
"prob_given_false": "[%key:component::bayesian::config_subentries::observation::step::state::data_description::prob_given_false%]"
|
||||
}
|
||||
},
|
||||
"template": {
|
||||
"title": "[%key:component::bayesian::config_subentries::observation::step::state::title%]",
|
||||
"description": "Add a custom observation which evaluates whether a template is observed (`True`) or not (`False`).",
|
||||
"data": {
|
||||
"name": "[%key:common::config_flow::data::name%]",
|
||||
"value_template": "Template",
|
||||
"prob_given_true": "[%key:component::bayesian::config_subentries::observation::step::state::data::prob_given_true%]",
|
||||
"prob_given_false": "[%key:component::bayesian::config_subentries::observation::step::state::data::prob_given_false%]"
|
||||
},
|
||||
"data_description": {
|
||||
"name": "[%key:component::bayesian::config_subentries::observation::step::state::data_description::name%]",
|
||||
"value_template": "A template that evaluates to `True` will update the prior accordingly, A template that returns `False` or `None` will update the prior with inverse probabilities. A template that returns an error will not update probabilities. Results are coerced into being `True` or `False`",
|
||||
"prob_given_true": "[%key:component::bayesian::config_subentries::observation::step::state::data_description::prob_given_true%]",
|
||||
"prob_given_false": "[%key:component::bayesian::config_subentries::observation::step::state::data_description::prob_given_false%]"
|
||||
}
|
||||
},
|
||||
"reconfigure": {
|
||||
"title": "Edit observation",
|
||||
"description": "[%key:component::bayesian::config_subentries::observation::step::state::description%]",
|
||||
"data": {
|
||||
"name": "[%key:common::config_flow::data::name%]",
|
||||
"entity_id": "[%key:component::bayesian::config_subentries::observation::step::state::data::entity_id%]",
|
||||
"to_state": "[%key:component::bayesian::config_subentries::observation::step::state::data::to_state%]",
|
||||
"above": "[%key:component::bayesian::config_subentries::observation::step::numeric_state::data::above%]",
|
||||
"below": "[%key:component::bayesian::config_subentries::observation::step::numeric_state::data::below%]",
|
||||
"value_template": "[%key:component::bayesian::config_subentries::observation::step::template::data::value_template%]",
|
||||
"prob_given_true": "[%key:component::bayesian::config_subentries::observation::step::state::data::prob_given_true%]",
|
||||
"prob_given_false": "[%key:component::bayesian::config_subentries::observation::step::state::data::prob_given_false%]"
|
||||
},
|
||||
"data_description": {
|
||||
"name": "[%key:component::bayesian::config_subentries::observation::step::state::data_description::name%]",
|
||||
"entity_id": "[%key:component::bayesian::config_subentries::observation::step::state::data_description::entity_id%]",
|
||||
"to_state": "[%key:component::bayesian::config_subentries::observation::step::state::data_description::to_state%]",
|
||||
"above": "[%key:component::bayesian::config_subentries::observation::step::numeric_state::data_description::above%]",
|
||||
"below": "[%key:component::bayesian::config_subentries::observation::step::numeric_state::data_description::below%]",
|
||||
"value_template": "[%key:component::bayesian::config_subentries::observation::step::template::data_description::value_template%]",
|
||||
"prob_given_true": "[%key:component::bayesian::config_subentries::observation::step::state::data_description::prob_given_true%]",
|
||||
"prob_given_false": "[%key:component::bayesian::config_subentries::observation::step::state::data_description::prob_given_false%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"initiate_flow": {
|
||||
"user": "[%key:component::bayesian::config_subentries::observation::step::user::title%]"
|
||||
},
|
||||
"entry_type": "Observation",
|
||||
"error": {
|
||||
"equal_probabilities": "[%key:component::bayesian::config::error::equal_probabilities%]",
|
||||
"extreme_prob_given_error": "[%key:component::bayesian::config::error::extreme_prob_given_error%]",
|
||||
"above_below": "[%key:component::bayesian::config::error::above_below%]",
|
||||
"above_or_below": "[%key:component::bayesian::config::error::above_or_below%]",
|
||||
"overlapping_ranges": "[%key:component::bayesian::config::error::overlapping_ranges%]"
|
||||
},
|
||||
"abort": {
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"binary_sensor_device_class": {
|
||||
"options": {
|
||||
"battery": "[%key:component::binary_sensor::entity_component::battery::name%]",
|
||||
"battery_charging": "[%key:component::binary_sensor::entity_component::battery_charging::name%]",
|
||||
"carbon_monoxide": "[%key:component::binary_sensor::entity_component::carbon_monoxide::name%]",
|
||||
"cold": "[%key:component::binary_sensor::entity_component::cold::name%]",
|
||||
"connectivity": "[%key:component::binary_sensor::entity_component::connectivity::name%]",
|
||||
"door": "[%key:component::binary_sensor::entity_component::door::name%]",
|
||||
"garage_door": "[%key:component::binary_sensor::entity_component::garage_door::name%]",
|
||||
"gas": "[%key:component::binary_sensor::entity_component::gas::name%]",
|
||||
"heat": "[%key:component::binary_sensor::entity_component::heat::name%]",
|
||||
"light": "[%key:component::binary_sensor::entity_component::light::name%]",
|
||||
"lock": "[%key:component::binary_sensor::entity_component::lock::name%]",
|
||||
"moisture": "[%key:component::binary_sensor::entity_component::moisture::name%]",
|
||||
"motion": "[%key:component::binary_sensor::entity_component::motion::name%]",
|
||||
"moving": "[%key:component::binary_sensor::entity_component::moving::name%]",
|
||||
"occupancy": "[%key:component::binary_sensor::entity_component::occupancy::name%]",
|
||||
"opening": "[%key:component::binary_sensor::entity_component::opening::name%]",
|
||||
"plug": "[%key:component::binary_sensor::entity_component::plug::name%]",
|
||||
"power": "[%key:component::binary_sensor::entity_component::power::name%]",
|
||||
"presence": "[%key:component::binary_sensor::entity_component::presence::name%]",
|
||||
"problem": "[%key:component::binary_sensor::entity_component::problem::name%]",
|
||||
"running": "[%key:component::binary_sensor::entity_component::running::name%]",
|
||||
"safety": "[%key:component::binary_sensor::entity_component::safety::name%]",
|
||||
"smoke": "[%key:component::binary_sensor::entity_component::smoke::name%]",
|
||||
"sound": "[%key:component::binary_sensor::entity_component::sound::name%]",
|
||||
"tamper": "[%key:component::binary_sensor::entity_component::tamper::name%]",
|
||||
"update": "[%key:component::binary_sensor::entity_component::update::name%]",
|
||||
"vibration": "[%key:component::binary_sensor::entity_component::vibration::name%]",
|
||||
"window": "[%key:component::binary_sensor::entity_component::window::name%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,11 +16,11 @@
|
||||
"quality_scale": "internal",
|
||||
"requirements": [
|
||||
"bleak==1.0.1",
|
||||
"bleak-retry-connector==4.4.3",
|
||||
"bleak-retry-connector==4.0.2",
|
||||
"bluetooth-adapters==2.0.0",
|
||||
"bluetooth-auto-recovery==1.5.2",
|
||||
"bluetooth-data-tools==1.28.2",
|
||||
"dbus-fast==2.44.3",
|
||||
"habluetooth==5.2.0"
|
||||
"habluetooth==5.0.1"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -564,6 +564,13 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
This is used by cameras with CameraEntityFeature.STREAM
|
||||
and StreamType.HLS.
|
||||
"""
|
||||
# Check if camera has a go2rtc provider that can provide stream sources
|
||||
if (
|
||||
self._webrtc_provider
|
||||
and hasattr(self._webrtc_provider, 'async_get_stream_source')
|
||||
and self._webrtc_provider.domain == "go2rtc"
|
||||
):
|
||||
return await self._webrtc_provider.async_get_stream_source(self)
|
||||
return None
|
||||
|
||||
async def async_handle_async_webrtc_offer(
|
||||
|
||||
@@ -3,10 +3,9 @@
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from ccm15 import CCM15DeviceState, CCM15SlaveDevice
|
||||
from ccm15 import CCM15DeviceState
|
||||
|
||||
from homeassistant.components.climate import (
|
||||
ATTR_HVAC_MODE,
|
||||
FAN_AUTO,
|
||||
FAN_HIGH,
|
||||
FAN_LOW,
|
||||
@@ -89,7 +88,7 @@ class CCM15Climate(CoordinatorEntity[CCM15Coordinator], ClimateEntity):
|
||||
)
|
||||
|
||||
@property
|
||||
def data(self) -> CCM15SlaveDevice | None:
|
||||
def data(self) -> CCM15DeviceState | None:
|
||||
"""Return device data."""
|
||||
return self.coordinator.get_ac_data(self._ac_index)
|
||||
|
||||
@@ -145,17 +144,15 @@ class CCM15Climate(CoordinatorEntity[CCM15Coordinator], ClimateEntity):
|
||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||
"""Set the target temperature."""
|
||||
if (temperature := kwargs.get(ATTR_TEMPERATURE)) is not None:
|
||||
await self.coordinator.async_set_temperature(
|
||||
self._ac_index, self.data, temperature, kwargs.get(ATTR_HVAC_MODE)
|
||||
)
|
||||
await self.coordinator.async_set_temperature(self._ac_index, temperature)
|
||||
|
||||
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||
"""Set the hvac mode."""
|
||||
await self.coordinator.async_set_hvac_mode(self._ac_index, self.data, hvac_mode)
|
||||
await self.coordinator.async_set_hvac_mode(self._ac_index, hvac_mode)
|
||||
|
||||
async def async_set_fan_mode(self, fan_mode: str) -> None:
|
||||
"""Set the fan mode."""
|
||||
await self.coordinator.async_set_fan_mode(self._ac_index, self.data, fan_mode)
|
||||
await self.coordinator.async_set_fan_mode(self._ac_index, fan_mode)
|
||||
|
||||
async def async_turn_off(self) -> None:
|
||||
"""Turn off."""
|
||||
|
||||
@@ -55,9 +55,9 @@ class CCM15Coordinator(DataUpdateCoordinator[CCM15DeviceState]):
|
||||
"""Get the current status of all AC devices."""
|
||||
return await self._ccm15.get_status_async()
|
||||
|
||||
async def async_set_state(self, ac_index: int, data) -> None:
|
||||
async def async_set_state(self, ac_index: int, state: str, value: int) -> None:
|
||||
"""Set new target states."""
|
||||
if await self._ccm15.async_set_state(ac_index, data):
|
||||
if await self._ccm15.async_set_state(ac_index, state, value):
|
||||
await self.async_request_refresh()
|
||||
|
||||
def get_ac_data(self, ac_index: int) -> CCM15SlaveDevice | None:
|
||||
@@ -67,32 +67,17 @@ class CCM15Coordinator(DataUpdateCoordinator[CCM15DeviceState]):
|
||||
return None
|
||||
return self.data.devices[ac_index]
|
||||
|
||||
async def async_set_hvac_mode(
|
||||
self, ac_index: int, data: CCM15SlaveDevice, hvac_mode: HVACMode
|
||||
) -> None:
|
||||
"""Set the HVAC mode."""
|
||||
async def async_set_hvac_mode(self, ac_index, hvac_mode: HVACMode) -> None:
|
||||
"""Set the hvac mode."""
|
||||
_LOGGER.debug("Set Hvac[%s]='%s'", ac_index, str(hvac_mode))
|
||||
data.ac_mode = CONST_STATE_CMD_MAP[hvac_mode]
|
||||
await self.async_set_state(ac_index, data)
|
||||
await self.async_set_state(ac_index, "mode", CONST_STATE_CMD_MAP[hvac_mode])
|
||||
|
||||
async def async_set_fan_mode(
|
||||
self, ac_index: int, data: CCM15SlaveDevice, fan_mode: str
|
||||
) -> None:
|
||||
async def async_set_fan_mode(self, ac_index, fan_mode: str) -> None:
|
||||
"""Set the fan mode."""
|
||||
_LOGGER.debug("Set Fan[%s]='%s'", ac_index, fan_mode)
|
||||
data.fan_mode = CONST_FAN_CMD_MAP[fan_mode]
|
||||
await self.async_set_state(ac_index, data)
|
||||
await self.async_set_state(ac_index, "fan", CONST_FAN_CMD_MAP[fan_mode])
|
||||
|
||||
async def async_set_temperature(
|
||||
self,
|
||||
ac_index: int,
|
||||
data: CCM15SlaveDevice,
|
||||
temp: int,
|
||||
hvac_mode: HVACMode | None,
|
||||
) -> None:
|
||||
async def async_set_temperature(self, ac_index, temp) -> None:
|
||||
"""Set the target temperature mode."""
|
||||
_LOGGER.debug("Set Temp[%s]='%s'", ac_index, temp)
|
||||
data.temperature_setpoint = temp
|
||||
if hvac_mode is not None:
|
||||
data.ac_mode = CONST_STATE_CMD_MAP[hvac_mode]
|
||||
await self.async_set_state(ac_index, data)
|
||||
await self.async_set_state(ac_index, "temp", temp)
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/ccm15",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["py_ccm15==0.1.2"]
|
||||
"requirements": ["py-ccm15==0.0.9"]
|
||||
}
|
||||
|
||||
@@ -6,10 +6,10 @@ from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from aioelectricitymaps import (
|
||||
CarbonIntensityResponse,
|
||||
ElectricityMaps,
|
||||
ElectricityMapsError,
|
||||
ElectricityMapsInvalidTokenError,
|
||||
HomeAssistantCarbonIntensityResponse,
|
||||
)
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
@@ -25,7 +25,7 @@ _LOGGER = logging.getLogger(__name__)
|
||||
type CO2SignalConfigEntry = ConfigEntry[CO2SignalCoordinator]
|
||||
|
||||
|
||||
class CO2SignalCoordinator(DataUpdateCoordinator[HomeAssistantCarbonIntensityResponse]):
|
||||
class CO2SignalCoordinator(DataUpdateCoordinator[CarbonIntensityResponse]):
|
||||
"""Data update coordinator."""
|
||||
|
||||
config_entry: CO2SignalConfigEntry
|
||||
@@ -51,7 +51,7 @@ class CO2SignalCoordinator(DataUpdateCoordinator[HomeAssistantCarbonIntensityRes
|
||||
"""Return entry ID."""
|
||||
return self.config_entry.entry_id
|
||||
|
||||
async def _async_update_data(self) -> HomeAssistantCarbonIntensityResponse:
|
||||
async def _async_update_data(self) -> CarbonIntensityResponse:
|
||||
"""Fetch the latest data from the source."""
|
||||
|
||||
try:
|
||||
|
||||
@@ -5,12 +5,8 @@ from __future__ import annotations
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from aioelectricitymaps import (
|
||||
CoordinatesRequest,
|
||||
ElectricityMaps,
|
||||
HomeAssistantCarbonIntensityResponse,
|
||||
ZoneRequest,
|
||||
)
|
||||
from aioelectricitymaps import ElectricityMaps
|
||||
from aioelectricitymaps.models import CarbonIntensityResponse
|
||||
|
||||
from homeassistant.const import CONF_COUNTRY_CODE, CONF_LATITUDE, CONF_LONGITUDE
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -20,16 +16,14 @@ async def fetch_latest_carbon_intensity(
|
||||
hass: HomeAssistant,
|
||||
em: ElectricityMaps,
|
||||
config: Mapping[str, Any],
|
||||
) -> HomeAssistantCarbonIntensityResponse:
|
||||
) -> CarbonIntensityResponse:
|
||||
"""Fetch the latest carbon intensity based on country code or location coordinates."""
|
||||
request: CoordinatesRequest | ZoneRequest = CoordinatesRequest(
|
||||
if CONF_COUNTRY_CODE in config:
|
||||
return await em.latest_carbon_intensity_by_country_code(
|
||||
code=config[CONF_COUNTRY_CODE]
|
||||
)
|
||||
|
||||
return await em.latest_carbon_intensity_by_coordinates(
|
||||
lat=config.get(CONF_LATITUDE, hass.config.latitude),
|
||||
lon=config.get(CONF_LONGITUDE, hass.config.longitude),
|
||||
)
|
||||
|
||||
if CONF_COUNTRY_CODE in config:
|
||||
request = ZoneRequest(
|
||||
zone=config[CONF_COUNTRY_CODE],
|
||||
)
|
||||
|
||||
return await em.carbon_intensity_for_home_assistant(request)
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioelectricitymaps"],
|
||||
"requirements": ["aioelectricitymaps==1.1.1"]
|
||||
"requirements": ["aioelectricitymaps==0.4.0"]
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from aioelectricitymaps import HomeAssistantCarbonIntensityResponse
|
||||
from aioelectricitymaps.models import CarbonIntensityResponse
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorEntity,
|
||||
@@ -28,10 +28,10 @@ class CO2SensorEntityDescription(SensorEntityDescription):
|
||||
|
||||
# For backwards compat, allow description to override unique ID key to use
|
||||
unique_id: str | None = None
|
||||
unit_of_measurement_fn: (
|
||||
Callable[[HomeAssistantCarbonIntensityResponse], str | None] | None
|
||||
) = None
|
||||
value_fn: Callable[[HomeAssistantCarbonIntensityResponse], float | None]
|
||||
unit_of_measurement_fn: Callable[[CarbonIntensityResponse], str | None] | None = (
|
||||
None
|
||||
)
|
||||
value_fn: Callable[[CarbonIntensityResponse], float | None]
|
||||
|
||||
|
||||
SENSORS = (
|
||||
|
||||
@@ -62,7 +62,6 @@ def async_setup(hass: HomeAssistant) -> bool:
|
||||
websocket_api.async_register_command(hass, config_entries_flow_subscribe)
|
||||
websocket_api.async_register_command(hass, ignore_config_flow)
|
||||
|
||||
websocket_api.async_register_command(hass, config_subentry_update)
|
||||
websocket_api.async_register_command(hass, config_subentry_delete)
|
||||
websocket_api.async_register_command(hass, config_subentry_list)
|
||||
|
||||
@@ -732,47 +731,6 @@ async def config_subentry_list(
|
||||
connection.send_result(msg["id"], result)
|
||||
|
||||
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
"type": "config_entries/subentries/update",
|
||||
"entry_id": str,
|
||||
"subentry_id": str,
|
||||
vol.Optional("title"): str,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def config_subentry_update(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Update a subentry of a config entry."""
|
||||
entry = get_entry(hass, connection, msg["entry_id"], msg["id"])
|
||||
if entry is None:
|
||||
connection.send_error(
|
||||
msg["entry_id"], websocket_api.const.ERR_NOT_FOUND, "Config entry not found"
|
||||
)
|
||||
return
|
||||
|
||||
subentry = entry.subentries.get(msg["subentry_id"])
|
||||
if subentry is None:
|
||||
connection.send_error(
|
||||
msg["id"], websocket_api.const.ERR_NOT_FOUND, "Config subentry not found"
|
||||
)
|
||||
return
|
||||
|
||||
changes = dict(msg)
|
||||
changes.pop("id")
|
||||
changes.pop("type")
|
||||
changes.pop("entry_id")
|
||||
changes.pop("subentry_id")
|
||||
|
||||
hass.config_entries.async_update_subentry(entry, subentry, **changes)
|
||||
|
||||
connection.send_result(msg["id"])
|
||||
|
||||
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/conversation",
|
||||
"integration_type": "system",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["hassil==3.2.0", "home-assistant-intents==2025.8.27"]
|
||||
"requirements": ["hassil==3.1.0", "home-assistant-intents==2025.7.30"]
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/ecovacs",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["sleekxmppfs", "sucks", "deebot_client"],
|
||||
"requirements": ["py-sucks==0.9.11", "deebot-client==13.7.0"]
|
||||
"requirements": ["py-sucks==0.9.11", "deebot-client==13.6.0"]
|
||||
}
|
||||
|
||||
@@ -22,5 +22,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["eq3btsmart"],
|
||||
"requirements": ["eq3btsmart==2.1.0", "bleak-esphome==3.2.0"]
|
||||
"requirements": ["eq3btsmart==2.1.0", "bleak-esphome==3.1.0"]
|
||||
}
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
"mqtt": ["esphome/discover/#"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": [
|
||||
"aioesphomeapi==39.0.1",
|
||||
"aioesphomeapi==39.0.0",
|
||||
"esphome-dashboard-api==1.3.0",
|
||||
"bleak-esphome==3.2.0"
|
||||
"bleak-esphome==3.1.0"
|
||||
],
|
||||
"zeroconf": ["_esphomelib._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -20,5 +20,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/frontend",
|
||||
"integration_type": "system",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20250828.0"]
|
||||
"requirements": ["home-assistant-frontend==20250811.0"]
|
||||
}
|
||||
|
||||
@@ -62,12 +62,6 @@ BUTTONS: tuple[FullyButtonEntityDescription, ...] = (
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
press_action=lambda fully: fully.loadStartUrl(),
|
||||
),
|
||||
FullyButtonEntityDescription(
|
||||
key="clearCache",
|
||||
translation_key="clear_cache",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
press_action=lambda fully: fully.clearCache(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -69,9 +69,6 @@
|
||||
},
|
||||
"load_start_url": {
|
||||
"name": "Load start URL"
|
||||
},
|
||||
"clear_cache": {
|
||||
"name": "Clear browser cache"
|
||||
}
|
||||
},
|
||||
"image": {
|
||||
|
||||
@@ -300,6 +300,13 @@ class WebRTCProvider(CameraWebRTCProvider):
|
||||
camera.entity_id, width, height
|
||||
)
|
||||
|
||||
async def async_get_stream_source(self, camera: Camera) -> str | None:
|
||||
"""Get a stream source URL suitable for recording."""
|
||||
await self._update_stream_source(camera)
|
||||
# Return an HLS stream URL that can be used by the stream component for recording
|
||||
# go2rtc provides HLS streams at /api/stream.m3u8?src=<stream_name>
|
||||
return f"{self._url}api/stream.m3u8?src={camera.entity_id}"
|
||||
|
||||
async def _update_stream_source(self, camera: Camera) -> None:
|
||||
"""Update the stream source in go2rtc config if needed."""
|
||||
if not (stream_source := await camera.stream_source()):
|
||||
|
||||
@@ -260,9 +260,9 @@ async def async_migrate_integration(hass: HomeAssistant) -> None:
|
||||
entity_disabled_by is er.RegistryEntryDisabler.CONFIG_ENTRY
|
||||
and not all_disabled
|
||||
):
|
||||
# Device and entity registries will set the disabled_by flag to None
|
||||
# when moving a device or entity disabled by CONFIG_ENTRY to an enabled
|
||||
# config entry, but we want to set it to DEVICE or USER instead,
|
||||
# Device and entity registries don't update the disabled_by flag
|
||||
# when moving a device or entity from one config entry to another,
|
||||
# so we need to do it manually.
|
||||
entity_disabled_by = (
|
||||
er.RegistryEntryDisabler.DEVICE
|
||||
if device
|
||||
@@ -277,9 +277,9 @@ async def async_migrate_integration(hass: HomeAssistant) -> None:
|
||||
)
|
||||
|
||||
if device is not None:
|
||||
# Device and entity registries will set the disabled_by flag to None
|
||||
# when moving a device or entity disabled by CONFIG_ENTRY to an enabled
|
||||
# config entry, but we want to set it to USER instead,
|
||||
# Device and entity registries don't update the disabled_by flag when
|
||||
# moving a device or entity from one config entry to another, so we
|
||||
# need to do it manually.
|
||||
device_disabled_by = device.disabled_by
|
||||
if (
|
||||
device.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY
|
||||
|
||||
@@ -9,7 +9,6 @@ import logging
|
||||
|
||||
from govee_local_api.controller import LISTENING_PORT
|
||||
|
||||
from homeassistant.components import network
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
@@ -24,24 +23,12 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: GoveeLocalConfigEntry) -> bool:
|
||||
"""Set up Govee light local from a config entry."""
|
||||
|
||||
# Get source IPs for all enabled adapters
|
||||
source_ips = await network.async_get_enabled_source_ips(hass)
|
||||
_LOGGER.debug("Enabled source IPs: %s", source_ips)
|
||||
|
||||
coordinator: GoveeLocalApiCoordinator = GoveeLocalApiCoordinator(
|
||||
hass=hass, config_entry=entry, source_ips=source_ips
|
||||
)
|
||||
coordinator = GoveeLocalApiCoordinator(hass, entry)
|
||||
|
||||
async def await_cleanup():
|
||||
cleanup_complete_events: [asyncio.Event] = coordinator.cleanup()
|
||||
cleanup_complete: asyncio.Event = coordinator.cleanup()
|
||||
with suppress(TimeoutError):
|
||||
await asyncio.gather(
|
||||
*[
|
||||
asyncio.wait_for(cleanup_complete_event.wait(), 1)
|
||||
for cleanup_complete_event in cleanup_complete_events
|
||||
]
|
||||
)
|
||||
await asyncio.wait_for(cleanup_complete.wait(), 1)
|
||||
|
||||
entry.async_on_unload(await_cleanup)
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from contextlib import suppress
|
||||
from ipaddress import IPv4Address, IPv6Address
|
||||
import logging
|
||||
|
||||
from govee_local_api import GoveeController
|
||||
@@ -24,13 +23,15 @@ from .const import (
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _async_discover(
|
||||
hass: HomeAssistant, adapter_ip: IPv4Address | IPv6Address
|
||||
) -> bool:
|
||||
async def _async_has_devices(hass: HomeAssistant) -> bool:
|
||||
"""Return if there are devices that can be discovered."""
|
||||
|
||||
adapter = await network.async_get_source_ip(hass, network.PUBLIC_TARGET_IP)
|
||||
|
||||
controller: GoveeController = GoveeController(
|
||||
loop=hass.loop,
|
||||
logger=_LOGGER,
|
||||
listening_address=str(adapter_ip),
|
||||
listening_address=adapter,
|
||||
broadcast_address=CONF_MULTICAST_ADDRESS_DEFAULT,
|
||||
broadcast_port=CONF_TARGET_PORT_DEFAULT,
|
||||
listening_port=CONF_LISTENING_PORT_DEFAULT,
|
||||
@@ -40,10 +41,9 @@ async def _async_discover(
|
||||
)
|
||||
|
||||
try:
|
||||
_LOGGER.debug("Starting discovery with IP %s", adapter_ip)
|
||||
await controller.start()
|
||||
except OSError as ex:
|
||||
_LOGGER.error("Start failed on IP %s, errno: %d", adapter_ip, ex.errno)
|
||||
_LOGGER.error("Start failed, errno: %d", ex.errno)
|
||||
return False
|
||||
|
||||
try:
|
||||
@@ -51,34 +51,16 @@ async def _async_discover(
|
||||
while not controller.devices:
|
||||
await asyncio.sleep(delay=1)
|
||||
except TimeoutError:
|
||||
_LOGGER.debug("No devices found with IP %s", adapter_ip)
|
||||
_LOGGER.debug("No devices found")
|
||||
|
||||
devices_count = len(controller.devices)
|
||||
cleanup_complete_events: list[asyncio.Event] = []
|
||||
cleanup_complete: asyncio.Event = controller.cleanup()
|
||||
with suppress(TimeoutError):
|
||||
await asyncio.gather(
|
||||
*[
|
||||
asyncio.wait_for(cleanup_complete_event.wait(), 1)
|
||||
for cleanup_complete_event in cleanup_complete_events
|
||||
]
|
||||
)
|
||||
await asyncio.wait_for(cleanup_complete.wait(), 1)
|
||||
|
||||
return devices_count > 0
|
||||
|
||||
|
||||
async def _async_has_devices(hass: HomeAssistant) -> bool:
|
||||
"""Return if there are devices that can be discovered."""
|
||||
|
||||
# Get source IPs for all enabled adapters
|
||||
source_ips = await network.async_get_enabled_source_ips(hass)
|
||||
_LOGGER.debug("Enabled source IPs: %s", source_ips)
|
||||
|
||||
# Run discovery on every IPv4 address and gather results
|
||||
results = await asyncio.gather(*[_async_discover(hass, ip) for ip in source_ips])
|
||||
|
||||
return any(results)
|
||||
|
||||
|
||||
config_entry_flow.register_discovery_flow(
|
||||
DOMAIN, "Govee light local", _async_has_devices
|
||||
)
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
from ipaddress import IPv4Address, IPv6Address
|
||||
import logging
|
||||
|
||||
from govee_local_api import GoveeController, GoveeDevice
|
||||
@@ -12,6 +11,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
||||
from .const import (
|
||||
CONF_DISCOVERY_INTERVAL_DEFAULT,
|
||||
CONF_LISTENING_PORT_DEFAULT,
|
||||
CONF_MULTICAST_ADDRESS_DEFAULT,
|
||||
CONF_TARGET_PORT_DEFAULT,
|
||||
@@ -26,11 +26,10 @@ type GoveeLocalConfigEntry = ConfigEntry[GoveeLocalApiCoordinator]
|
||||
class GoveeLocalApiCoordinator(DataUpdateCoordinator[list[GoveeDevice]]):
|
||||
"""Govee light local coordinator."""
|
||||
|
||||
config_entry: GoveeLocalConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: GoveeLocalConfigEntry,
|
||||
source_ips: list[IPv4Address | IPv6Address],
|
||||
self, hass: HomeAssistant, config_entry: GoveeLocalConfigEntry
|
||||
) -> None:
|
||||
"""Initialize my coordinator."""
|
||||
super().__init__(
|
||||
@@ -41,40 +40,32 @@ class GoveeLocalApiCoordinator(DataUpdateCoordinator[list[GoveeDevice]]):
|
||||
update_interval=SCAN_INTERVAL,
|
||||
)
|
||||
|
||||
self._controllers: list[GoveeController] = [
|
||||
GoveeController(
|
||||
loop=hass.loop,
|
||||
logger=_LOGGER,
|
||||
listening_address=str(source_ip),
|
||||
broadcast_address=CONF_MULTICAST_ADDRESS_DEFAULT,
|
||||
broadcast_port=CONF_TARGET_PORT_DEFAULT,
|
||||
listening_port=CONF_LISTENING_PORT_DEFAULT,
|
||||
discovery_enabled=True,
|
||||
discovery_interval=1,
|
||||
update_enabled=False,
|
||||
)
|
||||
for source_ip in source_ips
|
||||
]
|
||||
self._controller = GoveeController(
|
||||
loop=hass.loop,
|
||||
logger=_LOGGER,
|
||||
broadcast_address=CONF_MULTICAST_ADDRESS_DEFAULT,
|
||||
broadcast_port=CONF_TARGET_PORT_DEFAULT,
|
||||
listening_port=CONF_LISTENING_PORT_DEFAULT,
|
||||
discovery_enabled=True,
|
||||
discovery_interval=CONF_DISCOVERY_INTERVAL_DEFAULT,
|
||||
discovered_callback=None,
|
||||
update_enabled=False,
|
||||
)
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start the Govee coordinator."""
|
||||
|
||||
for controller in self._controllers:
|
||||
await controller.start()
|
||||
controller.send_update_message()
|
||||
await self._controller.start()
|
||||
self._controller.send_update_message()
|
||||
|
||||
async def set_discovery_callback(
|
||||
self, callback: Callable[[GoveeDevice, bool], bool]
|
||||
) -> None:
|
||||
"""Set discovery callback for automatic Govee light discovery."""
|
||||
self._controller.set_device_discovered_callback(callback)
|
||||
|
||||
for controller in self._controllers:
|
||||
controller.set_device_discovered_callback(callback)
|
||||
|
||||
def cleanup(self) -> list[asyncio.Event]:
|
||||
"""Stop and cleanup the coordinator."""
|
||||
|
||||
return [controller.cleanup() for controller in self._controllers]
|
||||
def cleanup(self) -> asyncio.Event:
|
||||
"""Stop and cleanup the cooridinator."""
|
||||
return self._controller.cleanup()
|
||||
|
||||
async def turn_on(self, device: GoveeDevice) -> None:
|
||||
"""Turn on the light."""
|
||||
@@ -105,14 +96,8 @@ class GoveeLocalApiCoordinator(DataUpdateCoordinator[list[GoveeDevice]]):
|
||||
@property
|
||||
def devices(self) -> list[GoveeDevice]:
|
||||
"""Return a list of discovered Govee devices."""
|
||||
|
||||
devices: list[GoveeDevice] = []
|
||||
for controller in self._controllers:
|
||||
devices = devices + controller.devices
|
||||
return devices
|
||||
return self._controller.devices
|
||||
|
||||
async def _async_update_data(self) -> list[GoveeDevice]:
|
||||
for controller in self._controllers:
|
||||
controller.send_update_message()
|
||||
|
||||
return self.devices
|
||||
self._controller.send_update_message()
|
||||
return self._controller.devices
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["habiticalib"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["habiticalib==0.4.3"]
|
||||
"requirements": ["habiticalib==0.4.2"]
|
||||
}
|
||||
|
||||
@@ -10,12 +10,7 @@ from typing import Any, NotRequired, TypedDict
|
||||
from uuid import UUID
|
||||
|
||||
from aiohasupervisor import SupervisorError
|
||||
from aiohasupervisor.models import (
|
||||
ContextType,
|
||||
Issue as SupervisorIssue,
|
||||
UnhealthyReason,
|
||||
UnsupportedReason,
|
||||
)
|
||||
from aiohasupervisor.models import ContextType, Issue as SupervisorIssue
|
||||
|
||||
from homeassistant.core import HassJob, HomeAssistant, callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
@@ -64,9 +59,42 @@ INFO_URL_UNSUPPORTED = "https://www.home-assistant.io/more-info/unsupported"
|
||||
|
||||
PLACEHOLDER_KEY_REASON = "reason"
|
||||
|
||||
UNSUPPORTED_REASONS = {
|
||||
"apparmor",
|
||||
"cgroup_version",
|
||||
"connectivity_check",
|
||||
"content_trust",
|
||||
"dbus",
|
||||
"dns_server",
|
||||
"docker_configuration",
|
||||
"docker_version",
|
||||
"job_conditions",
|
||||
"lxc",
|
||||
"network_manager",
|
||||
"os",
|
||||
"os_agent",
|
||||
"os_version",
|
||||
"restart_policy",
|
||||
"software",
|
||||
"source_mods",
|
||||
"supervisor_version",
|
||||
"systemd",
|
||||
"systemd_journal",
|
||||
"systemd_resolved",
|
||||
"virtualization_image",
|
||||
}
|
||||
# Some unsupported reasons also mark the system as unhealthy. If the unsupported reason
|
||||
# provides no additional information beyond the unhealthy one then skip that repair.
|
||||
UNSUPPORTED_SKIP_REPAIR = {"privileged"}
|
||||
UNHEALTHY_REASONS = {
|
||||
"docker",
|
||||
"duplicate_os_installation",
|
||||
"oserror_bad_message",
|
||||
"privileged",
|
||||
"setup",
|
||||
"supervisor",
|
||||
"untrusted",
|
||||
}
|
||||
|
||||
# Keys (type + context) of issues that when found should be made into a repair
|
||||
ISSUE_KEYS_FOR_REPAIRS = {
|
||||
@@ -178,7 +206,7 @@ class SupervisorIssues:
|
||||
def unhealthy_reasons(self, reasons: set[str]) -> None:
|
||||
"""Set unhealthy reasons. Create or delete repairs as necessary."""
|
||||
for unhealthy in reasons - self.unhealthy_reasons:
|
||||
if unhealthy in UnhealthyReason:
|
||||
if unhealthy in UNHEALTHY_REASONS:
|
||||
translation_key = f"{ISSUE_KEY_UNHEALTHY}_{unhealthy}"
|
||||
translation_placeholders = None
|
||||
else:
|
||||
@@ -210,7 +238,7 @@ class SupervisorIssues:
|
||||
def unsupported_reasons(self, reasons: set[str]) -> None:
|
||||
"""Set unsupported reasons. Create or delete repairs as necessary."""
|
||||
for unsupported in reasons - UNSUPPORTED_SKIP_REPAIR - self.unsupported_reasons:
|
||||
if unsupported in UnsupportedReason:
|
||||
if unsupported in UNSUPPORTED_REASONS:
|
||||
translation_key = f"{ISSUE_KEY_UNSUPPORTED}_{unsupported}"
|
||||
translation_placeholders = None
|
||||
else:
|
||||
|
||||
@@ -6,6 +6,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/hassio",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["aiohasupervisor==0.3.2"],
|
||||
"requirements": ["aiohasupervisor==0.3.2b0"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -61,12 +61,19 @@ BSH_DOOR_STATE_LOCKED = "BSH.Common.EnumType.DoorState.Locked"
|
||||
BSH_DOOR_STATE_OPEN = "BSH.Common.EnumType.DoorState.Open"
|
||||
|
||||
|
||||
SERVICE_OPTION_ACTIVE = "set_option_active"
|
||||
SERVICE_OPTION_SELECTED = "set_option_selected"
|
||||
SERVICE_PAUSE_PROGRAM = "pause_program"
|
||||
SERVICE_RESUME_PROGRAM = "resume_program"
|
||||
SERVICE_SELECT_PROGRAM = "select_program"
|
||||
SERVICE_SET_PROGRAM_AND_OPTIONS = "set_program_and_options"
|
||||
SERVICE_SETTING = "change_setting"
|
||||
SERVICE_START_PROGRAM = "start_program"
|
||||
|
||||
ATTR_AFFECTS_TO = "affects_to"
|
||||
ATTR_KEY = "key"
|
||||
ATTR_PROGRAM = "program"
|
||||
ATTR_UNIT = "unit"
|
||||
ATTR_VALUE = "value"
|
||||
|
||||
AFFECTS_TO_ACTIVE_PROGRAM = "active_program"
|
||||
|
||||
@@ -22,6 +22,6 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["aiohomeconnect"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aiohomeconnect==0.19.0"],
|
||||
"requirements": ["aiohomeconnect==0.18.1"],
|
||||
"zeroconf": ["_homeconnect._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ from typing import Any, cast
|
||||
from aiohomeconnect.client import Client as HomeConnectClient
|
||||
from aiohomeconnect.model import (
|
||||
ArrayOfOptions,
|
||||
CommandKey,
|
||||
Option,
|
||||
OptionKey,
|
||||
ProgramKey,
|
||||
@@ -20,6 +21,7 @@ from homeassistant.const import ATTR_DEVICE_ID
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv, device_registry as dr
|
||||
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
|
||||
|
||||
from .const import (
|
||||
AFFECTS_TO_ACTIVE_PROGRAM,
|
||||
@@ -27,11 +29,18 @@ from .const import (
|
||||
ATTR_AFFECTS_TO,
|
||||
ATTR_KEY,
|
||||
ATTR_PROGRAM,
|
||||
ATTR_UNIT,
|
||||
ATTR_VALUE,
|
||||
DOMAIN,
|
||||
PROGRAM_ENUM_OPTIONS,
|
||||
SERVICE_OPTION_ACTIVE,
|
||||
SERVICE_OPTION_SELECTED,
|
||||
SERVICE_PAUSE_PROGRAM,
|
||||
SERVICE_RESUME_PROGRAM,
|
||||
SERVICE_SELECT_PROGRAM,
|
||||
SERVICE_SET_PROGRAM_AND_OPTIONS,
|
||||
SERVICE_SETTING,
|
||||
SERVICE_START_PROGRAM,
|
||||
TRANSLATION_KEYS_PROGRAMS_MAP,
|
||||
)
|
||||
from .coordinator import HomeConnectConfigEntry
|
||||
@@ -79,6 +88,43 @@ SERVICE_SETTING_SCHEMA = vol.Schema(
|
||||
}
|
||||
)
|
||||
|
||||
# DEPRECATED: Remove in 2025.9.0
|
||||
SERVICE_OPTION_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_DEVICE_ID): str,
|
||||
vol.Required(ATTR_KEY): vol.All(
|
||||
vol.Coerce(OptionKey),
|
||||
vol.NotIn([OptionKey.UNKNOWN]),
|
||||
),
|
||||
vol.Required(ATTR_VALUE): vol.Any(str, int, bool),
|
||||
vol.Optional(ATTR_UNIT): str,
|
||||
}
|
||||
)
|
||||
|
||||
# DEPRECATED: Remove in 2025.9.0
|
||||
SERVICE_PROGRAM_SCHEMA = vol.Any(
|
||||
{
|
||||
vol.Required(ATTR_DEVICE_ID): str,
|
||||
vol.Required(ATTR_PROGRAM): vol.All(
|
||||
vol.Coerce(ProgramKey),
|
||||
vol.NotIn([ProgramKey.UNKNOWN]),
|
||||
),
|
||||
vol.Required(ATTR_KEY): vol.All(
|
||||
vol.Coerce(OptionKey),
|
||||
vol.NotIn([OptionKey.UNKNOWN]),
|
||||
),
|
||||
vol.Required(ATTR_VALUE): vol.Any(int, str),
|
||||
vol.Optional(ATTR_UNIT): str,
|
||||
},
|
||||
{
|
||||
vol.Required(ATTR_DEVICE_ID): str,
|
||||
vol.Required(ATTR_PROGRAM): vol.All(
|
||||
vol.Coerce(ProgramKey),
|
||||
vol.NotIn([ProgramKey.UNKNOWN]),
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _require_program_or_at_least_one_option(data: dict) -> dict:
|
||||
if ATTR_PROGRAM not in data and not any(
|
||||
@@ -170,6 +216,205 @@ async def _get_client_and_ha_id(
|
||||
return entry.runtime_data.client, ha_id
|
||||
|
||||
|
||||
async def _async_service_program(call: ServiceCall, start: bool) -> None:
|
||||
"""Execute calls to services taking a program."""
|
||||
program = call.data[ATTR_PROGRAM]
|
||||
client, ha_id = await _get_client_and_ha_id(call.hass, call.data[ATTR_DEVICE_ID])
|
||||
|
||||
option_key = call.data.get(ATTR_KEY)
|
||||
options = (
|
||||
[
|
||||
Option(
|
||||
option_key,
|
||||
call.data[ATTR_VALUE],
|
||||
unit=call.data.get(ATTR_UNIT),
|
||||
)
|
||||
]
|
||||
if option_key is not None
|
||||
else None
|
||||
)
|
||||
|
||||
async_create_issue(
|
||||
call.hass,
|
||||
DOMAIN,
|
||||
"deprecated_set_program_and_option_actions",
|
||||
breaks_in_ha_version="2025.9.0",
|
||||
is_fixable=True,
|
||||
is_persistent=True,
|
||||
severity=IssueSeverity.WARNING,
|
||||
translation_key="deprecated_set_program_and_option_actions",
|
||||
translation_placeholders={
|
||||
"new_action_key": SERVICE_SET_PROGRAM_AND_OPTIONS,
|
||||
"remove_release": "2025.9.0",
|
||||
"deprecated_action_yaml": "\n".join(
|
||||
[
|
||||
"```yaml",
|
||||
f"action: {DOMAIN}.{SERVICE_START_PROGRAM if start else SERVICE_SELECT_PROGRAM}",
|
||||
"data:",
|
||||
f" {ATTR_DEVICE_ID}: DEVICE_ID",
|
||||
f" {ATTR_PROGRAM}: {program}",
|
||||
*([f" {ATTR_KEY}: {options[0].key}"] if options else []),
|
||||
*([f" {ATTR_VALUE}: {options[0].value}"] if options else []),
|
||||
*(
|
||||
[f" {ATTR_UNIT}: {options[0].unit}"]
|
||||
if options and options[0].unit
|
||||
else []
|
||||
),
|
||||
"```",
|
||||
]
|
||||
),
|
||||
"new_action_yaml": "\n ".join(
|
||||
[
|
||||
"```yaml",
|
||||
f"action: {DOMAIN}.{SERVICE_SET_PROGRAM_AND_OPTIONS}",
|
||||
"data:",
|
||||
f" {ATTR_DEVICE_ID}: DEVICE_ID",
|
||||
f" {ATTR_AFFECTS_TO}: {AFFECTS_TO_ACTIVE_PROGRAM if start else AFFECTS_TO_SELECTED_PROGRAM}",
|
||||
f" {ATTR_PROGRAM}: {bsh_key_to_translation_key(program.value)}",
|
||||
*(
|
||||
[
|
||||
f" {bsh_key_to_translation_key(options[0].key)}: {options[0].value}"
|
||||
]
|
||||
if options
|
||||
else []
|
||||
),
|
||||
"```",
|
||||
]
|
||||
),
|
||||
"repo_link": "[aiohomeconnect](https://github.com/MartinHjelmare/aiohomeconnect)",
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
if start:
|
||||
await client.start_program(ha_id, program_key=program, options=options)
|
||||
else:
|
||||
await client.set_selected_program(
|
||||
ha_id, program_key=program, options=options
|
||||
)
|
||||
except HomeConnectError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="start_program" if start else "select_program",
|
||||
translation_placeholders={
|
||||
**get_dict_from_home_connect_error(err),
|
||||
"program": program,
|
||||
},
|
||||
) from err
|
||||
|
||||
|
||||
async def _async_service_set_program_options(call: ServiceCall, active: bool) -> None:
|
||||
"""Execute calls to services taking a program."""
|
||||
option_key = call.data[ATTR_KEY]
|
||||
value = call.data[ATTR_VALUE]
|
||||
unit = call.data.get(ATTR_UNIT)
|
||||
client, ha_id = await _get_client_and_ha_id(call.hass, call.data[ATTR_DEVICE_ID])
|
||||
|
||||
async_create_issue(
|
||||
call.hass,
|
||||
DOMAIN,
|
||||
"deprecated_set_program_and_option_actions",
|
||||
breaks_in_ha_version="2025.9.0",
|
||||
is_fixable=True,
|
||||
is_persistent=True,
|
||||
severity=IssueSeverity.WARNING,
|
||||
translation_key="deprecated_set_program_and_option_actions",
|
||||
translation_placeholders={
|
||||
"new_action_key": SERVICE_SET_PROGRAM_AND_OPTIONS,
|
||||
"remove_release": "2025.9.0",
|
||||
"deprecated_action_yaml": "\n".join(
|
||||
[
|
||||
"```yaml",
|
||||
f"action: {DOMAIN}.{SERVICE_OPTION_ACTIVE if active else SERVICE_OPTION_SELECTED}",
|
||||
"data:",
|
||||
f" {ATTR_DEVICE_ID}: DEVICE_ID",
|
||||
f" {ATTR_KEY}: {option_key}",
|
||||
f" {ATTR_VALUE}: {value}",
|
||||
*([f" {ATTR_UNIT}: {unit}"] if unit else []),
|
||||
"```",
|
||||
]
|
||||
),
|
||||
"new_action_yaml": "\n ".join(
|
||||
[
|
||||
"```yaml",
|
||||
f"action: {DOMAIN}.{SERVICE_SET_PROGRAM_AND_OPTIONS}",
|
||||
"data:",
|
||||
f" {ATTR_DEVICE_ID}: DEVICE_ID",
|
||||
f" {ATTR_AFFECTS_TO}: {AFFECTS_TO_ACTIVE_PROGRAM if active else AFFECTS_TO_SELECTED_PROGRAM}",
|
||||
f" {bsh_key_to_translation_key(option_key)}: {value}",
|
||||
"```",
|
||||
]
|
||||
),
|
||||
"repo_link": "[aiohomeconnect](https://github.com/MartinHjelmare/aiohomeconnect)",
|
||||
},
|
||||
)
|
||||
try:
|
||||
if active:
|
||||
await client.set_active_program_option(
|
||||
ha_id,
|
||||
option_key=option_key,
|
||||
value=value,
|
||||
unit=unit,
|
||||
)
|
||||
else:
|
||||
await client.set_selected_program_option(
|
||||
ha_id,
|
||||
option_key=option_key,
|
||||
value=value,
|
||||
unit=unit,
|
||||
)
|
||||
except HomeConnectError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="set_options_active_program"
|
||||
if active
|
||||
else "set_options_selected_program",
|
||||
translation_placeholders={
|
||||
**get_dict_from_home_connect_error(err),
|
||||
"key": option_key,
|
||||
"value": str(value),
|
||||
},
|
||||
) from err
|
||||
|
||||
|
||||
async def _async_service_command(call: ServiceCall, command_key: CommandKey) -> None:
|
||||
"""Execute calls to services executing a command."""
|
||||
client, ha_id = await _get_client_and_ha_id(call.hass, call.data[ATTR_DEVICE_ID])
|
||||
|
||||
async_create_issue(
|
||||
call.hass,
|
||||
DOMAIN,
|
||||
"deprecated_command_actions",
|
||||
breaks_in_ha_version="2025.9.0",
|
||||
is_fixable=True,
|
||||
is_persistent=True,
|
||||
severity=IssueSeverity.WARNING,
|
||||
translation_key="deprecated_command_actions",
|
||||
)
|
||||
|
||||
try:
|
||||
await client.put_command(ha_id, command_key=command_key, value=True)
|
||||
except HomeConnectError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="execute_command",
|
||||
translation_placeholders={
|
||||
**get_dict_from_home_connect_error(err),
|
||||
"command": command_key.value,
|
||||
},
|
||||
) from err
|
||||
|
||||
|
||||
async def async_service_option_active(call: ServiceCall) -> None:
|
||||
"""Service for setting an option for an active program."""
|
||||
await _async_service_set_program_options(call, True)
|
||||
|
||||
|
||||
async def async_service_option_selected(call: ServiceCall) -> None:
|
||||
"""Service for setting an option for a selected program."""
|
||||
await _async_service_set_program_options(call, False)
|
||||
|
||||
|
||||
async def async_service_setting(call: ServiceCall) -> None:
|
||||
"""Service for changing a setting."""
|
||||
key = call.data[ATTR_KEY]
|
||||
@@ -190,6 +435,21 @@ async def async_service_setting(call: ServiceCall) -> None:
|
||||
) from err
|
||||
|
||||
|
||||
async def async_service_pause_program(call: ServiceCall) -> None:
|
||||
"""Service for pausing a program."""
|
||||
await _async_service_command(call, CommandKey.BSH_COMMON_PAUSE_PROGRAM)
|
||||
|
||||
|
||||
async def async_service_resume_program(call: ServiceCall) -> None:
|
||||
"""Service for resuming a paused program."""
|
||||
await _async_service_command(call, CommandKey.BSH_COMMON_RESUME_PROGRAM)
|
||||
|
||||
|
||||
async def async_service_select_program(call: ServiceCall) -> None:
|
||||
"""Service for selecting a program."""
|
||||
await _async_service_program(call, False)
|
||||
|
||||
|
||||
async def async_service_set_program_and_options(call: ServiceCall) -> None:
|
||||
"""Service for setting a program and options."""
|
||||
data = dict(call.data)
|
||||
@@ -257,13 +517,54 @@ async def async_service_set_program_and_options(call: ServiceCall) -> None:
|
||||
) from err
|
||||
|
||||
|
||||
async def async_service_start_program(call: ServiceCall) -> None:
|
||||
"""Service for starting a program."""
|
||||
await _async_service_program(call, True)
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Register custom actions."""
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_OPTION_ACTIVE,
|
||||
async_service_option_active,
|
||||
schema=SERVICE_OPTION_SCHEMA,
|
||||
)
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_OPTION_SELECTED,
|
||||
async_service_option_selected,
|
||||
schema=SERVICE_OPTION_SCHEMA,
|
||||
)
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_SETTING, async_service_setting, schema=SERVICE_SETTING_SCHEMA
|
||||
)
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_PAUSE_PROGRAM,
|
||||
async_service_pause_program,
|
||||
schema=SERVICE_COMMAND_SCHEMA,
|
||||
)
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_RESUME_PROGRAM,
|
||||
async_service_resume_program,
|
||||
schema=SERVICE_COMMAND_SCHEMA,
|
||||
)
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_SELECT_PROGRAM,
|
||||
async_service_select_program,
|
||||
schema=SERVICE_PROGRAM_SCHEMA,
|
||||
)
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_START_PROGRAM,
|
||||
async_service_start_program,
|
||||
schema=SERVICE_PROGRAM_SCHEMA,
|
||||
)
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_SET_PROGRAM_AND_OPTIONS,
|
||||
|
||||
@@ -1,3 +1,51 @@
|
||||
start_program:
|
||||
fields:
|
||||
device_id:
|
||||
required: true
|
||||
selector:
|
||||
device:
|
||||
integration: home_connect
|
||||
program:
|
||||
example: "Dishcare.Dishwasher.Program.Auto2"
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
key:
|
||||
example: "BSH.Common.Option.StartInRelative"
|
||||
selector:
|
||||
text:
|
||||
value:
|
||||
example: 1800
|
||||
selector:
|
||||
object:
|
||||
unit:
|
||||
example: "seconds"
|
||||
selector:
|
||||
text:
|
||||
select_program:
|
||||
fields:
|
||||
device_id:
|
||||
required: true
|
||||
selector:
|
||||
device:
|
||||
integration: home_connect
|
||||
program:
|
||||
example: "Dishcare.Dishwasher.Program.Auto2"
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
key:
|
||||
example: "BSH.Common.Option.StartInRelative"
|
||||
selector:
|
||||
text:
|
||||
value:
|
||||
example: 1800
|
||||
selector:
|
||||
object:
|
||||
unit:
|
||||
example: "seconds"
|
||||
selector:
|
||||
text:
|
||||
set_program_and_options:
|
||||
fields:
|
||||
device_id:
|
||||
@@ -551,6 +599,54 @@ set_program_and_options:
|
||||
- laundry_care_common_enum_type_vario_perfect_off
|
||||
- laundry_care_common_enum_type_vario_perfect_eco_perfect
|
||||
- laundry_care_common_enum_type_vario_perfect_speed_perfect
|
||||
pause_program:
|
||||
fields:
|
||||
device_id:
|
||||
required: true
|
||||
selector:
|
||||
device:
|
||||
integration: home_connect
|
||||
resume_program:
|
||||
fields:
|
||||
device_id:
|
||||
required: true
|
||||
selector:
|
||||
device:
|
||||
integration: home_connect
|
||||
set_option_active:
|
||||
fields:
|
||||
device_id:
|
||||
required: true
|
||||
selector:
|
||||
device:
|
||||
integration: home_connect
|
||||
key:
|
||||
example: "LaundryCare.Dryer.Option.DryingTarget"
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
value:
|
||||
example: "LaundryCare.Dryer.EnumType.DryingTarget.IronDry"
|
||||
required: true
|
||||
selector:
|
||||
object:
|
||||
set_option_selected:
|
||||
fields:
|
||||
device_id:
|
||||
required: true
|
||||
selector:
|
||||
device:
|
||||
integration: home_connect
|
||||
key:
|
||||
example: "LaundryCare.Dryer.Option.DryingTarget"
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
value:
|
||||
example: "LaundryCare.Dryer.EnumType.DryingTarget.IronDry"
|
||||
required: true
|
||||
selector:
|
||||
object:
|
||||
change_setting:
|
||||
fields:
|
||||
device_id:
|
||||
|
||||
@@ -145,6 +145,28 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"deprecated_command_actions": {
|
||||
"title": "The command related actions are deprecated in favor of the new buttons",
|
||||
"fix_flow": {
|
||||
"step": {
|
||||
"confirm": {
|
||||
"title": "[%key:component::home_connect::issues::deprecated_command_actions::title%]",
|
||||
"description": "The `pause_program` and `resume_program` actions have been deprecated in favor of new button entities, if the command is available for your appliance. Please update your automations, scripts and panels that use this action to use the button entities instead, and press on submit to fix the issue."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"deprecated_set_program_and_option_actions": {
|
||||
"title": "The executed action is deprecated",
|
||||
"fix_flow": {
|
||||
"step": {
|
||||
"confirm": {
|
||||
"title": "[%key:component::home_connect::issues::deprecated_set_program_and_option_actions::title%]",
|
||||
"description": "`start_program`, `select_program`, `set_option_active`, and `set_option_selected` actions are deprecated and will be removed in the {remove_release} release, please use the `{new_action_key}` action instead. For the executed action:\n{deprecated_action_yaml}\nyou can do the following transformation using the recognized options:\n {new_action_yaml}\nIf the option is not in the recognized options, please submit an issue or a pull request requesting the addition of the option at {repo_link}."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
@@ -495,6 +517,49 @@
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"start_program": {
|
||||
"name": "Start program",
|
||||
"description": "Selects a program and starts it.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::name%]",
|
||||
"description": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::description%]"
|
||||
},
|
||||
"program": { "name": "Program", "description": "Program to select." },
|
||||
"key": { "name": "Option key", "description": "Key of the option." },
|
||||
"value": {
|
||||
"name": "Option value",
|
||||
"description": "Value of the option."
|
||||
},
|
||||
"unit": { "name": "Option unit", "description": "Unit for the option." }
|
||||
}
|
||||
},
|
||||
"select_program": {
|
||||
"name": "Select program",
|
||||
"description": "Selects a program without starting it.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::name%]",
|
||||
"description": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::description%]"
|
||||
},
|
||||
"program": {
|
||||
"name": "[%key:component::home_connect::services::start_program::fields::program::name%]",
|
||||
"description": "[%key:component::home_connect::services::start_program::fields::program::description%]"
|
||||
},
|
||||
"key": {
|
||||
"name": "[%key:component::home_connect::services::start_program::fields::key::name%]",
|
||||
"description": "[%key:component::home_connect::services::start_program::fields::key::description%]"
|
||||
},
|
||||
"value": {
|
||||
"name": "[%key:component::home_connect::services::start_program::fields::value::name%]",
|
||||
"description": "[%key:component::home_connect::services::start_program::fields::value::description%]"
|
||||
},
|
||||
"unit": {
|
||||
"name": "[%key:component::home_connect::services::start_program::fields::unit::name%]",
|
||||
"description": "[%key:component::home_connect::services::start_program::fields::unit::description%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"set_program_and_options": {
|
||||
"name": "Set program and options",
|
||||
"description": "Starts or selects a program with options or sets the options for the active or the selected program.",
|
||||
@@ -679,6 +744,62 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"pause_program": {
|
||||
"name": "Pause program",
|
||||
"description": "Pauses the current running program.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::name%]",
|
||||
"description": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::description%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"resume_program": {
|
||||
"name": "Resume program",
|
||||
"description": "Resumes a paused program.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::name%]",
|
||||
"description": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::description%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"set_option_active": {
|
||||
"name": "Set active program option",
|
||||
"description": "Sets an option for the active program.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::name%]",
|
||||
"description": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::description%]"
|
||||
},
|
||||
"key": {
|
||||
"name": "Key",
|
||||
"description": "[%key:component::home_connect::services::start_program::fields::key::description%]"
|
||||
},
|
||||
"value": {
|
||||
"name": "Value",
|
||||
"description": "[%key:component::home_connect::services::start_program::fields::value::description%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"set_option_selected": {
|
||||
"name": "Set selected program option",
|
||||
"description": "Sets options for the selected program.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::name%]",
|
||||
"description": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::description%]"
|
||||
},
|
||||
"key": {
|
||||
"name": "[%key:component::home_connect::services::start_program::fields::key::name%]",
|
||||
"description": "[%key:component::home_connect::services::start_program::fields::key::description%]"
|
||||
},
|
||||
"value": {
|
||||
"name": "[%key:component::home_connect::services::start_program::fields::value::name%]",
|
||||
"description": "[%key:component::home_connect::services::start_program::fields::value::description%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"change_setting": {
|
||||
"name": "Change setting",
|
||||
"description": "Changes a setting.",
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["pyhap"],
|
||||
"requirements": [
|
||||
"HAP-python==5.0.0",
|
||||
"HAP-python==4.9.2",
|
||||
"fnv-hash-fast==1.5.0",
|
||||
"PyQRCode==1.2.1",
|
||||
"base36==0.1.1"
|
||||
|
||||
@@ -228,9 +228,7 @@ class HKDevice:
|
||||
_LOGGER.debug(
|
||||
"Called async_set_available_state with %s for %s", available, self.unique_id
|
||||
)
|
||||
# Don't mark entities as unavailable during shutdown to preserve their last known state
|
||||
# Also skip if the availability state hasn't changed
|
||||
if (self.hass.is_stopping and not available) or self.available == available:
|
||||
if self.available == available:
|
||||
return
|
||||
self.available = available
|
||||
for callback_ in self._availability_callbacks:
|
||||
@@ -296,6 +294,7 @@ class HKDevice:
|
||||
await self.pairing.async_populate_accessories_state(
|
||||
force_update=True, attempts=attempts
|
||||
)
|
||||
self._async_start_polling()
|
||||
|
||||
entry.async_on_unload(pairing.dispatcher_connect(self.process_new_events))
|
||||
entry.async_on_unload(
|
||||
@@ -308,12 +307,6 @@ class HKDevice:
|
||||
|
||||
await self.async_process_entity_map()
|
||||
|
||||
if transport != Transport.BLE:
|
||||
# Do a single poll to make sure the chars are
|
||||
# up to date so we don't restore old data.
|
||||
await self.async_update()
|
||||
self._async_start_polling()
|
||||
|
||||
# If everything is up to date, we can create the entities
|
||||
# since we know the data is not stale.
|
||||
await self.async_add_new_entities()
|
||||
@@ -718,11 +711,9 @@ class HKDevice:
|
||||
"""Stop interacting with device and prepare for removal from hass."""
|
||||
await self.pairing.shutdown()
|
||||
|
||||
# Skip platform unloading during shutdown to preserve entity states
|
||||
if not self.hass.is_stopping:
|
||||
await self.hass.config_entries.async_unload_platforms(
|
||||
self.config_entry, self.platforms
|
||||
)
|
||||
await self.hass.config_entries.async_unload_platforms(
|
||||
self.config_entry, self.platforms
|
||||
)
|
||||
|
||||
def process_config_changed(self, config_num: int) -> None:
|
||||
"""Handle a config change notification from the pairing."""
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/homematicip_cloud",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["homematicip"],
|
||||
"requirements": ["homematicip==2.3.0"]
|
||||
"requirements": ["homematicip==2.2.0"]
|
||||
}
|
||||
|
||||
@@ -12,6 +12,6 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["homewizard_energy"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["python-homewizard-energy==9.3.0"],
|
||||
"requirements": ["python-homewizard-energy==9.2.0"],
|
||||
"zeroconf": ["_hwenergy._tcp.local.", "_homewizard._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ from typing import TYPE_CHECKING
|
||||
from aiohue.v2 import HueBridgeV2
|
||||
from aiohue.v2.controllers.events import EventType
|
||||
from aiohue.v2.controllers.groups import Room, Zone
|
||||
from aiohue.v2.models.device import Device
|
||||
from aiohue.v2.models.device import Device, DeviceArchetypes
|
||||
from aiohue.v2.models.resource import ResourceTypes
|
||||
|
||||
from homeassistant.const import (
|
||||
@@ -66,7 +66,7 @@ async def async_setup_devices(bridge: HueBridge):
|
||||
}
|
||||
if room := dev_controller.get_room(hue_resource.id):
|
||||
params[ATTR_SUGGESTED_AREA] = room.metadata.name
|
||||
if hue_resource.id == api.config.bridge_device.id:
|
||||
if hue_resource.metadata.archetype == DeviceArchetypes.BRIDGE_V2:
|
||||
params[ATTR_IDENTIFIERS].add((DOMAIN, api.config.bridge_id))
|
||||
else:
|
||||
params[ATTR_VIA_DEVICE] = (DOMAIN, api.config.bridge_device.id)
|
||||
@@ -97,7 +97,9 @@ async def async_setup_devices(bridge: HueBridge):
|
||||
# create/update all current devices found in controllers
|
||||
# sort the devices to ensure bridges are added first
|
||||
hue_devices = list(dev_controller)
|
||||
hue_devices.sort(key=lambda dev: dev.id != api.config.bridge_device.id)
|
||||
hue_devices.sort(
|
||||
key=lambda dev: dev.metadata.archetype != DeviceArchetypes.BRIDGE_V2
|
||||
)
|
||||
known_devices = [add_device(hue_device) for hue_device in hue_devices]
|
||||
known_devices += [add_device(hue_room) for hue_room in api.groups.room]
|
||||
known_devices += [add_device(hue_zone) for hue_zone in api.groups.zone]
|
||||
|
||||
@@ -30,8 +30,9 @@ _LOGGER = logging.getLogger(__name__)
|
||||
MAX_WS_RECONNECT_TIME = 600
|
||||
SCAN_INTERVAL = timedelta(minutes=8)
|
||||
DEFAULT_RECONNECT_TIME = 2 # Define a default reconnect time
|
||||
PING_INTERVAL = 60
|
||||
|
||||
PONG_TIMEOUT = timedelta(seconds=90)
|
||||
PING_INTERVAL = timedelta(seconds=10)
|
||||
PING_TIMEOUT = timedelta(seconds=5)
|
||||
type AutomowerConfigEntry = ConfigEntry[AutomowerDataUpdateCoordinator]
|
||||
|
||||
|
||||
@@ -62,7 +63,6 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]):
|
||||
self.new_areas_callbacks: list[Callable[[str, set[int]], None]] = []
|
||||
self.pong: datetime | None = None
|
||||
self.websocket_alive: bool = False
|
||||
self.websocket_callbacks: list[Callable[[bool], None]] = []
|
||||
self._watchdog_task: asyncio.Task | None = None
|
||||
|
||||
@override
|
||||
@@ -199,19 +199,14 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]):
|
||||
)
|
||||
|
||||
async def _pong_watchdog(self) -> None:
|
||||
"""Watchdog to check for pong messages."""
|
||||
_LOGGER.debug("Watchdog started")
|
||||
try:
|
||||
while True:
|
||||
_LOGGER.debug("Sending ping")
|
||||
is_alive = await self.api.send_empty_message()
|
||||
_LOGGER.debug("Ping result: %s", is_alive)
|
||||
if self.websocket_alive != is_alive:
|
||||
self.websocket_alive = is_alive
|
||||
for ws_callback in self.websocket_callbacks:
|
||||
ws_callback(is_alive)
|
||||
self.websocket_alive = await self.api.send_empty_message()
|
||||
_LOGGER.debug("Ping result: %s", self.websocket_alive)
|
||||
|
||||
await asyncio.sleep(PING_INTERVAL)
|
||||
await asyncio.sleep(60)
|
||||
_LOGGER.debug("Websocket alive %s", self.websocket_alive)
|
||||
if not self.websocket_alive:
|
||||
_LOGGER.debug("No pong received → restart polling")
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Creates the event entities for supported mowers."""
|
||||
|
||||
from collections.abc import Callable
|
||||
import logging
|
||||
|
||||
from aioautomower.model import SingleMessageData
|
||||
|
||||
@@ -19,7 +18,6 @@ from .const import ERROR_KEYS
|
||||
from .coordinator import AutomowerDataUpdateCoordinator
|
||||
from .entity import AutomowerBaseEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
ATTR_SEVERITY = "severity"
|
||||
@@ -82,12 +80,6 @@ class AutomowerMessageEventEntity(AutomowerBaseEntity, EventEntity):
|
||||
"""Initialize Automower message event entity."""
|
||||
super().__init__(mower_id, coordinator)
|
||||
self._attr_unique_id = f"{mower_id}_message"
|
||||
self.websocket_alive: bool = coordinator.websocket_alive
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if the entity is available."""
|
||||
return self.websocket_alive and self.mower_id in self.coordinator.data
|
||||
|
||||
@callback
|
||||
def _handle(self, msg: SingleMessageData) -> None:
|
||||
@@ -110,17 +102,7 @@ class AutomowerMessageEventEntity(AutomowerBaseEntity, EventEntity):
|
||||
"""Register callback when entity is added to hass."""
|
||||
await super().async_added_to_hass()
|
||||
self.coordinator.api.register_single_message_callback(self._handle)
|
||||
self.coordinator.websocket_callbacks.append(self._handle_websocket_update)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Unregister WebSocket callback when entity is removed."""
|
||||
self.coordinator.api.unregister_single_message_callback(self._handle)
|
||||
self.coordinator.websocket_callbacks.remove(self._handle_websocket_update)
|
||||
|
||||
def _handle_websocket_update(self, is_alive: bool) -> None:
|
||||
"""Handle websocket status changes."""
|
||||
if self.websocket_alive == is_alive:
|
||||
return
|
||||
self.websocket_alive = is_alive
|
||||
_LOGGER.debug("WebSocket status changed to %s, updating entity state", is_alive)
|
||||
self.async_write_ha_state()
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["aioautomower"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["aioautomower==2.2.0"]
|
||||
"requirements": ["aioautomower==2.1.2"]
|
||||
}
|
||||
|
||||
@@ -3,17 +3,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from automower_ble.mower import Mower
|
||||
from automower_ble.protocol import ResponseResult
|
||||
from bleak import BleakError
|
||||
from bleak_retry_connector import close_stale_connections_by_address, get_device
|
||||
|
||||
from homeassistant.components import bluetooth
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_ADDRESS, CONF_CLIENT_ID, CONF_PIN, Platform
|
||||
from homeassistant.const import CONF_ADDRESS, CONF_CLIENT_ID, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
|
||||
from .const import DOMAIN, LOGGER
|
||||
from .const import LOGGER
|
||||
from .coordinator import HusqvarnaCoordinator
|
||||
|
||||
type HusqvarnaConfigEntry = ConfigEntry[HusqvarnaCoordinator]
|
||||
@@ -26,18 +25,10 @@ PLATFORMS = [
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: HusqvarnaConfigEntry) -> bool:
|
||||
"""Set up Husqvarna Autoconnect Bluetooth from a config entry."""
|
||||
if CONF_PIN not in entry.data:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="pin_required",
|
||||
translation_placeholders={"domain_name": "Husqvarna Automower BLE"},
|
||||
)
|
||||
|
||||
address = entry.data[CONF_ADDRESS]
|
||||
pin = int(entry.data[CONF_PIN])
|
||||
channel_id = entry.data[CONF_CLIENT_ID]
|
||||
|
||||
mower = Mower(channel_id, address, pin)
|
||||
mower = Mower(channel_id, address)
|
||||
|
||||
await close_stale_connections_by_address(address)
|
||||
|
||||
@@ -46,20 +37,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: HusqvarnaConfigEntry) ->
|
||||
device = bluetooth.async_ble_device_from_address(
|
||||
hass, address, connectable=True
|
||||
) or await get_device(address)
|
||||
response_result = await mower.connect(device)
|
||||
if response_result == ResponseResult.INVALID_PIN:
|
||||
raise ConfigEntryAuthFailed(
|
||||
f"Unable to connect to device {address} due to wrong PIN"
|
||||
)
|
||||
if response_result != ResponseResult.OK:
|
||||
raise ConfigEntryNotReady(
|
||||
f"Unable to connect to device {address}, mower returned {response_result}"
|
||||
)
|
||||
if not await mower.connect(device):
|
||||
raise ConfigEntryNotReady
|
||||
except (TimeoutError, BleakError) as exception:
|
||||
raise ConfigEntryNotReady(
|
||||
f"Unable to connect to device {address} due to {exception}"
|
||||
) from exception
|
||||
|
||||
LOGGER.debug("connected and paired")
|
||||
|
||||
model = await mower.get_model()
|
||||
|
||||
@@ -2,20 +2,17 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
import random
|
||||
from typing import Any
|
||||
|
||||
from automower_ble.mower import Mower
|
||||
from automower_ble.protocol import ResponseResult
|
||||
from bleak import BleakError
|
||||
from bleak_retry_connector import get_device
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import bluetooth
|
||||
from homeassistant.components.bluetooth import BluetoothServiceInfo
|
||||
from homeassistant.config_entries import SOURCE_BLUETOOTH, ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_ADDRESS, CONF_CLIENT_ID, CONF_PIN
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_ADDRESS, CONF_CLIENT_ID
|
||||
|
||||
from .const import DOMAIN, LOGGER
|
||||
|
||||
@@ -34,17 +31,12 @@ def _is_supported(discovery_info: BluetoothServiceInfo):
|
||||
service == "98bd0001-0b0e-421a-84e5-ddbf75dc6de4"
|
||||
for service in discovery_info.service_uuids
|
||||
)
|
||||
service_generic = any(
|
||||
service == "00001800-0000-1000-8000-00805f9b34fb"
|
||||
for service in discovery_info.service_uuids
|
||||
)
|
||||
|
||||
return manufacturer and service_husqvarna
|
||||
|
||||
|
||||
def _pin_valid(pin: str) -> bool:
|
||||
"""Check if the pin is valid."""
|
||||
try:
|
||||
int(pin)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return True
|
||||
return manufacturer and service_husqvarna and service_generic
|
||||
|
||||
|
||||
class HusqvarnaAutomowerBleConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
@@ -52,9 +44,9 @@ class HusqvarnaAutomowerBleConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
VERSION = 1
|
||||
|
||||
address: str | None = None
|
||||
mower_name: str = ""
|
||||
pin: str | None = None
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the config flow."""
|
||||
self.address: str | None
|
||||
|
||||
async def async_step_bluetooth(
|
||||
self, discovery_info: BluetoothServiceInfo
|
||||
@@ -68,244 +60,62 @@ class HusqvarnaAutomowerBleConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
self.address = discovery_info.address
|
||||
await self.async_set_unique_id(self.address)
|
||||
self._abort_if_unique_id_configured()
|
||||
return await self.async_step_bluetooth_confirm()
|
||||
return await self.async_step_confirm()
|
||||
|
||||
async def async_step_bluetooth_confirm(
|
||||
async def async_step_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Confirm Bluetooth discovery."""
|
||||
"""Confirm discovery."""
|
||||
assert self.address
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
if not _pin_valid(user_input[CONF_PIN]):
|
||||
errors["base"] = "invalid_pin"
|
||||
else:
|
||||
self.pin = user_input[CONF_PIN]
|
||||
return await self.check_mower(user_input)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="bluetooth_confirm",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_PIN): str,
|
||||
},
|
||||
),
|
||||
user_input,
|
||||
),
|
||||
description_placeholders={"name": self.mower_name or self.address},
|
||||
errors=errors,
|
||||
device = bluetooth.async_ble_device_from_address(
|
||||
self.hass, self.address, connectable=True
|
||||
)
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial manual step."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
if not _pin_valid(user_input[CONF_PIN]):
|
||||
errors["base"] = "invalid_pin"
|
||||
else:
|
||||
self.address = user_input[CONF_ADDRESS]
|
||||
self.pin = user_input[CONF_PIN]
|
||||
await self.async_set_unique_id(self.address, raise_on_progress=False)
|
||||
self._abort_if_unique_id_configured()
|
||||
return await self.check_mower(user_input)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_ADDRESS): str,
|
||||
vol.Required(CONF_PIN): str,
|
||||
},
|
||||
),
|
||||
user_input,
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def probe_mower(self, device) -> str | None:
|
||||
"""Probe the mower to see if it exists."""
|
||||
channel_id = random.randint(1, 0xFFFFFFFF)
|
||||
|
||||
assert self.address
|
||||
|
||||
try:
|
||||
(manufacturer, device_type, model) = await Mower(
|
||||
channel_id, self.address
|
||||
).probe_gatts(device)
|
||||
except (BleakError, TimeoutError) as exception:
|
||||
LOGGER.exception("Failed to probe device (%s): %s", self.address, exception)
|
||||
return None
|
||||
LOGGER.exception("Failed to connect to device: %s", exception)
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
|
||||
title = manufacturer + " " + device_type
|
||||
|
||||
LOGGER.debug("Found device: %s", title)
|
||||
|
||||
return title
|
||||
|
||||
async def connect_mower(self, device) -> tuple[int, Mower]:
|
||||
"""Connect to the Mower."""
|
||||
assert self.address
|
||||
assert self.pin is not None
|
||||
|
||||
channel_id = random.randint(1, 0xFFFFFFFF)
|
||||
mower = Mower(channel_id, self.address, int(self.pin))
|
||||
|
||||
return (channel_id, mower)
|
||||
|
||||
async def check_mower(
|
||||
self,
|
||||
user_input: dict[str, Any] | None = None,
|
||||
) -> ConfigFlowResult:
|
||||
"""Check that the mower exists and is setup."""
|
||||
assert self.address
|
||||
|
||||
device = bluetooth.async_ble_device_from_address(
|
||||
self.hass, self.address, connectable=True
|
||||
)
|
||||
|
||||
title = await self.probe_mower(device)
|
||||
if title is None:
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
self.mower_name = title
|
||||
|
||||
try:
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
(channel_id, mower) = await self.connect_mower(device)
|
||||
|
||||
response_result = await mower.connect(device)
|
||||
await mower.disconnect()
|
||||
|
||||
if response_result is not ResponseResult.OK:
|
||||
LOGGER.debug("cannot connect, response: %s", response_result)
|
||||
|
||||
if (
|
||||
response_result is ResponseResult.INVALID_PIN
|
||||
or response_result is ResponseResult.NOT_ALLOWED
|
||||
):
|
||||
errors["base"] = "invalid_auth"
|
||||
else:
|
||||
errors["base"] = "cannot_connect"
|
||||
|
||||
if self.source == SOURCE_BLUETOOTH:
|
||||
return self.async_show_form(
|
||||
step_id="bluetooth_confirm",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_PIN): str,
|
||||
},
|
||||
),
|
||||
description_placeholders={
|
||||
"name": self.mower_name or self.address
|
||||
},
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
suggested_values = {}
|
||||
|
||||
if self.address:
|
||||
suggested_values[CONF_ADDRESS] = self.address
|
||||
if self.pin:
|
||||
suggested_values[CONF_PIN] = self.pin
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_ADDRESS): str,
|
||||
vol.Required(CONF_PIN): str,
|
||||
},
|
||||
),
|
||||
suggested_values,
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
except (TimeoutError, BleakError):
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
|
||||
return self.async_create_entry(
|
||||
title=title,
|
||||
data={
|
||||
CONF_ADDRESS: self.address,
|
||||
CONF_CLIENT_ID: channel_id,
|
||||
CONF_PIN: self.pin,
|
||||
},
|
||||
)
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Perform reauthentication upon an API authentication error."""
|
||||
reauth_entry = self._get_reauth_entry()
|
||||
self.address = reauth_entry.data[CONF_ADDRESS]
|
||||
self.mower_name = reauth_entry.title
|
||||
self.pin = reauth_entry.data.get(CONF_PIN, "")
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(
|
||||
title=title,
|
||||
data={CONF_ADDRESS: self.address, CONF_CLIENT_ID: channel_id},
|
||||
)
|
||||
|
||||
self.context["title_placeholders"] = {
|
||||
"name": self.mower_name,
|
||||
"address": self.address,
|
||||
"name": title,
|
||||
}
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self._set_confirm_only()
|
||||
return self.async_show_form(
|
||||
step_id="confirm",
|
||||
description_placeholders=self.context["title_placeholders"],
|
||||
)
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Confirm reauthentication dialog."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None and not _pin_valid(user_input[CONF_PIN]):
|
||||
errors["base"] = "invalid_pin"
|
||||
elif user_input is not None:
|
||||
reauth_entry = self._get_reauth_entry()
|
||||
self.pin = user_input[CONF_PIN]
|
||||
|
||||
try:
|
||||
assert self.address
|
||||
device = bluetooth.async_ble_device_from_address(
|
||||
self.hass, self.address, connectable=True
|
||||
) or await get_device(self.address)
|
||||
|
||||
mower = Mower(
|
||||
reauth_entry.data[CONF_CLIENT_ID], self.address, int(self.pin)
|
||||
)
|
||||
|
||||
response_result = await mower.connect(device)
|
||||
await mower.disconnect()
|
||||
if (
|
||||
response_result is ResponseResult.INVALID_PIN
|
||||
or response_result is ResponseResult.NOT_ALLOWED
|
||||
):
|
||||
errors["base"] = "invalid_auth"
|
||||
elif response_result is not ResponseResult.OK:
|
||||
errors["base"] = "cannot_connect"
|
||||
else:
|
||||
return self.async_update_reload_and_abort(
|
||||
self._get_reauth_entry(),
|
||||
data=reauth_entry.data | {CONF_PIN: self.pin},
|
||||
)
|
||||
|
||||
except (TimeoutError, BleakError):
|
||||
# We don't want to abort a reauth flow when we can't connect, so
|
||||
# we just show the form again with an error.
|
||||
errors["base"] = "cannot_connect"
|
||||
"""Handle the initial step."""
|
||||
if user_input is not None:
|
||||
self.address = user_input[CONF_ADDRESS]
|
||||
await self.async_set_unique_id(self.address, raise_on_progress=False)
|
||||
self._abort_if_unique_id_configured()
|
||||
return await self.async_step_confirm()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_PIN): str,
|
||||
},
|
||||
),
|
||||
{CONF_PIN: self.pin},
|
||||
step_id="user",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_ADDRESS): str,
|
||||
},
|
||||
),
|
||||
description_placeholders={"name": self.mower_name},
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
@@ -6,7 +6,6 @@ from datetime import timedelta
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from automower_ble.mower import Mower
|
||||
from automower_ble.protocol import ResponseResult
|
||||
from bleak import BleakError
|
||||
from bleak_retry_connector import close_stale_connections_by_address
|
||||
|
||||
@@ -63,7 +62,7 @@ class HusqvarnaCoordinator(DataUpdateCoordinator[dict[str, str | int]]):
|
||||
)
|
||||
|
||||
try:
|
||||
if await self.mower.connect(device) is not ResponseResult.OK:
|
||||
if not await self.mower.connect(device):
|
||||
raise UpdateFailed("Failed to connect")
|
||||
except BleakError as err:
|
||||
raise UpdateFailed("Failed to connect") from err
|
||||
|
||||
@@ -2,11 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.helpers.device_registry import (
|
||||
CONNECTION_BLUETOOTH,
|
||||
DeviceInfo,
|
||||
format_mac,
|
||||
)
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
@@ -27,8 +23,6 @@ class HusqvarnaAutomowerBleEntity(CoordinatorEntity[HusqvarnaCoordinator]):
|
||||
identifiers={(DOMAIN, f"{coordinator.address}_{coordinator.channel_id}")},
|
||||
manufacturer=MANUFACTURER,
|
||||
model_id=coordinator.model,
|
||||
suggested_area="Garden",
|
||||
connections={(CONNECTION_BLUETOOTH, format_mac(coordinator.address))},
|
||||
)
|
||||
|
||||
@property
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user